Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71fb20a271 | ||
|
|
ada39248d7 |
@@ -0,0 +1,73 @@
|
||||
name: Edu-Space Automated Security Audit (Gitea Local)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
|
||||
jobs:
|
||||
security-scan:
|
||||
# Usamos la etiqueta estándar para contenedores Ubuntu en Gitea Runner
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
# 1. Clonar el código del repositorio local
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 2. Preparar el entorno de paquetes del sistema (Linux)
|
||||
- name: Install System Dependencies (Python & Node.js)
|
||||
run: |
|
||||
apt-get update && apt-get install -y python3 python3-pip python3-venv nodejs npm curl
|
||||
python3 -m pip install --upgrade pip
|
||||
|
||||
# 3. Instalar librerías de Python y herramientas de seguridad
|
||||
- name: Install Python Dependencies & Security Tools
|
||||
run: |
|
||||
pip3 install -r backend/requirements.txt
|
||||
pip3 install -r backend/requirements-dev.txt
|
||||
|
||||
# 4. Instalar librerías de Node.js (Express BFF)
|
||||
- name: Install Node.js Dependencies
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
|
||||
# --- EJECUCIÓN DE CONTROLES (SAST & SCA) ---
|
||||
|
||||
- name: [1/5] Python SAST (Bandit)
|
||||
run: bandit -r ./backend/app -ll -ii
|
||||
|
||||
- name: [2/5] Python SCA (Pip-Audit)
|
||||
run: pip-audit -s osv --progress-spinner off -r backend/requirements.txt
|
||||
|
||||
- name: [3/5] Node.js SAST (Njsscan)
|
||||
run: njsscan ./frontend/src
|
||||
|
||||
- name: [4/5] Node.js SCA (Npm Audit)
|
||||
run: |
|
||||
cd frontend
|
||||
npm audit --audit-level=high
|
||||
|
||||
# --- CONTROL DINÁMICO DAST (Schemathesis) ---
|
||||
|
||||
- name: [5/5] Start Flask App & Run DAST (Schemathesis)
|
||||
env:
|
||||
PYTHONUTF8: 1
|
||||
FLASK_APP: backend/app # Ajustar a la ruta de inicio de tu Flask si varía
|
||||
run: |
|
||||
# Levantar Flask en segundo plano dentro del contenedor local
|
||||
# Usamos 'python3 -m flask run' para asegurar el bindeo local
|
||||
cd backend
|
||||
python3 -m flask run --host=127.0.0.1 --port=5000 &
|
||||
|
||||
# Esperar 5 segundos a que la API responda
|
||||
echo "Esperando a que el backend de Flask inicie en el entorno local..."
|
||||
sleep 5
|
||||
|
||||
# Verificar con un curl rápido si el JSON de OpenAPI está disponible
|
||||
curl -s http://127.0.0.1:5000/api/v1/openapi.json > /dev/null
|
||||
|
||||
# Ejecutar Schemathesis contra la instancia local efímera
|
||||
schemathesis run http://127.0.0.1:5000/api/v1/openapi.json --checks not_a_server_error --max-examples=10
|
||||
@@ -221,3 +221,4 @@ frontend/.env
|
||||
|
||||
legacy_admin-edu-space/
|
||||
.vscode/
|
||||
.schemathesis/
|
||||
+66
-7
@@ -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`).
|
||||
|
||||
@@ -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
@@ -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`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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** ✅ |
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .document_types import DOCUMENT_TYPES, DOCUMENT_TYPE_MAP, get_document_type, validate_document, format_document
|
||||
@@ -0,0 +1,160 @@
|
||||
import re
|
||||
|
||||
DOCUMENT_TYPES = [
|
||||
{
|
||||
'code': 'DNI',
|
||||
'name': 'Documento Nacional de Identidad',
|
||||
'short_name': 'DNI',
|
||||
'category': 'Nacional',
|
||||
'description': 'Documento oficial para ciudadanos argentinos nativos, naturalizados y residentes permanentes.',
|
||||
'regex': r'^\d{7,8}$',
|
||||
'placeholder': 'Ej: 38123456 (7 u 8 dígitos sin puntos)',
|
||||
'mask_example': '38.123.456'
|
||||
},
|
||||
{
|
||||
'code': 'CUIL',
|
||||
'name': 'Código Único de Identificación Laboral (CUIL/CUIT)',
|
||||
'short_name': 'CUIL',
|
||||
'category': 'Nacional / Laboral',
|
||||
'description': 'Identificador fiscal y laboral en Argentina (11 dígitos).',
|
||||
'regex': r'^\d{11}$|^\d{2}-\d{8}-\d{1}$',
|
||||
'placeholder': 'Ej: 20381234567 o 20-38123456-7',
|
||||
'mask_example': '20-38123456-7'
|
||||
},
|
||||
{
|
||||
'code': 'PAS',
|
||||
'name': 'Pasaporte Argentino',
|
||||
'short_name': 'Pasaporte ARG',
|
||||
'category': 'Internacional',
|
||||
'description': 'Pasaporte emitido por la República Argentina para viajes y acreditación internacional.',
|
||||
'regex': r'^[a-zA-Z0-9]{6,12}$',
|
||||
'placeholder': 'Ej: AAB123456',
|
||||
'mask_example': 'PAS AAB123456'
|
||||
},
|
||||
{
|
||||
'code': 'PASEXT',
|
||||
'name': 'Pasaporte Extranjero',
|
||||
'short_name': 'Pasaporte Extranjero',
|
||||
'category': 'Internacional / Extranjero',
|
||||
'description': 'Pasaporte oficial emitido por país de origen (docentes invitados, alumnos de intercambio).',
|
||||
'regex': r'^[a-zA-Z0-9\-\.]{4,20}$',
|
||||
'placeholder': 'Ej: US987654321 / B1234567',
|
||||
'mask_example': 'PASEXT US987654321'
|
||||
},
|
||||
{
|
||||
'code': 'DNIEXT',
|
||||
'name': 'DNI para Extranjeros (Residente)',
|
||||
'short_name': 'DNI Extranjero',
|
||||
'category': 'Extranjero / Residente',
|
||||
'description': 'DNI argentino otorgado a ciudadanos extranjeros con radicación o residencia temporaria.',
|
||||
'regex': r'^[a-zA-Z0-9]{7,10}$',
|
||||
'placeholder': 'Ej: 94123456 o E94123456',
|
||||
'mask_example': 'DNI-EXT 94.123.456'
|
||||
},
|
||||
{
|
||||
'code': 'CI',
|
||||
'name': 'Cédula de Identidad (PFA / Policía Provincial)',
|
||||
'short_name': 'Cédula Identidad',
|
||||
'category': 'Nacional (Histórico)',
|
||||
'description': 'Cédula de Identidad emitida por Policía Federal Argentina o Policías Provinciales.',
|
||||
'regex': r'^\d{6,9}$',
|
||||
'placeholder': 'Ej: 6123456',
|
||||
'mask_example': 'CI 6.123.456'
|
||||
},
|
||||
{
|
||||
'code': 'CIEXT',
|
||||
'name': 'Cédula de Identidad Extranjera (Mercosur)',
|
||||
'short_name': 'Cédula Mercosur',
|
||||
'category': 'Regional / Mercosur',
|
||||
'description': 'Cédula de Identidad emitida por países miembros o asociados al Mercosur (Brasil, Uruguay, etc.).',
|
||||
'regex': r'^[a-zA-Z0-9\-\.]{5,18}$',
|
||||
'placeholder': 'Ej: RG12345678 (Brasil) o 4.123.456-7 (Uruguay)',
|
||||
'mask_example': 'CI-EXT 12345678'
|
||||
},
|
||||
{
|
||||
'code': 'LC',
|
||||
'name': 'Libreta Cívica',
|
||||
'short_name': 'Libreta Cívica',
|
||||
'category': 'Histórico Nacional',
|
||||
'description': 'Documento histórico nacional para ciudadanas mujeres argentinas.',
|
||||
'regex': r'^\d{6,8}$',
|
||||
'placeholder': 'Ej: 5123456',
|
||||
'mask_example': 'LC 5.123.456'
|
||||
},
|
||||
{
|
||||
'code': 'LE',
|
||||
'name': 'Libreta de Enrolamiento',
|
||||
'short_name': 'Libreta Enrolamiento',
|
||||
'category': 'Histórico Nacional',
|
||||
'description': 'Documento histórico nacional para ciudadanos varones argentinos.',
|
||||
'regex': r'^\d{6,8}$',
|
||||
'placeholder': 'Ej: 4123456',
|
||||
'mask_example': 'LE 4.123.456'
|
||||
}
|
||||
]
|
||||
|
||||
DOCUMENT_TYPE_MAP = {doc['code']: doc for doc in DOCUMENT_TYPES}
|
||||
|
||||
def get_document_type(code):
|
||||
"""Devuelve la especificación de un tipo de documento o None."""
|
||||
if not code:
|
||||
return None
|
||||
return DOCUMENT_TYPE_MAP.get(str(code).strip().upper())
|
||||
|
||||
def validate_document(doc_type, doc_number):
|
||||
"""
|
||||
Valida un número de documento según las reglas de negocio de su tipificación.
|
||||
Retorna (is_valid: bool, cleaned_number: str, error_message: str|None).
|
||||
"""
|
||||
if not doc_type:
|
||||
return False, '', 'Debe especificar el tipo de documento.'
|
||||
|
||||
spec = get_document_type(doc_type)
|
||||
if not spec:
|
||||
return False, '', f"El tipo de documento '{doc_type}' no está registrado en el catálogo oficial."
|
||||
|
||||
if not doc_number:
|
||||
return True, '', None # Document number may be optional if pending registration
|
||||
|
||||
cleaned = str(doc_number).strip().upper()
|
||||
# Si es DNI, LC, LE, CI podemos remover puntos y guiones intermedios para validar y almacenar de forma uniforme
|
||||
if spec['code'] in ['DNI', 'LC', 'LE', 'CI', 'DNIEXT']:
|
||||
numeric_only = re.sub(r'[\.\-\s]', '', cleaned)
|
||||
if re.match(spec['regex'], numeric_only):
|
||||
return True, numeric_only, None
|
||||
return False, cleaned, f"Formato inválido para {spec['name']}. Debe contener {spec['placeholder']}."
|
||||
|
||||
elif spec['code'] == 'CUIL':
|
||||
numeric_only = re.sub(r'[\.\-\s]', '', cleaned)
|
||||
if re.match(r'^\d{11}$', numeric_only):
|
||||
return True, numeric_only, None
|
||||
return False, cleaned, "El CUIL debe contener 11 dígitos numéricos (ej. 20-38123456-7)."
|
||||
|
||||
else:
|
||||
# Pasaportes o Cédulas Extranjeras
|
||||
if re.match(spec['regex'], cleaned):
|
||||
return True, cleaned, None
|
||||
return False, cleaned, f"Formato inválido para {spec['name']} ({spec['placeholder']})."
|
||||
|
||||
def format_document(doc_type, doc_number):
|
||||
"""
|
||||
Formatea visualmente un número de documento con puntos/guiones estándar.
|
||||
"""
|
||||
if not doc_number:
|
||||
return ''
|
||||
raw = str(doc_number).strip().upper()
|
||||
dtype = (doc_type or 'DNI').upper()
|
||||
|
||||
if dtype in ['DNI', 'LC', 'LE', 'CI']:
|
||||
clean = re.sub(r'\D', '', raw)
|
||||
if len(clean) == 8:
|
||||
return f"{clean[:2]}.{clean[2:5]}.{clean[5:]}"
|
||||
elif len(clean) == 7:
|
||||
return f"{clean[:1]}.{clean[1:4]}.{clean[4:]}"
|
||||
return clean
|
||||
elif dtype == 'CUIL':
|
||||
clean = re.sub(r'\D', '', raw)
|
||||
if len(clean) == 11:
|
||||
return f"{clean[:2]}-{clean[2:10]}-{clean[10:]}"
|
||||
return clean
|
||||
return raw
|
||||
@@ -3,7 +3,7 @@ from .building import Building
|
||||
from .classroom import Classroom, ClassroomResource
|
||||
from .career import Career
|
||||
from .academic_term import AcademicTerm
|
||||
from .subject import Subject, Commission
|
||||
from .subject import Subject, Commission, CommissionTeacher
|
||||
from .reservation import Reservation, ReservationStatus
|
||||
from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer
|
||||
from .role import Role, Permission, SYSTEM_MODULES
|
||||
@@ -21,6 +21,7 @@ __all__ = [
|
||||
'AcademicTerm',
|
||||
'Subject',
|
||||
'Commission',
|
||||
'CommissionTeacher',
|
||||
'Reservation',
|
||||
'ReservationStatus',
|
||||
'GeneticAlgorithm',
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -12,6 +12,7 @@ class Subject(db.Model):
|
||||
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)
|
||||
|
||||
@@ -30,6 +31,7 @@ class Subject(db.Model):
|
||||
'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
|
||||
}
|
||||
@@ -73,6 +75,35 @@ class Commission(db.Model):
|
||||
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'),)
|
||||
|
||||
@@ -84,7 +115,10 @@ class Commission(db.Model):
|
||||
'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,
|
||||
@@ -116,3 +150,36 @@ class Commission(db.Model):
|
||||
|
||||
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}>'␍
|
||||
@@ -17,16 +17,30 @@ 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()
|
||||
|
||||
+305
-24
@@ -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': {
|
||||
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'},
|
||||
'last_login': getattr(u, 'last_login', None)
|
||||
})
|
||||
} 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
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ class ReservationBaseDTO(BaseModel):
|
||||
return self
|
||||
|
||||
class ReservationCreateDTO(ReservationBaseDTO):
|
||||
pass
|
||||
allow_co_teaching_exception: Optional[bool] = Field(default=False, description="Excepción justificada si hay co-docencia")
|
||||
|
||||
class ReservationUpdateDTO(BaseModel):
|
||||
classroom_id: Optional[int] = None
|
||||
@@ -34,6 +34,7 @@ class ReservationUpdateDTO(BaseModel):
|
||||
virtual_link: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
allow_co_teaching_exception: Optional[bool] = False
|
||||
|
||||
class ReservationResponseDTO(ReservationBaseDTO):
|
||||
id: int
|
||||
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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!")
|
||||
@@ -0,0 +1,4 @@
|
||||
bandit>=1.7.5
|
||||
pip-audit>=2.7.2
|
||||
schemathesis>=3.28.6
|
||||
njsscan>=0.3.5
|
||||
@@ -1,4 +1,4 @@
|
||||
Flask==3.0.0
|
||||
Flask>=3.1.3
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
Flask-Login==0.6.3
|
||||
Flask-WTF==1.2.1
|
||||
@@ -6,11 +6,11 @@ Flask-Migrate==4.0.5
|
||||
Flask-CORS==6.0.2
|
||||
Flask-Babel==4.0.0
|
||||
WTForms==3.1.0
|
||||
Werkzeug==3.0.1
|
||||
Werkzeug>=3.1.6
|
||||
SQLAlchemy>=2.0.32
|
||||
python-dotenv==1.0.0
|
||||
requests==2.31.0
|
||||
bleach==6.1.0
|
||||
python-dotenv>=1.2.2
|
||||
requests>=2.32.4
|
||||
bleach>=6.4.0
|
||||
python-dateutil==2.8.2
|
||||
Pillow>=10.4.0
|
||||
email_validator>=2.0.0
|
||||
|
||||
@@ -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()
|
||||
Generated
+42
@@ -15,6 +15,8 @@
|
||||
"cookie-parser": "^1.4.7",
|
||||
"dotenv": "^18.0.0",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.7.0",
|
||||
"helmet": "^8.3.0",
|
||||
"nunjucks": "^3.2.4"
|
||||
}
|
||||
},
|
||||
@@ -480,6 +482,25 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.7.0",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz",
|
||||
"integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
"ip-address": "^10.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/cookie-signature": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
@@ -720,6 +741,18 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
|
||||
"integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/EvanHahn"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -775,6 +808,15 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.7.2",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
|
||||
"integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"cookie-parser": "^1.4.7",
|
||||
"dotenv": "^18.0.0",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.7.0",
|
||||
"helmet": "^8.3.0",
|
||||
"nunjucks": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
{% 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 }}</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">
|
||||
<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">Cambiar / Asignar Docente</label>
|
||||
<select name="teacher_id" class="form-select form-select-sm" id="teacher-select">
|
||||
<option value="">— Sin asignar —</option>
|
||||
<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 }}" {% if commission.teacher_id == teacher.id %}selected{% endif %}>
|
||||
{{ teacher.name }}
|
||||
</option>
|
||||
<option value="{{ teacher.id }}">{{ 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
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
<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="mb-3">
|
||||
<label for="email" class="form-label fw-semibold">Email Address <span class="text-danger">*</span></label>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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="e.g. mgonzalez@edu-space.com">
|
||||
placeholder="usuario@edu-space.com">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label fw-semibold">
|
||||
Password {% if not user %}<span class="text-danger">*</span>{% endif %}
|
||||
<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 %}Leave empty to keep current password{% else %}At least 6 characters{% 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">
|
||||
Only fill this in if you want to reset this user's password.
|
||||
Solo complete este campo si desea restablecer la contraseña del usuario.
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="role_id" class="form-label fw-semibold">Role / Access Group <span class="text-danger">*</span></label>
|
||||
<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 %}>Select a role...</option>
|
||||
<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 }} - {{ r.description or '' }}
|
||||
{{ r.name }} {% if r.description %}— {{ r.description }}{% endif %}
|
||||
</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>
|
||||
Define los permisos para Bedelía, Profesores/Docentes, Alumnos o Administradores.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 form-check form-switch">
|
||||
<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 %}
|
||||
{% if user and user.id == user.id %}disabled{% endif %}>
|
||||
<label class="form-check-label fw-semibold" for="is_active">
|
||||
Active Account
|
||||
{% 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">
|
||||
Inactive users are prevented from signing in to the platform.
|
||||
Permite iniciar sesión en el sistema.
|
||||
</small>
|
||||
{% if user and user.id == user.id %}
|
||||
<input type="hidden" name="is_active" value="on">
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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 %}
|
||||
|
||||
@@ -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">«</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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user