2 Commits
40 changed files with 3421 additions and 446 deletions
+73
View File
@@ -0,0 +1,73 @@
name: Edu-Space Automated Security Audit (Gitea Local)
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
security-scan:
# Usamos la etiqueta estándar para contenedores Ubuntu en Gitea Runner
runs-on: ubuntu-latest
steps:
# 1. Clonar el código del repositorio local
- name: Checkout Code
uses: actions/checkout@v4
# 2. Preparar el entorno de paquetes del sistema (Linux)
- name: Install System Dependencies (Python & Node.js)
run: |
apt-get update && apt-get install -y python3 python3-pip python3-venv nodejs npm curl
python3 -m pip install --upgrade pip
# 3. Instalar librerías de Python y herramientas de seguridad
- name: Install Python Dependencies & Security Tools
run: |
pip3 install -r backend/requirements.txt
pip3 install -r backend/requirements-dev.txt
# 4. Instalar librerías de Node.js (Express BFF)
- name: Install Node.js Dependencies
run: |
cd frontend
npm ci
# --- EJECUCIÓN DE CONTROLES (SAST & SCA) ---
- name: [1/5] Python SAST (Bandit)
run: bandit -r ./backend/app -ll -ii
- name: [2/5] Python SCA (Pip-Audit)
run: pip-audit -s osv --progress-spinner off -r backend/requirements.txt
- name: [3/5] Node.js SAST (Njsscan)
run: njsscan ./frontend/src
- name: [4/5] Node.js SCA (Npm Audit)
run: |
cd frontend
npm audit --audit-level=high
# --- CONTROL DINÁMICO DAST (Schemathesis) ---
- name: [5/5] Start Flask App & Run DAST (Schemathesis)
env:
PYTHONUTF8: 1
FLASK_APP: backend/app # Ajustar a la ruta de inicio de tu Flask si varía
run: |
# Levantar Flask en segundo plano dentro del contenedor local
# Usamos 'python3 -m flask run' para asegurar el bindeo local
cd backend
python3 -m flask run --host=127.0.0.1 --port=5000 &
# Esperar 5 segundos a que la API responda
echo "Esperando a que el backend de Flask inicie en el entorno local..."
sleep 5
# Verificar con un curl rápido si el JSON de OpenAPI está disponible
curl -s http://127.0.0.1:5000/api/v1/openapi.json > /dev/null
# Ejecutar Schemathesis contra la instancia local efímera
schemathesis run http://127.0.0.1:5000/api/v1/openapi.json --checks not_a_server_error --max-examples=10
+2 -1
View File
@@ -220,4 +220,5 @@ npm-debug.log
frontend/.env
legacy_admin-edu-space/
.vscode/
.vscode/
.schemathesis/
+66 -7
View File
@@ -6,16 +6,75 @@ 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/<id>/teachers` y `DELETE /api/v1/commissions/<id>/teachers/<user_id>` con control de duplicados y asignación dinámica.
- Integración visual en el detalle de la comisión (`/admin/commission_detail`) con tarjeta interactiva para vincular/desvincular integrantes del equipo docente y badges de rol en la grilla general (`/admin/commissions_list`).
- **Estadio 2.2 — Matriz de Conflictos y Validación Integral:**
- **Conflicto Físico Estricto:** Bloqueo estricto de superposición horaria en la misma aula física (`ReservationRepository.find_overlapping` & `check_physical_conflicts`).
- **Conflicto Docente Inteligente:** Detección de doble reserva simultánea para un mismo profesor (`ReservationRepository.find_teacher_conflicts` & `check_teacher_conflicts`).
- **Excepción Justificada por Co-docencia:** Si un docente asignado presenta solapamiento pero la comisión cuenta con co-docentes registrados en `CommissionTeacher`, el motor de conflictos autoriza la reserva validando la cobertura del equipo de cátedra.
- **Conflicto de Aforo:** Bloqueo estricto cuando la cantidad de asistentes esperados (`expected_attendees`) excede la capacidad física declarada del aula (`classroom.capacity`).
- Endpoint unificado `POST /api/v1/reservations/check-conflicts` para pre-validación asíncrona de conflictos físicos, docentes y de capacidad antes del guardado.
- **Estadio 2.3 — Regla de Restricción Diaria del Alumno y Motor de Excepciones:**
- Servicio académico `EnrollmentService` (`backend/app/services/enrollment_service.py`) para control de cupos, inscripciones activas y análisis de días de cursada.
- **Regla Diaria:** Un alumno no puede matricularse en dos materias regulares programadas para el mismo día de la semana.
- **Excepción Automática por Curso Corto:** Indicador `is_short_course` en modelo `Subject`; si una de las asignaturas es un taller, seminario o curso corto, el sistema autoriza automáticamente la cursada simultánea en el mismo día.
- **Excepción Expresa de Bedelía:** Parámetros `allow_same_day_exception` y `exception_reason` en el modelo `StudentEnrollment`, permitiendo a Bedelía inscribir alumnos con justificación administrativa expresa (cambio de plan, equivalencias, etc.).
- Endpoints de gestión de matrículas por comisión: `GET /api/v1/commissions/<id>/enrollments`, `POST /api/v1/commissions/<id>/enrollments`, `DELETE /api/v1/commissions/<id>/enrollments/<student_id>` y `PUT /api/v1/commissions/<id>/enrollments/<student_id>`.
- Adaptación completa de UI en `/admin/commission_detail` con selector de estudiantes, toggle interactivo de excepción de Bedelía, campo de motivo justificado, y tabla de estudiantes matriculados con badges de excepción.
- **Suite de Pruebas Unitarias e Integrales de Reglas de Negocio:**
- Archivo `backend/tests/test_phase2_rules.py` con 6 tests completos verificando: bloqueo físico, conflicto de aforo, detección docente con bypass de co-docencia, restricción diaria de cursada, excepción por curso corto y excepción por autorización manual de Bedelía. 100% de tests aprobados.
- **Mejoras UI/UX en Dashboard de Bedelía:**
- Paneles interactivos para visualizar **Comisiones Sin Reserva de Aula** y **Aulas Disponibles** con soporte de filtrado temporal (Semana/Mes) directamente consumiendo estadísticas en tiempo real del motor backend.
- **Fase 1: Auditoría y Seguridad Automatizada:**
- **Control de Entorno Dev:** Aislamiento de herramientas de seguridad en `requirements-dev.txt`.
- **Automatización Local e Integración Continua:** Correcciones de evaluación en `security_audit.bat` para Windows y actualización de `.gitea/workflows/security-audit.yaml` para asegurar la correcta ejecución del stack de auditoría (Bandit, pip-audit, njsscan y Schemathesis).
## [2.5.0] - 2026-09-19
### Añadido
- **Auditoría y Seguridad Automatizada Completa (Python & Node.js):**
- **Python SAST (`bandit`):** Escaneo estático del backend Flask de más de 8.400 líneas. Corrección de vulnerabilidad B310 en `sheets_importer.py` mediante validación estricta de esquema HTTP/HTTPS para prevenir ataques de tipo SSRF. Resultado: 0 fallos detectados.
- **Python SCA (`pip-audit`):** Análisis de dependencias con base OSV; detección de 24 CVEs históricas en paquetes desactualizados y actualización completa a versiones seguras (`Flask>=3.1.3`, `Werkzeug>=3.1.8`, `requests>=2.34.2`, `bleach>=6.4.0`, `python-dotenv>=1.2.3`). Resultado: 0 CVEs activas.
- **Python DAST / Fuzzing (`schemathesis`):** Especificación OpenAPI 3.0.3 en `backend/app/routes/api/openapi.py` (`/api/v1/openapi.json`). Ejecución de 114 casos de prueba dinámicos con mutación de datos. Detección y corrección de caída 500 por payload de tipo JSON inválido en `/auth/login`. Resultado: 114/114 pruebas aprobadas con 0 errores de servidor.
- **Node.js SAST (`njsscan`):** Análisis de patrones de código inseguro en el BFF Express (`frontend/src`). Resultado: 0 hallazgos.
- **Node.js SCA (`npm audit`):** Auditoría continua de paquetes npm con 0 vulnerabilidades reportadas.
- **Blindaje Activo (`helmet` & `express-rate-limit`):** Incorporación de cabeceras HTTP defensivas (`HSTS`, `nosniff`, `SAMEORIGIN`, `X-Permitted-Cross-Domain-Policies`) y limitadores de tasa ante fuerza bruta en `/auth/login` (30 req / 15 min) y `/api` (180 req / min).
- **Script Unificado de Auditoría (`security_audit.bat`):** Ejecutable centralizado para correr los 5 controles en un solo comando.
- **Documento Formal de Seguridad (`SECURITY_AUDIT_REPORT.md`):** Reporte ejecutivo de auditoría y controles continuos.
---
## [2.4.0] - 2026-09-19
### Añadido
- **Perfil Extendido de Usuarios y Tipificaciones de Documentos Oficiales (Argentina y Extranjeros):**
- Incorporación en el modelo `User` de los campos: `first_name` (Nombre), `last_name` (Apellido), `email` (Email Institucional de acceso), `personal_email` (Email Personal/Particular de contacto), `phone` (Teléfono con código de área), `address` (Domicilio/Dirección), `document_type` (Tipo de documento) y `document_number` (Número de documento validado).
- Catálogo formal de reglas de negocio en `backend/app/constants/document_types.py` con tipificaciones oficiales de Argentina y extranjeros (DNI, CUIL, Pasaporte Argentino, Pasaporte Extranjero, DNI Extranjero, Cédula de Identidad PFA, Cédula Mercosur, Libreta Cívica y Libreta de Enrolamiento).
- Validaciones de formato, expresiones regulares, y verificación de unicidad de documento por tipo en endpoints `POST /api/v1/users` y `PUT /api/v1/users/<id>`.
- Búsqueda dinámica en `/admin/users_list` habilitada para filtrar por número de documento o email personal/institucional además de nombre.
- Rediseño del formulario `/admin/users/form` estructurado en secciones: Identidad y Documentación, Contacto y Domicilio (con Teléfono, Email Personal y Dirección), y Cuenta y Rol Institucional (con Email Institucional y Clave), permitiendo al Administrador General gestionar la totalidad de los datos.
- Script de migración SQLite `backend/migrate_user_profile_fields.py` para agregar las columnas e inicializar nombres desglosados de los usuarios existentes.
- **Co-Docencia y Cátedras Múltiples en Comisiones:**
- Creación del modelo relacional `CommissionTeacher` (`commission_teachers`) para asociar múltiples docentes a una misma comisión con roles académicos diferenciados (`Titular`, `Adjunto`, `JTP`, `Ayudante`).
- Nuevos endpoints en la API REST: `POST /api/v1/commissions/<id>/teachers` y `DELETE /api/v1/commissions/<id>/teachers/<user_id>`, junto con la serialización completa de cátedras en `GET /api/v1/commissions`.
- Nueva tarjeta interactiva *"Equipo Docente / Cátedra"* en el detalle de la comisión (`/admin/commission_detail?id=...`) permitiendo agregar y desvincular docentes con sus respectivos roles.
- Actualización de la grilla de comisiones (`/admin/commissions_list`) para exhibir a todos los profesores asignados con badges distintivos de rol.
- **Eliminación y Gestión Segura de Asignaturas:**
- Incorporación de botón y modal interactivo de confirmación de eliminación de asignaturas en `/admin/subjects_list`.
- Implementación de regla de negocio de integridad académica: eliminación física si la materia no tiene comisiones asociadas, o desactivación automática si posee historial o cursadas vinculadas.
### Corregido
- **Bug Visual en Modificación de Asignaturas (`/admin/subjects_list`):**
- Solucionado el problema de modales recortados o cubiertos por el fondo oscuro (`modal-backdrop`), originado por la presencia de 206 modales inline dentro de celdas `<td>` en una tabla con `overflow` (`.table-responsive`). Se refactorizó hacia un único modal global `#modalEditSubject` instanciado fuera de la tabla e inicializado dinámicamente con JavaScript.
### Añadido
- **Monitoreo y Percentiles de Asignación de Aulas Físicas (Admin & Bedelía):**
- Panel analítico *"Estado y Percentiles de Asignación de Aulas Físicas"* incorporado en los dashboards de Administrador (`/dashboard?role=admin`) y Bedelía (`/dashboard?role=bedelia`).
+50
View File
@@ -0,0 +1,50 @@
# CHANGELOG MVP — Admin Edu-Space (UniCABA)
Registro de hitos y versiones correspondientes a las Fases del [ROADMAP_MVP.md](./ROADMAP_MVP.md).
---
## [Fase 2: Co-docencia, Matriz de Conflictos y Regla Diaria] — v2.6.0 (2026-09-19)
**Estado:** ✅ 100% Completada
### Estadio 2.1 — Modelo y Gestión Integral de Co-docencia
* **Modelo `CommissionTeacher`:** Soporte para múltiples docentes por comisión en base de datos (`commission_teachers`), admitiendo roles académicos diferenciados (`Titular`, `Adjunto`, `JTP`, `Ayudante`) y flag `is_primary`.
* **API REST de Cátedras:** Endpoints `POST /api/v1/commissions/<id>/teachers` y `DELETE /api/v1/commissions/<id>/teachers/<user_id>` con serialización completa de cátedra en comisiones.
* **Interfaz de Gestión:** Tarjeta interactiva *"Equipo Docente / Cátedra"* en `/admin/commission_detail` para incorporar y desvincular profesores con rol dinámico, y visualización de badges en `/admin/commissions_list`.
### Estadio 2.2 — Matriz de Conflictos y Validación Integral
* **Conflicto Físico:** Bloqueo estricto e infranqueable de superposiciones de reservas en la misma aula física (`ReservationRepository.find_overlapping`).
* **Conflicto Docente:** Detección de doble asignación simultánea para un mismo docente en diferentes aulas o formatos.
* **Excepción Justificada por Co-docencia:** Si un docente asignado presenta solapamiento pero la comisión cuenta con co-docentes registrados en `CommissionTeacher`, la matriz de validación autoriza la reserva validando la cobertura del equipo de cátedra.
* **Conflicto de Aforo:** Bloqueo estricto cuando `expected_attendees > classroom.capacity` en espacios físicos.
* **Endpoint de Pre-validación:** `POST /api/v1/reservations/check-conflicts` para validación asíncrona de conflictos físicos, docentes y de aforo antes del commit.
### Estadio 2.3 — Regla de Restricción Diaria del Alumno y Motor de Excepciones
* **Servicio `EnrollmentService`:** Lógica de matriculación académica con análisis de días de cursada por comisión y verificación de cupos.
* **Regla Restrictiva:** Un alumno no puede cursar dos asignaturas regulares el mismo día de la semana.
* **Excepción Automática por Curso Corto:** Atributo `is_short_course` en modelo `Subject`; si una de las materias es un taller o curso corto, el sistema autoriza la cursada simultánea en el mismo día.
* **Excepción Expresa de Bedelía:** Campos `allow_same_day_exception` y `exception_reason` en `StudentEnrollment`. La oficina de Bedelía puede autorizar la matriculación simultánea mediante switch justificado en la interfaz.
* **Endpoints de Matrícula:** `GET/POST /api/v1/commissions/<id>/enrollments`, `DELETE/PUT /api/v1/commissions/<id>/enrollments/<student_id>`.
* **UI en Detalle de Comisión:** Formulario de matriculación con toggle de autorización de Bedelía, motivo de excepción y tabla de inscriptos con badges de estado.
### Cobertura de Pruebas
* Suite completa en `backend/tests/test_phase2_rules.py` validando los 6 criterios de aceptación (conflicto físico, aforo, co-docencia, restricción diaria, bypass de curso corto y bypass de Bedelía). 6/6 tests aprobados con 100% de éxito.
### Complementos y Seguridad Transversal
* **Dashboard de Bedelía:** Integración de paneles en tiempo real para análisis de **Comisiones Sin Reserva** y grilla de **Aulas Disponibles** (Filtros Semana/Mes) conectando frontend UI con el servicio analítico backend.
* **Auditoría Automatizada (Fase 1):** Implantación de un pipeline de seguridad local/Gitea que aísla dependencias de desarrollo (`requirements-dev.txt`) y ejecuta controles SAST, DAST y SCA (Bandit, pip-audit, njsscan y Schemathesis) sin vulnerabilidades detectadas.
---
## [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.
+12 -12
View File
@@ -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/<id>/teachers` para asociar y desvincular docentes de cátedra.
- [ ] Adaptación de vistas de Bedelía y Comisiones para visualizar el equipo docente completo.
- [x] Tabla relacional `commission_teachers` para soportar múltiples docentes por comisión (titulares, adjuntos, ayudantes).
- [x] Endpoints `/api/v1/commissions/<id>/teachers` para asociar y desvincular docentes de cátedra.
- [x] Adaptación de vistas de Bedelía y Comisiones para visualizar el equipo docente completo.
2. **Estadio 2.2 — Matriz de Conflictos y Validación:**
- [ ] Validación estricta de conflicto físico: bloqueo de doble asignación de aula en mismo día y horario.
- [ ] Validación de conflicto docente: detección de superposición horaria del profesor, con soporte de excepción justificada cuando la comisión cuenta con co-docencia.
- [ ] Validación de aforo: bloqueo cuando la cantidad de alumnos excede la capacidad del aula física.
- [x] Validación estricta de conflicto físico: bloqueo de doble asignación de aula en mismo día y horario.
- [x] Validación de conflicto docente: detección de superposición horaria del profesor, con soporte de excepción justificada cuando la comisión cuenta con co-docencia.
- [x] Validación de aforo: bloqueo cuando la cantidad de alumnos excede la capacidad del aula física.
3. **Estadio 2.3 — Regla de Restricción Diaria del Alumno:**
- [ ] Validación al matricular: un alumno no puede cursar dos asignaturas regulares en el mismo día calendario.
- [ ] Excepción parametrizable: autorización automática si al menos una de las materias es un "curso corto" o si existe autorización manual de Bedelía (`allow_same_day_exception`).
- [x] Validación al matricular: un alumno no puede cursar dos asignaturas regulares en el mismo día calendario.
- [x] Excepción parametrizable: autorización automática si al menos una de las materias es un "curso corto" (`is_short_course`) o si existe autorización manual de Bedelía (`allow_same_day_exception`).
---
+99
View File
@@ -0,0 +1,99 @@
# Informe de Auditoría y Seguridad Automatizada (Python & Node.js)
**Proyecto:** Edu-Space Admin Architecture
**Fecha:** Septiembre 2026
**Entorno:** Backend Flask (REST API) + Node.js BFF (Express)
**Estado General:** Aprobado ✅ (0 vulnerabilidades conocidas, 0 fallos críticos SAST/DAST)
---
## 1. Resumen Ejecutivo
Se completó la implementación del **Plan de Auditoría y Seguridad Automatizada** que cubre el ciclo de vida completo de las dos capas backend de la plataforma:
- **Capa Core API (Python / Flask)**: Análisis Estático (SAST), Análisis de Dependencias de Terceros (SCA), Pruebas Dinámicas / Fuzzing contra especificación OpenAPI (DAST).
- **Capa BFF / Frontend Server (Node.js / Express)**: Análisis Estático (SAST), Análisis de Dependencias (SCA) y Blindaje Activo con cabeceras HTTP (`helmet`) y limitador de tasa (`express-rate-limit`).
---
## 2. Fase 1: Backend Python (Flask API)
### 2.1. Análisis Estático de Código Fuente (SAST) - `Bandit`
- **Herramienta:** Bandit v1.8.3 (`backend/venv/Scripts/bandit.exe`)
- **Comando:** `bandit -r ./app -ll -ii`
- **Líneas Escaneadas:** 8.456 líneas de código Python.
- **Hallazgo Inicial:** 1 advertencia de severidad Media (CWE-22 / B310 en `backend/app/services/sheets_importer.py` por uso de `urllib.request.urlopen` sin validación estricta del esquema URL).
- **Remediación Aplicada:**
- Se incorporó validación explícita de esquema (`url.startswith('https://')` o `url.startswith('http://')`) previo a la invocación de `urlopen`.
- Se colocó anotación `# nosec B310` justificando la protección criptográfica/red implementada.
- **Resultado Actual:** **0 problemas identificados** (Medium/High = 0).
### 2.2. Auditoría de Dependencias de Terceros (SCA) - `Pip-Audit`
- **Herramienta:** Pip-Audit v2.10.1 con base de datos OSV (`backend/venv/Scripts/pip-audit.exe`)
- **Comando:** `pip-audit -s osv --progress-spinner off -r requirements.txt`
- **Hallazgo Inicial:** 24 vulnerabilidades conocidas asociadas a dependencias desactualizadas (`Flask==3.0.0`, `Werkzeug==3.0.1`, `requests==2.31.0`, `bleach==6.1.0`, `python-dotenv==1.0.0`).
- **Remediaciones Aplicadas:**
- Actualización de `Flask` a `>=3.1.3`
- Actualización de `Werkzeug` a `>=3.1.6` (instalada v3.1.8)
- Actualización de `requests` a `>=2.32.4` (instalada v2.34.2)
- Actualización de `bleach` a `>=6.4.0`
- Actualización de `python-dotenv` a `>=1.2.2` (instalada v1.2.3)
- **Resultado Actual:** **No known vulnerabilities found** (0 CVEs pendientes).
### 2.3. Pruebas Dinámicas y Fuzzing de API REST (DAST) - `Schemathesis`
- **Herramienta:** Schemathesis v4.27.4 (`backend/venv/Scripts/st.exe`)
- **Especificación OpenAPI:** Implementada en `backend/app/routes/api/openapi.py` (`/api/v1/openapi.json`) bajo el estándar OpenAPI 3.0.3.
- **Comando:** `st run http://127.0.0.1:5000/api/v1/openapi.json --checks not_a_server_error --max-examples=10`
- **Hallazgo Inicial:** Fuzzing detectó un error 500 no controlado en `POST /api/v1/auth/login` cuando el payload enviado consistía en un arreglo JSON anómalo (`[null, null]`) en vez de un objeto/diccionario, provocando un `TypeError` en el constructor de Pydantic (`LoginDTO(**data)`).
- **Remediación Aplicada:**
- Se agregó validación de tipo `if not isinstance(data, dict): return jsonify({'error': 'BadRequest', 'message': '...'}), 400` tanto en `/login` como en `/refresh`.
- **Resultado Actual:** **114 casos de prueba generados y superados con éxito**, **0 errores 500 del servidor**.
---
## 3. Fase 2: Backend Node.js (Express BFF)
### 3.1. Análisis Estático de Código Fuente (SAST) - `njsscan`
- **Herramienta:** NodeJsScan (`backend/venv/Scripts/njsscan.exe`)
- **Comando:** `njsscan ./frontend/src`
- **Reglas Evaluadas:** Inyecciones de código, llamadas inseguras a `eval()`, omisión de headers de seguridad, credenciales hardcodeadas, CORS inseguro.
- **Resultado:** **No issues found** (0 vulnerabilidades).
### 3.2. Blindaje de Middleware (`helmet` & `express-rate-limit`)
- **Paquetes Instalados:** `helmet`, `express-rate-limit`.
- **Configuración en `frontend/src/app.js`:**
- `app.disable('x-powered-by')`: Oculta el motor Express para prevenir finger-printing de atacantes.
- `helmet`: Aplica cabeceras HTTP de protección:
- `X-Content-Type-Options: nosniff`
- `X-Frame-Options: SAMEORIGIN` (prevención de Clickjacking)
- `Strict-Transport-Security: max-age=31536000; includeSubDomains` (HSTS)
- `Cross-Origin-Opener-Policy: same-origin`
- `Cross-Origin-Resource-Policy: same-origin`
- `express-rate-limit`:
- Endpoint `/auth/login`: Límite estricto de 30 peticiones por ventana de 15 minutos por IP para mitigar ataques de fuerza bruta de credenciales.
- Endpoints `/api`: Límite de 180 peticiones por minuto por IP para mitigar saturación y scraping.
### 3.3. Auditoría de Dependencias (SCA) - `npm audit`
- **Herramienta:** `npm audit` nativo (v11.x)
- **Comando:** `cmd.exe /c npm audit` en `frontend/`
- **Resultado:** **found 0 vulnerabilities** en 108 paquetes analizados.
---
## 4. Script de Auditoría Automatizada Unificada
Se diseñó y probó el script automatizado [security_audit.bat](file:///c:/Users/Soporte%20IT/workspace/admin-edu-space/security_audit.bat) en la raíz del repositorio, ejecutable en un solo paso:
```cmd
security_audit.bat
```
El script ejecuta secuencialmente los 5 controles de seguridad y genera un reporte en consola para desarrolladores y pipelines de integración continua.
---
## 5. Matriz de Resultados
| Control | Capa | Herramienta | Estado Previo | Estado Final |
|---|---|---|---|---|
| **SAST (Python)** | Flask API | `bandit` | 1 Advertencia Media (B310) | **0 Problemas (100% Limpio)** ✅ |
| **SCA (Python)** | Flask API | `pip-audit` | 24 Vulnerabilidades (CVEs) | **0 Vulnerabilidades** ✅ |
| **DAST (Python)** | Flask API | `schemathesis` | 1 Fallo HTTP 500 (payload fuzzing) | **0 Fallos (114/114 Pasados)** ✅ |
| **SAST (Node.js)**| Express BFF| `njsscan` | Sin controles previos | **0 Problemas (100% Limpio)** ✅ |
| **SCA (Node.js)** | Express BFF| `npm audit` | No auditado con Helmet | **0 Vulnerabilidades** ✅ |
| **Blindaje HTTP** | Express BFF| `helmet` + `rate-limit` | Cabeceras por defecto | **Protegido con HSTS, nosniff, limiters** ✅ |
+2
View File
@@ -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')
+1
View File
@@ -0,0 +1 @@
from .document_types import DOCUMENT_TYPES, DOCUMENT_TYPE_MAP, get_document_type, validate_document, format_document
+160
View File
@@ -0,0 +1,160 @@
import re
DOCUMENT_TYPES = [
{
'code': 'DNI',
'name': 'Documento Nacional de Identidad',
'short_name': 'DNI',
'category': 'Nacional',
'description': 'Documento oficial para ciudadanos argentinos nativos, naturalizados y residentes permanentes.',
'regex': r'^\d{7,8}$',
'placeholder': 'Ej: 38123456 (7 u 8 dígitos sin puntos)',
'mask_example': '38.123.456'
},
{
'code': 'CUIL',
'name': 'Código Único de Identificación Laboral (CUIL/CUIT)',
'short_name': 'CUIL',
'category': 'Nacional / Laboral',
'description': 'Identificador fiscal y laboral en Argentina (11 dígitos).',
'regex': r'^\d{11}$|^\d{2}-\d{8}-\d{1}$',
'placeholder': 'Ej: 20381234567 o 20-38123456-7',
'mask_example': '20-38123456-7'
},
{
'code': 'PAS',
'name': 'Pasaporte Argentino',
'short_name': 'Pasaporte ARG',
'category': 'Internacional',
'description': 'Pasaporte emitido por la República Argentina para viajes y acreditación internacional.',
'regex': r'^[a-zA-Z0-9]{6,12}$',
'placeholder': 'Ej: AAB123456',
'mask_example': 'PAS AAB123456'
},
{
'code': 'PASEXT',
'name': 'Pasaporte Extranjero',
'short_name': 'Pasaporte Extranjero',
'category': 'Internacional / Extranjero',
'description': 'Pasaporte oficial emitido por país de origen (docentes invitados, alumnos de intercambio).',
'regex': r'^[a-zA-Z0-9\-\.]{4,20}$',
'placeholder': 'Ej: US987654321 / B1234567',
'mask_example': 'PASEXT US987654321'
},
{
'code': 'DNIEXT',
'name': 'DNI para Extranjeros (Residente)',
'short_name': 'DNI Extranjero',
'category': 'Extranjero / Residente',
'description': 'DNI argentino otorgado a ciudadanos extranjeros con radicación o residencia temporaria.',
'regex': r'^[a-zA-Z0-9]{7,10}$',
'placeholder': 'Ej: 94123456 o E94123456',
'mask_example': 'DNI-EXT 94.123.456'
},
{
'code': 'CI',
'name': 'Cédula de Identidad (PFA / Policía Provincial)',
'short_name': 'Cédula Identidad',
'category': 'Nacional (Histórico)',
'description': 'Cédula de Identidad emitida por Policía Federal Argentina o Policías Provinciales.',
'regex': r'^\d{6,9}$',
'placeholder': 'Ej: 6123456',
'mask_example': 'CI 6.123.456'
},
{
'code': 'CIEXT',
'name': 'Cédula de Identidad Extranjera (Mercosur)',
'short_name': 'Cédula Mercosur',
'category': 'Regional / Mercosur',
'description': 'Cédula de Identidad emitida por países miembros o asociados al Mercosur (Brasil, Uruguay, etc.).',
'regex': r'^[a-zA-Z0-9\-\.]{5,18}$',
'placeholder': 'Ej: RG12345678 (Brasil) o 4.123.456-7 (Uruguay)',
'mask_example': 'CI-EXT 12345678'
},
{
'code': 'LC',
'name': 'Libreta Cívica',
'short_name': 'Libreta Cívica',
'category': 'Histórico Nacional',
'description': 'Documento histórico nacional para ciudadanas mujeres argentinas.',
'regex': r'^\d{6,8}$',
'placeholder': 'Ej: 5123456',
'mask_example': 'LC 5.123.456'
},
{
'code': 'LE',
'name': 'Libreta de Enrolamiento',
'short_name': 'Libreta Enrolamiento',
'category': 'Histórico Nacional',
'description': 'Documento histórico nacional para ciudadanos varones argentinos.',
'regex': r'^\d{6,8}$',
'placeholder': 'Ej: 4123456',
'mask_example': 'LE 4.123.456'
}
]
DOCUMENT_TYPE_MAP = {doc['code']: doc for doc in DOCUMENT_TYPES}
def get_document_type(code):
"""Devuelve la especificación de un tipo de documento o None."""
if not code:
return None
return DOCUMENT_TYPE_MAP.get(str(code).strip().upper())
def validate_document(doc_type, doc_number):
"""
Valida un número de documento según las reglas de negocio de su tipificación.
Retorna (is_valid: bool, cleaned_number: str, error_message: str|None).
"""
if not doc_type:
return False, '', 'Debe especificar el tipo de documento.'
spec = get_document_type(doc_type)
if not spec:
return False, '', f"El tipo de documento '{doc_type}' no está registrado en el catálogo oficial."
if not doc_number:
return True, '', None # Document number may be optional if pending registration
cleaned = str(doc_number).strip().upper()
# Si es DNI, LC, LE, CI podemos remover puntos y guiones intermedios para validar y almacenar de forma uniforme
if spec['code'] in ['DNI', 'LC', 'LE', 'CI', 'DNIEXT']:
numeric_only = re.sub(r'[\.\-\s]', '', cleaned)
if re.match(spec['regex'], numeric_only):
return True, numeric_only, None
return False, cleaned, f"Formato inválido para {spec['name']}. Debe contener {spec['placeholder']}."
elif spec['code'] == 'CUIL':
numeric_only = re.sub(r'[\.\-\s]', '', cleaned)
if re.match(r'^\d{11}$', numeric_only):
return True, numeric_only, None
return False, cleaned, "El CUIL debe contener 11 dígitos numéricos (ej. 20-38123456-7)."
else:
# Pasaportes o Cédulas Extranjeras
if re.match(spec['regex'], cleaned):
return True, cleaned, None
return False, cleaned, f"Formato inválido para {spec['name']} ({spec['placeholder']})."
def format_document(doc_type, doc_number):
"""
Formatea visualmente un número de documento con puntos/guiones estándar.
"""
if not doc_number:
return ''
raw = str(doc_number).strip().upper()
dtype = (doc_type or 'DNI').upper()
if dtype in ['DNI', 'LC', 'LE', 'CI']:
clean = re.sub(r'\D', '', raw)
if len(clean) == 8:
return f"{clean[:2]}.{clean[2:5]}.{clean[5:]}"
elif len(clean) == 7:
return f"{clean[:1]}.{clean[1:4]}.{clean[4:]}"
return clean
elif dtype == 'CUIL':
clean = re.sub(r'\D', '', raw)
if len(clean) == 11:
return f"{clean[:2]}-{clean[2:10]}-{clean[10:]}"
return clean
return raw
+2 -1
View File
@@ -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',
+4
View File
@@ -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):
+185 -118
View File
@@ -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'<Subject {self.code}>'
class Commission(db.Model):
__tablename__ = 'commissions'
id = db.Column(db.Integer, primary_key=True)
subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=False)
code = db.Column(db.String(50), nullable=False)
semester = db.Column(db.String(20), nullable=False)
year = db.Column(db.Integer, nullable=False)
teacher_id = db.Column(db.Integer, db.ForeignKey('users.id'))
max_students = db.Column(db.Integer, nullable=False)
current_students = db.Column(db.Integer, default=0)
schedule = db.Column(db.String(255))
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
virtual_link = db.Column(db.String(500), nullable=True) # Link Zoom, Meet, Teams, Moodle
active = db.Column(db.Boolean, default=True) # Changed from is_active to active
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Virtual property for compatibility
@property
def is_active(self):
return self.active
@is_active.setter
def is_active(self, value):
self.active = value
# Relationships
pass # Reservation already defines the relationship back to commission
teacher = db.relationship('User', backref='teaching_commissions', foreign_keys=[teacher_id])
# Virtual field for teacher_name
@property
def teacher_name(self):
return f"{self.teacher.first_name} {self.teacher.last_name}" if self.teacher else "TBD"
# Unique constraint
__table_args__ = (db.UniqueConstraint('subject_id', 'code', 'semester', 'year'),)
def to_dict(self):
"""Convert commission to dictionary"""
return {
'id': self.id,
'subject_id': self.subject_id,
'code': self.code,
'semester': self.semester,
'year': self.year,
'teacher_name': self.teacher_name,
'max_students': self.max_students,
'current_students': self.current_students,
'schedule': self.schedule,
'shift': self.shift,
'virtual_link': self.virtual_link,
'active': self.active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'full_code': self.get_full_code(),
'subject': self.subject.to_dict() if self.subject else None
}
def get_full_code(self):
"""Get full commission code"""
subj = self.subject.code if self.subject else 'N/A'
code = self.code or ''
sem = str(self.semester or '').strip()
yr = str(self.year or '').strip()
if yr and yr in sem:
return f"{subj}-{code}-{sem}"
elif sem and yr:
return f"{subj}-{code}-{sem}{yr}"
elif sem:
return f"{subj}-{code}-{sem}"
elif yr:
return f"{subj}-{code}-{yr}"
return f"{subj}-{code}"
def __repr__(self):
return f'<Commission {self.get_full_code()}>'
from app import db
from datetime import datetime
class Subject(db.Model):
__tablename__ = 'subjects'
id = db.Column(db.Integer, primary_key=True)
code = db.Column(db.String(50), unique=True, nullable=False, index=True)
name = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text)
department = db.Column(db.String(100), nullable=False, default='')
credits = db.Column(db.Integer, nullable=False, default=4)
career_id = db.Column(db.Integer, db.ForeignKey('careers.id', ondelete='SET NULL'), nullable=True)
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB
is_short_course = db.Column(db.Boolean, default=False, nullable=True) # True para talleres/cursos cortos
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
commissions = db.relationship('Commission', backref='subject', lazy=True, cascade='all, delete-orphan')
def to_dict(self):
"""Convert subject to dictionary"""
return {
'id': self.id,
'code': self.code,
'name': self.name,
'description': self.description,
'department': self.department,
'credits': self.credits,
'career_id': self.career_id,
'career_name': self.career_obj.name if self.career_obj else None,
'is_active': self.is_active,
'is_short_course': bool(self.is_short_course),
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}
def __repr__(self):
return f'<Subject {self.code}>'
class Commission(db.Model):
__tablename__ = 'commissions'
id = db.Column(db.Integer, primary_key=True)
subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=False)
code = db.Column(db.String(50), nullable=False)
semester = db.Column(db.String(20), nullable=False)
year = db.Column(db.Integer, nullable=False)
teacher_id = db.Column(db.Integer, db.ForeignKey('users.id'))
max_students = db.Column(db.Integer, nullable=False)
current_students = db.Column(db.Integer, default=0)
schedule = db.Column(db.String(255))
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
virtual_link = db.Column(db.String(500), nullable=True) # Link Zoom, Meet, Teams, Moodle
active = db.Column(db.Boolean, default=True) # Changed from is_active to active
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'<Commission {self.get_full_code()}>'
class CommissionTeacher(db.Model):
"""Modelo de asignación de múltiples docentes a una comisión (Co-Docencia / Cátedra)."""
__tablename__ = 'commission_teachers'
id = db.Column(db.Integer, primary_key=True)
commission_id = db.Column(db.Integer, db.ForeignKey('commissions.id', ondelete='CASCADE'), nullable=False, index=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
role = db.Column(db.String(50), default='Titular', nullable=False) # Titular, Adjunto, JTP, Ayudante
is_primary = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Relationships
commission = db.relationship('Commission', backref=db.backref('teachers_association', cascade='all, delete-orphan', lazy='joined'))
teacher = db.relationship('User', backref=db.backref('commission_assignments', cascade='all, delete-orphan', lazy='joined'))
__table_args__ = (db.UniqueConstraint('commission_id', 'user_id'),)
def to_dict(self):
return {
'id': self.id,
'commission_id': self.commission_id,
'user_id': self.user_id,
'name': self.teacher.name if self.teacher else 'Docente',
'email': self.teacher.email if self.teacher else '',
'role': self.role or 'Titular',
'is_primary': self.is_primary,
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self):
return f'<CommissionTeacher Commission:{self.commission_id} User:{self.user_id} Role:{self.role}>'␍
+30 -6
View File
@@ -17,15 +17,29 @@ class User(db.Model):
theme_preference = db.Column(db.String(10), default='auto', nullable=False)
role_id = db.Column(db.Integer, db.ForeignKey('roles.id', ondelete='SET NULL'), nullable=True)
# Virtual fields for future compatibility
@property
def first_name(self):
# Extended personal, contact and identity document fields
first_name = db.Column(db.String(100), nullable=True)
last_name = db.Column(db.String(100), nullable=True)
phone = db.Column(db.String(50), nullable=True)
personal_email = db.Column(db.String(255), nullable=True)
address = db.Column(db.String(255), nullable=True)
document_type = db.Column(db.String(20), default='DNI', nullable=True)
document_number = db.Column(db.String(30), nullable=True, index=True)
def get_first_name(self):
if self.first_name:
return self.first_name
return self.name.split()[0] if self.name else ''
@property
def last_name(self):
parts = self.name.split()
def get_last_name(self):
if self.last_name:
return self.last_name
parts = self.name.split() if self.name else []
return ' '.join(parts[1:]) if len(parts) > 1 else ''
def get_formatted_document(self):
from app.constants.document_types import format_document
return format_document(self.document_type, self.document_number)
# Relationships
reservations = db.relationship('Reservation', backref='user', lazy=True, cascade='all, delete-orphan')
@@ -132,8 +146,18 @@ class User(db.Model):
return {
'id': self.id,
'email': self.email,
'institutional_email': self.email,
'personal_email': self.personal_email or '',
'name': self.name,
'first_name': self.get_first_name(),
'last_name': self.get_last_name(),
'phone': self.phone or '',
'address': self.address or '',
'document_type': self.document_type or 'DNI',
'document_number': self.document_number or '',
'document_formatted': self.get_formatted_document(),
'role': self.role,
'role_id': self.role_id,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'last_login': self.last_login.isoformat() if self.last_login else None,
@@ -59,3 +59,22 @@ class ReservationRepository(BaseRepository[Reservation]):
return Reservation.query.filter_by(
commission_id=commission_id
).order_by(Reservation.start_time.asc()).all()
def find_teacher_conflicts(self, teacher_id: int, start_time: datetime, end_time: datetime,
exclude_reservation_id: Optional[int] = None) -> List[Reservation]:
"""
Encuentra reservas activas donde el docente ya está asignado en la misma franja horaria.
Condición de solapamiento: (start_time < existing.end_time) AND (end_time > existing.start_time).
"""
query = Reservation.query.options(
joinedload(Reservation.classroom),
joinedload(Reservation.commission)
).filter(
Reservation.user_id == teacher_id,
Reservation.status != ReservationStatus.CANCELLED.value,
Reservation.start_time < end_time,
Reservation.end_time > start_time
)
if exclude_reservation_id:
query = query.filter(Reservation.id != exclude_reservation_id)
return query.all()
+307 -26
View File
@@ -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/<int:id>', 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/<int:id>/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/<int:id>', 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/<int:id>', 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/<int:id>/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/<int:id>/teachers/<int:user_id>', 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/<int:id>/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/<int:id>/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/<int:id>/enrollments/<int:student_id>', 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/<int:id>/enrollments/<int:student_id>', 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
# ---------------------------------------------------------
+6 -2
View File
@@ -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:
+100 -2
View File
@@ -149,6 +149,92 @@ def get_stats():
upcoming_reservations = [serialize_reservation(r) for r in upcoming_reservations_q]
milestones = [serialize_milestone(m) for m in milestones_q]
# ---- Bedelía: Comisiones sin aula asignada (semana y mes) ----
import calendar
# Calcular rango de semana actual (lunes a domingo)
today_dt = datetime.utcnow()
week_start = today_dt - timedelta(days=today_dt.weekday()) # lunes
week_start = week_start.replace(hour=0, minute=0, second=0, microsecond=0)
week_end = week_start + timedelta(days=6, hours=23, minutes=59, seconds=59)
# Calcular rango del mes actual
month_start = today_dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
last_day = calendar.monthrange(today_dt.year, today_dt.month)[1]
month_end = today_dt.replace(day=last_day, hour=23, minute=59, second=59, microsecond=0)
# Comisiones activas totales
all_active_commissions = Commission.query.filter_by(active=True).all()
total_active_commissions = len(all_active_commissions)
# IDs de comisiones que SÍ tienen reserva en la semana
comm_ids_with_res_week = set(
r[0] for r in db.session.query(Reservation.commission_id).filter(
Reservation.commission_id.isnot(None),
Reservation.start_time >= week_start,
Reservation.start_time <= week_end
).distinct().all()
)
# IDs de comisiones que SÍ tienen reserva en el mes
comm_ids_with_res_month = set(
r[0] for r in db.session.query(Reservation.commission_id).filter(
Reservation.commission_id.isnot(None),
Reservation.start_time >= month_start,
Reservation.start_time <= month_end
).distinct().all()
)
unassigned_commissions_week = []
unassigned_commissions_month = []
for comm in all_active_commissions:
comm_data = {
'id': comm.id,
'code': comm.code,
'full_code': comm.get_full_code(),
'subject_name': comm.subject.name if comm.subject else 'Sin materia',
'subject_code': comm.subject.code if comm.subject else '',
'teacher_name': comm.teacher_name,
'schedule': comm.schedule or 'Sin horario',
'shift': comm.shift or 'Sin turno',
'max_students': comm.max_students,
'current_students': comm.current_students
}
if comm.id not in comm_ids_with_res_week:
unassigned_commissions_week.append(comm_data)
if comm.id not in comm_ids_with_res_month:
unassigned_commissions_month.append(comm_data)
# ---- Bedelía: Aulas disponibles (sin reservas) en la semana y mes ----
classroom_ids_used_week = set(
r[0] for r in db.session.query(Reservation.classroom_id).filter(
Reservation.classroom_id.isnot(None),
Reservation.start_time >= week_start,
Reservation.start_time <= week_end
).distinct().all()
)
classroom_ids_used_month = set(
r[0] for r in db.session.query(Reservation.classroom_id).filter(
Reservation.classroom_id.isnot(None),
Reservation.start_time >= month_start,
Reservation.start_time <= month_end
).distinct().all()
)
available_classrooms_week = []
available_classrooms_month = []
for c in all_active_classrooms:
c_data = {
'id': c.id,
'code': c.code,
'building': c.building or 'Sin edificio',
'floor': c.floor or '',
'capacity': c.capacity,
'is_virtual': getattr(c, 'is_virtual', False) or 'virtual' in (c.building or '').lower() or 'virtual' in (c.code or '').lower()
}
if c.id not in classroom_ids_used_week:
available_classrooms_week.append(c_data)
if c.id not in classroom_ids_used_month:
available_classrooms_month.append(c_data)
except Exception as e:
print(f"Error fetching stats: {e}")
total_classrooms = 0
@@ -171,6 +257,11 @@ def get_stats():
pending_reservations = []
today_schedule_list = []
upcoming_reservations = []
unassigned_commissions_week = []
unassigned_commissions_month = []
available_classrooms_week = []
available_classrooms_month = []
total_active_commissions = 0
milestones = []
# Mocked monthly data for chart since complex SQL group by month might differ based on dialect (SQLite vs Postgres)
@@ -207,14 +298,21 @@ def get_stats():
'assigned_today_classrooms': assigned_today_count,
'assigned_today_pct': assigned_today_pct,
'unassigned_today_classrooms': unassigned_today_count,
'unassigned_today_pct': unassigned_today_pct
'unassigned_today_pct': unassigned_today_pct,
'total_active_commissions': total_active_commissions
},
'audit_logs': audit_data,
'monthly_data': monthly_data,
'pending_reservations': pending_reservations,
'today_schedule_list': today_schedule_list,
'upcoming_reservations': upcoming_reservations,
'milestones': milestones
'milestones': milestones,
'bedelia': {
'unassigned_commissions_week': unassigned_commissions_week,
'unassigned_commissions_month': unassigned_commissions_month,
'available_classrooms_week': available_classrooms_week,
'available_classrooms_month': available_classrooms_month
}
}), 200
@api_dashboard_bp.route('/sync-sheets', methods=['POST'])
+242
View File
@@ -0,0 +1,242 @@
from flask import Blueprint, jsonify
api_openapi_bp = Blueprint('api_openapi', __name__, url_prefix='/api/v1')
OPENAPI_SPEC = {
"openapi": "3.0.3",
"info": {
"title": "Edu-Space REST API",
"description": "Enterprise Academic & Physical Space Management REST API with JWT Authentication and Role-Based Access Control.",
"version": "1.0.0"
},
"servers": [
{
"url": "http://127.0.0.1:5000",
"description": "Local Flask Backend Server"
}
],
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
},
"schemas": {
"LoginRequest": {
"type": "object",
"required": ["email", "password"],
"properties": {
"email": {
"type": "string",
"format": "email",
"example": "admin@eduspace.com"
},
"password": {
"type": "string",
"format": "password",
"example": "Admin123!"
}
}
},
"LoginResponse": {
"type": "object",
"properties": {
"access_token": {"type": "string"},
"refresh_token": {"type": "string"},
"token_type": {"type": "string", "example": "Bearer"},
"expires_in": {"type": "integer"},
"user": {"type": "object"}
}
},
"ErrorResponse": {
"type": "object",
"properties": {
"error": {"type": "string"},
"message": {"type": "string"}
}
},
"UserResponse": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"email": {"type": "string"},
"first_name": {"type": "string"},
"last_name": {"type": "string"},
"role": {"type": "string"},
"is_active": {"type": "boolean"}
}
},
"SubjectResponse": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"name": {"type": "string"},
"code": {"type": "string"},
"career": {"type": "string"}
}
},
"ClassroomResponse": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"name": {"type": "string"},
"capacity": {"type": "integer"},
"building": {"type": "string"},
"floor": {"type": "string"}
}
}
}
},
"paths": {
"/api/v1/auth/login": {
"post": {
"summary": "Authenticate user and issue JWT tokens",
"tags": ["Authentication"],
"requestBody": {
"required": True,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LoginRequest"
}
}
}
},
"responses": {
"200": {
"description": "Authentication successful",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LoginResponse"
}
}
}
},
"400": {
"description": "Validation error",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/ErrorResponse"}
}
}
},
"401": {
"description": "Unauthorized / Bad credentials",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/ErrorResponse"}
}
}
}
}
}
},
"/api/v1/auth/me": {
"get": {
"summary": "Get authenticated user profile",
"tags": ["Authentication"],
"security": [{"bearerAuth": []}],
"responses": {
"200": {
"description": "Current user profile",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/UserResponse"}
}
}
},
"401": {
"description": "Missing or invalid token",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/ErrorResponse"}
}
}
}
}
}
},
"/api/v1/classrooms": {
"get": {
"summary": "List all physical classrooms and facilities",
"tags": ["Classrooms"],
"security": [{"bearerAuth": []}],
"responses": {
"200": {
"description": "List of classrooms",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {"$ref": "#/components/schemas/ClassroomResponse"}
}
}
}
}
}
}
},
"/api/v1/admin/users": {
"get": {
"summary": "List all users (Admin only)",
"tags": ["Admin - Users"],
"security": [{"bearerAuth": []}],
"responses": {
"200": {
"description": "List of users",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {"$ref": "#/components/schemas/UserResponse"}
}
}
}
},
"403": {
"description": "Forbidden - Requires admin role"
}
}
}
},
"/api/v1/admin/subjects": {
"get": {
"summary": "List all academic subjects (Admin only)",
"tags": ["Admin - Academic"],
"security": [{"bearerAuth": []}],
"responses": {
"200": {
"description": "List of subjects",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {"$ref": "#/components/schemas/SubjectResponse"}
}
}
}
}
}
}
},
"/api/v1/openapi.json": {
"get": {
"summary": "Get OpenAPI 3.0.3 specification JSON",
"tags": ["Documentation"],
"responses": {
"200": {
"description": "OpenAPI Specification"
}
}
}
}
}
}
@api_openapi_bp.route('/openapi.json', methods=['GET'])
def get_openapi_spec():
"""Serves the OpenAPI 3.0 specification for Schemathesis / DAST and Swagger UI."""
return jsonify(OPENAPI_SPEC)
+45
View File
@@ -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
+2 -1
View File
@@ -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
+220
View File
@@ -0,0 +1,220 @@
import re
from typing import Optional, List, Set, Dict, Any
from datetime import datetime
from app import db
from app.models.user import User
from app.models.subject import Commission, Subject
from app.models.enrollment import StudentEnrollment
from app.models.reservation import Reservation
WEEKDAYS_MAP = {
0: 'lunes',
1: 'martes',
2: 'miercoles',
3: 'jueves',
4: 'viernes',
5: 'sabado',
6: 'domingo'
}
SPANISH_DAYS = ['lunes', 'martes', 'miercoles', 'miércoles', 'jueves', 'viernes', 'sabado', 'sábado', 'domingo']
class EnrollmentService:
"""
Servicio de matriculación y aplicación de reglas de negocio académicas (Fase 2 MVP):
- Control estricto de aforo y cupo de comisión.
- Prevención de doble inscripción.
- Regla de Restricción Diaria del Alumno (máximo 1 asignatura regular por día calendario).
- Soporte de excepciones automáticas por curso corto y excepciones autorizadas de Bedelía.
"""
@staticmethod
def normalize_day(day_str: str) -> str:
d = day_str.lower().strip()
if 'miér' in d or 'mier' in d:
return 'miercoles'
if 'sáb' in d or 'sab' in d:
return 'sabado'
return d
@classmethod
def get_commission_days(cls, commission: Commission) -> Set[str]:
"""Extrae el conjunto de días de cursada de la comisión desde schedule y reservas activas."""
days: Set[str] = set()
# 1. Analizar texto del campo schedule
if commission.schedule:
sched_lower = commission.schedule.lower()
for d in SPANISH_DAYS:
if re.search(r'\b' + re.escape(d) + r'\b', sched_lower):
days.add(cls.normalize_day(d))
# 2. Analizar reservas reales de la comisión
reservations = Reservation.query.filter(
Reservation.commission_id == commission.id,
Reservation.status != 'CANCELLED'
).all()
for r in reservations:
if r.start_time:
weekday_idx = r.start_time.weekday()
if weekday_idx in WEEKDAYS_MAP:
days.add(WEEKDAYS_MAP[weekday_idx])
return days
@classmethod
def list_enrollments(cls, commission_id: int) -> List[Dict[str, Any]]:
"""Lista todos los alumnos matriculados en una comisión con información extendida."""
enrollments = StudentEnrollment.query.filter_by(commission_id=commission_id).all()
result = []
for enr in enrollments:
data = enr.to_dict()
if enr.student:
data['student'] = {
'id': enr.student.id,
'name': enr.student.name,
'email': enr.student.email,
'personal_email': getattr(enr.student, 'personal_email', None),
'phone': getattr(enr.student, 'phone', None),
'document_type': getattr(enr.student, 'document_type', None),
'document_number': getattr(enr.student, 'document_number', None)
}
result.append(data)
return result
@classmethod
def enroll_student(cls, commission_id: int, student_id: int,
notes: Optional[str] = None,
allow_same_day_exception: bool = False,
exception_reason: Optional[str] = None) -> StudentEnrollment:
"""
Matricula a un estudiante en una comisión aplicando la regla de restricción diaria.
"""
commission = Commission.query.get(commission_id)
if not commission or not commission.active:
raise ValueError(f"La comisión #{commission_id} no existe o no se encuentra activa.")
student = User.query.get(student_id)
if not student or not student.is_active:
raise ValueError(f"El alumno #{student_id} no existe o su cuenta se encuentra inactiva.")
# Verificar si ya está matriculado
existing = StudentEnrollment.query.filter_by(commission_id=commission_id, student_id=student_id).first()
if existing:
if existing.status == 'activo':
raise ValueError(f"El alumno {student.name} ya está matriculado activamente en esta comisión.")
# Si estaba retirado, se reactiva
existing.status = 'activo'
existing.notes = notes or existing.notes
existing.allow_same_day_exception = allow_same_day_exception
existing.exception_reason = exception_reason if allow_same_day_exception else None
commission.current_students = (commission.current_students or 0) + 1
db.session.commit()
return existing
# Verificar cupo máximo
if commission.max_students and (commission.current_students or 0) >= commission.max_students:
raise ValueError(f"La comisión ha alcanzado su cupo máximo de {commission.max_students} estudiantes.")
# ─── REGLA DE RESTRICCIÓN DIARIA DE CURSADA (ESTADIO 2.3) ───
target_subject = commission.subject
is_target_short_course = bool(target_subject and target_subject.is_short_course)
target_days = cls.get_commission_days(commission)
# Buscar otras inscripciones activas del estudiante
active_enrollments = StudentEnrollment.query.filter_by(
student_id=student_id,
status='activo'
).all()
for enr in active_enrollments:
other_comm = enr.commission
if not other_comm or not other_comm.active:
continue
# Verificar si coinciden en el mismo ciclo/semestre/año
same_term = (
other_comm.year == commission.year and
other_comm.semester == commission.semester
)
if not same_term:
continue
other_subject = other_comm.subject
is_other_short_course = bool(other_subject and other_subject.is_short_course)
# Ambas son materias regulares: no pueden cursarse el mismo día sin excepción
if not is_target_short_course and not is_other_short_course:
other_days = cls.get_commission_days(other_comm)
overlapping_days = target_days.intersection(other_days)
if overlapping_days:
if allow_same_day_exception:
# Excepción otorgada expresamente por Bedelía
if not exception_reason:
exception_reason = "Autorización expresa de Bedelía para cursada simultánea en el mismo día."
else:
days_display = ", ".join([d.capitalize() for d in overlapping_days])
other_name = other_subject.name if other_subject else other_comm.code
raise ValueError(
f"Restricción de cursada diaria: El alumno ya cursa la materia regular '{other_name}' "
f"el día {days_display}. La normativa de UniCABA prohíbe cursar dos materias regulares "
f"el mismo día calendario salvo curso corto o autorización explícita de Bedelía."
)
enrollment = StudentEnrollment(
student_id=student_id,
commission_id=commission_id,
status='activo',
notes=notes,
allow_same_day_exception=allow_same_day_exception,
exception_reason=exception_reason if allow_same_day_exception else None
)
db.session.add(enrollment)
commission.current_students = (commission.current_students or 0) + 1
db.session.commit()
return enrollment
@classmethod
def unenroll_student(cls, commission_id: int, student_id: int) -> bool:
"""Da de baja o retira a un alumno de una comisión."""
enrollment = StudentEnrollment.query.filter_by(
commission_id=commission_id,
student_id=student_id
).first()
if not enrollment:
return False
commission = Commission.query.get(commission_id)
if commission and (commission.current_students or 0) > 0:
commission.current_students -= 1
db.session.delete(enrollment)
db.session.commit()
return True
@classmethod
def update_enrollment_status(cls, commission_id: int, student_id: int, status: str,
notes: Optional[str] = None) -> Optional[StudentEnrollment]:
"""Actualiza el estado de la matrícula (activo, condicional, retirado)."""
enrollment = StudentEnrollment.query.filter_by(
commission_id=commission_id,
student_id=student_id
).first()
if not enrollment:
return None
old_status = enrollment.status
enrollment.status = status
if notes is not None:
enrollment.notes = notes
commission = Commission.query.get(commission_id)
if commission:
if old_status == 'activo' and status in ['retirado']:
commission.current_students = max(0, (commission.current_students or 1) - 1)
elif old_status in ['retirado'] and status == 'activo':
commission.current_students = (commission.current_students or 0) + 1
db.session.commit()
return enrollment
+123 -25
View File
@@ -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
+3 -1
View File
@@ -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):
+44
View File
@@ -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()
+79
View File
@@ -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!")
+4
View File
@@ -0,0 +1,4 @@
bandit>=1.7.5
pip-audit>=2.7.2
schemathesis>=3.28.6
njsscan>=0.3.5
+5 -5
View File
@@ -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
+293
View File
@@ -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()
+42
View File
@@ -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",
+2
View File
@@ -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"
}
}
+27
View File
@@ -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 }));
+203 -35
View File
@@ -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 ────────────────────────────────────────────────────────
+8 -1
View File
@@ -68,6 +68,9 @@ router.get('/', async (req, res) => {
? apiData.milestones
: null;
// Bedelía-specific data
const bedeliaData = apiData.bedelia || {};
// Consolidated and role-specific stats
const stats = {
total_classrooms,
@@ -135,7 +138,11 @@ router.get('/', async (req, res) => {
{ start_time: '08:00', end_time: '12:00', classroom: { code: 'Aula 102' }, subject_name: 'Algoritmos y Estructuras de Datos', user: { name: 'Prof. Ana Vega' } },
{ start_time: '08:30', end_time: '12:30', classroom: { code: 'Aula 204' }, subject_name: 'Matemática Discreta', user: { name: 'Prof. Carlos López' } },
{ start_time: '14:00', end_time: '18:00', classroom: { code: 'Lab Redes' }, subject_name: 'Redes y Telecomunicaciones', user: { name: 'Ing. Marcos Ruiz' } }
]
],
unassigned_commissions_week: bedeliaData.unassigned_commissions_week || [],
unassigned_commissions_month: bedeliaData.unassigned_commissions_month || [],
available_classrooms_week: bedeliaData.available_classrooms_week || [],
available_classrooms_month: bedeliaData.available_classrooms_month || []
};
} else if (role === 'docente') {
const myClasses = apiTodaySchedule ? apiTodaySchedule.slice(0, 3) : null;
+137 -32
View File
@@ -13,8 +13,35 @@
</ol>
</nav>
<!-- Flash Messages -->
<!-- Flash & Action Alerts -->
{% if error %}
<div class="alert alert-danger alert-dismissible fade show d-flex align-items-center gap-2 shadow-sm mb-4" role="alert">
<i class="bi bi-exclamation-triangle-fill fs-5 text-danger flex-shrink-0"></i>
<div class="small fw-semibold flex-grow-1">{{ error }}</div>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
{% if success %}
<div class="alert alert-success alert-dismissible fade show d-flex align-items-center gap-2 shadow-sm mb-4" role="alert">
<i class="bi bi-check-circle-fill fs-5 text-success flex-shrink-0"></i>
<div class="small fw-semibold flex-grow-1">Alumno matriculado exitosamente en la comisión.</div>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
{% if unregistered %}
<div class="alert alert-info alert-dismissible fade show d-flex align-items-center gap-2 shadow-sm mb-4" role="alert">
<i class="bi bi-info-circle-fill fs-5 text-info flex-shrink-0"></i>
<div class="small fw-semibold flex-grow-1">El alumno ha sido desvinculado de la comisión.</div>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
{% if updated %}
<div class="alert alert-info alert-dismissible fade show d-flex align-items-center gap-2 shadow-sm mb-4" role="alert">
<i class="bi bi-info-circle-fill fs-5 text-info flex-shrink-0"></i>
<div class="small fw-semibold flex-grow-1">Estado de matrícula actualizado correctamente.</div>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
<div class="row g-4">
<!-- Left: Commission Info + Teacher Assignment -->
@@ -77,47 +104,96 @@
</div>
</div>
<!-- Teacher Assignment -->
<!-- Co-Docencia / Equipo Docente -->
<div class="card shadow-sm border-0">
<div class="card-header border-0 bg-transparent py-2 d-flex align-items-center gap-2">
<i class="bi bi-person-video3 text-info"></i>
<span class="fw-semibold">Docente Asignado</span>
<div class="card-header border-0 bg-transparent py-2 d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-people-fill text-info"></i>
<span class="fw-semibold">Equipo Docente / Cátedra</span>
</div>
<span class="badge bg-info-subtle text-info border border-info-subtle">
{{ commission.teachers|length if commission.teachers else (1 if commission.teacher else 0) }} docente(s)
</span>
</div>
<div class="card-body">
{% if commission.teacher %}
<div class="d-flex align-items-center gap-3 mb-3 p-2 rounded bg-info-subtle border border-info-subtle">
<div class="avatar-sm bg-info rounded-circle d-flex align-items-center justify-content-center" style="width:40px;height:40px;flex-shrink:0;">
<i class="bi bi-person-fill text-white"></i>
</div>
<div>
<div class="fw-semibold small">{{ commission.teacher.name }}</div>
<div class="text-muted" style="font-size:.75rem;">{{ commission.teacher.email }}</div>
{% if commission.teachers and commission.teachers|length > 0 %}
<div class="d-flex flex-column gap-2 mb-3">
{% for t in commission.teachers %}
<div class="d-flex align-items-center justify-content-between p-2 rounded bg-body-tertiary border">
<div class="d-flex align-items-center gap-2 overflow-hidden">
<div class="avatar-sm {% if t.role == 'Titular' %}bg-primary{% elif t.role == 'Adjunto' %}bg-info{% else %}bg-secondary{% endif %} rounded-circle text-white d-flex align-items-center justify-content-center" style="width:34px;height:34px;flex-shrink:0;">
<i class="bi bi-person-fill small"></i>
</div>
<div class="text-truncate">
<div class="d-flex align-items-center gap-1">
<span class="fw-semibold small text-truncate">{{ t.name }}</span>
<span class="badge {% if t.role == 'Titular' %}bg-primary-subtle text-primary border border-primary-subtle{% elif t.role == 'Adjunto' %}bg-info-subtle text-info border border-info-subtle{% elif t.role == 'JTP' %}bg-warning-subtle text-warning border border-warning-subtle{% else %}bg-secondary-subtle text-secondary border{% endif %} py-0 px-1" style="font-size: 0.65rem;">
{{ t.role or 'Docente' }}
</span>
</div>
<div class="text-muted text-truncate" style="font-size:.75rem;">{{ t.email or 'Sin email' }}</div>
</div>
</div>
{% if can_edit %}
<form method="POST" action="/admin/commission_remove_teacher" class="ms-2 flex-shrink-0" onsubmit="return confirm('¿Quitar a {{ t.name }} de la comisión?');">
<input type="hidden" name="id" value="{{ commission.id }}">
<input type="hidden" name="teacher_id" value="{{ t.user_id }}">
<button type="submit" class="btn btn-outline-danger btn-sm py-0 px-1" title="Quitar de la cátedra">
<i class="bi bi-trash" style="font-size: 0.75rem;"></i>
</button>
</form>
{% endif %}
</div>
{% endfor %}
</div>
{% elif commission.teacher %}
<div class="d-flex align-items-center justify-content-between p-2 rounded bg-body-tertiary border mb-3">
<div class="d-flex align-items-center gap-2">
<div class="avatar-sm bg-primary rounded-circle text-white d-flex align-items-center justify-content-center" style="width:34px;height:34px;flex-shrink:0;">
<i class="bi bi-person-fill small"></i>
</div>
<div>
<div class="fw-semibold small">{{ commission.teacher.name }} <span class="badge bg-primary-subtle text-primary border py-0 px-1" style="font-size:0.65rem;">Titular</span></div>
<div class="text-muted" style="font-size:.75rem;">{{ commission.teacher.email }}</div>
</div>
</div>
</div>
{% else %}
<div class="alert alert-warning py-2 small mb-3">
<i class="bi bi-exclamation-triangle me-1"></i>Sin docente asignado
<i class="bi bi-exclamation-triangle me-1"></i>Sin docentes asignados a la comisión.
</div>
{% endif %}
{% if can_edit %}
<form method="POST" action="/admin/commission_assign_teacher" id="assign-teacher-form">
<input type="hidden" name="id" value="{{ commission.id }}">
<div class="mb-2">
<label class="form-label small fw-semibold mb-1">Cambiar / Asignar Docente</label>
<select name="teacher_id" class="form-select form-select-sm" id="teacher-select">
<option value="">— Sin asignar —</option>
{% for teacher in teachers %}
<option value="{{ teacher.id }}" {% if commission.teacher_id == teacher.id %}selected{% endif %}>
{{ teacher.name }}
</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-info btn-sm w-100" id="assign-teacher-btn">
<i class="bi bi-person-check me-1"></i>Guardar Docente
</button>
</form>
<div class="border-top pt-3 mt-2">
<h6 class="small fw-bold text-muted text-uppercase mb-2">
<i class="bi bi-person-plus me-1"></i>Agregar Docente a la Cátedra
</h6>
<form method="POST" action="/admin/commission_add_teacher" id="add-teacher-form">
<input type="hidden" name="id" value="{{ commission.id }}">
<div class="mb-2">
<label class="form-label small fw-semibold mb-1">Docente</label>
<select name="teacher_id" class="form-select form-select-sm" required id="teacher-select">
<option value="">— Seleccionar Docente —</option>
{% for teacher in teachers %}
<option value="{{ teacher.id }}">{{ teacher.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold mb-1">Rol en la Cátedra</label>
<select name="role" class="form-select form-select-sm" required>
<option value="Titular">Profesor Titular</option>
<option value="Adjunto" selected>Profesor Adjunto</option>
<option value="JTP">Jefe de Trabajos Prácticos (JTP)</option>
<option value="Ayudante">Ayudante Diplomado / Alumno</option>
</select>
</div>
<button type="submit" class="btn btn-primary btn-sm w-100" id="assign-teacher-btn">
<i class="bi bi-plus-circle me-1"></i>Asignar a Comisión
</button>
</form>
</div>
{% endif %}
</div>
</div>
@@ -147,6 +223,7 @@
<div class="collapse" id="enroll-form">
<div class="card-body border-top bg-success-subtle py-3">
<form method="POST" action="/admin/commission_enroll_student" id="enroll-student-form">
<input type="hidden" name="id" value="{{ commission.id }}">
<div class="row g-2 align-items-end">
<div class="col-12 col-md-6">
<label class="form-label small fw-semibold mb-1">Seleccionar Alumno</label>
@@ -168,6 +245,20 @@
<i class="bi bi-plus-circle me-1"></i>Inscribir
</button>
</div>
<!-- Regla de Restricción Diaria - Excepción de Bedelía (Fase 2 MVP) -->
<div class="col-12 mt-2 pt-2 border-top border-success-subtle">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="allow_same_day_exception" value="1" id="sameDayCheck"
onchange="document.getElementById('reasonRow').classList.toggle('d-none', !this.checked)">
<label class="form-check-label small fw-semibold text-success-emphasis" for="sameDayCheck">
<i class="bi bi-shield-check me-1 text-primary"></i>Autorizar excepción de cursada en el mismo día (Bedelía)
</label>
</div>
</div>
<div class="col-12 mt-1 d-none" id="reasonRow">
<input type="text" name="exception_reason" class="form-control form-control-sm border-warning"
placeholder="Motivo o expediente de excepción de Bedelía (ej: Autorizado por Bedelía Central)..." id="exception-reason-input">
</div>
</div>
</form>
</div>
@@ -184,6 +275,7 @@
<th>Alumno</th>
<th class="text-center">Estado</th>
<th class="text-center">Fecha Inscripción</th>
<th class="text-center">Excepciones</th>
<th class="text-center">Notas</th>
{% if can_edit %}<th class="text-end pe-3">Acciones</th>{% endif %}
</tr>
@@ -205,12 +297,23 @@
<td class="text-center small text-muted">
{{ e.enrolled_at if e.enrolled_at else '—' }}
</td>
<td class="text-center small">
{% if e.allow_same_day_exception %}
<span class="badge bg-warning-subtle text-warning border border-warning-subtle py-1 px-2" title="{{ e.exception_reason or 'Excepción autorizada de Bedelía' }}">
<i class="bi bi-shield-check me-1"></i>Excepción Diaria
</span>
{% else %}
<span class="text-muted opacity-50">—</span>
{% endif %}
</td>
<td class="text-center small text-muted">{{ e.notes or '—' }}</td>
{% if can_edit %}
<td class="text-end pe-3">
<div class="d-flex gap-1 justify-content-end">
<!-- Quick status update -->
<form method="POST" action="/admin/commission_update_enrollment" class="d-inline">
<input type="hidden" name="id" value="{{ commission.id }}">
<input type="hidden" name="student_id" value="{{ e.student_id }}">
<select name="status" class="form-select form-select-sm d-inline-block w-auto"
onchange="this.form.submit()" title="Cambiar estado"
id="status-select-{{ e.student_id }}">
@@ -223,6 +326,8 @@
{% set st_display = e.student.name if e.student else 'este alumno' %}
<form method="POST" action="/admin/commission_unenroll_student"
onsubmit="return confirm('¿Dar de baja a {{ st_display }} de la comisión?')">
<input type="hidden" name="id" value="{{ commission.id }}">
<input type="hidden" name="student_id" value="{{ e.student_id }}">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Dar de baja"
id="unenroll-btn-{{ e.student_id }}">
<i class="bi bi-person-dash"></i>
+14 -2
View File
@@ -126,7 +126,18 @@
</div>
</td>
<td>
{% if comm.teacher %}
{% if comm.teachers and comm.teachers | length > 0 %}
<div class="d-flex flex-column gap-1">
{% for t in comm.teachers %}
<div class="d-flex align-items-center gap-1">
<span class="badge {% if t.role == 'Titular' %}bg-primary-subtle text-primary border border-primary-subtle{% elif t.role == 'Adjunto' %}bg-info-subtle text-info border border-info-subtle{% elif t.role == 'JTP' %}bg-warning-subtle text-warning border border-warning-subtle{% else %}bg-secondary-subtle text-secondary border{% endif %} py-0 px-1" style="font-size: 0.65rem;">
{{ t.role or 'Docente' }}
</span>
<span class="small text-truncate" style="max-width: 150px;" title="{{ t.name }}">{{ t.name }}</span>
</div>
{% endfor %}
</div>
{% elif comm.teacher %}
<div class="d-flex align-items-center gap-2">
<div class="avatar-xs bg-info-subtle rounded-circle d-flex align-items-center justify-content-center" style="width:28px;height:28px;">
<i class="bi bi-person-video3 small text-info"></i>
@@ -295,7 +306,7 @@
<div class="row g-3 mb-3">
<div class="col-md-8">
<label for="add_teacher_id" class="form-label fw-semibold small">
<i class="bi bi-person-video3 text-info me-1"></i>Docente Responsable
<i class="bi bi-person-video3 text-info me-1"></i>Docente Responsable (Titular)
</label>
<select class="form-select form-select-sm" id="add_teacher_id" name="teacher_id">
<option value="">-- Sin asignar (se puede asignar más tarde) --</option>
@@ -303,6 +314,7 @@
<option value="{{ t.id }}">{{ t.name }} ({{ t.email }})</option>
{% endfor %}
</select>
<div class="form-text small">Docente titular principal. Podrás agregar más docentes (co-docencia, adjuntos, JTP) desde el detalle de la comisión.</div>
</div>
<div class="col-md-4">
<label for="add_max_students" class="form-label fw-semibold small">Cupo Máximo de Alumnos</label>
+155 -60
View File
@@ -20,12 +20,30 @@
<i class="bi bi-plus-lg me-1"></i>Nueva Asignatura
</button>
{% endif %}
<a href="/schedule/commissions_view" class="btn btn-outline-secondary">
<a href="/admin/commissions_list" class="btn btn-outline-secondary">
<i class="bi bi-diagram-3 me-1"></i>Ver Comisiones
</a>
</div>
</div>
<!-- Notification Alerts -->
{% if msg == 'deleted' %}
<div class="alert alert-success alert-dismissible fade show mb-4 shadow-sm" role="alert">
<i class="bi bi-check-circle-fill me-2"></i>La asignatura ha sido eliminada permanentemente del sistema.
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% elif msg == 'deactivated' %}
<div class="alert alert-warning alert-dismissible fade show mb-4 shadow-sm" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i>La asignatura posee comisiones históricas asociadas. Por integridad de los registros académicos fue <strong>desactivada</strong>.
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% elif error == 'delete' %}
<div class="alert alert-danger alert-dismissible fade show mb-4 shadow-sm" role="alert">
<i class="bi bi-x-circle-fill me-2"></i>Ocurrió un error al procesar la eliminación de la asignatura.
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
<!-- Filters Bar -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body py-2">
@@ -133,7 +151,15 @@
<td class="text-end">
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary py-0 px-2"
data-bs-toggle="modal" data-bs-target="#modalEditSubject{{ s.id }}"
data-bs-toggle="modal" data-bs-target="#modalEditSubject"
data-id="{{ s.id }}"
data-code="{{ s.code }}"
data-name="{{ s.name }}"
data-credits="{{ s.credits }}"
data-career-id="{{ s.career_id or '' }}"
data-department="{{ s.department or '' }}"
data-description="{{ s.description or '' }}"
data-active="{{ 'true' if s.is_active else 'false' }}"
title="Editar Asignatura">
<i class="bi bi-pencil"></i>
</button>
@@ -144,64 +170,14 @@
<i class="bi {% if s.is_active %}bi-pause{% else %}bi-play{% endif %}"></i>
</button>
</form>
</div>
<!-- Modal Editar Asignatura -->
<div class="modal fade text-start" id="modalEditSubject{{ s.id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="/admin/subject_edit">
<input type="hidden" name="id" value="{{ s.id }}">
<div class="modal-header">
<h5 class="modal-title h6 fw-bold">
<i class="bi bi-pencil-square text-primary me-2"></i>Editar Asignatura
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-2 mb-3">
<div class="col-md-5">
<label class="form-label small fw-semibold">Código *</label>
<input type="text" name="code" class="form-control form-control-sm font-monospace" value="{{ s.code }}" required>
</div>
<div class="col-md-7">
<label class="form-label small fw-semibold">Créditos</label>
<input type="number" name="credits" class="form-control form-control-sm" value="{{ s.credits }}" min="1" max="20">
</div>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Nombre de la Asignatura *</label>
<input type="text" name="name" class="form-control form-control-sm" value="{{ s.name }}" required>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Carrera Vinculada</label>
<select name="career_id" class="form-select form-select-sm">
<option value="">Sin carrera específica (General)</option>
{% for c in careers %}
<option value="{{ c.id }}" {% if s.career_id == c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Departamento / Área</label>
<input type="text" name="department" class="form-control form-control-sm" value="{{ s.department or '' }}">
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Descripción / Objetivos</label>
<textarea name="description" class="form-control form-control-sm" rows="2">{{ s.description or '' }}</textarea>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_active" id="activeSubj{{ s.id }}" {% if s.is_active %}checked{% endif %}>
<label class="form-check-label small" for="activeSubj{{ s.id }}">Asignatura Activa</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="submit" class="btn btn-sm btn-primary">Guardar Cambios</button>
</div>
</form>
</div>
</div>
<button type="button" class="btn btn-outline-danger py-0 px-2"
data-bs-toggle="modal" data-bs-target="#modalDeleteSubject"
data-id="{{ s.id }}"
data-code="{{ s.code }}"
data-name="{{ s.name }}"
title="Eliminar Asignatura">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
{% endif %}
@@ -304,5 +280,124 @@
</div>
</div>
</div>
<!-- Modal Editar Asignatura (Global fuera de la tabla para evitar problemas de visualización) -->
<div class="modal fade" id="modalEditSubject" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="/admin/subject_edit" id="formEditSubject">
<input type="hidden" name="id" id="editSubjectId">
<div class="modal-header">
<h5 class="modal-title h6 fw-bold">
<i class="bi bi-pencil-square text-primary me-2"></i>Editar Asignatura
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-2 mb-3">
<div class="col-md-5">
<label class="form-label small fw-semibold">Código *</label>
<input type="text" name="code" id="editSubjectCode" class="form-control form-control-sm font-monospace" required>
</div>
<div class="col-md-7">
<label class="form-label small fw-semibold">Créditos</label>
<input type="number" name="credits" id="editSubjectCredits" class="form-control form-control-sm" min="1" max="20">
</div>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Nombre de la Asignatura *</label>
<input type="text" name="name" id="editSubjectName" class="form-control form-control-sm" required>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Carrera Vinculada</label>
<select name="career_id" id="editSubjectCareer" class="form-select form-select-sm">
<option value="">Sin carrera específica (General)</option>
{% for c in careers %}
<option value="{{ c.id }}">{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Departamento / Área</label>
<input type="text" name="department" id="editSubjectDept" class="form-control form-control-sm">
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Descripción / Objetivos</label>
<textarea name="description" id="editSubjectDesc" class="form-control form-control-sm" rows="2"></textarea>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_active" id="editSubjectActive">
<label class="form-check-label small" for="editSubjectActive">Asignatura Activa</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="submit" class="btn btn-sm btn-primary">Guardar Cambios</button>
</div>
</form>
</div>
</div>
</div>
<!-- Modal Eliminar Asignatura -->
<div class="modal fade" id="modalDeleteSubject" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<form method="POST" action="/admin/subject_delete">
<input type="hidden" name="id" id="deleteSubjectId">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title h6 fw-bold">
<i class="bi bi-exclamation-triangle-fill me-2"></i>Eliminar Asignatura
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body py-4">
<p class="mb-2">¿Estás seguro de que deseas eliminar la asignatura <strong id="deleteSubjectName"></strong> (<span class="font-monospace" id="deleteSubjectCode"></span>)?</p>
<div class="alert alert-warning small mb-0">
<i class="bi bi-info-circle me-1"></i>
<strong>Aviso de integridad:</strong> Si la materia no posee comisiones vinculadas se eliminará permanentemente. Si ya posee comisiones históricas o cursadas, el sistema la <strong>desactivará automáticamente</strong> para resguardar las actas académicas.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="submit" class="btn btn-sm btn-danger">Confirmar Eliminación</button>
</div>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Populate Edit Subject Modal
var modalEdit = document.getElementById('modalEditSubject');
if (modalEdit) {
modalEdit.addEventListener('show.bs.modal', function(event) {
var btn = event.relatedTarget;
if (!btn) return;
document.getElementById('editSubjectId').value = btn.getAttribute('data-id') || '';
document.getElementById('editSubjectCode').value = btn.getAttribute('data-code') || '';
document.getElementById('editSubjectName').value = btn.getAttribute('data-name') || '';
document.getElementById('editSubjectCredits').value = btn.getAttribute('data-credits') || '4';
document.getElementById('editSubjectCareer').value = btn.getAttribute('data-career-id') || '';
document.getElementById('editSubjectDept').value = btn.getAttribute('data-department') || '';
document.getElementById('editSubjectDesc').value = btn.getAttribute('data-description') || '';
document.getElementById('editSubjectActive').checked = (btn.getAttribute('data-active') === 'true');
});
}
// Populate Delete Subject Modal
var modalDel = document.getElementById('modalDeleteSubject');
if (modalDel) {
modalDel.addEventListener('show.bs.modal', function(event) {
var btn = event.relatedTarget;
if (!btn) return;
document.getElementById('deleteSubjectId').value = btn.getAttribute('data-id') || '';
document.getElementById('deleteSubjectCode').textContent = btn.getAttribute('data-code') || '';
document.getElementById('deleteSubjectName').textContent = btn.getAttribute('data-name') || '';
});
}
});
</script>
{% endif %}
{% endblock %}
+214 -69
View File
@@ -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 %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
<div>
<h1 class="h3 mb-1">
<h1 class="h3 mb-1 fw-bold">
<i class="bi bi-person-gear text-primary me-2"></i>
{% if user %}Edit User Account{% else %}Create User Account{% endif %}
{% if user %}Editar Cuenta de Usuario{% else %}Crear Nueva Cuenta de Usuario{% endif %}
</h1>
<p class="text-muted small mb-0">Configure user credentials, profile information, and role assignment.</p>
<p class="text-muted small mb-0">
Gestión integral de identidad, datos de contacto, credenciales y rol institucional para Bedelía, Docentes y Alumnos.
</p>
</div>
<div>
<a href="/admin/users_list" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Users
<i class="bi bi-arrow-left me-1"></i>Volver a Usuarios
</a>
</div>
</div>
<div class="row">
<div class="col-lg-6 mx-auto">
<div class="col-lg-8 mx-auto">
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary py-3">
<h5 class="card-title h6 mb-0">
<i class="bi bi-person-vcard text-primary me-2"></i>Account Details
</h5>
<div class="card-header bg-body-tertiary py-3 d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-person-vcard text-primary fs-5"></i>
<h5 class="card-title h6 mb-0 fw-bold">
{% if user %}Ficha de Datos de: {{ user.name }}{% else %}Datos del Nuevo Usuario{% endif %}
</h5>
</div>
{% if user %}
<span class="badge bg-primary-subtle text-primary border font-monospace">ID #{{ user.id }}</span>
{% endif %}
</div>
<div class="card-body p-4">
<form method="POST">
{% if user %}
<input type="hidden" name="id" value="{{ user.id }}">
{% endif %}
<div class="mb-3">
<label for="name" class="form-label fw-semibold">Full Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" name="name"
value="{{ user.name if user else '' }}" required
placeholder="e.g. María González">
<!-- SECCIÓN 1: IDENTIDAD Y DOCUMENTACIÓN -->
<div class="mb-4">
<h6 class="text-uppercase text-primary fw-bold small border-bottom pb-2 mb-3">
<i class="bi bi-person-badge me-1"></i>1. Identidad y Documentación Personal
</h6>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label for="first_name" class="form-label small fw-semibold">Nombre(s) <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="first_name" name="first_name"
value="{{ user.first_name if user else '' }}" required
placeholder="Ej: María Belén">
</div>
<div class="col-md-6">
<label for="last_name" class="form-label small fw-semibold">Apellido(s) <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="last_name" name="last_name"
value="{{ user.last_name if user else '' }}" required
placeholder="Ej: González">
</div>
</div>
<div class="row g-3">
<div class="col-md-6">
<label for="document_type" class="form-label small fw-semibold">
Tipo de Documento <span class="text-muted">(Argentina / Extranjero)</span>
</label>
<select class="form-select" id="document_type" name="document_type">
<optgroup label="Documentos Nacionales (Argentina)">
<option value="DNI" {% if not user or user.document_type == 'DNI' %}selected{% endif %}>DNI — Documento Nacional de Identidad</option>
<option value="CUIL" {% if user and user.document_type == 'CUIL' %}selected{% endif %}>CUIL / CUIT — Código Laboral / Fiscal</option>
<option value="PAS" {% if user and user.document_type == 'PAS' %}selected{% endif %}>Pasaporte Argentino</option>
</optgroup>
<optgroup label="Documentos Extranjeros e Internacionales">
<option value="PASEXT" {% if user and user.document_type == 'PASEXT' %}selected{% endif %}>Pasaporte Extranjero (Internacional)</option>
<option value="DNIEXT" {% if user and user.document_type == 'DNIEXT' %}selected{% endif %}>DNI para Extranjeros (Radicación)</option>
<option value="CIEXT" {% if user and user.document_type == 'CIEXT' %}selected{% endif %}>Cédula Mercosur / Extranjera</option>
</optgroup>
<optgroup label="Documentos Históricos Nacionales">
<option value="CI" {% if user and user.document_type == 'CI' %}selected{% endif %}>Cédula de Identidad (Policía Federal)</option>
<option value="LC" {% if user and user.document_type == 'LC' %}selected{% endif %}>Libreta Cívica (LC)</option>
<option value="LE" {% if user and user.document_type == 'LE' %}selected{% endif %}>Libreta de Enrolamiento (LE)</option>
</optgroup>
</select>
</div>
<div class="col-md-6">
<label for="document_number" class="form-label small fw-semibold">Número de Documento</label>
<input type="text" class="form-control font-monospace" id="document_number" name="document_number"
value="{{ user.document_number if user else '' }}"
placeholder="Ej: 38123456">
<div class="form-text small" id="docHelpText">
Ingrese el número según la tipificación oficial seleccionada.
</div>
</div>
</div>
</div>
<div class="mb-3">
<label for="email" class="form-label fw-semibold">Email Address <span class="text-danger">*</span></label>
<input type="email" class="form-control" id="email" name="email"
value="{{ user.email if user else '' }}" required
placeholder="e.g. mgonzalez@edu-space.com">
<!-- SECCIÓN 2: DATOS DE CONTACTO Y DOMICILIO -->
<div class="mb-4">
<h6 class="text-uppercase text-primary fw-bold small border-bottom pb-2 mb-3">
<i class="bi bi-geo-alt me-1"></i>2. Contacto y Domicilio
</h6>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label for="phone" class="form-label small fw-semibold">
<i class="bi bi-telephone me-1 text-secondary"></i>Teléfono de Contacto
</label>
<input type="tel" class="form-control" id="phone" name="phone"
value="{{ user.phone if user else '' }}"
placeholder="Ej: +54 9 11 4455-6677">
<div class="form-text small">Móvil o fijo con código de área.</div>
</div>
<div class="col-md-6">
<label for="personal_email" class="form-label small fw-semibold">
<i class="bi bi-envelope-at me-1 text-secondary"></i>Email Personal / Particular
</label>
<input type="email" class="form-control" id="personal_email" name="personal_email"
value="{{ user.personal_email if user else '' }}"
placeholder="Ej: nombre.apellido@gmail.com">
<div class="form-text small">Correo personal alternativo para notificaciones y recuperación.</div>
</div>
</div>
<div class="row g-3">
<div class="col-12">
<label for="address" class="form-label small fw-semibold">
<i class="bi bi-house-door me-1 text-secondary"></i>Domicilio / Dirección
</label>
<input type="text" class="form-control" id="address" name="address"
value="{{ user.address if user else '' }}"
placeholder="Ej: Av. Córdoba 1850, Piso 4 B, CABA">
<div class="form-text small">Calle, número, piso/departamento y localidad.</div>
</div>
</div>
</div>
<div class="mb-3">
<label for="password" class="form-label fw-semibold">
Password {% if not user %}<span class="text-danger">*</span>{% endif %}
</label>
<input type="password" class="form-control" id="password" name="password"
{% if not user %}required{% endif %}
placeholder="{% if user %}Leave empty to keep current password{% else %}At least 6 characters{% endif %}">
{% if user %}
<small class="text-muted d-block mt-1">
Only fill this in if you want to reset this user's password.
</small>
{% endif %}
<!-- SECCIÓN 3: CUENTA, ACCESO Y ROL INSTITUCIONAL -->
<div class="mb-4">
<h6 class="text-uppercase text-primary fw-bold small border-bottom pb-2 mb-3">
<i class="bi bi-shield-lock me-1"></i>3. Cuenta de Acceso y Rol Institucional
</h6>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label for="email" class="form-label small fw-semibold">Email Institucional <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-envelope"></i></span>
<input type="email" class="form-control" id="email" name="email"
value="{{ user.email if user else '' }}" required
placeholder="usuario@edu-space.com">
</div>
</div>
<div class="col-md-6">
<label for="password" class="form-label small fw-semibold">
Contraseña {% if not user %}<span class="text-danger">*</span>{% endif %}
</label>
<div class="input-group">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-key"></i></span>
<input type="password" class="form-control" id="password" name="password"
{% if not user %}required{% endif %}
placeholder="{% if user %}Dejar en blanco para mantener{% else %}Mínimo 6 caracteres{% endif %}">
</div>
{% if user %}
<small class="text-muted d-block mt-1">
Solo complete este campo si desea restablecer la contraseña del usuario.
</small>
{% endif %}
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-8">
<label for="role_id" class="form-label small fw-semibold">Rol / Grupo de Acceso <span class="text-danger">*</span></label>
<select class="form-select" id="role_id" name="role_id" required>
<option value="" disabled {% if not user or not user.role_id %}selected{% endif %}>Seleccione un rol...</option>
{% for r in roles %}
<option value="{{ r.id }}" {% if user and user.role_id == r.id %}selected{% endif %}>
{{ r.name }} {% if r.description %}— {{ r.description }}{% endif %}
</option>
{% endfor %}
</select>
<small class="text-muted d-block mt-1">
Define los permisos para Bedelía, Profesores/Docentes, Alumnos o Administradores.
</small>
</div>
<div class="col-md-4 d-flex align-items-center">
<div class="form-check form-switch mt-3">
<input class="form-check-input" type="checkbox" role="switch" id="is_active" name="is_active"
{% if not user or user.is_active %}checked{% endif %}>
<label class="form-check-label fw-semibold small" for="is_active">
Cuenta Activa
</label>
<small class="text-muted d-block">
Permite iniciar sesión en el sistema.
</small>
</div>
</div>
</div>
</div>
<div class="mb-3">
<label for="role_id" class="form-label fw-semibold">Role / Access Group <span class="text-danger">*</span></label>
<select class="form-select" id="role_id" name="role_id" required>
<option value="" disabled {% if not user or not user.role_id %}selected{% endif %}>Select a role...</option>
{% for r in roles %}
<option value="{{ r.id }}" {% if user and user.role_id == r.id %}selected{% endif %}>
{{ r.name }} - {{ r.description or '' }}
</option>
{% endfor %}
</select>
<small class="text-muted d-block mt-1">
<a href="/admin/roles_list" target="_blank" class="text-decoration-none">
<i class="bi bi-box-arrow-up-right me-1"></i>View or manage roles and permissions
</a>
</small>
</div>
<div class="mb-4 form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="is_active" name="is_active"
{% if not user or user.is_active %}checked{% endif %}
{% if user and user.id == user.id %}disabled{% endif %}>
<label class="form-check-label fw-semibold" for="is_active">
Active Account
</label>
<small class="text-muted d-block">
Inactive users are prevented from signing in to the platform.
</small>
{% if user and user.id == user.id %}
<input type="hidden" name="is_active" value="on">
{% endif %}
</div>
<div class="d-flex justify-content-between align-items-center pt-2">
<div class="d-flex justify-content-between align-items-center pt-3 border-top">
<a href="/admin/users_list" class="btn btn-outline-secondary">
Cancel
Cancelar
</a>
<button type="submit" class="btn btn-primary px-4 fw-semibold">
<i class="bi bi-check-lg me-1"></i>Save User
<button type="submit" class="btn btn-primary px-4 fw-semibold shadow-sm">
<i class="bi bi-save me-1"></i>Guardar Usuario
</button>
</div>
</form>
@@ -107,4 +220,36 @@
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var docTypeSelect = document.getElementById('document_type');
var docNumInput = document.getElementById('document_number');
var docHelpText = document.getElementById('docHelpText');
var hints = {
'DNI': 'DNI Argentino: 7 u 8 dígitos numéricos sin puntos (ej: 38123456).',
'CUIL': 'CUIL/CUIT: 11 dígitos numéricos o con guiones (ej: 20-38123456-7).',
'PAS': 'Pasaporte Argentino: 3 letras y 6 números (ej: AAB123456).',
'PASEXT': 'Pasaporte Extranjero: código alfanumérico emitido por país de origen.',
'DNIEXT': 'DNI Extranjero: 7 a 10 dígitos o alfanumérico con residencia.',
'CI': 'Cédula de Identidad Nacional: 6 a 8 dígitos numéricos.',
'CIEXT': 'Cédula de Identidad Mercosur: formato de país emisor.',
'LC': 'Libreta Cívica: 6 a 7 dígitos numéricos.',
'LE': 'Libreta de Enrolamiento: 6 a 7 dígitos numéricos.'
};
function updateDocHint() {
var val = docTypeSelect.value;
if (hints[val]) {
docHelpText.textContent = hints[val];
}
}
if (docTypeSelect) {
docTypeSelect.addEventListener('change', updateDocHint);
updateDocHint();
}
});
</script>
{% endblock %}
+102 -40
View File
@@ -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 %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-people text-primary me-2"></i>User Management
<h1 class="h3 mb-1 fw-bold">
<i class="bi bi-people text-primary me-2"></i>Gestión de Usuarios
</h1>
<p class="text-muted small mb-0">Manage user accounts, assign roles, and control active status.</p>
<p class="text-muted small mb-0">
Administración de cuentas, identidad y documentación, datos de contacto y asignación de roles institucionales (Bedelía, Docentes, Alumnos).
</p>
</div>
<div>
<a href="/admin/user_add" class="btn btn-primary">
<i class="bi bi-person-plus me-1"></i>New User
<a href="/admin/user_add" class="btn btn-primary shadow-sm">
<i class="bi bi-person-plus me-1"></i>Nuevo Usuario
</a>
</div>
</div>
<!-- Feedback Alerts -->
{% if msg == 'saved' %}
<div class="alert alert-success alert-dismissible fade show shadow-sm mb-4" role="alert">
<i class="bi bi-check-circle-fill me-2"></i>Los datos del usuario han sido guardados exitosamente.
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% elif error %}
<div class="alert alert-danger alert-dismissible fade show shadow-sm mb-4" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i>{{ error }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
<!-- Filters & Search -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-3">
@@ -29,13 +44,13 @@
<i class="bi bi-search text-muted"></i>
</span>
<input type="text" name="search" class="form-control border-start-0"
placeholder="Search by name or email..."
placeholder="Buscar por nombre, apellido, email o nro de documento..."
value="{{ search }}">
</div>
</div>
<div class="col-md-3">
<select name="role" class="form-select form-select-sm">
<option value="">All Roles</option>
<option value="">Todos los Roles</option>
{% for r in roles %}
<option value="{{ r.id }}" {% if role_filter == (r.id|string) %}selected{% endif %}>{{ r.name }}</option>
{% endfor %}
@@ -43,17 +58,17 @@
</div>
<div class="col-md-2">
<select name="status" class="form-select form-select-sm">
<option value="">All Statuses</option>
<option value="active" {% if status_filter == 'active' %}selected{% endif %}>Active</option>
<option value="inactive" {% if status_filter == 'inactive' %}selected{% endif %}>Inactive</option>
<option value="">Todos los Estados</option>
<option value="active" {% if status_filter == 'active' %}selected{% endif %}>Solo Activos</option>
<option value="inactive" {% if status_filter == 'inactive' %}selected{% endif %}>Solo Inactivos</option>
</select>
</div>
<div class="col-md-2 d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm w-100">
<i class="bi bi-filter me-1"></i>Filter
<i class="bi bi-filter me-1"></i>Filtrar
</button>
{% if search or role_filter or status_filter %}
<a href="/admin/users_list" class="btn btn-outline-secondary btn-sm" title="Clear filters">
<a href="/admin/users_list" class="btn btn-outline-secondary btn-sm" title="Limpiar filtros">
<i class="bi bi-x-lg"></i>
</a>
{% endif %}
@@ -68,12 +83,13 @@
<table class="table table-hover align-middle mb-0">
<thead class="bg-body-tertiary">
<tr>
<th class="ps-4">User</th>
<th>Email</th>
<th>Role / Group</th>
<th>Status</th>
<th>Last Login</th>
<th class="text-end pe-4">Actions</th>
<th class="ps-4">Usuario / Identidad</th>
<th>Documento</th>
<th>Contacto y Domicilio</th>
<th>Rol Institucional</th>
<th>Estado</th>
<th>Último Acceso</th>
<th class="text-end pe-4">Acciones</th>
</tr>
</thead>
<tbody>
@@ -81,17 +97,54 @@
<tr>
<td class="ps-4">
<div class="d-flex align-items-center">
<div class="user-avatar-small me-2">
{{ (u.first_name[0] if u.first_name else 'U')|upper }}
<div class="user-avatar-small me-2 rounded-circle bg-primary-subtle text-primary d-flex align-items-center justify-content-center fw-bold" style="width: 36px; height: 36px;">
{{ (u.first_name[0] if u.first_name else (u.name[0] if u.name else 'U'))|upper }}
</div>
<div>
<div class="fw-bold">{{ u.name }}</div>
<small class="text-muted">ID: #{{ u.id }}</small>
<div class="text-muted small">
{% if u.first_name and u.last_name %}
<span class="opacity-75">{{ u.last_name }}, {{ u.first_name }}</span> ·
{% endif %}
<span class="font-monospace">#{{ u.id }}</span>
</div>
</div>
</div>
</td>
<td>
<span class="font-monospace small">{{ u.email }}</span>
{% if u.document_number %}
<div>
<span class="badge {% if u.document_type in ['PAS', 'PASEXT'] %}bg-info-subtle text-info border border-info-subtle{% elif u.document_type == 'CUIL' %}bg-primary-subtle text-primary border border-primary-subtle{% else %}bg-secondary-subtle text-secondary border{% endif %} py-1 px-2 font-monospace">
{{ u.document_type or 'DNI' }}: {{ u.document_formatted or u.document_number }}
</span>
</div>
{% else %}
<span class="text-muted small fst-italic"><i class="bi bi-dash"></i> Sin registrar</span>
{% endif %}
</td>
<td>
<div>
<i class="bi bi-building me-1 text-primary" title="Email Institucional"></i>
<span class="font-monospace small fw-semibold">{{ u.email }}</span>
</div>
{% if u.personal_email %}
<div class="text-muted small">
<i class="bi bi-envelope-at me-1 text-secondary" title="Email Personal"></i>
<span class="font-monospace" style="font-size: 0.8rem;">{{ u.personal_email }}</span>
</div>
{% endif %}
{% if u.phone or u.address %}
<div class="text-muted small mt-1 d-flex flex-column gap-0">
{% if u.phone %}
<span><i class="bi bi-telephone me-1 text-secondary"></i>{{ u.phone }}</span>
{% endif %}
{% if u.address %}
<span class="text-truncate" style="max-width: 220px;" title="{{ u.address }}">
<i class="bi bi-geo-alt me-1 text-secondary"></i>{{ u.address }}
</span>
{% endif %}
</div>
{% endif %}
</td>
<td>
{% if u.role_obj %}
@@ -100,40 +153,40 @@
</span>
{% else %}
<span class="badge bg-secondary">
{{ u.role or _('No Role') }}
{{ u.role or 'Docente' }}
</span>
{% endif %}
</td>
<td>
{% if u.is_active %}
<span class="badge bg-success-subtle text-success border border-success-subtle px-2 py-1">
<i class="bi bi-check-circle me-1"></i>Active
<i class="bi bi-check-circle me-1"></i>Activo
</span>
{% else %}
<span class="badge bg-danger-subtle text-danger border border-danger-subtle px-2 py-1">
<i class="bi bi-dash-circle me-1"></i>Inactive
<i class="bi bi-dash-circle me-1"></i>Inactivo
</span>
{% endif %}
</td>
<td>
<small class="text-muted">
{% if u.last_login %}
{{ format_datetime(u.last_login, format='short') }}
{{ u.last_login[:16] if u.last_login is string else u.last_login }}
{% else %}
Never
Nunca
{% endif %}
</small>
</td>
<td class="text-end pe-4">
<div class="btn-group btn-group-sm">
<a href="/admin/users/edit/{{ u.id }}" class="btn btn-outline-primary" title="Edit User">
<a href="/admin/users/edit/{{ u.id }}" class="btn btn-outline-primary" title="Editar Ficha de Usuario">
<i class="bi bi-pencil"></i>
</a>
{% if u.id != user.id %}
<form action="/admin/user_toggle" method="POST" class="d-inline">
<input type="hidden" name="id" value="{{ u.id }}">
<button type="submit" class="btn {% if u.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %}"
title="{% if u.is_active %}Deactivate User{% else %}Activate User{% endif %}">
title="{% if u.is_active %}Desactivar Usuario{% else %}Activar Usuario{% endif %}">
<i class="bi {% if u.is_active %}bi-person-x{% else %}bi-person-check{% endif %}"></i>
</button>
</form>
@@ -151,9 +204,9 @@
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center py-5 text-muted">
<td colspan="7" class="text-center py-5 text-muted">
<i class="bi bi-people fs-1 d-block mb-2 text-secondary"></i>
No users found matching the criteria.
No se encontraron usuarios coincidentes con los criterios de búsqueda.
</td>
</tr>
{% endfor %}
@@ -162,11 +215,8 @@
</div>
<!-- Pagination -->
{% if pagination.pages > 1 %}
<div class="card-footer bg-body-tertiary d-flex justify-content-between align-items-center py-2 px-4">
<small class="text-muted">
Showing page {{ pagination.page }} of {{ pagination.pages }} ({{ pagination.total }} total)
</small>
{% if pagination and pagination.pages > 1 %}
<div class="card-footer bg-body-tertiary border-0 py-2 d-flex justify-content-center">
<ul class="pagination pagination-sm mb-0">
{% if pagination.has_prev %}
<li class="page-item"><a class="page-link" href="/admin/users_list">&laquo;</a></li>
@@ -189,7 +239,7 @@
</div>
</div>
<!-- Modal Global de Eliminación de Usuario (fuera de la tabla) -->
<!-- Modal Global de Eliminación de Usuario -->
<div class="modal fade" id="deleteUserModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow-lg" style="border-radius: 14px;">
@@ -237,9 +287,21 @@ document.addEventListener('DOMContentLoaded', function() {
var name = this.getAttribute('data-user-name');
var email = this.getAttribute('data-user-email');
var actionUrl = this.getAttribute('data-action-url');
var userId = this.getAttribute('data-user-id');
if (nameEl) nameEl.textContent = name;
if (emailEl) emailEl.textContent = email;
if (formEl) formEl.action = actionUrl;
if (formEl) {
formEl.action = actionUrl;
// Add hidden input with id
var hiddenId = formEl.querySelector('input[name="id"]');
if (!hiddenId) {
hiddenId = document.createElement('input');
hiddenId.type = 'hidden';
hiddenId.name = 'id';
formEl.appendChild(hiddenId);
}
hiddenId.value = userId;
}
if (deleteModal) deleteModal.show();
});
});
+265
View File
@@ -633,6 +633,254 @@
</div>
</div>
<!-- ========================= Panel: Comisiones Sin Reserva (Semana/Mes) ========================= -->
<div class="card border-0 shadow-sm mb-4" id="bedelia-unassigned-commissions">
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center flex-wrap gap-2">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-calendar-x-fill text-danger fs-5"></i>
<div>
<h5 class="mb-0 h6 fw-bold">Comisiones Sin Reserva de Aula</h5>
<small class="text-muted">Comisiones activas sin clase reservada en el período seleccionado</small>
</div>
</div>
<div class="d-flex gap-2 align-items-center flex-wrap">
<div class="btn-group btn-group-sm" role="group" id="commPeriodSwitcher">
<button type="button" class="btn btn-outline-danger active" data-period="week" onclick="switchCommPeriod('week')">
<i class="bi bi-calendar-week me-1"></i>Esta Semana
<span class="badge bg-danger ms-1" id="commWeekBadge">{{ role_data.unassigned_commissions_week | length }}</span>
</button>
<button type="button" class="btn btn-outline-danger" data-period="month" onclick="switchCommPeriod('month')">
<i class="bi bi-calendar-month me-1"></i>Este Mes
<span class="badge bg-danger ms-1" id="commMonthBadge">{{ role_data.unassigned_commissions_month | length }}</span>
</button>
</div>
<a href="/admin/commissions_list" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-list-ul me-1"></i>Ver Todas
</a>
</div>
</div>
<div class="card-body p-0">
<!-- Tabla Semana -->
<div id="commTableWeek">
{% if role_data.unassigned_commissions_week and role_data.unassigned_commissions_week | length > 0 %}
<div class="table-responsive">
<table class="table table-hover align-middle mb-0 small">
<thead class="table-light">
<tr>
<th class="ps-3">Código</th>
<th>Materia</th>
<th>Docente</th>
<th>Horario Programado</th>
<th>Turno</th>
<th>Cupo</th>
<th class="text-end pe-3">Acción</th>
</tr>
</thead>
<tbody>
{% for comm in role_data.unassigned_commissions_week %}
<tr>
<td class="ps-3">
<span class="badge bg-danger-subtle text-danger border font-monospace">{{ comm.full_code }}</span>
</td>
<td>
<div class="fw-semibold text-body">{{ comm.subject_name }}</div>
<small class="text-muted font-monospace">{{ comm.subject_code }}</small>
</td>
<td class="small">{{ comm.teacher_name }}</td>
<td>
<span class="badge bg-body-tertiary text-body border"><i class="bi bi-clock me-1"></i>{{ comm.schedule }}</span>
</td>
<td><span class="badge bg-info-subtle text-info-emphasis border">{{ comm.shift }}</span></td>
<td class="font-monospace">{{ comm.current_students }}/{{ comm.max_students }}</td>
<td class="text-end pe-3">
<a href="/schedule/add_reservation?commission_id={{ comm.id }}" class="btn btn-sm btn-outline-success py-0 px-2" title="Asignar aula a esta comisión">
<i class="bi bi-plus-circle me-1"></i>Asignar
</a>
<a href="/admin/commission_detail?id={{ comm.id }}" class="btn btn-sm btn-outline-primary py-0 px-2 ms-1" title="Ver detalle">
<i class="bi bi-eye me-1"></i>Detalle
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4">
<i class="bi bi-check-circle-fill text-success fs-1 d-block mb-2"></i>
<h6 class="text-success fw-bold">¡Todas las comisiones tienen reserva esta semana!</h6>
<p class="text-muted small mb-0">No hay comisiones activas sin aula reservada en la semana actual.</p>
</div>
{% endif %}
</div>
<!-- Tabla Mes -->
<div id="commTableMonth" style="display: none;">
{% if role_data.unassigned_commissions_month and role_data.unassigned_commissions_month | length > 0 %}
<div class="table-responsive">
<table class="table table-hover align-middle mb-0 small">
<thead class="table-light">
<tr>
<th class="ps-3">Código</th>
<th>Materia</th>
<th>Docente</th>
<th>Horario Programado</th>
<th>Turno</th>
<th>Cupo</th>
<th class="text-end pe-3">Acción</th>
</tr>
</thead>
<tbody>
{% for comm in role_data.unassigned_commissions_month %}
<tr>
<td class="ps-3">
<span class="badge bg-danger-subtle text-danger border font-monospace">{{ comm.full_code }}</span>
</td>
<td>
<div class="fw-semibold text-body">{{ comm.subject_name }}</div>
<small class="text-muted font-monospace">{{ comm.subject_code }}</small>
</td>
<td class="small">{{ comm.teacher_name }}</td>
<td>
<span class="badge bg-body-tertiary text-body border"><i class="bi bi-clock me-1"></i>{{ comm.schedule }}</span>
</td>
<td><span class="badge bg-info-subtle text-info-emphasis border">{{ comm.shift }}</span></td>
<td class="font-monospace">{{ comm.current_students }}/{{ comm.max_students }}</td>
<td class="text-end pe-3">
<a href="/schedule/add_reservation?commission_id={{ comm.id }}" class="btn btn-sm btn-outline-success py-0 px-2" title="Asignar aula a esta comisión">
<i class="bi bi-plus-circle me-1"></i>Asignar
</a>
<a href="/admin/commission_detail?id={{ comm.id }}" class="btn btn-sm btn-outline-primary py-0 px-2 ms-1" title="Ver detalle">
<i class="bi bi-eye me-1"></i>Detalle
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4">
<i class="bi bi-check-circle-fill text-success fs-1 d-block mb-2"></i>
<h6 class="text-success fw-bold">¡Todas las comisiones tienen reserva este mes!</h6>
<p class="text-muted small mb-0">No hay comisiones activas sin aula reservada en el mes actual.</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- ========================= Panel: Aulas Disponibles (Semana/Mes) ========================= -->
<div class="card border-0 shadow-sm mb-4" id="bedelia-available-classrooms">
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center flex-wrap gap-2">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-door-open-fill text-success fs-5"></i>
<div>
<h5 class="mb-0 h6 fw-bold">Aulas Disponibles (Sin Reservas)</h5>
<small class="text-muted">Espacios sin ninguna reserva cargada en el período seleccionado</small>
</div>
</div>
<div class="d-flex gap-2 align-items-center flex-wrap">
<div class="btn-group btn-group-sm" role="group" id="classroomPeriodSwitcher">
<button type="button" class="btn btn-outline-success active" data-period="week" onclick="switchClassroomPeriod('week')">
<i class="bi bi-calendar-week me-1"></i>Esta Semana
<span class="badge bg-success ms-1" id="classWeekBadge">{{ role_data.available_classrooms_week | length }}</span>
</button>
<button type="button" class="btn btn-outline-success" data-period="month" onclick="switchClassroomPeriod('month')">
<i class="bi bi-calendar-month me-1"></i>Este Mes
<span class="badge bg-success ms-1" id="classMonthBadge">{{ role_data.available_classrooms_month | length }}</span>
</button>
</div>
<a href="/classrooms/list_classrooms" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-list-ul me-1"></i>Catálogo Completo
</a>
</div>
</div>
<div class="card-body p-3">
<!-- Vista Semana -->
<div id="classroomGridWeek">
{% if role_data.available_classrooms_week and role_data.available_classrooms_week | length > 0 %}
<div class="row g-2">
{% for aula in role_data.available_classrooms_week %}
<div class="col-sm-6 col-md-4 col-lg-3">
<a href="/schedule/add_reservation?classroom_id={{ aula.id }}" class="text-decoration-none text-reset d-block h-100">
<div class="p-3 rounded border h-100 transition-all {% if aula.is_virtual %}bg-info bg-opacity-10 border-info-subtle{% else %}bg-success bg-opacity-10 border-success-subtle{% endif %}">
<div class="d-flex justify-content-between align-items-start mb-2">
<span class="badge {% if aula.is_virtual %}bg-info-subtle text-info{% else %}bg-success-subtle text-success{% endif %} border font-monospace fw-bold">
{{ aula.code }}
</span>
{% if aula.is_virtual %}
<i class="bi bi-camera-video text-info"></i>
{% else %}
<i class="bi bi-door-open text-success"></i>
{% endif %}
</div>
<div class="small text-muted mb-1">
<i class="bi bi-building me-1"></i>{{ aula.building }}{% if aula.floor %} · {{ aula.floor }}{% endif %}
</div>
<div class="d-flex justify-content-between align-items-center">
<span class="small fw-semibold text-body">
<i class="bi bi-people-fill me-1"></i>Cap: {{ aula.capacity }}
</span>
<span class="badge bg-success text-white small"><i class="bi bi-plus-circle me-1"></i>Asignar</span>
</div>
</div>
</a>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-4">
<i class="bi bi-calendar-check-fill text-primary fs-1 d-block mb-2"></i>
<h6 class="text-primary fw-bold">¡Todas las aulas están siendo utilizadas esta semana!</h6>
<p class="text-muted small mb-0">No hay aulas sin reservas activas en la semana actual.</p>
</div>
{% endif %}
</div>
<!-- Vista Mes -->
<div id="classroomGridMonth" style="display: none;">
{% if role_data.available_classrooms_month and role_data.available_classrooms_month | length > 0 %}
<div class="row g-2">
{% for aula in role_data.available_classrooms_month %}
<div class="col-sm-6 col-md-4 col-lg-3">
<a href="/schedule/add_reservation?classroom_id={{ aula.id }}" class="text-decoration-none text-reset d-block h-100">
<div class="p-3 rounded border h-100 transition-all {% if aula.is_virtual %}bg-info bg-opacity-10 border-info-subtle{% else %}bg-success bg-opacity-10 border-success-subtle{% endif %}">
<div class="d-flex justify-content-between align-items-start mb-2">
<span class="badge {% if aula.is_virtual %}bg-info-subtle text-info{% else %}bg-success-subtle text-success{% endif %} border font-monospace fw-bold">
{{ aula.code }}
</span>
{% if aula.is_virtual %}
<i class="bi bi-camera-video text-info"></i>
{% else %}
<i class="bi bi-door-open text-success"></i>
{% endif %}
</div>
<div class="small text-muted mb-1">
<i class="bi bi-building me-1"></i>{{ aula.building }}{% if aula.floor %} · {{ aula.floor }}{% endif %}
</div>
<div class="d-flex justify-content-between align-items-center">
<span class="small fw-semibold text-body">
<i class="bi bi-people-fill me-1"></i>Cap: {{ aula.capacity }}
</span>
<span class="badge bg-success text-white small"><i class="bi bi-plus-circle me-1"></i>Asignar</span>
</div>
</div>
</a>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-4">
<i class="bi bi-calendar-check-fill text-primary fs-1 d-block mb-2"></i>
<h6 class="text-primary fw-bold">¡Todas las aulas están siendo utilizadas este mes!</h6>
<p class="text-muted small mb-0">No hay aulas sin reservas activas en el mes actual.</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Bedelía Operational Launchpad -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-body-tertiary border-0 py-3">
@@ -1314,5 +1562,22 @@ document.querySelectorAll('[data-color]').forEach(function(el) {
const color = el.getAttribute('data-color');
if (color) el.style.setProperty('background-color', color, 'important');
});
// Bedelía: Switchers de período para paneles de Comisiones y Aulas
function switchCommPeriod(period) {
document.getElementById('commTableWeek').style.display = period === 'week' ? 'block' : 'none';
document.getElementById('commTableMonth').style.display = period === 'month' ? 'block' : 'none';
document.querySelectorAll('#commPeriodSwitcher button').forEach(function(btn) {
btn.classList.toggle('active', btn.getAttribute('data-period') === period);
});
}
function switchClassroomPeriod(period) {
document.getElementById('classroomGridWeek').style.display = period === 'week' ? 'block' : 'none';
document.getElementById('classroomGridMonth').style.display = period === 'month' ? 'block' : 'none';
document.querySelectorAll('#classroomPeriodSwitcher button').forEach(function(btn) {
btn.classList.toggle('active', btn.getAttribute('data-period') === period);
});
}
</script>
{% endblock %}
+74
View File
@@ -0,0 +1,74 @@
@echo off
setlocal enabledelayedexpansion
echo ======================================================================
echo EDU-SPACE: AUTOMATED SECURITY AUDIT SUITE
echo ======================================================================
echo.
set ROOT_DIR=%~dp0
set VENV_PY=%ROOT_DIR%backend\venv\Scripts\python.exe
set VENV_BANDIT=%ROOT_DIR%backend\venv\Scripts\bandit.exe
set VENV_PIPAUDIT=%ROOT_DIR%backend\venv\Scripts\pip-audit.exe
set VENV_ST=%ROOT_DIR%backend\venv\Scripts\st.exe
set VENV_NJSSCAN=%ROOT_DIR%backend\venv\Scripts\njsscan.exe
echo [1/5] Running Python SAST Analysis (bandit)...
echo ----------------------------------------------------------------------
call "%VENV_BANDIT%" -r "%ROOT_DIR%backend\app" -ll -ii
if %ERRORLEVEL% neq 0 (
echo [!] Warning: Bandit reported potential security concerns.
) else (
echo [OK] Bandit scan completed cleanly - 0 Medium/High issues.
)
echo.
echo [2/5] Running Python SCA Dependency Audit (pip-audit)...
echo ----------------------------------------------------------------------
call "%VENV_PIPAUDIT%" -s osv --progress-spinner off -r "%ROOT_DIR%backend\requirements.txt"
if %ERRORLEVEL% neq 0 (
echo [!] Warning: pip-audit reported package vulnerabilities.
) else (
echo [OK] All Python dependencies are secure - 0 known CVEs.
)
echo.
echo [3/5] Running Node.js SAST Analysis (njsscan)...
echo ----------------------------------------------------------------------
call "%VENV_NJSSCAN%" "%ROOT_DIR%frontend\src"
if %ERRORLEVEL% neq 0 (
echo [!] Warning: njsscan reported code smells or security concerns.
) else (
echo [OK] Node.js SAST analysis completed cleanly.
)
echo.
echo [4/5] Running Node.js SCA Dependency Audit (npm audit)...
echo ----------------------------------------------------------------------
cd /d "%ROOT_DIR%frontend"
call cmd.exe /c npm audit
if %ERRORLEVEL% neq 0 (
echo [!] Warning: npm audit reported package vulnerabilities.
) else (
echo [OK] All Node.js dependencies are secure - 0 vulnerabilities.
)
cd /d "%ROOT_DIR%"
echo.
echo [5/5] Running Dynamic API Security Fuzzing (schemathesis)...
echo ----------------------------------------------------------------------
echo Testing target: http://127.0.0.1:5000/api/v1/openapi.json
set PYTHONUTF8=1
set PYTHONIOENCODING=utf-8
call "%VENV_ST%" run http://127.0.0.1:5000/api/v1/openapi.json --checks not_a_server_error --max-examples=10
if %ERRORLEVEL% neq 0 (
echo [!] Note: Schemathesis found potential unhandled edge cases or backend server is not running on port 5000.
) else (
echo [OK] API DAST fuzzing passed - 0 unhandled 500 server errors.
)
echo.
echo ======================================================================
echo SECURITY AUDIT SUITE COMPLETED
echo ======================================================================
pause