Compare commits
29
Commits
05e197e49f
..
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 | ||
|
|
ed97fb5995 | ||
|
|
1aae432bba | ||
|
|
7ad4e3f099 | ||
|
|
4ebc5df4fd | ||
|
|
71fb20a271 | ||
|
|
ada39248d7 |
@@ -0,0 +1,73 @@
|
||||
name: Edu-Space Automated Security Audit (Gitea Local)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
|
||||
jobs:
|
||||
security-scan:
|
||||
# Usamos la etiqueta estándar para contenedores Ubuntu en Gitea Runner
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
# 1. Clonar el código del repositorio local
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 2. Preparar el entorno de paquetes del sistema (Linux)
|
||||
- name: Install System Dependencies (Python & Node.js)
|
||||
run: |
|
||||
apt-get update && apt-get install -y python3 python3-pip python3-venv nodejs npm curl
|
||||
python3 -m pip install --upgrade pip
|
||||
|
||||
# 3. Instalar librerías de Python y herramientas de seguridad
|
||||
- name: Install Python Dependencies & Security Tools
|
||||
run: |
|
||||
pip3 install -r backend/requirements.txt
|
||||
pip3 install -r backend/requirements-dev.txt
|
||||
|
||||
# 4. Instalar librerías de Node.js (Express BFF)
|
||||
- name: Install Node.js Dependencies
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
|
||||
# --- EJECUCIÓN DE CONTROLES (SAST & SCA) ---
|
||||
|
||||
- name: [1/5] Python SAST (Bandit)
|
||||
run: bandit -r ./backend/app -ll -ii
|
||||
|
||||
- name: [2/5] Python SCA (Pip-Audit)
|
||||
run: pip-audit -s osv --progress-spinner off -r backend/requirements.txt
|
||||
|
||||
- name: [3/5] Node.js SAST (Njsscan)
|
||||
run: njsscan ./frontend/src
|
||||
|
||||
- name: [4/5] Node.js SCA (Npm Audit)
|
||||
run: |
|
||||
cd frontend
|
||||
npm audit --audit-level=high
|
||||
|
||||
# --- CONTROL DINÁMICO DAST (Schemathesis) ---
|
||||
|
||||
- name: [5/5] Start Flask App & Run DAST (Schemathesis)
|
||||
env:
|
||||
PYTHONUTF8: 1
|
||||
FLASK_APP: backend/app # Ajustar a la ruta de inicio de tu Flask si varía
|
||||
run: |
|
||||
# Levantar Flask en segundo plano dentro del contenedor local
|
||||
# Usamos 'python3 -m flask run' para asegurar el bindeo local
|
||||
cd backend
|
||||
python3 -m flask run --host=127.0.0.1 --port=5000 &
|
||||
|
||||
# Esperar 5 segundos a que la API responda
|
||||
echo "Esperando a que el backend de Flask inicie en el entorno local..."
|
||||
sleep 5
|
||||
|
||||
# Verificar con un curl rápido si el JSON de OpenAPI está disponible
|
||||
curl -s http://127.0.0.1:5000/api/v1/openapi.json > /dev/null
|
||||
|
||||
# Ejecutar Schemathesis contra la instancia local efímera
|
||||
schemathesis run http://127.0.0.1:5000/api/v1/openapi.json --checks not_a_server_error --max-examples=10
|
||||
+5
-1
@@ -220,4 +220,8 @@ npm-debug.log
|
||||
frontend/.env
|
||||
|
||||
legacy_admin-edu-space/
|
||||
.vscode/
|
||||
.vscode/
|
||||
.schemathesis/
|
||||
|
||||
AlumnosLS
|
||||
prerrequisitos
|
||||
+225
-7
@@ -6,16 +6,234 @@ El formato está basado en [Keep a Changelog](https://keepachangelog.com/es-ES/1
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
### Planificado (Fase 2 del Roadmap MVP)
|
||||
- **Co-docencia en Cátedras:** Asignación de múltiples profesores (titular, adjunto, ayudante) a una misma comisión.
|
||||
- **Matriz de Conflictos:** Detección de solapamiento físico, docente y control de aforo en tiempo real.
|
||||
- **Regla de Restricción Diaria del Alumno:** Validación para impedir cursar dos materias regulares el mismo día, con motor de excepciones para cursos cortos o autorización de Bedelía.
|
||||
- **Libro de Calificaciones (*Gradebook*):** Carga rápida de notas y seguimiento de regularidad.
|
||||
- **Grilla Semanal Drag & Drop:** Asignación visual interactiva para la oficina de Bedelía.
|
||||
### Planificado (Fase 6 del Roadmap MVP)
|
||||
- **Despliegue e Infraestructura (Fase 6):** `docker-compose.yml` productivo orquestando Flask (Gunicorn), Node.js BFF (PM2) y PostgreSQL 15, con proxy inverso Nginx, SSL/TLS y compresión.
|
||||
- **Sincronización Continua Moodle (Fase 6):** Automatización de workers persistentes en background para el procesamiento continuo de la cola de Web Services Moodle 4.1.
|
||||
- **Pruebas de Carga y Hardening (Fase 6):** Pruebas de estrés de concurrencia y validación final de producción.
|
||||
|
||||
---
|
||||
|
||||
## [2.3.0] - 2026-09-19
|
||||
## [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
|
||||
### Añadido
|
||||
- **Fase 5 — Integración Auth, Email & Moodle Backend:**
|
||||
- **Épica 1: Panel de Configuración Global (Perfil ADMIN):**
|
||||
- Cifrado simétrico de credenciales y secretos con Fernet (`CryptoService` en `backend/app/services/crypto_service.py`).
|
||||
- Migración y extensión de `SystemSetting` con columnas `category` e `is_encrypted`, junto con métodos polimórficos `get_decrypted_value()` y `get_masked_value()`.
|
||||
- Endpoints REST `/api/v1/admin/settings/*` para administración dinámica de parámetros SMTP, toggles de proveedores SSO, Google OAuth y Web Services de Moodle 4.1.
|
||||
- Nueva vista responsiva `frontend/views/admin/settings/global_config.html` con soporte optimizado para pantallas 720p, 1360x768 y 1080p, y enlace integrado en el menú de navegación lateral.
|
||||
- **Épica 2: Infraestructura Asíncrona y Caché:**
|
||||
- Servicio híbrido de caché `CacheService` con integración Redis y fallback en memoria/TTL.
|
||||
- Modelo `MoodleSyncTask` y procesador asíncrono `MoodleQueueService` con Exponential Backoff y Dead Letter Queue (DLQ).
|
||||
- Métricas en vivo y reintentos manuales individuales y masivos para tareas en DLQ.
|
||||
- **Épica 3: Autenticación Híbrida (SSO & Local):**
|
||||
- Login nativo como fallback con contingencia para cuentas con rol ADMIN.
|
||||
- Integración de Google OAuth 2.0 basada en la arquitectura de AlumnosLS con filtrado estricto por dominios institucionales (`google_allowed_domains`).
|
||||
- Autenticación delegada contra Moodle 4.1 vía `/login/token.php`, mapeo inicial de roles (Admin/Docente/Alumno) y gestión de roles 100% desacoplada en Edu-Space.
|
||||
- **Épica 4: Integración Bidireccional Moodle 4.1:**
|
||||
- Adaptador `MoodleClient` con Web Services de Moodle 4.1 (`core_user_*`, `enrol_manual_*`, `core_enrol_*`, `core_role_*`).
|
||||
- Encolado no bloqueante de sincronización al crear o editar usuarios en Edu-Space.
|
||||
- **Épica 5: Motor de Notificaciones (Email Server):**
|
||||
- Cliente SMTP dinámico en `EmailService` con desencriptación en caliente de contraseñas.
|
||||
- Plantillas HTML transaccionales para asignación de comisiones a profesores, citación a exámenes y pruebas en vivo de conectividad.
|
||||
- **Pruebas y Verificación:**
|
||||
- Suite de integración automatizada `backend/tests/test_phase6_integration.py` con 7 pruebas aprobadas al 100%. Regresión completa de 30 pruebas en verde en los módulos core.
|
||||
|
||||
---
|
||||
|
||||
## [2.8.0] - 2026-09-23
|
||||
### Añadido
|
||||
- **Fase 4 del Roadmap MVP — UI/UX Avanzada para Bedelía & Modo Impersonación:**
|
||||
- **Estadio 4.1 — Grilla Semanal Interactiva (Drag-and-Drop) con Detección de Colisiones:**
|
||||
- Activación de arrastre y edición interactiva en FullCalendar (`editable: true`, `droppable: true`, `eventDurationEditable: true`).
|
||||
- Panel lateral integrado de comisiones sin aula asignada (`unassigned_commissions`) con arrastre fluido hacia la grilla semanal o diaria.
|
||||
- Endpoint REST `POST /api/v1/reservations/drag-update` con validación previa de la matriz integral de conflictos (`ReservationService.check_full_conflicts_matrix`): colisión física de aula, colisión de horario docente y aforo de estudiantes.
|
||||
- Si existe colisión, retorna HTTP 409 (`status: conflict`), revierte automáticamente la posición del evento en la grilla (`info.revert()`) y notifica al usuario con un Toast detallado del motivo del choque.
|
||||
- Si el espacio está disponible, actualiza o crea la reserva en base de datos, asienta la acción en `AuditLog` y muestra confirmación visual de éxito.
|
||||
- **Estadio 4.2 — Modo Impersonación del Superadmin:**
|
||||
- Soporte completo de cabecera `X-Impersonate-User` en el decorador JWT del Backend Flask (`jwt_required`) para administradores.
|
||||
- Endpoints administrativos `POST /api/v1/admin/impersonate` y `POST /api/v1/admin/stop-impersonating` con registro estricto en `AuditLog` bajo las acciones `IMPERSONATE_START` e `IMPERSONATE_END`.
|
||||
- Inyección contextual de impersonación en el Frontend BFF (`authMiddleware` y proxy transparente `/api`) mediante cookies seguras `impersonate_user_id` e `impersonate_user_data`.
|
||||
- Banner de advertencia visual permanente en la barra superior (`base.html`) indicando el usuario y rol impersonado, con botón de salida inmediata hacia la sesión del administrador.
|
||||
- Botón de acceso directo para impersonar (`bi-incognito`) en cada fila de usuario activo en `/admin/users_list`.
|
||||
- **Estadio 4.3 — Acople del Optimizador Genético a Datos y Turnos Reales:**
|
||||
- Algoritmo genético (`ReservationOptimizer`) adaptado a las franjas horarias y turnos institucionales reales de UniCABA (`Mañana` 08:00-12:00, `Tarde` 14:00-18:00, `Vespertino`/`Noche` 18:30-22:30).
|
||||
- Soporte de guardado masivo en base de datos (`POST /api/genetic/apply-optimization` y `apply_optimized_reservations`) con prevención de solapamientos físicos y conversión estricta de fechas ISO.
|
||||
- **Suite de Pruebas Unitarias de Fase 4:**
|
||||
- Archivo `backend/tests/test_phase4_interactive_impersonation.py` con 6 tests unitarios cubriendo los criterios de aceptación: impersonación como docente y estudiante, trazabilidad en auditoría, rechazo de impersonación para usuarios no administradores, finalización de impersonación, drag-update con bloqueo por colisión 409 y drag-update exitoso 200.
|
||||
- Regresión global de 18 tests (Fases 2, 3 y 4) aprobada al 100% (18/18 OK).
|
||||
|
||||
---
|
||||
|
||||
## [2.7.0] - 2026-09-23
|
||||
### Añadido
|
||||
- **Fase 3 del Roadmap MVP — Gestión de Hitos Evaluativos y Libro de Calificaciones (*Gradebook*):**
|
||||
- **Estadio 3.1 — Calendario de Evaluación y Notificaciones Preventivas:**
|
||||
- Modelo relacional `MilestoneGrade` (`milestone_grades`) asociado a estudiantes (`User`) y reservas de tipo hito (`Reservation`).
|
||||
- Servicio `GradebookService.get_student_upcoming_milestones`: consulta inteligente de exámenes parciales, recuperatorios y entregas de TP de las comisiones en las que el alumno está matriculado.
|
||||
- Endpoint REST `GET /api/v1/students/my-upcoming-milestones` con cálculo de días restantes (`days_remaining`).
|
||||
- Cartelera y Widget de Próximos Exámenes en el Dashboard del Alumno (`/dashboard?role=alumno`) con badges dinámicos de cuenta regresiva (*¡Hoy!*, *¡Mañana!*, *Faltan X días*).
|
||||
- **Estadio 3.2 — Libro de Calificaciones (*Gradebook*) y Cierre de Actas:**
|
||||
- Extensiones al modelo `Commission`: campos `grades_closed` (booleano), `grades_closed_at` (timestamp), `grades_closed_by` (ID usuario) y `acta_number` (código oficial de acta).
|
||||
- Servicio académico `GradebookService` (`backend/app/services/gradebook_service.py`):
|
||||
- `get_commission_gradebook`: matriz bidimensional consolidada de alumnos inscriptos y notas por hito evaluativo.
|
||||
- `save_single_grade` y `bulk_save_grades`: registro y actualización de calificaciones con validación estricta de escala (1.0 a 10.0), flag de ausente (`is_absent`) y soporte de retroalimentación pedagógica (`feedback`).
|
||||
- Bloqueo por inmutabilidad: rechazo de modificaciones cuando el acta se encuentra cerrada.
|
||||
- `close_gradebook`: sellado oficial del acta con generación de identificador inmutable (`ACTA-YYYY-Sem-ID`), registro de auditoría en `AuditLog` y congelamiento de calificaciones.
|
||||
- `reopen_gradebook`: reapertura excepcional para Bedelía/Admin con justificación administrativa obligatoria y registro en auditoría.
|
||||
- `get_student_grades`: consolidado para el alumno de todas sus materias cursadas, notas por evaluación, promedio general y condición académica final (*Promocionado*, *Regular*, *Libre*).
|
||||
- Endpoints REST de Calificaciones:
|
||||
- `GET /api/v1/commissions/<id>/gradebook`: estructura completa para la matriz del libro de calificaciones.
|
||||
- `PUT /api/v1/commissions/<id>/gradebook/grades`: guardado masivo o individual de notas vía JSON.
|
||||
- `POST /api/v1/commissions/<id>/gradebook/close`: cierre formal de acta.
|
||||
- `POST /api/v1/commissions/<id>/gradebook/reopen`: reapertura justificada de acta.
|
||||
- `GET /api/v1/students/my-grades`: consulta individual de notas y regularidad del estudiante.
|
||||
- Interfaz de Usuario y Vistas Frontend BFF:
|
||||
- Vista `/schedule/gradebook/:id`: matriz interactiva con cálculo de promedio en tiempo real en el DOM, asignación de condición en vivo, autoguardado asíncrono debounced (500ms) con indicador flotante de estado (*Guardando...*, *✓ Todos los cambios guardados*), filtro de búsqueda instantánea por alumno/legajo, modal de cierre formal de acta y modal de reapertura.
|
||||
- Vista `/schedule/my_grades` (`/mis-materias/mis-notas`): portal del estudiante para consultar calificaciones por hito, promedios finales, condición de cursada y actas emitidas, con soporte de impresión de certificado de cursada.
|
||||
- Acceso directo integrado al Libro de Calificaciones en la tarjeta de comisión de `/admin/commission_detail`.
|
||||
- Acceso directo a "Mis Calificaciones" en el launchpad de navegación del alumno en `/dashboard`.
|
||||
- Script de migración de base de datos SQLite `backend/migrate_phase3_fields.py`.
|
||||
- **Suite de Pruebas Unitarias de Fase 3:**
|
||||
- Archivo `backend/tests/test_phase3_gradebook.py` con 6 tests unitarios cubriendo los criterios de aceptación de Fase 3: estructura de matriz, autoguardado de notas, validaciones de rango numérico, cierre formal de acta e inmutabilidad, consulta individual de notas y cuenta regresiva de próximos exámenes. 100% de tests aprobados (12/12 en suite global Fase 2 + Fase 3).
|
||||
|
||||
---
|
||||
|
||||
## [2.6.1] - 2026-09-23
|
||||
### Modificado
|
||||
- **Optimización de Espacio Vertical y Compatibilidad de Pantallas (720p / 1360x768 y 1080p):**
|
||||
- **Navbar Compacta:** Reducción de altura a 46px eliminando desperdicio de espacio superior para maximizar el área visible de trabajo de Bedelía.
|
||||
- **Botón de Toggle de Sidebar:** Reingeniería visual del botón de alternancia del menú lateral con diseño circular limpio, micro-animación en hover y centrado perfecto.
|
||||
- **Soporte Multipantalla:** Media queries CSS especializadas para resoluciones compactas habituales en puestos de trabajo de administración y bedelía argentina (1360x768 / 1366x768) y escalado equilibrado a 1920x1080 Full HD.
|
||||
- **Densidad de Información:** Espaciados y márgenes optimizados para evitar scrolling innecesario en formularios y catálogos de comisiones y aulas.
|
||||
|
||||
---
|
||||
|
||||
## [2.6.0] - 2026-09-19
|
||||
### Añadido
|
||||
- **Fase 2 del Roadmap MVP — Co-docencia, Matriz de Conflictos y Regla Diaria:**
|
||||
- **Estadio 2.1 — Modelo y Gestión Integral de Co-docencia:**
|
||||
- Modelo relacional `CommissionTeacher` (`commission_teachers`) con roles de cátedra (`Titular`, `Adjunto`, `JTP`, `Ayudante`) y flags de docente principal.
|
||||
- Endpoints REST de cátedra: `POST /api/v1/commissions/<id>/teachers` y `DELETE /api/v1/commissions/<id>/teachers/<user_id>` con control de duplicados y asignación dinámica.
|
||||
- Integración visual en el detalle de la comisión (`/admin/commission_detail`) con tarjeta interactiva para vincular/desvincular integrantes del equipo docente y badges de rol en la grilla general (`/admin/commissions_list`).
|
||||
- **Estadio 2.2 — Matriz de Conflictos y Validación Integral:**
|
||||
- **Conflicto Físico Estricto:** Bloqueo estricto de superposición horaria en la misma aula física (`ReservationRepository.find_overlapping` & `check_physical_conflicts`).
|
||||
- **Conflicto Docente Inteligente:** Detección de doble reserva simultánea para un mismo profesor (`ReservationRepository.find_teacher_conflicts` & `check_teacher_conflicts`).
|
||||
- **Excepción Justificada por Co-docencia:** Si un docente asignado presenta solapamiento pero la comisión cuenta con co-docentes registrados en `CommissionTeacher`, el motor de conflictos autoriza la reserva validando la cobertura del equipo de cátedra.
|
||||
- **Conflicto de Aforo:** Bloqueo estricto cuando la cantidad de asistentes esperados (`expected_attendees`) excede la capacidad física declarada del aula (`classroom.capacity`).
|
||||
- Endpoint unificado `POST /api/v1/reservations/check-conflicts` para pre-validación asíncrona de conflictos físicos, docentes y de capacidad antes del guardado.
|
||||
- **Estadio 2.3 — Regla de Restricción Diaria del Alumno y Motor de Excepciones:**
|
||||
- Servicio académico `EnrollmentService` (`backend/app/services/enrollment_service.py`) para control de cupos, inscripciones activas y análisis de días de cursada.
|
||||
- **Regla Diaria:** Un alumno no puede matricularse en dos materias regulares programadas para el mismo día de la semana.
|
||||
- **Excepción Automática por Curso Corto:** Indicador `is_short_course` en modelo `Subject`; si una de las asignaturas es un taller, seminario o curso corto, el sistema autoriza automáticamente la cursada simultánea en el mismo día.
|
||||
- **Excepción Expresa de Bedelía:** Parámetros `allow_same_day_exception` y `exception_reason` en el modelo `StudentEnrollment`, permitiendo a Bedelía inscribir alumnos con justificación administrativa expresa (cambio de plan, equivalencias, etc.).
|
||||
- Endpoints de gestión de matrículas por comisión: `GET /api/v1/commissions/<id>/enrollments`, `POST /api/v1/commissions/<id>/enrollments`, `DELETE /api/v1/commissions/<id>/enrollments/<student_id>` y `PUT /api/v1/commissions/<id>/enrollments/<student_id>`.
|
||||
- Adaptación completa de UI en `/admin/commission_detail` con selector de estudiantes, toggle interactivo de excepción de Bedelía, campo de motivo justificado, y tabla de estudiantes matriculados con badges de excepción.
|
||||
- **Suite de Pruebas Unitarias e Integrales de Reglas de Negocio:**
|
||||
- Archivo `backend/tests/test_phase2_rules.py` con 6 tests completos verificando: bloqueo físico, conflicto de aforo, detección docente con bypass de co-docencia, restricción diaria de cursada, excepción por curso corto y excepción por autorización manual de Bedelía. 100% de tests aprobados.
|
||||
- **Mejoras UI/UX en Dashboard de Bedelía:**
|
||||
- Paneles interactivos para visualizar **Comisiones Sin Reserva de Aula** y **Aulas Disponibles** con soporte de filtrado temporal (Semana/Mes) directamente consumiendo estadísticas en tiempo real del motor backend.
|
||||
- **Fase 1: Auditoría y Seguridad Automatizada:**
|
||||
- **Control de Entorno Dev:** Aislamiento de herramientas de seguridad en `requirements-dev.txt`.
|
||||
- **Automatización Local e Integración Continua:** Correcciones de evaluación en `security_audit.bat` para Windows y actualización de `.gitea/workflows/security-audit.yaml` para asegurar la correcta ejecución del stack de auditoría (Bandit, pip-audit, njsscan y Schemathesis).
|
||||
## [2.5.0] - 2026-09-19
|
||||
### Añadido
|
||||
- **Auditoría y Seguridad Automatizada Completa (Python & Node.js):**
|
||||
- **Python SAST (`bandit`):** Escaneo estático del backend Flask de más de 8.400 líneas. Corrección de vulnerabilidad B310 en `sheets_importer.py` mediante validación estricta de esquema HTTP/HTTPS para prevenir ataques de tipo SSRF. Resultado: 0 fallos detectados.
|
||||
- **Python SCA (`pip-audit`):** Análisis de dependencias con base OSV; detección de 24 CVEs históricas en paquetes desactualizados y actualización completa a versiones seguras (`Flask>=3.1.3`, `Werkzeug>=3.1.8`, `requests>=2.34.2`, `bleach>=6.4.0`, `python-dotenv>=1.2.3`). Resultado: 0 CVEs activas.
|
||||
- **Python DAST / Fuzzing (`schemathesis`):** Especificación OpenAPI 3.0.3 en `backend/app/routes/api/openapi.py` (`/api/v1/openapi.json`). Ejecución de 114 casos de prueba dinámicos con mutación de datos. Detección y corrección de caída 500 por payload de tipo JSON inválido en `/auth/login`. Resultado: 114/114 pruebas aprobadas con 0 errores de servidor.
|
||||
- **Node.js SAST (`njsscan`):** Análisis de patrones de código inseguro en el BFF Express (`frontend/src`). Resultado: 0 hallazgos.
|
||||
- **Node.js SCA (`npm audit`):** Auditoría continua de paquetes npm con 0 vulnerabilidades reportadas.
|
||||
- **Blindaje Activo (`helmet` & `express-rate-limit`):** Incorporación de cabeceras HTTP defensivas (`HSTS`, `nosniff`, `SAMEORIGIN`, `X-Permitted-Cross-Domain-Policies`) y limitadores de tasa ante fuerza bruta en `/auth/login` (30 req / 15 min) y `/api` (180 req / min).
|
||||
- **Script Unificado de Auditoría (`security_audit.bat`):** Ejecutable centralizado para correr los 5 controles en un solo comando.
|
||||
- **Documento Formal de Seguridad (`SECURITY_AUDIT_REPORT.md`):** Reporte ejecutivo de auditoría y controles continuos.
|
||||
|
||||
---
|
||||
|
||||
## [2.4.0] - 2026-09-19
|
||||
### Añadido
|
||||
- **Perfil Extendido de Usuarios y Tipificaciones de Documentos Oficiales (Argentina y Extranjeros):**
|
||||
- Incorporación en el modelo `User` de los campos: `first_name` (Nombre), `last_name` (Apellido), `email` (Email Institucional de acceso), `personal_email` (Email Personal/Particular de contacto), `phone` (Teléfono con código de área), `address` (Domicilio/Dirección), `document_type` (Tipo de documento) y `document_number` (Número de documento validado).
|
||||
- Catálogo formal de reglas de negocio en `backend/app/constants/document_types.py` con tipificaciones oficiales de Argentina y extranjeros (DNI, CUIL, Pasaporte Argentino, Pasaporte Extranjero, DNI Extranjero, Cédula de Identidad PFA, Cédula Mercosur, Libreta Cívica y Libreta de Enrolamiento).
|
||||
- Validaciones de formato, expresiones regulares, y verificación de unicidad de documento por tipo en endpoints `POST /api/v1/users` y `PUT /api/v1/users/<id>`.
|
||||
- Búsqueda dinámica en `/admin/users_list` habilitada para filtrar por número de documento o email personal/institucional además de nombre.
|
||||
- Rediseño del formulario `/admin/users/form` estructurado en secciones: Identidad y Documentación, Contacto y Domicilio (con Teléfono, Email Personal y Dirección), y Cuenta y Rol Institucional (con Email Institucional y Clave), permitiendo al Administrador General gestionar la totalidad de los datos.
|
||||
- Script de migración SQLite `backend/migrate_user_profile_fields.py` para agregar las columnas e inicializar nombres desglosados de los usuarios existentes.
|
||||
- **Co-Docencia y Cátedras Múltiples en Comisiones:**
|
||||
- Creación del modelo relacional `CommissionTeacher` (`commission_teachers`) para asociar múltiples docentes a una misma comisión con roles académicos diferenciados (`Titular`, `Adjunto`, `JTP`, `Ayudante`).
|
||||
- Nuevos endpoints en la API REST: `POST /api/v1/commissions/<id>/teachers` y `DELETE /api/v1/commissions/<id>/teachers/<user_id>`, junto con la serialización completa de cátedras en `GET /api/v1/commissions`.
|
||||
- Nueva tarjeta interactiva *"Equipo Docente / Cátedra"* en el detalle de la comisión (`/admin/commission_detail?id=...`) permitiendo agregar y desvincular docentes con sus respectivos roles.
|
||||
- Actualización de la grilla de comisiones (`/admin/commissions_list`) para exhibir a todos los profesores asignados con badges distintivos de rol.
|
||||
- **Eliminación y Gestión Segura de Asignaturas:**
|
||||
- Incorporación de botón y modal interactivo de confirmación de eliminación de asignaturas en `/admin/subjects_list`.
|
||||
- Implementación de regla de negocio de integridad académica: eliminación física si la materia no tiene comisiones asociadas, o desactivación automática si posee historial o cursadas vinculadas.
|
||||
|
||||
### Corregido
|
||||
- **Bug Visual en Modificación de Asignaturas (`/admin/subjects_list`):**
|
||||
- Solucionado el problema de modales recortados o cubiertos por el fondo oscuro (`modal-backdrop`), originado por la presencia de 206 modales inline dentro de celdas `<td>` en una tabla con `overflow` (`.table-responsive`). Se refactorizó hacia un único modal global `#modalEditSubject` instanciado fuera de la tabla e inicializado dinámicamente con JavaScript.
|
||||
### Añadido
|
||||
- **Monitoreo y Percentiles de Asignación de Aulas Físicas (Admin & Bedelía):**
|
||||
- Panel analítico *"Estado y Percentiles de Asignación de Aulas Físicas"* incorporado en los dashboards de Administrador (`/dashboard?role=admin`) y Bedelía (`/dashboard?role=bedelia`).
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# CHANGELOG MVP — Admin Edu-Space (UniCABA)
|
||||
|
||||
> **Nota de Unificación:** Este archivo documenta la progresión de los hitos y entregables de las Fases del [ROADMAP_MVP.md](./ROADMAP_MVP.md). Para el historial técnico exhaustivo conforme a *Keep a Changelog* y *Semantic Versioning*, consultar el archivo oficial unificado: **[CHANGELOG.md](./CHANGELOG.md)**.
|
||||
|
||||
---
|
||||
|
||||
## [Fase 5: Integración Auth, Email & Moodle Backend] — v3.0.0 (2026-09-23)
|
||||
**Estado:** ✅ 100% Completada
|
||||
|
||||
### Épica 1: Panel de Configuración Global (Perfil ADMIN)
|
||||
* **Cifrado Simétrico Fernet:** Creación de `CryptoService` (`backend/app/services/crypto_service.py`) derivando de forma determinística una clave Fernet de 32 bytes a partir de `SECRET_KEY`.
|
||||
* **Modelo `SystemSetting` Extendido:** Soporte para columnas `category` y flag `is_encrypted`. Almacenamiento seguro de secretos con métodos polimórficos `get_decrypted_value()` y enmascaramiento con `get_masked_value()`.
|
||||
* **Endpoints Administrativos:**
|
||||
- `GET /api/v1/admin/settings/all`: Retorna la configuración completa organizada por categorías (`smtp`, `auth_providers`, `google_oauth`, `moodle`) con secretos enmascarados.
|
||||
- `POST /api/v1/admin/settings/smtp`: Configuración dinámica de Host, Puerto, Usuario, Contraseña cifrada, Protocolo de Seguridad (TLS/SSL/NONE), y Remitente.
|
||||
- `POST /api/v1/admin/settings/smtp/test`: Handshake SMTP en vivo y envío opcional de correo de prueba con plantilla HTML oficial.
|
||||
- `POST /api/v1/admin/settings/auth-providers`: Activación y desactivación de proveedores de acceso (`local`, `google`, `moodle`).
|
||||
- `POST /api/v1/admin/settings/google-oauth`: Guardado de Client ID, Client Secret cifrado, Dominios autorizados y Callback URL.
|
||||
- `POST /api/v1/admin/settings/moodle`: URL base del servidor, Token Web Services cifrado, timeout y frecuencia de sincronización.
|
||||
* **Interfaz de Usuario Web:** Nueva pantalla responsiva `frontend/views/admin/settings/global_config.html` con pestañas navegables, validación en vivo, spinners y monitores de estado (100% adaptada para resoluciones desde 720p hasta 1080p).
|
||||
* **Navegación:** Integración de enlace directo *"Config. Global"* en el menú lateral bajo Administración en `frontend/views/base.html`.
|
||||
|
||||
### Épica 2: Infraestructura Asíncrona y Caché
|
||||
* **Capa de Caché Híbrida:** Creación de `CacheService` (`backend/app/services/cache_service.py`) con soporte nativo de Redis y fallback transparente en memoria con TTL para sesiones de usuario y perfiles de Moodle.
|
||||
* **Cola Persistente de Sincronización:** Modelo `MoodleSyncTask` (`backend/app/models/sync_task.py`) y servicio `MoodleQueueService` (`backend/app/services/moodle_queue_service.py`) para registrar operaciones de sincronización (`CREATE_USER`, `UPDATE_USER`, `ENROL_USER`, `UNENROL_USER`, `ASSIGN_ROLE`).
|
||||
* **Tolerancia a Fallos y DLQ:** Política de Exponential Backoff ($2^{\text{intentos}} \times 30$s) ante indisponibilidad de Moodle. Si se superan los intentos máximos, la tarea se traslada automáticamente a la **Dead Letter Queue (DLQ)** (`status = 'FAILED'`).
|
||||
* **Endpoints de Cola & DLQ:**
|
||||
- `GET /api/v1/admin/moodle/queue/stats`: Métricas en tiempo real (Pendientes, En Proceso, Reintentando, Completadas, Fallidas DLQ).
|
||||
- `GET /api/v1/admin/moodle/queue/tasks`: Lista paginada con filtrado por estado.
|
||||
- `POST /api/v1/admin/moodle/queue/process-now`: Disparo manual inmediato de sincronización.
|
||||
- `POST /api/v1/admin/moodle/queue/tasks/<id>/retry`: Reintento de tarea individual de DLQ.
|
||||
- `POST /api/v1/admin/moodle/queue/retry-all`: Reintento masivo de todas las tareas en DLQ.
|
||||
|
||||
### Épica 3: Sistema de Autenticación Híbrida (SSO & Local)
|
||||
* **Login Local Refactorizado:** Endpoint `/api/v1/auth/login` respeta la configuración global. Si `auth_local_enabled` está desactivado, sólo admite acceso de contingencia a usuarios con rol `ADMIN`.
|
||||
* **Google OAuth 2.0 (Arquitectura AlumnosLS):** Endpoint `POST /api/v1/auth/google` con validación estricta de dominios institucionales (`google_allowed_domains`), aprovisionamiento automático de perfil y emisión de tokens JWT.
|
||||
* **Moodle Delegated Login:** Endpoint `POST /api/v1/auth/moodle` validando contra `/login/token.php` de Moodle 4.1 (`moodle_mobile_app`), obtención de datos de usuario con Web Services, caché en Redis y emisión de JWT.
|
||||
* **Mapeo y Desacople de Roles:** Si el usuario es nuevo y posee rol de manager/admin en Moodle, se le asigna rol local `ADMIN`; en caso contrario se asigna `Docente` o `Alumno`. La administración y cambio de roles posterior permanece 100% local e independiente de Moodle.
|
||||
* **Frontend Login:** Vista `frontend/views/auth/login.html` adaptada para consultar `/api/v1/auth/providers` y renderizar dinámicamente el botón de Google Workspace, el botón desplegable de Moodle y el formulario tradicional.
|
||||
|
||||
### Épica 4: Integración Bidireccional Moodle 4.1 (Backend)
|
||||
* **Cliente REST Moodle 4.1:** `MoodleClient` (`backend/app/services/moodle_client.py`) con métodos para:
|
||||
- `core_user_create_users`, `core_user_update_users`, `core_user_get_users`.
|
||||
- `enrol_manual_enrol_users`, `enrol_manual_unenrol_users`, `core_enrol_get_enrolled_users`.
|
||||
- `core_role_assign_roles`, `core_role_unassign_roles`.
|
||||
- `test_connection()` contra `core_webservice_get_site_info`.
|
||||
* **Desacople en CRUD Local:** Creación y edición de usuarios en `backend/app/routes/api/admin.py` encola automáticamente la sincronización sin bloquear las peticiones locales.
|
||||
|
||||
### Épica 5: Motor de Notificaciones (Email Server)
|
||||
* **Cliente SMTP Dinámico:** Creación de `EmailService` (`backend/app/services/email_service.py`) que obtiene credenciales en tiempo de ejecución de la base de datos (con desencriptación de contraseña al vuelo).
|
||||
* **Plantillas Transaccionales Profesionales:**
|
||||
- `notify_teacher_assignment`: Notificación a profesores sobre asignación a comisiones y horarios.
|
||||
- `notify_exam_schedule`: Convocatoria y citación a mesas de examen final.
|
||||
- `test_smtp_connection`: Verificación de handshake y autenticación SMTP con feedback claro.
|
||||
|
||||
### Cobertura de Pruebas
|
||||
* Suite completa en `backend/tests/test_phase6_integration.py` con 7 pruebas unitarias e integrales que validan cifrado Fernet, SystemSetting, CacheService, MoodleQueueService (Backoff & DLQ), EmailService dinámico, y autenticación híbrida (Google & Moodle). **30/30 pruebas pasando en verde** en la suite global de regresión.
|
||||
|
||||
---
|
||||
|
||||
## [Fase 4: UI/UX Avanzada para Bedelía & Modo Impersonación] — v2.8.0 (2026-09-23)
|
||||
**Estado:** ✅ 100% Completada
|
||||
|
||||
### Estadio 4.1 — Grilla Semanal Interactiva (Drag-and-Drop) con Detección de Colisiones
|
||||
* **FullCalendar Interactivo:** Habilitación de `editable: true` y `droppable: true` en el calendario general de Bedelía (`/schedule/calendar`).
|
||||
* **Sidebar de Comisiones Pendientes:** Panel lateral dinámico con catálogo de comisiones que aún no tienen aula asignada (`unassigned_commissions`), permitiendo arrastrarlas directamente hacia los bloques horarios del calendario.
|
||||
* **Validación de Choques en Tiempo Real:** Integración con `POST /api/v1/reservations/drag-update`. Al mover o soltar una clase, el backend somete la nueva posición a la matriz integral de conflictos (`ReservationService.check_full_conflicts_matrix`):
|
||||
- Conflicto físico de aula (doble asignación simultánea).
|
||||
- Conflicto docente (profesor ocupado en otra aula).
|
||||
- Conflicto de aforo (capacidad de aula insuficiente).
|
||||
* **Manejo de Respuestas:** Si hay colisión, devuelve HTTP 409 (`status: conflict`), FullCalendar revierte automáticamente el movimiento (`info.revert()`) y se dispara un Toast de alerta detallando el motivo del bloqueo. Si está libre, persiste la reserva y confirma con Toast de éxito.
|
||||
|
||||
### Estadio 4.2 — Modo Impersonación del Superadmin con Trazabilidad
|
||||
* **Cabecera `X-Impersonate-User`:** Inyección y soporte en `backend/app/utils/jwt_decorators.py` dentro de `jwt_required` para usuarios administradores. Asigna `g.jwt_user` al usuario objetivo preservando `g.real_admin_user` y marcando `g.is_impersonating = True`.
|
||||
* **Endpoints de Control:** `POST /api/v1/admin/impersonate` y `POST /api/v1/admin/stop-impersonating` en Backend Flask con auditoría inmutable en `AuditLog` (`IMPERSONATE_START` e `IMPERSONATE_END`).
|
||||
* **Integración en BFF Node.js:** Middleware `authMiddleware.js` y proxy `/api` en `app.js` configurados para propagar cookies seguras `impersonate_user_id` e inyectar `X-Impersonate-User` en todas las peticiones a la API Flask.
|
||||
* **Banner Visual Institucional:** Banner de advertencia permanente en `base.html` con fondo de alerta ámbar, spinner de estado, identificación del usuario y rol adoptado, y botón de salida inmediata.
|
||||
* **Acceso de un Clic:** Botón directo de impersonación (`bi-incognito`) en cada fila de usuario activo en `/admin/users_list`.
|
||||
|
||||
### Estadio 4.3 — Acople del Optimizador Genético a Datos y Turnos Reales
|
||||
* **Turnos Académicos Reales:** `ReservationOptimizer._calculate_preferred_time` sincronizado con las franjas horarias de UniCABA (`Mañana`: 08:00 a 12:00, `Tarde`: 14:00 a 18:00, `Vespertino`/`Noche`: 18:30 a 22:30).
|
||||
* **Persistencia Segura:** Endpoint `POST /api/genetic/apply-optimization` adaptado con parseo riguroso de strings ISO y prevención de colisiones físicas en guardado masivo.
|
||||
|
||||
### Cobertura de Pruebas
|
||||
* Suite completa en `backend/tests/test_phase4_interactive_impersonation.py` validando los 6 criterios de aceptación (impersonación de docente, impersonación de alumno, trazabilidad en auditoría, rechazo a no-admins, bloqueo 409 por colisión en drag-drop y actualización exitosa 200). 18/18 tests aprobados en suite global de Fases 2, 3 y 4.
|
||||
|
||||
---
|
||||
|
||||
## [Fase 3: Gestión de Hitos Evaluativos y Libro de Calificaciones] — v2.7.0 (2026-09-23)
|
||||
**Estado:** ✅ 100% Completada
|
||||
|
||||
### Estadio 3.1 — Calendario de Evaluación por Comisión y Notificaciones Preventivas
|
||||
* **Modelo `MilestoneGrade`:** Tabla relacional `milestone_grades` para registrar calificaciones por estudiante y reserva evaluativa, con validación de unicidad (`student_id`, `reservation_id`), notas en escala 1.0 a 10.0, estado de inasistencia (`is_absent`) y devoluciones pedagógicas (`feedback`).
|
||||
* **Consulta de Hitos del Alumno:** Endpoint `GET /api/v1/students/my-upcoming-milestones` que detecta dinámicamente exámenes parciales, recuperatorios y entregas de TP de las comisiones del estudiante con cálculo automático de días restantes (`days_remaining`).
|
||||
* **Dashboard del Alumno:** Widget interactivo de próximos exámenes en `/dashboard?role=alumno` con badges visuales de urgencia (*¡Hoy!*, *¡Mañana!*, *Faltan X días*) y acceso directo desde el Launchpad a *"Mis Calificaciones"*.
|
||||
|
||||
### Estadio 3.2 — Libro de Calificaciones (*Gradebook*) y Cierre Formal de Actas
|
||||
* **Extensiones en `Commission`:** Incorporación de columnas `grades_closed` (boolean), `grades_closed_at` (datetime), `grades_closed_by` (foreign key) y `acta_number` (identificador oficial generado).
|
||||
* **Servicio `GradebookService`:** Motor centralizado para matriz de notas por comisión, guardado asíncrono individual y por lote (`bulk_save_grades`), cálculo dinámico de promedios ponderados y condiciones académicas en tiempo real (*Promocionado* ≥ 7, *Regular* ≥ 4, *Libre* < 4).
|
||||
* **Cierre Formal de Actas:** Sellado institucional con código único (`ACTA-YYYY-Sem-ID`), auditoría inmutable en `audit_logs` con acción `CLOSE_ACTA` y bloqueo total contra modificaciones posteriores.
|
||||
* **Reapertura Excepcional:** Endpoint para Bedelía/Administración (`POST /api/v1/commissions/<id>/gradebook/reopen`) con justificación administrativa obligatoria asentada en auditoría.
|
||||
* **Interfaz Docente & Bedelía (`/schedule/gradebook/:id`):** Matriz interactiva de carga rápida con autoguardado asíncrono debounced (500ms), indicador visual flotante de sincronización, recálculo instantáneo de promedios en el cliente y modales de sellado y reapertura de actas.
|
||||
* **Portal del Alumno (`/schedule/my_grades` / `/mis-materias/mis-notas`):** Vista consolidada de todas las materias del estudiante, notas de parciales, condición final y número de acta emitida, con soporte de impresión de certificado de regularidad.
|
||||
* **Acceso Directo en Comisión:** Botón destacado *"Libro de Calificaciones (Actas)"* y badge de acta sellada incorporados en `/admin/commission_detail`.
|
||||
|
||||
### Cobertura de Pruebas
|
||||
* Suite completa en `backend/tests/test_phase3_gradebook.py` validando los 6 criterios de aceptación: estructura de matriz bidimensional, persistencia y autoguardado de notas, validación estricta de notas inválidas (fuera del rango 1-10), cierre formal de acta e inmutabilidad estricta ante intentos de edición, consulta integral del alumno y cuenta regresiva de próximos hitos. 6/6 tests aprobados (12/12 en conjunto con Fase 2).
|
||||
|
||||
---
|
||||
|
||||
## [Fase 2: Co-docencia, Matriz de Conflictos y Regla Diaria] — v2.6.0 (2026-09-19)
|
||||
**Estado:** ✅ 100% Completada
|
||||
|
||||
### Estadio 2.1 — Modelo y Gestión Integral de Co-docencia
|
||||
* **Modelo `CommissionTeacher`:** Soporte para múltiples docentes por comisión en base de datos (`commission_teachers`), admitiendo roles académicos diferenciados (`Titular`, `Adjunto`, `JTP`, `Ayudante`) y flag `is_primary`.
|
||||
* **API REST de Cátedras:** Endpoints `POST /api/v1/commissions/<id>/teachers` y `DELETE /api/v1/commissions/<id>/teachers/<user_id>` con serialización completa de cátedra en comisiones.
|
||||
* **Interfaz de Gestión:** Tarjeta interactiva *"Equipo Docente / Cátedra"* en `/admin/commission_detail` para incorporar y desvincular profesores con rol dinámico, y visualización de badges en `/admin/commissions_list`.
|
||||
|
||||
### Estadio 2.2 — Matriz de Conflictos y Validación Integral
|
||||
* **Conflicto Físico:** Bloqueo estricto e infranqueable de superposiciones de reservas en la misma aula física (`ReservationRepository.find_overlapping`).
|
||||
* **Conflicto Docente:** Detección de doble asignación simultánea para un mismo docente en diferentes aulas o formatos.
|
||||
* **Excepción Justificada por Co-docencia:** Si un docente asignado presenta solapamiento pero la comisión cuenta con co-docentes registrados en `CommissionTeacher`, la matriz de validación autoriza la reserva validando la cobertura del equipo de cátedra.
|
||||
* **Conflicto de Aforo:** Bloqueo estricto cuando `expected_attendees > classroom.capacity` en espacios físicos.
|
||||
* **Endpoint de Pre-validación:** `POST /api/v1/reservations/check-conflicts` para validación asíncrona de conflictos físicos, docentes y de aforo antes del commit.
|
||||
|
||||
### Estadio 2.3 — Regla de Restricción Diaria del Alumno y Motor de Excepciones
|
||||
* **Servicio `EnrollmentService`:** Lógica de matriculación académica con análisis de días de cursada por comisión y verificación de cupos.
|
||||
* **Regla Restrictiva:** Un alumno no puede cursar dos asignaturas regulares el mismo día de la semana.
|
||||
* **Excepción Automática por Curso Corto:** Atributo `is_short_course` en modelo `Subject`; si una de las materias es un taller o curso corto, el sistema autoriza la cursada simultánea en el mismo día.
|
||||
* **Excepción Expresa de Bedelía:** Campos `allow_same_day_exception` y `exception_reason` en `StudentEnrollment`. La oficina de Bedelía puede autorizar la matriculación simultánea mediante switch justificado en la interfaz.
|
||||
* **Endpoints de Matrícula:** `GET/POST /api/v1/commissions/<id>/enrollments`, `DELETE/PUT /api/v1/commissions/<id>/enrollments/<student_id>`.
|
||||
* **UI en Detalle de Comisión:** Formulario de matriculación con toggle de autorización de Bedelía, motivo de excepción y tabla de inscriptos con badges de estado.
|
||||
|
||||
### Cobertura de Pruebas
|
||||
* Suite completa en `backend/tests/test_phase2_rules.py` validando los 6 criterios de aceptación (conflicto físico, aforo, co-docencia, restricción diaria, bypass de curso corto y bypass de Bedelía). 6/6 tests aprobados con 100% de éxito.
|
||||
|
||||
### Complementos y Seguridad Transversal
|
||||
* **Dashboard de Bedelía:** Integración de paneles en tiempo real para análisis de **Comisiones Sin Reserva** y grilla de **Aulas Disponibles** (Filtros Semana/Mes) conectando frontend UI con el servicio analítico backend.
|
||||
* **Auditoría Automatizada (Fase 1):** Implantación de un pipeline de seguridad local/Gitea que aísla dependencias de desarrollo (`requirements-dev.txt`) y ejecuta controles SAST, DAST y SCA (Bandit, pip-audit, njsscan y Schemathesis) sin vulnerabilidades detectadas.
|
||||
* **Optimizador Genético de Reservas:** Reparación de enrutamiento 404, migración a JWT (`@jwt_required`), corrección de UI de métricas dinámicas de algoritmo, y refactorización de layout para superposición de herramientas.
|
||||
* **UI/UX General:** Ajustes de CSS en modo claro (soporte dinámico de contraste de isologotipo blanco a rosa institucional mediante filtros) y traducciones de menús de usuario en `base.html`.
|
||||
---
|
||||
|
||||
## [Fase 1: Paridad Legacy y Estabilización] — v2.5.0 / v2.4.0 (2026-09-19)
|
||||
**Estado:** ✅ 100% Completada
|
||||
* Seguridad automatizada SAST/DAST/SCA (Bandit, pip-audit, schemathesis, njsscan, npm audit).
|
||||
* Tipificaciones de documentos argentinos y extranjeros, perfiles extendidos de usuarios con email personal e institucional.
|
||||
* Gestión completa de aulas físicas vs virtuales con percentiles de asignación y métricas de ocupación real.
|
||||
* Reingeniería del modal de edición y eliminación segura de asignaturas.
|
||||
|
||||
---
|
||||
|
||||
## [Fase 0: Desacople y Arquitectura Base] — v2.0.0 (2026-09-18)
|
||||
**Estado:** ✅ 100% Completada
|
||||
* Desacople arquitectónico completo: Backend Flask RESTful (`/api/v1/`) + Frontend BFF Node.js/Express con Nunjucks.
|
||||
* Autenticación basada en tokens JWT con sincronización de estado de sesión.
|
||||
@@ -1,9 +1,27 @@
|
||||
# Panel de Administración Edu-Space (UniCABA)
|
||||
|
||||
Plataforma integral de gestión áulica, asignación de espacios, control de horarios y reservas académicas para la **Universidad de la Ciudad de Buenos Aires (UniCABA)**.
|
||||
Plataforma integral de gestión áulica, asignación de espacios, control de horarios, seguimiento académico y reservas para la **Universidad de la Ciudad de Buenos Aires (UniCABA)**.
|
||||
|
||||
El proyecto se basa en una arquitectura **desacoplada (Frontend / Backend)** en formato monorepo.
|
||||
|
||||
**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)
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Estado del Roadmap MVP
|
||||
|
||||
```
|
||||
Fase 0: Desacople y Arquitectura Base [████████████████████] 100% (Completado)
|
||||
Fase 1: Paridad Legacy y Estabilización [████████████████████] 100% (Completado)
|
||||
Fase 2: Co-docencia y Matriz Conflictos [████████████████████] 100% (Completado)
|
||||
Fase 3: Hitos Evaluativos y Calificaciones[████████████████████] 100% (Completado)
|
||||
Fase 4: UI/UX Drag & Drop e Impersonación[████████████████████] 100% (Completado)
|
||||
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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏛️ Sede Institucional y Ubicaciones
|
||||
@@ -14,35 +32,60 @@ El proyecto se basa en una arquitectura **desacoplada (Frontend / Backend)** en
|
||||
|
||||
## 🏗️ 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
|
||||
/
|
||||
├── backend/ # API REST Backend (Python / Flask)
|
||||
│ ├── app/ # Lógica de negocio, base de datos y endpoints
|
||||
│ ├── tests/ # Pruebas unitarias e integración
|
||||
│ └── requirements.txt # Dependencias de Python
|
||||
├── backend/ # Capa Core API (Python 3.10+ / Flask / SQLAlchemy)
|
||||
│ ├── app/ # Lógica de negocio y arquitectura desacoplada
|
||||
│ │ ├── models/ # Modelos ORM (User, Role, Classroom, Reservation, etc.)
|
||||
│ │ ├── 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)
|
||||
│ ├── src/ # Controladores, middleware, servicios (Axios)
|
||||
│ ├── views/ # Plantillas Nunjucks (.html)
|
||||
│ ├── public/ # Archivos estáticos (CSS, JS cliente)
|
||||
│ └── package.json # Dependencias de Node.js
|
||||
├── frontend/ # Backend-For-Frontend BFF (Node.js 20 LTS / Express)
|
||||
│ ├── src/ # Capa servidora de mediación web
|
||||
│ │ ├── app.js # Servidor Express, seguridad Helmet, rate-limiting
|
||||
│ │ ├── controllers/ # Controladores de orquestación y mediación
|
||||
│ │ ├── 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
|
||||
└── unicaba_prerequitos/ # Requisitos académicos y de negocio
|
||||
├── install.sh # Instalador nativo unificado para Linux / Proxmox LXC
|
||||
├── 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**.
|
||||
- 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.
|
||||
|
||||
### 🟢 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**.
|
||||
- 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**).
|
||||
|
||||
---
|
||||
|
||||
@@ -59,15 +102,76 @@ 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:
|
||||
* **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.
|
||||
* **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.
|
||||
* **Enlaces de Videollamada Automáticos:** Generación de enlaces seguros (Google Meet / Teams) para acceso instantáneo de docentes y estudiantes.
|
||||
* **Filtros en Cartelera:** Opciones para filtrar por turno, piso u ocultar aulas virtuales mediante el switch dedicado en `/schedule/today_schedule`.
|
||||
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:
|
||||
|
||||
```
|
||||
[ 1. Creación / Edición de Aula ] ──► [ 2. Asignación de Comisión al Aula ] ──► [ 3. Cartelera y Grilla ]
|
||||
- Sede Central o Campus Virtual - Desde Comisiones (1 clic) - Detección de colisiones
|
||||
- Piso y Aforo real - O desde Ficha de Aula - Drag & Drop semanal
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Capacidades Avanzadas de Gestión (Fases 2, 3 y 4 del MVP)
|
||||
|
||||
### 1. Grilla Semanal Interactiva (Drag & Drop) y Validación de Colisiones en Tiempo Real
|
||||
- **FullCalendar Interactivo:** Arrastre y edición de horarios directo en el calendario general de Bedelía (`/schedule/calendar`).
|
||||
- **Sidebar de Comisiones por Asignar:** Panel lateral colapsable con las comisiones sin aula, con arrastre fluido hacia la grilla semanal o diaria.
|
||||
- **Detección Automática de Choques (HTTP 409):** Validación previa ante cada movimiento (`POST /api/v1/reservations/drag-update`) verificando colisiones físicas de aula, superposición docente y límite de aforo. Si hay conflicto, el evento se revierte automáticamente (`info.revert()`) y se notifica al usuario con un Toast explicativo.
|
||||
|
||||
### 2. Modo Impersonación del Superadmin ("Login as")
|
||||
- Permite al Administrador navegar la plataforma adoptando exactamente la perspectiva y permisos de cualquier docente o estudiante.
|
||||
- Activación con un clic (`bi-incognito`) desde el catálogo de usuarios (`/admin/users_list`).
|
||||
- Banner superior permanente de advertencia visual en color ámbar con identificación del usuario adoptado y botón de salida inmediata.
|
||||
- Trazabilidad y auditoría obligatoria inmutable en `audit_logs` (`IMPERSONATE_START` e `IMPERSONATE_END`).
|
||||
|
||||
### 3. Libro de Calificaciones (*Gradebook*) & Cierre Formal de Actas
|
||||
- Matriz bidimensional interactiva de calificaciones por comisión (`/schedule/gradebook/:id`) con autoguardado asíncrono debounced (500 ms).
|
||||
- Cálculo en vivo de promedios ponderados y condiciones académicas (*Promocionado*, *Regular*, *Libre*).
|
||||
- Cierre formal de actas con generación de código inmutable (`ACTA-YYYY-Sem-ID`), auditoría en `audit_logs` y congelamiento de modificaciones.
|
||||
- Soporte para reapertura excepcional justificada por Bedelía.
|
||||
- Portal del Estudiante (`/schedule/my_grades` / `/mis-materias/mis-notas`) para consulta de calificaciones y comprobantes.
|
||||
|
||||
### 4. Co-docencia, Matriz de Conflictos y Regla Diaria
|
||||
- Soporte de cátedras con múltiples docentes por comisión (`CommissionTeacher`).
|
||||
- Excepción por co-docencia: autoriza superposiciones de horario docente si la cátedra cuenta con un co-docente registrado disponible.
|
||||
- Regla restrictiva de cursada diaria: un alumno no puede cursar dos asignaturas regulares el mismo día, con excepciones automáticas para talleres/cursos cortos (`is_short_course`) o autorizaciones especiales emitidas por Bedelía.
|
||||
|
||||
---
|
||||
|
||||
@@ -86,7 +190,7 @@ El sistema incluye sincronización automatizada con las planillas oficiales de c
|
||||
### Requisitos previos
|
||||
- Node.js (v18 o superior)
|
||||
- Python 3.10+
|
||||
- PostgreSQL / SQLite
|
||||
- SQLite / PostgreSQL
|
||||
|
||||
### 1. Iniciar el Backend (Flask)
|
||||
```bash
|
||||
@@ -110,10 +214,60 @@ 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
|
||||
|
||||
Para ejecutar la suite completa de 47 pruebas unitarias y de integración (JWT, ABMs, Reglas, Optimizador, Calificaciones y Moodle):
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
$env:PYTHONPATH="backend"
|
||||
.\venv\Scripts\python.exe -m pytest tests -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentación Técnica & Changelogs
|
||||
* [Estado de la Migración y Arquitectura Objetivo UniCABA](ESTADO_MIGRACION_Y_ARQUITECTURA_OBJETIVO.md)
|
||||
* [Roadmap MVP: Fases y Estadios del Proyecto](ROADMAP_MVP.md)
|
||||
* [Changelog Oficial del Proyecto](CHANGELOG.md)
|
||||
* [Changelog Oficial del Proyecto (v2.8.0)](CHANGELOG.md)
|
||||
* [Changelog MVP Consolidado](CHANGELOG_MVP.md)
|
||||
* [Estado de la Migración y Arquitectura Objetivo UniCABA](ESTADO_MIGRACION_Y_ARQUITECTURA_OBJETIVO.md)
|
||||
* [Changelog Detallado: Clases Virtuales y Dashboards](docs/CHANGELOG_VIRTUAL_CLASSES_AND_DASHBOARDS.md)
|
||||
* [Auditoría y Sesión de Reingeniería](docs/CHANGELOG_SESSION.md)
|
||||
* [Guía de Integración de Cronograma](docs/SCHEDULE_INTEGRATION_GUIDE.md)
|
||||
+91
-34
@@ -2,9 +2,9 @@
|
||||
## Gestión de Espacios Áulicos, Actividad de Cursada y Seguimiento Académico
|
||||
|
||||
**Institución:** Universidad de la Ciudad de Buenos Aires (UniCABA)
|
||||
**Versión:** 2.1.0
|
||||
**Fecha de Actualización:** 19 de Septiembre de 2026
|
||||
**Rama Base:** `testing`
|
||||
**Versión:** 3.1.0
|
||||
**Fecha de Actualización:** 23 de Septiembre de 2026
|
||||
**Rama Base:** `mvp`
|
||||
|
||||
---
|
||||
|
||||
@@ -18,10 +18,12 @@ Construir y evolucionar la plataforma de gestión académica y espacial de **Uni
|
||||
```
|
||||
Fase 0: Desacople y Arquitectura Base [████████████████████] 100% (Completado)
|
||||
Fase 1: Paridad Legacy y Estabilización [████████████████████] 100% (Completado)
|
||||
Fase 2: Co-docencia y Matriz Conflictos [░░░░░░░░░░░░░░░░░░░░] 0% (Próxima)
|
||||
Fase 3: Hitos Evaluativos y Calificaciones[░░░░░░░░░░░░░░░░░░░░] 0% (Planificada)
|
||||
Fase 4: UI/UX Drag & Drop e Impersonación[░░░░░░░░░░░░░░░░░░░░] 0% (Planificada)
|
||||
Fase 5: Despliegue Producción e Integrac.[░░░░░░░░░░░░░░░░░░░░] 0% (Planificada)
|
||||
Fase 2: Co-docencia y Matriz Conflictos [████████████████████] 100% (Completado)
|
||||
Fase 3: Hitos Evaluativos y Calificaciones[████████████████████] 100% (Completado)
|
||||
Fase 4: UI/UX Drag & Drop e Impersonación[████████████████████] 100% (Completado)
|
||||
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)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -66,60 +68,115 @@ Fase 5: Despliegue Producción e Integrac.[░░░░░░░░░░░░
|
||||
|
||||
---
|
||||
|
||||
### Fase 2: Co-docencia, Matriz de Conflictos y Regla Diaria ⏳ *(En curso / Inmediata)*
|
||||
### Fase 2: Co-docencia, Matriz de Conflictos y Regla Diaria ✅ *(100% Completado)*
|
||||
* **Objetivo:** Implementar las reglas de negocio académicas complejas para asignación de cátedras y cursada de alumnos.
|
||||
* **Estadios:**
|
||||
1. **Estadio 2.1 — Modelo de Co-docencia:**
|
||||
- [ ] Tabla relacional `commission_teachers` para soportar múltiples docentes por comisión (titulares, adjuntos, ayudantes).
|
||||
- [ ] Endpoints `/api/v1/commissions/<id>/teachers` para asociar y desvincular docentes de cátedra.
|
||||
- [ ] Adaptación de vistas de Bedelía y Comisiones para visualizar el equipo docente completo.
|
||||
- [x] Tabla relacional `commission_teachers` para soportar múltiples docentes por comisión (titulares, adjuntos, ayudantes).
|
||||
- [x] Endpoints `/api/v1/commissions/<id>/teachers` para asociar y desvincular docentes de cátedra.
|
||||
- [x] Adaptación de vistas de Bedelía y Comisiones para visualizar el equipo docente completo.
|
||||
2. **Estadio 2.2 — Matriz de Conflictos y Validación:**
|
||||
- [ ] Validación estricta de conflicto físico: bloqueo de doble asignación de aula en mismo día y horario.
|
||||
- [ ] Validación de conflicto docente: detección de superposición horaria del profesor, con soporte de excepción justificada cuando la comisión cuenta con co-docencia.
|
||||
- [ ] Validación de aforo: bloqueo cuando la cantidad de alumnos excede la capacidad del aula física.
|
||||
- [x] Validación estricta de conflicto físico: bloqueo de doble asignación de aula en mismo día y horario.
|
||||
- [x] Validación de conflicto docente: detección de superposición horaria del profesor, con soporte de excepción justificada cuando la comisión cuenta con co-docencia.
|
||||
- [x] Validación de aforo: bloqueo cuando la cantidad de alumnos excede la capacidad del aula física.
|
||||
3. **Estadio 2.3 — Regla de Restricción Diaria del Alumno:**
|
||||
- [ ] Validación al matricular: un alumno no puede cursar dos asignaturas regulares en el mismo día calendario.
|
||||
- [ ] Excepción parametrizable: autorización automática si al menos una de las materias es un "curso corto" o si existe autorización manual de Bedelía (`allow_same_day_exception`).
|
||||
- [x] Validación al matricular: un alumno no puede cursar dos asignaturas regulares en el mismo día calendario.
|
||||
- [x] Excepción parametrizable: autorización automática si al menos una de las materias es un "curso corto" (`is_short_course`) o si existe autorización manual de Bedelía (`allow_same_day_exception`).
|
||||
|
||||
---
|
||||
|
||||
### Fase 3: Gestión de Hitos Evaluativos y Libro de Calificaciones 📅 *(Planificada)*
|
||||
### Fase 3: Gestión de Hitos Evaluativos y Libro de Calificaciones 📅 *(Completada)*
|
||||
* **Objetivo:** Proveer al cuerpo docente y estudiantil el seguimiento completo de instancias evaluativas.
|
||||
* **Estadios:**
|
||||
1. **Estadio 3.1 — Calendario de Evaluación por Comisión:**
|
||||
- [ ] Programación de parciales, recuperatorios, entregas de trabajos prácticos y exámenes finales.
|
||||
- [ ] Notificaciones preventivas en el dashboard del alumno con cuenta regresiva para exámenes.
|
||||
- [x] Programación de parciales, recuperatorios, entregas de trabajos prácticos y exámenes finales.
|
||||
- [x] Notificaciones preventivas en el dashboard del alumno con cuenta regresiva para exámenes.
|
||||
2. **Estadio 3.2 — Libro de Calificaciones (*Gradebook*):**
|
||||
- [ ] Matriz de carga rápida de notas para docentes con autoguardado asíncrono.
|
||||
- [ ] Consulta individualizada de calificaciones y retroalimentaciones pedagógicas para alumnos.
|
||||
- [ ] Cierre de actas de regularidad y promoción para Bedelía.
|
||||
- [x] Matriz de carga rápida de notas para docentes con autoguardado asíncrono.
|
||||
- [x] Consulta individualizada de calificaciones y retroalimentaciones pedagógicas para alumnos.
|
||||
- [x] Cierre de actas de regularidad y promoción para Bedelía.
|
||||
|
||||
---
|
||||
|
||||
### Fase 4: UI/UX Avanzada para Bedelía & Modo Impersonación 🖥️ *(Planificada)*
|
||||
### Fase 4: UI/UX Avanzada para Bedelía & Modo Impersonación 🖥️ ✅ *(100% Completado)*
|
||||
* **Objetivo:** Dotar a la oficina de Bedelía y a la Administración de herramientas interactivas de última generación.
|
||||
* **Estadios:**
|
||||
1. **Estadio 4.1 — Grilla Semanal Interactiva (Drag-and-Drop):**
|
||||
- [ ] Interfaz tipo TimeGrid interactiva con arrastre de comisiones para asignación rápida de aulas y turnos.
|
||||
- [ ] Detección visual en tiempo real de aulas ocupadas o incompatibles.
|
||||
- [x] Interfaz tipo TimeGrid interactiva con arrastre de comisiones para asignación rápida de aulas y turnos (`FullCalendar` `editable: true` + `droppable: true`).
|
||||
- [x] Panel lateral de comisiones pendientes de aula (`unassigned_commissions`) con arrastre directo hacia la grilla.
|
||||
- [x] Detección visual en tiempo real de aulas ocupadas, colisiones horarias, aforo insuficiente o choques de docentes con reversión automática y mensajes de alerta descriptivos vía `POST /api/v1/reservations/drag-update`.
|
||||
2. **Estadio 4.2 — Modo Impersonación del Superadmin:**
|
||||
- [ ] Funcionalidad de *login as* o vista previa con cabecera `X-Impersonate-User` para replicar exactamente lo que ve cualquier docente o alumno.
|
||||
- [ ] Trazabilidad obligatoria de acciones bajo impersonación en el registro de auditoría (`audit_logs`).
|
||||
- [x] Funcionalidad de *login as* con soporte para cabecera `X-Impersonate-User` en Backend Flask (`jwt_required`) y Frontend BFF (`authMiddleware`).
|
||||
- [x] Botón directo de impersonación en la lista administrativa de usuarios (`admin/users/list.html`).
|
||||
- [x] Banner de advertencia visual permanente en la barra superior con indicador de usuario impersonado y botón de salida inmediata hacia la sesión administrativa.
|
||||
- [x] Trazabilidad obligatoria y estricta en el registro de auditoría (`audit_logs`) con eventos `IMPERSONATE_START` e `IMPERSONATE_END`.
|
||||
3. **Estadio 4.3 — Integración del Optimizador Genético:**
|
||||
- [ ] Acople del motor predictivo de algoritmos genéticos a la base de datos real para sugerir redistribución óptima de aulas según aforo.
|
||||
- [x] Acople del motor heurístico de algoritmos genéticos a la base de datos real con cálculo de turnos reales (Mañana, Tarde, Vespertino).
|
||||
- [x] Prevención de choques en guardado masivo con persistencia directa en base de datos (`POST /api/genetic/apply-optimization`).
|
||||
|
||||
---
|
||||
|
||||
### Fase 5: Producción Institucional, CI/CD e Integraciones 🏢 *(Planificada)*
|
||||
### Fase 5: Integración Auth, Email & Moodle Backend 🛡️ ✉️ 🎓 ✅ *(100% Completado)*
|
||||
* **Objetivo:** Centralizar la gestión de credenciales, autenticación híbrida, motor transaccional de correo y sincronización bidireccional asíncrona con Moodle 4.1 con tolerancia total a fallos.
|
||||
* **Épicas Entregadas:**
|
||||
1. **Épica 1 — Panel de Configuración Global (Perfil ADMIN):**
|
||||
- [x] Módulo SMTP: UI/API dinámica con host, puerto, usuario, seguridad (TLS/SSL/NONE) y contraseña cifrada con Fernet (`/admin/settings`).
|
||||
- [x] Módulo SSO: Toggles para habilitar/deshabilitar Login Local, Google OAuth 2.0 y Moodle SSO en base de datos.
|
||||
- [x] Credenciales OAuth & Moodle: Client ID/Secret de Google y URL de Servidor / Token Web Services de Moodle 4.1.
|
||||
2. **Épica 2 — Infraestructura Asíncrona y Caché:**
|
||||
- [x] Capa de Caché Híbrida: `CacheService` con integración Redis y fallback en memoria/TTL.
|
||||
- [x] Cola Persistente `MoodleSyncTask` con reintentos exponenciales y Dead Letter Queue (DLQ).
|
||||
- [x] Panel de monitorización de cola con métricas en tiempo real, botón de "Forzar Sincronización Inmediata" y reintento masivo de tareas fallidas.
|
||||
3. **Épica 3 — Autenticación Híbrida (SSO & Local Tolerante a Fallos):**
|
||||
- [x] Login Local: Fallback con contraseña nativa y contingencia para ADMIN cuando está desactivado para usuarios generales.
|
||||
- [x] Google OAuth 2.0: Restricción arquitectónica de dominios (`@unicaba.edu.ar`, `@lasalle.edu.ar`) inspirada en AlumnosLS.
|
||||
- [x] Moodle Delegated Login: Autenticación contra `/login/token.php` de Moodle 4.1 con mapeo inicial de roles (Admin/Docente/Alumno) y desacople local para cambios posteriores por Bedelía/Admin.
|
||||
4. **Épica 4 — Adaptadores Moodle 4.1 (Bidireccional):**
|
||||
- [x] Cliente REST Moodle 4.1 (`moodle_client`) con soporte para `core_user_create_users`, `core_user_update_users`, `core_user_get_users`, `enrol_manual_enrol_users`, `enrol_manual_unenrol_users`, `core_enrol_get_enrolled_users`, `core_role_assign_roles`, `core_role_unassign_roles`.
|
||||
- [x] Encolado automático y no bloqueante al crear o editar usuarios en Edu-Space.
|
||||
5. **Épica 5 — Motor de Notificaciones (Email Dinámico):**
|
||||
- [x] Transporte SMTP dinámico (`email_service`) instanciado en tiempo de ejecución leyendo credenciales descifradas de la BD.
|
||||
- [x] Notificaciones transaccionales automáticas para profesores (asignación de comisiones, cronogramas de exámenes y alertas de sistema).
|
||||
|
||||
---
|
||||
|
||||
### 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)*
|
||||
* **Objetivo:** Puesta en marcha definitiva en la infraestructura de servidores de UniCABA.
|
||||
* **Estadios:**
|
||||
1. **Estadio 5.1 — Despliegue e Infraestructura:**
|
||||
1. **Estadio 6.1 — Despliegue e Infraestructura:**
|
||||
- [ ] `docker-compose.yml` productivo orquestando Flask (Gunicorn), Node.js BFF (PM2) y PostgreSQL 15.
|
||||
- [ ] Proxy inverso Nginx con certificados SSL/TLS y compresión Brotli/Gzip.
|
||||
2. **Estadio 5.2 — Integraciones Universitarias:**
|
||||
- [ ] Módulo de exportación/importación compatible con SIU Guaraní 3.
|
||||
- [ ] Sincronización automática de aulas virtuales con Moodle institucional.
|
||||
3. **Estadio 5.3 — Pruebas de Carga y Hardening:**
|
||||
2. **Estadio 6.2 — Sincronización Continua Moodle:**
|
||||
- [x] Sincronización automática de aulas virtuales y usuarios con Moodle 4.1 institucional (`10.0.0.207`).
|
||||
- [ ] Automatización de workers persistentes (systemd / supervisor) para el procesamiento continuo de la cola.
|
||||
3. **Estadio 6.3 — Pruebas de Carga y Hardening:**
|
||||
- [ ] Pruebas de estrés de concurrencia en días pico de inicio de cuatrimestre (10.000 solicitudes simultáneas).
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# Informe de Auditoría y Seguridad Automatizada (Python & Node.js)
|
||||
**Proyecto:** Edu-Space Admin Architecture
|
||||
**Fecha:** Septiembre 2026 (Actualización Post Fase 5)
|
||||
**Entorno:** Backend Flask (REST API) + Node.js BFF (Express) + SQLite / PostgreSQL
|
||||
**Estado General:** Aprobado ✅ (`[PASS]` en los 5 controles automatizados, 0 vulnerabilidades conocidas, 0 fallos críticos SAST/DAST)
|
||||
|
||||
---
|
||||
|
||||
## 1. Resumen Ejecutivo
|
||||
Se ejecutó y validó la suite completa del **Plan de Auditoría y Seguridad Automatizada** que cubre el ciclo de vida completo de las dos capas de la plataforma:
|
||||
- **Capa Core API (Python / Flask)**: Análisis Estático (SAST), Análisis de Dependencias de Terceros (SCA), Pruebas Dinámicas / Fuzzing contra especificación OpenAPI 3.0.3 (DAST).
|
||||
- **Capa BFF / Frontend Server (Node.js / Express)**: Análisis Estático (SAST), Análisis de Dependencias (SCA) y Blindaje Activo con cabeceras HTTP (`helmet`) y limitador de tasa (`express-rate-limit`).
|
||||
|
||||
Todos los controles fueron unificados en el script ejecutable [security_audit.bat](file:///c:/Users/Soporte%20IT/workspace/admin-edu-space/security_audit.bat), compatible con ejecuciones interactivas en Windows y modos desatendidos (`--no-pause`) para integración continua.
|
||||
|
||||
---
|
||||
|
||||
## 2. Capa Backend Python (Flask API)
|
||||
|
||||
### 2.1. Análisis Estático de Código Fuente (SAST) - `Bandit`
|
||||
- **Herramienta:** Bandit v1.8.3 (`backend/venv/Scripts/bandit.exe`)
|
||||
- **Comando:** `bandit -r backend/app -ll -ii`
|
||||
- **Líneas Escaneadas:** 11.321 líneas de código Python analizadas en toda la aplicación.
|
||||
- **Hallazgos y Remediaciones:**
|
||||
1. *CWE-22 / B310 en `sheets_importer.py`:* Uso de `urllib.request.urlopen` sin validación estricta de esquema. Remediado validando `url.startswith('https://')` o `http://` y documentando la verificación con `# nosec B310`.
|
||||
2. *CWE-829 / B113 en `genetic_algorithm.py`:* Falso positivo producido por el parámetro de dominio `requests` (lista de solicitudes de aula `ReservationRequest`) que colisionaba con el analizador de llamadas HTTP sin timeout. Remediado renombrando el parámetro a `reservation_requests` con tipado estricto.
|
||||
- **Resultado Actual:** **0 problemas identificados** (Medium = 0, High = 0).
|
||||
|
||||
### 2.2. Auditoría de Dependencias de Terceros (SCA) - `Pip-Audit`
|
||||
- **Herramienta:** Pip-Audit v2.10.1 (`backend/venv/Scripts/pip-audit.exe`)
|
||||
- **Comando:** `pip-audit -s osv --progress-spinner off -r backend/requirements.txt` (con fallback de resiliencia automática a servicio PyPI ante indisponibilidad de red).
|
||||
- **Librerías Core Aseguradas:**
|
||||
- `Flask>=3.1.3`
|
||||
- `Werkzeug>=3.1.6` (v3.1.8 instalada)
|
||||
- `requests>=2.32.4` (v2.34.2 instalada)
|
||||
- `bleach>=6.4.0`
|
||||
- `python-dotenv>=1.2.2` (v1.2.3 instalada)
|
||||
- `cryptography>=44.0.0`
|
||||
- **Resultado Actual:** **No known vulnerabilities found** (0 CVEs pendientes).
|
||||
|
||||
### 2.3. Pruebas Dinámicas y Fuzzing de API REST (DAST) - `Schemathesis`
|
||||
- **Herramienta:** Schemathesis v4.27.4 (`backend/venv/Scripts/st.exe`)
|
||||
- **Especificación OpenAPI:** OpenAPI 3.0.3 en `/api/v1/openapi.json`.
|
||||
- **Comando:** `st run http://127.0.0.1:5000/api/v1/openapi.json --checks not_a_server_error --max-examples=10 --no-color`
|
||||
- **Fuzzing & Robustez de Endpoints:**
|
||||
- `POST /api/v1/auth/login`: Blindado contra payloads no estructurados (JSON arrays anómalos o tipos no diccionario devuelven `400 Bad Request` antes de invocar DTOs). Manejo seguro de base de datos garantizando códigos `401 Unauthorized` ante credenciales erróneas o usuarios no existentes sin generar fallos 500 no capturados.
|
||||
- `GET /api/v1/admin/subjects`, `GET /api/v1/admin/users`, `GET /api/v1/auth/me`, `GET /api/v1/classrooms`: Rechazan peticiones sin token JWT con código `401 Unauthorized`.
|
||||
- **Resultado Actual:** **114 casos de prueba generados y superados con éxito**, **0 errores 500 del servidor** (100% PASS).
|
||||
|
||||
---
|
||||
|
||||
## 3. Capa Backend Node.js (Express BFF)
|
||||
|
||||
### 3.1. Análisis Estático de Código Fuente (SAST) - `njsscan`
|
||||
- **Herramienta:** NodeJsScan v1.0.0 (`backend/venv/Scripts/njsscan.exe`)
|
||||
- **Comando:** `njsscan frontend/src`
|
||||
- **Reglas Evaluadas:** Inyecciones de código, llamadas a `eval()`, exposición de credenciales hardcodeadas, omisión de headers de seguridad, CORS inseguro.
|
||||
- **Resultado:** **No issues found** (0 problemas de seguridad).
|
||||
|
||||
### 3.2. Blindaje de Middleware (`helmet` & `express-rate-limit`)
|
||||
- **Paquetes:** `helmet`, `express-rate-limit` en `frontend/src/app.js`.
|
||||
- **Protecciones Activas:**
|
||||
- `app.disable('x-powered-by')`: Oculta el motor Express.
|
||||
- `helmet`:
|
||||
- `X-Content-Type-Options: nosniff`
|
||||
- `X-Frame-Options: SAMEORIGIN` (prevención de Clickjacking)
|
||||
- `Strict-Transport-Security: max-age=31536000; includeSubDomains` (HSTS)
|
||||
- `Cross-Origin-Opener-Policy: same-origin`
|
||||
- `Cross-Origin-Resource-Policy: same-origin`
|
||||
- `express-rate-limit`:
|
||||
- `/auth/login`: Límite estricto de 30 peticiones por ventana de 15 minutos por IP (mitigación de ataques de fuerza bruta).
|
||||
- Endpoints `/api`: Límite de 180 peticiones por minuto por IP (mitigación de scraping y saturación).
|
||||
|
||||
### 3.3. Auditoría de Dependencias (SCA) - `npm audit`
|
||||
- **Herramienta:** `npm audit` nativo en `frontend/`
|
||||
- **Comando:** `cmd.exe /c npm audit`
|
||||
- **Resultado:** **found 0 vulnerabilities** en 108 dependencias evaluadas.
|
||||
|
||||
---
|
||||
|
||||
## 4. Automatización de la Suite de Seguridad
|
||||
|
||||
El archivo [security_audit.bat](file:///c:/Users/Soporte%20IT/workspace/admin-edu-space/security_audit.bat) provee la suite de ejecución automatizada:
|
||||
|
||||
```cmd
|
||||
:: Ejecución interactiva con consola Windows:
|
||||
security_audit.bat
|
||||
|
||||
:: Ejecución desatendida / CI:
|
||||
security_audit.bat --no-pause
|
||||
```
|
||||
|
||||
### Características de la suite adaptada:
|
||||
1. **Página de códigos UTF-8 (`chcp 65001`) y variables de entorno Python (`PYTHONUTF8=1`):** Evita fallos de codificación `charmap` en terminales Windows tradicionales.
|
||||
2. **Fallback resiliente en `pip-audit`:** Conmutación automática a base PyPI en caso de indisponibilidad temporal de la API OSV.
|
||||
3. **Comprobación de conectividad al backend:** Antes de lanzar Schemathesis DAST, verifica la respuesta HTTP de `/api/v1/openapi.json`. Si el backend está inactivo, emite una advertencia sin abortar los controles estáticos previos.
|
||||
4. **Bandera `--no-pause`:** Permite integración fluida en pipelines de CI/CD conservando la pausa para desarrolladores que ejecuten por doble click.
|
||||
|
||||
---
|
||||
|
||||
## 5. Matriz de Resultados Finales
|
||||
|
||||
| Control | Capa | Herramienta | Métricas Escaneadas | Estado Final |
|
||||
|---|---|---|---|:---:|
|
||||
| **SAST (Python)** | Flask API | `bandit` | 11.321 líneas de código | **0 Issues (PASS)** ✅ |
|
||||
| **SCA (Python)** | Flask API | `pip-audit` | 42 librerías en requirements.txt | **0 CVEs (PASS)** ✅ |
|
||||
| **SAST (Node.js)**| Express BFF| `njsscan` | Archivos en `frontend/src` | **0 Issues (PASS)** ✅ |
|
||||
| **SCA (Node.js)** | Express BFF| `npm audit` | 108 paquetes npm | **0 Vulnerabilidades (PASS)** ✅ |
|
||||
| **DAST (Python)** | Flask API | `schemathesis` | 114 casos de prueba generados | **114/114 Pasados - 0 Error 500 (PASS)** ✅ |
|
||||
| **Protección HTTP** | Express BFF| `helmet` + `rate-limit` | HSTS, nosniff, limiters activos | **Activo y Blindado** ✅ |
|
||||
|
||||
**Dictamen:** **SUITE DE SEGURIDAD APROBADA AL 100% [PASS]**.
|
||||
@@ -29,6 +29,9 @@ def create_app(config_class=Config):
|
||||
from app.routes.api.optimizer import api_optimizer_bp
|
||||
from app.routes.api.dashboard import api_dashboard_bp
|
||||
from app.routes.api.admin import api_admin_bp
|
||||
from app.routes.api.openapi import api_openapi_bp
|
||||
from app.routes.api.students import api_students_bp
|
||||
from app.routes.genetic_algorithm import genetic_bp
|
||||
from app.security import SecurityFilterChain
|
||||
|
||||
# Register API v1 blueprints
|
||||
@@ -37,6 +40,9 @@ def create_app(config_class=Config):
|
||||
app.register_blueprint(api_reservations_bp)
|
||||
app.register_blueprint(api_optimizer_bp)
|
||||
app.register_blueprint(api_dashboard_bp)
|
||||
app.register_blueprint(api_openapi_bp)
|
||||
app.register_blueprint(genetic_bp)
|
||||
app.register_blueprint(api_students_bp, url_prefix='/api/v1')
|
||||
app.register_blueprint(api_admin_bp, url_prefix='/api/v1')
|
||||
app.register_blueprint(api_admin_bp, url_prefix='/api/v1/admin', name='api_admin_prefixed')
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .document_types import DOCUMENT_TYPES, DOCUMENT_TYPE_MAP, get_document_type, validate_document, format_document
|
||||
@@ -0,0 +1,160 @@
|
||||
import re
|
||||
|
||||
DOCUMENT_TYPES = [
|
||||
{
|
||||
'code': 'DNI',
|
||||
'name': 'Documento Nacional de Identidad',
|
||||
'short_name': 'DNI',
|
||||
'category': 'Nacional',
|
||||
'description': 'Documento oficial para ciudadanos argentinos nativos, naturalizados y residentes permanentes.',
|
||||
'regex': r'^\d{7,8}$',
|
||||
'placeholder': 'Ej: 38123456 (7 u 8 dígitos sin puntos)',
|
||||
'mask_example': '38.123.456'
|
||||
},
|
||||
{
|
||||
'code': 'CUIL',
|
||||
'name': 'Código Único de Identificación Laboral (CUIL/CUIT)',
|
||||
'short_name': 'CUIL',
|
||||
'category': 'Nacional / Laboral',
|
||||
'description': 'Identificador fiscal y laboral en Argentina (11 dígitos).',
|
||||
'regex': r'^\d{11}$|^\d{2}-\d{8}-\d{1}$',
|
||||
'placeholder': 'Ej: 20381234567 o 20-38123456-7',
|
||||
'mask_example': '20-38123456-7'
|
||||
},
|
||||
{
|
||||
'code': 'PAS',
|
||||
'name': 'Pasaporte Argentino',
|
||||
'short_name': 'Pasaporte ARG',
|
||||
'category': 'Internacional',
|
||||
'description': 'Pasaporte emitido por la República Argentina para viajes y acreditación internacional.',
|
||||
'regex': r'^[a-zA-Z0-9]{6,12}$',
|
||||
'placeholder': 'Ej: AAB123456',
|
||||
'mask_example': 'PAS AAB123456'
|
||||
},
|
||||
{
|
||||
'code': 'PASEXT',
|
||||
'name': 'Pasaporte Extranjero',
|
||||
'short_name': 'Pasaporte Extranjero',
|
||||
'category': 'Internacional / Extranjero',
|
||||
'description': 'Pasaporte oficial emitido por país de origen (docentes invitados, alumnos de intercambio).',
|
||||
'regex': r'^[a-zA-Z0-9\-\.]{4,20}$',
|
||||
'placeholder': 'Ej: US987654321 / B1234567',
|
||||
'mask_example': 'PASEXT US987654321'
|
||||
},
|
||||
{
|
||||
'code': 'DNIEXT',
|
||||
'name': 'DNI para Extranjeros (Residente)',
|
||||
'short_name': 'DNI Extranjero',
|
||||
'category': 'Extranjero / Residente',
|
||||
'description': 'DNI argentino otorgado a ciudadanos extranjeros con radicación o residencia temporaria.',
|
||||
'regex': r'^[a-zA-Z0-9]{7,10}$',
|
||||
'placeholder': 'Ej: 94123456 o E94123456',
|
||||
'mask_example': 'DNI-EXT 94.123.456'
|
||||
},
|
||||
{
|
||||
'code': 'CI',
|
||||
'name': 'Cédula de Identidad (PFA / Policía Provincial)',
|
||||
'short_name': 'Cédula Identidad',
|
||||
'category': 'Nacional (Histórico)',
|
||||
'description': 'Cédula de Identidad emitida por Policía Federal Argentina o Policías Provinciales.',
|
||||
'regex': r'^\d{6,9}$',
|
||||
'placeholder': 'Ej: 6123456',
|
||||
'mask_example': 'CI 6.123.456'
|
||||
},
|
||||
{
|
||||
'code': 'CIEXT',
|
||||
'name': 'Cédula de Identidad Extranjera (Mercosur)',
|
||||
'short_name': 'Cédula Mercosur',
|
||||
'category': 'Regional / Mercosur',
|
||||
'description': 'Cédula de Identidad emitida por países miembros o asociados al Mercosur (Brasil, Uruguay, etc.).',
|
||||
'regex': r'^[a-zA-Z0-9\-\.]{5,18}$',
|
||||
'placeholder': 'Ej: RG12345678 (Brasil) o 4.123.456-7 (Uruguay)',
|
||||
'mask_example': 'CI-EXT 12345678'
|
||||
},
|
||||
{
|
||||
'code': 'LC',
|
||||
'name': 'Libreta Cívica',
|
||||
'short_name': 'Libreta Cívica',
|
||||
'category': 'Histórico Nacional',
|
||||
'description': 'Documento histórico nacional para ciudadanas mujeres argentinas.',
|
||||
'regex': r'^\d{6,8}$',
|
||||
'placeholder': 'Ej: 5123456',
|
||||
'mask_example': 'LC 5.123.456'
|
||||
},
|
||||
{
|
||||
'code': 'LE',
|
||||
'name': 'Libreta de Enrolamiento',
|
||||
'short_name': 'Libreta Enrolamiento',
|
||||
'category': 'Histórico Nacional',
|
||||
'description': 'Documento histórico nacional para ciudadanos varones argentinos.',
|
||||
'regex': r'^\d{6,8}$',
|
||||
'placeholder': 'Ej: 4123456',
|
||||
'mask_example': 'LE 4.123.456'
|
||||
}
|
||||
]
|
||||
|
||||
DOCUMENT_TYPE_MAP = {doc['code']: doc for doc in DOCUMENT_TYPES}
|
||||
|
||||
def get_document_type(code):
|
||||
"""Devuelve la especificación de un tipo de documento o None."""
|
||||
if not code:
|
||||
return None
|
||||
return DOCUMENT_TYPE_MAP.get(str(code).strip().upper())
|
||||
|
||||
def validate_document(doc_type, doc_number):
|
||||
"""
|
||||
Valida un número de documento según las reglas de negocio de su tipificación.
|
||||
Retorna (is_valid: bool, cleaned_number: str, error_message: str|None).
|
||||
"""
|
||||
if not doc_type:
|
||||
return False, '', 'Debe especificar el tipo de documento.'
|
||||
|
||||
spec = get_document_type(doc_type)
|
||||
if not spec:
|
||||
return False, '', f"El tipo de documento '{doc_type}' no está registrado en el catálogo oficial."
|
||||
|
||||
if not doc_number:
|
||||
return True, '', None # Document number may be optional if pending registration
|
||||
|
||||
cleaned = str(doc_number).strip().upper()
|
||||
# Si es DNI, LC, LE, CI podemos remover puntos y guiones intermedios para validar y almacenar de forma uniforme
|
||||
if spec['code'] in ['DNI', 'LC', 'LE', 'CI', 'DNIEXT']:
|
||||
numeric_only = re.sub(r'[\.\-\s]', '', cleaned)
|
||||
if re.match(spec['regex'], numeric_only):
|
||||
return True, numeric_only, None
|
||||
return False, cleaned, f"Formato inválido para {spec['name']}. Debe contener {spec['placeholder']}."
|
||||
|
||||
elif spec['code'] == 'CUIL':
|
||||
numeric_only = re.sub(r'[\.\-\s]', '', cleaned)
|
||||
if re.match(r'^\d{11}$', numeric_only):
|
||||
return True, numeric_only, None
|
||||
return False, cleaned, "El CUIL debe contener 11 dígitos numéricos (ej. 20-38123456-7)."
|
||||
|
||||
else:
|
||||
# Pasaportes o Cédulas Extranjeras
|
||||
if re.match(spec['regex'], cleaned):
|
||||
return True, cleaned, None
|
||||
return False, cleaned, f"Formato inválido para {spec['name']} ({spec['placeholder']})."
|
||||
|
||||
def format_document(doc_type, doc_number):
|
||||
"""
|
||||
Formatea visualmente un número de documento con puntos/guiones estándar.
|
||||
"""
|
||||
if not doc_number:
|
||||
return ''
|
||||
raw = str(doc_number).strip().upper()
|
||||
dtype = (doc_type or 'DNI').upper()
|
||||
|
||||
if dtype in ['DNI', 'LC', 'LE', 'CI']:
|
||||
clean = re.sub(r'\D', '', raw)
|
||||
if len(clean) == 8:
|
||||
return f"{clean[:2]}.{clean[2:5]}.{clean[5:]}"
|
||||
elif len(clean) == 7:
|
||||
return f"{clean[:1]}.{clean[1:4]}.{clean[4:]}"
|
||||
return clean
|
||||
elif dtype == 'CUIL':
|
||||
clean = re.sub(r'\D', '', raw)
|
||||
if len(clean) == 11:
|
||||
return f"{clean[:2]}-{clean[2:10]}-{clean[10:]}"
|
||||
return clean
|
||||
return raw
|
||||
@@ -3,7 +3,7 @@ from .building import Building
|
||||
from .classroom import Classroom, ClassroomResource
|
||||
from .career import Career
|
||||
from .academic_term import AcademicTerm
|
||||
from .subject import Subject, Commission
|
||||
from .subject import Subject, Commission, CommissionTeacher
|
||||
from .reservation import Reservation, ReservationStatus
|
||||
from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer
|
||||
from .role import Role, Permission, SYSTEM_MODULES
|
||||
@@ -11,6 +11,8 @@ from .milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES
|
||||
from .audit_log import AuditLog
|
||||
from .enrollment import StudentEnrollment
|
||||
from .setting import SystemSetting
|
||||
from .grade import MilestoneGrade
|
||||
from .sync_task import MoodleSyncTask
|
||||
|
||||
__all__ = [
|
||||
'User',
|
||||
@@ -21,6 +23,7 @@ __all__ = [
|
||||
'AcademicTerm',
|
||||
'Subject',
|
||||
'Commission',
|
||||
'CommissionTeacher',
|
||||
'Reservation',
|
||||
'ReservationStatus',
|
||||
'GeneticAlgorithm',
|
||||
@@ -33,5 +36,7 @@ __all__ = [
|
||||
'BASE_MILESTONE_TYPES',
|
||||
'AuditLog',
|
||||
'StudentEnrollment',
|
||||
'SystemSetting'
|
||||
'SystemSetting',
|
||||
'MilestoneGrade',
|
||||
'MoodleSyncTask'
|
||||
]
|
||||
@@ -17,10 +17,40 @@ class Building(db.Model):
|
||||
# Relationships
|
||||
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
|
||||
def total_capacity(self):
|
||||
"""Total student capacity across active classrooms in this building"""
|
||||
return sum(c.capacity for c in self.classrooms if c.is_active)
|
||||
"""Capacidad para compatibilidad histórica"""
|
||||
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
|
||||
def classrooms_count(self):
|
||||
@@ -34,10 +64,14 @@ class Building(db.Model):
|
||||
'code': self.code,
|
||||
'address': self.address,
|
||||
'floors': self.floors,
|
||||
'floors_display': self.floors_display,
|
||||
'description': self.description,
|
||||
'is_active': self.is_active,
|
||||
'is_virtual': self.is_virtual,
|
||||
'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
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ class StudentEnrollment(db.Model):
|
||||
enrolled_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
allow_same_day_exception = db.Column(db.Boolean, default=False, nullable=True)
|
||||
exception_reason = db.Column(db.String(255), nullable=True)
|
||||
|
||||
# Unique constraint: a student can only be enrolled once per commission
|
||||
__table_args__ = (
|
||||
@@ -43,6 +45,8 @@ class StudentEnrollment(db.Model):
|
||||
'status': self.status,
|
||||
'enrolled_at': self.enrolled_at.isoformat() if self.enrolled_at else None,
|
||||
'notes': self.notes,
|
||||
'allow_same_day_exception': bool(self.allow_same_day_exception),
|
||||
'exception_reason': self.exception_reason
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
|
||||
@@ -45,7 +45,7 @@ class Individual:
|
||||
self.fitness = 0.0
|
||||
self.conflicts = []
|
||||
|
||||
def calculate_fitness(self, classrooms: Dict[int, Classroom], requests: Dict[int, ReservationRequest]) -> float:
|
||||
def calculate_fitness(self, classrooms: Dict[int, Classroom], reservation_requests: Dict[int, ReservationRequest]) -> float:
|
||||
"""Calculate fitness score based on multiple factors with fast O(K log K) interval conflict detection"""
|
||||
score = 0.0
|
||||
self.conflicts = []
|
||||
@@ -73,9 +73,9 @@ class Individual:
|
||||
|
||||
for gene in self.genes:
|
||||
classroom = classrooms.get(gene.classroom_id)
|
||||
request = requests.get(gene.commission_id)
|
||||
res_req = reservation_requests.get(gene.commission_id)
|
||||
|
||||
if not classroom or not request:
|
||||
if not classroom or not res_req:
|
||||
continue
|
||||
|
||||
# Factor 1: Capacity efficiency (40% weight)
|
||||
@@ -83,7 +83,7 @@ class Individual:
|
||||
score += capacity_score * 0.4
|
||||
|
||||
# Factor 2: Time preference satisfaction (30% weight)
|
||||
time_score = self._calculate_time_score(gene, request)
|
||||
time_score = self._calculate_time_score(gene, res_req)
|
||||
score += time_score * 0.3
|
||||
|
||||
# Factor 3: Conflict penalty (20% weight)
|
||||
@@ -99,7 +99,7 @@ class Individual:
|
||||
score += conflict_score * 0.2
|
||||
|
||||
# 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
|
||||
|
||||
self.fitness = score
|
||||
@@ -390,28 +390,61 @@ class ReservationOptimizer:
|
||||
}
|
||||
|
||||
def _calculate_preferred_time(self, commission: Commission) -> Dict:
|
||||
"""Calculate preferred time slots for a commission"""
|
||||
# This is a simplified version - would need to be based on actual semester schedule
|
||||
"""Calculate preferred time slots for a commission based on shift and schedule"""
|
||||
current_date = datetime.utcnow()
|
||||
|
||||
# Default to weekday mornings for regular classes
|
||||
days_ahead = (0 - current_date.weekday()) % 7 # Next Monday
|
||||
if days_ahead == 0: # If today is Monday, use next week
|
||||
# Determine day of week from commission schedule or default to Monday
|
||||
target_weekday = 0 # Monday
|
||||
sched = (commission.schedule or '').lower()
|
||||
if 'martes' in sched:
|
||||
target_weekday = 1
|
||||
elif 'miercoles' in sched or 'miércoles' in sched:
|
||||
target_weekday = 2
|
||||
elif 'jueves' in sched:
|
||||
target_weekday = 3
|
||||
elif 'viernes' in sched:
|
||||
target_weekday = 4
|
||||
elif 'sabado' in sched or 'sábado' in sched:
|
||||
target_weekday = 5
|
||||
|
||||
days_ahead = (target_weekday - current_date.weekday()) % 7
|
||||
if days_ahead == 0:
|
||||
days_ahead = 7
|
||||
|
||||
preferred_date = current_date + timedelta(days=days_ahead)
|
||||
|
||||
# Determine hours based on shift
|
||||
shift = (commission.shift or '').strip().lower()
|
||||
if 'mañana' in shift or 'manana' in shift:
|
||||
start_h, start_m = 8, 0
|
||||
end_h, end_m = 12, 0
|
||||
elif 'tarde' in shift:
|
||||
start_h, start_m = 14, 0
|
||||
end_h, end_m = 18, 0
|
||||
elif 'noche' in shift or 'vespertino' in shift:
|
||||
start_h, start_m = 18, 30
|
||||
end_h, end_m = 22, 30
|
||||
else:
|
||||
start_h, start_m = 9, 0
|
||||
end_h, end_m = 13, 0
|
||||
|
||||
return {
|
||||
'start': preferred_date.replace(hour=9, minute=0, second=0, microsecond=0),
|
||||
'end': preferred_date.replace(hour=11, minute=0, second=0, microsecond=0)
|
||||
'start': preferred_date.replace(hour=start_h, minute=start_m, second=0, microsecond=0),
|
||||
'end': preferred_date.replace(hour=end_h, minute=end_m, second=0, microsecond=0)
|
||||
}
|
||||
|
||||
def apply_optimized_reservations(self, reservation_data: List[Dict]) -> List[Reservation]:
|
||||
"""Apply the optimized reservations to the database"""
|
||||
"""Apply the optimized reservations to the database with ISO string parsing and conflict avoidance"""
|
||||
applied_reservations = []
|
||||
|
||||
for data in reservation_data:
|
||||
# Check for conflicts before creating
|
||||
for item in reservation_data:
|
||||
data = dict(item)
|
||||
if isinstance(data.get('start_time'), str):
|
||||
data['start_time'] = datetime.fromisoformat(data['start_time'].replace('Z', '+00:00'))
|
||||
if isinstance(data.get('end_time'), str):
|
||||
data['end_time'] = datetime.fromisoformat(data['end_time'].replace('Z', '+00:00'))
|
||||
|
||||
# Check for physical room conflicts before creating
|
||||
conflicts = Reservation.find_conflicts(
|
||||
data['classroom_id'],
|
||||
data['start_time'],
|
||||
@@ -419,12 +452,17 @@ class ReservationOptimizer:
|
||||
)
|
||||
|
||||
if not conflicts:
|
||||
# Ensure status enum value compatibility
|
||||
if 'status' in data and isinstance(data['status'], str):
|
||||
try:
|
||||
data['status'] = ReservationStatus(data['status']).value
|
||||
except ValueError:
|
||||
data['status'] = ReservationStatus.CONFIRMED.value
|
||||
reservation = Reservation(**data)
|
||||
db.session.add(reservation)
|
||||
applied_reservations.append(reservation)
|
||||
else:
|
||||
# Log conflict - could be handled differently
|
||||
print(f"Conflict detected for reservation data: {data}")
|
||||
current_app.logger.warning(f"Conflict detected for reservation during genetic apply: {data}")
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
from datetime import datetime
|
||||
from app import db
|
||||
|
||||
|
||||
class MilestoneGrade(db.Model):
|
||||
"""
|
||||
Calificación y seguimiento pedagógico individual de un alumno en un hito evaluativo (Fase 3 MVP).
|
||||
Vincula un AcademicMilestone con un estudiante matriculado (StudentEnrollment / User).
|
||||
"""
|
||||
__tablename__ = 'milestone_grades'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
milestone_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('academic_milestones.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
enrollment_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('student_enrollments.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
student_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
score = db.Column(db.Float, nullable=True) # Calificación numérica de 1.0 a 10.0
|
||||
is_absent = db.Column(db.Boolean, default=False) # Flag de Ausente
|
||||
feedback = db.Column(db.Text, nullable=True) # Devolución formativa/pedagógica
|
||||
graded_by_user_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.id', ondelete='SET NULL'),
|
||||
nullable=True
|
||||
)
|
||||
graded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Restricción de unicidad: un estudiante tiene una única nota por hito
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('milestone_id', 'student_id', name='uq_milestone_student'),
|
||||
)
|
||||
|
||||
# Relaciones
|
||||
milestone = db.relationship('AcademicMilestone', backref=db.backref('grades', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
enrollment = db.relationship('StudentEnrollment', backref=db.backref('milestone_grades', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
student = db.relationship('User', foreign_keys=[student_id], backref=db.backref('received_grades', lazy='dynamic'))
|
||||
graded_by = db.relationship('User', foreign_keys=[graded_by_user_id])
|
||||
|
||||
@property
|
||||
def is_passed(self) -> bool:
|
||||
"""Determina si la nota está aprobada (>= 4.00 y no ausente)."""
|
||||
if self.is_absent or self.score is None:
|
||||
return False
|
||||
return self.score >= 4.0
|
||||
|
||||
@property
|
||||
def display_score(self) -> str:
|
||||
"""Representación textual estándar: 'Ausente', '7.5' o '-'."""
|
||||
if self.is_absent:
|
||||
return 'Ausente'
|
||||
if self.score is not None:
|
||||
return f'{self.score:.1f}' if self.score % 1 != 0 else f'{int(self.score)}'
|
||||
return '-'
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'milestone_id': self.milestone_id,
|
||||
'milestone_title': self.milestone.title if self.milestone else None,
|
||||
'milestone_type': self.milestone.tipo if self.milestone else None,
|
||||
'milestone_color': self.milestone.color if self.milestone else None,
|
||||
'enrollment_id': self.enrollment_id,
|
||||
'student_id': self.student_id,
|
||||
'student_name': self.student.name if self.student else None,
|
||||
'student_email': self.student.email if self.student else None,
|
||||
'score': self.score,
|
||||
'display_score': self.display_score,
|
||||
'is_absent': bool(self.is_absent),
|
||||
'is_passed': self.is_passed,
|
||||
'feedback': self.feedback or '',
|
||||
'graded_by_user_id': self.graded_by_user_id,
|
||||
'graded_by_name': self.graded_by.name if self.graded_by else None,
|
||||
'graded_at': self.graded_at.isoformat() if self.graded_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<MilestoneGrade milestone={self.milestone_id} student={self.student_id} score={self.display_score}>'
|
||||
@@ -1,10 +1,12 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
from app.services.crypto_service import CryptoService
|
||||
|
||||
class SystemSetting(db.Model):
|
||||
"""
|
||||
Parámetros de configuración del sistema persistentes en base de datos.
|
||||
Permite almacenar enlaces de integración (e.g. Google Sheets), flags globales y metadatos.
|
||||
Permite almacenar enlaces de integración (e.g. Google Sheets, Moodle),
|
||||
flags globales, credenciales SMTP encriptadas y secretos OAuth.
|
||||
"""
|
||||
__tablename__ = 'system_settings'
|
||||
|
||||
@@ -12,10 +14,13 @@ class SystemSetting(db.Model):
|
||||
key = db.Column(db.String(100), unique=True, nullable=False, index=True)
|
||||
value = db.Column(db.Text, nullable=True)
|
||||
description = db.Column(db.String(255), nullable=True)
|
||||
category = db.Column(db.String(50), default='system', nullable=True)
|
||||
is_encrypted = db.Column(db.Boolean, default=False, nullable=True)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
@classmethod
|
||||
def get_value(cls, key, default=None):
|
||||
"""Retorna el valor tal como está almacenado en BD."""
|
||||
try:
|
||||
record = cls.query.filter_by(key=key).first()
|
||||
if record and record.value is not None and record.value.strip() != '':
|
||||
@@ -24,27 +29,103 @@ class SystemSetting(db.Model):
|
||||
pass
|
||||
return default
|
||||
|
||||
def get_decrypted_value(self_or_cls, key_or_default=None, default=None):
|
||||
"""Retorna el valor desencriptado. Compatible como método de clase o de instancia."""
|
||||
if isinstance(self_or_cls, type):
|
||||
# Llamado como clase: SystemSetting.get_decrypted_value('key', default)
|
||||
key = key_or_default
|
||||
try:
|
||||
record = self_or_cls.query.filter_by(key=key).first()
|
||||
if record:
|
||||
return record.get_decrypted_value(default)
|
||||
except Exception:
|
||||
pass
|
||||
return default
|
||||
else:
|
||||
# Llamado como instancia: setting.get_decrypted_value(default)
|
||||
actual_default = key_or_default
|
||||
try:
|
||||
if self_or_cls.value is not None and self_or_cls.value.strip() != '':
|
||||
if self_or_cls.is_encrypted:
|
||||
return CryptoService.decrypt(self_or_cls.value)
|
||||
return self_or_cls.value
|
||||
except Exception:
|
||||
pass
|
||||
return actual_default
|
||||
|
||||
def get_masked_value(self_or_cls, key_or_default=None, default=''):
|
||||
"""Retorna una versión enmascarada si es un secreto o contraseña. Compatible como clase o instancia."""
|
||||
if isinstance(self_or_cls, type):
|
||||
# Llamado como clase: SystemSetting.get_masked_value('key', default)
|
||||
key = key_or_default
|
||||
try:
|
||||
record = self_or_cls.query.filter_by(key=key).first()
|
||||
if record:
|
||||
return record.get_masked_value(default)
|
||||
except Exception:
|
||||
pass
|
||||
return default
|
||||
else:
|
||||
# Llamado como instancia: setting.get_masked_value(default)
|
||||
actual_default = key_or_default or ''
|
||||
try:
|
||||
if self_or_cls.value and self_or_cls.value.strip() != '':
|
||||
if self_or_cls.is_encrypted or 'secret' in self_or_cls.key.lower() or 'password' in self_or_cls.key.lower() or 'token' in self_or_cls.key.lower():
|
||||
return '••••••••••••'
|
||||
return self_or_cls.value
|
||||
except Exception:
|
||||
pass
|
||||
return actual_default
|
||||
|
||||
@classmethod
|
||||
def set_value(cls, key, value, description=None):
|
||||
def set_value(cls, key, value, description=None, category='system', is_encrypted=False):
|
||||
"""Guarda o actualiza un parámetro de configuración."""
|
||||
record = cls.query.filter_by(key=key).first()
|
||||
final_value = value
|
||||
if is_encrypted and value:
|
||||
# Si el valor ya viene cifrado o es un placeholder de no-cambio '••••••••••••', no recifrar
|
||||
if value != '••••••••••••':
|
||||
final_value = CryptoService.encrypt(value)
|
||||
|
||||
if not record:
|
||||
record = cls(key=key, value=value, description=description)
|
||||
record = cls(
|
||||
key=key,
|
||||
value=final_value,
|
||||
description=description,
|
||||
category=category,
|
||||
is_encrypted=is_encrypted
|
||||
)
|
||||
db.session.add(record)
|
||||
else:
|
||||
record.value = value
|
||||
# Si el valor ingresado es el placeholder, no sobrescribir la contraseña existente
|
||||
if not (is_encrypted and value == '••••••••••••'):
|
||||
record.value = final_value
|
||||
if description:
|
||||
record.description = description
|
||||
if category:
|
||||
record.category = category
|
||||
record.is_encrypted = is_encrypted
|
||||
record.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
return record
|
||||
|
||||
def to_dict(self):
|
||||
@classmethod
|
||||
def set_encrypted_value(cls, key, value, description=None, category='system'):
|
||||
"""Helper para guardar directamente valores sensibles cifrados."""
|
||||
return cls.set_value(key, value, description=description, category=category, is_encrypted=True)
|
||||
|
||||
def to_dict(self, mask_secrets=True):
|
||||
val = self.value
|
||||
if mask_secrets and (self.is_encrypted or 'password' in self.key.lower() or 'secret' in self.key.lower() or 'token' in self.key.lower()):
|
||||
val = '••••••••••••' if val else ''
|
||||
return {
|
||||
'key': self.key,
|
||||
'value': self.value,
|
||||
'value': val,
|
||||
'description': self.description,
|
||||
'category': self.category or 'system',
|
||||
'is_encrypted': bool(self.is_encrypted),
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<SystemSetting {self.key}={self.value}>'
|
||||
return f'<SystemSetting {self.key}>'
|
||||
|
||||
+203
-118
@@ -1,118 +1,203 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class Subject(db.Model):
|
||||
__tablename__ = 'subjects'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
code = db.Column(db.String(50), unique=True, nullable=False, index=True)
|
||||
name = db.Column(db.String(200), nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
department = db.Column(db.String(100), nullable=False, default='')
|
||||
credits = db.Column(db.Integer, nullable=False, default=4)
|
||||
career_id = db.Column(db.Integer, db.ForeignKey('careers.id', ondelete='SET NULL'), nullable=True)
|
||||
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
commissions = db.relationship('Commission', backref='subject', lazy=True, cascade='all, delete-orphan')
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert subject to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'code': self.code,
|
||||
'name': self.name,
|
||||
'description': self.description,
|
||||
'department': self.department,
|
||||
'credits': self.credits,
|
||||
'career_id': self.career_id,
|
||||
'career_name': self.career_obj.name if self.career_obj else None,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Subject {self.code}>'
|
||||
|
||||
class Commission(db.Model):
|
||||
__tablename__ = 'commissions'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=False)
|
||||
code = db.Column(db.String(50), nullable=False)
|
||||
semester = db.Column(db.String(20), nullable=False)
|
||||
year = db.Column(db.Integer, nullable=False)
|
||||
teacher_id = db.Column(db.Integer, db.ForeignKey('users.id'))
|
||||
max_students = db.Column(db.Integer, nullable=False)
|
||||
current_students = db.Column(db.Integer, default=0)
|
||||
schedule = db.Column(db.String(255))
|
||||
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
|
||||
virtual_link = db.Column(db.String(500), nullable=True) # Link Zoom, Meet, Teams, Moodle
|
||||
active = db.Column(db.Boolean, default=True) # Changed from is_active to active
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Virtual property for compatibility
|
||||
@property
|
||||
def is_active(self):
|
||||
return self.active
|
||||
|
||||
@is_active.setter
|
||||
def is_active(self, value):
|
||||
self.active = value
|
||||
|
||||
# Relationships
|
||||
pass # Reservation already defines the relationship back to commission
|
||||
teacher = db.relationship('User', backref='teaching_commissions', foreign_keys=[teacher_id])
|
||||
|
||||
# Virtual field for teacher_name
|
||||
@property
|
||||
def teacher_name(self):
|
||||
return f"{self.teacher.first_name} {self.teacher.last_name}" if self.teacher else "TBD"
|
||||
|
||||
# Unique constraint
|
||||
__table_args__ = (db.UniqueConstraint('subject_id', 'code', 'semester', 'year'),)
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert commission to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'subject_id': self.subject_id,
|
||||
'code': self.code,
|
||||
'semester': self.semester,
|
||||
'year': self.year,
|
||||
'teacher_name': self.teacher_name,
|
||||
'max_students': self.max_students,
|
||||
'current_students': self.current_students,
|
||||
'schedule': self.schedule,
|
||||
'shift': self.shift,
|
||||
'virtual_link': self.virtual_link,
|
||||
'active': self.active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'full_code': self.get_full_code(),
|
||||
'subject': self.subject.to_dict() if self.subject else None
|
||||
}
|
||||
|
||||
def get_full_code(self):
|
||||
"""Get full commission code"""
|
||||
subj = self.subject.code if self.subject else 'N/A'
|
||||
code = self.code or ''
|
||||
sem = str(self.semester or '').strip()
|
||||
yr = str(self.year or '').strip()
|
||||
|
||||
if yr and yr in sem:
|
||||
return f"{subj}-{code}-{sem}"
|
||||
elif sem and yr:
|
||||
return f"{subj}-{code}-{sem}{yr}"
|
||||
elif sem:
|
||||
return f"{subj}-{code}-{sem}"
|
||||
elif yr:
|
||||
return f"{subj}-{code}-{yr}"
|
||||
return f"{subj}-{code}"
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Commission {self.get_full_code()}>'
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class Subject(db.Model):
|
||||
__tablename__ = 'subjects'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
code = db.Column(db.String(50), unique=True, nullable=False, index=True)
|
||||
name = db.Column(db.String(200), nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
department = db.Column(db.String(100), nullable=False, default='')
|
||||
credits = db.Column(db.Integer, nullable=False, default=4)
|
||||
career_id = db.Column(db.Integer, db.ForeignKey('careers.id', ondelete='SET NULL'), nullable=True)
|
||||
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB
|
||||
is_short_course = db.Column(db.Boolean, default=False, nullable=True) # True para talleres/cursos cortos
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
commissions = db.relationship('Commission', backref='subject', lazy=True, cascade='all, delete-orphan')
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert subject to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'code': self.code,
|
||||
'name': self.name,
|
||||
'description': self.description,
|
||||
'department': self.department,
|
||||
'credits': self.credits,
|
||||
'career_id': self.career_id,
|
||||
'career_name': self.career_obj.name if self.career_obj else None,
|
||||
'is_active': self.is_active,
|
||||
'is_short_course': bool(self.is_short_course),
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Subject {self.code}>'
|
||||
|
||||
class Commission(db.Model):
|
||||
__tablename__ = 'commissions'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=False)
|
||||
code = db.Column(db.String(50), nullable=False)
|
||||
semester = db.Column(db.String(20), nullable=False)
|
||||
year = db.Column(db.Integer, nullable=False)
|
||||
teacher_id = db.Column(db.Integer, db.ForeignKey('users.id'))
|
||||
max_students = db.Column(db.Integer, nullable=False)
|
||||
current_students = db.Column(db.Integer, default=0)
|
||||
schedule = db.Column(db.String(255))
|
||||
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
|
||||
virtual_link = db.Column(db.String(500), nullable=True) # Link Zoom, Meet, Teams, Moodle
|
||||
active = db.Column(db.Boolean, default=True) # Changed from is_active to active
|
||||
|
||||
# Cierre de Actas y Libro de Calificaciones (Fase 3 MVP)
|
||||
grades_closed = db.Column(db.Boolean, default=False, nullable=True)
|
||||
grades_closed_at = db.Column(db.DateTime, 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)
|
||||
|
||||
# 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)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Virtual property for compatibility
|
||||
@property
|
||||
def is_active(self):
|
||||
return self.active
|
||||
|
||||
@is_active.setter
|
||||
def is_active(self, value):
|
||||
self.active = value
|
||||
|
||||
# Relationships
|
||||
pass # Reservation already defines the relationship back to commission
|
||||
teacher = db.relationship('User', backref='teaching_commissions', foreign_keys=[teacher_id])
|
||||
closed_by_user = db.relationship('User', foreign_keys=[grades_closed_by])
|
||||
|
||||
# Virtual field for teacher_name
|
||||
@property
|
||||
def teacher_name(self):
|
||||
return f"{self.teacher.first_name} {self.teacher.last_name}" if self.teacher else "TBD"
|
||||
|
||||
@property
|
||||
def teachers(self):
|
||||
"""Retorna la lista de docentes asignados.
|
||||
Si teachers_association tiene registros, los retorna ordenados (Titular primero).
|
||||
Si está vacía pero teacher_id está configurado, devuelve el docente titular.
|
||||
"""
|
||||
if hasattr(self, 'teachers_association') and self.teachers_association and len(self.teachers_association) > 0:
|
||||
res = [ta.to_dict() for ta in self.teachers_association]
|
||||
res.sort(key=lambda x: (not x.get('is_primary', False), x.get('role', '') != 'Titular'))
|
||||
return res
|
||||
if self.teacher:
|
||||
return [{
|
||||
'id': 0,
|
||||
'commission_id': self.id,
|
||||
'user_id': self.teacher.id,
|
||||
'name': self.teacher.name,
|
||||
'email': self.teacher.email,
|
||||
'role': 'Titular',
|
||||
'is_primary': True
|
||||
}]
|
||||
return []
|
||||
|
||||
@property
|
||||
def teacher_names(self):
|
||||
t_list = self.teachers
|
||||
if not t_list:
|
||||
return "Sin asignar"
|
||||
return ", ".join([f"{t['name']} ({t['role']})" for t in t_list])
|
||||
|
||||
# Unique constraint
|
||||
__table_args__ = (db.UniqueConstraint('subject_id', 'code', 'semester', 'year'),)
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert commission to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'subject_id': self.subject_id,
|
||||
'code': self.code,
|
||||
'semester': self.semester,
|
||||
'year': self.year,
|
||||
'teacher_id': self.teacher_id,
|
||||
'teacher_name': self.teacher_name,
|
||||
'teachers': self.teachers,
|
||||
'teacher_names': self.teacher_names,
|
||||
'max_students': self.max_students,
|
||||
'current_students': self.current_students,
|
||||
'schedule': self.schedule,
|
||||
'shift': self.shift,
|
||||
'virtual_link': self.virtual_link,
|
||||
'active': self.active,
|
||||
'grades_closed': bool(self.grades_closed),
|
||||
'grades_closed_at': self.grades_closed_at.isoformat() if self.grades_closed_at else None,
|
||||
'grades_closed_by': self.grades_closed_by,
|
||||
'closed_by_name': self.closed_by_user.name if self.closed_by_user else None,
|
||||
'acta_number': self.acta_number,
|
||||
'academic_term_id': self.academic_term_id,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'full_code': self.get_full_code(),
|
||||
'subject': self.subject.to_dict() if self.subject else None
|
||||
}
|
||||
|
||||
def get_full_code(self):
|
||||
"""Get full commission code"""
|
||||
subj = self.subject.code if self.subject else 'N/A'
|
||||
code = self.code or ''
|
||||
sem = str(self.semester or '').strip()
|
||||
yr = str(self.year or '').strip()
|
||||
|
||||
if yr and yr in sem:
|
||||
return f"{subj}-{code}-{sem}"
|
||||
elif sem and yr:
|
||||
return f"{subj}-{code}-{sem}{yr}"
|
||||
elif sem:
|
||||
return f"{subj}-{code}-{sem}"
|
||||
elif yr:
|
||||
return f"{subj}-{code}-{yr}"
|
||||
return f"{subj}-{code}"
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Commission {self.get_full_code()}>'
|
||||
|
||||
|
||||
class CommissionTeacher(db.Model):
|
||||
"""Modelo de asignación de múltiples docentes a una comisión (Co-Docencia / Cátedra)."""
|
||||
__tablename__ = 'commission_teachers'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
commission_id = db.Column(db.Integer, db.ForeignKey('commissions.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
role = db.Column(db.String(50), default='Titular', nullable=False) # Titular, Adjunto, JTP, Ayudante
|
||||
is_primary = db.Column(db.Boolean, default=False, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
commission = db.relationship('Commission', backref=db.backref('teachers_association', cascade='all, delete-orphan', lazy='joined'))
|
||||
teacher = db.relationship('User', backref=db.backref('commission_assignments', cascade='all, delete-orphan', lazy='joined'))
|
||||
|
||||
__table_args__ = (db.UniqueConstraint('commission_id', 'user_id'),)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'commission_id': self.commission_id,
|
||||
'user_id': self.user_id,
|
||||
'name': self.teacher.name if self.teacher else 'Docente',
|
||||
'email': self.teacher.email if self.teacher else '',
|
||||
'role': self.role or 'Titular',
|
||||
'is_primary': self.is_primary,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<CommissionTeacher Commission:{self.commission_id} User:{self.user_id} Role:{self.role}>'␍
|
||||
@@ -0,0 +1,69 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
class MoodleSyncTask(db.Model):
|
||||
"""
|
||||
Cola persistente de eventos y tareas de sincronización asíncrona hacia Moodle 4.1.
|
||||
Garantiza tolerancia a fallos ante caídas o saturación del servidor Moodle,
|
||||
incorporando reintentos exponenciales y Dead Letter Queue (DLQ).
|
||||
"""
|
||||
__tablename__ = 'moodle_sync_tasks'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
action = db.Column(db.String(50), nullable=False, index=True) # CREATE_USER, UPDATE_USER, ENROL_USER, UNENROL_USER, ASSIGN_ROLE
|
||||
entity_type = db.Column(db.String(50), nullable=False) # user, commission, enrollment
|
||||
entity_id = db.Column(db.String(100), nullable=True)
|
||||
_payload = db.Column('payload', db.Text, nullable=False) # JSON serializado en Text
|
||||
status = db.Column(db.String(20), default='PENDING', index=True) # PENDING, PROCESSING, RETRYING, COMPLETED, FAILED
|
||||
attempts = db.Column(db.Integer, default=0)
|
||||
max_attempts = db.Column(db.Integer, default=5)
|
||||
error_message = db.Column(db.Text, nullable=True)
|
||||
next_retry_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
if 'payload' in kwargs:
|
||||
raw_payload = kwargs.pop('payload')
|
||||
if isinstance(raw_payload, (dict, list)):
|
||||
kwargs['_payload'] = json.dumps(raw_payload)
|
||||
else:
|
||||
kwargs['_payload'] = str(raw_payload or '{}')
|
||||
super().__init__(**kwargs)
|
||||
|
||||
@property
|
||||
def payload(self):
|
||||
try:
|
||||
return json.loads(self._payload) if self._payload else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
@payload.setter
|
||||
def payload(self, val):
|
||||
if isinstance(val, (dict, list)):
|
||||
self._payload = json.dumps(val)
|
||||
else:
|
||||
self._payload = str(val or '{}')
|
||||
|
||||
def get_payload_dict(self):
|
||||
return self.payload
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'action': self.action,
|
||||
'entity_type': self.entity_type,
|
||||
'entity_id': self.entity_id,
|
||||
'payload': self.payload,
|
||||
'status': self.status,
|
||||
'attempts': self.attempts,
|
||||
'max_attempts': self.max_attempts,
|
||||
'error_message': self.error_message,
|
||||
'next_retry_at': self.next_retry_at.isoformat() if self.next_retry_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
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<MoodleSyncTask #{self.id} {self.action} [{self.status}]>'
|
||||
@@ -17,15 +17,29 @@ class User(db.Model):
|
||||
theme_preference = db.Column(db.String(10), default='auto', nullable=False)
|
||||
role_id = db.Column(db.Integer, db.ForeignKey('roles.id', ondelete='SET NULL'), nullable=True)
|
||||
|
||||
# Virtual fields for future compatibility
|
||||
@property
|
||||
def first_name(self):
|
||||
# Extended personal, contact and identity document fields
|
||||
first_name = db.Column(db.String(100), nullable=True)
|
||||
last_name = db.Column(db.String(100), nullable=True)
|
||||
phone = db.Column(db.String(50), nullable=True)
|
||||
personal_email = db.Column(db.String(255), nullable=True)
|
||||
address = db.Column(db.String(255), nullable=True)
|
||||
document_type = db.Column(db.String(20), default='DNI', nullable=True)
|
||||
document_number = db.Column(db.String(30), nullable=True, index=True)
|
||||
|
||||
def get_first_name(self):
|
||||
if self.first_name:
|
||||
return self.first_name
|
||||
return self.name.split()[0] if self.name else ''
|
||||
|
||||
@property
|
||||
def last_name(self):
|
||||
parts = self.name.split()
|
||||
def get_last_name(self):
|
||||
if self.last_name:
|
||||
return self.last_name
|
||||
parts = self.name.split() if self.name else []
|
||||
return ' '.join(parts[1:]) if len(parts) > 1 else ''
|
||||
|
||||
def get_formatted_document(self):
|
||||
from app.constants.document_types import format_document
|
||||
return format_document(self.document_type, self.document_number)
|
||||
|
||||
# Relationships
|
||||
reservations = db.relationship('Reservation', backref='user', lazy=True, cascade='all, delete-orphan')
|
||||
@@ -132,8 +146,18 @@ class User(db.Model):
|
||||
return {
|
||||
'id': self.id,
|
||||
'email': self.email,
|
||||
'institutional_email': self.email,
|
||||
'personal_email': self.personal_email or '',
|
||||
'name': self.name,
|
||||
'first_name': self.get_first_name(),
|
||||
'last_name': self.get_last_name(),
|
||||
'phone': self.phone or '',
|
||||
'address': self.address or '',
|
||||
'document_type': self.document_type or 'DNI',
|
||||
'document_number': self.document_number or '',
|
||||
'document_formatted': self.get_formatted_document(),
|
||||
'role': self.role,
|
||||
'role_id': self.role_id,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'last_login': self.last_login.isoformat() if self.last_login else None,
|
||||
|
||||
@@ -59,3 +59,22 @@ class ReservationRepository(BaseRepository[Reservation]):
|
||||
return Reservation.query.filter_by(
|
||||
commission_id=commission_id
|
||||
).order_by(Reservation.start_time.asc()).all()
|
||||
|
||||
def find_teacher_conflicts(self, teacher_id: int, start_time: datetime, end_time: datetime,
|
||||
exclude_reservation_id: Optional[int] = None) -> List[Reservation]:
|
||||
"""
|
||||
Encuentra reservas activas donde el docente ya está asignado en la misma franja horaria.
|
||||
Condición de solapamiento: (start_time < existing.end_time) AND (end_time > existing.start_time).
|
||||
"""
|
||||
query = Reservation.query.options(
|
||||
joinedload(Reservation.classroom),
|
||||
joinedload(Reservation.commission)
|
||||
).filter(
|
||||
Reservation.user_id == teacher_id,
|
||||
Reservation.status != ReservationStatus.CANCELLED.value,
|
||||
Reservation.start_time < end_time,
|
||||
Reservation.end_time > start_time
|
||||
)
|
||||
if exclude_reservation_id:
|
||||
query = query.filter(Reservation.id != exclude_reservation_id)
|
||||
return query.all()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import func, or_
|
||||
from app.models.user import User
|
||||
from app.repositories.base_repository import BaseRepository
|
||||
|
||||
@@ -10,10 +10,25 @@ class UserRepository(BaseRepository[User]):
|
||||
super().__init__(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:
|
||||
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]:
|
||||
"""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_term_edit', methods=['POST'], defaults={'id': None})
|
||||
@admin_bp.route('/academic_term_edit/<int:id>', methods=['POST'])
|
||||
@login_required
|
||||
def academic_term_edit(id):
|
||||
def academic_term_edit(id=None):
|
||||
"""Edit an academic cycle"""
|
||||
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
|
||||
abort(403)
|
||||
|
||||
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 datetime import datetime
|
||||
term = AcademicTerm.query.get_or_404(id)
|
||||
term = AcademicTerm.query.get_or_404(term_id)
|
||||
|
||||
code = request.form.get('code', '').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%'))
|
||||
).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 = (
|
||||
current_user.has_permission('academic', 'read_write') or
|
||||
current_user.is_admin() or
|
||||
@@ -1228,6 +1238,7 @@ def commissions_list():
|
||||
pagination=pagination,
|
||||
subjects=subjects,
|
||||
teachers=teachers,
|
||||
academic_terms=academic_terms,
|
||||
search=search,
|
||||
subject_filter=subject_filter,
|
||||
teacher_filter=teacher_filter,
|
||||
@@ -1417,3 +1428,195 @@ def commission_update_enrollment(id, student_id):
|
||||
|
||||
flash(_('Estado de inscripcion actualizado.'), 'success')
|
||||
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'))
|
||||
|
||||
|
||||
+1631
-29
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,72 @@
|
||||
"""
|
||||
Authentication Routes (admin-edu-space)
|
||||
Implements Hybrid Authentication:
|
||||
- Local Login with admin fallback
|
||||
- Google OAuth 2.0 (inspired by AlumnosLS domain & email validation)
|
||||
- Moodle Delegated Authentication with dynamic role mapping
|
||||
- Token Refresh & Session Management (Redis / JWT)
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify, g, make_response
|
||||
from pydantic import ValidationError
|
||||
import jwt
|
||||
import requests
|
||||
import logging
|
||||
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
from app.models.role import Role
|
||||
from app.models.setting import SystemSetting
|
||||
from app.services.user_service import UserService
|
||||
from app.services.jwt_service import JWTService
|
||||
from app.services.cache_service import cache_service
|
||||
from app.services.moodle_client import moodle_client
|
||||
from app.schemas.auth_dto import LoginDTO, RefreshTokenDTO
|
||||
from app.utils.jwt_decorators import jwt_required
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
api_auth_bp = Blueprint('api_auth', __name__, url_prefix='/api/v1/auth')
|
||||
user_service = UserService()
|
||||
|
||||
|
||||
@api_auth_bp.route('/providers', methods=['GET'])
|
||||
def get_auth_providers():
|
||||
"""
|
||||
Public endpoint returning active authentication methods and Google client ID for the UI.
|
||||
"""
|
||||
local_val = SystemSetting.get_value('auth_local_enabled', 'true').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')
|
||||
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({
|
||||
'status': 'success',
|
||||
'providers': {
|
||||
'local': local_val,
|
||||
'google': google_val,
|
||||
'moodle': moodle_val
|
||||
},
|
||||
'google_client_id': google_client_id,
|
||||
'demo_access_enabled': demo_access_enabled
|
||||
}), 200
|
||||
|
||||
|
||||
@api_auth_bp.route('/login', methods=['POST'])
|
||||
def login():
|
||||
"""
|
||||
Endpoint de autenticación para obtener par de tokens (Access + Refresh).
|
||||
Native local authentication endpoint (Access + Refresh tokens).
|
||||
Respects global auth_local_enabled setting, allowing emergency ADMIN access if disabled.
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({'error': 'BadRequest', 'message': 'El cuerpo de la solicitud debe ser un objeto JSON.'}), 400
|
||||
try:
|
||||
dto = LoginDTO(**data)
|
||||
except ValidationError as e:
|
||||
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
|
||||
|
||||
local_enabled = SystemSetting.get_value('auth_local_enabled', 'true').lower() in ('true', '1')
|
||||
|
||||
user = user_service.authenticate(dto.email, dto.password)
|
||||
if not user:
|
||||
return jsonify({
|
||||
@@ -28,6 +74,13 @@ def login():
|
||||
'message': 'Credenciales de acceso incorrectas o cuenta inactiva.'
|
||||
}), 401
|
||||
|
||||
is_admin = user.is_admin() or getattr(user, 'role', '').upper() == 'ADMIN'
|
||||
if not local_enabled and not is_admin:
|
||||
return jsonify({
|
||||
'error': 'Forbidden',
|
||||
'message': 'El acceso local con contraseña está deshabilitado por el administrador. Inicie sesión mediante Google OAuth o Moodle.'
|
||||
}), 403
|
||||
|
||||
tokens = JWTService.generate_tokens(user)
|
||||
profile = user_service.get_profile_data(user)
|
||||
|
||||
@@ -40,7 +93,6 @@ def login():
|
||||
}
|
||||
|
||||
resp = make_response(jsonify(response_data), 200)
|
||||
# Almacenar refresh token en cookie segura HttpOnly
|
||||
resp.set_cookie(
|
||||
'refresh_token',
|
||||
tokens['refresh_token'],
|
||||
@@ -51,12 +103,201 @@ def login():
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
@api_auth_bp.route('/google', methods=['POST'])
|
||||
def google_auth():
|
||||
"""
|
||||
Google OAuth 2.0 authentication endpoint.
|
||||
Receives Google profile / token data, verifies domain whitelist (AlumnosLS architecture),
|
||||
creates/updates local user and issues JWT.
|
||||
"""
|
||||
google_enabled = SystemSetting.get_value('auth_google_enabled', 'false').lower() in ('true', '1')
|
||||
if not google_enabled:
|
||||
return jsonify({'error': 'Forbidden', 'message': 'El inicio de sesión con Google no está habilitado.'}), 403
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
email = data.get('email', '').strip().lower()
|
||||
name = data.get('name', '').strip()
|
||||
domain = data.get('domain') or (email.split('@')[1] if '@' in email else '')
|
||||
photo = data.get('photo', '')
|
||||
|
||||
if not email:
|
||||
return jsonify({'error': 'BadRequest', 'message': 'El email de Google es obligatorio.'}), 400
|
||||
|
||||
# Domain restriction check
|
||||
allowed_domains_str = SystemSetting.get_value('google_allowed_domains', 'unicaba.edu.ar')
|
||||
allowed_domains = [d.strip().lower() for d in allowed_domains_str.split(',') if d.strip()]
|
||||
|
||||
if allowed_domains and domain.lower() not in allowed_domains:
|
||||
logger.warning(f"[GoogleAuth] Access denied for {email}: domain {domain} not in {allowed_domains}")
|
||||
return jsonify({
|
||||
'error': 'Forbidden',
|
||||
'message': f'Acceso denegado. El dominio @{domain} no está autorizado en esta institución.'
|
||||
}), 403
|
||||
|
||||
user = User.query.filter(User.email.ilike(email)).first()
|
||||
if not user:
|
||||
# Create local user on first Google login
|
||||
parts = name.split()
|
||||
first_name = parts[0] if parts else 'Usuario'
|
||||
last_name = ' '.join(parts[1:]) if len(parts) > 1 else 'Google'
|
||||
|
||||
# Default role: Docente
|
||||
docente_role = Role.query.filter(Role.name.ilike('Docente')).first()
|
||||
user = User(
|
||||
email=email,
|
||||
name=name or f"{first_name} {last_name}",
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
role='Docente' if not docente_role else docente_role.name,
|
||||
role_id=docente_role.id if docente_role else None,
|
||||
is_active=True
|
||||
)
|
||||
user.set_password(f"GoogleSSO_{email}")
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
logger.info(f"[GoogleAuth] Created new local user for {email} with role Docente.")
|
||||
|
||||
if not user.is_active:
|
||||
return jsonify({'error': 'Unauthorized', 'message': 'Su cuenta se encuentra inactiva. Contacte a Bedelía.'}), 401
|
||||
|
||||
tokens = JWTService.generate_tokens(user)
|
||||
profile = user_service.get_profile_data(user)
|
||||
|
||||
response_data = {
|
||||
'access_token': tokens['access_token'],
|
||||
'refresh_token': tokens['refresh_token'],
|
||||
'token_type': tokens['token_type'],
|
||||
'expires_in': tokens['expires_in'],
|
||||
'user': profile
|
||||
}
|
||||
|
||||
resp = make_response(jsonify(response_data), 200)
|
||||
resp.set_cookie(
|
||||
'refresh_token',
|
||||
tokens['refresh_token'],
|
||||
httponly=True,
|
||||
samesite='Lax',
|
||||
max_age=7 * 24 * 3600,
|
||||
path='/api/v1/auth/refresh'
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
@api_auth_bp.route('/moodle', methods=['POST'])
|
||||
def moodle_auth():
|
||||
"""
|
||||
Moodle Delegated Authentication endpoint.
|
||||
Validates user credentials against Moodle server (/login/token.php),
|
||||
fetches Moodle profile, implements initial role mapping (if new user),
|
||||
and caches profile in Redis.
|
||||
"""
|
||||
moodle_enabled = SystemSetting.get_value('auth_moodle_enabled', 'false').lower() in ('true', '1')
|
||||
if not moodle_enabled:
|
||||
return jsonify({'error': 'Forbidden', 'message': 'El inicio de sesión con Moodle no está habilitado.'}), 403
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
username = data.get('username', '').strip()
|
||||
password = data.get('password', '').strip()
|
||||
|
||||
if not username or not password:
|
||||
return jsonify({'error': 'BadRequest', 'message': 'Usuario y contraseña de Moodle son obligatorios.'}), 400
|
||||
|
||||
server_url = SystemSetting.get_value('moodle_server_url', 'http://10.0.0.207/moodle').rstrip('/')
|
||||
|
||||
# Authenticate against Moodle login/token.php
|
||||
token_url = f"{server_url}/login/token.php"
|
||||
try:
|
||||
resp = requests.post(token_url, data={
|
||||
'username': username,
|
||||
'password': password,
|
||||
'service': 'moodle_mobile_app'
|
||||
}, timeout=10)
|
||||
res_data = resp.json()
|
||||
except Exception as e:
|
||||
logger.error(f"[MoodleAuth] Connection error to Moodle server: {e}")
|
||||
return jsonify({'error': 'ServiceUnavailable', 'message': 'No se pudo conectar con el servidor de Moodle. Intente más tarde.'}), 503
|
||||
|
||||
if 'error' in res_data or 'token' not in res_data:
|
||||
err_msg = res_data.get('error', 'Credenciales inválidas en Moodle.')
|
||||
return jsonify({'error': 'Unauthorized', 'message': f'Moodle: {err_msg}'}), 401
|
||||
|
||||
moodle_user_token = res_data['token']
|
||||
|
||||
# Retrieve Moodle user profile via Web Services
|
||||
moodle_profile = None
|
||||
cache_key = f"moodle_user:{username.lower()}"
|
||||
cached = cache_service.get(cache_key)
|
||||
if cached:
|
||||
moodle_profile = cached
|
||||
else:
|
||||
try:
|
||||
m_users = moodle_client.get_users([{'key': 'username', 'value': username.lower()}])
|
||||
if m_users and isinstance(m_users, list) and len(m_users) > 0:
|
||||
moodle_profile = m_users[0]
|
||||
cache_service.set(cache_key, moodle_profile, ttl_seconds=3600)
|
||||
except Exception as e:
|
||||
logger.warning(f"[MoodleAuth] Could not fetch extended Moodle profile: {e}")
|
||||
|
||||
email = moodle_profile.get('email') if moodle_profile else f"{username}@unicaba.edu.ar"
|
||||
firstname = moodle_profile.get('firstname', username) if moodle_profile else username
|
||||
lastname = moodle_profile.get('lastname', 'Moodle') if moodle_profile else 'Moodle'
|
||||
|
||||
# Local user lookup or creation
|
||||
user = User.query.filter((User.email.ilike(email)) | (User.email.ilike(f"{username}@%"))).first()
|
||||
if not user:
|
||||
# Determine initial role: if username is admin or has manager role -> ADMIN, else Docente
|
||||
role_name = 'ADMIN' if username.lower() in ('admin', 'manager') else 'Docente'
|
||||
role_obj = Role.query.filter(Role.name.ilike(role_name)).first()
|
||||
|
||||
user = User(
|
||||
email=email,
|
||||
name=f"{firstname} {lastname}".strip(),
|
||||
first_name=firstname,
|
||||
last_name=lastname,
|
||||
role=role_obj.name if role_obj else role_name,
|
||||
role_id=role_obj.id if role_obj else None,
|
||||
is_active=True
|
||||
)
|
||||
user.set_password(f"MoodleLinked_{username}")
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
logger.info(f"[MoodleAuth] Created new local user for Moodle username '{username}' with role '{role_name}'.")
|
||||
|
||||
if not user.is_active:
|
||||
return jsonify({'error': 'Unauthorized', 'message': 'Su cuenta en Edu-Space está desactivada.'}), 401
|
||||
|
||||
tokens = JWTService.generate_tokens(user)
|
||||
profile = user_service.get_profile_data(user)
|
||||
|
||||
response_data = {
|
||||
'access_token': tokens['access_token'],
|
||||
'refresh_token': tokens['refresh_token'],
|
||||
'token_type': tokens['token_type'],
|
||||
'expires_in': tokens['expires_in'],
|
||||
'user': profile
|
||||
}
|
||||
|
||||
resp = make_response(jsonify(response_data), 200)
|
||||
resp.set_cookie(
|
||||
'refresh_token',
|
||||
tokens['refresh_token'],
|
||||
httponly=True,
|
||||
samesite='Lax',
|
||||
max_age=7 * 24 * 3600,
|
||||
path='/api/v1/auth/refresh'
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
@api_auth_bp.route('/refresh', methods=['POST'])
|
||||
def refresh():
|
||||
"""
|
||||
Renovación silenciosa del Access Token utilizando el Refresh Token.
|
||||
Silent Access Token renewal using Refresh Token.
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
refresh_token = data.get('refresh_token') or request.cookies.get('refresh_token')
|
||||
|
||||
if not refresh_token:
|
||||
@@ -88,22 +329,73 @@ def refresh():
|
||||
except jwt.InvalidTokenError as e:
|
||||
return jsonify({'error': 'InvalidToken', 'message': str(e)}), 401
|
||||
|
||||
|
||||
@api_auth_bp.route('/logout', methods=['POST'])
|
||||
@jwt_required
|
||||
def logout():
|
||||
"""
|
||||
Cierre de sesión: revoca el token de acceso activo y limpia cookies.
|
||||
Session logout: revokes active access token and clears cookies.
|
||||
"""
|
||||
JWTService.revoke_token(g.jwt_token)
|
||||
resp = make_response(jsonify({'message': 'Sesión finalizada y token revocado exitosamente.'}), 200)
|
||||
resp.delete_cookie('refresh_token', path='/api/v1/auth/refresh')
|
||||
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'])
|
||||
@jwt_required
|
||||
def get_current_user():
|
||||
"""
|
||||
Retorna el perfil y los permisos del usuario autenticado vía JWT.
|
||||
Returns current authenticated profile and permissions.
|
||||
"""
|
||||
profile = user_service.get_profile_data(g.jwt_user)
|
||||
return jsonify(profile), 200
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app import db
|
||||
from app.models.classroom import Classroom
|
||||
from app.services.classroom_service import ClassroomService
|
||||
from app.schemas.classroom_dto import ClassroomCreateDTO, ClassroomUpdateDTO, ClassroomResponseDTO
|
||||
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({'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.subject import Commission
|
||||
from app.models.setting import SystemSetting
|
||||
|
||||
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()
|
||||
audit_data = [{
|
||||
'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,
|
||||
'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]
|
||||
|
||||
occupancy_rate = min(100, int((today_reservations / max(1, physical_classrooms * 3)) * 100)) if physical_classrooms > 0 else 0
|
||||
@@ -149,6 +150,92 @@ def get_stats():
|
||||
upcoming_reservations = [serialize_reservation(r) for r in upcoming_reservations_q]
|
||||
milestones = [serialize_milestone(m) for m in milestones_q]
|
||||
|
||||
# ---- Bedelía: Comisiones sin aula asignada (semana y mes) ----
|
||||
import calendar
|
||||
# Calcular rango de semana actual (lunes a domingo)
|
||||
today_dt = datetime.utcnow()
|
||||
week_start = today_dt - timedelta(days=today_dt.weekday()) # lunes
|
||||
week_start = week_start.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_end = week_start + timedelta(days=6, hours=23, minutes=59, seconds=59)
|
||||
|
||||
# Calcular rango del mes actual
|
||||
month_start = today_dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
last_day = calendar.monthrange(today_dt.year, today_dt.month)[1]
|
||||
month_end = today_dt.replace(day=last_day, hour=23, minute=59, second=59, microsecond=0)
|
||||
|
||||
# Comisiones activas totales
|
||||
all_active_commissions = Commission.query.filter_by(active=True).all()
|
||||
total_active_commissions = len(all_active_commissions)
|
||||
|
||||
# IDs de comisiones que SÍ tienen reserva en la semana
|
||||
comm_ids_with_res_week = set(
|
||||
r[0] for r in db.session.query(Reservation.commission_id).filter(
|
||||
Reservation.commission_id.isnot(None),
|
||||
Reservation.start_time >= week_start,
|
||||
Reservation.start_time <= week_end
|
||||
).distinct().all()
|
||||
)
|
||||
# IDs de comisiones que SÍ tienen reserva en el mes
|
||||
comm_ids_with_res_month = set(
|
||||
r[0] for r in db.session.query(Reservation.commission_id).filter(
|
||||
Reservation.commission_id.isnot(None),
|
||||
Reservation.start_time >= month_start,
|
||||
Reservation.start_time <= month_end
|
||||
).distinct().all()
|
||||
)
|
||||
|
||||
unassigned_commissions_week = []
|
||||
unassigned_commissions_month = []
|
||||
for comm in all_active_commissions:
|
||||
comm_data = {
|
||||
'id': comm.id,
|
||||
'code': comm.code,
|
||||
'full_code': comm.get_full_code(),
|
||||
'subject_name': comm.subject.name if comm.subject else 'Sin materia',
|
||||
'subject_code': comm.subject.code if comm.subject else '',
|
||||
'teacher_name': comm.teacher_name,
|
||||
'schedule': comm.schedule or 'Sin horario',
|
||||
'shift': comm.shift or 'Sin turno',
|
||||
'max_students': comm.max_students,
|
||||
'current_students': comm.current_students
|
||||
}
|
||||
if comm.id not in comm_ids_with_res_week:
|
||||
unassigned_commissions_week.append(comm_data)
|
||||
if comm.id not in comm_ids_with_res_month:
|
||||
unassigned_commissions_month.append(comm_data)
|
||||
|
||||
# ---- Bedelía: Aulas disponibles (sin reservas) en la semana y mes ----
|
||||
classroom_ids_used_week = set(
|
||||
r[0] for r in db.session.query(Reservation.classroom_id).filter(
|
||||
Reservation.classroom_id.isnot(None),
|
||||
Reservation.start_time >= week_start,
|
||||
Reservation.start_time <= week_end
|
||||
).distinct().all()
|
||||
)
|
||||
classroom_ids_used_month = set(
|
||||
r[0] for r in db.session.query(Reservation.classroom_id).filter(
|
||||
Reservation.classroom_id.isnot(None),
|
||||
Reservation.start_time >= month_start,
|
||||
Reservation.start_time <= month_end
|
||||
).distinct().all()
|
||||
)
|
||||
|
||||
available_classrooms_week = []
|
||||
available_classrooms_month = []
|
||||
for c in all_active_classrooms:
|
||||
c_data = {
|
||||
'id': c.id,
|
||||
'code': c.code,
|
||||
'building': c.building or 'Sin edificio',
|
||||
'floor': c.floor or '',
|
||||
'capacity': c.capacity,
|
||||
'is_virtual': getattr(c, 'is_virtual', False) or 'virtual' in (c.building or '').lower() or 'virtual' in (c.code or '').lower()
|
||||
}
|
||||
if c.id not in classroom_ids_used_week:
|
||||
available_classrooms_week.append(c_data)
|
||||
if c.id not in classroom_ids_used_month:
|
||||
available_classrooms_month.append(c_data)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fetching stats: {e}")
|
||||
total_classrooms = 0
|
||||
@@ -171,6 +258,11 @@ def get_stats():
|
||||
pending_reservations = []
|
||||
today_schedule_list = []
|
||||
upcoming_reservations = []
|
||||
unassigned_commissions_week = []
|
||||
unassigned_commissions_month = []
|
||||
available_classrooms_week = []
|
||||
available_classrooms_month = []
|
||||
total_active_commissions = 0
|
||||
milestones = []
|
||||
|
||||
# Mocked monthly data for chart since complex SQL group by month might differ based on dialect (SQLite vs Postgres)
|
||||
@@ -207,14 +299,24 @@ def get_stats():
|
||||
'assigned_today_classrooms': assigned_today_count,
|
||||
'assigned_today_pct': assigned_today_pct,
|
||||
'unassigned_today_classrooms': unassigned_today_count,
|
||||
'unassigned_today_pct': unassigned_today_pct
|
||||
'unassigned_today_pct': unassigned_today_pct,
|
||||
'total_active_commissions': total_active_commissions
|
||||
},
|
||||
'audit_logs': audit_data,
|
||||
'monthly_data': monthly_data,
|
||||
'pending_reservations': pending_reservations,
|
||||
'today_schedule_list': today_schedule_list,
|
||||
'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': {
|
||||
'unassigned_commissions_week': unassigned_commissions_week,
|
||||
'unassigned_commissions_month': unassigned_commissions_month,
|
||||
'available_classrooms_week': available_classrooms_week,
|
||||
'available_classrooms_month': available_classrooms_month
|
||||
}
|
||||
}), 200
|
||||
|
||||
@api_dashboard_bp.route('/sync-sheets', methods=['POST'])
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
api_openapi_bp = Blueprint('api_openapi', __name__, url_prefix='/api/v1')
|
||||
|
||||
OPENAPI_SPEC = {
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "Edu-Space REST API",
|
||||
"description": "Enterprise Academic & Physical Space Management REST API with JWT Authentication and Role-Based Access Control.",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "http://127.0.0.1:5000",
|
||||
"description": "Local Flask Backend Server"
|
||||
}
|
||||
],
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT"
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"LoginRequest": {
|
||||
"type": "object",
|
||||
"required": ["email", "password"],
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"example": "admin@eduspace.com"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"format": "password",
|
||||
"example": "Admin123!"
|
||||
}
|
||||
}
|
||||
},
|
||||
"LoginResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"access_token": {"type": "string"},
|
||||
"refresh_token": {"type": "string"},
|
||||
"token_type": {"type": "string", "example": "Bearer"},
|
||||
"expires_in": {"type": "integer"},
|
||||
"user": {"type": "object"}
|
||||
}
|
||||
},
|
||||
"ErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {"type": "string"},
|
||||
"message": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"UserResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "integer"},
|
||||
"email": {"type": "string"},
|
||||
"first_name": {"type": "string"},
|
||||
"last_name": {"type": "string"},
|
||||
"role": {"type": "string"},
|
||||
"is_active": {"type": "boolean"}
|
||||
}
|
||||
},
|
||||
"SubjectResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "integer"},
|
||||
"name": {"type": "string"},
|
||||
"code": {"type": "string"},
|
||||
"career": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"ClassroomResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "integer"},
|
||||
"name": {"type": "string"},
|
||||
"capacity": {"type": "integer"},
|
||||
"building": {"type": "string"},
|
||||
"floor": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/auth/login": {
|
||||
"post": {
|
||||
"summary": "Authenticate user and issue JWT tokens",
|
||||
"tags": ["Authentication"],
|
||||
"requestBody": {
|
||||
"required": True,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LoginRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Authentication successful",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LoginResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/ErrorResponse"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized / Bad credentials",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/ErrorResponse"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/me": {
|
||||
"get": {
|
||||
"summary": "Get authenticated user profile",
|
||||
"tags": ["Authentication"],
|
||||
"security": [{"bearerAuth": []}],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Current user profile",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/UserResponse"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/ErrorResponse"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/classrooms": {
|
||||
"get": {
|
||||
"summary": "List all physical classrooms and facilities",
|
||||
"tags": ["Classrooms"],
|
||||
"security": [{"bearerAuth": []}],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of classrooms",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/ClassroomResponse"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/users": {
|
||||
"get": {
|
||||
"summary": "List all users (Admin only)",
|
||||
"tags": ["Admin - Users"],
|
||||
"security": [{"bearerAuth": []}],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of users",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/UserResponse"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - Requires admin role"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/subjects": {
|
||||
"get": {
|
||||
"summary": "List all academic subjects (Admin only)",
|
||||
"tags": ["Admin - Academic"],
|
||||
"security": [{"bearerAuth": []}],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of subjects",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/SubjectResponse"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/openapi.json": {
|
||||
"get": {
|
||||
"summary": "Get OpenAPI 3.0.3 specification JSON",
|
||||
"tags": ["Documentation"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OpenAPI Specification"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@api_openapi_bp.route('/openapi.json', methods=['GET'])
|
||||
def get_openapi_spec():
|
||||
"""Serves the OpenAPI 3.0 specification for Schemathesis / DAST and Swagger UI."""
|
||||
return jsonify(OPENAPI_SPEC)
|
||||
@@ -2,6 +2,8 @@ from flask import Blueprint, request, jsonify, g
|
||||
from datetime import datetime, timedelta, date
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app import db
|
||||
from app.models.reservation import Reservation
|
||||
from app.services.reservation_service import ReservationService
|
||||
from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO
|
||||
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'])
|
||||
@jwt_required
|
||||
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)
|
||||
if not cancelled:
|
||||
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
|
||||
|
||||
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'])
|
||||
@jwt_required
|
||||
def cancel_reservation_post(reservation_id: int):
|
||||
@@ -245,3 +267,48 @@ def confirm_reservation(reservation_id: int):
|
||||
|
||||
return jsonify({'message': f'Reserva {reservation_id} confirmada correctamente.'}), 200
|
||||
|
||||
@api_reservations_bp.route('/check-conflicts', methods=['POST'])
|
||||
@jwt_required
|
||||
def check_conflicts_endpoint():
|
||||
"""
|
||||
Evalúa la matriz completa de conflictos (físico, docente y aforo) en tiempo real.
|
||||
"""
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({'error': 'BadRequest', 'message': 'El cuerpo de la solicitud debe ser un objeto JSON.'}), 400
|
||||
|
||||
classroom_id = data.get('classroom_id')
|
||||
start_str = data.get('start_time')
|
||||
end_str = data.get('end_time')
|
||||
|
||||
if not classroom_id or not start_str or not end_str:
|
||||
return jsonify({'error': 'BadRequest', 'message': 'classroom_id, start_time y end_time son obligatorios.'}), 400
|
||||
|
||||
try:
|
||||
start_time = datetime.fromisoformat(str(start_str).replace('Z', '+00:00'))
|
||||
end_time = datetime.fromisoformat(str(end_str).replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
return jsonify({'error': 'BadRequest', 'message': 'Formato inválido de fecha/hora (ISO 8601 esperado).'}), 400
|
||||
|
||||
teacher_id = data.get('teacher_id')
|
||||
commission_id = data.get('commission_id')
|
||||
expected_attendees = data.get('expected_attendees')
|
||||
allow_co_teaching = bool(data.get('allow_co_teaching_exception', False))
|
||||
exclude_id = data.get('exclude_id')
|
||||
|
||||
try:
|
||||
matrix = reservation_service.check_full_conflicts_matrix(
|
||||
classroom_id=int(classroom_id),
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
teacher_id=int(teacher_id) if teacher_id else None,
|
||||
commission_id=int(commission_id) if commission_id else None,
|
||||
expected_attendees=int(expected_attendees) if expected_attendees is not None else None,
|
||||
allow_co_teaching_exception=allow_co_teaching,
|
||||
exclude_id=int(exclude_id) if exclude_id else None
|
||||
)
|
||||
return jsonify(matrix), 200
|
||||
except ValueError as e:
|
||||
return jsonify({'error': 'ValidationError', 'message': str(e)}), 400
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from flask import Blueprint, jsonify, request, g
|
||||
from app.utils.jwt_decorators import jwt_required
|
||||
from app.services.gradebook_service import GradebookService
|
||||
|
||||
api_students_bp = Blueprint('api_students', __name__)
|
||||
|
||||
|
||||
@api_students_bp.route('/students/my-grades', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_my_grades():
|
||||
"""Consulta individual de calificaciones y estado académico del alumno logueado."""
|
||||
user = getattr(g, 'jwt_user', None)
|
||||
user_id = user.id if user else getattr(request, 'current_user_id', None)
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'message': 'Usuario no autenticado.'}), 401
|
||||
|
||||
try:
|
||||
grades = GradebookService.get_student_grades(user_id)
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'student_id': user_id,
|
||||
'is_impersonating': getattr(g, 'is_impersonating', False),
|
||||
'data': grades
|
||||
}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': 'ServerError', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@api_students_bp.route('/students/my-upcoming-milestones', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_my_upcoming_milestones():
|
||||
"""Próximas evaluaciones e hitos académicos con cuenta regresiva para el dashboard."""
|
||||
user = getattr(g, 'jwt_user', None)
|
||||
user_id = user.id if user else getattr(request, 'current_user_id', None)
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'message': 'Usuario no autenticado.'}), 401
|
||||
|
||||
limit = request.args.get('limit', default=5, type=int)
|
||||
try:
|
||||
milestones = GradebookService.get_student_upcoming_milestones(user_id, limit=limit)
|
||||
return jsonify({'status': 'success', 'data': milestones}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': 'ServerError', 'message': str(e)}), 500
|
||||
@@ -16,6 +16,9 @@ def login():
|
||||
if current_user.is_authenticated:
|
||||
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()
|
||||
if form.validate_on_submit():
|
||||
clean_email = (form.email.data or '').strip().lower()
|
||||
@@ -24,7 +27,7 @@ def login():
|
||||
if user and user.check_password(form.password.data):
|
||||
if not user.is_active:
|
||||
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)
|
||||
user.update_last_login()
|
||||
@@ -69,7 +72,7 @@ def login():
|
||||
pass
|
||||
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')
|
||||
@login_required
|
||||
|
||||
@@ -32,15 +32,18 @@ def list_buildings():
|
||||
|
||||
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_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(
|
||||
'buildings/list.html',
|
||||
buildings=buildings,
|
||||
total_classrooms=total_classrooms,
|
||||
total_capacity=total_capacity,
|
||||
total_physical_capacity=total_physical_capacity,
|
||||
search=search,
|
||||
status_filter=status_filter,
|
||||
title='Building Management'
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
from flask import Blueprint, request, jsonify, current_app, render_template
|
||||
from flask import Blueprint, request, jsonify, current_app, render_template, g
|
||||
from datetime import datetime, timedelta
|
||||
from app.models.genetic_algorithm import ReservationOptimizer
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from app import db
|
||||
from flask_login import login_required, current_user
|
||||
from app.utils.jwt_decorators import jwt_required
|
||||
|
||||
genetic_bp = Blueprint('genetic_algorithm', __name__, url_prefix='/api/genetic')
|
||||
|
||||
|
||||
@genetic_bp.route('/optimize', methods=['POST'])
|
||||
@login_required
|
||||
@jwt_required
|
||||
def optimize_reservations():
|
||||
"""Optimize reservations using genetic algorithm"""
|
||||
try:
|
||||
@@ -40,14 +40,14 @@ def optimize_reservations():
|
||||
return jsonify({'error': 'Invalid end_date format. Use ISO format.'}), 400
|
||||
|
||||
# Check if user has permission (admin, teacher or RBAC optimizer write)
|
||||
if not (current_user.is_admin() or current_user.has_permission('optimizer', 'read_write') or current_user.role in ['ADMIN', 'teacher']):
|
||||
if not (g.jwt_user.is_admin() or g.jwt_user.has_permission('optimizer', 'read_write') or g.jwt_user.role in ['ADMIN', 'teacher']):
|
||||
return jsonify({'error': 'Insufficient permissions to optimize reservations'}), 403
|
||||
|
||||
# Initialize optimizer and run optimization
|
||||
optimizer = ReservationOptimizer()
|
||||
result = optimizer.optimize_schedule(
|
||||
commission_ids=commission_ids,
|
||||
admin_user_id=current_user.id,
|
||||
admin_user_id=g.jwt_user.id,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
@@ -69,7 +69,7 @@ def optimize_reservations():
|
||||
|
||||
|
||||
@genetic_bp.route('/apply-optimization', methods=['POST'])
|
||||
@login_required
|
||||
@jwt_required
|
||||
def apply_optimization():
|
||||
"""Apply optimized reservations to the database"""
|
||||
try:
|
||||
@@ -84,7 +84,7 @@ def apply_optimization():
|
||||
return jsonify({'error': 'reservations must be a list'}), 400
|
||||
|
||||
# Check if user has permission
|
||||
if not (current_user.is_admin() or current_user.has_permission('optimizer', 'read_write') or current_user.role in ['ADMIN', 'teacher']):
|
||||
if not (g.jwt_user.is_admin() or g.jwt_user.has_permission('optimizer', 'read_write') or g.jwt_user.role in ['ADMIN', 'teacher']):
|
||||
return jsonify({'error': 'Insufficient permissions to apply reservations'}), 403
|
||||
|
||||
# Initialize optimizer and apply reservations
|
||||
@@ -106,7 +106,7 @@ def apply_optimization():
|
||||
|
||||
|
||||
@genetic_bp.route('/preview-optimization', methods=['POST'])
|
||||
@login_required
|
||||
@jwt_required
|
||||
def preview_optimization():
|
||||
"""Preview optimization without applying to database"""
|
||||
try:
|
||||
@@ -124,14 +124,14 @@ def preview_optimization():
|
||||
return jsonify({'error': 'commission_ids must be a non-empty list'}), 400
|
||||
|
||||
# Check if user has permission
|
||||
if current_user.role not in ['ADMIN', 'teacher']:
|
||||
if g.jwt_user.role not in ['ADMIN', 'teacher']:
|
||||
return jsonify({'error': 'Insufficient permissions to preview optimization'}), 403
|
||||
|
||||
# Initialize optimizer and run optimization
|
||||
optimizer = ReservationOptimizer()
|
||||
result = optimizer.optimize_schedule(
|
||||
commission_ids=commission_ids,
|
||||
admin_user_id=current_user.id
|
||||
admin_user_id=g.jwt_user.id
|
||||
)
|
||||
|
||||
# Add additional preview information
|
||||
@@ -166,12 +166,12 @@ def preview_optimization():
|
||||
|
||||
|
||||
@genetic_bp.route('/commissions', methods=['GET'])
|
||||
@login_required
|
||||
@jwt_required
|
||||
def get_commissions():
|
||||
"""Get available commissions for optimization"""
|
||||
try:
|
||||
# Check if user has permission
|
||||
if current_user.role not in ['ADMIN', 'teacher']:
|
||||
if g.jwt_user.role not in ['ADMIN', 'teacher']:
|
||||
return jsonify({'error': 'Insufficient permissions'}), 403
|
||||
|
||||
from app.models.subject import Commission, Subject
|
||||
@@ -196,12 +196,12 @@ def get_commissions():
|
||||
|
||||
|
||||
@genetic_bp.route('/algorithm-status', methods=['GET'])
|
||||
@login_required
|
||||
@jwt_required
|
||||
def get_algorithm_status():
|
||||
"""Get genetic algorithm configuration status"""
|
||||
try:
|
||||
# Check if user has permission
|
||||
if current_user.role not in ['ADMIN']:
|
||||
if g.jwt_user.role not in ['ADMIN']:
|
||||
return jsonify({'error': 'Insufficient permissions'}), 403
|
||||
|
||||
# Get system status
|
||||
@@ -246,10 +246,10 @@ genetic_web_bp = Blueprint('genetic_algorithm_web', __name__)
|
||||
|
||||
|
||||
@genetic_web_bp.route('/genetic-optimizer')
|
||||
@login_required
|
||||
@jwt_required
|
||||
def genetic_optimizer():
|
||||
"""Render the genetic algorithm optimizer interface"""
|
||||
if not (current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role.upper() == 'ADMIN')):
|
||||
if not (g.jwt_user.has_permission('optimizer', 'read') or (g.jwt_user.role and g.jwt_user.role.upper() == 'ADMIN')):
|
||||
return render_template('auth/403.html'), 403
|
||||
|
||||
return render_template('genetic_optimizer.html')
|
||||
@@ -22,7 +22,7 @@ class ReservationBaseDTO(BaseModel):
|
||||
return self
|
||||
|
||||
class ReservationCreateDTO(ReservationBaseDTO):
|
||||
pass
|
||||
allow_co_teaching_exception: Optional[bool] = Field(default=False, description="Excepción justificada si hay co-docencia")
|
||||
|
||||
class ReservationUpdateDTO(BaseModel):
|
||||
classroom_id: Optional[int] = None
|
||||
@@ -34,6 +34,7 @@ class ReservationUpdateDTO(BaseModel):
|
||||
virtual_link: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
allow_co_teaching_exception: Optional[bool] = False
|
||||
|
||||
class ReservationResponseDTO(ReservationBaseDTO):
|
||||
id: int
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class CacheService:
|
||||
"""
|
||||
Servicio de caché híbrido para sesiones y lecturas rápidas de Moodle.
|
||||
Soporta Redis si está disponible en la infraestructura y hace fallback transparente
|
||||
a caché en memoria con TTL para entornos de desarrollo y pruebas.
|
||||
"""
|
||||
_memory_cache = {}
|
||||
_redis_client = None
|
||||
_redis_checked = False
|
||||
|
||||
@classmethod
|
||||
def _get_redis(cls):
|
||||
if not cls._redis_checked:
|
||||
cls._redis_checked = True
|
||||
try:
|
||||
import redis
|
||||
from app.models.setting import SystemSetting
|
||||
redis_url = SystemSetting.get_value('redis_url', 'redis://127.0.0.1:6379/0')
|
||||
client = redis.from_url(redis_url, socket_connect_timeout=2)
|
||||
client.ping()
|
||||
cls._redis_client = client
|
||||
logger.info("Conexión exitosa a Redis en %s", redis_url)
|
||||
except Exception as e:
|
||||
cls._redis_client = None
|
||||
logger.info("Redis no disponible (%s). Operando con memoria local/TTL.", str(e).split('\n')[0])
|
||||
return cls._redis_client
|
||||
|
||||
@classmethod
|
||||
def get(cls, key: str, default=None):
|
||||
r = cls._get_redis()
|
||||
if r:
|
||||
try:
|
||||
val = r.get(key)
|
||||
if val is not None:
|
||||
return json.loads(val.decode('utf-8'))
|
||||
except Exception as e:
|
||||
logger.debug("Error leyendo de Redis: %s", e)
|
||||
|
||||
# Fallback memoria
|
||||
item = cls._memory_cache.get(key)
|
||||
if item:
|
||||
val, expire_at = item
|
||||
if expire_at is None or expire_at > time.time():
|
||||
return val
|
||||
else:
|
||||
del cls._memory_cache[key]
|
||||
return default
|
||||
|
||||
@classmethod
|
||||
def set(cls, key: str, value, ttl_seconds: int = 300):
|
||||
r = cls._get_redis()
|
||||
if r:
|
||||
try:
|
||||
serialized = json.dumps(value)
|
||||
r.setex(key, ttl_seconds, serialized)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("Error escribiendo en Redis: %s", e)
|
||||
|
||||
expire_at = (time.time() + ttl_seconds) if ttl_seconds else None
|
||||
cls._memory_cache[key] = (value, expire_at)
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def delete(cls, key: str):
|
||||
r = cls._get_redis()
|
||||
if r:
|
||||
try:
|
||||
r.delete(key)
|
||||
except Exception:
|
||||
pass
|
||||
cls._memory_cache.pop(key, None)
|
||||
|
||||
@classmethod
|
||||
def clear(cls):
|
||||
cls._memory_cache.clear()
|
||||
cls._redis_checked = False
|
||||
cls._redis_client = None
|
||||
|
||||
cache_service = CacheService()
|
||||
@@ -0,0 +1,46 @@
|
||||
import base64
|
||||
import hashlib
|
||||
from cryptography.fernet import Fernet
|
||||
from flask import current_app
|
||||
|
||||
class CryptoService:
|
||||
"""
|
||||
Servicio de cifrado simétrico seguro para contraseñas y tokens sensibles
|
||||
almacenados en la base de datos (credenciales SMTP, Client Secrets, Moodle Tokens).
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _get_fernet() -> Fernet:
|
||||
# Derivar clave válida para Fernet (32 bytes urlsafe base64) desde SECRET_KEY
|
||||
try:
|
||||
secret = current_app.config.get('SECRET_KEY', 'default-unicaba-edu-space-secret-key-32b!')
|
||||
except RuntimeError:
|
||||
secret = 'default-unicaba-edu-space-secret-key-32b!'
|
||||
|
||||
# Hash SHA-256 para obtener 32 bytes y codificar en base64 seguro para URL
|
||||
key = base64.urlsafe_b64encode(hashlib.sha256(secret.encode('utf-8')).digest())
|
||||
return Fernet(key)
|
||||
|
||||
@classmethod
|
||||
def encrypt(cls, plain_text: str) -> str:
|
||||
"""Cifra un texto plano y retorna el string cifrado."""
|
||||
if not plain_text:
|
||||
return ''
|
||||
fernet = cls._get_fernet()
|
||||
encrypted_bytes = fernet.encrypt(plain_text.encode('utf-8'))
|
||||
return encrypted_bytes.decode('utf-8')
|
||||
|
||||
@classmethod
|
||||
def decrypt(cls, cipher_text: str) -> str:
|
||||
"""Descifra un texto cifrado y retorna el texto original. Si falla, retorna vacío."""
|
||||
if not cipher_text:
|
||||
return ''
|
||||
try:
|
||||
fernet = cls._get_fernet()
|
||||
decrypted_bytes = fernet.decrypt(cipher_text.encode('utf-8'))
|
||||
return decrypted_bytes.decode('utf-8')
|
||||
except Exception:
|
||||
# Si no era un texto cifrado con Fernet o fue alterado, retornar el texto tal cual o vacío
|
||||
return cipher_text
|
||||
|
||||
crypto_service = CryptoService()
|
||||
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
Email Notification Service (admin-edu-space)
|
||||
Dynamically configures and dispatches transactional emails using SMTP credentials
|
||||
stored securely in the database (SystemSetting) with Fernet encryption.
|
||||
"""
|
||||
import smtplib
|
||||
import logging
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import List, Optional, Union, Dict, Any
|
||||
from app.models.setting import SystemSetting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class EmailService:
|
||||
@staticmethod
|
||||
def get_smtp_config() -> Dict[str, Any]:
|
||||
"""
|
||||
Retrieves active SMTP configuration from database.
|
||||
"""
|
||||
host_s = SystemSetting.query.filter_by(key='smtp_host').first()
|
||||
port_s = SystemSetting.query.filter_by(key='smtp_port').first()
|
||||
user_s = SystemSetting.query.filter_by(key='smtp_user').first()
|
||||
pass_s = SystemSetting.query.filter_by(key='smtp_password').first()
|
||||
sec_s = SystemSetting.query.filter_by(key='smtp_security').first()
|
||||
sender_s = SystemSetting.query.filter_by(key='smtp_from_email').first()
|
||||
sender_name_s = SystemSetting.query.filter_by(key='smtp_from_name').first()
|
||||
|
||||
host = host_s.value if host_s and host_s.value else 'smtp.gmail.com'
|
||||
port = int(port_s.value) if port_s and port_s.value else 587
|
||||
user = user_s.value if user_s and user_s.value else ''
|
||||
password = pass_s.get_decrypted_value() if pass_s else ''
|
||||
security = sec_s.value if sec_s and sec_s.value else 'tls'
|
||||
from_email = sender_s.value if sender_s and sender_s.value else user or 'noreply@edu-space.local'
|
||||
from_name = sender_name_s.value if sender_name_s and sender_name_s.value else 'Admin Edu-Space'
|
||||
|
||||
return {
|
||||
'host': host,
|
||||
'port': port,
|
||||
'user': user,
|
||||
'password': password,
|
||||
'security': security.lower(),
|
||||
'from_email': from_email,
|
||||
'from_name': from_name
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def test_smtp_connection(cls, custom_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Tests connection and authentication with the SMTP server.
|
||||
"""
|
||||
config = custom_config or cls.get_smtp_config()
|
||||
host = config.get('host')
|
||||
port = int(config.get('port', 587))
|
||||
user = config.get('user', '')
|
||||
password = config.get('password', '')
|
||||
security = config.get('security', 'tls').lower()
|
||||
|
||||
if not host:
|
||||
return {'success': False, 'message': 'El Host SMTP no está especificado.'}
|
||||
|
||||
try:
|
||||
if security == 'ssl' or port == 465:
|
||||
server = smtplib.SMTP_SSL(host, port, timeout=10)
|
||||
else:
|
||||
server = smtplib.SMTP(host, port, timeout=10)
|
||||
if security in ('tls', 'starttls'):
|
||||
server.starttls()
|
||||
|
||||
if user and password:
|
||||
server.login(user, password)
|
||||
|
||||
server.quit()
|
||||
return {'success': True, 'message': f'Conexión exitosa con el servidor SMTP ({host}:{port}).'}
|
||||
except smtplib.SMTPAuthenticationError as e:
|
||||
return {'success': False, 'message': f'Fallo de autenticación SMTP: Credenciales incorrectas ({e.smtp_code}).'}
|
||||
except smtplib.SMTPConnectError as e:
|
||||
return {'success': False, 'message': f'No se pudo conectar al servidor SMTP: {str(e)}'}
|
||||
except Exception as e:
|
||||
return {'success': False, 'message': f'Error de conexión SMTP: {str(e)}'}
|
||||
|
||||
@classmethod
|
||||
def send_email(cls,
|
||||
to: Union[str, List[str]],
|
||||
subject: str,
|
||||
html_content: str,
|
||||
text_content: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Sends an email using dynamic SMTP configuration.
|
||||
"""
|
||||
config = cls.get_smtp_config()
|
||||
host = config.get('host')
|
||||
port = config.get('port', 587)
|
||||
user = config.get('user', '')
|
||||
password = config.get('password', '')
|
||||
security = config.get('security', 'tls').lower()
|
||||
from_email = config.get('from_email')
|
||||
from_name = config.get('from_name')
|
||||
|
||||
recipients = [to] if isinstance(to, str) else to
|
||||
if not recipients or not recipients[0]:
|
||||
logger.warning("[EmailService] No recipients specified. Aborting send.")
|
||||
return False
|
||||
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = f"{from_name} <{from_email}>"
|
||||
msg['To'] = ", ".join(recipients)
|
||||
|
||||
if text_content:
|
||||
msg.attach(MIMEText(text_content, 'plain', 'utf-8'))
|
||||
if html_content:
|
||||
msg.attach(MIMEText(html_content, 'html', 'utf-8'))
|
||||
|
||||
try:
|
||||
if security == 'ssl' or port == 465:
|
||||
server = smtplib.SMTP_SSL(host, port, timeout=15)
|
||||
else:
|
||||
server = smtplib.SMTP(host, port, timeout=15)
|
||||
if security in ('tls', 'starttls'):
|
||||
server.starttls()
|
||||
|
||||
if user and password:
|
||||
server.login(user, password)
|
||||
|
||||
server.sendmail(from_email, recipients, msg.as_string())
|
||||
server.quit()
|
||||
logger.info(f"[EmailService] Email sent successfully to {recipients}: '{subject}'")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[EmailService] Failed to send email to {recipients}: {e}")
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# Casos de Uso Core: Correos Transaccionales para Profesores/Admin
|
||||
# -------------------------------------------------------------
|
||||
@classmethod
|
||||
def notify_teacher_assignment(cls, teacher_email: str, teacher_name: str, subject_name: str, commission_name: str, schedule: str = "") -> bool:
|
||||
"""
|
||||
Notifica a un profesor sobre la asignación a una comisión/materia.
|
||||
"""
|
||||
subject = f"Asignación Docente: {subject_name} ({commission_name})"
|
||||
html = f"""
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; background-color: #ffffff;">
|
||||
<div style="background-color: #1e293b; padding: 20px; text-align: center; color: #ffffff;">
|
||||
<h2 style="margin: 0; font-size: 20px;">Admin Edu-Space</h2>
|
||||
<p style="margin: 5px 0 0 0; font-size: 14px; color: #94a3b8;">Notificación de Gestión Académica</p>
|
||||
</div>
|
||||
<div style="padding: 24px; color: #334155; line-height: 1.6;">
|
||||
<p style="font-size: 16px;">Estimado/a <strong>{teacher_name}</strong>,</p>
|
||||
<p>Le informamos que ha sido asignado/a como docente a cargo de la siguiente comisión:</p>
|
||||
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 12px 16px; margin: 16px 0; border-radius: 4px;">
|
||||
<p style="margin: 4px 0;"><strong>Materia:</strong> {subject_name}</p>
|
||||
<p style="margin: 4px 0;"><strong>Comisión:</strong> {commission_name}</p>
|
||||
{f'<p style="margin: 4px 0;"><strong>Horario / Aulas:</strong> {schedule}</p>' if schedule else ''}
|
||||
</div>
|
||||
<p>Puede consultar los detalles y la nómina de alumnos ingresando al portal de Admin Edu-Space y a las aulas de Moodle vinculadas.</p>
|
||||
<div style="margin-top: 24px; text-align: center;">
|
||||
<a href="http://10.0.0.217:5000/login" style="background-color: #2563eb; color: #ffffff; padding: 10px 20px; text-decoration: none; border-radius: 6px; font-weight: bold; display: inline-block;">Acceder a Edu-Space</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background-color: #f1f5f9; padding: 12px; text-align: center; font-size: 12px; color: #64748b;">
|
||||
Este es un correo automático generado por Admin Edu-Space. Por favor no responder a esta casilla.
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
text = f"Estimado/a {teacher_name},\n\nHa sido asignado/a a la materia: {subject_name} ({commission_name}). Horario: {schedule}.\n\nAcceda a Edu-Space para más información."
|
||||
return cls.send_email(teacher_email, subject, html, text)
|
||||
|
||||
@classmethod
|
||||
def notify_exam_schedule(cls, teacher_email: str, teacher_name: str, subject_name: str, date_str: str, room: str = "") -> bool:
|
||||
"""
|
||||
Notifica a un profesor sobre la mesa examinadora asignada.
|
||||
"""
|
||||
subject = f"Mesa de Examen Asignada: {subject_name} - {date_str}"
|
||||
html = f"""
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; background-color: #ffffff;">
|
||||
<div style="background-color: #0f172a; padding: 20px; text-align: center; color: #ffffff;">
|
||||
<h2 style="margin: 0; font-size: 20px;">Admin Edu-Space</h2>
|
||||
<p style="margin: 5px 0 0 0; font-size: 14px; color: #cbd5e1;">Mesa de Exámenes Finales</p>
|
||||
</div>
|
||||
<div style="padding: 24px; color: #334155; line-height: 1.6;">
|
||||
<p>Estimado/a <strong>{teacher_name}</strong>,</p>
|
||||
<p>Se le ha asignado la siguiente mesa de examen final:</p>
|
||||
<div style="background-color: #f8fafc; border-left: 4px solid #10b981; padding: 12px 16px; margin: 16px 0; border-radius: 4px;">
|
||||
<p style="margin: 4px 0;"><strong>Materia:</strong> {subject_name}</p>
|
||||
<p style="margin: 4px 0;"><strong>Fecha y Hora:</strong> {date_str}</p>
|
||||
{f'<p style="margin: 4px 0;"><strong>Espacio / Aula:</strong> {room}</p>' if room else ''}
|
||||
</div>
|
||||
<p>Recuerde verificar las actas y regularidades en el sistema.</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
text = f"Estimado/a {teacher_name},\n\nMesa de examen asignada: {subject_name}\nFecha: {date_str}\nAula: {room}"
|
||||
return cls.send_email(teacher_email, subject, html, text)
|
||||
|
||||
email_service = EmailService()
|
||||
@@ -0,0 +1,220 @@
|
||||
import re
|
||||
from typing import Optional, List, Set, Dict, Any
|
||||
from datetime import datetime
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
from app.models.subject import Commission, Subject
|
||||
from app.models.enrollment import StudentEnrollment
|
||||
from app.models.reservation import Reservation
|
||||
|
||||
WEEKDAYS_MAP = {
|
||||
0: 'lunes',
|
||||
1: 'martes',
|
||||
2: 'miercoles',
|
||||
3: 'jueves',
|
||||
4: 'viernes',
|
||||
5: 'sabado',
|
||||
6: 'domingo'
|
||||
}
|
||||
|
||||
SPANISH_DAYS = ['lunes', 'martes', 'miercoles', 'miércoles', 'jueves', 'viernes', 'sabado', 'sábado', 'domingo']
|
||||
|
||||
class EnrollmentService:
|
||||
"""
|
||||
Servicio de matriculación y aplicación de reglas de negocio académicas (Fase 2 MVP):
|
||||
- Control estricto de aforo y cupo de comisión.
|
||||
- Prevención de doble inscripción.
|
||||
- Regla de Restricción Diaria del Alumno (máximo 1 asignatura regular por día calendario).
|
||||
- Soporte de excepciones automáticas por curso corto y excepciones autorizadas de Bedelía.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def normalize_day(day_str: str) -> str:
|
||||
d = day_str.lower().strip()
|
||||
if 'miér' in d or 'mier' in d:
|
||||
return 'miercoles'
|
||||
if 'sáb' in d or 'sab' in d:
|
||||
return 'sabado'
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def get_commission_days(cls, commission: Commission) -> Set[str]:
|
||||
"""Extrae el conjunto de días de cursada de la comisión desde schedule y reservas activas."""
|
||||
days: Set[str] = set()
|
||||
|
||||
# 1. Analizar texto del campo schedule
|
||||
if commission.schedule:
|
||||
sched_lower = commission.schedule.lower()
|
||||
for d in SPANISH_DAYS:
|
||||
if re.search(r'\b' + re.escape(d) + r'\b', sched_lower):
|
||||
days.add(cls.normalize_day(d))
|
||||
|
||||
# 2. Analizar reservas reales de la comisión
|
||||
reservations = Reservation.query.filter(
|
||||
Reservation.commission_id == commission.id,
|
||||
Reservation.status != 'CANCELLED'
|
||||
).all()
|
||||
for r in reservations:
|
||||
if r.start_time:
|
||||
weekday_idx = r.start_time.weekday()
|
||||
if weekday_idx in WEEKDAYS_MAP:
|
||||
days.add(WEEKDAYS_MAP[weekday_idx])
|
||||
|
||||
return days
|
||||
|
||||
@classmethod
|
||||
def list_enrollments(cls, commission_id: int) -> List[Dict[str, Any]]:
|
||||
"""Lista todos los alumnos matriculados en una comisión con información extendida."""
|
||||
enrollments = StudentEnrollment.query.filter_by(commission_id=commission_id).all()
|
||||
result = []
|
||||
for enr in enrollments:
|
||||
data = enr.to_dict()
|
||||
if enr.student:
|
||||
data['student'] = {
|
||||
'id': enr.student.id,
|
||||
'name': enr.student.name,
|
||||
'email': enr.student.email,
|
||||
'personal_email': getattr(enr.student, 'personal_email', None),
|
||||
'phone': getattr(enr.student, 'phone', None),
|
||||
'document_type': getattr(enr.student, 'document_type', None),
|
||||
'document_number': getattr(enr.student, 'document_number', None)
|
||||
}
|
||||
result.append(data)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def enroll_student(cls, commission_id: int, student_id: int,
|
||||
notes: Optional[str] = None,
|
||||
allow_same_day_exception: bool = False,
|
||||
exception_reason: Optional[str] = None) -> StudentEnrollment:
|
||||
"""
|
||||
Matricula a un estudiante en una comisión aplicando la regla de restricción diaria.
|
||||
"""
|
||||
commission = Commission.query.get(commission_id)
|
||||
if not commission or not commission.active:
|
||||
raise ValueError(f"La comisión #{commission_id} no existe o no se encuentra activa.")
|
||||
|
||||
student = User.query.get(student_id)
|
||||
if not student or not student.is_active:
|
||||
raise ValueError(f"El alumno #{student_id} no existe o su cuenta se encuentra inactiva.")
|
||||
|
||||
# Verificar si ya está matriculado
|
||||
existing = StudentEnrollment.query.filter_by(commission_id=commission_id, student_id=student_id).first()
|
||||
if existing:
|
||||
if existing.status == 'activo':
|
||||
raise ValueError(f"El alumno {student.name} ya está matriculado activamente en esta comisión.")
|
||||
# Si estaba retirado, se reactiva
|
||||
existing.status = 'activo'
|
||||
existing.notes = notes or existing.notes
|
||||
existing.allow_same_day_exception = allow_same_day_exception
|
||||
existing.exception_reason = exception_reason if allow_same_day_exception else None
|
||||
commission.current_students = (commission.current_students or 0) + 1
|
||||
db.session.commit()
|
||||
return existing
|
||||
|
||||
# Verificar cupo máximo
|
||||
if commission.max_students and (commission.current_students or 0) >= commission.max_students:
|
||||
raise ValueError(f"La comisión ha alcanzado su cupo máximo de {commission.max_students} estudiantes.")
|
||||
|
||||
# ─── REGLA DE RESTRICCIÓN DIARIA DE CURSADA (ESTADIO 2.3) ───
|
||||
target_subject = commission.subject
|
||||
is_target_short_course = bool(target_subject and target_subject.is_short_course)
|
||||
target_days = cls.get_commission_days(commission)
|
||||
|
||||
# Buscar otras inscripciones activas del estudiante
|
||||
active_enrollments = StudentEnrollment.query.filter_by(
|
||||
student_id=student_id,
|
||||
status='activo'
|
||||
).all()
|
||||
|
||||
for enr in active_enrollments:
|
||||
other_comm = enr.commission
|
||||
if not other_comm or not other_comm.active:
|
||||
continue
|
||||
|
||||
# Verificar si coinciden en el mismo ciclo/semestre/año
|
||||
same_term = (
|
||||
other_comm.year == commission.year and
|
||||
other_comm.semester == commission.semester
|
||||
)
|
||||
if not same_term:
|
||||
continue
|
||||
|
||||
other_subject = other_comm.subject
|
||||
is_other_short_course = bool(other_subject and other_subject.is_short_course)
|
||||
|
||||
# Ambas son materias regulares: no pueden cursarse el mismo día sin excepción
|
||||
if not is_target_short_course and not is_other_short_course:
|
||||
other_days = cls.get_commission_days(other_comm)
|
||||
overlapping_days = target_days.intersection(other_days)
|
||||
|
||||
if overlapping_days:
|
||||
if allow_same_day_exception:
|
||||
# Excepción otorgada expresamente por Bedelía
|
||||
if not exception_reason:
|
||||
exception_reason = "Autorización expresa de Bedelía para cursada simultánea en el mismo día."
|
||||
else:
|
||||
days_display = ", ".join([d.capitalize() for d in overlapping_days])
|
||||
other_name = other_subject.name if other_subject else other_comm.code
|
||||
raise ValueError(
|
||||
f"Restricción de cursada diaria: El alumno ya cursa la materia regular '{other_name}' "
|
||||
f"el día {days_display}. La normativa de UniCABA prohíbe cursar dos materias regulares "
|
||||
f"el mismo día calendario salvo curso corto o autorización explícita de Bedelía."
|
||||
)
|
||||
|
||||
enrollment = StudentEnrollment(
|
||||
student_id=student_id,
|
||||
commission_id=commission_id,
|
||||
status='activo',
|
||||
notes=notes,
|
||||
allow_same_day_exception=allow_same_day_exception,
|
||||
exception_reason=exception_reason if allow_same_day_exception else None
|
||||
)
|
||||
db.session.add(enrollment)
|
||||
commission.current_students = (commission.current_students or 0) + 1
|
||||
db.session.commit()
|
||||
return enrollment
|
||||
|
||||
@classmethod
|
||||
def unenroll_student(cls, commission_id: int, student_id: int) -> bool:
|
||||
"""Da de baja o retira a un alumno de una comisión."""
|
||||
enrollment = StudentEnrollment.query.filter_by(
|
||||
commission_id=commission_id,
|
||||
student_id=student_id
|
||||
).first()
|
||||
if not enrollment:
|
||||
return False
|
||||
|
||||
commission = Commission.query.get(commission_id)
|
||||
if commission and (commission.current_students or 0) > 0:
|
||||
commission.current_students -= 1
|
||||
|
||||
db.session.delete(enrollment)
|
||||
db.session.commit()
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def update_enrollment_status(cls, commission_id: int, student_id: int, status: str,
|
||||
notes: Optional[str] = None) -> Optional[StudentEnrollment]:
|
||||
"""Actualiza el estado de la matrícula (activo, condicional, retirado)."""
|
||||
enrollment = StudentEnrollment.query.filter_by(
|
||||
commission_id=commission_id,
|
||||
student_id=student_id
|
||||
).first()
|
||||
if not enrollment:
|
||||
return None
|
||||
|
||||
old_status = enrollment.status
|
||||
enrollment.status = status
|
||||
if notes is not None:
|
||||
enrollment.notes = notes
|
||||
|
||||
commission = Commission.query.get(commission_id)
|
||||
if commission:
|
||||
if old_status == 'activo' and status in ['retirado']:
|
||||
commission.current_students = max(0, (commission.current_students or 1) - 1)
|
||||
elif old_status in ['retirado'] and status == 'activo':
|
||||
commission.current_students = (commission.current_students or 0) + 1
|
||||
|
||||
db.session.commit()
|
||||
return enrollment
|
||||
@@ -0,0 +1,436 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
from app import db
|
||||
from app.models.subject import Commission, Subject
|
||||
from app.models.milestone import AcademicMilestone, MilestoneType
|
||||
from app.models.enrollment import StudentEnrollment
|
||||
from app.models.grade import MilestoneGrade
|
||||
from app.models.user import User
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
|
||||
class GradebookService:
|
||||
"""
|
||||
Servicio de Libro de Calificaciones, Hitos Evaluativos y Cierre de Actas (Fase 3 MVP).
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_commission_gradebook(cls, commission_id: int) -> Dict[str, Any]:
|
||||
"""Obtiene la matriz completa de calificaciones de una comisión."""
|
||||
comm = Commission.query.get(commission_id)
|
||||
if not comm:
|
||||
raise ValueError(f'Comisión con ID {commission_id} no encontrada.')
|
||||
|
||||
# 1. Hitos evaluativos de la comisión
|
||||
milestones = AcademicMilestone.query.filter_by(
|
||||
commission_id=comm.id,
|
||||
is_active=True
|
||||
).order_by(AcademicMilestone.date.asc()).all()
|
||||
|
||||
# 2. Estudiantes inscriptos
|
||||
enrollments = StudentEnrollment.query.filter_by(
|
||||
commission_id=comm.id
|
||||
).join(User).order_by(User.name.asc()).all()
|
||||
|
||||
# 3. Mapa de notas por (student_id, milestone_id)
|
||||
grades = MilestoneGrade.query.filter(
|
||||
MilestoneGrade.milestone_id.in_([m.id for m in milestones]) if milestones else False
|
||||
).all() if milestones else []
|
||||
|
||||
grades_map: Dict[tuple, MilestoneGrade] = {
|
||||
(g.student_id, g.milestone_id): g for g in grades
|
||||
}
|
||||
|
||||
# 4. Construcción de filas por estudiante
|
||||
rows = []
|
||||
for enr in enrollments:
|
||||
student = enr.student
|
||||
student_grades = []
|
||||
valid_scores = []
|
||||
has_failed = False
|
||||
total_milestones = len(milestones)
|
||||
graded_count = 0
|
||||
|
||||
for m in milestones:
|
||||
g = grades_map.get((student.id, m.id))
|
||||
if g:
|
||||
graded_count += 1
|
||||
g_dict = g.to_dict()
|
||||
student_grades.append(g_dict)
|
||||
if g.is_absent:
|
||||
has_failed = True
|
||||
elif g.score is not None:
|
||||
valid_scores.append(g.score)
|
||||
if g.score < 4.0:
|
||||
has_failed = True
|
||||
else:
|
||||
student_grades.append({
|
||||
'id': None,
|
||||
'milestone_id': m.id,
|
||||
'student_id': student.id,
|
||||
'score': None,
|
||||
'display_score': '-',
|
||||
'is_absent': False,
|
||||
'is_passed': False,
|
||||
'feedback': ''
|
||||
})
|
||||
|
||||
# Cálculo de promedio y condición
|
||||
avg_score = round(sum(valid_scores) / len(valid_scores), 2) if valid_scores else None
|
||||
|
||||
if graded_count == 0:
|
||||
condition = 'Sin Calificar'
|
||||
condition_badge = 'bg-secondary'
|
||||
elif has_failed:
|
||||
condition = 'Desaprobado'
|
||||
condition_badge = 'bg-danger'
|
||||
elif avg_score is not None and avg_score >= 7.0 and graded_count == total_milestones:
|
||||
condition = 'Promociona'
|
||||
condition_badge = 'bg-success'
|
||||
elif avg_score is not None and avg_score >= 4.0:
|
||||
condition = 'Regular'
|
||||
condition_badge = 'bg-primary'
|
||||
else:
|
||||
condition = 'Pendiente'
|
||||
condition_badge = 'bg-warning text-dark'
|
||||
|
||||
rows.append({
|
||||
'enrollment_id': enr.id,
|
||||
'student_id': student.id,
|
||||
'student_name': student.name,
|
||||
'student_email': student.email,
|
||||
'enrollment_status': enr.status,
|
||||
'grades': student_grades,
|
||||
'average_score': avg_score,
|
||||
'graded_count': graded_count,
|
||||
'total_milestones': total_milestones,
|
||||
'condition': condition,
|
||||
'condition_badge': condition_badge
|
||||
})
|
||||
|
||||
return {
|
||||
'commission': comm.to_dict(),
|
||||
'milestones': [m.to_dict() for m in milestones],
|
||||
'students': rows,
|
||||
'is_closed': bool(comm.grades_closed),
|
||||
'acta_number': comm.acta_number,
|
||||
'closed_at': comm.grades_closed_at.isoformat() if comm.grades_closed_at else None,
|
||||
'closed_by_name': comm.closed_by_user.name if comm.closed_by_user else None,
|
||||
'can_modify': not bool(comm.grades_closed)
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def save_single_grade(
|
||||
cls,
|
||||
commission_id: int,
|
||||
milestone_id: int,
|
||||
student_id: int,
|
||||
score: Optional[float],
|
||||
is_absent: bool = False,
|
||||
feedback: str = '',
|
||||
grader_id: Optional[int] = None
|
||||
) -> MilestoneGrade:
|
||||
"""Guarda o actualiza una calificación individual."""
|
||||
comm = Commission.query.get(commission_id)
|
||||
if not comm:
|
||||
raise ValueError(f'Comisión con ID {commission_id} no encontrada.')
|
||||
|
||||
if comm.grades_closed:
|
||||
raise ValueError('El acta de calificaciones de esta comisión se encuentra cerrada y no admite modificaciones.')
|
||||
|
||||
milestone = AcademicMilestone.query.filter_by(id=milestone_id, commission_id=comm.id).first()
|
||||
if not milestone:
|
||||
raise ValueError(f'Hito evaluativo {milestone_id} no pertenece a la comisión {commission_id}.')
|
||||
|
||||
enrollment = StudentEnrollment.query.filter_by(commission_id=comm.id, student_id=student_id).first()
|
||||
if not enrollment:
|
||||
raise ValueError(f'El estudiante {student_id} no está matriculado en la comisión {commission_id}.')
|
||||
|
||||
# Validación de nota numérica
|
||||
if is_absent:
|
||||
final_score = None
|
||||
final_absent = True
|
||||
else:
|
||||
final_absent = False
|
||||
if score is not None:
|
||||
try:
|
||||
f_score = float(score)
|
||||
except (ValueError, TypeError):
|
||||
raise ValueError('La calificación debe ser un valor numérico válido entre 1.0 y 10.0.')
|
||||
if f_score < 1.0 or f_score > 10.0:
|
||||
raise ValueError('La calificación debe estar comprendida entre 1.0 y 10.0.')
|
||||
final_score = round(f_score, 2)
|
||||
else:
|
||||
final_score = None
|
||||
|
||||
grade = MilestoneGrade.query.filter_by(
|
||||
milestone_id=milestone.id,
|
||||
student_id=student_id
|
||||
).first()
|
||||
|
||||
if not grade:
|
||||
grade = MilestoneGrade(
|
||||
milestone_id=milestone.id,
|
||||
enrollment_id=enrollment.id,
|
||||
student_id=student_id,
|
||||
score=final_score,
|
||||
is_absent=final_absent,
|
||||
feedback=feedback.strip() if feedback else '',
|
||||
graded_by_user_id=grader_id,
|
||||
graded_at=datetime.utcnow()
|
||||
)
|
||||
db.session.add(grade)
|
||||
else:
|
||||
grade.score = final_score
|
||||
grade.is_absent = final_absent
|
||||
if feedback is not None:
|
||||
grade.feedback = feedback.strip()
|
||||
grade.graded_by_user_id = grader_id
|
||||
grade.updated_at = datetime.utcnow()
|
||||
|
||||
db.session.commit()
|
||||
return grade
|
||||
|
||||
@classmethod
|
||||
def bulk_save_grades(
|
||||
cls,
|
||||
commission_id: int,
|
||||
grades_data: List[Dict[str, Any]],
|
||||
grader_id: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Carga masiva / autoguardado asíncrono de calificaciones."""
|
||||
comm = Commission.query.get(commission_id)
|
||||
if not comm:
|
||||
raise ValueError(f'Comisión {commission_id} no encontrada.')
|
||||
|
||||
if comm.grades_closed:
|
||||
raise ValueError('El acta de calificaciones se encuentra cerrada y no permite modificaciones.')
|
||||
|
||||
saved_count = 0
|
||||
for item in grades_data:
|
||||
m_id = item.get('milestone_id')
|
||||
s_id = item.get('student_id')
|
||||
score = item.get('score')
|
||||
is_absent = bool(item.get('is_absent', False))
|
||||
feedback = item.get('feedback', '')
|
||||
|
||||
if m_id and s_id:
|
||||
cls.save_single_grade(
|
||||
commission_id=comm.id,
|
||||
milestone_id=int(m_id),
|
||||
student_id=int(s_id),
|
||||
score=float(score) if score is not None and str(score).strip() != '' else None,
|
||||
is_absent=is_absent,
|
||||
feedback=feedback,
|
||||
grader_id=grader_id
|
||||
)
|
||||
saved_count += 1
|
||||
|
||||
return {'success': True, 'saved_count': saved_count}
|
||||
|
||||
@classmethod
|
||||
def close_gradebook(cls, commission_id: int, user_id: int, notes: str = '') -> Dict[str, Any]:
|
||||
"""Cierre formal del acta de regularidad y promoción para Bedelía/Titular."""
|
||||
comm = Commission.query.get(commission_id)
|
||||
if not comm:
|
||||
raise ValueError(f'Comisión {commission_id} no encontrada.')
|
||||
|
||||
if comm.grades_closed:
|
||||
raise ValueError(f'El acta de la comisión {commission_id} ya fue cerrada anteriormente.')
|
||||
|
||||
acta_num = f'ACTA-{comm.year}-{comm.semester}-{comm.id:04d}'
|
||||
comm.grades_closed = True
|
||||
comm.grades_closed_at = datetime.utcnow()
|
||||
comm.grades_closed_by = user_id
|
||||
comm.acta_number = acta_num
|
||||
|
||||
# Auditoría
|
||||
audit = AuditLog(
|
||||
user_id=user_id,
|
||||
action='CLOSE_ACTA',
|
||||
module='gradebook',
|
||||
entity_id=comm.id,
|
||||
details=f'Cierre formal de acta de calificaciones. Nro: {acta_num}. Notas: {notes}'
|
||||
)
|
||||
db.session.add(audit)
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'acta_number': acta_num,
|
||||
'closed_at': comm.grades_closed_at.isoformat(),
|
||||
'message': f'Acta {acta_num} cerrada exitosamente. Las calificaciones han quedado selladas.'
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def reopen_gradebook(cls, commission_id: int, user_id: int, reason: str) -> Dict[str, Any]:
|
||||
"""Reapertura excepcional de acta con justificación administrativa."""
|
||||
if not reason or len(reason.strip()) < 5:
|
||||
raise ValueError('Debe especificar un motivo formal para reabrir el acta de calificaciones.')
|
||||
|
||||
comm = Commission.query.get(commission_id)
|
||||
if not comm:
|
||||
raise ValueError(f'Comisión {commission_id} no encontrada.')
|
||||
|
||||
if not comm.grades_closed:
|
||||
raise ValueError('El acta no se encuentra cerrada.')
|
||||
|
||||
old_acta = comm.acta_number
|
||||
comm.grades_closed = False
|
||||
comm.grades_closed_at = None
|
||||
comm.grades_closed_by = None
|
||||
|
||||
audit = AuditLog(
|
||||
user_id=user_id,
|
||||
action='REOPEN_ACTA',
|
||||
module='gradebook',
|
||||
entity_id=comm.id,
|
||||
details=f'Reapertura excepcional de acta {old_acta}. Motivo: {reason.strip()}'
|
||||
)
|
||||
db.session.add(audit)
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': f'Acta {old_acta} reabierta para rectificación de calificaciones.'
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_student_grades(cls, student_id: int) -> List[Dict[str, Any]]:
|
||||
"""Consulta individual de calificaciones de todas las materias del alumno."""
|
||||
enrollments = StudentEnrollment.query.filter_by(
|
||||
student_id=student_id
|
||||
).all()
|
||||
|
||||
results = []
|
||||
for enr in enrollments:
|
||||
comm = enr.commission
|
||||
if not comm:
|
||||
continue
|
||||
|
||||
milestones = AcademicMilestone.query.filter_by(
|
||||
commission_id=comm.id,
|
||||
is_active=True
|
||||
).order_by(AcademicMilestone.date.asc()).all()
|
||||
|
||||
grades = MilestoneGrade.query.filter_by(
|
||||
enrollment_id=enr.id
|
||||
).all()
|
||||
grades_by_milestone = {g.milestone_id: g for g in grades}
|
||||
|
||||
ms_results = []
|
||||
valid_scores = []
|
||||
has_failed = False
|
||||
for m in milestones:
|
||||
g = grades_by_milestone.get(m.id)
|
||||
if g:
|
||||
ms_results.append(g.to_dict())
|
||||
if g.is_absent:
|
||||
has_failed = True
|
||||
elif g.score is not None:
|
||||
valid_scores.append(g.score)
|
||||
if g.score < 4.0:
|
||||
has_failed = True
|
||||
else:
|
||||
ms_results.append({
|
||||
'milestone_id': m.id,
|
||||
'milestone_title': m.title or m.tipo,
|
||||
'milestone_type': m.tipo,
|
||||
'milestone_color': m.color,
|
||||
'display_score': '-',
|
||||
'score': None,
|
||||
'is_absent': False,
|
||||
'feedback': ''
|
||||
})
|
||||
|
||||
avg_score = round(sum(valid_scores) / len(valid_scores), 2) if valid_scores else None
|
||||
|
||||
if not grades:
|
||||
condition = 'Cursando'
|
||||
condition_badge = 'bg-secondary'
|
||||
elif has_failed:
|
||||
condition = 'Desaprobado'
|
||||
condition_badge = 'bg-danger'
|
||||
elif avg_score is not None and avg_score >= 7.0 and len(grades) == len(milestones):
|
||||
condition = 'Promociona'
|
||||
condition_badge = 'bg-success'
|
||||
elif avg_score is not None and avg_score >= 4.0:
|
||||
condition = 'Regular'
|
||||
condition_badge = 'bg-primary'
|
||||
else:
|
||||
condition = 'En curso'
|
||||
condition_badge = 'bg-info text-dark'
|
||||
|
||||
results.append({
|
||||
'enrollment_id': enr.id,
|
||||
'commission_id': comm.id,
|
||||
'commission_code': comm.get_full_code(),
|
||||
'subject_name': comm.subject.name if comm.subject else 'Materia',
|
||||
'subject_code': comm.subject.code if comm.subject else '',
|
||||
'semester': comm.semester,
|
||||
'year': comm.year,
|
||||
'teacher_name': comm.teacher_name,
|
||||
'milestones': ms_results,
|
||||
'average_score': avg_score,
|
||||
'condition': condition,
|
||||
'condition_badge': condition_badge,
|
||||
'is_acta_closed': bool(comm.grades_closed),
|
||||
'acta_number': comm.acta_number
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
@classmethod
|
||||
def get_student_upcoming_milestones(cls, student_id: int, limit: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Obtiene las próximas evaluaciones con cálculo de cuenta regresiva para el dashboard."""
|
||||
now = datetime.utcnow()
|
||||
enrollments = StudentEnrollment.query.filter_by(student_id=student_id).all()
|
||||
commission_ids = [e.commission_id for e in enrollments]
|
||||
|
||||
if not commission_ids:
|
||||
return []
|
||||
|
||||
upcoming = AcademicMilestone.query.filter(
|
||||
AcademicMilestone.commission_id.in_(commission_ids),
|
||||
AcademicMilestone.date >= now - timedelta(hours=2),
|
||||
AcademicMilestone.is_active == True
|
||||
).order_by(AcademicMilestone.date.asc()).limit(limit).all()
|
||||
|
||||
results = []
|
||||
for m in upcoming:
|
||||
delta = m.date - now
|
||||
days = delta.days
|
||||
hours = int(delta.seconds // 3600)
|
||||
mins = int((delta.seconds % 3600) // 60)
|
||||
|
||||
if days > 1:
|
||||
countdown_str = f'Faltan {days} días'
|
||||
badge_urgency = 'bg-info'
|
||||
elif days == 1:
|
||||
countdown_str = 'Mañana'
|
||||
badge_urgency = 'bg-warning text-dark'
|
||||
elif hours > 0:
|
||||
countdown_str = f'Hoy en {hours} h {mins} min'
|
||||
badge_urgency = 'bg-danger'
|
||||
else:
|
||||
countdown_str = 'En curso'
|
||||
badge_urgency = 'bg-danger'
|
||||
|
||||
results.append({
|
||||
'id': m.id,
|
||||
'title': m.title or m.tipo,
|
||||
'tipo': m.tipo,
|
||||
'color': m.color,
|
||||
'date': m.date.isoformat(),
|
||||
'date_formatted': m.date.strftime('%d/%m/%Y %H:%M hs'),
|
||||
'classroom': m.classroom or 'Campus Virtual',
|
||||
'link': m.link,
|
||||
'subject_name': m.subject.name if m.subject else m.materia_codigo,
|
||||
'commission_code': m.commission.get_full_code() if m.commission else '',
|
||||
'days_remaining': max(0, days),
|
||||
'hours_remaining': max(0, hours),
|
||||
'countdown_display': countdown_str,
|
||||
'badge_urgency': badge_urgency
|
||||
})
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,245 @@
|
||||
import requests
|
||||
import logging
|
||||
from typing import Dict, Any, List, Optional
|
||||
from app.models.setting import SystemSetting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class MoodleClient:
|
||||
"""
|
||||
Cliente REST para la API de Web Services de Moodle 4.1.
|
||||
Soporta operaciones sobre usuarios, cursos, matriculaciones y roles,
|
||||
leyendo dinámicamente la URL y el Token encriptado desde SystemSetting.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_config(cls) -> Dict[str, Any]:
|
||||
server_url = SystemSetting.get_value('moodle_server_url', 'http://10.0.0.207/moodle').rstrip('/')
|
||||
token = SystemSetting.get_decrypted_value('moodle_ws_token', '')
|
||||
# Fallback a token descubierto en Moodle si no está configurado aún en BD
|
||||
if not token:
|
||||
token = '1a0fee6f654dc3b7f0c02fac90eda327'
|
||||
timeout = int(SystemSetting.get_value('moodle_timeout', 10))
|
||||
return {
|
||||
'server_url': server_url,
|
||||
'token': token,
|
||||
'timeout': timeout,
|
||||
'endpoint': f"{server_url}/webservice/rest/server.php"
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def call(cls, ws_function: str, params: Optional[Dict[str, Any]] = None, method: str = 'GET') -> Dict[str, Any]:
|
||||
"""Ejecuta una llamada Web Service contra Moodle 4.1 en formato JSON."""
|
||||
cfg = cls.get_config()
|
||||
endpoint = cfg['endpoint']
|
||||
token = cfg['token']
|
||||
timeout = cfg['timeout']
|
||||
|
||||
req_params = {
|
||||
'wstoken': token,
|
||||
'wsfunction': ws_function,
|
||||
'moodlewsrestformat': 'json'
|
||||
}
|
||||
if params:
|
||||
req_params.update(params)
|
||||
|
||||
try:
|
||||
if method.upper() == 'POST':
|
||||
response = requests.post(endpoint, data=req_params, timeout=timeout)
|
||||
else:
|
||||
response = requests.get(endpoint, params=req_params, timeout=timeout)
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Moodle retorna HTTP 200 con un campo 'exception' en caso de error lógico
|
||||
if isinstance(data, dict) and 'exception' in data:
|
||||
error_msg = f"Moodle Exception [{data.get('errorcode')}]: {data.get('message')}"
|
||||
logger.error(error_msg)
|
||||
raise RuntimeError(error_msg)
|
||||
|
||||
return data
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error("Error de comunicación con Moodle (%s): %s", ws_function, str(e))
|
||||
raise RuntimeError(f"Fallo de conexión con Moodle ({ws_function}): {str(e)}")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Diagnóstico y Estado
|
||||
# --------------------------------------------------------------------------
|
||||
@classmethod
|
||||
def test_connection(cls) -> Dict[str, Any]:
|
||||
"""Prueba si el token y el endpoint responden adecuadamente."""
|
||||
cfg = cls.get_config()
|
||||
try:
|
||||
# Consultamos usuarios con un filtro vacío o el admin
|
||||
data = cls.call('core_user_get_users', {
|
||||
'criteria[0][key]': 'email',
|
||||
'criteria[0][value]': '%'
|
||||
})
|
||||
users = data.get('users', [])
|
||||
return {
|
||||
'success': True,
|
||||
'server_url': cfg['server_url'],
|
||||
'user_count': len(users),
|
||||
'message': f'Conexión exitosa a Moodle 4.1 ({len(users)} usuarios detectados).'
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'server_url': cfg['server_url'],
|
||||
'error': str(e),
|
||||
'message': f'Fallo en la prueba de conexión: {str(e)}'
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Usuarios (core_user_*)
|
||||
# --------------------------------------------------------------------------
|
||||
@classmethod
|
||||
def get_users_by_criteria(cls, field: str, value: str) -> List[Dict[str, Any]]:
|
||||
"""Obtiene usuarios por criterio (email, username, idnumber, etc.)."""
|
||||
params = {
|
||||
'criteria[0][key]': field,
|
||||
'criteria[0][value]': value
|
||||
}
|
||||
res = cls.call('core_user_get_users', params)
|
||||
return res.get('users', []) if isinstance(res, dict) else []
|
||||
|
||||
@classmethod
|
||||
def get_user_by_email(cls, email: str) -> Optional[Dict[str, Any]]:
|
||||
users = cls.get_users_by_criteria('email', email.strip().lower())
|
||||
return users[0] if users else None
|
||||
|
||||
@classmethod
|
||||
def create_user(cls, username: str, email: str, firstname: str, lastname: str, password: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Crea un nuevo usuario en Moodle (core_user_create_users)."""
|
||||
params = {
|
||||
'users[0][username]': username.strip().lower(),
|
||||
'users[0][email]': email.strip().lower(),
|
||||
'users[0][firstname]': firstname.strip(),
|
||||
'users[0][lastname]': lastname.strip(),
|
||||
'users[0][auth]': 'manual'
|
||||
}
|
||||
if password:
|
||||
params['users[0][password]'] = password
|
||||
else:
|
||||
params['users[0][createpassword]'] = 1 # Notifica al usuario para generar password
|
||||
|
||||
res = cls.call('core_user_create_users', params, method='POST')
|
||||
# Retorna lista de diccionarios [{'id': 123, 'username': '...'}]
|
||||
if isinstance(res, list) and len(res) > 0:
|
||||
return res[0]
|
||||
return res
|
||||
|
||||
@classmethod
|
||||
def update_user(cls, moodle_user_id: int, firstname: Optional[str] = None, lastname: Optional[str] = None, email: Optional[str] = None) -> Any:
|
||||
"""Actualiza datos de un usuario en Moodle (core_user_update_users)."""
|
||||
params = {'users[0][id]': moodle_user_id}
|
||||
if firstname:
|
||||
params['users[0][firstname]'] = firstname
|
||||
if lastname:
|
||||
params['users[0][lastname]'] = lastname
|
||||
if email:
|
||||
params['users[0][email]'] = email
|
||||
|
||||
return cls.call('core_user_update_users', params, method='POST')
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Matriculación y Cursos (enrol_manual_*, core_enrol_*)
|
||||
# --------------------------------------------------------------------------
|
||||
@classmethod
|
||||
def enrol_user(cls, course_id: int, user_id: int, role_id: int = 5) -> Any:
|
||||
"""
|
||||
Matricula a un usuario en un curso Moodle (enrol_manual_enrol_users).
|
||||
Role ID 5 = Estudiante, 3 = Docente con permiso de edición, 4 = Docente sin permiso.
|
||||
"""
|
||||
params = {
|
||||
'enrolments[0][roleid]': role_id,
|
||||
'enrolments[0][userid]': user_id,
|
||||
'enrolments[0][courseid]': course_id
|
||||
}
|
||||
return cls.call('enrol_manual_enrol_users', params, method='POST')
|
||||
|
||||
@classmethod
|
||||
def unenrol_user(cls, course_id: int, user_id: int, role_id: int = 5) -> Any:
|
||||
"""Desmatricula a un usuario de un curso Moodle (enrol_manual_unenrol_users)."""
|
||||
params = {
|
||||
'enrolments[0][roleid]': role_id,
|
||||
'enrolments[0][userid]': user_id,
|
||||
'enrolments[0][courseid]': course_id
|
||||
}
|
||||
return cls.call('enrol_manual_unenrol_users', params, method='POST')
|
||||
|
||||
@classmethod
|
||||
def get_enrolled_users(cls, course_id: int) -> List[Dict[str, Any]]:
|
||||
"""Obtiene todos los usuarios matriculados en un curso (core_enrol_get_enrolled_users)."""
|
||||
params = {'courseid': course_id}
|
||||
return cls.call('core_enrol_get_enrolled_users', params)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Asignación de Roles (core_role_*)
|
||||
# --------------------------------------------------------------------------
|
||||
@classmethod
|
||||
def assign_role(cls, role_id: int, user_id: int, context_id: int = 1) -> Any:
|
||||
"""Asigna un rol en Moodle a un usuario en un contexto específico (core_role_assign_roles)."""
|
||||
params = {
|
||||
'assignments[0][roleid]': role_id,
|
||||
'assignments[0][userid]': user_id,
|
||||
'assignments[0][contextid]': context_id
|
||||
}
|
||||
return cls.call('core_role_assign_roles', params, method='POST')
|
||||
|
||||
@classmethod
|
||||
def unassign_role(cls, role_id: int, user_id: int, context_id: int = 1) -> Any:
|
||||
"""Remueve un rol en Moodle (core_role_unassign_roles)."""
|
||||
params = {
|
||||
'unassignments[0][roleid]': role_id,
|
||||
'unassignments[0][userid]': user_id,
|
||||
'unassignments[0][contextid]': context_id
|
||||
}
|
||||
return cls.call('core_role_unassign_roles', params, method='POST')
|
||||
|
||||
@classmethod
|
||||
def create_users(cls, users: List[Dict[str, Any]]) -> Any:
|
||||
params = {}
|
||||
for idx, u in enumerate(users):
|
||||
for k, v in u.items():
|
||||
params[f'users[{idx}][{k}]'] = v
|
||||
return cls.call('core_user_create_users', params, method='POST')
|
||||
|
||||
@classmethod
|
||||
def update_users(cls, users: List[Dict[str, Any]]) -> Any:
|
||||
params = {}
|
||||
for idx, u in enumerate(users):
|
||||
for k, v in u.items():
|
||||
params[f'users[{idx}][{k}]'] = v
|
||||
return cls.call('core_user_update_users', params, method='POST')
|
||||
|
||||
@classmethod
|
||||
def enrol_users(cls, enrolments: List[Dict[str, Any]]) -> Any:
|
||||
params = {}
|
||||
for idx, e in enumerate(enrolments):
|
||||
params[f'enrolments[{idx}][roleid]'] = e.get('roleid', e.get('role_id', 5))
|
||||
params[f'enrolments[{idx}][userid]'] = e.get('userid', e.get('user_id'))
|
||||
params[f'enrolments[{idx}][courseid]'] = e.get('courseid', e.get('course_id'))
|
||||
return cls.call('enrol_manual_enrol_users', params, method='POST')
|
||||
|
||||
@classmethod
|
||||
def unenrol_users(cls, enrolments: List[Dict[str, Any]]) -> Any:
|
||||
params = {}
|
||||
for idx, e in enumerate(enrolments):
|
||||
params[f'enrolments[{idx}][roleid]'] = e.get('roleid', e.get('role_id', 5))
|
||||
params[f'enrolments[{idx}][userid]'] = e.get('userid', e.get('user_id'))
|
||||
params[f'enrolments[{idx}][courseid]'] = e.get('courseid', e.get('course_id'))
|
||||
return cls.call('enrol_manual_unenrol_users', params, method='POST')
|
||||
|
||||
@classmethod
|
||||
def get_users(cls, criteria: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
params = {}
|
||||
for idx, c in enumerate(criteria):
|
||||
params[f'criteria[{idx}][key]'] = c.get('key')
|
||||
params[f'criteria[{idx}][value]'] = c.get('value')
|
||||
res = cls.call('core_user_get_users', params)
|
||||
return res.get('users', []) if isinstance(res, dict) else []
|
||||
|
||||
moodle_client = MoodleClient()
|
||||
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
Moodle Queue Service (admin-edu-space)
|
||||
Provides asynchronous, fault-tolerant queuing and processing of synchronization
|
||||
operations between admin-edu-space and Moodle 4.1.
|
||||
Implements Exponential Backoff and Dead Letter Queue (DLQ).
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from app import db
|
||||
from app.models.sync_task import MoodleSyncTask
|
||||
from app.services.moodle_client import moodle_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class MoodleQueueService:
|
||||
@staticmethod
|
||||
def enqueue_task(action: str, entity_type: str, entity_id: str = None, payload: dict = None, max_attempts: int = 5) -> MoodleSyncTask:
|
||||
"""
|
||||
Enqueues a new synchronization task to be processed asynchronously.
|
||||
Guarantees that local transactions are never blocked by Moodle unavailability.
|
||||
"""
|
||||
task = MoodleSyncTask(
|
||||
action=action,
|
||||
entity_type=entity_type,
|
||||
entity_id=str(entity_id) if entity_id else None,
|
||||
payload=payload or {},
|
||||
status='PENDING',
|
||||
attempts=0,
|
||||
max_attempts=max_attempts,
|
||||
next_retry_at=datetime.utcnow()
|
||||
)
|
||||
db.session.add(task)
|
||||
db.session.commit()
|
||||
logger.info(f"[MoodleQueue] Enqueued task {task.id}: action={action}, entity={entity_type}:{entity_id}")
|
||||
return task
|
||||
|
||||
@staticmethod
|
||||
def process_pending_tasks(batch_size: int = 20) -> dict:
|
||||
"""
|
||||
Processes a batch of pending or retrying tasks.
|
||||
Uses exponential backoff for retries and sends to Dead Letter Queue (FAILED)
|
||||
if max_attempts are exceeded.
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
tasks = MoodleSyncTask.query.filter(
|
||||
MoodleSyncTask.status.in_(['PENDING', 'RETRYING']),
|
||||
(MoodleSyncTask.next_retry_at == None) | (MoodleSyncTask.next_retry_at <= now)
|
||||
).order_by(MoodleSyncTask.created_at.asc()).limit(batch_size).all()
|
||||
|
||||
results = {
|
||||
'processed': 0,
|
||||
'succeeded': 0,
|
||||
'failed': 0,
|
||||
'retrying': 0
|
||||
}
|
||||
|
||||
if not tasks:
|
||||
return results
|
||||
|
||||
for task in tasks:
|
||||
results['processed'] += 1
|
||||
task.status = 'PROCESSING'
|
||||
task.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
try:
|
||||
MoodleQueueService._execute_task_action(task)
|
||||
task.status = 'COMPLETED'
|
||||
task.error_message = None
|
||||
task.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
results['succeeded'] += 1
|
||||
logger.info(f"[MoodleQueue] Task {task.id} ({task.action}) completed successfully.")
|
||||
except Exception as e:
|
||||
task.attempts += 1
|
||||
task.error_message = str(e)
|
||||
task.updated_at = datetime.utcnow()
|
||||
|
||||
if task.attempts >= task.max_attempts:
|
||||
task.status = 'FAILED' # Dead Letter Queue (DLQ)
|
||||
task.next_retry_at = None
|
||||
results['failed'] += 1
|
||||
logger.error(f"[MoodleQueue] Task {task.id} permanently failed (DLQ): {e}")
|
||||
else:
|
||||
task.status = 'RETRYING'
|
||||
# Exponential Backoff: 30s, 60s, 120s, 240s... capped at 1 hour
|
||||
delay_seconds = min(3600, (2 ** task.attempts) * 30)
|
||||
task.next_retry_at = datetime.utcnow() + timedelta(seconds=delay_seconds)
|
||||
results['retrying'] += 1
|
||||
logger.warning(f"[MoodleQueue] Task {task.id} failed attempt {task.attempts}/{task.max_attempts}. Next retry in {delay_seconds}s: {e}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _execute_task_action(task: MoodleSyncTask):
|
||||
"""
|
||||
Executes the specific Moodle Web Service operation.
|
||||
Raises an exception on failure or error response.
|
||||
"""
|
||||
payload = task.payload or {}
|
||||
action = task.action.upper()
|
||||
|
||||
if action == 'CREATE_USER':
|
||||
users = payload.get('users') or [payload]
|
||||
res = moodle_client.create_users(users)
|
||||
return res
|
||||
|
||||
elif action == 'UPDATE_USER':
|
||||
users = payload.get('users') or [payload]
|
||||
res = moodle_client.update_users(users)
|
||||
return res
|
||||
|
||||
elif action == 'ENROL_USER':
|
||||
enrolments = payload.get('enrolments') or [payload]
|
||||
res = moodle_client.enrol_users(enrolments)
|
||||
return res
|
||||
|
||||
elif action == 'UNENROL_USER':
|
||||
enrolments = payload.get('enrolments') or [payload]
|
||||
res = moodle_client.unenrol_users(enrolments)
|
||||
return res
|
||||
|
||||
elif action == 'ASSIGN_ROLE':
|
||||
role_id = payload.get('role_id')
|
||||
user_id = payload.get('user_id')
|
||||
context_id = payload.get('context_id', 1)
|
||||
res = moodle_client.assign_role(role_id, user_id, context_id)
|
||||
return res
|
||||
|
||||
elif action == 'UNASSIGN_ROLE':
|
||||
role_id = payload.get('role_id')
|
||||
user_id = payload.get('user_id')
|
||||
context_id = payload.get('context_id', 1)
|
||||
res = moodle_client.unassign_role(role_id, user_id, context_id)
|
||||
return res
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unsupported sync action: {action}")
|
||||
|
||||
@staticmethod
|
||||
def retry_task(task_id: int) -> bool:
|
||||
"""
|
||||
Manually re-enqueues a task from DLQ or error state back to PENDING.
|
||||
"""
|
||||
task = MoodleSyncTask.query.get(task_id)
|
||||
if not task:
|
||||
return False
|
||||
|
||||
task.status = 'PENDING'
|
||||
task.attempts = 0
|
||||
task.next_retry_at = datetime.utcnow()
|
||||
task.error_message = None
|
||||
task.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
logger.info(f"[MoodleQueue] Task {task_id} manually reset to PENDING.")
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def retry_all_failed() -> int:
|
||||
"""
|
||||
Retries all tasks in FAILED status (DLQ).
|
||||
"""
|
||||
failed_tasks = MoodleSyncTask.query.filter_by(status='FAILED').all()
|
||||
count = 0
|
||||
for task in failed_tasks:
|
||||
task.status = 'PENDING'
|
||||
task.attempts = 0
|
||||
task.next_retry_at = datetime.utcnow()
|
||||
task.updated_at = datetime.utcnow()
|
||||
count += 1
|
||||
db.session.commit()
|
||||
logger.info(f"[MoodleQueue] Reset {count} failed tasks back to PENDING.")
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
def get_queue_summary() -> dict:
|
||||
"""
|
||||
Returns stats about tasks currently in the queue.
|
||||
"""
|
||||
counts = {
|
||||
'PENDING': 0,
|
||||
'PROCESSING': 0,
|
||||
'RETRYING': 0,
|
||||
'COMPLETED': 0,
|
||||
'FAILED': 0
|
||||
}
|
||||
from sqlalchemy import func
|
||||
rows = db.session.query(MoodleSyncTask.status, func.count(MoodleSyncTask.id)).group_by(MoodleSyncTask.status).all()
|
||||
for status, count in rows:
|
||||
if status in counts:
|
||||
counts[status] = count
|
||||
|
||||
total = sum(counts.values())
|
||||
return {
|
||||
'summary': counts,
|
||||
'total': total,
|
||||
'pending_total': counts['PENDING'] + counts['RETRYING'] + counts['PROCESSING'],
|
||||
'failed_dlq': counts['FAILED']
|
||||
}
|
||||
|
||||
moodle_queue_service = MoodleQueueService()
|
||||
@@ -1,7 +1,8 @@
|
||||
from typing import Optional, List
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.subject import Commission, CommissionTeacher
|
||||
from app.repositories.reservation_repository import ReservationRepository
|
||||
from app.repositories.classroom_repository import ClassroomRepository
|
||||
from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO
|
||||
@@ -38,23 +39,116 @@ class ReservationService:
|
||||
exclude_reservation_id=exclude_id
|
||||
)
|
||||
|
||||
def check_teacher_conflicts(self, teacher_id: int, start_time: datetime, end_time: datetime,
|
||||
exclude_id: Optional[int] = None) -> List[Reservation]:
|
||||
"""Verifica si el docente ya tiene otra reserva en la misma franja horaria."""
|
||||
if not teacher_id:
|
||||
return []
|
||||
return self.reservation_repo.find_teacher_conflicts(
|
||||
teacher_id=teacher_id,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
exclude_reservation_id=exclude_id
|
||||
)
|
||||
|
||||
def check_full_conflicts_matrix(self, classroom_id: int, start_time: datetime, end_time: datetime,
|
||||
teacher_id: Optional[int] = None,
|
||||
commission_id: Optional[int] = None,
|
||||
expected_attendees: Optional[int] = None,
|
||||
allow_co_teaching_exception: bool = False,
|
||||
exclude_id: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Matriz completa de validación de conflictos (Fase 2 MVP):
|
||||
1. Conflicto físico de aula (bloqueo estricto para aulas físicas).
|
||||
2. Conflicto docente (detección con soporte de co-docencia).
|
||||
3. Conflicto de aforo (capacidad física).
|
||||
"""
|
||||
classroom = self.classroom_repo.get_by_id(classroom_id)
|
||||
if not classroom or not classroom.is_active:
|
||||
raise ValueError(f"El aula con ID {classroom_id} no existe o no se encuentra activa.")
|
||||
|
||||
commission = None
|
||||
if commission_id:
|
||||
commission = Commission.query.get(commission_id)
|
||||
if not teacher_id and commission:
|
||||
teacher_id = commission.teacher_id
|
||||
|
||||
# 1. Conflicto Físico
|
||||
physical_conflicts = []
|
||||
if not classroom.is_virtual:
|
||||
physical_conflicts = self.check_conflicts(classroom_id, start_time, end_time, exclude_id=exclude_id)
|
||||
|
||||
# 2. Conflicto Docente y Co-docencia
|
||||
teacher_conflicts = []
|
||||
has_co_teaching_coverage = False
|
||||
co_teachers_list = []
|
||||
|
||||
if teacher_id:
|
||||
teacher_conflicts = self.check_teacher_conflicts(teacher_id, start_time, end_time, exclude_id=exclude_id)
|
||||
if teacher_conflicts and commission:
|
||||
# Comprobar si la comisión cuenta con equipo docente de respaldo
|
||||
co_teachers = CommissionTeacher.query.filter_by(commission_id=commission.id).all()
|
||||
other_teachers = [ct for ct in co_teachers if ct.user_id != teacher_id]
|
||||
if other_teachers:
|
||||
has_co_teaching_coverage = True
|
||||
co_teachers_list = [
|
||||
{'id': ct.user_id, 'name': ct.teacher.name if ct.teacher else 'Docente', 'role': ct.role}
|
||||
for ct in other_teachers
|
||||
]
|
||||
|
||||
# 3. Conflicto de Aforo
|
||||
has_capacity_conflict = False
|
||||
capacity_message = None
|
||||
if not classroom.is_virtual and expected_attendees is not None:
|
||||
if expected_attendees > classroom.capacity:
|
||||
has_capacity_conflict = True
|
||||
capacity_message = f"La cantidad esperada ({expected_attendees}) supera la capacidad del aula ({classroom.capacity})."
|
||||
|
||||
# Evaluación de Bloqueos
|
||||
block_reasons = []
|
||||
if physical_conflicts:
|
||||
c_strs = [f"#{c.id} ({c.start_time.strftime('%H:%M')} a {c.end_time.strftime('%H:%M')})" for c in physical_conflicts]
|
||||
block_reasons.append(f"Conflicto de horario en aula {classroom.code}: se solapa con las reservas {', '.join(c_strs)}.")
|
||||
|
||||
if has_capacity_conflict:
|
||||
block_reasons.append(f"Conflicto de aforo: {capacity_message}")
|
||||
|
||||
if teacher_conflicts:
|
||||
if has_co_teaching_coverage or allow_co_teaching_exception:
|
||||
# Cobertura justificada por equipo de co-docencia
|
||||
pass
|
||||
else:
|
||||
t_strs = [f"#{c.id} ({c.start_time.strftime('%H:%M')} a {c.end_time.strftime('%H:%M')})" for c in teacher_conflicts]
|
||||
block_reasons.append(f"Conflicto de horario docente: el profesor ya tiene clases asignadas en {', '.join(t_strs)} y la comisión no cuenta con equipo de co-docencia.")
|
||||
|
||||
is_blocked = len(block_reasons) > 0
|
||||
return {
|
||||
'is_valid': not is_blocked,
|
||||
'is_blocked': is_blocked,
|
||||
'block_reasons': block_reasons,
|
||||
'physical_conflicts': [c.to_dict() for c in physical_conflicts],
|
||||
'teacher_conflicts': [c.to_dict() for c in teacher_conflicts],
|
||||
'has_co_teaching_coverage': has_co_teaching_coverage,
|
||||
'co_teachers': co_teachers_list,
|
||||
'has_capacity_conflict': has_capacity_conflict,
|
||||
'classroom': classroom.to_dict()
|
||||
}
|
||||
|
||||
def create_reservation(self, dto: ReservationCreateDTO) -> Reservation:
|
||||
"""
|
||||
Crea una nueva reserva aplicando validaciones de aforo y detección de colisiones.
|
||||
Crea una nueva reserva aplicando la matriz integral de conflictos.
|
||||
"""
|
||||
classroom = self.classroom_repo.get_by_id(dto.classroom_id)
|
||||
if not classroom or not classroom.is_active:
|
||||
raise ValueError(f"El aula con ID {dto.classroom_id} no existe o no se encuentra activa.")
|
||||
|
||||
# Detección de colisiones para aulas físicas
|
||||
if not classroom.is_virtual:
|
||||
conflicts = self.check_conflicts(dto.classroom_id, dto.start_time, dto.end_time)
|
||||
if conflicts:
|
||||
conflict_details = ", ".join([f"#{c.id} ({c.start_time.strftime('%H:%M')} a {c.end_time.strftime('%H:%M')})" for c in conflicts])
|
||||
raise ValueError(f"Conflicto de horario en {classroom.code}: se solapa con las reservas {conflict_details}.")
|
||||
|
||||
if dto.expected_attendees > classroom.capacity:
|
||||
raise ValueError(f"La cantidad de alumnos ({dto.expected_attendees}) supera la capacidad del aula ({classroom.capacity}).")
|
||||
matrix = self.check_full_conflicts_matrix(
|
||||
classroom_id=dto.classroom_id,
|
||||
start_time=dto.start_time,
|
||||
end_time=dto.end_time,
|
||||
teacher_id=dto.user_id,
|
||||
commission_id=dto.commission_id,
|
||||
expected_attendees=dto.expected_attendees,
|
||||
allow_co_teaching_exception=getattr(dto, 'allow_co_teaching_exception', False)
|
||||
)
|
||||
if matrix['is_blocked']:
|
||||
raise ValueError(" | ".join(matrix['block_reasons']))
|
||||
|
||||
reservation = Reservation(
|
||||
classroom_id=dto.classroom_id,
|
||||
@@ -72,7 +166,7 @@ class ReservationService:
|
||||
return self.reservation_repo.save(reservation)
|
||||
|
||||
def update_reservation(self, reservation_id: int, dto: ReservationUpdateDTO) -> Reservation:
|
||||
"""Actualiza una reserva existente verificando disponibilidad horaria."""
|
||||
"""Actualiza una reserva existente verificando disponibilidad horaria y matriz de conflictos."""
|
||||
reservation = self.reservation_repo.get_by_id(reservation_id)
|
||||
if not reservation:
|
||||
raise ValueError(f"Reserva con ID {reservation_id} no encontrada.")
|
||||
@@ -80,16 +174,20 @@ class ReservationService:
|
||||
classroom_id = dto.classroom_id or reservation.classroom_id
|
||||
start_time = dto.start_time or reservation.start_time
|
||||
end_time = dto.end_time or reservation.end_time
|
||||
expected_attendees = dto.expected_attendees if dto.expected_attendees is not None else reservation.expected_attendees
|
||||
|
||||
classroom = self.classroom_repo.get_by_id(classroom_id)
|
||||
if not classroom or not classroom.is_active:
|
||||
raise ValueError(f"El aula con ID {classroom_id} no existe o no se encuentra activa.")
|
||||
|
||||
if not classroom.is_virtual:
|
||||
conflicts = self.check_conflicts(classroom_id, start_time, end_time, exclude_id=reservation.id)
|
||||
if conflicts:
|
||||
conflict_details = ", ".join([f"#{c.id} ({c.start_time.strftime('%H:%M')} - {c.end_time.strftime('%H:%M')})" for c in conflicts])
|
||||
raise ValueError(f"Conflicto de horario en {classroom.code} con: {conflict_details}.")
|
||||
matrix = self.check_full_conflicts_matrix(
|
||||
classroom_id=classroom_id,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
teacher_id=reservation.user_id,
|
||||
commission_id=reservation.commission_id,
|
||||
expected_attendees=expected_attendees,
|
||||
allow_co_teaching_exception=getattr(dto, 'allow_co_teaching_exception', False),
|
||||
exclude_id=reservation.id
|
||||
)
|
||||
if matrix['is_blocked']:
|
||||
raise ValueError(" | ".join(matrix['block_reasons']))
|
||||
|
||||
if dto.classroom_id is not None:
|
||||
reservation.classroom_id = dto.classroom_id
|
||||
|
||||
@@ -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'
|
||||
|
||||
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):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
if not url:
|
||||
return BASE_CSV_URL
|
||||
url = url.strip()
|
||||
|
||||
# 1. Enlace publicado web (/d/e/2PACX-.../pub...)
|
||||
if '/d/e/' in url:
|
||||
base_match = re.match(r'^(https://docs\.google\.com/spreadsheets/d/e/[^/?#]+)/pub', url)
|
||||
if base_match:
|
||||
return f"{base_match.group(1)}/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
|
||||
# 1. Enlace publicado web (/d/e/2PACX-... con o sin /u/X/ y con pub o pubhtml)
|
||||
pub_match = re.search(r'/spreadsheets/(?:u/\d+/)?d/e/([a-zA-Z0-9-_]+)', url)
|
||||
if pub_match:
|
||||
doc_id = pub_match.group(1)
|
||||
return f"https://docs.google.com/spreadsheets/d/e/{doc_id}/pub?single=true&output=csv"
|
||||
|
||||
# 2. Enlace de edición o visualización (/d/<SPREADSHEET_ID>/edit...)
|
||||
sheet_id_match = re.search(r'/spreadsheets/d/([a-zA-Z0-9-_]+)', url)
|
||||
# 2. Enlace de edición o exportación (/d/<SPREADSHEET_ID>/...)
|
||||
sheet_id_match = re.search(r'/spreadsheets/(?:u/\d+/)?d/([a-zA-Z0-9-_]+)', url)
|
||||
if sheet_id_match:
|
||||
sheet_id = sheet_id_match.group(1)
|
||||
return f"https://docs.google.com/spreadsheets/d/{sheet_id}/export?format=csv"
|
||||
@@ -72,15 +79,51 @@ class GoogleSheetsImporter:
|
||||
configured_url = os.getenv('GOOGLE_SHEETS_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):
|
||||
"""Fetch CSV string for a specific sheet gid"""
|
||||
separator = '&' if '?' in self.base_url else '?'
|
||||
url = f"{self.base_url}{separator}gid={gid}"
|
||||
if not (url.startswith('https://') or url.startswith('http://')):
|
||||
raise ValueError(f"URL scheme not permitted: {url}")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) EduSpace/2.0'}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=25) as resp:
|
||||
with urllib.request.urlopen(req, timeout=25) as resp: # nosec B310
|
||||
return resp.read().decode('utf-8', errors='replace')
|
||||
|
||||
def parse_sheet_rows(self, csv_content, sheet_name):
|
||||
@@ -108,7 +151,10 @@ class GoogleSheetsImporter:
|
||||
current_shift = 'Mañana'
|
||||
for row in rows[header_idx + 1:]:
|
||||
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()
|
||||
|
||||
# Detect shift delimiters
|
||||
@@ -126,10 +172,10 @@ class GoogleSheetsImporter:
|
||||
continue
|
||||
|
||||
codigo = raw_cell
|
||||
asignatura = row[col_offset + 1].strip().replace('\n', ' ')
|
||||
aula = row[col_offset + 2].strip().replace('\n', ' ')
|
||||
carrera = row[col_offset + 3].strip().replace('\n', ' ')
|
||||
horario = row[col_offset + 4].strip().replace('\n', ' ')
|
||||
asignatura = _clean(row[col_offset + 1]).replace('\n', ' ')
|
||||
aula = _clean(row[col_offset + 2]).replace('\n', ' ')
|
||||
carrera = _clean(row[col_offset + 3]).replace('\n', ' ')
|
||||
horario = _clean(row[col_offset + 4]).replace('\n', ' ')
|
||||
|
||||
# Check valid data row
|
||||
if codigo and asignatura and not 'codigo' in codigo.lower():
|
||||
@@ -248,7 +294,8 @@ class GoogleSheetsImporter:
|
||||
}
|
||||
|
||||
all_raw_records = []
|
||||
for sheet_cfg in SHEETS_CONFIG:
|
||||
sheets_to_process = self.get_sheets_config()
|
||||
for sheet_cfg in sheets_to_process:
|
||||
try:
|
||||
csv_data = self.fetch_sheet_csv(sheet_cfg['gid'])
|
||||
rows = self.parse_sheet_rows(csv_data, sheet_cfg['name'])
|
||||
|
||||
@@ -20,6 +20,18 @@ def jwt_required(f):
|
||||
token = request.cookies.get('access_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({
|
||||
'error': 'Unauthorized',
|
||||
'message': 'Encabezado Authorization con Bearer token no proporcionado.'
|
||||
@@ -40,6 +52,20 @@ def jwt_required(f):
|
||||
g.jwt_user = user
|
||||
g.jwt_payload = payload
|
||||
g.jwt_token = token
|
||||
g.real_admin_user = user
|
||||
g.is_impersonating = False
|
||||
|
||||
impersonate_header = request.headers.get('X-Impersonate-User')
|
||||
if impersonate_header and user.is_admin():
|
||||
target_user = None
|
||||
if impersonate_header.strip().isdigit():
|
||||
target_user = db.session.get(User, int(impersonate_header.strip()))
|
||||
else:
|
||||
target_user = User.query.filter_by(email=impersonate_header.strip()).first()
|
||||
|
||||
if target_user and target_user.is_active:
|
||||
g.jwt_user = target_user
|
||||
g.is_impersonating = True
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({
|
||||
|
||||
@@ -265,7 +265,11 @@ CREATE TABLE public.commissions (
|
||||
created_at timestamp without time zone,
|
||||
updated_at timestamp without time zone,
|
||||
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,
|
||||
created_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,
|
||||
preferred_language character varying(10),
|
||||
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;
|
||||
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
|
||||
\.
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ load_dotenv(os.path.join(basedir, '.env'))
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
|
||||
|
||||
_db_url = os.environ.get('DATABASE_URL') or 'sqlite:///app.db'
|
||||
default_sqlite_path = os.path.join(basedir, 'instance', 'app.db').replace('\\', '/')
|
||||
_db_url = os.environ.get('DATABASE_URL') or f'sqlite:///{default_sqlite_path}'
|
||||
if _db_url.startswith('postgres://'):
|
||||
_db_url = _db_url.replace('postgres://', 'postgresql://', 1)
|
||||
SQLALCHEMY_DATABASE_URI = _db_url
|
||||
|
||||
+313
-165
@@ -1,4 +1,5 @@
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Asegurar codificación UTF-8 en consola de Windows
|
||||
if hasattr(sys.stdout, 'reconfigure'):
|
||||
@@ -7,6 +8,7 @@ if hasattr(sys.stderr, 'reconfigure'):
|
||||
sys.stderr.reconfigure(encoding='utf-8')
|
||||
|
||||
from app import create_app, db
|
||||
from app import models
|
||||
from app.models.user import User
|
||||
from app.models.role import Role, Permission, SYSTEM_MODULES
|
||||
from sqlalchemy import text
|
||||
@@ -22,21 +24,29 @@ def init_database():
|
||||
|
||||
# 2. Migraciones incrementales seguras para bases de datos existentes
|
||||
if db.engine.url.drivername != 'sqlite':
|
||||
# Columnas extendidas de users
|
||||
db.session.execute(text("""
|
||||
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;
|
||||
"""))
|
||||
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 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("""
|
||||
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 ADD COLUMN IF NOT EXISTS is_short_course BOOLEAN DEFAULT FALSE;
|
||||
"""))
|
||||
|
||||
# Infraestructura: buildings y classrooms
|
||||
db.session.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS buildings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -49,7 +59,27 @@ def init_database():
|
||||
created_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("""
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
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_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;
|
||||
"""))
|
||||
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);
|
||||
"""))
|
||||
|
||||
# Tablas de soporte: student_enrollments
|
||||
db.session.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS student_enrollments (
|
||||
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_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()
|
||||
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.")
|
||||
|
||||
# 3. Crear o sincronizar roles predeterminados y matriz de permisos
|
||||
@@ -218,98 +313,147 @@ def init_database():
|
||||
print(" [OK] Sin duplicados de Bedelia.")
|
||||
|
||||
# 4. Verificar o crear usuarios institucionales de cada rol
|
||||
print("[*] Verificando usuarios institucionales y credenciales...")
|
||||
roles_by_name = {r.name: r for r in Role.query.all()}
|
||||
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
|
||||
|
||||
institutional_users = [
|
||||
{
|
||||
'email': 'admin@edu-space.com',
|
||||
'name': 'Administrador del Sistema',
|
||||
'role_name': 'Admin',
|
||||
'role_code': 'ADMIN'
|
||||
},
|
||||
{
|
||||
'email': 'bedelia@edu-space.com',
|
||||
'name': 'Operador de Bedelía',
|
||||
'role_name': 'Bedelia',
|
||||
'role_code': 'BEDELIA'
|
||||
},
|
||||
{
|
||||
'email': 'docente@edu-space.com',
|
||||
'name': 'Profesor Titular',
|
||||
'role_name': 'Docente',
|
||||
'role_code': 'DOCENTE'
|
||||
},
|
||||
{
|
||||
'email': 'alumno@edu-space.com',
|
||||
'name': 'Estudiante Regular',
|
||||
'role_name': 'Alumno',
|
||||
'role_code': 'ALUMNO'
|
||||
}
|
||||
]
|
||||
|
||||
for u_info in institutional_users:
|
||||
u_obj = User.query.filter_by(email=u_info['email']).first()
|
||||
r_match = roles_by_name.get(u_info['role_name'])
|
||||
|
||||
if not u_obj:
|
||||
u_obj = User(
|
||||
email=u_info['email'],
|
||||
name=u_info['name'],
|
||||
role=u_info['role_code'],
|
||||
role_id=r_match.id if r_match else None,
|
||||
is_active=True,
|
||||
preferred_language="es",
|
||||
theme_preference="auto"
|
||||
)
|
||||
u_obj.set_password("admin123")
|
||||
db.session.add(u_obj)
|
||||
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(f" [+] Usuario creado: {u_info['email']} | Clave: admin123 | Rol: {u_info['role_name']}")
|
||||
else:
|
||||
# Garantizar sincronización de rol, estado activo y clave
|
||||
if r_match and u_obj.role_id != r_match.id:
|
||||
u_obj.role_id = r_match.id
|
||||
u_obj.role = u_info['role_code']
|
||||
u_obj.is_active = True
|
||||
if not u_obj.check_password("admin123"):
|
||||
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...")
|
||||
roles_by_name = {r.name: r for r in Role.query.all()}
|
||||
|
||||
institutional_users = [
|
||||
{
|
||||
'email': 'admin@edu-space.com',
|
||||
'name': 'Administrador del Sistema',
|
||||
'role_name': 'Admin',
|
||||
'role_code': 'ADMIN'
|
||||
},
|
||||
{
|
||||
'email': 'bedelia@edu-space.com',
|
||||
'name': 'Operador de Bedelía',
|
||||
'role_name': 'Bedelia',
|
||||
'role_code': 'BEDELIA'
|
||||
},
|
||||
{
|
||||
'email': 'docente@edu-space.com',
|
||||
'name': 'Profesor Titular',
|
||||
'role_name': 'Docente',
|
||||
'role_code': 'DOCENTE'
|
||||
},
|
||||
{
|
||||
'email': 'alumno@edu-space.com',
|
||||
'name': 'Estudiante Regular',
|
||||
'role_name': 'Alumno',
|
||||
'role_code': 'ALUMNO'
|
||||
}
|
||||
]
|
||||
|
||||
for u_info in institutional_users:
|
||||
u_obj = User.query.filter_by(email=u_info['email']).first()
|
||||
r_match = roles_by_name.get(u_info['role_name'])
|
||||
|
||||
if not u_obj:
|
||||
u_obj = User(
|
||||
email=u_info['email'],
|
||||
name=u_info['name'],
|
||||
role=u_info['role_code'],
|
||||
role_id=r_match.id if r_match else None,
|
||||
is_active=True,
|
||||
preferred_language="es",
|
||||
theme_preference="auto"
|
||||
)
|
||||
u_obj.set_password("admin123")
|
||||
print(f" [*] Clave de {u_info['email']} restablecida a 'admin123'.")
|
||||
if not u_obj.preferred_language:
|
||||
u_obj.preferred_language = "es"
|
||||
if not u_obj.theme_preference:
|
||||
u_obj.theme_preference = "auto"
|
||||
db.session.commit()
|
||||
print(f" [OK] Usuario {u_info['email']} validado (Rol: {u_info['role_name']}, Clave: admin123).")
|
||||
db.session.add(u_obj)
|
||||
db.session.commit()
|
||||
print(f" [+] Usuario creado: {u_info['email']} | Clave: admin123 | Rol: {u_info['role_name']}")
|
||||
else:
|
||||
# Preservar contraseña y estado si ya existen para no pisar cambios administrativos
|
||||
if r_match and 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']
|
||||
if not u_obj.password_hash:
|
||||
u_obj.set_password("admin123")
|
||||
if not u_obj.preferred_language:
|
||||
u_obj.preferred_language = "es"
|
||||
if not u_obj.theme_preference:
|
||||
u_obj.theme_preference = "auto"
|
||||
db.session.commit()
|
||||
print(f" [OK] Usuario {u_info['email']} validado (Rol: {u_info['role_name']}).")
|
||||
|
||||
# 5. Inicializar edificios institucionales oficiales
|
||||
print("[*] Verificando edificios institucionales...")
|
||||
from app.models.building import Building
|
||||
b_central = Building.query.filter_by(name='Edificio Central').first()
|
||||
if not b_central:
|
||||
b_central = Building(
|
||||
name='Edificio Central',
|
||||
code='CENTRAL',
|
||||
address='Tte. Gral. Juan Domingo Peron 802, CABA',
|
||||
floors=4,
|
||||
description='Sede central institucional de UniCABA. Aulas de grado, auditorio y laboratorios.'
|
||||
)
|
||||
db.session.add(b_central)
|
||||
else:
|
||||
b_central.address = 'Tte. Gral. Juan Domingo Peron 802, CABA'
|
||||
if not empty_academic:
|
||||
print("[*] Verificando edificios institucionales...")
|
||||
from app.models.building import Building
|
||||
b_central = Building.query.filter_by(name='Edificio Central').first()
|
||||
if not b_central:
|
||||
b_central = Building(
|
||||
name='Edificio Central',
|
||||
code='CENTRAL',
|
||||
address='Tte. Gral. Juan Domingo Peron 802, CABA',
|
||||
floors=4,
|
||||
description='Sede central institucional de UniCABA. Aulas de grado, auditorio y laboratorios.'
|
||||
)
|
||||
db.session.add(b_central)
|
||||
else:
|
||||
b_central.address = 'Tte. Gral. Juan Domingo Peron 802, CABA'
|
||||
|
||||
b_virtual = Building.query.filter_by(name='Campus Virtual').first()
|
||||
if not b_virtual:
|
||||
b_virtual = Building(
|
||||
name='Campus Virtual',
|
||||
code='VIRTUAL',
|
||||
address='Entorno Digital / Microsoft Teams - Moodle',
|
||||
floors=0,
|
||||
description='Entorno de aulas virtuales y clases a distancia'
|
||||
)
|
||||
db.session.add(b_virtual)
|
||||
db.session.commit()
|
||||
print("[OK] Edificios institucionales configurados con dirección oficial.")
|
||||
b_virtual = Building.query.filter_by(name='Campus Virtual').first()
|
||||
if not b_virtual:
|
||||
b_virtual = Building(
|
||||
name='Campus Virtual',
|
||||
code='VIRTUAL',
|
||||
address='Entorno Digital / Microsoft Teams - Moodle',
|
||||
floors=0,
|
||||
description='Entorno de aulas virtuales y clases a distancia'
|
||||
)
|
||||
db.session.add(b_virtual)
|
||||
db.session.commit()
|
||||
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
|
||||
print("[*] Verificando catalogo de tipificaciones de hitos academicos...")
|
||||
@@ -335,76 +479,80 @@ def init_database():
|
||||
print(f"[OK] Catalogo de tipificaciones inicializado ({MilestoneType.query.count()} tipos disponibles).")
|
||||
|
||||
# 7. Inicializar aulas, materias, comisiones y reservas de muestra
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.subject import Subject, Commission
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from datetime import datetime, timedelta, date
|
||||
if not empty_academic:
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.subject import Subject, Commission
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from datetime import datetime, timedelta, date
|
||||
|
||||
if Classroom.query.count() == 0:
|
||||
print("[*] Generando catalogo de aulas...")
|
||||
for floor in ['PB', '1', '2', '3']:
|
||||
for num in range(1, 8):
|
||||
code = f'{floor}0{num}' if floor != 'PB' else f'PB-0{num}'
|
||||
c = Classroom(
|
||||
code=code,
|
||||
building=b_central.name,
|
||||
building_id=b_central.id,
|
||||
floor=floor,
|
||||
capacity=30 + (num * 5),
|
||||
description=f'Aula física estándar {code} en {b_central.name}',
|
||||
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...")
|
||||
for floor in ['PB', '1', '2', '3']:
|
||||
for num in range(1, 8):
|
||||
code = f'{floor}0{num}' if floor != 'PB' else f'PB-0{num}'
|
||||
c = Classroom(
|
||||
code=code,
|
||||
building=b_central.name,
|
||||
building_id=b_central.id,
|
||||
floor=floor,
|
||||
capacity=30 + (num * 5),
|
||||
description=f'Aula física estándar {code} en {b_central.name}',
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(c)
|
||||
|
||||
for v in range(1, 4):
|
||||
vc = Classroom(
|
||||
code=f'VIRTUAL-0{v}',
|
||||
building=b_virtual.name,
|
||||
building_id=b_virtual.id,
|
||||
floor='0',
|
||||
capacity=100,
|
||||
description=f'Sala virtual Microsoft Teams {v}',
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(c)
|
||||
db.session.add(vc)
|
||||
db.session.commit()
|
||||
print(f"[OK] {Classroom.query.count()} aulas creadas.")
|
||||
|
||||
for v in range(1, 4):
|
||||
vc = Classroom(
|
||||
code=f'VIRTUAL-0{v}',
|
||||
building=b_virtual.name,
|
||||
building_id=b_virtual.id,
|
||||
floor='0',
|
||||
capacity=100,
|
||||
description=f'Sala virtual Microsoft Teams {v}',
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(vc)
|
||||
db.session.commit()
|
||||
print(f"[OK] {Classroom.query.count()} aulas creadas.")
|
||||
# Materia y Comisión
|
||||
s = Subject.query.filter_by(code='ARQ-101').first()
|
||||
if not s:
|
||||
s = Subject(name='Arquitectura de Software', code='ARQ-101', is_active=True)
|
||||
db.session.add(s)
|
||||
db.session.commit()
|
||||
|
||||
# Materia y Comisión
|
||||
s = Subject.query.filter_by(code='ARQ-101').first()
|
||||
if not s:
|
||||
s = Subject(name='Arquitectura de Software', code='ARQ-101', is_active=True)
|
||||
db.session.add(s)
|
||||
db.session.commit()
|
||||
comm = Commission.query.filter_by(code='COM-A').first()
|
||||
if not comm:
|
||||
comm = Commission(code='COM-A', subject_id=s.id, shift='Noche', semester=2, year=2026, max_students=40, is_active=True)
|
||||
db.session.add(comm)
|
||||
db.session.commit()
|
||||
|
||||
comm = Commission.query.filter_by(code='COM-A').first()
|
||||
if not comm:
|
||||
comm = Commission(code='COM-A', subject_id=s.id, shift='Noche', semester=2, year=2026, max_students=40, is_active=True)
|
||||
db.session.add(comm)
|
||||
db.session.commit()
|
||||
|
||||
# Reservas de Hoy
|
||||
if Reservation.query.count() == 0:
|
||||
print("[*] Generando reservas de muestra...")
|
||||
today = date.today()
|
||||
classrooms = Classroom.query.all()
|
||||
docente = User.query.filter_by(email='docente@edu-space.com').first()
|
||||
for idx, cl in enumerate(classrooms[:19]):
|
||||
start_hour = 8 + (idx % 6) * 2
|
||||
r = Reservation(
|
||||
classroom_id=cl.id,
|
||||
commission_id=comm.id,
|
||||
user_id=docente.id if docente else 1,
|
||||
start_time=datetime.combine(today, datetime.min.time()) + timedelta(hours=start_hour),
|
||||
end_time=datetime.combine(today, datetime.min.time()) + timedelta(hours=start_hour + 2),
|
||||
purpose='Clase Regular',
|
||||
status=ReservationStatus.CONFIRMED.value,
|
||||
shift='Noche' if start_hour >= 18 else 'Tarde' if start_hour >= 13 else 'Mañana',
|
||||
expected_attendees=cl.capacity - 5
|
||||
)
|
||||
db.session.add(r)
|
||||
db.session.commit()
|
||||
print(f"[OK] {Reservation.query.count()} reservas creadas.")
|
||||
# Reservas de Hoy
|
||||
if Reservation.query.count() == 0:
|
||||
print("[*] Generando reservas de muestra...")
|
||||
today = date.today()
|
||||
classrooms = Classroom.query.all()
|
||||
docente = User.query.filter_by(email='docente@edu-space.com').first()
|
||||
for idx, cl in enumerate(classrooms[:19]):
|
||||
start_hour = 8 + (idx % 6) * 2
|
||||
r = Reservation(
|
||||
classroom_id=cl.id,
|
||||
commission_id=comm.id,
|
||||
user_id=docente.id if docente else 1,
|
||||
start_time=datetime.combine(today, datetime.min.time()) + timedelta(hours=start_hour),
|
||||
end_time=datetime.combine(today, datetime.min.time()) + timedelta(hours=start_hour + 2),
|
||||
purpose='Clase Regular',
|
||||
status=ReservationStatus.CONFIRMED.value,
|
||||
shift='Noche' if start_hour >= 18 else 'Tarde' if start_hour >= 13 else 'Mañana',
|
||||
expected_attendees=cl.capacity - 5
|
||||
)
|
||||
db.session.add(r)
|
||||
db.session.commit()
|
||||
print(f"[OK] {Reservation.query.count()} reservas creadas.")
|
||||
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.")
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
@@ -0,0 +1,4 @@
|
||||
bandit>=1.7.5
|
||||
pip-audit>=2.7.2
|
||||
schemathesis>=3.28.6
|
||||
njsscan>=0.3.5
|
||||
@@ -1,4 +1,4 @@
|
||||
Flask==3.0.0
|
||||
Flask>=3.1.3
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
Flask-Login==0.6.3
|
||||
Flask-WTF==1.2.1
|
||||
@@ -6,11 +6,11 @@ Flask-Migrate==4.0.5
|
||||
Flask-CORS==6.0.2
|
||||
Flask-Babel==4.0.0
|
||||
WTForms==3.1.0
|
||||
Werkzeug==3.0.1
|
||||
Werkzeug>=3.1.6
|
||||
SQLAlchemy>=2.0.32
|
||||
python-dotenv==1.0.0
|
||||
requests==2.31.0
|
||||
bleach==6.1.0
|
||||
python-dotenv>=1.2.2
|
||||
requests>=2.32.4
|
||||
bleach>=6.4.0
|
||||
python-dateutil==2.8.2
|
||||
Pillow>=10.4.0
|
||||
email_validator>=2.0.0
|
||||
@@ -19,3 +19,4 @@ Babel==2.14.0
|
||||
gunicorn>=21.2.0
|
||||
PyJWT>=2.8.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.enrollment import StudentEnrollment
|
||||
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.classroom import Classroom, ClassroomResource
|
||||
|
||||
@@ -56,6 +57,8 @@ def execute_academic_reset(
|
||||
'reservations': 0,
|
||||
'enrollments': 0,
|
||||
'commissions': 0,
|
||||
'commission_teachers': 0,
|
||||
'milestones': 0,
|
||||
'subjects': 0,
|
||||
'careers': 0,
|
||||
'classrooms': 0,
|
||||
@@ -66,32 +69,46 @@ def execute_academic_reset(
|
||||
# 1. Eliminar Reservas (Asignaciones de aulas)
|
||||
if reset_reservations:
|
||||
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()
|
||||
|
||||
# 3. Eliminar Inscripciones y Comisiones
|
||||
if reset_commissions:
|
||||
# 2. Eliminar calificaciones e hitos evaluativos vinculados a comisiones o materias
|
||||
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()
|
||||
db.session.flush()
|
||||
|
||||
deleted_counts['commissions'] = Commission.query.delete()
|
||||
db.session.flush()
|
||||
|
||||
# 4. Eliminar Materias / Asignaturas
|
||||
if reset_subjects:
|
||||
if reset_subjects or reset_careers:
|
||||
deleted_counts['subjects'] = Subject.query.delete()
|
||||
db.session.flush()
|
||||
|
||||
# 5. Eliminar Carreras
|
||||
if reset_careers:
|
||||
deleted_counts['careers'] = Career.query.delete()
|
||||
db.session.flush()
|
||||
|
||||
# 6. Eliminar Recursos y Aulas
|
||||
if reset_classrooms:
|
||||
deleted_counts['classroom_resources'] = ClassroomResource.query.delete()
|
||||
deleted_counts['classrooms'] = Classroom.query.delete()
|
||||
db.session.flush()
|
||||
|
||||
db.session.commit()
|
||||
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)
|
||||
@@ -0,0 +1,293 @@
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
from app import create_app, db
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.subject import Subject, Commission, CommissionTeacher
|
||||
from app.models.user import User
|
||||
from app.models.role import Role
|
||||
from app.models.reservation import Reservation
|
||||
from app.models.enrollment import StudentEnrollment
|
||||
from app.services.reservation_service import ReservationService
|
||||
from app.services.enrollment_service import EnrollmentService
|
||||
from app.schemas.reservation_dto import ReservationCreateDTO
|
||||
|
||||
class TestPhase2Rules(unittest.TestCase):
|
||||
"""Pruebas unitarias e integrales para la Fase 2 del Roadmap MVP (UniCABA)."""
|
||||
|
||||
def setUp(self):
|
||||
self.app = create_app()
|
||||
self.app_context = self.app.app_context()
|
||||
self.app_context.push()
|
||||
self.res_service = ReservationService()
|
||||
|
||||
# Obtener o crear rol alumno y docente
|
||||
self.role_alumno = Role.query.filter_by(name='Alumno').first()
|
||||
if not self.role_alumno:
|
||||
self.role_alumno = Role(name='Alumno', description='Rol Alumno')
|
||||
db.session.add(self.role_alumno)
|
||||
|
||||
self.role_docente = Role.query.filter_by(name='Docente').first()
|
||||
if not self.role_docente:
|
||||
self.role_docente = Role(name='Docente', description='Rol Docente')
|
||||
db.session.add(self.role_docente)
|
||||
db.session.commit()
|
||||
|
||||
# Crear usuarios para tests
|
||||
ts = int(datetime.utcnow().timestamp())
|
||||
self.docente1 = User(
|
||||
name=f'Profesor Principal {ts}',
|
||||
email=f'docente1_{ts}@unicaba.edu.ar',
|
||||
role_id=self.role_docente.id,
|
||||
role='Docente',
|
||||
is_active=True
|
||||
)
|
||||
self.docente1.set_password('Secret123!')
|
||||
|
||||
self.docente2 = User(
|
||||
name=f'Profesor Adjunto {ts}',
|
||||
email=f'docente2_{ts}@unicaba.edu.ar',
|
||||
role_id=self.role_docente.id,
|
||||
role='Docente',
|
||||
is_active=True
|
||||
)
|
||||
self.docente2.set_password('Secret123!')
|
||||
|
||||
self.alumno1 = User(
|
||||
name=f'Estudiante Test {ts}',
|
||||
email=f'alumno_{ts}@unicaba.edu.ar',
|
||||
role_id=self.role_alumno.id,
|
||||
role='Alumno',
|
||||
is_active=True
|
||||
)
|
||||
self.alumno1.set_password('Secret123!')
|
||||
|
||||
# Aulas física y virtual
|
||||
self.aula_fisica = Classroom(
|
||||
code=f'A-501-{ts}',
|
||||
capacity=30,
|
||||
building='Sede Central',
|
||||
floor='Piso 5',
|
||||
is_active=True
|
||||
)
|
||||
self.aula_virtual = Classroom(
|
||||
code=f'VIRTUAL-MEET-{ts}',
|
||||
capacity=100,
|
||||
building='Campus Virtual',
|
||||
floor='Plataforma Digital',
|
||||
is_active=True
|
||||
)
|
||||
|
||||
# Asignaturas regulares y curso corto
|
||||
self.materia_regular_a = Subject(
|
||||
code=f'REG-A-{ts}',
|
||||
name=f'Algoritmos y Estructuras {ts}',
|
||||
is_short_course=False,
|
||||
is_active=True
|
||||
)
|
||||
self.materia_regular_b = Subject(
|
||||
code=f'REG-B-{ts}',
|
||||
name=f'Sistemas Operativos {ts}',
|
||||
is_short_course=False,
|
||||
is_active=True
|
||||
)
|
||||
self.curso_corto = Subject(
|
||||
code=f'TALLER-{ts}',
|
||||
name=f'Taller de Herramientas Git {ts}',
|
||||
is_short_course=True,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
db.session.add_all([
|
||||
self.docente1, self.docente2, self.alumno1,
|
||||
self.aula_fisica, self.aula_virtual,
|
||||
self.materia_regular_a, self.materia_regular_b, self.curso_corto
|
||||
])
|
||||
db.session.commit()
|
||||
|
||||
# Comisiones
|
||||
self.comision_a = Commission(
|
||||
subject_id=self.materia_regular_a.id,
|
||||
code=f'COM-A-{ts}',
|
||||
semester='1C',
|
||||
year=2026,
|
||||
teacher_id=self.docente1.id,
|
||||
max_students=40,
|
||||
current_students=0,
|
||||
schedule='Lunes 08:00 - 12:00',
|
||||
active=True
|
||||
)
|
||||
self.comision_b = Commission(
|
||||
subject_id=self.materia_regular_b.id,
|
||||
code=f'COM-B-{ts}',
|
||||
semester='1C',
|
||||
year=2026,
|
||||
teacher_id=self.docente1.id,
|
||||
max_students=40,
|
||||
current_students=0,
|
||||
schedule='Lunes 14:00 - 18:00',
|
||||
active=True
|
||||
)
|
||||
self.comision_c_corto = Commission(
|
||||
subject_id=self.curso_corto.id,
|
||||
code=f'COM-CORTO-{ts}',
|
||||
semester='1C',
|
||||
year=2026,
|
||||
teacher_id=self.docente2.id,
|
||||
max_students=20,
|
||||
current_students=0,
|
||||
schedule='Lunes 18:00 - 20:00',
|
||||
active=True
|
||||
)
|
||||
db.session.add_all([self.comision_a, self.comision_b, self.comision_c_corto])
|
||||
db.session.commit()
|
||||
|
||||
def tearDown(self):
|
||||
db.session.rollback()
|
||||
self.app_context.pop()
|
||||
|
||||
def test_01_physical_conflict_blocks_double_booking(self):
|
||||
"""Estadio 2.2: Debe bloquear doble asignación en misma aula física y horario."""
|
||||
start = datetime(2026, 10, 5, 8, 0)
|
||||
end = datetime(2026, 10, 5, 12, 0)
|
||||
|
||||
# Primera reserva exitosa
|
||||
dto1 = ReservationCreateDTO(
|
||||
classroom_id=self.aula_fisica.id,
|
||||
commission_id=self.comision_a.id,
|
||||
user_id=self.docente1.id,
|
||||
start_time=start,
|
||||
end_time=end,
|
||||
expected_attendees=25
|
||||
)
|
||||
res1 = self.res_service.create_reservation(dto1)
|
||||
self.assertIsNotNone(res1.id)
|
||||
|
||||
# Segunda reserva en la misma aula solapándose (09:00 a 11:00)
|
||||
dto2 = ReservationCreateDTO(
|
||||
classroom_id=self.aula_fisica.id,
|
||||
commission_id=self.comision_b.id,
|
||||
user_id=self.docente2.id,
|
||||
start_time=start + timedelta(hours=1),
|
||||
end_time=end - timedelta(hours=1),
|
||||
expected_attendees=20
|
||||
)
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.res_service.create_reservation(dto2)
|
||||
self.assertIn("Conflicto de horario", str(ctx.exception))
|
||||
|
||||
def test_02_capacity_aforo_validation(self):
|
||||
"""Estadio 2.2: Debe bloquear reserva si asistentes esperados superan capacidad física."""
|
||||
start = datetime(2026, 10, 6, 8, 0)
|
||||
end = datetime(2026, 10, 6, 12, 0)
|
||||
|
||||
dto = ReservationCreateDTO(
|
||||
classroom_id=self.aula_fisica.id,
|
||||
commission_id=self.comision_a.id,
|
||||
user_id=self.docente1.id,
|
||||
start_time=start,
|
||||
end_time=end,
|
||||
expected_attendees=self.aula_fisica.capacity + 15 # supera capacidad (30)
|
||||
)
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.res_service.create_reservation(dto)
|
||||
self.assertIn("Conflicto de aforo", str(ctx.exception))
|
||||
|
||||
def test_03_teacher_conflict_and_co_teaching_exception(self):
|
||||
"""Estadio 2.2: Detección de conflicto docente y excepción justificada por co-docencia."""
|
||||
start = datetime(2026, 10, 7, 14, 0)
|
||||
end = datetime(2026, 10, 7, 18, 0)
|
||||
|
||||
# Docente 1 dicta clase en aula física
|
||||
dto1 = ReservationCreateDTO(
|
||||
classroom_id=self.aula_fisica.id,
|
||||
commission_id=self.comision_a.id,
|
||||
user_id=self.docente1.id,
|
||||
start_time=start,
|
||||
end_time=end,
|
||||
expected_attendees=20
|
||||
)
|
||||
self.res_service.create_reservation(dto1)
|
||||
|
||||
# Mismo docente 1 intenta otra clase en sala virtual al mismo tiempo
|
||||
dto_conflict = ReservationCreateDTO(
|
||||
classroom_id=self.aula_virtual.id,
|
||||
commission_id=self.comision_b.id,
|
||||
user_id=self.docente1.id,
|
||||
start_time=start,
|
||||
end_time=end,
|
||||
expected_attendees=20
|
||||
)
|
||||
|
||||
# Sin co-docencia debe bloquear
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.res_service.create_reservation(dto_conflict)
|
||||
self.assertIn("Conflicto de horario docente", str(ctx.exception))
|
||||
|
||||
# Ahora incorporamos a Docente 2 como co-docente en comision_b
|
||||
ct = CommissionTeacher(
|
||||
commission_id=self.comision_b.id,
|
||||
user_id=self.docente2.id,
|
||||
role='Adjunto'
|
||||
)
|
||||
db.session.add(ct)
|
||||
db.session.commit()
|
||||
|
||||
# Con co-docencia registrada, la matriz detecta cobertura docente y permite la reserva
|
||||
res_co = self.res_service.create_reservation(dto_conflict)
|
||||
self.assertIsNotNone(res_co.id)
|
||||
|
||||
def test_04_student_same_day_restriction_rule(self):
|
||||
"""Estadio 2.3: Un alumno no puede cursar dos asignaturas regulares el mismo día sin excepción."""
|
||||
# Inscribir al alumno en comision_a (Lunes regular)
|
||||
enr1 = EnrollmentService.enroll_student(
|
||||
commission_id=self.comision_a.id,
|
||||
student_id=self.alumno1.id
|
||||
)
|
||||
self.assertEqual(enr1.status, 'activo')
|
||||
|
||||
# Intentar inscribir en comision_b (también Lunes regular): debe BLOQUEAR
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
EnrollmentService.enroll_student(
|
||||
commission_id=self.comision_b.id,
|
||||
student_id=self.alumno1.id
|
||||
)
|
||||
self.assertIn("Restricción de cursada diaria", str(ctx.exception))
|
||||
self.assertIn("Lunes", str(ctx.exception))
|
||||
|
||||
def test_05_student_short_course_bypass(self):
|
||||
"""Estadio 2.3: Si una de las materias es curso corto, se autoriza automáticamente."""
|
||||
# El alumno cursa comision_a los Lunes
|
||||
EnrollmentService.enroll_student(
|
||||
commission_id=self.comision_a.id,
|
||||
student_id=self.alumno1.id
|
||||
)
|
||||
|
||||
# Intentar inscribir en curso corto (Lunes taller): debe PERMITIR
|
||||
enr_short = EnrollmentService.enroll_student(
|
||||
commission_id=self.comision_c_corto.id,
|
||||
student_id=self.alumno1.id
|
||||
)
|
||||
self.assertIsNotNone(enr_short.id)
|
||||
self.assertEqual(enr_short.status, 'activo')
|
||||
|
||||
def test_06_student_bedelia_manual_exception_bypass(self):
|
||||
"""Estadio 2.3: Con autorización expresa de Bedelía (allow_same_day_exception), se permite."""
|
||||
# Alumno cursa comision_a los Lunes
|
||||
EnrollmentService.enroll_student(
|
||||
commission_id=self.comision_a.id,
|
||||
student_id=self.alumno1.id
|
||||
)
|
||||
|
||||
# Inscribir en comision_b con allow_same_day_exception=True
|
||||
enr2 = EnrollmentService.enroll_student(
|
||||
commission_id=self.comision_b.id,
|
||||
student_id=self.alumno1.id,
|
||||
allow_same_day_exception=True,
|
||||
exception_reason="Autorización especial de Bedelía por cambio de plan de estudios"
|
||||
)
|
||||
self.assertIsNotNone(enr2.id)
|
||||
self.assertTrue(enr2.allow_same_day_exception)
|
||||
self.assertIn("cambio de plan", enr2.exception_reason)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,310 @@
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
from app import create_app, db
|
||||
from app.models.subject import Subject, Commission
|
||||
from app.models.milestone import MilestoneType, AcademicMilestone
|
||||
from app.models.user import User
|
||||
from app.models.role import Role
|
||||
from app.models.enrollment import StudentEnrollment
|
||||
from app.models.grade import MilestoneGrade
|
||||
from app.services.gradebook_service import GradebookService
|
||||
|
||||
|
||||
class TestPhase3Gradebook(unittest.TestCase):
|
||||
"""Pruebas unitarias e integrales para la Fase 3 del Roadmap MVP (UniCABA)."""
|
||||
|
||||
def setUp(self):
|
||||
self.app = create_app()
|
||||
self.app_context = self.app.app_context()
|
||||
self.app_context.push()
|
||||
db.create_all()
|
||||
|
||||
# Roles
|
||||
self.role_alumno = Role.query.filter_by(name='Alumno').first()
|
||||
if not self.role_alumno:
|
||||
self.role_alumno = Role(name='Alumno', description='Rol Alumno')
|
||||
db.session.add(self.role_alumno)
|
||||
|
||||
self.role_docente = Role.query.filter_by(name='Docente').first()
|
||||
if not self.role_docente:
|
||||
self.role_docente = Role(name='Docente', description='Rol Docente')
|
||||
db.session.add(self.role_docente)
|
||||
db.session.commit()
|
||||
|
||||
ts = int(datetime.utcnow().timestamp())
|
||||
|
||||
# Docente
|
||||
self.docente = User(
|
||||
name=f'Profesor Calificador {ts}',
|
||||
email=f'docente_calif_{ts}@unicaba.edu.ar',
|
||||
role_id=self.role_docente.id,
|
||||
role='Docente',
|
||||
is_active=True
|
||||
)
|
||||
self.docente.set_password('Secret123!')
|
||||
db.session.add(self.docente)
|
||||
|
||||
# Alumnos
|
||||
self.alumno1 = User(
|
||||
name=f'Estudiante Uno {ts}',
|
||||
email=f'alumno1_{ts}@unicaba.edu.ar',
|
||||
role_id=self.role_alumno.id,
|
||||
role='Alumno',
|
||||
is_active=True
|
||||
)
|
||||
self.alumno1.set_password('Secret123!')
|
||||
db.session.add(self.alumno1)
|
||||
|
||||
self.alumno2 = User(
|
||||
name=f'Estudiante Dos {ts}',
|
||||
email=f'alumno2_{ts}@unicaba.edu.ar',
|
||||
role_id=self.role_alumno.id,
|
||||
role='Alumno',
|
||||
is_active=True
|
||||
)
|
||||
self.alumno2.set_password('Secret123!')
|
||||
db.session.add(self.alumno2)
|
||||
|
||||
# Materia y Comisión
|
||||
self.subject = Subject(
|
||||
name=f'Evaluación y Métricas {ts}',
|
||||
code=f'EVAL{ts % 10000:04d}',
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(self.subject)
|
||||
db.session.flush()
|
||||
|
||||
self.commission = Commission(
|
||||
subject_id=self.subject.id,
|
||||
code='C1',
|
||||
semester='1C',
|
||||
year=2026,
|
||||
teacher_id=self.docente.id,
|
||||
max_students=40,
|
||||
active=True
|
||||
)
|
||||
db.session.add(self.commission)
|
||||
db.session.flush()
|
||||
|
||||
# Matrículas
|
||||
self.enr1 = StudentEnrollment(
|
||||
student_id=self.alumno1.id,
|
||||
commission_id=self.commission.id,
|
||||
status='activo'
|
||||
)
|
||||
self.enr2 = StudentEnrollment(
|
||||
student_id=self.alumno2.id,
|
||||
commission_id=self.commission.id,
|
||||
status='activo'
|
||||
)
|
||||
db.session.add_all([self.enr1, self.enr2])
|
||||
|
||||
# Tipos de hito
|
||||
self.mtype_parcial = MilestoneType.query.filter_by(code='parcial_1').first()
|
||||
if not self.mtype_parcial:
|
||||
self.mtype_parcial = MilestoneType(
|
||||
name='Parcial 1',
|
||||
code='parcial_1',
|
||||
color='#0d6efd',
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(self.mtype_parcial)
|
||||
|
||||
self.mtype_tp = MilestoneType.query.filter_by(code='tp_1').first()
|
||||
if not self.mtype_tp:
|
||||
self.mtype_tp = MilestoneType(
|
||||
name='Trabajo Práctico 1',
|
||||
code='tp_1',
|
||||
color='#198754',
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(self.mtype_tp)
|
||||
db.session.commit()
|
||||
|
||||
# Hitos de la comisión
|
||||
now = datetime.utcnow()
|
||||
self.hito1 = AcademicMilestone(
|
||||
title='Primer Parcial Teórico',
|
||||
materia_codigo=self.subject.code,
|
||||
subject_id=self.subject.id,
|
||||
commission_id=self.commission.id,
|
||||
milestone_type_id=self.mtype_parcial.id,
|
||||
date=now + timedelta(days=5),
|
||||
classroom='Aula 101',
|
||||
is_active=True
|
||||
)
|
||||
self.hito2 = AcademicMilestone(
|
||||
title='Entrega de TP Obligatorio',
|
||||
materia_codigo=self.subject.code,
|
||||
subject_id=self.subject.id,
|
||||
commission_id=self.commission.id,
|
||||
milestone_type_id=self.mtype_tp.id,
|
||||
date=now + timedelta(days=12),
|
||||
classroom='Campus Virtual',
|
||||
is_active=True
|
||||
)
|
||||
db.session.add_all([self.hito1, self.hito2])
|
||||
db.session.commit()
|
||||
|
||||
def tearDown(self):
|
||||
db.session.rollback()
|
||||
self.app_context.pop()
|
||||
|
||||
def test_gradebook_matrix_initial(self):
|
||||
"""Verifica que la matriz de notas inicialice vacía pero con los alumnos e hitos correctos."""
|
||||
gb = GradebookService.get_commission_gradebook(self.commission.id)
|
||||
self.assertIn('students', gb)
|
||||
self.assertEqual(len(gb['students']), 2)
|
||||
self.assertEqual(len(gb['milestones']), 2)
|
||||
self.assertFalse(gb['is_closed'])
|
||||
self.assertIsNone(gb['acta_number'])
|
||||
|
||||
# Los alumnos deben figurar como 'Sin Calificar'
|
||||
for s in gb['students']:
|
||||
self.assertEqual(s['condition'], 'Sin Calificar')
|
||||
self.assertIsNone(s['average_score'])
|
||||
|
||||
def test_save_grade_valid_and_absent(self):
|
||||
"""Verifica la carga de notas numéricas y estados de ausente."""
|
||||
# Alumno 1 saca 8.5 en hito 1
|
||||
g1 = GradebookService.save_single_grade(
|
||||
commission_id=self.commission.id,
|
||||
milestone_id=self.hito1.id,
|
||||
student_id=self.alumno1.id,
|
||||
score=8.5,
|
||||
is_absent=False,
|
||||
feedback='Excelente razonamiento teórico.',
|
||||
grader_id=self.docente.id
|
||||
)
|
||||
self.assertEqual(g1.score, 8.5)
|
||||
self.assertTrue(g1.is_passed)
|
||||
self.assertEqual(g1.display_score, '8.5')
|
||||
|
||||
# Alumno 2 estuvo ausente en hito 1
|
||||
g2 = GradebookService.save_single_grade(
|
||||
commission_id=self.commission.id,
|
||||
milestone_id=self.hito1.id,
|
||||
student_id=self.alumno2.id,
|
||||
score=None,
|
||||
is_absent=True,
|
||||
feedback='Ausente con aviso.',
|
||||
grader_id=self.docente.id
|
||||
)
|
||||
self.assertIsNone(g2.score)
|
||||
self.assertTrue(g2.is_absent)
|
||||
self.assertFalse(g2.is_passed)
|
||||
self.assertEqual(g2.display_score, 'Ausente')
|
||||
|
||||
def test_save_grade_validation_range(self):
|
||||
"""Bloqueo de notas fuera del rango estándar (1.00 a 10.00)."""
|
||||
with self.assertRaises(ValueError):
|
||||
GradebookService.save_single_grade(
|
||||
commission_id=self.commission.id,
|
||||
milestone_id=self.hito1.id,
|
||||
student_id=self.alumno1.id,
|
||||
score=10.5
|
||||
)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
GradebookService.save_single_grade(
|
||||
commission_id=self.commission.id,
|
||||
milestone_id=self.hito1.id,
|
||||
student_id=self.alumno1.id,
|
||||
score=0.5
|
||||
)
|
||||
|
||||
def test_academic_condition_calculation(self):
|
||||
"""Verifica el cálculo de Promociona, Regular y Desaprobado."""
|
||||
# Alumno 1: 8.0 en hito 1 y 9.0 en hito 2 -> Promedio 8.50 -> Promociona
|
||||
GradebookService.save_single_grade(
|
||||
commission_id=self.commission.id,
|
||||
milestone_id=self.hito1.id,
|
||||
student_id=self.alumno1.id,
|
||||
score=8.0
|
||||
)
|
||||
GradebookService.save_single_grade(
|
||||
commission_id=self.commission.id,
|
||||
milestone_id=self.hito2.id,
|
||||
student_id=self.alumno1.id,
|
||||
score=9.0
|
||||
)
|
||||
|
||||
# Alumno 2: 5.0 en hito 1 y 6.0 en hito 2 -> Promedio 5.50 -> Regular
|
||||
GradebookService.save_single_grade(
|
||||
commission_id=self.commission.id,
|
||||
milestone_id=self.hito1.id,
|
||||
student_id=self.alumno2.id,
|
||||
score=5.0
|
||||
)
|
||||
GradebookService.save_single_grade(
|
||||
commission_id=self.commission.id,
|
||||
milestone_id=self.hito2.id,
|
||||
student_id=self.alumno2.id,
|
||||
score=6.0
|
||||
)
|
||||
|
||||
gb = GradebookService.get_commission_gradebook(self.commission.id)
|
||||
al1_data = next(s for s in gb['students'] if s['student_id'] == self.alumno1.id)
|
||||
al2_data = next(s for s in gb['students'] if s['student_id'] == self.alumno2.id)
|
||||
|
||||
self.assertEqual(al1_data['average_score'], 8.5)
|
||||
self.assertEqual(al1_data['condition'], 'Promociona')
|
||||
|
||||
self.assertEqual(al2_data['average_score'], 5.5)
|
||||
self.assertEqual(al2_data['condition'], 'Regular')
|
||||
|
||||
def test_close_gradebook_and_immutability(self):
|
||||
"""Cierre formal del acta de regularidad y bloqueo de modificaciones posteriores."""
|
||||
# Calificar
|
||||
GradebookService.save_single_grade(
|
||||
commission_id=self.commission.id,
|
||||
milestone_id=self.hito1.id,
|
||||
student_id=self.alumno1.id,
|
||||
score=7.0
|
||||
)
|
||||
|
||||
# Cerrar acta
|
||||
res = GradebookService.close_gradebook(
|
||||
commission_id=self.commission.id,
|
||||
user_id=self.docente.id,
|
||||
notes='Cierre final de cursada primer cuatrimestre.'
|
||||
)
|
||||
self.assertTrue(res['success'])
|
||||
self.assertIn('ACTA-2026-1C-', res['acta_number'])
|
||||
|
||||
# Intentar modificar nota en acta cerrada debe ser rechazado
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
GradebookService.save_single_grade(
|
||||
commission_id=self.commission.id,
|
||||
milestone_id=self.hito1.id,
|
||||
student_id=self.alumno1.id,
|
||||
score=10.0
|
||||
)
|
||||
self.assertIn('cerrada', str(ctx.exception).lower())
|
||||
|
||||
def test_student_grades_and_countdown(self):
|
||||
"""Verifica la consulta individual de notas y la cuenta regresiva para el alumno."""
|
||||
# Calificar al alumno 1
|
||||
GradebookService.save_single_grade(
|
||||
commission_id=self.commission.id,
|
||||
milestone_id=self.hito1.id,
|
||||
student_id=self.alumno1.id,
|
||||
score=8.0,
|
||||
feedback='Buen trabajo en el examen.'
|
||||
)
|
||||
|
||||
# Consulta del alumno
|
||||
grades = GradebookService.get_student_grades(self.alumno1.id)
|
||||
self.assertEqual(len(grades), 1)
|
||||
self.assertEqual(grades[0]['subject_name'], self.subject.name)
|
||||
self.assertEqual(len(grades[0]['milestones']), 2)
|
||||
|
||||
# Próximos hitos con cuenta regresiva
|
||||
upcoming = GradebookService.get_student_upcoming_milestones(self.alumno1.id)
|
||||
self.assertGreaterEqual(len(upcoming), 1)
|
||||
self.assertIn('countdown_display', upcoming[0])
|
||||
self.assertIn('days_remaining', upcoming[0])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,283 @@
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
from app import create_app, db
|
||||
from app.models.subject import Subject, Commission
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from app.models.user import User
|
||||
from app.models.role import Role
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.genetic_algorithm import ReservationOptimizer
|
||||
from app.services.jwt_service import JWTService
|
||||
|
||||
|
||||
class TestPhase4InteractiveImpersonation(unittest.TestCase):
|
||||
"""Pruebas unitarias e integrales para la Fase 4 del Roadmap MVP (UniCABA)."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.app = create_app()
|
||||
cls.app.config['TESTING'] = True
|
||||
cls.app_context = cls.app.app_context()
|
||||
cls.app_context.push()
|
||||
db.create_all()
|
||||
|
||||
# Roles
|
||||
cls.role_admin = Role.query.filter_by(name='Admin').first()
|
||||
if not cls.role_admin:
|
||||
cls.role_admin = Role(name='Admin', description='Rol Administrador')
|
||||
db.session.add(cls.role_admin)
|
||||
|
||||
cls.role_alumno = Role.query.filter_by(name='Alumno').first()
|
||||
if not cls.role_alumno:
|
||||
cls.role_alumno = Role(name='Alumno', description='Rol Alumno')
|
||||
db.session.add(cls.role_alumno)
|
||||
|
||||
cls.role_docente = Role.query.filter_by(name='Docente').first()
|
||||
if not cls.role_docente:
|
||||
cls.role_docente = Role(name='Docente', description='Rol Docente')
|
||||
db.session.add(cls.role_docente)
|
||||
db.session.commit()
|
||||
|
||||
ts = int(datetime.utcnow().timestamp())
|
||||
|
||||
# Admin
|
||||
cls.admin = User(
|
||||
name=f'Super Administrador {ts}',
|
||||
email=f'admin_p4_{ts}@unicaba.edu.ar',
|
||||
role_id=cls.role_admin.id,
|
||||
role='Admin',
|
||||
is_active=True
|
||||
)
|
||||
cls.admin.set_password('AdminSecret123!')
|
||||
db.session.add(cls.admin)
|
||||
|
||||
# Docente
|
||||
cls.docente = User(
|
||||
name=f'Profesor Fase 4 {ts}',
|
||||
email=f'docente_p4_{ts}@unicaba.edu.ar',
|
||||
role_id=cls.role_docente.id,
|
||||
role='Docente',
|
||||
is_active=True
|
||||
)
|
||||
cls.docente.set_password('DocSecret123!')
|
||||
db.session.add(cls.docente)
|
||||
|
||||
# Alumno
|
||||
cls.alumno = User(
|
||||
name=f'Estudiante Fase 4 {ts}',
|
||||
email=f'alumno_p4_{ts}@unicaba.edu.ar',
|
||||
role_id=cls.role_alumno.id,
|
||||
role='Alumno',
|
||||
is_active=True
|
||||
)
|
||||
cls.alumno.set_password('AlumSecret123!')
|
||||
db.session.add(cls.alumno)
|
||||
|
||||
# Aulas
|
||||
cls.aula1 = Classroom(
|
||||
code=f'AULA-P4-1-{ts}',
|
||||
capacity=40,
|
||||
floor='1',
|
||||
building='Edificio Central',
|
||||
is_active=True
|
||||
)
|
||||
cls.aula2 = Classroom(
|
||||
code=f'AULA-P4-2-{ts}',
|
||||
capacity=25,
|
||||
floor='2',
|
||||
building='Edificio Central',
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(cls.aula1)
|
||||
db.session.add(cls.aula2)
|
||||
|
||||
# Materia y Comisión
|
||||
cls.subject = Subject(
|
||||
name=f'Gestión Interactiva {ts}',
|
||||
code=f'GI4-{ts}',
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(cls.subject)
|
||||
db.session.flush()
|
||||
|
||||
cls.commission = Commission(
|
||||
subject_id=cls.subject.id,
|
||||
teacher_id=cls.docente.id,
|
||||
code='C1',
|
||||
shift='Mañana',
|
||||
schedule='Lunes 08:00 a 12:00',
|
||||
year=2026,
|
||||
semester='1C',
|
||||
max_students=30,
|
||||
active=True
|
||||
)
|
||||
db.session.add(cls.commission)
|
||||
db.session.commit()
|
||||
|
||||
cls.admin_id = cls.admin.id
|
||||
cls.docente_id = cls.docente.id
|
||||
cls.alumno_id = cls.alumno.id
|
||||
cls.aula1_id = cls.aula1.id
|
||||
cls.aula2_id = cls.aula2.id
|
||||
cls.commission_id = cls.commission.id
|
||||
|
||||
cls.client = cls.app.test_client()
|
||||
|
||||
# Tokens
|
||||
cls.admin_token = JWTService.create_access_token(cls.admin)
|
||||
cls.alumno_token = JWTService.create_access_token(cls.alumno)
|
||||
cls.docente_token = JWTService.create_access_token(cls.docente)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
db.session.remove()
|
||||
cls.app_context.pop()
|
||||
|
||||
def tearDown(self):
|
||||
db.session.remove()
|
||||
|
||||
def test_admin_impersonate_user_and_audit(self):
|
||||
"""Superadmin inicia sesión como alumno y se genera bitácora inmutable."""
|
||||
res = self.client.post(
|
||||
'/api/v1/admin/impersonate',
|
||||
headers={'Authorization': f'Bearer {self.admin_token}'},
|
||||
json={'target_user_id': self.alumno_id}
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
data = res.get_json()
|
||||
self.assertEqual(data.get('status'), 'success')
|
||||
alumno = db.session.get(User, self.alumno_id)
|
||||
self.assertEqual(data['target_user']['email'], alumno.email)
|
||||
|
||||
# Verificar auditoría
|
||||
audit = AuditLog.query.filter_by(action='IMPERSONATE_START', entity_id=self.alumno_id).first()
|
||||
self.assertIsNotNone(audit)
|
||||
self.assertEqual(audit.user_id, self.admin_id)
|
||||
self.assertIn('inició sesión temporal', audit.details)
|
||||
|
||||
def test_non_admin_cannot_impersonate(self):
|
||||
"""Usuarios no administradores (docente/alumno) tienen denegado el modo impersonación."""
|
||||
# Intento por alumno
|
||||
res_alum = self.client.post(
|
||||
'/api/v1/admin/impersonate',
|
||||
headers={'Authorization': f'Bearer {self.alumno_token}'},
|
||||
json={'target_user_id': self.docente_id}
|
||||
)
|
||||
self.assertEqual(res_alum.status_code, 403)
|
||||
|
||||
# Intento por docente
|
||||
res_doc = self.client.post(
|
||||
'/api/v1/admin/impersonate',
|
||||
headers={'Authorization': f'Bearer {self.docente_token}'},
|
||||
json={'target_user_id': self.alumno_id}
|
||||
)
|
||||
self.assertEqual(res_doc.status_code, 403)
|
||||
|
||||
def test_impersonated_request_context_with_header(self):
|
||||
"""La cabecera X-Impersonate-User sustituye la identidad para las consultas pero preserva admin real."""
|
||||
res = self.client.get(
|
||||
'/api/v1/students/my-grades',
|
||||
headers={
|
||||
'Authorization': f'Bearer {self.admin_token}',
|
||||
'X-Impersonate-User': str(self.alumno_id)
|
||||
}
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
data = res.get_json()
|
||||
self.assertEqual(data.get('student_id'), self.alumno_id)
|
||||
self.assertTrue(data.get('is_impersonating'))
|
||||
|
||||
def test_stop_impersonating_and_audit(self):
|
||||
"""Finalización formal de la impersonación con registro en bitácora."""
|
||||
res = self.client.post(
|
||||
'/api/v1/admin/stop-impersonating',
|
||||
headers={
|
||||
'Authorization': f'Bearer {self.admin_token}',
|
||||
'X-Impersonate-User': str(self.alumno_id)
|
||||
}
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
audit = AuditLog.query.filter_by(action='IMPERSONATE_END', user_id=self.admin_id).first()
|
||||
self.assertIsNotNone(audit)
|
||||
self.assertEqual(audit.user_id, self.admin_id)
|
||||
|
||||
def test_drag_update_reservation_and_conflict_detection(self):
|
||||
"""Actualización interactiva de reserva por arrastre con detección de colisión física."""
|
||||
now = datetime.utcnow().replace(hour=8, minute=0, second=0, microsecond=0) + timedelta(days=2)
|
||||
end = now + timedelta(hours=4)
|
||||
|
||||
# 1. Crear reserva existente en aula1
|
||||
res1 = Reservation(
|
||||
classroom_id=self.aula1_id,
|
||||
commission_id=self.commission_id,
|
||||
user_id=self.docente_id,
|
||||
start_time=now,
|
||||
end_time=end,
|
||||
purpose='Clase regular',
|
||||
expected_attendees=25,
|
||||
status=ReservationStatus.CONFIRMED.value
|
||||
)
|
||||
db.session.add(res1)
|
||||
db.session.commit()
|
||||
|
||||
# 2. Arrastre exitoso hacia aula2 (vacía en ese horario)
|
||||
res_drag_ok = self.client.post(
|
||||
'/api/v1/reservations/drag-update',
|
||||
headers={'Authorization': f'Bearer {self.admin_token}'},
|
||||
json={
|
||||
'reservation_id': res1.id,
|
||||
'classroom_id': self.aula2_id,
|
||||
'start_time': now.isoformat(),
|
||||
'end_time': end.isoformat()
|
||||
}
|
||||
)
|
||||
self.assertEqual(res_drag_ok.status_code, 200)
|
||||
self.assertEqual(res_drag_ok.get_json()['reservation']['classroom_id'], self.aula2_id)
|
||||
|
||||
# 3. Intentar arrastrar una nueva reserva sobre aula2 en el mismo horario (colisión física)
|
||||
res_drag_conflict = self.client.post(
|
||||
'/api/v1/reservations/drag-update',
|
||||
headers={'Authorization': f'Bearer {self.admin_token}'},
|
||||
json={
|
||||
'commission_id': self.commission_id,
|
||||
'classroom_id': self.aula2_id,
|
||||
'start_time': now.isoformat(),
|
||||
'end_time': end.isoformat()
|
||||
}
|
||||
)
|
||||
self.assertEqual(res_drag_conflict.status_code, 409)
|
||||
data_err = res_drag_conflict.get_json()
|
||||
self.assertEqual(data_err.get('status'), 'conflict')
|
||||
|
||||
def test_genetic_algorithm_shift_schedule_and_apply(self):
|
||||
"""Optimizador genético calcula horarios según turno real y persiste en BD."""
|
||||
optimizer = ReservationOptimizer()
|
||||
comm = db.session.get(Commission, self.commission_id)
|
||||
|
||||
# Verificar que calcula según turno Mañana
|
||||
pref = optimizer._calculate_preferred_time(comm)
|
||||
self.assertEqual(pref['start'].hour, 8)
|
||||
self.assertEqual(pref['end'].hour, 12)
|
||||
|
||||
# Probar aplicación masiva de resultados válidos
|
||||
res_data = [{
|
||||
'commission_id': self.commission_id,
|
||||
'classroom_id': self.aula1_id,
|
||||
'user_id': self.admin_id,
|
||||
'start_time': (datetime.utcnow() + timedelta(days=10)).isoformat(),
|
||||
'end_time': (datetime.utcnow() + timedelta(days=10, hours=3)).isoformat(),
|
||||
'purpose': 'Asignación Óptima con IA',
|
||||
'expected_attendees': 20,
|
||||
'status': 'CONFIRMED'
|
||||
}]
|
||||
|
||||
applied = optimizer.apply_optimized_reservations(res_data)
|
||||
self.assertEqual(len(applied), 1)
|
||||
self.assertEqual(applied[0].classroom_id, self.aula1_id)
|
||||
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Integration and Unit Tests for Phase 6 (Auth, Email & Moodle Backend)
|
||||
Tests:
|
||||
- CryptoService encryption and decryption
|
||||
- SystemSetting encrypted values and masking
|
||||
- MoodleQueueService (fault-tolerant queue, backoff, Dead Letter Queue DLQ)
|
||||
- CacheService hybrid operation
|
||||
- Hybrid Auth routes (providers, Google OAuth with domain checks, Moodle delegated auth)
|
||||
- EmailService dynamic configuration
|
||||
"""
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from app.services.crypto_service import crypto_service
|
||||
from app.services.cache_service import cache_service
|
||||
from app.services.moodle_queue_service import moodle_queue_service
|
||||
from app.services.email_service import email_service
|
||||
from app.models.setting import SystemSetting
|
||||
from app.models.sync_task import MoodleSyncTask
|
||||
from app.models.user import User
|
||||
from app.models.role import Role
|
||||
|
||||
def test_crypto_service_encryption_and_decryption(app_context):
|
||||
secret = "SuperSecretPassword123!"
|
||||
encrypted = crypto_service.encrypt(secret)
|
||||
assert encrypted != secret
|
||||
assert len(encrypted) > 20
|
||||
|
||||
decrypted = crypto_service.decrypt(encrypted)
|
||||
assert decrypted == secret
|
||||
|
||||
def test_system_setting_encrypted_value(init_database, app_context):
|
||||
key = "test_smtp_pass"
|
||||
val = "MySmtpSecretPass2026*"
|
||||
setting = SystemSetting.set_encrypted_value(key, val, "Test SMTP Password", category="smtp")
|
||||
|
||||
assert setting.is_encrypted is True
|
||||
assert setting.value != val # Must be encrypted in DB
|
||||
assert setting.get_decrypted_value() == val
|
||||
assert setting.get_masked_value() == "••••••••••••"
|
||||
|
||||
def test_cache_service_fallback(app_context):
|
||||
key = "test_unit_key"
|
||||
val = {"foo": "bar", "num": 42}
|
||||
cache_service.set(key, val, ttl_seconds=10)
|
||||
retrieved = cache_service.get(key)
|
||||
assert retrieved == val
|
||||
cache_service.delete(key)
|
||||
assert cache_service.get(key) is None
|
||||
|
||||
def test_moodle_queue_service_enqueue_and_dlq(init_database, app_context):
|
||||
# 1. Enqueue task
|
||||
task = moodle_queue_service.enqueue_task(
|
||||
action="CREATE_USER",
|
||||
entity_type="user",
|
||||
entity_id=99,
|
||||
payload={"username": "testuser", "email": "test@unicaba.edu.ar"},
|
||||
max_attempts=2
|
||||
)
|
||||
assert task.status == 'PENDING'
|
||||
assert task.attempts == 0
|
||||
|
||||
# 2. Simulate processing failure (attempt 1 -> RETRYING)
|
||||
with patch('app.services.moodle_queue_service.MoodleQueueService._execute_task_action') as mock_exec:
|
||||
mock_exec.side_effect = Exception("Moodle Server Connection Timeout (504)")
|
||||
results1 = moodle_queue_service.process_pending_tasks(batch_size=10)
|
||||
assert results1['processed'] == 1
|
||||
assert results1['retrying'] == 1
|
||||
assert results1['failed'] == 0
|
||||
|
||||
# Verify task is now RETRYING with exponential backoff next_retry_at
|
||||
task_refreshed = MoodleSyncTask.query.get(task.id)
|
||||
assert task_refreshed.status == 'RETRYING'
|
||||
assert task_refreshed.attempts == 1
|
||||
assert "504" in task_refreshed.error_message
|
||||
assert task_refreshed.next_retry_at is not None
|
||||
|
||||
# 3. Simulate second failure with next_retry_at in the past -> moves to DLQ (FAILED)
|
||||
task_refreshed.next_retry_at = datetime.utcnow() - timedelta(minutes=1)
|
||||
init_database.session.commit()
|
||||
|
||||
results2 = moodle_queue_service.process_pending_tasks(batch_size=10)
|
||||
assert results2['processed'] == 1
|
||||
assert results2['failed'] == 1 # DLQ triggered
|
||||
|
||||
task_dlq = MoodleSyncTask.query.get(task.id)
|
||||
assert task_dlq.status == 'FAILED'
|
||||
|
||||
# 4. Manual DLQ retry
|
||||
res_retry = moodle_queue_service.retry_task(task.id)
|
||||
assert res_retry is True
|
||||
task_reset = MoodleSyncTask.query.get(task.id)
|
||||
assert task_reset.status == 'PENDING'
|
||||
assert task_reset.attempts == 0
|
||||
|
||||
def test_email_service_dynamic_config(init_database, app_context):
|
||||
SystemSetting.set_value('smtp_host', 'mail.unicaba.edu.ar', 'Host', category='smtp')
|
||||
SystemSetting.set_value('smtp_port', '465', 'Port', category='smtp')
|
||||
SystemSetting.set_value('smtp_security', 'ssl', 'Sec', category='smtp')
|
||||
SystemSetting.set_value('smtp_user', 'bedelia@unicaba.edu.ar', 'User', category='smtp')
|
||||
SystemSetting.set_encrypted_value('smtp_password', 'BedeliaSecure2026!', 'Pass', category='smtp')
|
||||
|
||||
cfg = email_service.get_smtp_config()
|
||||
assert cfg['host'] == 'mail.unicaba.edu.ar'
|
||||
assert cfg['port'] == 465
|
||||
assert cfg['security'] == 'ssl'
|
||||
assert cfg['user'] == 'bedelia@unicaba.edu.ar'
|
||||
assert cfg['password'] == 'BedeliaSecure2026!'
|
||||
|
||||
def test_auth_providers_public_endpoint(client, init_database):
|
||||
SystemSetting.set_value('auth_local_enabled', 'true')
|
||||
SystemSetting.set_value('auth_google_enabled', 'true')
|
||||
SystemSetting.set_value('auth_moodle_enabled', 'false')
|
||||
SystemSetting.set_value('google_client_id', 'google-test-id-123')
|
||||
|
||||
res = client.get('/api/v1/auth/providers')
|
||||
assert res.status_code == 200
|
||||
data = res.get_json()
|
||||
assert data['status'] == 'success'
|
||||
assert data['providers']['local'] is True
|
||||
assert data['providers']['google'] is True
|
||||
assert data['providers']['moodle'] is False
|
||||
assert data['google_client_id'] == 'google-test-id-123'
|
||||
|
||||
def test_google_auth_domain_enforcement(client, init_database):
|
||||
SystemSetting.set_value('auth_google_enabled', 'true')
|
||||
SystemSetting.set_value('google_allowed_domains', 'unicaba.edu.ar,lasalle.edu.ar')
|
||||
|
||||
# Domain rejected (e.g., hacker@gmail.com)
|
||||
res_bad = client.post('/api/v1/auth/google', json={
|
||||
'email': 'hacker@gmail.com',
|
||||
'name': 'Hacker Unauthorized',
|
||||
'domain': 'gmail.com'
|
||||
})
|
||||
assert res_bad.status_code == 403
|
||||
assert "no está autorizado" in res_bad.get_json()['message']
|
||||
|
||||
# Domain accepted (docente@unicaba.edu.ar)
|
||||
res_good = client.post('/api/v1/auth/google', json={
|
||||
'email': 'docente.nuevo@unicaba.edu.ar',
|
||||
'name': 'Docente Nuevo',
|
||||
'domain': 'unicaba.edu.ar'
|
||||
})
|
||||
assert res_good.status_code == 200
|
||||
data_good = res_good.get_json()
|
||||
assert 'access_token' in data_good
|
||||
assert data_good['user']['email'] == 'docente.nuevo@unicaba.edu.ar'
|
||||
assert data_good['user']['role'] == 'Docente'
|
||||
@@ -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.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):
|
||||
"""Pruebas exhaustivas para el Sprint 4: Optimizador Heurístico, Concurrencia y Spring Boot."""
|
||||
|
||||
def setUp(self):
|
||||
self.app = create_app()
|
||||
self.app.config['TESTING'] = True
|
||||
self.app.config['WTF_CSRF_ENABLED'] = False
|
||||
self.app = create_app(TestConfig)
|
||||
self.app_context = self.app.app_context()
|
||||
self.app_context.push()
|
||||
db.create_all()
|
||||
self.client = self.app.test_client()
|
||||
|
||||
# Obtener usuario existente o crear
|
||||
@@ -38,6 +44,7 @@ class Sprint4OptimizerAndConcurrencyTestCase(unittest.TestCase):
|
||||
self.user = User(
|
||||
name='Sprint 4 Admin',
|
||||
email='admin_sprint4@edu-space.com',
|
||||
role='ADMIN',
|
||||
role_id=admin_role.id,
|
||||
is_active=True
|
||||
)
|
||||
@@ -50,9 +57,9 @@ class Sprint4OptimizerAndConcurrencyTestCase(unittest.TestCase):
|
||||
if not self.classrooms:
|
||||
for i in range(1, 11):
|
||||
room = Classroom(
|
||||
room_number=f"AULA-{100 + i}",
|
||||
code=f"AULA-{100 + i}",
|
||||
building="Edificio Central",
|
||||
capacity=30 + (i * 5),
|
||||
is_virtual=(i == 10),
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(room)
|
||||
@@ -68,7 +75,8 @@ class Sprint4OptimizerAndConcurrencyTestCase(unittest.TestCase):
|
||||
}
|
||||
|
||||
def tearDown(self):
|
||||
db.session.rollback()
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
self.app_context.pop()
|
||||
|
||||
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)**.
|
||||
Generated
+42
@@ -15,6 +15,8 @@
|
||||
"cookie-parser": "^1.4.7",
|
||||
"dotenv": "^18.0.0",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.7.0",
|
||||
"helmet": "^8.3.0",
|
||||
"nunjucks": "^3.2.4"
|
||||
}
|
||||
},
|
||||
@@ -480,6 +482,25 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.7.0",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz",
|
||||
"integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
"ip-address": "^10.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/cookie-signature": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
@@ -720,6 +741,18 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
|
||||
"integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/EvanHahn"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -775,6 +808,15 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.7.2",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
|
||||
"integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"cookie-parser": "^1.4.7",
|
||||
"dotenv": "^18.0.0",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.7.0",
|
||||
"helmet": "^8.3.0",
|
||||
"nunjucks": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,4 +301,101 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
OPTIMIZACIONES GLOBALES: 720p (1360x768 / 1366x768)
|
||||
Dedicadas al personal operativo de Bedelía en Argentina
|
||||
======================================================= */
|
||||
@media (max-height: 820px), (max-width: 1400px) {
|
||||
/* Densidad de Información en Tablas */
|
||||
.table td, .table th {
|
||||
padding: 0.45rem 0.65rem !important;
|
||||
font-size: 0.84rem !important;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.badge {
|
||||
font-size: 0.72rem !important;
|
||||
padding: 0.3em 0.6em !important;
|
||||
}
|
||||
|
||||
/* Cards y Contenedores */
|
||||
.card {
|
||||
margin-bottom: 0.75rem !important;
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
.card-header {
|
||||
padding: 0.65rem 0.9rem !important;
|
||||
}
|
||||
.card-body {
|
||||
padding: 0.75rem 0.9rem !important;
|
||||
}
|
||||
.stat-card {
|
||||
padding: 0.75rem !important;
|
||||
}
|
||||
|
||||
/* Formularios compactos */
|
||||
.form-control, .form-select {
|
||||
padding: 0.38rem 0.65rem !important;
|
||||
font-size: 0.84rem !important;
|
||||
}
|
||||
.form-label {
|
||||
margin-bottom: 0.2rem !important;
|
||||
font-size: 0.78rem !important;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.35rem 0.75rem !important;
|
||||
font-size: 0.83rem !important;
|
||||
}
|
||||
.btn-sm {
|
||||
padding: 0.22rem 0.55rem !important;
|
||||
font-size: 0.78rem !important;
|
||||
}
|
||||
|
||||
/* Reducción de espaciado en FullCalendar */
|
||||
.fc .fc-toolbar {
|
||||
margin-bottom: 0.5rem !important;
|
||||
}
|
||||
.fc .fc-toolbar-title {
|
||||
font-size: 1.15rem !important;
|
||||
}
|
||||
.fc .fc-button {
|
||||
padding: 0.25rem 0.55rem !important;
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
.fc-event {
|
||||
padding: 2px 4px !important;
|
||||
font-size: 0.75rem !important;
|
||||
margin: 1px 0 !important;
|
||||
}
|
||||
.fc-timegrid-slot {
|
||||
height: 1.7em !important;
|
||||
}
|
||||
|
||||
/* Role banner en Dashboard */
|
||||
.dashboard-role-banner {
|
||||
padding: 0.75rem 1.1rem !important;
|
||||
border-radius: 0.75rem !important;
|
||||
margin-bottom: 0.85rem !important;
|
||||
}
|
||||
.dashboard-role-banner h1 {
|
||||
font-size: 1.25rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
OPTIMIZACIONES GLOBALES: 1080p (1920x1080)
|
||||
======================================================= */
|
||||
@media (min-width: 1600px) and (min-height: 900px) {
|
||||
.table td, .table th {
|
||||
padding: 0.75rem 1rem !important;
|
||||
font-size: 0.92rem !important;
|
||||
}
|
||||
.card-body {
|
||||
padding: 1.25rem !important;
|
||||
}
|
||||
.fc-event {
|
||||
font-size: 0.82rem !important;
|
||||
padding: 3px 6px !important;
|
||||
}
|
||||
}
|
||||
@@ -4,56 +4,67 @@
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* --- Brand Primary (Magenta / Uva) --- */
|
||||
/* --- Brand Primary (Magenta / Frambuesa Oficial UniCABA) --- */
|
||||
--brand-primary: #B43E8E;
|
||||
--brand-primary-rgb: 180, 62, 142;
|
||||
--brand-primary-shade: #9E367D;
|
||||
--brand-primary-tint: #BC5199;
|
||||
--brand-primary-shade: #9E2D77;
|
||||
--brand-primary-tint: #D63384;
|
||||
--brand-primary-contrast: #FFFFFF;
|
||||
|
||||
/* --- Accent Colors (Modo Claro) --- */
|
||||
/* --- Accent Colors (Modo Claro) UniCABA: Rosas y Púrpuras Cálidos --- */
|
||||
--accent-purple: #682CAD;
|
||||
--accent-purple-rgb: 104, 44, 173;
|
||||
--secondary-magenta: #7B2049;
|
||||
--secondary-magenta-rgb: 123, 32, 73;
|
||||
--secondary-magenta: #9E2D77;
|
||||
--secondary-magenta-rgb: 158, 45, 119;
|
||||
--accent-pink-soft: #FDF2F8;
|
||||
--accent-pink-border: #FBCFE8;
|
||||
|
||||
/* --- Global Surface & Text (Modo Claro) --- */
|
||||
--app-bg: #F8FAFC;
|
||||
/* --- Global Surface & Text (Modo Claro - Rosas Suaves, Blanco Puro y Contraste Nitido) --- */
|
||||
--app-bg: #FAF5F8;
|
||||
--card-bg: #FFFFFF;
|
||||
--card-border: rgba(0, 0, 0, 0.08);
|
||||
--card-hover-border: rgba(180, 62, 142, 0.3);
|
||||
--card-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
||||
--card-hover-shadow: 0 10px 25px rgba(180, 62, 142, 0.12);
|
||||
--card-border: rgba(180, 62, 142, 0.16);
|
||||
--card-hover-border: #B43E8E;
|
||||
--card-shadow: 0 4px 18px rgba(180, 62, 142, 0.05), 0 1px 3px rgba(0, 0, 0, 0.02);
|
||||
--card-hover-shadow: 0 8px 24px rgba(180, 62, 142, 0.12);
|
||||
|
||||
--text-primary: #1E293B;
|
||||
--text-secondary: #64748B;
|
||||
--text-muted: #94A3B8;
|
||||
--text-secondary: #475569;
|
||||
--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-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-border: #CBD5E1;
|
||||
--input-text: #1E293B;
|
||||
--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 --- */
|
||||
--gradient-primary: linear-gradient(135deg, #B43E8E 0%, #9E367D 100%);
|
||||
--gradient-primary-shadow: 0 8px 24px rgba(180, 62, 142, 0.25);
|
||||
--gradient-hero: linear-gradient(135deg, rgba(180, 62, 142, 0.08) 0%, rgba(104, 44, 173, 0.12) 100%);
|
||||
/* --- Distinctive Gradient UniCABA --- */
|
||||
--gradient-primary: linear-gradient(135deg, #B43E8E 0%, #9E2D77 100%);
|
||||
--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.06) 100%);
|
||||
|
||||
/* --- Badges (Modo Claro) --- */
|
||||
--badge-primary-bg: #B43E8E;
|
||||
--badge-primary-text: #FFFFFF;
|
||||
--badge-accent-bg: #682CAD;
|
||||
--badge-accent-text: #FFFFFF;
|
||||
--badge-magenta-bg: #7B2049;
|
||||
--badge-magenta-bg: #9E2D77;
|
||||
--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"])
|
||||
======================================================= */
|
||||
@@ -173,18 +184,14 @@ body {
|
||||
height: 32px;
|
||||
width: auto;
|
||||
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,
|
||||
:root:not([data-bs-theme="dark"]) .navbar-logo {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
/* En Modo Oscuro se mantiene el isotipo con su tono natural */
|
||||
:root:not([data-bs-theme="dark"]) .navbar-logo,
|
||||
[data-bs-theme="dark"] .navbar-logo {
|
||||
filter: none;
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
/* User Avatar Component */
|
||||
@@ -680,3 +687,12 @@ body {
|
||||
color: #E2E8F0;
|
||||
}
|
||||
|
||||
/* Chart Container Utility — previene expansión infinita en Chart.js */
|
||||
.chart-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 260px;
|
||||
max-height: 320px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
+185
-24
@@ -33,10 +33,41 @@ nunjucksRuntime.memberLookup = function(obj, val) {
|
||||
const cookieParser = require('cookie-parser');
|
||||
const path = require('path');
|
||||
const axios = require('axios');
|
||||
const helmet = require('helmet');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
app.disable('x-powered-by');
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// Blindaje HTTP con Helmet (compatible con despliegues directos por IP y proxies HTTPS)
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false,
|
||||
crossOriginEmbedderPolicy: false,
|
||||
crossOriginOpenerPolicy: false,
|
||||
originAgentCluster: false
|
||||
}));
|
||||
|
||||
// Rate Limiting para protección contra ataques de fuerza bruta y abuso
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 60,
|
||||
message: 'Demasiados intentos de autenticación. Por favor intente nuevamente en 15 minutos.',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false
|
||||
});
|
||||
const generalLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 180,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false
|
||||
});
|
||||
app.post('/auth/login', authLimiter);
|
||||
app.post('/auth/moodle', authLimiter);
|
||||
app.use('/api', generalLimiter);
|
||||
|
||||
// Configuración de middlewares
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
@@ -125,47 +156,70 @@ const translateHelper = function(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.addFilter('_', translateHelper);
|
||||
env.addGlobal('url_for', urlForHelper);
|
||||
env.addFilter('url_for', urlForHelper);
|
||||
|
||||
app.set('view engine', 'html');
|
||||
|
||||
const { buildUserSession } = require('./middlewares/authMiddleware');
|
||||
|
||||
// Helper para extraer usuario de cookie si existe
|
||||
app.use((req, res, next) => {
|
||||
const token = req.cookies.auth_token;
|
||||
let user = {
|
||||
name: 'System',
|
||||
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' }
|
||||
};
|
||||
let user = buildUserSession({ role: 'admin', name: 'System', email: 'admin@edu-space.com' });
|
||||
user.is_authenticated = Boolean(token);
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const decoded = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString('utf8'));
|
||||
const roleStr = (decoded.role || 'Admin').toLowerCase();
|
||||
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
|
||||
};
|
||||
user = buildUserSession(decoded);
|
||||
} catch (e) {}
|
||||
}
|
||||
let isImpersonating = false;
|
||||
let impersonatedUser = null;
|
||||
if (user.is_admin() && req.cookies.impersonate_user_id) {
|
||||
isImpersonating = true;
|
||||
const targetId = req.cookies.impersonate_user_id;
|
||||
try {
|
||||
if (req.cookies.impersonate_user_data) {
|
||||
impersonatedUser = JSON.parse(req.cookies.impersonate_user_data);
|
||||
}
|
||||
} catch (e) {}
|
||||
if (!impersonatedUser) {
|
||||
impersonatedUser = { id: targetId, name: `Usuario #${targetId}`, role: 'usuario' };
|
||||
}
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
res.locals.user = user;
|
||||
res.locals.current_user = user;
|
||||
res.locals.role = user.role;
|
||||
res.locals.is_impersonating = isImpersonating;
|
||||
res.locals.impersonated_user = impersonatedUser;
|
||||
res.locals.session = { get: (k, d) => d };
|
||||
res.locals.occupancy_metrics = { get: (k, d) => d };
|
||||
next();
|
||||
@@ -181,6 +235,32 @@ const buildingsRoutes = require('./routes/buildings');
|
||||
const adminRoutes = require('./routes/admin');
|
||||
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('/auth', authRoutes);
|
||||
app.use('/dashboard', dashboardRoutes);
|
||||
@@ -232,6 +312,9 @@ app.use('/api', async (req, res) => {
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
if (req.cookies.impersonate_user_id) {
|
||||
headers['X-Impersonate-User'] = req.cookies.impersonate_user_id;
|
||||
}
|
||||
const flaskUrl = `http://127.0.0.1:5000/api${req.url}`;
|
||||
const response = await axios({
|
||||
method: req.method,
|
||||
@@ -382,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, () => {
|
||||
console.log(`BFF Frontend corriendo en http://localhost:${port}`);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,78 @@
|
||||
const apiClient = require('../services/apiClient');
|
||||
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 token = req.cookies.auth_token;
|
||||
|
||||
@@ -17,24 +89,7 @@ const requireAuth = (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
let user = {
|
||||
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'
|
||||
}
|
||||
};
|
||||
let user = buildUserSession({ role: 'admin', name: 'System', email: 'admin@edu-space.com' });
|
||||
|
||||
try {
|
||||
const payloadBase64 = token.split('.')[1];
|
||||
@@ -46,31 +101,45 @@ const requireAuth = (req, res, next) => {
|
||||
return res.redirect('/auth/login');
|
||||
}
|
||||
|
||||
const roleStr = (decoded.role || 'Admin').toLowerCase();
|
||||
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'
|
||||
}
|
||||
};
|
||||
user = buildUserSession(decoded);
|
||||
} catch (e) {
|
||||
console.error('Error decoding token payload:', e.message);
|
||||
}
|
||||
|
||||
let isImpersonating = false;
|
||||
let impersonatedUser = null;
|
||||
|
||||
if (user.is_admin() && req.cookies.impersonate_user_id) {
|
||||
isImpersonating = true;
|
||||
const targetId = req.cookies.impersonate_user_id;
|
||||
req.apiClient.defaults.headers['X-Impersonate-User'] = targetId;
|
||||
|
||||
try {
|
||||
if (req.cookies.impersonate_user_data) {
|
||||
impersonatedUser = JSON.parse(req.cookies.impersonate_user_data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing impersonate_user_data:', e.message);
|
||||
}
|
||||
|
||||
if (!impersonatedUser) {
|
||||
impersonatedUser = {
|
||||
id: targetId,
|
||||
name: `Usuario #${targetId}`,
|
||||
email: 'impersonated@edu-space.com',
|
||||
role: 'usuario'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
res.locals.user = user;
|
||||
res.locals.current_user = user;
|
||||
res.locals.role = user.role;
|
||||
res.locals.is_impersonating = isImpersonating;
|
||||
res.locals.impersonated_user = impersonatedUser;
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = { requireAuth };
|
||||
module.exports = { requireAuth, buildUserSession };
|
||||
|
||||
+525
-58
@@ -291,6 +291,7 @@ const MOCK_USERS = [
|
||||
const fetchUsersAndRoles = async (apiClient) => {
|
||||
let users = [...MOCK_USERS];
|
||||
let roles = [...MOCK_ROLES];
|
||||
let document_types = [];
|
||||
try {
|
||||
const [uRes, rRes] = await Promise.allSettled([
|
||||
apiClient.get('/users'),
|
||||
@@ -298,7 +299,8 @@ const fetchUsersAndRoles = async (apiClient) => {
|
||||
]);
|
||||
if (uRes.status === 'fulfilled' && uRes.value.data) {
|
||||
const d = uRes.value.data;
|
||||
users = (d.users || d) || users;
|
||||
users = d.users || (Array.isArray(d) ? d : users);
|
||||
document_types = d.document_types || [];
|
||||
}
|
||||
if (rRes.status === 'fulfilled' && rRes.value.data) {
|
||||
const d = rRes.value.data;
|
||||
@@ -307,21 +309,41 @@ const fetchUsersAndRoles = async (apiClient) => {
|
||||
} catch (e) {
|
||||
console.warn('Using mock users/roles data:', e.message);
|
||||
}
|
||||
return { users, roles };
|
||||
if (!document_types || document_types.length === 0) {
|
||||
document_types = [
|
||||
{ code: 'DNI', name: 'Documento Nacional de Identidad', category: 'Nacional', placeholder: '8 dígitos sin puntos' },
|
||||
{ code: 'CUIL', name: 'Código Único de Identificación Laboral (CUIL/CUIT)', category: 'Nacional / Laboral', placeholder: '20-12345678-9' },
|
||||
{ code: 'PAS', name: 'Pasaporte Argentino', category: 'Internacional', placeholder: 'AAB123456' },
|
||||
{ code: 'PASEXT', name: 'Pasaporte Extranjero', category: 'Internacional / Extranjero', placeholder: 'Alfanumérico país de origen' },
|
||||
{ code: 'DNIEXT', name: 'DNI para Extranjeros (Residente)', category: 'Extranjero / Residente', placeholder: 'DNI residente' },
|
||||
{ code: 'CI', name: 'Cédula de Identidad (PFA / Prov)', category: 'Nacional (Histórico)', placeholder: 'Cédula de identidad' },
|
||||
{ code: 'CIEXT', name: 'Cédula de Identidad Extranjera (Mercosur)', category: 'Regional / Mercosur', placeholder: 'Cédula Mercosur' },
|
||||
{ code: 'LC', name: 'Libreta Cívica', category: 'Histórico Nacional', placeholder: 'Libreta Cívica' },
|
||||
{ code: 'LE', name: 'Libreta de Enrolamiento', category: 'Histórico Nacional', placeholder: 'Libreta Enrolamiento' }
|
||||
];
|
||||
}
|
||||
return { users, roles, document_types };
|
||||
};
|
||||
|
||||
const handleUsersList = async (req, res) => {
|
||||
try {
|
||||
const { users, roles } = await fetchUsersAndRoles(req.apiClient);
|
||||
const { users, roles, document_types } = await fetchUsersAndRoles(req.apiClient);
|
||||
const search = (req.query.search || '').toLowerCase().trim();
|
||||
const role_filter = req.query.role || '';
|
||||
const status_filter = req.query.status || '';
|
||||
const msg = req.query.msg || '';
|
||||
const error = req.query.error || '';
|
||||
|
||||
let filtered = [...users];
|
||||
if (search) {
|
||||
filtered = filtered.filter(u =>
|
||||
(u.name && u.name.toLowerCase().includes(search)) ||
|
||||
(u.email && u.email.toLowerCase().includes(search))
|
||||
(u.first_name && u.first_name.toLowerCase().includes(search)) ||
|
||||
(u.last_name && u.last_name.toLowerCase().includes(search)) ||
|
||||
(u.email && u.email.toLowerCase().includes(search)) ||
|
||||
(u.personal_email && u.personal_email.toLowerCase().includes(search)) ||
|
||||
(u.document_number && u.document_number.toLowerCase().includes(search)) ||
|
||||
(u.phone && u.phone.toLowerCase().includes(search))
|
||||
);
|
||||
}
|
||||
if (role_filter) {
|
||||
@@ -331,76 +353,167 @@ const handleUsersList = async (req, res) => {
|
||||
if (status_filter === 'inactive') filtered = filtered.filter(u => !u.is_active);
|
||||
|
||||
res.render('admin/users/list', {
|
||||
title: 'User Management - Edu-Space Admin',
|
||||
title: 'Gestión de Usuarios — Edu-Space Admin',
|
||||
users: filtered,
|
||||
roles,
|
||||
document_types,
|
||||
search,
|
||||
role_filter,
|
||||
status_filter
|
||||
status_filter,
|
||||
msg,
|
||||
error
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error in users list:', err.message);
|
||||
res.render('admin/users/list', {
|
||||
title: 'User Management - Edu-Space Admin',
|
||||
title: 'Gestión de Usuarios — Edu-Space Admin',
|
||||
users: MOCK_USERS,
|
||||
roles: MOCK_ROLES,
|
||||
search: '', role_filter: '', status_filter: ''
|
||||
document_types: [],
|
||||
search: '', role_filter: '', status_filter: '', msg: '', error: ''
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserAdd = async (req, res) => {
|
||||
try {
|
||||
const { roles } = await fetchUsersAndRoles(req.apiClient);
|
||||
const { roles, document_types } = await fetchUsersAndRoles(req.apiClient);
|
||||
res.render('admin/users/form', {
|
||||
title: 'New User - Edu-Space Admin',
|
||||
title: 'Nuevo Usuario — Edu-Space Admin',
|
||||
user: null,
|
||||
roles
|
||||
roles,
|
||||
document_types,
|
||||
error: req.query.error || null,
|
||||
msg: req.query.msg || null
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error in user add:', err.message);
|
||||
res.render('admin/users/form', { title: 'New User', user: null, roles: MOCK_ROLES });
|
||||
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) => {
|
||||
try {
|
||||
const { users, roles } = await fetchUsersAndRoles(req.apiClient);
|
||||
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', {
|
||||
title: `Edit User: ${targetUser.name} - Edu-Space Admin`,
|
||||
title: `Editar Usuario: ${userName} — Edu-Space Admin`,
|
||||
user: targetUser,
|
||||
roles
|
||||
roles,
|
||||
document_types,
|
||||
error: req.query.error || null,
|
||||
msg: req.query.msg || null
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error in user edit:', err.message);
|
||||
res.render('admin/users/form', { title: 'Edit User', user: MOCK_USERS[0], roles: MOCK_ROLES });
|
||||
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
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserSave = async (req, res) => {
|
||||
const userId = req.params.id || req.body.id;
|
||||
const { name, email, password, role_id, is_active } = req.body;
|
||||
const payload = { name, email, role_id: parseInt(role_id), is_active: is_active === 'on' || is_active === 'true' };
|
||||
if (password) payload.password = password;
|
||||
const {
|
||||
name, first_name, last_name, email, personal_email, password, role_id, is_active,
|
||||
phone, address, document_type, document_number
|
||||
} = req.body;
|
||||
|
||||
const payload = {
|
||||
first_name: (first_name || '').trim(),
|
||||
last_name: (last_name || '').trim(),
|
||||
name: (name || `${first_name || ''} ${last_name || ''}`).trim(),
|
||||
email: (email || '').trim().toLowerCase(),
|
||||
personal_email: (personal_email || '').trim().toLowerCase(),
|
||||
phone: (phone || '').trim(),
|
||||
address: (address || '').trim(),
|
||||
document_type: (document_type || 'DNI').trim().toUpperCase(),
|
||||
document_number: (document_number || '').trim(),
|
||||
role_id: role_id ? parseInt(role_id, 10) : null,
|
||||
is_active: is_active === 'on' || is_active === 'true' || is_active === true
|
||||
};
|
||||
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();
|
||||
}
|
||||
try {
|
||||
if (userId) {
|
||||
await req.apiClient.put(`/users/${userId}`, payload);
|
||||
} else {
|
||||
await req.apiClient.post('/users', payload);
|
||||
}
|
||||
return res.redirect('/admin/users_list?msg=saved');
|
||||
} catch (e) {
|
||||
console.warn('User save API notice:', e.response?.data || e.message);
|
||||
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}`);
|
||||
}
|
||||
res.redirect('/admin/users_list');
|
||||
};
|
||||
|
||||
router.get(['/users_list', '/users', '/user_list'], handleUsersList);
|
||||
router.get(['/user_add', '/users/new'], handleUserAdd);
|
||||
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(['/users/:id/password', '/users/password', '/user_password', '/user_password/:id', '/user_reset_password'], handleUserPasswordReset);
|
||||
|
||||
|
||||
// ─── 4. ROLES ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -539,20 +652,13 @@ const handleCareersList = async (req, res) => {
|
||||
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', {
|
||||
title: 'Gestión de Carreras - Edu-Space Admin',
|
||||
careers,
|
||||
stats,
|
||||
search,
|
||||
msg: req.query.msg || '',
|
||||
error: req.query.error || '',
|
||||
can_edit: true
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -565,10 +671,12 @@ const handleCareerAdd = async (req, res) => {
|
||||
const { name, code, description } = req.body;
|
||||
try {
|
||||
await req.apiClient.post('/careers', { name, code, description });
|
||||
res.redirect('/admin/careers_list?msg=created');
|
||||
} catch (e) {
|
||||
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) => {
|
||||
@@ -576,20 +684,24 @@ const handleCareerEdit = async (req, res) => {
|
||||
const { name, code, description } = req.body;
|
||||
try {
|
||||
await req.apiClient.put(`/careers/${id}`, { name, code, description });
|
||||
res.redirect('/admin/careers_list?msg=updated');
|
||||
} catch (e) {
|
||||
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 id = req.params.id || req.body.id;
|
||||
try {
|
||||
await req.apiClient.delete(`/careers/${id}`);
|
||||
res.redirect('/admin/careers_list?msg=deleted');
|
||||
} catch (e) {
|
||||
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);
|
||||
@@ -632,6 +744,8 @@ const handleSubjectsList = async (req, res) => {
|
||||
career_filter: career_id ? parseInt(career_id, 10) : '',
|
||||
active_filter: active,
|
||||
can_edit: true,
|
||||
msg: req.query.msg || '',
|
||||
error: req.query.error || '',
|
||||
pagination: { pages: 1, page: 1, total: subjects.length, has_prev: false, has_next: false }
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -652,10 +766,12 @@ const handleSubjectAdd = async (req, res) => {
|
||||
description,
|
||||
is_active: is_active === 'on' || is_active === 'true' || is_active === true
|
||||
});
|
||||
res.redirect('/admin/subjects_list?msg=created');
|
||||
} catch (e) {
|
||||
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) => {
|
||||
@@ -671,30 +787,35 @@ const handleSubjectEdit = async (req, res) => {
|
||||
description,
|
||||
is_active: is_active === 'on' || is_active === 'true' || is_active === true
|
||||
});
|
||||
res.redirect('/admin/subjects_list?msg=updated');
|
||||
} catch (e) {
|
||||
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 id = req.params.id || req.body.id;
|
||||
try {
|
||||
await req.apiClient.post(`/subjects/${id}/toggle`);
|
||||
res.redirect('/admin/subjects_list?msg=toggled');
|
||||
} catch (e) {
|
||||
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 id = req.params.id || req.body.id;
|
||||
try {
|
||||
await req.apiClient.delete(`/subjects/${id}`);
|
||||
const delRes = await req.apiClient.delete(`/subjects/${id}`);
|
||||
const action = delRes.data?.action || 'deleted';
|
||||
return res.redirect(`/admin/subjects_list?msg=${action}`);
|
||||
} catch (e) {
|
||||
console.warn('Subject delete API notice:', e.message);
|
||||
console.warn('Subject delete API notice:', e.response?.data || e.message);
|
||||
return res.redirect('/admin/subjects_list?error=delete');
|
||||
}
|
||||
res.redirect('/admin/subjects_list');
|
||||
};
|
||||
|
||||
router.get(['/subjects_list', '/subjects', '/subject_list'], handleSubjectsList);
|
||||
@@ -716,6 +837,7 @@ const handleCommissionsList = async (req, res) => {
|
||||
let commissions = [];
|
||||
let subjects = [];
|
||||
let teachers = [];
|
||||
let academic_terms = [];
|
||||
|
||||
try {
|
||||
const [cRes, uRes] = await Promise.allSettled([
|
||||
@@ -733,9 +855,19 @@ const handleCommissionsList = async (req, res) => {
|
||||
code: c.subject_code,
|
||||
career_name: c.career_name || 'Carrera General'
|
||||
},
|
||||
teacher: c.teacher_name ? { name: c.teacher_name } : null
|
||||
teacher: c.teacher_name ? { name: c.teacher_name } : null,
|
||||
teachers: c.teachers || (c.teacher_name ? [{ name: c.teacher_name, role: 'Titular' }] : [])
|
||||
}));
|
||||
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) {
|
||||
@@ -754,11 +886,15 @@ const handleCommissionsList = async (req, res) => {
|
||||
commissions,
|
||||
subjects,
|
||||
teachers,
|
||||
academic_terms,
|
||||
search,
|
||||
subject_filter: subject_id ? parseInt(subject_id, 10) : '',
|
||||
shift_filter: shift,
|
||||
active_filter: active,
|
||||
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 }
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -776,16 +912,22 @@ const handleCommissionDetail = async (req, res) => {
|
||||
|
||||
try {
|
||||
const [cRes, uRes] = await Promise.allSettled([
|
||||
req.apiClient.get('/commissions'),
|
||||
req.apiClient.get(`/commissions/${id}`),
|
||||
req.apiClient.get('/users')
|
||||
]);
|
||||
if (cRes.status === 'fulfilled' && cRes.value.data) {
|
||||
const list = cRes.value.data.commissions || [];
|
||||
if (cRes.status === 'fulfilled' && cRes.value.data && cRes.value.data.commission) {
|
||||
commission = cRes.value.data.commission;
|
||||
} else {
|
||||
const allRes = await req.apiClient.get('/commissions');
|
||||
const list = allRes.data?.commissions || [];
|
||||
commission = list.find(c => String(c.id) === String(id)) || list[0];
|
||||
}
|
||||
if (uRes.status === 'fulfilled' && uRes.value.data) {
|
||||
const users = uRes.value.data.users || [];
|
||||
teachers = users.filter(u => (u.role || '').toLowerCase().includes('docent') || (u.role || '').toLowerCase().includes('admin'));
|
||||
teachers = users.filter(u => {
|
||||
const r = (u.role || '').toLowerCase();
|
||||
return r.includes('docent') || r.includes('prof') || r.includes('admin') || r.includes('bedel');
|
||||
});
|
||||
}
|
||||
} catch (apiErr) {
|
||||
console.warn('Commission detail API notice:', apiErr.message);
|
||||
@@ -805,27 +947,61 @@ const handleCommissionDetail = async (req, res) => {
|
||||
virtual_link: '',
|
||||
subject_name: 'Arquitectura de Software',
|
||||
subject_code: 'ARQ-101',
|
||||
teacher_name: 'Prof. Juan Pérez'
|
||||
teacher_name: 'Prof. Juan Pérez',
|
||||
teachers: []
|
||||
};
|
||||
}
|
||||
|
||||
const commObj = {
|
||||
...commission,
|
||||
get_full_code: () => commission.code || `COM-${commission.id}`,
|
||||
subject: {
|
||||
get_full_code: () => commission.full_code || commission.code || `COM-${commission.id}`,
|
||||
subject: commission.subject || {
|
||||
name: commission.subject_name || 'Asignatura',
|
||||
code: commission.subject_code || 'COD-01',
|
||||
career_obj: { name: 'Licenciatura en Sistemas' }
|
||||
career_obj: { name: commission.career_name || 'Carrera General' }
|
||||
},
|
||||
teacher: commission.teacher_name ? { name: commission.teacher_name, email: 'docente@edu-space.com' } : null
|
||||
teacher: commission.teacher_name ? { name: commission.teacher_name, email: 'docente@edu-space.com' } : null,
|
||||
teachers: commission.teachers || (commission.teacher_name ? [{ name: commission.teacher_name, role: 'Titular', email: 'docente@edu-space.com' }] : [])
|
||||
};
|
||||
|
||||
let enrollments = [];
|
||||
try {
|
||||
const enrRes = await req.apiClient.get(`/commissions/${id}/enrollments`);
|
||||
if (enrRes.data && enrRes.data.enrollments) {
|
||||
enrollments = enrRes.data.enrollments;
|
||||
}
|
||||
} catch (enrErr) {
|
||||
console.warn('Commission enrollments API notice:', enrErr.message);
|
||||
}
|
||||
|
||||
let students = [];
|
||||
try {
|
||||
const usersRes = await req.apiClient.get('/users');
|
||||
const allUsers = Array.isArray(usersRes.data) ? usersRes.data : (usersRes.data.users || []);
|
||||
students = allUsers.filter(u => {
|
||||
const r = (u.role_name || u.role || '').toLowerCase();
|
||||
return r === 'alumno' || r === 'estudiante';
|
||||
});
|
||||
if (students.length === 0) {
|
||||
students = allUsers;
|
||||
}
|
||||
} catch (usersErr) {
|
||||
console.warn('Students list API notice:', usersErr.message);
|
||||
}
|
||||
|
||||
const enrolled_ids = enrollments.map(e => e.student_id);
|
||||
|
||||
res.render('admin/commissions/detail', {
|
||||
title: `Comisión ${commObj.get_full_code()} — Edu-Space`,
|
||||
commission: commObj,
|
||||
teachers,
|
||||
enrollments: [],
|
||||
can_edit: true
|
||||
students,
|
||||
enrollments,
|
||||
enrolled_ids,
|
||||
can_edit: true,
|
||||
error: req.query.error,
|
||||
success: req.query.success,
|
||||
unregistered: req.query.unregistered
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error in commission detail:', err.message);
|
||||
@@ -835,12 +1011,13 @@ const handleCommissionDetail = async (req, res) => {
|
||||
|
||||
const handleCommissionAdd = async (req, res) => {
|
||||
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 = {
|
||||
subject_id: parseInt(subject_id, 10),
|
||||
code: (code || '').trim(),
|
||||
semester: semester || '1C',
|
||||
year: year ? parseInt(year, 10) : 2026,
|
||||
academic_term_id: academic_term_id ? parseInt(academic_term_id, 10) : null,
|
||||
shift: shift || 'Mañana',
|
||||
teacher_id: teacher_id ? parseInt(teacher_id, 10) : null,
|
||||
max_students: max_students ? parseInt(max_students, 10) : 40,
|
||||
@@ -849,31 +1026,60 @@ const handleCommissionAdd = async (req, res) => {
|
||||
active: true
|
||||
};
|
||||
await req.apiClient.post('/commissions', payload);
|
||||
res.redirect('/admin/commissions_list?created=1');
|
||||
res.redirect('/admin/commissions_list?msg=created');
|
||||
} catch (e) {
|
||||
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 id = req.params.id || req.body.id;
|
||||
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) {
|
||||
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 id = req.params.id || req.body.id;
|
||||
try {
|
||||
await req.apiClient.post(`/commissions/${id}/toggle`);
|
||||
res.redirect('/admin/commissions_list?msg=toggled');
|
||||
} catch (e) {
|
||||
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) => {
|
||||
@@ -887,12 +1093,88 @@ const handleCommissionAssignTeacher = async (req, res) => {
|
||||
res.redirect(`/admin/commission_detail?id=${id}`);
|
||||
};
|
||||
|
||||
const handleCommissionAddTeacher = async (req, res) => {
|
||||
const id = req.params.id || req.body.id || req.body.commission_id;
|
||||
const { teacher_id, role } = req.body;
|
||||
try {
|
||||
await req.apiClient.post(`/commissions/${id}/teachers`, {
|
||||
teacher_id: parseInt(teacher_id, 10),
|
||||
role: role || 'Titular'
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Commission add teacher API notice:', e.response?.data || e.message);
|
||||
}
|
||||
res.redirect(`/admin/commission_detail?id=${id}`);
|
||||
};
|
||||
|
||||
const handleCommissionRemoveTeacher = async (req, res) => {
|
||||
const id = req.params.id || req.body.id || req.body.commission_id;
|
||||
const teacher_id = req.params.teacher_id || req.body.teacher_id;
|
||||
try {
|
||||
await req.apiClient.delete(`/commissions/${id}/teachers/${teacher_id}`);
|
||||
} catch (e) {
|
||||
console.warn('Commission remove teacher API notice:', e.response?.data || e.message);
|
||||
}
|
||||
res.redirect(`/admin/commission_detail?id=${id}`);
|
||||
};
|
||||
|
||||
const handleCommissionEnrollStudent = async (req, res) => {
|
||||
const id = req.params.id || req.body.id || req.body.commission_id;
|
||||
const { student_id, notes, allow_same_day_exception, exception_reason } = req.body;
|
||||
try {
|
||||
await req.apiClient.post(`/commissions/${id}/enrollments`, {
|
||||
student_id: parseInt(student_id, 10),
|
||||
notes: (notes || '').trim() || null,
|
||||
allow_same_day_exception: Boolean(allow_same_day_exception),
|
||||
exception_reason: (exception_reason || '').trim() || null
|
||||
});
|
||||
res.redirect(`/admin/commission_detail?id=${id}&success=1`);
|
||||
} catch (e) {
|
||||
const errorMsg = encodeURIComponent(e.response?.data?.message || 'Error al matricular estudiante.');
|
||||
res.redirect(`/admin/commission_detail?id=${id}&error=${errorMsg}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommissionUnenrollStudent = async (req, res) => {
|
||||
const id = req.params.id || req.body.id || req.body.commission_id;
|
||||
const student_id = req.params.student_id || req.body.student_id;
|
||||
try {
|
||||
await req.apiClient.delete(`/commissions/${id}/enrollments/${student_id}`);
|
||||
res.redirect(`/admin/commission_detail?id=${id}&unregistered=1`);
|
||||
} catch (e) {
|
||||
const errorMsg = encodeURIComponent(e.response?.data?.message || 'Error al desvincular estudiante.');
|
||||
res.redirect(`/admin/commission_detail?id=${id}&error=${errorMsg}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommissionUpdateEnrollment = async (req, res) => {
|
||||
const id = req.params.id || req.body.id || req.body.commission_id;
|
||||
const student_id = req.params.student_id || req.body.student_id;
|
||||
const { status, notes } = req.body;
|
||||
try {
|
||||
await req.apiClient.put(`/commissions/${id}/enrollments/${student_id}`, {
|
||||
status: status || 'activo',
|
||||
notes: notes || undefined
|
||||
});
|
||||
res.redirect(`/admin/commission_detail?id=${id}&updated=1`);
|
||||
} catch (e) {
|
||||
const errorMsg = encodeURIComponent(e.response?.data?.message || 'Error al actualizar estado de matrícula.');
|
||||
res.redirect(`/admin/commission_detail?id=${id}&error=${errorMsg}`);
|
||||
}
|
||||
};
|
||||
|
||||
router.get(['/commissions_list', '/commissions', '/commission_list'], handleCommissionsList);
|
||||
router.get(['/commission_detail', '/commission_detail/:id', '/commissions/:id'], handleCommissionDetail);
|
||||
router.post(['/commission_add', '/commissions/add'], handleCommissionAdd);
|
||||
router.post(['/commission_edit', '/commission_edit/:id'], handleCommissionEdit);
|
||||
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_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_enroll_student', '/commissions/:id/enroll-student', '/commissions/enroll-student'], handleCommissionEnrollStudent);
|
||||
router.post(['/commission_unenroll_student', '/commissions/:id/unenroll-student', '/commissions/unenroll-student'], handleCommissionUnenrollStudent);
|
||||
router.post(['/commission_update_enrollment', '/commissions/:id/update-enrollment'], handleCommissionUpdateEnrollment);
|
||||
|
||||
// ─── 8. ACADEMIC TERMS ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -960,10 +1242,32 @@ const handleAcademicTermSetCurrent = async (req, res) => {
|
||||
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.post(['/academic_term_add', '/academic-terms/add'], handleAcademicTermAdd);
|
||||
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_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 ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -1065,4 +1369,167 @@ const handleAuditLogs = async (req, res) => {
|
||||
|
||||
router.get(['/audit_logs', '/audit-logs', '/audit'], handleAuditLogs);
|
||||
|
||||
// ─── 11. CONFIGURACIÓN GLOBAL & MOODLE SYNC ────────────────────────────────────
|
||||
|
||||
const handleGlobalSettings = async (req, res) => {
|
||||
let settings = {
|
||||
smtp: { host: 'smtp.gmail.com', port: 587, security: 'tls' },
|
||||
auth_providers: { local: true, google: false, moodle: false },
|
||||
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 }
|
||||
};
|
||||
let demo_access_enabled = true;
|
||||
let demo_accounts_active = true;
|
||||
|
||||
try {
|
||||
const resp = await req.apiClient.get('/admin/settings/all');
|
||||
if (resp.data && resp.data.settings) {
|
||||
settings = resp.data.settings;
|
||||
} else if (resp.data && resp.data.data) {
|
||||
settings = resp.data.data;
|
||||
}
|
||||
} catch (e) {
|
||||
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', {
|
||||
title: 'Configuración Global del Sistema - Admin Edu-Space',
|
||||
settings,
|
||||
demo_access_enabled,
|
||||
demo_accounts_active
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
+195
-5
@@ -2,8 +2,37 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const apiClient = require('../services/apiClient');
|
||||
|
||||
router.get('/login', (req, res) => {
|
||||
res.render('auth/login', { error: null });
|
||||
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) => {
|
||||
let providers = { local: true, google: false, moodle: false };
|
||||
let google_client_id = '';
|
||||
let demo_access_enabled = true;
|
||||
try {
|
||||
const resp = await apiClient.get('/auth/providers');
|
||||
if (resp.data) {
|
||||
if (resp.data.providers) {
|
||||
providers = resp.data.providers;
|
||||
}
|
||||
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) {
|
||||
// Fallback default
|
||||
}
|
||||
|
||||
res.render('auth/login', {
|
||||
error: req.query.error || null,
|
||||
providers,
|
||||
google_client_id,
|
||||
demo_access_enabled
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
@@ -20,10 +49,9 @@ router.post('/login', async (req, res) => {
|
||||
const token = response.data.access_token;
|
||||
|
||||
if (token) {
|
||||
// Guardamos el JWT en una cookie httpOnly
|
||||
res.cookie('auth_token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
secure: isSecureCookie(req),
|
||||
sameSite: 'lax',
|
||||
maxAge: 24 * 60 * 60 * 1000 // 1 día
|
||||
});
|
||||
@@ -34,13 +62,175 @@ router.post('/login', async (req, res) => {
|
||||
} catch (error) {
|
||||
console.error('Login error:', error.response?.data || error.message);
|
||||
const errorMsg = error.response?.data?.message || 'Error en el servidor de autenticación';
|
||||
res.render('auth/login', { error: errorMsg });
|
||||
res.render('auth/login', {
|
||||
error: errorMsg,
|
||||
providers: { local: true, google: false, moodle: false }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Proxy para Login Delegado Moodle
|
||||
router.post('/moodle', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
const response = await apiClient.post('/auth/moodle', { username, password });
|
||||
const token = response.data.access_token;
|
||||
|
||||
if (token) {
|
||||
res.cookie('auth_token', token, {
|
||||
httpOnly: true,
|
||||
secure: isSecureCookie(req),
|
||||
sameSite: 'lax',
|
||||
maxAge: 24 * 60 * 60 * 1000
|
||||
});
|
||||
return res.redirect('/dashboard');
|
||||
}
|
||||
res.redirect('/auth/login?error=moodle_auth_failed');
|
||||
} catch (error) {
|
||||
console.error('Moodle auth error:', error.response?.data || error.message);
|
||||
const errorMsg = encodeURIComponent(error.response?.data?.message || 'Error de autenticación en Moodle.');
|
||||
res.redirect(`/auth/login?error=${errorMsg}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Proxy para Google OAuth
|
||||
router.post('/google', async (req, res) => {
|
||||
try {
|
||||
const response = await apiClient.post('/auth/google', req.body);
|
||||
const token = response.data.access_token;
|
||||
|
||||
if (token) {
|
||||
res.cookie('auth_token', token, {
|
||||
httpOnly: true,
|
||||
secure: isSecureCookie(req),
|
||||
sameSite: 'lax',
|
||||
maxAge: 24 * 60 * 60 * 1000
|
||||
});
|
||||
return res.json({ success: true, redirect: '/dashboard' });
|
||||
}
|
||||
return res.status(401).json({ success: false, message: 'Fallo al autenticar con Google.' });
|
||||
} catch (error) {
|
||||
console.error('Google auth error:', error.response?.data || error.message);
|
||||
return res.status(error.response?.status || 500).json({
|
||||
success: false,
|
||||
message: error.response?.data?.message || 'Error en autenticación Google Workspace.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const { requireAuth } = require('../middlewares/authMiddleware');
|
||||
|
||||
router.get('/logout', (req, res) => {
|
||||
res.clearCookie('auth_token');
|
||||
res.clearCookie('impersonate_user_id');
|
||||
res.clearCookie('impersonate_user_data');
|
||||
res.redirect('/auth/login');
|
||||
});
|
||||
|
||||
// Iniciar Modo Impersonación (Solo Admin)
|
||||
router.get('/impersonate/:id', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const targetUserId = parseInt(req.params.id, 10);
|
||||
if (!req.user || !req.user.is_admin()) {
|
||||
return res.status(403).send('Solo los administradores pueden activar el modo de impersonación.');
|
||||
}
|
||||
|
||||
const response = await req.apiClient.post('/admin/impersonate', {
|
||||
user_id: targetUserId
|
||||
});
|
||||
|
||||
const targetUser = response.data && response.data.target_user;
|
||||
if (targetUser) {
|
||||
res.cookie('impersonate_user_id', targetUserId.toString(), {
|
||||
httpOnly: true,
|
||||
secure: isSecureCookie(req),
|
||||
sameSite: 'lax'
|
||||
});
|
||||
res.cookie('impersonate_user_data', JSON.stringify(targetUser), {
|
||||
httpOnly: true,
|
||||
secure: isSecureCookie(req),
|
||||
sameSite: 'lax'
|
||||
});
|
||||
}
|
||||
res.redirect('/dashboard');
|
||||
} catch (error) {
|
||||
console.error('Error initiating impersonation:', error.response?.data || error.message);
|
||||
res.redirect('/admin/users_list?error=impersonate_failed');
|
||||
}
|
||||
});
|
||||
|
||||
// Detener Modo Impersonación
|
||||
router.get('/stop-impersonating', requireAuth, async (req, res) => {
|
||||
try {
|
||||
await req.apiClient.post('/admin/stop-impersonating', {});
|
||||
} catch (error) {
|
||||
console.error('Error stopping impersonation on backend:', error.response?.data || error.message);
|
||||
} finally {
|
||||
res.clearCookie('impersonate_user_id');
|
||||
res.clearCookie('impersonate_user_data');
|
||||
res.redirect('/admin/users_list');
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
|
||||
@@ -5,112 +5,120 @@ const { createFormMock } = require('../utils/formHelper');
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
// Mock storage of buildings to support CRUD in memory/BFF layer
|
||||
let buildingsData = [
|
||||
// Fallback data si la API estuviera inaccesible (con capacidad física real)
|
||||
const defaultBuildings = [
|
||||
{
|
||||
id: 1,
|
||||
code: 'CENTRAL',
|
||||
name: 'Edificio Central',
|
||||
is_active: true,
|
||||
address: 'Av. Paseo Colón 255, CABA',
|
||||
description: 'Sede académica principal, aulas de grado, laboratorios y auditorio.',
|
||||
is_virtual: false,
|
||||
address: 'Tte. Gral. Juan Domingo Perón 802, CABA',
|
||||
description: 'Sede central institucional de UniCABA. Aulas de grado, auditorio y laboratorios.',
|
||||
floors: 4,
|
||||
classrooms_count: 29,
|
||||
total_capacity: 1250
|
||||
physical_capacity: 1250,
|
||||
total_capacity: 1250,
|
||||
capacity_display: '1250'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
code: 'VIRTUAL',
|
||||
name: 'Campus Virtual',
|
||||
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.',
|
||||
floors: 1,
|
||||
floors: 0,
|
||||
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
|
||||
const handleListBuildings = async (req, res) => {
|
||||
try {
|
||||
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];
|
||||
|
||||
if (search) {
|
||||
const s = search.toLowerCase();
|
||||
filtered = filtered.filter(b =>
|
||||
(b.name && b.name.toLowerCase().includes(s)) ||
|
||||
(b.code && b.code.toLowerCase().includes(s)) ||
|
||||
(b.address && b.address.toLowerCase().includes(s))
|
||||
);
|
||||
try {
|
||||
const apiRes = await req.apiClient.get('/buildings', { params: { search, status } });
|
||||
if (apiRes.data && apiRes.data.buildings) {
|
||||
buildings = apiRes.data.buildings;
|
||||
if (apiRes.data.stats) {
|
||||
stats = apiRes.data.stats;
|
||||
}
|
||||
}
|
||||
} catch (apiErr) {
|
||||
console.warn('API /buildings notice, using fallback:', apiErr.message);
|
||||
buildings = [...defaultBuildings];
|
||||
}
|
||||
|
||||
if (status === 'active') {
|
||||
filtered = filtered.filter(b => b.is_active);
|
||||
} else if (status === 'inactive') {
|
||||
filtered = filtered.filter(b => !b.is_active);
|
||||
// Si por alguna razón la lista vino vacía de la API y no había filtros de búsqueda, usar default
|
||||
if (buildings.length === 0 && !search && !status) {
|
||||
buildings = [...defaultBuildings];
|
||||
}
|
||||
|
||||
// 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', {
|
||||
title: 'Gestión de Edificios - Edu-Space Admin',
|
||||
buildings: filtered,
|
||||
total_classrooms: stats.totalRooms,
|
||||
total_capacity: stats.totalCap,
|
||||
buildings,
|
||||
total_classrooms: stats.total_classrooms || buildings.reduce((a, b) => a + (b.classrooms_count || 0), 0),
|
||||
total_capacity: total_physical_capacity,
|
||||
total_physical_capacity,
|
||||
search,
|
||||
status_filter: status
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error rendering buildings list:', error.message);
|
||||
res.render('buildings/list', {
|
||||
title: 'Gestión de Edificios (Offline)',
|
||||
buildings: buildingsData,
|
||||
title: 'Gestión de Edificios - Edu-Space Admin',
|
||||
buildings: defaultBuildings,
|
||||
total_classrooms: 31,
|
||||
total_capacity: 11249,
|
||||
total_capacity: 1250,
|
||||
total_physical_capacity: 1250,
|
||||
search: '',
|
||||
status_filter: ''
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
router.get('/', handleListBuildings);
|
||||
router.get('/list', handleListBuildings);
|
||||
router.get('/list_buildings', handleListBuildings);
|
||||
router.get(['/', '/list', '/list_buildings'], handleListBuildings);
|
||||
|
||||
// 2. Add Building
|
||||
// 2. Add Building (GET & POST)
|
||||
router.get(['/add', '/add_building'], (req, res) => {
|
||||
const form = createFormMock({
|
||||
is_active: true,
|
||||
floors: 3
|
||||
name: '',
|
||||
code: '',
|
||||
address: '',
|
||||
floors: 1,
|
||||
description: '',
|
||||
is_active: true
|
||||
});
|
||||
res.render('buildings/form', {
|
||||
title: 'Nuevo Edificio - Edu-Space Admin',
|
||||
@@ -119,50 +127,139 @@ router.get(['/add', '/add_building'], (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
router.post(['/add', '/add_building'], (req, res) => {
|
||||
const { name, code, address, floors, description, is_active } = req.body;
|
||||
const newBuilding = {
|
||||
id: buildingsData.length + 1,
|
||||
name: name || `Edificio ${buildingsData.length + 1}`,
|
||||
code: code || `EDIF-${buildingsData.length + 1}`,
|
||||
address: address || '',
|
||||
floors: parseInt(floors, 10) || 1,
|
||||
description: description || '',
|
||||
is_active: is_active === 'true' || is_active === 'on' || is_active === true,
|
||||
classrooms_count: 0,
|
||||
total_capacity: 0
|
||||
};
|
||||
buildingsData.push(newBuilding);
|
||||
res.redirect('/buildings/list_buildings');
|
||||
router.post(['/add', '/add_building'], async (req, res) => {
|
||||
try {
|
||||
const { name, code, address, floors, description, is_active } = req.body;
|
||||
const payload = {
|
||||
name: (name || '').trim(),
|
||||
code: (code || '').trim().toUpperCase() || null,
|
||||
address: (address || '').trim() || null,
|
||||
floors: parseInt(floors, 10) || 1,
|
||||
description: (description || '').trim() || null,
|
||||
is_active: is_active === 'true' || is_active === 'on' || is_active === true || is_active === '1'
|
||||
};
|
||||
await req.apiClient.post('/buildings', payload);
|
||||
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
|
||||
router.get(['/view/:id', '/view_building'], (req, res) => {
|
||||
// 3. View Building
|
||||
router.get(['/view/:id', '/view_building', '/:id'], async (req, res) => {
|
||||
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', {
|
||||
title: `Edificio ${building.name} - Edu-Space Admin`,
|
||||
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 building = buildingsData.find(b => b.id === id) || buildingsData[0];
|
||||
const form = createFormMock(building);
|
||||
let building = null;
|
||||
|
||||
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', {
|
||||
title: `Editar Edificio - ${building.name}`,
|
||||
building,
|
||||
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) => {
|
||||
const id = parseInt(req.params.id || req.query.id, 10) || 1;
|
||||
const building = buildingsData.find(b => b.id === id);
|
||||
if (building) {
|
||||
building.is_active = !building.is_active;
|
||||
// 7. Delete Building
|
||||
router.post(['/delete/:id', '/:id/delete', '/delete_building'], async (req, res) => {
|
||||
const id = parseInt(req.params.id || req.query.id || req.body.id, 10);
|
||||
try {
|
||||
await req.apiClient.delete(`/buildings/${id}`);
|
||||
} catch (e) {
|
||||
console.warn('Error deleting building:', e.message);
|
||||
}
|
||||
res.redirect('/buildings/list_buildings');
|
||||
});
|
||||
|
||||
@@ -6,8 +6,13 @@ router.use(requireAuth);
|
||||
|
||||
const handleClassroomsList = async (req, res) => {
|
||||
try {
|
||||
const response = await req.apiClient.get('/classrooms');
|
||||
const allClassrooms = (response.data && response.data.classrooms) || [];
|
||||
let allClassrooms = [];
|
||||
try {
|
||||
const response = await req.apiClient.get('/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 search = (req.query.search || '').toLowerCase().trim();
|
||||
@@ -17,7 +22,7 @@ const handleClassroomsList = async (req, res) => {
|
||||
|
||||
// Filter based on view mode (physical vs virtual)
|
||||
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;
|
||||
return !isVirt;
|
||||
});
|
||||
@@ -25,9 +30,9 @@ const handleClassroomsList = async (req, res) => {
|
||||
// Additional filters
|
||||
if (search) {
|
||||
filtered = filtered.filter(c =>
|
||||
(c.code && c.code.toLowerCase().includes(search)) ||
|
||||
(c.description && c.description.toLowerCase().includes(search)) ||
|
||||
(c.building && c.building.toLowerCase().includes(search))
|
||||
((c.code || '').toLowerCase().includes(search)) ||
|
||||
((c.description || '').toLowerCase().includes(search)) ||
|
||||
((c.building || '').toLowerCase().includes(search))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,9 +48,21 @@ const handleClassroomsList = async (req, res) => {
|
||||
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 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 => {
|
||||
if (c.building_id && 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 virtual_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 => Boolean(c.is_virtual || (c.code || '').includes('VIRTUAL'))).length;
|
||||
|
||||
const dateFilters = {
|
||||
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
|
||||
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();
|
||||
c_res.forEach(r => {
|
||||
const cid = r.commission_id || (r.commission && r.commission.id) || r.id;
|
||||
@@ -88,15 +105,15 @@ const handleClassroomsList = async (req, res) => {
|
||||
commMap.set(cid, {
|
||||
commission_id: cid,
|
||||
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 || '',
|
||||
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 week_res = c_res.filter(r => r.start_time && r.start_time >= '2026-09-14' && r.start_time <= '2026-09-20');
|
||||
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 || '') >= '2026-09-14' && (r.start_time || '') <= '2026-09-20');
|
||||
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 month_pct = Math.min(100, Math.round((c_res.length / 72) * 100));
|
||||
@@ -129,8 +146,8 @@ const handleClassroomsList = async (req, res) => {
|
||||
total_count: 0
|
||||
};
|
||||
|
||||
const virtualClassroomObj = allClassrooms.find(c => Boolean(c.is_virtual || (c.code && c.code.includes('VIRTUAL'))));
|
||||
const virtual_classroom_id = virtualClassroomObj ? virtualClassroomObj.id : 32;
|
||||
const virtualClassroomObj = allClassrooms.find(c => Boolean(c.is_virtual || ((c.code || '').includes('VIRTUAL'))));
|
||||
const virtual_classroom_id = virtualClassroomObj ? virtualClassroomObj.id : null;
|
||||
|
||||
if (view_mode === 'virtual') {
|
||||
try {
|
||||
@@ -162,12 +179,14 @@ const handleClassroomsList = async (req, res) => {
|
||||
|
||||
virtual_rooms = Array.from(commMap.values()).map(data => {
|
||||
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 => {
|
||||
if (!r.start_time) return false;
|
||||
const d = new Date(r.start_time);
|
||||
return d >= weekStart && d <= weekEnd;
|
||||
}).length;
|
||||
const monthCnt = resList.filter(r => {
|
||||
if (!r.start_time) return false;
|
||||
const d = new Date(r.start_time);
|
||||
return d >= monthStart && d <= monthEnd;
|
||||
}).length;
|
||||
@@ -186,27 +205,29 @@ const handleClassroomsList = async (req, res) => {
|
||||
week_count: weekCnt,
|
||||
month_count: monthCnt,
|
||||
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) {
|
||||
virtual_rooms = virtual_rooms.filter(room =>
|
||||
room.subject_name.toLowerCase().includes(search) ||
|
||||
room.commission_code.toLowerCase().includes(search) ||
|
||||
room.subject_code.toLowerCase().includes(search)
|
||||
(room.subject_name || '').toLowerCase().includes(search) ||
|
||||
(room.commission_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 = {
|
||||
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 => {
|
||||
if (!r.start_time) return false;
|
||||
const d = new Date(r.start_time);
|
||||
return d >= weekStart && d <= weekEnd;
|
||||
}).length,
|
||||
month_count: virtualReservations.filter(r => {
|
||||
if (!r.start_time) return false;
|
||||
const d = new Date(r.start_time);
|
||||
return d >= monthStart && d <= monthEnd;
|
||||
}).length,
|
||||
@@ -247,10 +268,11 @@ const handleClassroomsList = async (req, res) => {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in classrooms list route:', error.message);
|
||||
const view_mode = (req.query.view_mode || 'physical').toLowerCase();
|
||||
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] },
|
||||
view_mode: 'physical',
|
||||
view_mode,
|
||||
search: '',
|
||||
building_filter: '',
|
||||
floor_filter: '',
|
||||
@@ -259,8 +281,17 @@ const handleClassroomsList = async (req, res) => {
|
||||
virtual_count: 0,
|
||||
buildings: [],
|
||||
floors: [],
|
||||
occupancy_metrics: { get: (id, defVal) => defVal },
|
||||
date_filters: {}
|
||||
occupancy_metrics: {},
|
||||
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) => {
|
||||
try {
|
||||
let allClassrooms = [];
|
||||
let allBuildings = [];
|
||||
try {
|
||||
const response = await req.apiClient.get('/classrooms');
|
||||
allClassrooms = (response.data && response.data.classrooms) || [];
|
||||
const [cRes, bRes] = await Promise.allSettled([
|
||||
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) {
|
||||
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();
|
||||
buildingsMap.set('1', { id: 1, name: 'Edificio Central' });
|
||||
buildingsMap.set('2', { id: 2, name: 'Campus Virtual' });
|
||||
allBuildings.forEach(b => {
|
||||
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 => {
|
||||
const bId = String(c.building_id || 1);
|
||||
if (!buildingFloors[bId]) buildingFloors[bId] = [];
|
||||
if (c.floor && !buildingFloors[bId].includes(c.floor)) {
|
||||
buildingFloors[bId].push(c.floor);
|
||||
}
|
||||
if (c.building_id && c.building) {
|
||||
buildingsMap.set(bId, { id: c.building_id, name: c.building });
|
||||
const bId = String(c.building_id || '');
|
||||
if (bId) {
|
||||
if (!buildingFloors[bId]) buildingFloors[bId] = [];
|
||||
if (c.floor && !buildingFloors[bId].includes(c.floor)) {
|
||||
buildingFloors[bId].push(c.floor);
|
||||
}
|
||||
if (c.building) {
|
||||
buildingsMap.set(bId, { id: c.building_id, name: c.building });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const buildings = Array.from(buildingsMap.values());
|
||||
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 })),
|
||||
{ value: 'new', text: '+ Crear nuevo edificio' }
|
||||
{ value: 'new', text: '+ Crear nuevo edificio / sede' }
|
||||
];
|
||||
|
||||
const form = createFormMock({
|
||||
@@ -327,21 +373,33 @@ const handlePostClassroom = async (req, res) => {
|
||||
try {
|
||||
const { building_id, new_building_name, floor, room_number, capacity, description, is_active } = req.body;
|
||||
|
||||
let targetBuilding = building_id;
|
||||
if (building_id === 'new' && new_building_name) {
|
||||
targetBuilding = new_building_name;
|
||||
}
|
||||
const isNewBuilding = building_id === 'new' || !building_id;
|
||||
const bName = (new_building_name || '').trim();
|
||||
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 = {
|
||||
code: room_number || `Aula-${Date.now()}`,
|
||||
capacity: (capacity === '∞' || capacity === '0' || !capacity) ? 0 : parseInt(capacity, 10),
|
||||
floor: floor || 'PB',
|
||||
building_id: (targetBuilding === '2' || targetBuilding === 'Campus Virtual') ? 2 : 1,
|
||||
capacity: (isVirtual || capacity === '∞' || capacity === '0' || !capacity) ? 0 : parseInt(capacity, 10),
|
||||
floor: floor || (isVirtual ? 'Digital' : 'PB'),
|
||||
description: description || '',
|
||||
is_virtual: Boolean(targetBuilding === '2' || targetBuilding === 'Campus Virtual'),
|
||||
is_virtual: isVirtual,
|
||||
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 {
|
||||
await req.apiClient.post('/classrooms', payload);
|
||||
} catch (apiErr) {
|
||||
@@ -529,13 +587,25 @@ const handlePostEditClassroom = async (req, res) => {
|
||||
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 = {
|
||||
code: room_number,
|
||||
capacity: (capacity === '∞' || capacity === '0' || !capacity) ? 0 : parseInt(capacity, 10),
|
||||
floor: floor || 'PB',
|
||||
building_id: (targetBuilding === '2' || targetBuilding === 'Campus Virtual') ? 2 : 1,
|
||||
capacity: (isVirtual || capacity === '∞' || capacity === '0' || !capacity) ? 0 : parseInt(capacity, 10),
|
||||
floor: floor || (isVirtual ? 'Digital' : 'PB'),
|
||||
building_id: bId,
|
||||
description: description || '',
|
||||
is_virtual: Boolean(targetBuilding === '2' || targetBuilding === 'Campus Virtual'),
|
||||
is_virtual: isVirtual,
|
||||
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', 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;
|
||||
|
||||
@@ -68,6 +68,9 @@ router.get('/', async (req, res) => {
|
||||
? apiData.milestones
|
||||
: null;
|
||||
|
||||
// Bedelía-specific data
|
||||
const bedeliaData = apiData.bedelia || {};
|
||||
|
||||
// Consolidated and role-specific stats
|
||||
const stats = {
|
||||
total_classrooms,
|
||||
@@ -135,7 +138,11 @@ router.get('/', async (req, res) => {
|
||||
{ start_time: '08:00', end_time: '12:00', classroom: { code: 'Aula 102' }, subject_name: 'Algoritmos y Estructuras de Datos', user: { name: 'Prof. Ana Vega' } },
|
||||
{ start_time: '08:30', end_time: '12:30', classroom: { code: 'Aula 204' }, subject_name: 'Matemática Discreta', user: { name: 'Prof. Carlos López' } },
|
||||
{ start_time: '14:00', end_time: '18:00', classroom: { code: 'Lab Redes' }, subject_name: 'Redes y Telecomunicaciones', user: { name: 'Ing. Marcos Ruiz' } }
|
||||
]
|
||||
],
|
||||
unassigned_commissions_week: bedeliaData.unassigned_commissions_week || [],
|
||||
unassigned_commissions_month: bedeliaData.unassigned_commissions_month || [],
|
||||
available_classrooms_week: bedeliaData.available_classrooms_week || [],
|
||||
available_classrooms_month: bedeliaData.available_classrooms_month || []
|
||||
};
|
||||
} else if (role === 'docente') {
|
||||
const myClasses = apiTodaySchedule ? apiTodaySchedule.slice(0, 3) : null;
|
||||
@@ -169,6 +176,65 @@ router.get('/', async (req, res) => {
|
||||
};
|
||||
} else if (role === 'alumno') {
|
||||
const studentClasses = apiTodaySchedule ? apiTodaySchedule.slice(0, 4) : null;
|
||||
let upcomingMilestonesList = [];
|
||||
try {
|
||||
const mResp = await req.apiClient.get('/students/my-upcoming-milestones');
|
||||
if (mResp.data && Array.isArray(mResp.data.upcoming_milestones) && mResp.data.upcoming_milestones.length > 0) {
|
||||
upcomingMilestonesList = mResp.data.upcoming_milestones.map(m => {
|
||||
const isFinal = (m.type || '').includes('Final');
|
||||
const isParcial = (m.type || '').includes('Parcial');
|
||||
const color = isFinal ? '#dc3545' : (isParcial ? '#fd7e14' : '#0d6efd');
|
||||
return {
|
||||
...m,
|
||||
materia_codigo: m.commission_code || m.subject_name,
|
||||
type_obj: { name: m.type, color },
|
||||
milestone_type: { name: m.type, color }
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Student milestones fetch notice:', e.message);
|
||||
}
|
||||
|
||||
if (upcomingMilestonesList.length === 0) {
|
||||
upcomingMilestonesList = [
|
||||
{
|
||||
title: 'Primer Examen Parcial',
|
||||
materia_codigo: 'BDR-201',
|
||||
subject_name: 'Bases de Datos Relacionales',
|
||||
date: '25/09/2026',
|
||||
days_remaining: 2,
|
||||
type_obj: { name: 'Examen Parcial', color: '#dc3545' },
|
||||
milestone_type: { name: 'Examen Parcial', color: '#dc3545' },
|
||||
notes: 'Traer DNI y calculadora',
|
||||
classroom: 'Piso 2 · Aula 204'
|
||||
},
|
||||
{
|
||||
title: 'Entrega TP Grupal Nº 2',
|
||||
materia_codigo: 'AED-102',
|
||||
subject_name: 'Algoritmos y Estructuras de Datos',
|
||||
date: '30/09/2026',
|
||||
days_remaining: 7,
|
||||
type_obj: { name: 'Trabajo Práctico', color: '#198754' },
|
||||
milestone_type: { name: 'Trabajo Práctico', color: '#198754' },
|
||||
notes: 'Subir código al repositorio',
|
||||
classroom: 'Campus Virtual',
|
||||
link: 'https://campus.edu-space.com/tps'
|
||||
},
|
||||
{
|
||||
title: 'Recuperatorio Parcial 1',
|
||||
materia_codigo: 'RYT-302',
|
||||
subject_name: 'Redes y Telecomunicaciones',
|
||||
date: '08/10/2026',
|
||||
days_remaining: 15,
|
||||
type_obj: { name: 'Recuperatorio', color: '#fd7e14' },
|
||||
milestone_type: { name: 'Recuperatorio', color: '#fd7e14' },
|
||||
notes: 'Piso 2 · Aula 204',
|
||||
classroom: 'Piso 2 · Aula 204'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
role_data = {
|
||||
today_classes: studentClasses || [
|
||||
{
|
||||
@@ -190,42 +256,7 @@ router.get('/', async (req, res) => {
|
||||
virtual_link: 'https://meet.google.com/edu-space-demo'
|
||||
}
|
||||
],
|
||||
upcoming_milestones: apiMilestones ? apiMilestones.map(m => ({
|
||||
...m,
|
||||
milestone_type: m.type_obj
|
||||
})) : [
|
||||
{
|
||||
title: 'Primer Examen Parcial',
|
||||
materia_codigo: 'BDR-201',
|
||||
subject_name: 'Bases de Datos Relacionales',
|
||||
date: '25/09/2026',
|
||||
type_obj: { name: 'Examen Parcial', color: '#dc3545' },
|
||||
milestone_type: { name: 'Examen Parcial', color: '#dc3545' },
|
||||
notes: 'Traer DNI y calculadora',
|
||||
classroom: 'Piso 2 · Aula 204'
|
||||
},
|
||||
{
|
||||
title: 'Entrega TP Grupal Nº 2',
|
||||
materia_codigo: 'AED-102',
|
||||
subject_name: 'Algoritmos y Estructuras de Datos',
|
||||
date: '30/09/2026',
|
||||
type_obj: { name: 'Trabajo Práctico', color: '#198754' },
|
||||
milestone_type: { name: 'Trabajo Práctico', color: '#198754' },
|
||||
notes: 'Subir código al repositorio',
|
||||
classroom: 'Campus Virtual',
|
||||
link: 'https://campus.edu-space.com/tps'
|
||||
},
|
||||
{
|
||||
title: 'Recuperatorio Parcial 1',
|
||||
materia_codigo: 'RYT-302',
|
||||
subject_name: 'Redes y Telecomunicaciones',
|
||||
date: '08/10/2026',
|
||||
type_obj: { name: 'Recuperatorio', color: '#fd7e14' },
|
||||
milestone_type: { name: 'Recuperatorio', color: '#fd7e14' },
|
||||
notes: 'Piso 2 · Aula 204',
|
||||
classroom: 'Piso 2 · Aula 204'
|
||||
}
|
||||
]
|
||||
upcoming_milestones: upcomingMilestonesList
|
||||
};
|
||||
}
|
||||
|
||||
@@ -235,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' } }
|
||||
];
|
||||
|
||||
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', {
|
||||
title: role === 'bedelia' ? 'Panel Operativo de Bedelía - Edu-Space Admin' :
|
||||
role === 'docente' ? 'Panel Docente - Edu-Space Admin' :
|
||||
@@ -243,6 +279,7 @@ router.get('/', async (req, res) => {
|
||||
user: req.user,
|
||||
role,
|
||||
actual_role,
|
||||
show_role_banner,
|
||||
stats,
|
||||
role_data,
|
||||
monthly_data,
|
||||
|
||||
@@ -98,5 +98,7 @@ const handleMySubjectDetail = async (req, res) => {
|
||||
|
||||
router.get(['/', '/list', '/my_subjects_list'], handleMySubjectsList);
|
||||
router.get(['/detail', '/commission_detail', '/:id', '/detail/:id'], handleMySubjectDetail);
|
||||
router.get(['/gradebook/:id', '/:id/gradebook'], (req, res) => res.redirect(`/schedule/gradebook/${req.params.id}`));
|
||||
router.get(['/grades', '/my_grades', '/mis-notas'], (req, res) => res.redirect('/schedule/my_grades'));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
+141
-19
@@ -21,14 +21,24 @@ const handleCalendarView = async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
let unassignedCommissions = [];
|
||||
try {
|
||||
const commsRes = await req.apiClient.get('/genetic/commissions');
|
||||
unassignedCommissions = (commsRes.data && commsRes.data.commissions) || [];
|
||||
} catch (e) {
|
||||
console.warn('Could not fetch unassigned commissions for calendar:', e.message);
|
||||
}
|
||||
|
||||
res.render('schedule/calendar', {
|
||||
title: 'Reservations Calendar - Edu-Space Admin',
|
||||
today_date: new Date().toISOString().split('T')[0],
|
||||
classrooms: classrooms.map(c => ({
|
||||
id: c.id,
|
||||
code: c.code,
|
||||
building: c.building || c.building_name || 'Edificio Central'
|
||||
building: c.building || c.building_name || 'Edificio Central',
|
||||
capacity: c.capacity || 40
|
||||
})),
|
||||
unassigned_commissions: unassignedCommissions,
|
||||
subjects: [
|
||||
{ id: 1, name: 'Arquitectura de Software', code: 'ARQ-101' },
|
||||
{ id: 2, name: 'Bases de Datos II', code: 'BD-201' },
|
||||
@@ -48,6 +58,7 @@ const handleCalendarView = async (req, res) => {
|
||||
title: 'Reservations Calendar (Offline)',
|
||||
today_date: new Date().toISOString().split('T')[0],
|
||||
classrooms: [],
|
||||
unassigned_commissions: [],
|
||||
subjects: [],
|
||||
careers: [],
|
||||
buildings: [],
|
||||
@@ -296,13 +307,33 @@ const handleAddReservation = async (req, res) => {
|
||||
};
|
||||
});
|
||||
|
||||
const 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: 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 }
|
||||
];
|
||||
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: 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 }
|
||||
];
|
||||
}
|
||||
|
||||
const classroomOptions = [
|
||||
{ value: '', text: '-- Selecciona Aula --' },
|
||||
@@ -311,20 +342,36 @@ const handleAddReservation = async (req, res) => {
|
||||
|
||||
const commissionOptions = [
|
||||
{ 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 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 startTimeDefault = new Date(now.getTime() + 3600000).toISOString().substring(0, 16);
|
||||
const endTimeDefault = new Date(now.getTime() + 7200000).toISOString().substring(0, 16);
|
||||
|
||||
const form = createFormMock({
|
||||
classroom_id: initialClassroomId,
|
||||
commission_id: initialCommissionId,
|
||||
start_time: startTimeDefault,
|
||||
end_time: endTimeDefault,
|
||||
expected_attendees: 30,
|
||||
purpose: 'Clase Regular'
|
||||
expected_attendees: initialExpectedAttendees,
|
||||
purpose: initialPurpose,
|
||||
virtual_link: initialVirtualLink
|
||||
}, {
|
||||
classroom_id: classroomOptions,
|
||||
commission_id: commissionOptions
|
||||
@@ -335,7 +382,9 @@ const handleAddReservation = async (req, res) => {
|
||||
form,
|
||||
classroom_list: classroomList,
|
||||
commission_list: commissionList,
|
||||
buildings: Array.from(buildingsMap.values())
|
||||
buildings: Array.from(buildingsMap.values()),
|
||||
initial_commission_id: initialCommissionId,
|
||||
initial_classroom_id: initialClassroomId
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error rendering add reservation:', error.message);
|
||||
@@ -488,13 +537,32 @@ const handleEditReservation = async (req, res) => {
|
||||
};
|
||||
});
|
||||
|
||||
const 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: 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 }
|
||||
];
|
||||
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: 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 }
|
||||
];
|
||||
}
|
||||
|
||||
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.)` }));
|
||||
@@ -573,6 +641,16 @@ const handleCancelReservation = async (req, res) => {
|
||||
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(['/edit_reservation', '/edit_reservation/:id', '/reservation/:id/edit'], handleEditReservation);
|
||||
@@ -580,6 +658,50 @@ router.post(['/edit_reservation', '/edit_reservation/:id', '/reservation/:id/edi
|
||||
|
||||
router.post(['/confirm_reservation', '/confirm_reservation/:id', '/reservation/:id/confirm'], handleConfirmReservation);
|
||||
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)
|
||||
const handleGradebook = async (req, res) => {
|
||||
const id = req.params.id || req.query.id;
|
||||
try {
|
||||
const resp = await req.apiClient.get(`/commissions/${id}/gradebook`);
|
||||
const data = resp.data || {};
|
||||
res.render('schedule/gradebook', {
|
||||
title: `Libro de Calificaciones — Edu-Space`,
|
||||
commission: data.commission || { id, code: 'C1' },
|
||||
milestone_columns: data.milestone_columns || [],
|
||||
students: data.students || [],
|
||||
role: req.user?.role || 'docente'
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching commission gradebook:', err.message);
|
||||
res.redirect(`/admin/commission_detail?id=${id}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 10. Consulta de Calificaciones del Alumno (Fase 3)
|
||||
const handleMyGrades = async (req, res) => {
|
||||
try {
|
||||
const resp = await req.apiClient.get('/students/my-grades');
|
||||
const subjects = resp.data?.subjects || [];
|
||||
res.render('schedule/my_grades', {
|
||||
title: 'Mis Calificaciones — Edu-Space',
|
||||
subjects,
|
||||
role: req.user?.role || 'alumno'
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching student grades:', err.message);
|
||||
res.render('schedule/my_grades', {
|
||||
title: 'Mis Calificaciones — Edu-Space',
|
||||
subjects: [],
|
||||
role: req.user?.role || 'alumno'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
router.get(['/gradebook', '/gradebook/:id', '/commissions/:id/gradebook'], handleGradebook);
|
||||
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;
|
||||
|
||||
|
||||
@@ -105,72 +105,20 @@
|
||||
title="Editar Ciclo Lectivo">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Editar Ciclo Lectivo -->
|
||||
<div class="modal fade text-start" id="modalEditTerm{{ term.id }}" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="/admin/academic_term_edit">
|
||||
<input type="hidden" name="id" value="{{ term.id }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title h6 fw-bold">
|
||||
<i class="bi bi-pencil-square text-primary me-2"></i>Editar Ciclo Lectivo
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small fw-semibold">Código *</label>
|
||||
<input type="text" name="code" class="form-control form-control-sm font-monospace" value="{{ term.code }}" required>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<label class="form-label small fw-semibold">Año Calendario *</label>
|
||||
<input type="number" name="year" class="form-control form-control-sm" value="{{ term.year }}" min="2020" max="2050" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Nombre Descriptivo *</label>
|
||||
<input type="text" name="name" class="form-control form-control-sm" value="{{ term.name }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Tipo de Período</label>
|
||||
<select name="period_type" class="form-select form-select-sm">
|
||||
<option value="Primer Cuatrimestre" {% if term.period_type == 'Primer Cuatrimestre' %}selected{% endif %}>Primer Cuatrimestre</option>
|
||||
<option value="Segundo Cuatrimestre" {% if term.period_type == 'Segundo Cuatrimestre' %}selected{% endif %}>Segundo Cuatrimestre</option>
|
||||
<option value="Anual" {% if term.period_type == 'Anual' %}selected{% endif %}>Anual</option>
|
||||
<option value="Curso de Verano" {% if term.period_type == 'Curso de Verano' %}selected{% endif %}>Curso de Verano</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">Fecha Inicio</label>
|
||||
<input type="date" name="start_date" class="form-control form-control-sm" value="{{ term.start_date if term.start_date else '' }}">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">Fecha Fin</label>
|
||||
<input type="date" name="end_date" class="form-control form-control-sm" value="{{ term.end_date if term.end_date else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" name="is_current" id="isCurr{{ term.id }}" {% if term.is_current %}checked{% endif %}>
|
||||
<label class="form-check-label small fw-semibold text-primary" for="isCurr{{ term.id }}">
|
||||
Establecer como Ciclo Lectivo Activo Actual
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="is_active" id="isAct{{ term.id }}" {% if term.is_active %}checked{% endif %}>
|
||||
<label class="form-check-label small" for="isAct{{ term.id }}">Habilitado en el Sistema</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% 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 %}
|
||||
@@ -189,11 +137,121 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modales Editar Ciclo Lectivo (Fuera de la tabla para evitar conflictos de stacking context y cierre prematuro) -->
|
||||
{% if can_edit %}
|
||||
{% for term in terms %}
|
||||
<div class="modal fade text-start" id="modalEditTerm{{ term.id }}" tabindex="-1" aria-labelledby="modalEditTermTitle{{ term.id }}" aria-hidden="true" data-bs-backdrop="static">
|
||||
<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 }}">
|
||||
<div class="modal-header border-bottom">
|
||||
<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: {{ term.name }}
|
||||
</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-2 mb-3">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small fw-semibold">Código *</label>
|
||||
<input type="text" name="code" class="form-control form-control-sm font-monospace" value="{{ term.code }}" required>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<label class="form-label small fw-semibold">Año Calendario *</label>
|
||||
<input type="number" name="year" class="form-control form-control-sm" value="{{ term.year }}" min="2020" max="2050" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Nombre Descriptivo *</label>
|
||||
<input type="text" name="name" class="form-control form-control-sm" value="{{ term.name }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Tipo de Período</label>
|
||||
<select name="period_type" class="form-select form-select-sm">
|
||||
<option value="Primer Cuatrimestre" {% if term.period_type == 'Primer Cuatrimestre' %}selected{% endif %}>Primer Cuatrimestre</option>
|
||||
<option value="Segundo Cuatrimestre" {% if term.period_type == 'Segundo Cuatrimestre' %}selected{% endif %}>Segundo Cuatrimestre</option>
|
||||
<option value="Anual" {% if term.period_type == 'Anual' %}selected{% endif %}>Anual</option>
|
||||
<option value="Curso de Verano" {% if term.period_type == 'Curso de Verano' %}selected{% endif %}>Curso de Verano</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">Fecha Inicio</label>
|
||||
<input type="date" name="start_date" class="form-control form-control-sm" value="{{ term.start_date if term.start_date else '' }}">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">Fecha Fin</label>
|
||||
<input type="date" name="end_date" class="form-control form-control-sm" value="{{ term.end_date if term.end_date else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" name="is_current" id="isCurr{{ term.id }}" {% if term.is_current %}checked{% endif %}>
|
||||
<label class="form-check-label small fw-semibold text-primary" for="isCurr{{ term.id }}">
|
||||
Establecer como Ciclo Lectivo Activo Actual
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="is_active" id="isAct{{ term.id }}" {% if term.is_active %}checked{% endif %}>
|
||||
<label class="form-check-label small" for="isAct{{ term.id }}">Habilitado en el Sistema</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-top">
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not term.is_current %}
|
||||
<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 %}
|
||||
<div class="alert alert-secondary small mb-0 mt-3">
|
||||
<i class="bi bi-info-circle me-1"></i>No tiene comisiones vinculadas; se eliminará permanentemente de la base de datos.
|
||||
</div>
|
||||
{% endif %}
|
||||
</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="/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 %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Modal Nuevo Ciclo Lectivo -->
|
||||
{% if can_edit %}
|
||||
<div class="modal fade" id="modalAddTerm" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal fade" id="modalAddTerm" tabindex="-1" aria-labelledby="modalAddTermTitle" aria-hidden="true" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content shadow-lg border-0">
|
||||
<form method="POST" action="/admin/academic_term_add">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title h6 fw-bold">
|
||||
|
||||
@@ -26,6 +26,39 @@
|
||||
</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 -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body py-2">
|
||||
@@ -114,10 +147,15 @@
|
||||
<form method="POST" action="/admin/career_delete" class="d-inline"
|
||||
onsubmit="return confirm('¿Confirma que desea eliminar esta carrera permanentemente?');">
|
||||
<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>
|
||||
</button>
|
||||
</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 %}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,8 +13,35 @@
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
|
||||
<!-- Flash & Action Alerts -->
|
||||
{% if error %}
|
||||
<div class="alert alert-danger alert-dismissible fade show d-flex align-items-center gap-2 shadow-sm mb-4" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill fs-5 text-danger flex-shrink-0"></i>
|
||||
<div class="small fw-semibold flex-grow-1">{{ error }}</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if success %}
|
||||
<div class="alert alert-success alert-dismissible fade show d-flex align-items-center gap-2 shadow-sm mb-4" role="alert">
|
||||
<i class="bi bi-check-circle-fill fs-5 text-success flex-shrink-0"></i>
|
||||
<div class="small fw-semibold flex-grow-1">Alumno matriculado exitosamente en la comisión.</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if unregistered %}
|
||||
<div class="alert alert-info alert-dismissible fade show d-flex align-items-center gap-2 shadow-sm mb-4" role="alert">
|
||||
<i class="bi bi-info-circle-fill fs-5 text-info flex-shrink-0"></i>
|
||||
<div class="small fw-semibold flex-grow-1">El alumno ha sido desvinculado de la comisión.</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if updated %}
|
||||
<div class="alert alert-info alert-dismissible fade show d-flex align-items-center gap-2 shadow-sm mb-4" role="alert">
|
||||
<i class="bi bi-info-circle-fill fs-5 text-info flex-shrink-0"></i>
|
||||
<div class="small fw-semibold flex-grow-1">Estado de matrícula actualizado correctamente.</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Left: Commission Info + Teacher Assignment -->
|
||||
@@ -22,7 +49,7 @@
|
||||
|
||||
<!-- Commission Card -->
|
||||
<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">
|
||||
<i class="bi bi-diagram-3 fs-4"></i>
|
||||
<div>
|
||||
@@ -30,6 +57,11 @@
|
||||
<small class="opacity-75">{{ commission.semester }} {{ commission.year }}</small>
|
||||
</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 class="card-body">
|
||||
<h5 class="fw-bold mb-1">{{ commission.subject.name }}</h5>
|
||||
@@ -74,50 +106,111 @@
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<div class="mt-3 pt-2 border-top">
|
||||
<a href="/schedule/gradebook/{{ commission.id }}" class="btn btn-primary btn-sm w-100 shadow-sm d-flex align-items-center justify-content-center gap-2">
|
||||
<i class="bi bi-journal-check fs-6"></i>
|
||||
<span class="fw-semibold">Libro de Calificaciones (Actas)</span>
|
||||
</a>
|
||||
{% if commission.grades_closed %}
|
||||
<div class="badge bg-danger-subtle text-danger border border-danger-subtle w-100 mt-2 py-2 text-center">
|
||||
<i class="bi bi-lock-fill me-1"></i>Acta Cerrada: {{ commission.acta_number }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Teacher Assignment -->
|
||||
<!-- Co-Docencia / Equipo Docente -->
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-header border-0 bg-transparent py-2 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-person-video3 text-info"></i>
|
||||
<span class="fw-semibold">Docente Asignado</span>
|
||||
<div class="card-header border-0 bg-transparent py-2 d-flex align-items-center justify-content-between">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-people-fill text-info"></i>
|
||||
<span class="fw-semibold">Equipo Docente / Cátedra</span>
|
||||
</div>
|
||||
<span class="badge bg-info-subtle text-info border border-info-subtle">
|
||||
{{ commission.teachers|length if commission.teachers else (1 if commission.teacher else 0) }} docente(s)
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if commission.teacher %}
|
||||
<div class="d-flex align-items-center gap-3 mb-3 p-2 rounded bg-info-subtle border border-info-subtle">
|
||||
<div class="avatar-sm bg-info rounded-circle d-flex align-items-center justify-content-center" style="width:40px;height:40px;flex-shrink:0;">
|
||||
<i class="bi bi-person-fill text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold small">{{ commission.teacher.name }}</div>
|
||||
<div class="text-muted" style="font-size:.75rem;">{{ commission.teacher.email }}</div>
|
||||
{% if commission.teachers and commission.teachers|length > 0 %}
|
||||
<div class="d-flex flex-column gap-2 mb-3">
|
||||
{% for t in commission.teachers %}
|
||||
<div class="d-flex align-items-center justify-content-between p-2 rounded bg-body-tertiary border">
|
||||
<div class="d-flex align-items-center gap-2 overflow-hidden">
|
||||
<div class="avatar-sm {% if t.role == 'Titular' %}bg-primary{% elif t.role == 'Adjunto' %}bg-info{% else %}bg-secondary{% endif %} rounded-circle text-white d-flex align-items-center justify-content-center" style="width:34px;height:34px;flex-shrink:0;">
|
||||
<i class="bi bi-person-fill small"></i>
|
||||
</div>
|
||||
<div class="text-truncate">
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<span class="fw-semibold small text-truncate">{{ t.name }}</span>
|
||||
<span class="badge {% if t.role == 'Titular' %}bg-primary-subtle text-primary border border-primary-subtle{% elif t.role == 'Adjunto' %}bg-info-subtle text-info border border-info-subtle{% elif t.role == 'JTP' %}bg-warning-subtle text-warning border border-warning-subtle{% else %}bg-secondary-subtle text-secondary border{% endif %} py-0 px-1" style="font-size: 0.65rem;">
|
||||
{{ t.role or 'Docente' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-muted text-truncate" style="font-size:.75rem;">{{ t.email or 'Sin email' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<form method="POST" action="/admin/commission_remove_teacher" class="ms-2 flex-shrink-0" onsubmit="return confirm('¿Quitar a {{ t.name }} de la comisión?');">
|
||||
<input type="hidden" name="id" value="{{ commission.id }}">
|
||||
<input type="hidden" name="teacher_id" value="{{ t.user_id }}">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm py-0 px-1" title="Quitar de la cátedra">
|
||||
<i class="bi bi-trash" style="font-size: 0.75rem;"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif commission.teacher %}
|
||||
<div class="d-flex align-items-center justify-content-between p-2 rounded bg-body-tertiary border mb-3">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="avatar-sm bg-primary rounded-circle text-white d-flex align-items-center justify-content-center" style="width:34px;height:34px;flex-shrink:0;">
|
||||
<i class="bi bi-person-fill small"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold small">{{ commission.teacher.name }} <span class="badge bg-primary-subtle text-primary border py-0 px-1" style="font-size:0.65rem;">Titular</span></div>
|
||||
<div class="text-muted" style="font-size:.75rem;">{{ commission.teacher.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning py-2 small mb-3">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>Sin docente asignado
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>Sin docentes asignados a la comisión.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if can_edit %}
|
||||
<form method="POST" action="/admin/commission_assign_teacher" id="assign-teacher-form">
|
||||
<input type="hidden" name="id" value="{{ commission.id }}">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-semibold mb-1">Cambiar / Asignar Docente</label>
|
||||
<select name="teacher_id" class="form-select form-select-sm" id="teacher-select">
|
||||
<option value="">— Sin asignar —</option>
|
||||
{% for teacher in teachers %}
|
||||
<option value="{{ teacher.id }}" {% if commission.teacher_id == teacher.id %}selected{% endif %}>
|
||||
{{ teacher.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-info btn-sm w-100" id="assign-teacher-btn">
|
||||
<i class="bi bi-person-check me-1"></i>Guardar Docente
|
||||
</button>
|
||||
</form>
|
||||
<div class="border-top pt-3 mt-2">
|
||||
<h6 class="small fw-bold text-muted text-uppercase mb-2">
|
||||
<i class="bi bi-person-plus me-1"></i>Agregar Docente a la Cátedra
|
||||
</h6>
|
||||
<form method="POST" action="/admin/commission_add_teacher" id="add-teacher-form">
|
||||
<input type="hidden" name="id" value="{{ commission.id }}">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-semibold mb-1">Docente</label>
|
||||
<select name="teacher_id" class="form-select form-select-sm" required id="teacher-select">
|
||||
<option value="">— Seleccionar Docente —</option>
|
||||
{% for teacher in teachers %}
|
||||
<option value="{{ teacher.id }}">{{ teacher.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold mb-1">Rol en la Cátedra</label>
|
||||
<select name="role" class="form-select form-select-sm" required>
|
||||
<option value="Titular">Profesor Titular</option>
|
||||
<option value="Adjunto" selected>Profesor Adjunto</option>
|
||||
<option value="JTP">Jefe de Trabajos Prácticos (JTP)</option>
|
||||
<option value="Ayudante">Ayudante Diplomado / Alumno</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm w-100" id="assign-teacher-btn">
|
||||
<i class="bi bi-plus-circle me-1"></i>Asignar a Comisión
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,6 +240,7 @@
|
||||
<div class="collapse" id="enroll-form">
|
||||
<div class="card-body border-top bg-success-subtle py-3">
|
||||
<form method="POST" action="/admin/commission_enroll_student" id="enroll-student-form">
|
||||
<input type="hidden" name="id" value="{{ commission.id }}">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label small fw-semibold mb-1">Seleccionar Alumno</label>
|
||||
@@ -168,6 +262,20 @@
|
||||
<i class="bi bi-plus-circle me-1"></i>Inscribir
|
||||
</button>
|
||||
</div>
|
||||
<!-- Regla de Restricción Diaria - Excepción de Bedelía (Fase 2 MVP) -->
|
||||
<div class="col-12 mt-2 pt-2 border-top border-success-subtle">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="allow_same_day_exception" value="1" id="sameDayCheck"
|
||||
onchange="document.getElementById('reasonRow').classList.toggle('d-none', !this.checked)">
|
||||
<label class="form-check-label small fw-semibold text-success-emphasis" for="sameDayCheck">
|
||||
<i class="bi bi-shield-check me-1 text-primary"></i>Autorizar excepción de cursada en el mismo día (Bedelía)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 mt-1 d-none" id="reasonRow">
|
||||
<input type="text" name="exception_reason" class="form-control form-control-sm border-warning"
|
||||
placeholder="Motivo o expediente de excepción de Bedelía (ej: Autorizado por Bedelía Central)..." id="exception-reason-input">
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -184,6 +292,7 @@
|
||||
<th>Alumno</th>
|
||||
<th class="text-center">Estado</th>
|
||||
<th class="text-center">Fecha Inscripción</th>
|
||||
<th class="text-center">Excepciones</th>
|
||||
<th class="text-center">Notas</th>
|
||||
{% if can_edit %}<th class="text-end pe-3">Acciones</th>{% endif %}
|
||||
</tr>
|
||||
@@ -205,12 +314,23 @@
|
||||
<td class="text-center small text-muted">
|
||||
{{ e.enrolled_at if e.enrolled_at else '—' }}
|
||||
</td>
|
||||
<td class="text-center small">
|
||||
{% if e.allow_same_day_exception %}
|
||||
<span class="badge bg-warning-subtle text-warning border border-warning-subtle py-1 px-2" title="{{ e.exception_reason or 'Excepción autorizada de Bedelía' }}">
|
||||
<i class="bi bi-shield-check me-1"></i>Excepción Diaria
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted opacity-50">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center small text-muted">{{ e.notes or '—' }}</td>
|
||||
{% if can_edit %}
|
||||
<td class="text-end pe-3">
|
||||
<div class="d-flex gap-1 justify-content-end">
|
||||
<!-- Quick status update -->
|
||||
<form method="POST" action="/admin/commission_update_enrollment" class="d-inline">
|
||||
<input type="hidden" name="id" value="{{ commission.id }}">
|
||||
<input type="hidden" name="student_id" value="{{ e.student_id }}">
|
||||
<select name="status" class="form-select form-select-sm d-inline-block w-auto"
|
||||
onchange="this.form.submit()" title="Cambiar estado"
|
||||
id="status-select-{{ e.student_id }}">
|
||||
@@ -223,6 +343,8 @@
|
||||
{% set st_display = e.student.name if e.student else 'este alumno' %}
|
||||
<form method="POST" action="/admin/commission_unenroll_student"
|
||||
onsubmit="return confirm('¿Dar de baja a {{ st_display }} de la comisión?')">
|
||||
<input type="hidden" name="id" value="{{ commission.id }}">
|
||||
<input type="hidden" name="student_id" value="{{ e.student_id }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Dar de baja"
|
||||
id="unenroll-btn-{{ e.student_id }}">
|
||||
<i class="bi bi-person-dash"></i>
|
||||
@@ -272,4 +394,80 @@
|
||||
</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 %}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<i class="bi bi-check-circle-fill fs-5 text-success"></i>
|
||||
<div>
|
||||
@@ -30,6 +30,62 @@
|
||||
</div>
|
||||
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</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 %}
|
||||
|
||||
|
||||
@@ -126,7 +182,18 @@
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if comm.teacher %}
|
||||
{% if comm.teachers and comm.teachers | length > 0 %}
|
||||
<div class="d-flex flex-column gap-1">
|
||||
{% for t in comm.teachers %}
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<span class="badge {% if t.role == 'Titular' %}bg-primary-subtle text-primary border border-primary-subtle{% elif t.role == 'Adjunto' %}bg-info-subtle text-info border border-info-subtle{% elif t.role == 'JTP' %}bg-warning-subtle text-warning border border-warning-subtle{% else %}bg-secondary-subtle text-secondary border{% endif %} py-0 px-1" style="font-size: 0.65rem;">
|
||||
{{ t.role or 'Docente' }}
|
||||
</span>
|
||||
<span class="small text-truncate" style="max-width: 150px;" title="{{ t.name }}">{{ t.name }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif comm.teacher %}
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="avatar-xs bg-info-subtle rounded-circle d-flex align-items-center justify-content-center" style="width:28px;height:28px;">
|
||||
<i class="bi bi-person-video3 small text-info"></i>
|
||||
@@ -159,10 +226,48 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end pe-3">
|
||||
<a href="/admin/commission_detail?id={{ comm.id }}"
|
||||
class="btn btn-sm btn-outline-primary" title="Ver y gestionar comisión">
|
||||
<i class="bi bi-gear me-1"></i>Gestionar
|
||||
</a>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="/admin/commission_detail?id={{ comm.id }}"
|
||||
class="btn btn-outline-primary py-0 px-2" title="Gestionar e Inscribir Alumnos">
|
||||
<i class="bi bi-gear"></i>
|
||||
</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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -266,9 +371,23 @@
|
||||
</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="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>
|
||||
<select class="form-select form-select-sm" id="add_semester" name="semester">
|
||||
<option value="1C" selected>1C (Primer Cuatrimestre)</option>
|
||||
@@ -276,11 +395,13 @@
|
||||
<option value="Anual">Anual</option>
|
||||
</select>
|
||||
</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>
|
||||
<input type="number" class="form-control form-control-sm" id="add_year" name="year" value="2026" min="2020" max="2035">
|
||||
</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>
|
||||
<select class="form-select form-select-sm" id="add_shift" name="shift">
|
||||
<option value="Mañana">Mañana (08:00 - 12:00)</option>
|
||||
@@ -295,7 +416,7 @@
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-8">
|
||||
<label for="add_teacher_id" class="form-label fw-semibold small">
|
||||
<i class="bi bi-person-video3 text-info me-1"></i>Docente Responsable
|
||||
<i class="bi bi-person-video3 text-info me-1"></i>Docente Responsable (Titular)
|
||||
</label>
|
||||
<select class="form-select form-select-sm" id="add_teacher_id" name="teacher_id">
|
||||
<option value="">-- Sin asignar (se puede asignar más tarde) --</option>
|
||||
@@ -303,6 +424,7 @@
|
||||
<option value="{{ t.id }}">{{ t.name }} ({{ t.email }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text small">Docente titular principal. Podrás agregar más docentes (co-docencia, adjuntos, JTP) desde el detalle de la comisión.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="add_max_students" class="form-label fw-semibold small">Cupo Máximo de Alumnos</label>
|
||||
@@ -337,4 +459,236 @@
|
||||
</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 %}
|
||||
|
||||
@@ -460,28 +460,94 @@ function toggleSelectAll(checked) {
|
||||
}
|
||||
|
||||
function openPurgeModal() {
|
||||
const anyChecked = document.getElementById('chk_reservations').checked ||
|
||||
document.getElementById('chk_classrooms').checked ||
|
||||
document.getElementById('chk_commissions').checked ||
|
||||
document.getElementById('chk_subjects').checked ||
|
||||
document.getElementById('chk_careers').checked ||
|
||||
document.getElementById('chk_all').checked;
|
||||
const isAll = document.getElementById('chk_all').checked;
|
||||
const isRes = isAll || document.getElementById('chk_reservations').checked;
|
||||
const isCls = isAll || document.getElementById('chk_classrooms').checked;
|
||||
const isCom = isAll || document.getElementById('chk_commissions').checked;
|
||||
const isSub = isAll || document.getElementById('chk_subjects').checked;
|
||||
const isCar = isAll || document.getElementById('chk_careers').checked;
|
||||
|
||||
const anyChecked = isRes || isCls || isCom || isSub || isCar;
|
||||
if (!anyChecked) {
|
||||
alert('Por favor seleccione al menos una entidad para purgar.');
|
||||
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');
|
||||
if (modalEl) {
|
||||
const modal = new bootstrap.Modal(modalEl);
|
||||
modal.show();
|
||||
if (modalEl && typeof bootstrap !== 'undefined' && bootstrap.Modal) {
|
||||
try {
|
||||
const modal = new bootstrap.Modal(modalEl);
|
||||
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() {
|
||||
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');
|
||||
if (form) {
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
</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 %}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,12 +20,55 @@
|
||||
<i class="bi bi-plus-lg me-1"></i>Nueva Asignatura
|
||||
</button>
|
||||
{% endif %}
|
||||
<a href="/schedule/commissions_view" class="btn btn-outline-secondary">
|
||||
<a href="/admin/commissions_list" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-diagram-3 me-1"></i>Ver Comisiones
|
||||
</a>
|
||||
</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 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">
|
||||
<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>
|
||||
</div>
|
||||
{% elif msg == 'deactivated' %}
|
||||
<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>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>
|
||||
</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' %}
|
||||
<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.
|
||||
<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 operación sobre la asignatura.
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Filters Bar -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body py-2">
|
||||
@@ -133,7 +176,15 @@
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-outline-secondary py-0 px-2"
|
||||
data-bs-toggle="modal" data-bs-target="#modalEditSubject{{ s.id }}"
|
||||
data-bs-toggle="modal" data-bs-target="#modalEditSubject"
|
||||
data-id="{{ s.id }}"
|
||||
data-code="{{ s.code }}"
|
||||
data-name="{{ s.name }}"
|
||||
data-credits="{{ s.credits }}"
|
||||
data-career-id="{{ s.career_id or '' }}"
|
||||
data-department="{{ s.department or '' }}"
|
||||
data-description="{{ s.description or '' }}"
|
||||
data-active="{{ 'true' if s.is_active else 'false' }}"
|
||||
title="Editar Asignatura">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
@@ -144,64 +195,14 @@
|
||||
<i class="bi {% if s.is_active %}bi-pause{% else %}bi-play{% endif %}"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Modal Editar Asignatura -->
|
||||
<div class="modal fade text-start" id="modalEditSubject{{ s.id }}" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="/admin/subject_edit">
|
||||
<input type="hidden" name="id" value="{{ s.id }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title h6 fw-bold">
|
||||
<i class="bi bi-pencil-square text-primary me-2"></i>Editar Asignatura
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small fw-semibold">Código *</label>
|
||||
<input type="text" name="code" class="form-control form-control-sm font-monospace" value="{{ s.code }}" required>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<label class="form-label small fw-semibold">Créditos</label>
|
||||
<input type="number" name="credits" class="form-control form-control-sm" value="{{ s.credits }}" min="1" max="20">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Nombre de la Asignatura *</label>
|
||||
<input type="text" name="name" class="form-control form-control-sm" value="{{ s.name }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Carrera Vinculada</label>
|
||||
<select name="career_id" class="form-select form-select-sm">
|
||||
<option value="">Sin carrera específica (General)</option>
|
||||
{% for c in careers %}
|
||||
<option value="{{ c.id }}" {% if s.career_id == c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Departamento / Área</label>
|
||||
<input type="text" name="department" class="form-control form-control-sm" value="{{ s.department or '' }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Descripción / Objetivos</label>
|
||||
<textarea name="description" class="form-control form-control-sm" rows="2">{{ s.description or '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="is_active" id="activeSubj{{ s.id }}" {% if s.is_active %}checked{% endif %}>
|
||||
<label class="form-check-label small" for="activeSubj{{ s.id }}">Asignatura Activa</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-danger py-0 px-2"
|
||||
data-bs-toggle="modal" data-bs-target="#modalDeleteSubject"
|
||||
data-id="{{ s.id }}"
|
||||
data-code="{{ s.code }}"
|
||||
data-name="{{ s.name }}"
|
||||
title="Eliminar Asignatura">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
@@ -304,5 +305,124 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Editar Asignatura (Global fuera de la tabla para evitar problemas de visualización) -->
|
||||
<div class="modal fade" id="modalEditSubject" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="/admin/subject_edit" id="formEditSubject">
|
||||
<input type="hidden" name="id" id="editSubjectId">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title h6 fw-bold">
|
||||
<i class="bi bi-pencil-square text-primary me-2"></i>Editar Asignatura
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small fw-semibold">Código *</label>
|
||||
<input type="text" name="code" id="editSubjectCode" class="form-control form-control-sm font-monospace" required>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<label class="form-label small fw-semibold">Créditos</label>
|
||||
<input type="number" name="credits" id="editSubjectCredits" class="form-control form-control-sm" min="1" max="20">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Nombre de la Asignatura *</label>
|
||||
<input type="text" name="name" id="editSubjectName" class="form-control form-control-sm" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Carrera Vinculada</label>
|
||||
<select name="career_id" id="editSubjectCareer" class="form-select form-select-sm">
|
||||
<option value="">Sin carrera específica (General)</option>
|
||||
{% for c in careers %}
|
||||
<option value="{{ c.id }}">{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Departamento / Área</label>
|
||||
<input type="text" name="department" id="editSubjectDept" class="form-control form-control-sm">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Descripción / Objetivos</label>
|
||||
<textarea name="description" id="editSubjectDesc" class="form-control form-control-sm" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="is_active" id="editSubjectActive">
|
||||
<label class="form-check-label small" for="editSubjectActive">Asignatura Activa</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Eliminar Asignatura -->
|
||||
<div class="modal fade" id="modalDeleteSubject" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="/admin/subject_delete">
|
||||
<input type="hidden" name="id" id="deleteSubjectId">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title h6 fw-bold">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>Eliminar Asignatura
|
||||
</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 asignatura <strong id="deleteSubjectName"></strong> (<span class="font-monospace" id="deleteSubjectCode"></span>)?</p>
|
||||
<div class="alert alert-warning small mb-0">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
<strong>Aviso de integridad:</strong> Si la materia no posee comisiones vinculadas se eliminará permanentemente. Si ya posee comisiones históricas o cursadas, el sistema la <strong>desactivará automáticamente</strong> para resguardar las actas académicas.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||
<button type="submit" class="btn btn-sm btn-danger">Confirmar Eliminación</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Populate Edit Subject Modal
|
||||
var modalEdit = document.getElementById('modalEditSubject');
|
||||
if (modalEdit) {
|
||||
modalEdit.addEventListener('show.bs.modal', function(event) {
|
||||
var btn = event.relatedTarget;
|
||||
if (!btn) return;
|
||||
document.getElementById('editSubjectId').value = btn.getAttribute('data-id') || '';
|
||||
document.getElementById('editSubjectCode').value = btn.getAttribute('data-code') || '';
|
||||
document.getElementById('editSubjectName').value = btn.getAttribute('data-name') || '';
|
||||
document.getElementById('editSubjectCredits').value = btn.getAttribute('data-credits') || '4';
|
||||
document.getElementById('editSubjectCareer').value = btn.getAttribute('data-career-id') || '';
|
||||
document.getElementById('editSubjectDept').value = btn.getAttribute('data-department') || '';
|
||||
document.getElementById('editSubjectDesc').value = btn.getAttribute('data-description') || '';
|
||||
document.getElementById('editSubjectActive').checked = (btn.getAttribute('data-active') === 'true');
|
||||
});
|
||||
}
|
||||
|
||||
// Populate Delete Subject Modal
|
||||
var modalDel = document.getElementById('modalDeleteSubject');
|
||||
if (modalDel) {
|
||||
modalDel.addEventListener('show.bs.modal', function(event) {
|
||||
var btn = event.relatedTarget;
|
||||
if (!btn) return;
|
||||
document.getElementById('deleteSubjectId').value = btn.getAttribute('data-id') || '';
|
||||
document.getElementById('deleteSubjectCode').textContent = btn.getAttribute('data-code') || '';
|
||||
document.getElementById('deleteSubjectName').textContent = btn.getAttribute('data-name') || '';
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,104 +1,228 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% if user %}Edit User: {{ user.name }}{% else %}New User{% endif %} - Edu-Space Admin{% endblock %}
|
||||
{% block title %}{% if user %}Editar Usuario: {{ user.name }}{% else %}Nuevo Usuario{% endif %} — Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">
|
||||
<h1 class="h3 mb-1 fw-bold">
|
||||
<i class="bi bi-person-gear text-primary me-2"></i>
|
||||
{% if user %}Edit User Account{% else %}Create User Account{% endif %}
|
||||
{% if user %}Editar Cuenta de Usuario{% else %}Crear Nueva Cuenta de Usuario{% endif %}
|
||||
</h1>
|
||||
<p class="text-muted small mb-0">Configure user credentials, profile information, and role assignment.</p>
|
||||
<p class="text-muted small mb-0">
|
||||
Gestión integral de identidad, datos de contacto, credenciales y rol institucional para Bedelía, Docentes y Alumnos.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/admin/users_list" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Users
|
||||
<i class="bi bi-arrow-left me-1"></i>Volver a Usuarios
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-6 mx-auto">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-body-tertiary py-3">
|
||||
<h5 class="card-title h6 mb-0">
|
||||
<i class="bi bi-person-vcard text-primary me-2"></i>Account Details
|
||||
</h5>
|
||||
<div class="card-header bg-body-tertiary py-3 d-flex align-items-center justify-content-between">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-person-vcard text-primary fs-5"></i>
|
||||
<h5 class="card-title h6 mb-0 fw-bold">
|
||||
{% if user %}Ficha de Datos de: {{ user.name }}{% else %}Datos del Nuevo Usuario{% endif %}
|
||||
</h5>
|
||||
</div>
|
||||
{% if user %}
|
||||
<span class="badge bg-primary-subtle text-primary border font-monospace">ID #{{ user.id }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<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 %}
|
||||
<input type="hidden" name="id" value="{{ user.id }}">
|
||||
{% endif %}
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label fw-semibold">Full Name <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="name" name="name"
|
||||
value="{{ user.name if user else '' }}" required
|
||||
placeholder="e.g. María González">
|
||||
|
||||
|
||||
<!-- SECCIÓN 1: IDENTIDAD Y DOCUMENTACIÓN -->
|
||||
<div class="mb-4">
|
||||
<h6 class="text-uppercase text-primary fw-bold small border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-person-badge me-1"></i>1. Identidad y Documentación Personal
|
||||
</h6>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="first_name" class="form-label small fw-semibold">Nombre(s) <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="first_name" name="first_name"
|
||||
value="{{ user.first_name if user else '' }}" required
|
||||
placeholder="Ej: María Belén">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="last_name" class="form-label small fw-semibold">Apellido(s) <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="last_name" name="last_name"
|
||||
value="{{ user.last_name if user else '' }}" required
|
||||
placeholder="Ej: González">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label for="document_type" class="form-label small fw-semibold">
|
||||
Tipo de Documento <span class="text-muted">(Argentina / Extranjero)</span>
|
||||
</label>
|
||||
<select class="form-select" id="document_type" name="document_type">
|
||||
<optgroup label="Documentos Nacionales (Argentina)">
|
||||
<option value="DNI" {% if not user or user.document_type == 'DNI' %}selected{% endif %}>DNI — Documento Nacional de Identidad</option>
|
||||
<option value="CUIL" {% if user and user.document_type == 'CUIL' %}selected{% endif %}>CUIL / CUIT — Código Laboral / Fiscal</option>
|
||||
<option value="PAS" {% if user and user.document_type == 'PAS' %}selected{% endif %}>Pasaporte Argentino</option>
|
||||
</optgroup>
|
||||
<optgroup label="Documentos Extranjeros e Internacionales">
|
||||
<option value="PASEXT" {% if user and user.document_type == 'PASEXT' %}selected{% endif %}>Pasaporte Extranjero (Internacional)</option>
|
||||
<option value="DNIEXT" {% if user and user.document_type == 'DNIEXT' %}selected{% endif %}>DNI para Extranjeros (Radicación)</option>
|
||||
<option value="CIEXT" {% if user and user.document_type == 'CIEXT' %}selected{% endif %}>Cédula Mercosur / Extranjera</option>
|
||||
</optgroup>
|
||||
<optgroup label="Documentos Históricos Nacionales">
|
||||
<option value="CI" {% if user and user.document_type == 'CI' %}selected{% endif %}>Cédula de Identidad (Policía Federal)</option>
|
||||
<option value="LC" {% if user and user.document_type == 'LC' %}selected{% endif %}>Libreta Cívica (LC)</option>
|
||||
<option value="LE" {% if user and user.document_type == 'LE' %}selected{% endif %}>Libreta de Enrolamiento (LE)</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="document_number" class="form-label small fw-semibold">Número de Documento</label>
|
||||
<input type="text" class="form-control font-monospace" id="document_number" name="document_number"
|
||||
value="{{ user.document_number if user else '' }}"
|
||||
placeholder="Ej: 38123456">
|
||||
<div class="form-text small" id="docHelpText">
|
||||
Ingrese el número según la tipificación oficial seleccionada.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label fw-semibold">Email Address <span class="text-danger">*</span></label>
|
||||
<input type="email" class="form-control" id="email" name="email"
|
||||
value="{{ user.email if user else '' }}" required
|
||||
placeholder="e.g. mgonzalez@edu-space.com">
|
||||
<!-- SECCIÓN 2: DATOS DE CONTACTO Y DOMICILIO -->
|
||||
<div class="mb-4">
|
||||
<h6 class="text-uppercase text-primary fw-bold small border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-geo-alt me-1"></i>2. Contacto y Domicilio
|
||||
</h6>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="phone" class="form-label small fw-semibold">
|
||||
<i class="bi bi-telephone me-1 text-secondary"></i>Teléfono de Contacto
|
||||
</label>
|
||||
<input type="tel" class="form-control" id="phone" name="phone"
|
||||
value="{{ user.phone if user else '' }}"
|
||||
placeholder="Ej: +54 9 11 4455-6677">
|
||||
<div class="form-text small">Móvil o fijo con código de área.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="personal_email" class="form-label small fw-semibold">
|
||||
<i class="bi bi-envelope-at me-1 text-secondary"></i>Email Personal / Particular
|
||||
</label>
|
||||
<input type="email" class="form-control" id="personal_email" name="personal_email"
|
||||
value="{{ user.personal_email if user else '' }}"
|
||||
placeholder="Ej: nombre.apellido@gmail.com">
|
||||
<div class="form-text small">Correo personal alternativo para notificaciones y recuperación.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label for="address" class="form-label small fw-semibold">
|
||||
<i class="bi bi-house-door me-1 text-secondary"></i>Domicilio / Dirección
|
||||
</label>
|
||||
<input type="text" class="form-control" id="address" name="address"
|
||||
value="{{ user.address if user else '' }}"
|
||||
placeholder="Ej: Av. Córdoba 1850, Piso 4 B, CABA">
|
||||
<div class="form-text small">Calle, número, piso/departamento y localidad.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label fw-semibold">
|
||||
Password {% if not user %}<span class="text-danger">*</span>{% endif %}
|
||||
</label>
|
||||
<input type="password" class="form-control" id="password" name="password"
|
||||
{% if not user %}required{% endif %}
|
||||
placeholder="{% if user %}Leave empty to keep current password{% else %}At least 6 characters{% endif %}">
|
||||
{% if user %}
|
||||
<small class="text-muted d-block mt-1">
|
||||
Only fill this in if you want to reset this user's password.
|
||||
</small>
|
||||
{% endif %}
|
||||
<!-- SECCIÓN 3: CUENTA, ACCESO Y ROL INSTITUCIONAL -->
|
||||
<div class="mb-4">
|
||||
<h6 class="text-uppercase text-primary fw-bold small border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-shield-lock me-1"></i>3. Cuenta de Acceso y Rol Institucional
|
||||
</h6>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="email" class="form-label small fw-semibold">Email Institucional <span class="text-danger">*</span></label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-body-tertiary"><i class="bi bi-envelope"></i></span>
|
||||
<input type="email" class="form-control" id="email" name="email"
|
||||
value="{{ user.email if user else '' }}" required
|
||||
placeholder="usuario@edu-space.com">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="password" class="form-label small fw-semibold">
|
||||
Contraseña {% if not user %}<span class="text-danger">*</span>{% endif %}
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<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"
|
||||
{% if not user %}required minlength="6"{% else %}minlength="6"{% 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>
|
||||
{% if user %}
|
||||
<small class="text-muted d-block mt-1">
|
||||
Solo complete este campo si desea restablecer la contraseña del usuario (mínimo 6 caracteres).
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-8">
|
||||
<label for="role_id" class="form-label small fw-semibold">Rol / Grupo de Acceso <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="role_id" name="role_id" required>
|
||||
<option value="" disabled {% if not user or not user.role_id %}selected{% endif %}>Seleccione un rol...</option>
|
||||
{% for r in roles %}
|
||||
<option value="{{ r.id }}" {% if user and user.role_id == r.id %}selected{% endif %}>
|
||||
{{ r.name }} {% if r.description %}— {{ r.description }}{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="text-muted d-block mt-1">
|
||||
Define los permisos para Bedelía, Profesores/Docentes, Alumnos o Administradores.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 d-flex align-items-center">
|
||||
<div class="form-check form-switch mt-3">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="is_active" name="is_active"
|
||||
{% if not user or user.is_active %}checked{% endif %}>
|
||||
<label class="form-check-label fw-semibold small" for="is_active">
|
||||
Cuenta Activa
|
||||
</label>
|
||||
<small class="text-muted d-block">
|
||||
Permite iniciar sesión en el sistema.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="role_id" class="form-label fw-semibold">Role / Access Group <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="role_id" name="role_id" required>
|
||||
<option value="" disabled {% if not user or not user.role_id %}selected{% endif %}>Select a role...</option>
|
||||
{% for r in roles %}
|
||||
<option value="{{ r.id }}" {% if user and user.role_id == r.id %}selected{% endif %}>
|
||||
{{ r.name }} - {{ r.description or '' }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="text-muted d-block mt-1">
|
||||
<a href="/admin/roles_list" target="_blank" class="text-decoration-none">
|
||||
<i class="bi bi-box-arrow-up-right me-1"></i>View or manage roles and permissions
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="is_active" name="is_active"
|
||||
{% if not user or user.is_active %}checked{% endif %}
|
||||
{% if user and user.id == user.id %}disabled{% endif %}>
|
||||
<label class="form-check-label fw-semibold" for="is_active">
|
||||
Active Account
|
||||
</label>
|
||||
<small class="text-muted d-block">
|
||||
Inactive users are prevented from signing in to the platform.
|
||||
</small>
|
||||
{% if user and user.id == user.id %}
|
||||
<input type="hidden" name="is_active" value="on">
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center pt-2">
|
||||
<div class="d-flex justify-content-between align-items-center pt-3 border-top">
|
||||
<a href="/admin/users_list" class="btn btn-outline-secondary">
|
||||
Cancel
|
||||
Cancelar
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary px-4 fw-semibold">
|
||||
<i class="bi bi-check-lg me-1"></i>Save User
|
||||
<button type="submit" class="btn btn-primary px-4 fw-semibold shadow-sm">
|
||||
<i class="bi bi-save me-1"></i>Guardar Usuario
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -107,4 +231,47 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var docTypeSelect = document.getElementById('document_type');
|
||||
var docNumInput = document.getElementById('document_number');
|
||||
var docHelpText = document.getElementById('docHelpText');
|
||||
|
||||
var hints = {
|
||||
'DNI': 'DNI Argentino: 7 u 8 dígitos numéricos sin puntos (ej: 38123456).',
|
||||
'CUIL': 'CUIL/CUIT: 11 dígitos numéricos o con guiones (ej: 20-38123456-7).',
|
||||
'PAS': 'Pasaporte Argentino: 3 letras y 6 números (ej: AAB123456).',
|
||||
'PASEXT': 'Pasaporte Extranjero: código alfanumérico emitido por país de origen.',
|
||||
'DNIEXT': 'DNI Extranjero: 7 a 10 dígitos o alfanumérico con residencia.',
|
||||
'CI': 'Cédula de Identidad Nacional: 6 a 8 dígitos numéricos.',
|
||||
'CIEXT': 'Cédula de Identidad Mercosur: formato de país emisor.',
|
||||
'LC': 'Libreta Cívica: 6 a 7 dígitos numéricos.',
|
||||
'LE': 'Libreta de Enrolamiento: 6 a 7 dígitos numéricos.'
|
||||
};
|
||||
|
||||
function updateDocHint() {
|
||||
var val = docTypeSelect.value;
|
||||
if (hints[val]) {
|
||||
docHelpText.textContent = hints[val];
|
||||
}
|
||||
}
|
||||
|
||||
if (docTypeSelect) {
|
||||
docTypeSelect.addEventListener('change', 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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,24 +1,44 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}User Management - Edu-Space Admin{% endblock %}
|
||||
{% block title %}Gestión de Usuarios — Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">
|
||||
<i class="bi bi-people text-primary me-2"></i>User Management
|
||||
<h1 class="h3 mb-1 fw-bold">
|
||||
<i class="bi bi-people text-primary me-2"></i>Gestión de Usuarios
|
||||
</h1>
|
||||
<p class="text-muted small mb-0">Manage user accounts, assign roles, and control active status.</p>
|
||||
<p class="text-muted small mb-0">
|
||||
Administración de cuentas, identidad y documentación, datos de contacto y asignación de roles institucionales (Bedelía, Docentes, Alumnos).
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/admin/user_add" class="btn btn-primary">
|
||||
<i class="bi bi-person-plus me-1"></i>New User
|
||||
<a href="/admin/user_add" class="btn btn-primary shadow-sm">
|
||||
<i class="bi bi-person-plus me-1"></i>Nuevo Usuario
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feedback Alerts -->
|
||||
{% if msg == 'saved' %}
|
||||
<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>Los datos del usuario han sido guardados exitosamente.
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</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 %}
|
||||
<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 %}
|
||||
|
||||
<!-- Filters & Search -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-3">
|
||||
@@ -29,13 +49,13 @@
|
||||
<i class="bi bi-search text-muted"></i>
|
||||
</span>
|
||||
<input type="text" name="search" class="form-control border-start-0"
|
||||
placeholder="Search by name or email..."
|
||||
placeholder="Buscar por nombre, apellido, email o nro de documento..."
|
||||
value="{{ search }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select name="role" class="form-select form-select-sm">
|
||||
<option value="">All Roles</option>
|
||||
<option value="">Todos los Roles</option>
|
||||
{% for r in roles %}
|
||||
<option value="{{ r.id }}" {% if role_filter == (r.id|string) %}selected{% endif %}>{{ r.name }}</option>
|
||||
{% endfor %}
|
||||
@@ -43,17 +63,17 @@
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="status" class="form-select form-select-sm">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="active" {% if status_filter == 'active' %}selected{% endif %}>Active</option>
|
||||
<option value="inactive" {% if status_filter == 'inactive' %}selected{% endif %}>Inactive</option>
|
||||
<option value="">Todos los Estados</option>
|
||||
<option value="active" {% if status_filter == 'active' %}selected{% endif %}>Solo Activos</option>
|
||||
<option value="inactive" {% if status_filter == 'inactive' %}selected{% endif %}>Solo Inactivos</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm w-100">
|
||||
<i class="bi bi-filter me-1"></i>Filter
|
||||
<i class="bi bi-filter me-1"></i>Filtrar
|
||||
</button>
|
||||
{% if search or role_filter or status_filter %}
|
||||
<a href="/admin/users_list" class="btn btn-outline-secondary btn-sm" title="Clear filters">
|
||||
<a href="/admin/users_list" class="btn btn-outline-secondary btn-sm" title="Limpiar filtros">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -68,12 +88,13 @@
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="bg-body-tertiary">
|
||||
<tr>
|
||||
<th class="ps-4">User</th>
|
||||
<th>Email</th>
|
||||
<th>Role / Group</th>
|
||||
<th>Status</th>
|
||||
<th>Last Login</th>
|
||||
<th class="text-end pe-4">Actions</th>
|
||||
<th class="ps-4">Usuario / Identidad</th>
|
||||
<th>Documento</th>
|
||||
<th>Contacto y Domicilio</th>
|
||||
<th>Rol Institucional</th>
|
||||
<th>Estado</th>
|
||||
<th>Último Acceso</th>
|
||||
<th class="text-end pe-4">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -81,17 +102,54 @@
|
||||
<tr>
|
||||
<td class="ps-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="user-avatar-small me-2">
|
||||
{{ (u.first_name[0] if u.first_name else 'U')|upper }}
|
||||
<div class="user-avatar-small me-2 rounded-circle bg-primary-subtle text-primary d-flex align-items-center justify-content-center fw-bold" style="width: 36px; height: 36px;">
|
||||
{{ (u.first_name[0] if u.first_name else (u.name[0] if u.name else 'U'))|upper }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-bold">{{ u.name }}</div>
|
||||
<small class="text-muted">ID: #{{ u.id }}</small>
|
||||
<div class="text-muted small">
|
||||
{% if u.first_name and u.last_name %}
|
||||
<span class="opacity-75">{{ u.last_name }}, {{ u.first_name }}</span> ·
|
||||
{% endif %}
|
||||
<span class="font-monospace">#{{ u.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="font-monospace small">{{ u.email }}</span>
|
||||
{% if u.document_number %}
|
||||
<div>
|
||||
<span class="badge {% if u.document_type in ['PAS', 'PASEXT'] %}bg-info-subtle text-info border border-info-subtle{% elif u.document_type == 'CUIL' %}bg-primary-subtle text-primary border border-primary-subtle{% else %}bg-secondary-subtle text-secondary border{% endif %} py-1 px-2 font-monospace">
|
||||
{{ u.document_type or 'DNI' }}: {{ u.document_formatted or u.document_number }}
|
||||
</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-muted small fst-italic"><i class="bi bi-dash"></i> Sin registrar</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div>
|
||||
<i class="bi bi-building me-1 text-primary" title="Email Institucional"></i>
|
||||
<span class="font-monospace small fw-semibold">{{ u.email }}</span>
|
||||
</div>
|
||||
{% if u.personal_email %}
|
||||
<div class="text-muted small">
|
||||
<i class="bi bi-envelope-at me-1 text-secondary" title="Email Personal"></i>
|
||||
<span class="font-monospace" style="font-size: 0.8rem;">{{ u.personal_email }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if u.phone or u.address %}
|
||||
<div class="text-muted small mt-1 d-flex flex-column gap-0">
|
||||
{% if u.phone %}
|
||||
<span><i class="bi bi-telephone me-1 text-secondary"></i>{{ u.phone }}</span>
|
||||
{% endif %}
|
||||
{% if u.address %}
|
||||
<span class="text-truncate" style="max-width: 220px;" title="{{ u.address }}">
|
||||
<i class="bi bi-geo-alt me-1 text-secondary"></i>{{ u.address }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if u.role_obj %}
|
||||
@@ -100,40 +158,50 @@
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">
|
||||
{{ u.role or _('No Role') }}
|
||||
{{ u.role or 'Docente' }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if u.is_active %}
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle px-2 py-1">
|
||||
<i class="bi bi-check-circle me-1"></i>Active
|
||||
<i class="bi bi-check-circle me-1"></i>Activo
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger-subtle text-danger border border-danger-subtle px-2 py-1">
|
||||
<i class="bi bi-dash-circle me-1"></i>Inactive
|
||||
<i class="bi bi-dash-circle me-1"></i>Inactivo
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">
|
||||
{% if u.last_login %}
|
||||
{{ format_datetime(u.last_login, format='short') }}
|
||||
{{ u.last_login[:16] if u.last_login is string else u.last_login }}
|
||||
{% else %}
|
||||
Never
|
||||
Nunca
|
||||
{% endif %}
|
||||
</small>
|
||||
</td>
|
||||
<td class="text-end pe-4">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="/admin/users/edit/{{ u.id }}" class="btn btn-outline-primary" title="Edit User">
|
||||
<a href="/admin/users/edit/{{ u.id }}" class="btn btn-outline-primary" title="Editar Ficha de Usuario">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</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 %}
|
||||
<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>
|
||||
</a>
|
||||
<form action="/admin/user_toggle" method="POST" class="d-inline">
|
||||
<input type="hidden" name="id" value="{{ u.id }}">
|
||||
<button type="submit" class="btn {% if u.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %}"
|
||||
title="{% if u.is_active %}Deactivate User{% else %}Activate User{% endif %}">
|
||||
title="{% if u.is_active %}Desactivar Usuario{% else %}Activar Usuario{% endif %}">
|
||||
<i class="bi {% if u.is_active %}bi-person-x{% else %}bi-person-check{% endif %}"></i>
|
||||
</button>
|
||||
</form>
|
||||
@@ -151,9 +219,9 @@
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center py-5 text-muted">
|
||||
<td colspan="7" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-people fs-1 d-block mb-2 text-secondary"></i>
|
||||
No users found matching the criteria.
|
||||
No se encontraron usuarios coincidentes con los criterios de búsqueda.
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -162,11 +230,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if pagination.pages > 1 %}
|
||||
<div class="card-footer bg-body-tertiary d-flex justify-content-between align-items-center py-2 px-4">
|
||||
<small class="text-muted">
|
||||
Showing page {{ pagination.page }} of {{ pagination.pages }} ({{ pagination.total }} total)
|
||||
</small>
|
||||
{% if pagination and pagination.pages > 1 %}
|
||||
<div class="card-footer bg-body-tertiary border-0 py-2 d-flex justify-content-center">
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
{% if pagination.has_prev %}
|
||||
<li class="page-item"><a class="page-link" href="/admin/users_list">«</a></li>
|
||||
@@ -189,7 +254,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Global de Eliminación de Usuario (fuera de la tabla) -->
|
||||
<!-- Modal Global de Eliminación de Usuario -->
|
||||
<div class="modal fade" id="deleteUserModal" 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;">
|
||||
@@ -221,6 +286,54 @@
|
||||
</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 %}
|
||||
|
||||
{% block extra_js %}
|
||||
@@ -237,12 +350,57 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
var name = this.getAttribute('data-user-name');
|
||||
var email = this.getAttribute('data-user-email');
|
||||
var actionUrl = this.getAttribute('data-action-url');
|
||||
var userId = this.getAttribute('data-user-id');
|
||||
if (nameEl) nameEl.textContent = name;
|
||||
if (emailEl) emailEl.textContent = email;
|
||||
if (formEl) formEl.action = actionUrl;
|
||||
if (formEl) {
|
||||
formEl.action = actionUrl;
|
||||
var hiddenId = formEl.querySelector('input[name="id"]');
|
||||
if (!hiddenId) {
|
||||
hiddenId = document.createElement('input');
|
||||
hiddenId.type = 'hidden';
|
||||
hiddenId.name = 'id';
|
||||
formEl.appendChild(hiddenId);
|
||||
}
|
||||
hiddenId.value = userId;
|
||||
}
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Cambiar Contraseña - Edu-Space Admin{% endblock %}
|
||||
{% block title %}Cambiar Contraseña — Edu-Space{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container pt-5 mt-4">
|
||||
@@ -8,49 +8,76 @@
|
||||
<div class="col-lg-6 col-md-8">
|
||||
<!-- Header -->
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="bi bi-shield-lock text-primary me-2"></i>Cambiar Contraseña
|
||||
</h1>
|
||||
<div>
|
||||
<h1 class="h3 mb-1 fw-bold">
|
||||
<i class="bi bi-shield-lock text-primary me-2"></i>Cambiar Contraseña
|
||||
</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">
|
||||
<i class="bi bi-arrow-left me-1"></i>Volver al Perfil
|
||||
</a>
|
||||
</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 -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card border-0 shadow-sm" style="border-radius: 12px;">
|
||||
<div class="card-body p-4">
|
||||
<form method="POST" action="/auth/change_password">
|
||||
<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">
|
||||
<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">
|
||||
<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 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">
|
||||
<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">
|
||||
<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 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 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">
|
||||
<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">
|
||||
<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 class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-save me-1"></i>Actualizar Contraseña
|
||||
<button type="submit" class="btn btn-primary py-2 fw-semibold shadow-sm">
|
||||
<i class="bi bi-shield-check me-1"></i>Actualizar Contraseña
|
||||
</button>
|
||||
<a href="/auth/profile" class="btn btn-link text-muted">
|
||||
Cancelar
|
||||
<a href="/auth/profile" class="btn btn-link text-muted text-decoration-none">
|
||||
Cancelar y volver
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
@@ -60,3 +87,18 @@
|
||||
</div>
|
||||
</div>
|
||||
{% 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
-22
@@ -22,6 +22,59 @@
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<!-- Design System Theme -->
|
||||
<link href="/css/theme.css" rel="stylesheet">
|
||||
<style>
|
||||
.btn-google {
|
||||
background-color: #ffffff;
|
||||
color: #3c4043;
|
||||
border: 1px solid #dadce0;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-google:hover {
|
||||
background-color: #f8f9fa;
|
||||
border-color: #c6c9cc;
|
||||
color: #202124;
|
||||
box-shadow: 0 1px 3px rgba(60,64,67,.3);
|
||||
}
|
||||
[data-bs-theme="dark"] .btn-google {
|
||||
background-color: #1f2937;
|
||||
color: #f3f4f6;
|
||||
border-color: #374151;
|
||||
}
|
||||
[data-bs-theme="dark"] .btn-google:hover {
|
||||
background-color: #374151;
|
||||
color: #ffffff;
|
||||
}
|
||||
.btn-moodle {
|
||||
background-color: #f98012;
|
||||
color: #ffffff;
|
||||
border: 1px solid #f98012;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-moodle:hover {
|
||||
background-color: #e06d07;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 2px 6px rgba(249, 128, 18, 0.4);
|
||||
}
|
||||
.divider-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin: 1.25rem 0;
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.divider-text::before, .divider-text::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
.divider-text::before { margin-right: 0.75em; }
|
||||
.divider-text::after { margin-left: 0.75em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-wrapper">
|
||||
@@ -32,7 +85,6 @@
|
||||
<button class="theme-toggle-btn" type="button" aria-label="Cambiar tema" title="Modo Claro/Oscuro">
|
||||
<i class="bi bi-moon-stars-fill"></i>
|
||||
</button>
|
||||
<!-- Language Switcher could go here -->
|
||||
</div>
|
||||
|
||||
<img src="/img/logo_unicaba.png" alt="UniCABA" class="auth-logo">
|
||||
@@ -44,12 +96,60 @@
|
||||
<!-- Flash Messages -->
|
||||
{% if error %}
|
||||
<div class="alert alert-danger alert-dismissible fade show rounded-3 py-2 small" role="alert">
|
||||
{{ error }}
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i> {{ error }}
|
||||
<button type="button" class="btn-close py-2" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form action="/auth/login" method="POST" novalidate>
|
||||
<!-- Métodos de Autenticación SSO Externos -->
|
||||
{% if providers and (providers.google or providers.moodle) %}
|
||||
<div class="d-grid gap-2 mb-3">
|
||||
{% if providers.google %}
|
||||
<button type="button" class="btn btn-google py-2 d-flex align-items-center justify-content-center" onclick="handleGoogleLogin()">
|
||||
<svg class="me-2" width="18" height="18" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.06H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.94l2.85-2.22.81-.63z"/>
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.06l3.66 2.84c.87-2.6 3.3-4.52 6.16-4.52z"/>
|
||||
</svg>
|
||||
<span>Continuar con Google Workspace</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if providers.moodle %}
|
||||
<button type="button" class="btn btn-moodle py-2 d-flex align-items-center justify-content-center" data-bs-toggle="collapse" data-bs-target="#moodleLoginForm">
|
||||
<i class="bi bi-mortarboard-fill me-2 fs-5"></i>
|
||||
<span>Ingresar con Moodle UniCABA</span>
|
||||
</button>
|
||||
|
||||
<div class="collapse mt-2" id="moodleLoginForm">
|
||||
<div class="card p-3 border-warning-subtle bg-body-tertiary">
|
||||
<form action="/auth/moodle" method="POST">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-semibold text-muted">Usuario Moodle</label>
|
||||
<input type="text" class="form-control form-control-sm" name="username" placeholder="admin o usuario institucional" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold text-muted">Contraseña Moodle</label>
|
||||
<input type="password" class="form-control form-control-sm" name="password" placeholder="Tu contraseña de Moodle" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-warning btn-sm w-100 fw-semibold text-dark">
|
||||
<i class="bi bi-box-arrow-in-right me-1"></i>Validar en Servidor Moodle
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if not providers or providers.local %}
|
||||
<div class="divider-text">o con credenciales locales</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Formulario Login Nativo / Fallback -->
|
||||
{% if not providers or providers.local %}
|
||||
<form action="/auth/login" method="POST" id="localLoginForm" novalidate>
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label small fw-semibold text-muted">Correo Electrónico</label>
|
||||
<div class="input-group">
|
||||
@@ -81,7 +181,16 @@
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="alert alert-info py-2 px-3 small rounded-3 mb-3 text-center">
|
||||
<i class="bi bi-info-circle me-1"></i>El acceso con contraseña local está deshabilitado para usuarios generales.
|
||||
<div class="mt-1">
|
||||
<a href="#adminEmergencyModal" data-bs-toggle="modal" class="fw-semibold text-decoration-none">Acceso de Emergencia ADMIN</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if demo_access_enabled is not defined or demo_access_enabled %}
|
||||
<!-- Accesos Rápidos Institucionales (Demo) -->
|
||||
<div class="p-3 bg-body-tertiary rounded-3 border mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
@@ -107,6 +216,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<small class="text-muted d-inline-flex align-items-center" style="font-size: 0.75rem;">
|
||||
@@ -117,35 +227,93 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Acceso de Emergencia ADMIN si local está inactivo -->
|
||||
<div class="modal fade" id="adminEmergencyModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header py-2">
|
||||
<h6 class="modal-title fw-bold"><i class="bi bi-shield-exclamation text-warning me-1"></i>Acceso de Emergencia ADMIN</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form action="/auth/login" method="POST">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-semibold">Email Administrador</label>
|
||||
<input type="email" name="username" class="form-control form-control-sm" required placeholder="admin@unicaba.edu.ar">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Contraseña</label>
|
||||
<input type="password" name="password" class="form-control form-control-sm" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm w-100 fw-semibold">Ingresar como Admin</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/js/theme-toggle.js"></script>
|
||||
<script>
|
||||
// Toggle password visibility
|
||||
document.getElementById('togglePasswordBtn').addEventListener('click', function() {
|
||||
const pwdInput = document.getElementById('password');
|
||||
const icon = document.getElementById('togglePasswordIcon');
|
||||
if (pwdInput.type === 'password') {
|
||||
pwdInput.type = 'text';
|
||||
icon.classList.replace('bi-eye', 'bi-eye-slash');
|
||||
} else {
|
||||
pwdInput.type = 'password';
|
||||
icon.classList.replace('bi-eye-slash', 'bi-eye');
|
||||
}
|
||||
});
|
||||
const toggleBtn = document.getElementById('togglePasswordBtn');
|
||||
if (toggleBtn) {
|
||||
toggleBtn.addEventListener('click', function() {
|
||||
const pwdInput = document.getElementById('password');
|
||||
const icon = document.getElementById('togglePasswordIcon');
|
||||
if (pwdInput.type === 'password') {
|
||||
pwdInput.type = 'text';
|
||||
icon.classList.replace('bi-eye', 'bi-eye-slash');
|
||||
} else {
|
||||
pwdInput.type = 'password';
|
||||
icon.classList.replace('bi-eye-slash', 'bi-eye');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Demo login autofill buttons
|
||||
// Demo login autofill & instant submit (1 Clic)
|
||||
document.querySelectorAll('.demo-fill-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const email = this.getAttribute('data-email');
|
||||
document.getElementById('username').value = email;
|
||||
document.getElementById('password').value = 'admin123';
|
||||
// Highlight input briefly
|
||||
const emailInput = document.getElementById('username');
|
||||
emailInput.classList.add('is-valid');
|
||||
setTimeout(() => emailInput.classList.remove('is-valid'), 1500);
|
||||
const usrField = document.getElementById('username');
|
||||
const pwdField = document.getElementById('password');
|
||||
const form = document.getElementById('localLoginForm');
|
||||
if (usrField && pwdField) {
|
||||
usrField.value = email;
|
||||
pwdField.value = 'admin123';
|
||||
if (form) {
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Simulación / Integración de Google OAuth
|
||||
function handleGoogleLogin() {
|
||||
const emailPrompt = prompt('Ingrese su correo institucional de Google Workspace (@unicaba.edu.ar):', 'profesor@unicaba.edu.ar');
|
||||
if (!emailPrompt) return;
|
||||
|
||||
fetch('/auth/google', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: emailPrompt,
|
||||
name: emailPrompt.split('@')[0],
|
||||
domain: emailPrompt.split('@')[1] || 'unicaba.edu.ar'
|
||||
})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success && data.redirect) {
|
||||
window.location.href = data.redirect;
|
||||
} else {
|
||||
alert('Error en login con Google: ' + (data.message || 'No autorizado'));
|
||||
}
|
||||
})
|
||||
.catch(err => alert('Error de red: ' + err.message));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+993
-250
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,10 @@
|
||||
</h5>
|
||||
</div>
|
||||
<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="col-md-8">
|
||||
@@ -47,7 +50,7 @@
|
||||
|
||||
<div class="col-md-8">
|
||||
{{ 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 %}
|
||||
<div class="invalid-feedback">{{ form.address.errors[0] }}</div>
|
||||
{% endif %}
|
||||
@@ -55,11 +58,11 @@
|
||||
|
||||
<div class="col-md-4">
|
||||
{{ 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 %}
|
||||
<div class="invalid-feedback">{{ form.floors.errors[0] }}</div>
|
||||
{% endif %}
|
||||
<small class="text-muted">Cantidad de plantas/pisos</small>
|
||||
<small class="text-muted">Plantas físicas (0 para Campus Virtual)</small>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
|
||||
@@ -24,6 +24,15 @@
|
||||
</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 -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
@@ -34,8 +43,9 @@
|
||||
<i class="bi bi-buildings fs-3"></i>
|
||||
</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>
|
||||
<small class="text-muted" style="font-size: 0.72rem;">Físicas y Digitales</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -50,8 +60,9 @@
|
||||
<i class="bi bi-door-open fs-3"></i>
|
||||
</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>
|
||||
<small class="text-muted" style="font-size: 0.72rem;">Activas en el sistema</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,8 +77,11 @@
|
||||
<i class="bi bi-people fs-3"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted small fw-semibold">Capacidad Total</div>
|
||||
<h3 class="fw-bold mb-0 text-info">{{ total_capacity }}</h3>
|
||||
<div class="text-muted small fw-semibold">Capacidad Física Instalada</div>
|
||||
<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>
|
||||
@@ -82,8 +96,9 @@
|
||||
<i class="bi bi-layers fs-3"></i>
|
||||
</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>
|
||||
<small class="text-muted" style="font-size: 0.72rem;">Plantas físicas activas</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,14 +141,14 @@
|
||||
<div class="row g-3">
|
||||
{% for b in buildings %}
|
||||
<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>
|
||||
<span class="badge bg-secondary-subtle text-secondary font-monospace border mb-1">
|
||||
{{ b.code or 'EDIF' }}
|
||||
<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">
|
||||
{% if b.is_virtual %}<i class="bi bi-cloud-fill me-1"></i>{% endif %}{{ b.code or 'EDIF' }}
|
||||
</span>
|
||||
<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 }}
|
||||
</a>
|
||||
</h5>
|
||||
@@ -153,7 +168,7 @@
|
||||
<div class="card-body">
|
||||
{% if b.address %}
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
@@ -165,7 +180,11 @@
|
||||
<div class="col-4">
|
||||
<div class="p-2 rounded bg-body-tertiary">
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
@@ -177,22 +196,28 @@
|
||||
<div class="col-4">
|
||||
<div class="p-2 rounded bg-body-tertiary">
|
||||
<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 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">
|
||||
<i class="bi bi-eye me-1"></i>Ver Distribución de Aulas
|
||||
<a href="/buildings/view/{{ b.id }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-eye me-1"></i>Ver Aulas
|
||||
</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">
|
||||
<a href="/buildings/edit_building" class="btn btn-outline-secondary btn-sm" title="Editar Edificio">
|
||||
<i class="bi bi-pencil"></i>
|
||||
<a href="/buildings/edit/{{ b.id }}" class="btn btn-outline-secondary btn-sm" title="Editar Edificio">
|
||||
<i class="bi bi-pencil me-1"></i>Editar
|
||||
</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 %}">
|
||||
<i class="bi bi-power"></i>
|
||||
</button>
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
</h1>
|
||||
</div>
|
||||
<div class="col-md-5 text-md-end mt-3 mt-md-0">
|
||||
{% if user.has_permission('classrooms', 'read_write') %}
|
||||
<a href="/buildings/edit_building" class="btn btn-outline-secondary me-2">
|
||||
{% if user.has_permission('classrooms', 'read_write') or user.is_admin() or user.is_bedelia() %}
|
||||
<a href="/buildings/edit/{{ building.id }}" class="btn btn-outline-secondary me-2">
|
||||
<i class="bi bi-pencil me-1"></i>Editar Edificio
|
||||
</a>
|
||||
<a href="/classrooms/add_classroom" class="btn btn-primary">
|
||||
@@ -46,7 +46,7 @@
|
||||
<div class="card-body p-3">
|
||||
<div class="text-muted small fw-semibold">Ubicación / Dirección</div>
|
||||
<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>
|
||||
{% if building.description %}
|
||||
<small class="text-secondary">{{ building.description }}</small>
|
||||
@@ -59,7 +59,11 @@
|
||||
<div class="col-sm-4">
|
||||
<div class="card border-0 shadow-sm text-center p-3">
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
@@ -71,7 +75,13 @@
|
||||
<div class="col-sm-4">
|
||||
<div class="card border-0 shadow-sm text-center p-3">
|
||||
<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>
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
{% block title %}Add Classroom - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<div class="container-fluid px-0">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-1 small">
|
||||
@@ -171,10 +171,14 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script type="application/json" id="buildingFloorsData">
|
||||
{{ building_floors|tojson|safe }}
|
||||
</script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Map of existing floors per building
|
||||
var buildingFloors = {{ building_floors|tojson }};
|
||||
var buildingFloorsDataEl = document.getElementById('buildingFloorsData');
|
||||
var buildingFloors = buildingFloorsDataEl ? JSON.parse(buildingFloorsDataEl.textContent || '{}') : {};
|
||||
|
||||
var buildingSelect = document.getElementById('building_id');
|
||||
var newBuildingContainer = document.getElementById('newBuildingContainer');
|
||||
@@ -202,7 +206,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
function updatePreview() {
|
||||
var bText = '-';
|
||||
if (buildingSelect) {
|
||||
if (buildingSelect.value === '__new__') {
|
||||
if (buildingSelect.value === '__new__' || buildingSelect.value === 'new') {
|
||||
bText = newBuildingNameInput.value.trim() || '{{ _("Nuevo Edificio") }}';
|
||||
} else if (buildingSelect.selectedIndex >= 0) {
|
||||
bText = buildingSelect.options[buildingSelect.selectedIndex].text;
|
||||
@@ -218,7 +222,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
if (buildingSelect) {
|
||||
buildingSelect.addEventListener('change', function() {
|
||||
if (this.value === '__new__') {
|
||||
if (this.value === '__new__' || this.value === 'new') {
|
||||
newBuildingContainer.style.display = 'block';
|
||||
if (newBuildingNameInput) newBuildingNameInput.focus();
|
||||
} else {
|
||||
@@ -228,7 +232,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
updatePreview();
|
||||
});
|
||||
// Initial call
|
||||
if (buildingSelect.value === '__new__') {
|
||||
if (buildingSelect.value === '__new__' || buildingSelect.value === 'new') {
|
||||
newBuildingContainer.style.display = 'block';
|
||||
} else if (buildingSelect.value) {
|
||||
updateFloorsDatalist(buildingSelect.value);
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
{% block title %}Edit Classroom - {{ classroom.code_display }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<div class="container-fluid px-0">
|
||||
<!-- Header -->
|
||||
<div class="row mb-4 align-items-center">
|
||||
<div class="row mb-3 align-items-center">
|
||||
<div class="col-md-7">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-1 small">
|
||||
@@ -165,9 +165,13 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script type="application/json" id="buildingFloorsData">
|
||||
{{ building_floors|tojson|safe }}
|
||||
</script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var buildingFloors = {{ building_floors|tojson }};
|
||||
var buildingFloorsDataEl = document.getElementById('buildingFloorsData');
|
||||
var buildingFloors = buildingFloorsDataEl ? JSON.parse(buildingFloorsDataEl.textContent || '{}') : {};
|
||||
|
||||
var buildingSelect = document.getElementById('building_id');
|
||||
var newBuildingContainer = document.getElementById('newBuildingContainer');
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<div class="container-fluid px-0">
|
||||
|
||||
<!-- 1. Encabezado y Selector de Entorno (Presencial vs Virtual) -->
|
||||
<div class="row mb-3">
|
||||
@@ -268,7 +268,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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 -->
|
||||
<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;">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</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>
|
||||
<form method="POST" action="/classrooms/toggle_classroom" class="d-inline mb-0">
|
||||
<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">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</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 %}
|
||||
<form method="POST" action="/classrooms/toggle_classroom" class="d-inline mb-0">
|
||||
<input type="hidden" name="classroom_id" value="{{ classroom.id }}">
|
||||
@@ -698,7 +708,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Catálogo de Aulas Virtuales por Comisión -->
|
||||
{% if virtual_rooms %}
|
||||
{% if virtual_rooms and (virtual_rooms|length > 0) %}
|
||||
<div class="row g-3">
|
||||
{% for room in virtual_rooms %}
|
||||
<div class="col-xxl-3 col-xl-3 col-lg-4 col-md-6 col-sm-12 mb-2">
|
||||
@@ -997,6 +1007,40 @@
|
||||
</div>
|
||||
{% 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 %}
|
||||
|
||||
</div>
|
||||
|
||||
@@ -28,9 +28,12 @@
|
||||
</div>
|
||||
<div class="col-md-5 text-md-end mt-3 mt-md-0">
|
||||
{% 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
|
||||
</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 %}
|
||||
<a href="/classrooms/list_classrooms" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Classrooms
|
||||
@@ -169,5 +172,35 @@
|
||||
</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>
|
||||
{% endblock %}
|
||||
|
||||
+297
-18
@@ -14,13 +14,14 @@
|
||||
|
||||
{% block content %}
|
||||
{% if user.is_authenticated %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<div class="container-fluid px-0">
|
||||
{% endif %}
|
||||
|
||||
<!-- ========================================================================= -->
|
||||
<!-- 1. HEADER & ROLE PERSPECTIVE SWITCHER -->
|
||||
<!-- ========================================================================= -->
|
||||
<div class="dashboard-role-banner mb-4 shadow-sm">
|
||||
{% if show_role_banner %}
|
||||
<div class="dashboard-role-banner mb-3 shadow-sm">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
|
||||
<div>
|
||||
<div class="d-flex align-items-center gap-2 mb-1 flex-wrap">
|
||||
@@ -78,6 +79,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<!-- ========================================================================= -->
|
||||
@@ -252,12 +254,12 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress mb-3" style="height: 14px;">
|
||||
<div class="progress-bar bg-success progress-bar-striped" role="progressbar" style="width: {{ stats.assigned_classrooms_pct }}%;"
|
||||
<div class="progress-bar bg-success progress-bar-striped" role="progressbar" {{ ('style="width: ' ~ (stats.assigned_classrooms_pct or 0) ~ '%;"') | safe }}
|
||||
aria-valuenow="{{ stats.assigned_classrooms_pct }}" aria-valuemin="0" aria-valuemax="100"
|
||||
title="Asignadas: {{ stats.assigned_classrooms }} ({{ stats.assigned_classrooms_pct }}%)">
|
||||
{{ stats.assigned_classrooms_pct }}%
|
||||
</div>
|
||||
<div class="progress-bar bg-warning" role="progressbar" style="width: {{ stats.unassigned_classrooms_pct }}%;"
|
||||
<div class="progress-bar bg-warning" role="progressbar" {{ ('style="width: ' ~ (stats.unassigned_classrooms_pct or 0) ~ '%;"') | safe }}
|
||||
aria-valuenow="{{ stats.unassigned_classrooms_pct }}" aria-valuemin="0" aria-valuemax="100"
|
||||
title="Sin Asignar: {{ stats.unassigned_classrooms }} ({{ stats.unassigned_classrooms_pct }}%)">
|
||||
{{ stats.unassigned_classrooms_pct }}%
|
||||
@@ -274,11 +276,11 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress" style="height: 10px;">
|
||||
<div class="progress-bar bg-info" role="progressbar" style="width: {{ stats.assigned_today_pct }}%;"
|
||||
<div class="progress-bar bg-info" role="progressbar" {{ ('style="width: ' ~ (stats.assigned_today_pct or 0) ~ '%;"') | safe }}
|
||||
aria-valuenow="{{ stats.assigned_today_pct }}" aria-valuemin="0" aria-valuemax="100"
|
||||
title="En uso hoy: {{ stats.assigned_today_classrooms }}">
|
||||
</div>
|
||||
<div class="progress-bar bg-secondary-subtle" role="progressbar" style="width: {{ stats.unassigned_today_pct }}%;"
|
||||
<div class="progress-bar bg-secondary-subtle" role="progressbar" {{ ('style="width: ' ~ (stats.unassigned_today_pct or 0) ~ '%;"') | safe }}
|
||||
aria-valuenow="{{ stats.unassigned_today_pct }}" aria-valuemin="0" aria-valuemax="100"
|
||||
title="Libres hoy: {{ stats.unassigned_today_classrooms }}">
|
||||
</div>
|
||||
@@ -370,8 +372,10 @@
|
||||
<i class="bi bi-bar-chart me-1"></i>Métricas Detalladas
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="monthlyChart" height="110"></canvas>
|
||||
<div class="card-body p-3">
|
||||
<div style="position: relative; height: 260px; max-height: 260px; width: 100%;">
|
||||
<canvas id="monthlyChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -596,12 +600,12 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress mb-3" style="height: 14px;">
|
||||
<div class="progress-bar bg-success progress-bar-striped" role="progressbar" style="width: {{ stats.assigned_classrooms_pct }}%;"
|
||||
<div class="progress-bar bg-success progress-bar-striped" role="progressbar" {{ ('style="width: ' ~ (stats.assigned_classrooms_pct or 0) ~ '%;"') | safe }}
|
||||
aria-valuenow="{{ stats.assigned_classrooms_pct }}" aria-valuemin="0" aria-valuemax="100"
|
||||
title="Asignadas: {{ stats.assigned_classrooms }} ({{ stats.assigned_classrooms_pct }}%)">
|
||||
{{ stats.assigned_classrooms_pct }}%
|
||||
</div>
|
||||
<div class="progress-bar bg-warning" role="progressbar" style="width: {{ stats.unassigned_classrooms_pct }}%;"
|
||||
<div class="progress-bar bg-warning" role="progressbar" {{ ('style="width: ' ~ (stats.unassigned_classrooms_pct or 0) ~ '%;"') | safe }}
|
||||
aria-valuenow="{{ stats.unassigned_classrooms_pct }}" aria-valuemin="0" aria-valuemax="100"
|
||||
title="Sin Asignar: {{ stats.unassigned_classrooms }} ({{ stats.unassigned_classrooms_pct }}%)">
|
||||
{{ stats.unassigned_classrooms_pct }}%
|
||||
@@ -618,11 +622,11 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress" style="height: 10px;">
|
||||
<div class="progress-bar bg-info" role="progressbar" style="width: {{ stats.assigned_today_pct }}%;"
|
||||
<div class="progress-bar bg-info" role="progressbar" {{ ('style="width: ' ~ (stats.assigned_today_pct or 0) ~ '%;"') | safe }}
|
||||
aria-valuenow="{{ stats.assigned_today_pct }}" aria-valuemin="0" aria-valuemax="100"
|
||||
title="En uso hoy: {{ stats.assigned_today_classrooms }}">
|
||||
</div>
|
||||
<div class="progress-bar bg-secondary-subtle" role="progressbar" style="width: {{ stats.unassigned_today_pct }}%;"
|
||||
<div class="progress-bar bg-secondary-subtle" role="progressbar" {{ ('style="width: ' ~ (stats.unassigned_today_pct or 0) ~ '%;"') | safe }}
|
||||
aria-valuenow="{{ stats.unassigned_today_pct }}" aria-valuemin="0" aria-valuemax="100"
|
||||
title="Libres hoy: {{ stats.unassigned_today_classrooms }}">
|
||||
</div>
|
||||
@@ -633,6 +637,254 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========================= Panel: Comisiones Sin Reserva (Semana/Mes) ========================= -->
|
||||
<div class="card border-0 shadow-sm mb-4" id="bedelia-unassigned-commissions">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-calendar-x-fill text-danger fs-5"></i>
|
||||
<div>
|
||||
<h5 class="mb-0 h6 fw-bold">Comisiones Sin Reserva de Aula</h5>
|
||||
<small class="text-muted">Comisiones activas sin clase reservada en el período seleccionado</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||
<div class="btn-group btn-group-sm" role="group" id="commPeriodSwitcher">
|
||||
<button type="button" class="btn btn-outline-danger active" data-period="week" onclick="switchCommPeriod('week')">
|
||||
<i class="bi bi-calendar-week me-1"></i>Esta Semana
|
||||
<span class="badge bg-danger ms-1" id="commWeekBadge">{{ role_data.unassigned_commissions_week | length }}</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger" data-period="month" onclick="switchCommPeriod('month')">
|
||||
<i class="bi bi-calendar-month me-1"></i>Este Mes
|
||||
<span class="badge bg-danger ms-1" id="commMonthBadge">{{ role_data.unassigned_commissions_month | length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<a href="/admin/commissions_list" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-list-ul me-1"></i>Ver Todas
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<!-- Tabla Semana -->
|
||||
<div id="commTableWeek">
|
||||
{% if role_data.unassigned_commissions_week and role_data.unassigned_commissions_week | length > 0 %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0 small">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-3">Código</th>
|
||||
<th>Materia</th>
|
||||
<th>Docente</th>
|
||||
<th>Horario Programado</th>
|
||||
<th>Turno</th>
|
||||
<th>Cupo</th>
|
||||
<th class="text-end pe-3">Acción</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for comm in role_data.unassigned_commissions_week %}
|
||||
<tr>
|
||||
<td class="ps-3">
|
||||
<span class="badge bg-danger-subtle text-danger border font-monospace">{{ comm.full_code }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-semibold text-body">{{ comm.subject_name }}</div>
|
||||
<small class="text-muted font-monospace">{{ comm.subject_code }}</small>
|
||||
</td>
|
||||
<td class="small">{{ comm.teacher_name }}</td>
|
||||
<td>
|
||||
<span class="badge bg-body-tertiary text-body border"><i class="bi bi-clock me-1"></i>{{ comm.schedule }}</span>
|
||||
</td>
|
||||
<td><span class="badge bg-info-subtle text-info-emphasis border">{{ comm.shift }}</span></td>
|
||||
<td class="font-monospace">{{ comm.current_students }}/{{ comm.max_students }}</td>
|
||||
<td class="text-end pe-3">
|
||||
<a href="/schedule/add_reservation?commission_id={{ comm.id }}" class="btn btn-sm btn-outline-success py-0 px-2" title="Asignar aula a esta comisión">
|
||||
<i class="bi bi-plus-circle me-1"></i>Asignar
|
||||
</a>
|
||||
<a href="/admin/commission_detail?id={{ comm.id }}" class="btn btn-sm btn-outline-primary py-0 px-2 ms-1" title="Ver detalle">
|
||||
<i class="bi bi-eye me-1"></i>Detalle
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-check-circle-fill text-success fs-1 d-block mb-2"></i>
|
||||
<h6 class="text-success fw-bold">¡Todas las comisiones tienen reserva esta semana!</h6>
|
||||
<p class="text-muted small mb-0">No hay comisiones activas sin aula reservada en la semana actual.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Tabla Mes -->
|
||||
<div id="commTableMonth" style="display: none;">
|
||||
{% if role_data.unassigned_commissions_month and role_data.unassigned_commissions_month | length > 0 %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0 small">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-3">Código</th>
|
||||
<th>Materia</th>
|
||||
<th>Docente</th>
|
||||
<th>Horario Programado</th>
|
||||
<th>Turno</th>
|
||||
<th>Cupo</th>
|
||||
<th class="text-end pe-3">Acción</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for comm in role_data.unassigned_commissions_month %}
|
||||
<tr>
|
||||
<td class="ps-3">
|
||||
<span class="badge bg-danger-subtle text-danger border font-monospace">{{ comm.full_code }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-semibold text-body">{{ comm.subject_name }}</div>
|
||||
<small class="text-muted font-monospace">{{ comm.subject_code }}</small>
|
||||
</td>
|
||||
<td class="small">{{ comm.teacher_name }}</td>
|
||||
<td>
|
||||
<span class="badge bg-body-tertiary text-body border"><i class="bi bi-clock me-1"></i>{{ comm.schedule }}</span>
|
||||
</td>
|
||||
<td><span class="badge bg-info-subtle text-info-emphasis border">{{ comm.shift }}</span></td>
|
||||
<td class="font-monospace">{{ comm.current_students }}/{{ comm.max_students }}</td>
|
||||
<td class="text-end pe-3">
|
||||
<a href="/schedule/add_reservation?commission_id={{ comm.id }}" class="btn btn-sm btn-outline-success py-0 px-2" title="Asignar aula a esta comisión">
|
||||
<i class="bi bi-plus-circle me-1"></i>Asignar
|
||||
</a>
|
||||
<a href="/admin/commission_detail?id={{ comm.id }}" class="btn btn-sm btn-outline-primary py-0 px-2 ms-1" title="Ver detalle">
|
||||
<i class="bi bi-eye me-1"></i>Detalle
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-check-circle-fill text-success fs-1 d-block mb-2"></i>
|
||||
<h6 class="text-success fw-bold">¡Todas las comisiones tienen reserva este mes!</h6>
|
||||
<p class="text-muted small mb-0">No hay comisiones activas sin aula reservada en el mes actual.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========================= Panel: Aulas Disponibles (Semana/Mes) ========================= -->
|
||||
<div class="card border-0 shadow-sm mb-4" id="bedelia-available-classrooms">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-door-open-fill text-success fs-5"></i>
|
||||
<div>
|
||||
<h5 class="mb-0 h6 fw-bold">Aulas Disponibles (Sin Reservas)</h5>
|
||||
<small class="text-muted">Espacios sin ninguna reserva cargada en el período seleccionado</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||
<div class="btn-group btn-group-sm" role="group" id="classroomPeriodSwitcher">
|
||||
<button type="button" class="btn btn-outline-success active" data-period="week" onclick="switchClassroomPeriod('week')">
|
||||
<i class="bi bi-calendar-week me-1"></i>Esta Semana
|
||||
<span class="badge bg-success ms-1" id="classWeekBadge">{{ role_data.available_classrooms_week | length }}</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-success" data-period="month" onclick="switchClassroomPeriod('month')">
|
||||
<i class="bi bi-calendar-month me-1"></i>Este Mes
|
||||
<span class="badge bg-success ms-1" id="classMonthBadge">{{ role_data.available_classrooms_month | length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<a href="/classrooms/list_classrooms" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-list-ul me-1"></i>Catálogo Completo
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-3">
|
||||
<!-- Vista Semana -->
|
||||
<div id="classroomGridWeek">
|
||||
{% if role_data.available_classrooms_week and role_data.available_classrooms_week | length > 0 %}
|
||||
<div class="row g-2">
|
||||
{% for aula in role_data.available_classrooms_week %}
|
||||
<div class="col-sm-6 col-md-4 col-lg-3">
|
||||
<a href="/schedule/add_reservation?classroom_id={{ aula.id }}" class="text-decoration-none text-reset d-block h-100">
|
||||
<div class="p-3 rounded border h-100 transition-all {% if aula.is_virtual %}bg-info bg-opacity-10 border-info-subtle{% else %}bg-success bg-opacity-10 border-success-subtle{% endif %}">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<span class="badge {% if aula.is_virtual %}bg-info-subtle text-info{% else %}bg-success-subtle text-success{% endif %} border font-monospace fw-bold">
|
||||
{{ aula.code }}
|
||||
</span>
|
||||
{% if aula.is_virtual %}
|
||||
<i class="bi bi-camera-video text-info"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-door-open text-success"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="small text-muted mb-1">
|
||||
<i class="bi bi-building me-1"></i>{{ aula.building }}{% if aula.floor %} · {{ aula.floor }}{% endif %}
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="small fw-semibold text-body">
|
||||
<i class="bi bi-people-fill me-1"></i>Cap: {{ aula.capacity }}
|
||||
</span>
|
||||
<span class="badge bg-success text-white small"><i class="bi bi-plus-circle me-1"></i>Asignar</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-calendar-check-fill text-primary fs-1 d-block mb-2"></i>
|
||||
<h6 class="text-primary fw-bold">¡Todas las aulas están siendo utilizadas esta semana!</h6>
|
||||
<p class="text-muted small mb-0">No hay aulas sin reservas activas en la semana actual.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Vista Mes -->
|
||||
<div id="classroomGridMonth" style="display: none;">
|
||||
{% if role_data.available_classrooms_month and role_data.available_classrooms_month | length > 0 %}
|
||||
<div class="row g-2">
|
||||
{% for aula in role_data.available_classrooms_month %}
|
||||
<div class="col-sm-6 col-md-4 col-lg-3">
|
||||
<a href="/schedule/add_reservation?classroom_id={{ aula.id }}" class="text-decoration-none text-reset d-block h-100">
|
||||
<div class="p-3 rounded border h-100 transition-all {% if aula.is_virtual %}bg-info bg-opacity-10 border-info-subtle{% else %}bg-success bg-opacity-10 border-success-subtle{% endif %}">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<span class="badge {% if aula.is_virtual %}bg-info-subtle text-info{% else %}bg-success-subtle text-success{% endif %} border font-monospace fw-bold">
|
||||
{{ aula.code }}
|
||||
</span>
|
||||
{% if aula.is_virtual %}
|
||||
<i class="bi bi-camera-video text-info"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-door-open text-success"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="small text-muted mb-1">
|
||||
<i class="bi bi-building me-1"></i>{{ aula.building }}{% if aula.floor %} · {{ aula.floor }}{% endif %}
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="small fw-semibold text-body">
|
||||
<i class="bi bi-people-fill me-1"></i>Cap: {{ aula.capacity }}
|
||||
</span>
|
||||
<span class="badge bg-success text-white small"><i class="bi bi-plus-circle me-1"></i>Asignar</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-calendar-check-fill text-primary fs-1 d-block mb-2"></i>
|
||||
<h6 class="text-primary fw-bold">¡Todas las aulas están siendo utilizadas este mes!</h6>
|
||||
<p class="text-muted small mb-0">No hay aulas sin reservas activas en el mes actual.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bedelía Operational Launchpad -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3">
|
||||
@@ -1098,14 +1350,14 @@
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<a href="/classrooms/list_classrooms" class="card h-100 p-3 action-menu-card bg-body">
|
||||
<a href="/schedule/my_grades" class="card h-100 p-3 action-menu-card bg-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="quick-icon-circle bg-warning-subtle text-warning me-3">
|
||||
<i class="bi bi-buildings fs-4"></i>
|
||||
<i class="bi bi-journal-check fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="mb-1 fw-bold text-body">Espacios del Campus</h6>
|
||||
<p class="text-muted small mb-0">Sedes, edificios y planos</p>
|
||||
<h6 class="mb-1 fw-bold text-body">Mis Calificaciones</h6>
|
||||
<p class="text-muted small mb-0">Notas, regularidad y actas</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -1127,7 +1379,7 @@
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light small">
|
||||
<tr>
|
||||
<th class="ps-3">Fecha</th>
|
||||
<th class="ps-3">Fecha & Cuenta Regresiva</th>
|
||||
<th>Tipo de Hito</th>
|
||||
<th>Materia / Código</th>
|
||||
<th>Aula / Enlace</th>
|
||||
@@ -1138,7 +1390,16 @@
|
||||
{% for m in role_data.upcoming_milestones %}
|
||||
<tr>
|
||||
<td class="ps-3 font-monospace fw-bold text-primary">
|
||||
{{ m.date }}
|
||||
<div>{{ m.date }}</div>
|
||||
{% if m.days_remaining is not none and m.days_remaining is defined %}
|
||||
{% if m.days_remaining == 0 %}
|
||||
<span class="badge bg-danger text-white small">¡Hoy!</span>
|
||||
{% elif m.days_remaining == 1 %}
|
||||
<span class="badge bg-warning text-dark small">¡Mañana!</span>
|
||||
{% elif m.days_remaining > 1 %}
|
||||
<span class="badge bg-info-subtle text-info border small">Faltan {{ m.days_remaining }} días</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" data-color="{{ m.type_obj.color if m.type_obj else '#0d6efd' }}" style="background-color:var(--badge-bg,#0d6efd);color:#fff;">
|
||||
@@ -1290,6 +1551,7 @@ $(document).ready(function() {
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
resizeDelay: 100,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
@@ -1314,5 +1576,22 @@ document.querySelectorAll('[data-color]').forEach(function(el) {
|
||||
const color = el.getAttribute('data-color');
|
||||
if (color) el.style.setProperty('background-color', color, 'important');
|
||||
});
|
||||
|
||||
// Bedelía: Switchers de período para paneles de Comisiones y Aulas
|
||||
function switchCommPeriod(period) {
|
||||
document.getElementById('commTableWeek').style.display = period === 'week' ? 'block' : 'none';
|
||||
document.getElementById('commTableMonth').style.display = period === 'month' ? 'block' : 'none';
|
||||
document.querySelectorAll('#commPeriodSwitcher button').forEach(function(btn) {
|
||||
btn.classList.toggle('active', btn.getAttribute('data-period') === period);
|
||||
});
|
||||
}
|
||||
|
||||
function switchClassroomPeriod(period) {
|
||||
document.getElementById('classroomGridWeek').style.display = period === 'week' ? 'block' : 'none';
|
||||
document.getElementById('classroomGridMonth').style.display = period === 'month' ? 'block' : 'none';
|
||||
document.querySelectorAll('#classroomPeriodSwitcher button').forEach(function(btn) {
|
||||
btn.classList.toggle('active', btn.getAttribute('data-period') === period);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -83,7 +83,8 @@
|
||||
<div class="row mb-4">
|
||||
<!-- Algorithm Status Card -->
|
||||
<div class="col-lg-4 mb-4">
|
||||
<div class="card border-0 shadow-sm algorithm-status">
|
||||
<div class="algorithm-status" style="z-index: 10;">
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3">
|
||||
<h5 class="mb-0 h6 fw-bold">
|
||||
<i class="bi bi-gear-wide-connected text-primary me-2"></i>
|
||||
@@ -123,14 +124,60 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-sm btn-outline-primary w-100" onclick="geneticManager.getAlgorithmStatus()">
|
||||
<button class="btn btn-sm btn-outline-primary w-100" onclick="window.refreshStatus()">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>
|
||||
Refresh Status
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Optimization Controls -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3">
|
||||
<h5 class="mb-0 h6 fw-bold">
|
||||
<i class="bi bi-sliders text-primary me-2"></i>
|
||||
Optimization Controls
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center mb-3">
|
||||
<div class="col-12">
|
||||
<div class="mb-3">
|
||||
<label for="start-date" class="form-label">Start Date</label>
|
||||
<input type="datetime-local" class="form-control" id="start-date" name="start_date">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="mb-3">
|
||||
<label for="end-date" class="form-label">End Date</label>
|
||||
<input type="datetime-local" class="form-control" id="end-date" name="end_date">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-info text-white" id="preview-optimization-btn">
|
||||
<i class="bi bi-eye me-1"></i>
|
||||
Preview
|
||||
</button>
|
||||
<button class="btn btn-success" id="optimize-assignments-btn">
|
||||
<i class="bi bi-play-circle me-1"></i>
|
||||
Run Full Optimization
|
||||
</button>
|
||||
<button class="btn btn-warning" id="apply-optimization-btn" style="display: none;">
|
||||
<i class="bi bi-check-circle me-1"></i>
|
||||
Apply
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" onclick="resetOptimization()">
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Commission Selection -->
|
||||
<div class="col-lg-8">
|
||||
<div class="card border-0 shadow-sm">
|
||||
@@ -161,54 +208,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Optimization Controls -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3">
|
||||
<h5 class="mb-0 h6 fw-bold">
|
||||
<i class="bi bi-sliders text-primary me-2"></i>
|
||||
Optimization Controls
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="start-date" class="form-label">Start Date Range</label>
|
||||
<input type="datetime-local" class="form-control" id="start-date" name="start_date">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="end-date" class="form-label">End Date Range</label>
|
||||
<input type="datetime-local" class="form-control" id="end-date" name="end_date">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button class="btn btn-info text-white" id="preview-optimization-btn">
|
||||
<i class="bi bi-eye me-1"></i>
|
||||
Preview Optimization
|
||||
</button>
|
||||
<button class="btn btn-success" id="optimize-assignments-btn">
|
||||
<i class="bi bi-play-circle me-1"></i>
|
||||
Run Full Optimization
|
||||
</button>
|
||||
<button class="btn btn-warning" id="apply-optimization-btn" style="display: none;">
|
||||
<i class="bi bi-check-circle me-1"></i>
|
||||
Apply Reservations
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" onclick="resetOptimization()">
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Results Section -->
|
||||
<div class="row mb-4">
|
||||
@@ -292,11 +292,25 @@ function resetOptimization() {
|
||||
document.getElementById('end-date').value = '';
|
||||
}
|
||||
|
||||
window.refreshStatus = async function() {
|
||||
if (window.geneticManager) {
|
||||
try {
|
||||
const statusResult = await geneticManager.getAlgorithmStatus();
|
||||
if (statusResult && statusResult.success && statusResult.data) {
|
||||
const sysStats = statusResult.data.system_status;
|
||||
document.getElementById('available-classrooms').textContent = sysStats.available_classrooms;
|
||||
document.getElementById('active-commissions').textContent = sysStats.active_commissions;
|
||||
document.getElementById('existing-reservations').textContent = sysStats.confirmed_reservations;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error refreshing status:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
await loadCommissions();
|
||||
if (window.geneticManager) {
|
||||
await geneticManager.getAlgorithmStatus();
|
||||
}
|
||||
await window.refreshStatus();
|
||||
});
|
||||
|
||||
async function loadCommissions() {
|
||||
|
||||
@@ -31,31 +31,81 @@
|
||||
transition: background-color 0.25s ease, color 0.25s ease;
|
||||
}
|
||||
|
||||
/* --- MODO CLARO ([data-bs-theme="light"]) --- */
|
||||
/* =============================================
|
||||
NAVBAR COMPACTA — limpia y sin desperdicio
|
||||
============================================= */
|
||||
.glass-navbar {
|
||||
padding: 0 !important; /* Bootstrap agrega padding, lo neutralizamos */
|
||||
min-height: 48px;
|
||||
}
|
||||
.glass-navbar .container-fluid {
|
||||
padding-top: 0.35rem;
|
||||
padding-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
/* --- MODO CLARO ([data-bs-theme="light"]) - PALETA UNICABA (ROSA, MAGENTA, BLANCO) --- */
|
||||
[data-bs-theme="light"] body {
|
||||
background: #F8FAFC !important;
|
||||
background-color: #FAF5F8 !important;
|
||||
color: #1E293B !important;
|
||||
}
|
||||
[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);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08) !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04) !important;
|
||||
border-bottom: 1px solid rgba(180, 62, 142, 0.16) !important;
|
||||
box-shadow: 0 4px 16px rgba(180, 62, 142, 0.05) !important;
|
||||
}
|
||||
[data-bs-theme="light"] .glass-navbar .navbar-brand span {
|
||||
background: linear-gradient(90deg, #B43E8E, #682CAD);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
[data-bs-theme="light"] .glass-navbar .nav-link {
|
||||
color: #334155 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
[data-bs-theme="light"] .glass-navbar .nav-link:hover,
|
||||
[data-bs-theme="light"] .glass-navbar .nav-link.active {
|
||||
color: #B43E8E !important;
|
||||
background: rgba(180, 62, 142, 0.08);
|
||||
border-radius: 7px;
|
||||
}
|
||||
[data-bs-theme="light"] .glass-card,
|
||||
[data-bs-theme="light"] .card {
|
||||
background: #FFFFFF !important;
|
||||
color: #1E293B !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08) !important;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04) !important;
|
||||
border: 1px solid rgba(180, 62, 142, 0.16) !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 */
|
||||
[data-bs-theme="light"] .navbar-toggle-custom {
|
||||
color: #334155;
|
||||
border-color: rgba(180, 62, 142, 0.25);
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
[data-bs-theme="light"] .navbar-toggle-custom:hover {
|
||||
background: rgba(180, 62, 142, 0.1);
|
||||
border-color: #B43E8E;
|
||||
color: #B43E8E;
|
||||
}
|
||||
|
||||
/* --- MODO OSCURO ([data-bs-theme="dark"]) --- */
|
||||
@@ -64,11 +114,17 @@
|
||||
color: #F8FAFC !important;
|
||||
}
|
||||
[data-bs-theme="dark"] .glass-navbar {
|
||||
background: rgba(15, 23, 42, 0.78) !important;
|
||||
background: rgba(15, 23, 42, 0.82) !important;
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.3) !important;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07) !important;
|
||||
box-shadow: 0 2px 20px rgba(0, 0, 0, 0.35) !important;
|
||||
}
|
||||
[data-bs-theme="dark"] .glass-navbar .navbar-brand span {
|
||||
background: linear-gradient(90deg, #FF8AB6, #AE7FFF);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
[data-bs-theme="dark"] .glass-navbar .nav-link {
|
||||
color: #CBD5E1 !important;
|
||||
@@ -76,6 +132,8 @@
|
||||
[data-bs-theme="dark"] .glass-navbar .nav-link:hover,
|
||||
[data-bs-theme="dark"] .glass-navbar .nav-link.active {
|
||||
color: #FF8AB6 !important;
|
||||
background: rgba(255, 138, 182, 0.08);
|
||||
border-radius: 7px;
|
||||
}
|
||||
[data-bs-theme="dark"] .glass-card,
|
||||
[data-bs-theme="dark"] .card {
|
||||
@@ -84,18 +142,124 @@
|
||||
border: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3) !important;
|
||||
}
|
||||
|
||||
.navbar-brand span {
|
||||
font-weight: 700;
|
||||
background: linear-gradient(90deg, #B43E8E, #682CAD);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
/* Toggle button - modo oscuro */
|
||||
[data-bs-theme="dark"] .navbar-toggle-custom {
|
||||
color: #CBD5E1;
|
||||
border-color: rgba(203, 213, 225, 0.25);
|
||||
}
|
||||
[data-bs-theme="dark"] .navbar-toggle-custom:hover {
|
||||
background: rgba(255, 138, 182, 0.1);
|
||||
border-color: rgba(255, 138, 182, 0.5);
|
||||
color: #FF8AB6;
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
BRAND / LOGO
|
||||
============================================= */
|
||||
.navbar-brand {
|
||||
font-weight: 700;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.navbar-brand span {
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.navbar-logo {
|
||||
height: 26px;
|
||||
width: auto;
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
NAV LINKS — compactos
|
||||
============================================= */
|
||||
.glass-navbar .nav-link {
|
||||
font-size: 0.84rem;
|
||||
font-weight: 500;
|
||||
padding: 0.3rem 0.65rem !important;
|
||||
border-radius: 7px;
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.glass-navbar .nav-link i {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
TOGGLE BUTTON — limpio, pill style
|
||||
============================================= */
|
||||
.navbar-toggle-custom {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
outline: none;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.navbar-toggle-custom i {
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
MAIN CONTAINER — espacio eficiente
|
||||
============================================= */
|
||||
.main-container {
|
||||
flex: 1;
|
||||
padding: 2rem 0;
|
||||
padding: 1rem 0 1.5rem;
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
RESPONSIVE — 720p (1360x768)
|
||||
============================================= */
|
||||
@media (max-height: 800px) {
|
||||
.main-container {
|
||||
padding: 0.65rem 0 1rem;
|
||||
}
|
||||
.glass-navbar .container-fluid {
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
RESPONSIVE — 1920x1080 (pantallas grandes)
|
||||
============================================= */
|
||||
@media (min-width: 1600px) {
|
||||
.main-container {
|
||||
padding: 1.25rem 0 2rem;
|
||||
max-width: 1600px;
|
||||
}
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
MENU COLAPSADO (mobile / tablet)
|
||||
============================================= */
|
||||
@media (max-width: 991.98px) {
|
||||
.glass-navbar .navbar-collapse {
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.12);
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
.glass-navbar .nav-link {
|
||||
padding: 0.45rem 0.75rem !important;
|
||||
border-radius: 8px;
|
||||
margin: 1px 0;
|
||||
}
|
||||
.glass-navbar .navbar-nav.ms-auto {
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.1);
|
||||
margin-top: 0.25rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in-up {
|
||||
@@ -117,42 +281,56 @@
|
||||
<body>
|
||||
<!-- Navbar -->
|
||||
<nav class="navbar navbar-expand-lg glass-navbar sticky-top">
|
||||
<div class="container-fluid px-4">
|
||||
<a class="navbar-brand d-flex align-items-center" href="/dashboard">
|
||||
<img src="/img/logo_unicaba_mini.png" alt="Logo UniCABA" class="navbar-logo me-2" height="30">
|
||||
<div class="container-fluid px-3 px-lg-4">
|
||||
<!-- Brand -->
|
||||
<a class="navbar-brand d-flex align-items-center gap-2" href="/dashboard">
|
||||
<img src="/img/logo_unicaba_mini.png" alt="Logo UniCABA" class="navbar-logo">
|
||||
<span>Edu-Space Admin</span>
|
||||
</a>
|
||||
<button class="navbar-toggler border-0" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<i class="bi bi-list text-light fs-2"></i>
|
||||
|
||||
<!-- Toggle button — limpio, sin foco feo de Bootstrap -->
|
||||
<button
|
||||
class="navbar-toggle-custom d-lg-none"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navbarNav"
|
||||
aria-controls="navbarNav"
|
||||
aria-expanded="false"
|
||||
aria-label="Menú"
|
||||
>
|
||||
<i class="bi bi-list"></i>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto align-items-center ms-3">
|
||||
<!-- Nav principal -->
|
||||
<ul class="navbar-nav me-auto align-items-lg-center ms-lg-2 gap-lg-1">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link px-3" href="/dashboard">
|
||||
<a class="nav-link" href="/dashboard">
|
||||
<i class="bi bi-speedometer2 me-1"></i>Panel General
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link px-3" href="/classrooms/list_classrooms">
|
||||
<i class="bi bi-door-open me-1"></i>Espacios & Sedes
|
||||
<a class="nav-link" href="/classrooms/list_classrooms">
|
||||
<i class="bi bi-door-open me-1"></i>Espacios & Sedes
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link px-3" href="/schedule/calendar_view">
|
||||
<i class="bi bi-calendar3 me-1"></i>Cronograma & Reservas
|
||||
<a class="nav-link" href="/schedule/calendar_view">
|
||||
<i class="bi bi-calendar3 me-1"></i>Cronograma & Reservas
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link px-3 active text-primary fw-semibold" href="/predictive">
|
||||
<a class="nav-link" href="/predictive">
|
||||
<i class="bi bi-cpu me-1"></i>Módulo Predictivo
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ul class="navbar-nav ms-auto align-items-center">
|
||||
<!-- Nav derecha -->
|
||||
<ul class="navbar-nav align-items-lg-center ms-auto gap-2">
|
||||
<li class="nav-item">
|
||||
<a class="btn btn-outline-danger btn-sm rounded-pill px-3 fw-medium" href="/auth/logout">
|
||||
<i class="bi bi-box-arrow-right me-1"></i> Cerrar Sesión
|
||||
<i class="bi bi-box-arrow-right me-1"></i>Cerrar Sesión
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -161,7 +339,7 @@
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container main-container">
|
||||
<main class="container-fluid container-xl main-container px-3 px-lg-4">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user