Compare commits
| 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 | ||
|
|
05e197e49f | ||
|
|
e93d784473 | ||
|
|
620e470614 | ||
|
|
b9a11d810a | ||
|
|
5087e22988 | ||
|
|
42b31ec44e | ||
|
|
500aeeb9df | ||
|
|
1080acd43a | ||
|
|
47b50ede7e | ||
|
|
865538b727 | ||
|
|
1fda344248 |
@@ -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
|
||||
@@ -210,3 +210,18 @@ cython_debug/
|
||||
marimo/_static/
|
||||
marimo/_lsp/
|
||||
__marimo__/
|
||||
|
||||
# Node.js / Frontend
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
frontend/.env
|
||||
|
||||
legacy_admin-edu-space/
|
||||
.vscode/
|
||||
.schemathesis/
|
||||
|
||||
AlumnosLS
|
||||
prerrequisitos
|
||||
@@ -0,0 +1,347 @@
|
||||
# Changelog Oficial — Admin Edu-Space (UniCABA)
|
||||
|
||||
Todas las modificaciones notables a este proyecto serán documentadas en este archivo.
|
||||
El formato está basado en [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/) y este proyecto se adhiere a [Semantic Versioning](https://semver.org/lang/es/).
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
## [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`).
|
||||
- Cálculo dinámico en tiempo real de **Aulas Asignadas** en número y percentil (**26 aulas · 96.3%**) con enlace directo a cursadas activas.
|
||||
- Cálculo dinámico en tiempo real de **Aulas Sin Asignar / Libres** en número y percentil (**1 aula · 3.7%**) con enlace directo al formulario de asignación de espacios.
|
||||
- Barras de progreso visuales con desglose dual: **Planificación Cuatrimestral** (96.3% asignado vs 3.7% disponible) y **Ocupación Operativa del Día** (aulas con clases hoy vs aulas libres).
|
||||
- Exposición de métricas de asignación y percentiles en el endpoint de la API REST `GET /api/v1/dashboard/stats`.
|
||||
- **Módulo de Cursadas Asignadas y Métricas Reales en Catálogo Presencial (`/classrooms/list_classrooms?view_mode=physical`):**
|
||||
- Incorporación de la sección *"Cursadas Asignadas"* dentro de cada tarjeta de aula física y columna en vista de tabla, detallando asignaturas, comisiones y turnos.
|
||||
- Métricas de ocupación física real por período (Día, Semana, Mes) con porcentajes calculados dinámicamente (6%, 11%, 17%, 22% según la carga horaria del aula).
|
||||
- Botones de acción directa con ID dinámico: **Ficha** (`/classrooms/view_classroom?id=...`), **Horarios en Calendario** (`/schedule/calendar_view?classroom_id=...`) y **Editar**.
|
||||
- Conexión de la tabla *"Próximas Clases y Reservas"* en la ficha individual del aula (`/classrooms/view_classroom`) con las reservas reales del cuatrimestre y enlaces a cada reserva (`/schedule/view_reservation?id=...`).
|
||||
|
||||
### Modificado
|
||||
- **Dashboard Docente (`/dashboard?role=docente`):**
|
||||
- Eliminado el botón flotante *"Reservar Aula / Solicitar espacio para clase"* (`/schedule/add_reservation`) a petición de los requerimientos funcionales del cuerpo docente.
|
||||
- Reorganizada la grilla superior a 3 columnas simétricas (`col-md-4`) para las tarjetas de *Mis Clases Hoy*, *Mis Reservas Activas* y *Aulas Virtuales*.
|
||||
|
||||
### Corregido
|
||||
- **Fallo Crítico de Renderizado en Nunjucks (`memberLookup` con `.items`):**
|
||||
- Solucionada la incompatibilidad de `nunjucks/src/jinja-compat` que interceptaba la propiedad `.items` convirtiendo `classrooms.items` en una función interna en lugar de un array, lo que impedía dibujar las tarjetas de aulas presenciales en `frontend/views/classrooms/list.html`.
|
||||
- Implementado interceptor en `memberLookup` en `frontend/src/app.js` para preservar arrays intactos.
|
||||
- **Clasificación Errónea de Reservas Físicas como Virtuales (`is_virtual`):**
|
||||
- Corregida la propiedad `@property is_virtual` en `Reservation` (`backend/app/models/reservation.py`) para garantizar que si una reserva tiene aula física asignada (`self.classroom and not self.classroom.is_virtual`), siempre sea clasificada como presencial (`is_virtual = False`), evitando falsos positivos cuando la comisión posee un enlace virtual predeterminado.
|
||||
- **Excepción de SQLAlchemy en Estadísticas de Dashboard:**
|
||||
- Corregido el error de ejecución generado al invocar `.ilike()` sobre la propiedad Python `@property Classroom.room_number` en `backend/app/routes/api/dashboard.py`, migrando la clasificación de aulas presenciales y virtuales a una evaluación robusta en memoria Python (`Classroom.code` y `Classroom.building`).
|
||||
|
||||
---
|
||||
|
||||
## [2.2.0] - 2026-09-19
|
||||
### Añadido
|
||||
- **Herramienta de Purga y Reinicio de Datos Académicos (`reset_academic_data`):**
|
||||
- Script CLI `backend/scripts/reset_academic_data.py` con argumentos de limpieza granular (`--all`, `--reservations`, `--classrooms`, `--careers`, `--yes`).
|
||||
- Endpoint de API `POST /api/v1/admin/reset-academic-data` protegido por JWT (rol `admin`) con registro en `AuditLog`.
|
||||
- Panel interactivo "Zona de Reinicio y Purga Académica" en `/admin/google_sheets_import` con checkboxes independientes para reservas, aulas, comisiones, asignaturas y carreras.
|
||||
- Modal modal de confirmación con advertencias de seguridad para prevenir purgas accidentales.
|
||||
- Preservación íntegra de usuarios del sistema, credenciales de acceso, roles RBAC y registros de auditoría.
|
||||
- **Configuración Global Dinámica de Google Sheets:**
|
||||
- Nuevo modelo `SystemSetting` (`backend/app/models/setting.py`) para almacenamiento persistente clave-valor en base de datos.
|
||||
- Endpoints `GET /api/v1/admin/sheets-config` y `POST /api/v1/admin/sheets-config`.
|
||||
- Normalizador de enlaces en `GoogleSheetsImporter` compatible con URLs compartidas de edición (`/spreadsheets/d/.../edit`) o enlaces publicados (`/pub?output=csv`).
|
||||
- Interfaz web con formulario de edición de URL, botón de guardado en caliente, restauración a URL oficial predeterminada y acceso directo a Google Docs.
|
||||
- **Visualización de Asignaturas Reales:**
|
||||
- Tabla de asignaturas recientemente sincronizadas conectada a la base de datos real en lugar de datos de muestra (*mock*).
|
||||
|
||||
### Corregido
|
||||
- **Fallo por Timeout en Google Sheets Academic Sync:**
|
||||
- Solucionado el corte prematuro de conexión en el BFF (`frontend/src/services/apiClient.js`), aumentando el timeout base a 25s y el timeout específico de sincronización a 90s (anteriormente limitado a 5s en operaciones que toman ~6.7s).
|
||||
- Eliminado el enmascaramiento silencioso de errores en el BFF, permitiendo reportar fallos reales o éxitos detallados (+carreras, +aulas, +materias, +comisiones, +reservas).
|
||||
|
||||
---
|
||||
|
||||
## [2.1.0] - 2026-09-19
|
||||
### Añadido
|
||||
- **Ficha Detallada de Reserva (`schedule.view_reservation`):**
|
||||
- Implementación en BFF Node.js de la ruta `/schedule/view_reservation` (soportando parámetros `:id`, `?id=` o selección automática del primer registro).
|
||||
- Vistas y controladores para modificar reservas con selectores en cascada (`/schedule/edit_reservation`).
|
||||
- Endpoints de acción rápida en backend y BFF: `POST /schedule/confirm_reservation` y `POST /schedule/cancel_reservation`.
|
||||
- Actualización de enlaces en `today.html`, `list.html`, `dashboard.html` y `view.html` para navegar directamente con el identificador de la reserva (`?id=...`).
|
||||
- **Creación y Asociación de Comisiones a Asignaturas (`admin/commissions_list`):**
|
||||
- Botón destacado `+ Nueva Comisión` en el encabezado y en el estado vacío de la tabla.
|
||||
- Modal interactivo `#modalAddCommission` con selección obligatoria y clara de la **Asignatura / Materia Asociada** (`<select name="subject_id" required>`), mostrando código oficial, nombre y carrera.
|
||||
- Campos completos de cursada: cuatrimestre (1C/2C/Anual), año lectivo, turno, docente a cargo, cupo máximo y días/horarios.
|
||||
- Generación y configuración automática de enlace a Google Meet institucional cuando no se ingresa un enlace personalizado.
|
||||
- Alerta de confirmación visual (`?created=1`) al registrar y vincular una comisión exitosamente.
|
||||
- Tabla de comisiones actualizada con visualización explícita del código de materia, nombre y badge de carrera.
|
||||
- **Enriquecimiento de la API REST:**
|
||||
- Endpoint `GET /api/v1/reservations/<id>` ampliado con objetos relacionales anidados: `classroom` (código, edificio, piso, capacidad, descripción, virtualidad), `commission` (código, código compuesto, materia), `user` (docente, nombre, apellido, correo), duración en minutos y enlaces virtuales.
|
||||
- Endpoint `POST /api/v1/admin/commissions` con validación estricta de existencia de asignatura y resolución inteligente de unicidad.
|
||||
|
||||
---
|
||||
|
||||
## [2.0.0] - 2026-09-18
|
||||
### Añadido
|
||||
- **Reingeniería de Arquitectura Fullstack (Desacople Monolito -> Backend + BFF):**
|
||||
- Desacople completo del backend Python/Flask como una **API REST pura** bajo `/api/v1/` retornando exclusivamente JSON.
|
||||
- Implementación del **Frontend BFF** en Node.js + Express con renderizado de servidor mediante Nunjucks.
|
||||
- Autenticación sin estado (*Stateless*) basada en tokens JWT firmados con algoritmo HS256.
|
||||
- Almacenamiento seguro del token de acceso en cookies del navegador con directivas `HttpOnly`, `SameSite=Lax` y `Secure`.
|
||||
- Inyección transparente del token `Bearer` en peticiones internas mediante cliente Axios centralizado.
|
||||
- **Dashboards Personalizados por Rol Institucional:**
|
||||
- **Dashboard de Bedelía (`/dashboard?role=bedelia`):** Métricas operativas de campus físico y virtual, cartelera del día y panel de aprobación de solicitudes pendientes.
|
||||
- **Dashboard de Docente (`/dashboard?role=docente`):** Sesiones del día con botones directos para ingresar a reuniones virtuales (Meet/Teams/Zoom) y formulario de solicitud de aulas.
|
||||
- **Dashboard de Alumno (`/dashboard?role=alumno`):** Brújula de cursada *"¿Dónde curso hoy?"* con ubicación por piso/aula o enlace virtual, junto al calendario de exámenes y entregas.
|
||||
- **Dashboard de Administrador (`/dashboard?role=admin`):** Cockpit institucional de capacidad, usuarios, auditoría RBAC y distribución de comisiones.
|
||||
- Selector dinámico de vista previa de roles en el banner superior para administradores.
|
||||
- **Cartelera del Día Interactiva (`/schedule/today_schedule`):**
|
||||
- Grilla estructurada en bloques horarios (07:00 a 21:00 hs) con métricas en tiempo real de ocupación y reservas confirmadas/pendientes.
|
||||
- Filtro dinámico para ocultar clases virtuales (`hide_virtual=1`) y segmentación por pisos o turnos.
|
||||
- **Seguridad Enterprise (SecurityFilterChain):**
|
||||
- Pipeline centralizado de cabeceras de blindaje OWASP (`nosniff`, `SAMEORIGIN`, `X-XSS-Protection`, `Strict-Transport-Security`).
|
||||
- Rate limiting dinámico contra ataques de fuerza bruta en endpoints de autenticación.
|
||||
|
||||
### Corregido
|
||||
- **Resolución de Colisión de Horarios en Clases Virtuales (Importador de Google Sheets):**
|
||||
- Corrección de la búsqueda de duplicados en `sheets_importer.py` que limitaba indebidamente la unicidad a `(classroom_id, start_time, end_time)`.
|
||||
- Habilitación de concurrencia ilimitada para el aula virtual (`Campus Virtual · VIRTUAL`) mediante discriminación por `commission_id`.
|
||||
- El total de reservas registradas aumentó de **363 a 643** (incorporando **280 clases virtuales** que eran descartadas por falso conflicto).
|
||||
- Generación y guardado automático de enlaces institucionales de Google Meet (`https://meet.google.com/edu-{materia}-{comision}`).
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] - 2026-09-04
|
||||
### Añadido
|
||||
- **Versión Monolítica Base (`legacy_admin-edu-space`):**
|
||||
- Aplicación monolítica en Flask con plantillas Jinja2 y persistencia SQLAlchemy (SQLite / PostgreSQL).
|
||||
- Modelado de datos inicial para sedes, aulas físicas, materias, comisiones, usuarios y reservas.
|
||||
- Sistema de roles inicial (Admin, Bedelía, Docente, Alumno) basado en sesión tradicional de Flask-Login.
|
||||
- Calendario de reservas mensual y semanal con FullCalendar.
|
||||
- Prototipo experimental de algoritmo genético para optimización de ocupación áulica.
|
||||
- Módulo de sincronización inicial con planillas de Google Sheets.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,223 @@
|
||||
# Estado Actual de la Migración y Arquitectura Objetivo
|
||||
## Sistema Integral de Gestión de Aulas, Cursadas y Seguimiento Académico — UniCABA
|
||||
|
||||
**Rol:** Arquitecto de Software y Analista Funcional Senior
|
||||
**Institución:** Universidad de la Ciudad de Buenos Aires (UniCABA)
|
||||
**Fecha de Actualización:** 19 de Septiembre de 2026
|
||||
**Rama Activa:** `testing`
|
||||
|
||||
---
|
||||
|
||||
## 1. De Dónde Venimos: La Versión Monolítica (`legacy_admin-edu-space`)
|
||||
|
||||
La versión original del sistema correspondía a un monolito clásico desarrollado en Python (Flask) con renderizado del lado del servidor mediante plantillas Jinja2 y ORM SQLAlchemy:
|
||||
|
||||
```
|
||||
[Cliente Web / Navegador]
|
||||
│
|
||||
▼ (HTTP / HTML)
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ Monolito Legacy (legacy_admin-edu-space) │
|
||||
│ - Rutas Flask con render_template (Jinja2) │
|
||||
│ - Sesión en Cookies de Flask (Flask-Login con estado) │
|
||||
│ - Consultas directas SQLAlchemy acopladas a vistas │
|
||||
│ - Base de Datos (SQLite / PostgreSQL) │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Fortalezas Heredadas del Monolito
|
||||
1. **Modelado de Dominio Inicial:** Entidades base para Sedes (`Building`), Aulas (`Classroom`), Materias (`Subject`), Comisiones (`Commission`), Reservas (`Reservation`) y Usuarios (`User`).
|
||||
2. **Importador de Planillas Oficiales:** Módulo de sincronización con Google Sheets para alimentar la cartelera con la oferta de comisiones y clases.
|
||||
3. **Algoritmo Genético Experimental:** Prototipo de optimización predictiva para asignación de aulas físicas según aforo y equipamiento.
|
||||
|
||||
### Limitaciones Críticas que Forzaron la Migración
|
||||
* **Acoplamiento Servidor-Vista:** Lógica de negocio mezclada dentro de controladores web y macros de plantillas Jinja2, impidiendo el consumo móvil (app institucional) o integraciones externas (SIU Guaraní, Moodle).
|
||||
* **Fallas Graves en Virtualidad:** El importador de Google Sheets descartaba más del 75% de las clases virtuales por asumir que el aula `Campus Virtual · VIRTUAL` sufría colisiones físicas de horario ante comisiones simultáneas.
|
||||
* **Falta de Perspectiva por Rol:** El dashboard era único y uniforme; no distinguía entre el trabajo administrativo de Bedelía, la consulta rápida de cursada del Alumno, o la gestión evaluativa del Docente.
|
||||
* **Escalabilidad y Seguridad:** Autenticación dependiente de la sesión en memoria del servidor web, sin soporte de tokens JWT sin estado ni separación de responsabilidades (BFF pattern).
|
||||
|
||||
---
|
||||
|
||||
## 2. Dónde Estamos Actualmente: Arquitectura Desacoplada Backend-BFF
|
||||
|
||||
La infraestructura ha sido transformada en un modelo desacoplado y de alta disponibilidad:
|
||||
|
||||
```
|
||||
[ Navegador Web / Cliente ]
|
||||
│
|
||||
│ (HTTP / HTML + Cookies HttpOnly)
|
||||
▼
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Frontend BFF (Node.js + Express) │
|
||||
│ Puerto: 3000 │
|
||||
│ - Renderizado Nunjucks con herencia limpia │
|
||||
│ - Orquestación y agregación de UI │
|
||||
│ - Gestión de Cookie JWT y Contexto de Rol │
|
||||
│ - Cliente Axios con Interceptor Bearer │
|
||||
└──────────────────────┬───────────────────────┘
|
||||
│
|
||||
│ (REST JSON / JWT Bearer)
|
||||
▼
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Backend Core API (Python + Flask) │
|
||||
│ Puerto: 5000 │
|
||||
│ - Endpoints RESTful bajo /api/v1/ │
|
||||
│ - Autenticación Stateless JWT (HS256) │
|
||||
│ - Pipeline Enterprise SecurityFilterChain │
|
||||
│ - Capa de Servicios y DTOs (Pydantic) │
|
||||
│ - Persistencia SQLAlchemy (Transaccional) │
|
||||
└──────────────────────┬───────────────────────┘
|
||||
│
|
||||
▼
|
||||
[ Base de Datos SQLite / PG ]
|
||||
```
|
||||
|
||||
### Estado de Implementación al Día de la Fecha
|
||||
|
||||
| Componente / Módulo | Estado en Legacy | Estado Actual (Backend API + BFF) | Nivel de Paridad |
|
||||
| :--- | :--- | :--- | :---: |
|
||||
| **Autenticación & Sesión** | Flask-Login basado en sesión de servidor | Autenticación REST JWT con cookie HttpOnly segura y RBAC centralizado | **120% (Superado)** |
|
||||
| **Dashboards por Rol** | Dashboard monolítico único para todos | 4 Dashboards especializados (`admin`, `bedelia`, `docente`, `alumno`) con selector dinámico | **150% (Superado)** |
|
||||
| **Campus Virtual & Enlaces** | Colisión de clases virtuales; pérdida de enlaces Meet | Aulas virtuales concurrentes sin límite, auto-generación de enlaces Google Meet por comisión | **140% (Superado)** |
|
||||
| **Importador Google Sheets** | 363 reservas importadas (colisiones masivas) | 643 reservas totales (388 virtuales) sin colisión horaria entre comisiones | **100% (Paridad Total)** |
|
||||
| **Cartelera del Día (`today_schedule`)** | Vista Jinja con bloques rígidos | Timeline interactivo por horas (7 a 21 hs), filtros por piso/turno y switch `hide_virtual` | **110% (Superado)** |
|
||||
| **Ficha de Reserva (`view_reservation`)** | Vista monolítica rígida | Acoplada al BFF con relaciones anidadas completas, edición en cascada, confirmación y cancelación | **100% (Paridad Total)** |
|
||||
| **Gestión de Comisiones (`commissions_list`)** | Lista plana sin modal de creación en admin | Modal `#modalAddCommission` con vinculación obligatoria a Asignatura, docente, cupo, horarios y Meet | **125% (Superado)** |
|
||||
| **Calendario FullCalendar** | Feed JSON básico | Endpoint `/api/v1/reservations` con discriminación cromática (`#6366F1` virtuales) | **100% (Paridad Total)** |
|
||||
|
||||
---
|
||||
|
||||
## 3. A Dónde Queremos Ir: El Sistema Integral UniCABA
|
||||
|
||||
El objetivo institucional es consolidar la plataforma como el **ERP Académico y Gestor de Espacios Oficial de UniCABA**, dando soporte a las dinámicas pedagógicas físicas y digitales de la universidad.
|
||||
|
||||
### A. Entorno Físico vs. Campus Virtual
|
||||
1. **Edificio Físico (Central):**
|
||||
- Espacios con aforo estrictamente limitado, asignación por piso/ala y equipamiento técnico (proyectores, laboratorios informáticos, acústica).
|
||||
- Validación matemática de no-solapamiento temporal para evitar doble asignación de aula física.
|
||||
2. **Campus Virtual:**
|
||||
- Capacidad física infinita; asignación lógica de salas vinculadas a la plataforma oficial (Google Meet institucional, Zoom o Microsoft Teams).
|
||||
- Control de concurrencia temporal de docentes y estudiantes (evitar que un docente o alumno deba estar en dos salas remotas al mismo tiempo).
|
||||
|
||||
---
|
||||
|
||||
## 4. Reglas de Negocio del Sistema Objetivo
|
||||
|
||||
### 1. Perfiles y Permisos (RBAC Avanzado)
|
||||
* **Administrador General (Superadmin):**
|
||||
* Gobernanza total del sistema, configuración de periodos y parametrización de reglas de conflicto.
|
||||
* **Modo Impersonación / Vista Previa:** Visualización en tiempo real del sistema desde la perspectiva exacta de cualquier usuario (Bedelía, Docente, Alumno) para soporte y auditoría.
|
||||
* Flexibilidad académica para autorizar excepciones de cursada simultánea y apertura de comisiones extraordinarias.
|
||||
* **Bedelía / Gestión Operativa:**
|
||||
* Administración integral del inventario físico (Sedes, Edificios, Pisos, Aulas) y virtual.
|
||||
* Creación y parametrización de Ciclos Lectivos, Asignaturas y Comisiones.
|
||||
* **Asignación Docente Co-docente:** Posibilidad de asignar más de un profesor a una misma comisión (equipo de cátedra: titulares, adjuntos, ayudantes).
|
||||
* Gestión de cupos máximos, padrón de alumnos matriculados y resolución de conflictos de asignación.
|
||||
* Supervisión de hitos evaluativos institucionales.
|
||||
* **Docente:**
|
||||
* Vista segmentada estrictamente a sus comisiones asignadas.
|
||||
* Calendario de clases presenciales y accesos directos a sesiones de videoconferencia.
|
||||
* Gestión del calendario de evaluación: carga de fechas de parciales, recuperatorios, entregas de trabajos prácticos y finales.
|
||||
* Matriz ágil de carga y edición de calificaciones y estados de regularidad.
|
||||
* **Alumno:**
|
||||
* Acceso enfocado a sus materias cursadas en el periodo lectivo activo.
|
||||
* Brújula de cursada diaria (*¿Dónde curso hoy?* con sede, piso y aula física o enlace virtual).
|
||||
* Calendario unificado de entregas de TPs, parciales y finales con avisos preventivos.
|
||||
* Consulta histórica de notas y retroalimentaciones pedagógicas.
|
||||
|
||||
### 2. Estructura Académica y Ciclo Lectivo
|
||||
* **Ciclo Lectivo Flexible:** Estructurado por Año (ej. `2026`) y Periodos estándar (`1C`, `2C`, `Anual`), permitiendo el alta de "Cursos Cortos Intensivos" o trayectos formativos extracurriculares con fechas especiales.
|
||||
* **Restricción Diaria de Cursada del Estudiante:**
|
||||
* **Regla por defecto:** Un estudiante universitario no puede cursar dos asignaturas regulares el mismo día calendario.
|
||||
* **Excepción parametrizable:** Se autoriza la coincidencia en el mismo día si una de las materias corresponde a un curso corto intensivo o mediante autorización explícita de Bedelía/Secretaría Académica.
|
||||
|
||||
### 3. Matriz de Conflictos y Validaciones de Solapamiento
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Req([Solicitud de Asignación / Reserva]) --> V1{¿Es Aula Física?}
|
||||
V1 -- Sí --> C1{¿Aula ocupada en ese día y horario?}
|
||||
C1 -- Sí --> Err1[BLOQUEO: Conflicto Físico Infranqueable]
|
||||
C1 -- No --> C2{¿Aforo de alumnos <= Capacidad aula?}
|
||||
C2 -- No --> Err2[BLOQUEO: Aforo Excedido]
|
||||
C2 -- Sí --> V2
|
||||
V1 -- No (Virtual) --> V2
|
||||
|
||||
V2{¿Docente en otra clase en el mismo horario?}
|
||||
V2 -- Sí --> C3{¿Comisión cuenta con Co-Docencia?}
|
||||
C3 -- No --> Err3[BLOQUEO: Conflicto Docente]
|
||||
C3 -- Sí --> Warn1[ALERTA: Excepción Co-Docente Justificada]
|
||||
C3 --> V3
|
||||
V2 -- No --> V3
|
||||
|
||||
V3{¿Alumno cursa otra materia regular el mismo día?}
|
||||
V3 -- Sí --> C4{¿Es Curso Corto o Autorizado?}
|
||||
C4 -- No --> Err4[BLOQUEO: Regla Restricción Diaria]
|
||||
C4 -- Sí --> Ok[ASIGNACIÓN EXITOSA]
|
||||
V3 -- No --> Ok
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Diseño Técnico: Base de Datos Relacional Normalizada
|
||||
|
||||
Para soportar las reglas de co-docencia, inscripciones, evaluaciones y restricciones de día, el esquema de datos evolucionará hacia la siguiente estructura:
|
||||
|
||||
### Entidades Principales
|
||||
1. **`academic_terms` (Ciclos Lectivos):** `id`, `name`, `code` (ej. '2026-1C'), `year`, `term_type` ('1C', '2C', 'ANUAL', 'CORTO'), `start_date`, `end_date`, `is_active`.
|
||||
2. **`subjects` (Materias / Asignaturas):** `id`, `code`, `name`, `career_id`, `is_short_course` (boolean), `credits`, `active`.
|
||||
3. **`commissions` (Comisiones de Cursada):** `id`, `subject_id`, `term_id`, `code`, `shift`, `schedule_display`, `max_students`, `virtual_link`, `active`.
|
||||
4. **`commission_teachers` (Co-docencia / Equipo Docente):** `id`, `commission_id`, `teacher_id`, `role` ('TITULAR', 'ADJUNTO', 'AYUDANTE'), `can_grade` (boolean).
|
||||
5. **`commission_schedules` (Asignación Horaria y Espacial):**
|
||||
* `id`, `commission_id`, `day_of_week` (1=Lunes .. 7=Domingo), `start_time`, `end_time`, `classroom_id` (física o virtual), `is_virtual`.
|
||||
* *Constraint de Integridad:* `UNIQUE(classroom_id, day_of_week, start_time, end_time)` para aulas no virtuales.
|
||||
6. **`enrollments` (Inscripciones de Alumnos):** `id`, `commission_id`, `student_id`, `status` ('REGULAR', 'CONDICIONAL', 'LIBRE', 'PROMOVIDO'), `enrolled_at`, `allow_same_day_exception` (boolean).
|
||||
7. **`evaluation_milestones` (Hitos Evaluativos):** `id`, `commission_id`, `milestone_type_id`, `title`, `date`, `start_time`, `weight_percentage`, `is_mandatory`.
|
||||
8. **`student_grades` (Calificaciones y Actas):** `id`, `milestone_id`, `student_id`, `numeric_score`, `concept_score`, `feedback`, `graded_by_teacher_id`, `graded_at`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Diseño de Contratos de API RESTful (Backend Core)
|
||||
|
||||
### A. Módulo de Comisiones y Co-docencia
|
||||
* `GET /api/v1/commissions`: Filtro por término académico, carrera, asignatura, turno y estado.
|
||||
* `POST /api/v1/commissions`: Alta de comisión vinculada a una asignatura con generación automática de Meet.
|
||||
* `POST /api/v1/commissions/{id}/teachers`: Asignación de docentes con rol en cátedra (co-docencia).
|
||||
* `DELETE /api/v1/commissions/{id}/teachers/{teacher_id}`: Desvinculación de docente.
|
||||
|
||||
### B. Módulo de Matriz de Conflictos y Validación
|
||||
* `POST /api/v1/schedules/validate`:
|
||||
* **Payload:** `{ commission_id, classroom_id, day_of_week, start_time, end_time, teacher_ids, student_ids }`
|
||||
* **Respuesta:** `{ valid: boolean, conflicts: [ { type: 'PHYSICAL'|'TEACHER'|'STUDENT_DAILY', message, details } ] }`
|
||||
|
||||
### C. Módulo de Hitos Evaluativos y Libro de Calificaciones
|
||||
* `GET /api/v1/commissions/{id}/milestones`: Listado de exámenes, entregas y parciales.
|
||||
* `POST /api/v1/commissions/{id}/milestones`: Creación de hito evaluativo.
|
||||
* `GET /api/v1/commissions/{id}/gradebook`: Matriz completa de alumnos y notas de la comisión.
|
||||
* `PUT /api/v1/commissions/{id}/gradebook`: Carga masiva o individual de calificaciones por docente.
|
||||
|
||||
### D. Modo Impersonación de Superadmin
|
||||
* Cabecera HTTP: `X-Impersonate-User: {user_id}`
|
||||
* Procesamiento en `authMiddleware`: Si el token emisor corresponde al rol `SUPERADMIN`, el contexto de ejecución adopta los permisos y vistas del usuario destino para auditoría, dejando registro en `audit_logs`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Directrices UI/UX para el Frontend BFF
|
||||
|
||||
1. **Panel de Bedelía:**
|
||||
- Grilla interactiva semanal (tipo TimeGrid) donde los bloques de comisiones se visualizan codificados por color según edificio, aula física o aula virtual.
|
||||
- Detección inmediata de colisiones con carteles de advertencia flotantes antes de confirmar cambios.
|
||||
2. **Matriz de Carga Rápida para Docentes:**
|
||||
- Tabla editable en línea (*spreadsheet-like*) para volcar notas de parciales y entregas sin recargar la página, con autoguardado asíncrono.
|
||||
3. **Perspectiva del Alumno:**
|
||||
- Interfaz simplificada con diseño enfocado en dispositivos móviles, mostrando en la tarjeta de inicio la clase del momento con botón directo para ingresar a la videollamada o indicador de piso y aula física.
|
||||
|
||||
---
|
||||
|
||||
## 8. Conclusión del Diagnóstico
|
||||
|
||||
El sistema ha superado con éxito la fase de desacople inicial y estabilización de virtualidad:
|
||||
- Se logró paridad total con `legacy_admin-edu-space` en importación de cronograma y cartelera.
|
||||
- Se eliminaron las fallas de colisión virtual y se establecieron los 4 dashboards por rol.
|
||||
- Se completó el acople de la ficha de reserva (`schedule.view_reservation`) y la creación asistida de comisiones en el BFF.
|
||||
|
||||
Las siguientes etapas se concentrarán en la consolidación de las reglas académicas complejas (co-docencia, restricción diaria de cursada, matriz de conflictos y libro de calificaciones) detalladas en el **`ROADMAP_MVP.md`**.
|
||||
@@ -1,453 +1,273 @@
|
||||
# Panel de Administración Edu-Space (UniCABA)
|
||||
|
||||
Plataforma integral de gestión áulica, asignación de espacios, control de horarios y reservas académicas para la **Universidad de la Ciudad de Buenos Aires (UniCABA)**, desarrollada con **Python / Flask**, **SQLAlchemy**, **PostgreSQL** y **Bootstrap 5**.
|
||||
|
||||
---
|
||||
|
||||
## 🏛️ Sede Institucional y Ubicaciones
|
||||
* **Sede Central UniCABA:** Tte. Gral. Juan Domingo Perón 802, Ciudad Autónoma de Buenos Aires (CABA) — Aulas de grado, auditorio principal y laboratorios distribuidos desde Planta Baja (Piso 0) hasta el 4.° Piso.
|
||||
* **Campus Virtual:** Espacio áulico digital integrado (Microsoft Teams / Moodle) configurado con **capacidad ilimitada ($\infty$)**.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Características Principales
|
||||
|
||||
### 🛡️ 1. Control de Accesos RBAC Granular (Matriz estilo FortiGate)
|
||||
* **Gestión por Módulos:** Permisos independientes por cada área del sistema:
|
||||
1. **Aulas y Espacios:** Creación, edición, capacidad y estado de aulas físicas y virtuales.
|
||||
2. **Edificios y Sedes:** Administración de sedes, pisos y arquitectura edilicia.
|
||||
3. **Reservas de Aulas:** Solicitud, aprobación, confirmación y cancelación.
|
||||
4. **Cartelera y Cronograma:** Calendario interactivo, visualización diaria y comisiones.
|
||||
5. **Gestión Académica:** Carreras, asignaturas, códigos y comisiones.
|
||||
6. **Sincronización Sheets:** Importador y sincronizador de Google Sheets.
|
||||
7. **Optimizador IA:** Algoritmo genético de distribución y resolución de conflictos.
|
||||
8. **Usuarios y Accesos RBAC:** Cuentas, roles y matriz de permisos.
|
||||
* **Niveles de Permiso Granulares:** `Ninguno`, `Solo Lectura` o `Lectura y Escritura`.
|
||||
* **Controles Rápidos:** Botón *"Establecer Todos"* para configuración masiva inmediata.
|
||||
* **Roles Predefinidos:** `Admin` (control total), `Docente`, `Operador` y `Consulta`.
|
||||
|
||||
### 📊 2. Sincronización e Importación desde Google Sheets
|
||||
* Integración directa con la cartelera académica de UniCABA en Google Sheets.
|
||||
* Extracción e importación normalizada de los 5 campos clave:
|
||||
* **Código Académico:** Códigos reales de asignatura (ej. `INGA0003`, `ASIG00165`).
|
||||
* **Asignatura:** Nombre oficial de la materia.
|
||||
* **Aula:** Mapeo a aulas físicas (PB a 4.° piso) o aula `VIRTUAL` ($\infty$).
|
||||
* **Carrera:** Normalización y vinculación relacional con entidades de carrera.
|
||||
* **Horarios y Turnos:** Detección automática de franjas horarias y delimitadores de bloque (`Turno Mañana` y `Turno Tarde / Vespertino`).
|
||||
|
||||
### 📅 3. Calendario Interactivo de Reservas (FullCalendar)
|
||||
* **Visualización Semanal, Mensual y Diaria:** Grilla ágil de alta legibilidad con jerarquía visual:
|
||||
* Asignatura destacada en tipografía grande y negrita.
|
||||
* Badges con código de materia, aula asignada, turno y franja horaria.
|
||||
* **Filtros Dinámicos Combinables:**
|
||||
* Filtrar por **Aula**.
|
||||
* Filtrar por **Materia / Asignatura**.
|
||||
* Filtrar por **Carrera**.
|
||||
* Filtrar por **Turno** (`Turno Mañana` / `Turno Tarde`).
|
||||
* Filtrar por **Piso / Planta** (`Planta Baja`, `Piso 1`, `Piso 2`, `Piso 3`, `Piso 4`).
|
||||
* **Switch "Ocultar Aulas Virtuales":** Aísla la ocupación física real en el edificio central excluyendo los eventos virtuales de capacidad infinita.
|
||||
* **Ficha Modal Instantánea:** Vista previa detallada al hacer clic en cualquier evento.
|
||||
|
||||
### 📋 4. Cartelera del Día y Listado de Reservas
|
||||
* **Cartelera del Día (`/schedule/today`):**
|
||||
* **Vista Cronológica (Timeline):** Bloques horarios de 07:00 a 22:00 hs con indicador de ocupación.
|
||||
* **Vista en Tabla:** Lista ordenada con acciones rápidas y detalles de comisión.
|
||||
* Filtros directos por piso, turno y switch para ocultar virtuales.
|
||||
* **Listado Paginado de Reservas (`/schedule/list`):**
|
||||
* Paginación de 20 registros por página preservando filtros aplicados.
|
||||
* Búsqueda por texto, estado (`Confirmada`, `Pendiente`, `Cancelada`), rango de fechas y piso.
|
||||
|
||||
### 🏫 5. Gestión de Aulas con Capacidad Infinita ($\infty$)
|
||||
* Soporte nativo para el símbolo **`∞`** o `0` para designar aulas virtuales o espacios sin aforo restringido.
|
||||
* Botón de un solo clic `Ilimitada (∞)` en formularios de alta y edición con leyendas explicativas.
|
||||
* Badges distintivos `∞ Ilimitada` en listados, tarjetas y fichas técnicas.
|
||||
|
||||
### 🤖 6. Optimizador Inteligente de Aulas (Algoritmo Genético)
|
||||
* Motor de optimización heurística para asignación automática de aulas a comisiones.
|
||||
* Pondera capacidad requerida vs. capacidad de aula, equipamiento tecnológico y minimización de desplazamientos entre pisos.
|
||||
|
||||
### 🎨 7. Experiencia de Usuario, Temas y Localización
|
||||
* **Tema Oscuro / Claro:** Alternador con botón flotante y persistencia en `localStorage` y perfil de usuario.
|
||||
* **Soporte Multilingüe:** Internacionalización integral (Español e Inglés) compilada con GNU gettext (`pybabel`).
|
||||
* **Tarjetas Estadísticas Interactivas:** Accesos directos rápidos desde el panel general hacia las distintas secciones operativas.
|
||||
|
||||
### 🏛️ 8. Sistema Integral de Gestión Institucional (8 Módulos)
|
||||
El menú desplegable **Gestión** en la barra de navegación organiza las áreas operativas de la universidad:
|
||||
* **Espacios e Infraestructura:**
|
||||
* **Edificios y Sedes (`/buildings/list`):** Administración de edificios físicos, arquitectura y estado edilicio.
|
||||
* **Aulas Presenciales (`/classrooms/list?view_mode=physical`):** Catálogo de aulas físicas por piso, capacidades y zócalos compactos con métricas de ocupación en tiempo real (día, semana, mes).
|
||||
* **Aulas Virtuales con Reunión (`/classrooms/list?view_mode=virtual`):** Campus Virtual con salas a demanda agrupadas por comisión, capacidad ilimitada ($\infty$) y enlaces directos de acceso a videollamadas (Zoom, Google Meet, Microsoft Teams).
|
||||
* **Gestión de Ocupación y Métricas (`/admin/occupancy-metrics`):** Dashboard analítico de tasas de ocupación horaria (día, semana, mes), horas pico, distribución por turno y comparativa física vs. virtual.
|
||||
* **Gestión Académica:**
|
||||
* **Carreras (`/admin/careers`):** ABM de carreras universitarias y planes de estudio con contador de asignaturas y comisiones activas.
|
||||
* **Asignaturas (`/admin/subjects`):** Catálogo institucional de materias con créditos, departamentos y filtros por carrera y estado.
|
||||
* **Comisiones y Cursadas (`/schedule/commissions`):** Gestión de cursadas, turnos, docentes, enlaces virtuales e hitos de evaluación.
|
||||
* **Ciclo Lectivo (`/admin/academic-terms`):** Modelo y panel de administración de períodos académicos, cuatrimestres y activación del ciclo lectivo en curso.
|
||||
* **Administración & Seguridad:**
|
||||
* **Usuarios y Roles RBAC:** Cuentas, asignación de permisos matriciales y auditoría.
|
||||
* **Tipificaciones de Hitos:** Catálogo de tipos de hitos de evaluación académica.
|
||||
* **Sincronización Sheets:** Integración con Google Sheets para actualización en lote.
|
||||
|
||||
### 🔀 9. Selector de Vista Universal (Tabla / Grilla vs. Tarjetas / Mosaico)
|
||||
La plataforma implementa una arquitectura desacoplada de visualización (`app/static/js/view_switcher.js` y `app/templates/partials/view_switcher.html`) que permite al usuario alternar instantáneamente entre diferentes formatos de presentación sin modificar los datos ni perder los filtros o búsquedas activas:
|
||||
* **Vista de Tabla / Grilla (`▤ Tabla`):**
|
||||
* Presentación tabular en filas y columnas de alta densidad informativa.
|
||||
* Muestra múltiples atributos simultáneamente (edificio, piso, aforo, métricas de ocupación, estado, acciones).
|
||||
* Ideal para consulta y comparación masiva de registros.
|
||||
* **Vista de Tarjetas / Mosaico (`▦ Tarjetas`):**
|
||||
* Presentación visual en tarjetas independientes distribuidas automáticamente según el ancho de pantalla (1 a 4 columnas).
|
||||
* Enfoque en la información destacada con badges semánticos, barras de uso y botones de acceso rápido.
|
||||
* **Características del Selector:**
|
||||
* **Cambio Inmediato:** Conmutación instantánea en el cliente sin recarga de página (`d-none` / manipulación de DOM).
|
||||
* **Persistencia de Preferencia:** Memorización automática mediante `localStorage` para conservar la elección del usuario en futuras visitas.
|
||||
* **Preservación de Filtros:** Inyección automática del parámetro `display` en formularios y sincronización de URL sin recarga (`history.replaceState`).
|
||||
* **Arquitectura Extensible:** Permite registrar futuras vistas (kanban, calendario, compacta) mediante atributos `data-view` y `data-view-container` sin alterar la lógica de negocio.
|
||||
|
||||
### 🧭 10. Arquitectura de Información & Navegación Basada en Estándares de la Industria
|
||||
La barra de navegación principal (`app/templates/base.html`) fue rediseñada integralmente bajo las mejores prácticas de experiencia de usuario (UX) y arquitectura de información (IA) de sistemas ERP universitarios de referencia internacional (Canvas, Blackboard, Workday Student):
|
||||
* **Eliminación de Redundancias:** Supresión de destinos duplicados que anteriormente saturaban los menús desplegables.
|
||||
* **Menús Estructurados por Rol Institucional:**
|
||||
* **Administrador (`Admin`):** Menú de gobernanza integral dividido en `Panel General`, `Espacios & Sedes`, `Cronograma & Reservas`, `Gestión Académica` y `Administración & Seguridad` (con Auditoría, Usuarios y Roles RBAC).
|
||||
* **Bedelía (`Bedelia`):** Menú operativo del campus dividido en `Panel Bedelía`, `Espacios & Aulas`, `Operaciones & Reservas` (Cartelera en tiempo real, registro de reservas) y `Académica & Cursadas`.
|
||||
* **Docente / Profesor (`Docente`):** Menú simplificado y centrado en la enseñanza dividido en `Panel Docente`, `Mis Clases & Reservas` (Mis reservas, solicitar aula, comisiones) y `Campus & Horarios` (Cartelera, calendario y aulas virtuales).
|
||||
* **Alumno / Estudiante (`Alumno`):** Navegación directa y accesible: `Mi Portal`, `Cartelera de Hoy` (¿Dónde curso hoy?), `Cronograma de Clases`, `Aulas Virtuales` y `Espacios del Campus`.
|
||||
* **Identidad Visual del Usuario:** Badge institucional con gradiente de color e icono específico de rol (`badge-admin`, `badge-bedelia`, `badge-docente`, `badge-alumno`) integrado en el avatar de usuario.
|
||||
|
||||
### 🎯 11. Dashboards Personalizados por Rol Institucional & Selector de Perspectiva
|
||||
El panel de control (`app/routes/main.py` y `app/templates/dashboard.html`) adapta dinámicamente sus KPIs, accesos directos y widgets según la identidad del usuario logueado:
|
||||
* **Dashboard Administrador:**
|
||||
* KPIs estratégicos: Aulas totales, oferta de materias, usuarios registrados y reservas de hoy.
|
||||
* Módulos visuales de lanzamiento rápido a las áreas clave del sistema.
|
||||
* Gráfico analítico de evolución mensual de reservas (`Chart.js`) y tabla de eventos recientes de auditoría.
|
||||
* **Dashboard Bedelía:**
|
||||
* KPIs de infraestructura: Aulas físicas operativas, salas virtuales, clases del día y reservas pendientes.
|
||||
* **Banner de Alerta Operativa:** Destaca solicitudes pendientes de aprobación con acceso de un clic para confirmarlas.
|
||||
* Atajos para supervisión de cartelera en vivo, asignación de espacios, sincronización Google Sheets y ocupación.
|
||||
* **Dashboard Profesor / Docente:**
|
||||
* KPIs del docente: Clases del día, reservas activas, salas virtuales disponibles y botón destacado de "Solicitar Aula".
|
||||
* **Widget "Mis Clases Programadas para Hoy":** Muestra horarios exactos, comisiones, aula física (Edificio/Piso/Aula) o botón directo "Ingresar a Reunión" para clases remotas.
|
||||
* **Dashboard Alumno / Estudiante:**
|
||||
* **Brújula Diaria "¿Dónde curso hoy?":** Tabla clara y legible con horario, materia, comisión, y ubicación exacta (Edificio · Piso · Aula) o botón de videoconferencia si la clase es virtual.
|
||||
* **Próximos Hitos Académicos & Exámenes:** Calendario de parciales, finales y entregas programadas.
|
||||
* **Selector de Perspectiva (Vista Previa de Roles):**
|
||||
* Los administradores y evaluadores cuentan con un control selector en la cabecera del dashboard que permite conmutar la visualización entre los 4 roles (`?view_as=admin|bedelia|docente|alumno`) para auditar la experiencia de cada perfil institucional.
|
||||
|
||||
### 🛡️ 12. Bitácora de Auditoría Institucional (`/admin/audit-logs`)
|
||||
* Registro inmutable de eventos institucionales críticos (creación, edición, borrado de aulas, reservas y autenticación).
|
||||
* Interfaz con filtrado multifactor (búsqueda por texto, módulo y tipo de acción), paginación y trazabilidad de dirección IP y usuario actuante.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Stack Tecnológico
|
||||
|
||||
| Componente | Tecnología |
|
||||
| :--- | :--- |
|
||||
| **Backend** | Python 3.12+ / Flask |
|
||||
| **Base de Datos** | PostgreSQL 16+ / SQLAlchemy ORM |
|
||||
| **Seguridad & Sesiones** | Flask-Login / Werkzeug / CSRF Protect |
|
||||
| **Formularios** | Flask-WTF / WTForms |
|
||||
| **Internacionalización** | Flask-Babel / GNU gettext |
|
||||
| **Frontend** | Bootstrap 5.3, Bootstrap Icons, Vanilla JS, CSS Variables |
|
||||
| **Calendario** | FullCalendar 5.11 |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Despliegue en Proxmox VE (LXC) con Entorno Virtual `venv`
|
||||
|
||||
El sistema está especialmente optimizado para ejecutarse en contenedores **LXC (Debian 12 Bookworm o Ubuntu 22.04/24.04 LTS)** dentro de **Proxmox VE**, utilizando un entorno virtual nativo de Python (`venv`), el motor **PostgreSQL** instalado en el propio contenedor y el servidor WSGI **Gunicorn** gestionado como servicio por **systemd**.
|
||||
|
||||
---
|
||||
|
||||
### 📋 Paso 1: Creación del Contenedor LXC en Proxmox VE
|
||||
|
||||
Podés crear el contenedor desde la interfaz Web de Proxmox (*Create CT*) o por terminal Proxmox con `pct create`:
|
||||
|
||||
* **Plantilla (Template):** `debian-12-standard` (*Recomendado*) o `ubuntu-22.04-standard` / `ubuntu-24.04-standard`.
|
||||
* **CPU:** 1 a 2 vCPU.
|
||||
* **Memoria RAM:** 1024 MB a 2048 MB (Swap: 512 MB).
|
||||
* **Disco:** 8 GB a 15 GB (almacenamiento `local-lvm` o `zfs`).
|
||||
* **Red (Network):** Modo Bridge `vmbr0` con IP estática en tu red local o DHCP (ej: `192.168.1.150/24`, Gateway: `192.168.1.1`).
|
||||
* **Opciones del Contenedor:**
|
||||
* *Unprivileged container:* Sí (marcado por defecto, compatible).
|
||||
* *Features (Características):* Habilitar **Nesting (`nesting=1`)** en `CT > Opciones > Características` (requerido para systemd).
|
||||
|
||||
---
|
||||
|
||||
### ⚡ Paso 2: Instalación Automatizada con `install.sh`
|
||||
|
||||
1. **Ingresar a la consola del contenedor LXC:**
|
||||
Desde la Web UI de Proxmox (consola noVNC) o desde el shell de tu nodo Proxmox:
|
||||
```bash
|
||||
pct enter <ID_DEL_CONTENEDOR>
|
||||
```
|
||||
|
||||
2. **Clonar el repositorio y lanzar el instalador:**
|
||||
```bash
|
||||
apt update && apt install -y git
|
||||
git clone https://gitea.oemspot.com.ar/carlostellocba/admin-edu-space.git
|
||||
cd admin-edu-space
|
||||
bash install.sh
|
||||
```
|
||||
|
||||
3. **Flujo de Ejecución Automatizado en el LXC:**
|
||||
* **Migración segura fuera de `/root`:** Si el repositorio se clonó en `/root`, el instalador lo traslada automáticamente a `/opt/admin-edu-space` para garantizar que el usuario del servicio (`eduspace`) tenga acceso de lectura y ejecución.
|
||||
* **Locales UTF-8:** Configura `en_US.UTF-8` y `es_AR.UTF-8` para asegurar la internacionalización con `Flask-Babel` y prevenir errores de encoding.
|
||||
* **Motor PostgreSQL:** Instala el motor PostgreSQL, inicia el servicio y verifica conectividad activa con bucle de espera.
|
||||
* **Restauración del Snapshot Oficial ([classrooms_db.sql](file:///c:/Workspace/admin-edu-space/classrooms_db.sql)):**
|
||||
* Detecta el volcado completo de datos.
|
||||
* Crea la base de datos `classrooms_db` y el usuario `eduspace_user`.
|
||||
* Restaura **25 aulas** (físicas y Campus Virtual $\infty$), **206 comisiones**, **206 materias**, **412 reservas históricas**, **24 carreras**, matrices RBAC y cuentas de acceso.
|
||||
* Otorga privilegios totales (`GRANT ALL`) en el esquema `public`, todas las tablas y **todas las secuencias** al usuario `eduspace_user`.
|
||||
* Ejecuta `init_db.py` de forma idempotente para validar roles y coherencia.
|
||||
* **Entorno Virtual Python (`venv`):** Crea el entorno aislado en `/opt/admin-edu-space/venv` e instala todas las dependencias oficiales (`Flask`, `SQLAlchemy`, `Gunicorn`, etc.).
|
||||
* **Servicio Systemd de Producción:** Configura el servicio `admin-edu-space.service` gestionado por **Gunicorn** (3 workers, puerto 5000), con reinicio automático ante fallos y habilitado al inicio del contenedor.
|
||||
|
||||
---
|
||||
|
||||
### 🌐 Paso 3: Acceso Inmediato a la Aplicación
|
||||
|
||||
Al finalizar la instalación, el script mostrará la dirección IP del contenedor:
|
||||
* **URL:** **`http://<IP-DEL-LXC>:5000`** (ejemplo: `http://192.168.1.150:5000`)
|
||||
* **Email Administrador:** `admin@edu-space.com`
|
||||
* **Contraseña Administrador:** `admin123`
|
||||
|
||||
---
|
||||
|
||||
### 🛠️ Comandos de Gestión del Servicio en el LXC
|
||||
|
||||
```bash
|
||||
# Consultar estado en tiempo real
|
||||
systemctl status admin-edu-space
|
||||
|
||||
# Seguir logs de la aplicación
|
||||
journalctl -u admin-edu-space -f
|
||||
|
||||
# Reiniciar la aplicación
|
||||
systemctl restart admin-edu-space
|
||||
|
||||
# Detener el servicio
|
||||
systemctl stop admin-edu-space
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 💾 Generación de Backups y Migración a Otros Contenedores LXC
|
||||
|
||||
Para respaldar la base de datos en ejecución o clonarla a otro contenedor LXC:
|
||||
|
||||
```bash
|
||||
# Dentro del contenedor LXC:
|
||||
cd /opt/admin-edu-space
|
||||
bash backup_db.sh
|
||||
```
|
||||
|
||||
* El script genera un archivo fechado en `backups/classrooms_db_YYYYMMDD_HHMMSS.sql`.
|
||||
* Actualiza automáticamente el archivo principal [classrooms_db.sql](file:///c:/Workspace/admin-edu-space/classrooms_db.sql).
|
||||
* **Para migrar a otro contenedor:** Podés transferir `classrooms_db.sql` mediante SCP o Git y ejecutar `bash install.sh` en el nuevo LXC.
|
||||
|
||||
---
|
||||
|
||||
### 💻 Instalación Manual para Desarrollo Local (con `venv`)
|
||||
|
||||
Si deseás correr el proyecto en una máquina de desarrollo (Windows / Linux / macOS) sin usar el instalador de Proxmox:
|
||||
|
||||
1. **Crear y activar el entorno virtual:**
|
||||
* En Windows (PowerShell):
|
||||
```powershell
|
||||
python -m venv venv
|
||||
.\venv\Scripts\Activate.ps1
|
||||
```
|
||||
* En Linux / macOS:
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
2. **Instalar dependencias:**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. **Configurar base de datos local y restaurar snapshot:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
|
||||
# Crear base de datos en PostgreSQL local y cargar datos
|
||||
sudo -u postgres psql -c "CREATE DATABASE classrooms_db;"
|
||||
sudo -u postgres psql -d classrooms_db < classrooms_db.sql
|
||||
```
|
||||
|
||||
4. **Compilar catálogos de traducción:**
|
||||
```bash
|
||||
pybabel compile -d translations
|
||||
```
|
||||
|
||||
5. **Iniciar el servidor:**
|
||||
```bash
|
||||
python wsgi.py
|
||||
```
|
||||
Disponible en: **`http://localhost:5000`**
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Credenciales Predeterminadas
|
||||
|
||||
| Usuario | Correo Electrónico | Contraseña | Rol |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Administrador del Sistema** | `admin@edu-space.com` | `admin123` | `Admin` (Acceso Total) |
|
||||
|
||||
> ⚠️ **Seguridad:** Cambiá la contraseña del administrador desde la interfaz web inmediatamente después del primer inicio de sesión.
|
||||
|
||||
---
|
||||
|
||||
## 📁 Estructura del Proyecto
|
||||
|
||||
```text
|
||||
admin-edu-space/
|
||||
├── app/
|
||||
│ ├── __init__.py # Factoría de la aplicación Flask y extensiones
|
||||
│ ├── forms/ # Formularios Flask-WTF con validación CSRF
|
||||
│ │ ├── auth.py # Login, perfil y cambio de clave
|
||||
│ │ ├── building.py # Sedes y edificios
|
||||
│ │ ├── classroom.py # Aulas y capacidades (con soporte ∞)
|
||||
│ │ └── reservation.py # Reservas de aulas y horarios
|
||||
│ ├── models/ # Modelos relacionales SQLAlchemy
|
||||
│ │ ├── academic_term.py # Ciclos lectivos y períodos académicos (activo/histórico)
|
||||
│ │ ├── audit_log.py # Bitácora inmutable de auditoría de eventos
|
||||
│ │ ├── building.py # Sedes y edificios institucionales
|
||||
│ │ ├── career.py # Carreras y titulaciones universitarias
|
||||
│ │ ├── classroom.py # Aulas físicas y virtuales con capacidades
|
||||
│ │ ├── genetic_algorithm.py # Estructuras de optimización heurística
|
||||
│ │ ├── milestone.py # Tipificaciones de hitos académicos y exámenes
|
||||
│ │ ├── reservation.py # Reservas horarias, estados y turnos
|
||||
│ │ ├── role.py # Roles y matriz de permisos granulares RBAC
|
||||
│ │ ├── subject.py # Asignaturas, materias y comisiones de cursada
|
||||
│ │ └── user.py # Usuarios, credenciales y asignación de roles
|
||||
│ ├── repositories/ # Capa de persistencia desacoplada (Patrón Repository)
|
||||
│ │ ├── base_repository.py # CRUD genérico tipado BaseRepository[T]
|
||||
│ │ ├── classroom_repository.py # Filtrado de aulas físicas y virtuales
|
||||
│ │ ├── reservation_repository.py# Detección de solapamiento y consultas temporales
|
||||
│ │ └── user_repository.py # Consultas seguras de usuarios y roles
|
||||
│ ├── schemas/ # Contratos DTOs con validación Pydantic v2
|
||||
│ │ ├── auth_dto.py # Esquemas de login y refresh tokens
|
||||
│ │ ├── classroom_dto.py # Contratos de creación y edición de aulas
|
||||
│ │ ├── optimizer_dto.py # DTOs para optimizador asíncrono y microservicios Spring Boot
|
||||
│ │ └── reservation_dto.py # Validación de franjas horarias y aforo
|
||||
│ ├── security/ # Pipeline de seguridad inspirado en Spring Security
|
||||
│ │ ├── filter_chain.py # SecurityFilterChain (OWASP Headers & Rate Limiting)
|
||||
│ │ └── decorators.py # @require_permission declarativo a nivel método
|
||||
│ ├── services/ # Capa de servicios y lógica de negocio
|
||||
│ │ ├── audit_service.py # Registro y consulta de auditoría inmutable
|
||||
│ │ ├── classroom_service.py # Lógica de asignación, aforo y validación áulica
|
||||
│ │ ├── jwt_service.py # Emisión de tokens (Dual Token) y lista negra
|
||||
│ │ ├── optimizer_service.py # Cola de trabajos asíncrona y acelerador concurrente
|
||||
│ │ ├── reservation_service.py # Motor de resolución de conflictos de cronograma
|
||||
│ │ ├── sheets_importer.py # Parser y sincronizador de Google Sheets
|
||||
│ │ └── user_service.py # Autenticación y perfil con matriz RBAC
|
||||
│ ├── routes/ # Controladores y rutas modulares (Blueprints)
|
||||
│ │ ├── api/ # Endpoints RESTful (v1) con Stateless JWT
|
||||
│ │ │ ├── auth.py # /api/v1/auth (login, refresh, logout, me)
|
||||
│ │ │ ├── classrooms.py # /api/v1/classrooms (CRUD REST tipado)
|
||||
│ │ │ ├── optimizer.py # /api/v1/optimizer (cola asíncrona y sincronización Spring Boot)
|
||||
│ │ │ └── reservations.py # /api/v1/reservations (Gestión y disponibilidad)
|
||||
│ │ ├── admin.py # Carreras, Asignaturas, Tipificaciones, Métricas, Usuarios
|
||||
│ │ ├── auth.py # Autenticación web tradicional Jinja2
|
||||
│ │ ├── buildings.py # Gestión de sedes y edificios
|
||||
│ │ ├── classrooms.py # Aulas presenciales vs. Virtuales (controlador delgado)
|
||||
│ │ ├── genetic_algorithm.py # Optimizador heurístico de asignación
|
||||
│ │ ├── main.py # Dashboard principal y métricas rápidas
|
||||
│ │ └── schedule.py # Calendario interactivo, cartelera del día y reservas
|
||||
│ ├── utils/ # Utilidades transversales
|
||||
│ │ └── jwt_decorators.py # @jwt_required y @jwt_role_required
|
||||
│ ├── static/ # Recursos estáticos web (CSS, JS, imágenes institucionales)
|
||||
│ └── templates/ # Vistas Jinja2 renderizadas con Bootstrap 5
|
||||
├── config/ # Configuraciones de entorno (desarrollo, testing, prod)
|
||||
├── translations/ # Catálogos GNU gettext compilados (es / en)
|
||||
├── classrooms_db.sql # Snapshot completo de base de datos (PostgreSQL 14+)
|
||||
├── install.sh # Instalador automatizado para Proxmox VE LXC (venv + PostgreSQL)
|
||||
├── backup_db.sh # Script para generar nuevos backups y actualizar snapshot
|
||||
├── init_db.py # Inicializador DDL y sincronizador de esquema/roles
|
||||
├── CHANGELOG_SESSION.md # Historial técnico cronológico
|
||||
├── requirements.txt # Dependencias Python (incluye Gunicorn)
|
||||
├── .env.example # Plantilla de variables de entorno
|
||||
└── wsgi.py # Punto de entrada de la aplicación Flask
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Roadmap Académico & Propuesta de Evolución Tecnológica
|
||||
|
||||
En respuesta a los requerimientos y sugerencias docentes, se formaliza el plan de evolución arquitectónica, estándares de seguridad y entrega de valor institucional para la plataforma **Admin Edu-Space (UniCABA)**.
|
||||
|
||||
### 💡 1. Propuesta de Mejora Integral
|
||||
|
||||
#### A. Arquitectura MVC Rigurosa & Clean Architecture
|
||||
* **Desacoplamiento de Capas:**
|
||||
* **Model (Dominio):** Entidades relacionales puras y reglas de integridad del negocio desacopladas del framework web (`app/models/`).
|
||||
* **View (Presentación):** Vistas web en Jinja2 con Bootstrap 5 y respuestas JSON estructuradas para clientes API/móviles.
|
||||
* **Controller (Controladores/Blueprints):** Controladores delgados en `app/routes/` enfocados exclusivamente en la orquestación HTTP y redirección.
|
||||
* **Service Layer & Repositories (`app/services/` & `app/repositories/`):** Aislamiento de la lógica de negocio (detección de colisiones, algoritmo genético de asignación, ETL de Google Sheets) y operaciones de persistencia encapsuladas.
|
||||
* **DTOs y Validación de Entrada:** Incorporación de esquemas tipados (Pydantic / Marshmallow) para filtrar y sanitizar datos de entrada antes de interactuar con el ORM, eliminando validaciones ad-hoc en los controladores.
|
||||
|
||||
#### B. Autenticación & Autorización Stateless con JWT (JSON Web Tokens)
|
||||
* **Arquitectura API-First Híbrida:**
|
||||
* Coexistencia armónica de sesiones web seguras (para el panel administrativo en navegadores) y tokens **JWT** (para microservicios, clientes móviles y aplicaciones externas).
|
||||
* **Esquema Dual de Tokens (Dual Token Pattern):**
|
||||
* `Access Token` (firmado con RS256/HS256, expiración corta de 15 minutos) con claims contextuales (`sub`, `roles`, `permissions`, `exp`).
|
||||
* `Refresh Token` (almacenado en cookie segura `HttpOnly`, `Secure`, `SameSite=Strict`, expiración de 7 días) para renovación silenciosa sin reingreso de credenciales.
|
||||
* **Mecanismo de Revocación Inmediata (Token Blacklist):** Almacenamiento en caché distribuida (Redis) para invalidación instantánea ante cierres de sesión forzados o rotación de contraseñas.
|
||||
|
||||
#### C. Seguridad Empresarial y Paradigma "Sprint / Spring Security"
|
||||
* **Cadena de Filtros de Interceptación (SecurityFilterChain):** Pipeline centralizado inspirado en los estándares de Spring Security:
|
||||
* **Authentication Filter:** Intercepta cabeceras `Authorization: Bearer <token>` y valida firmas criptográficas.
|
||||
* **Security Headers Filter:** Inyección obligatoria de cabeceras de blindaje HTTP (HSTS, CSP, X-Frame-Options: DENY, X-Content-Type-Options: nosniff).
|
||||
* **Rate Limiting Filter:** Prevención de ataques de fuerza bruta y DDoS en endpoints sensibles mediante límites dinámicos (`Flask-Limiter` / Redis).
|
||||
* **Seguridad Declarativa a Nivel Método (`@PreAuthorize` / `@require_permission`):**
|
||||
* Decoradores reutilizables que evalúan la matriz RBAC antes de ejecutar cualquier método de servicio:
|
||||
```python
|
||||
@require_permission('classrooms', min_level='read_write')
|
||||
def update_classroom_capacity(classroom_id, new_capacity): ...
|
||||
```
|
||||
* **Bitácora Inmutable de Auditoría (Audit Trail):** Registro trazable de eventos críticos (creación/modificación de reservas, cambios de roles y accesos fallidos) con IP de origen, usuario actuante, timestamp y delta de cambios.
|
||||
|
||||
---
|
||||
|
||||
### 📅 2. Planificación de Sprints (Roadmap)
|
||||
|
||||
| Sprint / Fase | Objetivo Principal | Entregables Clave | Estado | Hito de Evaluación |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **Sprint 1** | **Refactorización MVC & Capa de Servicios** | • Extracción de lógica a `app/services/`<br>• Patrón Repository para aulas y reservas (`app/repositories/`)<br>• DTOs tipados de validación con esquemas Pydantic v2 | **✔ Completado** | Controladores delgados (< 60 líneas), desacoplamiento Clean Architecture y persistencia aislada. |
|
||||
| **Sprint 2** | **Autenticación Stateless JWT & API Gateway** | • Endpoints `/api/v1/auth/login`, `/refresh`, `/logout`, `/me`<br>• Endpoints RESTful de aulas y reservas<br>• Decoradores `@jwt_required` y middleware Bearer Token | **✔ Completado** | Consumo seguro desde Swagger/Postman y clientes externos con Dual Token Pattern (15 min + 7 días). |
|
||||
| **Sprint 3** | **Sprint Security & Gobernanza RBAC** | • Pipeline centralizado `SecurityFilterChain`<br>• Rate Limiting dinámico (fuerza bruta / DoS)<br>• Decorador `@require_permission` a nivel método<br>• Bitácora inmutable de auditoría (`AuditLog`) | **✔ Completado** | Inyección de cabeceras OWASP Secure Headers y trazabilidad completa de eventos críticos con IP/usuario. |
|
||||
| **Sprint 4** | **Optimizador Heurístico & Alta Concurrencia** | • Paralelización del algoritmo genético con multiprocessing/workers<br>• Colas asíncronas para optimizaciones pesadas (`OptimizerService`)<br>• Integración con microservicio Spring Boot vía REST/JWT (`/api/v1/optimizer/spring-boot/sync`) | **✔ Completado** | Optimización de 200+ comisiones en < 2.8s con 0 colisiones horarias. |
|
||||
|
||||
---
|
||||
|
||||
### 💎 3. Valor Agregado & Retorno Institucional
|
||||
|
||||
1. **Eficiencia Edilicia y Reducción del Desperdicio de Espacio:**
|
||||
- Maximiza el aprovechamiento de las aulas físicas de la Sede Central (**Tte. Gral. Juan Domingo Perón 802**), pasando de una asignación manual y empírica a un modelo algorítmico basado en capacidad real, accesibilidad de pisos y equipamiento.
|
||||
2. **Ahorro de Tiempo Operativo (85% de reducción):**
|
||||
- Automatiza la resolución de conflictos de solapamiento horario en horarios pico (Turno Vespertino, 17:30 a 21:30 hs) y agiliza la sincronización con la cartelera de Google Sheets en lote.
|
||||
3. **Interoperabilidad Universal:**
|
||||
- La arquitectura desacoplada y la capa JWT permiten que cualquier otro sistema de la universidad (SIU Guaraní, Campus Virtual Moodle, App de estudiantes, backend Spring Boot institucional) consulte en tiempo real la disponibilidad áulica.
|
||||
4. **Despliegue de Alta Eficiencia (Bajo Costo de Infraestructura):**
|
||||
- A diferencia de arquitecturas pesadas basadas en contenedores Docker o Kubernetes que demandan múltiples gigabytes de RAM, **Admin Edu-Space corre de forma nativa en un contenedor Proxmox VE LXC consumiendo menos de 180MB de memoria**, garantizando alta disponibilidad con costos de hardware mínimos.
|
||||
|
||||
---
|
||||
|
||||
### 🧩 4. Desafíos Técnicos (Challenges)
|
||||
|
||||
* **Challenge 1: Resolución Heurística de Horarios Áulicos (NP-Hard Problem):**
|
||||
- La asignación de 206 asignaturas, 400+ reservas y 25 aulas considerando restricciones duras (no colisión horaria, capacidad no superada) y blandas (mismo docente en pisos cercanos, turnos consolidados) representa un problema de complejidad NP-Hard resuelto mediante un **Algoritmo Genético** con operadores de cruce y mutación adaptativa.
|
||||
* **Challenge 2: Sincronización Masiva Tolerante a Fallos:**
|
||||
- El parser ETL de Google Sheets normaliza datos no estructurados de la cartelera académica, resolviendo discrepancias ortográficas, turnos implícitos y formatos dispares de hora sin interrumpir la operación continua.
|
||||
* **Challenge 3: Portabilidad Extrema en Proxmox LXC sin Docker:**
|
||||
- Desarrollo de un instalador auto-recuperable ([install.sh](file:///c:/Workspace/admin-edu-space/install.sh)) capaz de levantar PostgreSQL nativo en Debian 12 y 13 dentro de `tmpfs`, gestionar clusters con `pg_createcluster`, operar de forma autónoma con o sin `sudo`, y garantizar persistencia íntegra de base de datos entre dispositivos.
|
||||
|
||||
---
|
||||
|
||||
## 📄 Licencia
|
||||
|
||||
Este proyecto forma parte del sistema de gestión institucional y educativa de **UniCABA**. Todos los derechos reservados.
|
||||
␍
|
||||
# Panel de Administración Edu-Space (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
|
||||
* **Sede Central UniCABA:** Tte. Gral. Juan Domingo Perón 802, Ciudad Autónoma de Buenos Aires (CABA) — Aulas de grado, auditorio principal y laboratorios distribuidos desde Planta Baja (Piso 0) hasta el 4.° Piso.
|
||||
* **Campus Virtual:** Espacio áulico digital integrado (Microsoft Teams / Moodle) configurado con **capacidad ilimitada ($\infty$)**.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Arquitectura del Proyecto (Monorepo)
|
||||
|
||||
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/ # 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 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
|
||||
│
|
||||
├── 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 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 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 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).
|
||||
- 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**).
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Dashboards Personalizados por Rol
|
||||
|
||||
La plataforma cuenta con paneles adaptados a las necesidades operativas de cada perfil institucional:
|
||||
|
||||
* **Administrador (`/dashboard?role=admin`):** Cockpit institucional con métricas globales, auditoría de seguridad RBAC, tendencias mensuales y panel interactivo de **Estado y Percentiles de Asignación de Aulas Físicas** (asignadas vs disponibles en número y porcentaje).
|
||||
* **Bedelía (`/dashboard?role=bedelia`):** Control operativo del campus en tiempo real, balance de aulas presenciales asignadas (96.3%) y libres (3.7%), cartelera del día y bandeja de aprobación de reservas pendientes.
|
||||
* **Docente / Profesor (`/dashboard?role=docente`):** Sesiones del día con accesos directos a videollamadas y gestión de comisiones.
|
||||
* **Alumno / Estudiante (`/dashboard?role=alumno`):** Brújula de cursada *"¿Dónde curso hoy?"* con ubicación por piso y aula o enlace virtual, junto al cronograma de exámenes y entregas de TPs.
|
||||
|
||||
> **Modo Vista Previa:** Los usuarios con perfil administrador o directivo pueden previsualizar cualquier perspectiva utilizando el selector de roles en la barra superior o pasando el parámetro `?role={admin|bedelia|docente|alumno}` en la URL.
|
||||
|
||||
---
|
||||
|
||||
## 🏛️ Circuito Operativo de Bedelía (UX & Flujo Simplificado)
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 📅 Ficha de Reserva & Gestión de Comisiones
|
||||
|
||||
* **Ficha de Reserva Detallada (`/schedule/view_reservation?id={id}`):** Información completa del espacio físico o virtual asignado, capacidad y asistencia prevista, comisión académica vinculada, docente a cargo, botones de acción inmediata (*Modificar Reserva*, *Confirmar Reserva*, *Cancelar Reserva*) y acceso con un clic a la sala de videollamada.
|
||||
* **Gestión de Comisiones y Vinculación con Asignaturas (`/admin/commissions_list`):**
|
||||
- Botón y modal interactivo **Nueva Comisión** (`#modalAddCommission`) para registrar comisiones y vincularlas directamente a su **Asignatura / Materia**, indicando cuatrimestre, año, turno, cupo y docente responsable.
|
||||
- Generación automática de enlace institucional de Google Meet cuando no se especifica un enlace propio.
|
||||
- Tabla operativa con visualización explícita del código de comisión, nombre de materia, código de asignatura y carrera.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Instalación y Despliegue Local
|
||||
|
||||
### Requisitos previos
|
||||
- Node.js (v18 o superior)
|
||||
- Python 3.10+
|
||||
- SQLite / PostgreSQL
|
||||
|
||||
### 1. Iniciar el Backend (Flask)
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv venv
|
||||
# Activar venv (Windows: venv\Scripts\activate, Linux/Mac: source venv/bin/activate)
|
||||
pip install -r requirements.txt
|
||||
flask run --port=5000
|
||||
```
|
||||
*(El backend quedará escuchando en http://localhost:5000)*
|
||||
|
||||
### 2. Iniciar el Frontend (Node/Express)
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
# o en su defecto:
|
||||
node src/app.js
|
||||
```
|
||||
*(El frontend quedará escuchando en http://localhost:3000)*
|
||||
|
||||
---
|
||||
|
||||
## 📦 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
|
||||
* [Roadmap MVP: Fases y Estadios del Proyecto](ROADMAP_MVP.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)
|
||||
@@ -0,0 +1,188 @@
|
||||
# ROADMAP MVP — Sistema Integral UniCABA
|
||||
## Gestión de Espacios Áulicos, Actividad de Cursada y Seguimiento Académico
|
||||
|
||||
**Institución:** Universidad de la Ciudad de Buenos Aires (UniCABA)
|
||||
**Versión:** 3.1.0
|
||||
**Fecha de Actualización:** 23 de Septiembre de 2026
|
||||
**Rama Base:** `mvp`
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Visión del Proyecto
|
||||
Construir y evolucionar la plataforma de gestión académica y espacial de **UniCABA** hacia un sistema integral de clase universitaria, desacoplado en una arquitectura **Backend RESTful (Python/Flask) + Frontend BFF (Node.js/Express/Nunjucks)**, capaz de gobernar tanto los espacios físicos de la sede central como la dinámica áulica del campus virtual, garantizando una experiencia fluida para **Administradores**, **Bedelía**, **Docentes** y **Estudiantes**.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Estado Global de Progreso
|
||||
|
||||
```
|
||||
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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Detalle de Fases y Estadios
|
||||
|
||||
### Fase 0: Desacople y Arquitectura Base ✅ *(Completada)*
|
||||
* **Objetivo:** Separar el monolito Jinja2 legacy en dos servicios independientes de alto rendimiento.
|
||||
* **Entregables:**
|
||||
* [x] Creación del Backend Flask como API REST pura bajo `/api/v1/` retornando exclusivamente JSON.
|
||||
* [x] Implementación del Frontend BFF en Node.js + Express con renderizado de plantillas mediante Nunjucks.
|
||||
* [x] Autenticación desacoplada mediante JWT (`access_token`) transportado en Cookie `HttpOnly` segura.
|
||||
* [x] Pipeline de seguridad `SecurityFilterChain` con cabeceras de blindaje OWASP y rate limiting dinámico.
|
||||
* [x] Cliente HTTP Axios centralizado con inyección automática de Bearer token y manejo de sesiones.
|
||||
|
||||
---
|
||||
|
||||
### Fase 1: Paridad Legacy, Estabilización de Virtualidad y BFF Core ✅ *(Completada)*
|
||||
* **Objetivo:** Alcanzar paridad funcional total con la versión legacy, subsanando defectos críticos de virtualidad y dotando de interfaz específica a cada rol.
|
||||
* **Entregables:**
|
||||
* [x] **Importador de Google Sheets Corregido:**
|
||||
* Eliminación de la colisión de horarios en el aula virtual única (`Campus Virtual · VIRTUAL`).
|
||||
* Incremento de reservas registradas de 363 a 643 (incorporando 280 clases virtuales que se descartaban).
|
||||
* Generación y persistencia automática de enlaces de Google Meet institucional por comisión y reserva.
|
||||
* [x] **Dashboards Especializados por Rol:**
|
||||
* Dashboard interactivo para **Bedelía** (aulas físicas, salas virtuales, aprobaciones pendientes).
|
||||
* Dashboard interactivo para **Docente** (clases del día, comisiones, enlaces directos a Meet).
|
||||
* Dashboard interactivo para **Alumno** (brújula de cursada *"¿Dónde curso hoy?"*, hitos y calendario).
|
||||
* Cockpit institucional para **Admin** (métricas de capacidad, auditoría RBAC, demanda mensual).
|
||||
* Selector dinámico de vista previa / roles en tiempo real (`?role=...`).
|
||||
* [x] **Cartelera del Día (`today_schedule`):**
|
||||
* Grilla temporal por bloques horarios (07:00 a 21:00 hs) con indicadores físicos y virtuales.
|
||||
* Filtro dinámico para ocultar clases virtuales (`hide_virtual=1`) y segmentación por piso o turno.
|
||||
* [x] **Acople de Ficha de Reserva (`schedule.view_reservation`):**
|
||||
* Ruteo en BFF (`/schedule/view_reservation?id=...` y `/view/:id`).
|
||||
* Endpoint REST `/api/v1/reservations/<id>` con enriquecimiento relacional (aula, comisión, docente).
|
||||
* Soporte para modificar reserva (`edit_reservation`) con selectores en cascada, confirmación y cancelación.
|
||||
* [x] **Gestión de Comisiones (`admin/commissions_list`):**
|
||||
* Botón y modal interactivo `+ Nueva Comisión` (`#modalAddCommission`).
|
||||
* Vinculación obligatoria y explícita a la **Asignatura / Materia**, indicando cuatrimestre, año lectivo, turno, docente a cargo, cupo y enlace virtual.
|
||||
* Visualización en tabla de la materia vinculada con su código y carrera asociada.
|
||||
|
||||
---
|
||||
|
||||
### 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:**
|
||||
- [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:**
|
||||
- [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:**
|
||||
- [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 📅 *(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:**
|
||||
- [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*):**
|
||||
- [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 🖥️ ✅ *(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):**
|
||||
- [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:**
|
||||
- [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:**
|
||||
- [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: 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 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 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).
|
||||
|
||||
---
|
||||
|
||||
## 📈 Criterios de Aceptación del MVP
|
||||
1. **Zero Colisiones Físicas:** Imposibilidad absoluta de asignar dos materias simultáneas a una misma aula física.
|
||||
2. **Virtualidad Fluida:** Acceso con un clic a reuniones virtuales válidas para el 100% de las comisiones remotas.
|
||||
3. **Roles Seguros:** Separación total de permisos donde ningún alumno o docente acceda a configuración global ni a datos privados de otras comisiones.
|
||||
4. **Respuesta Sub-segundo:** Carga de carteleras y calendarios en menos de 300 ms en el BFF Node.js.
|
||||
@@ -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]**.
|
||||
@@ -1,123 +0,0 @@
|
||||
from flask import Flask, request, session
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from flask_login import LoginManager
|
||||
from flask_cors import CORS
|
||||
from flask_babel import Babel, gettext as _
|
||||
from config.config import Config
|
||||
|
||||
# Initialize extensions
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
login_manager = LoginManager()
|
||||
cors = CORS()
|
||||
babel = Babel()
|
||||
|
||||
def create_app(config_class=Config):
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(config_class)
|
||||
|
||||
# Initialize extensions with app
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
login_manager.init_app(app)
|
||||
cors.init_app(app)
|
||||
|
||||
# Login manager configuration
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message = 'Por favor inicia sesión para acceder a esta página.'
|
||||
login_manager.login_message_category = 'info'
|
||||
|
||||
# Import models to ensure they are registered
|
||||
from app.models import user
|
||||
# Register blueprints
|
||||
from app.routes import auth_bp, classrooms_bp, buildings_bp, main_bp, schedule_bp, genetic_bp, genetic_web_bp, admin_bp
|
||||
from app.routes.api import api_auth_bp, api_classrooms_bp, api_reservations_bp, api_optimizer_bp
|
||||
from app.security import SecurityFilterChain
|
||||
|
||||
app.register_blueprint(auth_bp, url_prefix="/")
|
||||
app.register_blueprint(classrooms_bp, url_prefix="/classrooms")
|
||||
app.register_blueprint(buildings_bp)
|
||||
app.register_blueprint(main_bp, url_prefix="/")
|
||||
app.register_blueprint(schedule_bp, url_prefix="/schedule")
|
||||
app.register_blueprint(genetic_bp)
|
||||
app.register_blueprint(genetic_web_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
|
||||
# Register REST API (v1) Blueprints with Stateless JWT support
|
||||
app.register_blueprint(api_auth_bp)
|
||||
app.register_blueprint(api_classrooms_bp)
|
||||
app.register_blueprint(api_reservations_bp)
|
||||
app.register_blueprint(api_optimizer_bp)
|
||||
|
||||
# Initialize Enterprise Security Pipeline (SecurityFilterChain)
|
||||
SecurityFilterChain(app)
|
||||
|
||||
# Language selector function
|
||||
def get_locale():
|
||||
# 1. Parámetro explícito en la URL (?lang=es o ?lang=en)
|
||||
lang = request.args.get('lang')
|
||||
if lang and lang in app.config['LANGUAGES']:
|
||||
session['language'] = lang
|
||||
return lang
|
||||
|
||||
# 2. Si el usuario está autenticado y tiene idioma configurado en su perfil, prevalece en toda la sesión
|
||||
from flask_login import current_user
|
||||
if current_user.is_authenticated and getattr(current_user, 'preferred_language', None):
|
||||
if current_user.preferred_language in app.config['LANGUAGES']:
|
||||
return current_user.preferred_language
|
||||
|
||||
# 3. En la pantalla de Login (usuario no autenticado):
|
||||
# El idioma predeterminado del login debe ser español o respetar el idioma del navegador.
|
||||
# No debe verse afectado por cookies o sesiones residuales de otros usuarios.
|
||||
is_login = (request.endpoint == 'auth.login' or request.path.rstrip('/') == '/login')
|
||||
if is_login:
|
||||
# Solo si el usuario cambió el idioma explícitamente en el selector del login
|
||||
if session.get('login_lang_explicit') in app.config['LANGUAGES']:
|
||||
return session['login_lang_explicit']
|
||||
|
||||
# Detectar según el navegador del usuario (soporta es-419, es-AR, es-ES, etc.)
|
||||
if request.accept_languages:
|
||||
for accepted_lang, _quality in request.accept_languages:
|
||||
primary = accepted_lang.split('-')[0].lower()
|
||||
if primary in app.config['LANGUAGES']:
|
||||
return primary
|
||||
|
||||
# Idioma predeterminado por defecto del login: Español
|
||||
return 'es'
|
||||
|
||||
# 4. Para otras rutas: verificar si hay idioma en sesión (ej. selector manual)
|
||||
if 'language' in session and session['language'] in app.config['LANGUAGES']:
|
||||
return session['language']
|
||||
|
||||
# 5. Detección automática del navegador por defecto (soporta subtags regionales)
|
||||
if request.accept_languages:
|
||||
for accepted_lang, _quality in request.accept_languages:
|
||||
primary = accepted_lang.split('-')[0].lower()
|
||||
if primary in app.config['LANGUAGES']:
|
||||
return primary
|
||||
|
||||
# Fallback del sistema: Español
|
||||
return app.config['BABEL_DEFAULT_LOCALE']
|
||||
|
||||
|
||||
# Babel context processor
|
||||
@app.context_processor
|
||||
def inject_conf_vars():
|
||||
return dict(
|
||||
languages=app.config['LANGUAGES'],
|
||||
current_lang=str(get_locale())
|
||||
)
|
||||
|
||||
|
||||
# Configure Babel
|
||||
babel.init_app(app, locale_selector=get_locale)
|
||||
|
||||
# Register localized date and datetime formatters for Jinja2 templates
|
||||
from flask_babel import format_date, format_datetime
|
||||
app.jinja_env.globals['format_date'] = format_date
|
||||
app.jinja_env.globals['format_datetime'] = format_datetime
|
||||
app.jinja_env.filters['format_date'] = format_date
|
||||
app.jinja_env.filters['format_datetime'] = format_datetime
|
||||
|
||||
return app
|
||||
@@ -1,109 +0,0 @@
|
||||
from flask import Blueprint, request, jsonify, g, make_response
|
||||
from pydantic import ValidationError
|
||||
import jwt
|
||||
|
||||
from app.services.user_service import UserService
|
||||
from app.services.jwt_service import JWTService
|
||||
from app.schemas.auth_dto import LoginDTO, RefreshTokenDTO
|
||||
from app.utils.jwt_decorators import jwt_required
|
||||
|
||||
api_auth_bp = Blueprint('api_auth', __name__, url_prefix='/api/v1/auth')
|
||||
user_service = UserService()
|
||||
|
||||
@api_auth_bp.route('/login', methods=['POST'])
|
||||
def login():
|
||||
"""
|
||||
Endpoint de autenticación para obtener par de tokens (Access + Refresh).
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
dto = LoginDTO(**data)
|
||||
except ValidationError as e:
|
||||
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
|
||||
|
||||
user = user_service.authenticate(dto.email, dto.password)
|
||||
if not user:
|
||||
return jsonify({
|
||||
'error': 'Unauthorized',
|
||||
'message': 'Credenciales de acceso incorrectas o cuenta inactiva.'
|
||||
}), 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)
|
||||
# Almacenar refresh token en cookie segura HttpOnly
|
||||
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.
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
refresh_token = data.get('refresh_token') or request.cookies.get('refresh_token')
|
||||
|
||||
if not refresh_token:
|
||||
return jsonify({
|
||||
'error': 'BadRequest',
|
||||
'message': 'Refresh token no suministrado en el cuerpo ni en cookies.'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
payload = JWTService.decode_token(refresh_token, expected_type='refresh')
|
||||
user_id = int(payload.get('sub'))
|
||||
user = user_service.get_by_id(user_id)
|
||||
|
||||
if not user or not user.is_active:
|
||||
return jsonify({
|
||||
'error': 'Unauthorized',
|
||||
'message': 'Usuario no encontrado o inactivo.'
|
||||
}), 401
|
||||
|
||||
new_access_token = JWTService.create_access_token(user)
|
||||
return jsonify({
|
||||
'access_token': new_access_token,
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': int(JWTService.ACCESS_TOKEN_EXPIRES.total_seconds())
|
||||
}), 200
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'error': 'TokenExpired', 'message': 'El refresh token ha expirado. Reingrese credenciales.'}), 401
|
||||
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.
|
||||
"""
|
||||
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('/me', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_current_user():
|
||||
"""
|
||||
Retorna el perfil y los permisos del usuario autenticado vía JWT.
|
||||
"""
|
||||
profile = user_service.get_profile_data(g.jwt_user)
|
||||
return jsonify(profile), 200
|
||||
@@ -1,140 +0,0 @@
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from datetime import datetime, timedelta, date
|
||||
from pydantic import ValidationError
|
||||
|
||||
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
|
||||
|
||||
api_reservations_bp = Blueprint('api_reservations', __name__, url_prefix='/api/v1/reservations')
|
||||
reservation_service = ReservationService()
|
||||
|
||||
@api_reservations_bp.route('', methods=['GET'])
|
||||
@jwt_required
|
||||
def list_reservations():
|
||||
"""Consulta de reservas por rango de fechas y/o aula."""
|
||||
start_str = request.args.get('start_date')
|
||||
end_str = request.args.get('end_date')
|
||||
classroom_id = request.args.get('classroom_id', type=int)
|
||||
|
||||
now = datetime.utcnow()
|
||||
try:
|
||||
start_dt = datetime.strptime(start_str, '%Y-%m-%d') if start_str else now.replace(hour=0, minute=0, second=0)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'BadRequest', 'message': 'Formato inválido de start_date (debe ser YYYY-MM-DD).'}), 400
|
||||
|
||||
try:
|
||||
end_dt = datetime.strptime(end_str, '%Y-%m-%d').replace(hour=23, minute=59, second=59) if end_str else start_dt + timedelta(days=7)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'BadRequest', 'message': 'Formato inválido de end_date (debe ser YYYY-MM-DD).'}), 400
|
||||
|
||||
reservations = reservation_service.list_reservations(start_dt, end_dt, classroom_id=classroom_id)
|
||||
result = []
|
||||
for r in reservations:
|
||||
result.append({
|
||||
'id': r.id,
|
||||
'classroom_id': r.classroom_id,
|
||||
'classroom_name': r.classroom.code if r.classroom else None,
|
||||
'commission_id': r.commission_id,
|
||||
'teacher_name': r.user.name if r.user else None,
|
||||
'start_time': r.start_time.isoformat(),
|
||||
'end_time': r.end_time.isoformat(),
|
||||
'purpose': r.purpose,
|
||||
'status': str(r.status),
|
||||
'shift': r.shift,
|
||||
'virtual_link': r.virtual_link,
|
||||
'expected_attendees': r.expected_attendees
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'total': len(result),
|
||||
'start_date': start_dt.strftime('%Y-%m-%d'),
|
||||
'end_date': end_dt.strftime('%Y-%m-%d'),
|
||||
'reservations': result
|
||||
}), 200
|
||||
|
||||
@api_reservations_bp.route('/<int:reservation_id>', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_reservation(reservation_id: int):
|
||||
"""Obtiene el detalle de una reserva específica."""
|
||||
r = reservation_service.get_reservation(reservation_id)
|
||||
if not r:
|
||||
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
|
||||
|
||||
return jsonify({
|
||||
'id': r.id,
|
||||
'classroom_id': r.classroom_id,
|
||||
'classroom_name': r.classroom.code if r.classroom else None,
|
||||
'location_display': r.classroom.location_display if r.classroom else None,
|
||||
'commission_id': r.commission_id,
|
||||
'teacher_id': r.user_id,
|
||||
'teacher_name': r.user.name if r.user else None,
|
||||
'start_time': r.start_time.isoformat(),
|
||||
'end_time': r.end_time.isoformat(),
|
||||
'purpose': r.purpose,
|
||||
'status': str(r.status),
|
||||
'shift': r.shift,
|
||||
'virtual_link': r.virtual_link,
|
||||
'expected_attendees': r.expected_attendees,
|
||||
'notes': r.notes
|
||||
}), 200
|
||||
|
||||
@api_reservations_bp.route('', methods=['POST'])
|
||||
@jwt_required
|
||||
def create_reservation():
|
||||
"""Crea una reserva aplicando validación DTO y detección de colisiones."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
# Inyectar usuario autenticado si no se especifica
|
||||
if 'user_id' not in data:
|
||||
data['user_id'] = g.jwt_user.id
|
||||
|
||||
try:
|
||||
dto = ReservationCreateDTO(**data)
|
||||
except ValidationError as e:
|
||||
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
|
||||
|
||||
try:
|
||||
reservation = reservation_service.create_reservation(dto)
|
||||
return jsonify({
|
||||
'message': 'Reserva creada exitosamente.',
|
||||
'id': reservation.id,
|
||||
'classroom_id': reservation.classroom_id,
|
||||
'start_time': reservation.start_time.isoformat(),
|
||||
'end_time': reservation.end_time.isoformat(),
|
||||
'status': str(reservation.status)
|
||||
}), 201
|
||||
except ValueError as e:
|
||||
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 409
|
||||
|
||||
@api_reservations_bp.route('/<int:reservation_id>', methods=['PUT'])
|
||||
@jwt_required
|
||||
def update_reservation(reservation_id: int):
|
||||
"""Modifica una reserva existente."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
dto = ReservationUpdateDTO(**data)
|
||||
except ValidationError as e:
|
||||
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
|
||||
|
||||
try:
|
||||
reservation = reservation_service.update_reservation(reservation_id, dto)
|
||||
return jsonify({
|
||||
'message': 'Reserva actualizada exitosamente.',
|
||||
'id': reservation.id,
|
||||
'start_time': reservation.start_time.isoformat(),
|
||||
'end_time': reservation.end_time.isoformat(),
|
||||
'status': str(reservation.status)
|
||||
}), 200
|
||||
except ValueError as e:
|
||||
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 409
|
||||
|
||||
@api_reservations_bp.route('/<int:reservation_id>', methods=['DELETE'])
|
||||
@jwt_required
|
||||
def cancel_reservation(reservation_id: int):
|
||||
"""Cancela una reserva."""
|
||||
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
|
||||
@@ -1,122 +0,0 @@
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from app.models.classroom import Classroom
|
||||
from app.repositories.reservation_repository import ReservationRepository
|
||||
from app.repositories.classroom_repository import ClassroomRepository
|
||||
from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO
|
||||
from app import db
|
||||
|
||||
class ReservationService:
|
||||
"""Capa de servicios para la orquestación y validación de reservas y cronograma."""
|
||||
|
||||
def __init__(self, reservation_repo: Optional[ReservationRepository] = None,
|
||||
classroom_repo: Optional[ClassroomRepository] = None):
|
||||
self.reservation_repo = reservation_repo or ReservationRepository()
|
||||
self.classroom_repo = classroom_repo or ClassroomRepository()
|
||||
|
||||
def get_reservation(self, reservation_id: int) -> Optional[Reservation]:
|
||||
"""Obtiene una reserva por su ID."""
|
||||
return self.reservation_repo.get_by_id(reservation_id)
|
||||
|
||||
def list_reservations(self, start_dt: datetime, end_dt: datetime,
|
||||
classroom_id: Optional[int] = None) -> List[Reservation]:
|
||||
"""Obtiene reservas dentro de un rango temporal."""
|
||||
return self.reservation_repo.get_by_date_range(start_dt, end_dt, classroom_id=classroom_id)
|
||||
|
||||
def check_conflicts(self, classroom_id: int, start_time: datetime, end_time: datetime,
|
||||
exclude_id: Optional[int] = None) -> List[Reservation]:
|
||||
"""Verifica si existen solapamientos en un aula específica."""
|
||||
classroom = self.classroom_repo.get_by_id(classroom_id)
|
||||
# Las aulas virtuales tienen concurrencia ilimitada (sin colisiones)
|
||||
if classroom and classroom.is_virtual:
|
||||
return []
|
||||
return self.reservation_repo.find_conflicts(
|
||||
classroom_id=classroom_id,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
exclude_reservation_id=exclude_id
|
||||
)
|
||||
|
||||
def create_reservation(self, dto: ReservationCreateDTO) -> Reservation:
|
||||
"""
|
||||
Crea una nueva reserva aplicando validaciones de aforo y detección de colisiones.
|
||||
"""
|
||||
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}).")
|
||||
|
||||
reservation = Reservation(
|
||||
classroom_id=dto.classroom_id,
|
||||
commission_id=dto.commission_id,
|
||||
user_id=dto.user_id,
|
||||
start_time=dto.start_time,
|
||||
end_time=dto.end_time,
|
||||
purpose=dto.purpose,
|
||||
expected_attendees=dto.expected_attendees,
|
||||
shift=dto.shift,
|
||||
virtual_link=dto.virtual_link,
|
||||
notes=dto.notes,
|
||||
status=dto.status or 'CONFIRMED'
|
||||
)
|
||||
return self.reservation_repo.save(reservation)
|
||||
|
||||
def update_reservation(self, reservation_id: int, dto: ReservationUpdateDTO) -> Reservation:
|
||||
"""Actualiza una reserva existente verificando disponibilidad horaria."""
|
||||
reservation = self.reservation_repo.get_by_id(reservation_id)
|
||||
if not reservation:
|
||||
raise ValueError(f"Reserva con ID {reservation_id} no encontrada.")
|
||||
|
||||
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
|
||||
|
||||
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}.")
|
||||
|
||||
if dto.classroom_id is not None:
|
||||
reservation.classroom_id = dto.classroom_id
|
||||
if dto.start_time is not None:
|
||||
reservation.start_time = dto.start_time
|
||||
if dto.end_time is not None:
|
||||
reservation.end_time = dto.end_time
|
||||
if dto.purpose is not None:
|
||||
reservation.purpose = dto.purpose
|
||||
if dto.expected_attendees is not None:
|
||||
reservation.expected_attendees = dto.expected_attendees
|
||||
if dto.shift is not None:
|
||||
reservation.shift = dto.shift
|
||||
if dto.virtual_link is not None:
|
||||
reservation.virtual_link = dto.virtual_link
|
||||
if dto.notes is not None:
|
||||
reservation.notes = dto.notes
|
||||
if dto.status is not None:
|
||||
reservation.status = dto.status
|
||||
|
||||
return self.reservation_repo.save(reservation)
|
||||
|
||||
def cancel_reservation(self, reservation_id: int) -> bool:
|
||||
"""Cancela una reserva estableciendo su estado en CANCELLED."""
|
||||
reservation = self.reservation_repo.get_by_id(reservation_id)
|
||||
if not reservation:
|
||||
return False
|
||||
reservation.status = ReservationStatus.CANCELLED.value
|
||||
self.reservation_repo.save(reservation)
|
||||
return True
|
||||
@@ -1,252 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}Gestión de Ciclo Lectivo{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 fw-bold mb-1">
|
||||
<i class="bi bi-calendar-range text-primary me-2"></i>{% trans %}Gestión de Ciclo Lectivo y Períodos Académicos{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted mb-0 small">
|
||||
{% trans %}Administración de cuatrimestres, años académicos, fechas de inicio/fin y selección del ciclo lectivo activo.{% endtrans %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
{% if can_edit %}
|
||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalAddTerm">
|
||||
<i class="bi bi-plus-lg me-1"></i>{% trans %}Nuevo Ciclo Lectivo{% endtrans %}
|
||||
</button>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('schedule.commissions_view') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-diagram-3 me-1"></i>{% trans %}Ver Cursadas{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Academic Terms Table -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>{% trans %}Código{% endtrans %}</th>
|
||||
<th>{% trans %}Nombre del Ciclo Lectivo{% endtrans %}</th>
|
||||
<th>{% trans %}Año{% endtrans %}</th>
|
||||
<th>{% trans %}Período / Modalidad{% endtrans %}</th>
|
||||
<th>{% trans %}Fechas del Período{% endtrans %}</th>
|
||||
<th>{% trans %}Comisiones{% endtrans %}</th>
|
||||
<th>{% trans %}Estado Institucional{% endtrans %}</th>
|
||||
{% if can_edit %}
|
||||
<th class="text-end">{% trans %}Acciones{% endtrans %}</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for term in terms %}
|
||||
{% set t_stat = stats.get(term.id, {'commissions_count': 0}) %}
|
||||
<tr class="{% if term.is_current %}table-primary-subtle{% endif %}">
|
||||
<td>
|
||||
<span class="badge font-monospace bg-primary-subtle text-primary border fs-6">{{ term.code }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-bold text-body">{{ term.name }}</div>
|
||||
<small class="text-muted">ID: #{{ term.id }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="fw-semibold text-body">{{ term.year }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary-subtle text-secondary border">
|
||||
{{ term.period_type }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if term.start_date and term.end_date %}
|
||||
<small class="text-body font-monospace">
|
||||
<i class="bi bi-calendar3 me-1 text-primary"></i>{{ term.start_date.strftime('%d/%m/%Y') }} - {{ term.end_date.strftime('%d/%m/%Y') }}
|
||||
</small>
|
||||
{% else %}
|
||||
<small class="text-muted">{% trans %}Sin fechas fijadas{% endtrans %}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info-subtle text-info border">
|
||||
<i class="bi bi-diagram-3 me-1"></i>{{ t_stat.commissions_count }} {% trans %}comisiones{% endtrans %}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if term.is_current %}
|
||||
<span class="badge bg-success text-white px-2 py-1 shadow-sm">
|
||||
<i class="bi bi-check-circle-fill me-1"></i>{% trans %}Ciclo Activo Actual{% endtrans %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary-subtle text-secondary border">
|
||||
{% if term.is_active %}{% trans %}Habilitado{% endtrans %}{% else %}{% trans %}Archivado{% endtrans %}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% if can_edit %}
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm">
|
||||
{% if not term.is_current %}
|
||||
<form method="POST" action="{{ url_for('admin.academic_term_set_current', id=term.id) }}" class="d-inline"
|
||||
onsubmit="return confirm('{% trans %}¿Desea establecer este ciclo lectivo como el Activo para todo el sistema?{% endtrans %}');">
|
||||
<button type="submit" class="btn btn-outline-success py-0 px-2" title="{% trans %}Establecer como Ciclo Activo{% endtrans %}">
|
||||
<i class="bi bi-check2-circle me-1"></i>{% trans %}Activar{% endtrans %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<button type="button" class="btn btn-outline-secondary py-0 px-2"
|
||||
data-bs-toggle="modal" data-bs-target="#modalEditTerm{{ term.id }}"
|
||||
title="{% trans %}Editar Ciclo Lectivo{% endtrans %}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Editar Ciclo Lectivo -->
|
||||
<div class="modal fade text-start" id="modalEditTerm{{ term.id }}" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="{{ url_for('admin.academic_term_edit', id=term.id) }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title h6 fw-bold">
|
||||
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Editar Ciclo Lectivo{% endtrans %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small fw-semibold">{% trans %}Código{% endtrans %} *</label>
|
||||
<input type="text" name="code" class="form-control form-control-sm font-monospace" value="{{ term.code }}" required>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<label class="form-label small fw-semibold">{% trans %}Año Calendario{% endtrans %} *</label>
|
||||
<input type="number" name="year" class="form-control form-control-sm" value="{{ term.year }}" min="2020" max="2050" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Nombre Descriptivo{% endtrans %} *</label>
|
||||
<input type="text" name="name" class="form-control form-control-sm" value="{{ term.name }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Tipo de Período{% endtrans %}</label>
|
||||
<select name="period_type" class="form-select form-select-sm">
|
||||
<option value="Primer Cuatrimestre" {% if term.period_type == 'Primer Cuatrimestre' %}selected{% endif %}>Primer Cuatrimestre</option>
|
||||
<option value="Segundo Cuatrimestre" {% if term.period_type == 'Segundo Cuatrimestre' %}selected{% endif %}>Segundo Cuatrimestre</option>
|
||||
<option value="Anual" {% if term.period_type == 'Anual' %}selected{% endif %}>Anual</option>
|
||||
<option value="Curso de Verano" {% if term.period_type == 'Curso de Verano' %}selected{% endif %}>Curso de Verano</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">{% trans %}Fecha Inicio{% endtrans %}</label>
|
||||
<input type="date" name="start_date" class="form-control form-control-sm" value="{{ term.start_date.strftime('%Y-%m-%d') if term.start_date else '' }}">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">{% trans %}Fecha Fin{% endtrans %}</label>
|
||||
<input type="date" name="end_date" class="form-control form-control-sm" value="{{ term.end_date.strftime('%Y-%m-%d') if term.end_date else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" name="is_current" id="isCurr{{ term.id }}" {% if term.is_current %}checked{% endif %}>
|
||||
<label class="form-check-label small fw-semibold text-primary" for="isCurr{{ term.id }}">
|
||||
{% trans %}Establecer como Ciclo Lectivo Activo Actual{% endtrans %}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="is_active" id="isAct{{ term.id }}" {% if term.is_active %}checked{% endif %}>
|
||||
<label class="form-check-label small" for="isAct{{ term.id }}">{% trans %}Habilitado en el Sistema{% endtrans %}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Guardar Cambios{% endtrans %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-calendar-range fs-1 d-block mb-2"></i>
|
||||
{% trans %}No se encontraron ciclos lectivos registrados.{% endtrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Nuevo Ciclo Lectivo -->
|
||||
{% if can_edit %}
|
||||
<div class="modal fade" id="modalAddTerm" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="{{ url_for('admin.academic_term_add') }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title h6 fw-bold">
|
||||
<i class="bi bi-calendar-plus text-primary me-2"></i>{% trans %}Nuevo Ciclo Lectivo / Período Académico{% endtrans %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small fw-semibold">{% trans %}Código{% endtrans %} *</label>
|
||||
<input type="text" name="code" class="form-control form-control-sm font-monospace" placeholder="ej. 2027-1" required>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<label class="form-label small fw-semibold">{% trans %}Año Calendario{% endtrans %} *</label>
|
||||
<input type="number" name="year" class="form-control form-control-sm" value="2026" min="2020" max="2050" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Nombre Descriptivo{% endtrans %} *</label>
|
||||
<input type="text" name="name" class="form-control form-control-sm" placeholder="ej. Ciclo Lectivo 2027 - 1º Cuatrimestre" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Tipo de Período{% endtrans %}</label>
|
||||
<select name="period_type" class="form-select form-select-sm">
|
||||
<option value="Primer Cuatrimestre">Primer Cuatrimestre</option>
|
||||
<option value="Segundo Cuatrimestre" selected>Segundo Cuatrimestre</option>
|
||||
<option value="Anual">Anual</option>
|
||||
<option value="Curso de Verano">Curso de Verano</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">{% trans %}Fecha Inicio{% endtrans %}</label>
|
||||
<input type="date" name="start_date" class="form-control form-control-sm">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">{% trans %}Fecha Fin{% endtrans %}</label>
|
||||
<input type="date" name="end_date" class="form-control form-control-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="is_current" id="addIsCurr">
|
||||
<label class="form-check-label small fw-semibold text-primary" for="addIsCurr">
|
||||
{% trans %}Establecer de inmediato como Ciclo Lectivo Activo Actual{% endtrans %}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Crear Ciclo Lectivo{% endtrans %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,242 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}Import from Google Sheets{% endtrans %} - 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>
|
||||
<h1 class="h3 mb-1">
|
||||
<i class="bi bi-file-earmark-spreadsheet text-success me-2"></i>{% trans %}Google Sheets Academic Sync{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted small mb-0">{% trans %}Extract and synchronize classrooms, careers, subjects, and weekly schedules directly from the official Google Sheet.{% endtrans %}</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('main.dashboard') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>{% trans %}Dashboard{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metrics Cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-sm-6 col-xl-2">
|
||||
<div class="card border-0 shadow-sm text-center p-3 h-100">
|
||||
<div class="text-primary mb-1"><i class="bi bi-mortarboard fs-3"></i></div>
|
||||
<div class="h4 mb-0 fw-bold">{{ metrics.total_careers }}</div>
|
||||
<small class="text-muted">{% trans %}Careers{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-2">
|
||||
<div class="card border-0 shadow-sm text-center p-3 h-100">
|
||||
<div class="text-info mb-1"><i class="bi bi-book fs-3"></i></div>
|
||||
<div class="h4 mb-0 fw-bold">{{ metrics.total_subjects }}</div>
|
||||
<small class="text-muted">{% trans %}Subjects{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-2">
|
||||
<div class="card border-0 shadow-sm text-center p-3 h-100">
|
||||
<div class="text-success mb-1"><i class="bi bi-door-open fs-3"></i></div>
|
||||
<div class="h4 mb-0 fw-bold">{{ metrics.total_classrooms }}</div>
|
||||
<small class="text-muted">{% trans %}Classrooms{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="card border-0 shadow-sm text-center p-3 h-100">
|
||||
<div class="text-warning mb-1"><i class="bi bi-people fs-3"></i></div>
|
||||
<div class="h4 mb-0 fw-bold">{{ metrics.total_commissions }}</div>
|
||||
<small class="text-muted">{% trans %}Commissions{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="card border-0 shadow-sm text-center p-3 h-100">
|
||||
<div class="text-danger mb-1"><i class="bi bi-calendar-check fs-3"></i></div>
|
||||
<div class="h4 mb-0 fw-bold">{{ metrics.total_reservations }}</div>
|
||||
<small class="text-muted">{% trans %}Calendar Classes{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync Action Banner -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-4">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-lg-8 mb-3 mb-lg-0">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle px-3 py-1 me-2 fw-semibold">
|
||||
<i class="bi bi-cloud-check me-1"></i>Google Sheets Live Integration
|
||||
</span>
|
||||
<span class="badge bg-light text-muted border">UniCABA 2026</span>
|
||||
</div>
|
||||
<h4 class="h5 mb-2 fw-bold">{% trans %}Official Schedule Board (Cartelera de Aulas){% endtrans %}</h4>
|
||||
<p class="text-muted small mb-2">
|
||||
{% trans %}Reads published CSV sheets for all 6 days of the week, automatically creating or updating academic careers, classrooms, subject codes, commissions, and weekly calendar entries without duplicating records.{% endtrans %}
|
||||
</p>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
{% for s in metrics.sheets %}
|
||||
<span class="badge bg-body-tertiary text-secondary border small">
|
||||
<i class="bi bi-calendar-event me-1 text-primary"></i>{{ s.name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 text-lg-end">
|
||||
<form method="POST" id="syncForm" onsubmit="startSync()">
|
||||
<button type="submit" id="syncBtn" class="btn btn-success btn-lg px-4 py-2 shadow fw-semibold">
|
||||
<i class="bi bi-arrow-repeat me-2"></i>{% trans %}Sync Data Now{% endtrans %}
|
||||
</button>
|
||||
</form>
|
||||
<div id="syncSpinner" class="d-none mt-2 text-muted small">
|
||||
<div class="spinner-border spinner-border-sm text-success me-1" role="status"></div>
|
||||
{% trans %}Connecting to Google Sheets and processing records...{% endtrans %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync Results (if just completed) -->
|
||||
{% if stats %}
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-success bg-opacity-10 text-success py-3">
|
||||
<h5 class="card-title h6 mb-0 fw-bold">
|
||||
<i class="bi bi-check-circle-fill me-2"></i>{% trans %}Synchronization Report{% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3 text-center mb-3">
|
||||
<div class="col-md-2">
|
||||
<div class="p-2 border rounded bg-body-tertiary">
|
||||
<div class="fw-bold fs-5">{{ stats.total_rows_processed }}</div>
|
||||
<small class="text-muted">{% trans %}Rows Processed{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="p-2 border rounded bg-body-tertiary">
|
||||
<div class="fw-bold fs-5 text-primary">+{{ stats.careers_created }}</div>
|
||||
<small class="text-muted">{% trans %}Careers Created{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="p-2 border rounded bg-body-tertiary">
|
||||
<div class="fw-bold fs-5 text-success">+{{ stats.classrooms_created }}</div>
|
||||
<small class="text-muted">{% trans %}Classrooms Created{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="p-2 border rounded bg-body-tertiary">
|
||||
<div class="fw-bold fs-5 text-info">+{{ stats.subjects_created }}</div>
|
||||
<small class="text-muted">{% trans %}Subjects Created{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="p-2 border rounded bg-body-tertiary">
|
||||
<div class="fw-bold fs-5 text-warning">+{{ stats.commissions_created }}</div>
|
||||
<small class="text-muted">{% trans %}Commissions Created{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="p-2 border rounded bg-body-tertiary">
|
||||
<div class="fw-bold fs-5 text-danger">+{{ stats.reservations_created }}</div>
|
||||
<small class="text-muted">{% trans %}Reservations Created{% endtrans %}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if stats.errors %}
|
||||
<div class="alert alert-warning mb-0 small">
|
||||
<h6 class="alert-heading fw-bold mb-1">{% trans %}Warnings / Errors detected:{% endtrans %}</h6>
|
||||
<ul class="mb-0 ps-3">
|
||||
{% for err in stats.errors %}
|
||||
<li>{{ err }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Database Preview Table -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-body-tertiary d-flex justify-content-between align-items-center py-3">
|
||||
<h5 class="card-title h6 mb-0">
|
||||
<i class="bi bi-table text-primary me-2"></i>{% trans %}Recently Synchronized Subjects & Comissions{% endtrans %}
|
||||
</h5>
|
||||
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-calendar3 me-1"></i>{% trans %}View Calendar{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="bg-body-tertiary">
|
||||
<tr>
|
||||
<th class="ps-4">{% trans %}Code{% endtrans %}</th>
|
||||
<th>{% trans %}Subject Name{% endtrans %}</th>
|
||||
<th>{% trans %}Career{% endtrans %}</th>
|
||||
<th>{% trans %}Commissions{% endtrans %}</th>
|
||||
<th>{% trans %}Status{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in recent_subjects %}
|
||||
<tr>
|
||||
<td class="ps-4">
|
||||
<span class="badge bg-secondary-subtle text-secondary font-monospace border">
|
||||
{{ s.code }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-semibold">{{ s.name }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-brand small">
|
||||
{{ s.career_obj.name if s.career_obj else (s.department or _('General')) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="small text-muted">
|
||||
{{ s.commissions|length }} {% trans %}commission(s){% endtrans %}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if s.is_active %}
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle small">
|
||||
<i class="bi bi-check-circle me-1"></i>{% trans %}Active{% endtrans %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary small">{% trans %}Inactive{% endtrans %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-cloud-arrow-down fs-1 d-block mb-2 text-secondary"></i>
|
||||
{% trans %}No subjects in database yet. Click "Sync Data Now" to import.{% endtrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function startSync() {
|
||||
const btn = document.getElementById('syncBtn');
|
||||
const spinner = document.getElementById('syncSpinner');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status"></span>Sincronizando...';
|
||||
}
|
||||
if (spinner) {
|
||||
spinner.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,306 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}Gestión de Asignaturas{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 fw-bold mb-1">
|
||||
<i class="bi bi-book text-primary me-2"></i>{% trans %}Gestión de Asignaturas y Materias{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted mb-0 small">
|
||||
{% trans %}Catálogo institucional de asignaturas, códigos, créditos académicos y carreras vinculadas.{% endtrans %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
{% if can_edit %}
|
||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalAddSubject">
|
||||
<i class="bi bi-plus-lg me-1"></i>{% trans %}Nueva Asignatura{% endtrans %}
|
||||
</button>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('schedule.commissions_view') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-diagram-3 me-1"></i>{% trans %}Ver Comisiones{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters Bar -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body py-2">
|
||||
<form method="GET" class="row g-2 align-items-center">
|
||||
<!-- Buscador -->
|
||||
<div class="col-md-4">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-body-tertiary"><i class="bi bi-search"></i></span>
|
||||
<input type="text" name="search" class="form-control"
|
||||
placeholder="{% trans %}Buscar por código, nombre o departamento...{% endtrans %}"
|
||||
value="{{ search }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtro Carrera -->
|
||||
<div class="col-md-4">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-body-tertiary"><i class="bi bi-mortarboard text-primary"></i></span>
|
||||
<select name="career_id" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||
<option value="">{% trans %}Todas las carreras{% endtrans %}</option>
|
||||
{% for c in careers %}
|
||||
<option value="{{ c.id }}" {% if career_filter == c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtro Estado -->
|
||||
<div class="col-md-2">
|
||||
<select name="active" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||
<option value="">{% trans %}Todos los estados{% endtrans %}</option>
|
||||
<option value="true" {% if active_filter == 'true' %}selected{% endif %}>{% trans %}Solo activas{% endtrans %}</option>
|
||||
<option value="false" {% if active_filter == 'false' %}selected{% endif %}>{% trans %}Solo inactivas{% endtrans %}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 text-md-end">
|
||||
<button type="submit" class="btn btn-sm btn-primary me-1">
|
||||
<i class="bi bi-funnel me-1"></i>{% trans %}Filtrar{% endtrans %}
|
||||
</button>
|
||||
<a href="{{ url_for('admin.subjects_list') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-x-circle me-1"></i>{% trans %}Limpiar{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subjects Table -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>{% trans %}Código{% endtrans %}</th>
|
||||
<th>{% trans %}Nombre de la Asignatura{% endtrans %}</th>
|
||||
<th>{% trans %}Carrera{% endtrans %}</th>
|
||||
<th>{% trans %}Departamento{% endtrans %}</th>
|
||||
<th>{% trans %}Créditos{% endtrans %}</th>
|
||||
<th>{% trans %}Comisiones{% endtrans %}</th>
|
||||
<th>{% trans %}Estado{% endtrans %}</th>
|
||||
{% if can_edit %}
|
||||
<th class="text-end">{% trans %}Acciones{% endtrans %}</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in subjects %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="badge font-monospace bg-primary-subtle text-primary border">{{ s.code }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="fw-bold text-body">{{ s.name }}</span>
|
||||
{% if s.description %}
|
||||
<small class="text-muted d-block text-truncate" style="max-width: 300px;">{{ s.description }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if s.career_obj %}
|
||||
<span class="badge bg-secondary-subtle text-secondary border">
|
||||
<i class="bi bi-mortarboard me-1"></i>{{ s.career_obj.name }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted small">General</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="small text-muted">{{ s.department or '-' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark border">{{ s.credits }} cr.</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info-subtle text-info border">
|
||||
<i class="bi bi-diagram-3 me-1"></i>{{ s.commissions|length if s.commissions else 0 }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge rounded-pill {% if s.is_active %}bg-success-subtle text-success border border-success-subtle{% else %}bg-secondary-subtle text-secondary border{% endif %}">
|
||||
{% if s.is_active %}{% trans %}Activa{% endtrans %}{% else %}{% trans %}Inactiva{% endtrans %}{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
{% if can_edit %}
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-outline-secondary py-0 px-2"
|
||||
data-bs-toggle="modal" data-bs-target="#modalEditSubject{{ s.id }}"
|
||||
title="{% trans %}Editar Asignatura{% endtrans %}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<form method="POST" action="{{ url_for('admin.subject_toggle', id=s.id) }}" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-warning py-0 px-2"
|
||||
title="{% if s.is_active %}{% trans %}Desactivar{% endtrans %}{% else %}{% trans %}Activar{% endtrans %}{% endif %}">
|
||||
<i class="bi {% if s.is_active %}bi-pause{% else %}bi-play{% endif %}"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Modal Editar Asignatura -->
|
||||
<div class="modal fade text-start" id="modalEditSubject{{ s.id }}" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="{{ url_for('admin.subject_edit', id=s.id) }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title h6 fw-bold">
|
||||
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Editar Asignatura{% endtrans %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small fw-semibold">{% trans %}Código{% endtrans %} *</label>
|
||||
<input type="text" name="code" class="form-control form-control-sm font-monospace" value="{{ s.code }}" required>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<label class="form-label small fw-semibold">{% trans %}Créditos{% endtrans %}</label>
|
||||
<input type="number" name="credits" class="form-control form-control-sm" value="{{ s.credits }}" min="1" max="20">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Nombre de la Asignatura{% endtrans %} *</label>
|
||||
<input type="text" name="name" class="form-control form-control-sm" value="{{ s.name }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Carrera Vinculada{% endtrans %}</label>
|
||||
<select name="career_id" class="form-select form-select-sm">
|
||||
<option value="">{% trans %}Sin carrera específica (General){% endtrans %}</option>
|
||||
{% for c in careers %}
|
||||
<option value="{{ c.id }}" {% if s.career_id == c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Departamento / Área{% endtrans %}</label>
|
||||
<input type="text" name="department" class="form-control form-control-sm" value="{{ s.department or '' }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Descripción / Objetivos{% endtrans %}</label>
|
||||
<textarea name="description" class="form-control form-control-sm" rows="2">{{ s.description or '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="is_active" id="activeSubj{{ s.id }}" {% if s.is_active %}checked{% endif %}>
|
||||
<label class="form-check-label small" for="activeSubj{{ s.id }}">{% trans %}Asignatura Activa{% endtrans %}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Guardar Cambios{% endtrans %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-book fs-1 d-block mb-2"></i>
|
||||
{% trans %}No se encontraron asignaturas con los criterios indicados.{% endtrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Paginación -->
|
||||
{% if pagination.pages > 1 %}
|
||||
<div class="card-footer bg-body-tertiary border-0 py-2">
|
||||
<nav aria-label="Subjects pagination">
|
||||
<ul class="pagination pagination-sm justify-content-center mb-0">
|
||||
{% if pagination.has_prev %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.subjects_list', page=pagination.prev_num, search=search, career_id=career_filter, active=active_filter) }}">{% trans %}Anterior{% endtrans %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for p in pagination.iter_pages(left_edge=2, right_edge=2, left_current=2, right_current=2) %}
|
||||
{% if p %}
|
||||
<li class="page-item {% if p == pagination.page %}active{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('admin.subjects_list', page=p, search=search, career_id=career_filter, active=active_filter) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if pagination.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.subjects_list', page=pagination.next_num, search=search, career_id=career_filter, active=active_filter) }}">{% trans %}Siguiente{% endtrans %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Nueva Asignatura -->
|
||||
{% if can_edit %}
|
||||
<div class="modal fade" id="modalAddSubject" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="{{ url_for('admin.subject_add') }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title h6 fw-bold">
|
||||
<i class="bi bi-book text-primary me-2"></i>{% trans %}Nueva Asignatura{% endtrans %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small fw-semibold">{% trans %}Código{% endtrans %} *</label>
|
||||
<input type="text" name="code" class="form-control form-control-sm font-monospace" placeholder="ej. ASIG0015" required>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<label class="form-label small fw-semibold">{% trans %}Créditos{% endtrans %}</label>
|
||||
<input type="number" name="credits" class="form-control form-control-sm" value="4" min="1" max="20">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Nombre de la Asignatura{% endtrans %} *</label>
|
||||
<input type="text" name="name" class="form-control form-control-sm" placeholder="ej. Álgebra Lineal" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Carrera Vinculada{% endtrans %}</label>
|
||||
<select name="career_id" class="form-select form-select-sm">
|
||||
<option value="">{% trans %}Sin carrera específica (General){% endtrans %}</option>
|
||||
{% for c in careers %}
|
||||
<option value="{{ c.id }}">{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Departamento / Área{% endtrans %}</label>
|
||||
<input type="text" name="department" class="form-control form-control-sm" placeholder="ej. Ciencias Exactas, Informática">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Descripción{% endtrans %}</label>
|
||||
<textarea name="description" class="form-control form-control-sm" rows="2" placeholder="Programa de la materia..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Crear Asignatura{% endtrans %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,107 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% if user %}{% trans %}Edit User{% endtrans %}: {{ user.name }}{% else %}{% trans %}New User{% endtrans %}{% 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>
|
||||
<h1 class="h3 mb-1">
|
||||
<i class="bi bi-person-gear text-primary me-2"></i>
|
||||
{% if user %}{% trans %}Edit User Account{% endtrans %}{% else %}{% trans %}Create User Account{% endtrans %}{% endif %}
|
||||
</h1>
|
||||
<p class="text-muted small mb-0">{% trans %}Configure user credentials, profile information, and role assignment.{% endtrans %}</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('admin.users_list') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>{% trans %}Back to Users{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-6 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>{% trans %}Account Details{% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label fw-semibold">{% trans %}Full Name{% endtrans %} <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="name" name="name"
|
||||
value="{{ user.name if user else '' }}" required
|
||||
placeholder="{% trans %}e.g. María González{% endtrans %}">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label fw-semibold">{% trans %}Email Address{% endtrans %} <span class="text-danger">*</span></label>
|
||||
<input type="email" class="form-control" id="email" name="email"
|
||||
value="{{ user.email if user else '' }}" required
|
||||
placeholder="{% trans %}e.g. mgonzalez@edu-space.com{% endtrans %}">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label fw-semibold">
|
||||
{% trans %}Password{% endtrans %} {% 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 %}{% trans %}Leave empty to keep current password{% endtrans %}{% else %}{% trans %}At least 6 characters{% endtrans %}{% endif %}">
|
||||
{% if user %}
|
||||
<small class="text-muted d-block mt-1">
|
||||
{% trans %}Only fill this in if you want to reset this user's password.{% endtrans %}
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="role_id" class="form-label fw-semibold">{% trans %}Role / Access Group{% endtrans %} <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 %}>{% trans %}Select a role...{% endtrans %}</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="{{ url_for('admin.roles_list') }}" target="_blank" class="text-decoration-none">
|
||||
<i class="bi bi-box-arrow-up-right me-1"></i>{% trans %}View or manage roles and permissions{% endtrans %}
|
||||
</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 == current_user.id %}disabled{% endif %}>
|
||||
<label class="form-check-label fw-semibold" for="is_active">
|
||||
{% trans %}Active Account{% endtrans %}
|
||||
</label>
|
||||
<small class="text-muted d-block">
|
||||
{% trans %}Inactive users are prevented from signing in to the platform.{% endtrans %}
|
||||
</small>
|
||||
{% if user and user.id == current_user.id %}
|
||||
<input type="hidden" name="is_active" value="on">
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center pt-2">
|
||||
<a href="{{ url_for('admin.users_list') }}" class="btn btn-outline-secondary">
|
||||
{% trans %}Cancel{% endtrans %}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary px-4 fw-semibold">
|
||||
<i class="bi bi-check-lg me-1"></i>{% trans %}Save User{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,247 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}User Management{% endtrans %} - 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>
|
||||
<h1 class="h3 mb-1">
|
||||
<i class="bi bi-people text-primary me-2"></i>{% trans %}User Management{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted small mb-0">{% trans %}Manage user accounts, assign roles, and control active status.{% endtrans %}</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('admin.user_add') }}" class="btn btn-primary">
|
||||
<i class="bi bi-person-plus me-1"></i>{% trans %}New User{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters & Search -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-3">
|
||||
<form method="GET" class="row g-2 align-items-center">
|
||||
<div class="col-md-5">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-body-tertiary border-end-0">
|
||||
<i class="bi bi-search text-muted"></i>
|
||||
</span>
|
||||
<input type="text" name="search" class="form-control border-start-0"
|
||||
placeholder="{% trans %}Search by name or email...{% endtrans %}"
|
||||
value="{{ search }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select name="role" class="form-select form-select-sm">
|
||||
<option value="">{% trans %}All Roles{% endtrans %}</option>
|
||||
{% for r in roles %}
|
||||
<option value="{{ r.id }}" {% if role_filter == (r.id|string) %}selected{% endif %}>{{ r.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="status" class="form-select form-select-sm">
|
||||
<option value="">{% trans %}All Statuses{% endtrans %}</option>
|
||||
<option value="active" {% if status_filter == 'active' %}selected{% endif %}>{% trans %}Active{% endtrans %}</option>
|
||||
<option value="inactive" {% if status_filter == 'inactive' %}selected{% endif %}>{% trans %}Inactive{% endtrans %}</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>{% trans %}Filter{% endtrans %}
|
||||
</button>
|
||||
{% if search or role_filter or status_filter %}
|
||||
<a href="{{ url_for('admin.users_list') }}" class="btn btn-outline-secondary btn-sm" title="{% trans %}Clear filters{% endtrans %}">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="bg-body-tertiary">
|
||||
<tr>
|
||||
<th class="ps-4">{% trans %}User{% endtrans %}</th>
|
||||
<th>{% trans %}Email{% endtrans %}</th>
|
||||
<th>{% trans %}Role / Group{% endtrans %}</th>
|
||||
<th>{% trans %}Status{% endtrans %}</th>
|
||||
<th>{% trans %}Last Login{% endtrans %}</th>
|
||||
<th class="text-end pe-4">{% trans %}Actions{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<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>
|
||||
<div>
|
||||
<div class="fw-bold">{{ u.name }}</div>
|
||||
<small class="text-muted">ID: #{{ u.id }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="font-monospace small">{{ u.email }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if u.role_obj %}
|
||||
<span class="badge badge-brand">
|
||||
<i class="bi bi-shield-check me-1"></i>{{ u.role_obj.name }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">
|
||||
{{ u.role or _('No Role') }}
|
||||
</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>{% trans %}Active{% endtrans %}
|
||||
</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>{% trans %}Inactive{% endtrans %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">
|
||||
{% if u.last_login %}
|
||||
{{ format_datetime(u.last_login, format='short') }}
|
||||
{% else %}
|
||||
{% trans %}Never{% endtrans %}
|
||||
{% endif %}
|
||||
</small>
|
||||
</td>
|
||||
<td class="text-end pe-4">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{{ url_for('admin.user_edit', id=u.id) }}" class="btn btn-outline-primary" title="{% trans %}Edit User{% endtrans %}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
{% if u.id != current_user.id %}
|
||||
<form action="{{ url_for('admin.user_toggle', id=u.id) }}" method="POST" class="d-inline">
|
||||
<button type="submit" class="btn {% if u.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %}"
|
||||
title="{% if u.is_active %}{% trans %}Deactivate User{% endtrans %}{% else %}{% trans %}Activate User{% endtrans %}{% endif %}">
|
||||
<i class="bi {% if u.is_active %}bi-person-x{% else %}bi-person-check{% endif %}"></i>
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" class="btn btn-outline-danger btn-delete-user"
|
||||
data-user-id="{{ u.id }}"
|
||||
data-user-name="{{ u.name }}"
|
||||
data-user-email="{{ u.email }}"
|
||||
data-action-url="{{ url_for('admin.user_delete', id=u.id) }}"
|
||||
title="{% trans %}Eliminar Usuario{% endtrans %}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-people fs-1 d-block mb-2 text-secondary"></i>
|
||||
{% trans %}No users found matching the criteria.{% endtrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</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">
|
||||
{% trans %}Showing page{% endtrans %} {{ pagination.page }} {% trans %}of{% endtrans %} {{ pagination.pages }} ({{ pagination.total }} {% trans %}total{% endtrans %})
|
||||
</small>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
{% if pagination.has_prev %}
|
||||
<li class="page-item"><a class="page-link" href="{{ url_for('admin.users_list', page=pagination.prev_num, search=search, role=role_filter, status=status_filter) }}">«</a></li>
|
||||
{% endif %}
|
||||
{% for p in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||
{% if p %}
|
||||
<li class="page-item {% if p == pagination.page %}active{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('admin.users_list', page=p, search=search, role=role_filter, status=status_filter) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if pagination.has_next %}
|
||||
<li class="page-item"><a class="page-link" href="{{ url_for('admin.users_list', page=pagination.next_num, search=search, role=role_filter, status=status_filter) }}">»</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Global de Eliminación de Usuario (fuera de la tabla) -->
|
||||
<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;">
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="badge bg-danger-subtle text-danger p-2 rounded-circle me-2">
|
||||
<i class="bi bi-person-x-fill fs-5"></i>
|
||||
</div>
|
||||
<h5 class="modal-title fw-bold text-danger mb-0">{% trans %}Confirmar Eliminación de Usuario{% endtrans %}</h5>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body text-start py-3">
|
||||
<p class="mb-2">{% trans %}¿Está seguro de que desea eliminar permanentemente al siguiente usuario?{% endtrans %}</p>
|
||||
<div class="p-2 rounded bg-body-tertiary mb-2">
|
||||
<div class="fw-bold text-primary" id="deleteUserName"></div>
|
||||
<div class="small text-muted font-monospace" id="deleteUserEmail"></div>
|
||||
</div>
|
||||
<p class="text-muted small mb-0"><i class="bi bi-exclamation-triangle me-1 text-warning"></i>{% trans %}Esta acción desvinculará sus comisiones y reservas asociadas. No se puede deshacer.{% endtrans %}</p>
|
||||
</div>
|
||||
<div class="modal-footer border-0 pt-0">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||
<form id="deleteUserForm" method="POST" class="d-inline">
|
||||
<button type="submit" class="btn btn-danger px-3">
|
||||
<i class="bi bi-trash me-1"></i>{% trans %}Eliminar Usuario{% endtrans %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var deleteModalEl = document.getElementById('deleteUserModal');
|
||||
var deleteModal = deleteModalEl ? new bootstrap.Modal(deleteModalEl) : null;
|
||||
var nameEl = document.getElementById('deleteUserName');
|
||||
var emailEl = document.getElementById('deleteUserEmail');
|
||||
var formEl = document.getElementById('deleteUserForm');
|
||||
|
||||
document.querySelectorAll('.btn-delete-user').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var name = this.getAttribute('data-user-name');
|
||||
var email = this.getAttribute('data-user-email');
|
||||
var actionUrl = this.getAttribute('data-action-url');
|
||||
if (nameEl) nameEl.textContent = name;
|
||||
if (emailEl) emailEl.textContent = email;
|
||||
if (formEl) formEl.action = actionUrl;
|
||||
if (deleteModal) deleteModal.show();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,62 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Cambiar Contraseña - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container pt-5 mt-4">
|
||||
<div class="row justify-content-center">
|
||||
<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>
|
||||
<a href="{{ url_for('auth.profile') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-1"></i>Volver al Perfil
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Form Card -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<form method="POST" action="{{ url_for('auth.change_password') }}">
|
||||
<div class="mb-3">
|
||||
<label for="current_password" class="form-label">Contraseña Actual</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><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">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="new_password" class="form-label">Nueva Contraseña</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><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">
|
||||
</div>
|
||||
<div class="form-text">Debe tener al menos 6 caracteres.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="confirm_password" class="form-label">Confirmar Nueva Contraseña</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><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">
|
||||
</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>
|
||||
<a href="{{ url_for('auth.profile') }}" class="btn btn-link text-muted">
|
||||
Cancelar
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,171 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ current_lang }}" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% trans %}Login - Edu-Space Admin{% endtrans %}</title>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="{{ url_for('static', filename='img/favicon.png') }}">
|
||||
|
||||
<!-- Script Anti-Flicker para Modo Claro / Oscuro -->
|
||||
<script>
|
||||
(function() {
|
||||
const storedTheme = localStorage.getItem('theme');
|
||||
const theme = storedTheme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Bootstrap 5 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<!-- Design System Theme -->
|
||||
<link href="{{ url_for('static', filename='css/theme.css') }}" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-wrapper">
|
||||
<div class="auth-card">
|
||||
<!-- Header con Controles Superiores & Logo Oficial -->
|
||||
<div class="auth-header">
|
||||
<div class="auth-controls">
|
||||
<button class="theme-toggle-btn" type="button" aria-label="Cambiar tema" title="Modo Claro/Oscuro">
|
||||
<i class="bi bi-moon-stars-fill"></i>
|
||||
</button>
|
||||
{% include 'partials/language_switcher.html' %}
|
||||
</div>
|
||||
|
||||
<img src="{{ url_for('static', filename='img/logo_unicaba.png') }}" alt="UniCABA" class="auth-logo">
|
||||
<h4 class="fw-bold mb-1">Edu-Space Admin</h4>
|
||||
<p class="text-muted small mb-0">{% trans %}Classroom Management System{% endtrans %}</p>
|
||||
</div>
|
||||
|
||||
<div class="px-4 pb-4 pt-2">
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show rounded-3 py-2 small" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close py-2" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label small fw-semibold text-muted">{{ form.email.label.text }}</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-body-tertiary border-end-0 text-muted">
|
||||
<i class="bi bi-envelope"></i>
|
||||
</span>
|
||||
<input type="email" class="form-control border-start-0 ps-0" id="email" name="email"
|
||||
value="{{ form.email.data or '' }}" required
|
||||
placeholder="{% trans %}Enter your email{% endtrans %}">
|
||||
</div>
|
||||
{% for error in form.email.errors %}
|
||||
<div class="text-danger small mt-1">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label small fw-semibold text-muted">{{ form.password.label.text }}</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-body-tertiary border-end-0 text-muted">
|
||||
<i class="bi bi-lock"></i>
|
||||
</span>
|
||||
<input type="password" class="form-control border-start-0 border-end-0 ps-0" id="password" name="password"
|
||||
required placeholder="{% trans %}Enter your password{% endtrans %}">
|
||||
<button class="btn btn-outline-secondary border-start-0 bg-body-tertiary text-muted" type="button" id="togglePasswordBtn" title="Mostrar/Ocultar contraseña">
|
||||
<i class="bi bi-eye" id="togglePasswordIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% for error in form.password.errors %}
|
||||
<div class="text-danger small mt-1">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
{{ form.remember_me(class="form-check-input") }}
|
||||
<label class="form-check-label text-muted small" for="remember_me">
|
||||
{{ form.remember_me.label.text }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="d-grid mb-3">
|
||||
<button type="submit" class="btn btn-primary btn-lg fw-semibold py-2">
|
||||
<i class="bi bi-box-arrow-in-right me-1"></i> {{ form.submit.label.text }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- 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">
|
||||
<small class="fw-bold text-muted text-uppercase" style="font-size: 0.7rem; letter-spacing: 0.5px;">
|
||||
<i class="bi bi-lightning-charge-fill text-warning me-1"></i>{% trans %}Acceso Rápido Demo (1 Clic){% endtrans %}
|
||||
</small>
|
||||
<small class="text-muted font-monospace" style="font-size: 0.68rem;">Pass: admin123</small>
|
||||
</div>
|
||||
<div class="d-grid gap-1">
|
||||
<div class="btn-group btn-group-sm w-100" role="group">
|
||||
<button type="button" class="btn btn-outline-primary demo-fill-btn py-1" data-email="admin@edu-space.com" title="Ingresar como Administrador">
|
||||
<i class="bi bi-shield-check me-1"></i>Admin
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-warning demo-fill-btn py-1" data-email="bedelia@edu-space.com" title="Ingresar como Bedelía">
|
||||
<i class="bi bi-building-gear me-1"></i>Bedelía
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-info demo-fill-btn py-1" data-email="docente@edu-space.com" title="Ingresar como Docente">
|
||||
<i class="bi bi-person-video3 me-1"></i>Docente
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-success demo-fill-btn py-1" data-email="alumno@edu-space.com" title="Ingresar como Alumno">
|
||||
<i class="bi bi-mortarboard me-1"></i>Alumno
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<small class="text-muted d-inline-flex align-items-center" style="font-size: 0.75rem;">
|
||||
<i class="bi bi-shield-check text-primary me-1"></i>
|
||||
{% trans %}Acceso institucional seguro • Actividad auditada{% endtrans %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='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');
|
||||
}
|
||||
});
|
||||
|
||||
// Demo login autofill buttons
|
||||
document.querySelectorAll('.demo-fill-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const email = this.getAttribute('data-email');
|
||||
document.getElementById('email').value = email;
|
||||
document.getElementById('password').value = 'admin123';
|
||||
// Highlight input briefly
|
||||
const emailInput = document.getElementById('email');
|
||||
emailInput.classList.add('is-valid');
|
||||
setTimeout(() => emailInput.classList.remove('is-valid'), 1500);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,310 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ current_lang }}" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{% trans %}Edu-Space Admin{% endtrans %}{% endblock %}</title>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="{{ url_for('static', filename='img/favicon.png') }}">
|
||||
|
||||
<!-- Script Anti-Flicker para Modo Claro / Oscuro -->
|
||||
<script>
|
||||
(function() {
|
||||
let userTheme = "{{ current_user.theme_preference if (current_user and current_user.is_authenticated) else '' }}";
|
||||
let theme = userTheme;
|
||||
if (!theme || theme === 'auto') {
|
||||
const storedTheme = localStorage.getItem('theme');
|
||||
theme = storedTheme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
}
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Bootstrap 5 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<!-- Design System & Custom Theme -->
|
||||
<link href="{{ url_for('static', filename='css/theme.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body data-user-authenticated="{{ 'true' if (current_user and current_user.is_authenticated) else 'false' }}">
|
||||
{% if current_user.is_authenticated %}
|
||||
<!-- Navigation -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark navbar-app fixed-top">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand d-flex align-items-center" href="{{ url_for('main.dashboard') }}">
|
||||
<img src="{{ url_for('static', filename='img/logo_unicaba_mini.png') }}" alt="Logo UniCABA" class="navbar-logo me-2">
|
||||
<span>Edu-Space Admin</span>
|
||||
</a>
|
||||
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
{% set user_role = current_user.get_institutional_role() %}
|
||||
<ul class="navbar-nav me-auto">
|
||||
{% if user_role == 'admin' %}
|
||||
<!-- ================= ADMIN NAVIGATION ================= -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
|
||||
<i class="bi bi-speedometer2 me-1"></i>{% trans %}Panel General{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-door-open me-1"></i>{% trans %}Espacios & Sedes{% endtrans %}
|
||||
</a>
|
||||
<ul class="dropdown-menu shadow-sm">
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}"><i class="bi bi-door-open me-2 text-primary"></i>{% trans %}Aulas Presenciales{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}"><i class="bi bi-camera-video me-2 text-info"></i>{% trans %}Aulas Virtuales (Reunión){% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms') }}"><i class="bi bi-grid me-2 text-secondary"></i>{% trans %}Todas las Aulas{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.add_classroom') }}"><i class="bi bi-plus-circle me-2 text-success"></i>{% trans %}Nueva Aula{% endtrans %}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2 text-secondary"></i>{% trans %}Edificios y Sedes{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.occupancy_metrics') }}"><i class="bi bi-graph-up-arrow me-2 text-success"></i>{% trans %}Métricas de Ocupación{% endtrans %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-calendar3 me-1"></i>{% trans %}Cronograma & Reservas{% endtrans %}
|
||||
</a>
|
||||
<ul class="dropdown-menu shadow-sm">
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.calendar_view') }}"><i class="bi bi-calendar-week me-2 text-primary"></i>{% trans %}Calendario General{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}"><i class="bi bi-calendar-day me-2 text-info"></i>{% trans %}Cartelera del Día{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}"><i class="bi bi-list-check me-2 text-warning"></i>{% trans %}Gestión de Reservas{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}"><i class="bi bi-plus-circle me-2 text-success"></i>{% trans %}Nueva Reserva{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.commissions_view') }}"><i class="bi bi-diagram-3 me-2 text-primary"></i>{% trans %}Comisiones y Cursadas{% endtrans %}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('genetic_algorithm_web.genetic_optimizer') }}"><i class="bi bi-cpu me-2 text-info"></i>{% trans %}Optimizador de Aulas (IA){% endtrans %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-mortarboard me-1"></i>{% trans %}Gestión Académica{% endtrans %}
|
||||
</a>
|
||||
<ul class="dropdown-menu shadow-sm">
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.careers_list') }}"><i class="bi bi-mortarboard me-2 text-primary"></i>{% trans %}Carreras{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.subjects_list') }}"><i class="bi bi-book me-2 text-info"></i>{% trans %}Asignaturas{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.academic_terms_list') }}"><i class="bi bi-calendar-range me-2 text-warning"></i>{% trans %}Ciclo Lectivo{% endtrans %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-shield-lock me-1"></i>{% trans %}Administración{% endtrans %}
|
||||
</a>
|
||||
<ul class="dropdown-menu shadow-sm">
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.users_list') }}"><i class="bi bi-people me-2 text-primary"></i>{% trans %}Usuarios{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.roles_list') }}"><i class="bi bi-person-badge me-2 text-info"></i>{% trans %}Roles y Permisos RBAC{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.milestone_types_list') }}"><i class="bi bi-tags me-2 text-secondary"></i>{% trans %}Tipificaciones de Hitos{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.google_sheets_import') }}"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>{% trans %}Sincronización Sheets{% endtrans %}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.audit_logs') }}"><i class="bi bi-shield-check me-2 text-warning"></i>{% trans %}Registro de Auditoría{% endtrans %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
{% elif user_role == 'bedelia' %}
|
||||
<!-- ================= BEDELÍA NAVIGATION ================= -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
|
||||
<i class="bi bi-speedometer2 me-1"></i>{% trans %}Panel Bedelía{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-door-open me-1"></i>{% trans %}Espacios & Aulas{% endtrans %}
|
||||
</a>
|
||||
<ul class="dropdown-menu shadow-sm">
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}"><i class="bi bi-door-open me-2 text-primary"></i>{% trans %}Aulas Presenciales{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}"><i class="bi bi-camera-video me-2 text-info"></i>{% trans %}Aulas Virtuales (Reunión){% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms') }}"><i class="bi bi-grid me-2 text-secondary"></i>{% trans %}Todas las Aulas{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.add_classroom') }}"><i class="bi bi-plus-circle me-2 text-success"></i>{% trans %}Nueva Aula{% endtrans %}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2 text-secondary"></i>{% trans %}Edificios y Sedes{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.occupancy_metrics') }}"><i class="bi bi-graph-up-arrow me-2 text-success"></i>{% trans %}Métricas de Ocupación{% endtrans %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-calendar3 me-1"></i>{% trans %}Operaciones & Reservas{% endtrans %}
|
||||
</a>
|
||||
<ul class="dropdown-menu shadow-sm">
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}"><i class="bi bi-calendar-day me-2 text-info"></i>{% trans %}Cartelera del Día{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.calendar_view') }}"><i class="bi bi-calendar-week me-2 text-primary"></i>{% trans %}Calendario General{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}"><i class="bi bi-list-check me-2 text-warning"></i>{% trans %}Gestión de Reservas{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}"><i class="bi bi-plus-circle me-2 text-success"></i>{% trans %}Registrar Reserva{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.commissions_view') }}"><i class="bi bi-diagram-3 me-2 text-primary"></i>{% trans %}Comisiones y Cursadas{% endtrans %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-mortarboard me-1"></i>{% trans %}Académica & Cursadas{% endtrans %}
|
||||
</a>
|
||||
<ul class="dropdown-menu shadow-sm">
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.careers_list') }}"><i class="bi bi-mortarboard me-2 text-primary"></i>{% trans %}Carreras{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.subjects_list') }}"><i class="bi bi-book me-2 text-info"></i>{% trans %}Asignaturas{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.academic_terms_list') }}"><i class="bi bi-calendar-range me-2 text-warning"></i>{% trans %}Ciclo Lectivo{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.milestone_types_list') }}"><i class="bi bi-tags me-2 text-secondary"></i>{% trans %}Tipificaciones de Hitos{% endtrans %}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.google_sheets_import') }}"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>{% trans %}Sincronización Sheets{% endtrans %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
{% elif user_role == 'docente' %}
|
||||
<!-- ================= DOCENTE / PROFESOR NAVIGATION ================= -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
|
||||
<i class="bi bi-speedometer2 me-1"></i>{% trans %}Panel Docente{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-calendar-check me-1"></i>{% trans %}Mis Clases & Reservas{% endtrans %}
|
||||
</a>
|
||||
<ul class="dropdown-menu shadow-sm">
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}"><i class="bi bi-calendar2-check me-2 text-primary"></i>{% trans %}Mis Reservas de Aulas{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}"><i class="bi bi-plus-circle me-2 text-success"></i>{% trans %}Solicitar Nueva Reserva{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.commissions_view') }}"><i class="bi bi-diagram-3 me-2 text-info"></i>{% trans %}Mis Comisiones & Cursadas{% endtrans %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-building me-1"></i>{% trans %}Campus & Horarios{% endtrans %}
|
||||
</a>
|
||||
<ul class="dropdown-menu shadow-sm">
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}"><i class="bi bi-calendar-day me-2 text-info"></i>{% trans %}Cartelera del Día (¿Dónde curso hoy?){% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.calendar_view') }}"><i class="bi bi-calendar-week me-2 text-primary"></i>{% trans %}Calendario General de Aulas{% endtrans %}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}"><i class="bi bi-camera-video me-2 text-info"></i>{% trans %}Aulas Virtuales (Reuniones){% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}"><i class="bi bi-door-open me-2 text-secondary"></i>{% trans %}Consulta de Aulas Presenciales{% endtrans %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
{% else %}
|
||||
<!-- ================= ALUMNO / ESTUDIANTE NAVIGATION ================= -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
|
||||
<i class="bi bi-house-door me-1"></i>{% trans %}Mi Portal{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('schedule.today_schedule') }}">
|
||||
<i class="bi bi-geo-alt me-1"></i>{% trans %}Cartelera de Hoy{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('schedule.calendar_view') }}">
|
||||
<i class="bi bi-calendar-week me-1"></i>{% trans %}Cronograma de Clases{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}">
|
||||
<i class="bi bi-camera-video me-1"></i>{% trans %}Aulas Virtuales{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}">
|
||||
<i class="bi bi-door-open me-1"></i>{% trans %}Espacios del Campus{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<ul class="navbar-nav align-items-center">
|
||||
<!-- Theme Mode Toggle Button -->
|
||||
<li class="nav-item me-2">
|
||||
<button class="theme-toggle-btn" type="button" aria-label="Cambiar tema" title="Modo Claro/Oscuro">
|
||||
<i class="bi bi-moon-stars-fill"></i>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<!-- Language Switcher -->
|
||||
<li class="nav-item me-2">
|
||||
{% include 'partials/language_switcher.html' %}
|
||||
</li>
|
||||
|
||||
<!-- User Dropdown with Avatar and Institutional Role -->
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle d-flex align-items-center py-1" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<span class="user-avatar-small">{{ current_user.name[:1]|upper if current_user.name else 'U' }}</span>
|
||||
<div class="d-none d-lg-block text-start ms-2">
|
||||
<div class="lh-1 small fw-semibold">{{ current_user.first_name }}</div>
|
||||
<span class="badge {{ current_user.role_badge_display.class }} role-pill mt-1" style="font-size: 0.65rem; padding: 0.15rem 0.45rem;">
|
||||
<i class="bi {{ current_user.role_badge_display.icon }}"></i> {{ current_user.role_badge_display.label }}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm border-0">
|
||||
<li>
|
||||
<div class="px-3 py-2 border-bottom">
|
||||
<small class="text-muted d-block">{% trans %}Signed in as{% endtrans %}</small>
|
||||
<span class="fw-bold small d-block">{{ current_user.email }}</span>
|
||||
<span class="badge {{ current_user.role_badge_display.class }} role-pill mt-1">
|
||||
<i class="bi {{ current_user.role_badge_display.icon }}"></i> {{ current_user.role_badge_display.label }}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">
|
||||
<i class="bi bi-person me-2"></i> {% trans %}Profile{% endtrans %}
|
||||
</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('auth.change_password') }}">
|
||||
<i class="bi bi-key me-2"></i> {% trans %}Change Password{% endtrans %}
|
||||
</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item text-danger" href="{{ url_for('auth.logout') }}">
|
||||
<i class="bi bi-box-arrow-right me-2"></i> {% trans %}Logout{% endtrans %}
|
||||
</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<!-- Flash Messages -->
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3" style="z-index: 1080;">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="toast align-items-center text-white bg-{{ 'danger' if category == 'error' else 'success' if category == 'success' else 'info' if category == 'info' else 'warning' }} border-0 shadow" role="alert">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
{{ message }}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<!-- jQuery -->
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<!-- Theme Toggle JS -->
|
||||
<script src="{{ url_for('static', filename='js/theme-toggle.js') }}"></script>
|
||||
<!-- Custom JS -->
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
<!-- View Switcher JS -->
|
||||
<script src="{{ url_for('static', filename='js/view_switcher.js') }}"></script>
|
||||
|
||||
<!-- Show flash messages -->
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$('.toast').toast('show');
|
||||
});
|
||||
</script>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,103 @@
|
||||
from flask import Flask, request, session
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from flask_cors import CORS
|
||||
from flask_babel import Babel, gettext as _
|
||||
from config.config import Config
|
||||
|
||||
# Initialize extensions
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
cors = CORS()
|
||||
babel = Babel()
|
||||
|
||||
def create_app(config_class=Config):
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(config_class)
|
||||
|
||||
# Initialize extensions with app
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
cors.init_app(app)
|
||||
|
||||
# Import models to ensure they are registered
|
||||
from app.models import user
|
||||
# Register REST API (v1) Blueprints with Stateless JWT support
|
||||
from app.routes.api.auth import api_auth_bp
|
||||
from app.routes.api.classrooms import api_classrooms_bp
|
||||
from app.routes.api.reservations import api_reservations_bp
|
||||
from app.routes.api.optimizer import api_optimizer_bp
|
||||
from app.routes.api.dashboard import api_dashboard_bp
|
||||
from app.routes.api.admin import api_admin_bp
|
||||
from app.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
|
||||
app.register_blueprint(api_auth_bp)
|
||||
app.register_blueprint(api_classrooms_bp)
|
||||
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')
|
||||
|
||||
# Initialize Enterprise Security Pipeline (SecurityFilterChain)
|
||||
SecurityFilterChain(app)
|
||||
|
||||
# Language selector function
|
||||
def get_locale():
|
||||
# 1. Si el usuario está autenticado y tiene idioma configurado explícitamente en su perfil, prevalece.
|
||||
from flask_login import current_user
|
||||
if current_user.is_authenticated:
|
||||
if getattr(current_user, 'preferred_language', None) and current_user.preferred_language in app.config['LANGUAGES']:
|
||||
return current_user.preferred_language
|
||||
# El idioma por defecto siempre será español a menos que se configure a mano
|
||||
return app.config['BABEL_DEFAULT_LOCALE']
|
||||
|
||||
# 2. Parámetro explícito en la URL (?lang=es o ?lang=en) (para usuarios no autenticados o login)
|
||||
lang = request.args.get('lang')
|
||||
if lang and lang in app.config['LANGUAGES']:
|
||||
session['language'] = lang
|
||||
return lang
|
||||
|
||||
# 3. Si ya hay un idioma guardado en la sesión
|
||||
if 'language' in session and session['language'] in app.config['LANGUAGES']:
|
||||
return session['language']
|
||||
|
||||
# 4. Primera vez que se accede a la web (sin sesión): respetar el idioma del navegador
|
||||
if request.accept_languages:
|
||||
for accepted_lang, _quality in request.accept_languages:
|
||||
primary = accepted_lang.split('-')[0].lower()
|
||||
if primary in app.config['LANGUAGES']:
|
||||
session['language'] = primary # Se guarda para la sesión actual
|
||||
return primary
|
||||
|
||||
# Fallback del sistema: Español
|
||||
return app.config['BABEL_DEFAULT_LOCALE']
|
||||
|
||||
|
||||
# Babel context processor
|
||||
@app.context_processor
|
||||
def inject_conf_vars():
|
||||
return dict(
|
||||
languages=app.config['LANGUAGES'],
|
||||
current_lang=str(get_locale())
|
||||
)
|
||||
|
||||
|
||||
# Configure Babel
|
||||
babel.init_app(app, locale_selector=get_locale)
|
||||
|
||||
# Register localized date and datetime formatters for Jinja2 templates
|
||||
from flask_babel import format_date, format_datetime
|
||||
app.jinja_env.globals['format_date'] = format_date
|
||||
app.jinja_env.globals['format_datetime'] = format_datetime
|
||||
app.jinja_env.filters['format_date'] = format_date
|
||||
app.jinja_env.filters['format_datetime'] = format_datetime
|
||||
|
||||
return app
|
||||
@@ -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,12 +3,16 @@ 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
|
||||
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',
|
||||
@@ -19,6 +23,7 @@ __all__ = [
|
||||
'AcademicTerm',
|
||||
'Subject',
|
||||
'Commission',
|
||||
'CommissionTeacher',
|
||||
'Reservation',
|
||||
'ReservationStatus',
|
||||
'GeneticAlgorithm',
|
||||
@@ -29,5 +34,9 @@ __all__ = [
|
||||
'MilestoneType',
|
||||
'AcademicMilestone',
|
||||
'BASE_MILESTONE_TYPES',
|
||||
'AuditLog'
|
||||
'AuditLog',
|
||||
'StudentEnrollment',
|
||||
'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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class StudentEnrollment(db.Model):
|
||||
"""Association table: student (User) ↔ Commission enrollment managed by Bedelía."""
|
||||
__tablename__ = 'student_enrollments'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
student_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
commission_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('commissions.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
status = db.Column(db.String(20), nullable=False, default='activo') # activo | retirado | condicional
|
||||
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__ = (
|
||||
db.UniqueConstraint('student_id', 'commission_id', name='uq_student_commission'),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
student = db.relationship('User', backref=db.backref('enrollments', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
commission = db.relationship('Commission', backref=db.backref('enrollments', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.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,
|
||||
'commission_id': self.commission_id,
|
||||
'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):
|
||||
return f'<StudentEnrollment student={self.student_id} commission={self.commission_id} status={self.status}>'
|
||||
@@ -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}>'
|
||||
@@ -95,8 +95,8 @@ class Reservation(db.Model):
|
||||
@property
|
||||
def is_virtual(self):
|
||||
"""Verifica si la reserva es virtual por aula o por contar con enlace virtual"""
|
||||
if self.classroom and self.classroom.is_virtual:
|
||||
return True
|
||||
if self.classroom:
|
||||
return bool(self.classroom.is_virtual)
|
||||
if self.effective_virtual_link:
|
||||
return True
|
||||
return False
|
||||
@@ -72,6 +72,13 @@ SYSTEM_MODULES = [
|
||||
'name_en': 'Occupancy Metrics',
|
||||
'icon': 'bi-graph-up',
|
||||
'description': 'Visualización y análisis de porcentajes de ocupación por aula (día, semana y mes).'
|
||||
},
|
||||
{
|
||||
'id': 'enrollment',
|
||||
'name': 'Inscripciones y Cursadas',
|
||||
'name_en': 'Enrollments & Courses',
|
||||
'icon': 'bi-diagram-3',
|
||||
'description': 'Gestión de inscripciones de alumnos a comisiones y asignación de docentes.'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
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, Moodle),
|
||||
flags globales, credenciales SMTP encriptadas y secretos OAuth.
|
||||
"""
|
||||
__tablename__ = 'system_settings'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
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() != '':
|
||||
return record.value
|
||||
except Exception:
|
||||
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, 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=final_value,
|
||||
description=description,
|
||||
category=category,
|
||||
is_encrypted=is_encrypted
|
||||
)
|
||||
db.session.add(record)
|
||||
else:
|
||||
# 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
|
||||
|
||||
@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': 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}>'
|
||||
@@ -1,117 +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,
|
||||
'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}]>'
|
||||
@@ -1,9 +1,8 @@
|
||||
from app import db, login_manager
|
||||
from flask_login import UserMixin
|
||||
from app import db
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from datetime import datetime
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
class User(db.Model):
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
@@ -18,15 +17,29 @@ class User(UserMixin, 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')
|
||||
@@ -133,8 +146,18 @@ class User(UserMixin, 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,
|
||||
@@ -143,9 +166,4 @@ class User(UserMixin, db.Model):
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<User {self.email}>'
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
"""Load user from database by ID"""
|
||||
return User.query.get(int(user_id))
|
||||
return f'<User {self.email}>'
|
||||
@@ -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."""
|
||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
@@ -5,5 +5,6 @@ from .buildings import buildings_bp
|
||||
from .schedule import schedule_bp
|
||||
from .genetic_algorithm import genetic_bp, genetic_web_bp
|
||||
from .admin import admin_bp
|
||||
from .my_subjects import my_subjects_bp
|
||||
|
||||
__all__ = ['main_bp', 'auth_bp', 'classrooms_bp', 'buildings_bp', 'schedule_bp', 'genetic_bp', 'genetic_web_bp', 'admin_bp']
|
||||
__all__ = ['main_bp', 'auth_bp', 'classrooms_bp', 'buildings_bp', 'schedule_bp', 'genetic_bp', 'genetic_web_bp', 'admin_bp', 'my_subjects_bp']
|
||||
@@ -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()
|
||||
@@ -1165,3 +1172,451 @@ def audit_logs():
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 5. GESTION DE COMISIONES (BEDELIA / ADMIN)
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@admin_bp.route('/commissions')
|
||||
@login_required
|
||||
def commissions_list():
|
||||
"""List all commissions with filters - accessible by Bedelia and Admin"""
|
||||
if not (current_user.has_permission('enrollment', 'read_write') or current_user.is_admin() or current_user.is_bedelia()):
|
||||
abort(403)
|
||||
|
||||
from app.models.subject import Subject, Commission
|
||||
from app.models.user import User
|
||||
|
||||
search = request.args.get('search', '').strip()
|
||||
subject_filter = request.args.get('subject_id', type=int)
|
||||
teacher_filter = request.args.get('teacher_id', type=int)
|
||||
shift_filter = request.args.get('shift', '').strip()
|
||||
active_filter = request.args.get('active', 'true').strip()
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 20
|
||||
|
||||
query = Commission.query.join(Subject, Commission.subject_id == Subject.id)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Commission.code.ilike(f'%{search}%'),
|
||||
Subject.name.ilike(f'%{search}%'),
|
||||
Subject.code.ilike(f'%{search}%'),
|
||||
)
|
||||
)
|
||||
if subject_filter:
|
||||
query = query.filter(Commission.subject_id == subject_filter)
|
||||
if teacher_filter:
|
||||
query = query.filter(Commission.teacher_id == teacher_filter)
|
||||
if shift_filter:
|
||||
query = query.filter(Commission.shift.ilike(f'%{shift_filter}%'))
|
||||
if active_filter in ['true', 'false']:
|
||||
query = query.filter(Commission.active == (active_filter == 'true'))
|
||||
|
||||
pagination = query.order_by(Subject.name.asc(), Commission.code.asc()).paginate(
|
||||
page=page, per_page=per_page, error_out=False
|
||||
)
|
||||
|
||||
subjects = Subject.query.filter_by(is_active=True).order_by(Subject.name.asc()).all()
|
||||
teachers = User.query.filter(
|
||||
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
|
||||
current_user.is_bedelia()
|
||||
)
|
||||
|
||||
return render_template(
|
||||
'admin/commissions/list.html',
|
||||
commissions=pagination.items,
|
||||
pagination=pagination,
|
||||
subjects=subjects,
|
||||
teachers=teachers,
|
||||
academic_terms=academic_terms,
|
||||
search=search,
|
||||
subject_filter=subject_filter,
|
||||
teacher_filter=teacher_filter,
|
||||
shift_filter=shift_filter,
|
||||
active_filter=active_filter,
|
||||
can_edit=can_edit
|
||||
)
|
||||
|
||||
|
||||
@admin_bp.route('/commissions/<int:id>')
|
||||
@login_required
|
||||
def commission_detail(id):
|
||||
"""Detail view of a commission: enrolled students and teacher assignment"""
|
||||
if not (current_user.has_permission('enrollment', 'read_write') or current_user.is_admin() or current_user.is_bedelia()):
|
||||
abort(403)
|
||||
|
||||
from app.models.subject import Commission
|
||||
from app.models.enrollment import StudentEnrollment
|
||||
from app.models.user import User
|
||||
|
||||
commission = Commission.query.get_or_404(id)
|
||||
enrollments = (
|
||||
StudentEnrollment.query
|
||||
.filter_by(commission_id=id)
|
||||
.order_by(StudentEnrollment.enrolled_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
teachers = User.query.filter(
|
||||
or_(User.role.ilike('%DOCENTE%'), User.role.ilike('%docent%'))
|
||||
).filter_by(is_active=True).order_by(User.name.asc()).all()
|
||||
|
||||
students = User.query.filter(
|
||||
or_(User.role.ilike('%ALUMNO%'), User.role.ilike('%alumn%'), User.role.ilike('%estud%'))
|
||||
).filter_by(is_active=True).order_by(User.name.asc()).all()
|
||||
|
||||
enrolled_ids = {e.student_id for e in enrollments}
|
||||
can_edit = (
|
||||
current_user.has_permission('academic', 'read_write') or
|
||||
current_user.is_admin() or
|
||||
current_user.is_bedelia()
|
||||
)
|
||||
|
||||
return render_template(
|
||||
'admin/commissions/detail.html',
|
||||
commission=commission,
|
||||
enrollments=enrollments,
|
||||
teachers=teachers,
|
||||
students=students,
|
||||
enrolled_ids=enrolled_ids,
|
||||
can_edit=can_edit
|
||||
)
|
||||
|
||||
|
||||
@admin_bp.route('/commissions/<int:id>/assign-teacher', methods=['POST'])
|
||||
@login_required
|
||||
def commission_assign_teacher(id):
|
||||
"""Assign or change the teacher of a commission"""
|
||||
if not (
|
||||
current_user.has_permission('academic', 'read_write') or
|
||||
current_user.is_admin() or
|
||||
current_user.is_bedelia()
|
||||
):
|
||||
abort(403)
|
||||
|
||||
from app.models.subject import Commission
|
||||
from app.models.user import User
|
||||
|
||||
commission = Commission.query.get_or_404(id)
|
||||
teacher_id = request.form.get('teacher_id', type=int)
|
||||
|
||||
if teacher_id:
|
||||
teacher = User.query.get(teacher_id)
|
||||
if not teacher or not teacher.is_active:
|
||||
flash(_('El docente seleccionado no existe o esta inactivo.'), 'error')
|
||||
return redirect(url_for('admin.commission_detail', id=id))
|
||||
commission.teacher_id = teacher_id
|
||||
db.session.commit()
|
||||
flash(_('Docente asignado exitosamente a la comision.'), 'success')
|
||||
else:
|
||||
commission.teacher_id = None
|
||||
db.session.commit()
|
||||
flash(_('Se removio la asignacion de docente.'), 'warning')
|
||||
|
||||
return redirect(url_for('admin.commission_detail', id=id))
|
||||
|
||||
|
||||
@admin_bp.route('/commissions/<int:id>/enroll-student', methods=['POST'])
|
||||
@login_required
|
||||
def commission_enroll_student(id):
|
||||
"""Enroll a student into a commission"""
|
||||
if not (
|
||||
current_user.has_permission('academic', 'read_write') or
|
||||
current_user.is_admin() or
|
||||
current_user.is_bedelia()
|
||||
):
|
||||
abort(403)
|
||||
|
||||
from app.models.subject import Commission
|
||||
from app.models.enrollment import StudentEnrollment
|
||||
from app.models.user import User
|
||||
|
||||
commission = Commission.query.get_or_404(id)
|
||||
student_id = request.form.get('student_id', type=int)
|
||||
notes = request.form.get('notes', '').strip()
|
||||
|
||||
if not student_id:
|
||||
flash(_('Debes seleccionar un alumno.'), 'error')
|
||||
return redirect(url_for('admin.commission_detail', id=id))
|
||||
|
||||
student = User.query.get(student_id)
|
||||
if not student or not student.is_active:
|
||||
flash(_('El alumno seleccionado no existe o esta inactivo.'), 'error')
|
||||
return redirect(url_for('admin.commission_detail', id=id))
|
||||
|
||||
existing = StudentEnrollment.query.filter_by(student_id=student_id, commission_id=id).first()
|
||||
if existing:
|
||||
flash(_('El alumno ya esta inscripto en esta comision.'), 'warning')
|
||||
return redirect(url_for('admin.commission_detail', id=id))
|
||||
|
||||
enrollment = StudentEnrollment(
|
||||
student_id=student_id,
|
||||
commission_id=id,
|
||||
status='activo',
|
||||
notes=notes or None
|
||||
)
|
||||
db.session.add(enrollment)
|
||||
commission.current_students = (commission.current_students or 0) + 1
|
||||
db.session.commit()
|
||||
|
||||
flash(_('Alumno inscripto exitosamente en la comision.'), 'success')
|
||||
return redirect(url_for('admin.commission_detail', id=id))
|
||||
|
||||
|
||||
@admin_bp.route('/commissions/<int:id>/unenroll-student/<int:student_id>', methods=['POST'])
|
||||
@login_required
|
||||
def commission_unenroll_student(id, student_id):
|
||||
"""Remove a student from a commission"""
|
||||
if not (
|
||||
current_user.has_permission('academic', 'read_write') or
|
||||
current_user.is_admin() or
|
||||
current_user.is_bedelia()
|
||||
):
|
||||
abort(403)
|
||||
|
||||
from app.models.subject import Commission
|
||||
from app.models.enrollment import StudentEnrollment
|
||||
|
||||
commission = Commission.query.get_or_404(id)
|
||||
enrollment = StudentEnrollment.query.filter_by(
|
||||
student_id=student_id, commission_id=id
|
||||
).first_or_404()
|
||||
|
||||
db.session.delete(enrollment)
|
||||
if commission.current_students and commission.current_students > 0:
|
||||
commission.current_students -= 1
|
||||
db.session.commit()
|
||||
|
||||
flash(_('Alumno dado de baja de la comision.'), 'success')
|
||||
return redirect(url_for('admin.commission_detail', id=id))
|
||||
|
||||
|
||||
@admin_bp.route('/commissions/<int:id>/update-enrollment/<int:student_id>', methods=['POST'])
|
||||
@login_required
|
||||
def commission_update_enrollment(id, student_id):
|
||||
"""Update enrollment status for a student"""
|
||||
if not (
|
||||
current_user.has_permission('academic', 'read_write') or
|
||||
current_user.is_admin() or
|
||||
current_user.is_bedelia()
|
||||
):
|
||||
abort(403)
|
||||
|
||||
from app.models.enrollment import StudentEnrollment
|
||||
|
||||
enrollment = StudentEnrollment.query.filter_by(
|
||||
student_id=student_id, commission_id=id
|
||||
).first_or_404()
|
||||
|
||||
new_status = request.form.get('status', 'activo')
|
||||
if new_status not in ['activo', 'retirado', 'condicional']:
|
||||
new_status = 'activo'
|
||||
|
||||
enrollment.status = new_status
|
||||
enrollment.notes = request.form.get('notes', enrollment.notes or '')
|
||||
db.session.commit()
|
||||
|
||||
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'))
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
"""
|
||||
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():
|
||||
"""
|
||||
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)
|
||||
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({
|
||||
'error': 'Unauthorized',
|
||||
'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)
|
||||
|
||||
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('/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():
|
||||
"""
|
||||
Silent Access Token renewal using Refresh Token.
|
||||
"""
|
||||
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:
|
||||
return jsonify({
|
||||
'error': 'BadRequest',
|
||||
'message': 'Refresh token no suministrado en el cuerpo ni en cookies.'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
payload = JWTService.decode_token(refresh_token, expected_type='refresh')
|
||||
user_id = int(payload.get('sub'))
|
||||
user = user_service.get_by_id(user_id)
|
||||
|
||||
if not user or not user.is_active:
|
||||
return jsonify({
|
||||
'error': 'Unauthorized',
|
||||
'message': 'Usuario no encontrado o inactivo.'
|
||||
}), 401
|
||||
|
||||
new_access_token = JWTService.create_access_token(user)
|
||||
return jsonify({
|
||||
'access_token': new_access_token,
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': int(JWTService.ACCESS_TOKEN_EXPIRES.total_seconds())
|
||||
}), 200
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'error': 'TokenExpired', 'message': 'El refresh token ha expirado. Reingrese credenciales.'}), 401
|
||||
except jwt.InvalidTokenError as e:
|
||||
return jsonify({'error': 'InvalidToken', 'message': str(e)}), 401
|
||||
|
||||
|
||||
@api_auth_bp.route('/logout', methods=['POST'])
|
||||
@jwt_required
|
||||
def logout():
|
||||
"""
|
||||
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():
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,342 @@
|
||||
from flask import Blueprint, jsonify, request
|
||||
from app.utils.jwt_decorators import jwt_required
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.subject import Subject
|
||||
from app.models.user import User
|
||||
from app.models.reservation import Reservation
|
||||
from app.models.audit_log import AuditLog
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import func
|
||||
from app import db
|
||||
|
||||
from app.models.milestone import AcademicMilestone
|
||||
from app.models.subject import Commission
|
||||
from app.models.setting import SystemSetting
|
||||
|
||||
api_dashboard_bp = Blueprint('api_dashboard', __name__, url_prefix='/api/v1/dashboard')
|
||||
|
||||
def serialize_reservation(r):
|
||||
subject_name = (r.commission.subject.name if (r.commission and r.commission.subject) else r.purpose) or 'Actividad Académica'
|
||||
subject_code = (r.commission.subject.code if (r.commission and r.commission.subject) else '')
|
||||
classroom_code = r.classroom.code if r.classroom else 'Sin Aula'
|
||||
is_virtual = ('virtual' in classroom_code.lower()) or (getattr(r.classroom, 'is_virtual', False) if r.classroom else False)
|
||||
return {
|
||||
'id': r.id,
|
||||
'start_time': r.start_time.strftime('%H:%M') if r.start_time else '',
|
||||
'end_time': r.end_time.strftime('%H:%M') if r.end_time else '',
|
||||
'date_display': r.start_time.strftime('%d/%m') if r.start_time else '',
|
||||
'classroom': {
|
||||
'id': r.classroom_id,
|
||||
'code': classroom_code,
|
||||
'is_virtual': is_virtual,
|
||||
'location_display': getattr(r.classroom, 'location_display', f'Sede Central · {classroom_code}') if r.classroom else 'Sin Aula'
|
||||
},
|
||||
'subject_name': subject_name,
|
||||
'subject_code': subject_code,
|
||||
'shift': r.shift or 'Mañana',
|
||||
'commission': {
|
||||
'id': r.commission_id,
|
||||
'code': r.commission.code if r.commission else 'COM-1'
|
||||
},
|
||||
'status': str(r.status.value if hasattr(r.status, 'value') else r.status),
|
||||
'user': {
|
||||
'id': r.user_id,
|
||||
'name': r.user.name if r.user else 'Docente'
|
||||
},
|
||||
'virtual_link': r.virtual_link or ('https://meet.google.com/edu-space-' + classroom_code.lower().replace(' ', '-') if is_virtual else '')
|
||||
}
|
||||
|
||||
def serialize_milestone(m):
|
||||
return {
|
||||
'id': m.id,
|
||||
'title': m.title,
|
||||
'materia_codigo': m.materia_codigo,
|
||||
'subject_name': m.subject.name if (hasattr(m, 'subject') and m.subject) else m.materia_codigo,
|
||||
'date': m.date.strftime('%d/%m/%Y') if m.date else '',
|
||||
'type_obj': {
|
||||
'name': m.type_obj.name if m.type_obj else 'Evaluación',
|
||||
'color': m.type_obj.color if m.type_obj else '#0d6efd'
|
||||
},
|
||||
'classroom': m.classroom or 'Sede Central',
|
||||
'link': m.link or '',
|
||||
'notes': m.notes or ''
|
||||
}
|
||||
|
||||
@api_dashboard_bp.route('/stats', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_stats():
|
||||
"""Retorna estadísticas clave y datos operativos para el Dashboard."""
|
||||
today = datetime.utcnow().date()
|
||||
|
||||
try:
|
||||
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
||||
total_subjects = Subject.query.filter_by(is_active=True).count()
|
||||
total_users = User.query.filter_by(is_active=True).count()
|
||||
|
||||
# Aulas presenciales y virtuales
|
||||
all_active_classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||
virtual_classrooms_list = [
|
||||
c for c in all_active_classrooms
|
||||
if getattr(c, 'is_virtual', False)
|
||||
or 'virtual' in (c.building or '').lower()
|
||||
or 'virtual' in (c.code or '').lower()
|
||||
]
|
||||
virtual_classrooms = len(virtual_classrooms_list)
|
||||
|
||||
physical_classrooms_list = [
|
||||
c for c in all_active_classrooms
|
||||
if c not in virtual_classrooms_list
|
||||
]
|
||||
total_physical_count = len(physical_classrooms_list)
|
||||
physical_classrooms = total_physical_count
|
||||
physical_ids = set(c.id for c in physical_classrooms_list)
|
||||
|
||||
# 1. Asignación General del Cuatrimestre / Cronograma
|
||||
assigned_all_ids = set(
|
||||
r[0] for r in db.session.query(Reservation.classroom_id)
|
||||
.filter(Reservation.classroom_id.isnot(None)).distinct().all()
|
||||
)
|
||||
assigned_physical_ids = physical_ids.intersection(assigned_all_ids)
|
||||
assigned_classrooms = len(assigned_physical_ids)
|
||||
unassigned_classrooms = max(0, total_physical_count - assigned_classrooms)
|
||||
|
||||
assigned_pct = round((assigned_classrooms / max(1, total_physical_count)) * 100, 1) if total_physical_count > 0 else 0.0
|
||||
unassigned_pct = round(100.0 - assigned_pct, 1) if total_physical_count > 0 else 0.0
|
||||
|
||||
# 2. Asignación Operativa del Día de Hoy
|
||||
assigned_today_ids = set(
|
||||
r[0] for r in db.session.query(Reservation.classroom_id).filter(
|
||||
Reservation.classroom_id.isnot(None),
|
||||
func.date(Reservation.start_time) == today
|
||||
).distinct().all()
|
||||
)
|
||||
assigned_today_physical_ids = physical_ids.intersection(assigned_today_ids)
|
||||
assigned_today_count = len(assigned_today_physical_ids)
|
||||
unassigned_today_count = max(0, total_physical_count - assigned_today_count)
|
||||
|
||||
assigned_today_pct = round((assigned_today_count / max(1, total_physical_count)) * 100, 1) if total_physical_count > 0 else 0.0
|
||||
unassigned_today_pct = round(100.0 - assigned_today_pct, 1) if total_physical_count > 0 else 0.0
|
||||
|
||||
today_reservations_q = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) == today
|
||||
).order_by(Reservation.start_time.asc()).all()
|
||||
today_reservations = len(today_reservations_q)
|
||||
|
||||
pending_reservations_q = Reservation.query.filter(
|
||||
Reservation.status == 'PENDING'
|
||||
).order_by(Reservation.start_time.asc()).all()
|
||||
pending_count = len(pending_reservations_q)
|
||||
|
||||
upcoming_reservations_q = Reservation.query.filter(
|
||||
Reservation.start_time >= datetime.utcnow()
|
||||
).order_by(Reservation.start_time.asc()).limit(10).all()
|
||||
|
||||
milestones_q = AcademicMilestone.query.filter(
|
||||
AcademicMilestone.is_active == True
|
||||
).order_by(AcademicMilestone.date.asc()).limit(10).all()
|
||||
|
||||
recent_audits = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(6).all()
|
||||
audit_data = [{
|
||||
'created_at': a.created_at.strftime('%Y-%m-%d %H:%M') if a.created_at else None,
|
||||
'action_type': getattr(a, 'action', getattr(a, 'action_type', 'AUDIT')),
|
||||
'ip_address': a.ip_address,
|
||||
'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
|
||||
|
||||
pending_reservations = [serialize_reservation(r) for r in pending_reservations_q]
|
||||
today_schedule_list = [serialize_reservation(r) for r in today_reservations_q]
|
||||
upcoming_reservations = [serialize_reservation(r) for r in upcoming_reservations_q]
|
||||
milestones = [serialize_milestone(m) for m in milestones_q]
|
||||
|
||||
# ---- 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
|
||||
total_subjects = 0
|
||||
total_users = 0
|
||||
today_reservations = 0
|
||||
pending_count = 0
|
||||
physical_classrooms = 0
|
||||
virtual_classrooms = 0
|
||||
occupancy_rate = 0
|
||||
assigned_classrooms = 0
|
||||
assigned_pct = 0.0
|
||||
unassigned_classrooms = 0
|
||||
unassigned_pct = 0.0
|
||||
assigned_today_count = 0
|
||||
assigned_today_pct = 0.0
|
||||
unassigned_today_count = 0
|
||||
unassigned_today_pct = 0.0
|
||||
audit_data = []
|
||||
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)
|
||||
monthly_data = [
|
||||
{"month": "Ene", "reservations": 120},
|
||||
{"month": "Feb", "reservations": 180},
|
||||
{"month": "Mar", "reservations": 250},
|
||||
{"month": "Abr", "reservations": 220},
|
||||
{"month": "May", "reservations": 300},
|
||||
{"month": "Jun", "reservations": 310},
|
||||
{"month": "Jul", "reservations": 200},
|
||||
{"month": "Ago", "reservations": 350},
|
||||
{"month": "Sep", "reservations": 420},
|
||||
{"month": "Oct", "reservations": 480},
|
||||
{"month": "Nov", "reservations": 520},
|
||||
{"month": "Dic", "reservations": 210}
|
||||
]
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'stats': {
|
||||
'total_classrooms': total_classrooms,
|
||||
'active_subjects': total_subjects,
|
||||
'total_active_users': total_users,
|
||||
'today_reservations': today_reservations,
|
||||
'pending_count': pending_count,
|
||||
'physical_classrooms': physical_classrooms,
|
||||
'virtual_classrooms': virtual_classrooms,
|
||||
'occupancy_rate': occupancy_rate,
|
||||
'assigned_classrooms': assigned_classrooms,
|
||||
'assigned_classrooms_pct': assigned_pct,
|
||||
'unassigned_classrooms': unassigned_classrooms,
|
||||
'unassigned_classrooms_pct': unassigned_pct,
|
||||
'assigned_today_classrooms': assigned_today_count,
|
||||
'assigned_today_pct': assigned_today_pct,
|
||||
'unassigned_today_classrooms': unassigned_today_count,
|
||||
'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,
|
||||
'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'])
|
||||
@jwt_required
|
||||
def sync_google_sheets():
|
||||
"""Ejecuta la sincronización real con las hojas de cálculo de Google Sheets."""
|
||||
from app.services.sheets_importer import GoogleSheetsImporter
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
custom_url = data.get('sheets_url') or data.get('base_url')
|
||||
importer = GoogleSheetsImporter(base_url=custom_url)
|
||||
stats = importer.sync()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'stats': stats,
|
||||
'source_url': importer.base_url
|
||||
}), 200
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,314 @@
|
||||
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
|
||||
|
||||
api_reservations_bp = Blueprint('api_reservations', __name__, url_prefix='/api/v1/reservations')
|
||||
reservation_service = ReservationService()
|
||||
|
||||
@api_reservations_bp.route('', methods=['GET'])
|
||||
@jwt_required
|
||||
def list_reservations():
|
||||
"""Consulta de reservas por rango de fechas y/o aula."""
|
||||
start_str = request.args.get('start_date')
|
||||
end_str = request.args.get('end_date')
|
||||
classroom_id = request.args.get('classroom_id', type=int)
|
||||
|
||||
now = datetime.utcnow()
|
||||
try:
|
||||
start_dt = datetime.strptime(start_str, '%Y-%m-%d') if start_str else now.replace(hour=0, minute=0, second=0)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'BadRequest', 'message': 'Formato inválido de start_date (debe ser YYYY-MM-DD).'}), 400
|
||||
|
||||
try:
|
||||
end_dt = datetime.strptime(end_str, '%Y-%m-%d').replace(hour=23, minute=59, second=59) if end_str else start_dt + timedelta(days=7)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'BadRequest', 'message': 'Formato inválido de end_date (debe ser YYYY-MM-DD).'}), 400
|
||||
|
||||
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
|
||||
floor = request.args.get('floor')
|
||||
shift = request.args.get('shift')
|
||||
search = request.args.get('search')
|
||||
|
||||
reservations = reservation_service.list_reservations(start_dt, end_dt, classroom_id=classroom_id)
|
||||
result = []
|
||||
for r in reservations:
|
||||
is_virt = bool(r.is_virtual or (r.classroom and r.classroom.is_virtual))
|
||||
if hide_virtual and is_virt:
|
||||
continue
|
||||
if floor and r.classroom and str(r.classroom.floor).strip().lower() != str(floor).strip().lower():
|
||||
continue
|
||||
if shift and r.shift and shift.lower() not in r.shift.lower():
|
||||
continue
|
||||
if search:
|
||||
s_lower = search.lower()
|
||||
subj_n = (r.subject_name or '').lower()
|
||||
subj_c = (r.subject_code or '').lower()
|
||||
c_name = (r.classroom.code if r.classroom else '').lower()
|
||||
u_name = (r.user.name if r.user else '').lower()
|
||||
if s_lower not in subj_n and s_lower not in subj_c and s_lower not in c_name and s_lower not in u_name:
|
||||
continue
|
||||
|
||||
result.append({
|
||||
'id': r.id,
|
||||
'classroom_id': r.classroom_id,
|
||||
'classroom_name': r.classroom.code if r.classroom else None,
|
||||
'classroom': {
|
||||
'id': r.classroom.id,
|
||||
'code': r.classroom.code,
|
||||
'code_display': getattr(r.classroom, 'code_display', r.classroom.code),
|
||||
'floor': r.classroom.floor,
|
||||
'floor_display': getattr(r.classroom, 'floor_display', f"Piso {r.classroom.floor}"),
|
||||
'building': getattr(r.classroom, 'building_name', r.classroom.building),
|
||||
'is_virtual': getattr(r.classroom, 'is_virtual', False),
|
||||
'location_display': getattr(r.classroom, 'location_display', r.classroom.code)
|
||||
} if r.classroom else None,
|
||||
'commission_id': r.commission_id,
|
||||
'commission': {
|
||||
'id': r.commission.id,
|
||||
'code': r.commission.code,
|
||||
'virtual_link': r.commission.virtual_link,
|
||||
'max_students': r.commission.max_students
|
||||
} if r.commission else None,
|
||||
'teacher_name': r.user.name if r.user else None,
|
||||
'user': {
|
||||
'id': r.user.id,
|
||||
'name': r.user.name,
|
||||
'email': r.user.email
|
||||
} if r.user else None,
|
||||
'start_time': r.start_time.isoformat(),
|
||||
'end_time': r.end_time.isoformat(),
|
||||
'purpose': r.purpose,
|
||||
'subject_name': r.subject_name,
|
||||
'subject_code': r.subject_code,
|
||||
'status': str(r.status.value if hasattr(r.status, 'value') else r.status),
|
||||
'shift': r.shift or 'Mañana',
|
||||
'virtual_link': r.virtual_link,
|
||||
'effective_virtual_link': r.effective_virtual_link,
|
||||
'is_virtual': is_virt,
|
||||
'expected_attendees': r.expected_attendees or 30,
|
||||
'notes': r.notes or ''
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'total': len(result),
|
||||
'start_date': start_dt.strftime('%Y-%m-%d'),
|
||||
'end_date': end_dt.strftime('%Y-%m-%d'),
|
||||
'reservations': result
|
||||
}), 200
|
||||
|
||||
@api_reservations_bp.route('/<int:reservation_id>', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_reservation(reservation_id: int):
|
||||
"""Obtiene el detalle de una reserva específica con sus relaciones completas."""
|
||||
r = reservation_service.get_reservation(reservation_id)
|
||||
if not r:
|
||||
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
|
||||
|
||||
duration_min = int((r.end_time - r.start_time).total_seconds() / 60) if (r.start_time and r.end_time) else 120
|
||||
is_virt = bool(r.is_virtual or (r.classroom and r.classroom.is_virtual))
|
||||
|
||||
return jsonify({
|
||||
'id': r.id,
|
||||
'classroom_id': r.classroom_id,
|
||||
'classroom_name': r.classroom.code if r.classroom else None,
|
||||
'location_display': getattr(r.classroom, 'location_display', r.classroom.code if r.classroom else None),
|
||||
'classroom': {
|
||||
'id': r.classroom.id,
|
||||
'code': r.classroom.code,
|
||||
'code_display': getattr(r.classroom, 'code_display', r.classroom.code),
|
||||
'building_name': getattr(r.classroom, 'building_name', getattr(r.classroom, 'building', 'Edificio Central')),
|
||||
'floor': r.classroom.floor,
|
||||
'floor_display': getattr(r.classroom, 'floor_display', f"Piso {r.classroom.floor}"),
|
||||
'capacity': r.classroom.capacity,
|
||||
'capacity_display': getattr(r.classroom, 'capacity_display', str(r.classroom.capacity)),
|
||||
'description': getattr(r.classroom, 'description', '') or '',
|
||||
'is_virtual': getattr(r.classroom, 'is_virtual', False)
|
||||
} if r.classroom else None,
|
||||
'commission_id': r.commission_id,
|
||||
'commission': {
|
||||
'id': r.commission.id,
|
||||
'code': r.commission.code,
|
||||
'full_code': r.commission.get_full_code() if hasattr(r.commission, 'get_full_code') else r.commission.code,
|
||||
'virtual_link': r.commission.virtual_link,
|
||||
'max_students': r.commission.max_students,
|
||||
'current_students': getattr(r.commission, 'current_students', 0),
|
||||
'subject': {
|
||||
'id': r.commission.subject.id,
|
||||
'name': r.commission.subject.name,
|
||||
'code': r.commission.subject.code
|
||||
} if (r.commission and r.commission.subject) else None
|
||||
} if r.commission else None,
|
||||
'teacher_id': r.user_id,
|
||||
'teacher_name': r.user.name if r.user else None,
|
||||
'user': {
|
||||
'id': r.user.id,
|
||||
'name': r.user.name,
|
||||
'first_name': getattr(r.user, 'first_name', r.user.name.split()[0] if r.user.name else 'Usuario'),
|
||||
'last_name': getattr(r.user, 'last_name', ' '.join(r.user.name.split()[1:]) if r.user.name and len(r.user.name.split()) > 1 else ''),
|
||||
'email': r.user.email
|
||||
} if r.user else None,
|
||||
'start_time': r.start_time.isoformat(),
|
||||
'end_time': r.end_time.isoformat(),
|
||||
'duration': duration_min,
|
||||
'purpose': r.purpose,
|
||||
'status': str(r.status.value if hasattr(r.status, 'value') else r.status),
|
||||
'shift': r.shift or 'Mañana',
|
||||
'virtual_link': r.virtual_link,
|
||||
'effective_virtual_link': r.effective_virtual_link,
|
||||
'is_virtual': is_virt,
|
||||
'expected_attendees': r.expected_attendees or 30,
|
||||
'notes': r.notes or '',
|
||||
'can_be_cancelled': r.can_be_cancelled() if hasattr(r, 'can_be_cancelled') else True
|
||||
}), 200
|
||||
|
||||
@api_reservations_bp.route('', methods=['POST'])
|
||||
@jwt_required
|
||||
def create_reservation():
|
||||
"""Crea una reserva aplicando validación DTO y detección de colisiones."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
# Inyectar usuario autenticado si no se especifica
|
||||
if 'user_id' not in data:
|
||||
data['user_id'] = g.jwt_user.id
|
||||
|
||||
try:
|
||||
dto = ReservationCreateDTO(**data)
|
||||
except ValidationError as e:
|
||||
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
|
||||
|
||||
try:
|
||||
reservation = reservation_service.create_reservation(dto)
|
||||
return jsonify({
|
||||
'message': 'Reserva creada exitosamente.',
|
||||
'id': reservation.id,
|
||||
'classroom_id': reservation.classroom_id,
|
||||
'start_time': reservation.start_time.isoformat(),
|
||||
'end_time': reservation.end_time.isoformat(),
|
||||
'status': str(reservation.status)
|
||||
}), 201
|
||||
except ValueError as e:
|
||||
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 409
|
||||
|
||||
@api_reservations_bp.route('/<int:reservation_id>', methods=['PUT'])
|
||||
@jwt_required
|
||||
def update_reservation(reservation_id: int):
|
||||
"""Modifica una reserva existente."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
dto = ReservationUpdateDTO(**data)
|
||||
except ValidationError as e:
|
||||
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
|
||||
|
||||
try:
|
||||
reservation = reservation_service.update_reservation(reservation_id, dto)
|
||||
return jsonify({
|
||||
'message': 'Reserva actualizada exitosamente.',
|
||||
'id': reservation.id,
|
||||
'start_time': reservation.start_time.isoformat(),
|
||||
'end_time': reservation.end_time.isoformat(),
|
||||
'status': str(reservation.status)
|
||||
}), 200
|
||||
except ValueError as e:
|
||||
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 409
|
||||
|
||||
@api_reservations_bp.route('/<int:reservation_id>', methods=['DELETE'])
|
||||
@jwt_required
|
||||
def cancel_reservation(reservation_id: int):
|
||||
"""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):
|
||||
"""Cancela una reserva (endpoint POST compatible con formularios)."""
|
||||
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>/confirm', methods=['POST'])
|
||||
@jwt_required
|
||||
def confirm_reservation(reservation_id: int):
|
||||
"""Confirma una reserva cambiando su estado a CONFIRMED."""
|
||||
confirmed = reservation_service.confirm_reservation(reservation_id)
|
||||
if not confirmed:
|
||||
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
|
||||
|
||||
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')
|
||||
@@ -0,0 +1,126 @@
|
||||
from flask import Blueprint, render_template, abort
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.subject import Commission
|
||||
from app.models.enrollment import StudentEnrollment
|
||||
from app.models.classroom import Classroom
|
||||
|
||||
my_subjects_bp = Blueprint('my_subjects', __name__, url_prefix='/mis-materias')
|
||||
|
||||
|
||||
@my_subjects_bp.route('/')
|
||||
@login_required
|
||||
def my_subjects_list():
|
||||
"""Show the commissions assigned to the current user (teacher or student)."""
|
||||
role = current_user.get_institutional_role()
|
||||
|
||||
commissions = []
|
||||
|
||||
if role in ['admin', 'bedelia']:
|
||||
# Admin/Bedelia: see all active commissions
|
||||
commissions = (
|
||||
Commission.query
|
||||
.filter_by(active=True)
|
||||
.order_by(Commission.year.desc(), Commission.semester.asc(), Commission.code.asc())
|
||||
.all()
|
||||
)
|
||||
view_mode = 'all'
|
||||
|
||||
elif role == 'docente':
|
||||
# Teacher: only commissions where they are assigned as teacher
|
||||
commissions = (
|
||||
Commission.query
|
||||
.filter_by(teacher_id=current_user.id, active=True)
|
||||
.order_by(Commission.year.desc(), Commission.semester.asc(), Commission.code.asc())
|
||||
.all()
|
||||
)
|
||||
view_mode = 'teacher'
|
||||
|
||||
elif role == 'alumno':
|
||||
# Student: only commissions where they are enrolled
|
||||
enrollments = (
|
||||
StudentEnrollment.query
|
||||
.filter_by(student_id=current_user.id, status='activo')
|
||||
.all()
|
||||
)
|
||||
commission_ids = [e.commission_id for e in enrollments]
|
||||
commissions = (
|
||||
Commission.query
|
||||
.filter(Commission.id.in_(commission_ids), Commission.active == True)
|
||||
.order_by(Commission.year.desc(), Commission.semester.asc(), Commission.code.asc())
|
||||
.all()
|
||||
)
|
||||
view_mode = 'student'
|
||||
else:
|
||||
commissions = []
|
||||
view_mode = 'none'
|
||||
|
||||
return render_template(
|
||||
'my_subjects/list.html',
|
||||
commissions=commissions,
|
||||
view_mode=view_mode,
|
||||
role=role
|
||||
)
|
||||
|
||||
|
||||
@my_subjects_bp.route('/<int:commission_id>')
|
||||
@login_required
|
||||
def my_subject_detail(commission_id):
|
||||
"""Detail view of a single commission for the current user."""
|
||||
role = current_user.get_institutional_role()
|
||||
commission = Commission.query.get_or_404(commission_id)
|
||||
|
||||
# Access control: make sure the user is allowed to see this commission
|
||||
if role in ['admin', 'bedelia']:
|
||||
pass # full access
|
||||
elif role == 'docente':
|
||||
if commission.teacher_id != current_user.id:
|
||||
abort(403)
|
||||
elif role == 'alumno':
|
||||
enrollment = StudentEnrollment.query.filter_by(
|
||||
student_id=current_user.id,
|
||||
commission_id=commission_id
|
||||
).first()
|
||||
if not enrollment:
|
||||
abort(403)
|
||||
else:
|
||||
abort(403)
|
||||
|
||||
# Get enrolled students (visible to teachers and admin/bedelia)
|
||||
enrollments = []
|
||||
if role in ['admin', 'bedelia', 'docente']:
|
||||
enrollments = (
|
||||
StudentEnrollment.query
|
||||
.filter_by(commission_id=commission_id)
|
||||
.order_by(StudentEnrollment.enrolled_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
# Try to get classroom info from reservations linked to this commission
|
||||
from app.models.reservation import Reservation
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime
|
||||
|
||||
# Recent or upcoming reservation linked to this commission
|
||||
recent_reservation = (
|
||||
Reservation.query
|
||||
.filter(
|
||||
Reservation.commission_id == commission_id,
|
||||
Reservation.start_time >= datetime.utcnow()
|
||||
)
|
||||
.order_by(Reservation.start_time.asc())
|
||||
.first()
|
||||
)
|
||||
|
||||
classroom = None
|
||||
if recent_reservation and recent_reservation.classroom_id:
|
||||
classroom = Classroom.query.get(recent_reservation.classroom_id)
|
||||
|
||||
return render_template(
|
||||
'my_subjects/detail.html',
|
||||
commission=commission,
|
||||
enrollments=enrollments,
|
||||
classroom=classroom,
|
||||
recent_reservation=recent_reservation,
|
||||
role=role
|
||||
)
|
||||
@@ -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
|
||||
@@ -11,7 +11,7 @@ _token_blacklist: Dict[str, datetime] = {}
|
||||
class JWTService:
|
||||
"""Servicio de emisión, verificación y revocación de tokens JWT (Dual Token Pattern)."""
|
||||
|
||||
ACCESS_TOKEN_EXPIRES = timedelta(minutes=15)
|
||||
ACCESS_TOKEN_EXPIRES = timedelta(days=1)
|
||||
REFRESH_TOKEN_EXPIRES = timedelta(days=7)
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
@@ -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()
|
||||