From ada39248d74e25454b7cb759cc31a8a0b75a1705 Mon Sep 17 00:00:00 2001 From: Carlos Tello Date: Sat, 19 Sep 2026 14:05:16 -0300 Subject: [PATCH] feat: completar Fase 2 del Roadmap MVP (Co-docencia, Matriz de Conflictos y Regla Diaria) y auditoria de seguridad --- .gitea/workflows/security-audit.yaml | 73 ++++ .gitignore | 3 +- CHANGELOG.md | 69 +++- CHANGELOG_MVP.md | 47 +++ ROADMAP_MVP.md | 24 +- SECURITY_AUDIT_REPORT.md | 99 ++++++ backend/app/__init__.py | 2 + backend/app/constants/__init__.py | 1 + backend/app/constants/document_types.py | 160 +++++++++ backend/app/models/__init__.py | 3 +- backend/app/models/enrollment.py | 4 + backend/app/models/subject.py | 303 +++++++++------- backend/app/models/user.py | 36 +- .../repositories/reservation_repository.py | 19 + backend/app/routes/api/admin.py | 333 ++++++++++++++++-- backend/app/routes/api/auth.py | 8 +- backend/app/routes/api/openapi.py | 242 +++++++++++++ backend/app/routes/api/reservations.py | 45 +++ backend/app/schemas/reservation_dto.py | 3 +- backend/app/services/enrollment_service.py | 220 ++++++++++++ backend/app/services/reservation_service.py | 148 ++++++-- backend/app/services/sheets_importer.py | 4 +- backend/migrate_phase2_fields.py | 44 +++ backend/migrate_user_profile_fields.py | 79 +++++ backend/requirements.txt | 10 +- backend/tests/test_phase2_rules.py | 293 +++++++++++++++ frontend/package-lock.json | 42 +++ frontend/package.json | 2 + frontend/src/app.js | 27 ++ frontend/src/routes/admin.js | 238 +++++++++++-- frontend/views/admin/commissions/detail.html | 169 +++++++-- frontend/views/admin/commissions/list.html | 16 +- frontend/views/admin/subjects/list.html | 215 +++++++---- frontend/views/admin/users/form.html | 283 +++++++++++---- frontend/views/admin/users/list.html | 142 +++++--- security_audit.bat | 74 ++++ 36 files changed, 3037 insertions(+), 443 deletions(-) create mode 100644 .gitea/workflows/security-audit.yaml create mode 100644 CHANGELOG_MVP.md create mode 100644 SECURITY_AUDIT_REPORT.md create mode 100644 backend/app/constants/__init__.py create mode 100644 backend/app/constants/document_types.py create mode 100644 backend/app/routes/api/openapi.py create mode 100644 backend/app/services/enrollment_service.py create mode 100644 backend/migrate_phase2_fields.py create mode 100644 backend/migrate_user_profile_fields.py create mode 100644 backend/tests/test_phase2_rules.py create mode 100644 security_audit.bat diff --git a/.gitea/workflows/security-audit.yaml b/.gitea/workflows/security-audit.yaml new file mode 100644 index 0000000..ccab568 --- /dev/null +++ b/.gitea/workflows/security-audit.yaml @@ -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 bandit pip-audit schemathesis njsscan + + # 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 > /dev/null + + # Ejecutar Schemathesis contra la instancia local efímera + schemathesis run http://127.0.0 --checks not_a_server_error --max-examples=10 diff --git a/.gitignore b/.gitignore index 07c6f35..7c932ee 100644 --- a/.gitignore +++ b/.gitignore @@ -220,4 +220,5 @@ npm-debug.log frontend/.env legacy_admin-edu-space/ -.vscode/ \ No newline at end of file +.vscode/ +.schemathesis/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e986a3..b09144b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,16 +6,71 @@ El formato está basado en [Keep a Changelog](https://keepachangelog.com/es-ES/1 --- ## [Unreleased] -### Planificado (Fase 2 del Roadmap MVP) -- **Co-docencia en Cátedras:** Asignación de múltiples profesores (titular, adjunto, ayudante) a una misma comisión. -- **Matriz de Conflictos:** Detección de solapamiento físico, docente y control de aforo en tiempo real. -- **Regla de Restricción Diaria del Alumno:** Validación para impedir cursar dos materias regulares el mismo día, con motor de excepciones para cursos cortos o autorización de Bedelía. -- **Libro de Calificaciones (*Gradebook*):** Carga rápida de notas y seguimiento de regularidad. -- **Grilla Semanal Drag & Drop:** Asignación visual interactiva para la oficina de Bedelía. +### Planificado (Fase 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.3.0] - 2026-09-19 +## [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//teachers` y `DELETE /api/v1/commissions//teachers/` 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//enrollments`, `POST /api/v1/commissions//enrollments`, `DELETE /api/v1/commissions//enrollments/` y `PUT /api/v1/commissions//enrollments/`. + - 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. + +## [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/`. + - 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//teachers` y `DELETE /api/v1/commissions//teachers/`, 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 `` 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`). diff --git a/CHANGELOG_MVP.md b/CHANGELOG_MVP.md new file mode 100644 index 0000000..012543c --- /dev/null +++ b/CHANGELOG_MVP.md @@ -0,0 +1,47 @@ +# 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//teachers` y `DELETE /api/v1/commissions//teachers/` 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//enrollments`, `DELETE/PUT /api/v1/commissions//enrollments/`. +* **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. + +--- + +## [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. diff --git a/ROADMAP_MVP.md b/ROADMAP_MVP.md index 1576138..adb8208 100644 --- a/ROADMAP_MVP.md +++ b/ROADMAP_MVP.md @@ -2,7 +2,7 @@ ## Gestión de Espacios Áulicos, Actividad de Cursada y Seguimiento Académico **Institución:** Universidad de la Ciudad de Buenos Aires (UniCABA) -**Versión:** 2.1.0 +**Versión:** 2.6.0 **Fecha de Actualización:** 19 de Septiembre de 2026 **Rama Base:** `testing` @@ -18,8 +18,8 @@ Construir y evolucionar la plataforma de gestión académica y espacial de **Uni ``` Fase 0: Desacople y Arquitectura Base [████████████████████] 100% (Completado) Fase 1: Paridad Legacy y Estabilización [████████████████████] 100% (Completado) -Fase 2: Co-docencia y Matriz Conflictos [░░░░░░░░░░░░░░░░░░░░] 0% (Próxima) -Fase 3: Hitos Evaluativos y Calificaciones[░░░░░░░░░░░░░░░░░░░░] 0% (Planificada) +Fase 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) ``` @@ -66,20 +66,20 @@ Fase 5: Despliegue Producción e Integrac.[░░░░░░░░░░░░ --- -### Fase 2: Co-docencia, Matriz de Conflictos y Regla Diaria ⏳ *(En curso / Inmediata)* +### Fase 2: Co-docencia, Matriz de Conflictos y Regla Diaria ✅ *(100% Completado)* * **Objetivo:** Implementar las reglas de negocio académicas complejas para asignación de cátedras y cursada de alumnos. * **Estadios:** 1. **Estadio 2.1 — Modelo de Co-docencia:** - - [ ] Tabla relacional `commission_teachers` para soportar múltiples docentes por comisión (titulares, adjuntos, ayudantes). - - [ ] Endpoints `/api/v1/commissions//teachers` para asociar y desvincular docentes de cátedra. - - [ ] Adaptación de vistas de Bedelía y Comisiones para visualizar el equipo docente completo. + - [x] Tabla relacional `commission_teachers` para soportar múltiples docentes por comisión (titulares, adjuntos, ayudantes). + - [x] Endpoints `/api/v1/commissions//teachers` para asociar y desvincular docentes de cátedra. + - [x] Adaptación de vistas de Bedelía y Comisiones para visualizar el equipo docente completo. 2. **Estadio 2.2 — Matriz de Conflictos y Validación:** - - [ ] Validación estricta de conflicto físico: bloqueo de doble asignación de aula en mismo día y horario. - - [ ] Validación de conflicto docente: detección de superposición horaria del profesor, con soporte de excepción justificada cuando la comisión cuenta con co-docencia. - - [ ] Validación de aforo: bloqueo cuando la cantidad de alumnos excede la capacidad del aula física. + - [x] Validación estricta de conflicto físico: bloqueo de doble asignación de aula en mismo día y horario. + - [x] Validación de conflicto docente: detección de superposición horaria del profesor, con soporte de excepción justificada cuando la comisión cuenta con co-docencia. + - [x] Validación de aforo: bloqueo cuando la cantidad de alumnos excede la capacidad del aula física. 3. **Estadio 2.3 — Regla de Restricción Diaria del Alumno:** - - [ ] Validación al matricular: un alumno no puede cursar dos asignaturas regulares en el mismo día calendario. - - [ ] Excepción parametrizable: autorización automática si al menos una de las materias es un "curso corto" o si existe autorización manual de Bedelía (`allow_same_day_exception`). + - [x] Validación al matricular: un alumno no puede cursar dos asignaturas regulares en el mismo día calendario. + - [x] Excepción parametrizable: autorización automática si al menos una de las materias es un "curso corto" (`is_short_course`) o si existe autorización manual de Bedelía (`allow_same_day_exception`). --- diff --git a/SECURITY_AUDIT_REPORT.md b/SECURITY_AUDIT_REPORT.md new file mode 100644 index 0000000..67f46a3 --- /dev/null +++ b/SECURITY_AUDIT_REPORT.md @@ -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** ✅ | diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 4fa1207..f0c390f 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -29,6 +29,7 @@ def create_app(config_class=Config): from app.routes.api.optimizer import api_optimizer_bp from app.routes.api.dashboard import api_dashboard_bp from app.routes.api.admin import api_admin_bp + from app.routes.api.openapi import api_openapi_bp from app.security import SecurityFilterChain # Register API v1 blueprints @@ -37,6 +38,7 @@ def create_app(config_class=Config): app.register_blueprint(api_reservations_bp) app.register_blueprint(api_optimizer_bp) app.register_blueprint(api_dashboard_bp) + app.register_blueprint(api_openapi_bp) app.register_blueprint(api_admin_bp, url_prefix='/api/v1') app.register_blueprint(api_admin_bp, url_prefix='/api/v1/admin', name='api_admin_prefixed') diff --git a/backend/app/constants/__init__.py b/backend/app/constants/__init__.py new file mode 100644 index 0000000..bdc9191 --- /dev/null +++ b/backend/app/constants/__init__.py @@ -0,0 +1 @@ +from .document_types import DOCUMENT_TYPES, DOCUMENT_TYPE_MAP, get_document_type, validate_document, format_document diff --git a/backend/app/constants/document_types.py b/backend/app/constants/document_types.py new file mode 100644 index 0000000..f09f228 --- /dev/null +++ b/backend/app/constants/document_types.py @@ -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 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 3ee7fcc..5c6f938 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -3,7 +3,7 @@ from .building import Building from .classroom import Classroom, ClassroomResource from .career import Career from .academic_term import AcademicTerm -from .subject import Subject, Commission +from .subject import Subject, Commission, CommissionTeacher from .reservation import Reservation, ReservationStatus from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer from .role import Role, Permission, SYSTEM_MODULES @@ -21,6 +21,7 @@ __all__ = [ 'AcademicTerm', 'Subject', 'Commission', + 'CommissionTeacher', 'Reservation', 'ReservationStatus', 'GeneticAlgorithm', diff --git a/backend/app/models/enrollment.py b/backend/app/models/enrollment.py index fa52b80..fb32548 100644 --- a/backend/app/models/enrollment.py +++ b/backend/app/models/enrollment.py @@ -23,6 +23,8 @@ class StudentEnrollment(db.Model): enrolled_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) notes = db.Column(db.Text, nullable=True) + allow_same_day_exception = db.Column(db.Boolean, default=False, nullable=True) + exception_reason = db.Column(db.String(255), nullable=True) # Unique constraint: a student can only be enrolled once per commission __table_args__ = ( @@ -43,6 +45,8 @@ class StudentEnrollment(db.Model): 'status': self.status, 'enrolled_at': self.enrolled_at.isoformat() if self.enrolled_at else None, 'notes': self.notes, + 'allow_same_day_exception': bool(self.allow_same_day_exception), + 'exception_reason': self.exception_reason } def __repr__(self): diff --git a/backend/app/models/subject.py b/backend/app/models/subject.py index 7a30e28..0714b5d 100644 --- a/backend/app/models/subject.py +++ b/backend/app/models/subject.py @@ -1,118 +1,185 @@ -from app import db -from datetime import datetime - -class Subject(db.Model): - __tablename__ = 'subjects' - - id = db.Column(db.Integer, primary_key=True) - code = db.Column(db.String(50), unique=True, nullable=False, index=True) - name = db.Column(db.String(200), nullable=False) - description = db.Column(db.Text) - department = db.Column(db.String(100), nullable=False, default='') - credits = db.Column(db.Integer, nullable=False, default=4) - career_id = db.Column(db.Integer, db.ForeignKey('careers.id', ondelete='SET NULL'), nullable=True) - is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB - created_at = db.Column(db.DateTime, default=datetime.utcnow) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - - # Relationships - commissions = db.relationship('Commission', backref='subject', lazy=True, cascade='all, delete-orphan') - - def to_dict(self): - """Convert subject to dictionary""" - return { - 'id': self.id, - 'code': self.code, - 'name': self.name, - 'description': self.description, - 'department': self.department, - 'credits': self.credits, - 'career_id': self.career_id, - 'career_name': self.career_obj.name if self.career_obj else None, - 'is_active': self.is_active, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None - } - - def __repr__(self): - return f'' - -class Commission(db.Model): - __tablename__ = 'commissions' - - id = db.Column(db.Integer, primary_key=True) - subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=False) - code = db.Column(db.String(50), nullable=False) - semester = db.Column(db.String(20), nullable=False) - year = db.Column(db.Integer, nullable=False) - teacher_id = db.Column(db.Integer, db.ForeignKey('users.id')) - max_students = db.Column(db.Integer, nullable=False) - current_students = db.Column(db.Integer, default=0) - schedule = db.Column(db.String(255)) - shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche - virtual_link = db.Column(db.String(500), nullable=True) # Link Zoom, Meet, Teams, Moodle - active = db.Column(db.Boolean, default=True) # Changed from is_active to active - created_at = db.Column(db.DateTime, default=datetime.utcnow) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - - # Virtual property for compatibility - @property - def is_active(self): - return self.active - - @is_active.setter - def is_active(self, value): - self.active = value - -# Relationships - pass # Reservation already defines the relationship back to commission - teacher = db.relationship('User', backref='teaching_commissions', foreign_keys=[teacher_id]) - - # Virtual field for teacher_name - @property - def teacher_name(self): - return f"{self.teacher.first_name} {self.teacher.last_name}" if self.teacher else "TBD" - - # Unique constraint - __table_args__ = (db.UniqueConstraint('subject_id', 'code', 'semester', 'year'),) - - def to_dict(self): - """Convert commission to dictionary""" - return { - 'id': self.id, - 'subject_id': self.subject_id, - 'code': self.code, - 'semester': self.semester, - 'year': self.year, - 'teacher_name': self.teacher_name, - 'max_students': self.max_students, - 'current_students': self.current_students, - 'schedule': self.schedule, - 'shift': self.shift, - 'virtual_link': self.virtual_link, - 'active': self.active, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - 'full_code': self.get_full_code(), - 'subject': self.subject.to_dict() if self.subject else None - } - - def get_full_code(self): - """Get full commission code""" - subj = self.subject.code if self.subject else 'N/A' - code = self.code or '' - sem = str(self.semester or '').strip() - yr = str(self.year or '').strip() - - if yr and yr in sem: - return f"{subj}-{code}-{sem}" - elif sem and yr: - return f"{subj}-{code}-{sem}{yr}" - elif sem: - return f"{subj}-{code}-{sem}" - elif yr: - return f"{subj}-{code}-{yr}" - return f"{subj}-{code}" - - def __repr__(self): - return f'' \ No newline at end of file +from app import db +from datetime import datetime + +class Subject(db.Model): + __tablename__ = 'subjects' + + id = db.Column(db.Integer, primary_key=True) + code = db.Column(db.String(50), unique=True, nullable=False, index=True) + name = db.Column(db.String(200), nullable=False) + description = db.Column(db.Text) + department = db.Column(db.String(100), nullable=False, default='') + credits = db.Column(db.Integer, nullable=False, default=4) + career_id = db.Column(db.Integer, db.ForeignKey('careers.id', ondelete='SET NULL'), nullable=True) + is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB + is_short_course = db.Column(db.Boolean, default=False, nullable=True) # True para talleres/cursos cortos + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + commissions = db.relationship('Commission', backref='subject', lazy=True, cascade='all, delete-orphan') + + def to_dict(self): + """Convert subject to dictionary""" + return { + 'id': self.id, + 'code': self.code, + 'name': self.name, + 'description': self.description, + 'department': self.department, + 'credits': self.credits, + 'career_id': self.career_id, + 'career_name': self.career_obj.name if self.career_obj else None, + 'is_active': self.is_active, + 'is_short_course': bool(self.is_short_course), + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None + } + + def __repr__(self): + return f'' + +class Commission(db.Model): + __tablename__ = 'commissions' + + id = db.Column(db.Integer, primary_key=True) + subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=False) + code = db.Column(db.String(50), nullable=False) + semester = db.Column(db.String(20), nullable=False) + year = db.Column(db.Integer, nullable=False) + teacher_id = db.Column(db.Integer, db.ForeignKey('users.id')) + max_students = db.Column(db.Integer, nullable=False) + current_students = db.Column(db.Integer, default=0) + schedule = db.Column(db.String(255)) + shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche + virtual_link = db.Column(db.String(500), nullable=True) # Link Zoom, Meet, Teams, Moodle + active = db.Column(db.Boolean, default=True) # Changed from is_active to active + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Virtual property for compatibility + @property + def is_active(self): + return self.active + + @is_active.setter + def is_active(self, value): + self.active = value + +# Relationships + pass # Reservation already defines the relationship back to commission + teacher = db.relationship('User', backref='teaching_commissions', foreign_keys=[teacher_id]) + + # Virtual field for teacher_name + @property + def teacher_name(self): + return f"{self.teacher.first_name} {self.teacher.last_name}" if self.teacher else "TBD" + + @property + def teachers(self): + """Retorna la lista de docentes asignados. + Si teachers_association tiene registros, los retorna ordenados (Titular primero). + Si está vacía pero teacher_id está configurado, devuelve el docente titular. + """ + if hasattr(self, 'teachers_association') and self.teachers_association and len(self.teachers_association) > 0: + res = [ta.to_dict() for ta in self.teachers_association] + res.sort(key=lambda x: (not x.get('is_primary', False), x.get('role', '') != 'Titular')) + return res + if self.teacher: + return [{ + 'id': 0, + 'commission_id': self.id, + 'user_id': self.teacher.id, + 'name': self.teacher.name, + 'email': self.teacher.email, + 'role': 'Titular', + 'is_primary': True + }] + return [] + + @property + def teacher_names(self): + t_list = self.teachers + if not t_list: + return "Sin asignar" + return ", ".join([f"{t['name']} ({t['role']})" for t in t_list]) + + # Unique constraint + __table_args__ = (db.UniqueConstraint('subject_id', 'code', 'semester', 'year'),) + + def to_dict(self): + """Convert commission to dictionary""" + return { + 'id': self.id, + 'subject_id': self.subject_id, + 'code': self.code, + 'semester': self.semester, + 'year': self.year, + 'teacher_id': self.teacher_id, + 'teacher_name': self.teacher_name, + 'teachers': self.teachers, + 'teacher_names': self.teacher_names, + 'max_students': self.max_students, + 'current_students': self.current_students, + 'schedule': self.schedule, + 'shift': self.shift, + 'virtual_link': self.virtual_link, + 'active': self.active, + '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'' + + +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'' \ No newline at end of file diff --git a/backend/app/models/user.py b/backend/app/models/user.py index f7241d4..cd82f37 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -17,15 +17,29 @@ class User(db.Model): theme_preference = db.Column(db.String(10), default='auto', nullable=False) role_id = db.Column(db.Integer, db.ForeignKey('roles.id', ondelete='SET NULL'), nullable=True) - # Virtual fields for future compatibility - @property - def first_name(self): + # Extended personal, contact and identity document fields + first_name = db.Column(db.String(100), nullable=True) + last_name = db.Column(db.String(100), nullable=True) + phone = db.Column(db.String(50), nullable=True) + personal_email = db.Column(db.String(255), nullable=True) + address = db.Column(db.String(255), nullable=True) + document_type = db.Column(db.String(20), default='DNI', nullable=True) + document_number = db.Column(db.String(30), nullable=True, index=True) + + def get_first_name(self): + if self.first_name: + return self.first_name return self.name.split()[0] if self.name else '' - @property - def last_name(self): - parts = self.name.split() + def get_last_name(self): + if self.last_name: + return self.last_name + parts = self.name.split() if self.name else [] return ' '.join(parts[1:]) if len(parts) > 1 else '' + + def get_formatted_document(self): + from app.constants.document_types import format_document + return format_document(self.document_type, self.document_number) # Relationships reservations = db.relationship('Reservation', backref='user', lazy=True, cascade='all, delete-orphan') @@ -132,8 +146,18 @@ class User(db.Model): return { 'id': self.id, 'email': self.email, + 'institutional_email': self.email, + 'personal_email': self.personal_email or '', 'name': self.name, + 'first_name': self.get_first_name(), + 'last_name': self.get_last_name(), + 'phone': self.phone or '', + 'address': self.address or '', + 'document_type': self.document_type or 'DNI', + 'document_number': self.document_number or '', + 'document_formatted': self.get_formatted_document(), 'role': self.role, + 'role_id': self.role_id, 'is_active': self.is_active, 'created_at': self.created_at.isoformat() if self.created_at else None, 'last_login': self.last_login.isoformat() if self.last_login else None, diff --git a/backend/app/repositories/reservation_repository.py b/backend/app/repositories/reservation_repository.py index d950685..f5ef67c 100644 --- a/backend/app/repositories/reservation_repository.py +++ b/backend/app/repositories/reservation_repository.py @@ -59,3 +59,22 @@ class ReservationRepository(BaseRepository[Reservation]): return Reservation.query.filter_by( commission_id=commission_id ).order_by(Reservation.start_time.asc()).all() + + def find_teacher_conflicts(self, teacher_id: int, start_time: datetime, end_time: datetime, + exclude_reservation_id: Optional[int] = None) -> List[Reservation]: + """ + Encuentra reservas activas donde el docente ya está asignado en la misma franja horaria. + Condición de solapamiento: (start_time < existing.end_time) AND (end_time > existing.start_time). + """ + query = Reservation.query.options( + joinedload(Reservation.classroom), + joinedload(Reservation.commission) + ).filter( + Reservation.user_id == teacher_id, + Reservation.status != ReservationStatus.CANCELLED.value, + Reservation.start_time < end_time, + Reservation.end_time > start_time + ) + if exclude_reservation_id: + query = query.filter(Reservation.id != exclude_reservation_id) + return query.all() diff --git a/backend/app/routes/api/admin.py b/backend/app/routes/api/admin.py index dd3aa16..8ec404a 100644 --- a/backend/app/routes/api/admin.py +++ b/backend/app/routes/api/admin.py @@ -4,11 +4,13 @@ import re from app.utils.jwt_decorators import jwt_required from app.models.user import User from app.models.role import Role, Permission, SYSTEM_MODULES -from app.models.subject import Subject, Commission +from app.models.subject import Subject, Commission, CommissionTeacher from app.models.career import Career from app.models.academic_term import AcademicTerm from app.models.milestone import MilestoneType, AcademicMilestone from app.models.audit_log import AuditLog +from app.constants.document_types import DOCUMENT_TYPES, validate_document, format_document +from app.services.enrollment_service import EnrollmentService from app import db api_admin_bp = Blueprint('api_admin', __name__) @@ -22,7 +24,14 @@ def get_users(): query = User.query if search: - query = query.filter((User.name.ilike(f'%{search}%')) | (User.email.ilike(f'%{search}%'))) + query = query.filter( + (User.name.ilike(f'%{search}%')) | + (User.email.ilike(f'%{search}%')) | + (User.personal_email.ilike(f'%{search}%')) | + (User.document_number.ilike(f'%{search}%')) | + (User.first_name.ilike(f'%{search}%')) | + (User.last_name.ilike(f'%{search}%')) + ) if role_id: query = query.filter(User.role_id == role_id) if status == 'active': @@ -35,19 +44,12 @@ def get_users(): users_data = [] for u in users: - users_data.append({ - 'id': u.id, - 'name': u.name, - 'first_name': u.name.split(' ')[0] if u.name else 'U', - 'email': u.email, - 'is_active': u.is_active, - 'role': u.role, - 'role_obj': { - 'id': u.role_obj.id, - 'name': u.role_obj.name - } if u.role_obj else {'id': 1, 'name': u.role or 'Admin'}, - 'last_login': getattr(u, 'last_login', None) - }) + d = u.to_dict() + d['role_obj'] = { + 'id': u.role_obj.id, + 'name': u.role_obj.name + } if u.role_obj else {'id': 1, 'name': u.role or 'Admin'} + users_data.append(d) roles_data = [{'id': r.id, 'name': r.name} for r in roles] @@ -55,7 +57,8 @@ def get_users(): 'status': 'success', 'total': len(users_data), 'users': users_data, - 'roles': roles_data + 'roles': roles_data, + 'document_types': DOCUMENT_TYPES }), 200 @api_admin_bp.route('/roles', methods=['GET']) @@ -213,6 +216,8 @@ def get_commissions(): 'year': getattr(c, 'year', 2026) or 2026, 'teacher_id': c.teacher_id, 'teacher_name': c.teacher_name if hasattr(c, 'teacher_name') else (c.teacher.name if c.teacher else None), + 'teachers': c.teachers if hasattr(c, 'teachers') else [], + 'teacher_names': c.teacher_names if hasattr(c, 'teacher_names') else (c.teacher_name if hasattr(c, 'teacher_name') else 'Sin asignar'), 'schedule': c.schedule or 'A coordinar', 'shift': c.shift or 'Mañana', 'max_students': c.max_students or 35, @@ -248,19 +253,47 @@ def get_commissions(): def create_user(): data = request.get_json(silent=True) or request.form.to_dict() or {} email = data.get('email', '').strip().lower() + first_name = data.get('first_name', '').strip() + last_name = data.get('last_name', '').strip() name = data.get('name', '').strip() + if not name and (first_name or last_name): + name = f"{first_name} {last_name}".strip() + elif name and not first_name: + parts = name.split() + first_name = parts[0] if parts else '' + last_name = ' '.join(parts[1:]) if len(parts) > 1 else '' + password = data.get('password', '').strip() role_id = data.get('role_id') is_active = data.get('is_active', True) if isinstance(is_active, str): is_active = is_active.lower() in ['true', '1', 'on'] + phone = data.get('phone', '').strip() + personal_email = data.get('personal_email', '').strip().lower() + address = data.get('address', '').strip() + document_type = data.get('document_type', 'DNI').strip().upper() + document_number = data.get('document_number', '').strip() + if not email or not name: return jsonify({'error': 'ValidationError', 'message': 'El nombre y el email son obligatorios.'}), 400 if User.query.filter(User.email.ilike(email)).first(): return jsonify({'error': 'Conflict', 'message': f'Ya existe un usuario con el email {email}.'}), 409 + if document_number: + is_valid, clean_doc, err_msg = validate_document(document_type, document_number) + if not is_valid: + return jsonify({'error': 'ValidationError', 'message': err_msg}), 400 + document_number = clean_doc + + existing_doc = User.query.filter( + User.document_type == document_type, + User.document_number == document_number + ).first() + if existing_doc: + return jsonify({'error': 'Conflict', 'message': f'Ya existe un usuario con {document_type} {document_number}.'}), 409 + role_obj = None if role_id: role_obj = Role.query.get(int(role_id)) @@ -268,7 +301,14 @@ def create_user(): user = User( email=email, + personal_email=personal_email, name=name, + first_name=first_name, + last_name=last_name, + phone=phone, + address=address, + document_type=document_type, + document_number=document_number, role=role_name, role_id=int(role_id) if role_id else None, is_active=bool(is_active) @@ -280,7 +320,7 @@ def create_user(): return jsonify({ 'status': 'success', 'message': 'Usuario creado exitosamente.', - 'user': {'id': user.id, 'name': user.name, 'email': user.email, 'role': user.role, 'is_active': user.is_active} + 'user': user.to_dict() }), 201 @api_admin_bp.route('/users/', methods=['PUT']) @@ -289,8 +329,48 @@ def update_user(id): user = User.query.get_or_404(id) data = request.get_json(silent=True) or request.form.to_dict() or {} - if 'name' in data and data['name']: - user.name = data['name'].strip() + first_name = data.get('first_name', user.first_name or '').strip() + last_name = data.get('last_name', user.last_name or '').strip() + name = data.get('name', '').strip() + + if 'first_name' in data or 'last_name' in data: + user.first_name = first_name + user.last_name = last_name + user.name = f"{first_name} {last_name}".strip() + elif name: + user.name = name + parts = name.split() + user.first_name = parts[0] if parts else '' + user.last_name = ' '.join(parts[1:]) if len(parts) > 1 else '' + + if 'phone' in data: + user.phone = (data['phone'] or '').strip() + if 'personal_email' in data: + user.personal_email = (data['personal_email'] or '').strip().lower() + if 'address' in data: + user.address = (data['address'] or '').strip() + + if 'document_type' in data or 'document_number' in data: + doc_type = data.get('document_type', user.document_type or 'DNI').strip().upper() + doc_num = data.get('document_number', user.document_number or '').strip() + + if doc_num: + is_valid, clean_doc, err_msg = validate_document(doc_type, doc_num) + if not is_valid: + return jsonify({'error': 'ValidationError', 'message': err_msg}), 400 + doc_num = clean_doc + + existing_doc = User.query.filter( + User.document_type == doc_type, + User.document_number == doc_num, + User.id != id + ).first() + if existing_doc: + return jsonify({'error': 'Conflict', 'message': f'El documento {doc_type} {doc_num} ya está asignado a otro usuario.'}), 409 + + user.document_type = doc_type + user.document_number = doc_num + if 'email' in data and data['email']: email = data['email'].strip().lower() existing = User.query.filter(User.email.ilike(email), User.id != id).first() @@ -312,7 +392,15 @@ def update_user(id): return jsonify({ 'status': 'success', 'message': 'Usuario actualizado correctamente.', - 'user': {'id': user.id, 'name': user.name, 'email': user.email, 'role': user.role, 'is_active': user.is_active} + 'user': user.to_dict() + }), 200 + +@api_admin_bp.route('/users/document-types', methods=['GET']) +@jwt_required +def get_document_types(): + return jsonify({ + 'status': 'success', + 'document_types': DOCUMENT_TYPES }), 200 @api_admin_bp.route('/users//toggle', methods=['POST']) @@ -598,15 +686,18 @@ def delete_subject(id): subject.is_active = False db.session.commit() return jsonify({ - 'status': 'success', - 'message': 'La asignatura tiene comisiones activas. Se ha desactivado en su lugar.' + 'status': 'deactivated', + 'action': 'deactivated', + 'message': f'La asignatura "{subject.name}" tiene {len(subject.commissions)} comisión(es) vinculada(s). Se ha desactivado en su lugar para proteger el historial académico.' }), 200 + name = subject.name db.session.delete(subject) db.session.commit() return jsonify({ - 'status': 'success', - 'message': 'Asignatura eliminada permanentemente.' + 'status': 'deleted', + 'action': 'deleted', + 'message': f'Asignatura "{name}" eliminada permanentemente del sistema.' }), 200 # --------------------------------------------------------- @@ -659,6 +750,10 @@ def create_commission(): if virtual_link: existing.virtual_link = virtual_link existing.active = bool(active) + if teacher_id: + ct = CommissionTeacher.query.filter_by(commission_id=existing.id, user_id=teacher_id).first() + if not ct: + db.session.add(CommissionTeacher(commission_id=existing.id, user_id=teacher_id, role='Titular', is_primary=True)) db.session.commit() return jsonify({ 'status': 'success', @@ -679,6 +774,19 @@ def create_commission(): active=bool(active) ) db.session.add(commission) + db.session.flush() + + if teacher_id: + db.session.add(CommissionTeacher(commission_id=commission.id, user_id=teacher_id, role='Titular', is_primary=True)) + + # Soporte para docentes adicionales al crear comisión + extra_teachers = data.get('teacher_ids') or data.get('teachers') or [] + for et in extra_teachers: + u_id = et.get('user_id') if isinstance(et, dict) else et + u_role = et.get('role', 'Adjunto') if isinstance(et, dict) else 'Adjunto' + if u_id and int(u_id) != teacher_id: + db.session.add(CommissionTeacher(commission_id=commission.id, user_id=int(u_id), role=u_role, is_primary=False)) + db.session.commit() return jsonify({ @@ -688,6 +796,16 @@ def create_commission(): }), 201 +@api_admin_bp.route('/commissions/', methods=['GET']) +@jwt_required +def get_commission_detail(id): + comm = Commission.query.get_or_404(id) + return jsonify({ + 'status': 'success', + 'commission': comm.to_dict() + }), 200 + + @api_admin_bp.route('/commissions/', methods=['PUT']) @jwt_required def update_commission(id): @@ -703,6 +821,10 @@ def update_commission(id): if 'teacher_id' in data: t_id = data['teacher_id'] comm.teacher_id = int(t_id) if t_id else None + if comm.teacher_id: + ct = CommissionTeacher.query.filter_by(commission_id=comm.id, user_id=comm.teacher_id).first() + if not ct: + db.session.add(CommissionTeacher(commission_id=comm.id, user_id=comm.teacher_id, role='Titular', is_primary=True)) if 'max_students' in data and data['max_students']: comm.max_students = int(data['max_students']) if 'schedule' in data: @@ -740,17 +862,176 @@ def toggle_commission(id): def assign_commission_teacher(id): comm = Commission.query.get_or_404(id) data = request.get_json(silent=True) or request.form.to_dict() or {} - t_id = data.get('teacher_id') - comm.teacher_id = int(t_id) if t_id else None + t_id = data.get('teacher_id') or data.get('user_id') + role = (data.get('role') or 'Titular').strip() + + if t_id: + t_id = int(t_id) + comm.teacher_id = t_id + ct = CommissionTeacher.query.filter_by(commission_id=comm.id, user_id=t_id).first() + if not ct: + ct = CommissionTeacher(commission_id=comm.id, user_id=t_id, role=role, is_primary=True) + db.session.add(ct) + else: + ct.role = role + ct.is_primary = True + else: + comm.teacher_id = None + db.session.commit() return jsonify({ 'status': 'success', 'id': comm.id, 'teacher_id': comm.teacher_id, 'teacher_name': comm.teacher_name, + 'teachers': comm.teachers, 'message': 'Docente asignado correctamente.' }), 200 +@api_admin_bp.route('/commissions//teachers', methods=['POST']) +@jwt_required +def add_commission_teacher(id): + comm = Commission.query.get_or_404(id) + data = request.get_json(silent=True) or request.form.to_dict() or {} + user_id = data.get('user_id') or data.get('teacher_id') + if not user_id: + return jsonify({'error': 'ValidationError', 'message': 'El docente es requerido.'}), 400 + + user = User.query.get(int(user_id)) + if not user: + return jsonify({'error': 'NotFound', 'message': f'El usuario docente con ID {user_id} no existe.'}), 404 + + role = (data.get('role') or 'Adjunto').strip() + is_primary = data.get('is_primary', False) + if isinstance(is_primary, str): + is_primary = is_primary.lower() in ['true', '1', 'on'] + + existing = CommissionTeacher.query.filter_by(commission_id=comm.id, user_id=user.id).first() + if existing: + existing.role = role + existing.is_primary = bool(is_primary) + else: + if is_primary or comm.teacher_id is None: + comm.teacher_id = user.id + is_primary = True + ct = CommissionTeacher( + commission_id=comm.id, + user_id=user.id, + role=role, + is_primary=bool(is_primary) + ) + db.session.add(ct) + + db.session.commit() + return jsonify({ + 'status': 'success', + 'message': f'{user.name} asignado/a como {role} en la comisión.', + 'teachers': comm.teachers, + 'commission': comm.to_dict() + }), 200 + +@api_admin_bp.route('/commissions//teachers/', methods=['DELETE']) +@jwt_required +def remove_commission_teacher(id, user_id): + comm = Commission.query.get_or_404(id) + ct = CommissionTeacher.query.filter_by(commission_id=comm.id, user_id=user_id).first() + if ct: + db.session.delete(ct) + + # Si era el docente titular, reasignar a otro docente disponible o dejar en None + if comm.teacher_id == user_id: + other = CommissionTeacher.query.filter(CommissionTeacher.commission_id == comm.id, CommissionTeacher.user_id != user_id).first() + comm.teacher_id = other.user_id if other else None + if other: + other.is_primary = True + + db.session.commit() + return jsonify({ + 'status': 'success', + 'message': 'Docente desvinculado de la comisión correctamente.', + 'teachers': comm.teachers, + 'commission': comm.to_dict() + }), 200 + +# --------------------------------------------------------- +# COMMISSION STUDENT ENROLLMENTS (FASE 2 MVP) +# --------------------------------------------------------- + +@api_admin_bp.route('/commissions//enrollments', methods=['GET']) +@jwt_required +def get_commission_enrollments(id): + comm = Commission.query.get_or_404(id) + enrollments = EnrollmentService.list_enrollments(comm.id) + return jsonify({ + 'commission_id': comm.id, + 'commission_code': comm.code, + 'count': len(enrollments), + 'max_students': comm.max_students, + 'current_students': comm.current_students, + 'enrollments': enrollments + }), 200 + +@api_admin_bp.route('/commissions//enrollments', methods=['POST']) +@jwt_required +def enroll_commission_student(id): + data = request.get_json(silent=True) or {} + student_id = data.get('student_id') + notes = data.get('notes') + allow_same_day_exception = bool(data.get('allow_same_day_exception', False)) + exception_reason = data.get('exception_reason') + + if not student_id: + return jsonify({'error': 'BadRequest', 'message': 'student_id es obligatorio.'}), 400 + + try: + enrollment = EnrollmentService.enroll_student( + commission_id=id, + student_id=int(student_id), + notes=notes, + allow_same_day_exception=allow_same_day_exception, + exception_reason=exception_reason + ) + return jsonify({ + 'status': 'success', + 'message': 'Estudiante matriculado exitosamente en la comisión.', + 'enrollment': enrollment.to_dict() + }), 201 + except ValueError as e: + return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 409 + +@api_admin_bp.route('/commissions//enrollments/', methods=['DELETE']) +@jwt_required +def unenroll_commission_student(id, student_id): + success = EnrollmentService.unenroll_student(commission_id=id, student_id=student_id) + if not success: + return jsonify({'error': 'NotFound', 'message': 'Matrícula no encontrada para este estudiante.'}), 404 + return jsonify({ + 'status': 'success', + 'message': 'Estudiante desvinculado de la comisión exitosamente.' + }), 200 + +@api_admin_bp.route('/commissions//enrollments/', methods=['PUT']) +@jwt_required +def update_commission_enrollment(id, student_id): + data = request.get_json(silent=True) or {} + status = data.get('status', 'activo') + notes = data.get('notes') + + enrollment = EnrollmentService.update_enrollment_status( + commission_id=id, + student_id=student_id, + status=status, + notes=notes + ) + if not enrollment: + return jsonify({'error': 'NotFound', 'message': 'Matrícula no encontrada.'}), 404 + + return jsonify({ + 'status': 'success', + 'message': 'Estado de matrícula actualizado.', + 'enrollment': enrollment.to_dict() + }), 200 + # --------------------------------------------------------- # ACADEMIC TERMS CRUD # --------------------------------------------------------- diff --git a/backend/app/routes/api/auth.py b/backend/app/routes/api/auth.py index 16cdb7e..53468f2 100644 --- a/backend/app/routes/api/auth.py +++ b/backend/app/routes/api/auth.py @@ -15,7 +15,9 @@ def login(): """ 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: dto = LoginDTO(**data) except ValidationError as e: @@ -56,7 +58,9 @@ def refresh(): """ 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') if not refresh_token: diff --git a/backend/app/routes/api/openapi.py b/backend/app/routes/api/openapi.py new file mode 100644 index 0000000..b54c2bb --- /dev/null +++ b/backend/app/routes/api/openapi.py @@ -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) diff --git a/backend/app/routes/api/reservations.py b/backend/app/routes/api/reservations.py index 63e782c..7155a6b 100644 --- a/backend/app/routes/api/reservations.py +++ b/backend/app/routes/api/reservations.py @@ -245,3 +245,48 @@ def confirm_reservation(reservation_id: int): return jsonify({'message': f'Reserva {reservation_id} confirmada correctamente.'}), 200 +@api_reservations_bp.route('/check-conflicts', methods=['POST']) +@jwt_required +def check_conflicts_endpoint(): + """ + Evalúa la matriz completa de conflictos (físico, docente y aforo) en tiempo real. + """ + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({'error': 'BadRequest', 'message': 'El cuerpo de la solicitud debe ser un objeto JSON.'}), 400 + + classroom_id = data.get('classroom_id') + start_str = data.get('start_time') + end_str = data.get('end_time') + + if not classroom_id or not start_str or not end_str: + return jsonify({'error': 'BadRequest', 'message': 'classroom_id, start_time y end_time son obligatorios.'}), 400 + + try: + start_time = datetime.fromisoformat(str(start_str).replace('Z', '+00:00')) + end_time = datetime.fromisoformat(str(end_str).replace('Z', '+00:00')) + except ValueError: + return jsonify({'error': 'BadRequest', 'message': 'Formato inválido de fecha/hora (ISO 8601 esperado).'}), 400 + + teacher_id = data.get('teacher_id') + commission_id = data.get('commission_id') + expected_attendees = data.get('expected_attendees') + allow_co_teaching = bool(data.get('allow_co_teaching_exception', False)) + exclude_id = data.get('exclude_id') + + try: + matrix = reservation_service.check_full_conflicts_matrix( + classroom_id=int(classroom_id), + start_time=start_time, + end_time=end_time, + teacher_id=int(teacher_id) if teacher_id else None, + commission_id=int(commission_id) if commission_id else None, + expected_attendees=int(expected_attendees) if expected_attendees is not None else None, + allow_co_teaching_exception=allow_co_teaching, + exclude_id=int(exclude_id) if exclude_id else None + ) + return jsonify(matrix), 200 + except ValueError as e: + return jsonify({'error': 'ValidationError', 'message': str(e)}), 400 + + diff --git a/backend/app/schemas/reservation_dto.py b/backend/app/schemas/reservation_dto.py index fdf8389..ce0db7b 100644 --- a/backend/app/schemas/reservation_dto.py +++ b/backend/app/schemas/reservation_dto.py @@ -22,7 +22,7 @@ class ReservationBaseDTO(BaseModel): return self class ReservationCreateDTO(ReservationBaseDTO): - pass + allow_co_teaching_exception: Optional[bool] = Field(default=False, description="Excepción justificada si hay co-docencia") class ReservationUpdateDTO(BaseModel): classroom_id: Optional[int] = None @@ -34,6 +34,7 @@ class ReservationUpdateDTO(BaseModel): virtual_link: Optional[str] = None notes: Optional[str] = None status: Optional[str] = None + allow_co_teaching_exception: Optional[bool] = False class ReservationResponseDTO(ReservationBaseDTO): id: int diff --git a/backend/app/services/enrollment_service.py b/backend/app/services/enrollment_service.py new file mode 100644 index 0000000..b1ca30a --- /dev/null +++ b/backend/app/services/enrollment_service.py @@ -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 diff --git a/backend/app/services/reservation_service.py b/backend/app/services/reservation_service.py index f5a79a0..d30c809 100644 --- a/backend/app/services/reservation_service.py +++ b/backend/app/services/reservation_service.py @@ -1,7 +1,8 @@ -from typing import Optional, List +from typing import Optional, List, Dict, Any from datetime import datetime from app.models.reservation import Reservation, ReservationStatus from app.models.classroom import Classroom +from app.models.subject import Commission, CommissionTeacher from app.repositories.reservation_repository import ReservationRepository from app.repositories.classroom_repository import ClassroomRepository from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO @@ -38,23 +39,116 @@ class ReservationService: exclude_reservation_id=exclude_id ) + def check_teacher_conflicts(self, teacher_id: int, start_time: datetime, end_time: datetime, + exclude_id: Optional[int] = None) -> List[Reservation]: + """Verifica si el docente ya tiene otra reserva en la misma franja horaria.""" + if not teacher_id: + return [] + return self.reservation_repo.find_teacher_conflicts( + teacher_id=teacher_id, + start_time=start_time, + end_time=end_time, + exclude_reservation_id=exclude_id + ) + + def check_full_conflicts_matrix(self, classroom_id: int, start_time: datetime, end_time: datetime, + teacher_id: Optional[int] = None, + commission_id: Optional[int] = None, + expected_attendees: Optional[int] = None, + allow_co_teaching_exception: bool = False, + exclude_id: Optional[int] = None) -> Dict[str, Any]: + """ + Matriz completa de validación de conflictos (Fase 2 MVP): + 1. Conflicto físico de aula (bloqueo estricto para aulas físicas). + 2. Conflicto docente (detección con soporte de co-docencia). + 3. Conflicto de aforo (capacidad física). + """ + classroom = self.classroom_repo.get_by_id(classroom_id) + if not classroom or not classroom.is_active: + raise ValueError(f"El aula con ID {classroom_id} no existe o no se encuentra activa.") + + commission = None + if commission_id: + commission = Commission.query.get(commission_id) + if not teacher_id and commission: + teacher_id = commission.teacher_id + + # 1. Conflicto Físico + physical_conflicts = [] + if not classroom.is_virtual: + physical_conflicts = self.check_conflicts(classroom_id, start_time, end_time, exclude_id=exclude_id) + + # 2. Conflicto Docente y Co-docencia + teacher_conflicts = [] + has_co_teaching_coverage = False + co_teachers_list = [] + + if teacher_id: + teacher_conflicts = self.check_teacher_conflicts(teacher_id, start_time, end_time, exclude_id=exclude_id) + if teacher_conflicts and commission: + # Comprobar si la comisión cuenta con equipo docente de respaldo + co_teachers = CommissionTeacher.query.filter_by(commission_id=commission.id).all() + other_teachers = [ct for ct in co_teachers if ct.user_id != teacher_id] + if other_teachers: + has_co_teaching_coverage = True + co_teachers_list = [ + {'id': ct.user_id, 'name': ct.teacher.name if ct.teacher else 'Docente', 'role': ct.role} + for ct in other_teachers + ] + + # 3. Conflicto de Aforo + has_capacity_conflict = False + capacity_message = None + if not classroom.is_virtual and expected_attendees is not None: + if expected_attendees > classroom.capacity: + has_capacity_conflict = True + capacity_message = f"La cantidad esperada ({expected_attendees}) supera la capacidad del aula ({classroom.capacity})." + + # Evaluación de Bloqueos + block_reasons = [] + if physical_conflicts: + c_strs = [f"#{c.id} ({c.start_time.strftime('%H:%M')} a {c.end_time.strftime('%H:%M')})" for c in physical_conflicts] + block_reasons.append(f"Conflicto de horario en aula {classroom.code}: se solapa con las reservas {', '.join(c_strs)}.") + + if has_capacity_conflict: + block_reasons.append(f"Conflicto de aforo: {capacity_message}") + + if teacher_conflicts: + if has_co_teaching_coverage or allow_co_teaching_exception: + # Cobertura justificada por equipo de co-docencia + pass + else: + t_strs = [f"#{c.id} ({c.start_time.strftime('%H:%M')} a {c.end_time.strftime('%H:%M')})" for c in teacher_conflicts] + block_reasons.append(f"Conflicto de horario docente: el profesor ya tiene clases asignadas en {', '.join(t_strs)} y la comisión no cuenta con equipo de co-docencia.") + + is_blocked = len(block_reasons) > 0 + return { + 'is_valid': not is_blocked, + 'is_blocked': is_blocked, + 'block_reasons': block_reasons, + 'physical_conflicts': [c.to_dict() for c in physical_conflicts], + 'teacher_conflicts': [c.to_dict() for c in teacher_conflicts], + 'has_co_teaching_coverage': has_co_teaching_coverage, + 'co_teachers': co_teachers_list, + 'has_capacity_conflict': has_capacity_conflict, + 'classroom': classroom.to_dict() + } + def create_reservation(self, dto: ReservationCreateDTO) -> Reservation: """ - Crea una nueva reserva aplicando validaciones de aforo y detección de colisiones. + Crea una nueva reserva aplicando la matriz integral de conflictos. """ - classroom = self.classroom_repo.get_by_id(dto.classroom_id) - if not classroom or not classroom.is_active: - raise ValueError(f"El aula con ID {dto.classroom_id} no existe o no se encuentra activa.") - - # Detección de colisiones para aulas físicas - if not classroom.is_virtual: - conflicts = self.check_conflicts(dto.classroom_id, dto.start_time, dto.end_time) - if conflicts: - conflict_details = ", ".join([f"#{c.id} ({c.start_time.strftime('%H:%M')} a {c.end_time.strftime('%H:%M')})" for c in conflicts]) - raise ValueError(f"Conflicto de horario en {classroom.code}: se solapa con las reservas {conflict_details}.") - - if dto.expected_attendees > classroom.capacity: - raise ValueError(f"La cantidad de alumnos ({dto.expected_attendees}) supera la capacidad del aula ({classroom.capacity}).") + matrix = self.check_full_conflicts_matrix( + classroom_id=dto.classroom_id, + start_time=dto.start_time, + end_time=dto.end_time, + teacher_id=dto.user_id, + commission_id=dto.commission_id, + expected_attendees=dto.expected_attendees, + allow_co_teaching_exception=getattr(dto, 'allow_co_teaching_exception', False) + ) + if matrix['is_blocked']: + raise ValueError(" | ".join(matrix['block_reasons'])) reservation = Reservation( classroom_id=dto.classroom_id, @@ -72,7 +166,7 @@ class ReservationService: return self.reservation_repo.save(reservation) def update_reservation(self, reservation_id: int, dto: ReservationUpdateDTO) -> Reservation: - """Actualiza una reserva existente verificando disponibilidad horaria.""" + """Actualiza una reserva existente verificando disponibilidad horaria y matriz de conflictos.""" reservation = self.reservation_repo.get_by_id(reservation_id) if not reservation: raise ValueError(f"Reserva con ID {reservation_id} no encontrada.") @@ -80,16 +174,20 @@ class ReservationService: classroom_id = dto.classroom_id or reservation.classroom_id start_time = dto.start_time or reservation.start_time end_time = dto.end_time or reservation.end_time + expected_attendees = dto.expected_attendees if dto.expected_attendees is not None else reservation.expected_attendees - classroom = self.classroom_repo.get_by_id(classroom_id) - if not classroom or not classroom.is_active: - raise ValueError(f"El aula con ID {classroom_id} no existe o no se encuentra activa.") - - if not classroom.is_virtual: - conflicts = self.check_conflicts(classroom_id, start_time, end_time, exclude_id=reservation.id) - if conflicts: - conflict_details = ", ".join([f"#{c.id} ({c.start_time.strftime('%H:%M')} - {c.end_time.strftime('%H:%M')})" for c in conflicts]) - raise ValueError(f"Conflicto de horario en {classroom.code} con: {conflict_details}.") + matrix = self.check_full_conflicts_matrix( + classroom_id=classroom_id, + start_time=start_time, + end_time=end_time, + teacher_id=reservation.user_id, + commission_id=reservation.commission_id, + expected_attendees=expected_attendees, + allow_co_teaching_exception=getattr(dto, 'allow_co_teaching_exception', False), + exclude_id=reservation.id + ) + if matrix['is_blocked']: + raise ValueError(" | ".join(matrix['block_reasons'])) if dto.classroom_id is not None: reservation.classroom_id = dto.classroom_id diff --git a/backend/app/services/sheets_importer.py b/backend/app/services/sheets_importer.py index c124b6e..f7896ec 100644 --- a/backend/app/services/sheets_importer.py +++ b/backend/app/services/sheets_importer.py @@ -76,11 +76,13 @@ class GoogleSheetsImporter: """Fetch CSV string for a specific sheet gid""" separator = '&' if '?' in self.base_url else '?' url = f"{self.base_url}{separator}gid={gid}" + if not (url.startswith('https://') or url.startswith('http://')): + raise ValueError(f"URL scheme not permitted: {url}") req = urllib.request.Request( url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) EduSpace/2.0'} ) - with urllib.request.urlopen(req, timeout=25) as resp: + with urllib.request.urlopen(req, timeout=25) as resp: # nosec B310 return resp.read().decode('utf-8', errors='replace') def parse_sheet_rows(self, csv_content, sheet_name): diff --git a/backend/migrate_phase2_fields.py b/backend/migrate_phase2_fields.py new file mode 100644 index 0000000..7bb177e --- /dev/null +++ b/backend/migrate_phase2_fields.py @@ -0,0 +1,44 @@ +""" +Migration script for Phase 2 MVP fields: +- subjects.is_short_course (BOOLEAN DEFAULT 0) +- student_enrollments.allow_same_day_exception (BOOLEAN DEFAULT 0) +- student_enrollments.exception_reason (VARCHAR(255)) +""" +import sqlite3 +import os + +def migrate(): + db_paths = [ + os.path.join(os.path.dirname(__file__), 'instance', 'app.db'), + os.path.join(os.path.dirname(__file__), 'app.db') + ] + for db_path in db_paths: + if not os.path.exists(db_path): + continue + print(f"Migrating SQLite DB: {db_path}") + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Check subjects.is_short_course + cursor.execute("PRAGMA table_info(subjects)") + columns = [row[1] for row in cursor.fetchall()] + if 'is_short_course' not in columns: + print("Adding column 'is_short_course' to subjects table...") + cursor.execute("ALTER TABLE subjects ADD COLUMN is_short_course BOOLEAN DEFAULT 0") + + # Check student_enrollments.allow_same_day_exception + cursor.execute("PRAGMA table_info(student_enrollments)") + enr_columns = [row[1] for row in cursor.fetchall()] + if 'allow_same_day_exception' not in enr_columns: + print("Adding column 'allow_same_day_exception' to student_enrollments table...") + cursor.execute("ALTER TABLE student_enrollments ADD COLUMN allow_same_day_exception BOOLEAN DEFAULT 0") + if 'exception_reason' not in enr_columns: + print("Adding column 'exception_reason' to student_enrollments table...") + cursor.execute("ALTER TABLE student_enrollments ADD COLUMN exception_reason VARCHAR(255)") + + conn.commit() + conn.close() + print(f"Migration completed for {db_path}") + +if __name__ == '__main__': + migrate() diff --git a/backend/migrate_user_profile_fields.py b/backend/migrate_user_profile_fields.py new file mode 100644 index 0000000..4cb6120 --- /dev/null +++ b/backend/migrate_user_profile_fields.py @@ -0,0 +1,79 @@ +import sqlite3 +import os +import sys + +# Locate database +possible_paths = [ + os.path.join(os.path.dirname(__file__), 'instance', 'edu_space.db'), + os.path.join(os.path.dirname(__file__), 'instance', 'app.db'), + os.path.join(os.path.dirname(__file__), 'edu_space.db'), + os.path.join(os.path.dirname(__file__), 'app.db'), +] + +db_path = None +for p in possible_paths: + if os.path.exists(p): + db_path = p + break + +if not db_path: + # Check current directory + for root, dirs, files in os.walk(os.path.dirname(__file__)): + for f in files: + if f.endswith('.db'): + db_path = os.path.join(root, f) + break + if db_path: + break + +print(f"Connecting to database: {db_path}") +conn = sqlite3.connect(db_path) +cursor = conn.cursor() + +# Check existing columns in users table +cursor.execute("PRAGMA table_info(users)") +columns = [row[1] for row in cursor.fetchall()] +print(f"Existing columns in 'users': {columns}") + +new_columns = [ + ('first_name', 'VARCHAR(100)'), + ('last_name', 'VARCHAR(100)'), + ('phone', 'VARCHAR(50)'), + ('address', 'VARCHAR(255)'), + ('document_type', 'VARCHAR(20) DEFAULT "DNI"'), + ('document_number', 'VARCHAR(30)') +] + +for col_name, col_type in new_columns: + if col_name not in columns: + print(f"Adding column '{col_name}'...") + cursor.execute(f"ALTER TABLE users ADD COLUMN {col_name} {col_type}") + else: + print(f"Column '{col_name}' already exists.") + +conn.commit() + +# Create index on document_number if not exists +try: + cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_document_number ON users(document_number)") + conn.commit() + print("Index on document_number verified.") +except Exception as e: + print(f"Notice on index: {e}") + +# Migrate existing users: split name into first_name and last_name if empty +cursor.execute("SELECT id, name, first_name, last_name FROM users") +users = cursor.fetchall() +migrated = 0 +for u_id, name, fn, ln in users: + if not fn and name: + parts = name.strip().split() + first_n = parts[0] if parts else '' + last_n = ' '.join(parts[1:]) if len(parts) > 1 else '' + cursor.execute("UPDATE users SET first_name = ?, last_name = ? WHERE id = ?", (first_n, last_n, u_id)) + migrated += 1 + +conn.commit() +print(f"Successfully migrated {migrated} users with first_name and last_name from name.") +conn.close() +print("Migration completed successfully!") diff --git a/backend/requirements.txt b/backend/requirements.txt index b837a64..97c274b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,4 @@ -Flask==3.0.0 +Flask>=3.1.3 Flask-SQLAlchemy==3.1.1 Flask-Login==0.6.3 Flask-WTF==1.2.1 @@ -6,11 +6,11 @@ Flask-Migrate==4.0.5 Flask-CORS==6.0.2 Flask-Babel==4.0.0 WTForms==3.1.0 -Werkzeug==3.0.1 +Werkzeug>=3.1.6 SQLAlchemy>=2.0.32 -python-dotenv==1.0.0 -requests==2.31.0 -bleach==6.1.0 +python-dotenv>=1.2.2 +requests>=2.32.4 +bleach>=6.4.0 python-dateutil==2.8.2 Pillow>=10.4.0 email_validator>=2.0.0 diff --git a/backend/tests/test_phase2_rules.py b/backend/tests/test_phase2_rules.py new file mode 100644 index 0000000..99854e7 --- /dev/null +++ b/backend/tests/test_phase2_rules.py @@ -0,0 +1,293 @@ +import unittest +from datetime import datetime, timedelta +from app import create_app, db +from app.models.classroom import Classroom +from app.models.subject import Subject, Commission, CommissionTeacher +from app.models.user import User +from app.models.role import Role +from app.models.reservation import Reservation +from app.models.enrollment import StudentEnrollment +from app.services.reservation_service import ReservationService +from app.services.enrollment_service import EnrollmentService +from app.schemas.reservation_dto import ReservationCreateDTO + +class TestPhase2Rules(unittest.TestCase): + """Pruebas unitarias e integrales para la Fase 2 del Roadmap MVP (UniCABA).""" + + def setUp(self): + self.app = create_app() + self.app_context = self.app.app_context() + self.app_context.push() + self.res_service = ReservationService() + + # Obtener o crear rol alumno y docente + self.role_alumno = Role.query.filter_by(name='Alumno').first() + if not self.role_alumno: + self.role_alumno = Role(name='Alumno', description='Rol Alumno') + db.session.add(self.role_alumno) + + self.role_docente = Role.query.filter_by(name='Docente').first() + if not self.role_docente: + self.role_docente = Role(name='Docente', description='Rol Docente') + db.session.add(self.role_docente) + db.session.commit() + + # Crear usuarios para tests + ts = int(datetime.utcnow().timestamp()) + self.docente1 = User( + name=f'Profesor Principal {ts}', + email=f'docente1_{ts}@unicaba.edu.ar', + role_id=self.role_docente.id, + role='Docente', + is_active=True + ) + self.docente1.set_password('Secret123!') + + self.docente2 = User( + name=f'Profesor Adjunto {ts}', + email=f'docente2_{ts}@unicaba.edu.ar', + role_id=self.role_docente.id, + role='Docente', + is_active=True + ) + self.docente2.set_password('Secret123!') + + self.alumno1 = User( + name=f'Estudiante Test {ts}', + email=f'alumno_{ts}@unicaba.edu.ar', + role_id=self.role_alumno.id, + role='Alumno', + is_active=True + ) + self.alumno1.set_password('Secret123!') + + # Aulas física y virtual + self.aula_fisica = Classroom( + code=f'A-501-{ts}', + capacity=30, + building='Sede Central', + floor='Piso 5', + is_active=True + ) + self.aula_virtual = Classroom( + code=f'VIRTUAL-MEET-{ts}', + capacity=100, + building='Campus Virtual', + floor='Plataforma Digital', + is_active=True + ) + + # Asignaturas regulares y curso corto + self.materia_regular_a = Subject( + code=f'REG-A-{ts}', + name=f'Algoritmos y Estructuras {ts}', + is_short_course=False, + is_active=True + ) + self.materia_regular_b = Subject( + code=f'REG-B-{ts}', + name=f'Sistemas Operativos {ts}', + is_short_course=False, + is_active=True + ) + self.curso_corto = Subject( + code=f'TALLER-{ts}', + name=f'Taller de Herramientas Git {ts}', + is_short_course=True, + is_active=True + ) + + db.session.add_all([ + self.docente1, self.docente2, self.alumno1, + self.aula_fisica, self.aula_virtual, + self.materia_regular_a, self.materia_regular_b, self.curso_corto + ]) + db.session.commit() + + # Comisiones + self.comision_a = Commission( + subject_id=self.materia_regular_a.id, + code=f'COM-A-{ts}', + semester='1C', + year=2026, + teacher_id=self.docente1.id, + max_students=40, + current_students=0, + schedule='Lunes 08:00 - 12:00', + active=True + ) + self.comision_b = Commission( + subject_id=self.materia_regular_b.id, + code=f'COM-B-{ts}', + semester='1C', + year=2026, + teacher_id=self.docente1.id, + max_students=40, + current_students=0, + schedule='Lunes 14:00 - 18:00', + active=True + ) + self.comision_c_corto = Commission( + subject_id=self.curso_corto.id, + code=f'COM-CORTO-{ts}', + semester='1C', + year=2026, + teacher_id=self.docente2.id, + max_students=20, + current_students=0, + schedule='Lunes 18:00 - 20:00', + active=True + ) + db.session.add_all([self.comision_a, self.comision_b, self.comision_c_corto]) + db.session.commit() + + def tearDown(self): + db.session.rollback() + self.app_context.pop() + + def test_01_physical_conflict_blocks_double_booking(self): + """Estadio 2.2: Debe bloquear doble asignación en misma aula física y horario.""" + start = datetime(2026, 10, 5, 8, 0) + end = datetime(2026, 10, 5, 12, 0) + + # Primera reserva exitosa + dto1 = ReservationCreateDTO( + classroom_id=self.aula_fisica.id, + commission_id=self.comision_a.id, + user_id=self.docente1.id, + start_time=start, + end_time=end, + expected_attendees=25 + ) + res1 = self.res_service.create_reservation(dto1) + self.assertIsNotNone(res1.id) + + # Segunda reserva en la misma aula solapándose (09:00 a 11:00) + dto2 = ReservationCreateDTO( + classroom_id=self.aula_fisica.id, + commission_id=self.comision_b.id, + user_id=self.docente2.id, + start_time=start + timedelta(hours=1), + end_time=end - timedelta(hours=1), + expected_attendees=20 + ) + with self.assertRaises(ValueError) as ctx: + self.res_service.create_reservation(dto2) + self.assertIn("Conflicto de horario", str(ctx.exception)) + + def test_02_capacity_aforo_validation(self): + """Estadio 2.2: Debe bloquear reserva si asistentes esperados superan capacidad física.""" + start = datetime(2026, 10, 6, 8, 0) + end = datetime(2026, 10, 6, 12, 0) + + dto = ReservationCreateDTO( + classroom_id=self.aula_fisica.id, + commission_id=self.comision_a.id, + user_id=self.docente1.id, + start_time=start, + end_time=end, + expected_attendees=self.aula_fisica.capacity + 15 # supera capacidad (30) + ) + with self.assertRaises(ValueError) as ctx: + self.res_service.create_reservation(dto) + self.assertIn("Conflicto de aforo", str(ctx.exception)) + + def test_03_teacher_conflict_and_co_teaching_exception(self): + """Estadio 2.2: Detección de conflicto docente y excepción justificada por co-docencia.""" + start = datetime(2026, 10, 7, 14, 0) + end = datetime(2026, 10, 7, 18, 0) + + # Docente 1 dicta clase en aula física + dto1 = ReservationCreateDTO( + classroom_id=self.aula_fisica.id, + commission_id=self.comision_a.id, + user_id=self.docente1.id, + start_time=start, + end_time=end, + expected_attendees=20 + ) + self.res_service.create_reservation(dto1) + + # Mismo docente 1 intenta otra clase en sala virtual al mismo tiempo + dto_conflict = ReservationCreateDTO( + classroom_id=self.aula_virtual.id, + commission_id=self.comision_b.id, + user_id=self.docente1.id, + start_time=start, + end_time=end, + expected_attendees=20 + ) + + # Sin co-docencia debe bloquear + with self.assertRaises(ValueError) as ctx: + self.res_service.create_reservation(dto_conflict) + self.assertIn("Conflicto de horario docente", str(ctx.exception)) + + # Ahora incorporamos a Docente 2 como co-docente en comision_b + ct = CommissionTeacher( + commission_id=self.comision_b.id, + user_id=self.docente2.id, + role='Adjunto' + ) + db.session.add(ct) + db.session.commit() + + # Con co-docencia registrada, la matriz detecta cobertura docente y permite la reserva + res_co = self.res_service.create_reservation(dto_conflict) + self.assertIsNotNone(res_co.id) + + def test_04_student_same_day_restriction_rule(self): + """Estadio 2.3: Un alumno no puede cursar dos asignaturas regulares el mismo día sin excepción.""" + # Inscribir al alumno en comision_a (Lunes regular) + enr1 = EnrollmentService.enroll_student( + commission_id=self.comision_a.id, + student_id=self.alumno1.id + ) + self.assertEqual(enr1.status, 'activo') + + # Intentar inscribir en comision_b (también Lunes regular): debe BLOQUEAR + with self.assertRaises(ValueError) as ctx: + EnrollmentService.enroll_student( + commission_id=self.comision_b.id, + student_id=self.alumno1.id + ) + self.assertIn("Restricción de cursada diaria", str(ctx.exception)) + self.assertIn("Lunes", str(ctx.exception)) + + def test_05_student_short_course_bypass(self): + """Estadio 2.3: Si una de las materias es curso corto, se autoriza automáticamente.""" + # El alumno cursa comision_a los Lunes + EnrollmentService.enroll_student( + commission_id=self.comision_a.id, + student_id=self.alumno1.id + ) + + # Intentar inscribir en curso corto (Lunes taller): debe PERMITIR + enr_short = EnrollmentService.enroll_student( + commission_id=self.comision_c_corto.id, + student_id=self.alumno1.id + ) + self.assertIsNotNone(enr_short.id) + self.assertEqual(enr_short.status, 'activo') + + def test_06_student_bedelia_manual_exception_bypass(self): + """Estadio 2.3: Con autorización expresa de Bedelía (allow_same_day_exception), se permite.""" + # Alumno cursa comision_a los Lunes + EnrollmentService.enroll_student( + commission_id=self.comision_a.id, + student_id=self.alumno1.id + ) + + # Inscribir en comision_b con allow_same_day_exception=True + enr2 = EnrollmentService.enroll_student( + commission_id=self.comision_b.id, + student_id=self.alumno1.id, + allow_same_day_exception=True, + exception_reason="Autorización especial de Bedelía por cambio de plan de estudios" + ) + self.assertIsNotNone(enr2.id) + self.assertTrue(enr2.allow_same_day_exception) + self.assertIn("cambio de plan", enr2.exception_reason) + +if __name__ == '__main__': + unittest.main() diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 972c253..3a0d665 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,8 @@ "cookie-parser": "^1.4.7", "dotenv": "^18.0.0", "express": "^5.2.1", + "express-rate-limit": "^8.7.0", + "helmet": "^8.3.0", "nunjucks": "^3.2.4" } }, @@ -480,6 +482,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express/node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", @@ -720,6 +741,18 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -775,6 +808,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index aaacf50..03a784b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,8 @@ "cookie-parser": "^1.4.7", "dotenv": "^18.0.0", "express": "^5.2.1", + "express-rate-limit": "^8.7.0", + "helmet": "^8.3.0", "nunjucks": "^3.2.4" } } diff --git a/frontend/src/app.js b/frontend/src/app.js index 1a0ca3d..04bcde4 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -33,10 +33,37 @@ nunjucksRuntime.memberLookup = function(obj, val) { const cookieParser = require('cookie-parser'); const path = require('path'); const axios = require('axios'); +const helmet = require('helmet'); +const rateLimit = require('express-rate-limit'); const app = express(); const port = process.env.PORT || 3000; +app.disable('x-powered-by'); + +// Blindaje HTTP con Helmet +app.use(helmet({ + contentSecurityPolicy: false, + crossOriginEmbedderPolicy: false +})); + +// Rate Limiting para protección contra ataques de fuerza bruta y abuso +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 30, + message: 'Demasiados intentos de autenticación. Por favor intente nuevamente en 15 minutos.', + standardHeaders: true, + legacyHeaders: false +}); +const generalLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 180, + standardHeaders: true, + legacyHeaders: false +}); +app.use('/auth/login', authLimiter); +app.use('/api', generalLimiter); + // Configuración de middlewares app.use(express.json()); app.use(express.urlencoded({ extended: true })); diff --git a/frontend/src/routes/admin.js b/frontend/src/routes/admin.js index 30bfd21..c65a687 100644 --- a/frontend/src/routes/admin.js +++ b/frontend/src/routes/admin.js @@ -291,6 +291,7 @@ const MOCK_USERS = [ const fetchUsersAndRoles = async (apiClient) => { let users = [...MOCK_USERS]; let roles = [...MOCK_ROLES]; + let document_types = []; try { const [uRes, rRes] = await Promise.allSettled([ apiClient.get('/users'), @@ -298,7 +299,8 @@ const fetchUsersAndRoles = async (apiClient) => { ]); if (uRes.status === 'fulfilled' && uRes.value.data) { const d = uRes.value.data; - users = (d.users || d) || users; + users = d.users || (Array.isArray(d) ? d : users); + document_types = d.document_types || []; } if (rRes.status === 'fulfilled' && rRes.value.data) { const d = rRes.value.data; @@ -307,21 +309,41 @@ const fetchUsersAndRoles = async (apiClient) => { } catch (e) { console.warn('Using mock users/roles data:', e.message); } - return { users, roles }; + if (!document_types || document_types.length === 0) { + document_types = [ + { code: 'DNI', name: 'Documento Nacional de Identidad', category: 'Nacional', placeholder: '8 dígitos sin puntos' }, + { code: 'CUIL', name: 'Código Único de Identificación Laboral (CUIL/CUIT)', category: 'Nacional / Laboral', placeholder: '20-12345678-9' }, + { code: 'PAS', name: 'Pasaporte Argentino', category: 'Internacional', placeholder: 'AAB123456' }, + { code: 'PASEXT', name: 'Pasaporte Extranjero', category: 'Internacional / Extranjero', placeholder: 'Alfanumérico país de origen' }, + { code: 'DNIEXT', name: 'DNI para Extranjeros (Residente)', category: 'Extranjero / Residente', placeholder: 'DNI residente' }, + { code: 'CI', name: 'Cédula de Identidad (PFA / Prov)', category: 'Nacional (Histórico)', placeholder: 'Cédula de identidad' }, + { code: 'CIEXT', name: 'Cédula de Identidad Extranjera (Mercosur)', category: 'Regional / Mercosur', placeholder: 'Cédula Mercosur' }, + { code: 'LC', name: 'Libreta Cívica', category: 'Histórico Nacional', placeholder: 'Libreta Cívica' }, + { code: 'LE', name: 'Libreta de Enrolamiento', category: 'Histórico Nacional', placeholder: 'Libreta Enrolamiento' } + ]; + } + return { users, roles, document_types }; }; const handleUsersList = async (req, res) => { try { - const { users, roles } = await fetchUsersAndRoles(req.apiClient); + const { users, roles, document_types } = await fetchUsersAndRoles(req.apiClient); const search = (req.query.search || '').toLowerCase().trim(); const role_filter = req.query.role || ''; const status_filter = req.query.status || ''; + const msg = req.query.msg || ''; + const error = req.query.error || ''; let filtered = [...users]; if (search) { filtered = filtered.filter(u => (u.name && u.name.toLowerCase().includes(search)) || - (u.email && u.email.toLowerCase().includes(search)) + (u.first_name && u.first_name.toLowerCase().includes(search)) || + (u.last_name && u.last_name.toLowerCase().includes(search)) || + (u.email && u.email.toLowerCase().includes(search)) || + (u.personal_email && u.personal_email.toLowerCase().includes(search)) || + (u.document_number && u.document_number.toLowerCase().includes(search)) || + (u.phone && u.phone.toLowerCase().includes(search)) ); } if (role_filter) { @@ -331,69 +353,95 @@ const handleUsersList = async (req, res) => { if (status_filter === 'inactive') filtered = filtered.filter(u => !u.is_active); res.render('admin/users/list', { - title: 'User Management - Edu-Space Admin', + title: 'Gestión de Usuarios — Edu-Space Admin', users: filtered, roles, + document_types, search, role_filter, - status_filter + status_filter, + msg, + error }); } catch (err) { console.error('Error in users list:', err.message); res.render('admin/users/list', { - title: 'User Management - Edu-Space Admin', + title: 'Gestión de Usuarios — Edu-Space Admin', users: MOCK_USERS, roles: MOCK_ROLES, - search: '', role_filter: '', status_filter: '' + document_types: [], + search: '', role_filter: '', status_filter: '', msg: '', error: '' }); } }; const handleUserAdd = async (req, res) => { try { - const { roles } = await fetchUsersAndRoles(req.apiClient); + const { roles, document_types } = await fetchUsersAndRoles(req.apiClient); res.render('admin/users/form', { - title: 'New User - Edu-Space Admin', + title: 'Nuevo Usuario — Edu-Space Admin', user: null, - roles + roles, + document_types }); } catch (err) { console.error('Error in user add:', err.message); - res.render('admin/users/form', { title: 'New User', user: null, roles: MOCK_ROLES }); + res.render('admin/users/form', { title: 'Nuevo Usuario', user: null, roles: MOCK_ROLES, document_types: [] }); } }; const handleUserEdit = async (req, res) => { try { - const { users, roles } = await fetchUsersAndRoles(req.apiClient); + const { users, roles, document_types } = await fetchUsersAndRoles(req.apiClient); const userId = req.params.id || req.query.id; const targetUser = (userId ? users.find(u => String(u.id) === String(userId)) : null) || users[0] || MOCK_USERS[0]; res.render('admin/users/form', { - title: `Edit User: ${targetUser.name} - Edu-Space Admin`, + title: `Editar Usuario: ${targetUser.name} — Edu-Space Admin`, user: targetUser, - roles + roles, + document_types }); } catch (err) { console.error('Error in user edit:', err.message); - res.render('admin/users/form', { title: 'Edit User', user: MOCK_USERS[0], roles: MOCK_ROLES }); + res.render('admin/users/form', { title: 'Editar Usuario', user: MOCK_USERS[0], roles: MOCK_ROLES, document_types: [] }); } }; const handleUserSave = async (req, res) => { const userId = req.params.id || req.body.id; - const { name, email, password, role_id, is_active } = req.body; - const payload = { name, email, role_id: parseInt(role_id), is_active: is_active === 'on' || is_active === 'true' }; - if (password) payload.password = password; + const { + name, first_name, last_name, email, personal_email, password, role_id, is_active, + phone, address, document_type, document_number + } = req.body; + + const payload = { + first_name: (first_name || '').trim(), + last_name: (last_name || '').trim(), + name: (name || `${first_name || ''} ${last_name || ''}`).trim(), + email: (email || '').trim().toLowerCase(), + personal_email: (personal_email || '').trim().toLowerCase(), + phone: (phone || '').trim(), + address: (address || '').trim(), + document_type: (document_type || 'DNI').trim().toUpperCase(), + document_number: (document_number || '').trim(), + role_id: role_id ? parseInt(role_id, 10) : null, + is_active: is_active === 'on' || is_active === 'true' || is_active === true + }; + if (password && password.trim()) { + payload.password = password.trim(); + } try { if (userId) { await req.apiClient.put(`/users/${userId}`, payload); } else { await req.apiClient.post('/users', payload); } + return res.redirect('/admin/users_list?msg=saved'); } catch (e) { console.warn('User save API notice:', e.response?.data || e.message); + const errMsg = encodeURIComponent(e.response?.data?.message || 'Error al guardar el usuario'); + return res.redirect(`/admin/users_list?error=${errMsg}`); } - res.redirect('/admin/users_list'); }; router.get(['/users_list', '/users', '/user_list'], handleUsersList); @@ -632,6 +680,8 @@ const handleSubjectsList = async (req, res) => { career_filter: career_id ? parseInt(career_id, 10) : '', active_filter: active, can_edit: true, + msg: req.query.msg || '', + error: req.query.error || '', pagination: { pages: 1, page: 1, total: subjects.length, has_prev: false, has_next: false } }); } catch (err) { @@ -690,11 +740,13 @@ const handleSubjectToggle = async (req, res) => { const handleSubjectDelete = async (req, res) => { const id = req.params.id || req.body.id; try { - await req.apiClient.delete(`/subjects/${id}`); + const delRes = await req.apiClient.delete(`/subjects/${id}`); + const action = delRes.data?.action || 'deleted'; + return res.redirect(`/admin/subjects_list?msg=${action}`); } catch (e) { - console.warn('Subject delete API notice:', e.message); + console.warn('Subject delete API notice:', e.response?.data || e.message); + return res.redirect('/admin/subjects_list?error=delete'); } - res.redirect('/admin/subjects_list'); }; router.get(['/subjects_list', '/subjects', '/subject_list'], handleSubjectsList); @@ -733,7 +785,8 @@ const handleCommissionsList = async (req, res) => { code: c.subject_code, career_name: c.career_name || 'Carrera General' }, - teacher: c.teacher_name ? { name: c.teacher_name } : null + teacher: c.teacher_name ? { name: c.teacher_name } : null, + teachers: c.teachers || (c.teacher_name ? [{ name: c.teacher_name, role: 'Titular' }] : []) })); subjects = cRes.value.data.subjects || []; } @@ -776,16 +829,22 @@ const handleCommissionDetail = async (req, res) => { try { const [cRes, uRes] = await Promise.allSettled([ - req.apiClient.get('/commissions'), + req.apiClient.get(`/commissions/${id}`), req.apiClient.get('/users') ]); - if (cRes.status === 'fulfilled' && cRes.value.data) { - const list = cRes.value.data.commissions || []; + if (cRes.status === 'fulfilled' && cRes.value.data && cRes.value.data.commission) { + commission = cRes.value.data.commission; + } else { + const allRes = await req.apiClient.get('/commissions'); + const list = allRes.data?.commissions || []; commission = list.find(c => String(c.id) === String(id)) || list[0]; } if (uRes.status === 'fulfilled' && uRes.value.data) { const users = uRes.value.data.users || []; - teachers = users.filter(u => (u.role || '').toLowerCase().includes('docent') || (u.role || '').toLowerCase().includes('admin')); + teachers = users.filter(u => { + const r = (u.role || '').toLowerCase(); + return r.includes('docent') || r.includes('prof') || r.includes('admin') || r.includes('bedel'); + }); } } catch (apiErr) { console.warn('Commission detail API notice:', apiErr.message); @@ -805,27 +864,61 @@ const handleCommissionDetail = async (req, res) => { virtual_link: '', subject_name: 'Arquitectura de Software', subject_code: 'ARQ-101', - teacher_name: 'Prof. Juan Pérez' + teacher_name: 'Prof. Juan Pérez', + teachers: [] }; } const commObj = { ...commission, - get_full_code: () => commission.code || `COM-${commission.id}`, - subject: { + get_full_code: () => commission.full_code || commission.code || `COM-${commission.id}`, + subject: commission.subject || { name: commission.subject_name || 'Asignatura', code: commission.subject_code || 'COD-01', - career_obj: { name: 'Licenciatura en Sistemas' } + career_obj: { name: commission.career_name || 'Carrera General' } }, - teacher: commission.teacher_name ? { name: commission.teacher_name, email: 'docente@edu-space.com' } : null + teacher: commission.teacher_name ? { name: commission.teacher_name, email: 'docente@edu-space.com' } : null, + teachers: commission.teachers || (commission.teacher_name ? [{ name: commission.teacher_name, role: 'Titular', email: 'docente@edu-space.com' }] : []) }; + let enrollments = []; + try { + const enrRes = await req.apiClient.get(`/commissions/${id}/enrollments`); + if (enrRes.data && enrRes.data.enrollments) { + enrollments = enrRes.data.enrollments; + } + } catch (enrErr) { + console.warn('Commission enrollments API notice:', enrErr.message); + } + + let students = []; + try { + const usersRes = await req.apiClient.get('/users'); + const allUsers = Array.isArray(usersRes.data) ? usersRes.data : (usersRes.data.users || []); + students = allUsers.filter(u => { + const r = (u.role_name || u.role || '').toLowerCase(); + return r === 'alumno' || r === 'estudiante'; + }); + if (students.length === 0) { + students = allUsers; + } + } catch (usersErr) { + console.warn('Students list API notice:', usersErr.message); + } + + const enrolled_ids = enrollments.map(e => e.student_id); + res.render('admin/commissions/detail', { title: `Comisión ${commObj.get_full_code()} — Edu-Space`, commission: commObj, teachers, - enrollments: [], - can_edit: true + students, + enrollments, + enrolled_ids, + can_edit: true, + error: req.query.error, + success: req.query.success, + unregistered: req.query.unregistered }); } catch (err) { console.error('Error in commission detail:', err.message); @@ -887,12 +980,87 @@ const handleCommissionAssignTeacher = async (req, res) => { res.redirect(`/admin/commission_detail?id=${id}`); }; +const handleCommissionAddTeacher = async (req, res) => { + const id = req.params.id || req.body.id || req.body.commission_id; + const { teacher_id, role } = req.body; + try { + await req.apiClient.post(`/commissions/${id}/teachers`, { + teacher_id: parseInt(teacher_id, 10), + role: role || 'Titular' + }); + } catch (e) { + console.warn('Commission add teacher API notice:', e.response?.data || e.message); + } + res.redirect(`/admin/commission_detail?id=${id}`); +}; + +const handleCommissionRemoveTeacher = async (req, res) => { + const id = req.params.id || req.body.id || req.body.commission_id; + const teacher_id = req.params.teacher_id || req.body.teacher_id; + try { + await req.apiClient.delete(`/commissions/${id}/teachers/${teacher_id}`); + } catch (e) { + console.warn('Commission remove teacher API notice:', e.response?.data || e.message); + } + res.redirect(`/admin/commission_detail?id=${id}`); +}; + +const handleCommissionEnrollStudent = async (req, res) => { + const id = req.params.id || req.body.id || req.body.commission_id; + const { student_id, notes, allow_same_day_exception, exception_reason } = req.body; + try { + await req.apiClient.post(`/commissions/${id}/enrollments`, { + student_id: parseInt(student_id, 10), + notes: (notes || '').trim() || null, + allow_same_day_exception: Boolean(allow_same_day_exception), + exception_reason: (exception_reason || '').trim() || null + }); + res.redirect(`/admin/commission_detail?id=${id}&success=1`); + } catch (e) { + const errorMsg = encodeURIComponent(e.response?.data?.message || 'Error al matricular estudiante.'); + res.redirect(`/admin/commission_detail?id=${id}&error=${errorMsg}`); + } +}; + +const handleCommissionUnenrollStudent = async (req, res) => { + const id = req.params.id || req.body.id || req.body.commission_id; + const student_id = req.params.student_id || req.body.student_id; + try { + await req.apiClient.delete(`/commissions/${id}/enrollments/${student_id}`); + res.redirect(`/admin/commission_detail?id=${id}&unregistered=1`); + } catch (e) { + const errorMsg = encodeURIComponent(e.response?.data?.message || 'Error al desvincular estudiante.'); + res.redirect(`/admin/commission_detail?id=${id}&error=${errorMsg}`); + } +}; + +const handleCommissionUpdateEnrollment = async (req, res) => { + const id = req.params.id || req.body.id || req.body.commission_id; + const student_id = req.params.student_id || req.body.student_id; + const { status, notes } = req.body; + try { + await req.apiClient.put(`/commissions/${id}/enrollments/${student_id}`, { + status: status || 'activo', + notes: notes || undefined + }); + res.redirect(`/admin/commission_detail?id=${id}&updated=1`); + } catch (e) { + const errorMsg = encodeURIComponent(e.response?.data?.message || 'Error al actualizar estado de matrícula.'); + res.redirect(`/admin/commission_detail?id=${id}&error=${errorMsg}`); + } +}; + router.get(['/commissions_list', '/commissions', '/commission_list'], handleCommissionsList); router.get(['/commission_detail', '/commission_detail/:id', '/commissions/:id'], handleCommissionDetail); router.post(['/commission_add', '/commissions/add'], handleCommissionAdd); router.post(['/commission_edit', '/commission_edit/:id'], handleCommissionEdit); router.post(['/commission_toggle', '/commission_toggle/:id'], handleCommissionToggle); router.post(['/commission_assign_teacher', '/commission_assign_teacher/:id'], handleCommissionAssignTeacher); +router.post(['/commission_add_teacher', '/commissions/:id/teachers', '/commissions/teachers/add'], handleCommissionAddTeacher); +router.post(['/commission_remove_teacher', '/commissions/:id/teachers/delete', '/commissions/teachers/remove'], handleCommissionRemoveTeacher); +router.post(['/commission_enroll_student', '/commissions/:id/enroll-student', '/commissions/enroll-student'], handleCommissionEnrollStudent); +router.post(['/commission_unenroll_student', '/commissions/:id/unenroll-student', '/commissions/unenroll-student'], handleCommissionUnenrollStudent); +router.post(['/commission_update_enrollment', '/commissions/:id/update-enrollment'], handleCommissionUpdateEnrollment); // ─── 8. ACADEMIC TERMS ──────────────────────────────────────────────────────── diff --git a/frontend/views/admin/commissions/detail.html b/frontend/views/admin/commissions/detail.html index ca0d349..b52cbc2 100644 --- a/frontend/views/admin/commissions/detail.html +++ b/frontend/views/admin/commissions/detail.html @@ -13,8 +13,35 @@ - - + + {% if error %} + + {% endif %} + {% if success %} + + {% endif %} + {% if unregistered %} + + {% endif %} + {% if updated %} + + {% endif %}
@@ -77,47 +104,96 @@
- +
-
- - Docente Asignado +
+
+ + Equipo Docente / Cátedra +
+ + {{ commission.teachers|length if commission.teachers else (1 if commission.teacher else 0) }} docente(s) +
- {% if commission.teacher %} -
-
- -
-
-
{{ commission.teacher.name }}
-
{{ commission.teacher.email }}
+ {% if commission.teachers and commission.teachers|length > 0 %} +
+ {% for t in commission.teachers %} +
+
+
+ +
+
+
+ {{ t.name }} + + {{ t.role or 'Docente' }} + +
+
{{ t.email or 'Sin email' }}
+
+
+ {% if can_edit %} +
+ + + +
+ {% endif %} +
+ {% endfor %} +
+ {% elif commission.teacher %} +
+
+
+ +
+
+
{{ commission.teacher.name }} Titular
+
{{ commission.teacher.email }}
+
{% else %}
- Sin docente asignado + Sin docentes asignados a la comisión.
{% endif %} {% if can_edit %} -
- -
- - -
- -
+
+
+ Agregar Docente a la Cátedra +
+
+ +
+ + +
+
+ + +
+ +
+
{% endif %}
@@ -147,6 +223,7 @@
+
@@ -168,6 +245,20 @@ Inscribir
+ +
+
+ + +
+
+
+ +
@@ -184,6 +275,7 @@ Alumno Estado Fecha Inscripción + Excepciones Notas {% if can_edit %}Acciones{% endif %} @@ -205,12 +297,23 @@ {{ e.enrolled_at if e.enrolled_at else '—' }} + + {% if e.allow_same_day_exception %} + + Excepción Diaria + + {% else %} + — + {% endif %} + {{ e.notes or '—' }} {% if can_edit %}
+ + +
- {% if comm.teacher %} + {% if comm.teachers and comm.teachers | length > 0 %} +
+ {% for t in comm.teachers %} +
+ + {{ t.role or 'Docente' }} + + {{ t.name }} +
+ {% endfor %} +
+ {% elif comm.teacher %}
@@ -295,7 +306,7 @@
+
Docente titular principal. Podrás agregar más docentes (co-docencia, adjuntos, JTP) desde el detalle de la comisión.
diff --git a/frontend/views/admin/subjects/list.html b/frontend/views/admin/subjects/list.html index b3a06fc..364d0cf 100644 --- a/frontend/views/admin/subjects/list.html +++ b/frontend/views/admin/subjects/list.html @@ -20,12 +20,30 @@ Nueva Asignatura {% endif %} - + Ver Comisiones
+ + {% if msg == 'deleted' %} + + {% elif msg == 'deactivated' %} + + {% elif error == 'delete' %} + + {% endif %} +
@@ -133,7 +151,15 @@
@@ -144,64 +170,14 @@ -
- - - {% endif %} @@ -304,5 +280,124 @@
+ + + + + + + + {% endif %} {% endblock %} diff --git a/frontend/views/admin/users/form.html b/frontend/views/admin/users/form.html index d63ac69..0cd689f 100644 --- a/frontend/views/admin/users/form.html +++ b/frontend/views/admin/users/form.html @@ -1,104 +1,217 @@ {% extends "base.html" %} -{% block title %}{% if user %}Edit User: {{ user.name }}{% else %}New User{% endif %} - Edu-Space Admin{% endblock %} +{% block title %}{% if user %}Editar Usuario: {{ user.name }}{% else %}Nuevo Usuario{% endif %} — Edu-Space Admin{% endblock %} {% block content %}
-
+
-

+

- {% if user %}Edit User Account{% else %}Create User Account{% endif %} + {% if user %}Editar Cuenta de Usuario{% else %}Crear Nueva Cuenta de Usuario{% endif %}

-

Configure user credentials, profile information, and role assignment.

+

+ Gestión integral de identidad, datos de contacto, credenciales y rol institucional para Bedelía, Docentes y Alumnos. +

-
+
-
-
- Account Details -
+
+
+ +
+ {% if user %}Ficha de Datos de: {{ user.name }}{% else %}Datos del Nuevo Usuario{% endif %} +
+
+ {% if user %} + ID #{{ user.id }} + {% endif %}
+
{% if user %} {% endif %} -
- - + + +
+
+ 1. Identidad y Documentación Personal +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+ +
+ + +
+ Ingrese el número según la tipificación oficial seleccionada. +
+
+
-
- - + +
+
+ 2. Contacto y Domicilio +
+ +
+
+ + +
Móvil o fijo con código de área.
+
+
+ + +
Correo personal alternativo para notificaciones y recuperación.
+
+
+ +
+
+ + +
Calle, número, piso/departamento y localidad.
+
+
-
- - - {% if user %} - - Only fill this in if you want to reset this user's password. - - {% endif %} + +
+
+ 3. Cuenta de Acceso y Rol Institucional +
+ +
+
+ +
+ + +
+
+ +
+ +
+ + +
+ {% if user %} + + Solo complete este campo si desea restablecer la contraseña del usuario. + + {% endif %} +
+
+ +
+
+ + + + Define los permisos para Bedelía, Profesores/Docentes, Alumnos o Administradores. + +
+ +
+
+ + + + Permite iniciar sesión en el sistema. + +
+
+
-
- - - - - View or manage roles and permissions - - -
- -
- - - - Inactive users are prevented from signing in to the platform. - - {% if user and user.id == user.id %} - - {% endif %} -
- -
+
- Cancel + Cancelar -
@@ -107,4 +220,36 @@
+ + {% endblock %} diff --git a/frontend/views/admin/users/list.html b/frontend/views/admin/users/list.html index 045075a..6ed3582 100644 --- a/frontend/views/admin/users/list.html +++ b/frontend/views/admin/users/list.html @@ -1,24 +1,39 @@ {% extends "base.html" %} -{% block title %}User Management - Edu-Space Admin{% endblock %} +{% block title %}Gestión de Usuarios — Edu-Space Admin{% endblock %} {% block content %}
-
+
-

- User Management +

+ Gestión de Usuarios

-

Manage user accounts, assign roles, and control active status.

+

+ Administración de cuentas, identidad y documentación, datos de contacto y asignación de roles institucionales (Bedelía, Docentes, Alumnos). +

+ + {% if msg == 'saved' %} + + {% elif error %} + + {% endif %} +
@@ -29,13 +44,13 @@
- - - + + +
{% if search or role_filter or status_filter %} - + {% endif %} @@ -68,12 +83,13 @@ - - - - - - + + + + + + + @@ -81,17 +97,54 @@ + {% else %} - {% endfor %} @@ -162,11 +215,8 @@ - {% if pagination.pages > 1 %} - - +
UserEmailRole / GroupStatusLast LoginActionsUsuario / IdentidadDocumentoContacto y DomicilioRol InstitucionalEstadoÚltimo AccesoAcciones
-
- {{ (u.first_name[0] if u.first_name else 'U')|upper }} +
+ {{ (u.first_name[0] if u.first_name else (u.name[0] if u.name else 'U'))|upper }}
{{ u.name }}
- ID: #{{ u.id }} +
+ {% if u.first_name and u.last_name %} + {{ u.last_name }}, {{ u.first_name }} · + {% endif %} + #{{ u.id }} +
- {{ u.email }} + {% if u.document_number %} +
+ + {{ u.document_type or 'DNI' }}: {{ u.document_formatted or u.document_number }} + +
+ {% else %} + Sin registrar + {% endif %} +
+
+ + {{ u.email }} +
+ {% if u.personal_email %} +
+ + {{ u.personal_email }} +
+ {% endif %} + {% if u.phone or u.address %} +
+ {% if u.phone %} + {{ u.phone }} + {% endif %} + {% if u.address %} + + {{ u.address }} + + {% endif %} +
+ {% endif %}
{% if u.role_obj %} @@ -100,40 +153,40 @@ {% else %} - {{ u.role or _('No Role') }} + {{ u.role or 'Docente' }} {% endif %} {% if u.is_active %} - Active + Activo {% else %} - Inactive + Inactivo {% endif %} {% if u.last_login %} - {{ format_datetime(u.last_login, format='short') }} + {{ u.last_login[:16] if u.last_login is string else u.last_login }} {% else %} - Never + Nunca {% endif %}
- + {% if u.id != user.id %}
@@ -151,9 +204,9 @@
+ - No users found matching the criteria. + No se encontraron usuarios coincidentes con los criterios de búsqueda.