11 Commits
Author SHA1 Message Date
carlostellocba 4ebc5df4fd fix(ui): resolucion del optimizador genetico, superposicion de tarjetas, contraste de logo y traducciones 2026-09-19 16:23:36 -03:00
carlostellocba 71fb20a271 feat: implement Bedelía dashboard panels and Phase 1 Automated Security Audit 2026-09-19 15:17:17 -03:00
carlostellocba ada39248d7 feat: completar Fase 2 del Roadmap MVP (Co-docencia, Matriz de Conflictos y Regla Diaria) y auditoria de seguridad 2026-09-19 14:05:16 -03:00
carlostellocba 05e197e49f docs: actualizar changelog y readme con detalles de la version 2.3.0 2026-09-19 11:54:35 -03:00
carlostellocba e93d784473 docs: actualizar changelog con version 2.3.0 2026-09-19 11:42:32 -03:00
carlostellocba 620e470614 feat(dashboard): eliminar boton de reserva en panel docente y agregar monitoreo de aulas asignadas y sin asignar con percentiles en admin y bedelia 2026-09-19 11:42:02 -03:00
carlostellocba b9a11d810a fix(classrooms): corregir renderizado de aulas presenciales, metricas de ocupacion y asignacion de cursadas 2026-09-19 11:27:50 -03:00
carlostellocba 5087e22988 feat(sync): add academic data reset tool, configurable sheets URL and fix sync timeout 2026-09-19 11:13:24 -03:00
carlostellocba 42b31ec44e docs: estado de migracion, arquitectura objetivo UniCABA, roadmap MVP y changelog oficial 2026-09-19 10:54:03 -03:00
carlostellocba 500aeeb9df feat: acople de schedule.view_reservation al BFF y creacion/asociacion de comisiones a materias 2026-09-19 10:23:50 -03:00
carlostellocba 1080acd43a feat: implementacion de dashboards por rol, soporte de clases virtuales concurrentes en importador de sheets y cartelera del dia 2026-09-18 23:04:48 -03:00
264 changed files with 14949 additions and 10380 deletions
+73
View File
@@ -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
+12
View File
@@ -210,3 +210,15 @@ cython_debug/
marimo/_static/ marimo/_static/
marimo/_lsp/ marimo/_lsp/
__marimo__/ __marimo__/
# Node.js / Frontend
node_modules/
npm-debug.log
.env
.env.*
!.env.example
frontend/.env
legacy_admin-edu-space/
.vscode/
.schemathesis/
+188
View File
@@ -0,0 +1,188 @@
# 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 3 del Roadmap MVP)
- **Calendario de Evaluación por Comisión:** Programación de parciales, recuperatorios, entregas de trabajos prácticos y exámenes finales con cuenta regresiva.
- **Libro de Calificaciones (*Gradebook*):** Matriz de carga rápida de notas para docentes, consulta pedagógica individual para alumnos y cierre de actas para Bedelía.
- **Grilla Semanal Drag & Drop (Fase 4):** Asignación visual interactiva para la oficina de Bedelía.
---
## [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.
+52
View File
@@ -0,0 +1,52 @@
# CHANGELOG MVP — Admin Edu-Space (UniCABA)
Registro de hitos y versiones correspondientes a las Fases del [ROADMAP_MVP.md](./ROADMAP_MVP.md).
---
## [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.
+223
View File
@@ -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`**.
+119 -453
View File
@@ -1,453 +1,119 @@
# Panel de Administración Edu-Space (UniCABA) # Panel de Administración Edu-Space (UniCABA)
Plataforma integral de gestión áulica, asignación de espacios, control de horarios y reservas académicas para la **Universidad de la Ciudad de Buenos Aires (UniCABA)**, desarrollada con **Python / Flask**, **SQLAlchemy**, **PostgreSQL** y **Bootstrap 5**. Plataforma integral de gestión áulica, asignación de espacios, control de horarios y reservas académicas para la **Universidad de la Ciudad de Buenos Aires (UniCABA)**.
--- El proyecto se basa en una arquitectura **desacoplada (Frontend / Backend)** en formato monorepo.
## 🏛️ Sede Institucional y Ubicaciones ---
* **Sede Central UniCABA:** Tte. Gral. Juan Domingo Perón 802, Ciudad Autónoma de Buenos Aires (CABA) — Aulas de grado, auditorio principal y laboratorios distribuidos desde Planta Baja (Piso 0) hasta el 4.° Piso.
* **Campus Virtual:** Espacio áulico digital integrado (Microsoft Teams / Moodle) configurado con **capacidad ilimitada ($\infty$)**. ## 🏛️ Sede Institucional y Ubicaciones
* **Sede Central UniCABA:** Tte. Gral. Juan Domingo Perón 802, Ciudad Autónoma de Buenos Aires (CABA) — Aulas de grado, auditorio principal y laboratorios distribuidos desde Planta Baja (Piso 0) hasta el 4.° Piso.
--- * **Campus Virtual:** Espacio áulico digital integrado (Microsoft Teams / Moodle) configurado con **capacidad ilimitada ($\infty$)**.
## ✨ Características Principales ---
### 🛡️ 1. Control de Accesos RBAC Granular (Matriz estilo FortiGate) ## 🏗️ Arquitectura del Proyecto (Monorepo)
* **Gestión por Módulos:** Permisos independientes por cada área del sistema:
1. **Aulas y Espacios:** Creación, edición, capacidad y estado de aulas físicas y virtuales. El repositorio está dividido en dos aplicaciones principales que interactúan mediante una API REST y un patrón Backend-For-Frontend (BFF).
2. **Edificios y Sedes:** Administración de sedes, pisos y arquitectura edilicia.
3. **Reservas de Aulas:** Solicitud, aprobación, confirmación y cancelación. ```text
4. **Cartelera y Cronograma:** Calendario interactivo, visualización diaria y comisiones. /
5. **Gestión Académica:** Carreras, asignaturas, códigos y comisiones. ├── backend/ # API REST Backend (Python / Flask)
6. **Sincronización Sheets:** Importador y sincronizador de Google Sheets. │ ├── app/ # Lógica de negocio, base de datos y endpoints
7. **Optimizador IA:** Algoritmo genético de distribución y resolución de conflictos. │ ├── tests/ # Pruebas unitarias e integración
8. **Usuarios y Accesos RBAC:** Cuentas, roles y matriz de permisos. │ └── requirements.txt # Dependencias de Python
* **Niveles de Permiso Granulares:** `Ninguno`, `Solo Lectura` o `Lectura y Escritura`. │
* **Controles Rápidos:** Botón *"Establecer Todos"* para configuración masiva inmediata. ├── frontend/ # Backend-For-Frontend (Node.js / Express)
* **Roles Predefinidos:** `Admin` (control total), `Docente`, `Operador` y `Consulta`. │ ├── src/ # Controladores, middleware, servicios (Axios)
│ ├── views/ # Plantillas Nunjucks (.html)
### 📊 2. Sincronización e Importación desde Google Sheets │ ├── public/ # Archivos estáticos (CSS, JS cliente)
* Integración directa con la cartelera académica de UniCABA en Google Sheets. │ └── package.json # Dependencias de Node.js
* Extracción e importación normalizada de los 5 campos clave: │
* **Código Académico:** Códigos reales de asignatura (ej. `INGA0003`, `ASIG00165`). ├── docs/ # Historial de cambios y documentación técnica
* **Asignatura:** Nombre oficial de la materia. └── unicaba_prerequitos/ # Requisitos académicos y de negocio
* **Aula:** Mapeo a aulas físicas (PB a 4.° piso) o aula `VIRTUAL` ($\infty$). ```
* **Carrera:** Normalización y vinculación relacional con entidades de carrera.
* **Horarios y Turnos:** Detección automática de franjas horarias y delimitadores de bloque (`Turno Mañana` y `Turno Tarde / Vespertino`). ### 🐍 Backend (Python + Flask)
- Funciona exclusivamente como una **REST API** que expone endpoints bajo `/api/v1/` retornando **JSON**.
### 📅 3. Calendario Interactivo de Reservas (FullCalendar) - Encargado de la persistencia de datos (SQLAlchemy / PostgreSQL), lógica de negocio dura, JWT auth service, seguridad, y algoritmos de optimización (Módulo Predictivo).
* **Visualización Semanal, Mensual y Diaria:** Grilla ágil de alta legibilidad con jerarquía visual: - **Prohibido** retornar HTML o utilizar Jinja2/`render_template` en este entorno.
* Asignatura destacada en tipografía grande y negrita.
* Badges con código de materia, aula asignada, turno y franja horaria. ### 🟢 Frontend BFF (Node.js + Express)
* **Filtros Dinámicos Combinables:** - Aplicación de servicio que renderiza las vistas para el cliente mediante **Nunjucks**.
* Filtrar por **Aula**. - Gestiona la sesión segura del usuario almacenando el token JWT recibido de Flask en una **Cookie HttpOnly**.
* Filtrar por **Materia / Asignatura**. - Actúa como proxy de conexión con el Backend de Python empleando **Axios** (inyectando el Bearer token interceptado).
* Filtrar por **Carrera**. - Utiliza **Chart.js** y JS vanilla en el lado del cliente para visualizaciones interactivas.
* Filtrar por **Turno** (`Turno Mañana` / `Turno Tarde`).
* Filtrar por **Piso / Planta** (`Planta Baja`, `Piso 1`, `Piso 2`, `Piso 3`, `Piso 4`). ---
* **Switch "Ocultar Aulas Virtuales":** Aísla la ocupación física real en el edificio central excluyendo los eventos virtuales de capacidad infinita.
* **Ficha Modal Instantánea:** Vista previa detallada al hacer clic en cualquier evento. ## 🎯 Dashboards Personalizados por Rol
### 📋 4. Cartelera del Día y Listado de Reservas La plataforma cuenta con paneles adaptados a las necesidades operativas de cada perfil institucional:
* **Cartelera del Día (`/schedule/today`):**
* **Vista Cronológica (Timeline):** Bloques horarios de 07:00 a 22:00 hs con indicador de ocupación. * **Administrador (`/dashboard?role=admin`):** Cockpit institucional con métricas globales, auditoría de seguridad RBAC, tendencias mensuales y panel interactivo de **Estado y Percentiles de Asignación de Aulas Físicas** (asignadas vs disponibles en número y porcentaje).
* **Vista en Tabla:** Lista ordenada con acciones rápidas y detalles de comisión. * **Bedelía (`/dashboard?role=bedelia`):** Control operativo del campus en tiempo real, balance de aulas presenciales asignadas (96.3%) y libres (3.7%), cartelera del día y bandeja de aprobación de reservas pendientes.
* Filtros directos por piso, turno y switch para ocultar virtuales. * **Docente / Profesor (`/dashboard?role=docente`):** Sesiones del día con accesos directos a videollamadas y gestión de comisiones.
* **Listado Paginado de Reservas (`/schedule/list`):** * **Alumno / Estudiante (`/dashboard?role=alumno`):** Brújula de cursada *"¿Dónde curso hoy?"* con ubicación por piso y aula o enlace virtual, junto al cronograma de exámenes y entregas de TPs.
* Paginación de 20 registros por página preservando filtros aplicados.
* Búsqueda por texto, estado (`Confirmada`, `Pendiente`, `Cancelada`), rango de fechas y piso. > **Modo Vista Previa:** Los usuarios con perfil administrador o directivo pueden previsualizar cualquier perspectiva utilizando el selector de roles en la barra superior o pasando el parámetro `?role={admin|bedelia|docente|alumno}` en la URL.
### 🏫 5. Gestión de Aulas con Capacidad Infinita ($\infty$) ---
* Soporte nativo para el símbolo **`∞`** o `0` para designar aulas virtuales o espacios sin aforo restringido.
* Botón de un solo clic `Ilimitada (∞)` en formularios de alta y edición con leyendas explicativas. ## ☁️ Sincronización Google Sheets, Configuración Global & Purga Académica
* Badges distintivos `∞ Ilimitada` en listados, tarjetas y fichas técnicas.
El sistema incluye sincronización automatizada con las planillas oficiales de cursada de UniCABA:
### 🤖 6. Optimizador Inteligente de Aulas (Algoritmo Genético) * **Configuración Global Dinámica:** Selección y persistencia de la URL de Google Sheets directamente desde la interfaz web (`/admin/google_sheets_import`) sin valores fijos en el código.
* Motor de optimización heurística para asignación automática de aulas a comisiones. * **Herramienta de Purga y Reinicio (`reset_academic_data`):** Módulo CLI y web para limpiar selectivamente reservas, aulas, asignaturas, comisiones o carreras, garantizando pruebas limpias desde cero y preservando siempre usuarios y credenciales.
* Pondera capacidad requerida vs. capacidad de aula, equipamiento tecnológico y minimización de desplazamientos entre pisos. * **Concurrencia Virtual Ilimitada:** Reconocimiento de espacios áulicos remotos (`Campus Virtual · VIRTUAL`), permitiendo la coexistencia de múltiples comisiones en la misma franja horaria sin colisiones de agenda.
* **Catálogo Presencial con Cursadas Asignadas:** Listado de aulas físicas con cálculo de ocupación real (semana/mes), vista previa de asignaturas y enlaces directos a calendario y ficha del espacio.
### 🎨 7. Experiencia de Usuario, Temas y Localización * **Enlaces de Videollamada Automáticos:** Generación de enlaces seguros (Google Meet / Teams) para acceso instantáneo de docentes y estudiantes.
* **Tema Oscuro / Claro:** Alternador con botón flotante y persistencia en `localStorage` y perfil de usuario. * **Filtros en Cartelera:** Opciones para filtrar por turno, piso u ocultar aulas virtuales mediante el switch dedicado en `/schedule/today_schedule`.
* **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) ## 📅 Ficha de Reserva & Gestión de Comisiones
El menú desplegable **Gestión** en la barra de navegación organiza las áreas operativas de la universidad:
* **Espacios e Infraestructura:** * **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.
* **Edificios y Sedes (`/buildings/list`):** Administración de edificios físicos, arquitectura y estado edilicio. * **Gestión de Comisiones y Vinculación con Asignaturas (`/admin/commissions_list`):**
* **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). - 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.
* **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). - Generación automática de enlace institucional de Google Meet cuando no se especifica un enlace propio.
* **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. - Tabla operativa con visualización explícita del código de comisión, nombre de materia, código de asignatura y carrera.
* **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. ## 🚀 Instalación y Despliegue Local
* **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:** ### Requisitos previos
* **Usuarios y Roles RBAC:** Cuentas, asignación de permisos matriciales y auditoría. - Node.js (v18 o superior)
* **Tipificaciones de Hitos:** Catálogo de tipos de hitos de evaluación académica. - Python 3.10+
* **Sincronización Sheets:** Integración con Google Sheets para actualización en lote. - PostgreSQL / SQLite
### 🔀 9. Selector de Vista Universal (Tabla / Grilla vs. Tarjetas / Mosaico) ### 1. Iniciar el Backend (Flask)
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: ```bash
* **Vista de Tabla / Grilla (`▤ Tabla`):** cd backend
* Presentación tabular en filas y columnas de alta densidad informativa. python -m venv venv
* Muestra múltiples atributos simultáneamente (edificio, piso, aforo, métricas de ocupación, estado, acciones). # Activar venv (Windows: venv\Scripts\activate, Linux/Mac: source venv/bin/activate)
* Ideal para consulta y comparación masiva de registros. pip install -r requirements.txt
* **Vista de Tarjetas / Mosaico (`▦ Tarjetas`):** flask run --port=5000
* 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. *(El backend quedará escuchando en http://localhost:5000)*
* **Características del Selector:**
* **Cambio Inmediato:** Conmutación instantánea en el cliente sin recarga de página (`d-none` / manipulación de DOM). ### 2. Iniciar el Frontend (Node/Express)
* **Persistencia de Preferencia:** Memorización automática mediante `localStorage` para conservar la elección del usuario en futuras visitas. ```bash
* **Preservación de Filtros:** Inyección automática del parámetro `display` en formularios y sincronización de URL sin recarga (`history.replaceState`). cd frontend
* **Arquitectura Extensible:** Permite registrar futuras vistas (kanban, calendario, compacta) mediante atributos `data-view` y `data-view-container` sin alterar la lógica de negocio. npm install
npm run dev
### 🧭 10. Arquitectura de Información & Navegación Basada en Estándares de la Industria # o en su defecto:
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): node src/app.js
* **Eliminación de Redundancias:** Supresión de destinos duplicados que anteriormente saturaban los menús desplegables. ```
* **Menús Estructurados por Rol Institucional:** *(El frontend quedará escuchando en http://localhost:3000)*
* **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`. ## 📚 Documentación Técnica & Changelogs
* **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. * [Estado de la Migración y Arquitectura Objetivo UniCABA](ESTADO_MIGRACION_Y_ARQUITECTURA_OBJETIVO.md)
* [Roadmap MVP: Fases y Estadios del Proyecto](ROADMAP_MVP.md)
### 🎯 11. Dashboards Personalizados por Rol Institucional & Selector de Perspectiva * [Changelog Oficial del Proyecto](CHANGELOG.md)
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: * [Changelog Detallado: Clases Virtuales y Dashboards](docs/CHANGELOG_VIRTUAL_CLASSES_AND_DASHBOARDS.md)
* **Dashboard Administrador:** * [Auditoría y Sesión de Reingeniería](docs/CHANGELOG_SESSION.md)
* KPIs estratégicos: Aulas totales, oferta de materias, usuarios registrados y reservas de hoy. * [Guía de Integración de Cronograma](docs/SCHEDULE_INTEGRATION_GUIDE.md)
* 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.
␍
+131
View File
@@ -0,0 +1,131 @@
# 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:** 2.6.0
**Fecha de Actualización:** 19 de Septiembre de 2026
**Rama Base:** `testing`
---
## 🗺️ 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[░░░░░░░░░░░░░░░░░░░░] 0% (Próxima)
Fase 4: UI/UX Drag & Drop e Impersonación[░░░░░░░░░░░░░░░░░░░░] 0% (Planificada)
Fase 5: Despliegue Producción e Integrac.[░░░░░░░░░░░░░░░░░░░░] 0% (Planificada)
```
---
## 🚀 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 📅 *(Planificada)*
* **Objetivo:** Proveer al cuerpo docente y estudiantil el seguimiento completo de instancias evaluativas.
* **Estadios:**
1. **Estadio 3.1 — Calendario de Evaluación por Comisión:**
- [ ] Programación de parciales, recuperatorios, entregas de trabajos prácticos y exámenes finales.
- [ ] Notificaciones preventivas en el dashboard del alumno con cuenta regresiva para exámenes.
2. **Estadio 3.2 — Libro de Calificaciones (*Gradebook*):**
- [ ] Matriz de carga rápida de notas para docentes con autoguardado asíncrono.
- [ ] Consulta individualizada de calificaciones y retroalimentaciones pedagógicas para alumnos.
- [ ] Cierre de actas de regularidad y promoción para Bedelía.
---
### Fase 4: UI/UX Avanzada para Bedelía & Modo Impersonación 🖥️ *(Planificada)*
* **Objetivo:** Dotar a la oficina de Bedelía y a la Administración de herramientas interactivas de última generación.
* **Estadios:**
1. **Estadio 4.1 — Grilla Semanal Interactiva (Drag-and-Drop):**
- [ ] Interfaz tipo TimeGrid interactiva con arrastre de comisiones para asignación rápida de aulas y turnos.
- [ ] Detección visual en tiempo real de aulas ocupadas o incompatibles.
2. **Estadio 4.2 — Modo Impersonación del Superadmin:**
- [ ] Funcionalidad de *login as* o vista previa con cabecera `X-Impersonate-User` para replicar exactamente lo que ve cualquier docente o alumno.
- [ ] Trazabilidad obligatoria de acciones bajo impersonación en el registro de auditoría (`audit_logs`).
3. **Estadio 4.3 — Integración del Optimizador Genético:**
- [ ] Acople del motor predictivo de algoritmos genéticos a la base de datos real para sugerir redistribución óptima de aulas según aforo.
---
### Fase 5: Producción Institucional, CI/CD e Integraciones 🏢 *(Planificada)*
* **Objetivo:** Puesta en marcha definitiva en la infraestructura de servidores de UniCABA.
* **Estadios:**
1. **Estadio 5.1 — Despliegue e Infraestructura:**
- [ ] `docker-compose.yml` productivo orquestando Flask (Gunicorn), Node.js BFF (PM2) y PostgreSQL 15.
- [ ] Proxy inverso Nginx con certificados SSL/TLS y compresión Brotli/Gzip.
2. **Estadio 5.2 — Integraciones Universitarias:**
- [ ] Módulo de exportación/importación compatible con SIU Guaraní 3.
- [ ] Sincronización automática de aulas virtuales con Moodle institucional.
3. **Estadio 5.3 — Pruebas de Carga y Hardening:**
- [ ] 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.
+99
View File
@@ -0,0 +1,99 @@
# Informe de Auditoría y Seguridad Automatizada (Python & Node.js)
**Proyecto:** Edu-Space Admin Architecture
**Fecha:** Septiembre 2026
**Entorno:** Backend Flask (REST API) + Node.js BFF (Express)
**Estado General:** Aprobado ✅ (0 vulnerabilidades conocidas, 0 fallos críticos SAST/DAST)
---
## 1. Resumen Ejecutivo
Se completó la implementación del **Plan de Auditoría y Seguridad Automatizada** que cubre el ciclo de vida completo de las dos capas backend 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 (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`).
---
## 2. Fase 1: 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 ./app -ll -ii`
- **Líneas Escaneadas:** 8.456 líneas de código Python.
- **Hallazgo Inicial:** 1 advertencia de severidad Media (CWE-22 / B310 en `backend/app/services/sheets_importer.py` por uso de `urllib.request.urlopen` sin validación estricta del esquema URL).
- **Remediación Aplicada:**
- Se incorporó validación explícita de esquema (`url.startswith('https://')` o `url.startswith('http://')`) previo a la invocación de `urlopen`.
- Se colocó anotación `# nosec B310` justificando la protección criptográfica/red implementada.
- **Resultado Actual:** **0 problemas identificados** (Medium/High = 0).
### 2.2. Auditoría de Dependencias de Terceros (SCA) - `Pip-Audit`
- **Herramienta:** Pip-Audit v2.10.1 con base de datos OSV (`backend/venv/Scripts/pip-audit.exe`)
- **Comando:** `pip-audit -s osv --progress-spinner off -r requirements.txt`
- **Hallazgo Inicial:** 24 vulnerabilidades conocidas asociadas a dependencias desactualizadas (`Flask==3.0.0`, `Werkzeug==3.0.1`, `requests==2.31.0`, `bleach==6.1.0`, `python-dotenv==1.0.0`).
- **Remediaciones Aplicadas:**
- Actualización de `Flask` a `>=3.1.3`
- Actualización de `Werkzeug` a `>=3.1.6` (instalada v3.1.8)
- Actualización de `requests` a `>=2.32.4` (instalada v2.34.2)
- Actualización de `bleach` a `>=6.4.0`
- Actualización de `python-dotenv` a `>=1.2.2` (instalada v1.2.3)
- **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:** Implementada en `backend/app/routes/api/openapi.py` (`/api/v1/openapi.json`) bajo el estándar OpenAPI 3.0.3.
- **Comando:** `st run http://127.0.0.1:5000/api/v1/openapi.json --checks not_a_server_error --max-examples=10`
- **Hallazgo Inicial:** Fuzzing detectó un error 500 no controlado en `POST /api/v1/auth/login` cuando el payload enviado consistía en un arreglo JSON anómalo (`[null, null]`) en vez de un objeto/diccionario, provocando un `TypeError` en el constructor de Pydantic (`LoginDTO(**data)`).
- **Remediación Aplicada:**
- Se agregó validación de tipo `if not isinstance(data, dict): return jsonify({'error': 'BadRequest', 'message': '...'}), 400` tanto en `/login` como en `/refresh`.
- **Resultado Actual:** **114 casos de prueba generados y superados con éxito**, **0 errores 500 del servidor**.
---
## 3. Fase 2: Backend Node.js (Express BFF)
### 3.1. Análisis Estático de Código Fuente (SAST) - `njsscan`
- **Herramienta:** NodeJsScan (`backend/venv/Scripts/njsscan.exe`)
- **Comando:** `njsscan ./frontend/src`
- **Reglas Evaluadas:** Inyecciones de código, llamadas inseguras a `eval()`, omisión de headers de seguridad, credenciales hardcodeadas, CORS inseguro.
- **Resultado:** **No issues found** (0 vulnerabilidades).
### 3.2. Blindaje de Middleware (`helmet` & `express-rate-limit`)
- **Paquetes Instalados:** `helmet`, `express-rate-limit`.
- **Configuración en `frontend/src/app.js`:**
- `app.disable('x-powered-by')`: Oculta el motor Express para prevenir finger-printing de atacantes.
- `helmet`: Aplica cabeceras HTTP de protección:
- `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`:
- Endpoint `/auth/login`: Límite estricto de 30 peticiones por ventana de 15 minutos por IP para mitigar ataques de fuerza bruta de credenciales.
- Endpoints `/api`: Límite de 180 peticiones por minuto por IP para mitigar saturación y scraping.
### 3.3. Auditoría de Dependencias (SCA) - `npm audit`
- **Herramienta:** `npm audit` nativo (v11.x)
- **Comando:** `cmd.exe /c npm audit` en `frontend/`
- **Resultado:** **found 0 vulnerabilities** en 108 paquetes analizados.
---
## 4. Script de Auditoría Automatizada Unificada
Se diseñó y probó el script automatizado [security_audit.bat](file:///c:/Users/Soporte%20IT/workspace/admin-edu-space/security_audit.bat) en la raíz del repositorio, ejecutable en un solo paso:
```cmd
security_audit.bat
```
El script ejecuta secuencialmente los 5 controles de seguridad y genera un reporte en consola para desarrolladores y pipelines de integración continua.
---
## 5. Matriz de Resultados
| Control | Capa | Herramienta | Estado Previo | Estado Final |
|---|---|---|---|---|
| **SAST (Python)** | Flask API | `bandit` | 1 Advertencia Media (B310) | **0 Problemas (100% Limpio)** ✅ |
| **SCA (Python)** | Flask API | `pip-audit` | 24 Vulnerabilidades (CVEs) | **0 Vulnerabilidades** ✅ |
| **DAST (Python)** | Flask API | `schemathesis` | 1 Fallo HTTP 500 (payload fuzzing) | **0 Fallos (114/114 Pasados)** ✅ |
| **SAST (Node.js)**| Express BFF| `njsscan` | Sin controles previos | **0 Problemas (100% Limpio)** ✅ |
| **SCA (Node.js)** | Express BFF| `npm audit` | No auditado con Helmet | **0 Vulnerabilidades** ✅ |
| **Blindaje HTTP** | Express BFF| `helmet` + `rate-limit` | Cabeceras por defecto | **Protegido con HSTS, nosniff, limiters** ✅ |
-140
View File
@@ -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
-122
View File
@@ -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
-282
View File
@@ -1,282 +0,0 @@
{% extends 'base.html' %}
{% block title %}Comisión {{ commission.get_full_code() }} — Edu-Space{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb small">
<li class="breadcrumb-item"><a href="{{ url_for('main.dashboard') }}">Inicio</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('admin.commissions_list') }}">Comisiones</a></li>
<li class="breadcrumb-item active">{{ commission.get_full_code() }}</li>
</ol>
</nav>
<!-- 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" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="row g-4">
<!-- Left: Commission Info + Teacher Assignment -->
<div class="col-12 col-lg-4">
<!-- Commission Card -->
<div class="card shadow-sm border-0 mb-4">
<div class="card-header border-0 bg-primary text-white rounded-top py-3">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-diagram-3 fs-4"></i>
<div>
<div class="fw-bold font-monospace">{{ commission.get_full_code() }}</div>
<small class="opacity-75">{{ commission.semester }} {{ commission.year }}</small>
</div>
</div>
</div>
<div class="card-body">
<h5 class="fw-bold mb-1">{{ commission.subject.name }}</h5>
<p class="text-muted small mb-3">{{ commission.subject.code }}
{% if commission.subject.career_obj %}— {{ commission.subject.career_obj.name }}{% endif %}
</p>
<div class="d-flex flex-wrap gap-2 mb-3">
{% if commission.shift %}
{% set shift_colors = {'Mañana': 'warning', 'Tarde': 'info', 'Vespertino': 'secondary', 'Noche': 'dark'} %}
<span class="badge bg-{{ shift_colors.get(commission.shift, 'secondary') }}-subtle text-{{ shift_colors.get(commission.shift, 'secondary') }} border border-{{ shift_colors.get(commission.shift, 'secondary') }}-subtle">
<i class="bi bi-clock me-1"></i>{{ commission.shift }}
</span>
{% endif %}
<span class="badge {% if commission.active %}bg-success-subtle text-success{% else %}bg-secondary-subtle text-secondary{% endif %}">
{{ 'Activa' if commission.active else 'Inactiva' }}
</span>
</div>
<ul class="list-unstyled small mb-0">
<li class="d-flex justify-content-between py-1 border-bottom">
<span class="text-muted">Cupo máximo</span>
<strong>{{ commission.max_students }}</strong>
</li>
<li class="d-flex justify-content-between py-1 border-bottom">
<span class="text-muted">Inscriptos</span>
<strong class="{% if (commission.current_students or 0) >= commission.max_students %}text-danger{% else %}text-success{% endif %}">
{{ commission.current_students or 0 }}
</strong>
</li>
{% if commission.schedule %}
<li class="d-flex justify-content-between py-1 border-bottom">
<span class="text-muted">Horario</span>
<span>{{ commission.schedule }}</span>
</li>
{% endif %}
{% if commission.virtual_link %}
<li class="py-1">
<a href="{{ commission.virtual_link }}" target="_blank" class="btn btn-sm btn-outline-info w-100">
<i class="bi bi-camera-video me-1"></i>Acceso Virtual
</a>
</li>
{% endif %}
</ul>
</div>
</div>
<!-- Teacher Assignment -->
<div class="card shadow-sm border-0">
<div class="card-header border-0 bg-transparent py-2 d-flex align-items-center gap-2">
<i class="bi bi-person-video3 text-info"></i>
<span class="fw-semibold">Docente Asignado</span>
</div>
<div class="card-body">
{% if commission.teacher %}
<div class="d-flex align-items-center gap-3 mb-3 p-2 rounded bg-info-subtle border border-info-subtle">
<div class="avatar-sm bg-info rounded-circle d-flex align-items-center justify-content-center" style="width:40px;height:40px;flex-shrink:0;">
<i class="bi bi-person-fill text-white"></i>
</div>
<div>
<div class="fw-semibold small">{{ commission.teacher.name }}</div>
<div class="text-muted" style="font-size:.75rem;">{{ commission.teacher.email }}</div>
</div>
</div>
{% else %}
<div class="alert alert-warning py-2 small mb-3">
<i class="bi bi-exclamation-triangle me-1"></i>Sin docente asignado
</div>
{% endif %}
{% if can_edit %}
<form method="POST" action="{{ url_for('admin.commission_assign_teacher', id=commission.id) }}" id="assign-teacher-form">
<div class="mb-2">
<label class="form-label small fw-semibold mb-1">Cambiar / Asignar Docente</label>
<select name="teacher_id" class="form-select form-select-sm" id="teacher-select">
<option value="">— Sin asignar —</option>
{% for teacher in teachers %}
<option value="{{ teacher.id }}" {% if commission.teacher_id == teacher.id %}selected{% endif %}>
{{ teacher.name }}
</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-info btn-sm w-100" id="assign-teacher-btn">
<i class="bi bi-person-check me-1"></i>Guardar Docente
</button>
</form>
{% endif %}
</div>
</div>
</div>
<!-- Right: Students -->
<div class="col-12 col-lg-8">
<!-- Enrolled Students -->
<div class="card shadow-sm border-0 mb-4">
<div class="card-header border-0 bg-transparent py-2 d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-mortarboard text-success"></i>
<span class="fw-semibold">Alumnos Inscriptos</span>
<span class="badge bg-success-subtle text-success">{{ enrollments | length }}</span>
</div>
{% if can_edit %}
<button class="btn btn-sm btn-success" data-bs-toggle="collapse" data-bs-target="#enroll-form" id="add-student-btn">
<i class="bi bi-person-plus me-1"></i>Inscribir Alumno
</button>
{% endif %}
</div>
<!-- Enroll Student Form (collapsible) -->
{% if can_edit %}
<div class="collapse" id="enroll-form">
<div class="card-body border-top bg-success-subtle py-3">
<form method="POST" action="{{ url_for('admin.commission_enroll_student', id=commission.id) }}" id="enroll-student-form">
<div class="row g-2 align-items-end">
<div class="col-12 col-md-6">
<label class="form-label small fw-semibold mb-1">Seleccionar Alumno</label>
<select name="student_id" class="form-select form-select-sm" required id="student-select">
<option value="">— Elegir alumno —</option>
{% for student in students %}
{% if student.id not in enrolled_ids %}
<option value="{{ student.id }}">{{ student.name }} — {{ student.email }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<div class="col-12 col-md-4">
<label class="form-label small fw-semibold mb-1">Observaciones (opcional)</label>
<input type="text" name="notes" class="form-control form-control-sm" placeholder="Notas…" id="enroll-notes">
</div>
<div class="col-12 col-md-2">
<button type="submit" class="btn btn-success btn-sm w-100" id="enroll-submit-btn">
<i class="bi bi-plus-circle me-1"></i>Inscribir
</button>
</div>
</div>
</form>
</div>
</div>
{% endif %}
<div class="card-body p-0">
{% if enrollments %}
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle" id="enrollments-table">
<thead class="table-light">
<tr>
<th class="ps-3">#</th>
<th>Alumno</th>
<th class="text-center">Estado</th>
<th class="text-center">Fecha Inscripción</th>
<th class="text-center">Notas</th>
{% if can_edit %}<th class="text-end pe-3">Acciones</th>{% endif %}
</tr>
</thead>
<tbody>
{% for e in enrollments %}
<tr id="enrollment-row-{{ e.student_id }}">
<td class="ps-3 text-muted small">{{ loop.index }}</td>
<td>
<div class="fw-semibold small">{{ e.student.name if e.student else '—' }}</div>
<div class="text-muted" style="font-size:.75rem;">{{ e.student.email if e.student else '' }}</div>
</td>
<td class="text-center">
{% set st_colors = {'activo': 'success', 'retirado': 'danger', 'condicional': 'warning'} %}
<span class="badge bg-{{ st_colors.get(e.status, 'secondary') }}-subtle text-{{ st_colors.get(e.status, 'secondary') }} border border-{{ st_colors.get(e.status, 'secondary') }}-subtle">
{{ e.status | capitalize }}
</span>
</td>
<td class="text-center small text-muted">
{{ e.enrolled_at.strftime('%d/%m/%Y') if e.enrolled_at else '—' }}
</td>
<td class="text-center small text-muted">{{ e.notes or '—' }}</td>
{% if can_edit %}
<td class="text-end pe-3">
<div class="d-flex gap-1 justify-content-end">
<!-- Quick status update -->
<form method="POST" action="{{ url_for('admin.commission_update_enrollment', id=commission.id, student_id=e.student_id) }}" class="d-inline">
<select name="status" class="form-select form-select-sm d-inline-block w-auto"
onchange="this.form.submit()" title="Cambiar estado"
id="status-select-{{ e.student_id }}">
<option value="activo" {% if e.status == 'activo' %}selected{% endif %}>Activo</option>
<option value="condicional" {% if e.status == 'condicional' %}selected{% endif %}>Condicional</option>
<option value="retirado" {% if e.status == 'retirado' %}selected{% endif %}>Retirado</option>
</select>
</form>
<!-- Remove button -->
<form method="POST" action="{{ url_for('admin.commission_unenroll_student', id=commission.id, student_id=e.student_id) }}"
onsubmit="return confirm('¿Dar de baja a {{ e.student.name if e.student else 'este alumno' }} de la comisión?')">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Dar de baja"
id="unenroll-btn-{{ e.student_id }}">
<i class="bi bi-person-dash"></i>
</button>
</form>
</div>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-people display-5 text-muted opacity-40"></i>
<p class="text-muted mt-3 small">No hay alumnos inscriptos en esta comisión.</p>
{% if can_edit %}
<button class="btn btn-outline-success btn-sm" data-bs-toggle="collapse" data-bs-target="#enroll-form">
<i class="bi bi-person-plus me-1"></i>Inscribir primer alumno
</button>
{% endif %}
</div>
{% endif %}
</div>
</div>
<!-- Capacity Bar -->
{% set enrolled_count = enrollments | length %}
{% set max_students = commission.max_students or 1 %}
{% set cap_pct = [((enrolled_count / max_students) * 100) | round | int, 100] | min %}
<div class="card shadow-sm border-0">
<div class="card-body py-3">
<div class="d-flex justify-content-between small fw-semibold mb-1">
<span><i class="bi bi-bar-chart-fill me-1 text-primary"></i>Ocupación del cupo</span>
<span>{{ enrolled_count }} / {{ max_students }} ({{ cap_pct }}%)</span>
</div>
<div class="progress" style="height:10px;">
<div class="progress-bar {% if cap_pct >= 90 %}bg-danger{% elif cap_pct >= 70 %}bg-warning{% else %}bg-success{% endif %}"
style="width:{{ cap_pct }}%;" role="progressbar"
aria-valuenow="{{ cap_pct }}" aria-valuemin="0" aria-valuemax="100">
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
-209
View File
@@ -1,209 +0,0 @@
{% extends 'base.html' %}
{% block title %}Gestión de Comisiones — Edu-Space{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header -->
<div class="d-flex align-items-center justify-content-between mb-4 flex-wrap gap-2">
<div>
<h1 class="h3 fw-bold mb-1">
<i class="bi bi-diagram-3 me-2 text-primary"></i>Gestión de Comisiones
</h1>
<p class="text-muted mb-0 small">Asignación de docentes e inscripción de alumnos por comisión</p>
</div>
<a href="{{ url_for('admin.subjects_list') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-book me-1"></i>Ver Asignaturas
</a>
</div>
<!-- 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" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- Filters Card -->
<div class="card shadow-sm border-0 mb-4">
<div class="card-body py-3">
<form method="GET" class="row g-2 align-items-end" id="filter-form">
<div class="col-12 col-md-3">
<label class="form-label small fw-semibold mb-1">Buscar</label>
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" name="search" class="form-control" placeholder="Código o asignatura…"
value="{{ search or '' }}" id="search-input">
</div>
</div>
<div class="col-6 col-md-3">
<label class="form-label small fw-semibold mb-1">Asignatura</label>
<select name="subject_id" class="form-select form-select-sm" id="subject-filter">
<option value="">Todas</option>
{% for subj in subjects %}
<option value="{{ subj.id }}" {% if subject_filter == subj.id %}selected{% endif %}>
{{ subj.name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-6 col-md-2">
<label class="form-label small fw-semibold mb-1">Turno</label>
<select name="shift" class="form-select form-select-sm" id="shift-filter">
<option value="">Todos</option>
{% for shift in ['Mañana', 'Tarde', 'Vespertino', 'Noche'] %}
<option value="{{ shift }}" {% if shift_filter == shift %}selected{% endif %}>{{ shift }}</option>
{% endfor %}
</select>
</div>
<div class="col-6 col-md-2">
<label class="form-label small fw-semibold mb-1">Estado</label>
<select name="active" class="form-select form-select-sm" id="active-filter">
<option value="true" {% if active_filter == 'true' %}selected{% endif %}>Activas</option>
<option value="false" {% if active_filter == 'false' %}selected{% endif %}>Inactivas</option>
<option value="" {% if not active_filter %}selected{% endif %}>Todas</option>
</select>
</div>
<div class="col-6 col-md-2 d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm flex-fill" id="filter-btn">
<i class="bi bi-funnel me-1"></i>Filtrar
</button>
<a href="{{ url_for('admin.commissions_list') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-x"></i>
</a>
</div>
</form>
</div>
</div>
<!-- Commissions Table -->
<div class="card shadow-sm border-0">
<div class="card-header bg-transparent border-bottom d-flex align-items-center justify-content-between py-2">
<span class="fw-semibold small">
<i class="bi bi-list-ul me-1 text-primary"></i>
{{ pagination.total }} comisión(es) encontrada(s)
</span>
<span class="badge bg-primary-subtle text-primary">Página {{ pagination.page }} / {{ pagination.pages or 1 }}</span>
</div>
<div class="card-body p-0">
{% if commissions %}
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle" id="commissions-table">
<thead class="table-light">
<tr>
<th class="ps-3">Comisión</th>
<th>Asignatura</th>
<th>Docente</th>
<th class="text-center">Turno</th>
<th class="text-center">Alumnos</th>
<th class="text-center">Estado</th>
<th class="text-end pe-3">Acción</th>
</tr>
</thead>
<tbody>
{% for comm in commissions %}
<tr>
<td class="ps-3">
<span class="badge bg-primary-subtle text-primary fw-semibold font-monospace">
{{ comm.get_full_code() }}
</span>
</td>
<td>
<div class="fw-semibold">{{ comm.subject.name }}</div>
<small class="text-muted">{{ comm.subject.code }} — {{ comm.semester }} {{ comm.year }}</small>
</td>
<td>
{% if comm.teacher %}
<div class="d-flex align-items-center gap-2">
<div class="avatar-xs bg-info-subtle rounded-circle d-flex align-items-center justify-content-center" style="width:28px;height:28px;">
<i class="bi bi-person-video3 small text-info"></i>
</div>
<span class="small">{{ comm.teacher.name }}</span>
</div>
{% else %}
<span class="text-muted small fst-italic"><i class="bi bi-exclamation-circle me-1 text-warning"></i>Sin asignar</span>
{% endif %}
</td>
<td class="text-center">
{% if comm.shift %}
{% set shift_colors = {'Mañana': 'warning', 'Tarde': 'info', 'Vespertino': 'secondary', 'Noche': 'dark'} %}
<span class="badge bg-{{ shift_colors.get(comm.shift, 'secondary') }}-subtle text-{{ shift_colors.get(comm.shift, 'secondary') }} border border-{{ shift_colors.get(comm.shift, 'secondary') }}-subtle">
{{ comm.shift }}
</span>
{% else %}
<span class="text-muted small">—</span>
{% endif %}
</td>
<td class="text-center">
<span class="fw-semibold">{{ comm.current_students or 0 }}</span>
<span class="text-muted small">/ {{ comm.max_students }}</span>
</td>
<td class="text-center">
{% if comm.active %}
<span class="badge bg-success-subtle text-success border border-success-subtle">Activa</span>
{% else %}
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">Inactiva</span>
{% endif %}
</td>
<td class="text-end pe-3">
<a href="{{ url_for('admin.commission_detail', id=comm.id) }}"
class="btn btn-sm btn-outline-primary" title="Ver y gestionar comisión">
<i class="bi bi-gear me-1"></i>Gestionar
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if pagination.pages > 1 %}
<div class="d-flex justify-content-center py-3">
<nav>
<ul class="pagination pagination-sm mb-0">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.commissions_list', page=pagination.prev_num, search=search, subject_id=subject_filter, shift=shift_filter, active=active_filter) }}">
<i class="bi bi-chevron-left"></i>
</a>
</li>
{% endif %}
{% for pg in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if pg %}
<li class="page-item {% if pg == pagination.page %}active{% endif %}">
<a class="page-link" href="{{ url_for('admin.commissions_list', page=pg, search=search, subject_id=subject_filter, shift=shift_filter, active=active_filter) }}">{{ pg }}</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.commissions_list', page=pagination.next_num, search=search, subject_id=subject_filter, shift=shift_filter, active=active_filter) }}">
<i class="bi bi-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="bi bi-diagram-3 display-4 text-muted opacity-50"></i>
<p class="mt-3 text-muted">No se encontraron comisiones con los filtros aplicados.</p>
<a href="{{ url_for('admin.commissions_list') }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-arrow-clockwise me-1"></i>Limpiar filtros
</a>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
-242
View File
@@ -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 %}
-306
View File
@@ -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 %}
-107
View File
@@ -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 %}
-322
View File
@@ -1,322 +0,0 @@
<!DOCTYPE html>
<html lang="{{ current_lang }}" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{% trans %}Edu-Space Admin{% endtrans %}{% endblock %}</title>
<!-- Favicon -->
<link rel="icon" type="image/png" href="{{ url_for('static', filename='img/favicon.png') }}">
<!-- Script Anti-Flicker para Modo Claro / Oscuro -->
<script>
(function() {
let userTheme = "{{ current_user.theme_preference if (current_user and current_user.is_authenticated) else '' }}";
let theme = userTheme;
if (!theme || theme === 'auto') {
const storedTheme = localStorage.getItem('theme');
theme = storedTheme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
}
document.documentElement.setAttribute('data-bs-theme', theme);
})();
</script>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
<!-- Design System & Custom Theme -->
<link href="{{ url_for('static', filename='css/theme.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
{% block extra_css %}{% endblock %}
</head>
<body data-user-authenticated="{{ 'true' if (current_user and current_user.is_authenticated) else 'false' }}">
{% if current_user.is_authenticated %}
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark navbar-app fixed-top">
<div class="container-fluid">
<a class="navbar-brand d-flex align-items-center" href="{{ url_for('main.dashboard') }}">
<img src="{{ url_for('static', filename='img/logo_unicaba_mini.png') }}" alt="Logo UniCABA" class="navbar-logo me-2">
<span>Edu-Space Admin</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
{% set user_role = current_user.get_institutional_role() %}
<ul class="navbar-nav me-auto">
{% if user_role == 'admin' %}
<!-- ================= ADMIN NAVIGATION ================= -->
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
<i class="bi bi-speedometer2 me-1"></i>{% trans %}Panel General{% endtrans %}
</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-door-open me-1"></i>{% trans %}Espacios & Sedes{% endtrans %}
</a>
<ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}"><i class="bi bi-door-open me-2 text-primary"></i>{% trans %}Aulas Presenciales{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}"><i class="bi bi-camera-video me-2 text-info"></i>{% trans %}Aulas Virtuales (Reunión){% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms') }}"><i class="bi bi-grid me-2 text-secondary"></i>{% trans %}Todas las Aulas{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('classrooms.add_classroom') }}"><i class="bi bi-plus-circle me-2 text-success"></i>{% trans %}Nueva Aula{% endtrans %}</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2 text-secondary"></i>{% trans %}Edificios y Sedes{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.occupancy_metrics') }}"><i class="bi bi-graph-up-arrow me-2 text-success"></i>{% trans %}Métricas de Ocupación{% endtrans %}</a></li>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-calendar3 me-1"></i>{% trans %}Cronograma & Reservas{% endtrans %}
</a>
<ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="{{ url_for('schedule.calendar_view') }}"><i class="bi bi-calendar-week me-2 text-primary"></i>{% trans %}Calendario General{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}"><i class="bi bi-calendar-day me-2 text-info"></i>{% trans %}Cartelera del Día{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}"><i class="bi bi-list-check me-2 text-warning"></i>{% trans %}Gestión de Reservas{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}"><i class="bi bi-plus-circle me-2 text-success"></i>{% trans %}Nueva Reserva{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.commissions_view') }}"><i class="bi bi-diagram-3 me-2 text-primary"></i>{% trans %}Comisiones y Cursadas{% endtrans %}</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('genetic_algorithm_web.genetic_optimizer') }}"><i class="bi bi-cpu me-2 text-info"></i>{% trans %}Optimizador de Aulas (IA){% endtrans %}</a></li>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-mortarboard me-1"></i>{% trans %}Gestión Académica{% endtrans %}
</a>
<ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="{{ url_for('admin.careers_list') }}"><i class="bi bi-mortarboard me-2 text-primary"></i>{% trans %}Carreras{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.subjects_list') }}"><i class="bi bi-book me-2 text-info"></i>{% trans %}Asignaturas{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.commissions_list') }}"><i class="bi bi-diagram-3 me-2 text-success"></i>{% trans %}Comisiones{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.academic_terms_list') }}"><i class="bi bi-calendar-range me-2 text-warning"></i>{% trans %}Ciclo Lectivo{% endtrans %}</a></li>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-shield-lock me-1"></i>{% trans %}Administración{% endtrans %}
</a>
<ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="{{ url_for('admin.users_list') }}"><i class="bi bi-people me-2 text-primary"></i>{% trans %}Usuarios{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.roles_list') }}"><i class="bi bi-person-badge me-2 text-info"></i>{% trans %}Roles y Permisos RBAC{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.milestone_types_list') }}"><i class="bi bi-tags me-2 text-secondary"></i>{% trans %}Tipificaciones de Hitos{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.google_sheets_import') }}"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>{% trans %}Sincronización Sheets{% endtrans %}</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('admin.audit_logs') }}"><i class="bi bi-shield-check me-2 text-warning"></i>{% trans %}Registro de Auditoría{% endtrans %}</a></li>
</ul>
</li>
{% elif user_role == 'bedelia' %}
<!-- ================= BEDELÍA NAVIGATION ================= -->
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
<i class="bi bi-speedometer2 me-1"></i>{% trans %}Panel Bedelía{% endtrans %}
</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-door-open me-1"></i>{% trans %}Espacios & Aulas{% endtrans %}
</a>
<ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}"><i class="bi bi-door-open me-2 text-primary"></i>{% trans %}Aulas Presenciales{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}"><i class="bi bi-camera-video me-2 text-info"></i>{% trans %}Aulas Virtuales (Reunión){% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms') }}"><i class="bi bi-grid me-2 text-secondary"></i>{% trans %}Todas las Aulas{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('classrooms.add_classroom') }}"><i class="bi bi-plus-circle me-2 text-success"></i>{% trans %}Nueva Aula{% endtrans %}</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2 text-secondary"></i>{% trans %}Edificios y Sedes{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.occupancy_metrics') }}"><i class="bi bi-graph-up-arrow me-2 text-success"></i>{% trans %}Métricas de Ocupación{% endtrans %}</a></li>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-calendar3 me-1"></i>{% trans %}Operaciones & Reservas{% endtrans %}
</a>
<ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}"><i class="bi bi-calendar-day me-2 text-info"></i>{% trans %}Cartelera del Día{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.calendar_view') }}"><i class="bi bi-calendar-week me-2 text-primary"></i>{% trans %}Calendario General{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}"><i class="bi bi-list-check me-2 text-warning"></i>{% trans %}Gestión de Reservas{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}"><i class="bi bi-plus-circle me-2 text-success"></i>{% trans %}Registrar Reserva{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.commissions_view') }}"><i class="bi bi-diagram-3 me-2 text-primary"></i>{% trans %}Comisiones y Cursadas{% endtrans %}</a></li>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-mortarboard me-1"></i>{% trans %}Académica & Cursadas{% endtrans %}
</a>
<ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="{{ url_for('admin.careers_list') }}"><i class="bi bi-mortarboard me-2 text-primary"></i>{% trans %}Carreras{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.subjects_list') }}"><i class="bi bi-book me-2 text-info"></i>{% trans %}Asignaturas{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.commissions_list') }}"><i class="bi bi-diagram-3 me-2 text-success"></i>{% trans %}Gestión de Comisiones{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.academic_terms_list') }}"><i class="bi bi-calendar-range me-2 text-warning"></i>{% trans %}Ciclo Lectivo{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.milestone_types_list') }}"><i class="bi bi-tags me-2 text-secondary"></i>{% trans %}Tipificaciones de Hitos{% endtrans %}</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('admin.google_sheets_import') }}"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>{% trans %}Sincronización Sheets{% endtrans %}</a></li>
</ul>
</li>
{% elif user_role == 'docente' %}
<!-- ================= DOCENTE / PROFESOR NAVIGATION ================= -->
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
<i class="bi bi-speedometer2 me-1"></i>{% trans %}Panel Docente{% endtrans %}
</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-calendar-check me-1"></i>{% trans %}Mis Clases & Reservas{% endtrans %}
</a>
<ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}"><i class="bi bi-calendar2-check me-2 text-primary"></i>{% trans %}Mis Reservas de Aulas{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}"><i class="bi bi-plus-circle me-2 text-success"></i>{% trans %}Solicitar Nueva Reserva{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.commissions_view') }}"><i class="bi bi-diagram-3 me-2 text-info"></i>{% trans %}Mis Comisiones & Cursadas{% endtrans %}</a></li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link fw-semibold" href="{{ url_for('my_subjects.my_subjects_list') }}">
<i class="bi bi-mortarboard me-1 text-info"></i>{% trans %}Mis Materias{% endtrans %}
</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-building me-1"></i>{% trans %}Campus & Horarios{% endtrans %}
</a>
<ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}"><i class="bi bi-calendar-day me-2 text-info"></i>{% trans %}Cartelera del Día (¿Dónde curso hoy?){% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.calendar_view') }}"><i class="bi bi-calendar-week me-2 text-primary"></i>{% trans %}Calendario General de Aulas{% endtrans %}</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}"><i class="bi bi-camera-video me-2 text-info"></i>{% trans %}Aulas Virtuales (Reuniones){% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}"><i class="bi bi-door-open me-2 text-secondary"></i>{% trans %}Consulta de Aulas Presenciales{% endtrans %}</a></li>
</ul>
</li>
{% else %}
<!-- ================= ALUMNO / ESTUDIANTE NAVIGATION ================= -->
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
<i class="bi bi-house-door me-1"></i>{% trans %}Mi Portal{% endtrans %}
</a>
</li>
<li class="nav-item">
<a class="nav-link fw-semibold" href="{{ url_for('my_subjects.my_subjects_list') }}">
<i class="bi bi-mortarboard me-1 text-success"></i>{% trans %}Mis Materias{% endtrans %}
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('schedule.today_schedule') }}">
<i class="bi bi-geo-alt me-1"></i>{% trans %}Cartelera de Hoy{% endtrans %}
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('schedule.calendar_view') }}">
<i class="bi bi-calendar-week me-1"></i>{% trans %}Cronograma de Clases{% endtrans %}
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}">
<i class="bi bi-camera-video me-1"></i>{% trans %}Aulas Virtuales{% endtrans %}
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}">
<i class="bi bi-door-open me-1"></i>{% trans %}Espacios del Campus{% endtrans %}
</a>
</li>
{% endif %}
</ul>
<ul class="navbar-nav align-items-center">
<!-- Theme Mode Toggle Button -->
<li class="nav-item me-2">
<button class="theme-toggle-btn" type="button" aria-label="Cambiar tema" title="Modo Claro/Oscuro">
<i class="bi bi-moon-stars-fill"></i>
</button>
</li>
<!-- Language Switcher -->
<li class="nav-item me-2">
{% include 'partials/language_switcher.html' %}
</li>
<!-- User Dropdown with Avatar and Institutional Role -->
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle d-flex align-items-center py-1" href="#" role="button" data-bs-toggle="dropdown">
<span class="user-avatar-small">{{ current_user.name[:1]|upper if current_user.name else 'U' }}</span>
<div class="d-none d-lg-block text-start ms-2">
<div class="lh-1 small fw-semibold">{{ current_user.first_name }}</div>
<span class="badge {{ current_user.role_badge_display.class }} role-pill mt-1" style="font-size: 0.65rem; padding: 0.15rem 0.45rem;">
<i class="bi {{ current_user.role_badge_display.icon }}"></i> {{ current_user.role_badge_display.label }}
</span>
</div>
</a>
<ul class="dropdown-menu dropdown-menu-end shadow-sm border-0">
<li>
<div class="px-3 py-2 border-bottom">
<small class="text-muted d-block">{% trans %}Signed in as{% endtrans %}</small>
<span class="fw-bold small d-block">{{ current_user.email }}</span>
<span class="badge {{ current_user.role_badge_display.class }} role-pill mt-1">
<i class="bi {{ current_user.role_badge_display.icon }}"></i> {{ current_user.role_badge_display.label }}
</span>
</div>
</li>
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">
<i class="bi bi-person me-2"></i> {% trans %}Profile{% endtrans %}
</a></li>
<li><a class="dropdown-item" href="{{ url_for('auth.change_password') }}">
<i class="bi bi-key me-2"></i> {% trans %}Change Password{% endtrans %}
</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="{{ url_for('auth.logout') }}">
<i class="bi bi-box-arrow-right me-2"></i> {% trans %}Logout{% endtrans %}
</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
{% endif %}
<!-- Flash Messages -->
<div class="toast-container position-fixed bottom-0 end-0 p-3" style="z-index: 1080;">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="toast align-items-center text-white bg-{{ 'danger' if category == 'error' else 'success' if category == 'success' else 'info' if category == 'info' else 'warning' }} border-0 shadow" role="alert">
<div class="d-flex">
<div class="toast-body">
{{ message }}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
<!-- Main Content -->
{% block content %}{% endblock %}
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<!-- Theme Toggle JS -->
<script src="{{ url_for('static', filename='js/theme-toggle.js') }}"></script>
<!-- Custom JS -->
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
<!-- View Switcher JS -->
<script src="{{ url_for('static', filename='js/view_switcher.js') }}"></script>
<!-- Show flash messages -->
<script>
$(document).ready(function() {
$('.toast').toast('show');
});
</script>
{% block extra_js %}{% endblock %}
</body>
</html>
+17 -25
View File
@@ -1,7 +1,6 @@
from flask import Flask, request, session from flask import Flask, request, session
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate from flask_migrate import Migrate
from flask_login import LoginManager
from flask_cors import CORS from flask_cors import CORS
from flask_babel import Babel, gettext as _ from flask_babel import Babel, gettext as _
from config.config import Config from config.config import Config
@@ -9,7 +8,6 @@ from config.config import Config
# Initialize extensions # Initialize extensions
db = SQLAlchemy() db = SQLAlchemy()
migrate = Migrate() migrate = Migrate()
login_manager = LoginManager()
cors = CORS() cors = CORS()
babel = Babel() babel = Babel()
@@ -20,37 +18,31 @@ def create_app(config_class=Config):
# Initialize extensions with app # Initialize extensions with app
db.init_app(app) db.init_app(app)
migrate.init_app(app, db) migrate.init_app(app, db)
login_manager.init_app(app)
cors.init_app(app) cors.init_app(app)
# Login manager configuration # Import models to ensure they are registered
from flask_babel import lazy_gettext
login_manager.login_view = 'auth.login'
login_manager.login_message = lazy_gettext('Por favor inicia sesión para acceder a esta página.')
login_manager.login_message_category = 'info'
# Import models to ensure they are registered
from app.models import user from app.models import user
# Register blueprints
from app.routes import auth_bp, classrooms_bp, buildings_bp, main_bp, schedule_bp, genetic_bp, genetic_web_bp, admin_bp, my_subjects_bp
from app.routes.api import api_auth_bp, api_classrooms_bp, api_reservations_bp, api_optimizer_bp
from app.security import SecurityFilterChain
app.register_blueprint(auth_bp, url_prefix="/")
app.register_blueprint(classrooms_bp, url_prefix="/classrooms")
app.register_blueprint(buildings_bp)
app.register_blueprint(main_bp, url_prefix="/")
app.register_blueprint(schedule_bp, url_prefix="/schedule")
app.register_blueprint(genetic_bp)
app.register_blueprint(genetic_web_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(my_subjects_bp)
# Register REST API (v1) Blueprints with Stateless JWT support # Register REST API (v1) Blueprints with Stateless JWT support
from app.routes.api.auth import api_auth_bp
from app.routes.api.classrooms import api_classrooms_bp
from app.routes.api.reservations import api_reservations_bp
from app.routes.api.optimizer import api_optimizer_bp
from app.routes.api.dashboard import api_dashboard_bp
from app.routes.api.admin import api_admin_bp
from app.routes.api.openapi import api_openapi_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_auth_bp)
app.register_blueprint(api_classrooms_bp) app.register_blueprint(api_classrooms_bp)
app.register_blueprint(api_reservations_bp) app.register_blueprint(api_reservations_bp)
app.register_blueprint(api_optimizer_bp) app.register_blueprint(api_optimizer_bp)
app.register_blueprint(api_dashboard_bp)
app.register_blueprint(api_openapi_bp)
app.register_blueprint(genetic_bp)
app.register_blueprint(api_admin_bp, url_prefix='/api/v1')
app.register_blueprint(api_admin_bp, url_prefix='/api/v1/admin', name='api_admin_prefixed')
# Initialize Enterprise Security Pipeline (SecurityFilterChain) # Initialize Enterprise Security Pipeline (SecurityFilterChain)
SecurityFilterChain(app) SecurityFilterChain(app)
+1
View File
@@ -0,0 +1 @@
from .document_types import DOCUMENT_TYPES, DOCUMENT_TYPE_MAP, get_document_type, validate_document, format_document
+160
View File
@@ -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,13 +3,14 @@ from .building import Building
from .classroom import Classroom, ClassroomResource from .classroom import Classroom, ClassroomResource
from .career import Career from .career import Career
from .academic_term import AcademicTerm from .academic_term import AcademicTerm
from .subject import Subject, Commission from .subject import Subject, Commission, CommissionTeacher
from .reservation import Reservation, ReservationStatus from .reservation import Reservation, ReservationStatus
from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer
from .role import Role, Permission, SYSTEM_MODULES from .role import Role, Permission, SYSTEM_MODULES
from .milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES from .milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES
from .audit_log import AuditLog from .audit_log import AuditLog
from .enrollment import StudentEnrollment from .enrollment import StudentEnrollment
from .setting import SystemSetting
__all__ = [ __all__ = [
'User', 'User',
@@ -20,6 +21,7 @@ __all__ = [
'AcademicTerm', 'AcademicTerm',
'Subject', 'Subject',
'Commission', 'Commission',
'CommissionTeacher',
'Reservation', 'Reservation',
'ReservationStatus', 'ReservationStatus',
'GeneticAlgorithm', 'GeneticAlgorithm',
@@ -31,5 +33,6 @@ __all__ = [
'AcademicMilestone', 'AcademicMilestone',
'BASE_MILESTONE_TYPES', 'BASE_MILESTONE_TYPES',
'AuditLog', 'AuditLog',
'StudentEnrollment' 'StudentEnrollment',
'SystemSetting'
] ]
@@ -23,6 +23,8 @@ class StudentEnrollment(db.Model):
enrolled_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) enrolled_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
notes = db.Column(db.Text, nullable=True) 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 # Unique constraint: a student can only be enrolled once per commission
__table_args__ = ( __table_args__ = (
@@ -43,6 +45,8 @@ class StudentEnrollment(db.Model):
'status': self.status, 'status': self.status,
'enrolled_at': self.enrolled_at.isoformat() if self.enrolled_at else None, 'enrolled_at': self.enrolled_at.isoformat() if self.enrolled_at else None,
'notes': self.notes, 'notes': self.notes,
'allow_same_day_exception': bool(self.allow_same_day_exception),
'exception_reason': self.exception_reason
} }
def __repr__(self): def __repr__(self):
@@ -95,8 +95,8 @@ class Reservation(db.Model):
@property @property
def is_virtual(self): def is_virtual(self):
"""Verifica si la reserva es virtual por aula o por contar con enlace virtual""" """Verifica si la reserva es virtual por aula o por contar con enlace virtual"""
if self.classroom and self.classroom.is_virtual: if self.classroom:
return True return bool(self.classroom.is_virtual)
if self.effective_virtual_link: if self.effective_virtual_link:
return True return True
return False return False
+50
View File
@@ -0,0 +1,50 @@
from app import db
from datetime import datetime
class SystemSetting(db.Model):
"""
Parámetros de configuración del sistema persistentes en base de datos.
Permite almacenar enlaces de integración (e.g. Google Sheets), flags globales y metadatos.
"""
__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)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@classmethod
def get_value(cls, key, default=None):
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
@classmethod
def set_value(cls, key, value, description=None):
record = cls.query.filter_by(key=key).first()
if not record:
record = cls(key=key, value=value, description=description)
db.session.add(record)
else:
record.value = value
if description:
record.description = description
record.updated_at = datetime.utcnow()
db.session.commit()
return record
def to_dict(self):
return {
'key': self.key,
'value': self.value,
'description': self.description,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}
def __repr__(self):
return f'<SystemSetting {self.key}={self.value}>'
@@ -1,117 +1,185 @@
from app import db from app import db
from datetime import datetime from datetime import datetime
class Subject(db.Model): class Subject(db.Model):
__tablename__ = 'subjects' __tablename__ = 'subjects'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
code = db.Column(db.String(50), unique=True, nullable=False, index=True) code = db.Column(db.String(50), unique=True, nullable=False, index=True)
name = db.Column(db.String(200), nullable=False) name = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text) description = db.Column(db.Text)
department = db.Column(db.String(100), nullable=False, default='') department = db.Column(db.String(100), nullable=False, default='')
credits = db.Column(db.Integer, nullable=False, default=4) credits = db.Column(db.Integer, nullable=False, default=4)
career_id = db.Column(db.Integer, db.ForeignKey('careers.id', ondelete='SET NULL'), nullable=True) 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_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) is_short_course = db.Column(db.Boolean, default=False, nullable=True) # True para talleres/cursos cortos
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) 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') # Relationships
commissions = db.relationship('Commission', backref='subject', lazy=True, cascade='all, delete-orphan')
def to_dict(self):
"""Convert subject to dictionary""" def to_dict(self):
return { """Convert subject to dictionary"""
'id': self.id, return {
'code': self.code, 'id': self.id,
'name': self.name, 'code': self.code,
'description': self.description, 'name': self.name,
'department': self.department, 'description': self.description,
'credits': self.credits, 'department': self.department,
'career_id': self.career_id, 'credits': self.credits,
'career_name': self.career_obj.name if self.career_obj else None, 'career_id': self.career_id,
'is_active': self.is_active, 'career_name': self.career_obj.name if self.career_obj else None,
'created_at': self.created_at.isoformat() if self.created_at else None, 'is_active': self.is_active,
'updated_at': self.updated_at.isoformat() if self.updated_at else None '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}>'
def __repr__(self):
class Commission(db.Model): return f'<Subject {self.code}>'
__tablename__ = 'commissions'
class Commission(db.Model):
id = db.Column(db.Integer, primary_key=True) __tablename__ = 'commissions'
subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=False)
code = db.Column(db.String(50), nullable=False) id = db.Column(db.Integer, primary_key=True)
semester = db.Column(db.String(20), nullable=False) subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=False)
year = db.Column(db.Integer, nullable=False) code = db.Column(db.String(50), nullable=False)
teacher_id = db.Column(db.Integer, db.ForeignKey('users.id')) semester = db.Column(db.String(20), nullable=False)
max_students = db.Column(db.Integer, nullable=False) year = db.Column(db.Integer, nullable=False)
current_students = db.Column(db.Integer, default=0) teacher_id = db.Column(db.Integer, db.ForeignKey('users.id'))
schedule = db.Column(db.String(255)) max_students = db.Column(db.Integer, nullable=False)
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche current_students = db.Column(db.Integer, default=0)
virtual_link = db.Column(db.String(500), nullable=True) # Link Zoom, Meet, Teams, Moodle schedule = db.Column(db.String(255))
active = db.Column(db.Boolean, default=True) # Changed from is_active to active shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
created_at = db.Column(db.DateTime, default=datetime.utcnow) virtual_link = db.Column(db.String(500), nullable=True) # Link Zoom, Meet, Teams, Moodle
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) active = db.Column(db.Boolean, default=True) # Changed from is_active to active
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Virtual property for compatibility updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@property
def is_active(self): # Virtual property for compatibility
return self.active @property
def is_active(self):
@is_active.setter return self.active
def is_active(self, value):
self.active = value @is_active.setter
def is_active(self, value):
# Relationships self.active = value
pass # Reservation already defines the relationship back to commission
teacher = db.relationship('User', backref='teaching_commissions', foreign_keys=[teacher_id]) # Relationships
pass # Reservation already defines the relationship back to commission
# Virtual field for teacher_name teacher = db.relationship('User', backref='teaching_commissions', foreign_keys=[teacher_id])
@property
def teacher_name(self): # Virtual field for teacher_name
return f"{self.teacher.first_name} {self.teacher.last_name}" if self.teacher else "TBD" @property
def teacher_name(self):
# Unique constraint return f"{self.teacher.first_name} {self.teacher.last_name}" if self.teacher else "TBD"
__table_args__ = (db.UniqueConstraint('subject_id', 'code', 'semester', 'year'),)
@property
def to_dict(self): def teachers(self):
"""Convert commission to dictionary""" """Retorna la lista de docentes asignados.
return { Si teachers_association tiene registros, los retorna ordenados (Titular primero).
'id': self.id, Si está vacía pero teacher_id está configurado, devuelve el docente titular.
'subject_id': self.subject_id, """
'code': self.code, if hasattr(self, 'teachers_association') and self.teachers_association and len(self.teachers_association) > 0:
'semester': self.semester, res = [ta.to_dict() for ta in self.teachers_association]
'year': self.year, res.sort(key=lambda x: (not x.get('is_primary', False), x.get('role', '') != 'Titular'))
'teacher_name': self.teacher_name, return res
'max_students': self.max_students, if self.teacher:
'current_students': self.current_students, return [{
'schedule': self.schedule, 'id': 0,
'shift': self.shift, 'commission_id': self.id,
'virtual_link': self.virtual_link, 'user_id': self.teacher.id,
'active': self.active, 'name': self.teacher.name,
'created_at': self.created_at.isoformat() if self.created_at else None, 'email': self.teacher.email,
'updated_at': self.updated_at.isoformat() if self.updated_at else None, 'role': 'Titular',
'subject': self.subject.to_dict() if self.subject else None 'is_primary': True
} }]
return []
def get_full_code(self):
"""Get full commission code""" @property
subj = self.subject.code if self.subject else 'N/A' def teacher_names(self):
code = self.code or '' t_list = self.teachers
sem = str(self.semester or '').strip() if not t_list:
yr = str(self.year or '').strip() return "Sin asignar"
return ", ".join([f"{t['name']} ({t['role']})" for t in t_list])
if yr and yr in sem:
return f"{subj}-{code}-{sem}" # Unique constraint
elif sem and yr: __table_args__ = (db.UniqueConstraint('subject_id', 'code', 'semester', 'year'),)
return f"{subj}-{code}-{sem}{yr}"
elif sem: def to_dict(self):
return f"{subj}-{code}-{sem}" """Convert commission to dictionary"""
elif yr: return {
return f"{subj}-{code}-{yr}" 'id': self.id,
return f"{subj}-{code}" 'subject_id': self.subject_id,
'code': self.code,
def __repr__(self): 'semester': self.semester,
return f'<Commission {self.get_full_code()}>' '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,
'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}>'
@@ -1,9 +1,8 @@
from app import db, login_manager from app import db
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime from datetime import datetime
class User(UserMixin, db.Model): class User(db.Model):
__tablename__ = 'users' __tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
@@ -18,15 +17,29 @@ class User(UserMixin, db.Model):
theme_preference = db.Column(db.String(10), default='auto', nullable=False) 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) role_id = db.Column(db.Integer, db.ForeignKey('roles.id', ondelete='SET NULL'), nullable=True)
# Virtual fields for future compatibility # Extended personal, contact and identity document fields
@property first_name = db.Column(db.String(100), nullable=True)
def first_name(self): 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 '' return self.name.split()[0] if self.name else ''
@property def get_last_name(self):
def last_name(self): if self.last_name:
parts = self.name.split() return self.last_name
parts = self.name.split() if self.name else []
return ' '.join(parts[1:]) if len(parts) > 1 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 # Relationships
reservations = db.relationship('Reservation', backref='user', lazy=True, cascade='all, delete-orphan') reservations = db.relationship('Reservation', backref='user', lazy=True, cascade='all, delete-orphan')
@@ -133,8 +146,18 @@ class User(UserMixin, db.Model):
return { return {
'id': self.id, 'id': self.id,
'email': self.email, 'email': self.email,
'institutional_email': self.email,
'personal_email': self.personal_email or '',
'name': self.name, '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': self.role,
'role_id': self.role_id,
'is_active': self.is_active, 'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None, 'created_at': self.created_at.isoformat() if self.created_at else None,
'last_login': self.last_login.isoformat() if self.last_login 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): def __repr__(self):
return f'<User {self.email}>' return f'<User {self.email}>'
@login_manager.user_loader
def load_user(user_id):
"""Load user from database by ID"""
return User.query.get(int(user_id))
@@ -59,3 +59,22 @@ class ReservationRepository(BaseRepository[Reservation]):
return Reservation.query.filter_by( return Reservation.query.filter_by(
commission_id=commission_id commission_id=commission_id
).order_by(Reservation.start_time.asc()).all() ).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()

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

File diff suppressed because it is too large Load Diff
@@ -15,7 +15,9 @@ def login():
""" """
Endpoint de autenticación para obtener par de tokens (Access + Refresh). Endpoint de autenticación para obtener par de tokens (Access + Refresh).
""" """
data = request.get_json(silent=True) or {} data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({'error': 'BadRequest', 'message': 'El cuerpo de la solicitud debe ser un objeto JSON.'}), 400
try: try:
dto = LoginDTO(**data) dto = LoginDTO(**data)
except ValidationError as e: except ValidationError as e:
@@ -56,7 +58,9 @@ def refresh():
""" """
Renovación silenciosa del Access Token utilizando el Refresh Token. Renovación silenciosa del Access Token utilizando el Refresh Token.
""" """
data = request.get_json(silent=True) or {} data = request.get_json(silent=True)
if not isinstance(data, dict):
data = {}
refresh_token = data.get('refresh_token') or request.cookies.get('refresh_token') refresh_token = data.get('refresh_token') or request.cookies.get('refresh_token')
if not refresh_token: if not refresh_token:
+338
View File
@@ -0,0 +1,338 @@
from flask import Blueprint, jsonify, request
from app.utils.jwt_decorators import jwt_required
from app.models.classroom import Classroom
from app.models.subject import Subject
from app.models.user import User
from app.models.reservation import Reservation
from app.models.audit_log import AuditLog
from datetime import datetime, timedelta
from sqlalchemy import func
from app import db
from app.models.milestone import AcademicMilestone
from app.models.subject import Commission
api_dashboard_bp = Blueprint('api_dashboard', __name__, url_prefix='/api/v1/dashboard')
def serialize_reservation(r):
subject_name = (r.commission.subject.name if (r.commission and r.commission.subject) else r.purpose) or 'Actividad Académica'
subject_code = (r.commission.subject.code if (r.commission and r.commission.subject) else '')
classroom_code = r.classroom.code if r.classroom else 'Sin Aula'
is_virtual = ('virtual' in classroom_code.lower()) or (getattr(r.classroom, 'is_virtual', False) if r.classroom else False)
return {
'id': r.id,
'start_time': r.start_time.strftime('%H:%M') if r.start_time else '',
'end_time': r.end_time.strftime('%H:%M') if r.end_time else '',
'date_display': r.start_time.strftime('%d/%m') if r.start_time else '',
'classroom': {
'id': r.classroom_id,
'code': classroom_code,
'is_virtual': is_virtual,
'location_display': getattr(r.classroom, 'location_display', f'Sede Central · {classroom_code}') if r.classroom else 'Sin Aula'
},
'subject_name': subject_name,
'subject_code': subject_code,
'shift': r.shift or 'Mañana',
'commission': {
'id': r.commission_id,
'code': r.commission.code if r.commission else 'COM-1'
},
'status': str(r.status.value if hasattr(r.status, 'value') else r.status),
'user': {
'id': r.user_id,
'name': r.user.name if r.user else 'Docente'
},
'virtual_link': r.virtual_link or ('https://meet.google.com/edu-space-' + classroom_code.lower().replace(' ', '-') if is_virtual else '')
}
def serialize_milestone(m):
return {
'id': m.id,
'title': m.title,
'materia_codigo': m.materia_codigo,
'subject_name': m.subject.name if (hasattr(m, 'subject') and m.subject) else m.materia_codigo,
'date': m.date.strftime('%d/%m/%Y') if m.date else '',
'type_obj': {
'name': m.type_obj.name if m.type_obj else 'Evaluación',
'color': m.type_obj.color if m.type_obj else '#0d6efd'
},
'classroom': m.classroom or 'Sede Central',
'link': m.link or '',
'notes': m.notes or ''
}
@api_dashboard_bp.route('/stats', methods=['GET'])
@jwt_required
def get_stats():
"""Retorna estadísticas clave y datos operativos para el Dashboard."""
today = datetime.utcnow().date()
try:
total_classrooms = Classroom.query.filter_by(is_active=True).count()
total_subjects = Subject.query.filter_by(is_active=True).count()
total_users = User.query.filter_by(is_active=True).count()
# Aulas presenciales y virtuales
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': a.action_type,
'ip_address': a.ip_address,
'user_email': getattr(a.user, 'email', 'System') if hasattr(a, 'user') else 'System'
} for a in recent_audits]
occupancy_rate = min(100, int((today_reservations / max(1, physical_classrooms * 3)) * 100)) if physical_classrooms > 0 else 0
pending_reservations = [serialize_reservation(r) for r in pending_reservations_q]
today_schedule_list = [serialize_reservation(r) for r in today_reservations_q]
upcoming_reservations = [serialize_reservation(r) for r in upcoming_reservations_q]
milestones = [serialize_milestone(m) for m in milestones_q]
# ---- 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,
'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
+242
View File
@@ -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)
+292
View File
@@ -0,0 +1,292 @@
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
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 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
@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
@@ -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 datetime import datetime, timedelta
from app.models.genetic_algorithm import ReservationOptimizer from app.models.genetic_algorithm import ReservationOptimizer
from app.models.reservation import Reservation, ReservationStatus from app.models.reservation import Reservation, ReservationStatus
from app import db 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 = Blueprint('genetic_algorithm', __name__, url_prefix='/api/genetic')
@genetic_bp.route('/optimize', methods=['POST']) @genetic_bp.route('/optimize', methods=['POST'])
@login_required @jwt_required
def optimize_reservations(): def optimize_reservations():
"""Optimize reservations using genetic algorithm""" """Optimize reservations using genetic algorithm"""
try: try:
@@ -40,14 +40,14 @@ def optimize_reservations():
return jsonify({'error': 'Invalid end_date format. Use ISO format.'}), 400 return jsonify({'error': 'Invalid end_date format. Use ISO format.'}), 400
# Check if user has permission (admin, teacher or RBAC optimizer write) # 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 return jsonify({'error': 'Insufficient permissions to optimize reservations'}), 403
# Initialize optimizer and run optimization # Initialize optimizer and run optimization
optimizer = ReservationOptimizer() optimizer = ReservationOptimizer()
result = optimizer.optimize_schedule( result = optimizer.optimize_schedule(
commission_ids=commission_ids, commission_ids=commission_ids,
admin_user_id=current_user.id, admin_user_id=g.jwt_user.id,
start_date=start_date, start_date=start_date,
end_date=end_date end_date=end_date
) )
@@ -69,7 +69,7 @@ def optimize_reservations():
@genetic_bp.route('/apply-optimization', methods=['POST']) @genetic_bp.route('/apply-optimization', methods=['POST'])
@login_required @jwt_required
def apply_optimization(): def apply_optimization():
"""Apply optimized reservations to the database""" """Apply optimized reservations to the database"""
try: try:
@@ -84,7 +84,7 @@ def apply_optimization():
return jsonify({'error': 'reservations must be a list'}), 400 return jsonify({'error': 'reservations must be a list'}), 400
# Check if user has permission # 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 return jsonify({'error': 'Insufficient permissions to apply reservations'}), 403
# Initialize optimizer and apply reservations # Initialize optimizer and apply reservations
@@ -106,7 +106,7 @@ def apply_optimization():
@genetic_bp.route('/preview-optimization', methods=['POST']) @genetic_bp.route('/preview-optimization', methods=['POST'])
@login_required @jwt_required
def preview_optimization(): def preview_optimization():
"""Preview optimization without applying to database""" """Preview optimization without applying to database"""
try: try:
@@ -124,14 +124,14 @@ def preview_optimization():
return jsonify({'error': 'commission_ids must be a non-empty list'}), 400 return jsonify({'error': 'commission_ids must be a non-empty list'}), 400
# Check if user has permission # 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 return jsonify({'error': 'Insufficient permissions to preview optimization'}), 403
# Initialize optimizer and run optimization # Initialize optimizer and run optimization
optimizer = ReservationOptimizer() optimizer = ReservationOptimizer()
result = optimizer.optimize_schedule( result = optimizer.optimize_schedule(
commission_ids=commission_ids, commission_ids=commission_ids,
admin_user_id=current_user.id admin_user_id=g.jwt_user.id
) )
# Add additional preview information # Add additional preview information
@@ -166,12 +166,12 @@ def preview_optimization():
@genetic_bp.route('/commissions', methods=['GET']) @genetic_bp.route('/commissions', methods=['GET'])
@login_required @jwt_required
def get_commissions(): def get_commissions():
"""Get available commissions for optimization""" """Get available commissions for optimization"""
try: try:
# Check if user has permission # 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 return jsonify({'error': 'Insufficient permissions'}), 403
from app.models.subject import Commission, Subject from app.models.subject import Commission, Subject
@@ -196,12 +196,12 @@ def get_commissions():
@genetic_bp.route('/algorithm-status', methods=['GET']) @genetic_bp.route('/algorithm-status', methods=['GET'])
@login_required @jwt_required
def get_algorithm_status(): def get_algorithm_status():
"""Get genetic algorithm configuration status""" """Get genetic algorithm configuration status"""
try: try:
# Check if user has permission # 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 return jsonify({'error': 'Insufficient permissions'}), 403
# Get system status # Get system status
@@ -246,10 +246,10 @@ genetic_web_bp = Blueprint('genetic_algorithm_web', __name__)
@genetic_web_bp.route('/genetic-optimizer') @genetic_web_bp.route('/genetic-optimizer')
@login_required @jwt_required
def genetic_optimizer(): def genetic_optimizer():
"""Render the genetic algorithm optimizer interface""" """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('auth/403.html'), 403
return render_template('genetic_optimizer.html') return render_template('genetic_optimizer.html')
@@ -22,7 +22,7 @@ class ReservationBaseDTO(BaseModel):
return self return self
class ReservationCreateDTO(ReservationBaseDTO): class ReservationCreateDTO(ReservationBaseDTO):
pass allow_co_teaching_exception: Optional[bool] = Field(default=False, description="Excepción justificada si hay co-docencia")
class ReservationUpdateDTO(BaseModel): class ReservationUpdateDTO(BaseModel):
classroom_id: Optional[int] = None classroom_id: Optional[int] = None
@@ -34,6 +34,7 @@ class ReservationUpdateDTO(BaseModel):
virtual_link: Optional[str] = None virtual_link: Optional[str] = None
notes: Optional[str] = None notes: Optional[str] = None
status: Optional[str] = None status: Optional[str] = None
allow_co_teaching_exception: Optional[bool] = False
class ReservationResponseDTO(ReservationBaseDTO): class ReservationResponseDTO(ReservationBaseDTO):
id: int id: int
+220
View File
@@ -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
@@ -11,7 +11,7 @@ _token_blacklist: Dict[str, datetime] = {}
class JWTService: class JWTService:
"""Servicio de emisión, verificación y revocación de tokens JWT (Dual Token Pattern).""" """Servicio de emisión, verificación y revocación de tokens JWT (Dual Token Pattern)."""
ACCESS_TOKEN_EXPIRES = timedelta(minutes=15) ACCESS_TOKEN_EXPIRES = timedelta(days=1)
REFRESH_TOKEN_EXPIRES = timedelta(days=7) REFRESH_TOKEN_EXPIRES = timedelta(days=7)
ALGORITHM = "HS256" ALGORITHM = "HS256"
+229
View File
@@ -0,0 +1,229 @@
from typing import Optional, List, Dict, Any
from datetime import datetime
from app.models.reservation import Reservation, ReservationStatus
from app.models.classroom import Classroom
from app.models.subject import Commission, CommissionTeacher
from app.repositories.reservation_repository import ReservationRepository
from app.repositories.classroom_repository import ClassroomRepository
from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO
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 check_teacher_conflicts(self, teacher_id: int, start_time: datetime, end_time: datetime,
exclude_id: Optional[int] = None) -> List[Reservation]:
"""Verifica si el docente ya tiene otra reserva en la misma franja horaria."""
if not teacher_id:
return []
return self.reservation_repo.find_teacher_conflicts(
teacher_id=teacher_id,
start_time=start_time,
end_time=end_time,
exclude_reservation_id=exclude_id
)
def check_full_conflicts_matrix(self, classroom_id: int, start_time: datetime, end_time: datetime,
teacher_id: Optional[int] = None,
commission_id: Optional[int] = None,
expected_attendees: Optional[int] = None,
allow_co_teaching_exception: bool = False,
exclude_id: Optional[int] = None) -> Dict[str, Any]:
"""
Matriz completa de validación de conflictos (Fase 2 MVP):
1. Conflicto físico de aula (bloqueo estricto para aulas físicas).
2. Conflicto docente (detección con soporte de co-docencia).
3. Conflicto de aforo (capacidad física).
"""
classroom = self.classroom_repo.get_by_id(classroom_id)
if not classroom or not classroom.is_active:
raise ValueError(f"El aula con ID {classroom_id} no existe o no se encuentra activa.")
commission = None
if commission_id:
commission = Commission.query.get(commission_id)
if not teacher_id and commission:
teacher_id = commission.teacher_id
# 1. Conflicto Físico
physical_conflicts = []
if not classroom.is_virtual:
physical_conflicts = self.check_conflicts(classroom_id, start_time, end_time, exclude_id=exclude_id)
# 2. Conflicto Docente y Co-docencia
teacher_conflicts = []
has_co_teaching_coverage = False
co_teachers_list = []
if teacher_id:
teacher_conflicts = self.check_teacher_conflicts(teacher_id, start_time, end_time, exclude_id=exclude_id)
if teacher_conflicts and commission:
# Comprobar si la comisión cuenta con equipo docente de respaldo
co_teachers = CommissionTeacher.query.filter_by(commission_id=commission.id).all()
other_teachers = [ct for ct in co_teachers if ct.user_id != teacher_id]
if other_teachers:
has_co_teaching_coverage = True
co_teachers_list = [
{'id': ct.user_id, 'name': ct.teacher.name if ct.teacher else 'Docente', 'role': ct.role}
for ct in other_teachers
]
# 3. Conflicto de Aforo
has_capacity_conflict = False
capacity_message = None
if not classroom.is_virtual and expected_attendees is not None:
if expected_attendees > classroom.capacity:
has_capacity_conflict = True
capacity_message = f"La cantidad esperada ({expected_attendees}) supera la capacidad del aula ({classroom.capacity})."
# Evaluación de Bloqueos
block_reasons = []
if physical_conflicts:
c_strs = [f"#{c.id} ({c.start_time.strftime('%H:%M')} a {c.end_time.strftime('%H:%M')})" for c in physical_conflicts]
block_reasons.append(f"Conflicto de horario en aula {classroom.code}: se solapa con las reservas {', '.join(c_strs)}.")
if has_capacity_conflict:
block_reasons.append(f"Conflicto de aforo: {capacity_message}")
if teacher_conflicts:
if has_co_teaching_coverage or allow_co_teaching_exception:
# Cobertura justificada por equipo de co-docencia
pass
else:
t_strs = [f"#{c.id} ({c.start_time.strftime('%H:%M')} a {c.end_time.strftime('%H:%M')})" for c in teacher_conflicts]
block_reasons.append(f"Conflicto de horario docente: el profesor ya tiene clases asignadas en {', '.join(t_strs)} y la comisión no cuenta con equipo de co-docencia.")
is_blocked = len(block_reasons) > 0
return {
'is_valid': not is_blocked,
'is_blocked': is_blocked,
'block_reasons': block_reasons,
'physical_conflicts': [c.to_dict() for c in physical_conflicts],
'teacher_conflicts': [c.to_dict() for c in teacher_conflicts],
'has_co_teaching_coverage': has_co_teaching_coverage,
'co_teachers': co_teachers_list,
'has_capacity_conflict': has_capacity_conflict,
'classroom': classroom.to_dict()
}
def create_reservation(self, dto: ReservationCreateDTO) -> Reservation:
"""
Crea una nueva reserva aplicando la matriz integral de conflictos.
"""
matrix = self.check_full_conflicts_matrix(
classroom_id=dto.classroom_id,
start_time=dto.start_time,
end_time=dto.end_time,
teacher_id=dto.user_id,
commission_id=dto.commission_id,
expected_attendees=dto.expected_attendees,
allow_co_teaching_exception=getattr(dto, 'allow_co_teaching_exception', False)
)
if matrix['is_blocked']:
raise ValueError(" | ".join(matrix['block_reasons']))
reservation = Reservation(
classroom_id=dto.classroom_id,
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 y matriz de conflictos."""
reservation = self.reservation_repo.get_by_id(reservation_id)
if not reservation:
raise ValueError(f"Reserva con ID {reservation_id} no encontrada.")
classroom_id = dto.classroom_id or reservation.classroom_id
start_time = dto.start_time or reservation.start_time
end_time = dto.end_time or reservation.end_time
expected_attendees = dto.expected_attendees if dto.expected_attendees is not None else reservation.expected_attendees
matrix = self.check_full_conflicts_matrix(
classroom_id=classroom_id,
start_time=start_time,
end_time=end_time,
teacher_id=reservation.user_id,
commission_id=reservation.commission_id,
expected_attendees=expected_attendees,
allow_co_teaching_exception=getattr(dto, 'allow_co_teaching_exception', False),
exclude_id=reservation.id
)
if matrix['is_blocked']:
raise ValueError(" | ".join(matrix['block_reasons']))
if dto.classroom_id is not None:
reservation.classroom_id = dto.classroom_id
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
def confirm_reservation(self, reservation_id: int) -> bool:
"""Confirma una reserva estableciendo su estado en CONFIRMED."""
reservation = self.reservation_repo.get_by_id(reservation_id)
if not reservation:
return False
reservation.status = ReservationStatus.CONFIRMED.value
self.reservation_repo.save(reservation)
return True
@@ -2,6 +2,7 @@ import urllib.request
import csv import csv
import io import io
import re import re
import os
from datetime import datetime, date, time, timedelta from datetime import datetime, date, time, timedelta
from app import db from app import db
from app.models.classroom import Classroom from app.models.classroom import Classroom
@@ -12,6 +13,34 @@ from app.models.user import User
BASE_CSV_URL = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vSc_T_BQjbn3uPelioCgx52UM5Py-qNhJN0TYPd1kmsN5jdb3Q8rAaIvNMF_2ZTzQt6bH--yIWQKrKR/pub?single=true&output=csv' BASE_CSV_URL = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vSc_T_BQjbn3uPelioCgx52UM5Py-qNhJN0TYPd1kmsN5jdb3Q8rAaIvNMF_2ZTzQt6bH--yIWQKrKR/pub?single=true&output=csv'
def normalize_sheets_url(url):
"""
Normaliza enlaces de Google Sheets (compartidos, de edición o publicados)
para obtener la URL base que acepta parámetro gid=<gid>&output=csv o format=csv.
"""
if not url:
return BASE_CSV_URL
url = url.strip()
# 1. Enlace publicado web (/d/e/2PACX-.../pub...)
if '/d/e/' in url:
base_match = re.match(r'^(https://docs\.google\.com/spreadsheets/d/e/[^/?#]+)/pub', url)
if base_match:
return f"{base_match.group(1)}/pub?single=true&output=csv"
clean = url.split('&gid=')[0].split('?gid=')[0]
sep = '&' if '?' in clean else '?'
if 'output=csv' not in clean:
clean = f"{clean}{sep}single=true&output=csv"
return clean
# 2. Enlace de edición o visualización (/d/<SPREADSHEET_ID>/edit...)
sheet_id_match = re.search(r'/spreadsheets/d/([a-zA-Z0-9-_]+)', url)
if sheet_id_match:
sheet_id = sheet_id_match.group(1)
return f"https://docs.google.com/spreadsheets/d/{sheet_id}/export?format=csv"
return url
SHEETS_CONFIG = [ SHEETS_CONFIG = [
{ 'name': 'Lunes', 'gid': '1915752353', 'day_idx': 0 }, { 'name': 'Lunes', 'gid': '1915752353', 'day_idx': 0 },
{ 'name': 'Martes', 'gid': '466778263', 'day_idx': 1 }, { 'name': 'Martes', 'gid': '466778263', 'day_idx': 1 },
@@ -30,16 +59,30 @@ class GoogleSheetsImporter:
""" """
def __init__(self, base_url=None): def __init__(self, base_url=None):
self.base_url = base_url or BASE_CSV_URL if base_url:
self.base_url = normalize_sheets_url(base_url)
else:
configured_url = None
try:
from app.models.setting import SystemSetting
configured_url = SystemSetting.get_value('google_sheets_url')
except Exception:
pass
if not configured_url:
configured_url = os.getenv('GOOGLE_SHEETS_URL')
self.base_url = normalize_sheets_url(configured_url) if configured_url else BASE_CSV_URL
def fetch_sheet_csv(self, gid): def fetch_sheet_csv(self, gid):
"""Fetch CSV string for a specific sheet gid""" """Fetch CSV string for a specific sheet gid"""
url = f"{self.base_url}&gid={gid}" separator = '&' if '?' in self.base_url else '?'
url = f"{self.base_url}{separator}gid={gid}"
if not (url.startswith('https://') or url.startswith('http://')):
raise ValueError(f"URL scheme not permitted: {url}")
req = urllib.request.Request( req = urllib.request.Request(
url, url,
headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) EduSpace/2.0'} headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) EduSpace/2.0'}
) )
with urllib.request.urlopen(req, timeout=15) as resp: with urllib.request.urlopen(req, timeout=25) as resp: # nosec B310
return resp.read().decode('utf-8', errors='replace') return resp.read().decode('utf-8', errors='replace')
def parse_sheet_rows(self, csv_content, sheet_name): def parse_sheet_rows(self, csv_content, sheet_name):
@@ -366,16 +409,36 @@ class GoogleSheetsImporter:
# 5. Generate Calendar Reservations # 5. Generate Calendar Reservations
t_start, t_end = self.parse_time_range(horario_raw) t_start, t_end = self.parse_time_range(horario_raw)
if classroom and t_start and t_end and day_idx is not None: if classroom and t_start and t_end and day_idx is not None:
is_room_virtual = bool(classroom.is_virtual or 'VIRTUAL' in (classroom.code or '').upper())
# Generar enlace de videollamada para clases virtuales si no tienen uno asignado
v_link = None
if is_room_virtual:
clean_subj = re.sub(r'[^a-zA-Z0-9]', '', subject.code or 'cls').lower()
clean_comm = re.sub(r'[^a-zA-Z0-9]', '', commission.code or 'c1').lower()
v_link = f"https://meet.google.com/edu-{clean_subj}-{clean_comm}"
if not commission.virtual_link:
commission.virtual_link = v_link
for w in range(weeks_to_generate): for w in range(weeks_to_generate):
class_date = base_monday + timedelta(days=day_idx, weeks=w) class_date = base_monday + timedelta(days=day_idx, weeks=w)
dt_start = datetime.combine(class_date, t_start) dt_start = datetime.combine(class_date, t_start)
dt_end = datetime.combine(class_date, t_end) dt_end = datetime.combine(class_date, t_end)
existing_res = Reservation.query.filter_by( # En aulas virtuales la capacidad es infinita y pueden cursar múltiples comisiones en paralelo
classroom_id=classroom.id, if is_room_virtual:
start_time=dt_start, existing_res = Reservation.query.filter_by(
end_time=dt_end classroom_id=classroom.id,
).first() commission_id=commission.id,
start_time=dt_start,
end_time=dt_end
).first()
else:
existing_res = Reservation.query.filter_by(
classroom_id=classroom.id,
start_time=dt_start,
end_time=dt_end
).first()
if not existing_res: if not existing_res:
res = Reservation( res = Reservation(
@@ -386,8 +449,9 @@ class GoogleSheetsImporter:
end_time=dt_end, end_time=dt_end,
purpose=f"{subject.code} - {subject.name}", purpose=f"{subject.code} - {subject.name}",
status='CONFIRMED', status='CONFIRMED',
expected_attendees=min(classroom.capacity, 30), expected_attendees=min(classroom.capacity, 30) if not is_room_virtual else 35,
shift=turno, shift=turno,
virtual_link=v_link,
notes=f"Clase semanal {dia} ({turno}) - Carrera: {career.name}" notes=f"Clase semanal {dia} ({turno}) - Carrera: {career.name}"
) )
db.session.add(res) db.session.add(res)
@@ -395,6 +459,8 @@ class GoogleSheetsImporter:
else: else:
existing_res.shift = turno existing_res.shift = turno
existing_res.notes = f"Clase semanal {dia} ({turno}) - Carrera: {career.name}" existing_res.notes = f"Clase semanal {dia} ({turno}) - Carrera: {career.name}"
if is_room_virtual and not existing_res.virtual_link:
existing_res.virtual_link = v_link
if len(stats['sample_records']) < 10: if len(stats['sample_records']) < 10:
stats['sample_records'].append({ stats['sample_records'].append({
View File
@@ -7,7 +7,7 @@ load_dotenv(os.path.join(basedir, '.env'))
class Config: class Config:
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production' SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
_db_url = os.environ.get('DATABASE_URL') or 'postgresql://postgres:password@localhost:5432/classrooms_db' _db_url = os.environ.get('DATABASE_URL') or 'sqlite:///app.db'
if _db_url.startswith('postgres://'): if _db_url.startswith('postgres://'):
_db_url = _db_url.replace('postgres://', 'postgresql://', 1) _db_url = _db_url.replace('postgres://', 'postgresql://', 1)
SQLALCHEMY_DATABASE_URI = _db_url SQLALCHEMY_DATABASE_URI = _db_url
+72 -2
View File
@@ -205,8 +205,6 @@ def init_database():
).first() ).first()
bedelia_canonical = Role.query.filter_by(name='Bedelia').first() bedelia_canonical = Role.query.filter_by(name='Bedelia').first()
if bedelia_accented and bedelia_canonical and bedelia_accented.id != bedelia_canonical.id: if bedelia_accented and bedelia_canonical and bedelia_accented.id != bedelia_canonical.id:
# Migrate all users from accented role to canonical
from app.models.user import User
affected = User.query.filter_by(role_id=bedelia_accented.id).all() affected = User.query.filter_by(role_id=bedelia_accented.id).all()
for u in affected: for u in affected:
u.role_id = bedelia_canonical.id u.role_id = bedelia_canonical.id
@@ -335,6 +333,78 @@ def init_database():
mt.sort_order = item['sort_order'] mt.sort_order = item['sort_order']
db.session.commit() db.session.commit()
print(f"[OK] Catalogo de tipificaciones inicializado ({MilestoneType.query.count()} tipos disponibles).") print(f"[OK] Catalogo de tipificaciones inicializado ({MilestoneType.query.count()} tipos disponibles).")
# 7. Inicializar aulas, materias, comisiones y reservas de muestra
from app.models.classroom import Classroom
from app.models.subject import Subject, Commission
from app.models.reservation import Reservation, ReservationStatus
from datetime import datetime, timedelta, date
if Classroom.query.count() == 0:
print("[*] Generando catalogo de aulas...")
for floor in ['PB', '1', '2', '3']:
for num in range(1, 8):
code = f'{floor}0{num}' if floor != 'PB' else f'PB-0{num}'
c = Classroom(
code=code,
building=b_central.name,
building_id=b_central.id,
floor=floor,
capacity=30 + (num * 5),
description=f'Aula física estándar {code} en {b_central.name}',
is_active=True
)
db.session.add(c)
for v in range(1, 4):
vc = Classroom(
code=f'VIRTUAL-0{v}',
building=b_virtual.name,
building_id=b_virtual.id,
floor='0',
capacity=100,
description=f'Sala virtual Microsoft Teams {v}',
is_active=True
)
db.session.add(vc)
db.session.commit()
print(f"[OK] {Classroom.query.count()} aulas creadas.")
# Materia y Comisión
s = Subject.query.filter_by(code='ARQ-101').first()
if not s:
s = Subject(name='Arquitectura de Software', code='ARQ-101', is_active=True)
db.session.add(s)
db.session.commit()
comm = Commission.query.filter_by(code='COM-A').first()
if not comm:
comm = Commission(code='COM-A', subject_id=s.id, shift='Noche', semester=2, year=2026, max_students=40, is_active=True)
db.session.add(comm)
db.session.commit()
# Reservas de Hoy
if Reservation.query.count() == 0:
print("[*] Generando reservas de muestra...")
today = date.today()
classrooms = Classroom.query.all()
docente = User.query.filter_by(email='docente@edu-space.com').first()
for idx, cl in enumerate(classrooms[:19]):
start_hour = 8 + (idx % 6) * 2
r = Reservation(
classroom_id=cl.id,
commission_id=comm.id,
user_id=docente.id if docente else 1,
start_time=datetime.combine(today, datetime.min.time()) + timedelta(hours=start_hour),
end_time=datetime.combine(today, datetime.min.time()) + timedelta(hours=start_hour + 2),
purpose='Clase Regular',
status=ReservationStatus.CONFIRMED.value,
shift='Noche' if start_hour >= 18 else 'Tarde' if start_hour >= 13 else 'Mañana',
expected_attendees=cl.capacity - 5
)
db.session.add(r)
db.session.commit()
print(f"[OK] {Reservation.query.count()} reservas creadas.")
print("\n[LISTO] Base de datos inicializada y lista para usar.") print("\n[LISTO] Base de datos inicializada y lista para usar.")

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