Compare commits
23
Commits
ed97fb5995
...
mvp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e6f493358 | ||
|
|
95d2aac323 | ||
|
|
ddd1714eb2 | ||
|
|
a188c3bd8f | ||
|
|
63511b9311 | ||
|
|
165a0b65b8 | ||
|
|
195bfd915e | ||
|
|
5771476ee9 | ||
|
|
b20e206a76 | ||
|
|
223a129a2e | ||
|
|
7be57aeeb2 | ||
|
|
6907134c5e | ||
|
|
dd4a4c042a | ||
|
|
122caca9ce | ||
|
|
7b6baca2b5 | ||
|
|
9ebfedd17f | ||
|
|
6ecaef9b9d | ||
|
|
b540015b7e | ||
|
|
bddd382c96 | ||
|
|
a19f3911fe | ||
|
|
7a3468cd9f | ||
|
|
6bfaab634f | ||
|
|
902e30a261 |
@@ -224,3 +224,4 @@ legacy_admin-edu-space/
|
|||||||
.schemathesis/
|
.schemathesis/
|
||||||
|
|
||||||
AlumnosLS
|
AlumnosLS
|
||||||
|
prerrequisitos
|
||||||
@@ -13,6 +13,70 @@ El formato está basado en [Keep a Changelog](https://keepachangelog.com/es-ES/1
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [3.2.0] - 2026-09-23
|
||||||
|
### Añadido
|
||||||
|
- **Función de Reinicialización Total del Sistema (`POST /api/v1/admin/system/reset`):**
|
||||||
|
- **Módulo de Mantenimiento en Configuración Global (`/admin/settings`):** Nueva pestaña "Mantenimiento & Reinicio" con tarjeta de "Zona de Peligro" y advertencia sobre el alcance de la purga.
|
||||||
|
- **Requisito Estricto de Seguridad Institucional:** Exige obligatoriamente la validación de la **contraseña del usuario Administrador en sesión** antes de ejecutar la acción; retorna 401 y aborta ante claves incorrectas registrando el evento `SYSTEM_RESET_FAILED`.
|
||||||
|
- **Purga Referencial Completa:** Vacía de forma segura y en orden estricto de claves foráneas: historiales previos (`AuditLog`), reservas (`Reservation`), hitos evaluativos (`AcademicMilestone`), notas (`MilestoneGrade`), matrículas (`StudentEnrollment`), asignaciones docentes (`CommissionTeacher`), comisiones (`Commission`), materias (`Subject`), carreras (`Career`), recursos de aula (`ClassroomResource`), aulas (`Classroom`), sedes/edificios (`Building`), ciclos lectivos (`AcademicTerm`) y colas de sincronización Moodle (`MoodleSyncTask`).
|
||||||
|
- **Preservación Estricta de Administración y Cuentas:** Mantiene 100% intactas todas las cuentas de usuario (`User` con hashes de contraseñas), roles RBAC (`Role`), permisos (`Permission`), tipos de hitos (`MilestoneType`), configuraciones globales (`SystemSetting`) y todos los módulos de la pestaña Administración.
|
||||||
|
- **Trazabilidad Inmutable:** Genera automáticamente un nuevo registro en `AuditLog` con la acción `SYSTEM_RESET`, asociando el correo del administrador ejecutor, su IP y el recuento de entidades purgadas.
|
||||||
|
- **Interfaz de Usuario y Modal Interactivo:** Diálogo modal `#modalResetSystem` con campo de contraseña enmascarada, switch de visibilidad, casilla de confirmación explícita y botón de confirmación con estado de carga (spinner).
|
||||||
|
|
||||||
|
### Corregido
|
||||||
|
- **Blindaje de Espacios y Sedes Vacíos (Presenciales y Virtuales):**
|
||||||
|
- **Prevención de Excepciones TypeError:** Se corrigieron accesos inseguros a propiedades nulas o no definidas (`c.code.includes`, `r.start_time.startsWith`, `localeCompare`) en `frontend/src/routes/classrooms.js`, permitiendo navegar a `/classrooms/list_classrooms?view_mode=physical` y `/classrooms/list_classrooms?view_mode=virtual` sin caídas ante bases vacías.
|
||||||
|
- **Corrección de Evaluación de Longitud en Nunjucks:** Actualizadas las directivas condicionales en `frontend/views/classrooms/list.html` a `classrooms.items|length > 0` y `virtual_rooms|length > 0`, garantizando el despliegue de los *Empty States* institucionales con botones de alta cuando no existen aulas registradas.
|
||||||
|
- **Mapeo Dinámico de Sedes:** `handleAddClassroom` y `handleClassroomsList` ahora sincronizan con el catálogo `/buildings`, ofreciendo la creación guiada de sedes y vinculación dinámica en el alta de aulas físicas.
|
||||||
|
- **Blindaje del Fallback Offline:** En caso de fallas o demoras en la API, el manejador de captura provee valores predeterminados seguros para `global_virtual_metrics` y `virtual_rooms`, impidiendo errores 500 no controlados.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.1.0] - 2026-09-23
|
||||||
|
### Añadido
|
||||||
|
- **ABM Integral de Ciclos Lectivos (`AcademicTerm`):**
|
||||||
|
- **Gestión End-to-End de Períodos Académicos:** Implementación de ciclo de vida completo para ciclos lectivos: Alta, Edición, Establecimiento de Ciclo Actual, Toggle de Habilitación rápida y Eliminación protegida (`DELETE /api/v1/admin/academic-terms/<id>` y `POST /api/v1/admin/academic-terms/<id>/toggle`).
|
||||||
|
- **Integridad Relacional Protegida:** Bloqueo de eliminación si el ciclo es el activo corriente, y fallback seguro a desactivación (`is_active = False`) si el ciclo tiene comisiones históricas asociadas.
|
||||||
|
- **Interfaz Enriquecida en Bedelía / Admin:** Botones de acción contextuales, switches y modales interactivos de confirmación (`#modalDeleteTerm`) en `frontend/views/admin/academic_terms/list.html`.
|
||||||
|
- **ABM Completo de Espacios y Aulas (`Classroom`):**
|
||||||
|
- **Control de Estado y Borrado Seguro:** Incorporación de toggle activo/inactivo (`POST /api/v1/classrooms/<id>/toggle`) y endpoint de baja de aulas integrado con el frontend BFF (`/classrooms/toggle_classroom` y `/classrooms/delete_classroom`).
|
||||||
|
- **Acciones Rápidas en Vista Lista y Ficha:** Botones de edición, alternancia de estado y eliminación con modales de advertencia en las vistas de cuadrícula/tabla (`list.html`) y detalle (`view.html`).
|
||||||
|
- **ABM Completo de Reservas (`Reservation`):**
|
||||||
|
- **Eliminación Definitiva (Hard Delete):** Soporte en backend para purga física de reservas (`DELETE /api/v1/reservations/<id>?hard=true` y `POST /api/v1/reservations/<id>/delete`) complementando el flujo de cancelación lógica (`CANCELLED`).
|
||||||
|
- **Control Operativo en Vista Detalle:** Botón de "Eliminar Reserva Definitivamente" con diálogo modal de confirmación en `/schedule/view.html`.
|
||||||
|
- **Auditoría y Cobertura 100% de ABMs en la Plataforma:**
|
||||||
|
- Verificación y garantía de operaciones ABM completas en todos los componentes centrales: **Sedes/Edificios** (`Building`), **Aulas** (`Classroom`), **Ciclos Lectivos** (`AcademicTerm`), **Carreras** (`Career`), **Materias** (`Subject`), **Comisiones** (`Commission`) y **Reservas** (`Reservation`).
|
||||||
|
- **Circuito Integral y Simplificado de Bedelía:**
|
||||||
|
- **Flujo Operativo Ágil para Bedelía:**
|
||||||
|
- Circuito intuitivo de 3 pasos: Catálogo de Aulas (`/classrooms/list_classrooms`) -> Creación/Edición con validación física/virtual y aforo real -> Asignación de Comisiones (`/schedule/add_reservation`).
|
||||||
|
- Botón de acción rápida `Asignar Aula` (`bi-calendar-plus`) en cada fila de `/admin/commissions_list`, permitiendo asignar espacio y horario a cualquier comisión en 1 solo clic.
|
||||||
|
- Sincronización bidireccional de parámetros por URL (`?commission_id=...` y `?classroom_id=...`) precargando automáticamente en el formulario de reserva la materia, código, cupo máximo de alumnos y enlace virtual de Google Meet / Teams.
|
||||||
|
- Selectores inteligentes en cascada en `/schedule/add.html` con autoselección de Edificio, Piso y Aula al navegar desde el catálogo de espacios.
|
||||||
|
- **Conexión REST en Vivo para Reservas:**
|
||||||
|
- Sustitución de listas fijas/mockeadas en `frontend/src/routes/schedule.js` por consulta dinámica a `/commissions` en vivo, permitiendo reservar aulas para cualquiera de las comisiones reales de la plataforma.
|
||||||
|
- **Soporte Multisede Consistente en Aulas:**
|
||||||
|
- Resolución robusta de `building_id` y flag `is_virtual` en `frontend/src/routes/classrooms.js`, eliminando hardcodes y asegurando que las aulas físicas pertenezcan a la sede seleccionada.
|
||||||
|
- **Inicialización de Base de Datos Limpia sin Datos Demo (`--empty-academic` / `--no-demo`):**
|
||||||
|
- **Soporte en `install.sh` y Menú Interactivo:** Incorporación de la opción `sudo bash install.sh --empty-academic` (o `--no-demo`) y opción `[3]` en el menú interactivo para crear la base de datos limpia desde cero, omitiendo la inyección de snapshots con carreras, materias, comisiones, aulas o reservas heredadas.
|
||||||
|
- **Inicializador de Esquema `init_db.py`:** Nuevo flag `--empty-academic` que genera el esquema de tablas completo, los roles y permisos RBAC, los usuarios institucionales y las tipificaciones de hitos, purgando y omitiendo cualquier edificio, aula física o virtual, carrera, materia, comisión o reserva demo por defecto.
|
||||||
|
- **Personalización de Interfaz y Saludo en Dashboard (Configuración Global):**
|
||||||
|
- **Nueva Pestaña de Interfaz & Dashboard (`/admin/settings`):** Módulo en el panel de configuración global para personalizar elementos visuales del sistema.
|
||||||
|
- **Desactivación Opcional del Saludo y Selector de Rol:** Switch interactivo para habilitar o deshabilitar el banner superior `¡Hola, {Usuario}!` y el selector de perspectiva de roles en `dashboard.html`. Al deshabilitarlo, el usuario accede directamente a los indicadores y accesos operativos de su rol sin distracciones ni dudas de contexto.
|
||||||
|
- **Persistencia y Endpoint REST:** Parámetro `dashboard_role_banner_enabled` administrado vía `POST /api/v1/admin/settings/ui`, respaldado por `SystemSetting` y registrado en `AuditLog`.
|
||||||
|
|
||||||
|
### Corregido
|
||||||
|
- **Motor de Purga y Reinicio de Datos Académicos:**
|
||||||
|
- **Solución al botón inerte de Purga:** En `/admin/google_sheets_import`, el botón *"Ejecutar Purga de Datos Seleccionados"* llamaba a `openPurgeModal()`, el cual no ejecutaba ninguna acción por ausencia del modal `#purgeConfirmModal` en el DOM. Se incorporó el modal interactivo completo con desglose dinámico de elementos marcados para borrado, advertencia de preservación de usuarios y estado de carga (spinner).
|
||||||
|
- **Integridad Referencial en Purga Backend:** En `backend/scripts/reset_academic_data.py`, se reordenó la secuencia de borrado para respetar las restricciones de clave foránea (FK): `Reservation` -> `MilestoneGrade` / `AcademicMilestone` -> `CommissionTeacher` -> `StudentEnrollment` -> `Commission` -> `Subject` -> `Career` -> `ClassroomResource` / `Classroom`. Esto previene fallos silenciosos por FK y garantiza que las carreras, asignaturas y comisiones se eliminen de manera limpia cuando son seleccionadas.
|
||||||
|
- **Preservación de Usuarios y Roles:** La purga garantiza que ningún usuario (`User`), contraseña administrativa ni rol (`Role`) sea alterado o eliminado durante el reseteo académico.
|
||||||
|
- **Saneamiento de Métricas y Edición de Edificios:**
|
||||||
|
- Corrección de métricas de edificios en `/buildings/list_buildings`: la capacidad virtual (9999) ya no distorsiona el cómputo de bancos físicos ni los percentiles de ocupación institucional.
|
||||||
|
- Formulario de edición de sedes (`/buildings/edit/:id`) corregido y persistido adecuadamente.
|
||||||
|
- **Instalador `install.sh`:**
|
||||||
|
- Inclusión de banderas `--keep-db` y `--keep-users` para evitar sobreescritura de contraseñas y usuarios en reinstalaciones o actualizaciones.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [3.0.0] - 2026-09-23
|
## [3.0.0] - 2026-09-23
|
||||||
### Añadido
|
### Añadido
|
||||||
- **Fase 5 — Integración Auth, Email & Moodle Backend:**
|
- **Fase 5 — Integración Auth, Email & Moodle Backend:**
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Plataforma integral de gestión áulica, asignación de espacios, control de hor
|
|||||||
|
|
||||||
El proyecto se basa en una arquitectura **desacoplada (Frontend / Backend)** en formato monorepo.
|
El proyecto se basa en una arquitectura **desacoplada (Frontend / Backend)** en formato monorepo.
|
||||||
|
|
||||||
**Versión Actual:** `2.8.0` (Fases 0 a 4 del Roadmap MVP 100% completadas)
|
**Versión Actual:** `3.1.0` (Fases 0 a 5 completadas, Fase 5.1 Bedelía UX & ABMs finalizada)
|
||||||
**Institución:** Universidad de la Ciudad de Buenos Aires (UniCABA)
|
**Institución:** Universidad de la Ciudad de Buenos Aires (UniCABA)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -17,7 +17,9 @@ Fase 1: Paridad Legacy y Estabilización [████████████
|
|||||||
Fase 2: Co-docencia y Matriz Conflictos [████████████████████] 100% (Completado)
|
Fase 2: Co-docencia y Matriz Conflictos [████████████████████] 100% (Completado)
|
||||||
Fase 3: Hitos Evaluativos y Calificaciones[████████████████████] 100% (Completado)
|
Fase 3: Hitos Evaluativos y Calificaciones[████████████████████] 100% (Completado)
|
||||||
Fase 4: UI/UX Drag & Drop e Impersonación[████████████████████] 100% (Completado)
|
Fase 4: UI/UX Drag & Drop e Impersonación[████████████████████] 100% (Completado)
|
||||||
Fase 5: Despliegue Producción e Integrac.[░░░░░░░░░░░░░░░░░░░░] 0% (Planificada)
|
Fase 5: Integración Auth, Email & Moodle [████████████████████] 100% (Completado)
|
||||||
|
Fase 5.1: Bedelía UX, ABMs y Purga Datos [████████████████████] 100% (Completado)
|
||||||
|
Fase 6: Despliegue Producción e Infraest.[███████░░░░░░░░░░░░░] 35% (En Curso)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -30,35 +32,60 @@ Fase 5: Despliegue Producción e Integrac.[░░░░░░░░░░░░
|
|||||||
|
|
||||||
## 🏗️ Arquitectura del Proyecto (Monorepo)
|
## 🏗️ Arquitectura del Proyecto (Monorepo)
|
||||||
|
|
||||||
El repositorio está dividido en dos aplicaciones principales que interactúan mediante una API REST y un patrón Backend-For-Frontend (BFF).
|
El repositorio implementa un patrón desacoplado en formato monorepo compuesto por la **Core API (Backend)**, la capa de mediación y vistas **BFF (Frontend)**, herramientas unificadas de despliegue, auditoría automatizada y flujos de CI/CD:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/
|
/
|
||||||
├── backend/ # API REST Backend (Python / Flask)
|
├── backend/ # Capa Core API (Python 3.10+ / Flask / SQLAlchemy)
|
||||||
│ ├── app/ # Lógica de negocio, base de datos y endpoints
|
│ ├── app/ # Lógica de negocio y arquitectura desacoplada
|
||||||
│ ├── tests/ # Pruebas unitarias e integración
|
│ │ ├── models/ # Modelos ORM (User, Role, Classroom, Reservation, etc.)
|
||||||
│ └── requirements.txt # Dependencias de Python
|
│ │ ├── routes/api/ # Endpoints REST JSON v1 (/auth, /classrooms, /admin, etc.)
|
||||||
|
│ │ ├── schemas/ # DTOs y validación de contratos (Pydantic v2)
|
||||||
|
│ │ ├── services/ # Servicios de dominio (JWT, Email SMTP, Moodle, Queue)
|
||||||
|
│ │ └── security/ # Pipeline de filtros y validación stateless de JWT
|
||||||
|
│ ├── tests/ # Suite activa de pruebas unitarias y de integración (pytest)
|
||||||
|
│ ├── init_db.py # Inicializador idempotente de esquema, roles RBAC y semillas
|
||||||
|
│ ├── backup_db.sh # Generador automatizado de backups para PostgreSQL
|
||||||
|
│ ├── classrooms_db.sql # Snapshot integral de base de datos para despliegue
|
||||||
|
│ ├── wsgi.py # Entrada WSGI para ejecución con Gunicorn
|
||||||
|
│ └── requirements.txt # Dependencias Python auditadas (0 vulnerabilidades / CVEs)
|
||||||
│
|
│
|
||||||
├── frontend/ # Backend-For-Frontend (Node.js / Express)
|
├── frontend/ # Backend-For-Frontend BFF (Node.js 20 LTS / Express)
|
||||||
│ ├── src/ # Controladores, middleware, servicios (Axios)
|
│ ├── src/ # Capa servidora de mediación web
|
||||||
│ ├── views/ # Plantillas Nunjucks (.html)
|
│ │ ├── app.js # Servidor Express, seguridad Helmet, rate-limiting
|
||||||
│ ├── public/ # Archivos estáticos (CSS, JS cliente)
|
│ │ ├── controllers/ # Controladores de orquestación y mediación
|
||||||
│ └── package.json # Dependencias de Node.js
|
│ │ ├── middlewares/ # Sesión por Cookie HttpOnly, JWT injection y RBAC
|
||||||
|
│ │ ├── routes/ # Enrutamiento web (auth, schedule, classrooms, admin)
|
||||||
|
│ │ └── services/ # Cliente Axios centralizado hacia Flask API
|
||||||
|
│ ├── views/ # Plantillas y componentes dinámicos en Nunjucks (.html)
|
||||||
|
│ │ ├── admin/ # Paneles de gestión institucional y configuración
|
||||||
|
│ │ ├── classrooms/ # Catálogo de aulas y visualización de sedes
|
||||||
|
│ │ ├── schedule/ # Cronograma de comisiones, reservas y calendario
|
||||||
|
│ │ ├── my_subjects/ # Libreta de calificaciones, hitos evaluativos y cursada
|
||||||
|
│ │ └── partials/ # Componentes reutilizables, modales y navegación
|
||||||
|
│ ├── public/ # Assets estáticos (CSS responsive 720p/1080p, JS cliente)
|
||||||
|
│ └── package.json # Dependencias y manifiesto de Node.js
|
||||||
│
|
│
|
||||||
├── docs/ # Historial de cambios y documentación técnica
|
├── install.sh # Instalador nativo unificado para Linux / Proxmox LXC
|
||||||
└── unicaba_prerequitos/ # Requisitos académicos y de negocio
|
├── security_audit.bat # Suite automatizada de auditoría (SAST, SCA, DAST)
|
||||||
|
├── .gitea/workflows/ # Pipelines de integración continua (CI/CD en Gitea)
|
||||||
|
├── docs/ # Especificaciones técnicas, auditorías y changelogs
|
||||||
|
├── ROADMAP_MVP.md # Plan de evolución, estadios y fases del proyecto
|
||||||
|
├── SECURITY_AUDIT_REPORT.md # Informe formal de auditoría y hardening
|
||||||
|
└── README.md # Documentación general y guía operativa
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🐍 Backend (Python + Flask)
|
### 🐍 Backend Core API (Python + Flask)
|
||||||
- Funciona exclusivamente como una **REST API** que expone endpoints bajo `/api/v1/` retornando **JSON**.
|
- Funciona exclusivamente como una **REST API** que expone endpoints bajo `/api/v1/` retornando **JSON**.
|
||||||
- Encargado de la persistencia de datos (SQLAlchemy / PostgreSQL), lógica de negocio dura, JWT auth service, seguridad, y algoritmos de optimización (Módulo Predictivo).
|
- Encargado de la persistencia de datos (SQLAlchemy / PostgreSQL o SQLite), lógica de negocio, servicio de tokens JWT (Access & Refresh), matriz RBAC, y algoritmos de optimización heurística (Módulo Predictivo).
|
||||||
- **Prohibido** retornar HTML o utilizar Jinja2/`render_template` en este entorno.
|
- **Prohibido** retornar HTML o utilizar Jinja2/`render_template` en este entorno.
|
||||||
|
|
||||||
### 🟢 Frontend BFF (Node.js + Express)
|
### 🟢 Frontend BFF (Node.js + Express)
|
||||||
- Aplicación de servicio que renderiza las vistas para el cliente mediante **Nunjucks**.
|
- Aplicación Backend-For-Frontend que renderiza las vistas para el cliente mediante **Nunjucks**.
|
||||||
- Gestiona la sesión segura del usuario almacenando el token JWT recibido de Flask en una **Cookie HttpOnly**.
|
- Gestiona la sesión segura del usuario almacenando el token JWT recibido de Flask en una **Cookie HttpOnly**.
|
||||||
- Actúa como proxy de conexión con el Backend de Python empleando **Axios** (inyectando el Bearer token interceptado).
|
- Actúa como proxy de conexión con el Backend de Python empleando **Axios** (inyectando el Bearer token interceptado).
|
||||||
- Utiliza **Chart.js** y JS vanilla en el lado del cliente para visualizaciones interactivas.
|
- Blindado con **Helmet** (cabeceras de seguridad HTTP) y **express-rate-limit** para mitigar ataques de fuerza bruta.
|
||||||
|
- Interfaz adaptativa y responsive diseñada para pantallas estándar argentinas (720p, 1366x768 y 1080p) con gráficos dinámicos (**Chart.js**).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,15 +102,49 @@ La plataforma cuenta con paneles adaptados a las necesidades operativas de cada
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ☁️ Sincronización Google Sheets, Configuración Global & Purga Académica
|
## 🏛️ Circuito Operativo de Bedelía (UX & Flujo Simplificado)
|
||||||
|
|
||||||
El sistema incluye sincronización automatizada con las planillas oficiales de cursada de UniCABA:
|
Para garantizar una experiencia de usuario rápida y sin fricción para el personal de Bedelía, el circuito de gestión áulica se divide en 3 etapas directas:
|
||||||
* **Configuración Global Dinámica:** Selección y persistencia de la URL de Google Sheets directamente desde la interfaz web (`/admin/google_sheets_import`) sin valores fijos en el código.
|
|
||||||
* **Herramienta de Purga y Reinicio (`reset_academic_data`):** Módulo CLI y web para limpiar selectivamente reservas, aulas, asignaturas, comisiones o carreras, garantizando pruebas limpias desde cero y preservando siempre usuarios y credenciales.
|
```
|
||||||
* **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.
|
[ 1. Creación / Edición de Aula ] ──► [ 2. Asignación de Comisión al Aula ] ──► [ 3. Cartelera y Grilla ]
|
||||||
* **Catálogo Presencial con Cursadas Asignadas:** Listado de aulas físicas con cálculo de ocupación real (semana/mes), vista previa de asignaturas y enlaces directos a calendario y ficha del espacio.
|
- Sede Central o Campus Virtual - Desde Comisiones (1 clic) - Detección de colisiones
|
||||||
* **Enlaces de Videollamada Automáticos:** Generación de enlaces seguros (Google Meet / Teams) para acceso instantáneo de docentes y estudiantes.
|
- Piso y Aforo real - O desde Ficha de Aula - Drag & Drop semanal
|
||||||
* **Filtros en Cartelera:** Opciones para filtrar por turno, piso u ocultar aulas virtuales mediante el switch dedicado en `/schedule/today_schedule`.
|
- Tipo (Teórica / Laboratorio) - Autocompletado de cupo y Meet - Cartelera del día en vivo
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. Creación y Mantenimiento de Aulas (`/classrooms/list_classrooms`)
|
||||||
|
* Acceso directo a **"Nueva Aula"**: se selecciona la sede (Edificio Central o Campus Virtual), piso (`PB`, `1`, `2`, `3`, `4`) y tipo de espacio (Teórica, Laboratorio de Informática, etc.).
|
||||||
|
* Las aulas virtuales se configuran con capacidad infinita ($\infty$) y no distorsionan la capacidad de bancos físicos del edificio central.
|
||||||
|
* En el catálogo de aulas, cada tarjeta muestra el aforo, piso, equipamiento y el botón de acción rápida **"Asignar Comisión"**, que abre el formulario de reserva con el aula ya preseleccionada.
|
||||||
|
|
||||||
|
### 2. Asignación de Comisión al Aula (`/schedule/add_reservation`)
|
||||||
|
La asignación puede realizarse desde dos puntos de entrada según la necesidad operativa:
|
||||||
|
* **Desde el Catálogo de Comisiones (`/admin/commissions_list`):**
|
||||||
|
- Cada fila cuenta con el botón de acceso rápido **"Asignar Aula / Horario"** (`bi-calendar-plus`).
|
||||||
|
- Al hacer clic, redirige a `/schedule/add_reservation?commission_id={id}` precargando de forma automática:
|
||||||
|
- Nombre de la asignatura y código de comisión.
|
||||||
|
- Cupo esperado de alumnos (`expected_attendees = max_students`).
|
||||||
|
- Enlace de videollamada preexistente (Google Meet / Teams).
|
||||||
|
* **Desde el Catálogo de Aulas (`/classrooms/list_classrooms`):**
|
||||||
|
- Al pulsar **"Asignar Comisión"**, redirige a `/schedule/add_reservation?classroom_id={id}`.
|
||||||
|
- El selector en cascada (**1. Edificio ➔ 2. Piso ➔ 3. Aula**) se auto-posiciona en la sede, planta y aula elegidas.
|
||||||
|
* **Validación en Vivo:** El sistema alerta inmediatamente si la cantidad de alumnos de la comisión excede la capacidad física del aula seleccionada.
|
||||||
|
|
||||||
|
### 3. Visualización y Control de Ocupación
|
||||||
|
* **Cartelera del Día (`/schedule/today_schedule`):** Bloques horarios de 07:00 a 21:00 hs con filtros por piso, turno y switch para ocultar aulas virtuales.
|
||||||
|
* **Grilla Semanal Interactiva (`/schedule/calendar`):** Arrastre de comisiones sin aula asignada desde el panel lateral con detección de colisiones físicas, solapamientos docentes y aforo en tiempo real (HTTP 409).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧹 Módulo de Purga y Sincronización Académica
|
||||||
|
|
||||||
|
* **Purga Segura y Selectiva (`/admin/google_sheets_import`):**
|
||||||
|
- Permite restablecer limpiamente reservas, comisiones, asignaturas, carreras y/o aulas para comenzar nuevos ciclos lectivos o pruebas de carga.
|
||||||
|
- **Confirmación Interactiva:** Cuenta con un modal de confirmación (`#purgeConfirmModal`) que lista las entidades específicas a eliminar y previene clics accidentales.
|
||||||
|
- **Integridad Referencial Estricta:** Ejecuta la eliminación en orden topológico inverso de claves foráneas (`Reservation` ➔ `MilestoneGrade` ➔ `CommissionTeacher` ➔ `StudentEnrollment` ➔ `Commission` ➔ `Subject` ➔ `Career` ➔ `Classroom`), garantizando que la base de datos no rechace el borrado por restricciones relacionales.
|
||||||
|
- **Preservación Total de Identidades:** Los usuarios (`User`), contraseñas y roles (`Role`) están estrictamente blindados y nunca son afectados por la purga de datos académicos.
|
||||||
|
* **Importación desde Google Sheets:** Carga y sincronización dinámica de todas las materias y franjas horarias directamente desde la hoja institucional publicada.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -153,13 +214,51 @@ node src/app.js
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 📦 Despliegue Automatizado en Producción (Linux / Proxmox LXC)
|
||||||
|
|
||||||
|
Para servidores Debian 12 / Ubuntu en Proxmox LXC o VPS, el repositorio incluye el instalador unificado [install.sh](install.sh) en la raíz:
|
||||||
|
|
||||||
|
# Despliegue interactivo (pregunta si mantener BD, restaurar snapshot o crear base limpia):
|
||||||
|
sudo bash install.sh
|
||||||
|
|
||||||
|
# Despliegue desatendido manteniendo la base de datos existente:
|
||||||
|
sudo bash install.sh --keep-db
|
||||||
|
|
||||||
|
# Despliegue desatendido preservando la base de datos y los usuarios/credenciales configurados:
|
||||||
|
sudo bash install.sh --keep-db --keep-users
|
||||||
|
|
||||||
|
# Despliegue desatendido con base de datos limpia sin carreras, materias, ciclos lectivos, aulas ni edificios demo:
|
||||||
|
sudo bash install.sh --empty-academic
|
||||||
|
|
||||||
|
# Despliegue desatendido recreando la base de datos desde cero con snapshot de muestra (DROP y restore classrooms_db.sql):
|
||||||
|
sudo bash install.sh --fresh-db
|
||||||
|
```
|
||||||
|
|
||||||
|
También es posible inicializar directamente la base de datos limpia desde Python en cualquier entorno:
|
||||||
|
```bash
|
||||||
|
# Inicialización estándar con datos de muestra:
|
||||||
|
python backend/init_db.py
|
||||||
|
|
||||||
|
# Inicialización limpia (sin carreras, materias, ciclos lectivos, aulas, edificios ni reservas):
|
||||||
|
python backend/init_db.py --empty-academic
|
||||||
|
```
|
||||||
|
|
||||||
|
El script configura automáticamente:
|
||||||
|
1. Entorno Python 3.10+ y virtualenv en `backend/venv` con Gunicorn y servicio `admin-edu-space-backend.service` (puerto 5000).
|
||||||
|
2. Entorno Node.js 20 LTS en `frontend/` con servicio `admin-edu-space-frontend.service` (puerto 3000).
|
||||||
|
3. Motor PostgreSQL local con configuración de usuario y opciones seguras para preservar o restaurar la base `classrooms_db`.
|
||||||
|
4. Servicio maestro unificado `admin-edu-space.service` gestionable vía `systemctl {status|restart|stop} admin-edu-space`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🧪 Pruebas Automatizadas
|
## 🧪 Pruebas Automatizadas
|
||||||
|
|
||||||
Para ejecutar la suite completa de pruebas unitarias de regresión (Fases 2, 3 y 4):
|
Para ejecutar la suite completa de 47 pruebas unitarias y de integración (JWT, ABMs, Reglas, Optimizador, Calificaciones y Moodle):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
.\venv\Scripts\python.exe -m unittest tests/test_phase2_rules.py tests/test_phase3_gradebook.py tests/test_phase4_interactive_impersonation.py
|
$env:PYTHONPATH="backend"
|
||||||
|
.\venv\Scripts\python.exe -m pytest tests -v
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+32
-3
@@ -2,9 +2,9 @@
|
|||||||
## Gestión de Espacios Áulicos, Actividad de Cursada y Seguimiento Académico
|
## Gestión de Espacios Áulicos, Actividad de Cursada y Seguimiento Académico
|
||||||
|
|
||||||
**Institución:** Universidad de la Ciudad de Buenos Aires (UniCABA)
|
**Institución:** Universidad de la Ciudad de Buenos Aires (UniCABA)
|
||||||
**Versión:** 2.8.0
|
**Versión:** 3.1.0
|
||||||
**Fecha de Actualización:** 23 de Septiembre de 2026
|
**Fecha de Actualización:** 23 de Septiembre de 2026
|
||||||
**Rama Base:** `testing`
|
**Rama Base:** `mvp`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -21,7 +21,9 @@ Fase 1: Paridad Legacy y Estabilización [████████████
|
|||||||
Fase 2: Co-docencia y Matriz Conflictos [████████████████████] 100% (Completado)
|
Fase 2: Co-docencia y Matriz Conflictos [████████████████████] 100% (Completado)
|
||||||
Fase 3: Hitos Evaluativos y Calificaciones[████████████████████] 100% (Completado)
|
Fase 3: Hitos Evaluativos y Calificaciones[████████████████████] 100% (Completado)
|
||||||
Fase 4: UI/UX Drag & Drop e Impersonación[████████████████████] 100% (Completado)
|
Fase 4: UI/UX Drag & Drop e Impersonación[████████████████████] 100% (Completado)
|
||||||
Fase 5: Despliegue Producción e Integrac.[░░░░░░░░░░░░░░░░░░░░] 0% (Planificada)
|
Fase 5: Integración Auth, Email & Moodle [████████████████████] 100% (Completado)
|
||||||
|
Fase 5.1: Bedelía UX, ABMs y Purga Datos [████████████████████] 100% (Completado)
|
||||||
|
Fase 6: Despliegue Producción e Infraest.[███████░░░░░░░░░░░░░] 35% (En Curso)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -138,6 +140,33 @@ Fase 5: Despliegue Producción e Integrac.[░░░░░░░░░░░░
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Fase 5.1: Bedelía UX, Cobertura Integral de ABMs y Purga Segura de Datos 🏛️ ⚡ ✅ *(100% Completado)*
|
||||||
|
* **Objetivo:** Optimizar de punta a punta la experiencia de usuario de Bedelía, dotar de ABMs completos a todas las entidades de Espacios, Académica y Reservas, y asegurar la purga de datos sin comprometer identidades ni claves foráneas.
|
||||||
|
* **Entregables:**
|
||||||
|
1. **Circuito Ágil de Bedelía:**
|
||||||
|
- [x] Flujo de asignación ágil: de lista de comisiones (`/admin/commissions_list`) a reserva de aula (`/schedule/add_reservation?commission_id=...`) con 1 clic.
|
||||||
|
- [x] Sincronización en cascada de selectores (Edificio -> Piso -> Aula) con detección de modalidad física/virtual y aforo.
|
||||||
|
- [x] Conexión de reservas con comisiones activas en vivo vía API REST desacoplada (fin de mocks estáticos).
|
||||||
|
- [x] Creación de aulas con vinculación consistente de sede y tipología.
|
||||||
|
2. **Cobertura ABM 100% (Espacios, Académica y Reservas):**
|
||||||
|
- [x] **Ciclos Lectivos (`AcademicTerm`):** ABM completo (Crear, Editar, Activar como Ciclo Actual, Toggle Activo/Inactivo rápido y Borrado protegido con soft-deactivate ante comisiones asociadas). Endpoints `/api/v1/admin/academic-terms` y vistas en `/admin/academic_terms`.
|
||||||
|
- [x] **Aulas y Espacios Físicos/Virtuales (`Classroom`):** ABM completo (Alta, Edición de aforo y piso, Toggle rápido activo/inactivo vía `/api/v1/classrooms/<id>/toggle`, y Eliminación con validación de reservas previas). Botones y modales en lista y detalle.
|
||||||
|
- [x] **Sedes y Edificios (`Building`):** ABM completo (Crear, Editar, Toggle y Eliminar sedes físicas e institucionales).
|
||||||
|
- [x] **Carreras (`Career`):** ABM completo (`/admin/careers_list`) con protección ante asignaturas existentes (soft-deactivation).
|
||||||
|
- [x] **Asignaturas (`Subject`):** ABM completo (`/admin/subjects_list`) con vinculación por carrera y mitigación de desvinculación forzada.
|
||||||
|
- [x] **Comisiones (`Commission`):** ABM completo (`/admin/commissions_list`) con asignación directa de profesores, turnos y cupos.
|
||||||
|
- [x] **Reservas (`Reservation`):** ABM completo (Creación, Edición, Confirmación, Cancelación lógica y Eliminación definitiva / Hard Delete con `/api/v1/reservations/<id>?hard=true`).
|
||||||
|
3. **Purga Segura de Datos Académicos:**
|
||||||
|
- [x] Modal de confirmación interactivo en frontend (`#purgeConfirmModal`) con feedback visual y prevención de borrado accidental.
|
||||||
|
- [x] Secuencia de purga backend en estricto orden de integridad referencial (respetando Foreign Keys).
|
||||||
|
- [x] Preservación garantizada de usuarios y roles RBAC ante reseteos de datos de cursada.
|
||||||
|
4. **Configuración y Personalización UI:**
|
||||||
|
- [x] Panel de configuración global (`/admin/settings`) con opción para habilitar o deshabilitar el saludo superior y banner de rol en el dashboard.
|
||||||
|
5. **Inicialización y Despliegue con Base Limpia:**
|
||||||
|
- [x] Soporte `--empty-academic` / `--no-demo` en `install.sh` e `init_db.py` para inicializar el sistema con roles y usuarios intactos pero con tablas académicas y de espacios totalmente vacías para configuración desde cero.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Fase 6: Producción Institucional, CI/CD y Despliegue 🏢 *(En curso)*
|
### Fase 6: Producción Institucional, CI/CD y Despliegue 🏢 *(En curso)*
|
||||||
* **Objetivo:** Puesta en marcha definitiva en la infraestructura de servidores de UniCABA.
|
* **Objetivo:** Puesta en marcha definitiva en la infraestructura de servidores de UniCABA.
|
||||||
* **Estadios:**
|
* **Estadios:**
|
||||||
|
|||||||
@@ -17,10 +17,40 @@ class Building(db.Model):
|
|||||||
# Relationships
|
# Relationships
|
||||||
classrooms = db.relationship('Classroom', backref='building_entity', lazy=True, foreign_keys='Classroom.building_id')
|
classrooms = db.relationship('Classroom', backref='building_entity', lazy=True, foreign_keys='Classroom.building_id')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_virtual(self):
|
||||||
|
"""Identifica si la sede corresponde al Campus Virtual"""
|
||||||
|
c = (self.code or '').upper()
|
||||||
|
n = (self.name or '').upper()
|
||||||
|
return 'VIRTUAL' in c or 'VIRTUAL' in n
|
||||||
|
|
||||||
|
@property
|
||||||
|
def physical_capacity(self):
|
||||||
|
"""Capacidad física instalada real (excluye aulas virtuales con capacidad 9999 o infinitas)"""
|
||||||
|
if self.is_virtual:
|
||||||
|
return 0
|
||||||
|
return sum(c.capacity for c in self.classrooms if c.is_active and (c.capacity or 0) < 999 and not getattr(c, 'is_virtual', False))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_capacity(self):
|
def total_capacity(self):
|
||||||
"""Total student capacity across active classrooms in this building"""
|
"""Capacidad para compatibilidad histórica"""
|
||||||
return sum(c.capacity for c in self.classrooms if c.is_active)
|
if self.is_virtual:
|
||||||
|
return 0
|
||||||
|
return self.physical_capacity
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capacity_display(self):
|
||||||
|
"""Representación textual clara para UI/UX"""
|
||||||
|
if self.is_virtual:
|
||||||
|
return "Ilimitada (Virtual)"
|
||||||
|
return str(self.physical_capacity)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def floors_display(self):
|
||||||
|
"""Representación de pisos"""
|
||||||
|
if self.is_virtual:
|
||||||
|
return "Digital"
|
||||||
|
return str(self.floors)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def classrooms_count(self):
|
def classrooms_count(self):
|
||||||
@@ -34,10 +64,14 @@ class Building(db.Model):
|
|||||||
'code': self.code,
|
'code': self.code,
|
||||||
'address': self.address,
|
'address': self.address,
|
||||||
'floors': self.floors,
|
'floors': self.floors,
|
||||||
|
'floors_display': self.floors_display,
|
||||||
'description': self.description,
|
'description': self.description,
|
||||||
'is_active': self.is_active,
|
'is_active': self.is_active,
|
||||||
|
'is_virtual': self.is_virtual,
|
||||||
'classrooms_count': self.classrooms_count,
|
'classrooms_count': self.classrooms_count,
|
||||||
'total_capacity': self.total_capacity,
|
'physical_capacity': self.physical_capacity,
|
||||||
|
'total_capacity': self.physical_capacity if not self.is_virtual else 0,
|
||||||
|
'capacity_display': self.capacity_display,
|
||||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ class Individual:
|
|||||||
score += conflict_score * 0.2
|
score += conflict_score * 0.2
|
||||||
|
|
||||||
# Factor 4: Resource matching (10% weight)
|
# Factor 4: Resource matching (10% weight)
|
||||||
resource_score = self._calculate_resource_score(classroom, request.subject_requirements)
|
resource_score = self._calculate_resource_score(classroom, res_req.subject_requirements)
|
||||||
score += resource_score * 0.1
|
score += resource_score * 0.1
|
||||||
|
|
||||||
self.fitness = score
|
self.fitness = score
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ class Commission(db.Model):
|
|||||||
grades_closed_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
grades_closed_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||||
acta_number = db.Column(db.String(50), nullable=True)
|
acta_number = db.Column(db.String(50), nullable=True)
|
||||||
|
|
||||||
|
# Vinculación con Ciclo Lectivo / Período Académico
|
||||||
|
academic_term_id = db.Column(db.Integer, db.ForeignKey('academic_terms.id', ondelete='SET NULL'), nullable=True)
|
||||||
|
academic_term = db.relationship('AcademicTerm', foreign_keys=[academic_term_id])
|
||||||
|
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
@@ -138,6 +142,7 @@ class Commission(db.Model):
|
|||||||
'grades_closed_by': self.grades_closed_by,
|
'grades_closed_by': self.grades_closed_by,
|
||||||
'closed_by_name': self.closed_by_user.name if self.closed_by_user else None,
|
'closed_by_name': self.closed_by_user.name if self.closed_by_user else None,
|
||||||
'acta_number': self.acta_number,
|
'acta_number': self.acta_number,
|
||||||
|
'academic_term_id': self.academic_term_id,
|
||||||
'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(),
|
'full_code': self.get_full_code(),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func, or_
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.repositories.base_repository import BaseRepository
|
from app.repositories.base_repository import BaseRepository
|
||||||
|
|
||||||
@@ -10,10 +10,25 @@ class UserRepository(BaseRepository[User]):
|
|||||||
super().__init__(User)
|
super().__init__(User)
|
||||||
|
|
||||||
def get_by_email(self, email: str) -> Optional[User]:
|
def get_by_email(self, email: str) -> Optional[User]:
|
||||||
"""Obtiene un usuario por su dirección de email normalizada."""
|
"""Obtiene un usuario por su dirección de email normalizada o alias de usuario."""
|
||||||
if not email:
|
if not email:
|
||||||
return None
|
return None
|
||||||
return User.query.filter(func.lower(User.email) == email.strip().lower()).first()
|
clean = email.strip().lower()
|
||||||
|
user = User.query.filter(func.lower(User.email) == clean).first()
|
||||||
|
if user:
|
||||||
|
return user
|
||||||
|
# Si el usuario ingresó solo el nombre de cuenta (ej. 'admin' o 'bedelia')
|
||||||
|
if '@' not in clean:
|
||||||
|
user = User.query.filter(
|
||||||
|
or_(
|
||||||
|
func.lower(User.email) == f"{clean}@edu-space.com",
|
||||||
|
func.lower(User.name) == clean,
|
||||||
|
func.lower(User.role) == clean.upper()
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if user:
|
||||||
|
return user
|
||||||
|
return None
|
||||||
|
|
||||||
def get_active_users(self) -> List[User]:
|
def get_active_users(self) -> List[User]:
|
||||||
"""Obtiene todos los usuarios con cuenta activa."""
|
"""Obtiene todos los usuarios con cuenta activa."""
|
||||||
|
|||||||
+205
-2
@@ -904,15 +904,22 @@ def academic_term_add():
|
|||||||
|
|
||||||
|
|
||||||
@admin_bp.route('/academic-terms/<int:id>/edit', methods=['POST'])
|
@admin_bp.route('/academic-terms/<int:id>/edit', methods=['POST'])
|
||||||
|
@admin_bp.route('/academic_term_edit', methods=['POST'], defaults={'id': None})
|
||||||
|
@admin_bp.route('/academic_term_edit/<int:id>', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def academic_term_edit(id):
|
def academic_term_edit(id=None):
|
||||||
"""Edit an academic cycle"""
|
"""Edit an academic cycle"""
|
||||||
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
|
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
|
term_id = id or request.form.get('id', type=int)
|
||||||
|
if not term_id:
|
||||||
|
flash(_('Identificador de ciclo lectivo no especificado.'), 'error')
|
||||||
|
return redirect(url_for('admin.academic_terms_list'))
|
||||||
|
|
||||||
from app.models.academic_term import AcademicTerm
|
from app.models.academic_term import AcademicTerm
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
term = AcademicTerm.query.get_or_404(id)
|
term = AcademicTerm.query.get_or_404(term_id)
|
||||||
|
|
||||||
code = request.form.get('code', '').strip()
|
code = request.form.get('code', '').strip()
|
||||||
name = request.form.get('name', '').strip()
|
name = request.form.get('name', '').strip()
|
||||||
@@ -1216,6 +1223,9 @@ def commissions_list():
|
|||||||
or_(User.role.ilike('%DOCENTE%'), User.role.ilike('%docent%'), User.role.ilike('%ADMIN%'))
|
or_(User.role.ilike('%DOCENTE%'), User.role.ilike('%docent%'), User.role.ilike('%ADMIN%'))
|
||||||
).filter_by(is_active=True).order_by(User.name.asc()).all()
|
).filter_by(is_active=True).order_by(User.name.asc()).all()
|
||||||
|
|
||||||
|
from app.models.academic_term import AcademicTerm
|
||||||
|
academic_terms = AcademicTerm.query.filter_by(is_active=True).order_by(AcademicTerm.year.desc(), AcademicTerm.start_date.desc()).all()
|
||||||
|
|
||||||
can_edit = (
|
can_edit = (
|
||||||
current_user.has_permission('academic', 'read_write') or
|
current_user.has_permission('academic', 'read_write') or
|
||||||
current_user.is_admin() or
|
current_user.is_admin() or
|
||||||
@@ -1228,6 +1238,7 @@ def commissions_list():
|
|||||||
pagination=pagination,
|
pagination=pagination,
|
||||||
subjects=subjects,
|
subjects=subjects,
|
||||||
teachers=teachers,
|
teachers=teachers,
|
||||||
|
academic_terms=academic_terms,
|
||||||
search=search,
|
search=search,
|
||||||
subject_filter=subject_filter,
|
subject_filter=subject_filter,
|
||||||
teacher_filter=teacher_filter,
|
teacher_filter=teacher_filter,
|
||||||
@@ -1417,3 +1428,195 @@ def commission_update_enrollment(id, student_id):
|
|||||||
|
|
||||||
flash(_('Estado de inscripcion actualizado.'), 'success')
|
flash(_('Estado de inscripcion actualizado.'), 'success')
|
||||||
return redirect(url_for('admin.commission_detail', id=id))
|
return redirect(url_for('admin.commission_detail', id=id))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# CONFIGURACIÓN GLOBAL DEL SISTEMA & MODO DEMO
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
|
||||||
|
@admin_bp.route('/settings', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def global_settings():
|
||||||
|
"""Vista principal del panel de Configuración Global del Sistema"""
|
||||||
|
if not (current_user.is_admin() or (current_user.role and current_user.role.upper() == 'ADMIN') or current_user.has_permission('system_settings', 'read')):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
from app.models.setting import SystemSetting
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
# Diccionario de parámetros para inicializar el template
|
||||||
|
settings = {
|
||||||
|
'smtp': {
|
||||||
|
'host': SystemSetting.get_value('smtp_host', 'smtp.gmail.com'),
|
||||||
|
'port': int(SystemSetting.get_value('smtp_port', 587)),
|
||||||
|
'user': SystemSetting.get_value('smtp_user', ''),
|
||||||
|
'password_masked': SystemSetting.get_masked_value('smtp_password', ''),
|
||||||
|
'security': SystemSetting.get_value('smtp_security', 'tls'),
|
||||||
|
'from_email': SystemSetting.get_value('smtp_sender_email', 'notificaciones@unicaba.edu.ar'),
|
||||||
|
'from_name': SystemSetting.get_value('smtp_sender_name', 'Edu-Space UniCABA'),
|
||||||
|
'enabled': SystemSetting.get_value('smtp_enabled', 'true') in ['true', 'True', '1', True]
|
||||||
|
},
|
||||||
|
'auth_providers': {
|
||||||
|
'local': SystemSetting.get_value('auth_local_enabled', 'true') in ['true', 'True', '1', True],
|
||||||
|
'local_enabled': SystemSetting.get_value('auth_local_enabled', 'true') in ['true', 'True', '1', True],
|
||||||
|
'google': SystemSetting.get_value('auth_google_enabled', 'false') in ['true', 'True', '1', True],
|
||||||
|
'google_enabled': SystemSetting.get_value('auth_google_enabled', 'false') in ['true', 'True', '1', True],
|
||||||
|
'moodle': SystemSetting.get_value('auth_moodle_enabled', 'false') in ['true', 'True', '1', True],
|
||||||
|
'moodle_enabled': SystemSetting.get_value('auth_moodle_enabled', 'false') in ['true', 'True', '1', True]
|
||||||
|
},
|
||||||
|
'google_oauth': {
|
||||||
|
'client_id': SystemSetting.get_value('google_client_id', ''),
|
||||||
|
'client_secret_masked': SystemSetting.get_masked_value('google_client_secret', ''),
|
||||||
|
'callback_url': SystemSetting.get_value('google_callback_url', '/auth/google/callback'),
|
||||||
|
'allowed_domains': SystemSetting.get_value('google_allowed_domains', 'unicaba.edu.ar,lasalle.edu.ar')
|
||||||
|
},
|
||||||
|
'moodle': {
|
||||||
|
'server_url': SystemSetting.get_value('moodle_server_url', 'http://10.0.0.207/moodle'),
|
||||||
|
'ws_token_masked': SystemSetting.get_masked_value('moodle_ws_token', ''),
|
||||||
|
'timeout': int(SystemSetting.get_value('moodle_timeout', 10)),
|
||||||
|
'auto_sync_enabled': SystemSetting.get_value('moodle_auto_sync_enabled', 'true') in ['true', 'True', '1', True],
|
||||||
|
'sync_interval_minutes': int(SystemSetting.get_value('moodle_sync_interval_minutes', 15))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Estado del Modo Demo y Cuentas Preconfiguradas
|
||||||
|
demo_access_enabled = SystemSetting.get_value('demo_access_enabled', 'true') in ['true', 'True', '1', True]
|
||||||
|
demo_emails = ['bedelia@edu-space.com', 'docente@edu-space.com', 'alumno@edu-space.com']
|
||||||
|
demo_users = User.query.filter(func.lower(User.email).in_(demo_emails)).all()
|
||||||
|
demo_accounts_active = any(u.is_active for u in demo_users)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'admin/settings/global_config.html',
|
||||||
|
settings=settings,
|
||||||
|
demo_access_enabled=demo_access_enabled,
|
||||||
|
demo_accounts_active=demo_accounts_active,
|
||||||
|
demo_users=demo_users
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/settings/demo-mode', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def toggle_demo_mode():
|
||||||
|
"""Habilita o deshabilita el Acceso Rápido Demo y las cuentas default (Bedelía, Docente, Alumno)"""
|
||||||
|
if not (current_user.is_admin() or (current_user.role and current_user.role.upper() == 'ADMIN')):
|
||||||
|
if request.is_json:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Acción no autorizada'}), 403
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
from app.models.setting import SystemSetting
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||||
|
|
||||||
|
# Si viene explícito el booleano 'enabled', lo usamos; si no, alternamos el valor actual
|
||||||
|
if 'enabled' in data:
|
||||||
|
target_state = data['enabled'] in [True, 'true', '1', 'on']
|
||||||
|
else:
|
||||||
|
current_state = SystemSetting.get_value('demo_access_enabled', 'true') in ['true', 'True', '1', True]
|
||||||
|
target_state = not current_state
|
||||||
|
|
||||||
|
# Guardar estado en SystemSetting
|
||||||
|
SystemSetting.set_value(
|
||||||
|
'demo_access_enabled',
|
||||||
|
'true' if target_state else 'false',
|
||||||
|
'Acceso Rápido Demo en pantalla de Login',
|
||||||
|
category='security'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Activar o desactivar las 3 cuentas default
|
||||||
|
demo_emails = ['bedelia@edu-space.com', 'docente@edu-space.com', 'alumno@edu-space.com']
|
||||||
|
demo_users = User.query.filter(func.lower(User.email).in_(demo_emails)).all()
|
||||||
|
for u in demo_users:
|
||||||
|
u.is_active = target_state
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Registro de auditoría
|
||||||
|
action_name = 'ENABLE_DEMO_ACCOUNTS' if target_state else 'DISABLE_DEMO_ACCOUNTS'
|
||||||
|
msg_detail = (
|
||||||
|
'Acceso Rápido Demo habilitado y cuentas Bedelía, Docente y Alumno activadas'
|
||||||
|
if target_state else
|
||||||
|
'Acceso Rápido Demo deshabilitado y cuentas default (Bedelía, Docente, Alumno) desactivadas'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
audit = AuditLog(
|
||||||
|
user_id=current_user.id,
|
||||||
|
user_email=current_user.email,
|
||||||
|
action=action_name,
|
||||||
|
module='settings',
|
||||||
|
details=msg_detail
|
||||||
|
)
|
||||||
|
db.session.add(audit)
|
||||||
|
db.session.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if request.is_json or request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': msg_detail,
|
||||||
|
'demo_access_enabled': target_state,
|
||||||
|
'demo_accounts_active': target_state
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
flash(_(msg_detail), 'success' if not target_state else 'info')
|
||||||
|
return redirect(url_for('admin.global_settings'))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/settings/auth-providers', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def update_auth_providers_web():
|
||||||
|
"""Actualiza los métodos de autenticación habilitados desde la interfaz de administración."""
|
||||||
|
if not (current_user.is_admin() or (current_user.role and current_user.role.upper() == 'ADMIN')):
|
||||||
|
if request.is_json or request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return jsonify({'status': 'error', 'message': 'Acción no autorizada'}), 403
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
from app.models.setting import SystemSetting
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||||
|
local_input = data.get('local_enabled') if 'local_enabled' in data else data.get('local', True)
|
||||||
|
google_input = data.get('google_enabled') if 'google_enabled' in data else data.get('google', False)
|
||||||
|
moodle_input = data.get('moodle_enabled') if 'moodle_enabled' in data else data.get('moodle', False)
|
||||||
|
|
||||||
|
local_val = 'true' if local_input in [True, 'true', '1', 'on'] else 'false'
|
||||||
|
google_val = 'true' if google_input in [True, 'true', '1', 'on'] else 'false'
|
||||||
|
moodle_val = 'true' if moodle_input in [True, 'true', '1', 'on'] else 'false'
|
||||||
|
|
||||||
|
SystemSetting.set_value('auth_local_enabled', local_val, 'Habilitar login nativo con usuario/contraseña', category='sso')
|
||||||
|
SystemSetting.set_value('auth_google_enabled', google_val, 'Habilitar login SSO con Google Workspace', category='sso')
|
||||||
|
SystemSetting.set_value('auth_moodle_enabled', moodle_val, 'Habilitar login delegado con Moodle', category='sso')
|
||||||
|
|
||||||
|
try:
|
||||||
|
audit = AuditLog(
|
||||||
|
user_id=current_user.id,
|
||||||
|
user_email=current_user.email,
|
||||||
|
action='UPDATE_AUTH_PROVIDERS',
|
||||||
|
module='settings',
|
||||||
|
details=f"Métodos de login actualizados: Local={local_val}, Google={google_val}, Moodle={moodle_val}"
|
||||||
|
)
|
||||||
|
db.session.add(audit)
|
||||||
|
db.session.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
resp_data = {
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Métodos de autenticación actualizados correctamente.',
|
||||||
|
'auth_providers': {
|
||||||
|
'local': local_val == 'true',
|
||||||
|
'local_enabled': local_val == 'true',
|
||||||
|
'google': google_val == 'true',
|
||||||
|
'google_enabled': google_val == 'true',
|
||||||
|
'moodle': moodle_val == 'true',
|
||||||
|
'moodle_enabled': moodle_val == 'true'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.is_json or request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return jsonify(resp_data), 200
|
||||||
|
|
||||||
|
flash(_('Métodos de autenticación actualizados correctamente.'), 'success')
|
||||||
|
return redirect(url_for('admin.global_settings'))
|
||||||
|
|
||||||
|
|||||||
@@ -131,7 +131,9 @@ def get_subjects():
|
|||||||
'name': s.name,
|
'name': s.name,
|
||||||
'career_id': s.career_id,
|
'career_id': s.career_id,
|
||||||
'career_name': s.career_obj.name if s.career_obj else (s.department or 'General'),
|
'career_name': s.career_obj.name if s.career_obj else (s.department or 'General'),
|
||||||
|
'career_obj': {'id': s.career_obj.id, 'name': s.career_obj.name} if s.career_obj else None,
|
||||||
'department': s.department or 'General',
|
'department': s.department or 'General',
|
||||||
|
'description': getattr(s, 'description', '') or '',
|
||||||
'credits': getattr(s, 'credits', 4),
|
'credits': getattr(s, 'credits', 4),
|
||||||
'is_active': s.is_active,
|
'is_active': s.is_active,
|
||||||
'commissions_count': len(s.commissions),
|
'commissions_count': len(s.commissions),
|
||||||
@@ -162,6 +164,7 @@ def get_careers():
|
|||||||
'id': c.id,
|
'id': c.id,
|
||||||
'name': c.name,
|
'name': c.name,
|
||||||
'code': getattr(c, 'code', f'CAR-{c.id}'),
|
'code': getattr(c, 'code', f'CAR-{c.id}'),
|
||||||
|
'description': getattr(c, 'description', '') or '',
|
||||||
'degree_level': getattr(c, 'degree_level', 'Grado / Tecnicatura'),
|
'degree_level': getattr(c, 'degree_level', 'Grado / Tecnicatura'),
|
||||||
'is_active': getattr(c, 'is_active', True),
|
'is_active': getattr(c, 'is_active', True),
|
||||||
'subjects_count': len(c.subjects),
|
'subjects_count': len(c.subjects),
|
||||||
@@ -217,6 +220,7 @@ def get_commissions():
|
|||||||
'career_name': career_name,
|
'career_name': career_name,
|
||||||
'semester': getattr(c, 'semester', '1C') or '1C',
|
'semester': getattr(c, 'semester', '1C') or '1C',
|
||||||
'year': getattr(c, 'year', 2026) or 2026,
|
'year': getattr(c, 'year', 2026) or 2026,
|
||||||
|
'academic_term_id': getattr(c, 'academic_term_id', None),
|
||||||
'teacher_id': c.teacher_id,
|
'teacher_id': c.teacher_id,
|
||||||
'teacher_name': c.teacher_name if hasattr(c, 'teacher_name') else (c.teacher.name if c.teacher else None),
|
'teacher_name': c.teacher_name if hasattr(c, 'teacher_name') else (c.teacher.name if c.teacher else None),
|
||||||
'teachers': c.teachers if hasattr(c, 'teachers') else [],
|
'teachers': c.teachers if hasattr(c, 'teachers') else [],
|
||||||
@@ -240,11 +244,16 @@ def get_commissions():
|
|||||||
'career_name': c_name
|
'career_name': c_name
|
||||||
})
|
})
|
||||||
|
|
||||||
|
from app.models.academic_term import AcademicTerm
|
||||||
|
terms = AcademicTerm.query.filter_by(is_active=True).order_by(AcademicTerm.year.desc(), AcademicTerm.start_date.desc()).all()
|
||||||
|
terms_data = [t.to_dict() for t in terms]
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'total': len(commissions_data),
|
'total': len(commissions_data),
|
||||||
'commissions': commissions_data,
|
'commissions': commissions_data,
|
||||||
'subjects': subjects_data
|
'subjects': subjects_data,
|
||||||
|
'academic_terms': terms_data
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
@@ -345,6 +354,20 @@ def create_user():
|
|||||||
'user': user.to_dict()
|
'user': user.to_dict()
|
||||||
}), 201
|
}), 201
|
||||||
|
|
||||||
|
@api_admin_bp.route('/users/<int:id>', methods=['GET'])
|
||||||
|
@jwt_required
|
||||||
|
def get_user_by_id(id):
|
||||||
|
user = User.query.get_or_404(id)
|
||||||
|
d = user.to_dict()
|
||||||
|
d['role_obj'] = {
|
||||||
|
'id': user.role_obj.id,
|
||||||
|
'name': user.role_obj.name
|
||||||
|
} if user.role_obj else {'id': 1, 'name': user.role or 'Admin'}
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'user': d
|
||||||
|
}), 200
|
||||||
|
|
||||||
@api_admin_bp.route('/users/<int:id>', methods=['PUT'])
|
@api_admin_bp.route('/users/<int:id>', methods=['PUT'])
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def update_user(id):
|
def update_user(id):
|
||||||
@@ -444,6 +467,38 @@ def get_document_types():
|
|||||||
'document_types': DOCUMENT_TYPES
|
'document_types': DOCUMENT_TYPES
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
|
@api_admin_bp.route('/users/<int:id>/password', methods=['POST', 'PUT'])
|
||||||
|
@jwt_required
|
||||||
|
def reset_user_password(id):
|
||||||
|
"""
|
||||||
|
Permite a los administradores restablecer la contraseña de acceso de cualquier usuario.
|
||||||
|
"""
|
||||||
|
if not g.jwt_user.is_admin():
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Forbidden',
|
||||||
|
'message': 'Solo los administradores institucionales pueden restablecer contraseñas.'
|
||||||
|
}), 403
|
||||||
|
|
||||||
|
user = User.query.get_or_404(id)
|
||||||
|
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||||
|
new_password = (data.get('password') or data.get('new_password') or '').strip()
|
||||||
|
|
||||||
|
if not new_password or len(new_password) < 6:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'BadRequest',
|
||||||
|
'message': 'La nueva contraseña debe contener al menos 6 caracteres.'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
user.set_password(new_password)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Contraseña restablecida exitosamente para {user.name}.',
|
||||||
|
'user_id': user.id
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
@api_admin_bp.route('/users/<int:id>/toggle', methods=['POST'])
|
@api_admin_bp.route('/users/<int:id>/toggle', methods=['POST'])
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def toggle_user(id):
|
def toggle_user(id):
|
||||||
@@ -753,6 +808,25 @@ def create_commission():
|
|||||||
code = data.get('code', '').strip()
|
code = data.get('code', '').strip()
|
||||||
semester = (data.get('semester') or '1C').strip()
|
semester = (data.get('semester') or '1C').strip()
|
||||||
year = int(data.get('year', datetime.now().year) or datetime.now().year)
|
year = int(data.get('year', datetime.now().year) or datetime.now().year)
|
||||||
|
academic_term_id = data.get('academic_term_id')
|
||||||
|
academic_term_id = int(academic_term_id) if (academic_term_id and str(academic_term_id).strip()) else None
|
||||||
|
|
||||||
|
# Si se seleccionó Ciclo Lectivo, sincronizar año y cuatrimestre inteligentemente
|
||||||
|
if academic_term_id:
|
||||||
|
from app.models.academic_term import AcademicTerm
|
||||||
|
term_obj = AcademicTerm.query.get(academic_term_id)
|
||||||
|
if term_obj:
|
||||||
|
if not data.get('year') or int(data.get('year')) == datetime.now().year:
|
||||||
|
year = term_obj.year
|
||||||
|
if not data.get('semester') or data.get('semester') == '1C':
|
||||||
|
p_text = f"{term_obj.name} {term_obj.period_type or ''} {term_obj.code}".lower()
|
||||||
|
if '2c' in p_text or 'segundo' in p_text or '2º' in p_text or '2do' in p_text:
|
||||||
|
semester = '2C'
|
||||||
|
elif 'anual' in p_text:
|
||||||
|
semester = 'Anual'
|
||||||
|
else:
|
||||||
|
semester = '1C'
|
||||||
|
|
||||||
teacher_id = data.get('teacher_id')
|
teacher_id = data.get('teacher_id')
|
||||||
teacher_id = int(teacher_id) if (teacher_id and str(teacher_id).strip()) else None
|
teacher_id = int(teacher_id) if (teacher_id and str(teacher_id).strip()) else None
|
||||||
max_students = int(data.get('max_students', 35) or 35)
|
max_students = int(data.get('max_students', 35) or 35)
|
||||||
@@ -788,6 +862,8 @@ def create_commission():
|
|||||||
existing.max_students = max_students
|
existing.max_students = max_students
|
||||||
existing.schedule = schedule
|
existing.schedule = schedule
|
||||||
existing.shift = shift
|
existing.shift = shift
|
||||||
|
if academic_term_id:
|
||||||
|
existing.academic_term_id = academic_term_id
|
||||||
if virtual_link:
|
if virtual_link:
|
||||||
existing.virtual_link = virtual_link
|
existing.virtual_link = virtual_link
|
||||||
existing.active = bool(active)
|
existing.active = bool(active)
|
||||||
@@ -812,6 +888,7 @@ def create_commission():
|
|||||||
schedule=schedule,
|
schedule=schedule,
|
||||||
shift=shift,
|
shift=shift,
|
||||||
virtual_link=virtual_link,
|
virtual_link=virtual_link,
|
||||||
|
academic_term_id=academic_term_id,
|
||||||
active=bool(active)
|
active=bool(active)
|
||||||
)
|
)
|
||||||
db.session.add(commission)
|
db.session.add(commission)
|
||||||
@@ -853,6 +930,11 @@ def update_commission(id):
|
|||||||
comm = Commission.query.get_or_404(id)
|
comm = Commission.query.get_or_404(id)
|
||||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||||
|
|
||||||
|
if 'subject_id' in data and data['subject_id']:
|
||||||
|
comm.subject_id = int(data['subject_id'])
|
||||||
|
if 'academic_term_id' in data:
|
||||||
|
t_id = data['academic_term_id']
|
||||||
|
comm.academic_term_id = int(t_id) if (t_id and str(t_id).strip()) else None
|
||||||
if 'code' in data and data['code']:
|
if 'code' in data and data['code']:
|
||||||
comm.code = data['code'].strip()
|
comm.code = data['code'].strip()
|
||||||
if 'semester' in data and data['semester']:
|
if 'semester' in data and data['semester']:
|
||||||
@@ -861,7 +943,7 @@ def update_commission(id):
|
|||||||
comm.year = int(data['year'])
|
comm.year = int(data['year'])
|
||||||
if 'teacher_id' in data:
|
if 'teacher_id' in data:
|
||||||
t_id = data['teacher_id']
|
t_id = data['teacher_id']
|
||||||
comm.teacher_id = int(t_id) if t_id else None
|
comm.teacher_id = int(t_id) if (t_id and str(t_id).strip()) else None
|
||||||
if comm.teacher_id:
|
if comm.teacher_id:
|
||||||
ct = CommissionTeacher.query.filter_by(commission_id=comm.id, user_id=comm.teacher_id).first()
|
ct = CommissionTeacher.query.filter_by(commission_id=comm.id, user_id=comm.teacher_id).first()
|
||||||
if not ct:
|
if not ct:
|
||||||
@@ -898,6 +980,33 @@ def toggle_commission(id):
|
|||||||
'message': f'Comisión {"activada" if comm.active else "desactivada"} correctamente.'
|
'message': f'Comisión {"activada" if comm.active else "desactivada"} correctamente.'
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
|
@api_admin_bp.route('/commissions/<int:id>', methods=['DELETE'])
|
||||||
|
@jwt_required
|
||||||
|
def delete_commission(id):
|
||||||
|
comm = Commission.query.get_or_404(id)
|
||||||
|
reservations_count = len(comm.reservations) if comm.reservations else 0
|
||||||
|
enrollments_count = len(comm.enrollments) if hasattr(comm, 'enrollments') and comm.enrollments else 0
|
||||||
|
|
||||||
|
if reservations_count > 0 or enrollments_count > 0:
|
||||||
|
comm.active = False
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'deactivated',
|
||||||
|
'action': 'deactivated',
|
||||||
|
'message': f'La comisión tiene {reservations_count} reserva(s) y {enrollments_count} alumno(s) inscriptos. Se ha desactivado en su lugar para mantener la consistencia histórica.'
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
from app.models.commission_teacher import CommissionTeacher
|
||||||
|
CommissionTeacher.query.filter_by(commission_id=comm.id).delete()
|
||||||
|
code = comm.code
|
||||||
|
db.session.delete(comm)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'deleted',
|
||||||
|
'action': 'deleted',
|
||||||
|
'message': f'Comisión {code} eliminada exitosamente.'
|
||||||
|
}), 200
|
||||||
|
|
||||||
@api_admin_bp.route('/commissions/<int:id>/assign-teacher', methods=['POST'])
|
@api_admin_bp.route('/commissions/<int:id>/assign-teacher', methods=['POST'])
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def assign_commission_teacher(id):
|
def assign_commission_teacher(id):
|
||||||
@@ -1278,6 +1387,53 @@ def set_current_academic_term(id):
|
|||||||
'message': f'"{term.name}" establecido como ciclo activo.'
|
'message': f'"{term.name}" establecido como ciclo activo.'
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
|
@api_admin_bp.route('/academic-terms/<int:id>/toggle', methods=['POST'])
|
||||||
|
@jwt_required
|
||||||
|
def toggle_academic_term(id):
|
||||||
|
term = AcademicTerm.query.get_or_404(id)
|
||||||
|
if term.is_current and term.is_active:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Conflict',
|
||||||
|
'message': 'No se puede desactivar el ciclo lectivo actualmente activo. Seleccione otro ciclo activo primero.'
|
||||||
|
}), 400
|
||||||
|
term.is_active = not term.is_active
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'id': term.id,
|
||||||
|
'is_active': term.is_active,
|
||||||
|
'message': f'Ciclo lectivo {"habilitado" if term.is_active else "desactivado"} correctamente.'
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
@api_admin_bp.route('/academic-terms/<int:id>', methods=['DELETE'])
|
||||||
|
@jwt_required
|
||||||
|
def delete_academic_term(id):
|
||||||
|
term = AcademicTerm.query.get_or_404(id)
|
||||||
|
if term.is_current:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Conflict',
|
||||||
|
'message': 'No se puede eliminar el ciclo lectivo actualmente activo. Seleccione otro ciclo activo primero.'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
commissions_count = Commission.query.filter_by(academic_term_id=id).count()
|
||||||
|
if commissions_count > 0:
|
||||||
|
# Soft delete para preservar integridad de comisiones
|
||||||
|
term.is_active = False
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'soft_deleted': True,
|
||||||
|
'message': f'El ciclo lectivo tiene {commissions_count} comisiones asociadas. Ha sido desactivado para proteger el historial académico.'
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
db.session.delete(term)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'soft_deleted': False,
|
||||||
|
'message': 'Ciclo lectivo eliminado permanentemente.'
|
||||||
|
}), 200
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
# MILESTONE TYPES CRUD
|
# MILESTONE TYPES CRUD
|
||||||
# ---------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
@@ -1700,8 +1856,11 @@ def get_all_settings():
|
|||||||
}
|
}
|
||||||
|
|
||||||
auth_providers = {
|
auth_providers = {
|
||||||
|
'local': SystemSetting.get_value('auth_local_enabled', 'true') in ['true', 'True', '1', True],
|
||||||
'local_enabled': SystemSetting.get_value('auth_local_enabled', 'true') in ['true', 'True', '1', True],
|
'local_enabled': SystemSetting.get_value('auth_local_enabled', 'true') in ['true', 'True', '1', True],
|
||||||
|
'google': SystemSetting.get_value('auth_google_enabled', 'false') in ['true', 'True', '1', True],
|
||||||
'google_enabled': SystemSetting.get_value('auth_google_enabled', 'false') in ['true', 'True', '1', True],
|
'google_enabled': SystemSetting.get_value('auth_google_enabled', 'false') in ['true', 'True', '1', True],
|
||||||
|
'moodle': SystemSetting.get_value('auth_moodle_enabled', 'false') in ['true', 'True', '1', True],
|
||||||
'moodle_enabled': SystemSetting.get_value('auth_moodle_enabled', 'false') in ['true', 'True', '1', True]
|
'moodle_enabled': SystemSetting.get_value('auth_moodle_enabled', 'false') in ['true', 'True', '1', True]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1720,13 +1879,18 @@ def get_all_settings():
|
|||||||
'sync_interval_minutes': int(SystemSetting.get_value('moodle_sync_interval_minutes', 15))
|
'sync_interval_minutes': int(SystemSetting.get_value('moodle_sync_interval_minutes', 15))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ui_config = {
|
||||||
|
'show_role_banner': SystemSetting.get_value('dashboard_role_banner_enabled', 'true') in ['true', 'True', '1', True]
|
||||||
|
}
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'settings': {
|
'settings': {
|
||||||
'smtp': smtp_config,
|
'smtp': smtp_config,
|
||||||
'auth_providers': auth_providers,
|
'auth_providers': auth_providers,
|
||||||
'google_oauth': google_oauth,
|
'google_oauth': google_oauth,
|
||||||
'moodle': moodle_config
|
'moodle': moodle_config,
|
||||||
|
'ui': ui_config
|
||||||
}
|
}
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
@@ -1868,9 +2032,15 @@ def update_auth_providers():
|
|||||||
return jsonify({'error': 'Forbidden', 'message': 'Solo administradores pueden modificar los métodos de autenticación.'}), 403
|
return jsonify({'error': 'Forbidden', 'message': 'Solo administradores pueden modificar los métodos de autenticación.'}), 403
|
||||||
|
|
||||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||||
local_val = 'true' if data.get('local_enabled', True) in [True, 'true', '1', 'on'] else 'false'
|
|
||||||
google_val = 'true' if data.get('google_enabled', False) in [True, 'true', '1', 'on'] else 'false'
|
# Soporte para claves tanto cortas (local) como largas (local_enabled)
|
||||||
moodle_val = 'true' if data.get('moodle_enabled', False) in [True, 'true', '1', 'on'] else 'false'
|
local_input = data.get('local_enabled') if 'local_enabled' in data else data.get('local', True)
|
||||||
|
google_input = data.get('google_enabled') if 'google_enabled' in data else data.get('google', False)
|
||||||
|
moodle_input = data.get('moodle_enabled') if 'moodle_enabled' in data else data.get('moodle', False)
|
||||||
|
|
||||||
|
local_val = 'true' if local_input in [True, 'true', '1', 'on'] else 'false'
|
||||||
|
google_val = 'true' if google_input in [True, 'true', '1', 'on'] else 'false'
|
||||||
|
moodle_val = 'true' if moodle_input in [True, 'true', '1', 'on'] else 'false'
|
||||||
|
|
||||||
SystemSetting.set_value('auth_local_enabled', local_val, 'Habilitar login nativo con usuario/contraseña', category='sso')
|
SystemSetting.set_value('auth_local_enabled', local_val, 'Habilitar login nativo con usuario/contraseña', category='sso')
|
||||||
SystemSetting.set_value('auth_google_enabled', google_val, 'Habilitar login SSO con Google Workspace', category='sso')
|
SystemSetting.set_value('auth_google_enabled', google_val, 'Habilitar login SSO con Google Workspace', category='sso')
|
||||||
@@ -1891,7 +2061,127 @@ def update_auth_providers():
|
|||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'message': 'Métodos de autenticación actualizados correctamente.'
|
'message': 'Métodos de autenticación actualizados correctamente.',
|
||||||
|
'auth_providers': {
|
||||||
|
'local': local_val == 'true',
|
||||||
|
'local_enabled': local_val == 'true',
|
||||||
|
'google': google_val == 'true',
|
||||||
|
'google_enabled': google_val == 'true',
|
||||||
|
'moodle': moodle_val == 'true',
|
||||||
|
'moodle_enabled': moodle_val == 'true'
|
||||||
|
}
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@api_admin_bp.route('/settings/ui', methods=['POST'])
|
||||||
|
@jwt_required
|
||||||
|
def update_ui_settings():
|
||||||
|
"""Habilita o deshabilita elementos de interfaz de usuario, como el banner de saludo institucional y selector de roles."""
|
||||||
|
from app.models.setting import SystemSetting
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
|
||||||
|
acting_user = getattr(g, 'jwt_user', None)
|
||||||
|
if not (acting_user and (acting_user.is_admin() or getattr(acting_user, 'role', '').upper() == 'ADMIN')):
|
||||||
|
return jsonify({'error': 'Forbidden', 'message': 'Solo administradores pueden modificar las preferencias de interfaz.'}), 403
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||||
|
show_banner_input = data.get('show_role_banner')
|
||||||
|
if show_banner_input is None:
|
||||||
|
show_banner_input = data.get('dashboard_role_banner_enabled')
|
||||||
|
|
||||||
|
show_val = 'true' if show_banner_input in [True, 'true', '1', 'on', 1] else 'false'
|
||||||
|
SystemSetting.set_value('dashboard_role_banner_enabled', show_val, 'Mostrar banner de saludo y selector de rol en Dashboard', category='ui')
|
||||||
|
|
||||||
|
try:
|
||||||
|
audit = AuditLog(
|
||||||
|
user_id=acting_user.id,
|
||||||
|
user_email=acting_user.email,
|
||||||
|
action='UPDATE_UI_SETTINGS',
|
||||||
|
module='settings',
|
||||||
|
details=f"Preferencia de interfaz actualizada: banner_saludo_rol={show_val}"
|
||||||
|
)
|
||||||
|
db.session.add(audit)
|
||||||
|
db.session.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Preferencia de interfaz guardada correctamente.',
|
||||||
|
'ui': {
|
||||||
|
'show_role_banner': show_val == 'true'
|
||||||
|
}
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@api_admin_bp.route('/settings/demo-mode', methods=['GET', 'POST'])
|
||||||
|
@jwt_required
|
||||||
|
def toggle_demo_mode_api():
|
||||||
|
"""Habilita o deshabilita el Acceso Rápido Demo y las cuentas default (Bedelía, Docente, Alumno) vía API REST"""
|
||||||
|
from app.models.setting import SystemSetting
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
acting_user = getattr(g, 'jwt_user', None)
|
||||||
|
if not (acting_user and (acting_user.is_admin() or getattr(acting_user, 'role', '').upper() == 'ADMIN')):
|
||||||
|
return jsonify({'error': 'Forbidden', 'message': 'Solo administradores pueden modificar el modo demo.'}), 403
|
||||||
|
|
||||||
|
demo_emails = ['bedelia@edu-space.com', 'docente@edu-space.com', 'alumno@edu-space.com']
|
||||||
|
|
||||||
|
if request.method == 'GET':
|
||||||
|
demo_access_enabled = SystemSetting.get_value('demo_access_enabled', 'true') in ['true', 'True', '1', True]
|
||||||
|
demo_users = User.query.filter(func.lower(User.email).in_(demo_emails)).all()
|
||||||
|
demo_accounts_active = any(u.is_active for u in demo_users)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'demo_access_enabled': demo_access_enabled,
|
||||||
|
'demo_accounts_active': demo_accounts_active
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||||
|
if 'enabled' in data:
|
||||||
|
target_state = data['enabled'] in [True, 'true', '1', 'on']
|
||||||
|
else:
|
||||||
|
current_state = SystemSetting.get_value('demo_access_enabled', 'true') in ['true', 'True', '1', True]
|
||||||
|
target_state = not current_state
|
||||||
|
|
||||||
|
SystemSetting.set_value(
|
||||||
|
'demo_access_enabled',
|
||||||
|
'true' if target_state else 'false',
|
||||||
|
'Acceso Rápido Demo en pantalla de Login',
|
||||||
|
category='security'
|
||||||
|
)
|
||||||
|
|
||||||
|
demo_users = User.query.filter(func.lower(User.email).in_(demo_emails)).all()
|
||||||
|
for u in demo_users:
|
||||||
|
u.is_active = target_state
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
action_name = 'ENABLE_DEMO_ACCOUNTS' if target_state else 'DISABLE_DEMO_ACCOUNTS'
|
||||||
|
msg_detail = (
|
||||||
|
'Acceso Rápido Demo habilitado y cuentas Bedelía, Docente y Alumno activadas'
|
||||||
|
if target_state else
|
||||||
|
'Acceso Rápido Demo deshabilitado y cuentas default (Bedelía, Docente, Alumno) desactivadas'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
audit = AuditLog(
|
||||||
|
user_id=acting_user.id,
|
||||||
|
user_email=acting_user.email,
|
||||||
|
action=action_name,
|
||||||
|
module='settings',
|
||||||
|
details=msg_detail
|
||||||
|
)
|
||||||
|
db.session.add(audit)
|
||||||
|
db.session.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': msg_detail,
|
||||||
|
'demo_access_enabled': target_state,
|
||||||
|
'demo_accounts_active': target_state
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
@@ -2076,3 +2366,335 @@ def retry_all_failed_moodle_tasks():
|
|||||||
'status': 'success',
|
'status': 'success',
|
||||||
'message': f'Se reiniciaron {count} tareas fallidas a estado PENDIENTE.'
|
'message': f'Se reiniciaron {count} tareas fallidas a estado PENDIENTE.'
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# BUILDINGS CRUD
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
|
||||||
|
@api_admin_bp.route('/buildings', methods=['GET'])
|
||||||
|
@jwt_required
|
||||||
|
def get_buildings():
|
||||||
|
from app.models.building import Building
|
||||||
|
from app.models.classroom import Classroom
|
||||||
|
|
||||||
|
search = request.args.get('search', '').strip().lower()
|
||||||
|
status_filter = request.args.get('status', '').strip().lower()
|
||||||
|
|
||||||
|
query = Building.query
|
||||||
|
if search:
|
||||||
|
query = query.filter(
|
||||||
|
db.or_(
|
||||||
|
Building.name.ilike(f'%{search}%'),
|
||||||
|
Building.code.ilike(f'%{search}%'),
|
||||||
|
Building.address.ilike(f'%{search}%')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if status_filter == 'active':
|
||||||
|
query = query.filter(Building.is_active == True)
|
||||||
|
elif status_filter == 'inactive':
|
||||||
|
query = query.filter(Building.is_active == False)
|
||||||
|
|
||||||
|
buildings = query.order_by(Building.name.asc()).all()
|
||||||
|
buildings_data = [b.to_dict() for b in buildings]
|
||||||
|
|
||||||
|
# Estadísticas globales: Capacidad física real sin sumar 9999 de aulas virtuales
|
||||||
|
all_classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||||
|
physical_classrooms = [c for c in all_classrooms if not getattr(c, 'is_virtual', False) and (c.capacity or 0) < 999 and 'VIRTUAL' not in (c.code or '').upper()]
|
||||||
|
total_physical_capacity = sum(c.capacity for c in physical_classrooms)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'total': len(buildings_data),
|
||||||
|
'buildings': buildings_data,
|
||||||
|
'stats': {
|
||||||
|
'total_buildings': len(buildings),
|
||||||
|
'total_classrooms': len(all_classrooms),
|
||||||
|
'physical_classrooms_count': len(physical_classrooms),
|
||||||
|
'total_physical_capacity': total_physical_capacity,
|
||||||
|
'total_floors': sum(b.floors for b in buildings if not b.is_virtual)
|
||||||
|
}
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@api_admin_bp.route('/buildings/<int:id>', methods=['GET'])
|
||||||
|
@jwt_required
|
||||||
|
def get_building(id):
|
||||||
|
from app.models.building import Building
|
||||||
|
building = Building.query.get(id)
|
||||||
|
if not building:
|
||||||
|
return jsonify({'error': 'NotFound', 'message': f'Edificio con ID {id} no encontrado.'}), 404
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'building': building.to_dict(),
|
||||||
|
'classrooms': [c.to_dict() for c in building.classrooms]
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@api_admin_bp.route('/buildings', methods=['POST'])
|
||||||
|
@jwt_required
|
||||||
|
def create_building():
|
||||||
|
from app.models.building import Building
|
||||||
|
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||||
|
name = (data.get('name') or '').strip()
|
||||||
|
code = (data.get('code') or '').strip().upper() or None
|
||||||
|
address = (data.get('address') or '').strip() or None
|
||||||
|
floors = int(data.get('floors', 1) or 1)
|
||||||
|
description = (data.get('description') or '').strip() or None
|
||||||
|
is_active = data.get('is_active', True)
|
||||||
|
if isinstance(is_active, str):
|
||||||
|
is_active = is_active.lower() in ['true', '1', 'on']
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
return jsonify({'error': 'ValidationError', 'message': 'El nombre del edificio es obligatorio.'}), 400
|
||||||
|
|
||||||
|
existing = Building.query.filter(db.func.lower(Building.name) == name.lower()).first()
|
||||||
|
if existing:
|
||||||
|
return jsonify({'error': 'Conflict', 'message': f'Ya existe un edificio con el nombre "{name}".'}), 409
|
||||||
|
|
||||||
|
building = Building(
|
||||||
|
name=name,
|
||||||
|
code=code,
|
||||||
|
address=address,
|
||||||
|
floors=floors,
|
||||||
|
description=description,
|
||||||
|
is_active=bool(is_active)
|
||||||
|
)
|
||||||
|
db.session.add(building)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Edificio "{name}" creado con éxito.',
|
||||||
|
'building': building.to_dict()
|
||||||
|
}), 201
|
||||||
|
|
||||||
|
|
||||||
|
@api_admin_bp.route('/buildings/<int:id>', methods=['PUT', 'POST'])
|
||||||
|
@jwt_required
|
||||||
|
def update_building(id):
|
||||||
|
from app.models.building import Building
|
||||||
|
from app.models.classroom import Classroom
|
||||||
|
building = Building.query.get(id)
|
||||||
|
if not building:
|
||||||
|
return jsonify({'error': 'NotFound', 'message': f'Edificio con ID {id} no encontrado.'}), 404
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||||
|
name = (data.get('name') or '').strip()
|
||||||
|
if not name:
|
||||||
|
return jsonify({'error': 'ValidationError', 'message': 'El nombre del edificio es obligatorio.'}), 400
|
||||||
|
|
||||||
|
existing = Building.query.filter(
|
||||||
|
db.func.lower(Building.name) == name.lower(),
|
||||||
|
Building.id != id
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
return jsonify({'error': 'Conflict', 'message': f'Ya existe otro edificio con el nombre "{name}".'}), 409
|
||||||
|
|
||||||
|
old_name = building.name
|
||||||
|
building.name = name
|
||||||
|
if 'code' in data:
|
||||||
|
building.code = (data.get('code') or '').strip().upper() or None
|
||||||
|
if 'address' in data:
|
||||||
|
building.address = (data.get('address') or '').strip() or None
|
||||||
|
if 'floors' in data:
|
||||||
|
building.floors = int(data.get('floors', 0) or 0)
|
||||||
|
if 'description' in data:
|
||||||
|
building.description = (data.get('description') or '').strip() or None
|
||||||
|
if 'is_active' in data:
|
||||||
|
is_active = data.get('is_active')
|
||||||
|
if isinstance(is_active, str):
|
||||||
|
building.is_active = is_active.lower() in ['true', '1', 'on']
|
||||||
|
else:
|
||||||
|
building.is_active = bool(is_active)
|
||||||
|
building.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
if old_name != name:
|
||||||
|
Classroom.query.filter_by(building_id=building.id).update({'building': name})
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Edificio "{building.name}" actualizado con éxito.',
|
||||||
|
'building': building.to_dict()
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@api_admin_bp.route('/buildings/<int:id>/toggle', methods=['POST'])
|
||||||
|
@jwt_required
|
||||||
|
def toggle_building(id):
|
||||||
|
from app.models.building import Building
|
||||||
|
building = Building.query.get(id)
|
||||||
|
if not building:
|
||||||
|
return jsonify({'error': 'NotFound', 'message': f'Edificio con ID {id} no encontrado.'}), 404
|
||||||
|
|
||||||
|
building.is_active = not building.is_active
|
||||||
|
building.updated_at = datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Edificio "{building.name}" {"activado" if building.is_active else "desactivado"} exitosamente.',
|
||||||
|
'building': building.to_dict()
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@api_admin_bp.route('/buildings/<int:id>', methods=['DELETE'])
|
||||||
|
@jwt_required
|
||||||
|
def delete_building(id):
|
||||||
|
from app.models.building import Building
|
||||||
|
from app.models.classroom import Classroom
|
||||||
|
building = Building.query.get(id)
|
||||||
|
if not building:
|
||||||
|
return jsonify({'error': 'NotFound', 'message': f'Edificio con ID {id} no encontrado.'}), 404
|
||||||
|
|
||||||
|
count = Classroom.query.filter(
|
||||||
|
db.or_(Classroom.building_id == building.id, Classroom.building == building.name)
|
||||||
|
).count()
|
||||||
|
if count > 0:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Conflict',
|
||||||
|
'message': f'No se puede eliminar el edificio "{building.name}" porque tiene {count} aulas asociadas.'
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
db.session.delete(building)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Edificio "{building.name}" eliminado correctamente.'
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@api_admin_bp.route('/system/reset', methods=['POST'])
|
||||||
|
@api_admin_bp.route('/settings/reset_system', methods=['POST'])
|
||||||
|
@jwt_required
|
||||||
|
def reset_entire_system():
|
||||||
|
"""
|
||||||
|
Reinicializa el sistema depurando historiales, reservas, espacios, sedes y académica.
|
||||||
|
PRESERVA: Todas las cuentas de usuario, roles RBAC, permisos, configuraciones globales
|
||||||
|
y tipos de hitos (pestaña Administración intacta).
|
||||||
|
REQUISITO CRÍTICO DE SEGURIDAD: Debe solicitar y validar la contraseña del perfil administrador en sesión.
|
||||||
|
"""
|
||||||
|
from app.models.grade import MilestoneGrade
|
||||||
|
from app.models.enrollment import StudentEnrollment
|
||||||
|
from app.models.classroom import ClassroomResource
|
||||||
|
from app.models.building import Building
|
||||||
|
from app.models.sync_task import MoodleSyncTask
|
||||||
|
|
||||||
|
acting_user = getattr(g, 'jwt_user', None)
|
||||||
|
if not (acting_user and (acting_user.is_admin() or getattr(acting_user, 'role', '').upper() == 'ADMIN')):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error': 'Forbidden',
|
||||||
|
'message': 'Acceso denegado. Solo un usuario con perfil Administrador puede solicitar la reinicialización del sistema.'
|
||||||
|
}), 403
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||||
|
password = str(data.get('password') or '').strip()
|
||||||
|
|
||||||
|
if not password:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error': 'BadRequest',
|
||||||
|
'message': 'Debe ingresar la contraseña de su usuario administrador para autorizar la reinicialización.'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
if not acting_user.check_password(password):
|
||||||
|
# Registrar intento fallido de reinicio en bitácora de auditoría
|
||||||
|
try:
|
||||||
|
failed_audit = AuditLog(
|
||||||
|
user_id=acting_user.id,
|
||||||
|
user_email=acting_user.email,
|
||||||
|
action='SYSTEM_RESET_FAILED',
|
||||||
|
module='admin',
|
||||||
|
details='Intento de reinicialización del sistema cancelado: contraseña incorrecta.',
|
||||||
|
ip_address=request.remote_addr
|
||||||
|
)
|
||||||
|
db.session.add(failed_audit)
|
||||||
|
db.session.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error': 'Unauthorized',
|
||||||
|
'message': 'Contraseña de administrador incorrecta. Operación cancelada por seguridad.'
|
||||||
|
}), 401
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Purga referencial ordenada de entidades operativas
|
||||||
|
# 1. Reservas
|
||||||
|
reservations_deleted = Reservation.query.delete()
|
||||||
|
|
||||||
|
# 2. Calificaciones e Hitos de cursadas
|
||||||
|
grades_deleted = MilestoneGrade.query.delete()
|
||||||
|
milestones_deleted = AcademicMilestone.query.delete()
|
||||||
|
|
||||||
|
# 3. Matrículas de alumnos y asignaciones docentes a comisiones
|
||||||
|
enrollments_deleted = StudentEnrollment.query.delete()
|
||||||
|
teachers_deleted = CommissionTeacher.query.delete()
|
||||||
|
|
||||||
|
# 4. Comisiones, Materias y Carreras
|
||||||
|
commissions_deleted = Commission.query.delete()
|
||||||
|
subjects_deleted = Subject.query.delete()
|
||||||
|
careers_deleted = Career.query.delete()
|
||||||
|
|
||||||
|
# 5. Recursos de aula, Aulas y Edificios/Sedes
|
||||||
|
resources_deleted = ClassroomResource.query.delete()
|
||||||
|
classrooms_deleted = Classroom.query.delete()
|
||||||
|
buildings_deleted = Building.query.delete()
|
||||||
|
|
||||||
|
# 6. Ciclos Lectivos / Términos Académicos
|
||||||
|
terms_deleted = AcademicTerm.query.delete()
|
||||||
|
|
||||||
|
# 7. Cola de eventos de sincronización Moodle huérfanos
|
||||||
|
moodle_tasks_deleted = MoodleSyncTask.query.delete()
|
||||||
|
|
||||||
|
# 8. Historiales: Limpiar auditorías anteriores
|
||||||
|
logs_deleted = AuditLog.query.delete()
|
||||||
|
|
||||||
|
# 9. Asentar registro oficial inmutable del reinicio en AuditLog
|
||||||
|
reset_audit = AuditLog(
|
||||||
|
user_id=acting_user.id,
|
||||||
|
user_email=acting_user.email,
|
||||||
|
action='SYSTEM_RESET',
|
||||||
|
module='admin',
|
||||||
|
details=(
|
||||||
|
f"Reinicialización total ejecutada por administrador {acting_user.email}. "
|
||||||
|
f"Entidades purgadas: {reservations_deleted} reservas, {classrooms_deleted} aulas, "
|
||||||
|
f"{buildings_deleted} sedes, {commissions_deleted} comisiones, {subjects_deleted} materias, "
|
||||||
|
f"{careers_deleted} carreras, {terms_deleted} ciclos lectivos, {enrollments_deleted} inscripciones, "
|
||||||
|
f"{grades_deleted} notas, {logs_deleted} logs históricos. Cuentas y configuración administrativa preservadas."
|
||||||
|
),
|
||||||
|
ip_address=request.remote_addr
|
||||||
|
)
|
||||||
|
db.session.add(reset_audit)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Sistema reinicializado correctamente. Se han depurado los historiales, reservas, espacios, sedes y académica. Las cuentas de usuario y configuraciones se encuentran intactas.',
|
||||||
|
'purged_stats': {
|
||||||
|
'reservations': reservations_deleted,
|
||||||
|
'classrooms': classrooms_deleted,
|
||||||
|
'buildings': buildings_deleted,
|
||||||
|
'commissions': commissions_deleted,
|
||||||
|
'subjects': subjects_deleted,
|
||||||
|
'careers': careers_deleted,
|
||||||
|
'academic_terms': terms_deleted,
|
||||||
|
'enrollments': enrollments_deleted,
|
||||||
|
'grades': grades_deleted,
|
||||||
|
'prior_audit_logs': logs_deleted
|
||||||
|
}
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error': 'InternalServerError',
|
||||||
|
'message': f'Error durante la reinicialización de la base de datos: {str(e)}'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ def get_auth_providers():
|
|||||||
google_val = SystemSetting.get_value('auth_google_enabled', 'false').lower() in ('true', '1')
|
google_val = SystemSetting.get_value('auth_google_enabled', 'false').lower() in ('true', '1')
|
||||||
moodle_val = SystemSetting.get_value('auth_moodle_enabled', 'false').lower() in ('true', '1')
|
moodle_val = SystemSetting.get_value('auth_moodle_enabled', 'false').lower() in ('true', '1')
|
||||||
google_client_id = SystemSetting.get_value('google_client_id', '')
|
google_client_id = SystemSetting.get_value('google_client_id', '')
|
||||||
|
demo_access_enabled = SystemSetting.get_value('demo_access_enabled', 'true').lower() in ('true', '1')
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
@@ -45,7 +46,8 @@ def get_auth_providers():
|
|||||||
'google': google_val,
|
'google': google_val,
|
||||||
'moodle': moodle_val
|
'moodle': moodle_val
|
||||||
},
|
},
|
||||||
'google_client_id': google_client_id
|
'google_client_id': google_client_id,
|
||||||
|
'demo_access_enabled': demo_access_enabled
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
@@ -340,6 +342,54 @@ def logout():
|
|||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@api_auth_bp.route('/change_password', methods=['POST'])
|
||||||
|
@api_auth_bp.route('/change-password', methods=['POST'])
|
||||||
|
@jwt_required
|
||||||
|
def change_password():
|
||||||
|
"""
|
||||||
|
Permite al usuario autenticado cambiar su propia contraseña institucional.
|
||||||
|
Valida la contraseña actual y la confirmación de la nueva clave.
|
||||||
|
"""
|
||||||
|
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||||
|
current_password = data.get('current_password', '').strip()
|
||||||
|
new_password = data.get('new_password', '').strip()
|
||||||
|
confirm_password = data.get('confirm_password', '').strip()
|
||||||
|
|
||||||
|
if not current_password or not new_password:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'BadRequest',
|
||||||
|
'message': 'Debe ingresar la contraseña actual y la nueva contraseña.'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
if new_password != confirm_password:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'BadRequest',
|
||||||
|
'message': 'La nueva contraseña y su confirmación no coinciden.'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
if len(new_password) < 6:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'BadRequest',
|
||||||
|
'message': 'La nueva contraseña debe tener al menos 6 caracteres.'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
user = g.jwt_user
|
||||||
|
if not user.check_password(current_password):
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Unauthorized',
|
||||||
|
'message': 'La contraseña actual ingresada es incorrecta.'
|
||||||
|
}), 401
|
||||||
|
|
||||||
|
user.set_password(new_password)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f"[Auth] Password successfully updated for user {user.email}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Contraseña actualizada correctamente.'
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
@api_auth_bp.route('/me', methods=['GET'])
|
@api_auth_bp.route('/me', methods=['GET'])
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def get_current_user():
|
def get_current_user():
|
||||||
@@ -348,3 +398,4 @@ def get_current_user():
|
|||||||
"""
|
"""
|
||||||
profile = user_service.get_profile_data(g.jwt_user)
|
profile = user_service.get_profile_data(g.jwt_user)
|
||||||
return jsonify(profile), 200
|
return jsonify(profile), 200
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from flask import Blueprint, request, jsonify, g
|
from flask import Blueprint, request, jsonify, g
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.models.classroom import Classroom
|
||||||
from app.services.classroom_service import ClassroomService
|
from app.services.classroom_service import ClassroomService
|
||||||
from app.schemas.classroom_dto import ClassroomCreateDTO, ClassroomUpdateDTO, ClassroomResponseDTO
|
from app.schemas.classroom_dto import ClassroomCreateDTO, ClassroomUpdateDTO, ClassroomResponseDTO
|
||||||
from app.utils.jwt_decorators import jwt_required, jwt_role_required
|
from app.utils.jwt_decorators import jwt_required, jwt_role_required
|
||||||
@@ -117,3 +119,17 @@ def delete_classroom(classroom_id: int):
|
|||||||
return jsonify({'error': 'NotFound', 'message': f'Aula {classroom_id} no encontrada.'}), 404
|
return jsonify({'error': 'NotFound', 'message': f'Aula {classroom_id} no encontrada.'}), 404
|
||||||
|
|
||||||
return jsonify({'message': f'Aula {classroom_id} eliminada/desactivada correctamente.'}), 200
|
return jsonify({'message': f'Aula {classroom_id} eliminada/desactivada correctamente.'}), 200
|
||||||
|
|
||||||
|
@api_classrooms_bp.route('/<int:classroom_id>/toggle', methods=['POST'])
|
||||||
|
@jwt_role_required('ADMIN', 'ADMINISTRADOR', 'BEDELIA')
|
||||||
|
def toggle_classroom(classroom_id: int):
|
||||||
|
"""Alterna el estado activo/inactivo de un aula."""
|
||||||
|
classroom = Classroom.query.get_or_404(classroom_id)
|
||||||
|
classroom.is_active = not classroom.is_active
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'id': classroom.id,
|
||||||
|
'is_active': classroom.is_active,
|
||||||
|
'message': f'Aula {"activada" if classroom.is_active else "desactivada"} correctamente.'
|
||||||
|
}), 200
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from app import db
|
|||||||
|
|
||||||
from app.models.milestone import AcademicMilestone
|
from app.models.milestone import AcademicMilestone
|
||||||
from app.models.subject import Commission
|
from app.models.subject import Commission
|
||||||
|
from app.models.setting import SystemSetting
|
||||||
|
|
||||||
api_dashboard_bp = Blueprint('api_dashboard', __name__, url_prefix='/api/v1/dashboard')
|
api_dashboard_bp = Blueprint('api_dashboard', __name__, url_prefix='/api/v1/dashboard')
|
||||||
|
|
||||||
@@ -137,9 +138,9 @@ def get_stats():
|
|||||||
recent_audits = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(6).all()
|
recent_audits = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(6).all()
|
||||||
audit_data = [{
|
audit_data = [{
|
||||||
'created_at': a.created_at.strftime('%Y-%m-%d %H:%M') if a.created_at else None,
|
'created_at': a.created_at.strftime('%Y-%m-%d %H:%M') if a.created_at else None,
|
||||||
'action_type': a.action_type,
|
'action_type': getattr(a, 'action', getattr(a, 'action_type', 'AUDIT')),
|
||||||
'ip_address': a.ip_address,
|
'ip_address': a.ip_address,
|
||||||
'user_email': getattr(a.user, 'email', 'System') if hasattr(a, 'user') else 'System'
|
'user_email': getattr(a, 'user_email', None) or (getattr(a.user, 'email', 'System') if hasattr(a, 'user') else 'System')
|
||||||
} for a in recent_audits]
|
} for a in recent_audits]
|
||||||
|
|
||||||
occupancy_rate = min(100, int((today_reservations / max(1, physical_classrooms * 3)) * 100)) if physical_classrooms > 0 else 0
|
occupancy_rate = min(100, int((today_reservations / max(1, physical_classrooms * 3)) * 100)) if physical_classrooms > 0 else 0
|
||||||
@@ -307,6 +308,9 @@ def get_stats():
|
|||||||
'today_schedule_list': today_schedule_list,
|
'today_schedule_list': today_schedule_list,
|
||||||
'upcoming_reservations': upcoming_reservations,
|
'upcoming_reservations': upcoming_reservations,
|
||||||
'milestones': milestones,
|
'milestones': milestones,
|
||||||
|
'ui': {
|
||||||
|
'show_role_banner': SystemSetting.get_value('dashboard_role_banner_enabled', 'true') in ['true', 'True', '1', True]
|
||||||
|
},
|
||||||
'bedelia': {
|
'bedelia': {
|
||||||
'unassigned_commissions_week': unassigned_commissions_week,
|
'unassigned_commissions_week': unassigned_commissions_week,
|
||||||
'unassigned_commissions_month': unassigned_commissions_month,
|
'unassigned_commissions_month': unassigned_commissions_month,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ from flask import Blueprint, request, jsonify, g
|
|||||||
from datetime import datetime, timedelta, date
|
from datetime import datetime, timedelta, date
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.models.reservation import Reservation
|
||||||
from app.services.reservation_service import ReservationService
|
from app.services.reservation_service import ReservationService
|
||||||
from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO
|
from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO
|
||||||
from app.utils.jwt_decorators import jwt_required, jwt_role_required
|
from app.utils.jwt_decorators import jwt_required, jwt_role_required
|
||||||
@@ -218,13 +220,33 @@ def update_reservation(reservation_id: int):
|
|||||||
@api_reservations_bp.route('/<int:reservation_id>', methods=['DELETE'])
|
@api_reservations_bp.route('/<int:reservation_id>', methods=['DELETE'])
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def cancel_reservation(reservation_id: int):
|
def cancel_reservation(reservation_id: int):
|
||||||
"""Cancela una reserva."""
|
"""Cancela o elimina una reserva según el parámetro hard."""
|
||||||
|
hard_delete = request.args.get('hard', '').lower() in ['true', '1']
|
||||||
|
if hard_delete:
|
||||||
|
res = Reservation.query.get(reservation_id)
|
||||||
|
if not res:
|
||||||
|
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
|
||||||
|
db.session.delete(res)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'message': f'Reserva {reservation_id} eliminada permanentemente.'}), 200
|
||||||
|
|
||||||
cancelled = reservation_service.cancel_reservation(reservation_id)
|
cancelled = reservation_service.cancel_reservation(reservation_id)
|
||||||
if not cancelled:
|
if not cancelled:
|
||||||
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
|
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
|
||||||
|
|
||||||
return jsonify({'message': f'Reserva {reservation_id} cancelada correctamente.'}), 200
|
return jsonify({'message': f'Reserva {reservation_id} cancelada correctamente.'}), 200
|
||||||
|
|
||||||
|
@api_reservations_bp.route('/<int:reservation_id>/delete', methods=['POST'])
|
||||||
|
@jwt_required
|
||||||
|
def delete_reservation_post(reservation_id: int):
|
||||||
|
"""Elimina permanentemente una reserva."""
|
||||||
|
res = Reservation.query.get(reservation_id)
|
||||||
|
if not res:
|
||||||
|
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
|
||||||
|
db.session.delete(res)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'message': f'Reserva {reservation_id} eliminada permanentemente.'}), 200
|
||||||
|
|
||||||
@api_reservations_bp.route('/<int:reservation_id>/cancel', methods=['POST'])
|
@api_reservations_bp.route('/<int:reservation_id>/cancel', methods=['POST'])
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def cancel_reservation_post(reservation_id: int):
|
def cancel_reservation_post(reservation_id: int):
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ def login():
|
|||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
return redirect(url_for('main.dashboard'))
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
from app.models.setting import SystemSetting
|
||||||
|
demo_access_enabled = SystemSetting.get_value('demo_access_enabled', 'true') in ['true', 'True', '1', True]
|
||||||
|
|
||||||
form = LoginForm()
|
form = LoginForm()
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
clean_email = (form.email.data or '').strip().lower()
|
clean_email = (form.email.data or '').strip().lower()
|
||||||
@@ -24,7 +27,7 @@ def login():
|
|||||||
if user and user.check_password(form.password.data):
|
if user and user.check_password(form.password.data):
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
flash(_('Tu cuenta ha sido desactivada. Por favor contacta al administrador.'), 'error')
|
flash(_('Tu cuenta ha sido desactivada. Por favor contacta al administrador.'), 'error')
|
||||||
return render_template('auth/login.html', form=form)
|
return render_template('auth/login.html', form=form, demo_access_enabled=demo_access_enabled)
|
||||||
|
|
||||||
login_user(user, remember=form.remember_me.data)
|
login_user(user, remember=form.remember_me.data)
|
||||||
user.update_last_login()
|
user.update_last_login()
|
||||||
@@ -69,7 +72,7 @@ def login():
|
|||||||
pass
|
pass
|
||||||
flash(_('Correo electrónico o contraseña inválidos.'), 'error')
|
flash(_('Correo electrónico o contraseña inválidos.'), 'error')
|
||||||
|
|
||||||
return render_template('auth/login.html', form=form)
|
return render_template('auth/login.html', form=form, demo_access_enabled=demo_access_enabled)
|
||||||
|
|
||||||
@auth_bp.route('/logout')
|
@auth_bp.route('/logout')
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
@@ -32,15 +32,18 @@ def list_buildings():
|
|||||||
|
|
||||||
buildings = query.order_by(Building.name.asc()).all()
|
buildings = query.order_by(Building.name.asc()).all()
|
||||||
|
|
||||||
# Calculate global building stats
|
# Calculate global building stats (excluye aulas virtuales con 9999)
|
||||||
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
||||||
total_capacity = sum(c.capacity for c in Classroom.query.filter_by(is_active=True).all())
|
physical_classrooms = [c for c in Classroom.query.filter_by(is_active=True).all() if not getattr(c, 'is_virtual', False) and (c.capacity or 0) < 999 and 'VIRTUAL' not in (c.code or '').upper()]
|
||||||
|
total_physical_capacity = sum(c.capacity for c in physical_classrooms)
|
||||||
|
total_capacity = total_physical_capacity
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'buildings/list.html',
|
'buildings/list.html',
|
||||||
buildings=buildings,
|
buildings=buildings,
|
||||||
total_classrooms=total_classrooms,
|
total_classrooms=total_classrooms,
|
||||||
total_capacity=total_capacity,
|
total_capacity=total_capacity,
|
||||||
|
total_physical_capacity=total_physical_capacity,
|
||||||
search=search,
|
search=search,
|
||||||
status_filter=status_filter,
|
status_filter=status_filter,
|
||||||
title='Building Management'
|
title='Building Management'
|
||||||
|
|||||||
@@ -13,28 +13,35 @@ from app.models.user import User
|
|||||||
|
|
||||||
BASE_CSV_URL = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vSc_T_BQjbn3uPelioCgx52UM5Py-qNhJN0TYPd1kmsN5jdb3Q8rAaIvNMF_2ZTzQt6bH--yIWQKrKR/pub?single=true&output=csv'
|
BASE_CSV_URL = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vSc_T_BQjbn3uPelioCgx52UM5Py-qNhJN0TYPd1kmsN5jdb3Q8rAaIvNMF_2ZTzQt6bH--yIWQKrKR/pub?single=true&output=csv'
|
||||||
|
|
||||||
|
def map_day_to_idx(day_str):
|
||||||
|
"""Mapea el nombre del día (o prefijo) a su índice numérico 0..6 (Lunes=0)"""
|
||||||
|
d = (day_str or '').lower()
|
||||||
|
if 'lun' in d: return 0
|
||||||
|
if 'mar' in d: return 1
|
||||||
|
if 'mie' in d or 'mié' in d: return 2
|
||||||
|
if 'jue' in d: return 3
|
||||||
|
if 'vie' in d: return 4
|
||||||
|
if 'sab' in d or 'sáb' in d: return 5
|
||||||
|
if 'dom' in d: return 6
|
||||||
|
return 0
|
||||||
|
|
||||||
def normalize_sheets_url(url):
|
def normalize_sheets_url(url):
|
||||||
"""
|
"""
|
||||||
Normaliza enlaces de Google Sheets (compartidos, de edición o publicados)
|
Normaliza enlaces de Google Sheets (compartidos, de edición, con /u/1/, #gid=... o /pubhtml)
|
||||||
para obtener la URL base que acepta parámetro gid=<gid>&output=csv o format=csv.
|
para obtener la URL base que acepta parámetro gid=<gid>&output=csv o format=csv.
|
||||||
"""
|
"""
|
||||||
if not url:
|
if not url:
|
||||||
return BASE_CSV_URL
|
return BASE_CSV_URL
|
||||||
url = url.strip()
|
url = url.strip()
|
||||||
|
|
||||||
# 1. Enlace publicado web (/d/e/2PACX-.../pub...)
|
# 1. Enlace publicado web (/d/e/2PACX-... con o sin /u/X/ y con pub o pubhtml)
|
||||||
if '/d/e/' in url:
|
pub_match = re.search(r'/spreadsheets/(?:u/\d+/)?d/e/([a-zA-Z0-9-_]+)', url)
|
||||||
base_match = re.match(r'^(https://docs\.google\.com/spreadsheets/d/e/[^/?#]+)/pub', url)
|
if pub_match:
|
||||||
if base_match:
|
doc_id = pub_match.group(1)
|
||||||
return f"{base_match.group(1)}/pub?single=true&output=csv"
|
return f"https://docs.google.com/spreadsheets/d/e/{doc_id}/pub?single=true&output=csv"
|
||||||
clean = url.split('&gid=')[0].split('?gid=')[0]
|
|
||||||
sep = '&' if '?' in clean else '?'
|
|
||||||
if 'output=csv' not in clean:
|
|
||||||
clean = f"{clean}{sep}single=true&output=csv"
|
|
||||||
return clean
|
|
||||||
|
|
||||||
# 2. Enlace de edición o visualización (/d/<SPREADSHEET_ID>/edit...)
|
# 2. Enlace de edición o exportación (/d/<SPREADSHEET_ID>/...)
|
||||||
sheet_id_match = re.search(r'/spreadsheets/d/([a-zA-Z0-9-_]+)', url)
|
sheet_id_match = re.search(r'/spreadsheets/(?:u/\d+/)?d/([a-zA-Z0-9-_]+)', url)
|
||||||
if sheet_id_match:
|
if sheet_id_match:
|
||||||
sheet_id = sheet_id_match.group(1)
|
sheet_id = sheet_id_match.group(1)
|
||||||
return f"https://docs.google.com/spreadsheets/d/{sheet_id}/export?format=csv"
|
return f"https://docs.google.com/spreadsheets/d/{sheet_id}/export?format=csv"
|
||||||
@@ -72,6 +79,40 @@ class GoogleSheetsImporter:
|
|||||||
configured_url = os.getenv('GOOGLE_SHEETS_URL')
|
configured_url = os.getenv('GOOGLE_SHEETS_URL')
|
||||||
self.base_url = normalize_sheets_url(configured_url) if configured_url else BASE_CSV_URL
|
self.base_url = normalize_sheets_url(configured_url) if configured_url else BASE_CSV_URL
|
||||||
|
|
||||||
|
def get_sheets_config(self):
|
||||||
|
"""
|
||||||
|
Descubre las pestañas y GIDs dinámicamente desde /pubhtml si está disponible,
|
||||||
|
o utiliza el fallback SHEETS_CONFIG preconfigurado.
|
||||||
|
"""
|
||||||
|
pub_match = re.search(r'/spreadsheets/d/e/([a-zA-Z0-9-_]+)', self.base_url)
|
||||||
|
if pub_match:
|
||||||
|
doc_id = pub_match.group(1)
|
||||||
|
pubhtml_url = f"https://docs.google.com/spreadsheets/d/e/{doc_id}/pubhtml"
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
pubhtml_url,
|
||||||
|
headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) EduSpace/2.0'}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=12) as resp:
|
||||||
|
html_content = resp.read().decode('utf-8', errors='replace')
|
||||||
|
# Extraer pares name y gid de Google Sheets pubhtml
|
||||||
|
found = re.findall(r'name:\s*["\']([^"\']+)["\'],\s*gid:\s*["\'](\d+)["\']', html_content)
|
||||||
|
if found:
|
||||||
|
discovered = []
|
||||||
|
for name, gid in found:
|
||||||
|
clean_name = re.sub(r'[^a-zA-ZáéíóúÁÉÍÓÚñÑ]', '', name).capitalize()
|
||||||
|
d_idx = map_day_to_idx(clean_name)
|
||||||
|
discovered.append({
|
||||||
|
'name': clean_name if clean_name else name,
|
||||||
|
'gid': gid,
|
||||||
|
'day_idx': d_idx
|
||||||
|
})
|
||||||
|
if discovered:
|
||||||
|
return discovered
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return SHEETS_CONFIG
|
||||||
|
|
||||||
def fetch_sheet_csv(self, gid):
|
def fetch_sheet_csv(self, gid):
|
||||||
"""Fetch CSV string for a specific sheet gid"""
|
"""Fetch CSV string for a specific sheet gid"""
|
||||||
separator = '&' if '?' in self.base_url else '?'
|
separator = '&' if '?' in self.base_url else '?'
|
||||||
@@ -110,7 +151,10 @@ class GoogleSheetsImporter:
|
|||||||
current_shift = 'Mañana'
|
current_shift = 'Mañana'
|
||||||
for row in rows[header_idx + 1:]:
|
for row in rows[header_idx + 1:]:
|
||||||
if len(row) >= col_offset + 5:
|
if len(row) >= col_offset + 5:
|
||||||
raw_cell = row[col_offset].strip().replace('\n', ' - ')
|
def _clean(val):
|
||||||
|
return val.replace('\ufb01', 'fi').replace('\ufb02', 'fl').strip()
|
||||||
|
|
||||||
|
raw_cell = _clean(row[col_offset]).replace('\n', ' - ')
|
||||||
row_full_text = ' '.join(row).upper()
|
row_full_text = ' '.join(row).upper()
|
||||||
|
|
||||||
# Detect shift delimiters
|
# Detect shift delimiters
|
||||||
@@ -128,10 +172,10 @@ class GoogleSheetsImporter:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
codigo = raw_cell
|
codigo = raw_cell
|
||||||
asignatura = row[col_offset + 1].strip().replace('\n', ' ')
|
asignatura = _clean(row[col_offset + 1]).replace('\n', ' ')
|
||||||
aula = row[col_offset + 2].strip().replace('\n', ' ')
|
aula = _clean(row[col_offset + 2]).replace('\n', ' ')
|
||||||
carrera = row[col_offset + 3].strip().replace('\n', ' ')
|
carrera = _clean(row[col_offset + 3]).replace('\n', ' ')
|
||||||
horario = row[col_offset + 4].strip().replace('\n', ' ')
|
horario = _clean(row[col_offset + 4]).replace('\n', ' ')
|
||||||
|
|
||||||
# Check valid data row
|
# Check valid data row
|
||||||
if codigo and asignatura and not 'codigo' in codigo.lower():
|
if codigo and asignatura and not 'codigo' in codigo.lower():
|
||||||
@@ -250,7 +294,8 @@ class GoogleSheetsImporter:
|
|||||||
}
|
}
|
||||||
|
|
||||||
all_raw_records = []
|
all_raw_records = []
|
||||||
for sheet_cfg in SHEETS_CONFIG:
|
sheets_to_process = self.get_sheets_config()
|
||||||
|
for sheet_cfg in sheets_to_process:
|
||||||
try:
|
try:
|
||||||
csv_data = self.fetch_sheet_csv(sheet_cfg['gid'])
|
csv_data = self.fetch_sheet_csv(sheet_cfg['gid'])
|
||||||
rows = self.parse_sheet_rows(csv_data, sheet_cfg['name'])
|
rows = self.parse_sheet_rows(csv_data, sheet_cfg['name'])
|
||||||
|
|||||||
@@ -20,6 +20,18 @@ def jwt_required(f):
|
|||||||
token = request.cookies.get('access_token')
|
token = request.cookies.get('access_token')
|
||||||
|
|
||||||
if not token:
|
if not token:
|
||||||
|
try:
|
||||||
|
from flask_login import current_user
|
||||||
|
if current_user.is_authenticated and current_user.is_active:
|
||||||
|
g.jwt_user = current_user
|
||||||
|
g.jwt_payload = {'sub': str(current_user.id), 'role': getattr(current_user, 'role', '')}
|
||||||
|
g.jwt_token = None
|
||||||
|
g.real_admin_user = current_user
|
||||||
|
g.is_impersonating = False
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'error': 'Unauthorized',
|
'error': 'Unauthorized',
|
||||||
'message': 'Encabezado Authorization con Bearer token no proporcionado.'
|
'message': 'Encabezado Authorization con Bearer token no proporcionado.'
|
||||||
|
|||||||
@@ -265,7 +265,11 @@ CREATE TABLE public.commissions (
|
|||||||
created_at timestamp without time zone,
|
created_at timestamp without time zone,
|
||||||
updated_at timestamp without time zone,
|
updated_at timestamp without time zone,
|
||||||
shift character varying(30),
|
shift character varying(30),
|
||||||
virtual_link character varying(500)
|
virtual_link character varying(500),
|
||||||
|
grades_closed boolean DEFAULT false,
|
||||||
|
grades_closed_at timestamp without time zone,
|
||||||
|
grades_closed_by integer,
|
||||||
|
acta_number character varying(50)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
@@ -447,7 +451,8 @@ CREATE TABLE public.subjects (
|
|||||||
active boolean NOT NULL,
|
active boolean NOT NULL,
|
||||||
created_at timestamp without time zone,
|
created_at timestamp without time zone,
|
||||||
updated_at timestamp without time zone,
|
updated_at timestamp without time zone,
|
||||||
career_id integer
|
career_id integer,
|
||||||
|
is_short_course boolean DEFAULT false
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
@@ -486,7 +491,14 @@ CREATE TABLE public.users (
|
|||||||
last_login timestamp without time zone,
|
last_login timestamp without time zone,
|
||||||
preferred_language character varying(10),
|
preferred_language character varying(10),
|
||||||
theme_preference character varying(10) DEFAULT 'auto'::character varying NOT NULL,
|
theme_preference character varying(10) DEFAULT 'auto'::character varying NOT NULL,
|
||||||
role_id integer
|
role_id integer,
|
||||||
|
first_name character varying(100),
|
||||||
|
last_name character varying(100),
|
||||||
|
phone character varying(50),
|
||||||
|
personal_email character varying(255),
|
||||||
|
address character varying(255),
|
||||||
|
document_type character varying(20) DEFAULT 'DNI'::character varying,
|
||||||
|
document_number character varying(30)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
@@ -1635,7 +1647,7 @@ COPY public.subjects (id, code, name, description, department, credits, active,
|
|||||||
--
|
--
|
||||||
|
|
||||||
COPY public.users (id, email, password_hash, name, role, is_active, created_at, last_login, preferred_language, theme_preference, role_id) FROM stdin;
|
COPY public.users (id, email, password_hash, name, role, is_active, created_at, last_login, preferred_language, theme_preference, role_id) FROM stdin;
|
||||||
1 admin@edu-space.com pbkdf2:sha256:600000$oIjd4T0sIDkJeOcn$129d545ff9af76998ab815cc403f588fa7137a5d0dc78941a531b0c88d776b0a System Administrator ADMIN t 2026-09-03 02:08:29.597255 2026-09-05 01:32:51.778156 es light 1
|
1 admin@edu-space.com pbkdf2:sha256:1000000$7UJAkGOl42ZHvDk8$f38ef5fe9b7247692b812abe9199df2d25c015e04b3807267b0f43536817f5c6 System Administrator ADMIN t 2026-09-03 02:08:29.597255 2026-09-05 01:32:51.778156 es light 1
|
||||||
3 carlostellocba@gmail.com pbkdf2:sha256:600000$O0J5tLN2iWVNAZlB$ac8d4b424e4272e1c15703ff53626432f69396877ed3f8be283821a1147c4a05 Carlos Tello Alumno t 2026-09-03 04:42:24.060312 2026-09-03 07:20:39.403354 \N dark 6
|
3 carlostellocba@gmail.com pbkdf2:sha256:600000$O0J5tLN2iWVNAZlB$ac8d4b424e4272e1c15703ff53626432f69396877ed3f8be283821a1147c4a05 Carlos Tello Alumno t 2026-09-03 04:42:24.060312 2026-09-03 07:20:39.403354 \N dark 6
|
||||||
\.
|
\.
|
||||||
|
|
||||||
|
|||||||
+169
-21
@@ -1,4 +1,5 @@
|
|||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
# Asegurar codificación UTF-8 en consola de Windows
|
# Asegurar codificación UTF-8 en consola de Windows
|
||||||
if hasattr(sys.stdout, 'reconfigure'):
|
if hasattr(sys.stdout, 'reconfigure'):
|
||||||
@@ -7,6 +8,7 @@ if hasattr(sys.stderr, 'reconfigure'):
|
|||||||
sys.stderr.reconfigure(encoding='utf-8')
|
sys.stderr.reconfigure(encoding='utf-8')
|
||||||
|
|
||||||
from app import create_app, db
|
from app import create_app, db
|
||||||
|
from app import models
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.role import Role, Permission, SYSTEM_MODULES
|
from app.models.role import Role, Permission, SYSTEM_MODULES
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
@@ -22,21 +24,29 @@ def init_database():
|
|||||||
|
|
||||||
# 2. Migraciones incrementales seguras para bases de datos existentes
|
# 2. Migraciones incrementales seguras para bases de datos existentes
|
||||||
if db.engine.url.drivername != 'sqlite':
|
if db.engine.url.drivername != 'sqlite':
|
||||||
|
# Columnas extendidas de users
|
||||||
db.session.execute(text("""
|
db.session.execute(text("""
|
||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS preferred_language VARCHAR(10) DEFAULT NULL;
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS preferred_language VARCHAR(10) DEFAULT NULL;
|
||||||
"""))
|
|
||||||
db.session.execute(text("""
|
|
||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS theme_preference VARCHAR(10) DEFAULT 'auto' NOT NULL;
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS theme_preference VARCHAR(10) DEFAULT 'auto' NOT NULL;
|
||||||
"""))
|
|
||||||
db.session.execute(text("""
|
|
||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS role_id INTEGER REFERENCES roles(id) ON DELETE SET NULL;
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS role_id INTEGER REFERENCES roles(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name VARCHAR(100);
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name VARCHAR(100);
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone VARCHAR(50);
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS personal_email VARCHAR(255);
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS address VARCHAR(255);
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS document_type VARCHAR(20) DEFAULT 'DNI';
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS document_number VARCHAR(30);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_users_document_number ON users (document_number);
|
||||||
"""))
|
"""))
|
||||||
|
|
||||||
|
# Columnas extendidas de subjects
|
||||||
db.session.execute(text("""
|
db.session.execute(text("""
|
||||||
ALTER TABLE subjects ADD COLUMN IF NOT EXISTS career_id INTEGER REFERENCES careers(id) ON DELETE SET NULL;
|
ALTER TABLE subjects ADD COLUMN IF NOT EXISTS career_id INTEGER REFERENCES careers(id) ON DELETE SET NULL;
|
||||||
"""))
|
|
||||||
db.session.execute(text("""
|
|
||||||
ALTER TABLE subjects ALTER COLUMN code TYPE VARCHAR(50);
|
ALTER TABLE subjects ALTER COLUMN code TYPE VARCHAR(50);
|
||||||
|
ALTER TABLE subjects ADD COLUMN IF NOT EXISTS is_short_course BOOLEAN DEFAULT FALSE;
|
||||||
"""))
|
"""))
|
||||||
|
|
||||||
|
# Infraestructura: buildings y classrooms
|
||||||
db.session.execute(text("""
|
db.session.execute(text("""
|
||||||
CREATE TABLE IF NOT EXISTS buildings (
|
CREATE TABLE IF NOT EXISTS buildings (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -49,7 +59,27 @@ def init_database():
|
|||||||
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW()
|
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
ALTER TABLE classrooms ADD COLUMN IF NOT EXISTS building_id INTEGER REFERENCES buildings(id) ON DELETE SET NULL;
|
||||||
"""))
|
"""))
|
||||||
|
|
||||||
|
# Columnas extendidas de commissions
|
||||||
|
db.session.execute(text("""
|
||||||
|
ALTER TABLE commissions ADD COLUMN IF NOT EXISTS shift VARCHAR(30);
|
||||||
|
ALTER TABLE commissions ADD COLUMN IF NOT EXISTS virtual_link VARCHAR(500);
|
||||||
|
ALTER TABLE commissions ADD COLUMN IF NOT EXISTS grades_closed BOOLEAN DEFAULT FALSE;
|
||||||
|
ALTER TABLE commissions ADD COLUMN IF NOT EXISTS grades_closed_at TIMESTAMP WITHOUT TIME ZONE;
|
||||||
|
ALTER TABLE commissions ADD COLUMN IF NOT EXISTS grades_closed_by INTEGER REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE commissions ADD COLUMN IF NOT EXISTS acta_number VARCHAR(50);
|
||||||
|
ALTER TABLE commissions ADD COLUMN IF NOT EXISTS academic_term_id INTEGER REFERENCES academic_terms(id) ON DELETE SET NULL;
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# Columnas extendidas de reservations
|
||||||
|
db.session.execute(text("""
|
||||||
|
ALTER TABLE reservations ADD COLUMN IF NOT EXISTS shift VARCHAR(30);
|
||||||
|
ALTER TABLE reservations ADD COLUMN IF NOT EXISTS virtual_link VARCHAR(500);
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# Tablas de soporte: audit_logs
|
||||||
db.session.execute(text("""
|
db.session.execute(text("""
|
||||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -66,15 +96,8 @@ def init_database():
|
|||||||
CREATE INDEX IF NOT EXISTS ix_audit_logs_module ON audit_logs (module);
|
CREATE INDEX IF NOT EXISTS ix_audit_logs_module ON audit_logs (module);
|
||||||
CREATE INDEX IF NOT EXISTS ix_audit_logs_created_at ON audit_logs (created_at);
|
CREATE INDEX IF NOT EXISTS ix_audit_logs_created_at ON audit_logs (created_at);
|
||||||
"""))
|
"""))
|
||||||
db.session.execute(text("""
|
|
||||||
ALTER TABLE classrooms ADD COLUMN IF NOT EXISTS building_id INTEGER REFERENCES buildings(id) ON DELETE SET NULL;
|
# Tablas de soporte: student_enrollments
|
||||||
"""))
|
|
||||||
db.session.execute(text("""
|
|
||||||
ALTER TABLE commissions ADD COLUMN IF NOT EXISTS shift VARCHAR(30);
|
|
||||||
"""))
|
|
||||||
db.session.execute(text("""
|
|
||||||
ALTER TABLE reservations ADD COLUMN IF NOT EXISTS shift VARCHAR(30);
|
|
||||||
"""))
|
|
||||||
db.session.execute(text("""
|
db.session.execute(text("""
|
||||||
CREATE TABLE IF NOT EXISTS student_enrollments (
|
CREATE TABLE IF NOT EXISTS student_enrollments (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -89,7 +112,79 @@ def init_database():
|
|||||||
CREATE INDEX IF NOT EXISTS ix_student_enrollments_student_id ON student_enrollments (student_id);
|
CREATE INDEX IF NOT EXISTS ix_student_enrollments_student_id ON student_enrollments (student_id);
|
||||||
CREATE INDEX IF NOT EXISTS ix_student_enrollments_commission_id ON student_enrollments (commission_id);
|
CREATE INDEX IF NOT EXISTS ix_student_enrollments_commission_id ON student_enrollments (commission_id);
|
||||||
"""))
|
"""))
|
||||||
|
|
||||||
|
# Tablas de soporte: commission_teachers
|
||||||
|
db.session.execute(text("""
|
||||||
|
CREATE TABLE IF NOT EXISTS commission_teachers (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
commission_id INTEGER NOT NULL REFERENCES commissions(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
role VARCHAR(50) DEFAULT 'Titular' NOT NULL,
|
||||||
|
is_primary BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW(),
|
||||||
|
CONSTRAINT uq_commission_teacher UNIQUE (commission_id, user_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_commission_teachers_commission_id ON commission_teachers (commission_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_commission_teachers_user_id ON commission_teachers (user_id);
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# Tablas de soporte: system_settings
|
||||||
|
db.session.execute(text("""
|
||||||
|
CREATE TABLE IF NOT EXISTS system_settings (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
key VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
value TEXT,
|
||||||
|
description VARCHAR(255),
|
||||||
|
category VARCHAR(50) DEFAULT 'system',
|
||||||
|
is_encrypted BOOLEAN DEFAULT FALSE,
|
||||||
|
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_system_settings_key ON system_settings (key);
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# Tablas de soporte: moodle_sync_tasks
|
||||||
|
db.session.execute(text("""
|
||||||
|
CREATE TABLE IF NOT EXISTS moodle_sync_tasks (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
action VARCHAR(50) NOT NULL,
|
||||||
|
entity_type VARCHAR(50) NOT NULL,
|
||||||
|
entity_id VARCHAR(100),
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
status VARCHAR(20) DEFAULT 'PENDING',
|
||||||
|
attempts INTEGER DEFAULT 0,
|
||||||
|
max_attempts INTEGER DEFAULT 5,
|
||||||
|
error_message TEXT,
|
||||||
|
next_retry_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_moodle_sync_tasks_action ON moodle_sync_tasks (action);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_moodle_sync_tasks_status ON moodle_sync_tasks (status);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_moodle_sync_tasks_next_retry_at ON moodle_sync_tasks (next_retry_at);
|
||||||
|
"""))
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
cursor = db.session.connection().connection.cursor()
|
||||||
|
cursor.execute("PRAGMA table_info(users)")
|
||||||
|
existing_cols = [row[1] for row in cursor.fetchall()]
|
||||||
|
for col_name, col_def in [
|
||||||
|
('preferred_language', 'VARCHAR(10)'),
|
||||||
|
('theme_preference', "VARCHAR(10) DEFAULT 'auto'"),
|
||||||
|
('role_id', 'INTEGER'),
|
||||||
|
('first_name', 'VARCHAR(100)'),
|
||||||
|
('last_name', 'VARCHAR(100)'),
|
||||||
|
('phone', 'VARCHAR(50)'),
|
||||||
|
('personal_email', 'VARCHAR(255)'),
|
||||||
|
('address', 'VARCHAR(255)'),
|
||||||
|
('document_type', "VARCHAR(20) DEFAULT 'DNI'"),
|
||||||
|
('document_number', 'VARCHAR(30)')
|
||||||
|
]:
|
||||||
|
if col_name not in existing_cols:
|
||||||
|
cursor.execute(f"ALTER TABLE users ADD COLUMN {col_name} {col_def}")
|
||||||
|
db.session.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
print("[OK] Esquema y tablas actualizadas con éxito.")
|
print("[OK] Esquema y tablas actualizadas con éxito.")
|
||||||
|
|
||||||
# 3. Crear o sincronizar roles predeterminados y matriz de permisos
|
# 3. Crear o sincronizar roles predeterminados y matriz de permisos
|
||||||
@@ -218,6 +313,53 @@ def init_database():
|
|||||||
print(" [OK] Sin duplicados de Bedelia.")
|
print(" [OK] Sin duplicados de Bedelia.")
|
||||||
|
|
||||||
# 4. Verificar o crear usuarios institucionales de cada rol
|
# 4. Verificar o crear usuarios institucionales de cada rol
|
||||||
|
keep_users = ('--keep-users' in sys.argv) or (os.environ.get('KEEP_USERS', '').lower() in ['1', 'true', 'yes'])
|
||||||
|
empty_academic = any(arg in sys.argv for arg in ['--empty-academic', '--no-demo', '--clean-db', '--without-demo-data', '--skip-demo']) or \
|
||||||
|
(os.environ.get('EMPTY_ACADEMIC', '').lower() in ['1', 'true', 'yes'])
|
||||||
|
|
||||||
|
if empty_academic:
|
||||||
|
print("[*] Opción --empty-academic activa: Se configurará la base de datos limpia SIN carreras, materias, ciclos lectivos, aulas, edificios ni reservas por defecto.")
|
||||||
|
try:
|
||||||
|
from app.models.reservation import Reservation
|
||||||
|
from app.models.grade import MilestoneGrade
|
||||||
|
from app.models.milestone import AcademicMilestone
|
||||||
|
from app.models.subject import CommissionTeacher, Commission, Subject
|
||||||
|
from app.models.enrollment import StudentEnrollment
|
||||||
|
from app.models.career import Career
|
||||||
|
from app.models.classroom import ClassroomResource, Classroom
|
||||||
|
from app.models.building import Building
|
||||||
|
from app.models.academic_term import AcademicTerm
|
||||||
|
|
||||||
|
Reservation.query.delete()
|
||||||
|
try:
|
||||||
|
MilestoneGrade.query.delete()
|
||||||
|
AcademicMilestone.query.delete()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
CommissionTeacher.query.delete()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
StudentEnrollment.query.delete()
|
||||||
|
Commission.query.delete()
|
||||||
|
Subject.query.delete()
|
||||||
|
Career.query.delete()
|
||||||
|
try:
|
||||||
|
ClassroomResource.query.delete()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
Classroom.query.delete()
|
||||||
|
Building.query.delete()
|
||||||
|
AcademicTerm.query.delete()
|
||||||
|
db.session.commit()
|
||||||
|
print(" [OK] Tablas de carreras, materias, comisiones, aulas, edificios y reservas aseguradas vacías.")
|
||||||
|
except Exception as pe:
|
||||||
|
db.session.rollback()
|
||||||
|
print(f" [!] Nota durante verificación de tablas académicas vacías: {pe}")
|
||||||
|
|
||||||
|
if keep_users:
|
||||||
|
print("[*] Opción --keep-users activa: Preservando usuarios, roles y contraseñas existentes intactos (no se modifican ni pisan).")
|
||||||
|
else:
|
||||||
print("[*] Verificando usuarios institucionales y credenciales...")
|
print("[*] Verificando usuarios institucionales y credenciales...")
|
||||||
roles_by_name = {r.name: r for r in Role.query.all()}
|
roles_by_name = {r.name: r for r in Role.query.all()}
|
||||||
|
|
||||||
@@ -267,22 +409,22 @@ def init_database():
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
print(f" [+] Usuario creado: {u_info['email']} | Clave: admin123 | Rol: {u_info['role_name']}")
|
print(f" [+] Usuario creado: {u_info['email']} | Clave: admin123 | Rol: {u_info['role_name']}")
|
||||||
else:
|
else:
|
||||||
# Garantizar sincronización de rol, estado activo y clave
|
# Preservar contraseña y estado si ya existen para no pisar cambios administrativos
|
||||||
if r_match and u_obj.role_id != r_match.id:
|
if r_match and u_obj.role_id != r_match.id:
|
||||||
u_obj.role_id = r_match.id
|
u_obj.role_id = r_match.id
|
||||||
|
if not u_obj.role:
|
||||||
u_obj.role = u_info['role_code']
|
u_obj.role = u_info['role_code']
|
||||||
u_obj.is_active = True
|
if not u_obj.password_hash:
|
||||||
if not u_obj.check_password("admin123"):
|
|
||||||
u_obj.set_password("admin123")
|
u_obj.set_password("admin123")
|
||||||
print(f" [*] Clave de {u_info['email']} restablecida a 'admin123'.")
|
|
||||||
if not u_obj.preferred_language:
|
if not u_obj.preferred_language:
|
||||||
u_obj.preferred_language = "es"
|
u_obj.preferred_language = "es"
|
||||||
if not u_obj.theme_preference:
|
if not u_obj.theme_preference:
|
||||||
u_obj.theme_preference = "auto"
|
u_obj.theme_preference = "auto"
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
print(f" [OK] Usuario {u_info['email']} validado (Rol: {u_info['role_name']}, Clave: admin123).")
|
print(f" [OK] Usuario {u_info['email']} validado (Rol: {u_info['role_name']}).")
|
||||||
|
|
||||||
# 5. Inicializar edificios institucionales oficiales
|
# 5. Inicializar edificios institucionales oficiales
|
||||||
|
if not empty_academic:
|
||||||
print("[*] Verificando edificios institucionales...")
|
print("[*] Verificando edificios institucionales...")
|
||||||
from app.models.building import Building
|
from app.models.building import Building
|
||||||
b_central = Building.query.filter_by(name='Edificio Central').first()
|
b_central = Building.query.filter_by(name='Edificio Central').first()
|
||||||
@@ -310,6 +452,8 @@ def init_database():
|
|||||||
db.session.add(b_virtual)
|
db.session.add(b_virtual)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
print("[OK] Edificios institucionales configurados con dirección oficial.")
|
print("[OK] Edificios institucionales configurados con dirección oficial.")
|
||||||
|
else:
|
||||||
|
print("[*] Modo --empty-academic: Omitiendo creación de edificios institucionales por defecto.")
|
||||||
|
|
||||||
# 6. Inicializar catalogo dinamico de tipificaciones de hitos academicos
|
# 6. Inicializar catalogo dinamico de tipificaciones de hitos academicos
|
||||||
print("[*] Verificando catalogo de tipificaciones de hitos academicos...")
|
print("[*] Verificando catalogo de tipificaciones de hitos academicos...")
|
||||||
@@ -335,12 +479,13 @@ def init_database():
|
|||||||
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
|
# 7. Inicializar aulas, materias, comisiones y reservas de muestra
|
||||||
|
if not empty_academic:
|
||||||
from app.models.classroom import Classroom
|
from app.models.classroom import Classroom
|
||||||
from app.models.subject import Subject, Commission
|
from app.models.subject import Subject, Commission
|
||||||
from app.models.reservation import Reservation, ReservationStatus
|
from app.models.reservation import Reservation, ReservationStatus
|
||||||
from datetime import datetime, timedelta, date
|
from datetime import datetime, timedelta, date
|
||||||
|
|
||||||
if Classroom.query.count() == 0:
|
if Classroom.query.count() == 0 and 'b_central' in locals() and b_central and 'b_virtual' in locals() and b_virtual:
|
||||||
print("[*] Generando catalogo de aulas...")
|
print("[*] Generando catalogo de aulas...")
|
||||||
for floor in ['PB', '1', '2', '3']:
|
for floor in ['PB', '1', '2', '3']:
|
||||||
for num in range(1, 8):
|
for num in range(1, 8):
|
||||||
@@ -405,6 +550,9 @@ def init_database():
|
|||||||
db.session.add(r)
|
db.session.add(r)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
print(f"[OK] {Reservation.query.count()} reservas creadas.")
|
print(f"[OK] {Reservation.query.count()} reservas creadas.")
|
||||||
|
else:
|
||||||
|
print("[*] Modo --empty-academic: Omitiendo creación de aulas, materias, comisiones y reservas de muestra.")
|
||||||
|
print("[OK] Base de datos creada con tablas vacías para gestión académica y de espacios desde cero.")
|
||||||
|
|
||||||
print("\n[LISTO] Base de datos inicializada y lista para usar.")
|
print("\n[LISTO] Base de datos inicializada y lista para usar.")
|
||||||
|
|
||||||
|
|||||||
@@ -1,474 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# ==============================================================================
|
|
||||||
# install.sh — Admin Edu-Space (UniCABA)
|
|
||||||
# Instalador automatizado para Proxmox VE (LXC) / Debian 12 / Ubuntu (entorno venv)
|
|
||||||
# Uso: sudo bash install.sh
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# ─── Colores y Helpers ─────────────────────────────────────────────────────────
|
|
||||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
|
||||||
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
|
||||||
|
|
||||||
ok() { echo -e "${GREEN} ✔ $*${RESET}"; }
|
|
||||||
info() { echo -e "${CYAN} ▸ $*${RESET}"; }
|
|
||||||
warn() { echo -e "${YELLOW} ⚠ $*${RESET}"; }
|
|
||||||
err() { echo -e "${RED} ✘ $*${RESET}" >&2; exit 1; }
|
|
||||||
hdr() { echo -e "\n${BOLD}${CYAN}══ $* ══${RESET}\n"; }
|
|
||||||
|
|
||||||
# ─── Variables Principales ─────────────────────────────────────────────────────
|
|
||||||
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
VENV_DIR="${APP_DIR}/venv"
|
|
||||||
APP_ENV="${APP_DIR}/.env"
|
|
||||||
SERVICE_NAME="admin-edu-space"
|
|
||||||
APP_USER="eduspace"
|
|
||||||
PYTHON_REQUIRED_MAJOR=3
|
|
||||||
PYTHON_REQUIRED_MINOR=10
|
|
||||||
LOG_DIR="/var/log/${SERVICE_NAME}"
|
|
||||||
SQL_BACKUP_FILE="${APP_DIR}/classrooms_db.sql"
|
|
||||||
|
|
||||||
# ─── Verificación de Permisos Root ─────────────────────────────────────────────
|
|
||||||
[[ $EUID -ne 0 ]] && err "Ejecutá este script con permisos de root: sudo bash install.sh"
|
|
||||||
|
|
||||||
# Si el directorio está dentro de /root, lo trasladamos a /opt para evitar problemas de permisos con el usuario de servicio
|
|
||||||
if [[ "${APP_DIR}" == /root/* ]]; then
|
|
||||||
NEW_DIR="/opt/admin-edu-space"
|
|
||||||
warn "Detectado directorio en ${APP_DIR}. Trasladando a ${NEW_DIR} para evitar bloqueos de permisos..."
|
|
||||||
systemctl stop "${SERVICE_NAME}" 2>/dev/null || true
|
|
||||||
|
|
||||||
# Limpiar cualquier subdirectorio anidado previo si quedó de un intento fallido
|
|
||||||
rm -rf "${NEW_DIR}/admin-edu-space" 2>/dev/null || true
|
|
||||||
|
|
||||||
mkdir -p "${NEW_DIR}"
|
|
||||||
# Copiar recursivamente todo el contenido (incluyendo dotfiles) directamente a NEW_DIR
|
|
||||||
cp -a "${APP_DIR}/." "${NEW_DIR}/"
|
|
||||||
|
|
||||||
cd "${NEW_DIR}"
|
|
||||||
exec bash install.sh
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Limpiar posible subdirectorio anidado residual en caso de ejecuciones previas
|
|
||||||
rm -rf "${APP_DIR}/admin-edu-space" 2>/dev/null || true
|
|
||||||
|
|
||||||
# Verificar e instalar git si no está presente en el sistema
|
|
||||||
if ! command -v git &>/dev/null; then
|
|
||||||
info "Git no está instalado. Instalando paquete git..."
|
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
|
||||||
apt-get update -qq >/dev/null 2>&1 || true
|
|
||||||
apt-get install -y -qq git >/dev/null 2>&1 || apt-get install -y git
|
|
||||||
ok "Git instalado correctamente"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Configurar safe.directory en Git para evitar errores de propiedad cruzada (root vs eduspace)
|
|
||||||
git config --global --add safe.directory '*' 2>/dev/null || true
|
|
||||||
|
|
||||||
[[ ! -f "${APP_DIR}/wsgi.py" ]] && err "No se encontró wsgi.py en ${APP_DIR}. ¿Estás en el directorio raíz del proyecto?"
|
|
||||||
|
|
||||||
echo -e "\n${BOLD}${CYAN}"
|
|
||||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
|
||||||
echo "║ Admin Edu-Space (UniCABA) — Proxmox LXC Installer ║"
|
|
||||||
echo "║ Despliegue Nativo con venv + Motor PostgreSQL ║"
|
|
||||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
|
||||||
echo -e "${RESET}"
|
|
||||||
|
|
||||||
OS_NAME=$(grep -oP '(?<=^PRETTY_NAME=).+' /etc/os-release 2>/dev/null | tr -d '"' || uname -s)
|
|
||||||
info "Sistema Operativo: ${OS_NAME}"
|
|
||||||
info "Directorio del proyecto: ${APP_DIR}"
|
|
||||||
info "Servicio systemd: ${SERVICE_NAME}"
|
|
||||||
info "Usuario de servicio: ${APP_USER}"
|
|
||||||
|
|
||||||
# ─── 1. Actualizar sistema e instalar dependencias base ────────────────────────
|
|
||||||
hdr "1. Actualizando repositorios y paquetes base"
|
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
|
||||||
apt-get update -qq
|
|
||||||
apt-get install -y -qq \
|
|
||||||
sudo curl wget git ca-certificates gnupg lsb-release \
|
|
||||||
build-essential libpq-dev \
|
|
||||||
locales \
|
|
||||||
> /dev/null 2>&1
|
|
||||||
ok "Paquetes base instalados"
|
|
||||||
|
|
||||||
# Función auxiliar para ejecutar comandos psql como superusuario postgres de forma universal (sin depender de sudo)
|
|
||||||
pg_cmd() {
|
|
||||||
if command -v sudo >/dev/null 2>&1; then
|
|
||||||
sudo -u postgres psql "$@"
|
|
||||||
elif command -v runuser >/dev/null 2>&1; then
|
|
||||||
runuser -u postgres -- psql "$@"
|
|
||||||
else
|
|
||||||
su - postgres -c "psql $(printf '%q ' "$@")"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─── 2. Configurar Locale UTF-8 ────────────────────────────────────────────────
|
|
||||||
hdr "2. Configurando soporte de codificación (Locales UTF-8)"
|
|
||||||
if [ -f /etc/locale.gen ]; then
|
|
||||||
sed -i 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen
|
|
||||||
sed -i 's/# es_AR.UTF-8 UTF-8/es_AR.UTF-8 UTF-8/' /etc/locale.gen
|
|
||||||
locale-gen en_US.UTF-8 es_AR.UTF-8 > /dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 > /dev/null 2>&1 || true
|
|
||||||
export LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 PYTHONUTF8=1 PYTHONIOENCODING=utf-8
|
|
||||||
ok "Locales configurados: en_US.UTF-8 / es_AR.UTF-8"
|
|
||||||
|
|
||||||
# ─── 3. Instalar Python 3.10+ y entorno de desarrollo ──────────────────────────
|
|
||||||
hdr "3. Verificando entorno Python"
|
|
||||||
apt-get install -y -qq python3 python3-venv python3-pip python3-dev > /dev/null 2>&1
|
|
||||||
|
|
||||||
PY_VERSION=$(python3 --version 2>/dev/null | awk '{print $2}' || echo "0.0")
|
|
||||||
PY_MAJOR=$(echo "$PY_VERSION" | cut -d. -f1)
|
|
||||||
PY_MINOR=$(echo "$PY_VERSION" | cut -d. -f2)
|
|
||||||
|
|
||||||
if [[ "$PY_MAJOR" -ge "$PYTHON_REQUIRED_MAJOR" && "$PY_MINOR" -ge "$PYTHON_REQUIRED_MINOR" ]]; then
|
|
||||||
ok "Python $PY_VERSION disponible"
|
|
||||||
else
|
|
||||||
info "Python $PY_VERSION es menor a 3.10 — instalando Python 3.11..."
|
|
||||||
apt-get install -y -qq python3.11 python3.11-venv python3.11-dev > /dev/null 2>&1
|
|
||||||
update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1 > /dev/null 2>&1
|
|
||||||
PY_VERSION=$(python3 --version | awk '{print $2}')
|
|
||||||
ok "Python $PY_VERSION instalado"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ─── 4. Instalar y Levantar Motor PostgreSQL ───────────────────────────────────
|
|
||||||
hdr "4. Instalando y configurando motor de base de datos PostgreSQL"
|
|
||||||
apt-get install -y -qq postgresql postgresql-contrib postgresql-client > /dev/null 2>&1
|
|
||||||
PG_VER=$(psql --version 2>/dev/null | awk '{print $3}' || echo "instalado")
|
|
||||||
PG_MAJOR=$(psql --version 2>/dev/null | awk '{print $3}' | cut -d. -f1 || echo "")
|
|
||||||
[[ -z "$PG_MAJOR" ]] && PG_MAJOR=$(ls /etc/postgresql/ 2>/dev/null | sort -V | tail -n1 || echo "")
|
|
||||||
[[ -z "$PG_MAJOR" ]] && PG_MAJOR="15"
|
|
||||||
|
|
||||||
ok "Motor PostgreSQL instalado: ${PG_VER}"
|
|
||||||
|
|
||||||
# Asegurar directorios de socket y permisos para PostgreSQL en contenedores LXC
|
|
||||||
mkdir -p /run/postgresql /var/run/postgresql /var/lib/postgresql /var/log/postgresql
|
|
||||||
chown -R postgres:postgres /run/postgresql /var/run/postgresql /var/lib/postgresql /var/log/postgresql
|
|
||||||
chmod 2775 /run/postgresql /var/run/postgresql
|
|
||||||
|
|
||||||
# Verificar si existe el cluster 'main'. Si no existe (común en Debian si falló la creación automática), crearlo
|
|
||||||
if ! pg_lsclusters 2>/dev/null | grep -q 'main'; then
|
|
||||||
info "Cluster PostgreSQL no detectado — inicializando cluster ${PG_MAJOR} main..."
|
|
||||||
pg_createcluster "${PG_MAJOR}" main --locale en_US.UTF-8 --start >/dev/null 2>&1 \
|
|
||||||
|| pg_createcluster "${PG_MAJOR}" main --locale C.UTF-8 --start >/dev/null 2>&1 \
|
|
||||||
|| true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Levantar servicio de PostgreSQL asegurando compatibilidad con contenedores LXC y VMs
|
|
||||||
info "Iniciando servicio de PostgreSQL..."
|
|
||||||
pg_ctlcluster "${PG_MAJOR}" main start >/dev/null 2>&1 || true
|
|
||||||
systemctl start "postgresql@${PG_MAJOR}-main" 2>/dev/null || true
|
|
||||||
systemctl start postgresql 2>/dev/null || true
|
|
||||||
service postgresql start 2>/dev/null || true
|
|
||||||
|
|
||||||
# Esperar a que el socket de PostgreSQL esté completamente activo
|
|
||||||
PG_READY=false
|
|
||||||
for i in {1..20}; do
|
|
||||||
if pg_cmd -c '\q' >/dev/null 2>&1; then
|
|
||||||
PG_READY=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if [[ $i -eq 3 || $i -eq 8 ]]; then
|
|
||||||
pg_ctlcluster "${PG_MAJOR}" main start 2>/dev/null || true
|
|
||||||
systemctl restart "postgresql@${PG_MAJOR}-main" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$PG_READY" = true ]; then
|
|
||||||
# Configurar pg_hba.conf para permitir conexiones locales (socket y 127.0.0.1) sin fallos de autenticación
|
|
||||||
PG_HBA=$(find /etc/postgresql/ -name pg_hba.conf 2>/dev/null | sort -V | tail -n1)
|
|
||||||
if [[ -n "$PG_HBA" && -f "$PG_HBA" ]]; then
|
|
||||||
sed -i -E 's/^(local\s+all\s+all\s+)peer/\1trust/' "$PG_HBA" 2>/dev/null || true
|
|
||||||
sed -i -E 's/^(host\s+all\s+all\s+127\.0\.0\.1\/32\s+)(scram-sha-256|md5)/\1trust/' "$PG_HBA" 2>/dev/null || true
|
|
||||||
sed -i -E 's/^(host\s+all\s+all\s+::1\/128\s+)(scram-sha-256|md5)/\1trust/' "$PG_HBA" 2>/dev/null || true
|
|
||||||
systemctl reload "postgresql@${PG_MAJOR}-main" 2>/dev/null || systemctl reload postgresql 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
systemctl enable "postgresql@${PG_MAJOR}-main" > /dev/null 2>&1 || true
|
|
||||||
systemctl enable postgresql > /dev/null 2>&1 || true
|
|
||||||
ok "PostgreSQL activo y aceptando conexiones"
|
|
||||||
else
|
|
||||||
warn "Diagnóstico de arranque de PostgreSQL en LXC:"
|
|
||||||
pg_lsclusters 2>/dev/null || true
|
|
||||||
systemctl status "postgresql@${PG_MAJOR}-main" --no-pager -n 10 2>/dev/null || true
|
|
||||||
journalctl -u postgresql -n 15 --no-pager 2>/dev/null || true
|
|
||||||
info "Prueba directa de conexión psql:"
|
|
||||||
pg_cmd -c '\q' || true
|
|
||||||
err "No se pudo conectar al motor PostgreSQL. Verificá los registros del sistema."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ─── 5. Configurar Variables de Entorno (.env) ─────────────────────────────────
|
|
||||||
hdr "5. Configurando variables de entorno (.env)"
|
|
||||||
|
|
||||||
# Valores predeterminados (paridad con sistema activo)
|
|
||||||
DB_NAME="classrooms_db"
|
|
||||||
DB_USER="postgres"
|
|
||||||
DB_PASSWORD="password"
|
|
||||||
|
|
||||||
if [[ -f "$APP_ENV" ]]; then
|
|
||||||
ok ".env ya existe — se preserva la configuración actual"
|
|
||||||
set -a
|
|
||||||
# shellcheck disable=SC1090
|
|
||||||
source <(grep -v '^#' "$APP_ENV" | grep '=')
|
|
||||||
set +a
|
|
||||||
|
|
||||||
# Extraer credenciales desde DATABASE_URL si está presente
|
|
||||||
if [[ -n "${DATABASE_URL:-}" ]]; then
|
|
||||||
URL_USER=$(echo "$DATABASE_URL" | sed -E 's|^.*://([^:@]+).*|\1|')
|
|
||||||
URL_PASS=$(echo "$DATABASE_URL" | sed -E 's|^.*://[^:]+:([^@]+)@.*|\1|')
|
|
||||||
URL_NAME=$(echo "$DATABASE_URL" | sed -E 's|^.*/([^?]+).*|\1|')
|
|
||||||
|
|
||||||
if [[ "$URL_USER" != "$DATABASE_URL" && -n "$URL_USER" ]]; then
|
|
||||||
DB_USER="$URL_USER"
|
|
||||||
fi
|
|
||||||
if [[ "$URL_PASS" != "$DATABASE_URL" && -n "$URL_PASS" ]]; then
|
|
||||||
DB_PASSWORD="$URL_PASS"
|
|
||||||
fi
|
|
||||||
if [[ "$URL_NAME" != "$DATABASE_URL" && -n "$URL_NAME" ]]; then
|
|
||||||
DB_NAME="$URL_NAME"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
DB_USER="${DB_USER:-${DB_USERNAME:-postgres}}"
|
|
||||||
DB_PASSWORD="${DB_PASSWORD:-password}"
|
|
||||||
DB_NAME="${DB_NAME:-classrooms_db}"
|
|
||||||
else
|
|
||||||
SECRET_KEY=$(python3 -c 'import secrets; print(secrets.token_hex(32))')
|
|
||||||
cat > "$APP_ENV" <<ENVEOF
|
|
||||||
# Admin Edu-Space — Configuración de producción
|
|
||||||
# Generado automáticamente el $(date '+%Y-%m-%d %H:%M:%S')
|
|
||||||
|
|
||||||
FLASK_APP=wsgi.py
|
|
||||||
FLASK_ENV=production
|
|
||||||
|
|
||||||
SECRET_KEY=${SECRET_KEY}
|
|
||||||
|
|
||||||
DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@localhost:5432/${DB_NAME}
|
|
||||||
DB_USERNAME=${DB_USER}
|
|
||||||
DB_PASSWORD=${DB_PASSWORD}
|
|
||||||
|
|
||||||
API_BASE_URL=http://localhost:8080/api
|
|
||||||
|
|
||||||
BABEL_DEFAULT_LOCALE=es
|
|
||||||
BABEL_DEFAULT_TIMEZONE=America/Argentina/Buenos_Aires
|
|
||||||
ENVEOF
|
|
||||||
chmod 640 "$APP_ENV"
|
|
||||||
ok ".env generado automáticamente con configuración de producción"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ─── 6. Configurar Usuario y Base de Datos en PostgreSQL ───────────────────────
|
|
||||||
hdr "6. Configurando motor PostgreSQL y credenciales de base de datos"
|
|
||||||
|
|
||||||
# Configurar usuarios en PostgreSQL con privilegios completos
|
|
||||||
for u in "${DB_USER}" "${DB_USERNAME:-}" eduspace_user; do
|
|
||||||
if [[ -n "$u" && "$u" != "postgres" ]]; then
|
|
||||||
pg_cmd -c "CREATE USER \"${u}\" WITH PASSWORD '${DB_PASSWORD}';" >/dev/null 2>&1 \
|
|
||||||
|| pg_cmd -c "ALTER USER \"${u}\" WITH PASSWORD '${DB_PASSWORD}';" >/dev/null 2>&1 \
|
|
||||||
|| true
|
|
||||||
pg_cmd -c "ALTER USER \"${u}\" WITH SUPERUSER CREATEDB;" >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
pg_cmd -c "ALTER USER postgres WITH PASSWORD '${DB_PASSWORD}';" >/dev/null 2>&1 || true
|
|
||||||
pg_cmd -c "ALTER USER postgres WITH SUPERUSER CREATEDB;" >/dev/null 2>&1 || true
|
|
||||||
|
|
||||||
# Crear base de datos
|
|
||||||
pg_cmd -c "CREATE DATABASE \"${DB_NAME}\" OWNER postgres;" >/dev/null 2>&1 \
|
|
||||||
|| info "La base de datos '${DB_NAME}' ya existía."
|
|
||||||
|
|
||||||
for u in "${DB_USER}" "${DB_USERNAME:-}" postgres eduspace_user; do
|
|
||||||
if [[ -n "$u" ]]; then
|
|
||||||
pg_cmd -c "GRANT ALL PRIVILEGES ON DATABASE \"${DB_NAME}\" TO \"${u}\";" >/dev/null 2>&1 || true
|
|
||||||
pg_cmd -d "${DB_NAME}" -c "GRANT ALL ON SCHEMA public TO \"${u}\";" >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
ok "Motor PostgreSQL configurado (usuario: ${DB_USER}, base: ${DB_NAME})"
|
|
||||||
|
|
||||||
# ─── 7. Crear Entorno Virtual e Instalar Dependencias ──────────────────────────
|
|
||||||
hdr "7. Creando virtualenv e instalando dependencias Python"
|
|
||||||
|
|
||||||
if [[ -d "$VENV_DIR" ]]; then
|
|
||||||
info "Recreando entorno virtual para asegurar dependencias limpias..."
|
|
||||||
rm -rf "$VENV_DIR"
|
|
||||||
fi
|
|
||||||
|
|
||||||
python3 -m venv "$VENV_DIR" > /dev/null 2>&1
|
|
||||||
"${VENV_DIR}/bin/pip" install --upgrade pip setuptools wheel --quiet
|
|
||||||
|
|
||||||
info "Instalando paquetes desde requirements.txt..."
|
|
||||||
if "${VENV_DIR}/bin/pip" install -r "${APP_DIR}/requirements.txt" --quiet 2>/dev/null; then
|
|
||||||
ok "Dependencias instaladas correctamente"
|
|
||||||
else
|
|
||||||
warn "Reintentando instalación con --no-build-isolation (compatibilidad Python 3.12/3.13)..."
|
|
||||||
"${VENV_DIR}/bin/pip" install -r "${APP_DIR}/requirements.txt" --no-build-isolation --quiet
|
|
||||||
ok "Dependencias instaladas"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ─── 8. Despliegue de Datos: Restauración o Inicialización ─────────────────────
|
|
||||||
hdr "8. Desplegando esquema y datos de la aplicación"
|
|
||||||
cd "$APP_DIR"
|
|
||||||
|
|
||||||
if [[ -f "$SQL_BACKUP_FILE" ]]; then
|
|
||||||
BACKUP_SIZE=$(du -h "$SQL_BACKUP_FILE" | awk '{print $1}')
|
|
||||||
info "Se detectó snapshot de base de datos '${SQL_BACKUP_FILE}' (${BACKUP_SIZE})."
|
|
||||||
info "Restaurando volcado integral de aulas, comisiones, materias, reservas y usuarios..."
|
|
||||||
|
|
||||||
# Recrear base de datos limpia para carga sin colisiones
|
|
||||||
pg_cmd -c "DROP DATABASE IF EXISTS \"${DB_NAME}\";" > /dev/null 2>&1 || true
|
|
||||||
pg_cmd -c "CREATE DATABASE \"${DB_NAME}\" OWNER postgres;" > /dev/null 2>&1
|
|
||||||
|
|
||||||
# Restaurar backup filtrando directivas específicas de PG18 si existiesen
|
|
||||||
if sed '/^\\restrict/d' "$SQL_BACKUP_FILE" | pg_cmd -d "${DB_NAME}" > /dev/null 2>&1; then
|
|
||||||
ok "Volcado SQL restaurado exitosamente."
|
|
||||||
else
|
|
||||||
warn "Restauración completada con avisos menores — aplicando permisos..."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Otorgar propiedad y permisos absolutos sobre todas las tablas y secuencias al usuario de la app
|
|
||||||
info "Garantizando permisos y secuencias para ${DB_USER}..."
|
|
||||||
for u in "${DB_USER}" "${DB_USERNAME:-}" postgres eduspace_user; do
|
|
||||||
if [[ -n "$u" ]]; then
|
|
||||||
pg_cmd -d "${DB_NAME}" -c "GRANT ALL PRIVILEGES ON DATABASE \"${DB_NAME}\" TO \"${u}\";" > /dev/null 2>&1 || true
|
|
||||||
pg_cmd -d "${DB_NAME}" -c "GRANT ALL ON SCHEMA public TO \"${u}\";" > /dev/null 2>&1 || true
|
|
||||||
pg_cmd -d "${DB_NAME}" -c "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO \"${u}\";" > /dev/null 2>&1 || true
|
|
||||||
pg_cmd -d "${DB_NAME}" -c "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO \"${u}\";" > /dev/null 2>&1 || true
|
|
||||||
pg_cmd -d "${DB_NAME}" -c "GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO \"${u}\";" > /dev/null 2>&1 || true
|
|
||||||
pg_cmd -d "${DB_NAME}" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO \"${u}\";" > /dev/null 2>&1 || true
|
|
||||||
pg_cmd -d "${DB_NAME}" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO \"${u}\";" > /dev/null 2>&1 || true
|
|
||||||
pg_cmd -d "${DB_NAME}" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON FUNCTIONS TO \"${u}\";" > /dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ "${DB_USER}" != "postgres" ]]; then
|
|
||||||
pg_cmd -d "${DB_NAME}" -c "ALTER SCHEMA public OWNER TO \"${DB_USER}\";" > /dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Ejecutar init_db.py de forma idempotente para verificar roles y migraciones incrementales
|
|
||||||
info "Validando coherencia de modelos con init_db.py..."
|
|
||||||
LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 PYTHONUTF8=1 PYTHONIOENCODING=utf-8 \
|
|
||||||
FLASK_APP=wsgi.py "${VENV_DIR}/bin/python" -X utf8 init_db.py
|
|
||||||
ok "Base de datos sincronizada y verificada"
|
|
||||||
else
|
|
||||||
info "No se encontró classrooms_db.sql. Creando esquema desde cero con init_db.py..."
|
|
||||||
LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 PYTHONUTF8=1 PYTHONIOENCODING=utf-8 \
|
|
||||||
FLASK_APP=wsgi.py "${VENV_DIR}/bin/python" -X utf8 init_db.py
|
|
||||||
ok "Esquema, roles RBAC, edificios y usuario admin creados"
|
|
||||||
fi
|
|
||||||
cd - > /dev/null
|
|
||||||
|
|
||||||
# ─── 9. Usuario del Sistema y Permisos de Directorio ───────────────────────────
|
|
||||||
hdr "9. Configurando permisos y usuario de ejecución del sistema"
|
|
||||||
if id "$APP_USER" &>/dev/null; then
|
|
||||||
info "El usuario de sistema '${APP_USER}' ya existe."
|
|
||||||
else
|
|
||||||
useradd --system --no-create-home --shell /usr/sbin/nologin "$APP_USER"
|
|
||||||
ok "Usuario de sistema '${APP_USER}' creado (sin shell)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "${APP_DIR}/app/static/uploads"
|
|
||||||
chown -R "${APP_USER}:${APP_USER}" "$APP_DIR"
|
|
||||||
chmod -R 755 "$APP_DIR"
|
|
||||||
chmod -R 775 "${APP_DIR}/app/static/uploads"
|
|
||||||
chmod 640 "$APP_ENV"
|
|
||||||
|
|
||||||
mkdir -p "$LOG_DIR"
|
|
||||||
chown -R "${APP_USER}:${APP_USER}" "$LOG_DIR"
|
|
||||||
chmod 755 "$LOG_DIR"
|
|
||||||
ok "Permisos de seguridad aplicados en ${APP_DIR} y ${LOG_DIR}"
|
|
||||||
|
|
||||||
# ─── 10. Servicio Systemd (Gunicorn de Producción) ─────────────────────────────
|
|
||||||
hdr "10. Configurando servicio systemd de arranque automático"
|
|
||||||
|
|
||||||
GUNICORN_BIN="${VENV_DIR}/bin/gunicorn"
|
|
||||||
|
|
||||||
cat > "/etc/systemd/system/${SERVICE_NAME}.service" <<SVCEOF
|
|
||||||
[Unit]
|
|
||||||
Description=Admin Edu-Space — Flask/Gunicorn (UniCABA)
|
|
||||||
Documentation=https://gitea.oemspot.com.ar/carlostellocba/admin-edu-space
|
|
||||||
After=network.target postgresql.service
|
|
||||||
Wants=postgresql.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=${APP_USER}
|
|
||||||
Group=${APP_USER}
|
|
||||||
WorkingDirectory=${APP_DIR}
|
|
||||||
EnvironmentFile=${APP_ENV}
|
|
||||||
Environment="LANG=en_US.UTF-8"
|
|
||||||
Environment="LC_ALL=en_US.UTF-8"
|
|
||||||
Environment="PYTHONUTF8=1"
|
|
||||||
Environment="PYTHONIOENCODING=utf-8"
|
|
||||||
Environment="FLASK_ENV=production"
|
|
||||||
Environment="HOME=${APP_DIR}"
|
|
||||||
ExecStart=${GUNICORN_BIN} \
|
|
||||||
--workers 3 \
|
|
||||||
--bind 0.0.0.0:5000 \
|
|
||||||
--timeout 120 \
|
|
||||||
--access-logfile ${LOG_DIR}/access.log \
|
|
||||||
--error-logfile - \
|
|
||||||
--capture-output \
|
|
||||||
--enable-stdio-inheritance \
|
|
||||||
--log-level info \
|
|
||||||
wsgi:app
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
SyslogIdentifier=${SERVICE_NAME}
|
|
||||||
NoNewPrivileges=true
|
|
||||||
PrivateTmp=true
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
SVCEOF
|
|
||||||
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl enable "${SERVICE_NAME}" > /dev/null 2>&1
|
|
||||||
systemctl restart "${SERVICE_NAME}"
|
|
||||||
sleep 3
|
|
||||||
|
|
||||||
if systemctl is-active --quiet "$SERVICE_NAME"; then
|
|
||||||
ok "Servicio '${SERVICE_NAME}' activo y en ejecución"
|
|
||||||
|
|
||||||
# Validar respuesta HTTP local
|
|
||||||
info "Verificando respuesta del servidor web..."
|
|
||||||
sleep 2
|
|
||||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:5000/ || echo "000")
|
|
||||||
if [[ "$HTTP_CODE" == "200" || "$HTTP_CODE" == "302" ]]; then
|
|
||||||
ok "Servidor web respondiendo correctamente (HTTP ${HTTP_CODE})"
|
|
||||||
else
|
|
||||||
warn "El servidor web devolvió código HTTP ${HTTP_CODE}. Podés revisar los logs con: journalctl -u ${SERVICE_NAME} -n 30 --no-pager"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
warn "El servicio tardó en arrancar. Podés revisar los logs con:"
|
|
||||||
warn " journalctl -u ${SERVICE_NAME} -n 50 --no-pager"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ─── Resumen Final y Datos de Acceso ──────────────────────────────────────────
|
|
||||||
hdr "¡Despliegue Completado con Éxito!"
|
|
||||||
|
|
||||||
IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "127.0.0.1")
|
|
||||||
|
|
||||||
echo -e " ${BOLD}Acceso Web a la Aplicación:${RESET}"
|
|
||||||
echo -e " ${CYAN}http://${IP}:5000${RESET} o ${CYAN}http://localhost:5000${RESET}"
|
|
||||||
echo ""
|
|
||||||
echo -e " ${BOLD}Credenciales Iniciales de Administrador:${RESET}"
|
|
||||||
echo -e " Email: ${CYAN}admin@edu-space.com${RESET}"
|
|
||||||
echo -e " Password: ${CYAN}admin123${RESET}"
|
|
||||||
echo ""
|
|
||||||
echo -e " ${BOLD}Base de Datos Desplegada:${RESET}"
|
|
||||||
echo -e " Motor: PostgreSQL (${PG_VER})"
|
|
||||||
echo -e " Base: ${DB_NAME}"
|
|
||||||
echo -e " Usuario: ${DB_USER}"
|
|
||||||
echo -e " Snapshot: $( [ -f "$SQL_BACKUP_FILE" ] && echo "Restaurada desde classrooms_db.sql" || echo "Inicializada limpia" )"
|
|
||||||
echo ""
|
|
||||||
echo -e " ${BOLD}Comandos de Gestión del Servicio:${RESET}"
|
|
||||||
echo -e " Estado: ${CYAN}systemctl status ${SERVICE_NAME}${RESET}"
|
|
||||||
echo -e " Logs: ${CYAN}journalctl -u ${SERVICE_NAME} -f${RESET}"
|
|
||||||
echo -e " Reiniciar: ${CYAN}systemctl restart ${SERVICE_NAME}${RESET}"
|
|
||||||
echo -e " Detener: ${CYAN}systemctl stop ${SERVICE_NAME}${RESET}"
|
|
||||||
echo ""
|
|
||||||
echo -e " ${BOLD}Generar Nuevos Backups:${RESET}"
|
|
||||||
echo -e " ${CYAN}bash backup_db.sh${RESET}"
|
|
||||||
echo ""
|
|
||||||
ok "Despliegue finalizado. El sistema se encuentra 100% operativo."
|
|
||||||
echo ""
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
"""
|
|
||||||
Migration script for Phase 2 MVP fields:
|
|
||||||
- subjects.is_short_course (BOOLEAN DEFAULT 0)
|
|
||||||
- student_enrollments.allow_same_day_exception (BOOLEAN DEFAULT 0)
|
|
||||||
- student_enrollments.exception_reason (VARCHAR(255))
|
|
||||||
"""
|
|
||||||
import sqlite3
|
|
||||||
import os
|
|
||||||
|
|
||||||
def migrate():
|
|
||||||
db_paths = [
|
|
||||||
os.path.join(os.path.dirname(__file__), 'instance', 'app.db'),
|
|
||||||
os.path.join(os.path.dirname(__file__), 'app.db')
|
|
||||||
]
|
|
||||||
for db_path in db_paths:
|
|
||||||
if not os.path.exists(db_path):
|
|
||||||
continue
|
|
||||||
print(f"Migrating SQLite DB: {db_path}")
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Check subjects.is_short_course
|
|
||||||
cursor.execute("PRAGMA table_info(subjects)")
|
|
||||||
columns = [row[1] for row in cursor.fetchall()]
|
|
||||||
if 'is_short_course' not in columns:
|
|
||||||
print("Adding column 'is_short_course' to subjects table...")
|
|
||||||
cursor.execute("ALTER TABLE subjects ADD COLUMN is_short_course BOOLEAN DEFAULT 0")
|
|
||||||
|
|
||||||
# Check student_enrollments.allow_same_day_exception
|
|
||||||
cursor.execute("PRAGMA table_info(student_enrollments)")
|
|
||||||
enr_columns = [row[1] for row in cursor.fetchall()]
|
|
||||||
if 'allow_same_day_exception' not in enr_columns:
|
|
||||||
print("Adding column 'allow_same_day_exception' to student_enrollments table...")
|
|
||||||
cursor.execute("ALTER TABLE student_enrollments ADD COLUMN allow_same_day_exception BOOLEAN DEFAULT 0")
|
|
||||||
if 'exception_reason' not in enr_columns:
|
|
||||||
print("Adding column 'exception_reason' to student_enrollments table...")
|
|
||||||
cursor.execute("ALTER TABLE student_enrollments ADD COLUMN exception_reason VARCHAR(255)")
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
print(f"Migration completed for {db_path}")
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
migrate()
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
"""
|
|
||||||
Migration script for Phase 3 MVP fields & tables:
|
|
||||||
- commissions.grades_closed (BOOLEAN DEFAULT 0)
|
|
||||||
- commissions.grades_closed_at (DATETIME)
|
|
||||||
- commissions.grades_closed_by (INTEGER)
|
|
||||||
- commissions.acta_number (VARCHAR(50))
|
|
||||||
- milestone_grades table
|
|
||||||
"""
|
|
||||||
import sqlite3
|
|
||||||
import os
|
|
||||||
|
|
||||||
def migrate():
|
|
||||||
db_paths = [
|
|
||||||
os.path.join(os.path.dirname(__file__), 'instance', 'app.db'),
|
|
||||||
os.path.join(os.path.dirname(__file__), 'app.db')
|
|
||||||
]
|
|
||||||
for db_path in db_paths:
|
|
||||||
if not os.path.exists(db_path):
|
|
||||||
continue
|
|
||||||
print(f"Migrating SQLite DB: {db_path}")
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Check commissions columns
|
|
||||||
cursor.execute("PRAGMA table_info(commissions)")
|
|
||||||
columns = [row[1] for row in cursor.fetchall()]
|
|
||||||
if 'grades_closed' not in columns:
|
|
||||||
print("Adding column 'grades_closed' to commissions table...")
|
|
||||||
cursor.execute("ALTER TABLE commissions ADD COLUMN grades_closed BOOLEAN DEFAULT 0")
|
|
||||||
if 'grades_closed_at' not in columns:
|
|
||||||
print("Adding column 'grades_closed_at' to commissions table...")
|
|
||||||
cursor.execute("ALTER TABLE commissions ADD COLUMN grades_closed_at DATETIME")
|
|
||||||
if 'grades_closed_by' not in columns:
|
|
||||||
print("Adding column 'grades_closed_by' to commissions table...")
|
|
||||||
cursor.execute("ALTER TABLE commissions ADD COLUMN grades_closed_by INTEGER")
|
|
||||||
if 'acta_number' not in columns:
|
|
||||||
print("Adding column 'acta_number' to commissions table...")
|
|
||||||
cursor.execute("ALTER TABLE commissions ADD COLUMN acta_number VARCHAR(50)")
|
|
||||||
|
|
||||||
# Create milestone_grades table if not exists
|
|
||||||
cursor.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS milestone_grades (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
milestone_id INTEGER NOT NULL,
|
|
||||||
enrollment_id INTEGER NOT NULL,
|
|
||||||
student_id INTEGER NOT NULL,
|
|
||||||
score FLOAT,
|
|
||||||
is_absent BOOLEAN DEFAULT 0,
|
|
||||||
feedback TEXT,
|
|
||||||
graded_by_user_id INTEGER,
|
|
||||||
graded_at DATETIME,
|
|
||||||
updated_at DATETIME,
|
|
||||||
FOREIGN KEY (milestone_id) REFERENCES academic_milestones(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (enrollment_id) REFERENCES student_enrollments(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (student_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (graded_by_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
CONSTRAINT uq_milestone_student UNIQUE (milestone_id, student_id)
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
cursor.execute("CREATE INDEX IF NOT EXISTS ix_milestone_grades_milestone_id ON milestone_grades (milestone_id)")
|
|
||||||
cursor.execute("CREATE INDEX IF NOT EXISTS ix_milestone_grades_enrollment_id ON milestone_grades (enrollment_id)")
|
|
||||||
cursor.execute("CREATE INDEX IF NOT EXISTS ix_milestone_grades_student_id ON milestone_grades (student_id)")
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
print(f"Migration completed for {db_path}")
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
migrate()
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
from app import create_app, db
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
app = create_app()
|
|
||||||
|
|
||||||
with app.app_context():
|
|
||||||
print("Verificando columnas en system_settings...")
|
|
||||||
try:
|
|
||||||
db.session.execute(text("ALTER TABLE system_settings ADD COLUMN category VARCHAR(50) DEFAULT 'system'"))
|
|
||||||
db.session.commit()
|
|
||||||
print("Columna 'category' agregada exitosamente.")
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
print("Aviso al agregar 'category':", str(e).split('\n')[0])
|
|
||||||
|
|
||||||
try:
|
|
||||||
db.session.execute(text("ALTER TABLE system_settings ADD COLUMN is_encrypted BOOLEAN DEFAULT 0"))
|
|
||||||
db.session.commit()
|
|
||||||
print("Columna 'is_encrypted' agregada exitosamente.")
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
print("Aviso al agregar 'is_encrypted':", str(e).split('\n')[0])
|
|
||||||
|
|
||||||
print("Migración de system_settings completa.")
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
"""
|
|
||||||
Migration script to create moodle_sync_tasks table if it doesn't exist.
|
|
||||||
Supports both SQLite and PostgreSQL.
|
|
||||||
"""
|
|
||||||
from app import create_app, db
|
|
||||||
from app.models.sync_task import MoodleSyncTask
|
|
||||||
from sqlalchemy import inspect
|
|
||||||
|
|
||||||
def run_migration():
|
|
||||||
app = create_app()
|
|
||||||
with app.app_context():
|
|
||||||
engine = db.engine
|
|
||||||
print(f"Connecting to database using engine: {engine.name}")
|
|
||||||
|
|
||||||
# db.create_all() creates any missing tables including moodle_sync_tasks
|
|
||||||
db.create_all()
|
|
||||||
print("db.create_all() executed successfully. Checking table existence...")
|
|
||||||
|
|
||||||
inspector = inspect(engine)
|
|
||||||
tables = inspector.get_table_names()
|
|
||||||
if 'moodle_sync_tasks' in tables:
|
|
||||||
print("SUCCESS: 'moodle_sync_tasks' table exists and is ready.")
|
|
||||||
cols = [c['name'] for c in inspector.get_columns('moodle_sync_tasks')]
|
|
||||||
print(f"Columns in moodle_sync_tasks: {cols}")
|
|
||||||
else:
|
|
||||||
print("ERROR: 'moodle_sync_tasks' table was not created.")
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
run_migration()
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import sqlite3
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# Locate database
|
|
||||||
possible_paths = [
|
|
||||||
os.path.join(os.path.dirname(__file__), 'instance', 'edu_space.db'),
|
|
||||||
os.path.join(os.path.dirname(__file__), 'instance', 'app.db'),
|
|
||||||
os.path.join(os.path.dirname(__file__), 'edu_space.db'),
|
|
||||||
os.path.join(os.path.dirname(__file__), 'app.db'),
|
|
||||||
]
|
|
||||||
|
|
||||||
db_path = None
|
|
||||||
for p in possible_paths:
|
|
||||||
if os.path.exists(p):
|
|
||||||
db_path = p
|
|
||||||
break
|
|
||||||
|
|
||||||
if not db_path:
|
|
||||||
# Check current directory
|
|
||||||
for root, dirs, files in os.walk(os.path.dirname(__file__)):
|
|
||||||
for f in files:
|
|
||||||
if f.endswith('.db'):
|
|
||||||
db_path = os.path.join(root, f)
|
|
||||||
break
|
|
||||||
if db_path:
|
|
||||||
break
|
|
||||||
|
|
||||||
print(f"Connecting to database: {db_path}")
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Check existing columns in users table
|
|
||||||
cursor.execute("PRAGMA table_info(users)")
|
|
||||||
columns = [row[1] for row in cursor.fetchall()]
|
|
||||||
print(f"Existing columns in 'users': {columns}")
|
|
||||||
|
|
||||||
new_columns = [
|
|
||||||
('first_name', 'VARCHAR(100)'),
|
|
||||||
('last_name', 'VARCHAR(100)'),
|
|
||||||
('phone', 'VARCHAR(50)'),
|
|
||||||
('address', 'VARCHAR(255)'),
|
|
||||||
('document_type', 'VARCHAR(20) DEFAULT "DNI"'),
|
|
||||||
('document_number', 'VARCHAR(30)')
|
|
||||||
]
|
|
||||||
|
|
||||||
for col_name, col_type in new_columns:
|
|
||||||
if col_name not in columns:
|
|
||||||
print(f"Adding column '{col_name}'...")
|
|
||||||
cursor.execute(f"ALTER TABLE users ADD COLUMN {col_name} {col_type}")
|
|
||||||
else:
|
|
||||||
print(f"Column '{col_name}' already exists.")
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
# Create index on document_number if not exists
|
|
||||||
try:
|
|
||||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_document_number ON users(document_number)")
|
|
||||||
conn.commit()
|
|
||||||
print("Index on document_number verified.")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Notice on index: {e}")
|
|
||||||
|
|
||||||
# Migrate existing users: split name into first_name and last_name if empty
|
|
||||||
cursor.execute("SELECT id, name, first_name, last_name FROM users")
|
|
||||||
users = cursor.fetchall()
|
|
||||||
migrated = 0
|
|
||||||
for u_id, name, fn, ln in users:
|
|
||||||
if not fn and name:
|
|
||||||
parts = name.strip().split()
|
|
||||||
first_n = parts[0] if parts else ''
|
|
||||||
last_n = ' '.join(parts[1:]) if len(parts) > 1 else ''
|
|
||||||
cursor.execute("UPDATE users SET first_name = ?, last_name = ? WHERE id = ?", (first_n, last_n, u_id))
|
|
||||||
migrated += 1
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
print(f"Successfully migrated {migrated} users with first_name and last_name from name.")
|
|
||||||
conn.close()
|
|
||||||
print("Migration completed successfully!")
|
|
||||||
@@ -19,3 +19,4 @@ Babel==2.14.0
|
|||||||
gunicorn>=21.2.0
|
gunicorn>=21.2.0
|
||||||
PyJWT>=2.8.0
|
PyJWT>=2.8.0
|
||||||
pydantic>=2.0.0
|
pydantic>=2.0.0
|
||||||
|
cryptography>=42.0.0
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ from app import create_app, db
|
|||||||
from app.models.reservation import Reservation
|
from app.models.reservation import Reservation
|
||||||
from app.models.enrollment import StudentEnrollment
|
from app.models.enrollment import StudentEnrollment
|
||||||
from app.models.milestone import AcademicMilestone
|
from app.models.milestone import AcademicMilestone
|
||||||
from app.models.subject import Commission, Subject
|
from app.models.grade import MilestoneGrade
|
||||||
|
from app.models.subject import Commission, Subject, CommissionTeacher
|
||||||
from app.models.career import Career
|
from app.models.career import Career
|
||||||
from app.models.classroom import Classroom, ClassroomResource
|
from app.models.classroom import Classroom, ClassroomResource
|
||||||
|
|
||||||
@@ -56,6 +57,8 @@ def execute_academic_reset(
|
|||||||
'reservations': 0,
|
'reservations': 0,
|
||||||
'enrollments': 0,
|
'enrollments': 0,
|
||||||
'commissions': 0,
|
'commissions': 0,
|
||||||
|
'commission_teachers': 0,
|
||||||
|
'milestones': 0,
|
||||||
'subjects': 0,
|
'subjects': 0,
|
||||||
'careers': 0,
|
'careers': 0,
|
||||||
'classrooms': 0,
|
'classrooms': 0,
|
||||||
@@ -66,32 +69,46 @@ def execute_academic_reset(
|
|||||||
# 1. Eliminar Reservas (Asignaciones de aulas)
|
# 1. Eliminar Reservas (Asignaciones de aulas)
|
||||||
if reset_reservations:
|
if reset_reservations:
|
||||||
deleted_counts['reservations'] = Reservation.query.delete()
|
deleted_counts['reservations'] = Reservation.query.delete()
|
||||||
|
|
||||||
# 2. Desvincular hitos de materias o comisiones si se van a eliminar
|
|
||||||
if reset_subjects or reset_commissions:
|
|
||||||
AcademicMilestone.query.update({
|
|
||||||
AcademicMilestone.subject_id: None,
|
|
||||||
AcademicMilestone.commission_id: None
|
|
||||||
})
|
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
# 3. Eliminar Inscripciones y Comisiones
|
# 2. Eliminar calificaciones e hitos evaluativos vinculados a comisiones o materias
|
||||||
if reset_commissions:
|
if reset_subjects or reset_commissions or reset_careers:
|
||||||
|
try:
|
||||||
|
MilestoneGrade.query.delete()
|
||||||
|
deleted_counts['milestones'] = AcademicMilestone.query.delete()
|
||||||
|
except Exception as me:
|
||||||
|
print("Notice during milestones cleanup:", me)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
# 3. Eliminar Docentes de comisión, Inscripciones de alumnos y Comisiones
|
||||||
|
if reset_commissions or reset_subjects or reset_careers:
|
||||||
|
try:
|
||||||
|
deleted_counts['commission_teachers'] = CommissionTeacher.query.delete()
|
||||||
|
except Exception as cte:
|
||||||
|
print("Notice during commission teachers cleanup:", cte)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
deleted_counts['enrollments'] = StudentEnrollment.query.delete()
|
deleted_counts['enrollments'] = StudentEnrollment.query.delete()
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
deleted_counts['commissions'] = Commission.query.delete()
|
deleted_counts['commissions'] = Commission.query.delete()
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
# 4. Eliminar Materias / Asignaturas
|
# 4. Eliminar Materias / Asignaturas
|
||||||
if reset_subjects:
|
if reset_subjects or reset_careers:
|
||||||
deleted_counts['subjects'] = Subject.query.delete()
|
deleted_counts['subjects'] = Subject.query.delete()
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
# 5. Eliminar Carreras
|
# 5. Eliminar Carreras
|
||||||
if reset_careers:
|
if reset_careers:
|
||||||
deleted_counts['careers'] = Career.query.delete()
|
deleted_counts['careers'] = Career.query.delete()
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
# 6. Eliminar Recursos y Aulas
|
# 6. Eliminar Recursos y Aulas
|
||||||
if reset_classrooms:
|
if reset_classrooms:
|
||||||
deleted_counts['classroom_resources'] = ClassroomResource.query.delete()
|
deleted_counts['classroom_resources'] = ClassroomResource.query.delete()
|
||||||
deleted_counts['classrooms'] = Classroom.query.delete()
|
deleted_counts['classrooms'] = Classroom.query.delete()
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
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")
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
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,230 @@
|
|||||||
|
import pytest
|
||||||
|
from app import create_app, db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.role import Role
|
||||||
|
from app.models.academic_term import AcademicTerm
|
||||||
|
from app.models.classroom import Classroom
|
||||||
|
from app.models.building import Building
|
||||||
|
from app.models.subject import Subject, Commission
|
||||||
|
from app.models.reservation import Reservation
|
||||||
|
from app.services.jwt_service import JWTService
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app():
|
||||||
|
app = create_app()
|
||||||
|
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
admin_role = Role.query.filter_by(name='ADMIN').first()
|
||||||
|
if not admin_role:
|
||||||
|
admin_role = Role(name='ADMIN', description='Administrator')
|
||||||
|
db.session.add(admin_role)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
email='admin_abm@unicaba.edu.ar',
|
||||||
|
name='Admin ABM',
|
||||||
|
first_name='Admin',
|
||||||
|
last_name='ABM',
|
||||||
|
role='ADMIN',
|
||||||
|
role_id=admin_role.id,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
user.set_password('Admin1234!')
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
yield app
|
||||||
|
|
||||||
|
db.session.remove()
|
||||||
|
db.drop_all()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app):
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_headers(app):
|
||||||
|
with app.app_context():
|
||||||
|
user = User.query.filter_by(email='admin_abm@unicaba.edu.ar').first()
|
||||||
|
token = JWTService.generate_tokens(user)['access_token']
|
||||||
|
return {
|
||||||
|
'Authorization': f'Bearer {token}',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_academic_term_abm(client, auth_headers):
|
||||||
|
# 1. Crear término
|
||||||
|
res = client.post('/api/v1/admin/academic-terms', headers=auth_headers, json={
|
||||||
|
'name': '2026-Segundo Cuatrimestre Test',
|
||||||
|
'code': '2026-2C-TEST',
|
||||||
|
'year': 2026,
|
||||||
|
'period_type': 'Segundo Cuatrimestre',
|
||||||
|
'is_active': True,
|
||||||
|
'is_current': False
|
||||||
|
})
|
||||||
|
assert res.status_code == 201
|
||||||
|
data = res.get_json()
|
||||||
|
term_id = data['term']['id']
|
||||||
|
|
||||||
|
# 2. Toggle active
|
||||||
|
res_toggle = client.post(f'/api/v1/admin/academic-terms/{term_id}/toggle', headers=auth_headers)
|
||||||
|
assert res_toggle.status_code == 200
|
||||||
|
assert res_toggle.get_json()['is_active'] is False
|
||||||
|
|
||||||
|
# 3. Delete término
|
||||||
|
res_del = client.delete(f'/api/v1/admin/academic-terms/{term_id}', headers=auth_headers)
|
||||||
|
assert res_del.status_code == 200
|
||||||
|
assert 'eliminado' in res_del.get_json()['message']
|
||||||
|
|
||||||
|
def test_classroom_toggle(client, auth_headers):
|
||||||
|
# Crear edificio y aula
|
||||||
|
building = Building(name='Sede Central Test', code='SC-TEST', address='Av Test 123')
|
||||||
|
db.session.add(building)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
classroom = Classroom(
|
||||||
|
building=building.name,
|
||||||
|
code='A-101-TEST',
|
||||||
|
capacity=50,
|
||||||
|
floor='1',
|
||||||
|
building_id=building.id,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.session.add(classroom)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Toggle aula
|
||||||
|
res = client.post(f'/api/v1/classrooms/{classroom.id}/toggle', headers=auth_headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.get_json()['is_active'] is False
|
||||||
|
|
||||||
|
def test_reservation_hard_delete(client, auth_headers):
|
||||||
|
# Crear edificio, aula, materia, comisión y reserva
|
||||||
|
building = Building(name='Sede Norte Test', code='SN-TEST', address='Av Norte 456')
|
||||||
|
db.session.add(building)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
classroom = Classroom(
|
||||||
|
building=building.name,
|
||||||
|
code='LAB-1-TEST',
|
||||||
|
capacity=30,
|
||||||
|
floor='PB',
|
||||||
|
building_id=building.id,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.session.add(classroom)
|
||||||
|
|
||||||
|
subject = Subject(name='Base de Datos Test', code='BD-TEST', is_active=True)
|
||||||
|
db.session.add(subject)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
user = User.query.filter_by(email='admin_abm@unicaba.edu.ar').first()
|
||||||
|
commission = Commission(
|
||||||
|
subject_id=subject.id,
|
||||||
|
code='COM-101',
|
||||||
|
semester='2C',
|
||||||
|
year=2026,
|
||||||
|
teacher_id=user.id,
|
||||||
|
max_students=30,
|
||||||
|
active=True
|
||||||
|
)
|
||||||
|
db.session.add(commission)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
reservation = Reservation(
|
||||||
|
classroom_id=classroom.id,
|
||||||
|
commission_id=commission.id,
|
||||||
|
user_id=user.id,
|
||||||
|
purpose='Clase Regular de BD',
|
||||||
|
expected_attendees=25,
|
||||||
|
start_time=now,
|
||||||
|
end_time=now + timedelta(hours=2),
|
||||||
|
status='CONFIRMED'
|
||||||
|
)
|
||||||
|
db.session.add(reservation)
|
||||||
|
db.session.commit()
|
||||||
|
res_id = reservation.id
|
||||||
|
|
||||||
|
# Hard delete
|
||||||
|
res_del = client.delete(f'/api/v1/reservations/{res_id}?hard=true', headers=auth_headers)
|
||||||
|
assert res_del.status_code == 200
|
||||||
|
assert 'eliminada' in res_del.get_json()['message']
|
||||||
|
assert db.session.get(Reservation, res_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_reset_validation(client, auth_headers):
|
||||||
|
# 1. Sin contraseña -> 400
|
||||||
|
res_no_pass = client.post('/api/v1/admin/system/reset', headers=auth_headers, json={})
|
||||||
|
assert res_no_pass.status_code == 400
|
||||||
|
assert 'contraseña' in res_no_pass.get_json()['message'].lower()
|
||||||
|
|
||||||
|
# 2. Con contraseña errónea -> 401
|
||||||
|
res_bad_pass = client.post('/api/v1/admin/system/reset', headers=auth_headers, json={'password': 'PasswordIncorrecto123'})
|
||||||
|
assert res_bad_pass.status_code == 401
|
||||||
|
assert 'incorrecta' in res_bad_pass.get_json()['message'].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_reset_success_preserves_users(client, auth_headers):
|
||||||
|
from app.models.career import Career
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
|
||||||
|
# Crear datos operativos
|
||||||
|
career = Career(name='Ingenieria en Software', code='ING-SOFT')
|
||||||
|
db.session.add(career)
|
||||||
|
building = Building(name='Pabellón Norte', code='PNORTE', is_active=True)
|
||||||
|
db.session.add(building)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
classroom = Classroom(code='Aula 501', floor='5', capacity=40, building_id=building.id, building=building.name, is_active=True)
|
||||||
|
subject = Subject(name='Sistemas Operativos', code='SO-101', is_active=True, career_id=career.id)
|
||||||
|
term = AcademicTerm(name='2026-1C', code='2026-1C', start_date=datetime.now().date(), end_date=(datetime.now() + timedelta(days=120)).date(), is_active=True)
|
||||||
|
db.session.add_all([classroom, subject, term])
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
user = User.query.filter_by(email='admin_abm@unicaba.edu.ar').first()
|
||||||
|
comm = Commission(code='COM-SO-1', subject_id=subject.id, semester=1, year=2026, max_students=35, is_active=True)
|
||||||
|
db.session.add(comm)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
resv = Reservation(classroom_id=classroom.id, commission_id=comm.id, user_id=user.id, purpose='Clase', expected_attendees=25, start_time=datetime.now(), end_time=datetime.now() + timedelta(hours=2))
|
||||||
|
db.session.add(resv)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Verificar que los datos existen antes del reset
|
||||||
|
assert Classroom.query.count() >= 1
|
||||||
|
assert Building.query.count() >= 1
|
||||||
|
assert Subject.query.count() >= 1
|
||||||
|
assert Career.query.count() >= 1
|
||||||
|
assert Commission.query.count() >= 1
|
||||||
|
assert Reservation.query.count() >= 1
|
||||||
|
assert AcademicTerm.query.count() >= 1
|
||||||
|
|
||||||
|
# Ejecutar reset del sistema con la contraseña correcta del admin
|
||||||
|
res_reset = client.post('/api/v1/admin/system/reset', headers=auth_headers, json={'password': 'Admin1234!'})
|
||||||
|
assert res_reset.status_code == 200
|
||||||
|
data = res_reset.get_json()
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
assert 'reinicializado' in data['message'].lower()
|
||||||
|
|
||||||
|
# Comprobar que los datos operativos fueron purgados
|
||||||
|
assert Classroom.query.count() == 0
|
||||||
|
assert Building.query.count() == 0
|
||||||
|
assert Subject.query.count() == 0
|
||||||
|
assert Career.query.count() == 0
|
||||||
|
assert Commission.query.count() == 0
|
||||||
|
assert Reservation.query.count() == 0
|
||||||
|
assert AcademicTerm.query.count() == 0
|
||||||
|
|
||||||
|
# Comprobar que los usuarios y roles se PRESERVAN
|
||||||
|
assert User.query.filter_by(email='admin_abm@unicaba.edu.ar').first() is not None
|
||||||
|
assert Role.query.count() >= 1
|
||||||
|
|
||||||
|
# Comprobar que se generó un nuevo registro de auditoría del evento SYSTEM_RESET
|
||||||
|
reset_logs = AuditLog.query.filter_by(action='SYSTEM_RESET').all()
|
||||||
|
assert len(reset_logs) == 1
|
||||||
|
assert reset_logs[0].user_email == 'admin_abm@unicaba.edu.ar'
|
||||||
|
|
||||||
@@ -1,539 +0,0 @@
|
|||||||
"""
|
|
||||||
test_enrollment_and_commissions.py
|
|
||||||
Tests for the enrollment management system:
|
|
||||||
- StudentEnrollment model CRUD
|
|
||||||
- Commission management routes (Bedelia/Admin)
|
|
||||||
- /mis-materias routes per role (Docente, Alumno, Admin, Bedelia)
|
|
||||||
- RBAC enrollment permissions
|
|
||||||
- Bedelia role deduplication (no accented duplicate)
|
|
||||||
"""
|
|
||||||
import unittest
|
|
||||||
from app import create_app, db
|
|
||||||
from app.models.user import User
|
|
||||||
from app.models.role import Role, SYSTEM_MODULES
|
|
||||||
from app.models.subject import Subject, Commission
|
|
||||||
from app.models.enrollment import StudentEnrollment
|
|
||||||
|
|
||||||
|
|
||||||
def _login_as(client, user_id):
|
|
||||||
"""Helper: inject Flask-Login session for the given user id."""
|
|
||||||
with client.session_transaction() as sess:
|
|
||||||
sess['_user_id'] = str(user_id)
|
|
||||||
sess['_fresh'] = True
|
|
||||||
|
|
||||||
|
|
||||||
class TestStudentEnrollmentModel(unittest.TestCase):
|
|
||||||
"""Unit tests for the StudentEnrollment SQLAlchemy model."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.app = create_app()
|
|
||||||
self.app.config['TESTING'] = True
|
|
||||||
self.app.config['WTF_CSRF_ENABLED'] = False
|
|
||||||
self.ctx = self.app.app_context()
|
|
||||||
self.ctx.push()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self.ctx.pop()
|
|
||||||
|
|
||||||
def test_table_exists(self):
|
|
||||||
"""student_enrollments table must exist in the database."""
|
|
||||||
result = db.session.execute(
|
|
||||||
db.text("SELECT to_regclass('public.student_enrollments')")
|
|
||||||
).scalar()
|
|
||||||
self.assertIsNotNone(result, "Tabla student_enrollments no existe en la BD")
|
|
||||||
|
|
||||||
def test_model_columns(self):
|
|
||||||
"""StudentEnrollment must expose all required columns."""
|
|
||||||
cols = {c.name for c in StudentEnrollment.__table__.columns}
|
|
||||||
for expected in ('id', 'student_id', 'commission_id', 'status', 'enrolled_at', 'notes'):
|
|
||||||
self.assertIn(expected, cols, f"Columna faltante: {expected}")
|
|
||||||
|
|
||||||
def test_unique_constraint_name(self):
|
|
||||||
"""Unique constraint uq_student_commission must exist."""
|
|
||||||
constraint_names = {c.name for c in StudentEnrollment.__table__.constraints}
|
|
||||||
self.assertIn('uq_student_commission', constraint_names)
|
|
||||||
|
|
||||||
def test_valid_statuses(self):
|
|
||||||
"""to_dict must return a status in the accepted set."""
|
|
||||||
alumno = User.query.filter(
|
|
||||||
User.role.ilike('%ALUMNO%'), User.is_active == True
|
|
||||||
).first()
|
|
||||||
comm = Commission.query.filter_by(active=True).first()
|
|
||||||
|
|
||||||
if not alumno or not comm:
|
|
||||||
self.skipTest("Se requiere al menos un alumno y una comision activa en la BD")
|
|
||||||
|
|
||||||
# Check if already enrolled to avoid integrity error
|
|
||||||
existing = StudentEnrollment.query.filter_by(
|
|
||||||
student_id=alumno.id, commission_id=comm.id
|
|
||||||
).first()
|
|
||||||
if not existing:
|
|
||||||
enr = StudentEnrollment(
|
|
||||||
student_id=alumno.id,
|
|
||||||
commission_id=comm.id,
|
|
||||||
status='activo',
|
|
||||||
notes='Test enrollment'
|
|
||||||
)
|
|
||||||
db.session.add(enr)
|
|
||||||
db.session.commit()
|
|
||||||
enrolled = enr
|
|
||||||
else:
|
|
||||||
enrolled = existing
|
|
||||||
|
|
||||||
d = enrolled.to_dict()
|
|
||||||
self.assertIn(d['status'], ['activo', 'retirado', 'condicional'])
|
|
||||||
self.assertEqual(d['student_id'], alumno.id)
|
|
||||||
self.assertEqual(d['commission_id'], comm.id)
|
|
||||||
|
|
||||||
# Cleanup (only if we created it)
|
|
||||||
if not existing:
|
|
||||||
db.session.delete(enrolled)
|
|
||||||
# Restore current_students counter
|
|
||||||
comm.current_students = max(0, (comm.current_students or 1) - 1)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
def test_relationships_accessible(self):
|
|
||||||
"""student and commission relationships must resolve without error."""
|
|
||||||
enr = StudentEnrollment.query.first()
|
|
||||||
if enr:
|
|
||||||
_ = enr.student
|
|
||||||
_ = enr.commission
|
|
||||||
|
|
||||||
|
|
||||||
class TestRBACEnrollmentModule(unittest.TestCase):
|
|
||||||
"""Tests for the enrollment RBAC module and role permissions."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.app = create_app()
|
|
||||||
self.app.config['TESTING'] = True
|
|
||||||
self.ctx = self.app.app_context()
|
|
||||||
self.ctx.push()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self.ctx.pop()
|
|
||||||
|
|
||||||
def test_enrollment_module_in_system_modules(self):
|
|
||||||
"""SYSTEM_MODULES must contain an 'enrollment' module."""
|
|
||||||
module_ids = [m['id'] for m in SYSTEM_MODULES]
|
|
||||||
self.assertIn('enrollment', module_ids,
|
|
||||||
"Modulo 'enrollment' no encontrado en SYSTEM_MODULES")
|
|
||||||
|
|
||||||
def test_bedelia_has_enrollment_read_write(self):
|
|
||||||
"""Bedelia role must have read_write on enrollment."""
|
|
||||||
bedelia = Role.query.filter_by(name='Bedelia').first()
|
|
||||||
self.assertIsNotNone(bedelia, "Rol 'Bedelia' no existe en la BD")
|
|
||||||
self.assertEqual(bedelia.get_permission('enrollment'), 'read_write')
|
|
||||||
self.assertTrue(bedelia.has_permission('enrollment', 'read_write'))
|
|
||||||
|
|
||||||
def test_admin_has_enrollment_read_write(self):
|
|
||||||
"""Admin role must have read_write on enrollment."""
|
|
||||||
admin = Role.query.filter_by(name='Admin').first()
|
|
||||||
self.assertIsNotNone(admin)
|
|
||||||
self.assertEqual(admin.get_permission('enrollment'), 'read_write')
|
|
||||||
|
|
||||||
def test_docente_has_enrollment_read(self):
|
|
||||||
"""Docente role must have read on enrollment (not write)."""
|
|
||||||
docente = Role.query.filter_by(name='Docente').first()
|
|
||||||
self.assertIsNotNone(docente)
|
|
||||||
self.assertTrue(docente.has_permission('enrollment', 'read'))
|
|
||||||
self.assertFalse(docente.has_permission('enrollment', 'read_write'))
|
|
||||||
|
|
||||||
def test_alumno_has_enrollment_read(self):
|
|
||||||
"""Alumno role must have read on enrollment."""
|
|
||||||
alumno_role = Role.query.filter_by(name='Alumno').first()
|
|
||||||
self.assertIsNotNone(alumno_role)
|
|
||||||
self.assertTrue(alumno_role.has_permission('enrollment', 'read'))
|
|
||||||
|
|
||||||
def test_no_duplicate_bedelia_roles(self):
|
|
||||||
"""There must be exactly one Bedelia role (no accented duplicate)."""
|
|
||||||
bedelia_variants = Role.query.filter(
|
|
||||||
Role.name.in_(['Bedelia', 'Bedelía', 'BEDELÍA'])
|
|
||||||
).all()
|
|
||||||
names = [r.name for r in bedelia_variants]
|
|
||||||
self.assertNotIn('Bedelía', names,
|
|
||||||
"Rol duplicado 'Bedelía' (con acento) todavía existe en la BD")
|
|
||||||
self.assertEqual(len(bedelia_variants), 1,
|
|
||||||
f"Se encontraron {len(bedelia_variants)} variantes de Bedelia: {names}")
|
|
||||||
|
|
||||||
|
|
||||||
class TestCommissionsManagementRoutes(unittest.TestCase):
|
|
||||||
"""Integration tests for /admin/commissions/ routes (Bedelia/Admin)."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.app = create_app()
|
|
||||||
self.app.config['TESTING'] = True
|
|
||||||
self.app.config['WTF_CSRF_ENABLED'] = False
|
|
||||||
self.client = self.app.test_client()
|
|
||||||
self.ctx = self.app.app_context()
|
|
||||||
self.ctx.push()
|
|
||||||
|
|
||||||
# Login as admin (first active user)
|
|
||||||
self.admin = User.query.filter_by(email='admin@edu-space.com').first()
|
|
||||||
self.bedelia = User.query.filter_by(email='bedelia@edu-space.com').first()
|
|
||||||
self.docente = User.query.filter_by(email='docente@edu-space.com').first()
|
|
||||||
self.alumno_user = User.query.filter_by(email='alumno@edu-space.com').first()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self.ctx.pop()
|
|
||||||
|
|
||||||
def _login(self, user):
|
|
||||||
_login_as(self.client, user.id)
|
|
||||||
|
|
||||||
def test_commissions_list_accessible_by_admin(self):
|
|
||||||
"""Admin can access /admin/commissions."""
|
|
||||||
self._login(self.admin)
|
|
||||||
res = self.client.get('/admin/commissions')
|
|
||||||
self.assertEqual(res.status_code, 200)
|
|
||||||
html = res.get_data(as_text=True)
|
|
||||||
self.assertIn('Gestión de Comisiones', html)
|
|
||||||
|
|
||||||
def test_commissions_list_accessible_by_bedelia(self):
|
|
||||||
"""Bedelia can access /admin/commissions."""
|
|
||||||
if not self.bedelia:
|
|
||||||
self.skipTest("No bedelia user in DB")
|
|
||||||
self._login(self.bedelia)
|
|
||||||
res = self.client.get('/admin/commissions')
|
|
||||||
self.assertEqual(res.status_code, 200)
|
|
||||||
|
|
||||||
def test_commissions_list_forbidden_for_docente(self):
|
|
||||||
"""Docente without manage permissions should get 403 on /admin/commissions."""
|
|
||||||
if not self.docente:
|
|
||||||
self.skipTest("No docente user in DB")
|
|
||||||
self._login(self.docente)
|
|
||||||
res = self.client.get('/admin/commissions')
|
|
||||||
# Docente has 'academic: read' → can_manage() is False → 403
|
|
||||||
self.assertIn(res.status_code, [403, 302])
|
|
||||||
|
|
||||||
def test_commission_detail_accessible_by_admin(self):
|
|
||||||
"""Commission detail page returns 200 for an existing commission."""
|
|
||||||
self._login(self.admin)
|
|
||||||
comm = Commission.query.filter_by(active=True).first()
|
|
||||||
if not comm:
|
|
||||||
self.skipTest("No active commission in DB")
|
|
||||||
res = self.client.get(f'/admin/commissions/{comm.id}')
|
|
||||||
self.assertEqual(res.status_code, 200)
|
|
||||||
html = res.get_data(as_text=True)
|
|
||||||
self.assertIn('Alumnos Inscriptos', html)
|
|
||||||
self.assertIn('Docente Asignado', html)
|
|
||||||
|
|
||||||
def test_commission_detail_accessible_by_bedelia(self):
|
|
||||||
"""Bedelia can view commission detail."""
|
|
||||||
if not self.bedelia:
|
|
||||||
self.skipTest("No bedelia user in DB")
|
|
||||||
self._login(self.bedelia)
|
|
||||||
comm = Commission.query.filter_by(active=True).first()
|
|
||||||
if not comm:
|
|
||||||
self.skipTest("No active commission in DB")
|
|
||||||
res = self.client.get(f'/admin/commissions/{comm.id}')
|
|
||||||
self.assertEqual(res.status_code, 200)
|
|
||||||
|
|
||||||
def test_commission_detail_nonexistent_returns_404(self):
|
|
||||||
"""Non-existent commission ID must return 404."""
|
|
||||||
self._login(self.admin)
|
|
||||||
res = self.client.get('/admin/commissions/999999')
|
|
||||||
self.assertEqual(res.status_code, 404)
|
|
||||||
|
|
||||||
def test_assign_teacher_post(self):
|
|
||||||
"""Bedelia can POST to assign a teacher to a commission."""
|
|
||||||
if not self.bedelia or not self.docente:
|
|
||||||
self.skipTest("Need bedelia and docente users")
|
|
||||||
self._login(self.bedelia)
|
|
||||||
comm = Commission.query.filter_by(active=True).first()
|
|
||||||
if not comm:
|
|
||||||
self.skipTest("No active commission in DB")
|
|
||||||
|
|
||||||
res = self.client.post(
|
|
||||||
f'/admin/commissions/{comm.id}/assign-teacher',
|
|
||||||
data={'teacher_id': self.docente.id},
|
|
||||||
follow_redirects=True
|
|
||||||
)
|
|
||||||
self.assertEqual(res.status_code, 200)
|
|
||||||
# Verify assignment persisted
|
|
||||||
db.session.refresh(comm)
|
|
||||||
self.assertEqual(comm.teacher_id, self.docente.id)
|
|
||||||
|
|
||||||
def test_enroll_student_and_unenroll(self):
|
|
||||||
"""Bedelia can enroll and unenroll an alumno in a commission."""
|
|
||||||
if not self.bedelia or not self.alumno_user:
|
|
||||||
self.skipTest("Need bedelia and alumno users")
|
|
||||||
self._login(self.bedelia)
|
|
||||||
comm = Commission.query.filter_by(active=True).first()
|
|
||||||
if not comm:
|
|
||||||
self.skipTest("No active commission in DB")
|
|
||||||
|
|
||||||
# Clean up any prior enrollment to ensure idempotent test
|
|
||||||
prior = StudentEnrollment.query.filter_by(
|
|
||||||
student_id=self.alumno_user.id, commission_id=comm.id
|
|
||||||
).first()
|
|
||||||
if prior:
|
|
||||||
db.session.delete(prior)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
prev_count = comm.current_students or 0
|
|
||||||
|
|
||||||
# Enroll
|
|
||||||
res = self.client.post(
|
|
||||||
f'/admin/commissions/{comm.id}/enroll-student',
|
|
||||||
data={'student_id': self.alumno_user.id, 'notes': 'Test'},
|
|
||||||
follow_redirects=True
|
|
||||||
)
|
|
||||||
self.assertEqual(res.status_code, 200)
|
|
||||||
enr = StudentEnrollment.query.filter_by(
|
|
||||||
student_id=self.alumno_user.id, commission_id=comm.id
|
|
||||||
).first()
|
|
||||||
self.assertIsNotNone(enr, "Inscripción no encontrada tras POST enroll")
|
|
||||||
self.assertEqual(enr.status, 'activo')
|
|
||||||
db.session.refresh(comm)
|
|
||||||
self.assertEqual(comm.current_students, prev_count + 1)
|
|
||||||
|
|
||||||
# Unenroll
|
|
||||||
res2 = self.client.post(
|
|
||||||
f'/admin/commissions/{comm.id}/unenroll-student/{self.alumno_user.id}',
|
|
||||||
follow_redirects=True
|
|
||||||
)
|
|
||||||
self.assertEqual(res2.status_code, 200)
|
|
||||||
enr_after = StudentEnrollment.query.filter_by(
|
|
||||||
student_id=self.alumno_user.id, commission_id=comm.id
|
|
||||||
).first()
|
|
||||||
self.assertIsNone(enr_after, "Inscripción debería haber sido eliminada")
|
|
||||||
db.session.refresh(comm)
|
|
||||||
self.assertEqual(comm.current_students, prev_count)
|
|
||||||
|
|
||||||
def test_update_enrollment_status(self):
|
|
||||||
"""Bedelia can change enrollment status (activo→condicional→retirado)."""
|
|
||||||
if not self.bedelia or not self.alumno_user:
|
|
||||||
self.skipTest("Need bedelia and alumno users")
|
|
||||||
self._login(self.bedelia)
|
|
||||||
comm = Commission.query.filter_by(active=True).first()
|
|
||||||
if not comm:
|
|
||||||
self.skipTest("No active commission in DB")
|
|
||||||
|
|
||||||
# Ensure enrollment exists
|
|
||||||
enr = StudentEnrollment.query.filter_by(
|
|
||||||
student_id=self.alumno_user.id, commission_id=comm.id
|
|
||||||
).first()
|
|
||||||
if not enr:
|
|
||||||
enr = StudentEnrollment(
|
|
||||||
student_id=self.alumno_user.id,
|
|
||||||
commission_id=comm.id,
|
|
||||||
status='activo'
|
|
||||||
)
|
|
||||||
db.session.add(enr)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
for new_status in ['condicional', 'retirado', 'activo']:
|
|
||||||
res = self.client.post(
|
|
||||||
f'/admin/commissions/{comm.id}/update-enrollment/{self.alumno_user.id}',
|
|
||||||
data={'status': new_status},
|
|
||||||
follow_redirects=True
|
|
||||||
)
|
|
||||||
self.assertEqual(res.status_code, 200)
|
|
||||||
db.session.refresh(enr)
|
|
||||||
self.assertEqual(enr.status, new_status)
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
db.session.delete(enr)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
def test_duplicate_enrollment_rejected(self):
|
|
||||||
"""Enrolling the same student twice in the same commission is rejected."""
|
|
||||||
if not self.bedelia or not self.alumno_user:
|
|
||||||
self.skipTest("Need bedelia and alumno users")
|
|
||||||
self._login(self.bedelia)
|
|
||||||
comm = Commission.query.filter_by(active=True).first()
|
|
||||||
if not comm:
|
|
||||||
self.skipTest("No active commission in DB")
|
|
||||||
|
|
||||||
# Ensure a clean start
|
|
||||||
prior = StudentEnrollment.query.filter_by(
|
|
||||||
student_id=self.alumno_user.id, commission_id=comm.id
|
|
||||||
).first()
|
|
||||||
if not prior:
|
|
||||||
enr = StudentEnrollment(
|
|
||||||
student_id=self.alumno_user.id,
|
|
||||||
commission_id=comm.id,
|
|
||||||
status='activo'
|
|
||||||
)
|
|
||||||
db.session.add(enr)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# Second enroll attempt
|
|
||||||
res = self.client.post(
|
|
||||||
f'/admin/commissions/{comm.id}/enroll-student',
|
|
||||||
data={'student_id': self.alumno_user.id},
|
|
||||||
follow_redirects=True
|
|
||||||
)
|
|
||||||
self.assertEqual(res.status_code, 200)
|
|
||||||
# Should see a warning message, and only one enrollment should exist
|
|
||||||
count = StudentEnrollment.query.filter_by(
|
|
||||||
student_id=self.alumno_user.id, commission_id=comm.id
|
|
||||||
).count()
|
|
||||||
self.assertEqual(count, 1)
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
StudentEnrollment.query.filter_by(
|
|
||||||
student_id=self.alumno_user.id, commission_id=comm.id
|
|
||||||
).delete()
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
class TestMySubjectsRoutes(unittest.TestCase):
|
|
||||||
"""/mis-materias route tests per role."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.app = create_app()
|
|
||||||
self.app.config['TESTING'] = True
|
|
||||||
self.app.config['WTF_CSRF_ENABLED'] = False
|
|
||||||
self.client = self.app.test_client()
|
|
||||||
self.ctx = self.app.app_context()
|
|
||||||
self.ctx.push()
|
|
||||||
|
|
||||||
self.admin = User.query.filter_by(email='admin@edu-space.com').first()
|
|
||||||
self.bedelia = User.query.filter_by(email='bedelia@edu-space.com').first()
|
|
||||||
self.docente = User.query.filter_by(email='docente@edu-space.com').first()
|
|
||||||
self.alumno_user = User.query.filter_by(email='alumno@edu-space.com').first()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self.ctx.pop()
|
|
||||||
|
|
||||||
def _login(self, user):
|
|
||||||
_login_as(self.client, user.id)
|
|
||||||
|
|
||||||
def test_mis_materias_requires_login(self):
|
|
||||||
"""Unauthenticated request to /mis-materias/ must redirect to login."""
|
|
||||||
res = self.client.get('/mis-materias/')
|
|
||||||
self.assertIn(res.status_code, [302, 401])
|
|
||||||
|
|
||||||
def test_mis_materias_admin_ok(self):
|
|
||||||
"""Admin sees all commissions at /mis-materias/."""
|
|
||||||
self._login(self.admin)
|
|
||||||
res = self.client.get('/mis-materias/')
|
|
||||||
self.assertEqual(res.status_code, 200)
|
|
||||||
html = res.get_data(as_text=True)
|
|
||||||
self.assertIn('Todas las Comisiones', html)
|
|
||||||
|
|
||||||
def test_mis_materias_bedelia_ok(self):
|
|
||||||
"""Bedelia sees all commissions at /mis-materias/."""
|
|
||||||
if not self.bedelia:
|
|
||||||
self.skipTest("No bedelia user")
|
|
||||||
self._login(self.bedelia)
|
|
||||||
res = self.client.get('/mis-materias/')
|
|
||||||
self.assertEqual(res.status_code, 200)
|
|
||||||
|
|
||||||
def test_mis_materias_docente_ok(self):
|
|
||||||
"""Docente gets 200 at /mis-materias/ (even with no assigned commissions)."""
|
|
||||||
if not self.docente:
|
|
||||||
self.skipTest("No docente user")
|
|
||||||
self._login(self.docente)
|
|
||||||
res = self.client.get('/mis-materias/')
|
|
||||||
self.assertEqual(res.status_code, 200)
|
|
||||||
html = res.get_data(as_text=True)
|
|
||||||
self.assertIn('Mis Materias', html)
|
|
||||||
|
|
||||||
def test_mis_materias_alumno_ok(self):
|
|
||||||
"""Alumno gets 200 at /mis-materias/ (even with no enrollments)."""
|
|
||||||
if not self.alumno_user:
|
|
||||||
self.skipTest("No alumno user")
|
|
||||||
self._login(self.alumno_user)
|
|
||||||
res = self.client.get('/mis-materias/')
|
|
||||||
self.assertEqual(res.status_code, 200)
|
|
||||||
html = res.get_data(as_text=True)
|
|
||||||
self.assertIn('Mis Materias', html)
|
|
||||||
|
|
||||||
def test_mis_materias_detail_enrolled_alumno(self):
|
|
||||||
"""Alumno enrolled in a commission can see its detail page."""
|
|
||||||
if not self.alumno_user:
|
|
||||||
self.skipTest("No alumno user")
|
|
||||||
|
|
||||||
comm = Commission.query.filter_by(active=True).first()
|
|
||||||
if not comm:
|
|
||||||
self.skipTest("No active commission in DB")
|
|
||||||
|
|
||||||
# Create temporary enrollment
|
|
||||||
enr = StudentEnrollment.query.filter_by(
|
|
||||||
student_id=self.alumno_user.id, commission_id=comm.id
|
|
||||||
).first()
|
|
||||||
cleanup_needed = False
|
|
||||||
if not enr:
|
|
||||||
enr = StudentEnrollment(
|
|
||||||
student_id=self.alumno_user.id,
|
|
||||||
commission_id=comm.id,
|
|
||||||
status='activo'
|
|
||||||
)
|
|
||||||
db.session.add(enr)
|
|
||||||
db.session.commit()
|
|
||||||
cleanup_needed = True
|
|
||||||
|
|
||||||
self._login(self.alumno_user)
|
|
||||||
res = self.client.get(f'/mis-materias/{comm.id}')
|
|
||||||
self.assertEqual(res.status_code, 200)
|
|
||||||
html = res.get_data(as_text=True)
|
|
||||||
self.assertIn('Tu inscripción', html)
|
|
||||||
|
|
||||||
if cleanup_needed:
|
|
||||||
db.session.delete(enr)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
def test_mis_materias_detail_forbidden_for_unenrolled_alumno(self):
|
|
||||||
"""Alumno NOT enrolled in a commission gets 403 on its detail page."""
|
|
||||||
if not self.alumno_user:
|
|
||||||
self.skipTest("No alumno user")
|
|
||||||
|
|
||||||
comm = Commission.query.filter_by(active=True).first()
|
|
||||||
if not comm:
|
|
||||||
self.skipTest("No active commission in DB")
|
|
||||||
|
|
||||||
# Ensure alumno is NOT enrolled
|
|
||||||
StudentEnrollment.query.filter_by(
|
|
||||||
student_id=self.alumno_user.id, commission_id=comm.id
|
|
||||||
).delete()
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
self._login(self.alumno_user)
|
|
||||||
res = self.client.get(f'/mis-materias/{comm.id}')
|
|
||||||
self.assertEqual(res.status_code, 403)
|
|
||||||
|
|
||||||
def test_mis_materias_detail_docente_own_commission(self):
|
|
||||||
"""Docente can see detail of their own assigned commission."""
|
|
||||||
if not self.docente:
|
|
||||||
self.skipTest("No docente user")
|
|
||||||
|
|
||||||
comm = Commission.query.filter_by(active=True).first()
|
|
||||||
if not comm:
|
|
||||||
self.skipTest("No active commission in DB")
|
|
||||||
|
|
||||||
# Assign commission to docente
|
|
||||||
prev_teacher = comm.teacher_id
|
|
||||||
comm.teacher_id = self.docente.id
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
self._login(self.docente)
|
|
||||||
res = self.client.get(f'/mis-materias/{comm.id}')
|
|
||||||
self.assertEqual(res.status_code, 200)
|
|
||||||
html = res.get_data(as_text=True)
|
|
||||||
self.assertIn(comm.subject.name, html)
|
|
||||||
|
|
||||||
# Restore
|
|
||||||
comm.teacher_id = prev_teacher
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
def test_mis_materias_detail_docente_unauthorized_commission(self):
|
|
||||||
"""Docente cannot see a commission they are not assigned to."""
|
|
||||||
if not self.docente:
|
|
||||||
self.skipTest("No docente user")
|
|
||||||
|
|
||||||
# Find a commission where docente is NOT the teacher
|
|
||||||
comm = Commission.query.filter(
|
|
||||||
Commission.active == True,
|
|
||||||
Commission.teacher_id != self.docente.id
|
|
||||||
).first()
|
|
||||||
if not comm:
|
|
||||||
self.skipTest("No commission without this docente in DB")
|
|
||||||
|
|
||||||
self._login(self.docente)
|
|
||||||
res = self.client.get(f'/mis-materias/{comm.id}')
|
|
||||||
self.assertEqual(res.status_code, 403)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main(verbosity=2)
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import unittest
|
|
||||||
from app import create_app, db
|
|
||||||
from app.models.user import User
|
|
||||||
|
|
||||||
class TestRoleDashboardAndMenu(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.app = create_app()
|
|
||||||
self.app.config['TESTING'] = True
|
|
||||||
self.app.config['WTF_CSRF_ENABLED'] = False
|
|
||||||
self.client = self.app.test_client()
|
|
||||||
self.app_context = self.app.app_context()
|
|
||||||
self.app_context.push()
|
|
||||||
|
|
||||||
# Usar usuario administrador para autenticación en sesión de pruebas
|
|
||||||
self.user = User.query.filter_by(is_active=True).first()
|
|
||||||
if self.user:
|
|
||||||
with self.client.session_transaction() as sess:
|
|
||||||
sess['_user_id'] = str(self.user.id)
|
|
||||||
sess['_fresh'] = True
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self.app_context.pop()
|
|
||||||
|
|
||||||
def test_user_role_methods(self):
|
|
||||||
"""Verifica la normalización y detección de roles en el modelo User"""
|
|
||||||
u = User(name="Test Teacher", email="prof@test.edu", role="Docente")
|
|
||||||
self.assertEqual(u.get_institutional_role(), "docente")
|
|
||||||
self.assertTrue(u.is_docente())
|
|
||||||
self.assertFalse(u.is_admin())
|
|
||||||
self.assertFalse(u.is_alumno())
|
|
||||||
self.assertFalse(u.is_bedelia())
|
|
||||||
|
|
||||||
u_bedelia = User(name="Test Bedelia", email="bedelia@test.edu", role="Bedelia")
|
|
||||||
self.assertEqual(u_bedelia.get_institutional_role(), "bedelia")
|
|
||||||
self.assertTrue(u_bedelia.is_bedelia())
|
|
||||||
|
|
||||||
u_student = User(name="Test Student", email="student@test.edu", role="Alumno")
|
|
||||||
self.assertEqual(u_student.get_institutional_role(), "alumno")
|
|
||||||
self.assertTrue(u_student.is_alumno())
|
|
||||||
|
|
||||||
u_admin = User(name="Test Admin", email="admin@test.edu", role="ADMIN")
|
|
||||||
self.assertEqual(u_admin.get_institutional_role(), "admin")
|
|
||||||
self.assertTrue(u_admin.is_admin())
|
|
||||||
|
|
||||||
badge = u_admin.role_badge_display
|
|
||||||
self.assertIn("badge-admin", badge["class"])
|
|
||||||
self.assertIn("Administrador", badge["label"])
|
|
||||||
|
|
||||||
def test_admin_dashboard_rendered(self):
|
|
||||||
"""Verifica que el dashboard administrativo renderiza KPIs y accesos de administración"""
|
|
||||||
res = self.client.get('/?view_as=admin')
|
|
||||||
self.assertEqual(res.status_code, 200)
|
|
||||||
html = res.get_data(as_text=True)
|
|
||||||
self.assertIn('Aulas Totales', html)
|
|
||||||
self.assertIn('Oferta de Materias', html)
|
|
||||||
self.assertIn('Usuarios Activos', html)
|
|
||||||
self.assertIn('Módulos de Gestión & Visualización de Menús', html)
|
|
||||||
self.assertIn('Espacios & Sedes', html)
|
|
||||||
self.assertIn('Auditoría & RBAC', html)
|
|
||||||
|
|
||||||
def test_bedelia_dashboard_rendered(self):
|
|
||||||
"""Verifica que el dashboard de bedelía renderiza métricas operativas de campus"""
|
|
||||||
res = self.client.get('/?view_as=bedelia')
|
|
||||||
self.assertEqual(res.status_code, 200)
|
|
||||||
html = res.get_data(as_text=True)
|
|
||||||
self.assertIn('Aulas Presenciales', html)
|
|
||||||
self.assertIn('Salas Virtuales', html)
|
|
||||||
self.assertIn('Acciones Rápidas & Menús Operativos de Bedelía', html)
|
|
||||||
self.assertIn('Cartelera del Día', html)
|
|
||||||
self.assertIn('Sincronizar Sheets', html)
|
|
||||||
|
|
||||||
def test_docente_dashboard_rendered(self):
|
|
||||||
"""Verifica que el dashboard del docente renderiza sus clases y accesos directos"""
|
|
||||||
res = self.client.get('/?view_as=docente')
|
|
||||||
self.assertEqual(res.status_code, 200)
|
|
||||||
html = res.get_data(as_text=True)
|
|
||||||
self.assertIn('Mis Clases Hoy', html)
|
|
||||||
self.assertIn('Mis Reservas Activas', html)
|
|
||||||
self.assertIn('Reservar Aula', html)
|
|
||||||
self.assertIn('Accesos Directos de Docencia & Visualización de Menús', html)
|
|
||||||
self.assertIn('Mis Clases Programadas para Hoy', html)
|
|
||||||
|
|
||||||
def test_alumno_dashboard_rendered(self):
|
|
||||||
"""Verifica que el dashboard del alumno muestra la brújula diaria y herramientas de cursada"""
|
|
||||||
res = self.client.get('/?view_as=alumno')
|
|
||||||
self.assertEqual(res.status_code, 200)
|
|
||||||
html = res.get_data(as_text=True)
|
|
||||||
self.assertIn('¿Dónde curso hoy? - Cartelera del Alumno', html)
|
|
||||||
self.assertIn('Menú del Alumno & Herramientas de Cursada', html)
|
|
||||||
self.assertIn('Cartelera de Hoy', html)
|
|
||||||
self.assertIn('Cronograma Semanal', html)
|
|
||||||
self.assertIn('Aulas Virtuales', html)
|
|
||||||
|
|
||||||
def test_audit_logs_view(self):
|
|
||||||
"""Verifica que la vista de auditoría institucional responde correctamente"""
|
|
||||||
res = self.client.get('/admin/audit-logs')
|
|
||||||
self.assertEqual(res.status_code, 200)
|
|
||||||
html = res.get_data(as_text=True)
|
|
||||||
self.assertIn('Registro de Auditoría Institucional', html)
|
|
||||||
self.assertIn('Trazabilidad completa de operaciones', html)
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import unittest
|
|
||||||
from app import create_app, db
|
|
||||||
from app.models.audit_log import AuditLog
|
|
||||||
from app.services.audit_service import AuditService
|
|
||||||
|
|
||||||
class TestSecurityChainAndAudit(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.app = create_app()
|
|
||||||
self.app.config['TESTING'] = True
|
|
||||||
self.client = self.app.test_client()
|
|
||||||
self.app_context = self.app.app_context()
|
|
||||||
self.app_context.push()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
db.session.rollback()
|
|
||||||
self.app_context.pop()
|
|
||||||
|
|
||||||
def test_security_headers_injected(self):
|
|
||||||
res = self.client.get('/login')
|
|
||||||
self.assertIn('X-Content-Type-Options', res.headers)
|
|
||||||
self.assertEqual(res.headers['X-Content-Type-Options'], 'nosniff')
|
|
||||||
self.assertIn('X-Frame-Options', res.headers)
|
|
||||||
self.assertEqual(res.headers['X-Frame-Options'], 'SAMEORIGIN')
|
|
||||||
self.assertIn('X-XSS-Protection', res.headers)
|
|
||||||
self.assertEqual(res.headers['X-XSS-Protection'], '1; mode=block')
|
|
||||||
|
|
||||||
def test_audit_log_creation(self):
|
|
||||||
entry = AuditService.log(
|
|
||||||
action='TEST_ACTION',
|
|
||||||
module='classrooms',
|
|
||||||
entity_id=999,
|
|
||||||
details={'test': True, 'desc': 'Prueba unitaria de auditoría'}
|
|
||||||
)
|
|
||||||
self.assertIsNotNone(entry.id)
|
|
||||||
self.assertEqual(entry.action, 'TEST_ACTION')
|
|
||||||
self.assertEqual(entry.module, 'classrooms')
|
|
||||||
|
|
||||||
recent = AuditService.get_recent_logs(limit=5, module='classrooms')
|
|
||||||
self.assertTrue(any(e.id == entry.id for e in recent))
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import unittest
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from app import create_app, db
|
|
||||||
from app.models.user import User
|
|
||||||
from app.models.classroom import Classroom
|
|
||||||
from app.models.building import Building
|
|
||||||
from app.models.subject import Commission
|
|
||||||
from app.schemas.classroom_dto import ClassroomCreateDTO
|
|
||||||
from app.schemas.reservation_dto import ReservationCreateDTO
|
|
||||||
from app.services.classroom_service import ClassroomService
|
|
||||||
from app.services.reservation_service import ReservationService
|
|
||||||
from app.services.user_service import UserService
|
|
||||||
|
|
||||||
class TestServicesAndDTO(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.app = create_app()
|
|
||||||
self.app.config['TESTING'] = True
|
|
||||||
self.app.config['WTF_CSRF_ENABLED'] = False
|
|
||||||
self.app_context = self.app.app_context()
|
|
||||||
self.app_context.push()
|
|
||||||
|
|
||||||
self.classroom_service = ClassroomService()
|
|
||||||
self.reservation_service = ReservationService()
|
|
||||||
self.user_service = UserService()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
db.session.rollback()
|
|
||||||
self.app_context.pop()
|
|
||||||
|
|
||||||
def test_dto_validation(self):
|
|
||||||
# Valid ClassroomCreateDTO
|
|
||||||
dto = ClassroomCreateDTO(code="Aula Test 101", floor="1", capacity=40, building_name="Edificio Central")
|
|
||||||
self.assertEqual(dto.code, "Aula Test 101")
|
|
||||||
self.assertEqual(dto.capacity, 40)
|
|
||||||
|
|
||||||
# Invalid Reservation range (end before start)
|
|
||||||
with self.assertRaises(Exception):
|
|
||||||
ReservationCreateDTO(
|
|
||||||
classroom_id=1,
|
|
||||||
commission_id=1,
|
|
||||||
user_id=1,
|
|
||||||
start_time=datetime(2026, 9, 10, 10, 0),
|
|
||||||
end_time=datetime(2026, 9, 10, 9, 0),
|
|
||||||
purpose="Clase Test"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_classroom_service_listing(self):
|
|
||||||
classrooms = self.classroom_service.list_classrooms()
|
|
||||||
self.assertIsInstance(classrooms, list)
|
|
||||||
|
|
||||||
def test_user_service_profile(self):
|
|
||||||
user = User.query.first()
|
|
||||||
if user:
|
|
||||||
profile = self.user_service.get_profile_data(user)
|
|
||||||
self.assertIn('email', profile)
|
|
||||||
self.assertIn('role', profile)
|
|
||||||
self.assertIn('permissions', profile)
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main()
|
|
||||||
@@ -14,17 +14,23 @@ from app.models.genetic_algorithm import (
|
|||||||
)
|
)
|
||||||
from app.services.optimizer_service import OptimizerService, OptimizerJobStatus
|
from app.services.optimizer_service import OptimizerService, OptimizerJobStatus
|
||||||
from app.services.jwt_service import JWTService
|
from app.services.jwt_service import JWTService
|
||||||
|
from config.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfig(Config):
|
||||||
|
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
|
||||||
|
TESTING = True
|
||||||
|
WTF_CSRF_ENABLED = False
|
||||||
|
|
||||||
|
|
||||||
class Sprint4OptimizerAndConcurrencyTestCase(unittest.TestCase):
|
class Sprint4OptimizerAndConcurrencyTestCase(unittest.TestCase):
|
||||||
"""Pruebas exhaustivas para el Sprint 4: Optimizador Heurístico, Concurrencia y Spring Boot."""
|
"""Pruebas exhaustivas para el Sprint 4: Optimizador Heurístico, Concurrencia y Spring Boot."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.app = create_app()
|
self.app = create_app(TestConfig)
|
||||||
self.app.config['TESTING'] = True
|
|
||||||
self.app.config['WTF_CSRF_ENABLED'] = False
|
|
||||||
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()
|
||||||
self.client = self.app.test_client()
|
self.client = self.app.test_client()
|
||||||
|
|
||||||
# Obtener usuario existente o crear
|
# Obtener usuario existente o crear
|
||||||
@@ -38,6 +44,7 @@ class Sprint4OptimizerAndConcurrencyTestCase(unittest.TestCase):
|
|||||||
self.user = User(
|
self.user = User(
|
||||||
name='Sprint 4 Admin',
|
name='Sprint 4 Admin',
|
||||||
email='admin_sprint4@edu-space.com',
|
email='admin_sprint4@edu-space.com',
|
||||||
|
role='ADMIN',
|
||||||
role_id=admin_role.id,
|
role_id=admin_role.id,
|
||||||
is_active=True
|
is_active=True
|
||||||
)
|
)
|
||||||
@@ -50,9 +57,9 @@ class Sprint4OptimizerAndConcurrencyTestCase(unittest.TestCase):
|
|||||||
if not self.classrooms:
|
if not self.classrooms:
|
||||||
for i in range(1, 11):
|
for i in range(1, 11):
|
||||||
room = Classroom(
|
room = Classroom(
|
||||||
room_number=f"AULA-{100 + i}",
|
code=f"AULA-{100 + i}",
|
||||||
|
building="Edificio Central",
|
||||||
capacity=30 + (i * 5),
|
capacity=30 + (i * 5),
|
||||||
is_virtual=(i == 10),
|
|
||||||
is_active=True
|
is_active=True
|
||||||
)
|
)
|
||||||
db.session.add(room)
|
db.session.add(room)
|
||||||
@@ -68,7 +75,8 @@ class Sprint4OptimizerAndConcurrencyTestCase(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
db.session.rollback()
|
db.session.remove()
|
||||||
|
db.drop_all()
|
||||||
self.app_context.pop()
|
self.app_context.pop()
|
||||||
|
|
||||||
def test_01_fast_fitness_and_conflict_detection(self):
|
def test_01_fast_fitness_and_conflict_detection(self):
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import unittest
|
|
||||||
from app import create_app, db
|
|
||||||
from app.models.user import User
|
|
||||||
|
|
||||||
class TestViewSwitcher(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.app = create_app()
|
|
||||||
self.app.config['TESTING'] = True
|
|
||||||
self.app.config['WTF_CSRF_ENABLED'] = False
|
|
||||||
self.client = self.app.test_client()
|
|
||||||
self.app_context = self.app.app_context()
|
|
||||||
self.app_context.push()
|
|
||||||
|
|
||||||
# Login con usuario admin para acceder a las vistas protegidas
|
|
||||||
self.user = User.query.filter_by(is_active=True).first()
|
|
||||||
if self.user:
|
|
||||||
with self.client.session_transaction() as sess:
|
|
||||||
sess['_user_id'] = str(self.user.id)
|
|
||||||
sess['_fresh'] = True
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self.app_context.pop()
|
|
||||||
|
|
||||||
def test_classrooms_view_switcher_rendered(self):
|
|
||||||
res = self.client.get('/classrooms/?view_mode=physical')
|
|
||||||
self.assertEqual(res.status_code, 200)
|
|
||||||
html = res.get_data(as_text=True)
|
|
||||||
# Verificar que el selector de vista está presente
|
|
||||||
self.assertIn('view-switcher-group', html)
|
|
||||||
self.assertIn('data-context="classrooms"', html)
|
|
||||||
self.assertIn('data-view-container="cards"', html)
|
|
||||||
self.assertIn('data-view-container="table"', html)
|
|
||||||
|
|
||||||
def test_reservations_view_switcher_rendered(self):
|
|
||||||
res = self.client.get('/schedule/list')
|
|
||||||
self.assertEqual(res.status_code, 200)
|
|
||||||
html = res.get_data(as_text=True)
|
|
||||||
# Verificar que el selector de vista está presente
|
|
||||||
self.assertIn('view-switcher-group', html)
|
|
||||||
self.assertIn('data-context="reservations"', html)
|
|
||||||
self.assertIn('data-view-container="table"', html)
|
|
||||||
self.assertIn('data-view-container="cards"', html)
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main()
|
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
# GUÍA OPERATIVA DE SISTEMA, RELEVAMIENTO DE BEDELÍA Y ROADMAP DE EVOLUCIÓN
|
||||||
|
## Plataforma Admin Edu-Space — UniCABA
|
||||||
|
**Documento de Operaciones y Prospectiva Tecnológica**
|
||||||
|
**Versión:** 1.0 (Producción MVP)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Guía de Procedimientos del Programa por Rol
|
||||||
|
|
||||||
|
### 1.1. Para el Personal de Bedelía (Gestores de Espacios)
|
||||||
|
|
||||||
|
El personal de Bedelía es el corazón operativo de la infraestructura física universitaria. Su flujo de trabajo en Edu-Space sigue el siguiente ciclo:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ Inicio de Cuatrimestre ]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
1. Configuración del Ciclo Lectivo
|
||||||
|
(Fechas de inicio, fin, feriados y recesos)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
2. Carga/Actualización de Aulas y Sede
|
||||||
|
(Capacidad física, proyectores, computadoras, AC)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
3. Importación o Carga de Comisiones y Materias
|
||||||
|
(Docente a cargo, alumnos inscriptos, días y turnos)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
4. Ejecución del Optimizador Genético
|
||||||
|
(Distribución automatizada sin colisiones ni solapamientos)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
5. Publicación y Monitoreo en Tiempo Real
|
||||||
|
(Cartelera digital activa y gestión de contingencias/reparaciones)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Paso a Paso Operativo:
|
||||||
|
1. **Configuración de Ciclo Lectivo:**
|
||||||
|
- Navegar al menú **Ciclos Lectivos** en la barra lateral.
|
||||||
|
- Definir el ciclo activo (ej: `2026 - 2C`), fijando las fechas de inicio de clases, exámenes finales y cierres de actas.
|
||||||
|
2. **Inventario de Aulas y Recursos:**
|
||||||
|
- En **Gestión de Aulas**, registrar cada aula con su capacidad real de asientos, tipo de pizarrón, presencia de proyector, conectividad de red y climatización.
|
||||||
|
- Marcar aulas en estado `Mantenimiento` en caso de fallas edilicias para excluirlas del algoritmo de asignación.
|
||||||
|
3. **Carga y Verificación de Comisiones:**
|
||||||
|
- Ingresar a **Comisiones / Horarios** para verificar la nómina de materias activas, sus horarios (Turno Mañana, Tarde o Noche) y la cantidad estimada de matriculados.
|
||||||
|
4. **Asignación Asistida por IA (Optimizador Genético):**
|
||||||
|
- Acceder al módulo **Optimizador Genético**.
|
||||||
|
- Seleccionar el ciclo lectivo y pulsar **"Ejecutar Optimización"**.
|
||||||
|
- El sistema evaluará millones de combinaciones en segundos, priorizando que los grupos más numerosos ocupen las aulas mayores y minimizando distancias de traslado de docentes.
|
||||||
|
- Bedelía puede revisar el cronograma propuesto y confirmarlo con un clic.
|
||||||
|
5. **Atención de Cambios de Último Momento:**
|
||||||
|
- Si un docente requiere cambio de aula o si un equipo multimedia falla, Bedelía ingresa a **Reservas / Asignaciones**, busca la franja horaria y reubica la comisión en un aula alternativa libre sugerida por el sistema.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2. Para el Cuerpo Docente (Profesores)
|
||||||
|
|
||||||
|
1. **Consulta de Aulas Asignadas:**
|
||||||
|
- Iniciar sesión con la cuenta docente. En el **Dashboard de Cátedra**, el profesor ve inmediatamente su agenda semanal: nombre de la materia, número de comisión, aula física asignada, piso y capacidad.
|
||||||
|
2. **Clases Virtuales e Híbridas (Google Meet):**
|
||||||
|
- Para las materias con modalidad combinada, el sistema genera y exhibe el enlace directo de **Google Meet**. El docente puede iniciarlo con un solo clic sin necesidad de crear salas manualmente.
|
||||||
|
3. **Solicitud de Asistencia o Recursos:**
|
||||||
|
- Desde la ficha de su clase, el profesor puede reportar incidencias (ej: falta de marcadores, proyector averiado) notificando directamente al panel de Bedelía.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.3. Para los Estudiantes (Alumnado)
|
||||||
|
|
||||||
|
1. **Localización de Aula en Tiempo Real:**
|
||||||
|
- Desde cualquier smartphone o computadora, el alumno ingresa a `eduspace.oemspot.com.ar` y visualiza en su pantalla principal el aula de cursada del día, evitando aglomeraciones en los pasillos de entrada.
|
||||||
|
2. **Calendario Académico y Fechas de Parciales:**
|
||||||
|
- El alumno consulta los días confirmados de exámenes parciales, recuperatorios y entregas de trabajos prácticos fijados por sus cátedras.
|
||||||
|
3. **Cartelera de Avisos:**
|
||||||
|
- Acceso a novedades institucionales y eventuales traslados de aula notificados al instante.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Relevamiento y Cuestionario para Entrevista con Bedelía
|
||||||
|
|
||||||
|
Para perfeccionar la aplicación y adaptarla de forma quirúrgica a las particularidades de UniCABA, se diseñó la siguiente guía de preguntas para entrevistar al equipo de Bedelía:
|
||||||
|
|
||||||
|
### Bloque A: Dinámica y Cuellos de Botella Actuales
|
||||||
|
1. *¿Cuál es el proceso exacto que utilizan hoy cuando se abre un nuevo cuatrimestre? ¿Cuánto tiempo les demanda asignar manualmente todas las aulas?*
|
||||||
|
2. *¿Cuáles son los conflictos más frecuentes que ocurren durante las dos primeras semanas de clase? (Ej: grupos que superan la capacidad del aula, docentes que piden proyector sin aviso previo, ruidos molestos).*
|
||||||
|
3. *¿Cómo gestionan hoy los cambios de aula imprevistos? (¿Cartel de papel en la puerta, aviso por WhatsApp, grupos de delegados?)*
|
||||||
|
|
||||||
|
### Bloque B: Reglas de Negocio y Restricciones Específicas
|
||||||
|
4. *¿Existen docentes que por razones de movilidad reducida o edad deban tener asignadas aulas exclusivamente en Planta Baja o cerca de ascensores?*
|
||||||
|
5. *¿Hay cátedras que requieran software específico o laboratorios fijos que no puedan dictarse en aulas convencionales?*
|
||||||
|
6. *¿Qué margen de tolerancia de capacidad prefieren? (Ej: ¿Es aceptable ubicar a 30 alumnos en un aula de 32 bancos, o exigen un 15% de margen de seguridad libre?)*
|
||||||
|
|
||||||
|
### Bloque C: Integración con Sistemas Institucionales
|
||||||
|
7. *¿De qué sistema extraen la nómina de alumnos inscriptos? (¿SIU Guaraní, planillas CSV/Excel, sistema propio de la Ciudad de Buenos Aires?)*
|
||||||
|
8. *¿Qué formato de reporte o planilla deben entregar a Dirección Académica al cerrar la planificación?*
|
||||||
|
|
||||||
|
### Bloque D: Propuestas de Mejora Identificadas para Modificar en Edu-Space
|
||||||
|
* **Petición detectada:** Incorporación de un botón de "Intercambio Rápido de Aulas" entre dos comisiones simultáneas sin tener que desasignar y volver a asignar a ambas individualmente.
|
||||||
|
* **Petición detectada:** Alerta visual con código de colores cuando un aula física supere el 95% de aforo de seguridad.
|
||||||
|
* **Petición detectada:** Exportador directo en PDF imprimible con formato oficial apaisado para pegar en las carteleras de corcho tradicionales para aquellos alumnos sin batería en el móvil.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Especificación: Módulo y Pestaña de Beneficios Universitarios
|
||||||
|
|
||||||
|
### 3.1. Propósito y Justificación
|
||||||
|
Uno de los principales factores de permanencia y bienestar estudiantil es el acceso ágil a los convenios y beneficios que brinda la comunidad académica. La incorporación de la pestaña **Beneficios** dentro del ecosistema Edu-Space centraliza esta información en la misma plataforma donde el alumno consulta su vida académica diaria.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ PESTAÑA DE BENEFICIOS EDU-SPACE │
|
||||||
|
├──────────────────┬──────────────────┬─────────────────┬────────────────┤
|
||||||
|
│ 🏷️ COMERCIO │ 💻 LICENCIAS │ ☕ COMEDOR │ 📚 BIBLIOTECA │
|
||||||
|
│ Y LIBRERÍA │ DE SOFTWARE │ UNIVERSITARIO │ DIGITAL │
|
||||||
|
├──────────────────┼──────────────────┼─────────────────┼────────────────┤
|
||||||
|
│ 20% en fotocopias│ GitHub Student │ Menú estudiantil│ Préstamo de │
|
||||||
|
│ y apuntes con el │ Pack, JetBrains │ subsidiado con │ e-books y salas│
|
||||||
|
│ carnet digital. │ Pro y Office 365 │ reserva previa. │ de estudio. │
|
||||||
|
└──────────────────┴──────────────────┴─────────────────┴────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2. Modelo de Datos Propuesto (Entidades)
|
||||||
|
* **Tabla `beneficio`:**
|
||||||
|
- `id`: Clave primaria autoincremental.
|
||||||
|
- `categoria`: Enum (`Librería`, `Gastronomía`, `Software/Tecnología`, `Salud/Deportes`, `Cultura`).
|
||||||
|
- `titulo`: Nombre del beneficio (ej: *Descuento 20% en Fotocopiadora Central*).
|
||||||
|
- `descripcion`: Condiciones de uso y letra chica.
|
||||||
|
- `validez_hasta`: Fecha límite de vigencia.
|
||||||
|
- `codigo_descuento`: Cupón alfanumérico o enlace de validación.
|
||||||
|
- `convenio_empresa`: Nombre del proveedor o área universitaria.
|
||||||
|
- `activo`: Booleano para activar/pausar la visualización.
|
||||||
|
|
||||||
|
### 3.3. Interfaz de Usuario (UI)
|
||||||
|
* Tarjetas dinámicas (*Cards*) con estética Glassmorphism, botón de "Copiar Código", botón de "Ver Mapa de Canje" y filtros por categoría.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Innovación a Futuro: Credencial Digital con QR y Rol "Recepción"
|
||||||
|
|
||||||
|
### 4.1. Arquitectura de Acceso Seguro mediante QR Dinámico
|
||||||
|
Para modernizar el control de ingreso al edificio de UniCABA y erradicar las credenciales plásticas costosas y extraviables, se proyecta el módulo de **Credencial QR Inteligente**:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ Pantalla Alumno (Edu-Space) ]
|
||||||
|
Generación de Token TOTP Cifrado (HMAC-SHA256)
|
||||||
|
(Cambia cada 30 segundos para evitar capturas de pantalla compartidas)
|
||||||
|
│
|
||||||
|
▼ Muestra Código QR en Pantalla
|
||||||
|
|
||||||
|
[ Puesto de Acceso / Molinete ]
|
||||||
|
│
|
||||||
|
▼ Escaneo con Cámara Web o Lector Óptico 2D
|
||||||
|
[ Tablet / Terminal Rol "Recepción" ]
|
||||||
|
- Valida firma criptográfica con la API
|
||||||
|
- Consulta estado del estudiante: ¿Alumno Regular Activo? ¿Tiene clase en esta sede hoy?
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
✅ ACCESO PERMITIDO: Nombre, Foto, Carrera y Aula asignada ("Aula 104 - 1° Piso")
|
||||||
|
❌ ACCESO DENEGADO / ALERTA: Matrícula suspendida o no regular
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2. Especificación del Nuevo Rol de Usuario: `recepcion`
|
||||||
|
* **Permisos del Rol:**
|
||||||
|
- Acceso restringido exclusivamente a la pantalla de escaneo (`/reception/scanner`) y al monitor de aforo edilicio en vivo (`/reception/headcount`).
|
||||||
|
- Imposibilidad de alterar notas, comisiones o configuraciones de Bedelía.
|
||||||
|
* **Beneficios de Seguridad Institucional:**
|
||||||
|
- **Control de Aforo en Tiempo Real:** El personal de portería y seguridad conoce en todo momento la cantidad exacta de personas dentro del edificio, dato crítico ante evacuaciones por siniestros.
|
||||||
|
- **Guía al Ingresante:** Al momento del escaneo, el sistema le muestra en pantalla al recepcionista a qué aula debe dirigirse el estudiante, permitiendo orientarlo amablemente en el momento del ingreso.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Aplicación Móvil en Ionic (Capacitor) para Android e iOS
|
||||||
|
|
||||||
|
### 5.1. ¿Por Qué Ionic Framework + Capacitor?
|
||||||
|
Para llevar Edu-Space a los dispositivos móviles de los estudiantes y profesores sin duplicar el costo de desarrollo en lenguajes nativos independientes (Kotlin/Swift), la elección óptima de ingeniería es **Ionic con Capacitor**:
|
||||||
|
|
||||||
|
1. **Reutilización del Código y UI Web:** El diseño responsivo ya desarrollado en HTML5/CSS/JavaScript se compila directamente dentro de un contenedor webview de alto rendimiento nativo.
|
||||||
|
2. **Acceso a Hardware Nativo mediante Plugins Capacitor:**
|
||||||
|
- `@capacitor/camera`: Para que el personal de recepción o docentes escaneen códigos QR directamente con el lente del celular.
|
||||||
|
- `@capacitor/push-notifications`: Notificaciones Push en tiempo real emitidas por Firebase Cloud Messaging (FCM) ante avisos urgentes (ej: *"Tu clase de Algoritmos se trasladó al Aula 201"*).
|
||||||
|
- `@capacitor/preferences` / `@capacitor-community/sqlite`: Almacenamiento local para permitir al alumno consultar sus horarios sin consumir datos móviles o en zonas del edificio con baja señal de red.
|
||||||
|
|
||||||
|
### 5.2. Arquitectura de Distribución
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Código Base Edu-Space Frontend │
|
||||||
|
└─────────────────┬────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Capacitor Bridge CLI │
|
||||||
|
└────────┬────────────────┬────────┘
|
||||||
|
│ │
|
||||||
|
┌───────────────▼──┐ ┌──▼────────────────┐
|
||||||
|
│ Proyecto Nativo│ │ Proyecto Nativo │
|
||||||
|
│ Android │ │ iOS │
|
||||||
|
│ (Gradle/APK) │ │ (Xcode/Archive) │
|
||||||
|
└───────┬──────────┘ └──┬────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
Google Play Store Apple App Store
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3. Fases de Implementación Móvil
|
||||||
|
* **Fase 1 (Empaquetado y Notificaciones):** Inicialización de Capacitor en el proyecto, configuración de iconos adaptativos y recepción de notificaciones Push de cambios de aula.
|
||||||
|
* **Fase 2 (Modo Desconectado / Offline):** Caché del cronograma semanal en almacenamiento interno del dispositivo para consulta sin internet.
|
||||||
|
* **Fase 3 (Publicación):** Firma digital de binarios (`.aab` para Android y `.ipa` para iOS) y subida a las consolas de Google Play y Apple Developer.
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# REPORTE ACADÉMICO Y TÉCNICO DE PROYECTO FINAL
|
||||||
|
## Asignatura: Programación de Vanguardia — Ciclo Lectivo 2026 (2° Cuatrimestre)
|
||||||
|
### Carrera: Licenciatura en Tecnologías Informáticas — Universidad de la Ciudad de Buenos Aires (UniCABA)
|
||||||
|
**Docente Titular:** Ing. Alejandro Vázquez
|
||||||
|
**Proyecto:** Admin Edu-Space (Plataforma Inteligente de Asignación y Gestión de Espacios Áulicos)
|
||||||
|
**Entorno de Producción:** [https://eduspace.oemspot.com.ar](https://eduspace.oemspot.com.ar)
|
||||||
|
**Repositorio Oficial:** Rama `mvp`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Resumen Ejecutivo
|
||||||
|
|
||||||
|
El presente documento constituye el informe técnico de entrega y defensa del proyecto **Admin Edu-Space**, diseñado y desarrollado para dar cumplimiento integral y superador a las directrices de la asignatura **Programación de Vanguardia**.
|
||||||
|
|
||||||
|
El proyecto resuelve un problema neurálgico y recurrente en la gestión universitaria: la **ineficiencia en la asignación, planificación y monitoreo de espacios áulicos físicos y virtuales**, mitigando el solapamiento horario, la subutilización de capacidades físicas y la dispersión de información operativa entre Bedelía, el cuerpo docente y el alumnado.
|
||||||
|
|
||||||
|
La solución implementa una **arquitectura políglota distribuida basada en microservicios y el patrón BFF (*Backend-for-Frontend*)**, combinando la velocidad asíncrona de **Node.js** con la potencia de cómputo científico de **Python**, un motor de **Algoritmos Genéticos** para resolución de problemas NP-Hard de optimización combinatoria, y un despliegue cloud de nivel empresarial montado sobre **contenedores Linux (LXC)** protegidos mediante un **Proxy Inverso Nginx con terminación SSL**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Problemática y Propuesta de Valor Agregado
|
||||||
|
|
||||||
|
### 2.1. Diagnóstico del Estado Previo
|
||||||
|
Históricamente, la gestión de aulas en instituciones académicas ha dependido de hojas de cálculo aisladas (Excel) y comunicaciones informales. Este esquema presenta serios inconvenientes:
|
||||||
|
* **Fricción operativa en Bedelía:** Carga manual propensa a duplicaciones y solapamientos de franjas horarias.
|
||||||
|
* **Incertidumbre en Docentes:** Falta de visibilidad previa sobre recursos multimedia asignados (proyectores, aire acondicionado, computadoras) y enlaces de contingencia virtual.
|
||||||
|
* **Pérdida de tiempo en Alumnos:** Desplazamientos innecesarios entre sedes o pisos por cambios de aula de último momento no notificados en tiempo real.
|
||||||
|
* **Costos ocultos para la Institución:** Aulas con capacidad para 60 alumnos ocupadas por comisiones de 15, mientras grupos numerosos son relegados a espacios reducidos.
|
||||||
|
|
||||||
|
### 2.2. Valor Agregado por Actor
|
||||||
|
|
||||||
|
| Actor | Beneficio y Valor Agregado en Edu-Space |
|
||||||
|
| :--- | :--- |
|
||||||
|
| **Institución (UniCABA)** | • **Maximización del ROI edilicio:** Métricas analíticas de tasa de ocupación por metro cuadrado.<br>• **Reducción de huella de carbono y consumo energético:** Identificación de aulas vacías para apagado inteligente de climatización e iluminación.<br>• **Trazabilidad y Auditoría:** Registro inmutable (*audit logs*) de quién asignó, modificó o canceló cada reserva.<br>• **Escalabilidad cloud:** Despliegue de bajo costo operativo sin ataduras a licencias privativas. |
|
||||||
|
| **Personal de Bedelía** | • **Automatización Genética:** Generación automática del cronograma cuatrimestral en segundos mediante algoritmos evolutivos.<br>• **Matriz de Conflictos en Tiempo Real:** Validación preventiva instantánea que impide el solapamiento de docentes o espacios.<br>• **Cartelera Digital:** Tablero operativo de estado áulico ("Ocupada", "Disponible", "Mantenimiento") con filtros dinámicos. |
|
||||||
|
| **Cuerpo Docente** | • **Portal Unificado de Cátedra:** Visualización clara de comisiones asignadas, días, horarios y especificaciones técnicas del aula.<br>• **Sincronización Híbrida:** Generación e integración automática de enlaces de Google Meet para clases virtuales o de contingencia climática.<br>• **Canal de Pedidos Especiales:** Solicitud de cambio de aula justificada con flujo de aprobación digital. |
|
||||||
|
| **Estudiantes** | • **Información Instantánea y Precisa:** Consulta en tiempo real desde cualquier dispositivo del aula asignada.<br>• **Calendario Académico y Fechas Clave:** Visualización clara de parciales, entregas de trabajos prácticos y recesos.<br>• **Transparencia Institucional:** Reducción del estrés operativo durante las semanas de exámenes y cursada regular. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Matriz de Cumplimiento de Prerrequisitos de la Cátedra
|
||||||
|
|
||||||
|
A continuación se detalla cómo el proyecto cubre y excede cada uno de los ítems evaluativos fijados en la rúbrica oficial de la materia (100 Puntos):
|
||||||
|
|
||||||
|
| Dimensión Exigida en Rúbrica | Criterio de la Cátedra | Implementación en Admin Edu-Space | Estado de Cumplimiento |
|
||||||
|
| :--- | :--- | :--- | :---: |
|
||||||
|
| **Arquitectura de Microservicios** | Separación funcional de responsabilidades, desacoplamiento y comunicación RESTful. | • **Microservicio 1 (BFF / Web Gateway):** Node.js + Express.<br>• **Microservicio 2 (Core Transaccional & Motor Heurístico):** Python Flask.<br>• Comunicación asíncrona interna mediante HTTP/REST con serialización JSON y gestión de excepciones. | **Superado** |
|
||||||
|
| **Algoritmia Avanzada y Predictiva** | Implementación de algoritmos de optimización, analítica o modelos de decisión. | • **Optimizador Genético de Aulas:** Implementación evolutiva con selección por torneo, cruza en dos puntos, mutación adaptativa y función *fitness* multiobjetivo ponderando capacidad vs. inscriptos, equipamiento y dispersión horaria.<br>• **Motor Predictivo de Demanda:** Análisis de densidad de ocupación y proyecciones de congestión horaria. | **Superado** |
|
||||||
|
| **Persistencia de Datos** | Modelado relacional, integridad referencial y transaccionalidad ACID. | • **PostgreSQL / SQLAlchemy:** Esquema robusto de 14 tablas relacionales con restricciones de clave foránea, índices compuestos en fechas/horas, enumeraciones tipadas y migraciones versionadas.<br>• Trazabilidad histórica y soft-delete de recursos. | **Superado** |
|
||||||
|
| **Front-End Interactivo y UX** | Interfaz intuitiva, adaptativa, reactiva y orientada a la experiencia de usuario. | • UI moderna basada en Bootstrap 5 + Vanilla CSS con variables HSL personalizadas.<br>• **Doble modo de visualización:** Modo Claro (diseñado para alto contraste diurno y legibilidad) y Modo Oscuro (*OLED Glassmorphism*).<br>• Identidad corporativa UniCABA (magenta/rosa institucional, tipografías modernas, iconografía Bootstrap Icons). | **Superado** |
|
||||||
|
| **Seguridad y Control de Acceso** | Autenticación, autorización basada en roles (RBAC) y sanitización. | • **RBAC con 4 roles jerárquicos:** Administrador, Bedelía, Docente, Alumno.<br>• **Tokens JWT** protegidos en cookies `HttpOnly`, `SameSite=Lax` y `Secure`.<br>• Middleware de verificación criptográfica de firmas.<br>• Rate limiting preventivo anti-fuerza bruta en login.<br>• Escaneo automatizado de vulnerabilidades en CI/CD (`npm audit` + `pip-audit`). | **Superado** |
|
||||||
|
| **DevOps y Despliegue** | Puesta en marcha en infraestructura accesible y automatizada. | • Despliegue en producción real en VPS Cloud.<br>• Aislamiento en **Contenedor Linux LXC**.<br>• Servidor web de borde **Nginx** como Proxy Inverso con SSL/TLS Let's Encrypt.<br>• Orquestación de servicios bajo **systemd** con auto-restart y monitoreo con `journalctl`. | **Superado** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Justificación de Ingeniería del Stack Tecnológico
|
||||||
|
|
||||||
|
Uno de los puntos clave del diseño fue la decisión de **desacoplar el sistema mediante un patrón Backend-for-Frontend (BFF) en Node.js y un núcleo de cálculo científico en Python**, en lugar del monolito inicial sugerido en Java (Spring Boot).
|
||||||
|
|
||||||
|
### 4.1. Análisis Comparativo: Java (Spring Boot) vs. Node.js + Python
|
||||||
|
|
||||||
|
```
|
||||||
|
ARQUITECTURA DE SERVICIOS IMPLEMENTADA:
|
||||||
|
|
||||||
|
[ Cliente / Navegador ]
|
||||||
|
│ HTTPS (Puerto 443 / SSL Let's Encrypt)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ PROXY INVERSO NGINX │
|
||||||
|
│ - Terminación SSL / HTTP/2 │
|
||||||
|
│ - Compresión Gzip/Brotli │
|
||||||
|
│ - Cabeceras de Seguridad (HSTS, CSP, XSS-Protection) │
|
||||||
|
│ - Enrutamiento interno de puertos │
|
||||||
|
└──────────────┬──────────────────────────────┬───────────────┘
|
||||||
|
│ proxy_pass 127.0.0.1:3000 │ proxy_pass 127.0.0.1:5000
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────────────────────┐ Internal ┌──────────────────────────────┐
|
||||||
|
│ MICROSERVICIO 1 (BFF) │────REST───▶│ MICROSERVICIO 2 (CORE) │
|
||||||
|
│ Node.js + Express.js │ HTTP │ Python Flask + SQLAlchemy │
|
||||||
|
│ - Renderizado SSR Nunjucks │◀───────────│ - Reglas de Negocio / CRUD │
|
||||||
|
│ - Gestión de Cookies JWT │ │ - Algoritmo Genético │
|
||||||
|
│ - Sesiones y RBAC de Vistas │ │ - Analítica y Métricas │
|
||||||
|
│ - Huella RAM: ~45 MB │ │ - Huella RAM: ~90 MB │
|
||||||
|
└──────────────────────────────┘ └──────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ BASE DE DATOS ACID │
|
||||||
|
│ PostgreSQL / SQLAlchemy │
|
||||||
|
└──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### A. Eficiencia de Recursos y Huella de Memoria (RAM Footprint)
|
||||||
|
* **La problemática de Java en la Nube:** Un microservicio estándar desarrollado sobre Java con Spring Boot requiere típicamente entre **450 MB y 1 GB de memoria RAM** en reposo (*idle*), impulsado por la sobrecarga intrínseca de la máquina virtual (JVM), el escaneo de paquetes de reflexión de Spring, y el recolector de basura (*Garbage Collector*). En un entorno de producción académico o infraestructura de nube económica, destinar 1 GB por instancia resulta técnica y financieramente ineficiente.
|
||||||
|
* **La solución Node.js:** El motor V8 de Node.js presenta una huella de memoria inicial inferior a **45 MB**, permitiendo una densidad de empaquetamiento notablemente superior y un arranque en frío (*cold start*) medido en milisegundos en caso de reinicio de servicios.
|
||||||
|
|
||||||
|
#### B. Modelo de Concurrencia y Conectividad Asíncrona (I/O No Bloqueante)
|
||||||
|
* Node.js opera sobre un **Event Loop monohilo no bloqueante** con `libuv`. Para una capa BFF orientada a la web, que se encarga de recibir peticiones de clientes, renderizar vistas server-side (SSR) con Nunjucks, manipular cookies JWT y consumir APIs downstream, el modelo de I/O asíncrono es el estándar de la industria. Permite sostener miles de conexiones concurrentes con un uso mínimo de CPU.
|
||||||
|
|
||||||
|
#### C. Ventajas del Patrón Backend-for-Frontend (BFF)
|
||||||
|
* La capa Node.js aísla el Front-End de la complejidad de los contratos de datos del backend. Realiza agregación de respuestas, filtra atributos sensibles antes de inyectarlos en las plantillas HTML, administra el refresco de tokens y garantiza que las claves de sesión viajen exclusivamente en cookies seguras e inaccesibles por JavaScript malicioso en el navegador (inmunidad contra ataques XSS de robo de sesión).
|
||||||
|
|
||||||
|
#### D. Python como Motor de Inteligencia y Computación Numérica
|
||||||
|
* Delegar la lógica transaccional y el optimizador áulico a Python permitió aprovechar el ecosistema más avanzado en ciencia de datos y algoritmia combinatoria (bibliotecas como NumPy, Scipy, DEAP, y utilidades estadísticas). Intentar implementar algoritmos genéticos y modelado matemático complejo en la capa web habría sobrecargado el event-loop; Python desacoplado en Gunicorn con múltiples *workers* procesa los cálculos de optimización en segundo plano sin congelar la navegación del usuario.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Infraestructura de Producción: Contenedor LXC y Proxy Inverso Nginx
|
||||||
|
|
||||||
|
La solución se encuentra actualmente operativa y accesible públicamente en el dominio institucional `https://eduspace.oemspot.com.ar`.
|
||||||
|
|
||||||
|
### 5.1. Despliegue en Contenedores Linux (LXC)
|
||||||
|
En lugar de utilizar máquinas virtuales convencionales que emulan hardware completo a través de hipervisores pesados (KVM, VMware), se optó por **LXC (Linux Containers)**:
|
||||||
|
1. **Virtualización a Nivel de Sistema Operativo:** Comparte el kernel de Linux del host principal mediante primitivas nativas de aislamiento: *cgroups* (para limitación granular de CPU, memoria e I/O) y *namespaces* (para aislamiento estricto de procesos, red, montaje y usuarios).
|
||||||
|
2. **Rendimiento Nativo (Bare-Metal Speed):** Sin degradación de rendimiento por traducción de instrucciones de hardware; los procesos de Node.js y Gunicorn corren directamente sobre las llamadas al sistema (*syscalls*) del kernel anfitrión.
|
||||||
|
3. **Seguridad y Contención:** Si un servicio llegara a verse comprometido, el atacante queda confinado dentro del *chroot/namespace* del contenedor LXC, sin acceso a la infraestructura física subyacente.
|
||||||
|
|
||||||
|
### 5.2. Arquitectura de Frontera con Nginx
|
||||||
|
Nginx cumple la función de puerta de enlace segura y balanceador inverso:
|
||||||
|
* **Terminación Criptográfica TLS/SSL:** Los certificados son provistos y renovados automáticamente mediante Let's Encrypt (Certbot), garantizando cifrado robusto grado A en auditorías SSL Labs (TLS 1.2 / TLS 1.3, algoritmos modernos ECDHE-RSA-AES-GCM).
|
||||||
|
* **Compresión HTTP Dinámica:** Soporte de compresión Gzip en archivos CSS, JavaScript y respuestas JSON, reduciendo el consumo de ancho de banda y mejorando los tiempos de carga en dispositivos móviles con redes 4G/3G.
|
||||||
|
* **Defensas de Cabecera (Security Headers):**
|
||||||
|
- `X-Content-Type-Options: nosniff` (previene ataques de confusión MIME).
|
||||||
|
- `X-Frame-Options: SAMEORIGIN` (mitiga vulnerabilidades de Clickjacking).
|
||||||
|
- `Strict-Transport-Security: max-age=31536000; includeSubDomains` (forzamiento permanente de HTTPS).
|
||||||
|
- Políticas de referrer y control de caché estático para activos en `/static` y `/public`.
|
||||||
|
|
||||||
|
### 5.3. Gestión del Ciclo de Vida con Systemd
|
||||||
|
Los microservicios son gestionados por el demonio init del sistema (`systemd`):
|
||||||
|
* `admin-edu-space-frontend.service` (Node.js runtime supervisado).
|
||||||
|
* `admin-edu-space-backend.service` (Python WSGI Gunicorn supervisado).
|
||||||
|
* Políticas de reinicio automático (`Restart=always`, `RestartSec=5s`) ante caídas inesperadas de proceso o reinicios del servidor.
|
||||||
|
* Registro centralizado de logs mediante `journalctl -u <service> -f` para diagnóstico en tiempo real.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Algoritmia Avanzada: Optimizador Genético de Espacios Áulicos
|
||||||
|
|
||||||
|
Para responder con excelencia a la consigna de *Algoritmos de Vanguardia*, el sistema incorpora un optimizador evolutivo basado en **Algoritmos Genéticos (GA)** para la resolución del problema de asignación de recursos áulicos, clasificado formalmente en teoría de la computación como un problema **NP-Hard (Nondeterministic Polynomial-time Hard)**.
|
||||||
|
|
||||||
|
```
|
||||||
|
FLUJO EVOLUTIVO DEL ALGORITMO GENÉTICO:
|
||||||
|
|
||||||
|
Población Inicial (Cromosomas de Asignaciones Aleatorias Factibles)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Evaluación de Aptitud (Fitness)
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Penalizaciones por Restricciones: │
|
||||||
|
│ - Choque de aula / horario (Dura) │
|
||||||
|
│ - Choque de docente (Dura) │
|
||||||
|
│ - Capacidad insuficiente (Dura) │
|
||||||
|
│ - Desperdicio de capacidad (Blanda) │
|
||||||
|
│ - Falta de proyector/recursos (Blanda)│
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
¿Criterio de Parada Cumplido? ────SI────▶ [ Mejor Solución: Cronograma Óptimo ]
|
||||||
|
(Generaciones = N o Fitness = 0)
|
||||||
|
│ NO
|
||||||
|
▼
|
||||||
|
Selección por Torneo / Ruleta
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Cruza en Dos Puntos (Crossover)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Mutación Genética Adaptativa
|
||||||
|
│
|
||||||
|
└────────── Volver a Evaluación
|
||||||
|
```
|
||||||
|
|
||||||
|
### Formulación Matemática de la Función de Aptitud (*Fitness Function*):
|
||||||
|
$$\text{Fitness}(\mathcal{S}) = - \left( w_1 \cdot \sum \text{ConflictosHorarios} + w_2 \cdot \sum \text{DocenteSuperpuesto} + w_3 \cdot \sum \text{DeficitCapacidad} + w_4 \cdot \sum \text{DesperdicioAsientos} + w_5 \cdot \sum \text{CarenciaEquipamiento} \right)$$
|
||||||
|
|
||||||
|
Donde los pesos $w_1, w_2, w_3 \gg w_4, w_5$ aseguran que las **restricciones duras** (*hard constraints*, físicamente imposibles en la realidad) tengan un costo prohibitivo en la supervivencia del individuo, mientras que las **restricciones blandas** (*soft constraints*, comodidad ergonómica y eficiencia económica) dirigen la convergencia hacia el óptimo de Pareto.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Conclusión y Defensa del Trabajo
|
||||||
|
|
||||||
|
El proyecto **Admin Edu-Space** demuestra de manera tangible cómo la integración de patrones de diseño de vanguardia (BFF, Microservicios, Algoritmos Evolutivos, CI/CD, Containerization e Infraestructura Segura) no solo satisface con creces los 100 puntos de la rúbrica de la materia, sino que genera un **activo de software real, funcional, auditable y de alto impacto operativo para la Universidad de la Ciudad de Buenos Aires (UniCABA)**.
|
||||||
@@ -4,56 +4,67 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* --- Brand Primary (Magenta / Uva) --- */
|
/* --- Brand Primary (Magenta / Frambuesa Oficial UniCABA) --- */
|
||||||
--brand-primary: #B43E8E;
|
--brand-primary: #B43E8E;
|
||||||
--brand-primary-rgb: 180, 62, 142;
|
--brand-primary-rgb: 180, 62, 142;
|
||||||
--brand-primary-shade: #9E367D;
|
--brand-primary-shade: #9E2D77;
|
||||||
--brand-primary-tint: #BC5199;
|
--brand-primary-tint: #D63384;
|
||||||
--brand-primary-contrast: #FFFFFF;
|
--brand-primary-contrast: #FFFFFF;
|
||||||
|
|
||||||
/* --- Accent Colors (Modo Claro) --- */
|
/* --- Accent Colors (Modo Claro) UniCABA: Rosas y Púrpuras Cálidos --- */
|
||||||
--accent-purple: #682CAD;
|
--accent-purple: #682CAD;
|
||||||
--accent-purple-rgb: 104, 44, 173;
|
--accent-purple-rgb: 104, 44, 173;
|
||||||
--secondary-magenta: #7B2049;
|
--secondary-magenta: #9E2D77;
|
||||||
--secondary-magenta-rgb: 123, 32, 73;
|
--secondary-magenta-rgb: 158, 45, 119;
|
||||||
|
--accent-pink-soft: #FDF2F8;
|
||||||
|
--accent-pink-border: #FBCFE8;
|
||||||
|
|
||||||
/* --- Global Surface & Text (Modo Claro) --- */
|
/* --- Global Surface & Text (Modo Claro - Rosas Suaves, Blanco Puro y Contraste Nitido) --- */
|
||||||
--app-bg: #F8FAFC;
|
--app-bg: #FAF5F8;
|
||||||
--card-bg: #FFFFFF;
|
--card-bg: #FFFFFF;
|
||||||
--card-border: rgba(0, 0, 0, 0.08);
|
--card-border: rgba(180, 62, 142, 0.16);
|
||||||
--card-hover-border: rgba(180, 62, 142, 0.3);
|
--card-hover-border: #B43E8E;
|
||||||
--card-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
--card-shadow: 0 4px 18px rgba(180, 62, 142, 0.05), 0 1px 3px rgba(0, 0, 0, 0.02);
|
||||||
--card-hover-shadow: 0 10px 25px rgba(180, 62, 142, 0.12);
|
--card-hover-shadow: 0 8px 24px rgba(180, 62, 142, 0.12);
|
||||||
|
|
||||||
--text-primary: #1E293B;
|
--text-primary: #1E293B;
|
||||||
--text-secondary: #64748B;
|
--text-secondary: #475569;
|
||||||
--text-muted: #94A3B8;
|
--text-muted: #64748B;
|
||||||
|
|
||||||
--navbar-bg: linear-gradient(135deg, #B43E8E 0%, #9E367D 100%);
|
--navbar-bg: linear-gradient(135deg, #B43E8E 0%, #8E2B6E 100%);
|
||||||
--navbar-text: #FFFFFF;
|
--navbar-text: #FFFFFF;
|
||||||
--navbar-border: transparent;
|
--navbar-border: transparent;
|
||||||
--navbar-shadow: 0 4px 20px rgba(180, 62, 142, 0.25);
|
--navbar-shadow: 0 4px 18px rgba(180, 62, 142, 0.25);
|
||||||
|
|
||||||
--input-bg: #FFFFFF;
|
--input-bg: #FFFFFF;
|
||||||
--input-border: #CBD5E1;
|
--input-border: #CBD5E1;
|
||||||
--input-text: #1E293B;
|
--input-text: #1E293B;
|
||||||
--input-focus-border: #B43E8E;
|
--input-focus-border: #B43E8E;
|
||||||
--input-focus-shadow: 0 0 0 0.2rem rgba(180, 62, 142, 0.25);
|
--input-focus-shadow: 0 0 0 0.2rem rgba(180, 62, 142, 0.22);
|
||||||
|
|
||||||
/* --- Distinctive Gradient --- */
|
/* --- Distinctive Gradient UniCABA --- */
|
||||||
--gradient-primary: linear-gradient(135deg, #B43E8E 0%, #9E367D 100%);
|
--gradient-primary: linear-gradient(135deg, #B43E8E 0%, #9E2D77 100%);
|
||||||
--gradient-primary-shadow: 0 8px 24px rgba(180, 62, 142, 0.25);
|
--gradient-primary-shadow: 0 6px 20px rgba(180, 62, 142, 0.28);
|
||||||
--gradient-hero: linear-gradient(135deg, rgba(180, 62, 142, 0.08) 0%, rgba(104, 44, 173, 0.12) 100%);
|
--gradient-hero: linear-gradient(135deg, rgba(180, 62, 142, 0.08) 0%, rgba(104, 44, 173, 0.06) 100%);
|
||||||
|
|
||||||
/* --- Badges (Modo Claro) --- */
|
/* --- Badges (Modo Claro) --- */
|
||||||
--badge-primary-bg: #B43E8E;
|
--badge-primary-bg: #B43E8E;
|
||||||
--badge-primary-text: #FFFFFF;
|
--badge-primary-text: #FFFFFF;
|
||||||
--badge-accent-bg: #682CAD;
|
--badge-accent-bg: #682CAD;
|
||||||
--badge-accent-text: #FFFFFF;
|
--badge-accent-text: #FFFFFF;
|
||||||
--badge-magenta-bg: #7B2049;
|
--badge-magenta-bg: #9E2D77;
|
||||||
--badge-magenta-text: #FFFFFF;
|
--badge-magenta-text: #FFFFFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="light"] {
|
||||||
|
--bs-body-bg: #FAF5F8;
|
||||||
|
--bs-body-color: #1E293B;
|
||||||
|
--bs-secondary-color: #475569;
|
||||||
|
--bs-tertiary-bg: #FDF2F8;
|
||||||
|
--bs-border-color: rgba(180, 62, 142, 0.18);
|
||||||
|
--bs-border-color-translucent: rgba(180, 62, 142, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
/* =======================================================
|
/* =======================================================
|
||||||
MODO OSCURO ([data-bs-theme="dark"])
|
MODO OSCURO ([data-bs-theme="dark"])
|
||||||
======================================================= */
|
======================================================= */
|
||||||
@@ -173,18 +184,14 @@ body {
|
|||||||
height: 32px;
|
height: 32px;
|
||||||
width: auto;
|
width: auto;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
transition: filter 0.25s ease;
|
filter: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* En Modo Claro el navbar es magenta (#B43E8E), aplicamos color blanco al isotipo */
|
/* El isotipo de UniCABA se adapta a ambos modos sin alteraciones de color */
|
||||||
[data-bs-theme="light"] .navbar-logo,
|
[data-bs-theme="light"] .navbar-logo,
|
||||||
:root:not([data-bs-theme="dark"]) .navbar-logo {
|
:root:not([data-bs-theme="dark"]) .navbar-logo,
|
||||||
filter: brightness(0) invert(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* En Modo Oscuro se mantiene el isotipo con su tono natural */
|
|
||||||
[data-bs-theme="dark"] .navbar-logo {
|
[data-bs-theme="dark"] .navbar-logo {
|
||||||
filter: none;
|
filter: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* User Avatar Component */
|
/* User Avatar Component */
|
||||||
@@ -680,3 +687,12 @@ body {
|
|||||||
color: #E2E8F0;
|
color: #E2E8F0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Chart Container Utility — previene expansión infinita en Chart.js */
|
||||||
|
.chart-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 260px;
|
||||||
|
max-height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+142
-28
@@ -40,17 +40,20 @@ const app = express();
|
|||||||
const port = process.env.PORT || 3000;
|
const port = process.env.PORT || 3000;
|
||||||
|
|
||||||
app.disable('x-powered-by');
|
app.disable('x-powered-by');
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
// Blindaje HTTP con Helmet
|
// Blindaje HTTP con Helmet (compatible con despliegues directos por IP y proxies HTTPS)
|
||||||
app.use(helmet({
|
app.use(helmet({
|
||||||
contentSecurityPolicy: false,
|
contentSecurityPolicy: false,
|
||||||
crossOriginEmbedderPolicy: false
|
crossOriginEmbedderPolicy: false,
|
||||||
|
crossOriginOpenerPolicy: false,
|
||||||
|
originAgentCluster: false
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Rate Limiting para protección contra ataques de fuerza bruta y abuso
|
// Rate Limiting para protección contra ataques de fuerza bruta y abuso
|
||||||
const authLimiter = rateLimit({
|
const authLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000,
|
windowMs: 15 * 60 * 1000,
|
||||||
max: 30,
|
max: 60,
|
||||||
message: 'Demasiados intentos de autenticación. Por favor intente nuevamente en 15 minutos.',
|
message: 'Demasiados intentos de autenticación. Por favor intente nuevamente en 15 minutos.',
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false
|
legacyHeaders: false
|
||||||
@@ -61,7 +64,8 @@ const generalLimiter = rateLimit({
|
|||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false
|
legacyHeaders: false
|
||||||
});
|
});
|
||||||
app.use('/auth/login', authLimiter);
|
app.post('/auth/login', authLimiter);
|
||||||
|
app.post('/auth/moodle', authLimiter);
|
||||||
app.use('/api', generalLimiter);
|
app.use('/api', generalLimiter);
|
||||||
|
|
||||||
// Configuración de middlewares
|
// Configuración de middlewares
|
||||||
@@ -152,41 +156,47 @@ const translateHelper = function(s) {
|
|||||||
return s || '';
|
return s || '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const urlForHelper = function(endpoint, params = {}) {
|
||||||
|
if (!endpoint) return '#';
|
||||||
|
const parts = endpoint.split('.');
|
||||||
|
const blueprint = parts.length > 1 ? parts[0] : '';
|
||||||
|
const action = parts.length > 1 ? parts[1] : parts[0];
|
||||||
|
let pathStr = `/${blueprint}/${action}`.replace(/^\/\//, '/').replace(/\/$/, '');
|
||||||
|
if (params && typeof params === 'object') {
|
||||||
|
const q = [];
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (k === 'id' || k === 'pk') {
|
||||||
|
pathStr += `/${v}`;
|
||||||
|
} else {
|
||||||
|
q.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (q.length > 0) {
|
||||||
|
pathStr += (pathStr.includes('?') ? '&' : '?') + q.join('&');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pathStr;
|
||||||
|
};
|
||||||
|
|
||||||
env.addGlobal('_', translateHelper);
|
env.addGlobal('_', translateHelper);
|
||||||
env.addFilter('_', translateHelper);
|
env.addFilter('_', translateHelper);
|
||||||
|
env.addGlobal('url_for', urlForHelper);
|
||||||
|
env.addFilter('url_for', urlForHelper);
|
||||||
|
|
||||||
app.set('view engine', 'html');
|
app.set('view engine', 'html');
|
||||||
|
|
||||||
|
const { buildUserSession } = require('./middlewares/authMiddleware');
|
||||||
|
|
||||||
// Helper para extraer usuario de cookie si existe
|
// Helper para extraer usuario de cookie si existe
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const token = req.cookies.auth_token;
|
const token = req.cookies.auth_token;
|
||||||
let user = {
|
let user = buildUserSession({ role: 'admin', name: 'System', email: 'admin@edu-space.com' });
|
||||||
name: 'System',
|
user.is_authenticated = Boolean(token);
|
||||||
first_name: 'System',
|
|
||||||
email: 'admin@edu-space.com',
|
|
||||||
role: 'admin',
|
|
||||||
is_authenticated: Boolean(token),
|
|
||||||
is_admin: () => true,
|
|
||||||
has_permission: () => true,
|
|
||||||
has_role: () => true,
|
|
||||||
can_edit_reservation: () => true,
|
|
||||||
can_delete_reservation: () => true,
|
|
||||||
get_institutional_role: () => 'admin',
|
|
||||||
role_badge_display: { class: 'bg-primary text-white', icon: 'bi-shield-check', label: 'Administrador' }
|
|
||||||
};
|
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
const decoded = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString('utf8'));
|
const decoded = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString('utf8'));
|
||||||
const roleStr = (decoded.role || 'Admin').toLowerCase();
|
user = buildUserSession(decoded);
|
||||||
user = {
|
|
||||||
...user,
|
|
||||||
id: decoded.sub,
|
|
||||||
name: decoded.name || 'System',
|
|
||||||
first_name: (decoded.name || 'System').split(' ')[0],
|
|
||||||
email: decoded.email || 'admin@edu-space.com',
|
|
||||||
role: roleStr,
|
|
||||||
is_admin: () => roleStr === 'admin' || decoded.is_admin === true
|
|
||||||
};
|
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
let isImpersonating = false;
|
let isImpersonating = false;
|
||||||
@@ -225,6 +235,32 @@ const buildingsRoutes = require('./routes/buildings');
|
|||||||
const adminRoutes = require('./routes/admin');
|
const adminRoutes = require('./routes/admin');
|
||||||
const mySubjectsRoutes = require('./routes/my_subjects');
|
const mySubjectsRoutes = require('./routes/my_subjects');
|
||||||
|
|
||||||
|
// Proxy transparente hacia la API REST de Flask para llamadas AJAX frontend
|
||||||
|
app.use('/api/v1', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const token = req.cookies.auth_token;
|
||||||
|
const targetUrl = `${process.env.FLASK_API_URL || 'http://localhost:5000/api/v1'}${req.url}`;
|
||||||
|
const headers = { ...req.headers };
|
||||||
|
delete headers.host;
|
||||||
|
delete headers['content-length'];
|
||||||
|
if (token && !headers.authorization) {
|
||||||
|
headers.authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
const resp = await axios({
|
||||||
|
method: req.method,
|
||||||
|
url: targetUrl,
|
||||||
|
data: req.body,
|
||||||
|
params: req.query,
|
||||||
|
headers,
|
||||||
|
validateStatus: () => true
|
||||||
|
});
|
||||||
|
return res.status(resp.status).json(resp.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('API v1 proxy error:', err.message);
|
||||||
|
return res.status(500).json({ error: 'ProxyError', message: 'Error de comunicación con el backend API: ' + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.use('/', indexRoutes);
|
app.use('/', indexRoutes);
|
||||||
app.use('/auth', authRoutes);
|
app.use('/auth', authRoutes);
|
||||||
app.use('/dashboard', dashboardRoutes);
|
app.use('/dashboard', dashboardRoutes);
|
||||||
@@ -429,6 +465,84 @@ app.get('/:blueprint/:route', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Middleware 404 para cualquier ruta no encontrada (evita pantallas en blanco y errores Cannot POST/GET)
|
||||||
|
app.use((req, res) => {
|
||||||
|
res.status(404).send(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Página No Encontrada — Edu-Space</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||||
|
<style>
|
||||||
|
body { background: #0f172a; color: #f8fafc; font-family: system-ui, -apple-system, sans-serif; min-height: 100vh; display: flex; align-items: center; justify-content: center; margin: 0; }
|
||||||
|
.card-error { background: #1e293b; border: 1px solid #334155; border-radius: 16px; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container text-center p-4" style="max-width: 520px;">
|
||||||
|
<div class="card card-error p-4 p-md-5">
|
||||||
|
<div class="mb-3 text-warning">
|
||||||
|
<i class="bi bi-compass fs-1"></i>
|
||||||
|
</div>
|
||||||
|
<h1 class="h3 fw-bold mb-2">Página o Acción No Encontrada</h1>
|
||||||
|
<p class="text-secondary small mb-4">
|
||||||
|
La ruta solicitada <code class="text-light">${req.originalUrl}</code> no existe o no admite el método HTTP ejecutado.
|
||||||
|
</p>
|
||||||
|
<div class="d-flex justify-content-center gap-2">
|
||||||
|
<a href="/dashboard" class="btn btn-primary px-4 fw-semibold">
|
||||||
|
<i class="bi bi-house-door me-1"></i>Volver al Panel
|
||||||
|
</a>
|
||||||
|
<button onclick="window.history.back()" class="btn btn-outline-light px-4">
|
||||||
|
Volver Atrás
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Middleware Global de Error 500
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
console.error('Error no capturado en BFF:', err);
|
||||||
|
res.status(500).send(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Error del Sistema — Edu-Space</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||||
|
<style>
|
||||||
|
body { background: #0f172a; color: #f8fafc; font-family: system-ui, -apple-system, sans-serif; min-height: 100vh; display: flex; align-items: center; justify-content: center; margin: 0; }
|
||||||
|
.card-error { background: #1e293b; border: 1px solid #334155; border-radius: 16px; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container text-center p-4" style="max-width: 520px;">
|
||||||
|
<div class="card card-error p-4 p-md-5">
|
||||||
|
<div class="mb-3 text-danger">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill fs-1"></i>
|
||||||
|
</div>
|
||||||
|
<h1 class="h3 fw-bold mb-2">Error en la Aplicación</h1>
|
||||||
|
<p class="text-secondary small mb-4">
|
||||||
|
Ocurrió una eventualidad procesando la solicitud. Nuestro equipo técnico ha sido notificado.
|
||||||
|
</p>
|
||||||
|
<a href="/dashboard" class="btn btn-primary px-4 fw-semibold">
|
||||||
|
<i class="bi bi-house-door me-1"></i>Volver al Panel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`BFF Frontend corriendo en http://localhost:${port}`);
|
console.log(`BFF Frontend corriendo en http://localhost:${port}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,78 @@
|
|||||||
const apiClient = require('../services/apiClient');
|
const apiClient = require('../services/apiClient');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const buildUserSession = (decoded) => {
|
||||||
|
const roleStr = (decoded?.role || 'Admin').toLowerCase();
|
||||||
|
const isAdmin = roleStr === 'admin' || decoded?.is_admin === true;
|
||||||
|
const isBedelia = roleStr === 'bedelia';
|
||||||
|
const isDocente = roleStr === 'docente' || roleStr === 'profesor';
|
||||||
|
const isAlumno = roleStr === 'alumno' || roleStr === 'estudiante';
|
||||||
|
const canManage = isAdmin || isBedelia;
|
||||||
|
|
||||||
|
let badgeClass = 'bg-primary text-white';
|
||||||
|
let badgeIcon = 'bi-shield-check';
|
||||||
|
let badgeLabel = decoded?.role || 'Administrador';
|
||||||
|
|
||||||
|
if (isBedelia) {
|
||||||
|
badgeClass = 'bg-warning text-dark';
|
||||||
|
badgeIcon = 'bi-building-gear';
|
||||||
|
badgeLabel = 'Bedelía';
|
||||||
|
} else if (isDocente) {
|
||||||
|
badgeClass = 'bg-info text-white';
|
||||||
|
badgeIcon = 'bi-person-video3';
|
||||||
|
badgeLabel = 'Docente';
|
||||||
|
} else if (isAlumno) {
|
||||||
|
badgeClass = 'bg-success text-white';
|
||||||
|
badgeIcon = 'bi-mortarboard';
|
||||||
|
badgeLabel = 'Alumno';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: decoded?.sub || 1,
|
||||||
|
name: decoded?.name || 'System',
|
||||||
|
first_name: (decoded?.name || 'System').split(' ')[0],
|
||||||
|
email: decoded?.email || 'admin@edu-space.com',
|
||||||
|
role: roleStr,
|
||||||
|
is_authenticated: true,
|
||||||
|
is_active: true,
|
||||||
|
is_admin: () => isAdmin,
|
||||||
|
is_bedelia: () => isBedelia,
|
||||||
|
is_docente: () => isDocente,
|
||||||
|
is_alumno: () => isAlumno,
|
||||||
|
can_manage: () => canManage,
|
||||||
|
has_role: (r) => roleStr === (r || '').toLowerCase(),
|
||||||
|
has_permission: (module, action) => {
|
||||||
|
if (isAdmin) return true;
|
||||||
|
if (isBedelia) {
|
||||||
|
if (module === 'reservations' || module === 'classrooms' || module === 'schedule') return true;
|
||||||
|
if (module === 'academic' && action === 'read') return true;
|
||||||
|
}
|
||||||
|
if (isDocente) {
|
||||||
|
if (module === 'my_subjects' || module === 'academic') return true;
|
||||||
|
if (module === 'reservations' && action === 'read') return true;
|
||||||
|
}
|
||||||
|
if (isAlumno) {
|
||||||
|
if (module === 'my_subjects' && action === 'read') return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
can_edit_reservation: (res) => {
|
||||||
|
if (isAdmin || isBedelia) return true;
|
||||||
|
if (isDocente && res && (res.user_id === decoded?.sub || res.user?.id === decoded?.sub)) return true;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
can_delete_reservation: (res) => {
|
||||||
|
return isAdmin || isBedelia;
|
||||||
|
},
|
||||||
|
get_institutional_role: () => roleStr,
|
||||||
|
role_badge_display: {
|
||||||
|
class: badgeClass,
|
||||||
|
icon: badgeIcon,
|
||||||
|
label: badgeLabel
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const requireAuth = (req, res, next) => {
|
const requireAuth = (req, res, next) => {
|
||||||
const token = req.cookies.auth_token;
|
const token = req.cookies.auth_token;
|
||||||
|
|
||||||
@@ -17,24 +89,7 @@ const requireAuth = (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let user = {
|
let user = buildUserSession({ role: 'admin', name: 'System', email: 'admin@edu-space.com' });
|
||||||
name: 'System',
|
|
||||||
first_name: 'System',
|
|
||||||
email: 'admin@edu-space.com',
|
|
||||||
role: 'admin',
|
|
||||||
is_authenticated: true,
|
|
||||||
is_admin: () => true,
|
|
||||||
has_permission: () => true,
|
|
||||||
can_edit_reservation: () => true,
|
|
||||||
can_delete_reservation: () => true,
|
|
||||||
has_role: () => true,
|
|
||||||
get_institutional_role: () => 'admin',
|
|
||||||
role_badge_display: {
|
|
||||||
class: 'bg-primary text-white',
|
|
||||||
icon: 'bi-shield-check',
|
|
||||||
label: 'Administrador'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payloadBase64 = token.split('.')[1];
|
const payloadBase64 = token.split('.')[1];
|
||||||
@@ -46,21 +101,7 @@ const requireAuth = (req, res, next) => {
|
|||||||
return res.redirect('/auth/login');
|
return res.redirect('/auth/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleStr = (decoded.role || 'Admin').toLowerCase();
|
user = buildUserSession(decoded);
|
||||||
user = {
|
|
||||||
...user,
|
|
||||||
id: decoded.sub,
|
|
||||||
name: decoded.name || 'System',
|
|
||||||
first_name: (decoded.name || 'System').split(' ')[0],
|
|
||||||
email: decoded.email || 'admin@edu-space.com',
|
|
||||||
role: roleStr,
|
|
||||||
is_admin: () => roleStr === 'admin' || decoded.is_admin === true,
|
|
||||||
role_badge_display: {
|
|
||||||
class: roleStr === 'admin' ? 'bg-primary text-white' : 'bg-info text-white',
|
|
||||||
icon: roleStr === 'admin' ? 'bi-shield-check' : 'bi-person',
|
|
||||||
label: decoded.role || 'Administrador'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error decoding token payload:', e.message);
|
console.error('Error decoding token payload:', e.message);
|
||||||
}
|
}
|
||||||
@@ -101,4 +142,4 @@ const requireAuth = (req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { requireAuth };
|
module.exports = { requireAuth, buildUserSession };
|
||||||
|
|||||||
+303
-31
@@ -382,28 +382,63 @@ const handleUserAdd = async (req, res) => {
|
|||||||
title: 'Nuevo Usuario — Edu-Space Admin',
|
title: 'Nuevo Usuario — Edu-Space Admin',
|
||||||
user: null,
|
user: null,
|
||||||
roles,
|
roles,
|
||||||
document_types
|
document_types,
|
||||||
|
error: req.query.error || null,
|
||||||
|
msg: req.query.msg || null
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error in user add:', err.message);
|
console.error('Error in user add:', err.message);
|
||||||
res.render('admin/users/form', { title: 'Nuevo Usuario', user: null, roles: MOCK_ROLES, document_types: [] });
|
res.render('admin/users/form', {
|
||||||
|
title: 'Nuevo Usuario — Edu-Space Admin',
|
||||||
|
user: null,
|
||||||
|
roles: MOCK_ROLES,
|
||||||
|
document_types: [],
|
||||||
|
error: req.query.error || null,
|
||||||
|
msg: null
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUserEdit = async (req, res) => {
|
const handleUserEdit = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { users, roles, document_types } = await fetchUsersAndRoles(req.apiClient);
|
|
||||||
const userId = req.params.id || req.query.id;
|
const userId = req.params.id || req.query.id;
|
||||||
const targetUser = (userId ? users.find(u => String(u.id) === String(userId)) : null) || users[0] || MOCK_USERS[0];
|
const { users, roles, document_types } = await fetchUsersAndRoles(req.apiClient);
|
||||||
|
let targetUser = userId ? users.find(u => String(u.id) === String(userId)) : null;
|
||||||
|
|
||||||
|
// Si no se encontró en la lista general y hay userId, intentar fetch individual
|
||||||
|
if (!targetUser && userId && req.apiClient) {
|
||||||
|
try {
|
||||||
|
const singleRes = await req.apiClient.get(`/users/${userId}`);
|
||||||
|
if (singleRes.data && (singleRes.data.user || singleRes.data.data)) {
|
||||||
|
targetUser = singleRes.data.user || singleRes.data.data;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`User ${userId} not found via single endpoint:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
targetUser = targetUser || (userId ? null : users[0]) || MOCK_USERS[0];
|
||||||
|
|
||||||
|
const userName = targetUser ? (targetUser.name || `${targetUser.first_name || ''} ${targetUser.last_name || ''}`.trim() || targetUser.email || `Usuario #${targetUser.id}`) : 'Usuario';
|
||||||
|
|
||||||
res.render('admin/users/form', {
|
res.render('admin/users/form', {
|
||||||
title: `Editar Usuario: ${targetUser.name} — Edu-Space Admin`,
|
title: `Editar Usuario: ${userName} — Edu-Space Admin`,
|
||||||
user: targetUser,
|
user: targetUser,
|
||||||
roles,
|
roles,
|
||||||
document_types
|
document_types,
|
||||||
|
error: req.query.error || null,
|
||||||
|
msg: req.query.msg || null
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error in user edit:', err.message);
|
console.error('Error in user edit:', err.message);
|
||||||
res.render('admin/users/form', { title: 'Editar Usuario', user: MOCK_USERS[0], roles: MOCK_ROLES, document_types: [] });
|
res.render('admin/users/form', {
|
||||||
|
title: 'Editar Usuario — Edu-Space Admin',
|
||||||
|
user: MOCK_USERS[0],
|
||||||
|
roles: MOCK_ROLES,
|
||||||
|
document_types: [],
|
||||||
|
error: 'No se pudo cargar la información del usuario.',
|
||||||
|
msg: null
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -428,6 +463,10 @@ const handleUserSave = async (req, res) => {
|
|||||||
is_active: is_active === 'on' || is_active === 'true' || is_active === true
|
is_active: is_active === 'on' || is_active === 'true' || is_active === true
|
||||||
};
|
};
|
||||||
if (password && password.trim()) {
|
if (password && password.trim()) {
|
||||||
|
if (password.trim().length < 6) {
|
||||||
|
const errMsg = encodeURIComponent('La contraseña debe tener al menos 6 caracteres.');
|
||||||
|
return res.redirect(userId ? `/admin/users/edit/${userId}?error=${errMsg}` : `/admin/user_add?error=${errMsg}`);
|
||||||
|
}
|
||||||
payload.password = password.trim();
|
payload.password = password.trim();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -440,6 +479,30 @@ const handleUserSave = async (req, res) => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('User save API notice:', e.response?.data || e.message);
|
console.warn('User save API notice:', e.response?.data || e.message);
|
||||||
const errMsg = encodeURIComponent(e.response?.data?.message || 'Error al guardar el usuario');
|
const errMsg = encodeURIComponent(e.response?.data?.message || 'Error al guardar el usuario');
|
||||||
|
return res.redirect(userId ? `/admin/users/edit/${userId}?error=${errMsg}` : `/admin/users_list?error=${errMsg}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUserPasswordReset = async (req, res) => {
|
||||||
|
const userId = req.params.id || req.body.id || req.body.user_id;
|
||||||
|
const password = (req.body.password || req.body.new_password || '').trim();
|
||||||
|
|
||||||
|
if (!userId || !password) {
|
||||||
|
const errMsg = encodeURIComponent('Debe seleccionar un usuario e ingresar la nueva contraseña.');
|
||||||
|
return res.redirect(`/admin/users_list?error=${errMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
const errMsg = encodeURIComponent('La nueva contraseña debe tener al menos 6 caracteres.');
|
||||||
|
return res.redirect(`/admin/users_list?error=${errMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await req.apiClient.post(`/users/${userId}/password`, { password });
|
||||||
|
return res.redirect('/admin/users_list?msg=password_updated');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Password reset notice:', e.response?.data || e.message);
|
||||||
|
const errMsg = encodeURIComponent(e.response?.data?.message || 'Error al restablecer la contraseña.');
|
||||||
return res.redirect(`/admin/users_list?error=${errMsg}`);
|
return res.redirect(`/admin/users_list?error=${errMsg}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -447,8 +510,10 @@ const handleUserSave = async (req, res) => {
|
|||||||
router.get(['/users_list', '/users', '/user_list'], handleUsersList);
|
router.get(['/users_list', '/users', '/user_list'], handleUsersList);
|
||||||
router.get(['/user_add', '/users/new'], handleUserAdd);
|
router.get(['/user_add', '/users/new'], handleUserAdd);
|
||||||
router.get(['/user_edit', '/user_edit/:id', '/users/edit', '/users/edit/:id'], handleUserEdit);
|
router.get(['/user_edit', '/user_edit/:id', '/users/edit', '/users/edit/:id'], handleUserEdit);
|
||||||
router.post(['/user_add', '/users/new', '/user_save'], handleUserSave);
|
router.post(['/user_add', '/users/new', '/user_save', '/users/save'], handleUserSave);
|
||||||
router.post(['/user_edit', '/user_edit/:id', '/users/edit', '/users/edit/:id'], handleUserSave);
|
router.post(['/user_edit', '/user_edit/:id', '/users/edit', '/users/edit/:id'], handleUserSave);
|
||||||
|
router.post(['/users/:id/password', '/users/password', '/user_password', '/user_password/:id', '/user_reset_password'], handleUserPasswordReset);
|
||||||
|
|
||||||
|
|
||||||
// ─── 4. ROLES ─────────────────────────────────────────────────────────────────
|
// ─── 4. ROLES ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -587,20 +652,13 @@ const handleCareersList = async (req, res) => {
|
|||||||
console.warn('Careers list API notice:', apiErr.message);
|
console.warn('Careers list API notice:', apiErr.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (careers.length === 0) {
|
|
||||||
careers = [
|
|
||||||
{ id: 1, name: 'Licenciatura en Sistemas', code: 'LIC-SIS', description: 'Formación en ingeniería de software y sistemas.' },
|
|
||||||
{ id: 2, name: 'Tecnicatura en Desarrollo de Software', code: 'TEC-DS', description: 'Desarrollo web y aplicaciones.' }
|
|
||||||
];
|
|
||||||
stats[1] = { subjects_count: 8, commissions_count: 12 };
|
|
||||||
stats[2] = { subjects_count: 6, commissions_count: 8 };
|
|
||||||
}
|
|
||||||
|
|
||||||
res.render('admin/careers/list', {
|
res.render('admin/careers/list', {
|
||||||
title: 'Gestión de Carreras - Edu-Space Admin',
|
title: 'Gestión de Carreras - Edu-Space Admin',
|
||||||
careers,
|
careers,
|
||||||
stats,
|
stats,
|
||||||
search,
|
search,
|
||||||
|
msg: req.query.msg || '',
|
||||||
|
error: req.query.error || '',
|
||||||
can_edit: true
|
can_edit: true
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -613,10 +671,12 @@ const handleCareerAdd = async (req, res) => {
|
|||||||
const { name, code, description } = req.body;
|
const { name, code, description } = req.body;
|
||||||
try {
|
try {
|
||||||
await req.apiClient.post('/careers', { name, code, description });
|
await req.apiClient.post('/careers', { name, code, description });
|
||||||
|
res.redirect('/admin/careers_list?msg=created');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Career add API notice:', e.response?.data || e.message);
|
console.warn('Career add API notice:', e.response?.data || e.message);
|
||||||
|
const errCode = e.response?.status === 409 ? 'conflict' : 'create';
|
||||||
|
res.redirect(`/admin/careers_list?error=${errCode}`);
|
||||||
}
|
}
|
||||||
res.redirect('/admin/careers_list');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCareerEdit = async (req, res) => {
|
const handleCareerEdit = async (req, res) => {
|
||||||
@@ -624,20 +684,24 @@ const handleCareerEdit = async (req, res) => {
|
|||||||
const { name, code, description } = req.body;
|
const { name, code, description } = req.body;
|
||||||
try {
|
try {
|
||||||
await req.apiClient.put(`/careers/${id}`, { name, code, description });
|
await req.apiClient.put(`/careers/${id}`, { name, code, description });
|
||||||
|
res.redirect('/admin/careers_list?msg=updated');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Career edit API notice:', e.response?.data || e.message);
|
console.warn('Career edit API notice:', e.response?.data || e.message);
|
||||||
|
const errCode = e.response?.status === 409 ? 'conflict' : 'edit';
|
||||||
|
res.redirect(`/admin/careers_list?error=${errCode}`);
|
||||||
}
|
}
|
||||||
res.redirect('/admin/careers_list');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCareerDelete = async (req, res) => {
|
const handleCareerDelete = async (req, res) => {
|
||||||
const id = req.params.id || req.body.id;
|
const id = req.params.id || req.body.id;
|
||||||
try {
|
try {
|
||||||
await req.apiClient.delete(`/careers/${id}`);
|
await req.apiClient.delete(`/careers/${id}`);
|
||||||
|
res.redirect('/admin/careers_list?msg=deleted');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Career delete API notice:', e.response?.data || e.message);
|
console.warn('Career delete API notice:', e.response?.data || e.message);
|
||||||
|
const errCode = e.response?.status === 409 ? 'has_subjects' : 'delete';
|
||||||
|
res.redirect(`/admin/careers_list?error=${errCode}`);
|
||||||
}
|
}
|
||||||
res.redirect('/admin/careers_list');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
router.get(['/careers_list', '/careers', '/career_list'], handleCareersList);
|
router.get(['/careers_list', '/careers', '/career_list'], handleCareersList);
|
||||||
@@ -702,10 +766,12 @@ const handleSubjectAdd = async (req, res) => {
|
|||||||
description,
|
description,
|
||||||
is_active: is_active === 'on' || is_active === 'true' || is_active === true
|
is_active: is_active === 'on' || is_active === 'true' || is_active === true
|
||||||
});
|
});
|
||||||
|
res.redirect('/admin/subjects_list?msg=created');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Subject add API notice:', e.response?.data || e.message);
|
console.warn('Subject add API notice:', e.response?.data || e.message);
|
||||||
|
const errCode = e.response?.status === 409 ? 'conflict' : 'create';
|
||||||
|
res.redirect(`/admin/subjects_list?error=${errCode}`);
|
||||||
}
|
}
|
||||||
res.redirect('/admin/subjects_list');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubjectEdit = async (req, res) => {
|
const handleSubjectEdit = async (req, res) => {
|
||||||
@@ -721,20 +787,23 @@ const handleSubjectEdit = async (req, res) => {
|
|||||||
description,
|
description,
|
||||||
is_active: is_active === 'on' || is_active === 'true' || is_active === true
|
is_active: is_active === 'on' || is_active === 'true' || is_active === true
|
||||||
});
|
});
|
||||||
|
res.redirect('/admin/subjects_list?msg=updated');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Subject edit API notice:', e.response?.data || e.message);
|
console.warn('Subject edit API notice:', e.response?.data || e.message);
|
||||||
|
const errCode = e.response?.status === 409 ? 'conflict' : 'edit';
|
||||||
|
res.redirect(`/admin/subjects_list?error=${errCode}`);
|
||||||
}
|
}
|
||||||
res.redirect('/admin/subjects_list');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubjectToggle = async (req, res) => {
|
const handleSubjectToggle = async (req, res) => {
|
||||||
const id = req.params.id || req.body.id;
|
const id = req.params.id || req.body.id;
|
||||||
try {
|
try {
|
||||||
await req.apiClient.post(`/subjects/${id}/toggle`);
|
await req.apiClient.post(`/subjects/${id}/toggle`);
|
||||||
|
res.redirect('/admin/subjects_list?msg=toggled');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Subject toggle API notice:', e.message);
|
console.warn('Subject toggle API notice:', e.message);
|
||||||
|
res.redirect('/admin/subjects_list?error=toggle');
|
||||||
}
|
}
|
||||||
res.redirect('/admin/subjects_list');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubjectDelete = async (req, res) => {
|
const handleSubjectDelete = async (req, res) => {
|
||||||
@@ -768,6 +837,7 @@ const handleCommissionsList = async (req, res) => {
|
|||||||
let commissions = [];
|
let commissions = [];
|
||||||
let subjects = [];
|
let subjects = [];
|
||||||
let teachers = [];
|
let teachers = [];
|
||||||
|
let academic_terms = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [cRes, uRes] = await Promise.allSettled([
|
const [cRes, uRes] = await Promise.allSettled([
|
||||||
@@ -789,6 +859,15 @@ const handleCommissionsList = async (req, res) => {
|
|||||||
teachers: c.teachers || (c.teacher_name ? [{ name: c.teacher_name, role: 'Titular' }] : [])
|
teachers: c.teachers || (c.teacher_name ? [{ name: c.teacher_name, role: 'Titular' }] : [])
|
||||||
}));
|
}));
|
||||||
subjects = cRes.value.data.subjects || [];
|
subjects = cRes.value.data.subjects || [];
|
||||||
|
academic_terms = cRes.value.data.academic_terms || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si por alguna razón no vinieron academic_terms en commissions, intentar fallback
|
||||||
|
if (!academic_terms || academic_terms.length === 0) {
|
||||||
|
try {
|
||||||
|
const termsRes = await req.apiClient.get('/academic-terms');
|
||||||
|
academic_terms = termsRes.data?.terms || termsRes.data?.academic_terms || [];
|
||||||
|
} catch (tErr) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uRes.status === 'fulfilled' && uRes.value.data) {
|
if (uRes.status === 'fulfilled' && uRes.value.data) {
|
||||||
@@ -807,11 +886,15 @@ const handleCommissionsList = async (req, res) => {
|
|||||||
commissions,
|
commissions,
|
||||||
subjects,
|
subjects,
|
||||||
teachers,
|
teachers,
|
||||||
|
academic_terms,
|
||||||
search,
|
search,
|
||||||
subject_filter: subject_id ? parseInt(subject_id, 10) : '',
|
subject_filter: subject_id ? parseInt(subject_id, 10) : '',
|
||||||
shift_filter: shift,
|
shift_filter: shift,
|
||||||
active_filter: active,
|
active_filter: active,
|
||||||
created,
|
created,
|
||||||
|
msg: req.query.msg || (created ? 'created' : ''),
|
||||||
|
error: req.query.error || '',
|
||||||
|
can_edit: true,
|
||||||
pagination: { pages: 1, page: 1, total: commissions.length, has_prev: false, has_next: false }
|
pagination: { pages: 1, page: 1, total: commissions.length, has_prev: false, has_next: false }
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -928,12 +1011,13 @@ const handleCommissionDetail = async (req, res) => {
|
|||||||
|
|
||||||
const handleCommissionAdd = async (req, res) => {
|
const handleCommissionAdd = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { subject_id, code, semester, year, shift, teacher_id, max_students, schedule, virtual_link } = req.body;
|
const { subject_id, code, semester, year, shift, teacher_id, max_students, schedule, virtual_link, academic_term_id } = req.body;
|
||||||
const payload = {
|
const payload = {
|
||||||
subject_id: parseInt(subject_id, 10),
|
subject_id: parseInt(subject_id, 10),
|
||||||
code: (code || '').trim(),
|
code: (code || '').trim(),
|
||||||
semester: semester || '1C',
|
semester: semester || '1C',
|
||||||
year: year ? parseInt(year, 10) : 2026,
|
year: year ? parseInt(year, 10) : 2026,
|
||||||
|
academic_term_id: academic_term_id ? parseInt(academic_term_id, 10) : null,
|
||||||
shift: shift || 'Mañana',
|
shift: shift || 'Mañana',
|
||||||
teacher_id: teacher_id ? parseInt(teacher_id, 10) : null,
|
teacher_id: teacher_id ? parseInt(teacher_id, 10) : null,
|
||||||
max_students: max_students ? parseInt(max_students, 10) : 40,
|
max_students: max_students ? parseInt(max_students, 10) : 40,
|
||||||
@@ -942,31 +1026,60 @@ const handleCommissionAdd = async (req, res) => {
|
|||||||
active: true
|
active: true
|
||||||
};
|
};
|
||||||
await req.apiClient.post('/commissions', payload);
|
await req.apiClient.post('/commissions', payload);
|
||||||
res.redirect('/admin/commissions_list?created=1');
|
res.redirect('/admin/commissions_list?msg=created');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Commission add API notice:', e.response?.data || e.message);
|
console.warn('Commission add API notice:', e.response?.data || e.message);
|
||||||
res.redirect('/admin/commissions_list');
|
res.redirect('/admin/commissions_list?error=create');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCommissionEdit = async (req, res) => {
|
const handleCommissionEdit = async (req, res) => {
|
||||||
const id = req.params.id || req.body.id;
|
const id = req.params.id || req.body.id;
|
||||||
try {
|
try {
|
||||||
await req.apiClient.put(`/commissions/${id}`, req.body);
|
const { subject_id, code, semester, year, shift, teacher_id, max_students, schedule, virtual_link, academic_term_id, active } = req.body;
|
||||||
|
const payload = {
|
||||||
|
code: (code || '').trim(),
|
||||||
|
semester: semester || '1C',
|
||||||
|
year: year ? parseInt(year, 10) : 2026,
|
||||||
|
shift: shift || 'Mañana',
|
||||||
|
teacher_id: teacher_id ? parseInt(teacher_id, 10) : null,
|
||||||
|
max_students: max_students ? parseInt(max_students, 10) : 40,
|
||||||
|
schedule: (schedule || '').trim(),
|
||||||
|
virtual_link: (virtual_link || '').trim()
|
||||||
|
};
|
||||||
|
if (subject_id) payload.subject_id = parseInt(subject_id, 10);
|
||||||
|
if (academic_term_id) payload.academic_term_id = parseInt(academic_term_id, 10);
|
||||||
|
if (active !== undefined) payload.active = (active === 'on' || active === 'true' || active === true);
|
||||||
|
|
||||||
|
await req.apiClient.put(`/commissions/${id}`, payload);
|
||||||
|
res.redirect('/admin/commissions_list?msg=updated');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Commission edit API notice:', e.response?.data || e.message);
|
console.warn('Commission edit API notice:', e.response?.data || e.message);
|
||||||
|
res.redirect('/admin/commissions_list?error=edit');
|
||||||
}
|
}
|
||||||
res.redirect('/admin/commissions_list');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCommissionToggle = async (req, res) => {
|
const handleCommissionToggle = async (req, res) => {
|
||||||
const id = req.params.id || req.body.id;
|
const id = req.params.id || req.body.id;
|
||||||
try {
|
try {
|
||||||
await req.apiClient.post(`/commissions/${id}/toggle`);
|
await req.apiClient.post(`/commissions/${id}/toggle`);
|
||||||
|
res.redirect('/admin/commissions_list?msg=toggled');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Commission toggle API notice:', e.message);
|
console.warn('Commission toggle API notice:', e.message);
|
||||||
|
res.redirect('/admin/commissions_list?error=toggle');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCommissionDelete = async (req, res) => {
|
||||||
|
const id = req.params.id || req.body.id;
|
||||||
|
try {
|
||||||
|
const delRes = await req.apiClient.delete(`/commissions/${id}`);
|
||||||
|
const action = delRes.data?.action || 'deleted';
|
||||||
|
res.redirect(`/admin/commissions_list?msg=${action}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Commission delete API notice:', e.response?.data || e.message);
|
||||||
|
res.redirect('/admin/commissions_list?error=delete');
|
||||||
}
|
}
|
||||||
res.redirect('/admin/commissions_list');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCommissionAssignTeacher = async (req, res) => {
|
const handleCommissionAssignTeacher = async (req, res) => {
|
||||||
@@ -1055,6 +1168,7 @@ router.get(['/commission_detail', '/commission_detail/:id', '/commissions/:id'],
|
|||||||
router.post(['/commission_add', '/commissions/add'], handleCommissionAdd);
|
router.post(['/commission_add', '/commissions/add'], handleCommissionAdd);
|
||||||
router.post(['/commission_edit', '/commission_edit/:id'], handleCommissionEdit);
|
router.post(['/commission_edit', '/commission_edit/:id'], handleCommissionEdit);
|
||||||
router.post(['/commission_toggle', '/commission_toggle/:id'], handleCommissionToggle);
|
router.post(['/commission_toggle', '/commission_toggle/:id'], handleCommissionToggle);
|
||||||
|
router.post(['/commission_delete', '/commission_delete/:id', '/commissions/:id/delete'], handleCommissionDelete);
|
||||||
router.post(['/commission_assign_teacher', '/commission_assign_teacher/:id'], handleCommissionAssignTeacher);
|
router.post(['/commission_assign_teacher', '/commission_assign_teacher/:id'], handleCommissionAssignTeacher);
|
||||||
router.post(['/commission_add_teacher', '/commissions/:id/teachers', '/commissions/teachers/add'], handleCommissionAddTeacher);
|
router.post(['/commission_add_teacher', '/commissions/:id/teachers', '/commissions/teachers/add'], handleCommissionAddTeacher);
|
||||||
router.post(['/commission_remove_teacher', '/commissions/:id/teachers/delete', '/commissions/teachers/remove'], handleCommissionRemoveTeacher);
|
router.post(['/commission_remove_teacher', '/commissions/:id/teachers/delete', '/commissions/teachers/remove'], handleCommissionRemoveTeacher);
|
||||||
@@ -1128,10 +1242,32 @@ const handleAcademicTermSetCurrent = async (req, res) => {
|
|||||||
res.redirect('/admin/academic_terms_list');
|
res.redirect('/admin/academic_terms_list');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAcademicTermToggle = async (req, res) => {
|
||||||
|
const id = req.params.id || req.body.id;
|
||||||
|
try {
|
||||||
|
await req.apiClient.post(`/academic-terms/${id}/toggle`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Academic term toggle API notice:', e.response?.data || e.message);
|
||||||
|
}
|
||||||
|
res.redirect('/admin/academic_terms_list');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAcademicTermDelete = async (req, res) => {
|
||||||
|
const id = req.params.id || req.body.id;
|
||||||
|
try {
|
||||||
|
await req.apiClient.delete(`/academic-terms/${id}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Academic term delete API notice:', e.response?.data || e.message);
|
||||||
|
}
|
||||||
|
res.redirect('/admin/academic_terms_list');
|
||||||
|
};
|
||||||
|
|
||||||
router.get(['/academic_terms_list', '/academic_terms', '/academic-terms'], handleAcademicTermsList);
|
router.get(['/academic_terms_list', '/academic_terms', '/academic-terms'], handleAcademicTermsList);
|
||||||
router.post(['/academic_term_add', '/academic-terms/add'], handleAcademicTermAdd);
|
router.post(['/academic_term_add', '/academic-terms/add'], handleAcademicTermAdd);
|
||||||
router.post(['/academic_term_edit', '/academic_term_edit/:id'], handleAcademicTermEdit);
|
router.post(['/academic_term_edit', '/academic_term_edit/:id'], handleAcademicTermEdit);
|
||||||
router.post(['/academic_term_set_current', '/academic_term_set_current/:id'], handleAcademicTermSetCurrent);
|
router.post(['/academic_term_set_current', '/academic_term_set_current/:id'], handleAcademicTermSetCurrent);
|
||||||
|
router.post(['/academic_term_toggle', '/academic_term_toggle/:id', '/academic-terms/toggle/:id'], handleAcademicTermToggle);
|
||||||
|
router.post(['/academic_term_delete', '/academic_term_delete/:id', '/academic-terms/delete/:id'], handleAcademicTermDelete);
|
||||||
|
|
||||||
// ─── 9. MILESTONE TYPES ───────────────────────────────────────────────────────
|
// ─── 9. MILESTONE TYPES ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1242,22 +1378,158 @@ const handleGlobalSettings = async (req, res) => {
|
|||||||
google_oauth: { allowed_domains: 'unicaba.edu.ar', callback_url: '/auth/google/callback' },
|
google_oauth: { allowed_domains: 'unicaba.edu.ar', callback_url: '/auth/google/callback' },
|
||||||
moodle: { server_url: 'http://10.0.0.207/moodle', auto_sync_enabled: true, sync_interval_minutes: 15 }
|
moodle: { server_url: 'http://10.0.0.207/moodle', auto_sync_enabled: true, sync_interval_minutes: 15 }
|
||||||
};
|
};
|
||||||
|
let demo_access_enabled = true;
|
||||||
|
let demo_accounts_active = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await req.apiClient.get('/admin/settings/all');
|
const resp = await req.apiClient.get('/admin/settings/all');
|
||||||
if (resp.data && resp.data.data) {
|
if (resp.data && resp.data.settings) {
|
||||||
|
settings = resp.data.settings;
|
||||||
|
} else if (resp.data && resp.data.data) {
|
||||||
settings = resp.data.data;
|
settings = resp.data.data;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Global settings fetch notice:', e.message);
|
console.warn('Global settings fetch notice:', e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalización robusta de auth_providers para asegurar compatibilidad total con Nunjucks
|
||||||
|
if (settings && settings.auth_providers) {
|
||||||
|
const ap = settings.auth_providers;
|
||||||
|
const isLocal = ap.local !== undefined ? Boolean(ap.local) : (ap.local_enabled !== undefined ? Boolean(ap.local_enabled) : true);
|
||||||
|
const isGoogle = ap.google !== undefined ? Boolean(ap.google) : (ap.google_enabled !== undefined ? Boolean(ap.google_enabled) : false);
|
||||||
|
const isMoodle = ap.moodle !== undefined ? Boolean(ap.moodle) : (ap.moodle_enabled !== undefined ? Boolean(ap.moodle_enabled) : false);
|
||||||
|
settings.auth_providers = {
|
||||||
|
local: isLocal,
|
||||||
|
local_enabled: isLocal,
|
||||||
|
google: isGoogle,
|
||||||
|
google_enabled: isGoogle,
|
||||||
|
moodle: isMoodle,
|
||||||
|
moodle_enabled: isMoodle
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
settings.auth_providers = {
|
||||||
|
local: true,
|
||||||
|
local_enabled: true,
|
||||||
|
google: false,
|
||||||
|
google_enabled: false,
|
||||||
|
moodle: false,
|
||||||
|
moodle_enabled: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings && settings.smtp) {
|
||||||
|
settings.smtp.password_masked = settings.smtp.password_masked || settings.smtp.password || '';
|
||||||
|
settings.smtp.from_email = settings.smtp.from_email || settings.smtp.sender_email || '';
|
||||||
|
settings.smtp.from_name = settings.smtp.from_name || settings.smtp.sender_name || 'Admin Edu-Space';
|
||||||
|
}
|
||||||
|
if (settings && settings.google_oauth) {
|
||||||
|
settings.google_oauth.client_secret_masked = settings.google_oauth.client_secret_masked || settings.google_oauth.client_secret || '';
|
||||||
|
}
|
||||||
|
if (settings && settings.moodle) {
|
||||||
|
settings.moodle.ws_token_masked = settings.moodle.ws_token_masked || settings.moodle.ws_token || '';
|
||||||
|
}
|
||||||
|
if (settings) {
|
||||||
|
settings.ui = settings.ui || { show_role_banner: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const demoResp = await req.apiClient.get('/settings/demo-mode');
|
||||||
|
if (demoResp.data) {
|
||||||
|
if (demoResp.data.demo_access_enabled !== undefined) {
|
||||||
|
demo_access_enabled = Boolean(demoResp.data.demo_access_enabled);
|
||||||
|
}
|
||||||
|
if (demoResp.data.demo_accounts_active !== undefined) {
|
||||||
|
demo_accounts_active = Boolean(demoResp.data.demo_accounts_active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Demo mode status fetch notice:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
res.render('admin/settings/global_config', {
|
res.render('admin/settings/global_config', {
|
||||||
title: 'Configuración Global del Sistema - Admin Edu-Space',
|
title: 'Configuración Global del Sistema - Admin Edu-Space',
|
||||||
settings
|
settings,
|
||||||
|
demo_access_enabled,
|
||||||
|
demo_accounts_active
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
router.get(['/settings', '/global-config', '/config'], handleGlobalSettings);
|
router.get(['/settings', '/global-config', '/config'], handleGlobalSettings);
|
||||||
|
|
||||||
|
// Endpoint específico para alternar modo demo y cuentas default
|
||||||
|
router.post('/settings/demo-mode', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const resp = await req.apiClient.post('/settings/demo-mode', req.body);
|
||||||
|
return res.status(resp.status || 200).json(resp.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in POST /admin/settings/demo-mode:', err.response?.data || err.message);
|
||||||
|
return res.status(err.response?.status || 500).json(err.response?.data || {
|
||||||
|
status: 'error',
|
||||||
|
message: 'Error al comunicarse con el servicio de configuración: ' + (err.response?.data?.message || err.message)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Endpoint explícito para consultar y actualizar métodos de autenticación
|
||||||
|
router.get('/settings/auth-providers', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const resp = await req.apiClient.get('/settings/all');
|
||||||
|
const ap = resp.data?.settings?.auth_providers || resp.data?.data?.auth_providers || {};
|
||||||
|
return res.json({
|
||||||
|
status: 'success',
|
||||||
|
auth_providers: {
|
||||||
|
local: ap.local !== undefined ? Boolean(ap.local) : (ap.local_enabled !== undefined ? Boolean(ap.local_enabled) : true),
|
||||||
|
google: ap.google !== undefined ? Boolean(ap.google) : (ap.google_enabled !== undefined ? Boolean(ap.google_enabled) : false),
|
||||||
|
moodle: ap.moodle !== undefined ? Boolean(ap.moodle) : (ap.moodle_enabled !== undefined ? Boolean(ap.moodle_enabled) : false)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({ status: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/settings/auth-providers', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const resp = await req.apiClient.post('/settings/auth-providers', req.body);
|
||||||
|
return res.status(resp.status || 200).json(resp.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in POST /admin/settings/auth-providers:', err.response?.data || err.message);
|
||||||
|
return res.status(err.response?.status || 500).json(err.response?.data || {
|
||||||
|
status: 'error',
|
||||||
|
message: 'Error al actualizar métodos de autenticación: ' + (err.response?.data?.message || err.message)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Función forwarder para acciones de configuración hacia Flask
|
||||||
|
const forwardSettingsAction = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const relativeUrl = req.originalUrl.replace(/^\/admin/, '');
|
||||||
|
const resp = await req.apiClient({
|
||||||
|
method: req.method,
|
||||||
|
url: relativeUrl,
|
||||||
|
data: req.body,
|
||||||
|
params: req.query
|
||||||
|
});
|
||||||
|
return res.status(resp.status).json(resp.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error in settings proxy (${req.method} ${req.originalUrl}):`, err.response?.data || err.message);
|
||||||
|
return res.status(err.response?.status || 500).json(err.response?.data || {
|
||||||
|
status: 'error',
|
||||||
|
message: err.response?.data?.message || err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
router.post('/settings/smtp', forwardSettingsAction);
|
||||||
|
router.post('/settings/smtp/test', forwardSettingsAction);
|
||||||
|
router.post('/settings/google-oauth', forwardSettingsAction);
|
||||||
|
router.post('/settings/moodle', forwardSettingsAction);
|
||||||
|
router.post('/settings/moodle/test', forwardSettingsAction);
|
||||||
|
router.post('/settings/moodle/sync', forwardSettingsAction);
|
||||||
|
router.get('/settings/moodle/stats', forwardSettingsAction);
|
||||||
|
router.post('/settings/ui', forwardSettingsAction);
|
||||||
|
router.post('/settings/reset_system', forwardSettingsAction);
|
||||||
|
router.post('/system/reset', forwardSettingsAction);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -2,14 +2,26 @@ const express = require('express');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const apiClient = require('../services/apiClient');
|
const apiClient = require('../services/apiClient');
|
||||||
|
|
||||||
|
const isSecureCookie = (req) => {
|
||||||
|
if (process.env.COOKIE_SECURE === 'true') return true;
|
||||||
|
if (process.env.COOKIE_SECURE === 'false') return false;
|
||||||
|
return Boolean(req.secure || req.protocol === 'https' || req.get('x-forwarded-proto') === 'https');
|
||||||
|
};
|
||||||
|
|
||||||
router.get('/login', async (req, res) => {
|
router.get('/login', async (req, res) => {
|
||||||
let providers = { local: true, google: false, moodle: false };
|
let providers = { local: true, google: false, moodle: false };
|
||||||
let google_client_id = '';
|
let google_client_id = '';
|
||||||
|
let demo_access_enabled = true;
|
||||||
try {
|
try {
|
||||||
const resp = await apiClient.get('/auth/providers');
|
const resp = await apiClient.get('/auth/providers');
|
||||||
if (resp.data && resp.data.providers) {
|
if (resp.data) {
|
||||||
|
if (resp.data.providers) {
|
||||||
providers = resp.data.providers;
|
providers = resp.data.providers;
|
||||||
|
}
|
||||||
google_client_id = resp.data.google_client_id || '';
|
google_client_id = resp.data.google_client_id || '';
|
||||||
|
if (resp.data.demo_access_enabled !== undefined) {
|
||||||
|
demo_access_enabled = Boolean(resp.data.demo_access_enabled);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Fallback default
|
// Fallback default
|
||||||
@@ -18,7 +30,8 @@ router.get('/login', async (req, res) => {
|
|||||||
res.render('auth/login', {
|
res.render('auth/login', {
|
||||||
error: req.query.error || null,
|
error: req.query.error || null,
|
||||||
providers,
|
providers,
|
||||||
google_client_id
|
google_client_id,
|
||||||
|
demo_access_enabled
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,7 +51,7 @@ router.post('/login', async (req, res) => {
|
|||||||
if (token) {
|
if (token) {
|
||||||
res.cookie('auth_token', token, {
|
res.cookie('auth_token', token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: isSecureCookie(req),
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
maxAge: 24 * 60 * 60 * 1000 // 1 día
|
maxAge: 24 * 60 * 60 * 1000 // 1 día
|
||||||
});
|
});
|
||||||
@@ -66,7 +79,7 @@ router.post('/moodle', async (req, res) => {
|
|||||||
if (token) {
|
if (token) {
|
||||||
res.cookie('auth_token', token, {
|
res.cookie('auth_token', token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: isSecureCookie(req),
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
maxAge: 24 * 60 * 60 * 1000
|
maxAge: 24 * 60 * 60 * 1000
|
||||||
});
|
});
|
||||||
@@ -89,7 +102,7 @@ router.post('/google', async (req, res) => {
|
|||||||
if (token) {
|
if (token) {
|
||||||
res.cookie('auth_token', token, {
|
res.cookie('auth_token', token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: isSecureCookie(req),
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
maxAge: 24 * 60 * 60 * 1000
|
maxAge: 24 * 60 * 60 * 1000
|
||||||
});
|
});
|
||||||
@@ -130,12 +143,12 @@ router.get('/impersonate/:id', requireAuth, async (req, res) => {
|
|||||||
if (targetUser) {
|
if (targetUser) {
|
||||||
res.cookie('impersonate_user_id', targetUserId.toString(), {
|
res.cookie('impersonate_user_id', targetUserId.toString(), {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: isSecureCookie(req),
|
||||||
sameSite: 'lax'
|
sameSite: 'lax'
|
||||||
});
|
});
|
||||||
res.cookie('impersonate_user_data', JSON.stringify(targetUser), {
|
res.cookie('impersonate_user_data', JSON.stringify(targetUser), {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: isSecureCookie(req),
|
||||||
sameSite: 'lax'
|
sameSite: 'lax'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -159,4 +172,65 @@ router.get('/stop-impersonating', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cambiar Contraseña Propia (GET)
|
||||||
|
router.get(['/change_password', '/change-password'], requireAuth, (req, res) => {
|
||||||
|
res.render('auth/change_password', {
|
||||||
|
title: 'Cambiar Contraseña — Edu-Space',
|
||||||
|
error: req.query.error || null,
|
||||||
|
success: req.query.success || null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cambiar Contraseña Propia (POST)
|
||||||
|
router.post(['/change_password', '/change-password'], requireAuth, async (req, res) => {
|
||||||
|
const { current_password, new_password, confirm_password } = req.body;
|
||||||
|
|
||||||
|
if (!current_password || !new_password || !confirm_password) {
|
||||||
|
return res.render('auth/change_password', {
|
||||||
|
title: 'Cambiar Contraseña — Edu-Space',
|
||||||
|
error: 'Todos los campos son obligatorios.',
|
||||||
|
success: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new_password !== confirm_password) {
|
||||||
|
return res.render('auth/change_password', {
|
||||||
|
title: 'Cambiar Contraseña — Edu-Space',
|
||||||
|
error: 'La nueva contraseña y su confirmación no coinciden.',
|
||||||
|
success: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new_password.length < 6) {
|
||||||
|
return res.render('auth/change_password', {
|
||||||
|
title: 'Cambiar Contraseña — Edu-Space',
|
||||||
|
error: 'La nueva contraseña debe tener al menos 6 caracteres.',
|
||||||
|
success: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await req.apiClient.post('/auth/change_password', {
|
||||||
|
current_password,
|
||||||
|
new_password,
|
||||||
|
confirm_password
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.render('auth/change_password', {
|
||||||
|
title: 'Cambiar Contraseña — Edu-Space',
|
||||||
|
success: response.data?.message || 'Contraseña actualizada correctamente.',
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Change password error:', error.response?.data || error.message);
|
||||||
|
const errMsg = error.response?.data?.message || 'Error al actualizar la contraseña. Verifique su clave actual.';
|
||||||
|
return res.render('auth/change_password', {
|
||||||
|
title: 'Cambiar Contraseña — Edu-Space',
|
||||||
|
error: errMsg,
|
||||||
|
success: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
||||||
|
|||||||
@@ -5,112 +5,120 @@ const { createFormMock } = require('../utils/formHelper');
|
|||||||
|
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
|
||||||
// Mock storage of buildings to support CRUD in memory/BFF layer
|
// Fallback data si la API estuviera inaccesible (con capacidad física real)
|
||||||
let buildingsData = [
|
const defaultBuildings = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
code: 'CENTRAL',
|
code: 'CENTRAL',
|
||||||
name: 'Edificio Central',
|
name: 'Edificio Central',
|
||||||
is_active: true,
|
is_active: true,
|
||||||
address: 'Av. Paseo Colón 255, CABA',
|
is_virtual: false,
|
||||||
description: 'Sede académica principal, aulas de grado, laboratorios y auditorio.',
|
address: 'Tte. Gral. Juan Domingo Perón 802, CABA',
|
||||||
|
description: 'Sede central institucional de UniCABA. Aulas de grado, auditorio y laboratorios.',
|
||||||
floors: 4,
|
floors: 4,
|
||||||
classrooms_count: 29,
|
classrooms_count: 29,
|
||||||
total_capacity: 1250
|
physical_capacity: 1250,
|
||||||
|
total_capacity: 1250,
|
||||||
|
capacity_display: '1250'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
code: 'VIRTUAL',
|
code: 'VIRTUAL',
|
||||||
name: 'Campus Virtual',
|
name: 'Campus Virtual',
|
||||||
is_active: true,
|
is_active: true,
|
||||||
address: 'campus.unicaba.edu.ar',
|
is_virtual: true,
|
||||||
|
address: 'campus.unicaba.edu.ar / MS Teams & Moodle',
|
||||||
description: 'Entorno de aprendizaje digital, aulas sincrónicas y videoconferencias.',
|
description: 'Entorno de aprendizaje digital, aulas sincrónicas y videoconferencias.',
|
||||||
floors: 1,
|
floors: 0,
|
||||||
classrooms_count: 2,
|
classrooms_count: 2,
|
||||||
total_capacity: 9999
|
physical_capacity: 0,
|
||||||
|
total_capacity: 0,
|
||||||
|
capacity_display: 'Ilimitada (Virtual)'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// Helper to compute live totals
|
|
||||||
const getLiveStats = async (apiClient) => {
|
|
||||||
try {
|
|
||||||
const response = await apiClient.get('/classrooms');
|
|
||||||
const classrooms = (response.data && response.data.classrooms) || [];
|
|
||||||
const physical = classrooms.filter(c => !c.is_virtual && (!c.code || !c.code.includes('VIRTUAL')));
|
|
||||||
const virtual = classrooms.filter(c => c.is_virtual || (c.code && c.code.includes('VIRTUAL')));
|
|
||||||
|
|
||||||
const physCap = physical.reduce((acc, c) => acc + (c.capacity || 0), 0);
|
|
||||||
|
|
||||||
if (buildingsData[0]) {
|
|
||||||
buildingsData[0].classrooms_count = physical.length;
|
|
||||||
buildingsData[0].total_capacity = physCap;
|
|
||||||
}
|
|
||||||
if (buildingsData[1]) {
|
|
||||||
buildingsData[1].classrooms_count = virtual.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalRooms = classrooms.length;
|
|
||||||
const totalCap = physCap + (buildingsData[1]?.total_capacity || 9999);
|
|
||||||
|
|
||||||
return { totalRooms, totalCap };
|
|
||||||
} catch (e) {
|
|
||||||
return { totalRooms: 31, totalCap: 11249 };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 1. List Buildings
|
// 1. List Buildings
|
||||||
const handleListBuildings = async (req, res) => {
|
const handleListBuildings = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { search = '', status = '' } = req.query;
|
const { search = '', status = '' } = req.query;
|
||||||
const stats = await getLiveStats(req.apiClient);
|
let buildings = [];
|
||||||
|
let stats = {
|
||||||
|
total_buildings: 2,
|
||||||
|
total_classrooms: 31,
|
||||||
|
physical_classrooms_count: 29,
|
||||||
|
total_physical_capacity: 1250,
|
||||||
|
total_floors: 4
|
||||||
|
};
|
||||||
|
|
||||||
let filtered = [...buildingsData];
|
try {
|
||||||
|
const apiRes = await req.apiClient.get('/buildings', { params: { search, status } });
|
||||||
if (search) {
|
if (apiRes.data && apiRes.data.buildings) {
|
||||||
const s = search.toLowerCase();
|
buildings = apiRes.data.buildings;
|
||||||
filtered = filtered.filter(b =>
|
if (apiRes.data.stats) {
|
||||||
(b.name && b.name.toLowerCase().includes(s)) ||
|
stats = apiRes.data.stats;
|
||||||
(b.code && b.code.toLowerCase().includes(s)) ||
|
}
|
||||||
(b.address && b.address.toLowerCase().includes(s))
|
}
|
||||||
);
|
} catch (apiErr) {
|
||||||
|
console.warn('API /buildings notice, using fallback:', apiErr.message);
|
||||||
|
buildings = [...defaultBuildings];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'active') {
|
// Si por alguna razón la lista vino vacía de la API y no había filtros de búsqueda, usar default
|
||||||
filtered = filtered.filter(b => b.is_active);
|
if (buildings.length === 0 && !search && !status) {
|
||||||
} else if (status === 'inactive') {
|
buildings = [...defaultBuildings];
|
||||||
filtered = filtered.filter(b => !b.is_active);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Asegurar campos calculados consistentes para cada edificio
|
||||||
|
buildings = buildings.map(b => {
|
||||||
|
const is_virtual = Boolean(b.is_virtual || (b.code && b.code.toUpperCase() === 'VIRTUAL') || (b.name && b.name.toUpperCase().includes('VIRTUAL')));
|
||||||
|
return {
|
||||||
|
...b,
|
||||||
|
is_virtual,
|
||||||
|
capacity_display: is_virtual ? 'Ilimitada (Virtual)' : String(b.physical_capacity !== undefined ? b.physical_capacity : (b.total_capacity || 0)),
|
||||||
|
total_capacity: is_virtual ? 0 : (b.physical_capacity !== undefined ? b.physical_capacity : (b.total_capacity || 0)),
|
||||||
|
floors_display: is_virtual ? 'Digital' : String(b.floors ?? 1)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Capacidad física total: suma exclusiva de sedes presenciales (nunca suma 9999)
|
||||||
|
const total_physical_capacity = stats.total_physical_capacity || buildings
|
||||||
|
.filter(b => !b.is_virtual)
|
||||||
|
.reduce((acc, b) => acc + (b.physical_capacity || b.total_capacity || 0), 0);
|
||||||
|
|
||||||
res.render('buildings/list', {
|
res.render('buildings/list', {
|
||||||
title: 'Gestión de Edificios - Edu-Space Admin',
|
title: 'Gestión de Edificios - Edu-Space Admin',
|
||||||
buildings: filtered,
|
buildings,
|
||||||
total_classrooms: stats.totalRooms,
|
total_classrooms: stats.total_classrooms || buildings.reduce((a, b) => a + (b.classrooms_count || 0), 0),
|
||||||
total_capacity: stats.totalCap,
|
total_capacity: total_physical_capacity,
|
||||||
|
total_physical_capacity,
|
||||||
search,
|
search,
|
||||||
status_filter: status
|
status_filter: status
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error rendering buildings list:', error.message);
|
console.error('Error rendering buildings list:', error.message);
|
||||||
res.render('buildings/list', {
|
res.render('buildings/list', {
|
||||||
title: 'Gestión de Edificios (Offline)',
|
title: 'Gestión de Edificios - Edu-Space Admin',
|
||||||
buildings: buildingsData,
|
buildings: defaultBuildings,
|
||||||
total_classrooms: 31,
|
total_classrooms: 31,
|
||||||
total_capacity: 11249,
|
total_capacity: 1250,
|
||||||
|
total_physical_capacity: 1250,
|
||||||
search: '',
|
search: '',
|
||||||
status_filter: ''
|
status_filter: ''
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
router.get('/', handleListBuildings);
|
router.get(['/', '/list', '/list_buildings'], handleListBuildings);
|
||||||
router.get('/list', handleListBuildings);
|
|
||||||
router.get('/list_buildings', handleListBuildings);
|
|
||||||
|
|
||||||
// 2. Add Building
|
// 2. Add Building (GET & POST)
|
||||||
router.get(['/add', '/add_building'], (req, res) => {
|
router.get(['/add', '/add_building'], (req, res) => {
|
||||||
const form = createFormMock({
|
const form = createFormMock({
|
||||||
is_active: true,
|
name: '',
|
||||||
floors: 3
|
code: '',
|
||||||
|
address: '',
|
||||||
|
floors: 1,
|
||||||
|
description: '',
|
||||||
|
is_active: true
|
||||||
});
|
});
|
||||||
res.render('buildings/form', {
|
res.render('buildings/form', {
|
||||||
title: 'Nuevo Edificio - Edu-Space Admin',
|
title: 'Nuevo Edificio - Edu-Space Admin',
|
||||||
@@ -119,50 +127,139 @@ router.get(['/add', '/add_building'], (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post(['/add', '/add_building'], (req, res) => {
|
router.post(['/add', '/add_building'], async (req, res) => {
|
||||||
|
try {
|
||||||
const { name, code, address, floors, description, is_active } = req.body;
|
const { name, code, address, floors, description, is_active } = req.body;
|
||||||
const newBuilding = {
|
const payload = {
|
||||||
id: buildingsData.length + 1,
|
name: (name || '').trim(),
|
||||||
name: name || `Edificio ${buildingsData.length + 1}`,
|
code: (code || '').trim().toUpperCase() || null,
|
||||||
code: code || `EDIF-${buildingsData.length + 1}`,
|
address: (address || '').trim() || null,
|
||||||
address: address || '',
|
|
||||||
floors: parseInt(floors, 10) || 1,
|
floors: parseInt(floors, 10) || 1,
|
||||||
description: description || '',
|
description: (description || '').trim() || null,
|
||||||
is_active: is_active === 'true' || is_active === 'on' || is_active === true,
|
is_active: is_active === 'true' || is_active === 'on' || is_active === true || is_active === '1'
|
||||||
classrooms_count: 0,
|
|
||||||
total_capacity: 0
|
|
||||||
};
|
};
|
||||||
buildingsData.push(newBuilding);
|
await req.apiClient.post('/buildings', payload);
|
||||||
res.redirect('/buildings/list_buildings');
|
res.redirect('/buildings/list_buildings?created=1');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Error creating building:', err.response?.data || err.message);
|
||||||
|
res.redirect('/buildings/list_buildings?error=create');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. View / Edit / Toggle Building
|
// 3. View Building
|
||||||
router.get(['/view/:id', '/view_building'], (req, res) => {
|
router.get(['/view/:id', '/view_building', '/:id'], async (req, res) => {
|
||||||
const id = parseInt(req.params.id || req.query.id, 10) || 1;
|
const id = parseInt(req.params.id || req.query.id, 10) || 1;
|
||||||
const building = buildingsData.find(b => b.id === id) || buildingsData[0];
|
let building = null;
|
||||||
|
let classrooms = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiRes = await req.apiClient.get(`/buildings/${id}`);
|
||||||
|
if (apiRes.data && apiRes.data.building) {
|
||||||
|
building = apiRes.data.building;
|
||||||
|
classrooms = apiRes.data.classrooms || [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
building = defaultBuildings.find(b => b.id === id) || defaultBuildings[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!building) {
|
||||||
|
return res.redirect('/buildings/list_buildings');
|
||||||
|
}
|
||||||
|
|
||||||
|
const is_virtual = Boolean(building.is_virtual || (building.code && building.code.toUpperCase() === 'VIRTUAL') || (building.name && building.name.toUpperCase().includes('VIRTUAL')));
|
||||||
|
building = {
|
||||||
|
...building,
|
||||||
|
is_virtual,
|
||||||
|
capacity_display: is_virtual ? 'Ilimitada (Virtual)' : String(building.physical_capacity || building.total_capacity || 0),
|
||||||
|
floors_display: is_virtual ? 'Digital' : String(building.floors ?? 1)
|
||||||
|
};
|
||||||
|
|
||||||
res.render('buildings/view', {
|
res.render('buildings/view', {
|
||||||
title: `Edificio ${building.name} - Edu-Space Admin`,
|
title: `Edificio ${building.name} - Edu-Space Admin`,
|
||||||
building,
|
building,
|
||||||
classrooms: []
|
classrooms
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get(['/edit/:id', '/edit_building'], (req, res) => {
|
// 4. Edit Building (GET)
|
||||||
|
const handleEditBuildingGet = async (req, res) => {
|
||||||
const id = parseInt(req.params.id || req.query.id, 10) || 1;
|
const id = parseInt(req.params.id || req.query.id, 10) || 1;
|
||||||
const building = buildingsData.find(b => b.id === id) || buildingsData[0];
|
let building = null;
|
||||||
const form = createFormMock(building);
|
|
||||||
|
try {
|
||||||
|
const apiRes = await req.apiClient.get(`/buildings/${id}`);
|
||||||
|
if (apiRes.data && apiRes.data.building) {
|
||||||
|
building = apiRes.data.building;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
building = defaultBuildings.find(b => b.id === id) || defaultBuildings[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!building) {
|
||||||
|
return res.redirect('/buildings/list_buildings');
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = createFormMock({
|
||||||
|
name: building.name,
|
||||||
|
code: building.code,
|
||||||
|
address: building.address,
|
||||||
|
floors: building.floors,
|
||||||
|
description: building.description,
|
||||||
|
is_active: building.is_active
|
||||||
|
});
|
||||||
|
|
||||||
res.render('buildings/form', {
|
res.render('buildings/form', {
|
||||||
title: `Editar Edificio - ${building.name}`,
|
title: `Editar Edificio - ${building.name}`,
|
||||||
building,
|
building,
|
||||||
form
|
form
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
router.get(['/edit/:id', '/:id/edit', '/edit_building'], handleEditBuildingGet);
|
||||||
|
|
||||||
|
// 5. Edit Building (POST)
|
||||||
|
const handleEditBuildingPost = async (req, res) => {
|
||||||
|
const id = parseInt(req.params.id || req.query.id || req.body.id, 10) || 1;
|
||||||
|
const { name, code, address, floors, description, is_active } = req.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: (name || '').trim(),
|
||||||
|
code: (code || '').trim().toUpperCase() || null,
|
||||||
|
address: (address || '').trim() || null,
|
||||||
|
floors: parseInt(floors, 10) || 0,
|
||||||
|
description: (description || '').trim() || null,
|
||||||
|
is_active: is_active === 'true' || is_active === 'on' || is_active === true || is_active === '1'
|
||||||
|
};
|
||||||
|
|
||||||
|
await req.apiClient.put(`/buildings/${id}`, payload);
|
||||||
|
res.redirect('/buildings/list_buildings?updated=1');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Error updating building:', err.response?.data || err.message);
|
||||||
|
res.redirect('/buildings/list_buildings?error=update');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
router.post(['/edit/:id', '/:id/edit', '/edit_building'], handleEditBuildingPost);
|
||||||
|
|
||||||
|
// 6. Toggle Building
|
||||||
|
router.post(['/toggle/:id', '/:id/toggle', '/toggle_building'], async (req, res) => {
|
||||||
|
const id = parseInt(req.params.id || req.query.id || req.body.id, 10) || 1;
|
||||||
|
try {
|
||||||
|
await req.apiClient.post(`/buildings/${id}/toggle`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Error toggling building:', e.message);
|
||||||
|
}
|
||||||
|
res.redirect('/buildings/list_buildings');
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post(['/toggle/:id', '/toggle_building'], (req, res) => {
|
// 7. Delete Building
|
||||||
const id = parseInt(req.params.id || req.query.id, 10) || 1;
|
router.post(['/delete/:id', '/:id/delete', '/delete_building'], async (req, res) => {
|
||||||
const building = buildingsData.find(b => b.id === id);
|
const id = parseInt(req.params.id || req.query.id || req.body.id, 10);
|
||||||
if (building) {
|
try {
|
||||||
building.is_active = !building.is_active;
|
await req.apiClient.delete(`/buildings/${id}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Error deleting building:', e.message);
|
||||||
}
|
}
|
||||||
res.redirect('/buildings/list_buildings');
|
res.redirect('/buildings/list_buildings');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,9 +5,14 @@ const { requireAuth } = require('../middlewares/authMiddleware');
|
|||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
|
||||||
const handleClassroomsList = async (req, res) => {
|
const handleClassroomsList = async (req, res) => {
|
||||||
|
try {
|
||||||
|
let allClassrooms = [];
|
||||||
try {
|
try {
|
||||||
const response = await req.apiClient.get('/classrooms');
|
const response = await req.apiClient.get('/classrooms');
|
||||||
const allClassrooms = (response.data && response.data.classrooms) || [];
|
allClassrooms = (response.data && response.data.classrooms) || [];
|
||||||
|
} catch (cErr) {
|
||||||
|
console.warn('Notice fetching classrooms:', cErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
const view_mode = (req.query.view_mode || 'physical').toLowerCase();
|
const view_mode = (req.query.view_mode || 'physical').toLowerCase();
|
||||||
const search = (req.query.search || '').toLowerCase().trim();
|
const search = (req.query.search || '').toLowerCase().trim();
|
||||||
@@ -17,7 +22,7 @@ const handleClassroomsList = async (req, res) => {
|
|||||||
|
|
||||||
// Filter based on view mode (physical vs virtual)
|
// Filter based on view mode (physical vs virtual)
|
||||||
let filtered = allClassrooms.filter(c => {
|
let filtered = allClassrooms.filter(c => {
|
||||||
const isVirt = Boolean(c.is_virtual || (c.code && c.code.includes('VIRTUAL')) || (c.building && c.building.toLowerCase().includes('virtual')));
|
const isVirt = Boolean(c.is_virtual || ((c.code || '').includes('VIRTUAL')) || ((c.building || '').toLowerCase().includes('virtual')));
|
||||||
if (view_mode === 'virtual') return isVirt;
|
if (view_mode === 'virtual') return isVirt;
|
||||||
return !isVirt;
|
return !isVirt;
|
||||||
});
|
});
|
||||||
@@ -25,9 +30,9 @@ const handleClassroomsList = async (req, res) => {
|
|||||||
// Additional filters
|
// Additional filters
|
||||||
if (search) {
|
if (search) {
|
||||||
filtered = filtered.filter(c =>
|
filtered = filtered.filter(c =>
|
||||||
(c.code && c.code.toLowerCase().includes(search)) ||
|
((c.code || '').toLowerCase().includes(search)) ||
|
||||||
(c.description && c.description.toLowerCase().includes(search)) ||
|
((c.description || '').toLowerCase().includes(search)) ||
|
||||||
(c.building && c.building.toLowerCase().includes(search))
|
((c.building || '').toLowerCase().includes(search))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,9 +48,21 @@ const handleClassroomsList = async (req, res) => {
|
|||||||
filtered = filtered.filter(c => c.is_active);
|
filtered = filtered.filter(c => c.is_active);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract unique buildings and floors
|
// Extract unique buildings and floors (consultando también /buildings si está disponible)
|
||||||
const buildingsMap = new Map();
|
const buildingsMap = new Map();
|
||||||
const floorsSet = new Set();
|
const floorsSet = new Set();
|
||||||
|
try {
|
||||||
|
const bRes = await req.apiClient.get('/buildings');
|
||||||
|
const apiBuildings = (bRes.data && bRes.data.buildings) || [];
|
||||||
|
apiBuildings.forEach(b => {
|
||||||
|
if (b.id && b.name) {
|
||||||
|
buildingsMap.set(b.id, { id: b.id, name: b.name });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (bErr) {
|
||||||
|
console.warn('Notice fetching buildings for classrooms filter:', bErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
allClassrooms.forEach(c => {
|
allClassrooms.forEach(c => {
|
||||||
if (c.building_id && c.building) {
|
if (c.building_id && c.building) {
|
||||||
buildingsMap.set(c.building_id, { id: c.building_id, name: c.building });
|
buildingsMap.set(c.building_id, { id: c.building_id, name: c.building });
|
||||||
@@ -55,8 +72,8 @@ const handleClassroomsList = async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const physical_count = allClassrooms.filter(c => !c.is_virtual && !c.code.includes('VIRTUAL')).length;
|
const physical_count = allClassrooms.filter(c => !c.is_virtual && !(c.code || '').includes('VIRTUAL')).length;
|
||||||
const virtual_count = allClassrooms.filter(c => c.is_virtual || c.code.includes('VIRTUAL')).length;
|
const virtual_count = allClassrooms.filter(c => Boolean(c.is_virtual || (c.code || '').includes('VIRTUAL'))).length;
|
||||||
|
|
||||||
const dateFilters = {
|
const dateFilters = {
|
||||||
today: new Date().toISOString().split('T')[0],
|
today: new Date().toISOString().split('T')[0],
|
||||||
@@ -80,7 +97,7 @@ const handleClassroomsList = async (req, res) => {
|
|||||||
|
|
||||||
// Format classrooms with fallback fields and assigned commissions/classes
|
// Format classrooms with fallback fields and assigned commissions/classes
|
||||||
const formattedItems = filtered.map(c => {
|
const formattedItems = filtered.map(c => {
|
||||||
const c_res = allReservations.filter(r => String(r.classroom_id) === String(c.id) || (r.classroom && String(r.classroom.id) === String(c.id)));
|
const c_res = allReservations.filter(r => (c.id !== undefined && (String(r.classroom_id) === String(c.id) || (r.classroom && String(r.classroom.id) === String(c.id)))));
|
||||||
const commMap = new Map();
|
const commMap = new Map();
|
||||||
c_res.forEach(r => {
|
c_res.forEach(r => {
|
||||||
const cid = r.commission_id || (r.commission && r.commission.id) || r.id;
|
const cid = r.commission_id || (r.commission && r.commission.id) || r.id;
|
||||||
@@ -88,15 +105,15 @@ const handleClassroomsList = async (req, res) => {
|
|||||||
commMap.set(cid, {
|
commMap.set(cid, {
|
||||||
commission_id: cid,
|
commission_id: cid,
|
||||||
commission_code: (r.commission && r.commission.code) || 'C1',
|
commission_code: (r.commission && r.commission.code) || 'C1',
|
||||||
subject_name: r.subject_name || r.purpose,
|
subject_name: r.subject_name || r.purpose || 'Clase Regular',
|
||||||
subject_code: r.subject_code || '',
|
subject_code: r.subject_code || '',
|
||||||
shift: r.shift || 'Tarde'
|
shift: r.shift || 'Tarde'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const day_res = c_res.filter(r => r.start_time && (r.start_time.startsWith('2026-09-18') || r.start_time.startsWith('2026-09-15')));
|
const day_res = c_res.filter(r => (r.start_time || '').startsWith('2026-09-18') || (r.start_time || '').startsWith('2026-09-15'));
|
||||||
const week_res = c_res.filter(r => r.start_time && r.start_time >= '2026-09-14' && r.start_time <= '2026-09-20');
|
const week_res = c_res.filter(r => (r.start_time || '') >= '2026-09-14' && (r.start_time || '') <= '2026-09-20');
|
||||||
const day_pct = Math.min(100, Math.round((day_res.length / 3) * 100));
|
const day_pct = Math.min(100, Math.round((day_res.length / 3) * 100));
|
||||||
const week_pct = Math.min(100, Math.round((week_res.length / 18) * 100));
|
const week_pct = Math.min(100, Math.round((week_res.length / 18) * 100));
|
||||||
const month_pct = Math.min(100, Math.round((c_res.length / 72) * 100));
|
const month_pct = Math.min(100, Math.round((c_res.length / 72) * 100));
|
||||||
@@ -129,8 +146,8 @@ const handleClassroomsList = async (req, res) => {
|
|||||||
total_count: 0
|
total_count: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
const virtualClassroomObj = allClassrooms.find(c => Boolean(c.is_virtual || (c.code && c.code.includes('VIRTUAL'))));
|
const virtualClassroomObj = allClassrooms.find(c => Boolean(c.is_virtual || ((c.code || '').includes('VIRTUAL'))));
|
||||||
const virtual_classroom_id = virtualClassroomObj ? virtualClassroomObj.id : 32;
|
const virtual_classroom_id = virtualClassroomObj ? virtualClassroomObj.id : null;
|
||||||
|
|
||||||
if (view_mode === 'virtual') {
|
if (view_mode === 'virtual') {
|
||||||
try {
|
try {
|
||||||
@@ -162,12 +179,14 @@ const handleClassroomsList = async (req, res) => {
|
|||||||
|
|
||||||
virtual_rooms = Array.from(commMap.values()).map(data => {
|
virtual_rooms = Array.from(commMap.values()).map(data => {
|
||||||
const resList = data.reservations;
|
const resList = data.reservations;
|
||||||
const dayCnt = resList.filter(r => r.start_time.startsWith(todayStr)).length;
|
const dayCnt = resList.filter(r => (r.start_time || '').startsWith(todayStr)).length;
|
||||||
const weekCnt = resList.filter(r => {
|
const weekCnt = resList.filter(r => {
|
||||||
|
if (!r.start_time) return false;
|
||||||
const d = new Date(r.start_time);
|
const d = new Date(r.start_time);
|
||||||
return d >= weekStart && d <= weekEnd;
|
return d >= weekStart && d <= weekEnd;
|
||||||
}).length;
|
}).length;
|
||||||
const monthCnt = resList.filter(r => {
|
const monthCnt = resList.filter(r => {
|
||||||
|
if (!r.start_time) return false;
|
||||||
const d = new Date(r.start_time);
|
const d = new Date(r.start_time);
|
||||||
return d >= monthStart && d <= monthEnd;
|
return d >= monthStart && d <= monthEnd;
|
||||||
}).length;
|
}).length;
|
||||||
@@ -186,27 +205,29 @@ const handleClassroomsList = async (req, res) => {
|
|||||||
week_count: weekCnt,
|
week_count: weekCnt,
|
||||||
month_count: monthCnt,
|
month_count: monthCnt,
|
||||||
total_count: resList.length,
|
total_count: resList.length,
|
||||||
reservations: resList.sort((a, b) => a.start_time.localeCompare(b.start_time))
|
reservations: resList.sort((a, b) => (a.start_time || '').localeCompare(b.start_time || ''))
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
virtual_rooms = virtual_rooms.filter(room =>
|
virtual_rooms = virtual_rooms.filter(room =>
|
||||||
room.subject_name.toLowerCase().includes(search) ||
|
(room.subject_name || '').toLowerCase().includes(search) ||
|
||||||
room.commission_code.toLowerCase().includes(search) ||
|
(room.commission_code || '').toLowerCase().includes(search) ||
|
||||||
room.subject_code.toLowerCase().includes(search)
|
(room.subject_code || '').toLowerCase().includes(search)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
virtual_rooms.sort((a, b) => a.subject_name.localeCompare(b.subject_name));
|
virtual_rooms.sort((a, b) => (a.subject_name || '').localeCompare(b.subject_name || ''));
|
||||||
|
|
||||||
global_virtual_metrics = {
|
global_virtual_metrics = {
|
||||||
today_count: virtualReservations.filter(r => r.start_time.startsWith(todayStr)).length,
|
today_count: virtualReservations.filter(r => (r.start_time || '').startsWith(todayStr)).length,
|
||||||
week_count: virtualReservations.filter(r => {
|
week_count: virtualReservations.filter(r => {
|
||||||
|
if (!r.start_time) return false;
|
||||||
const d = new Date(r.start_time);
|
const d = new Date(r.start_time);
|
||||||
return d >= weekStart && d <= weekEnd;
|
return d >= weekStart && d <= weekEnd;
|
||||||
}).length,
|
}).length,
|
||||||
month_count: virtualReservations.filter(r => {
|
month_count: virtualReservations.filter(r => {
|
||||||
|
if (!r.start_time) return false;
|
||||||
const d = new Date(r.start_time);
|
const d = new Date(r.start_time);
|
||||||
return d >= monthStart && d <= monthEnd;
|
return d >= monthStart && d <= monthEnd;
|
||||||
}).length,
|
}).length,
|
||||||
@@ -247,10 +268,11 @@ const handleClassroomsList = async (req, res) => {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in classrooms list route:', error.message);
|
console.error('Error in classrooms list route:', error.message);
|
||||||
|
const view_mode = (req.query.view_mode || 'physical').toLowerCase();
|
||||||
res.render('classrooms/list', {
|
res.render('classrooms/list', {
|
||||||
title: 'Aulas y Espacios (Offline)',
|
title: 'Aulas y Espacios - Edu-Space Admin',
|
||||||
classrooms: { items: [], total: 0, pages: 1, page: 1, has_prev: false, has_next: false, iter_pages: () => [1] },
|
classrooms: { items: [], total: 0, pages: 1, page: 1, has_prev: false, has_next: false, iter_pages: () => [1] },
|
||||||
view_mode: 'physical',
|
view_mode,
|
||||||
search: '',
|
search: '',
|
||||||
building_filter: '',
|
building_filter: '',
|
||||||
floor_filter: '',
|
floor_filter: '',
|
||||||
@@ -259,8 +281,17 @@ const handleClassroomsList = async (req, res) => {
|
|||||||
virtual_count: 0,
|
virtual_count: 0,
|
||||||
buildings: [],
|
buildings: [],
|
||||||
floors: [],
|
floors: [],
|
||||||
occupancy_metrics: { get: (id, defVal) => defVal },
|
occupancy_metrics: {},
|
||||||
date_filters: {}
|
date_filters: {},
|
||||||
|
virtual_rooms: [],
|
||||||
|
global_virtual_metrics: {
|
||||||
|
today_count: 0,
|
||||||
|
week_count: 0,
|
||||||
|
month_count: 0,
|
||||||
|
commissions_count: 0,
|
||||||
|
total_count: 0
|
||||||
|
},
|
||||||
|
virtual_classroom_id: null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -270,38 +301,53 @@ const { createFormMock } = require('../utils/formHelper');
|
|||||||
const handleAddClassroom = async (req, res) => {
|
const handleAddClassroom = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
let allClassrooms = [];
|
let allClassrooms = [];
|
||||||
|
let allBuildings = [];
|
||||||
try {
|
try {
|
||||||
const response = await req.apiClient.get('/classrooms');
|
const [cRes, bRes] = await Promise.allSettled([
|
||||||
allClassrooms = (response.data && response.data.classrooms) || [];
|
req.apiClient.get('/classrooms'),
|
||||||
|
req.apiClient.get('/buildings')
|
||||||
|
]);
|
||||||
|
if (cRes.status === 'fulfilled' && cRes.value.data?.classrooms) {
|
||||||
|
allClassrooms = cRes.value.data.classrooms;
|
||||||
|
}
|
||||||
|
if (bRes.status === 'fulfilled' && bRes.value.data?.buildings) {
|
||||||
|
allBuildings = bRes.value.data.buildings;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Could not fetch classrooms for building floors:', e.message);
|
console.warn('Could not fetch classrooms/buildings for add page:', e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map floors per building
|
|
||||||
const buildingFloors = {
|
|
||||||
'1': ['PB', '1', '2', '3'],
|
|
||||||
'2': ['Virtual']
|
|
||||||
};
|
|
||||||
const buildingsMap = new Map();
|
const buildingsMap = new Map();
|
||||||
buildingsMap.set('1', { id: 1, name: 'Edificio Central' });
|
allBuildings.forEach(b => {
|
||||||
buildingsMap.set('2', { id: 2, name: 'Campus Virtual' });
|
if (b.id && b.name) {
|
||||||
|
buildingsMap.set(String(b.id), { id: b.id, name: b.name });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildingFloors = {};
|
||||||
|
allBuildings.forEach(b => {
|
||||||
|
const bId = String(b.id);
|
||||||
|
buildingFloors[bId] = b.is_virtual ? ['Digital'] : ['PB', '1', '2'];
|
||||||
|
});
|
||||||
|
|
||||||
allClassrooms.forEach(c => {
|
allClassrooms.forEach(c => {
|
||||||
const bId = String(c.building_id || 1);
|
const bId = String(c.building_id || '');
|
||||||
|
if (bId) {
|
||||||
if (!buildingFloors[bId]) buildingFloors[bId] = [];
|
if (!buildingFloors[bId]) buildingFloors[bId] = [];
|
||||||
if (c.floor && !buildingFloors[bId].includes(c.floor)) {
|
if (c.floor && !buildingFloors[bId].includes(c.floor)) {
|
||||||
buildingFloors[bId].push(c.floor);
|
buildingFloors[bId].push(c.floor);
|
||||||
}
|
}
|
||||||
if (c.building_id && c.building) {
|
if (c.building) {
|
||||||
buildingsMap.set(bId, { id: c.building_id, name: c.building });
|
buildingsMap.set(bId, { id: c.building_id, name: c.building });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildings = Array.from(buildingsMap.values());
|
const buildings = Array.from(buildingsMap.values());
|
||||||
const buildingOptions = [
|
const buildingOptions = [
|
||||||
{ value: '', text: '-- Selecciona Edificio / Sede --' },
|
{ value: '', text: buildings.length > 0 ? '-- Selecciona Edificio / Sede --' : '-- Sin sedes registradas (Cree una abajo) --' },
|
||||||
...buildings.map(b => ({ value: b.id, text: b.name })),
|
...buildings.map(b => ({ value: b.id, text: b.name })),
|
||||||
{ value: 'new', text: '+ Crear nuevo edificio' }
|
{ value: 'new', text: '+ Crear nuevo edificio / sede' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const form = createFormMock({
|
const form = createFormMock({
|
||||||
@@ -327,21 +373,33 @@ const handlePostClassroom = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { building_id, new_building_name, floor, room_number, capacity, description, is_active } = req.body;
|
const { building_id, new_building_name, floor, room_number, capacity, description, is_active } = req.body;
|
||||||
|
|
||||||
let targetBuilding = building_id;
|
const isNewBuilding = building_id === 'new' || !building_id;
|
||||||
if (building_id === 'new' && new_building_name) {
|
const bName = (new_building_name || '').trim();
|
||||||
targetBuilding = new_building_name;
|
const bId = (!isNewBuilding && building_id) ? parseInt(building_id, 10) : null;
|
||||||
}
|
|
||||||
|
const isVirtual = Boolean(
|
||||||
|
(bName && bName.toLowerCase().includes('virtual')) ||
|
||||||
|
String(room_number).toUpperCase().includes('VIRTUAL') ||
|
||||||
|
String(floor).toUpperCase() === 'DIGITAL'
|
||||||
|
);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
code: room_number || `Aula-${Date.now()}`,
|
code: room_number || `Aula-${Date.now()}`,
|
||||||
capacity: (capacity === '∞' || capacity === '0' || !capacity) ? 0 : parseInt(capacity, 10),
|
capacity: (isVirtual || capacity === '∞' || capacity === '0' || !capacity) ? 0 : parseInt(capacity, 10),
|
||||||
floor: floor || 'PB',
|
floor: floor || (isVirtual ? 'Digital' : 'PB'),
|
||||||
building_id: (targetBuilding === '2' || targetBuilding === 'Campus Virtual') ? 2 : 1,
|
|
||||||
description: description || '',
|
description: description || '',
|
||||||
is_virtual: Boolean(targetBuilding === '2' || targetBuilding === 'Campus Virtual'),
|
is_virtual: isVirtual,
|
||||||
is_active: is_active === 'true' || is_active === 'on' || is_active === true
|
is_active: is_active === 'true' || is_active === 'on' || is_active === true
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (bId && !isNaN(bId) && bId > 0) {
|
||||||
|
payload.building_id = bId;
|
||||||
|
} else if (bName) {
|
||||||
|
payload.building_name = bName;
|
||||||
|
} else {
|
||||||
|
payload.building_name = isVirtual ? 'Campus Virtual' : 'Edificio Central';
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await req.apiClient.post('/classrooms', payload);
|
await req.apiClient.post('/classrooms', payload);
|
||||||
} catch (apiErr) {
|
} catch (apiErr) {
|
||||||
@@ -529,13 +587,25 @@ const handlePostEditClassroom = async (req, res) => {
|
|||||||
targetBuilding = new_building_name;
|
targetBuilding = new_building_name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let bId = parseInt(targetBuilding, 10);
|
||||||
|
if (isNaN(bId) || bId <= 0) {
|
||||||
|
bId = (String(targetBuilding).toLowerCase().includes('virtual')) ? 2 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isVirtual = Boolean(
|
||||||
|
bId === 2 ||
|
||||||
|
String(targetBuilding).toLowerCase().includes('virtual') ||
|
||||||
|
String(room_number).toUpperCase().includes('VIRTUAL') ||
|
||||||
|
String(floor).toUpperCase() === 'DIGITAL'
|
||||||
|
);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
code: room_number,
|
code: room_number,
|
||||||
capacity: (capacity === '∞' || capacity === '0' || !capacity) ? 0 : parseInt(capacity, 10),
|
capacity: (isVirtual || capacity === '∞' || capacity === '0' || !capacity) ? 0 : parseInt(capacity, 10),
|
||||||
floor: floor || 'PB',
|
floor: floor || (isVirtual ? 'Digital' : 'PB'),
|
||||||
building_id: (targetBuilding === '2' || targetBuilding === 'Campus Virtual') ? 2 : 1,
|
building_id: bId,
|
||||||
description: description || '',
|
description: description || '',
|
||||||
is_virtual: Boolean(targetBuilding === '2' || targetBuilding === 'Campus Virtual'),
|
is_virtual: isVirtual,
|
||||||
is_active: is_active === 'true' || is_active === 'on' || is_active === true
|
is_active: is_active === 'true' || is_active === 'on' || is_active === true
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -559,4 +629,28 @@ router.post('/edit/:id', handlePostEditClassroom);
|
|||||||
router.post('/edit_classroom/:id', handlePostEditClassroom);
|
router.post('/edit_classroom/:id', handlePostEditClassroom);
|
||||||
router.post('/edit_classroom', handlePostEditClassroom);
|
router.post('/edit_classroom', handlePostEditClassroom);
|
||||||
|
|
||||||
|
const handleToggleClassroom = async (req, res) => {
|
||||||
|
const id = req.params.id || req.body.classroom_id || req.body.id || req.query.id;
|
||||||
|
try {
|
||||||
|
await req.apiClient.post(`/classrooms/${id}/toggle`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Error toggling classroom:', e.response?.data || e.message);
|
||||||
|
}
|
||||||
|
const referer = req.get('Referer') || '/classrooms/list_classrooms';
|
||||||
|
res.redirect(referer);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClassroom = async (req, res) => {
|
||||||
|
const id = req.params.id || req.body.classroom_id || req.body.id || req.query.id;
|
||||||
|
try {
|
||||||
|
await req.apiClient.delete(`/classrooms/${id}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Error deleting classroom:', e.response?.data || e.message);
|
||||||
|
}
|
||||||
|
res.redirect('/classrooms/list_classrooms');
|
||||||
|
};
|
||||||
|
|
||||||
|
router.post(['/toggle_classroom', '/toggle/:id', '/:id/toggle'], handleToggleClassroom);
|
||||||
|
router.post(['/delete_classroom', '/delete/:id', '/:id/delete'], handleDeleteClassroom);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -266,6 +266,11 @@ router.get('/', async (req, res) => {
|
|||||||
{ start_time: '18:30', end_time: '22:30', date_display: '24/09', subject_name: 'Ingeniería de Software', subject_code: 'IS-301', shift: 'Noche', commission: { code: 'COM-3' }, status: 'PENDING', classroom: { code: 'Aula Magna' }, user: { name: 'Ing. Marcos Ruiz' } }
|
{ start_time: '18:30', end_time: '22:30', date_display: '24/09', subject_name: 'Ingeniería de Software', subject_code: 'IS-301', shift: 'Noche', commission: { code: 'COM-3' }, status: 'PENDING', classroom: { code: 'Aula Magna' }, user: { name: 'Ing. Marcos Ruiz' } }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let show_role_banner = true;
|
||||||
|
if (apiData && apiData.ui && apiData.ui.show_role_banner !== undefined) {
|
||||||
|
show_role_banner = Boolean(apiData.ui.show_role_banner);
|
||||||
|
}
|
||||||
|
|
||||||
res.render('dashboard', {
|
res.render('dashboard', {
|
||||||
title: role === 'bedelia' ? 'Panel Operativo de Bedelía - Edu-Space Admin' :
|
title: role === 'bedelia' ? 'Panel Operativo de Bedelía - Edu-Space Admin' :
|
||||||
role === 'docente' ? 'Panel Docente - Edu-Space Admin' :
|
role === 'docente' ? 'Panel Docente - Edu-Space Admin' :
|
||||||
@@ -274,6 +279,7 @@ router.get('/', async (req, res) => {
|
|||||||
user: req.user,
|
user: req.user,
|
||||||
role,
|
role,
|
||||||
actual_role,
|
actual_role,
|
||||||
|
show_role_banner,
|
||||||
stats,
|
stats,
|
||||||
role_data,
|
role_data,
|
||||||
monthly_data,
|
monthly_data,
|
||||||
|
|||||||
@@ -307,13 +307,33 @@ const handleAddReservation = async (req, res) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const commissionList = [
|
let commissionList = [];
|
||||||
|
try {
|
||||||
|
const commRes = await req.apiClient.get('/commissions', { params: { active: 'true' } });
|
||||||
|
if (commRes.data && commRes.data.commissions) {
|
||||||
|
commissionList = commRes.data.commissions.map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
code: c.full_code || c.code || `COM-${c.id}`,
|
||||||
|
name: `${c.subject_name || 'Asignatura'} · ${c.code}`,
|
||||||
|
subject_name: c.subject_name || 'Asignatura',
|
||||||
|
max_students: c.max_students || 35,
|
||||||
|
current_students: c.current_students || 0,
|
||||||
|
shift: c.shift || 'Tarde',
|
||||||
|
schedule: c.schedule || '',
|
||||||
|
virtual_link: c.virtual_link || ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (commErr) {
|
||||||
|
console.warn('Could not fetch real commissions:', commErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commissionList.length === 0) {
|
||||||
|
commissionList = [
|
||||||
{ id: 1, code: 'COM-ARQ-01', name: 'Arquitectura de Software - Comisión A', virtual_link: 'https://meet.google.com/edu-arq-01', max_students: 35 },
|
{ id: 1, code: 'COM-ARQ-01', name: 'Arquitectura de Software - Comisión A', virtual_link: 'https://meet.google.com/edu-arq-01', max_students: 35 },
|
||||||
{ id: 2, code: 'COM-BD-01', name: 'Bases de Datos II - Comisión Única', virtual_link: 'https://meet.google.com/edu-bd-01', max_students: 40 },
|
{ id: 2, code: 'COM-BD-01', name: 'Bases de Datos II - Comisión Única', virtual_link: 'https://meet.google.com/edu-bd-01', max_students: 40 },
|
||||||
{ id: 3, code: 'COM-PRG-01', name: 'Programación Avanzada - Comisión B', virtual_link: 'https://meet.google.com/edu-prg-01', max_students: 30 },
|
{ id: 3, code: 'COM-PRG-01', name: 'Programación Avanzada - Comisión B', virtual_link: 'https://meet.google.com/edu-prg-01', max_students: 30 }
|
||||||
{ id: 4, code: 'COM-RED-01', name: 'Redes y Telecomunicaciones - Comisión A', virtual_link: '', max_students: 28 },
|
|
||||||
{ id: 5, code: 'COM-IA-01', name: 'Inteligencia Artificial - Comisión Tarde', virtual_link: 'https://meet.google.com/edu-ia-01', max_students: 32 }
|
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
const classroomOptions = [
|
const classroomOptions = [
|
||||||
{ value: '', text: '-- Selecciona Aula --' },
|
{ value: '', text: '-- Selecciona Aula --' },
|
||||||
@@ -322,20 +342,36 @@ const handleAddReservation = async (req, res) => {
|
|||||||
|
|
||||||
const commissionOptions = [
|
const commissionOptions = [
|
||||||
{ value: '', text: '-- Selecciona Comisión / Materia --' },
|
{ value: '', text: '-- Selecciona Comisión / Materia --' },
|
||||||
...commissionList.map(c => ({ value: c.id, text: `${c.code} · ${c.name} (${c.max_students} alum.)` }))
|
...commissionList.map(c => ({ value: c.id, text: `${c.code} · ${c.name} (${c.max_students} alum. - ${c.shift || 'Turno'})` }))
|
||||||
];
|
];
|
||||||
|
|
||||||
const initialClassroomId = req.query.classroom_id || '';
|
const initialClassroomId = req.query.classroom_id || '';
|
||||||
|
const initialCommissionId = req.query.commission_id || '';
|
||||||
|
let initialPurpose = 'Clase Regular';
|
||||||
|
let initialExpectedAttendees = 30;
|
||||||
|
let initialVirtualLink = '';
|
||||||
|
|
||||||
|
if (initialCommissionId) {
|
||||||
|
const foundComm = commissionList.find(c => String(c.id) === String(initialCommissionId));
|
||||||
|
if (foundComm) {
|
||||||
|
initialPurpose = `${foundComm.subject_name} (${foundComm.code})`;
|
||||||
|
initialExpectedAttendees = foundComm.max_students || 30;
|
||||||
|
initialVirtualLink = foundComm.virtual_link || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const startTimeDefault = new Date(now.getTime() + 3600000).toISOString().substring(0, 16);
|
const startTimeDefault = new Date(now.getTime() + 3600000).toISOString().substring(0, 16);
|
||||||
const endTimeDefault = new Date(now.getTime() + 7200000).toISOString().substring(0, 16);
|
const endTimeDefault = new Date(now.getTime() + 7200000).toISOString().substring(0, 16);
|
||||||
|
|
||||||
const form = createFormMock({
|
const form = createFormMock({
|
||||||
classroom_id: initialClassroomId,
|
classroom_id: initialClassroomId,
|
||||||
|
commission_id: initialCommissionId,
|
||||||
start_time: startTimeDefault,
|
start_time: startTimeDefault,
|
||||||
end_time: endTimeDefault,
|
end_time: endTimeDefault,
|
||||||
expected_attendees: 30,
|
expected_attendees: initialExpectedAttendees,
|
||||||
purpose: 'Clase Regular'
|
purpose: initialPurpose,
|
||||||
|
virtual_link: initialVirtualLink
|
||||||
}, {
|
}, {
|
||||||
classroom_id: classroomOptions,
|
classroom_id: classroomOptions,
|
||||||
commission_id: commissionOptions
|
commission_id: commissionOptions
|
||||||
@@ -346,7 +382,9 @@ const handleAddReservation = async (req, res) => {
|
|||||||
form,
|
form,
|
||||||
classroom_list: classroomList,
|
classroom_list: classroomList,
|
||||||
commission_list: commissionList,
|
commission_list: commissionList,
|
||||||
buildings: Array.from(buildingsMap.values())
|
buildings: Array.from(buildingsMap.values()),
|
||||||
|
initial_commission_id: initialCommissionId,
|
||||||
|
initial_classroom_id: initialClassroomId
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error rendering add reservation:', error.message);
|
console.error('Error rendering add reservation:', error.message);
|
||||||
@@ -499,13 +537,32 @@ const handleEditReservation = async (req, res) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const commissionList = [
|
let commissionList = [];
|
||||||
|
try {
|
||||||
|
const commRes = await req.apiClient.get('/commissions');
|
||||||
|
if (commRes.data && commRes.data.commissions) {
|
||||||
|
commissionList = commRes.data.commissions.map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
code: c.full_code || c.code || `COM-${c.id}`,
|
||||||
|
name: `${c.subject_name || 'Asignatura'} · ${c.code}`,
|
||||||
|
subject_name: c.subject_name || 'Asignatura',
|
||||||
|
max_students: c.max_students || 35,
|
||||||
|
current_students: c.current_students || 0,
|
||||||
|
shift: c.shift || 'Tarde',
|
||||||
|
schedule: c.schedule || '',
|
||||||
|
virtual_link: c.virtual_link || ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (commErr) {
|
||||||
|
console.warn('Could not fetch real commissions for edit:', commErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commissionList.length === 0) {
|
||||||
|
commissionList = [
|
||||||
{ id: 1, code: 'COM-ARQ-01', name: 'Arquitectura de Software - Comisión A', virtual_link: 'https://meet.google.com/edu-arq-01', max_students: 35 },
|
{ id: 1, code: 'COM-ARQ-01', name: 'Arquitectura de Software - Comisión A', virtual_link: 'https://meet.google.com/edu-arq-01', max_students: 35 },
|
||||||
{ id: 2, code: 'COM-BD-01', name: 'Bases de Datos II - Comisión Única', virtual_link: 'https://meet.google.com/edu-bd-01', max_students: 40 },
|
{ id: 2, code: 'COM-BD-01', name: 'Bases de Datos II - Comisión Única', virtual_link: 'https://meet.google.com/edu-bd-01', max_students: 40 }
|
||||||
{ id: 3, code: 'COM-PRG-01', name: 'Programación Avanzada - Comisión B', virtual_link: 'https://meet.google.com/edu-prg-01', max_students: 30 },
|
|
||||||
{ id: 4, code: 'COM-RED-01', name: 'Redes y Telecomunicaciones - Comisión A', virtual_link: '', max_students: 28 },
|
|
||||||
{ id: 5, code: 'COM-IA-01', name: 'Inteligencia Artificial - Comisión Tarde', virtual_link: 'https://meet.google.com/edu-ia-01', max_students: 32 }
|
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
const classroomOptions = classroomList.map(c => ({ value: c.id, text: `${c.code} · ${c.floor_display} (Cap: ${c.capacity_display})` }));
|
const classroomOptions = classroomList.map(c => ({ value: c.id, text: `${c.code} · ${c.floor_display} (Cap: ${c.capacity_display})` }));
|
||||||
const commissionOptions = commissionList.map(c => ({ value: c.id, text: `${c.code} · ${c.name} (${c.max_students} alum.)` }));
|
const commissionOptions = commissionList.map(c => ({ value: c.id, text: `${c.code} · ${c.name} (${c.max_students} alum.)` }));
|
||||||
@@ -584,6 +641,16 @@ const handleCancelReservation = async (req, res) => {
|
|||||||
res.redirect(`/schedule/view_reservation?id=${id}`);
|
res.redirect(`/schedule/view_reservation?id=${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteReservation = async (req, res) => {
|
||||||
|
const id = req.params.id || req.body.id || req.query.id;
|
||||||
|
try {
|
||||||
|
await req.apiClient.post(`/reservations/${id}/delete`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Delete reservation API notice:', e.response?.data || e.message);
|
||||||
|
}
|
||||||
|
res.redirect('/schedule/list_reservations');
|
||||||
|
};
|
||||||
|
|
||||||
router.get(['/view_reservation', '/view_reservation/:id', '/view/:id', '/reservation/:id/view'], handleViewReservation);
|
router.get(['/view_reservation', '/view_reservation/:id', '/view/:id', '/reservation/:id/view'], handleViewReservation);
|
||||||
|
|
||||||
router.get(['/edit_reservation', '/edit_reservation/:id', '/reservation/:id/edit'], handleEditReservation);
|
router.get(['/edit_reservation', '/edit_reservation/:id', '/reservation/:id/edit'], handleEditReservation);
|
||||||
@@ -591,6 +658,7 @@ router.post(['/edit_reservation', '/edit_reservation/:id', '/reservation/:id/edi
|
|||||||
|
|
||||||
router.post(['/confirm_reservation', '/confirm_reservation/:id', '/reservation/:id/confirm'], handleConfirmReservation);
|
router.post(['/confirm_reservation', '/confirm_reservation/:id', '/reservation/:id/confirm'], handleConfirmReservation);
|
||||||
router.post(['/cancel_reservation', '/cancel_reservation/:id', '/reservation/:id/cancel'], handleCancelReservation);
|
router.post(['/cancel_reservation', '/cancel_reservation/:id', '/reservation/:id/cancel'], handleCancelReservation);
|
||||||
|
router.post(['/delete_reservation', '/delete_reservation/:id', '/reservation/:id/delete'], handleDeleteReservation);
|
||||||
|
|
||||||
// 9. Libro de Calificaciones / Gradebook (Fase 3)
|
// 9. Libro de Calificaciones / Gradebook (Fase 3)
|
||||||
const handleGradebook = async (req, res) => {
|
const handleGradebook = async (req, res) => {
|
||||||
@@ -633,6 +701,7 @@ const handleMyGrades = async (req, res) => {
|
|||||||
|
|
||||||
router.get(['/gradebook', '/gradebook/:id', '/commissions/:id/gradebook'], handleGradebook);
|
router.get(['/gradebook', '/gradebook/:id', '/commissions/:id/gradebook'], handleGradebook);
|
||||||
router.get(['/my_grades', '/my-grades', '/mis_calificaciones', '/mis-notas'], handleMyGrades);
|
router.get(['/my_grades', '/my-grades', '/mis_calificaciones', '/mis-notas'], handleMyGrades);
|
||||||
|
router.get(['/commissions_view', '/commissions'], (req, res) => res.redirect('/admin/commissions_list'));
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
||||||
|
|||||||
@@ -105,21 +105,53 @@
|
|||||||
title="Editar Ciclo Lectivo">
|
title="Editar Ciclo Lectivo">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
</button>
|
</button>
|
||||||
|
{% if not term.is_current %}
|
||||||
|
<form method="POST" action="/admin/academic_term_toggle" class="d-inline">
|
||||||
|
<input type="hidden" name="id" value="{{ term.id }}">
|
||||||
|
<button type="submit" class="btn btn-outline-warning py-0 px-2"
|
||||||
|
title="{% if term.is_active %}Desactivar Ciclo{% else %}Habilitar Ciclo{% endif %}">
|
||||||
|
<i class="bi {% if term.is_active %}bi-pause{% else %}bi-play{% endif %}"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<button type="button" class="btn btn-outline-danger py-0 px-2"
|
||||||
|
data-bs-toggle="modal" data-bs-target="#modalDeleteTerm{{ term.id }}"
|
||||||
|
title="Eliminar Ciclo Lectivo">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-calendar-range fs-1 d-block mb-2"></i>
|
||||||
|
No se encontraron ciclos lectivos registrados.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal Editar Ciclo Lectivo -->
|
<!-- Modales Editar Ciclo Lectivo (Fuera de la tabla para evitar conflictos de stacking context y cierre prematuro) -->
|
||||||
<div class="modal fade text-start" id="modalEditTerm{{ term.id }}" tabindex="-1" aria-hidden="true">
|
{% if can_edit %}
|
||||||
<div class="modal-dialog">
|
{% for term in terms %}
|
||||||
<div class="modal-content">
|
<div class="modal fade text-start" id="modalEditTerm{{ term.id }}" tabindex="-1" aria-labelledby="modalEditTermTitle{{ term.id }}" aria-hidden="true" data-bs-backdrop="static">
|
||||||
<form method="POST" action="/admin/academic_term_edit">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content shadow-lg border-0">
|
||||||
|
<form method="POST" action="/admin/academic_term_edit/{{ term.id }}">
|
||||||
<input type="hidden" name="id" value="{{ term.id }}">
|
<input type="hidden" name="id" value="{{ term.id }}">
|
||||||
<div class="modal-header">
|
<div class="modal-header border-bottom">
|
||||||
<h5 class="modal-title h6 fw-bold">
|
<h5 class="modal-title h6 fw-bold" id="modalEditTermTitle{{ term.id }}">
|
||||||
<i class="bi bi-pencil-square text-primary me-2"></i>Editar Ciclo Lectivo
|
<i class="bi bi-pencil-square text-primary me-2"></i>Editar Ciclo Lectivo: {{ term.name }}
|
||||||
</h5>
|
</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body p-4">
|
||||||
<div class="row g-2 mb-3">
|
<div class="row g-2 mb-3">
|
||||||
<div class="col-md-5">
|
<div class="col-md-5">
|
||||||
<label class="form-label small fw-semibold">Código *</label>
|
<label class="form-label small fw-semibold">Código *</label>
|
||||||
@@ -164,7 +196,7 @@
|
|||||||
<label class="form-check-label small" for="isAct{{ term.id }}">Habilitado en el Sistema</label>
|
<label class="form-check-label small" for="isAct{{ term.id }}">Habilitado en el Sistema</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer border-top">
|
||||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||||
<button type="submit" class="btn btn-sm btn-primary">Guardar Cambios</button>
|
<button type="submit" class="btn btn-sm btn-primary">Guardar Cambios</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -172,28 +204,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
|
||||||
{% endif %}
|
{% if not term.is_current %}
|
||||||
</tr>
|
<div class="modal fade text-start" id="modalDeleteTerm{{ term.id }}" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content border-0 shadow">
|
||||||
|
<div class="modal-header bg-danger text-white py-2">
|
||||||
|
<h6 class="modal-title fw-bold">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>Confirmar Eliminación de Ciclo Lectivo
|
||||||
|
</h6>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-4">
|
||||||
|
<p class="mb-2">¿Está seguro que desea eliminar el ciclo lectivo <strong>{{ term.name }}</strong> (<code>{{ term.code }}</code>)?</p>
|
||||||
|
{% set t_stat_del = stats[term.id] or {'commissions_count': 0} %}
|
||||||
|
{% if t_stat_del.commissions_count > 0 %}
|
||||||
|
<div class="alert alert-warning small mb-0 mt-3 d-flex align-items-center gap-2">
|
||||||
|
<i class="bi bi-shield-exclamation fs-5"></i>
|
||||||
|
<div>
|
||||||
|
Este ciclo cuenta con <strong>{{ t_stat_del.commissions_count }} comisiones asociadas</strong>. Al confirmar, será <strong>desactivado</strong> para proteger el historial académico de cursadas.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<div class="alert alert-secondary small mb-0 mt-3">
|
||||||
<td colspan="8" class="text-center py-5 text-muted">
|
<i class="bi bi-info-circle me-1"></i>No tiene comisiones vinculadas; se eliminará permanentemente de la base de datos.
|
||||||
<i class="bi bi-calendar-range fs-1 d-block mb-2"></i>
|
</div>
|
||||||
No se encontraron ciclos lectivos registrados.
|
{% endif %}
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
<div class="modal-footer py-2 bg-light">
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||||
|
<form method="POST" action="/admin/academic_term_delete" class="d-inline">
|
||||||
|
<input type="hidden" name="id" value="{{ term.id }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger">
|
||||||
|
<i class="bi bi-trash me-1"></i>Confirmar Eliminación
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
{% endif %}
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Modal Nuevo Ciclo Lectivo -->
|
<!-- Modal Nuevo Ciclo Lectivo -->
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<div class="modal fade" id="modalAddTerm" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="modalAddTerm" tabindex="-1" aria-labelledby="modalAddTermTitle" aria-hidden="true" data-bs-backdrop="static">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
<div class="modal-content">
|
<div class="modal-content shadow-lg border-0">
|
||||||
<form method="POST" action="/admin/academic_term_add">
|
<form method="POST" action="/admin/academic_term_add">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title h6 fw-bold">
|
<h5 class="modal-title h6 fw-bold">
|
||||||
|
|||||||
@@ -26,6 +26,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Notification Alerts -->
|
||||||
|
{% if msg == 'created' %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-check-circle-fill me-2"></i>La carrera ha sido registrada exitosamente.
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif msg == 'updated' %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-check-circle-fill me-2"></i>Los datos de la carrera se actualizaron correctamente.
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif msg == 'deleted' %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-check-circle-fill me-2"></i>La carrera ha sido eliminada permanentemente del sistema.
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif error == 'has_subjects' %}
|
||||||
|
<div class="alert alert-warning alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>No es posible eliminar esta carrera porque tiene asignaturas vinculadas. Debe reasignar o eliminar primero sus materias.
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif error == 'conflict' %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-x-circle-fill me-2"></i>Ya existe una carrera registrada con ese nombre o código institucional.
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif error %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-x-circle-fill me-2"></i>Ocurrió un error al procesar la solicitud en el catálogo de carreras.
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Search & Filter Bar -->
|
<!-- Search & Filter Bar -->
|
||||||
<div class="card border-0 shadow-sm mb-4">
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
<div class="card-body py-2">
|
<div class="card-body py-2">
|
||||||
@@ -114,10 +147,15 @@
|
|||||||
<form method="POST" action="/admin/career_delete" class="d-inline"
|
<form method="POST" action="/admin/career_delete" class="d-inline"
|
||||||
onsubmit="return confirm('¿Confirma que desea eliminar esta carrera permanentemente?');">
|
onsubmit="return confirm('¿Confirma que desea eliminar esta carrera permanentemente?');">
|
||||||
<input type="hidden" name="id" value="{{ career.id }}">
|
<input type="hidden" name="id" value="{{ career.id }}">
|
||||||
<button type="submit" class="btn btn-outline-danger py-0 px-2" title="Eliminar">
|
<button type="submit" class="btn btn-outline-danger py-0 px-2" title="Eliminar Carrera">
|
||||||
<i class="bi bi-trash"></i>
|
<i class="bi bi-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<button type="button" class="btn btn-outline-secondary py-0 px-2 opacity-50"
|
||||||
|
disabled title="No se puede eliminar: posee {{ c_stat.subjects_count }} asignatura(s) vinculada(s)">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
|
|
||||||
<!-- Commission Card -->
|
<!-- Commission Card -->
|
||||||
<div class="card shadow-sm border-0 mb-4">
|
<div class="card shadow-sm border-0 mb-4">
|
||||||
<div class="card-header border-0 bg-primary text-white rounded-top py-3">
|
<div class="card-header border-0 bg-primary text-white rounded-top py-3 d-flex justify-content-between align-items-center">
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<i class="bi bi-diagram-3 fs-4"></i>
|
<i class="bi bi-diagram-3 fs-4"></i>
|
||||||
<div>
|
<div>
|
||||||
@@ -57,6 +57,11 @@
|
|||||||
<small class="opacity-75">{{ commission.semester }} {{ commission.year }}</small>
|
<small class="opacity-75">{{ commission.semester }} {{ commission.year }}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if can_edit %}
|
||||||
|
<button type="button" class="btn btn-sm btn-light py-1 px-2 fw-semibold text-primary" data-bs-toggle="modal" data-bs-target="#modalEditCommissionDetail">
|
||||||
|
<i class="bi bi-pencil me-1"></i>Editar
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="fw-bold mb-1">{{ commission.subject.name }}</h5>
|
<h5 class="fw-bold mb-1">{{ commission.subject.name }}</h5>
|
||||||
@@ -389,4 +394,80 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Editar Datos de la Comisión -->
|
||||||
|
{% if can_edit %}
|
||||||
|
<div class="modal fade" id="modalEditCommissionDetail" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||||
|
<div class="modal-content border-0 shadow">
|
||||||
|
<form action="/admin/commission_edit" method="POST">
|
||||||
|
<input type="hidden" name="id" value="{{ commission.id }}">
|
||||||
|
<div class="modal-header bg-body-tertiary border-bottom py-3">
|
||||||
|
<h5 class="modal-title fw-bold">
|
||||||
|
<i class="bi bi-pencil-square text-primary me-2"></i>Editar Datos de la Comisión
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-4">
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-semibold small">Identificador / Código</label>
|
||||||
|
<input type="text" class="form-control form-control-sm font-monospace" name="code" value="{{ commission.code }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label fw-semibold small">Cuatrimestre</label>
|
||||||
|
<select class="form-select form-select-sm" name="semester">
|
||||||
|
<option value="1C" {% if commission.semester == '1C' %}selected{% endif %}>1C (Primer Cuatrimestre)</option>
|
||||||
|
<option value="2C" {% if commission.semester == '2C' %}selected{% endif %}>2C (Segundo Cuatrimestre)</option>
|
||||||
|
<option value="Anual" {% if commission.semester == 'Anual' %}selected{% endif %}>Anual</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label fw-semibold small">Año</label>
|
||||||
|
<input type="number" class="form-control form-control-sm" name="year" value="{{ commission.year }}" min="2020" max="2035">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-semibold small">Turno</label>
|
||||||
|
<select class="form-select form-select-sm" name="shift">
|
||||||
|
<option value="Mañana" {% if commission.shift == 'Mañana' %}selected{% endif %}>Mañana (08:00 - 12:00)</option>
|
||||||
|
<option value="Tarde" {% if commission.shift == 'Tarde' %}selected{% endif %}>Tarde (17:30 - 21:30)</option>
|
||||||
|
<option value="Vespertino" {% if commission.shift == 'Vespertino' %}selected{% endif %}>Vespertino</option>
|
||||||
|
<option value="Noche" {% if commission.shift == 'Noche' %}selected{% endif %}>Noche (19:00 - 23:00)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-semibold small">Cupo Máximo de Alumnos</label>
|
||||||
|
<input type="number" class="form-control form-control-sm" name="max_students" value="{{ commission.max_students }}" min="1" max="300">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold small">Días y Horarios</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" name="schedule" value="{{ commission.schedule or '' }}" placeholder="Ej: Lun y Mié 17:30 a 21:30 hs">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold small">Enlace Aula Virtual (Meet, Teams, Zoom)</label>
|
||||||
|
<input type="url" class="form-control form-control-sm" name="virtual_link" value="{{ commission.virtual_link or '' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="active" id="detail_edit_active" value="on" {% if commission.active %}checked{% endif %}>
|
||||||
|
<label class="form-check-label small fw-semibold" for="detail_edit_active">
|
||||||
|
Comisión Activa
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer bg-body-tertiary border-top py-2">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm px-4">Guardar Cambios</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Flash Messages & Feedback -->
|
<!-- Flash Messages & Feedback -->
|
||||||
{% if created %}
|
{% if msg == 'created' or created %}
|
||||||
<div class="alert alert-success alert-dismissible fade show d-flex align-items-center gap-2 mb-4 shadow-sm" role="alert">
|
<div class="alert alert-success alert-dismissible fade show d-flex align-items-center gap-2 mb-4 shadow-sm" role="alert">
|
||||||
<i class="bi bi-check-circle-fill fs-5 text-success"></i>
|
<i class="bi bi-check-circle-fill fs-5 text-success"></i>
|
||||||
<div>
|
<div>
|
||||||
@@ -30,6 +30,62 @@
|
|||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
|
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
|
{% elif msg == 'updated' %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show d-flex align-items-center gap-2 mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-check-circle-fill fs-5 text-success"></i>
|
||||||
|
<div>
|
||||||
|
<strong>¡Comisión actualizada!</strong> Los datos de la comisión, cupos y horarios fueron guardados correctamente.
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif msg == 'toggled' %}
|
||||||
|
<div class="alert alert-info alert-dismissible fade show d-flex align-items-center gap-2 mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-info-circle-fill fs-5 text-info"></i>
|
||||||
|
<div>
|
||||||
|
El estado de actividad de la comisión ha sido modificado.
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif msg == 'deleted' %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show d-flex align-items-center gap-2 mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-check-circle-fill fs-5 text-success"></i>
|
||||||
|
<div>
|
||||||
|
La comisión ha sido eliminada permanentemente del sistema.
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif msg == 'deactivated' %}
|
||||||
|
<div class="alert alert-warning alert-dismissible fade show d-flex align-items-center gap-2 mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill fs-5 text-warning"></i>
|
||||||
|
<div>
|
||||||
|
La comisión posee reservas horarias o alumnos inscriptos. Por integridad del historial académico fue <strong>desactivada</strong>.
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif error == 'create' %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show d-flex align-items-center gap-2 mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-x-circle-fill fs-5 text-danger"></i>
|
||||||
|
<div>No se pudo crear la comisión. Verifique que no exista otra comisión con el mismo código en el mismo período.</div>
|
||||||
|
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif error == 'edit' %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show d-flex align-items-center gap-2 mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-x-circle-fill fs-5 text-danger"></i>
|
||||||
|
<div>Ocurrió un error al actualizar los datos de la comisión.</div>
|
||||||
|
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif error == 'delete' %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show d-flex align-items-center gap-2 mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-x-circle-fill fs-5 text-danger"></i>
|
||||||
|
<div>Ocurrió un error al intentar eliminar la comisión.</div>
|
||||||
|
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif error %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show d-flex align-items-center gap-2 mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-x-circle-fill fs-5 text-danger"></i>
|
||||||
|
<div>Ocurrió una eventualidad procesando la comisión.</div>
|
||||||
|
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
@@ -170,10 +226,48 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end pe-3">
|
<td class="text-end pe-3">
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
<a href="/admin/commission_detail?id={{ comm.id }}"
|
<a href="/admin/commission_detail?id={{ comm.id }}"
|
||||||
class="btn btn-sm btn-outline-primary" title="Ver y gestionar comisión">
|
class="btn btn-outline-primary py-0 px-2" title="Gestionar e Inscribir Alumnos">
|
||||||
<i class="bi bi-gear me-1"></i>Gestionar
|
<i class="bi bi-gear"></i>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/schedule/add_reservation?commission_id={{ comm.id }}"
|
||||||
|
class="btn btn-outline-info py-0 px-2" title="Asignar Aula / Horario a esta Comisión">
|
||||||
|
<i class="bi bi-calendar-plus"></i>
|
||||||
|
</a>
|
||||||
|
<button type="button" class="btn btn-outline-secondary py-0 px-2"
|
||||||
|
data-bs-toggle="modal" data-bs-target="#modalEditCommission"
|
||||||
|
data-id="{{ comm.id }}"
|
||||||
|
data-code="{{ comm.code }}"
|
||||||
|
data-subject-id="{{ comm.subject_id }}"
|
||||||
|
data-academic-term-id="{{ comm.academic_term_id or '' }}"
|
||||||
|
data-semester="{{ comm.semester }}"
|
||||||
|
data-year="{{ comm.year }}"
|
||||||
|
data-shift="{{ comm.shift }}"
|
||||||
|
data-teacher-id="{{ comm.teacher_id or '' }}"
|
||||||
|
data-max-students="{{ comm.max_students }}"
|
||||||
|
data-schedule="{{ comm.schedule or '' }}"
|
||||||
|
data-virtual-link="{{ comm.virtual_link or '' }}"
|
||||||
|
data-active="{{ 'true' if comm.active else 'false' }}"
|
||||||
|
title="Editar Comisión">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<form method="POST" action="/admin/commission_toggle" class="d-inline">
|
||||||
|
<input type="hidden" name="id" value="{{ comm.id }}">
|
||||||
|
<button type="submit" class="btn btn-outline-warning py-0 px-2"
|
||||||
|
title="{% if comm.active %}Desactivar comisión{% else %}Activar comisión{% endif %}">
|
||||||
|
<i class="bi {% if comm.active %}bi-pause{% else %}bi-play{% endif %}"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<button type="button" class="btn btn-outline-danger py-0 px-2"
|
||||||
|
data-bs-toggle="modal" data-bs-target="#modalDeleteCommission"
|
||||||
|
data-id="{{ comm.id }}"
|
||||||
|
data-code="{{ comm.get_full_code() }}"
|
||||||
|
data-subject-name="{{ comm.subject.name }}"
|
||||||
|
title="Eliminar Comisión">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -277,9 +371,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 2. Período, Año y Turno -->
|
<!-- 2. Ciclo Lectivo, Período, Año y Turno -->
|
||||||
<div class="row g-3 mb-3">
|
<div class="row g-3 mb-3">
|
||||||
<div class="col-md-4">
|
<div class="col-md-6">
|
||||||
|
<label for="add_academic_term_id" class="form-label fw-semibold small">
|
||||||
|
<i class="bi bi-calendar-range text-primary me-1"></i>Ciclo Lectivo
|
||||||
|
</label>
|
||||||
|
<select class="form-select form-select-sm" id="add_academic_term_id" name="academic_term_id">
|
||||||
|
<option value="">-- Seleccionar Ciclo Lectivo --</option>
|
||||||
|
{% for term in academic_terms %}
|
||||||
|
<option value="{{ term.id }}" data-year="{{ term.year }}" data-period="{{ term.period_type }}" data-code="{{ term.code }}" {% if term.is_current %}selected{% endif %}>
|
||||||
|
{{ term.name }} ({{ term.code }}){% if term.is_current %} [Actual]{% endif %}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div class="form-text small">Asocia la comisión al calendario académico.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
<label for="add_semester" class="form-label fw-semibold small">Cuatrimestre / Período</label>
|
<label for="add_semester" class="form-label fw-semibold small">Cuatrimestre / Período</label>
|
||||||
<select class="form-select form-select-sm" id="add_semester" name="semester">
|
<select class="form-select form-select-sm" id="add_semester" name="semester">
|
||||||
<option value="1C" selected>1C (Primer Cuatrimestre)</option>
|
<option value="1C" selected>1C (Primer Cuatrimestre)</option>
|
||||||
@@ -287,11 +395,13 @@
|
|||||||
<option value="Anual">Anual</option>
|
<option value="Anual">Anual</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-3">
|
||||||
<label for="add_year" class="form-label fw-semibold small">Año Académico</label>
|
<label for="add_year" class="form-label fw-semibold small">Año Académico</label>
|
||||||
<input type="number" class="form-control form-control-sm" id="add_year" name="year" value="2026" min="2020" max="2035">
|
<input type="number" class="form-control form-control-sm" id="add_year" name="year" value="2026" min="2020" max="2035">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
</div>
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
<label for="add_shift" class="form-label fw-semibold small">Turno de Cursada</label>
|
<label for="add_shift" class="form-label fw-semibold small">Turno de Cursada</label>
|
||||||
<select class="form-select form-select-sm" id="add_shift" name="shift">
|
<select class="form-select form-select-sm" id="add_shift" name="shift">
|
||||||
<option value="Mañana">Mañana (08:00 - 12:00)</option>
|
<option value="Mañana">Mañana (08:00 - 12:00)</option>
|
||||||
@@ -349,4 +459,236 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Editar Comisión -->
|
||||||
|
<div class="modal fade" id="modalEditCommission" tabindex="-1" aria-labelledby="modalEditCommissionLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||||
|
<div class="modal-content border-0 shadow">
|
||||||
|
<div class="modal-header bg-body-tertiary border-bottom py-3">
|
||||||
|
<h5 class="modal-title fw-bold" id="modalEditCommissionLabel">
|
||||||
|
<i class="bi bi-pencil-square text-primary me-2"></i>Editar Comisión de Cursada
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<form action="/admin/commission_edit" method="POST" id="formEditCommission">
|
||||||
|
<input type="hidden" name="id" id="edit_commission_id">
|
||||||
|
<div class="modal-body p-4">
|
||||||
|
<!-- 1. Asignatura y Código -->
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-7">
|
||||||
|
<label for="edit_subject_id" class="form-label fw-semibold small">
|
||||||
|
<i class="bi bi-book text-primary me-1"></i>Asignatura Asociada <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<select class="form-select form-select-sm" id="edit_subject_id" name="subject_id" required>
|
||||||
|
{% for subj in subjects %}
|
||||||
|
<option value="{{ subj.id }}">{{ subj.code }} — {{ subj.name }} ({{ subj.career_name or 'General' }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label for="edit_code" class="form-label fw-semibold small">
|
||||||
|
<i class="bi bi-tag text-primary me-1"></i>Identificador / Código <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" class="form-control form-control-sm font-monospace" id="edit_code" name="code" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. Ciclo Lectivo, Semestre y Año -->
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label for="edit_academic_term_id" class="form-label fw-semibold small">
|
||||||
|
<i class="bi bi-calendar-event text-info me-1"></i>Ciclo Lectivo
|
||||||
|
</label>
|
||||||
|
<select class="form-select form-select-sm" id="edit_academic_term_id" name="academic_term_id">
|
||||||
|
<option value="">-- Sin ciclo específico --</option>
|
||||||
|
{% for term in academic_terms %}
|
||||||
|
<option value="{{ term.id }}" data-year="{{ term.year }}" data-period="{{ term.period_type or '' }}" data-code="{{ term.code }}">
|
||||||
|
{{ term.name }} ({{ term.code }}) - {{ term.year }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="edit_semester" class="form-label fw-semibold small">Período / Cuatrimestre</label>
|
||||||
|
<select class="form-select form-select-sm" id="edit_semester" name="semester">
|
||||||
|
<option value="1C">1C (Primer Cuatrimestre)</option>
|
||||||
|
<option value="2C">2C (Segundo Cuatrimestre)</option>
|
||||||
|
<option value="Anual">Anual</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="edit_year" class="form-label fw-semibold small">Año Académico</label>
|
||||||
|
<input type="number" class="form-control form-control-sm" id="edit_year" name="year" min="2020" max="2035">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="edit_shift" class="form-label fw-semibold small">Turno de Cursada</label>
|
||||||
|
<select class="form-select form-select-sm" id="edit_shift" name="shift">
|
||||||
|
<option value="Mañana">Mañana (08:00 - 12:00)</option>
|
||||||
|
<option value="Tarde">Tarde (17:30 - 21:30)</option>
|
||||||
|
<option value="Vespertino">Vespertino</option>
|
||||||
|
<option value="Noche">Noche (19:00 - 23:00)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="edit_max_students" class="form-label fw-semibold small">Cupo Máximo de Alumnos</label>
|
||||||
|
<input type="number" class="form-control form-control-sm" id="edit_max_students" name="max_students" min="1" max="300">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. Docente Responsable -->
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label for="edit_teacher_id" class="form-label fw-semibold small">
|
||||||
|
<i class="bi bi-person-video3 text-info me-1"></i>Docente Responsable (Titular)
|
||||||
|
</label>
|
||||||
|
<select class="form-select form-select-sm" id="edit_teacher_id" name="teacher_id">
|
||||||
|
<option value="">-- Sin asignar (se puede asignar más tarde) --</option>
|
||||||
|
{% for t in teachers %}
|
||||||
|
<option value="{{ t.id }}">{{ t.name }} ({{ t.email }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 4. Días y Horarios -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="edit_schedule" class="form-label fw-semibold small">
|
||||||
|
<i class="bi bi-calendar-week text-warning me-1"></i>Días y Horarios de Cursada
|
||||||
|
</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="edit_schedule" name="schedule" placeholder="Ej: Lun y Mié 17:30 a 21:30 hs">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 5. Enlace Virtual -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="edit_virtual_link" class="form-label fw-semibold small">
|
||||||
|
<i class="bi bi-camera-video text-primary me-1"></i>Enlace a Aula Virtual (Meet, Zoom, Teams)
|
||||||
|
</label>
|
||||||
|
<input type="url" class="form-control form-control-sm" id="edit_virtual_link" name="virtual_link">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 6. Estado Activo -->
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="active" id="edit_active" value="on">
|
||||||
|
<label class="form-check-label small fw-semibold" for="edit_active">
|
||||||
|
Comisión Activa para Cursada y Reservas
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer bg-body-tertiary border-top py-2">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm px-4 shadow-sm">
|
||||||
|
<i class="bi bi-save me-1"></i>Guardar Cambios
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Eliminar Comisión -->
|
||||||
|
<div class="modal fade" id="modalDeleteCommission" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content border-0 shadow">
|
||||||
|
<form action="/admin/commission_delete" method="POST">
|
||||||
|
<input type="hidden" name="id" id="delete_commission_id">
|
||||||
|
<div class="modal-header bg-danger text-white py-3">
|
||||||
|
<h5 class="modal-title h6 fw-bold">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>Eliminar Comisión
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body py-4">
|
||||||
|
<p class="mb-2">¿Estás seguro de que deseas eliminar la comisión <strong id="delete_commission_code" class="font-monospace"></strong> vinculada a <strong id="delete_commission_subject"></strong>?</p>
|
||||||
|
<div class="alert alert-warning small mb-0">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
<strong>Integridad Académica:</strong> Si la comisión posee reservas en aulas o alumnos inscriptos, el sistema la <strong>desactivará automáticamente</strong> para salvaguardar las actas y calificaciones.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer bg-body-tertiary border-top py-2">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm px-3">
|
||||||
|
<i class="bi bi-trash me-1"></i>Confirmar Eliminación
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Sincronización Ciclo Lectivo en Alta
|
||||||
|
const addTermSelect = document.getElementById('add_academic_term_id');
|
||||||
|
const addYearInput = document.getElementById('add_year');
|
||||||
|
const addSemSelect = document.getElementById('add_semester');
|
||||||
|
|
||||||
|
function syncTerm(termSelect, yearInput, semSelect) {
|
||||||
|
if (!termSelect) return;
|
||||||
|
const opt = termSelect.options[termSelect.selectedIndex];
|
||||||
|
if (!opt || !opt.value) return;
|
||||||
|
const yr = opt.getAttribute('data-year');
|
||||||
|
const period = (opt.getAttribute('data-period') || opt.text || '').toLowerCase();
|
||||||
|
const code = (opt.getAttribute('data-code') || '').toLowerCase();
|
||||||
|
if (yr && yearInput) yearInput.value = yr;
|
||||||
|
if (semSelect) {
|
||||||
|
if (period.includes('segundo') || code.includes('2c') || code.includes('2º') || code.includes('2do') || code.includes('-2')) {
|
||||||
|
semSelect.value = '2C';
|
||||||
|
} else if (period.includes('anual') || code.includes('anual')) {
|
||||||
|
semSelect.value = 'Anual';
|
||||||
|
} else if (period.includes('primer') || code.includes('1c') || code.includes('1º') || code.includes('1ro') || code.includes('-1')) {
|
||||||
|
semSelect.value = '1C';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addTermSelect) {
|
||||||
|
addTermSelect.addEventListener('change', () => syncTerm(addTermSelect, addYearInput, addSemSelect));
|
||||||
|
if (addTermSelect.value) syncTerm(addTermSelect, addYearInput, addSemSelect);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sincronización Ciclo Lectivo en Edición
|
||||||
|
const editTermSelect = document.getElementById('edit_academic_term_id');
|
||||||
|
const editYearInput = document.getElementById('edit_year');
|
||||||
|
const editSemSelect = document.getElementById('edit_semester');
|
||||||
|
|
||||||
|
if (editTermSelect) {
|
||||||
|
editTermSelect.addEventListener('change', () => syncTerm(editTermSelect, editYearInput, editSemSelect));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poblar Modal Editar Comisión
|
||||||
|
const modalEdit = document.getElementById('modalEditCommission');
|
||||||
|
if (modalEdit) {
|
||||||
|
modalEdit.addEventListener('show.bs.modal', function(event) {
|
||||||
|
const btn = event.relatedTarget;
|
||||||
|
if (!btn) return;
|
||||||
|
document.getElementById('edit_commission_id').value = btn.getAttribute('data-id') || '';
|
||||||
|
document.getElementById('edit_code').value = btn.getAttribute('data-code') || '';
|
||||||
|
document.getElementById('edit_subject_id').value = btn.getAttribute('data-subject-id') || '';
|
||||||
|
document.getElementById('edit_academic_term_id').value = btn.getAttribute('data-academic-term-id') || '';
|
||||||
|
document.getElementById('edit_semester').value = btn.getAttribute('data-semester') || '1C';
|
||||||
|
document.getElementById('edit_year').value = btn.getAttribute('data-year') || '2026';
|
||||||
|
document.getElementById('edit_shift').value = btn.getAttribute('data-shift') || 'Mañana';
|
||||||
|
document.getElementById('edit_teacher_id').value = btn.getAttribute('data-teacher-id') || '';
|
||||||
|
document.getElementById('edit_max_students').value = btn.getAttribute('data-max-students') || '40';
|
||||||
|
document.getElementById('edit_schedule').value = btn.getAttribute('data-schedule') || '';
|
||||||
|
document.getElementById('edit_virtual_link').value = btn.getAttribute('data-virtual-link') || '';
|
||||||
|
document.getElementById('edit_active').checked = (btn.getAttribute('data-active') === 'true');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poblar Modal Eliminar Comisión
|
||||||
|
const modalDel = document.getElementById('modalDeleteCommission');
|
||||||
|
if (modalDel) {
|
||||||
|
modalDel.addEventListener('show.bs.modal', function(event) {
|
||||||
|
const btn = event.relatedTarget;
|
||||||
|
if (!btn) return;
|
||||||
|
document.getElementById('delete_commission_id').value = btn.getAttribute('data-id') || '';
|
||||||
|
document.getElementById('delete_commission_code').textContent = btn.getAttribute('data-code') || '';
|
||||||
|
document.getElementById('delete_commission_subject').textContent = btn.getAttribute('data-subject-name') || '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -460,28 +460,94 @@ function toggleSelectAll(checked) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openPurgeModal() {
|
function openPurgeModal() {
|
||||||
const anyChecked = document.getElementById('chk_reservations').checked ||
|
const isAll = document.getElementById('chk_all').checked;
|
||||||
document.getElementById('chk_classrooms').checked ||
|
const isRes = isAll || document.getElementById('chk_reservations').checked;
|
||||||
document.getElementById('chk_commissions').checked ||
|
const isCls = isAll || document.getElementById('chk_classrooms').checked;
|
||||||
document.getElementById('chk_subjects').checked ||
|
const isCom = isAll || document.getElementById('chk_commissions').checked;
|
||||||
document.getElementById('chk_careers').checked ||
|
const isSub = isAll || document.getElementById('chk_subjects').checked;
|
||||||
document.getElementById('chk_all').checked;
|
const isCar = isAll || document.getElementById('chk_careers').checked;
|
||||||
|
|
||||||
|
const anyChecked = isRes || isCls || isCom || isSub || isCar;
|
||||||
if (!anyChecked) {
|
if (!anyChecked) {
|
||||||
alert('Por favor seleccione al menos una entidad para purgar.');
|
alert('Por favor seleccione al menos una entidad para purgar.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listEl = document.getElementById('purgeEntitiesList');
|
||||||
|
if (listEl) {
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
if (isAll) {
|
||||||
|
listEl.innerHTML += '<li class="list-group-item list-group-item-danger fw-bold"><i class="bi bi-trash-fill me-2"></i>REINICIO TOTAL DEL ESQUEMA (Carreras, Asignaturas, Comisiones, Aulas y Reservas)</li>';
|
||||||
|
} else {
|
||||||
|
if (isCar) listEl.innerHTML += '<li class="list-group-item text-danger fw-semibold"><i class="bi bi-mortarboard me-2"></i>Carreras Académicas y sus Asignaturas asociadas</li>';
|
||||||
|
if (isSub && !isCar) listEl.innerHTML += '<li class="list-group-item text-danger fw-semibold"><i class="bi bi-book me-2"></i>Asignaturas / Materias y sus Comisiones</li>';
|
||||||
|
if (isCom && !isSub && !isCar) listEl.innerHTML += '<li class="list-group-item text-danger fw-semibold"><i class="bi bi-diagram-3 me-2"></i>Comisiones de Cursada e Inscripciones</li>';
|
||||||
|
if (isRes) listEl.innerHTML += '<li class="list-group-item text-danger"><i class="bi bi-calendar-event me-2"></i>Asignaciones de Aulas (Reservas del Calendario)</li>';
|
||||||
|
if (isCls) listEl.innerHTML += '<li class="list-group-item text-danger"><i class="bi bi-door-open me-2"></i>Aulas Físicas y Virtuales</li>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const modalEl = document.getElementById('purgeConfirmModal');
|
const modalEl = document.getElementById('purgeConfirmModal');
|
||||||
if (modalEl) {
|
if (modalEl && typeof bootstrap !== 'undefined' && bootstrap.Modal) {
|
||||||
|
try {
|
||||||
const modal = new bootstrap.Modal(modalEl);
|
const modal = new bootstrap.Modal(modalEl);
|
||||||
modal.show();
|
modal.show();
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Bootstrap modal error, falling back to window.confirm:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback nativo si Bootstrap modal no estuviera disponible
|
||||||
|
if (confirm("ATENCIÓN: ¿Confirma la purga y eliminación permanente de los datos académicos seleccionados?\nEsta acción es irreversible.")) {
|
||||||
|
confirmPurge();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmPurge() {
|
function confirmPurge() {
|
||||||
|
const btn = document.getElementById('btnDoPurge');
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Purgando base de datos...';
|
||||||
|
}
|
||||||
const form = document.getElementById('purgeForm');
|
const form = document.getElementById('purgeForm');
|
||||||
if (form) {
|
if (form) {
|
||||||
form.submit();
|
form.submit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Modal: Confirmación de Purga de Datos Académicos -->
|
||||||
|
<div class="modal fade" id="purgeConfirmModal" tabindex="-1" aria-labelledby="purgeConfirmModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content border-0 shadow-lg">
|
||||||
|
<div class="modal-header bg-danger text-white py-3">
|
||||||
|
<h5 class="modal-title h6 fw-bold" id="purgeConfirmModalLabel">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>Confirmar Purga de Datos Académicos
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-4">
|
||||||
|
<p class="mb-3 text-body">
|
||||||
|
Se procederá a <strong>eliminar de forma permanente</strong> los siguientes registros de la base de datos:
|
||||||
|
</p>
|
||||||
|
<ul class="list-group list-group-flush border rounded mb-3 small" id="purgeEntitiesList">
|
||||||
|
<!-- Poblado dinámicamente -->
|
||||||
|
</ul>
|
||||||
|
<div class="alert alert-warning small mb-0 d-flex gap-2 align-items-start">
|
||||||
|
<i class="bi bi-shield-lock-fill fs-5 text-warning flex-shrink-0"></i>
|
||||||
|
<div>
|
||||||
|
<strong>Seguridad del Sistema:</strong> Los usuarios del sistema (administradores, bedeles, docentes, alumnos), sus contraseñas, roles RBAC y registros de auditoría <u>permanecerán intactos</u>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer bg-body-tertiary border-top py-2">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
|
||||||
|
<button type="button" class="btn btn-danger btn-sm px-3" id="btnDoPurge" onclick="confirmPurge()">
|
||||||
|
<i class="bi bi-trash-fill me-1"></i>Sí, Confirmar Purga
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -83,6 +83,16 @@
|
|||||||
<i class="bi bi-mortarboard me-2 text-danger"></i>Moodle 4.1 & Cola de Sync
|
<i class="bi bi-mortarboard me-2 text-danger"></i>Moodle 4.1 & Cola de Sync
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="tab-ui-btn" data-bs-toggle="tab" data-bs-target="#tab-ui" type="button" role="tab">
|
||||||
|
<i class="bi bi-window-desktop me-2 text-primary"></i>Interfaz & Dashboard
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link text-danger" id="tab-maintenance-btn" data-bs-toggle="tab" data-bs-target="#tab-maintenance" type="button" role="tab">
|
||||||
|
<i class="bi bi-radioactive me-2 text-danger"></i>Mantenimiento & Reinicio
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -186,46 +196,140 @@
|
|||||||
<div class="tab-pane fade" id="tab-sso" role="tabpanel">
|
<div class="tab-pane fade" id="tab-sso" role="tabpanel">
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
|
{% set is_local_active = (not settings or not settings.auth_providers or settings.auth_providers.local or settings.auth_providers.local_enabled) %}
|
||||||
|
{% set is_google_active = (settings and settings.auth_providers and (settings.auth_providers.google or settings.auth_providers.google_enabled)) %}
|
||||||
|
{% set is_moodle_active = (settings and settings.auth_providers and (settings.auth_providers.moodle or settings.auth_providers.moodle_enabled)) %}
|
||||||
<div class="card border-0 bg-body-tertiary p-3 rounded-3 mb-3">
|
<div class="card border-0 bg-body-tertiary p-3 rounded-3 mb-3">
|
||||||
<h6 class="fw-bold mb-3 d-flex align-items-center">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6 class="fw-bold mb-0 d-flex align-items-center">
|
||||||
<i class="bi bi-toggle2-on text-primary me-2"></i>Métodos de Autenticación Habilitados
|
<i class="bi bi-toggle2-on text-primary me-2"></i>Métodos de Autenticación Habilitados
|
||||||
</h6>
|
</h6>
|
||||||
|
<span class="badge bg-primary-subtle text-primary border border-primary-subtle" style="font-size: 0.72rem;">
|
||||||
|
Políticas de Acceso
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<form id="providersForm" onsubmit="saveAuthProviders(event)">
|
<form id="providersForm" onsubmit="saveAuthProviders(event)">
|
||||||
<div class="d-flex align-items-center justify-content-between p-2 rounded mb-2 border bg-body">
|
<!-- Login Local -->
|
||||||
<div>
|
<div class="p-2 rounded mb-2 border bg-body">
|
||||||
<div class="fw-semibold small">Login Nativo con Contraseña</div>
|
<div class="d-flex align-items-center justify-content-between mb-1">
|
||||||
<small class="text-muted" style="font-size: 11px;">Permite inicio de sesión local. Si se desactiva, queda reservado exclusivamente para rol ADMIN como contingencia.</small>
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="bi bi-key-fill text-primary me-2 fs-6"></i>
|
||||||
|
<span class="fw-semibold small">Login Nativo con Contraseña</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check form-switch fs-5 ms-3">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<input class="form-check-input" type="checkbox" id="auth_local_enabled" {% if not settings or not settings.auth_providers or settings.auth_providers.local %}checked{% endif %}>
|
<span id="badgeLocalStatus" class="badge {% if is_local_active %}bg-success-subtle text-success border border-success-subtle{% else %}bg-danger-subtle text-danger border border-danger-subtle{% endif %}" style="font-size: 0.72rem;">
|
||||||
|
{% if is_local_active %}Habilitado{% else %}Desactivado{% endif %}
|
||||||
|
</span>
|
||||||
|
<div class="form-check form-switch fs-5 m-0">
|
||||||
|
<input class="form-check-input" type="checkbox" id="auth_local_enabled" onchange="updateProviderStatusBadges()" {% if is_local_active %}checked{% endif %}>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted d-block ps-4" style="font-size: 11px;">Permite inicio de sesión local para alumnos, docentes y bedelía. Si se desactiva, queda reservado exclusivamente para rol ADMIN como contingencia.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="d-flex align-items-center justify-content-between p-2 rounded mb-2 border bg-body">
|
<!-- Google OAuth -->
|
||||||
<div>
|
<div class="p-2 rounded mb-2 border bg-body">
|
||||||
<div class="fw-semibold small">Google OAuth 2.0 (Google Workspace)</div>
|
<div class="d-flex align-items-center justify-content-between mb-1">
|
||||||
<small class="text-muted" style="font-size: 11px;">Inicio de sesión con cuentas institucionales @unicaba.edu.ar mediante SSO.</small>
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="bi bi-google text-danger me-2 fs-6"></i>
|
||||||
|
<span class="fw-semibold small">Google OAuth 2.0 (Google Workspace)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check form-switch fs-5 ms-3">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<input class="form-check-input" type="checkbox" id="auth_google_enabled" {% if settings and settings.auth_providers and settings.auth_providers.google %}checked{% endif %}>
|
<span id="badgeGoogleStatus" class="badge {% if is_google_active %}bg-success-subtle text-success border border-success-subtle{% else %}bg-secondary-subtle text-secondary border border-secondary-subtle{% endif %}" style="font-size: 0.72rem;">
|
||||||
|
{% if is_google_active %}Habilitado{% else %}Desactivado{% endif %}
|
||||||
|
</span>
|
||||||
|
<div class="form-check form-switch fs-5 m-0">
|
||||||
|
<input class="form-check-input" type="checkbox" id="auth_google_enabled" onchange="updateProviderStatusBadges()" {% if is_google_active %}checked{% endif %}>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted d-block ps-4" style="font-size: 11px;">Inicio de sesión con cuentas institucionales @unicaba.edu.ar mediante Single Sign-On (SSO).</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="d-flex align-items-center justify-content-between p-2 rounded mb-3 border bg-body">
|
<!-- Moodle SSO -->
|
||||||
<div>
|
<div class="p-2 rounded mb-3 border bg-body">
|
||||||
<div class="fw-semibold small">Moodle SSO Delegado</div>
|
<div class="d-flex align-items-center justify-content-between mb-1">
|
||||||
<small class="text-muted" style="font-size: 11px;">Valida credenciales contra el servidor de Moodle 4.1 y aprovisiona perfil.</small>
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="bi bi-mortarboard-fill text-warning me-2 fs-6"></i>
|
||||||
|
<span class="fw-semibold small">Moodle SSO Delegado</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check form-switch fs-5 ms-3">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<input class="form-check-input" type="checkbox" id="auth_moodle_enabled" {% if settings and settings.auth_providers and settings.auth_providers.moodle %}checked{% endif %}>
|
<span id="badgeMoodleStatus" class="badge {% if is_moodle_active %}bg-success-subtle text-success border border-success-subtle{% else %}bg-secondary-subtle text-secondary border border-secondary-subtle{% endif %}" style="font-size: 0.72rem;">
|
||||||
|
{% if is_moodle_active %}Habilitado{% else %}Desactivado{% endif %}
|
||||||
|
</span>
|
||||||
|
<div class="form-check form-switch fs-5 m-0">
|
||||||
|
<input class="form-check-input" type="checkbox" id="auth_moodle_enabled" onchange="updateProviderStatusBadges()" {% if is_moodle_active %}checked{% endif %}>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted d-block ps-4" style="font-size: 11px;">Valida credenciales contra el servidor de Moodle 4.1 y auto-aprovisiona perfiles.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-sm btn-primary" id="btnSaveProviders">
|
<!-- Aviso de contingencia si local está apagado -->
|
||||||
<i class="bi bi-save me-1"></i>Actualizar Métodos de Login
|
<div id="alertLocalDisabled" class="alert alert-warning py-2 px-3 small mb-3 {% if is_local_active %}d-none{% endif %}" style="font-size: 11px;">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-1"></i>
|
||||||
|
<strong>Atención de Contingencia:</strong> Al apagar el Login Nativo, solo los usuarios con rol <code>ADMIN</code> podrán iniciar sesión con usuario y contraseña local.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary px-3" id="btnSaveProviders">
|
||||||
|
<i class="bi bi-save me-1"></i>Guardar Métodos de Autenticación
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-link text-decoration-none text-muted py-0" onclick="refreshAuthProvidersLive()" title="Recargar estado desde servidor">
|
||||||
|
<i class="bi bi-arrow-clockwise me-1"></i>Sincronizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Control de Modo Demo y Cuentas de Prueba Iniciales -->
|
||||||
|
<div class="card border border-warning-subtle shadow-sm p-3 rounded-3 mb-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h6 class="fw-bold mb-0 d-flex align-items-center text-body">
|
||||||
|
<i class="bi bi-shield-lock-fill text-warning me-2"></i>Acceso Rápido Demo & Cuentas de Prueba
|
||||||
|
</h6>
|
||||||
|
<span id="demoModeBadge" class="badge {% if demo_access_enabled %}bg-warning-subtle text-warning border border-warning-subtle{% else %}bg-secondary-subtle text-secondary border border-secondary-subtle{% endif %}">
|
||||||
|
{% if demo_access_enabled %}Activo en Login{% else %}Deshabilitado{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="small text-muted mb-2" style="font-size: 0.82rem;">
|
||||||
|
Permite ocultar el bloque de <em>"Acceso Rápido Demo (1 Clic)"</em> de la pantalla de inicio de sesión y deshabilitar las 3 cuentas de usuario de prueba iniciales para pasar a producción.
|
||||||
|
</p>
|
||||||
|
<div class="p-2 rounded bg-body-tertiary border mb-3 small">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
|
<span><i class="bi bi-building-gear me-1 text-warning"></i><strong>Bedelía:</strong> <code>bedelia@edu-space.com</code></span>
|
||||||
|
<span id="badgeBedelia" class="badge {% if demo_accounts_active %}bg-success-subtle text-success{% else %}bg-danger-subtle text-danger{% endif %}">
|
||||||
|
{% if demo_accounts_active %}Habilitado{% else %}Desactivado{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
|
<span><i class="bi bi-person-video3 me-1 text-info"></i><strong>Docente:</strong> <code>docente@edu-space.com</code></span>
|
||||||
|
<span id="badgeDocente" class="badge {% if demo_accounts_active %}bg-success-subtle text-success{% else %}bg-danger-subtle text-danger{% endif %}">
|
||||||
|
{% if demo_accounts_active %}Habilitado{% else %}Desactivado{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-mortarboard me-1 text-success"></i><strong>Alumno:</strong> <code>alumno@edu-space.com</code></span>
|
||||||
|
<span id="badgeAlumno" class="badge {% if demo_accounts_active %}bg-success-subtle text-success{% else %}bg-danger-subtle text-danger{% endif %}">
|
||||||
|
{% if demo_accounts_active %}Habilitado{% else %}Desactivado{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button type="button" class="btn btn-sm {% if demo_access_enabled or demo_accounts_active %}btn-outline-danger{% else %}btn-outline-success{% endif %}" id="btnToggleDemoMode" onclick="toggleDemoMode()">
|
||||||
|
<i class="bi {% if demo_access_enabled or demo_accounts_active %}bi-shield-slash{% else %}bi-shield-check{% endif %} me-1"></i>
|
||||||
|
<span id="btnToggleDemoText">
|
||||||
|
{% if demo_access_enabled or demo_accounts_active %}
|
||||||
|
Deshabilitar Acceso Demo y Cuentas Default
|
||||||
|
{% else %}
|
||||||
|
Reactivar Acceso Demo y Cuentas Default
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
@@ -401,6 +505,142 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ========================================== -->
|
||||||
|
<!-- PESTAÑA 4: INTERFAZ & DASHBOARD -->
|
||||||
|
<!-- ========================================== -->
|
||||||
|
<div class="tab-pane fade" id="tab-ui" role="tabpanel">
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<form id="uiConfigForm" onsubmit="saveUiConfig(event)">
|
||||||
|
<div class="card border shadow-none bg-body-tertiary p-3 mb-4 rounded-3">
|
||||||
|
<h6 class="fw-bold mb-3 d-flex align-items-center">
|
||||||
|
<i class="bi bi-layout-text-window-reverse text-primary me-2"></i>Personalización Visual del Dashboard
|
||||||
|
</h6>
|
||||||
|
|
||||||
|
<div class="form-check form-switch p-0 d-flex align-items-center justify-content-between mb-2 border rounded p-3 bg-body">
|
||||||
|
<div class="pe-3">
|
||||||
|
<label class="form-check-label fw-bold mb-1 d-block text-body" for="show_role_banner_switch">
|
||||||
|
Mostrar banner de saludo y selector de roles en el Dashboard
|
||||||
|
</label>
|
||||||
|
<div class="text-muted small">
|
||||||
|
Habilita el banner superior <code>¡Hola, {Usuario}!</code> junto a la insignia de rol y la barra de cambio de perspectiva de roles (Admin, Bedelía, Docente, Alumno) en el Dashboard.
|
||||||
|
<div class="text-warning-emphasis small mt-1">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>Si se desactiva, el dashboard se mostrará directamente con los indicadores operativos del rol del usuario, evitando confusiones.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input class="form-check-input ms-2 fs-4" type="checkbox" role="switch" id="show_role_banner_switch"
|
||||||
|
{% if settings.ui and settings.ui.show_role_banner %}checked{% elif not settings.ui or settings.ui.show_role_banner is not defined %}checked{% endif %}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm px-4" id="btnSaveUi">
|
||||||
|
<i class="bi bi-check2-circle me-1"></i>Guardar Preferencias de Interfaz
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Panel de Consejos / UX -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card border-0 bg-primary-subtle text-primary-emphasis p-3 rounded-3 mb-3">
|
||||||
|
<h6 class="fw-bold mb-2"><i class="bi bi-lightbulb me-1"></i>Experiencia de Usuario (UX)</h6>
|
||||||
|
<p class="small mb-0">
|
||||||
|
El banner de saludo institucional y selector de perspectiva es ideal durante capacitaciones. Al deshabilitarlo para el uso diario institucional, los administradores y usuarios acceden de inmediato a sus módulos de gestión y carteleras sin distracciones ni dudas de rol.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ========================================== -->
|
||||||
|
<!-- PESTAÑA 5: MANTENIMIENTO & REINICIO -->
|
||||||
|
<!-- ========================================== -->
|
||||||
|
<div class="tab-pane fade" id="tab-maintenance" role="tabpanel">
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<!-- Danger Zone Card -->
|
||||||
|
<div class="card border border-danger shadow-sm rounded-3 overflow-hidden">
|
||||||
|
<div class="card-header bg-danger text-white py-3">
|
||||||
|
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill fs-4 me-2"></i>
|
||||||
|
<div>
|
||||||
|
<h5 class="h6 fw-bold mb-0 text-white">Zona de Peligro: Reinicialización Total del Sistema</h5>
|
||||||
|
<small class="text-white-50">Depuración integral de datos operacionales para inicio de ciclo o migración en limpio</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-white text-danger font-monospace px-2 py-1 small">Requiere Clave Admin</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-4 bg-body">
|
||||||
|
<p class="mb-3 text-body">
|
||||||
|
Esta herramienta permite vaciar completamente los datos operacionales generados por la actividad académica y de uso diario en la plataforma, restableciendo el sistema a su estado inicial.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="p-3 rounded-3 bg-danger-subtle border border-danger-subtle h-100">
|
||||||
|
<h6 class="fw-bold text-danger mb-2 d-flex align-items-center">
|
||||||
|
<i class="bi bi-trash3-fill me-2"></i>Datos que se eliminarán:
|
||||||
|
</h6>
|
||||||
|
<ul class="small mb-0 ps-3 text-danger-emphasis">
|
||||||
|
<li><strong>Historiales:</strong> Toda la bitácora histórica y registros previos de auditoría.</li>
|
||||||
|
<li><strong>Reservas:</strong> Todas las reservas de aulas, asignaciones de espacios y estados.</li>
|
||||||
|
<li><strong>Espacios y Sedes:</strong> Todas las aulas físicas, virtuales, recursos y edificios/sedes.</li>
|
||||||
|
<li><strong>Académica:</strong> Carreras, materias, comisiones, docentes asignados, matrículas, notas y ciclos lectivos.</li>
|
||||||
|
<li><strong>Colas de sincronización:</strong> Tareas pendientes o fallidas hacia sistemas externos.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="p-3 rounded-3 bg-success-subtle border border-success-subtle h-100">
|
||||||
|
<h6 class="fw-bold text-success mb-2 d-flex align-items-center">
|
||||||
|
<i class="bi bi-shield-check me-2"></i>Datos que se PRESERVAN intactos:
|
||||||
|
</h6>
|
||||||
|
<ul class="small mb-0 ps-3 text-success-emphasis">
|
||||||
|
<li><strong>Cuentas de Usuarios:</strong> Todos los usuarios (Administradores, Bedeles, Docentes, Alumnos) con sus contraseñas activas.</li>
|
||||||
|
<li><strong>Roles y Permisos (RBAC):</strong> Estructura completa de perfiles institucionales.</li>
|
||||||
|
<li><strong>Configuración Global:</strong> Preferencias de correo SMTP, Single Sign-On, Moodle y opciones de interfaz.</li>
|
||||||
|
<li><strong>Tipos de Hitos:</strong> Tipos maestros configurados (Parciales, Finales, TP, etc.).</li>
|
||||||
|
<li><strong>Módulo de Administración:</strong> Todos los componentes de la pestaña Administración.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-warning py-2 px-3 small d-flex align-items-center mb-4">
|
||||||
|
<i class="bi bi-shield-lock-fill text-warning fs-5 me-2 flex-shrink-0"></i>
|
||||||
|
<div>
|
||||||
|
Por estrictas razones de seguridad institucional, para ejecutar esta acción deberá ingresar la <strong>contraseña del usuario Administrador</strong> actualmente autenticado.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button type="button" class="btn btn-danger btn-sm px-4 fw-semibold shadow-sm" data-bs-toggle="modal" data-bs-target="#modalResetSystem">
|
||||||
|
<i class="bi bi-radioactive me-2"></i>Reinicializar Sistema...
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info / Good Practices -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card border-0 bg-body-tertiary p-3 rounded-3 mb-3">
|
||||||
|
<h6 class="fw-bold mb-2"><i class="bi bi-info-circle text-primary me-1"></i>¿Cuándo usar esta función?</h6>
|
||||||
|
<p class="small text-muted mb-2">
|
||||||
|
Utilice la reinicialización cuando desee comenzar un nuevo ciclo lectivo desde cero, tras finalizar períodos de prueba o capacitación, o para cargar una nueva grilla de sedes y carreras sin arrastrar información residual.
|
||||||
|
</p>
|
||||||
|
<p class="small text-muted mb-0">
|
||||||
|
Al conservar intactos los usuarios y las configuraciones de seguridad, el personal administrativo y docente mantiene su acceso ininterrumpido a la plataforma.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -432,6 +672,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Confirmar Reinicialización del Sistema -->
|
||||||
|
<div class="modal fade" id="modalResetSystem" tabindex="-1" aria-labelledby="modalResetSystemLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content border-0 shadow">
|
||||||
|
<div class="modal-header bg-danger text-white py-2">
|
||||||
|
<h6 class="modal-title fw-bold" id="modalResetSystemLabel">
|
||||||
|
<i class="bi bi-shield-exclamation me-2"></i>Confirmación Crítica: Reinicio Total del Sistema
|
||||||
|
</h6>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-4">
|
||||||
|
<div class="alert alert-danger py-2 px-3 small mb-3">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-1"></i>
|
||||||
|
<strong>¡Atención!</strong> Esta acción es irreversible. Se eliminarán todas las reservas, aulas, sedes, materias, carreras, comisiones, notas y registros de auditoría previa.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="small text-muted mb-3">
|
||||||
|
Las cuentas de usuario y la configuración del sistema permanecerán intactas. Para autorizar la purga definitiva, ingrese su <strong>contraseña de Administrador</strong>:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id="resetFeedbackAlert"></div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="admin_reset_password" class="form-label small fw-bold">Contraseña del Administrador en sesión <span class="text-danger">*</span></label>
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<span class="input-group-text bg-body-tertiary"><i class="bi bi-key-fill text-danger"></i></span>
|
||||||
|
<input type="password" class="form-control" id="admin_reset_password" placeholder="Ingrese su contraseña institucional" required autocomplete="current-password">
|
||||||
|
<button class="btn btn-outline-secondary pass-toggle-btn" type="button" onclick="togglePassVisibility('admin_reset_password')" title="Mostrar/ocultar contraseña">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="confirm_reset_checkbox" onchange="document.getElementById('btnConfirmSystemReset').disabled = !this.checked">
|
||||||
|
<label class="form-check-label small text-body" for="confirm_reset_checkbox">
|
||||||
|
Entiendo las consecuencias irreversibles de esta purga y confirmo la reinicialización de los datos.
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer py-2 bg-light">
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal" id="btnCancelReset">Cancelar</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-danger fw-semibold px-3" id="btnConfirmSystemReset" disabled onclick="executeSystemReset()">
|
||||||
|
<i class="bi bi-radioactive me-1"></i>Confirmar Reinicio Completo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function showAlert(message, type = 'success') {
|
function showAlert(message, type = 'success') {
|
||||||
const container = document.getElementById('settingsAlertContainer');
|
const container = document.getElementById('settingsAlertContainer');
|
||||||
@@ -521,6 +811,79 @@ async function runSmtpTest() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// Reactividad Dinámica de Badges de Métodos de Autenticación
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
function updateProviderStatusBadges() {
|
||||||
|
const localCheck = document.getElementById('auth_local_enabled');
|
||||||
|
const googleCheck = document.getElementById('auth_google_enabled');
|
||||||
|
const moodleCheck = document.getElementById('auth_moodle_enabled');
|
||||||
|
|
||||||
|
const badgeLocal = document.getElementById('badgeLocalStatus');
|
||||||
|
const badgeGoogle = document.getElementById('badgeGoogleStatus');
|
||||||
|
const badgeMoodle = document.getElementById('badgeMoodleStatus');
|
||||||
|
const alertLocal = document.getElementById('alertLocalDisabled');
|
||||||
|
|
||||||
|
if (localCheck && badgeLocal) {
|
||||||
|
if (localCheck.checked) {
|
||||||
|
badgeLocal.className = 'badge bg-success-subtle text-success border border-success-subtle';
|
||||||
|
badgeLocal.textContent = 'Habilitado';
|
||||||
|
if (alertLocal) alertLocal.classList.add('d-none');
|
||||||
|
} else {
|
||||||
|
badgeLocal.className = 'badge bg-danger-subtle text-danger border border-danger-subtle';
|
||||||
|
badgeLocal.textContent = 'Desactivado';
|
||||||
|
if (alertLocal) alertLocal.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (googleCheck && badgeGoogle) {
|
||||||
|
if (googleCheck.checked) {
|
||||||
|
badgeGoogle.className = 'badge bg-success-subtle text-success border border-success-subtle';
|
||||||
|
badgeGoogle.textContent = 'Habilitado';
|
||||||
|
} else {
|
||||||
|
badgeGoogle.className = 'badge bg-secondary-subtle text-secondary border border-secondary-subtle';
|
||||||
|
badgeGoogle.textContent = 'Desactivado';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moodleCheck && badgeMoodle) {
|
||||||
|
if (moodleCheck.checked) {
|
||||||
|
badgeMoodle.className = 'badge bg-success-subtle text-success border border-success-subtle';
|
||||||
|
badgeMoodle.textContent = 'Habilitado';
|
||||||
|
} else {
|
||||||
|
badgeMoodle.className = 'badge bg-secondary-subtle text-secondary border border-secondary-subtle';
|
||||||
|
badgeMoodle.textContent = 'Desactivado';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// Sincronización en Vivo de Métodos de Autenticación desde API
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
async function refreshAuthProvidersLive() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/admin/settings/auth-providers');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data && data.auth_providers) {
|
||||||
|
const ap = data.auth_providers;
|
||||||
|
const localCheck = document.getElementById('auth_local_enabled');
|
||||||
|
const googleCheck = document.getElementById('auth_google_enabled');
|
||||||
|
const moodleCheck = document.getElementById('auth_moodle_enabled');
|
||||||
|
|
||||||
|
if (localCheck && ap.local !== undefined) localCheck.checked = Boolean(ap.local);
|
||||||
|
if (googleCheck && ap.google !== undefined) googleCheck.checked = Boolean(ap.google);
|
||||||
|
if (moodleCheck && ap.moodle !== undefined) moodleCheck.checked = Boolean(ap.moodle);
|
||||||
|
|
||||||
|
updateProviderStatusBadges();
|
||||||
|
showAlert('Estados de métodos de autenticación sincronizados con el servidor.', 'info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Error al sincronizar auth providers en vivo:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
// Guardar Métodos de Autenticación
|
// Guardar Métodos de Autenticación
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
@@ -532,19 +895,42 @@ async function saveAuthProviders(e) {
|
|||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
local_enabled: document.getElementById('auth_local_enabled').checked,
|
local_enabled: document.getElementById('auth_local_enabled').checked,
|
||||||
|
local: document.getElementById('auth_local_enabled').checked,
|
||||||
google_enabled: document.getElementById('auth_google_enabled').checked,
|
google_enabled: document.getElementById('auth_google_enabled').checked,
|
||||||
moodle_enabled: document.getElementById('auth_moodle_enabled').checked
|
google: document.getElementById('auth_google_enabled').checked,
|
||||||
|
moodle_enabled: document.getElementById('auth_moodle_enabled').checked,
|
||||||
|
moodle: document.getElementById('auth_moodle_enabled').checked
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/admin/settings/auth-providers', {
|
let res = await fetch('/admin/settings/auth-providers', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
// Reintento por endpoint API directo
|
||||||
|
res = await fetch('/api/v1/admin/settings/auth-providers', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
showAlert(data.message || 'Métodos de acceso actualizados.', 'success');
|
if (data.auth_providers) {
|
||||||
|
const ap = data.auth_providers;
|
||||||
|
if (ap.local !== undefined) document.getElementById('auth_local_enabled').checked = Boolean(ap.local);
|
||||||
|
if (ap.google !== undefined) document.getElementById('auth_google_enabled').checked = Boolean(ap.google);
|
||||||
|
if (ap.moodle !== undefined) document.getElementById('auth_moodle_enabled').checked = Boolean(ap.moodle);
|
||||||
|
}
|
||||||
|
updateProviderStatusBadges();
|
||||||
|
showAlert(data.message || 'Métodos de autenticación actualizados y guardados exitosamente.', 'success');
|
||||||
} else {
|
} else {
|
||||||
showAlert(data.message || 'Error al actualizar métodos de acceso.', 'danger');
|
showAlert(data.message || 'Error al actualizar métodos de acceso.', 'danger');
|
||||||
}
|
}
|
||||||
@@ -552,7 +938,72 @@ async function saveAuthProviders(e) {
|
|||||||
showAlert('Error de comunicación: ' + err.message, 'danger');
|
showAlert('Error de comunicación: ' + err.message, 'danger');
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.innerHTML = '<i class="bi bi-save me-1"></i>Actualizar Métodos de Login';
|
btn.innerHTML = '<i class="bi bi-save me-1"></i>Guardar Métodos de Autenticación';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
updateProviderStatusBadges();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// Alternar Modo Demo y Cuentas de Prueba Default
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
async function toggleDemoMode() {
|
||||||
|
const btn = document.getElementById('btnToggleDemoMode');
|
||||||
|
const btnText = document.getElementById('btnToggleDemoText');
|
||||||
|
const badge = document.getElementById('demoModeBadge');
|
||||||
|
btn.disabled = true;
|
||||||
|
const isCurrentlyActive = btn.classList.contains('btn-outline-danger');
|
||||||
|
const targetEnable = !isCurrentlyActive;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/admin/settings/demo-mode', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ enabled: targetEnable })
|
||||||
|
});
|
||||||
|
const contentType = res.headers.get('content-type') || '';
|
||||||
|
let data = {};
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
data = await res.json();
|
||||||
|
} else {
|
||||||
|
const rawText = await res.text();
|
||||||
|
throw new Error(res.ok ? 'Respuesta no válida' : `Error del servidor (${res.status})`);
|
||||||
|
}
|
||||||
|
if (res.ok) {
|
||||||
|
showAlert(data.message, targetEnable ? 'info' : 'success');
|
||||||
|
if (targetEnable) {
|
||||||
|
btn.className = 'btn btn-sm btn-outline-danger';
|
||||||
|
btn.querySelector('i').className = 'bi bi-shield-slash me-1';
|
||||||
|
btnText.textContent = 'Deshabilitar Acceso Demo y Cuentas Default';
|
||||||
|
badge.className = 'badge bg-warning-subtle text-warning border border-warning-subtle';
|
||||||
|
badge.textContent = 'Activo en Login';
|
||||||
|
['badgeBedelia', 'badgeDocente', 'badgeAlumno'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) { el.className = 'badge bg-success-subtle text-success'; el.textContent = 'Habilitado'; }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
btn.className = 'btn btn-sm btn-outline-success';
|
||||||
|
btn.querySelector('i').className = 'bi bi-shield-check me-1';
|
||||||
|
btnText.textContent = 'Reactivar Acceso Demo y Cuentas Default';
|
||||||
|
badge.className = 'badge bg-secondary-subtle text-secondary border border-secondary-subtle';
|
||||||
|
badge.textContent = 'Deshabilitado';
|
||||||
|
['badgeBedelia', 'badgeDocente', 'badgeAlumno'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) { el.className = 'badge bg-danger-subtle text-danger'; el.textContent = 'Desactivado'; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showAlert(data.message || 'Error al cambiar modo demo', 'danger');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showAlert('Error de conexión: ' + err.message, 'danger');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -769,6 +1220,94 @@ function escapeHtml(str) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// Guardar Configuración de Interfaz (UI & Dashboard)
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
async function saveUiConfig(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = document.getElementById('btnSaveUi');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Guardando...';
|
||||||
|
|
||||||
|
const showRoleBanner = document.getElementById('show_role_banner_switch').checked;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/admin/settings/ui', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ show_role_banner: showRoleBanner })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
showAlert(data.message || 'Preferencias de interfaz guardadas correctamente.', 'success');
|
||||||
|
} else {
|
||||||
|
showAlert(data.message || 'Error al guardar preferencias de interfaz.', 'danger');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showAlert('Error de comunicación con el servidor: ' + err.message, 'danger');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-check2-circle me-1"></i>Guardar Preferencias de Interfaz';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// Reinicialización Total del Sistema (Requiere Contraseña Admin)
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
async function executeSystemReset() {
|
||||||
|
const passwordInput = document.getElementById('admin_reset_password');
|
||||||
|
const feedback = document.getElementById('resetFeedbackAlert');
|
||||||
|
const btnConfirm = document.getElementById('btnConfirmSystemReset');
|
||||||
|
const btnCancel = document.getElementById('btnCancelReset');
|
||||||
|
const check = document.getElementById('confirm_reset_checkbox');
|
||||||
|
|
||||||
|
const password = (passwordInput ? passwordInput.value : '').trim();
|
||||||
|
if (!password) {
|
||||||
|
feedback.innerHTML = '<div class="alert alert-warning py-2 px-3 small mb-3"><i class="bi bi-exclamation-circle me-1"></i>Por favor ingrese su contraseña de administrador.</div>';
|
||||||
|
if (passwordInput) passwordInput.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btnConfirm.disabled = true;
|
||||||
|
btnCancel.disabled = true;
|
||||||
|
btnConfirm.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Reinicializando sistema...';
|
||||||
|
feedback.innerHTML = '<div class="alert alert-info py-2 px-3 small mb-3"><span class="spinner-border spinner-border-sm me-1"></span>Procesando purga de datos operacionales...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/admin/settings/reset_system', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok && data.status === 'success') {
|
||||||
|
feedback.innerHTML = `<div class="alert alert-success py-2 px-3 small mb-3"><i class="bi bi-check-circle-fill me-1"></i>${data.message}</div>`;
|
||||||
|
showAlert(data.message, 'success');
|
||||||
|
passwordInput.value = '';
|
||||||
|
if (check) check.checked = false;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const modalEl = document.getElementById('modalResetSystem');
|
||||||
|
const modalInstance = bootstrap.Modal.getInstance(modalEl);
|
||||||
|
if (modalInstance) modalInstance.hide();
|
||||||
|
window.location.reload();
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
feedback.innerHTML = `<div class="alert alert-danger py-2 px-3 small mb-3"><i class="bi bi-x-circle-fill me-1"></i>${data.message || 'Error al autorizar la reinicialización.'}</div>`;
|
||||||
|
btnConfirm.disabled = false;
|
||||||
|
btnCancel.disabled = false;
|
||||||
|
btnConfirm.innerHTML = '<i class="bi bi-radioactive me-1"></i>Confirmar Reinicio Completo';
|
||||||
|
if (passwordInput) passwordInput.focus();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
feedback.innerHTML = `<div class="alert alert-danger py-2 px-3 small mb-3"><i class="bi bi-exclamation-triangle-fill me-1"></i>Error de comunicación: ${err.message}</div>`;
|
||||||
|
btnConfirm.disabled = false;
|
||||||
|
btnCancel.disabled = false;
|
||||||
|
btnConfirm.innerHTML = '<i class="bi bi-radioactive me-1"></i>Confirmar Reinicio Completo';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Cargar estadísticas y tareas al iniciar
|
// Cargar estadísticas y tareas al iniciar
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
refreshQueueStats();
|
refreshQueueStats();
|
||||||
|
|||||||
@@ -27,7 +27,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Notification Alerts -->
|
<!-- Notification Alerts -->
|
||||||
{% if msg == 'deleted' %}
|
{% if msg == 'created' %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-check-circle-fill me-2"></i>La asignatura ha sido registrada exitosamente en el catálogo académico.
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif msg == 'updated' %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-check-circle-fill me-2"></i>Los datos de la asignatura fueron actualizados correctamente.
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif msg == 'toggled' %}
|
||||||
|
<div class="alert alert-info alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-info-circle-fill me-2"></i>El estado de actividad de la asignatura ha sido modificado.
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% elif msg == 'deleted' %}
|
||||||
<div class="alert alert-success alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
<div class="alert alert-success alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
||||||
<i class="bi bi-check-circle-fill me-2"></i>La asignatura ha sido eliminada permanentemente del sistema.
|
<i class="bi bi-check-circle-fill me-2"></i>La asignatura ha sido eliminada permanentemente del sistema.
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
@@ -37,11 +52,21 @@
|
|||||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>La asignatura posee comisiones históricas asociadas. Por integridad de los registros académicos fue <strong>desactivada</strong>.
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>La asignatura posee comisiones históricas asociadas. Por integridad de los registros académicos fue <strong>desactivada</strong>.
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
|
{% elif error == 'conflict' %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-x-circle-fill me-2"></i>Ya existe una asignatura con ese código o denominación.
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
{% elif error == 'delete' %}
|
{% elif error == 'delete' %}
|
||||||
<div class="alert alert-danger alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
<div class="alert alert-danger alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
||||||
<i class="bi bi-x-circle-fill me-2"></i>Ocurrió un error al procesar la eliminación de la asignatura.
|
<i class="bi bi-x-circle-fill me-2"></i>Ocurrió un error al procesar la eliminación de la asignatura.
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
|
{% elif error %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show mb-4 shadow-sm" role="alert">
|
||||||
|
<i class="bi bi-x-circle-fill me-2"></i>Ocurrió un error al procesar la operación sobre la asignatura.
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Filters Bar -->
|
<!-- Filters Bar -->
|
||||||
|
|||||||
@@ -38,11 +38,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<form method="POST">
|
{% if error %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show mb-4" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>{{ error }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="{% if user and user.id %}/admin/users/edit/{{ user.id }}{% else %}/admin/users/new{% endif %}">
|
||||||
{% if user %}
|
{% if user %}
|
||||||
<input type="hidden" name="id" value="{{ user.id }}">
|
<input type="hidden" name="id" value="{{ user.id }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
<!-- SECCIÓN 1: IDENTIDAD Y DOCUMENTACIÓN -->
|
<!-- SECCIÓN 1: IDENTIDAD Y DOCUMENTACIÓN -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h6 class="text-uppercase text-primary fw-bold small border-bottom pb-2 mb-3">
|
<h6 class="text-uppercase text-primary fw-bold small border-bottom pb-2 mb-3">
|
||||||
@@ -164,12 +172,15 @@
|
|||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text bg-body-tertiary"><i class="bi bi-key"></i></span>
|
<span class="input-group-text bg-body-tertiary"><i class="bi bi-key"></i></span>
|
||||||
<input type="password" class="form-control" id="password" name="password"
|
<input type="password" class="form-control" id="password" name="password"
|
||||||
{% if not user %}required{% endif %}
|
{% if not user %}required minlength="6"{% else %}minlength="6"{% endif %}
|
||||||
placeholder="{% if user %}Dejar en blanco para mantener{% else %}Mínimo 6 caracteres{% endif %}">
|
placeholder="{% if user %}Dejar en blanco para mantener{% else %}Mínimo 6 caracteres{% endif %}">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" id="togglePasswordBtn" title="Mostrar u ocultar contraseña">
|
||||||
|
<i class="bi bi-eye" id="togglePasswordIcon"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% if user %}
|
{% if user %}
|
||||||
<small class="text-muted d-block mt-1">
|
<small class="text-muted d-block mt-1">
|
||||||
Solo complete este campo si desea restablecer la contraseña del usuario.
|
Solo complete este campo si desea restablecer la contraseña del usuario (mínimo 6 caracteres).
|
||||||
</small>
|
</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -250,6 +261,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
docTypeSelect.addEventListener('change', updateDocHint);
|
docTypeSelect.addEventListener('change', updateDocHint);
|
||||||
updateDocHint();
|
updateDocHint();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var togglePasswordBtn = document.getElementById('togglePasswordBtn');
|
||||||
|
var passwordInput = document.getElementById('password');
|
||||||
|
var togglePasswordIcon = document.getElementById('togglePasswordIcon');
|
||||||
|
if (togglePasswordBtn && passwordInput && togglePasswordIcon) {
|
||||||
|
togglePasswordBtn.addEventListener('click', function() {
|
||||||
|
var isPass = passwordInput.getAttribute('type') === 'password';
|
||||||
|
passwordInput.setAttribute('type', isPass ? 'text' : 'password');
|
||||||
|
togglePasswordIcon.className = isPass ? 'bi bi-eye-slash' : 'bi bi-eye';
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -27,6 +27,11 @@
|
|||||||
<i class="bi bi-check-circle-fill me-2"></i>Los datos del usuario han sido guardados exitosamente.
|
<i class="bi bi-check-circle-fill me-2"></i>Los datos del usuario han sido guardados exitosamente.
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
|
{% elif msg == 'password_updated' %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show shadow-sm mb-4" role="alert">
|
||||||
|
<i class="bi bi-shield-check me-2"></i>La contraseña del usuario ha sido restablecida exitosamente.
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
{% elif error %}
|
{% elif error %}
|
||||||
<div class="alert alert-danger alert-dismissible fade show shadow-sm mb-4" role="alert">
|
<div class="alert alert-danger alert-dismissible fade show shadow-sm mb-4" role="alert">
|
||||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>{{ error }}
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>{{ error }}
|
||||||
@@ -182,6 +187,13 @@
|
|||||||
<a href="/admin/users/edit/{{ u.id }}" class="btn btn-outline-primary" title="Editar Ficha de Usuario">
|
<a href="/admin/users/edit/{{ u.id }}" class="btn btn-outline-primary" title="Editar Ficha de Usuario">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
</a>
|
</a>
|
||||||
|
<button type="button" class="btn btn-outline-warning btn-reset-password"
|
||||||
|
data-user-id="{{ u.id }}"
|
||||||
|
data-user-name="{{ u.name }}"
|
||||||
|
data-user-email="{{ u.email }}"
|
||||||
|
title="Restablecer Contraseña">
|
||||||
|
<i class="bi bi-key"></i>
|
||||||
|
</button>
|
||||||
{% if u.id != user.id %}
|
{% if u.id != user.id %}
|
||||||
<a href="/auth/impersonate/{{ u.id }}" class="btn btn-outline-info" title="Impersonar usuario (Iniciar sesión como {{ u.name }})">
|
<a href="/auth/impersonate/{{ u.id }}" class="btn btn-outline-info" title="Impersonar usuario (Iniciar sesión como {{ u.name }})">
|
||||||
<i class="bi bi-incognito"></i>
|
<i class="bi bi-incognito"></i>
|
||||||
@@ -274,6 +286,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Restablecimiento Directo de Contraseña -->
|
||||||
|
<div class="modal fade" id="resetPasswordModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content border-0 shadow-lg" style="border-radius: 14px;">
|
||||||
|
<div class="modal-header border-0 pb-0">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="badge bg-warning-subtle text-warning p-2 rounded-circle me-2">
|
||||||
|
<i class="bi bi-key-fill fs-5"></i>
|
||||||
|
</div>
|
||||||
|
<h5 class="modal-title fw-bold mb-0">Restablecer Contraseña</h5>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<form id="resetPasswordForm" method="POST" action="/admin/users/password">
|
||||||
|
<input type="hidden" name="id" id="resetPasswordUserId">
|
||||||
|
<div class="modal-body text-start py-3">
|
||||||
|
<p class="mb-2 text-muted small">
|
||||||
|
Ingrese la nueva contraseña de acceso institucional para el siguiente usuario:
|
||||||
|
</p>
|
||||||
|
<div class="p-2 rounded bg-body-tertiary mb-3">
|
||||||
|
<div class="fw-bold text-primary" id="resetPasswordUserName"></div>
|
||||||
|
<div class="small text-muted font-monospace" id="resetPasswordUserEmail"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="modalNewPassword" class="form-label small fw-semibold">Nueva Contraseña <span class="text-danger">*</span></label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text bg-body-tertiary"><i class="bi bi-lock"></i></span>
|
||||||
|
<input type="password" class="form-control" id="modalNewPassword" name="password" required minlength="6" placeholder="Mínimo 6 caracteres">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" id="toggleModalPasswordBtn" title="Mostrar/Ocultar clave">
|
||||||
|
<i class="bi bi-eye" id="toggleModalPasswordIcon"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-text small">
|
||||||
|
La contraseña debe contener al menos 6 caracteres.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer border-0 pt-0">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||||
|
<button type="submit" class="btn btn-warning px-3 fw-semibold">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>Guardar Contraseña
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
@@ -295,7 +355,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
if (emailEl) emailEl.textContent = email;
|
if (emailEl) emailEl.textContent = email;
|
||||||
if (formEl) {
|
if (formEl) {
|
||||||
formEl.action = actionUrl;
|
formEl.action = actionUrl;
|
||||||
// Add hidden input with id
|
|
||||||
var hiddenId = formEl.querySelector('input[name="id"]');
|
var hiddenId = formEl.querySelector('input[name="id"]');
|
||||||
if (!hiddenId) {
|
if (!hiddenId) {
|
||||||
hiddenId = document.createElement('input');
|
hiddenId = document.createElement('input');
|
||||||
@@ -308,6 +367,40 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
if (deleteModal) deleteModal.show();
|
if (deleteModal) deleteModal.show();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reset Password Modal
|
||||||
|
var resetModalEl = document.getElementById('resetPasswordModal');
|
||||||
|
var resetModal = resetModalEl ? new bootstrap.Modal(resetModalEl) : null;
|
||||||
|
var resetUserIdInput = document.getElementById('resetPasswordUserId');
|
||||||
|
var resetUserName = document.getElementById('resetPasswordUserName');
|
||||||
|
var resetUserEmail = document.getElementById('resetPasswordUserEmail');
|
||||||
|
var resetPasswordForm = document.getElementById('resetPasswordForm');
|
||||||
|
var modalNewPassword = document.getElementById('modalNewPassword');
|
||||||
|
|
||||||
|
document.querySelectorAll('.btn-reset-password').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var userId = this.getAttribute('data-user-id');
|
||||||
|
var name = this.getAttribute('data-user-name');
|
||||||
|
var email = this.getAttribute('data-user-email');
|
||||||
|
if (resetUserIdInput) resetUserIdInput.value = userId;
|
||||||
|
if (resetUserName) resetUserName.textContent = name;
|
||||||
|
if (resetUserEmail) resetUserEmail.textContent = email;
|
||||||
|
if (resetPasswordForm) resetPasswordForm.action = '/admin/users/' + userId + '/password';
|
||||||
|
if (modalNewPassword) modalNewPassword.value = '';
|
||||||
|
if (resetModal) resetModal.show();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var toggleModalPasswordBtn = document.getElementById('toggleModalPasswordBtn');
|
||||||
|
var toggleModalPasswordIcon = document.getElementById('toggleModalPasswordIcon');
|
||||||
|
if (toggleModalPasswordBtn && modalNewPassword && toggleModalPasswordIcon) {
|
||||||
|
toggleModalPasswordBtn.addEventListener('click', function() {
|
||||||
|
var isPass = modalNewPassword.getAttribute('type') === 'password';
|
||||||
|
modalNewPassword.setAttribute('type', isPass ? 'text' : 'password');
|
||||||
|
toggleModalPasswordIcon.className = isPass ? 'bi bi-eye-slash' : 'bi bi-eye';
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Cambiar Contraseña - Edu-Space Admin{% endblock %}
|
{% block title %}Cambiar Contraseña — Edu-Space{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container pt-5 mt-4">
|
<div class="container pt-5 mt-4">
|
||||||
@@ -8,49 +8,76 @@
|
|||||||
<div class="col-lg-6 col-md-8">
|
<div class="col-lg-6 col-md-8">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||||
<h1 class="h3 mb-0">
|
<div>
|
||||||
|
<h1 class="h3 mb-1 fw-bold">
|
||||||
<i class="bi bi-shield-lock text-primary me-2"></i>Cambiar Contraseña
|
<i class="bi bi-shield-lock text-primary me-2"></i>Cambiar Contraseña
|
||||||
</h1>
|
</h1>
|
||||||
|
<p class="text-muted small mb-0">Actualización segura de credencial de acceso institucional</p>
|
||||||
|
</div>
|
||||||
<a href="/auth/profile" class="btn btn-outline-secondary btn-sm">
|
<a href="/auth/profile" class="btn btn-outline-secondary btn-sm">
|
||||||
<i class="bi bi-arrow-left me-1"></i>Volver al Perfil
|
<i class="bi bi-arrow-left me-1"></i>Volver al Perfil
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Feedback Alerts -->
|
||||||
|
{% if success %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show shadow-sm mb-4" role="alert">
|
||||||
|
<i class="bi bi-check-circle-fill me-2"></i>{{ success }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show shadow-sm mb-4" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>{{ error }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Form Card -->
|
<!-- Form Card -->
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm" style="border-radius: 12px;">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<form method="POST" action="/auth/change_password">
|
<form method="POST" action="/auth/change_password">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="current_password" class="form-label">Contraseña Actual</label>
|
<label for="current_password" class="form-label small fw-semibold">Contraseña Actual <span class="text-danger">*</span></label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text"><i class="bi bi-lock"></i></span>
|
<span class="input-group-text bg-body-tertiary"><i class="bi bi-lock"></i></span>
|
||||||
<input type="password" class="form-control" id="current_password" name="current_password" required placeholder="Ingresa tu contraseña actual">
|
<input type="password" class="form-control" id="current_password" name="current_password" required placeholder="Ingresa tu contraseña actual">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePass('current_password', 'currentPassIcon')" title="Ver/Ocultar">
|
||||||
|
<i class="bi bi-eye" id="currentPassIcon"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="new_password" class="form-label">Nueva Contraseña</label>
|
<label for="new_password" class="form-label small fw-semibold">Nueva Contraseña <span class="text-danger">*</span></label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text"><i class="bi bi-key"></i></span>
|
<span class="input-group-text bg-body-tertiary"><i class="bi bi-key"></i></span>
|
||||||
<input type="password" class="form-control" id="new_password" name="new_password" required minlength="6" placeholder="Mínimo 6 caracteres">
|
<input type="password" class="form-control" id="new_password" name="new_password" required minlength="6" placeholder="Mínimo 6 caracteres">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePass('new_password', 'newPassIcon')" title="Ver/Ocultar">
|
||||||
|
<i class="bi bi-eye" id="newPassIcon"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text">Debe tener al menos 6 caracteres.</div>
|
<div class="form-text small">La nueva clave debe tener al menos 6 caracteres.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="confirm_password" class="form-label">Confirmar Nueva Contraseña</label>
|
<label for="confirm_password" class="form-label small fw-semibold">Confirmar Nueva Contraseña <span class="text-danger">*</span></label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text"><i class="bi bi-check2-circle"></i></span>
|
<span class="input-group-text bg-body-tertiary"><i class="bi bi-check2-circle"></i></span>
|
||||||
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required minlength="6" placeholder="Repite la nueva contraseña">
|
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required minlength="6" placeholder="Repite la nueva contraseña">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePass('confirm_password', 'confirmPassIcon')" title="Ver/Ocultar">
|
||||||
|
<i class="bi bi-eye" id="confirmPassIcon"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary py-2 fw-semibold shadow-sm">
|
||||||
<i class="bi bi-save me-1"></i>Actualizar Contraseña
|
<i class="bi bi-shield-check me-1"></i>Actualizar Contraseña
|
||||||
</button>
|
</button>
|
||||||
<a href="/auth/profile" class="btn btn-link text-muted">
|
<a href="/auth/profile" class="btn btn-link text-muted text-decoration-none">
|
||||||
Cancelar
|
Cancelar y volver
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -60,3 +87,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
function togglePass(inputId, iconId) {
|
||||||
|
var input = document.getElementById(inputId);
|
||||||
|
var icon = document.getElementById(iconId);
|
||||||
|
if (input && icon) {
|
||||||
|
var isPass = input.getAttribute('type') === 'password';
|
||||||
|
input.setAttribute('type', isPass ? 'text' : 'password');
|
||||||
|
icon.className = isPass ? 'bi bi-eye-slash' : 'bi bi-eye';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -190,6 +190,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if demo_access_enabled is not defined or demo_access_enabled %}
|
||||||
<!-- Accesos Rápidos Institucionales (Demo) -->
|
<!-- Accesos Rápidos Institucionales (Demo) -->
|
||||||
<div class="p-3 bg-body-tertiary rounded-3 border mt-3">
|
<div class="p-3 bg-body-tertiary rounded-3 border mt-3">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
@@ -215,6 +216,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="text-center mt-3">
|
<div class="text-center mt-3">
|
||||||
<small class="text-muted d-inline-flex align-items-center" style="font-size: 0.75rem;">
|
<small class="text-muted d-inline-flex align-items-center" style="font-size: 0.75rem;">
|
||||||
@@ -270,17 +272,20 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Demo login autofill buttons
|
// Demo login autofill & instant submit (1 Clic)
|
||||||
document.querySelectorAll('.demo-fill-btn').forEach(btn => {
|
document.querySelectorAll('.demo-fill-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', function() {
|
btn.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
const email = this.getAttribute('data-email');
|
const email = this.getAttribute('data-email');
|
||||||
const usrField = document.getElementById('username');
|
const usrField = document.getElementById('username');
|
||||||
const pwdField = document.getElementById('password');
|
const pwdField = document.getElementById('password');
|
||||||
|
const form = document.getElementById('localLoginForm');
|
||||||
if (usrField && pwdField) {
|
if (usrField && pwdField) {
|
||||||
usrField.value = email;
|
usrField.value = email;
|
||||||
pwdField.value = 'admin123';
|
pwdField.value = 'admin123';
|
||||||
usrField.classList.add('is-valid');
|
if (form) {
|
||||||
setTimeout(() => usrField.classList.remove('is-valid'), 1500);
|
form.submit();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+490
-46
@@ -1,3 +1,4 @@
|
|||||||
|
{% set auth_user = current_user if (current_user and current_user.is_authenticated) else user %}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="{{ current_lang }}" data-bs-theme="dark">
|
<html lang="{{ current_lang }}" data-bs-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
@@ -11,7 +12,7 @@
|
|||||||
<!-- Script Anti-Flicker para Modo Claro / Oscuro -->
|
<!-- Script Anti-Flicker para Modo Claro / Oscuro -->
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
let userTheme = "{{ user.theme_preference if (user and user.is_authenticated) else '' }}";
|
let userTheme = "{{ auth_user.theme_preference if (auth_user and auth_user.is_authenticated) else '' }}";
|
||||||
let theme = userTheme;
|
let theme = userTheme;
|
||||||
if (!theme || theme === 'auto') {
|
if (!theme || theme === 'auto') {
|
||||||
const storedTheme = localStorage.getItem('theme');
|
const storedTheme = localStorage.getItem('theme');
|
||||||
@@ -37,66 +38,327 @@
|
|||||||
transition: background-color 0.25s ease, color 0.25s ease;
|
transition: background-color 0.25s ease, color 0.25s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- MODO CLARO ([data-bs-theme="light"]) --- */
|
/* --- MODO CLARO ([data-bs-theme="light"]) - PALETA OFICIAL UNICABA: ROSAS, MAGENTA Y BLANCO --- */
|
||||||
[data-bs-theme="light"] body {
|
[data-bs-theme="light"] body {
|
||||||
background: #F8FAFC !important;
|
background-color: #FAF5F8 !important;
|
||||||
color: #1E293B !important;
|
color: #1E293B !important;
|
||||||
}
|
}
|
||||||
[data-bs-theme="light"] .navbar-logo {
|
.navbar-logo,
|
||||||
/* Filtro para convertir blanco a rosa (#B43E8E) */
|
[data-bs-theme="light"] .navbar-logo,
|
||||||
filter: brightness(0) saturate(100%) invert(35%) sepia(55%) saturate(2250%) hue-rotate(289deg) brightness(90%) contrast(90%);
|
[data-bs-theme="dark"] .navbar-logo {
|
||||||
|
filter: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sidebar en Modo Claro */
|
||||||
|
[data-bs-theme="light"] .sidebar {
|
||||||
|
background-color: #FFFFFF !important;
|
||||||
|
border-right: 1px solid rgba(180, 62, 142, 0.16) !important;
|
||||||
|
box-shadow: 2px 0 14px rgba(180, 62, 142, 0.05) !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .sidebar-header {
|
||||||
|
background-color: #FFFFFF !important;
|
||||||
|
border-bottom: 1px solid rgba(180, 62, 142, 0.12) !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .sidebar-header .brand-text {
|
||||||
|
color: #1E293B !important;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .sidebar-toggle-btn {
|
||||||
|
background-color: rgba(180, 62, 142, 0.08) !important;
|
||||||
|
border: 1px solid rgba(180, 62, 142, 0.25) !important;
|
||||||
|
color: #B43E8E !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .sidebar-toggle-btn:hover {
|
||||||
|
background-color: #B43E8E !important;
|
||||||
|
border-color: #B43E8E !important;
|
||||||
|
color: #FFFFFF !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .sidebar-nav .nav-link {
|
||||||
|
color: #1E293B !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .sidebar-nav .nav-link:hover,
|
||||||
|
[data-bs-theme="light"] .sidebar-nav .nav-link[aria-expanded="true"] {
|
||||||
|
background-color: rgba(180, 62, 142, 0.1) !important;
|
||||||
|
color: #B43E8E !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .sidebar-submenu .nav-link {
|
||||||
|
color: #475569 !important;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .sidebar-submenu .nav-link:hover {
|
||||||
|
background-color: rgba(180, 62, 142, 0.08) !important;
|
||||||
|
color: #B43E8E !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .sidebar-footer {
|
||||||
|
background-color: #FFFFFF !important;
|
||||||
|
border-top: 1px solid rgba(180, 62, 142, 0.12) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navbar & Dropdowns */
|
||||||
[data-bs-theme="light"] .glass-navbar {
|
[data-bs-theme="light"] .glass-navbar {
|
||||||
background: rgba(255, 255, 255, 0.88) !important;
|
background: rgba(255, 255, 255, 0.96) !important;
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
-webkit-backdrop-filter: blur(12px);
|
-webkit-backdrop-filter: blur(12px);
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08) !important;
|
border-bottom: 1px solid rgba(180, 62, 142, 0.16) !important;
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04) !important;
|
box-shadow: 0 4px 18px rgba(180, 62, 142, 0.06) !important;
|
||||||
}
|
}
|
||||||
[data-bs-theme="light"] .glass-navbar .nav-link {
|
[data-bs-theme="light"] .glass-navbar .nav-link {
|
||||||
color: #334155 !important;
|
color: #1E293B !important;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
[data-bs-theme="light"] .glass-navbar .nav-link:hover,
|
[data-bs-theme="light"] .glass-navbar .nav-link:hover,
|
||||||
[data-bs-theme="light"] .glass-navbar .nav-link.active {
|
[data-bs-theme="light"] .glass-navbar .nav-link.active {
|
||||||
color: #B43E8E !important;
|
color: #B43E8E !important;
|
||||||
}
|
}
|
||||||
[data-bs-theme="light"] .glass-navbar .dropdown-menu {
|
[data-bs-theme="light"] .dropdown-menu {
|
||||||
background: #FFFFFF !important;
|
background-color: #FFFFFF !important;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1) !important;
|
border: 1px solid rgba(180, 62, 142, 0.2) !important;
|
||||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1) !important;
|
box-shadow: 0 12px 32px rgba(180, 62, 142, 0.12) !important;
|
||||||
}
|
}
|
||||||
[data-bs-theme="light"] .glass-navbar .dropdown-item {
|
[data-bs-theme="light"] .dropdown-item {
|
||||||
color: #334155 !important;
|
color: #1E293B !important;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
[data-bs-theme="light"] .glass-navbar .dropdown-item:hover {
|
[data-bs-theme="light"] .dropdown-item:hover {
|
||||||
background-color: #F1F5F9 !important;
|
background-color: #FDF2F8 !important;
|
||||||
color: #B43E8E !important;
|
color: #B43E8E !important;
|
||||||
}
|
}
|
||||||
|
[data-bs-theme="light"] .dropdown-divider {
|
||||||
|
border-color: rgba(180, 62, 142, 0.12) !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .dropdown-header {
|
||||||
|
color: #64748B !important;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards & Contenedores UniCABA (Blanco Puro con acentos Rosas/Magenta) */
|
||||||
[data-bs-theme="light"] .glass-card,
|
[data-bs-theme="light"] .glass-card,
|
||||||
[data-bs-theme="light"] .card {
|
[data-bs-theme="light"] .card {
|
||||||
background: #FFFFFF !important;
|
background: #FFFFFF !important;
|
||||||
color: #1E293B !important;
|
color: #1E293B !important;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.08) !important;
|
border: 1px solid rgba(180, 62, 142, 0.16) !important;
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04) !important;
|
box-shadow: 0 4px 18px rgba(180, 62, 142, 0.05), 0 1px 3px rgba(0, 0, 0, 0.02) !important;
|
||||||
}
|
}
|
||||||
|
[data-bs-theme="light"] .card-header {
|
||||||
|
background-color: #FFF5F9 !important;
|
||||||
|
border-bottom: 1px solid rgba(180, 62, 142, 0.14) !important;
|
||||||
|
color: #1E293B !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .card-footer {
|
||||||
|
background-color: #FFF5F9 !important;
|
||||||
|
border-top: 1px solid rgba(180, 62, 142, 0.14) !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .dashboard-role-banner {
|
||||||
|
background: linear-gradient(135deg, #FFFFFF 0%, #FFF5F9 100%) !important;
|
||||||
|
border: 1px solid rgba(180, 62, 142, 0.2) !important;
|
||||||
|
border-left: 6px solid #B43E8E !important;
|
||||||
|
box-shadow: 0 4px 18px rgba(180, 62, 142, 0.08) !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .dashboard-stat-card:hover {
|
||||||
|
border-color: #B43E8E !important;
|
||||||
|
box-shadow: 0 8px 24px rgba(180, 62, 142, 0.16) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tablas con Alto Contraste y Encabezados UniCABA */
|
||||||
[data-bs-theme="light"] .table {
|
[data-bs-theme="light"] .table {
|
||||||
--bs-table-color: #1E293B;
|
--bs-table-color: #1E293B;
|
||||||
--bs-table-bg: transparent;
|
--bs-table-bg: transparent;
|
||||||
|
border-color: rgba(180, 62, 142, 0.12) !important;
|
||||||
}
|
}
|
||||||
[data-bs-theme="light"] .bg-body-tertiary {
|
[data-bs-theme="light"] .table thead th {
|
||||||
background-color: #F1F5F9 !important;
|
background-color: #FFF5F9 !important;
|
||||||
|
color: #1E293B !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
border-bottom: 2px solid rgba(180, 62, 142, 0.25) !important;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .table tbody td {
|
||||||
|
color: #1E293B !important;
|
||||||
|
border-bottom: 1px solid rgba(180, 62, 142, 0.1) !important;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .table tbody tr:hover td {
|
||||||
|
background-color: rgba(180, 62, 142, 0.04) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Formularios, Inputs y Selects */
|
||||||
|
[data-bs-theme="light"] .form-label {
|
||||||
|
color: #1E293B !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .form-control,
|
||||||
|
[data-bs-theme="light"] .form-select {
|
||||||
|
background-color: #FFFFFF !important;
|
||||||
|
border: 1px solid #CBD5E1 !important;
|
||||||
|
color: #1E293B !important;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .form-control:focus,
|
||||||
|
[data-bs-theme="light"] .form-select:focus {
|
||||||
|
border-color: #B43E8E !important;
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(180, 62, 142, 0.22) !important;
|
||||||
|
background-color: #FFFFFF !important;
|
||||||
|
color: #1E293B !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .form-control::placeholder {
|
||||||
|
color: #64748B !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .input-group-text {
|
||||||
|
background-color: #FFF5F9 !important;
|
||||||
|
border: 1px solid #CBD5E1 !important;
|
||||||
color: #334155 !important;
|
color: #334155 !important;
|
||||||
}
|
}
|
||||||
|
[data-bs-theme="light"] .form-text {
|
||||||
|
color: #475569 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Jerarquía Tipográfica y Contraste de Texto */
|
||||||
|
[data-bs-theme="light"] .bg-body-tertiary {
|
||||||
|
background-color: #FFF5F9 !important;
|
||||||
|
color: #1E293B !important;
|
||||||
|
}
|
||||||
[data-bs-theme="light"] .text-body {
|
[data-bs-theme="light"] .text-body {
|
||||||
color: #1E293B !important;
|
color: #1E293B !important;
|
||||||
}
|
}
|
||||||
[data-bs-theme="light"] .text-muted {
|
[data-bs-theme="light"] .text-muted {
|
||||||
color: #64748B !important;
|
color: #64748B !important;
|
||||||
}
|
}
|
||||||
|
[data-bs-theme="light"] h1, [data-bs-theme="light"] h2,
|
||||||
|
[data-bs-theme="light"] h3, [data-bs-theme="light"] h4,
|
||||||
|
[data-bs-theme="light"] h5, [data-bs-theme="light"] h6 {
|
||||||
|
color: #1E293B !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Colores Semánticos UniCABA */
|
||||||
|
[data-bs-theme="light"] .text-primary {
|
||||||
|
color: #B43E8E !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .text-warning {
|
||||||
|
color: #B45309 !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .text-info {
|
||||||
|
color: #0369A1 !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .text-success {
|
||||||
|
color: #15803D !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .text-danger {
|
||||||
|
color: #B91C1C !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .text-secondary {
|
||||||
|
color: #475569 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges de Estado Rosas y Magentas UniCABA */
|
||||||
|
[data-bs-theme="light"] .badge.bg-primary-subtle {
|
||||||
|
background-color: #FDF2F8 !important;
|
||||||
|
color: #9D174D !important;
|
||||||
|
border: 1px solid #FBCFE8 !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .badge.bg-success-subtle {
|
||||||
|
background-color: #DCFCE7 !important;
|
||||||
|
color: #14532D !important;
|
||||||
|
border: 1px solid #86EFAC !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .badge.bg-warning-subtle {
|
||||||
|
background-color: #FEF3C7 !important;
|
||||||
|
color: #78350F !important;
|
||||||
|
border: 1px solid #FCD34D !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .badge.bg-info-subtle {
|
||||||
|
background-color: #E0F2FE !important;
|
||||||
|
color: #0C4A6E !important;
|
||||||
|
border: 1px solid #7DD3FC !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .badge.bg-danger-subtle {
|
||||||
|
background-color: #FEE2E2 !important;
|
||||||
|
color: #7F1D1D !important;
|
||||||
|
border: 1px solid #FCA5A5 !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .badge.bg-secondary-subtle {
|
||||||
|
background-color: #FFF5F9 !important;
|
||||||
|
color: #1E293B !important;
|
||||||
|
border: 1px solid rgba(180, 62, 142, 0.18) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fondos con opacidad suave para KPIs */
|
||||||
|
[data-bs-theme="light"] .bg-primary.bg-opacity-10 {
|
||||||
|
background-color: rgba(180, 62, 142, 0.12) !important;
|
||||||
|
color: #B43E8E !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .bg-success.bg-opacity-10 {
|
||||||
|
background-color: rgba(21, 128, 61, 0.12) !important;
|
||||||
|
color: #15803D !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .bg-info.bg-opacity-10 {
|
||||||
|
background-color: rgba(3, 105, 161, 0.12) !important;
|
||||||
|
color: #0369A1 !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .bg-warning.bg-opacity-10 {
|
||||||
|
background-color: rgba(180, 83, 9, 0.12) !important;
|
||||||
|
color: #B45309 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Botones en Modo Claro */
|
||||||
|
[data-bs-theme="light"] .btn-primary {
|
||||||
|
background: linear-gradient(135deg, #B43E8E 0%, #9E2D77 100%) !important;
|
||||||
|
border: none !important;
|
||||||
|
color: #FFFFFF !important;
|
||||||
|
box-shadow: 0 4px 14px rgba(180, 62, 142, 0.35) !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .btn-primary:hover {
|
||||||
|
background: linear-gradient(135deg, #9E2D77 0%, #832062 100%) !important;
|
||||||
|
box-shadow: 0 6px 18px rgba(180, 62, 142, 0.45) !important;
|
||||||
|
color: #FFFFFF !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .btn-outline-secondary {
|
||||||
|
border-color: #CBD5E1 !important;
|
||||||
|
color: #1E293B !important;
|
||||||
|
background-color: #FFFFFF !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .btn-outline-secondary:hover {
|
||||||
|
background-color: #FFF5F9 !important;
|
||||||
|
border-color: #B43E8E !important;
|
||||||
|
color: #B43E8E !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .btn-outline-primary {
|
||||||
|
color: #B43E8E !important;
|
||||||
|
border-color: #B43E8E !important;
|
||||||
|
background-color: #FFFFFF !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .btn-outline-primary:hover {
|
||||||
|
background: linear-gradient(135deg, #B43E8E 0%, #9E2D77 100%) !important;
|
||||||
|
border-color: #9E2D77 !important;
|
||||||
|
color: #FFFFFF !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .role-view-selector {
|
||||||
|
background-color: #FFF5F9 !important;
|
||||||
|
border: 1px solid rgba(180, 62, 142, 0.2) !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .role-view-selector .btn-outline-secondary {
|
||||||
|
color: #475569 !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bordes y Líneas Divisorias */
|
||||||
|
[data-bs-theme="light"] .border,
|
||||||
|
[data-bs-theme="light"] .border-top,
|
||||||
|
[data-bs-theme="light"] .border-bottom,
|
||||||
|
[data-bs-theme="light"] .border-start,
|
||||||
|
[data-bs-theme="light"] .border-end {
|
||||||
|
border-color: rgba(180, 62, 142, 0.16) !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .border-secondary-subtle {
|
||||||
|
border-color: rgba(180, 62, 142, 0.16) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Botón de Alternancia de Tema */
|
||||||
[data-bs-theme="light"] .theme-toggle-btn {
|
[data-bs-theme="light"] .theme-toggle-btn {
|
||||||
background: rgba(0, 0, 0, 0.06);
|
background: rgba(180, 62, 142, 0.08);
|
||||||
color: #1E293B;
|
color: #B43E8E;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
border: 1px solid rgba(180, 62, 142, 0.25);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
@@ -107,7 +369,7 @@
|
|||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
[data-bs-theme="light"] .theme-toggle-btn:hover {
|
[data-bs-theme="light"] .theme-toggle-btn:hover {
|
||||||
background: rgba(0, 0, 0, 0.12);
|
background: rgba(180, 62, 142, 0.16);
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +527,8 @@
|
|||||||
|
|
||||||
/* Sidebar Colapsado (Compacto, ultra limpio para pantallas chicas) */
|
/* Sidebar Colapsado (Compacto, ultra limpio para pantallas chicas) */
|
||||||
.sidebar.collapsed {
|
.sidebar.collapsed {
|
||||||
width: 68px;
|
width: 68px !important;
|
||||||
|
overflow: visible !important;
|
||||||
}
|
}
|
||||||
.sidebar.collapsed .brand-link {
|
.sidebar.collapsed .brand-link {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
@@ -281,13 +544,52 @@
|
|||||||
.sidebar.collapsed .dropdown-header {
|
.sidebar.collapsed .dropdown-header {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
.sidebar.collapsed .nav-link {
|
.sidebar.collapsed .sidebar-body {
|
||||||
justify-content: center !important;
|
overflow: visible !important;
|
||||||
padding: 0.55rem 0 !important;
|
|
||||||
}
|
}
|
||||||
.sidebar.collapsed .nav-link i {
|
.sidebar.collapsed .sidebar-nav {
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.sidebar.collapsed .sidebar-nav .nav-item {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.sidebar.collapsed .sidebar-nav .nav-link {
|
||||||
|
width: 44px !important;
|
||||||
|
height: 44px !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
margin: 0 auto !important;
|
||||||
|
color: #475569 !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="dark"] .sidebar.collapsed .sidebar-nav .nav-link {
|
||||||
|
color: #CBD5E1 !important;
|
||||||
|
}
|
||||||
|
.sidebar.collapsed .sidebar-nav .nav-link:hover,
|
||||||
|
.sidebar.collapsed .sidebar-nav .nav-link[aria-expanded="true"],
|
||||||
|
.sidebar.collapsed .sidebar-nav .nav-item.show-flyout > .nav-link {
|
||||||
|
background-color: rgba(180, 62, 142, 0.12) !important;
|
||||||
|
color: #B43E8E !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="dark"] .sidebar.collapsed .sidebar-nav .nav-link:hover,
|
||||||
|
[data-bs-theme="dark"] .sidebar.collapsed .sidebar-nav .nav-link[aria-expanded="true"],
|
||||||
|
[data-bs-theme="dark"] .sidebar.collapsed .sidebar-nav .nav-item.show-flyout > .nav-link {
|
||||||
|
background-color: rgba(255, 138, 182, 0.16) !important;
|
||||||
|
color: #FF8AB6 !important;
|
||||||
|
}
|
||||||
|
.sidebar.collapsed .sidebar-nav .nav-link i {
|
||||||
margin-right: 0 !important;
|
margin-right: 0 !important;
|
||||||
font-size: 1.25rem !important;
|
font-size: 1.25rem !important;
|
||||||
|
min-width: unset !important;
|
||||||
}
|
}
|
||||||
.sidebar.collapsed .sidebar-footer {
|
.sidebar.collapsed .sidebar-footer {
|
||||||
padding: 0.5rem !important;
|
padding: 0.5rem !important;
|
||||||
@@ -299,8 +601,94 @@
|
|||||||
.sidebar.collapsed .sidebar-footer .theme-toggle-btn {
|
.sidebar.collapsed .sidebar-footer .theme-toggle-btn {
|
||||||
margin-bottom: 0.5rem !important;
|
margin-bottom: 0.5rem !important;
|
||||||
}
|
}
|
||||||
.sidebar.collapsed .collapse {
|
|
||||||
|
/* Ocultar cualquier animación o flujo vertical de Bootstrap dentro de los 68px */
|
||||||
|
.sidebar.collapsed .collapse,
|
||||||
|
.sidebar.collapsed .collapsing,
|
||||||
|
.sidebar.collapsed .sidebar-submenu {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
|
height: auto !important;
|
||||||
|
position: static !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submenús flotantes (Flyout) cuando el sidebar está contraído:
|
||||||
|
SÓLO se muestran para el ítem con :hover directo o con clase .show-flyout activa */
|
||||||
|
.sidebar.collapsed .nav-item:hover > .collapse,
|
||||||
|
.sidebar.collapsed .nav-item.show-flyout > .collapse {
|
||||||
|
display: block !important;
|
||||||
|
position: absolute !important;
|
||||||
|
left: 100% !important;
|
||||||
|
top: 0 !important;
|
||||||
|
margin-left: 10px !important;
|
||||||
|
min-width: 230px !important;
|
||||||
|
background: #FFFFFF !important;
|
||||||
|
border: 1px solid rgba(180, 62, 142, 0.22) !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
box-shadow: 0 12px 32px rgba(180, 62, 142, 0.15), 0 4px 12px rgba(0, 0, 0, 0.08) !important;
|
||||||
|
z-index: 1090 !important;
|
||||||
|
padding: 0.5rem 0 !important;
|
||||||
|
animation: flyoutSlideIn 0.15s cubic-bezier(0.16, 1, 0.3, 1) !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="dark"] .sidebar.collapsed .nav-item:hover > .collapse,
|
||||||
|
[data-bs-theme="dark"] .sidebar.collapsed .nav-item.show-flyout > .collapse {
|
||||||
|
background: #18202F !important;
|
||||||
|
border-color: rgba(255, 138, 182, 0.25) !important;
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.6) !important;
|
||||||
|
}
|
||||||
|
@keyframes flyoutSlideIn {
|
||||||
|
from { opacity: 0; transform: translateX(-6px); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
.sidebar.collapsed .flyout-header {
|
||||||
|
display: block !important;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 0.35rem 1rem 0.45rem;
|
||||||
|
color: #B43E8E !important;
|
||||||
|
border-bottom: 1px solid rgba(180, 62, 142, 0.12);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
[data-bs-theme="dark"] .sidebar.collapsed .flyout-header {
|
||||||
|
color: #FF8AB6 !important;
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
.sidebar.collapsed .nav-item:hover .sidebar-submenu,
|
||||||
|
.sidebar.collapsed .nav-item.show-flyout .sidebar-submenu {
|
||||||
|
display: block !important;
|
||||||
|
padding-left: 0 !important;
|
||||||
|
padding: 0.25rem 0.5rem !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
list-style: none !important;
|
||||||
|
}
|
||||||
|
.sidebar.collapsed .nav-item:hover .sidebar-submenu .nav-link,
|
||||||
|
.sidebar.collapsed .nav-item.show-flyout .sidebar-submenu .nav-link {
|
||||||
|
padding: 0.5rem 0.85rem !important;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: flex-start !important;
|
||||||
|
width: 100% !important;
|
||||||
|
height: auto !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
color: #1E293B !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="dark"] .sidebar.collapsed .nav-item:hover .sidebar-submenu .nav-link,
|
||||||
|
[data-bs-theme="dark"] .sidebar.collapsed .nav-item.show-flyout .sidebar-submenu .nav-link {
|
||||||
|
color: #F8FAFC !important;
|
||||||
|
}
|
||||||
|
.sidebar.collapsed .nav-item:hover .sidebar-submenu .nav-link:hover,
|
||||||
|
.sidebar.collapsed .nav-item.show-flyout .sidebar-submenu .nav-link:hover {
|
||||||
|
background-color: rgba(180, 62, 142, 0.1) !important;
|
||||||
|
color: #B43E8E !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="dark"] .sidebar.collapsed .nav-item:hover .sidebar-submenu .nav-link:hover,
|
||||||
|
[data-bs-theme="dark"] .sidebar.collapsed .nav-item.show-flyout .sidebar-submenu .nav-link:hover {
|
||||||
|
background-color: rgba(255, 138, 182, 0.15) !important;
|
||||||
|
color: #FF8AB6 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Navegación del Sidebar */
|
/* Navegación del Sidebar */
|
||||||
@@ -489,8 +877,8 @@
|
|||||||
|
|
||||||
{% block extra_css %}{% endblock %}
|
{% block extra_css %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body data-user-authenticated="{{ 'true' if (user and user.is_authenticated) else 'false' }}">
|
<body data-user-authenticated="{{ 'true' if (auth_user and auth_user.is_authenticated) else 'false' }}">
|
||||||
{% if user.is_authenticated %}
|
{% if auth_user and auth_user.is_authenticated %}
|
||||||
|
|
||||||
<div class="app-wrapper">
|
<div class="app-wrapper">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
@@ -510,7 +898,7 @@
|
|||||||
|
|
||||||
<div class="sidebar-body flex-grow-1 overflow-y-auto p-2">
|
<div class="sidebar-body flex-grow-1 overflow-y-auto p-2">
|
||||||
<ul class="sidebar-nav" id="sidebarNav">
|
<ul class="sidebar-nav" id="sidebarNav">
|
||||||
{% set user_role = role if role else 'admin' %}
|
{% set user_role = role if role else (auth_user.role if auth_user else 'admin') %}
|
||||||
{% if user_role == 'admin' %}
|
{% if user_role == 'admin' %}
|
||||||
<!-- ADMIN -->
|
<!-- ADMIN -->
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
@@ -521,6 +909,7 @@
|
|||||||
<i class="bi bi-door-open text-info"></i> <span class="nav-text flex-grow-1">Espacios & Sedes</span> <i class="bi bi-chevron-down nav-caret ms-auto fs-6"></i>
|
<i class="bi bi-door-open text-info"></i> <span class="nav-text flex-grow-1">Espacios & Sedes</span> <i class="bi bi-chevron-down nav-caret ms-auto fs-6"></i>
|
||||||
</a>
|
</a>
|
||||||
<div class="collapse" id="menuEspacios" data-bs-parent="#sidebarNav">
|
<div class="collapse" id="menuEspacios" data-bs-parent="#sidebarNav">
|
||||||
|
<div class="flyout-header text-info d-none"><i class="bi bi-door-open me-1"></i>Espacios & Sedes</div>
|
||||||
<ul class="sidebar-submenu">
|
<ul class="sidebar-submenu">
|
||||||
<li><a class="nav-link" href="/classrooms/list_classrooms?view_mode=physical">Presenciales</a></li>
|
<li><a class="nav-link" href="/classrooms/list_classrooms?view_mode=physical">Presenciales</a></li>
|
||||||
<li><a class="nav-link" href="/classrooms/list_classrooms?view_mode=virtual">Virtuales</a></li>
|
<li><a class="nav-link" href="/classrooms/list_classrooms?view_mode=virtual">Virtuales</a></li>
|
||||||
@@ -536,6 +925,7 @@
|
|||||||
<i class="bi bi-calendar3 text-warning"></i> <span class="nav-text flex-grow-1">Reservas</span> <i class="bi bi-chevron-down nav-caret ms-auto fs-6"></i>
|
<i class="bi bi-calendar3 text-warning"></i> <span class="nav-text flex-grow-1">Reservas</span> <i class="bi bi-chevron-down nav-caret ms-auto fs-6"></i>
|
||||||
</a>
|
</a>
|
||||||
<div class="collapse" id="menuReservas" data-bs-parent="#sidebarNav">
|
<div class="collapse" id="menuReservas" data-bs-parent="#sidebarNav">
|
||||||
|
<div class="flyout-header text-warning d-none"><i class="bi bi-calendar3 me-1"></i>Reservas</div>
|
||||||
<ul class="sidebar-submenu">
|
<ul class="sidebar-submenu">
|
||||||
<li><a class="nav-link" href="/schedule/calendar_view">Calendario</a></li>
|
<li><a class="nav-link" href="/schedule/calendar_view">Calendario</a></li>
|
||||||
<li><a class="nav-link" href="/schedule/today_schedule">Cartelera</a></li>
|
<li><a class="nav-link" href="/schedule/today_schedule">Cartelera</a></li>
|
||||||
@@ -551,6 +941,7 @@
|
|||||||
<i class="bi bi-mortarboard text-success"></i> <span class="nav-text flex-grow-1">Académica</span> <i class="bi bi-chevron-down nav-caret ms-auto fs-6"></i>
|
<i class="bi bi-mortarboard text-success"></i> <span class="nav-text flex-grow-1">Académica</span> <i class="bi bi-chevron-down nav-caret ms-auto fs-6"></i>
|
||||||
</a>
|
</a>
|
||||||
<div class="collapse" id="menuAcademica" data-bs-parent="#sidebarNav">
|
<div class="collapse" id="menuAcademica" data-bs-parent="#sidebarNav">
|
||||||
|
<div class="flyout-header text-success d-none"><i class="bi bi-mortarboard me-1"></i>Académica</div>
|
||||||
<ul class="sidebar-submenu">
|
<ul class="sidebar-submenu">
|
||||||
<li><a class="nav-link" href="/admin/careers_list">Carreras</a></li>
|
<li><a class="nav-link" href="/admin/careers_list">Carreras</a></li>
|
||||||
<li><a class="nav-link" href="/admin/subjects_list">Asignaturas</a></li>
|
<li><a class="nav-link" href="/admin/subjects_list">Asignaturas</a></li>
|
||||||
@@ -564,6 +955,7 @@
|
|||||||
<i class="bi bi-shield-lock text-secondary"></i> <span class="nav-text flex-grow-1">Administración</span> <i class="bi bi-chevron-down nav-caret ms-auto fs-6"></i>
|
<i class="bi bi-shield-lock text-secondary"></i> <span class="nav-text flex-grow-1">Administración</span> <i class="bi bi-chevron-down nav-caret ms-auto fs-6"></i>
|
||||||
</a>
|
</a>
|
||||||
<div class="collapse" id="menuAdmin" data-bs-parent="#sidebarNav">
|
<div class="collapse" id="menuAdmin" data-bs-parent="#sidebarNav">
|
||||||
|
<div class="flyout-header text-secondary d-none"><i class="bi bi-shield-lock me-1"></i>Administración</div>
|
||||||
<ul class="sidebar-submenu">
|
<ul class="sidebar-submenu">
|
||||||
<li><a class="nav-link" href="/admin/users_list">Usuarios</a></li>
|
<li><a class="nav-link" href="/admin/users_list">Usuarios</a></li>
|
||||||
<li><a class="nav-link" href="/admin/roles_list">Roles RBAC</a></li>
|
<li><a class="nav-link" href="/admin/roles_list">Roles RBAC</a></li>
|
||||||
@@ -615,15 +1007,15 @@
|
|||||||
<div class="dropdown dropup">
|
<div class="dropdown dropup">
|
||||||
<a href="#" class="d-flex align-items-center text-decoration-none dropdown-toggle text-body w-100" data-bs-toggle="dropdown" aria-expanded="false">
|
<a href="#" class="d-flex align-items-center text-decoration-none dropdown-toggle text-body w-100" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
<div class="user-avatar-small bg-primary text-white rounded-circle d-flex align-items-center justify-content-center" style="width:32px; height:32px; font-weight:bold; flex-shrink:0;">
|
<div class="user-avatar-small bg-primary text-white rounded-circle d-flex align-items-center justify-content-center" style="width:32px; height:32px; font-weight:bold; flex-shrink:0;">
|
||||||
{{ user.name[0]|upper if user.name else 'U' }}
|
{{ auth_user.name[0]|upper if auth_user.name else 'U' }}
|
||||||
</div>
|
</div>
|
||||||
<div class="ms-2 sidebar-footer-text text-truncate flex-grow-1" style="min-width:0;">
|
<div class="ms-2 sidebar-footer-text text-truncate flex-grow-1" style="min-width:0;">
|
||||||
<small class="d-block fw-semibold text-truncate">{{ user.first_name }}</small>
|
<small class="d-block fw-semibold text-truncate">{{ auth_user.first_name or auth_user.name }}</small>
|
||||||
<small class="text-muted d-block text-truncate" style="font-size:0.7rem;">{{ user.role_badge_display.label }}</small>
|
<small class="text-muted d-block text-truncate" style="font-size:0.7rem;">{{ auth_user.role_badge_display.label if (auth_user and auth_user.role_badge_display) else (auth_user.role|capitalize if auth_user else 'Usuario') }}</small>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu shadow-sm border-0 mb-2">
|
<ul class="dropdown-menu shadow-sm border-0 mb-2">
|
||||||
<li><h6 class="dropdown-header">{{ user.email }}</h6></li>
|
<li><h6 class="dropdown-header">{{ auth_user.email if auth_user else '' }}</h6></li>
|
||||||
<li><a class="dropdown-item" href="/auth/profile"><i class="bi bi-person me-2"></i>Mi Perfil</a></li>
|
<li><a class="dropdown-item" href="/auth/profile"><i class="bi bi-person me-2"></i>Mi Perfil</a></li>
|
||||||
<li><a class="dropdown-item" href="/auth/change_password"><i class="bi bi-key me-2"></i>Cambiar Contraseña</a></li>
|
<li><a class="dropdown-item" href="/auth/change_password"><i class="bi bi-key me-2"></i>Cambiar Contraseña</a></li>
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
@@ -723,11 +1115,26 @@
|
|||||||
toggleBtn.setAttribute("aria-label", isCollapsed ? "Expandir menú" : "Colapsar menú");
|
toggleBtn.setAttribute("aria-label", isCollapsed ? "Expandir menú" : "Colapsar menú");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeAllCollapses() {
|
||||||
|
document.querySelectorAll('#sidebarNav .collapse.show').forEach(function(el) {
|
||||||
|
el.classList.remove('show');
|
||||||
|
const trigger = document.querySelector(`[data-bs-target="#${el.id}"]`);
|
||||||
|
if (trigger) {
|
||||||
|
trigger.setAttribute('aria-expanded', 'false');
|
||||||
|
trigger.classList.add('collapsed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.querySelectorAll('#sidebarNav > .nav-item.show-flyout').forEach(function(item) {
|
||||||
|
item.classList.remove('show-flyout');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Restore state from localStorage (desktop only)
|
// Restore state from localStorage (desktop only)
|
||||||
if (window.innerWidth >= 992) {
|
if (window.innerWidth >= 992) {
|
||||||
const isCollapsed = localStorage.getItem("sidebarCollapsed") === "true";
|
const isCollapsed = localStorage.getItem("sidebarCollapsed") === "true";
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
sidebar.classList.add("collapsed");
|
sidebar.classList.add("collapsed");
|
||||||
|
closeAllCollapses();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateToggleIcon();
|
updateToggleIcon();
|
||||||
@@ -735,18 +1142,55 @@
|
|||||||
if (toggleBtn) {
|
if (toggleBtn) {
|
||||||
toggleBtn.addEventListener("click", function() {
|
toggleBtn.addEventListener("click", function() {
|
||||||
sidebar.classList.toggle("collapsed");
|
sidebar.classList.toggle("collapsed");
|
||||||
localStorage.setItem("sidebarCollapsed", sidebar.classList.contains("collapsed"));
|
const isNowCollapsed = sidebar.classList.contains("collapsed");
|
||||||
|
localStorage.setItem("sidebarCollapsed", isNowCollapsed);
|
||||||
updateToggleIcon();
|
updateToggleIcon();
|
||||||
// Collapse any open submenus when collapsing sidebar
|
if (isNowCollapsed) {
|
||||||
if (sidebar.classList.contains("collapsed")) {
|
closeAllCollapses();
|
||||||
document.querySelectorAll('.sidebar-submenu').forEach(function(submenu) {
|
|
||||||
const collapseInstance = bootstrap.Collapse.getInstance(submenu.parentElement);
|
|
||||||
if (collapseInstance) collapseInstance.hide();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Manejo de clicks e interacciones en submenús cuando el sidebar está colapsado
|
||||||
|
document.querySelectorAll('#sidebarNav > .nav-item').forEach(function(item) {
|
||||||
|
const trigger = item.querySelector('[data-bs-toggle="collapse"]');
|
||||||
|
if (trigger) {
|
||||||
|
// Usar fase de captura (true) para interceptar ANTES de que Bootstrap dispare .collapsing
|
||||||
|
trigger.addEventListener('click', function(e) {
|
||||||
|
if (sidebar.classList.contains('collapsed')) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
const isAlreadyOpen = item.classList.contains('show-flyout');
|
||||||
|
document.querySelectorAll('#sidebarNav > .nav-item.show-flyout').forEach(function(other) {
|
||||||
|
other.classList.remove('show-flyout');
|
||||||
|
});
|
||||||
|
if (!isAlreadyOpen) {
|
||||||
|
item.classList.add('show-flyout');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Al hacer hover, limpiar cualquier flyout fijo anterior
|
||||||
|
item.addEventListener('mouseenter', function() {
|
||||||
|
if (sidebar.classList.contains('collapsed')) {
|
||||||
|
document.querySelectorAll('#sidebarNav > .nav-item.show-flyout').forEach(function(other) {
|
||||||
|
if (other !== item) other.classList.remove('show-flyout');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cerrar flyouts flotantes al hacer click fuera del sidebar
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!sidebar.contains(e.target)) {
|
||||||
|
document.querySelectorAll('#sidebarNav > .nav-item.show-flyout').forEach(function(item) {
|
||||||
|
item.classList.remove('show-flyout');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (mobileBtn) {
|
if (mobileBtn) {
|
||||||
mobileBtn.addEventListener("click", function() {
|
mobileBtn.addEventListener("click", function() {
|
||||||
sidebar.classList.add("mobile-open");
|
sidebar.classList.add("mobile-open");
|
||||||
|
|||||||
@@ -25,7 +25,10 @@
|
|||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<form method="POST" novalidate>
|
<form method="POST" action="{% if building %}/buildings/edit/{{ building.id }}{% else %}/buildings/add_building{% endif %}" novalidate>
|
||||||
|
{% if building %}
|
||||||
|
<input type="hidden" name="id" value="{{ building.id }}">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
@@ -47,7 +50,7 @@
|
|||||||
|
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
{{ form.address.label(class="form-label fw-semibold") }}
|
{{ form.address.label(class="form-label fw-semibold") }}
|
||||||
{{ form.address(class="form-control" + (" is-invalid" if form.address.errors else ""), placeholder="ej. Av. Paseo Colón 255, CABA") }}
|
{{ form.address(class="form-control" + (" is-invalid" if form.address.errors else ""), placeholder="ej. Av. Paseo Colón 255, CABA o campus.unicaba.edu.ar") }}
|
||||||
{% if form.address.errors %}
|
{% if form.address.errors %}
|
||||||
<div class="invalid-feedback">{{ form.address.errors[0] }}</div>
|
<div class="invalid-feedback">{{ form.address.errors[0] }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -55,11 +58,11 @@
|
|||||||
|
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
{{ form.floors.label(class="form-label fw-semibold") }}
|
{{ form.floors.label(class="form-label fw-semibold") }}
|
||||||
{{ form.floors(class="form-control" + (" is-invalid" if form.floors.errors else ""), min=1, max=100) }}
|
{{ form.floors(class="form-control" + (" is-invalid" if form.floors.errors else ""), min=0, max=100) }}
|
||||||
{% if form.floors.errors %}
|
{% if form.floors.errors %}
|
||||||
<div class="invalid-feedback">{{ form.floors.errors[0] }}</div>
|
<div class="invalid-feedback">{{ form.floors.errors[0] }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<small class="text-muted">Cantidad de plantas/pisos</small>
|
<small class="text-muted">Plantas físicas (0 para Campus Virtual)</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
|
|||||||
@@ -24,6 +24,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Alertas de estado -->
|
||||||
|
{% if success or updated or created %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show border-0 shadow-sm mb-4" role="alert">
|
||||||
|
<i class="bi bi-check-circle-fill me-2"></i>
|
||||||
|
{% if created %}El edificio ha sido creado exitosamente.{% else %}Los datos del edificio se actualizaron correctamente.{% endif %}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Statistics Cards -->
|
<!-- Statistics Cards -->
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-sm-6 col-xl-3">
|
<div class="col-sm-6 col-xl-3">
|
||||||
@@ -34,8 +43,9 @@
|
|||||||
<i class="bi bi-buildings fs-3"></i>
|
<i class="bi bi-buildings fs-3"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-muted small fw-semibold">Total Edificios</div>
|
<div class="text-muted small fw-semibold">Sedes y Edificios</div>
|
||||||
<h3 class="fw-bold mb-0">{{ buildings|length }}</h3>
|
<h3 class="fw-bold mb-0">{{ buildings|length }}</h3>
|
||||||
|
<small class="text-muted" style="font-size: 0.72rem;">Físicas y Digitales</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,8 +60,9 @@
|
|||||||
<i class="bi bi-door-open fs-3"></i>
|
<i class="bi bi-door-open fs-3"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-muted small fw-semibold">Aulas Activas</div>
|
<div class="text-muted small fw-semibold">Aulas Totales</div>
|
||||||
<h3 class="fw-bold mb-0 text-success">{{ total_classrooms }}</h3>
|
<h3 class="fw-bold mb-0 text-success">{{ total_classrooms }}</h3>
|
||||||
|
<small class="text-muted" style="font-size: 0.72rem;">Activas en el sistema</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,8 +77,11 @@
|
|||||||
<i class="bi bi-people fs-3"></i>
|
<i class="bi bi-people fs-3"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-muted small fw-semibold">Capacidad Total</div>
|
<div class="text-muted small fw-semibold">Capacidad Física Instalada</div>
|
||||||
<h3 class="fw-bold mb-0 text-info">{{ total_capacity }}</h3>
|
<h3 class="fw-bold mb-0 text-info">{{ total_physical_capacity or total_capacity }}</h3>
|
||||||
|
<small class="text-info fw-semibold" style="font-size: 0.72rem;">
|
||||||
|
<i class="bi bi-cloud-check me-1"></i>+ Campus Virtual (Ilimitado)
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,8 +96,9 @@
|
|||||||
<i class="bi bi-layers fs-3"></i>
|
<i class="bi bi-layers fs-3"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-muted small fw-semibold">Pisos Disponibles</div>
|
<div class="text-muted small fw-semibold">Pisos Presenciales</div>
|
||||||
<h3 class="fw-bold mb-0 text-warning">{{ buildings|sum(attribute='floors') }}</h3>
|
<h3 class="fw-bold mb-0 text-warning">{{ buildings|sum(attribute='floors') }}</h3>
|
||||||
|
<small class="text-muted" style="font-size: 0.72rem;">Plantas físicas activas</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,14 +141,14 @@
|
|||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
{% for b in buildings %}
|
{% for b in buildings %}
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
<div class="card border-0 shadow-sm h-100 position-relative">
|
<div class="card border-0 shadow-sm h-100 position-relative {% if b.is_virtual %}border-start border-4 border-info{% endif %}">
|
||||||
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
|
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
|
||||||
<div>
|
<div>
|
||||||
<span class="badge bg-secondary-subtle text-secondary font-monospace border mb-1">
|
<span class="badge {% if b.is_virtual %}bg-info-subtle text-info border border-info-subtle{% else %}bg-secondary-subtle text-secondary border{% endif %} font-monospace mb-1">
|
||||||
{{ b.code or 'EDIF' }}
|
{% if b.is_virtual %}<i class="bi bi-cloud-fill me-1"></i>{% endif %}{{ b.code or 'EDIF' }}
|
||||||
</span>
|
</span>
|
||||||
<h5 class="card-title fw-bold mb-0">
|
<h5 class="card-title fw-bold mb-0">
|
||||||
<a href="/buildings/view_building" class="text-decoration-none text-primary">
|
<a href="/buildings/view/{{ b.id }}" class="text-decoration-none text-primary">
|
||||||
{{ b.name }}
|
{{ b.name }}
|
||||||
</a>
|
</a>
|
||||||
</h5>
|
</h5>
|
||||||
@@ -153,7 +168,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% if b.address %}
|
{% if b.address %}
|
||||||
<p class="text-muted small mb-3">
|
<p class="text-muted small mb-3">
|
||||||
<i class="bi bi-geo-alt-fill text-danger me-1"></i>{{ b.address }}
|
<i class="bi {% if b.is_virtual %}bi-globe2 text-info{% else %}bi-geo-alt-fill text-danger{% endif %} me-1"></i>{{ b.address }}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -165,7 +180,11 @@
|
|||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<div class="p-2 rounded bg-body-tertiary">
|
<div class="p-2 rounded bg-body-tertiary">
|
||||||
<small class="text-muted d-block">Pisos</small>
|
<small class="text-muted d-block">Pisos</small>
|
||||||
|
{% if b.is_virtual %}
|
||||||
|
<span class="badge bg-info-subtle text-info border border-info-subtle mt-1">Digital</span>
|
||||||
|
{% else %}
|
||||||
<span class="fw-bold fs-6">{{ b.floors }}</span>
|
<span class="fw-bold fs-6">{{ b.floors }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
@@ -177,22 +196,28 @@
|
|||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<div class="p-2 rounded bg-body-tertiary">
|
<div class="p-2 rounded bg-body-tertiary">
|
||||||
<small class="text-muted d-block">Capacidad</small>
|
<small class="text-muted d-block">Capacidad</small>
|
||||||
<span class="fw-bold fs-6 text-success">{{ b.total_capacity }}</span>
|
{% if b.is_virtual %}
|
||||||
|
<span class="badge bg-primary-subtle text-primary border border-primary-subtle mt-1" title="Capacidad de concurrencia virtual ilimitada">
|
||||||
|
<i class="bi bi-infinity me-1"></i>Ilimitada
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="fw-bold fs-6 text-success">{{ b.physical_capacity or b.total_capacity }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer bg-transparent border-0 py-3 d-flex justify-content-between align-items-center">
|
<div class="card-footer bg-transparent border-0 py-3 d-flex justify-content-between align-items-center">
|
||||||
<a href="/buildings/view_building" class="btn btn-outline-primary btn-sm">
|
<a href="/buildings/view/{{ b.id }}" class="btn btn-outline-primary btn-sm">
|
||||||
<i class="bi bi-eye me-1"></i>Ver Distribución de Aulas
|
<i class="bi bi-eye me-1"></i>Ver Aulas
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{% if user.has_permission('classrooms', 'read_write') %}
|
{% if user.has_permission('classrooms', 'read_write') or user.is_admin() or user.is_bedelia() %}
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<a href="/buildings/edit_building" class="btn btn-outline-secondary btn-sm" title="Editar Edificio">
|
<a href="/buildings/edit/{{ b.id }}" class="btn btn-outline-secondary btn-sm" title="Editar Edificio">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil me-1"></i>Editar
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" action="/buildings/toggle_building" class="d-inline">
|
<form method="POST" action="/buildings/toggle/{{ b.id }}" class="d-inline">
|
||||||
<button type="submit" class="btn btn-outline-secondary btn-sm" title="{% if b.is_active %}Desactivar{% else %}Activar{% endif %}">
|
<button type="submit" class="btn btn-outline-secondary btn-sm" title="{% if b.is_active %}Desactivar{% else %}Activar{% endif %}">
|
||||||
<i class="bi bi-power"></i>
|
<i class="bi bi-power"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -28,8 +28,8 @@
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-5 text-md-end mt-3 mt-md-0">
|
<div class="col-md-5 text-md-end mt-3 mt-md-0">
|
||||||
{% if user.has_permission('classrooms', 'read_write') %}
|
{% if user.has_permission('classrooms', 'read_write') or user.is_admin() or user.is_bedelia() %}
|
||||||
<a href="/buildings/edit_building" class="btn btn-outline-secondary me-2">
|
<a href="/buildings/edit/{{ building.id }}" class="btn btn-outline-secondary me-2">
|
||||||
<i class="bi bi-pencil me-1"></i>Editar Edificio
|
<i class="bi bi-pencil me-1"></i>Editar Edificio
|
||||||
</a>
|
</a>
|
||||||
<a href="/classrooms/add_classroom" class="btn btn-primary">
|
<a href="/classrooms/add_classroom" class="btn btn-primary">
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
<div class="card-body p-3">
|
<div class="card-body p-3">
|
||||||
<div class="text-muted small fw-semibold">Ubicación / Dirección</div>
|
<div class="text-muted small fw-semibold">Ubicación / Dirección</div>
|
||||||
<p class="h6 fw-bold mb-2">
|
<p class="h6 fw-bold mb-2">
|
||||||
<i class="bi bi-geo-alt text-danger me-1"></i>{{ building.address or 'Sin dirección especificada' }}
|
<i class="bi {% if building.is_virtual %}bi-globe2 text-info{% else %}bi-geo-alt text-danger{% endif %} me-1"></i>{{ building.address or 'Sin dirección especificada' }}
|
||||||
</p>
|
</p>
|
||||||
{% if building.description %}
|
{% if building.description %}
|
||||||
<small class="text-secondary">{{ building.description }}</small>
|
<small class="text-secondary">{{ building.description }}</small>
|
||||||
@@ -59,7 +59,11 @@
|
|||||||
<div class="col-sm-4">
|
<div class="col-sm-4">
|
||||||
<div class="card border-0 shadow-sm text-center p-3">
|
<div class="card border-0 shadow-sm text-center p-3">
|
||||||
<small class="text-muted text-uppercase fw-bold">Pisos</small>
|
<small class="text-muted text-uppercase fw-bold">Pisos</small>
|
||||||
|
{% if building.is_virtual %}
|
||||||
|
<span class="fs-5 fw-bold text-info d-block mt-2">Digital / Nube</span>
|
||||||
|
{% else %}
|
||||||
<span class="display-6 fw-bold text-primary">{{ building.floors }}</span>
|
<span class="display-6 fw-bold text-primary">{{ building.floors }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-4">
|
<div class="col-sm-4">
|
||||||
@@ -71,7 +75,13 @@
|
|||||||
<div class="col-sm-4">
|
<div class="col-sm-4">
|
||||||
<div class="card border-0 shadow-sm text-center p-3">
|
<div class="card border-0 shadow-sm text-center p-3">
|
||||||
<small class="text-muted text-uppercase fw-bold">Capacidad Total</small>
|
<small class="text-muted text-uppercase fw-bold">Capacidad Total</small>
|
||||||
<span class="display-6 fw-bold text-info">{{ building.total_capacity }}</span>
|
{% if building.is_virtual %}
|
||||||
|
<span class="fs-5 fw-bold text-primary d-block mt-2" title="Capacidad de concurrencia virtual ilimitada">
|
||||||
|
<i class="bi bi-infinity me-1"></i>Ilimitada
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="display-6 fw-bold text-info">{{ building.physical_capacity or building.total_capacity }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
function updatePreview() {
|
function updatePreview() {
|
||||||
var bText = '-';
|
var bText = '-';
|
||||||
if (buildingSelect) {
|
if (buildingSelect) {
|
||||||
if (buildingSelect.value === '__new__') {
|
if (buildingSelect.value === '__new__' || buildingSelect.value === 'new') {
|
||||||
bText = newBuildingNameInput.value.trim() || '{{ _("Nuevo Edificio") }}';
|
bText = newBuildingNameInput.value.trim() || '{{ _("Nuevo Edificio") }}';
|
||||||
} else if (buildingSelect.selectedIndex >= 0) {
|
} else if (buildingSelect.selectedIndex >= 0) {
|
||||||
bText = buildingSelect.options[buildingSelect.selectedIndex].text;
|
bText = buildingSelect.options[buildingSelect.selectedIndex].text;
|
||||||
@@ -222,7 +222,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
|
|
||||||
if (buildingSelect) {
|
if (buildingSelect) {
|
||||||
buildingSelect.addEventListener('change', function() {
|
buildingSelect.addEventListener('change', function() {
|
||||||
if (this.value === '__new__') {
|
if (this.value === '__new__' || this.value === 'new') {
|
||||||
newBuildingContainer.style.display = 'block';
|
newBuildingContainer.style.display = 'block';
|
||||||
if (newBuildingNameInput) newBuildingNameInput.focus();
|
if (newBuildingNameInput) newBuildingNameInput.focus();
|
||||||
} else {
|
} else {
|
||||||
@@ -232,7 +232,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
updatePreview();
|
updatePreview();
|
||||||
});
|
});
|
||||||
// Initial call
|
// Initial call
|
||||||
if (buildingSelect.value === '__new__') {
|
if (buildingSelect.value === '__new__' || buildingSelect.value === 'new') {
|
||||||
newBuildingContainer.style.display = 'block';
|
newBuildingContainer.style.display = 'block';
|
||||||
} else if (buildingSelect.value) {
|
} else if (buildingSelect.value) {
|
||||||
updateFloorsDatalist(buildingSelect.value);
|
updateFloorsDatalist(buildingSelect.value);
|
||||||
|
|||||||
@@ -268,7 +268,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Grilla y Tabla de Aulas Físicas -->
|
<!-- Grilla y Tabla de Aulas Físicas -->
|
||||||
{% if classrooms and classrooms.items %}
|
{% if classrooms and classrooms.items and (classrooms.items|length > 0) %}
|
||||||
|
|
||||||
<!-- 1. VISTA DE TARJETAS / MOSAICO -->
|
<!-- 1. VISTA DE TARJETAS / MOSAICO -->
|
||||||
<div data-view-context="classrooms" data-view-container="cards">
|
<div data-view-context="classrooms" data-view-container="cards">
|
||||||
@@ -399,6 +399,11 @@
|
|||||||
class="btn btn-outline-secondary py-0 px-2" title="Editar Aula" style="font-size: 0.75rem;">
|
class="btn btn-outline-secondary py-0 px-2" title="Editar Aula" style="font-size: 0.75rem;">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
</a>
|
</a>
|
||||||
|
<button type="button" class="btn btn-outline-danger py-0 px-2"
|
||||||
|
data-bs-toggle="modal" data-bs-target="#modalDeleteClassroom{{ classroom.id }}"
|
||||||
|
title="Eliminar Aula" style="font-size: 0.75rem;">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form method="POST" action="/classrooms/toggle_classroom" class="d-inline mb-0">
|
<form method="POST" action="/classrooms/toggle_classroom" class="d-inline mb-0">
|
||||||
<input type="hidden" name="classroom_id" value="{{ classroom.id }}">
|
<input type="hidden" name="classroom_id" value="{{ classroom.id }}">
|
||||||
@@ -501,6 +506,11 @@
|
|||||||
<a href="/classrooms/edit_classroom?id={{ classroom.id }}" class="btn btn-outline-secondary" title="Editar">
|
<a href="/classrooms/edit_classroom?id={{ classroom.id }}" class="btn btn-outline-secondary" title="Editar">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
</a>
|
</a>
|
||||||
|
<button type="button" class="btn btn-outline-danger"
|
||||||
|
data-bs-toggle="modal" data-bs-target="#modalDeleteClassroom{{ classroom.id }}"
|
||||||
|
title="Eliminar Aula">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="POST" action="/classrooms/toggle_classroom" class="d-inline mb-0">
|
<form method="POST" action="/classrooms/toggle_classroom" class="d-inline mb-0">
|
||||||
<input type="hidden" name="classroom_id" value="{{ classroom.id }}">
|
<input type="hidden" name="classroom_id" value="{{ classroom.id }}">
|
||||||
@@ -698,7 +708,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Catálogo de Aulas Virtuales por Comisión -->
|
<!-- Catálogo de Aulas Virtuales por Comisión -->
|
||||||
{% if virtual_rooms %}
|
{% if virtual_rooms and (virtual_rooms|length > 0) %}
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
{% for room in virtual_rooms %}
|
{% for room in virtual_rooms %}
|
||||||
<div class="col-xxl-3 col-xl-3 col-lg-4 col-md-6 col-sm-12 mb-2">
|
<div class="col-xxl-3 col-xl-3 col-lg-4 col-md-6 col-sm-12 mb-2">
|
||||||
@@ -997,6 +1007,40 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Modales de Eliminación de Aulas Físicas -->
|
||||||
|
{% if classrooms and classrooms.items and (classrooms.items|length > 0) %}
|
||||||
|
{% for classroom in classrooms.items %}
|
||||||
|
<div class="modal fade text-start" id="modalDeleteClassroom{{ classroom.id }}" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content border-0 shadow">
|
||||||
|
<div class="modal-header bg-danger text-white py-2">
|
||||||
|
<h6 class="modal-title fw-bold">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>Confirmar Eliminación de Aula
|
||||||
|
</h6>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-4">
|
||||||
|
<p class="mb-2">¿Está seguro de que desea eliminar el aula <strong>{{ classroom.code }}</strong> ({{ classroom.building_name }} · {{ classroom.floor_display }})?</p>
|
||||||
|
<div class="alert alert-warning small mb-0 mt-3 d-flex align-items-center gap-2">
|
||||||
|
<i class="bi bi-shield-exclamation fs-5"></i>
|
||||||
|
<div>
|
||||||
|
Si el espacio cuenta con reservas históricas o cursadas registradas, se <strong>desactivará</strong> automáticamente para mantener la integridad académica del sistema.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer py-2 bg-light">
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||||
|
<form method="POST" action="/classrooms/delete_classroom" class="d-inline">
|
||||||
|
<input type="hidden" name="classroom_id" value="{{ classroom.id }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger">
|
||||||
|
<i class="bi bi-trash me-1"></i>Confirmar Eliminación
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,9 +28,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-5 text-md-end mt-3 mt-md-0">
|
<div class="col-md-5 text-md-end mt-3 mt-md-0">
|
||||||
{% if user.has_permission('classrooms', 'read_write') %}
|
{% if user.has_permission('classrooms', 'read_write') %}
|
||||||
<a href="/classrooms/edit_classroom" class="btn btn-primary me-2">
|
<a href="/classrooms/edit_classroom/{{ classroom.id }}" class="btn btn-primary me-2">
|
||||||
<i class="bi bi-pencil me-1"></i>Edit Classroom
|
<i class="bi bi-pencil me-1"></i>Edit Classroom
|
||||||
</a>
|
</a>
|
||||||
|
<button type="button" class="btn btn-outline-danger me-2" data-bs-toggle="modal" data-bs-target="#modalDeleteClassroom">
|
||||||
|
<i class="bi bi-trash me-1"></i>Eliminar
|
||||||
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/classrooms/list_classrooms" class="btn btn-outline-secondary">
|
<a href="/classrooms/list_classrooms" class="btn btn-outline-secondary">
|
||||||
<i class="bi bi-arrow-left me-1"></i>Back to Classrooms
|
<i class="bi bi-arrow-left me-1"></i>Back to Classrooms
|
||||||
@@ -169,5 +172,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Modal Confirmar Eliminación de Aula -->
|
||||||
|
<div class="modal fade text-start" id="modalDeleteClassroom" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content border-0 shadow">
|
||||||
|
<div class="modal-header bg-danger text-white py-2">
|
||||||
|
<h6 class="modal-title fw-bold">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>Confirmar Eliminación de Aula
|
||||||
|
</h6>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-4">
|
||||||
|
<p class="mb-2">¿Está seguro de que desea eliminar el aula <strong>{{ classroom.code_display }}</strong>?</p>
|
||||||
|
<div class="alert alert-warning small mb-0 mt-3 d-flex align-items-center gap-2">
|
||||||
|
<i class="bi bi-shield-exclamation fs-5"></i>
|
||||||
|
<div>
|
||||||
|
Si el espacio cuenta con reservas históricas o cursadas registradas, se <strong>desactivará</strong> automáticamente para mantener la integridad académica del sistema.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer py-2 bg-light">
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||||
|
<form method="POST" action="/classrooms/delete_classroom" class="d-inline">
|
||||||
|
<input type="hidden" name="classroom_id" value="{{ classroom.id }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger">
|
||||||
|
<i class="bi bi-trash me-1"></i>Confirmar Eliminación
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<!-- ========================================================================= -->
|
<!-- ========================================================================= -->
|
||||||
<!-- 1. HEADER & ROLE PERSPECTIVE SWITCHER -->
|
<!-- 1. HEADER & ROLE PERSPECTIVE SWITCHER -->
|
||||||
<!-- ========================================================================= -->
|
<!-- ========================================================================= -->
|
||||||
|
{% if show_role_banner %}
|
||||||
<div class="dashboard-role-banner mb-3 shadow-sm">
|
<div class="dashboard-role-banner mb-3 shadow-sm">
|
||||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
|
<div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -78,6 +79,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
<!-- ========================================================================= -->
|
<!-- ========================================================================= -->
|
||||||
@@ -370,8 +372,10 @@
|
|||||||
<i class="bi bi-bar-chart me-1"></i>Métricas Detalladas
|
<i class="bi bi-bar-chart me-1"></i>Métricas Detalladas
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body p-3">
|
||||||
<canvas id="monthlyChart" height="110"></canvas>
|
<div style="position: relative; height: 260px; max-height: 260px; width: 100%;">
|
||||||
|
<canvas id="monthlyChart"></canvas>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1547,6 +1551,7 @@ $(document).ready(function() {
|
|||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
|
resizeDelay: 100,
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
display: false
|
display: false
|
||||||
|
|||||||
@@ -43,17 +43,17 @@
|
|||||||
padding-bottom: 0.35rem;
|
padding-bottom: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- MODO CLARO ([data-bs-theme="light"]) --- */
|
/* --- MODO CLARO ([data-bs-theme="light"]) - PALETA UNICABA (ROSA, MAGENTA, BLANCO) --- */
|
||||||
[data-bs-theme="light"] body {
|
[data-bs-theme="light"] body {
|
||||||
background: #F8FAFC !important;
|
background-color: #FAF5F8 !important;
|
||||||
color: #1E293B !important;
|
color: #1E293B !important;
|
||||||
}
|
}
|
||||||
[data-bs-theme="light"] .glass-navbar {
|
[data-bs-theme="light"] .glass-navbar {
|
||||||
background: rgba(255, 255, 255, 0.92) !important;
|
background: rgba(255, 255, 255, 0.96) !important;
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
-webkit-backdrop-filter: blur(12px);
|
-webkit-backdrop-filter: blur(12px);
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08) !important;
|
border-bottom: 1px solid rgba(180, 62, 142, 0.16) !important;
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05) !important;
|
box-shadow: 0 4px 16px rgba(180, 62, 142, 0.05) !important;
|
||||||
}
|
}
|
||||||
[data-bs-theme="light"] .glass-navbar .navbar-brand span {
|
[data-bs-theme="light"] .glass-navbar .navbar-brand span {
|
||||||
background: linear-gradient(90deg, #B43E8E, #682CAD);
|
background: linear-gradient(90deg, #B43E8E, #682CAD);
|
||||||
@@ -63,27 +63,47 @@
|
|||||||
}
|
}
|
||||||
[data-bs-theme="light"] .glass-navbar .nav-link {
|
[data-bs-theme="light"] .glass-navbar .nav-link {
|
||||||
color: #334155 !important;
|
color: #334155 !important;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
[data-bs-theme="light"] .glass-navbar .nav-link:hover,
|
[data-bs-theme="light"] .glass-navbar .nav-link:hover,
|
||||||
[data-bs-theme="light"] .glass-navbar .nav-link.active {
|
[data-bs-theme="light"] .glass-navbar .nav-link.active {
|
||||||
color: #B43E8E !important;
|
color: #B43E8E !important;
|
||||||
background: rgba(180, 62, 142, 0.06);
|
background: rgba(180, 62, 142, 0.08);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
}
|
}
|
||||||
[data-bs-theme="light"] .glass-card,
|
[data-bs-theme="light"] .glass-card,
|
||||||
[data-bs-theme="light"] .card {
|
[data-bs-theme="light"] .card {
|
||||||
background: #FFFFFF !important;
|
background: #FFFFFF !important;
|
||||||
color: #1E293B !important;
|
color: #1E293B !important;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.08) !important;
|
border: 1px solid rgba(180, 62, 142, 0.16) !important;
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04) !important;
|
box-shadow: 0 2px 10px rgba(180, 62, 142, 0.05), 0 1px 3px rgba(0, 0, 0, 0.02) !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .card-header {
|
||||||
|
background-color: #FDF9FB !important;
|
||||||
|
border-bottom: 1px solid rgba(180, 62, 142, 0.1) !important;
|
||||||
|
color: #1E293B !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .table {
|
||||||
|
--bs-table-color: #1E293B;
|
||||||
|
border-color: rgba(180, 62, 142, 0.1) !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .table thead th {
|
||||||
|
background-color: #F8EEF4 !important;
|
||||||
|
color: #1E293B !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
border-bottom: 2px solid rgba(180, 62, 142, 0.2) !important;
|
||||||
|
}
|
||||||
|
[data-bs-theme="light"] .text-muted {
|
||||||
|
color: #64748B !important;
|
||||||
}
|
}
|
||||||
/* Toggle button - modo claro */
|
/* Toggle button - modo claro */
|
||||||
[data-bs-theme="light"] .navbar-toggle-custom {
|
[data-bs-theme="light"] .navbar-toggle-custom {
|
||||||
color: #334155;
|
color: #334155;
|
||||||
border-color: rgba(51, 65, 85, 0.25);
|
border-color: rgba(180, 62, 142, 0.25);
|
||||||
|
background-color: #FFFFFF;
|
||||||
}
|
}
|
||||||
[data-bs-theme="light"] .navbar-toggle-custom:hover {
|
[data-bs-theme="light"] .navbar-toggle-custom:hover {
|
||||||
background: rgba(180, 62, 142, 0.08);
|
background: rgba(180, 62, 142, 0.1);
|
||||||
border-color: #B43E8E;
|
border-color: #B43E8E;
|
||||||
color: #B43E8E;
|
color: #B43E8E;
|
||||||
}
|
}
|
||||||
@@ -149,6 +169,7 @@
|
|||||||
.navbar-logo {
|
.navbar-logo {
|
||||||
height: 26px;
|
height: 26px;
|
||||||
width: auto;
|
width: auto;
|
||||||
|
filter: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =============================================
|
/* =============================================
|
||||||
|
|||||||
@@ -605,7 +605,33 @@ $(document).ready(function() {
|
|||||||
updateSummary();
|
updateSummary();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sincronización inicial si classroom_id viene precargado (desde aulas o query)
|
||||||
|
const preselectedClassroomId = $classroom.val();
|
||||||
|
if (preselectedClassroomId && classroomData[preselectedClassroomId]) {
|
||||||
|
const c = classroomData[preselectedClassroomId];
|
||||||
|
if (c.building_id) {
|
||||||
|
$building.val(c.building_id).trigger('change');
|
||||||
|
if (c.floor) {
|
||||||
|
$floor.val(c.floor).trigger('change');
|
||||||
|
}
|
||||||
|
$classroom.val(preselectedClassroomId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sincronización inicial si commission_id viene precargado (desde comisiones o query)
|
||||||
|
const preselectedCommId = $('#commission-select').val();
|
||||||
|
if (preselectedCommId && commissionData[preselectedCommId]) {
|
||||||
|
const comm = commissionData[preselectedCommId];
|
||||||
|
if (comm.virtual_link && !$('#virtual_link').val().trim()) {
|
||||||
|
$('#virtual_link').val(comm.virtual_link);
|
||||||
|
}
|
||||||
|
if (comm.max_students && (!($('#expected_attendees').val()) || $('#expected_attendees').val() == '30')) {
|
||||||
|
$('#expected_attendees').val(comm.max_students);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updateSummary();
|
updateSummary();
|
||||||
|
checkCapacity();
|
||||||
|
|
||||||
// Form validation
|
// Form validation
|
||||||
$('#reservation-form').on('submit', function(e) {
|
$('#reservation-form').on('submit', function(e) {
|
||||||
|
|||||||
@@ -248,12 +248,19 @@
|
|||||||
{% if reservation.can_be_cancelled() %}
|
{% if reservation.can_be_cancelled() %}
|
||||||
<form method="POST" action="/schedule/cancel_reservation?id={{ reservation.id }}">
|
<form method="POST" action="/schedule/cancel_reservation?id={{ reservation.id }}">
|
||||||
<input type="hidden" name="id" value="{{ reservation.id }}">
|
<input type="hidden" name="id" value="{{ reservation.id }}">
|
||||||
<button type="submit" class="btn btn-danger" onclick="return confirm('¿Cancelar esta reserva?')">
|
<button type="submit" class="btn btn-outline-warning" onclick="return confirm('¿Cancelar esta reserva?')">
|
||||||
<i class="bi bi-x-circle me-1"></i>Cancelar Reserva
|
<i class="bi bi-x-circle me-1"></i>Cancelar Reserva
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="/schedule/delete_reservation?id={{ reservation.id }}" onsubmit="return confirm('¿Está seguro de que desea eliminar definitivamente esta reserva? Esta acción no se puede deshacer.');">
|
||||||
|
<input type="hidden" name="id" value="{{ reservation.id }}">
|
||||||
|
<button type="submit" class="btn btn-outline-danger w-100">
|
||||||
|
<i class="bi bi-trash me-1"></i>Eliminar Reserva Definitivamente
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<a href="/schedule/list_reservations" class="btn btn-outline-secondary">
|
<a href="/schedule/list_reservations" class="btn btn-outline-secondary">
|
||||||
<i class="bi bi-list"></i> View All Reservations
|
<i class="bi bi-list"></i> View All Reservations
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
Executable
+668
@@ -0,0 +1,668 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ==============================================================================
|
||||||
|
# install.sh — Admin Edu-Space (UniCABA)
|
||||||
|
# Instalador automatizado para Proxmox VE (LXC) / Debian 12 / Ubuntu
|
||||||
|
# Despliegue unificado: Backend Flask (Gunicorn) + Frontend Node.js (BFF) + PostgreSQL
|
||||||
|
# Uso: sudo bash install.sh [--keep-db | --fresh-db]
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ─── Colores y Helpers ─────────────────────────────────────────────────────────
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||||
|
|
||||||
|
ok() { echo -e "${GREEN} ✔ $*${RESET}"; }
|
||||||
|
info() { echo -e "${CYAN} ▸ $*${RESET}"; }
|
||||||
|
warn() { echo -e "${YELLOW} ⚠ $*${RESET}"; }
|
||||||
|
err() { echo -e "${RED} ✘ $*${RESET}" >&2; exit 1; }
|
||||||
|
hdr() { echo -e "\n${BOLD}${CYAN}══ $* ══${RESET}\n"; }
|
||||||
|
|
||||||
|
# ─── Argumentos de Línea de Comandos ───────────────────────────────────────────
|
||||||
|
ARG_DB_ACTION=""
|
||||||
|
ARG_KEEP_USERS=false
|
||||||
|
ARG_EMPTY_ACADEMIC=false
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--keep-db)
|
||||||
|
ARG_DB_ACTION="keep"
|
||||||
|
;;
|
||||||
|
--keep-users)
|
||||||
|
ARG_KEEP_USERS=true
|
||||||
|
;;
|
||||||
|
--fresh-db|--restore-db)
|
||||||
|
ARG_DB_ACTION="fresh"
|
||||||
|
;;
|
||||||
|
--empty-academic|--no-demo|--clean-db|--without-demo-data)
|
||||||
|
ARG_DB_ACTION="fresh"
|
||||||
|
ARG_EMPTY_ACADEMIC=true
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
echo "Uso: sudo bash install.sh [OPCIONES]"
|
||||||
|
echo "Opciones:"
|
||||||
|
echo " --keep-db Mantiene la base de datos existente si ya contiene datos."
|
||||||
|
echo " --keep-users Preserva los usuarios existentes sin volver a crearlos o pisar sus contraseñas."
|
||||||
|
echo " --fresh-db Restaura la base de datos desde cero (DROP y recarga snapshot classrooms_db.sql)."
|
||||||
|
echo " --empty-academic Crea la base de datos limpia sin carreras, materias, ciclos lectivos, aulas, edificios ni reservas demo."
|
||||||
|
echo " --no-demo Alias de --empty-academic."
|
||||||
|
echo " -h, --help Muestra esta ayuda."
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
warn "Opción desconocida ignorada: $arg"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ─── Variables Principales ─────────────────────────────────────────────────────
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
BACKEND_DIR="${ROOT_DIR}/backend"
|
||||||
|
FRONTEND_DIR="${ROOT_DIR}/frontend"
|
||||||
|
|
||||||
|
VENV_DIR="${BACKEND_DIR}/venv"
|
||||||
|
BACKEND_ENV="${BACKEND_DIR}/.env"
|
||||||
|
FRONTEND_ENV="${FRONTEND_DIR}/.env"
|
||||||
|
|
||||||
|
SERVICE_BACKEND="admin-edu-space-backend"
|
||||||
|
SERVICE_FRONTEND="admin-edu-space-frontend"
|
||||||
|
SERVICE_META="admin-edu-space"
|
||||||
|
|
||||||
|
APP_USER="eduspace"
|
||||||
|
PYTHON_REQUIRED_MAJOR=3
|
||||||
|
PYTHON_REQUIRED_MINOR=10
|
||||||
|
LOG_DIR="/var/log/${SERVICE_META}"
|
||||||
|
|
||||||
|
# Ubicación del snapshot de base de datos
|
||||||
|
SQL_BACKUP_FILE="${BACKEND_DIR}/classrooms_db.sql"
|
||||||
|
if [[ ! -f "$SQL_BACKUP_FILE" && -f "${ROOT_DIR}/classrooms_db.sql" ]]; then
|
||||||
|
SQL_BACKUP_FILE="${ROOT_DIR}/classrooms_db.sql"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Verificación de Permisos Root ─────────────────────────────────────────────
|
||||||
|
[[ $EUID -ne 0 ]] && err "Ejecutá este script con permisos de root: sudo bash install.sh"
|
||||||
|
|
||||||
|
# Si el directorio está dentro de /root, trasladarlo a /opt para evitar problemas de permisos con el usuario de servicio
|
||||||
|
if [[ "${ROOT_DIR}" == /root/* ]]; then
|
||||||
|
NEW_DIR="/opt/admin-edu-space"
|
||||||
|
warn "Detectado directorio en ${ROOT_DIR}. Trasladando a ${NEW_DIR} para evitar bloqueos de permisos..."
|
||||||
|
systemctl stop "${SERVICE_BACKEND}" 2>/dev/null || true
|
||||||
|
systemctl stop "${SERVICE_FRONTEND}" 2>/dev/null || true
|
||||||
|
systemctl stop "${SERVICE_META}" 2>/dev/null || true
|
||||||
|
|
||||||
|
mkdir -p "${NEW_DIR}"
|
||||||
|
cp -a "${ROOT_DIR}/." "${NEW_DIR}/"
|
||||||
|
cd "${NEW_DIR}"
|
||||||
|
exec bash install.sh "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validar estructura básica del repositorio
|
||||||
|
[[ ! -f "${BACKEND_DIR}/wsgi.py" ]] && err "No se encontró wsgi.py en ${BACKEND_DIR}. Verificá la estructura del proyecto."
|
||||||
|
[[ ! -f "${FRONTEND_DIR}/package.json" ]] && err "No se encontró package.json en ${FRONTEND_DIR}. Verificá la estructura del proyecto."
|
||||||
|
|
||||||
|
# Verificar e instalar git si no está presente en el sistema
|
||||||
|
if ! command -v git &>/dev/null; then
|
||||||
|
info "Git no está instalado. Instalando paquete git..."
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update -qq >/dev/null 2>&1 || true
|
||||||
|
apt-get install -y -qq git >/dev/null 2>&1 || apt-get install -y git
|
||||||
|
ok "Git instalado correctamente"
|
||||||
|
fi
|
||||||
|
git config --global --add safe.directory '*' 2>/dev/null || true
|
||||||
|
|
||||||
|
echo -e "\n${BOLD}${CYAN}"
|
||||||
|
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ Admin Edu-Space (UniCABA) — Instalador ║"
|
||||||
|
echo "║ Backend (Flask/Gunicorn) + Frontend (Node.js) + PostgreSQL ║"
|
||||||
|
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||||
|
echo -e "${RESET}"
|
||||||
|
|
||||||
|
OS_NAME=$(grep -oP '(?<=^PRETTY_NAME=).+' /etc/os-release 2>/dev/null | tr -d '"' || uname -s)
|
||||||
|
info "Sistema Operativo: ${OS_NAME}"
|
||||||
|
info "Directorio Raíz: ${ROOT_DIR}"
|
||||||
|
info "Directorio Backend: ${BACKEND_DIR}"
|
||||||
|
info "Directorio Frontend: ${FRONTEND_DIR}"
|
||||||
|
info "Usuario de Servicio: ${APP_USER}"
|
||||||
|
info "Preservar Base de Datos: ${ARG_DB_ACTION:-auto (interactivo/detección)}"
|
||||||
|
info "Preservar Usuarios: ${ARG_KEEP_USERS}"
|
||||||
|
info "Base Limpia (Sin Demo): ${ARG_EMPTY_ACADEMIC}"
|
||||||
|
|
||||||
|
# ─── 1. Paquetes Base del Sistema ──────────────────────────────────────────────
|
||||||
|
hdr "1. Actualizando repositorios y paquetes base"
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq \
|
||||||
|
sudo curl wget git ca-certificates gnupg lsb-release \
|
||||||
|
build-essential libpq-dev libffi-dev libssl-dev pkg-config \
|
||||||
|
locales \
|
||||||
|
> /dev/null 2>&1
|
||||||
|
ok "Paquetes base instalados"
|
||||||
|
|
||||||
|
pg_cmd() {
|
||||||
|
if command -v sudo >/dev/null 2>&1; then
|
||||||
|
sudo -u postgres psql "$@"
|
||||||
|
elif command -v runuser >/dev/null 2>&1; then
|
||||||
|
runuser -u postgres -- psql "$@"
|
||||||
|
else
|
||||||
|
su - postgres -c "psql $(printf '%q ' "$@")"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── 2. Configurar Locale UTF-8 ────────────────────────────────────────────────
|
||||||
|
hdr "2. Configurando soporte de codificación (Locales UTF-8)"
|
||||||
|
if [ -f /etc/locale.gen ]; then
|
||||||
|
sed -i 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen
|
||||||
|
sed -i 's/# es_AR.UTF-8 UTF-8/es_AR.UTF-8 UTF-8/' /etc/locale.gen
|
||||||
|
locale-gen en_US.UTF-8 es_AR.UTF-8 > /dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 > /dev/null 2>&1 || true
|
||||||
|
export LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 PYTHONUTF8=1 PYTHONIOENCODING=utf-8
|
||||||
|
ok "Locales configurados: en_US.UTF-8 / es_AR.UTF-8"
|
||||||
|
|
||||||
|
# ─── 3. Instalar Python 3.10+ y entorno virtual ────────────────────────────────
|
||||||
|
hdr "3. Verificando entorno Python"
|
||||||
|
apt-get install -y -qq python3 python3-venv python3-pip python3-dev > /dev/null 2>&1
|
||||||
|
|
||||||
|
PY_VERSION=$(python3 --version 2>/dev/null | awk '{print $2}' || echo "0.0")
|
||||||
|
PY_MAJOR=$(echo "$PY_VERSION" | cut -d. -f1)
|
||||||
|
PY_MINOR=$(echo "$PY_VERSION" | cut -d. -f2)
|
||||||
|
|
||||||
|
if [[ "$PY_MAJOR" -ge "$PYTHON_REQUIRED_MAJOR" && "$PY_MINOR" -ge "$PYTHON_REQUIRED_MINOR" ]]; then
|
||||||
|
ok "Python $PY_VERSION disponible"
|
||||||
|
else
|
||||||
|
info "Python $PY_VERSION es menor a 3.10 — instalando Python 3.11..."
|
||||||
|
apt-get install -y -qq python3.11 python3.11-venv python3.11-dev > /dev/null 2>&1
|
||||||
|
update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1 > /dev/null 2>&1
|
||||||
|
PY_VERSION=$(python3 --version | awk '{print $2}')
|
||||||
|
ok "Python $PY_VERSION instalado"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── 4. Instalar Node.js y npm para el Frontend BFF ───────────────────────────
|
||||||
|
hdr "4. Verificando entorno Node.js para el Frontend BFF"
|
||||||
|
|
||||||
|
NODE_INSTALLED=false
|
||||||
|
if command -v node &>/dev/null; then
|
||||||
|
NODE_VER_NUM=$(node -v | tr -d 'v' | cut -d. -f1 || echo "0")
|
||||||
|
if [[ "$NODE_VER_NUM" -ge 18 ]]; then
|
||||||
|
NODE_INSTALLED=true
|
||||||
|
ok "Node.js $(node -v) y npm $(npm -v 2>/dev/null || echo '') ya disponibles"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$NODE_INSTALLED" = false ]; then
|
||||||
|
info "Instalando Node.js 20 LTS vía repositorio oficial NodeSource..."
|
||||||
|
mkdir -p /etc/apt/keyrings
|
||||||
|
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg --yes 2>/dev/null || true
|
||||||
|
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list > /dev/null
|
||||||
|
apt-get update -qq >/dev/null 2>&1 || true
|
||||||
|
apt-get install -y -qq nodejs >/dev/null 2>&1 || apt-get install -y nodejs
|
||||||
|
ok "Node.js $(node -v) instalado exitosamente"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── 5. Instalar y Levantar Motor PostgreSQL ───────────────────────────────────
|
||||||
|
hdr "5. Instalando y configurando motor de base de datos PostgreSQL"
|
||||||
|
apt-get install -y -qq postgresql postgresql-contrib postgresql-client > /dev/null 2>&1
|
||||||
|
PG_VER=$(psql --version 2>/dev/null | awk '{print $3}' || echo "instalado")
|
||||||
|
PG_MAJOR=$(psql --version 2>/dev/null | awk '{print $3}' | cut -d. -f1 || echo "")
|
||||||
|
[[ -z "$PG_MAJOR" ]] && PG_MAJOR=$(ls /etc/postgresql/ 2>/dev/null | sort -V | tail -n1 || echo "")
|
||||||
|
[[ -z "$PG_MAJOR" ]] && PG_MAJOR="15"
|
||||||
|
|
||||||
|
ok "Motor PostgreSQL instalado: ${PG_VER}"
|
||||||
|
|
||||||
|
mkdir -p /run/postgresql /var/run/postgresql /var/lib/postgresql /var/log/postgresql
|
||||||
|
chown -R postgres:postgres /run/postgresql /var/run/postgresql /var/lib/postgresql /var/log/postgresql
|
||||||
|
chmod 2775 /run/postgresql /var/run/postgresql
|
||||||
|
|
||||||
|
if ! pg_lsclusters 2>/dev/null | grep -q 'main'; then
|
||||||
|
info "Cluster PostgreSQL no detectado — inicializando cluster ${PG_MAJOR} main..."
|
||||||
|
pg_createcluster "${PG_MAJOR}" main --locale en_US.UTF-8 --start >/dev/null 2>&1 \
|
||||||
|
|| pg_createcluster "${PG_MAJOR}" main --locale C.UTF-8 --start >/dev/null 2>&1 \
|
||||||
|
|| true
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Iniciando servicio de PostgreSQL..."
|
||||||
|
pg_ctlcluster "${PG_MAJOR}" main start >/dev/null 2>&1 || true
|
||||||
|
systemctl start "postgresql@${PG_MAJOR}-main" 2>/dev/null || true
|
||||||
|
systemctl start postgresql 2>/dev/null || true
|
||||||
|
service postgresql start 2>/dev/null || true
|
||||||
|
|
||||||
|
PG_READY=false
|
||||||
|
for i in {1..20}; do
|
||||||
|
if pg_cmd -c '\q' >/dev/null 2>&1; then
|
||||||
|
PG_READY=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$PG_READY" = true ]; then
|
||||||
|
PG_HBA=$(find /etc/postgresql/ -name pg_hba.conf 2>/dev/null | sort -V | tail -n1)
|
||||||
|
if [[ -n "$PG_HBA" && -f "$PG_HBA" ]]; then
|
||||||
|
sed -i -E 's/^(local\s+all\s+all\s+)peer/\1trust/' "$PG_HBA" 2>/dev/null || true
|
||||||
|
sed -i -E 's/^(host\s+all\s+all\s+127\.0\.0\.1\/32\s+)(scram-sha-256|md5)/\1trust/' "$PG_HBA" 2>/dev/null || true
|
||||||
|
sed -i -E 's/^(host\s+all\s+all\s+::1\/128\s+)(scram-sha-256|md5)/\1trust/' "$PG_HBA" 2>/dev/null || true
|
||||||
|
systemctl reload "postgresql@${PG_MAJOR}-main" 2>/dev/null || systemctl reload postgresql 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
systemctl enable "postgresql@${PG_MAJOR}-main" > /dev/null 2>&1 || true
|
||||||
|
systemctl enable postgresql > /dev/null 2>&1 || true
|
||||||
|
ok "PostgreSQL activo y aceptando conexiones"
|
||||||
|
else
|
||||||
|
err "No se pudo conectar al motor PostgreSQL local. Verificá los registros del sistema."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── 6. Configurar Variables de Entorno (.env) ─────────────────────────────────
|
||||||
|
hdr "6. Configurando variables de entorno (Backend y Frontend)"
|
||||||
|
|
||||||
|
DB_NAME="classrooms_db"
|
||||||
|
DB_USER="postgres"
|
||||||
|
DB_PASSWORD="password"
|
||||||
|
|
||||||
|
if [[ -f "$BACKEND_ENV" ]]; then
|
||||||
|
ok "backend/.env ya existe — se preserva la configuración actual"
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source <(grep -v '^#' "$BACKEND_ENV" | grep '=')
|
||||||
|
set +a
|
||||||
|
|
||||||
|
if [[ -n "${DATABASE_URL:-}" ]]; then
|
||||||
|
URL_USER=$(echo "$DATABASE_URL" | sed -E 's|^.*://([^:@]+).*|\1|')
|
||||||
|
URL_PASS=$(echo "$DATABASE_URL" | sed -E 's|^.*://[^:]+:([^@]+)@.*|\1|')
|
||||||
|
URL_NAME=$(echo "$DATABASE_URL" | sed -E 's|^.*/([^?]+).*|\1|')
|
||||||
|
[[ "$URL_USER" != "$DATABASE_URL" && -n "$URL_USER" ]] && DB_USER="$URL_USER"
|
||||||
|
[[ "$URL_PASS" != "$DATABASE_URL" && -n "$URL_PASS" ]] && DB_PASSWORD="$URL_PASS"
|
||||||
|
[[ "$URL_NAME" != "$DATABASE_URL" && -n "$URL_NAME" ]] && DB_NAME="$URL_NAME"
|
||||||
|
fi
|
||||||
|
DB_USER="${DB_USER:-${DB_USERNAME:-postgres}}"
|
||||||
|
DB_PASSWORD="${DB_PASSWORD:-password}"
|
||||||
|
DB_NAME="${DB_NAME:-classrooms_db}"
|
||||||
|
else
|
||||||
|
SECRET_KEY=$(python3 -c 'import secrets; print(secrets.token_hex(32))')
|
||||||
|
cat > "$BACKEND_ENV" <<ENVEOF
|
||||||
|
# Admin Edu-Space — Backend Flask Configuración de Producción
|
||||||
|
# Generado automáticamente el $(date '+%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
FLASK_APP=wsgi.py
|
||||||
|
FLASK_ENV=production
|
||||||
|
|
||||||
|
SECRET_KEY=${SECRET_KEY}
|
||||||
|
|
||||||
|
DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@localhost:5432/${DB_NAME}
|
||||||
|
DB_USERNAME=${DB_USER}
|
||||||
|
DB_PASSWORD=${DB_PASSWORD}
|
||||||
|
|
||||||
|
API_BASE_URL=http://localhost:5000/api/v1
|
||||||
|
|
||||||
|
BABEL_DEFAULT_LOCALE=es
|
||||||
|
BABEL_DEFAULT_TIMEZONE=America/Argentina/Buenos_Aires
|
||||||
|
ENVEOF
|
||||||
|
chmod 640 "$BACKEND_ENV"
|
||||||
|
ok "backend/.env generado con configuración de producción"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Configurar frontend/.env
|
||||||
|
if [[ -f "$FRONTEND_ENV" ]]; then
|
||||||
|
ok "frontend/.env ya existe — se preserva la configuración actual"
|
||||||
|
else
|
||||||
|
cat > "$FRONTEND_ENV" <<FRONTENVEOF
|
||||||
|
# Admin Edu-Space — Frontend Node.js BFF Configuración de Producción
|
||||||
|
# Generado automáticamente el $(date '+%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
PORT=3000
|
||||||
|
FLASK_API_URL=http://127.0.0.1:5000/api/v1
|
||||||
|
NODE_ENV=production
|
||||||
|
COOKIE_SECURE=auto
|
||||||
|
FRONTENVEOF
|
||||||
|
chmod 640 "$FRONTEND_ENV"
|
||||||
|
ok "frontend/.env generado con configuración conectada al backend local (puerto 3000)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── 7. Gestión y Elección de Base de Datos PostgreSQL ─────────────────────────
|
||||||
|
hdr "7. Configuración de Base de Datos PostgreSQL (Existente vs Nueva)"
|
||||||
|
|
||||||
|
# Asegurar roles de PostgreSQL
|
||||||
|
for u in "${DB_USER}" "${DB_USERNAME:-}" eduspace_user; do
|
||||||
|
if [[ -n "$u" && "$u" != "postgres" ]]; then
|
||||||
|
pg_cmd -c "CREATE USER \"${u}\" WITH PASSWORD '${DB_PASSWORD}';" >/dev/null 2>&1 \
|
||||||
|
|| pg_cmd -c "ALTER USER \"${u}\" WITH PASSWORD '${DB_PASSWORD}';" >/dev/null 2>&1 \
|
||||||
|
|| true
|
||||||
|
pg_cmd -c "ALTER USER \"${u}\" WITH SUPERUSER CREATEDB;" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
pg_cmd -c "ALTER USER postgres WITH PASSWORD '${DB_PASSWORD}';" >/dev/null 2>&1 || true
|
||||||
|
pg_cmd -c "ALTER USER postgres WITH SUPERUSER CREATEDB;" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# Detectar existencia y tablas previas
|
||||||
|
DB_EXISTS=false
|
||||||
|
TABLE_COUNT=0
|
||||||
|
if pg_cmd -lqt 2>/dev/null | cut -d \| -f 1 | grep -qw "${DB_NAME}"; then
|
||||||
|
DB_EXISTS=true
|
||||||
|
TABLE_COUNT=$(pg_cmd -d "${DB_NAME}" -tAc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public';" 2>/dev/null || echo "0")
|
||||||
|
fi
|
||||||
|
|
||||||
|
DO_RESTORE_FRESH=false
|
||||||
|
|
||||||
|
if [ "$DB_EXISTS" = true ] && [ "$TABLE_COUNT" -gt 0 ]; then
|
||||||
|
info "Base de datos detectada: '${DB_NAME}' con ${TABLE_COUNT} tabla(s) existente(s)."
|
||||||
|
|
||||||
|
if [[ "$ARG_DB_ACTION" == "keep" ]]; then
|
||||||
|
info "Bandera --keep-db recibida: Se mantiene la base de datos existente intacta."
|
||||||
|
DO_RESTORE_FRESH=false
|
||||||
|
elif [[ "$ARG_DB_ACTION" == "fresh" ]]; then
|
||||||
|
if [ "$ARG_EMPTY_ACADEMIC" = true ]; then
|
||||||
|
warn "Bandera --empty-academic recibida: Se inicializará la base de datos limpia sin datos demo."
|
||||||
|
else
|
||||||
|
warn "Bandera --fresh-db recibida: Se recreará la base de datos desde cero con snapshot."
|
||||||
|
fi
|
||||||
|
DO_RESTORE_FRESH=true
|
||||||
|
elif [ -t 0 ]; then
|
||||||
|
echo -e "\n${BOLD}${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
|
||||||
|
echo -e "${BOLD} ¿Qué deseas hacer con la base de datos PostgreSQL existente?${RESET}"
|
||||||
|
echo -e " [1] ${GREEN}${BOLD}Mantener la base de datos actual${RESET} (Recomendado: preserva registros y sincroniza esquema)"
|
||||||
|
echo -e " [2] ${RED}${BOLD}Restaurar desde cero con datos de muestra${RESET} (DROP DATABASE y recargar snapshot ${SQL_BACKUP_FILE##*/})"
|
||||||
|
echo -e " [3] ${CYAN}${BOLD}Base de datos limpia sin datos demo${RESET} (DROP DATABASE y crear tablas vacías sin carreras, materias, aulas ni sedes)"
|
||||||
|
echo -e "${BOLD}${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
|
||||||
|
|
||||||
|
DB_CHOICE=""
|
||||||
|
read -t 30 -p " Ingresá tu opción [1/2/3] (por defecto 1 en 30s): " DB_CHOICE || DB_CHOICE="1"
|
||||||
|
DB_CHOICE="${DB_CHOICE:-1}"
|
||||||
|
|
||||||
|
if [[ "$DB_CHOICE" == "2" ]]; then
|
||||||
|
warn "Opción [2] seleccionada: Restauración destructiva desde snapshot demo."
|
||||||
|
DO_RESTORE_FRESH=true
|
||||||
|
ARG_EMPTY_ACADEMIC=false
|
||||||
|
elif [[ "$DB_CHOICE" == "3" ]]; then
|
||||||
|
warn "Opción [3] seleccionada: Base de datos limpia sin carreras, materias, aulas ni sedes demo."
|
||||||
|
DO_RESTORE_FRESH=true
|
||||||
|
ARG_EMPTY_ACADEMIC=true
|
||||||
|
else
|
||||||
|
ok "Opción [1] seleccionada: Mantener base de datos existente."
|
||||||
|
DO_RESTORE_FRESH=false
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
info "Ejecución no interactiva: se mantiene la base de datos existente por defecto."
|
||||||
|
DO_RESTORE_FRESH=false
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
info "La base de datos '${DB_NAME}' no existe o está vacía. Se inicializará limpia."
|
||||||
|
DO_RESTORE_FRESH=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Otorgar privilegios base
|
||||||
|
pg_cmd -c "CREATE DATABASE \"${DB_NAME}\" OWNER postgres;" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# ─── 8. Crear Entorno Virtual e Instalar Dependencias Backend ───────────────────
|
||||||
|
hdr "8. Configurando entorno virtual Python e instalando dependencias backend"
|
||||||
|
if [[ -d "$VENV_DIR" ]]; then
|
||||||
|
info "Recreando entorno virtual para asegurar dependencias limpias..."
|
||||||
|
rm -rf "$VENV_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 -m venv "$VENV_DIR" > /dev/null 2>&1
|
||||||
|
"${VENV_DIR}/bin/pip" install --upgrade pip setuptools wheel --quiet
|
||||||
|
|
||||||
|
info "Instalando dependencias desde backend/requirements.txt..."
|
||||||
|
if ! "${VENV_DIR}/bin/pip" install -r "${BACKEND_DIR}/requirements.txt" --quiet; then
|
||||||
|
warn "Reintentando instalación con aislamiento deshabilitado..."
|
||||||
|
"${VENV_DIR}/bin/pip" install -r "${BACKEND_DIR}/requirements.txt" --no-build-isolation || err "Falló la instalación de dependencias de Python."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verificación explícita de módulos críticos requeridos por modelos y esquemas
|
||||||
|
if ! "${VENV_DIR}/bin/python" -c "import flask, sqlalchemy, cryptography, pydantic; print('Módulos críticos OK')" >/dev/null 2>&1; then
|
||||||
|
warn "Asegurando librerías críticas en el venv (cryptography, pydantic)..."
|
||||||
|
"${VENV_DIR}/bin/pip" install "cryptography>=42.0.0" "pydantic>=2.0.0" --quiet
|
||||||
|
fi
|
||||||
|
ok "Dependencias de Python instaladas correctamente"
|
||||||
|
|
||||||
|
# ─── 9. Ejecución de Despliegue de Datos (Restaurar o Sincronizar) ──────────────
|
||||||
|
hdr "9. Aplicando esquema y datos en la base de datos"
|
||||||
|
|
||||||
|
cd "$BACKEND_DIR"
|
||||||
|
|
||||||
|
if [ "$DO_RESTORE_FRESH" = true ]; then
|
||||||
|
if [ "$ARG_EMPTY_ACADEMIC" = true ]; then
|
||||||
|
info "Inicializando base de datos limpia (sin datos demo de carreras, materias, aulas o sedes)..."
|
||||||
|
pg_cmd -c "DROP DATABASE IF EXISTS \"${DB_NAME}\";" > /dev/null 2>&1 || true
|
||||||
|
pg_cmd -c "CREATE DATABASE \"${DB_NAME}\" OWNER postgres;" > /dev/null 2>&1
|
||||||
|
ok "Base de datos '${DB_NAME}' creada limpia (sin snapshot demo)"
|
||||||
|
elif [[ -f "$SQL_BACKUP_FILE" ]]; then
|
||||||
|
BACKUP_SIZE=$(du -h "$SQL_BACKUP_FILE" | awk '{print $1}')
|
||||||
|
info "Restaurando snapshot '${SQL_BACKUP_FILE##*/}' (${BACKUP_SIZE})..."
|
||||||
|
pg_cmd -c "DROP DATABASE IF EXISTS \"${DB_NAME}\";" > /dev/null 2>&1 || true
|
||||||
|
pg_cmd -c "CREATE DATABASE \"${DB_NAME}\" OWNER postgres;" > /dev/null 2>&1
|
||||||
|
|
||||||
|
sed '/^\\restrict/d' "$SQL_BACKUP_FILE" | pg_cmd -d "${DB_NAME}" > /dev/null 2>&1 || true
|
||||||
|
ok "Snapshot SQL restaurado exitosamente"
|
||||||
|
else
|
||||||
|
info "No se encontró classrooms_db.sql. Creando esquema limpio con init_db.py..."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
ok "Conservando tablas existentes en '${DB_NAME}'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Otorgar permisos completos al usuario de la aplicación
|
||||||
|
for u in "${DB_USER}" "${DB_USERNAME:-}" postgres eduspace_user; do
|
||||||
|
if [[ -n "$u" ]]; then
|
||||||
|
pg_cmd -d "${DB_NAME}" -c "GRANT ALL PRIVILEGES ON DATABASE \"${DB_NAME}\" TO \"${u}\";" > /dev/null 2>&1 || true
|
||||||
|
pg_cmd -d "${DB_NAME}" -c "GRANT ALL ON SCHEMA public TO \"${u}\";" > /dev/null 2>&1 || true
|
||||||
|
pg_cmd -d "${DB_NAME}" -c "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO \"${u}\";" > /dev/null 2>&1 || true
|
||||||
|
pg_cmd -d "${DB_NAME}" -c "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO \"${u}\";" > /dev/null 2>&1 || true
|
||||||
|
pg_cmd -d "${DB_NAME}" -c "GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO \"${u}\";" > /dev/null 2>&1 || true
|
||||||
|
pg_cmd -d "${DB_NAME}" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO \"${u}\";" > /dev/null 2>&1 || true
|
||||||
|
pg_cmd -d "${DB_NAME}" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO \"${u}\";" > /dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Sincronización idempotente con init_db.py (crea tablas o columnas faltantes sin borrar datos)
|
||||||
|
info "Sincronizando modelos y esquemas institucionales con init_db.py..."
|
||||||
|
INIT_DB_EXTRA_FLAGS=()
|
||||||
|
if [ "$ARG_KEEP_USERS" = true ]; then
|
||||||
|
INIT_DB_EXTRA_FLAGS+=( "--keep-users" )
|
||||||
|
info "Bandera --keep-users activada: init_db.py preservará los usuarios y contraseñas sin modificarlos ni recrearlos."
|
||||||
|
fi
|
||||||
|
if [ "$ARG_EMPTY_ACADEMIC" = true ]; then
|
||||||
|
INIT_DB_EXTRA_FLAGS+=( "--empty-academic" )
|
||||||
|
info "Bandera --empty-academic activada: init_db.py creará las tablas vacías sin carreras, materias, ciclos lectivos, aulas, edificios ni reservas."
|
||||||
|
fi
|
||||||
|
|
||||||
|
LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 PYTHONUTF8=1 PYTHONIOENCODING=utf-8 \
|
||||||
|
PYTHONPATH="${BACKEND_DIR}" FLASK_APP=wsgi.py "${VENV_DIR}/bin/python" -X utf8 init_db.py "${INIT_DB_EXTRA_FLAGS[@]}"
|
||||||
|
ok "Esquema, usuarios y roles RBAC sincronizados con éxito"
|
||||||
|
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
# ─── 10. Instalar Dependencias del Frontend Node.js ────────────────────────────
|
||||||
|
hdr "10. Instalando dependencias de Node.js en frontend/"
|
||||||
|
|
||||||
|
cd "$FRONTEND_DIR"
|
||||||
|
info "Ejecutando npm install en frontend..."
|
||||||
|
if [ -f "package-lock.json" ]; then
|
||||||
|
npm ci --omit=dev --silent 2>/dev/null || npm install --omit=dev --silent
|
||||||
|
else
|
||||||
|
npm install --omit=dev --silent
|
||||||
|
fi
|
||||||
|
ok "Dependencias de Node.js instaladas en frontend/"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
# ─── 11. Usuario del Sistema y Permisos de Directorio ───────────────────────────
|
||||||
|
hdr "11. Configurando usuario del sistema y permisos de seguridad"
|
||||||
|
if id "$APP_USER" &>/dev/null; then
|
||||||
|
info "El usuario de sistema '${APP_USER}' ya existe."
|
||||||
|
else
|
||||||
|
useradd --system --no-create-home --shell /usr/sbin/nologin "$APP_USER"
|
||||||
|
ok "Usuario de sistema '${APP_USER}' creado (sin shell interactiva)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${BACKEND_DIR}/app/static/uploads"
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
|
||||||
|
chown -R "${APP_USER}:${APP_USER}" "$ROOT_DIR"
|
||||||
|
chown -R "${APP_USER}:${APP_USER}" "$LOG_DIR"
|
||||||
|
chmod -R 755 "$ROOT_DIR"
|
||||||
|
chmod -R 775 "${BACKEND_DIR}/app/static/uploads"
|
||||||
|
chmod 640 "$BACKEND_ENV" "$FRONTEND_ENV"
|
||||||
|
chmod 755 "$LOG_DIR"
|
||||||
|
ok "Permisos aplicados en ${ROOT_DIR} y ${LOG_DIR}"
|
||||||
|
|
||||||
|
# ─── 12. Servicios Systemd (Backend Flask y Frontend Node.js) ──────────────────
|
||||||
|
hdr "12. Configurando servicios systemd de arranque automático"
|
||||||
|
|
||||||
|
GUNICORN_BIN="${VENV_DIR}/bin/gunicorn"
|
||||||
|
NODE_BIN="$(command -v node)"
|
||||||
|
|
||||||
|
# 12.1 Servicio Backend
|
||||||
|
cat > "/etc/systemd/system/${SERVICE_BACKEND}.service" <<SVCEOF
|
||||||
|
[Unit]
|
||||||
|
Description=Admin Edu-Space — Backend Flask API (Gunicorn)
|
||||||
|
Documentation=https://gitea.oemspot.com.ar/carlostellocba/admin-edu-space
|
||||||
|
After=network.target postgresql.service
|
||||||
|
Wants=postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=${APP_USER}
|
||||||
|
Group=${APP_USER}
|
||||||
|
WorkingDirectory=${BACKEND_DIR}
|
||||||
|
EnvironmentFile=${BACKEND_ENV}
|
||||||
|
Environment="LANG=en_US.UTF-8"
|
||||||
|
Environment="LC_ALL=en_US.UTF-8"
|
||||||
|
Environment="PYTHONUTF8=1"
|
||||||
|
Environment="PYTHONIOENCODING=utf-8"
|
||||||
|
Environment="PYTHONPATH=${BACKEND_DIR}"
|
||||||
|
Environment="FLASK_ENV=production"
|
||||||
|
Environment="HOME=${BACKEND_DIR}"
|
||||||
|
ExecStart=${GUNICORN_BIN} \
|
||||||
|
--workers 3 \
|
||||||
|
--bind 0.0.0.0:5000 \
|
||||||
|
--timeout 120 \
|
||||||
|
--access-logfile ${LOG_DIR}/backend-access.log \
|
||||||
|
--error-logfile ${LOG_DIR}/backend-error.log \
|
||||||
|
--capture-output \
|
||||||
|
--enable-stdio-inheritance \
|
||||||
|
--log-level info \
|
||||||
|
wsgi:app
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=${SERVICE_BACKEND}
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
SVCEOF
|
||||||
|
|
||||||
|
# 12.2 Servicio Frontend BFF
|
||||||
|
cat > "/etc/systemd/system/${SERVICE_FRONTEND}.service" <<SVCEOF
|
||||||
|
[Unit]
|
||||||
|
Description=Admin Edu-Space — Frontend Node.js BFF (Express)
|
||||||
|
Documentation=https://gitea.oemspot.com.ar/carlostellocba/admin-edu-space
|
||||||
|
After=network.target ${SERVICE_BACKEND}.service
|
||||||
|
Wants=${SERVICE_BACKEND}.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=${APP_USER}
|
||||||
|
Group=${APP_USER}
|
||||||
|
WorkingDirectory=${FRONTEND_DIR}
|
||||||
|
EnvironmentFile=${FRONTEND_ENV}
|
||||||
|
Environment="NODE_ENV=production"
|
||||||
|
Environment="PORT=3000"
|
||||||
|
ExecStart=${NODE_BIN} src/app.js
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=${SERVICE_FRONTEND}
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
SVCEOF
|
||||||
|
|
||||||
|
# 12.3 Servicio Meta Unificador
|
||||||
|
cat > "/etc/systemd/system/${SERVICE_META}.service" <<SVCEOF
|
||||||
|
[Unit]
|
||||||
|
Description=Admin Edu-Space — Plataforma Integral (Backend + Frontend)
|
||||||
|
Documentation=https://gitea.oemspot.com.ar/carlostellocba/admin-edu-space
|
||||||
|
After=${SERVICE_BACKEND}.service ${SERVICE_FRONTEND}.service
|
||||||
|
Wants=${SERVICE_BACKEND}.service ${SERVICE_FRONTEND}.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
ExecStart=/bin/true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
SVCEOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable "${SERVICE_BACKEND}" "${SERVICE_FRONTEND}" "${SERVICE_META}" > /dev/null 2>&1
|
||||||
|
|
||||||
|
info "Iniciando servicio backend..."
|
||||||
|
systemctl restart "${SERVICE_BACKEND}"
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
info "Iniciando servicio frontend..."
|
||||||
|
systemctl restart "${SERVICE_FRONTEND}"
|
||||||
|
systemctl restart "${SERVICE_META}"
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
# Verificar salud de los servicios
|
||||||
|
BACKEND_OK=false
|
||||||
|
if systemctl is-active --quiet "$SERVICE_BACKEND"; then
|
||||||
|
HTTP_BACK=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:5000/api/v1/openapi.json || echo "000")
|
||||||
|
if [[ "$HTTP_BACK" == "200" ]]; then
|
||||||
|
ok "Backend Flask respondiendo activamente en puerto 5000 (OpenAPI HTTP 200)"
|
||||||
|
BACKEND_OK=true
|
||||||
|
else
|
||||||
|
warn "Backend activo pero devolvió HTTP ${HTTP_BACK} al consultar OpenAPI."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "El servicio backend tardó en iniciar. Logs: journalctl -u ${SERVICE_BACKEND} -n 20 --no-pager"
|
||||||
|
fi
|
||||||
|
|
||||||
|
FRONTEND_OK=false
|
||||||
|
if systemctl is-active --quiet "$SERVICE_FRONTEND"; then
|
||||||
|
HTTP_FRONT=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/ || echo "000")
|
||||||
|
if [[ "$HTTP_FRONT" == "200" || "$HTTP_FRONT" == "302" ]]; then
|
||||||
|
ok "Frontend Node.js respondiendo activamente en puerto 3000 (HTTP ${HTTP_FRONT})"
|
||||||
|
FRONTEND_OK=true
|
||||||
|
else
|
||||||
|
warn "Frontend activo pero devolvió HTTP ${HTTP_FRONT} al consultar raíz."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "El servicio frontend tardó en iniciar. Logs: journalctl -u ${SERVICE_FRONTEND} -n 20 --no-pager"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Resumen Final y Datos de Acceso ──────────────────────────────────────────
|
||||||
|
hdr "¡Despliegue Completado con Éxito!"
|
||||||
|
|
||||||
|
IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "127.0.0.1")
|
||||||
|
|
||||||
|
echo -e " ${BOLD}Acceso Web a la Plataforma (Frontend):${RESET}"
|
||||||
|
echo -e " ${GREEN}${BOLD}http://${IP}:3000${RESET} o ${GREEN}${BOLD}http://localhost:3000${RESET}"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}Acceso a la API REST (Backend Flask):${RESET}"
|
||||||
|
echo -e " ${CYAN}http://${IP}:5000/api/v1${RESET} (OpenAPI spec en /api/v1/openapi.json)"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}Credenciales de Administrador Iniciales:${RESET}"
|
||||||
|
echo -e " Email: ${CYAN}admin@edu-space.com${RESET}"
|
||||||
|
echo -e " Password: ${CYAN}admin123${RESET}"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}Base de Datos Desplegada:${RESET}"
|
||||||
|
echo -e " Motor: PostgreSQL (${PG_VER})"
|
||||||
|
echo -e " Base: ${DB_NAME}"
|
||||||
|
echo -e " Usuario: ${DB_USER}"
|
||||||
|
echo -e " Modo: $( [ "$DO_RESTORE_FRESH" = true ] && echo "Restaurada desde cero" || echo "Base existente mantenida y sincronizada" )"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}Comandos de Gestión del Servicio:${RESET}"
|
||||||
|
echo -e " Estado General: ${CYAN}systemctl status ${SERVICE_META}${RESET}"
|
||||||
|
echo -e " Reiniciar Todo: ${CYAN}systemctl restart ${SERVICE_META}${RESET}"
|
||||||
|
echo -e " Logs Backend: ${CYAN}journalctl -u ${SERVICE_BACKEND} -f${RESET}"
|
||||||
|
echo -e " Logs Frontend: ${CYAN}journalctl -u ${SERVICE_FRONTEND} -f${RESET}"
|
||||||
|
echo ""
|
||||||
|
ok "Despliegue finalizado. Todos los servicios han sido configurados."
|
||||||
|
echo ""
|
||||||
Reference in New Issue
Block a user