feat: completar fases 3 y 4 del roadmap mvp (gradebook, drag-and-drop, impersonacion) y actualizar README

This commit is contained in:
2026-09-23 12:10:31 -03:00
parent 4ebc5df4fd
commit 7ad4e3f099
38 changed files with 3804 additions and 390 deletions
+72 -4
View File
@@ -6,10 +6,78 @@ El formato está basado en [Keep a Changelog](https://keepachangelog.com/es-ES/1
---
## [Unreleased]
### Planificado (Fase 3 del Roadmap MVP)
- **Calendario de Evaluación por Comisión:** Programación de parciales, recuperatorios, entregas de trabajos prácticos y exámenes finales con cuenta regresiva.
- **Libro de Calificaciones (*Gradebook*):** Matriz de carga rápida de notas para docentes, consulta pedagógica individual para alumnos y cierre de actas para Bedelía.
- **Grilla Semanal Drag & Drop (Fase 4):** Asignación visual interactiva para la oficina de Bedelía.
### Planificado (Fase 5 del Roadmap MVP)
- **Despliegue e Infraestructura (Fase 5):** `docker-compose.yml` productivo orquestando Flask (Gunicorn), Node.js BFF (PM2) y PostgreSQL 15, con proxy inverso Nginx, SSL/TLS y compresión.
- **Integraciones Universitarias (Fase 5):** Módulo de exportación/importación compatible con SIU Guaraní 3 y sincronización con Moodle.
- **Pruebas de Carga y Hardening (Fase 5):** Pruebas de estrés de concurrencia y validación final de producción.
---
## [2.8.0] - 2026-09-23
### Añadido
- **Fase 4 del Roadmap MVP — UI/UX Avanzada para Bedelía & Modo Impersonación:**
- **Estadio 4.1 — Grilla Semanal Interactiva (Drag-and-Drop) con Detección de Colisiones:**
- Activación de arrastre y edición interactiva en FullCalendar (`editable: true`, `droppable: true`, `eventDurationEditable: true`).
- Panel lateral integrado de comisiones sin aula asignada (`unassigned_commissions`) con arrastre fluido hacia la grilla semanal o diaria.
- Endpoint REST `POST /api/v1/reservations/drag-update` con validación previa de la matriz integral de conflictos (`ReservationService.check_full_conflicts_matrix`): colisión física de aula, colisión de horario docente y aforo de estudiantes.
- Si existe colisión, retorna HTTP 409 (`status: conflict`), revierte automáticamente la posición del evento en la grilla (`info.revert()`) y notifica al usuario con un Toast detallado del motivo del choque.
- Si el espacio está disponible, actualiza o crea la reserva en base de datos, asienta la acción en `AuditLog` y muestra confirmación visual de éxito.
- **Estadio 4.2 — Modo Impersonación del Superadmin:**
- Soporte completo de cabecera `X-Impersonate-User` en el decorador JWT del Backend Flask (`jwt_required`) para administradores.
- Endpoints administrativos `POST /api/v1/admin/impersonate` y `POST /api/v1/admin/stop-impersonating` con registro estricto en `AuditLog` bajo las acciones `IMPERSONATE_START` e `IMPERSONATE_END`.
- Inyección contextual de impersonación en el Frontend BFF (`authMiddleware` y proxy transparente `/api`) mediante cookies seguras `impersonate_user_id` e `impersonate_user_data`.
- Banner de advertencia visual permanente en la barra superior (`base.html`) indicando el usuario y rol impersonado, con botón de salida inmediata hacia la sesión del administrador.
- Botón de acceso directo para impersonar (`bi-incognito`) en cada fila de usuario activo en `/admin/users_list`.
- **Estadio 4.3 — Acople del Optimizador Genético a Datos y Turnos Reales:**
- Algoritmo genético (`ReservationOptimizer`) adaptado a las franjas horarias y turnos institucionales reales de UniCABA (`Mañana` 08:00-12:00, `Tarde` 14:00-18:00, `Vespertino`/`Noche` 18:30-22:30).
- Soporte de guardado masivo en base de datos (`POST /api/genetic/apply-optimization` y `apply_optimized_reservations`) con prevención de solapamientos físicos y conversión estricta de fechas ISO.
- **Suite de Pruebas Unitarias de Fase 4:**
- Archivo `backend/tests/test_phase4_interactive_impersonation.py` con 6 tests unitarios cubriendo los criterios de aceptación: impersonación como docente y estudiante, trazabilidad en auditoría, rechazo de impersonación para usuarios no administradores, finalización de impersonación, drag-update con bloqueo por colisión 409 y drag-update exitoso 200.
- Regresión global de 18 tests (Fases 2, 3 y 4) aprobada al 100% (18/18 OK).
---
## [2.7.0] - 2026-09-23
### Añadido
- **Fase 3 del Roadmap MVP — Gestión de Hitos Evaluativos y Libro de Calificaciones (*Gradebook*):**
- **Estadio 3.1 — Calendario de Evaluación y Notificaciones Preventivas:**
- Modelo relacional `MilestoneGrade` (`milestone_grades`) asociado a estudiantes (`User`) y reservas de tipo hito (`Reservation`).
- Servicio `GradebookService.get_student_upcoming_milestones`: consulta inteligente de exámenes parciales, recuperatorios y entregas de TP de las comisiones en las que el alumno está matriculado.
- Endpoint REST `GET /api/v1/students/my-upcoming-milestones` con cálculo de días restantes (`days_remaining`).
- Cartelera y Widget de Próximos Exámenes en el Dashboard del Alumno (`/dashboard?role=alumno`) con badges dinámicos de cuenta regresiva (*¡Hoy!*, *¡Mañana!*, *Faltan X días*).
- **Estadio 3.2 — Libro de Calificaciones (*Gradebook*) y Cierre de Actas:**
- Extensiones al modelo `Commission`: campos `grades_closed` (booleano), `grades_closed_at` (timestamp), `grades_closed_by` (ID usuario) y `acta_number` (código oficial de acta).
- Servicio académico `GradebookService` (`backend/app/services/gradebook_service.py`):
- `get_commission_gradebook`: matriz bidimensional consolidada de alumnos inscriptos y notas por hito evaluativo.
- `save_single_grade` y `bulk_save_grades`: registro y actualización de calificaciones con validación estricta de escala (1.0 a 10.0), flag de ausente (`is_absent`) y soporte de retroalimentación pedagógica (`feedback`).
- Bloqueo por inmutabilidad: rechazo de modificaciones cuando el acta se encuentra cerrada.
- `close_gradebook`: sellado oficial del acta con generación de identificador inmutable (`ACTA-YYYY-Sem-ID`), registro de auditoría en `AuditLog` y congelamiento de calificaciones.
- `reopen_gradebook`: reapertura excepcional para Bedelía/Admin con justificación administrativa obligatoria y registro en auditoría.
- `get_student_grades`: consolidado para el alumno de todas sus materias cursadas, notas por evaluación, promedio general y condición académica final (*Promocionado*, *Regular*, *Libre*).
- Endpoints REST de Calificaciones:
- `GET /api/v1/commissions/<id>/gradebook`: estructura completa para la matriz del libro de calificaciones.
- `PUT /api/v1/commissions/<id>/gradebook/grades`: guardado masivo o individual de notas vía JSON.
- `POST /api/v1/commissions/<id>/gradebook/close`: cierre formal de acta.
- `POST /api/v1/commissions/<id>/gradebook/reopen`: reapertura justificada de acta.
- `GET /api/v1/students/my-grades`: consulta individual de notas y regularidad del estudiante.
- Interfaz de Usuario y Vistas Frontend BFF:
- Vista `/schedule/gradebook/:id`: matriz interactiva con cálculo de promedio en tiempo real en el DOM, asignación de condición en vivo, autoguardado asíncrono debounced (500ms) con indicador flotante de estado (*Guardando...*, *✓ Todos los cambios guardados*), filtro de búsqueda instantánea por alumno/legajo, modal de cierre formal de acta y modal de reapertura.
- Vista `/schedule/my_grades` (`/mis-materias/mis-notas`): portal del estudiante para consultar calificaciones por hito, promedios finales, condición de cursada y actas emitidas, con soporte de impresión de certificado de cursada.
- Acceso directo integrado al Libro de Calificaciones en la tarjeta de comisión de `/admin/commission_detail`.
- Acceso directo a "Mis Calificaciones" en el launchpad de navegación del alumno en `/dashboard`.
- Script de migración de base de datos SQLite `backend/migrate_phase3_fields.py`.
- **Suite de Pruebas Unitarias de Fase 3:**
- Archivo `backend/tests/test_phase3_gradebook.py` con 6 tests unitarios cubriendo los criterios de aceptación de Fase 3: estructura de matriz, autoguardado de notas, validaciones de rango numérico, cierre formal de acta e inmutabilidad, consulta individual de notas y cuenta regresiva de próximos exámenes. 100% de tests aprobados (12/12 en suite global Fase 2 + Fase 3).
---
## [2.6.1] - 2026-09-23
### Modificado
- **Optimización de Espacio Vertical y Compatibilidad de Pantallas (720p / 1360x768 y 1080p):**
- **Navbar Compacta:** Reducción de altura a 46px eliminando desperdicio de espacio superior para maximizar el área visible de trabajo de Bedelía.
- **Botón de Toggle de Sidebar:** Reingeniería visual del botón de alternancia del menú lateral con diseño circular limpio, micro-animación en hover y centrado perfecto.
- **Soporte Multipantalla:** Media queries CSS especializadas para resoluciones compactas habituales en puestos de trabajo de administración y bedelía argentina (1360x768 / 1366x768) y escalado equilibrado a 1920x1080 Full HD.
- **Densidad de Información:** Espaciados y márgenes optimizados para evitar scrolling innecesario en formularios y catálogos de comisiones y aulas.
---
+51 -1
View File
@@ -1,6 +1,56 @@
# CHANGELOG MVP — Admin Edu-Space (UniCABA)
Registro de hitos y versiones correspondientes a las Fases del [ROADMAP_MVP.md](./ROADMAP_MVP.md).
> **Nota de Unificación:** Este archivo documenta la progresión de los hitos y entregables de las Fases del [ROADMAP_MVP.md](./ROADMAP_MVP.md). Para el historial técnico exhaustivo conforme a *Keep a Changelog* y *Semantic Versioning*, consultar el archivo oficial unificado: **[CHANGELOG.md](./CHANGELOG.md)**.
---
## [Fase 4: UI/UX Avanzada para Bedelía & Modo Impersonación] — v2.8.0 (2026-09-23)
**Estado:** ✅ 100% Completada
### Estadio 4.1 — Grilla Semanal Interactiva (Drag-and-Drop) con Detección de Colisiones
* **FullCalendar Interactivo:** Habilitación de `editable: true` y `droppable: true` en el calendario general de Bedelía (`/schedule/calendar`).
* **Sidebar de Comisiones Pendientes:** Panel lateral dinámico con catálogo de comisiones que aún no tienen aula asignada (`unassigned_commissions`), permitiendo arrastrarlas directamente hacia los bloques horarios del calendario.
* **Validación de Choques en Tiempo Real:** Integración con `POST /api/v1/reservations/drag-update`. Al mover o soltar una clase, el backend somete la nueva posición a la matriz integral de conflictos (`ReservationService.check_full_conflicts_matrix`):
- Conflicto físico de aula (doble asignación simultánea).
- Conflicto docente (profesor ocupado en otra aula).
- Conflicto de aforo (capacidad de aula insuficiente).
* **Manejo de Respuestas:** Si hay colisión, devuelve HTTP 409 (`status: conflict`), FullCalendar revierte automáticamente el movimiento (`info.revert()`) y se dispara un Toast de alerta detallando el motivo del bloqueo. Si está libre, persiste la reserva y confirma con Toast de éxito.
### Estadio 4.2 — Modo Impersonación del Superadmin con Trazabilidad
* **Cabecera `X-Impersonate-User`:** Inyección y soporte en `backend/app/utils/jwt_decorators.py` dentro de `jwt_required` para usuarios administradores. Asigna `g.jwt_user` al usuario objetivo preservando `g.real_admin_user` y marcando `g.is_impersonating = True`.
* **Endpoints de Control:** `POST /api/v1/admin/impersonate` y `POST /api/v1/admin/stop-impersonating` en Backend Flask con auditoría inmutable en `AuditLog` (`IMPERSONATE_START` e `IMPERSONATE_END`).
* **Integración en BFF Node.js:** Middleware `authMiddleware.js` y proxy `/api` en `app.js` configurados para propagar cookies seguras `impersonate_user_id` e inyectar `X-Impersonate-User` en todas las peticiones a la API Flask.
* **Banner Visual Institucional:** Banner de advertencia permanente en `base.html` con fondo de alerta ámbar, spinner de estado, identificación del usuario y rol adoptado, y botón de salida inmediata.
* **Acceso de un Clic:** Botón directo de impersonación (`bi-incognito`) en cada fila de usuario activo en `/admin/users_list`.
### Estadio 4.3 — Acople del Optimizador Genético a Datos y Turnos Reales
* **Turnos Académicos Reales:** `ReservationOptimizer._calculate_preferred_time` sincronizado con las franjas horarias de UniCABA (`Mañana`: 08:00 a 12:00, `Tarde`: 14:00 a 18:00, `Vespertino`/`Noche`: 18:30 a 22:30).
* **Persistencia Segura:** Endpoint `POST /api/genetic/apply-optimization` adaptado con parseo riguroso de strings ISO y prevención de colisiones físicas en guardado masivo.
### Cobertura de Pruebas
* Suite completa en `backend/tests/test_phase4_interactive_impersonation.py` validando los 6 criterios de aceptación (impersonación de docente, impersonación de alumno, trazabilidad en auditoría, rechazo a no-admins, bloqueo 409 por colisión en drag-drop y actualización exitosa 200). 18/18 tests aprobados en suite global de Fases 2, 3 y 4.
---
## [Fase 3: Gestión de Hitos Evaluativos y Libro de Calificaciones] — v2.7.0 (2026-09-23)
**Estado:** ✅ 100% Completada
### Estadio 3.1 — Calendario de Evaluación por Comisión y Notificaciones Preventivas
* **Modelo `MilestoneGrade`:** Tabla relacional `milestone_grades` para registrar calificaciones por estudiante y reserva evaluativa, con validación de unicidad (`student_id`, `reservation_id`), notas en escala 1.0 a 10.0, estado de inasistencia (`is_absent`) y devoluciones pedagógicas (`feedback`).
* **Consulta de Hitos del Alumno:** Endpoint `GET /api/v1/students/my-upcoming-milestones` que detecta dinámicamente exámenes parciales, recuperatorios y entregas de TP de las comisiones del estudiante con cálculo automático de días restantes (`days_remaining`).
* **Dashboard del Alumno:** Widget interactivo de próximos exámenes en `/dashboard?role=alumno` con badges visuales de urgencia (*¡Hoy!*, *¡Mañana!*, *Faltan X días*) y acceso directo desde el Launchpad a *"Mis Calificaciones"*.
### Estadio 3.2 — Libro de Calificaciones (*Gradebook*) y Cierre Formal de Actas
* **Extensiones en `Commission`:** Incorporación de columnas `grades_closed` (boolean), `grades_closed_at` (datetime), `grades_closed_by` (foreign key) y `acta_number` (identificador oficial generado).
* **Servicio `GradebookService`:** Motor centralizado para matriz de notas por comisión, guardado asíncrono individual y por lote (`bulk_save_grades`), cálculo dinámico de promedios ponderados y condiciones académicas en tiempo real (*Promocionado* ≥ 7, *Regular* ≥ 4, *Libre* < 4).
* **Cierre Formal de Actas:** Sellado institucional con código único (`ACTA-YYYY-Sem-ID`), auditoría inmutable en `audit_logs` con acción `CLOSE_ACTA` y bloqueo total contra modificaciones posteriores.
* **Reapertura Excepcional:** Endpoint para Bedelía/Administración (`POST /api/v1/commissions/<id>/gradebook/reopen`) con justificación administrativa obligatoria asentada en auditoría.
* **Interfaz Docente & Bedelía (`/schedule/gradebook/:id`):** Matriz interactiva de carga rápida con autoguardado asíncrono debounced (500ms), indicador visual flotante de sincronización, recálculo instantáneo de promedios en el cliente y modales de sellado y reapertura de actas.
* **Portal del Alumno (`/schedule/my_grades` / `/mis-materias/mis-notas`):** Vista consolidada de todas las materias del estudiante, notas de parciales, condición final y número de acta emitida, con soporte de impresión de certificado de regularidad.
* **Acceso Directo en Comisión:** Botón destacado *"Libro de Calificaciones (Actas)"* y badge de acta sellada incorporados en `/admin/commission_detail`.
### Cobertura de Pruebas
* Suite completa en `backend/tests/test_phase3_gradebook.py` validando los 6 criterios de aceptación: estructura de matriz bidimensional, persistencia y autoguardado de notas, validación estricta de notas inválidas (fuera del rango 1-10), cierre formal de acta e inmutabilidad estricta ante intentos de edición, consulta integral del alumno y cuenta regresiva de próximos hitos. 6/6 tests aprobados (12/12 en conjunto con Fase 2).
---
+59 -4
View File
@@ -1,9 +1,25 @@
# Panel de Administración Edu-Space (UniCABA)
Plataforma integral de gestión áulica, asignación de espacios, control de horarios y reservas académicas para la **Universidad de la Ciudad de Buenos Aires (UniCABA)**.
Plataforma integral de gestión áulica, asignación de espacios, control de horarios, seguimiento académico y reservas para la **Universidad de la Ciudad de Buenos Aires (UniCABA)**.
El proyecto se basa en una arquitectura **desacoplada (Frontend / Backend)** en formato monorepo.
**Versión Actual:** `2.8.0` (Fases 0 a 4 del Roadmap MVP 100% completadas)
**Institución:** Universidad de la Ciudad de Buenos Aires (UniCABA)
---
## 🗺️ Estado del Roadmap MVP
```
Fase 0: Desacople y Arquitectura Base [████████████████████] 100% (Completado)
Fase 1: Paridad Legacy y Estabilización [████████████████████] 100% (Completado)
Fase 2: Co-docencia y Matriz Conflictos [████████████████████] 100% (Completado)
Fase 3: Hitos Evaluativos y Calificaciones[████████████████████] 100% (Completado)
Fase 4: UI/UX Drag & Drop e Impersonación[████████████████████] 100% (Completado)
Fase 5: Despliegue Producción e Integrac.[░░░░░░░░░░░░░░░░░░░░] 0% (Planificada)
```
---
## 🏛️ Sede Institucional y Ubicaciones
@@ -71,6 +87,33 @@ El sistema incluye sincronización automatizada con las planillas oficiales de c
---
## ⚡ Capacidades Avanzadas de Gestión (Fases 2, 3 y 4 del MVP)
### 1. Grilla Semanal Interactiva (Drag & Drop) y Validación de Colisiones en Tiempo Real
- **FullCalendar Interactivo:** Arrastre y edición de horarios directo en el calendario general de Bedelía (`/schedule/calendar`).
- **Sidebar de Comisiones por Asignar:** Panel lateral colapsable con las comisiones sin aula, con arrastre fluido hacia la grilla semanal o diaria.
- **Detección Automática de Choques (HTTP 409):** Validación previa ante cada movimiento (`POST /api/v1/reservations/drag-update`) verificando colisiones físicas de aula, superposición docente y límite de aforo. Si hay conflicto, el evento se revierte automáticamente (`info.revert()`) y se notifica al usuario con un Toast explicativo.
### 2. Modo Impersonación del Superadmin ("Login as")
- Permite al Administrador navegar la plataforma adoptando exactamente la perspectiva y permisos de cualquier docente o estudiante.
- Activación con un clic (`bi-incognito`) desde el catálogo de usuarios (`/admin/users_list`).
- Banner superior permanente de advertencia visual en color ámbar con identificación del usuario adoptado y botón de salida inmediata.
- Trazabilidad y auditoría obligatoria inmutable en `audit_logs` (`IMPERSONATE_START` e `IMPERSONATE_END`).
### 3. Libro de Calificaciones (*Gradebook*) & Cierre Formal de Actas
- Matriz bidimensional interactiva de calificaciones por comisión (`/schedule/gradebook/:id`) con autoguardado asíncrono debounced (500 ms).
- Cálculo en vivo de promedios ponderados y condiciones académicas (*Promocionado*, *Regular*, *Libre*).
- Cierre formal de actas con generación de código inmutable (`ACTA-YYYY-Sem-ID`), auditoría en `audit_logs` y congelamiento de modificaciones.
- Soporte para reapertura excepcional justificada por Bedelía.
- Portal del Estudiante (`/schedule/my_grades` / `/mis-materias/mis-notas`) para consulta de calificaciones y comprobantes.
### 4. Co-docencia, Matriz de Conflictos y Regla Diaria
- Soporte de cátedras con múltiples docentes por comisión (`CommissionTeacher`).
- Excepción por co-docencia: autoriza superposiciones de horario docente si la cátedra cuenta con un co-docente registrado disponible.
- Regla restrictiva de cursada diaria: un alumno no puede cursar dos asignaturas regulares el mismo día, con excepciones automáticas para talleres/cursos cortos (`is_short_course`) o autorizaciones especiales emitidas por Bedelía.
---
## 📅 Ficha de Reserva & Gestión de Comisiones
* **Ficha de Reserva Detallada (`/schedule/view_reservation?id={id}`):** Información completa del espacio físico o virtual asignado, capacidad y asistencia prevista, comisión académica vinculada, docente a cargo, botones de acción inmediata (*Modificar Reserva*, *Confirmar Reserva*, *Cancelar Reserva*) y acceso con un clic a la sala de videollamada.
@@ -86,7 +129,7 @@ El sistema incluye sincronización automatizada con las planillas oficiales de c
### Requisitos previos
- Node.js (v18 o superior)
- Python 3.10+
- PostgreSQL / SQLite
- SQLite / PostgreSQL
### 1. Iniciar el Backend (Flask)
```bash
@@ -110,10 +153,22 @@ node src/app.js
---
## 🧪 Pruebas Automatizadas
Para ejecutar la suite completa de pruebas unitarias de regresión (Fases 2, 3 y 4):
```bash
cd backend
.\venv\Scripts\python.exe -m unittest tests/test_phase2_rules.py tests/test_phase3_gradebook.py tests/test_phase4_interactive_impersonation.py
```
---
## 📚 Documentación Técnica & Changelogs
* [Estado de la Migración y Arquitectura Objetivo UniCABA](ESTADO_MIGRACION_Y_ARQUITECTURA_OBJETIVO.md)
* [Roadmap MVP: Fases y Estadios del Proyecto](ROADMAP_MVP.md)
* [Changelog Oficial del Proyecto](CHANGELOG.md)
* [Changelog Oficial del Proyecto (v2.8.0)](CHANGELOG.md)
* [Changelog MVP Consolidado](CHANGELOG_MVP.md)
* [Estado de la Migración y Arquitectura Objetivo UniCABA](ESTADO_MIGRACION_Y_ARQUITECTURA_OBJETIVO.md)
* [Changelog Detallado: Clases Virtuales y Dashboards](docs/CHANGELOG_VIRTUAL_CLASSES_AND_DASHBOARDS.md)
* [Auditoría y Sesión de Reingeniería](docs/CHANGELOG_SESSION.md)
* [Guía de Integración de Cronograma](docs/SCHEDULE_INTEGRATION_GUIDE.md)
+20 -16
View File
@@ -2,8 +2,8 @@
## Gestión de Espacios Áulicos, Actividad de Cursada y Seguimiento Académico
**Institución:** Universidad de la Ciudad de Buenos Aires (UniCABA)
**Versión:** 2.6.0
**Fecha de Actualización:** 19 de Septiembre de 2026
**Versión:** 2.8.0
**Fecha de Actualización:** 23 de Septiembre de 2026
**Rama Base:** `testing`
---
@@ -19,8 +19,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 [████████████████████] 100% (Completado)
Fase 3: Hitos Evaluativos y Calificaciones[░░░░░░░░░░░░░░░░░░░░] 0% (Próxima)
Fase 4: UI/UX Drag & Drop e Impersonación[░░░░░░░░░░░░░░░░░░░░] 0% (Planificada)
Fase 3: Hitos Evaluativos y Calificaciones[████████████████████] 100% (Completado)
Fase 4: UI/UX Drag & Drop e Impersonación[████████████████████] 100% (Completado)
Fase 5: Despliegue Producción e Integrac.[░░░░░░░░░░░░░░░░░░░░] 0% (Planificada)
```
@@ -83,30 +83,34 @@ Fase 5: Despliegue Producción e Integrac.[░░░░░░░░░░░░
---
### Fase 3: Gestión de Hitos Evaluativos y Libro de Calificaciones 📅 *(Planificada)*
### Fase 3: Gestión de Hitos Evaluativos y Libro de Calificaciones 📅 *(Completada)*
* **Objetivo:** Proveer al cuerpo docente y estudiantil el seguimiento completo de instancias evaluativas.
* **Estadios:**
1. **Estadio 3.1 — Calendario de Evaluación por Comisión:**
- [ ] Programación de parciales, recuperatorios, entregas de trabajos prácticos y exámenes finales.
- [ ] Notificaciones preventivas en el dashboard del alumno con cuenta regresiva para exámenes.
- [x] Programación de parciales, recuperatorios, entregas de trabajos prácticos y exámenes finales.
- [x] Notificaciones preventivas en el dashboard del alumno con cuenta regresiva para exámenes.
2. **Estadio 3.2 — Libro de Calificaciones (*Gradebook*):**
- [ ] Matriz de carga rápida de notas para docentes con autoguardado asíncrono.
- [ ] Consulta individualizada de calificaciones y retroalimentaciones pedagógicas para alumnos.
- [ ] Cierre de actas de regularidad y promoción para Bedelía.
- [x] Matriz de carga rápida de notas para docentes con autoguardado asíncrono.
- [x] Consulta individualizada de calificaciones y retroalimentaciones pedagógicas para alumnos.
- [x] Cierre de actas de regularidad y promoción para Bedelía.
---
### Fase 4: UI/UX Avanzada para Bedelía & Modo Impersonación 🖥️ *(Planificada)*
### Fase 4: UI/UX Avanzada para Bedelía & Modo Impersonación 🖥️ ✅ *(100% Completado)*
* **Objetivo:** Dotar a la oficina de Bedelía y a la Administración de herramientas interactivas de última generación.
* **Estadios:**
1. **Estadio 4.1 — Grilla Semanal Interactiva (Drag-and-Drop):**
- [ ] Interfaz tipo TimeGrid interactiva con arrastre de comisiones para asignación rápida de aulas y turnos.
- [ ] Detección visual en tiempo real de aulas ocupadas o incompatibles.
- [x] Interfaz tipo TimeGrid interactiva con arrastre de comisiones para asignación rápida de aulas y turnos (`FullCalendar` `editable: true` + `droppable: true`).
- [x] Panel lateral de comisiones pendientes de aula (`unassigned_commissions`) con arrastre directo hacia la grilla.
- [x] Detección visual en tiempo real de aulas ocupadas, colisiones horarias, aforo insuficiente o choques de docentes con reversión automática y mensajes de alerta descriptivos vía `POST /api/v1/reservations/drag-update`.
2. **Estadio 4.2 — Modo Impersonación del Superadmin:**
- [ ] Funcionalidad de *login as* o vista previa con cabecera `X-Impersonate-User` para replicar exactamente lo que ve cualquier docente o alumno.
- [ ] Trazabilidad obligatoria de acciones bajo impersonación en el registro de auditoría (`audit_logs`).
- [x] Funcionalidad de *login as* con soporte para cabecera `X-Impersonate-User` en Backend Flask (`jwt_required`) y Frontend BFF (`authMiddleware`).
- [x] Botón directo de impersonación en la lista administrativa de usuarios (`admin/users/list.html`).
- [x] Banner de advertencia visual permanente en la barra superior con indicador de usuario impersonado y botón de salida inmediata hacia la sesión administrativa.
- [x] Trazabilidad obligatoria y estricta en el registro de auditoría (`audit_logs`) con eventos `IMPERSONATE_START` e `IMPERSONATE_END`.
3. **Estadio 4.3 — Integración del Optimizador Genético:**
- [ ] Acople del motor predictivo de algoritmos genéticos a la base de datos real para sugerir redistribución óptima de aulas según aforo.
- [x] Acople del motor heurístico de algoritmos genéticos a la base de datos real con cálculo de turnos reales (Mañana, Tarde, Vespertino).
- [x] Prevención de choques en guardado masivo con persistencia directa en base de datos (`POST /api/genetic/apply-optimization`).
---
+2
View File
@@ -30,6 +30,7 @@ def create_app(config_class=Config):
from app.routes.api.dashboard import api_dashboard_bp
from app.routes.api.admin import api_admin_bp
from app.routes.api.openapi import api_openapi_bp
from app.routes.api.students import api_students_bp
from app.routes.genetic_algorithm import genetic_bp
from app.security import SecurityFilterChain
@@ -41,6 +42,7 @@ def create_app(config_class=Config):
app.register_blueprint(api_dashboard_bp)
app.register_blueprint(api_openapi_bp)
app.register_blueprint(genetic_bp)
app.register_blueprint(api_students_bp, url_prefix='/api/v1')
app.register_blueprint(api_admin_bp, url_prefix='/api/v1')
app.register_blueprint(api_admin_bp, url_prefix='/api/v1/admin', name='api_admin_prefixed')
+3 -1
View File
@@ -11,6 +11,7 @@ from .milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES
from .audit_log import AuditLog
from .enrollment import StudentEnrollment
from .setting import SystemSetting
from .grade import MilestoneGrade
__all__ = [
'User',
@@ -34,5 +35,6 @@ __all__ = [
'BASE_MILESTONE_TYPES',
'AuditLog',
'StudentEnrollment',
'SystemSetting'
'SystemSetting',
'MilestoneGrade'
]
+50 -12
View File
@@ -390,28 +390,61 @@ class ReservationOptimizer:
}
def _calculate_preferred_time(self, commission: Commission) -> Dict:
"""Calculate preferred time slots for a commission"""
# This is a simplified version - would need to be based on actual semester schedule
"""Calculate preferred time slots for a commission based on shift and schedule"""
current_date = datetime.utcnow()
# Default to weekday mornings for regular classes
days_ahead = (0 - current_date.weekday()) % 7 # Next Monday
if days_ahead == 0: # If today is Monday, use next week
# Determine day of week from commission schedule or default to Monday
target_weekday = 0 # Monday
sched = (commission.schedule or '').lower()
if 'martes' in sched:
target_weekday = 1
elif 'miercoles' in sched or 'miércoles' in sched:
target_weekday = 2
elif 'jueves' in sched:
target_weekday = 3
elif 'viernes' in sched:
target_weekday = 4
elif 'sabado' in sched or 'sábado' in sched:
target_weekday = 5
days_ahead = (target_weekday - current_date.weekday()) % 7
if days_ahead == 0:
days_ahead = 7
preferred_date = current_date + timedelta(days=days_ahead)
# Determine hours based on shift
shift = (commission.shift or '').strip().lower()
if 'mañana' in shift or 'manana' in shift:
start_h, start_m = 8, 0
end_h, end_m = 12, 0
elif 'tarde' in shift:
start_h, start_m = 14, 0
end_h, end_m = 18, 0
elif 'noche' in shift or 'vespertino' in shift:
start_h, start_m = 18, 30
end_h, end_m = 22, 30
else:
start_h, start_m = 9, 0
end_h, end_m = 13, 0
return {
'start': preferred_date.replace(hour=9, minute=0, second=0, microsecond=0),
'end': preferred_date.replace(hour=11, minute=0, second=0, microsecond=0)
'start': preferred_date.replace(hour=start_h, minute=start_m, second=0, microsecond=0),
'end': preferred_date.replace(hour=end_h, minute=end_m, second=0, microsecond=0)
}
def apply_optimized_reservations(self, reservation_data: List[Dict]) -> List[Reservation]:
"""Apply the optimized reservations to the database"""
"""Apply the optimized reservations to the database with ISO string parsing and conflict avoidance"""
applied_reservations = []
for data in reservation_data:
# Check for conflicts before creating
for item in reservation_data:
data = dict(item)
if isinstance(data.get('start_time'), str):
data['start_time'] = datetime.fromisoformat(data['start_time'].replace('Z', '+00:00'))
if isinstance(data.get('end_time'), str):
data['end_time'] = datetime.fromisoformat(data['end_time'].replace('Z', '+00:00'))
# Check for physical room conflicts before creating
conflicts = Reservation.find_conflicts(
data['classroom_id'],
data['start_time'],
@@ -419,12 +452,17 @@ class ReservationOptimizer:
)
if not conflicts:
# Ensure status enum value compatibility
if 'status' in data and isinstance(data['status'], str):
try:
data['status'] = ReservationStatus(data['status']).value
except ValueError:
data['status'] = ReservationStatus.CONFIRMED.value
reservation = Reservation(**data)
db.session.add(reservation)
applied_reservations.append(reservation)
else:
# Log conflict - could be handled differently
print(f"Conflict detected for reservation data: {data}")
current_app.logger.warning(f"Conflict detected for reservation during genetic apply: {data}")
try:
db.session.commit()
+92
View File
@@ -0,0 +1,92 @@
from datetime import datetime
from app import db
class MilestoneGrade(db.Model):
"""
Calificación y seguimiento pedagógico individual de un alumno en un hito evaluativo (Fase 3 MVP).
Vincula un AcademicMilestone con un estudiante matriculado (StudentEnrollment / User).
"""
__tablename__ = 'milestone_grades'
id = db.Column(db.Integer, primary_key=True)
milestone_id = db.Column(
db.Integer,
db.ForeignKey('academic_milestones.id', ondelete='CASCADE'),
nullable=False,
index=True
)
enrollment_id = db.Column(
db.Integer,
db.ForeignKey('student_enrollments.id', ondelete='CASCADE'),
nullable=False,
index=True
)
student_id = db.Column(
db.Integer,
db.ForeignKey('users.id', ondelete='CASCADE'),
nullable=False,
index=True
)
score = db.Column(db.Float, nullable=True) # Calificación numérica de 1.0 a 10.0
is_absent = db.Column(db.Boolean, default=False) # Flag de Ausente
feedback = db.Column(db.Text, nullable=True) # Devolución formativa/pedagógica
graded_by_user_id = db.Column(
db.Integer,
db.ForeignKey('users.id', ondelete='SET NULL'),
nullable=True
)
graded_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Restricción de unicidad: un estudiante tiene una única nota por hito
__table_args__ = (
db.UniqueConstraint('milestone_id', 'student_id', name='uq_milestone_student'),
)
# Relaciones
milestone = db.relationship('AcademicMilestone', backref=db.backref('grades', lazy='dynamic', cascade='all, delete-orphan'))
enrollment = db.relationship('StudentEnrollment', backref=db.backref('milestone_grades', lazy='dynamic', cascade='all, delete-orphan'))
student = db.relationship('User', foreign_keys=[student_id], backref=db.backref('received_grades', lazy='dynamic'))
graded_by = db.relationship('User', foreign_keys=[graded_by_user_id])
@property
def is_passed(self) -> bool:
"""Determina si la nota está aprobada (>= 4.00 y no ausente)."""
if self.is_absent or self.score is None:
return False
return self.score >= 4.0
@property
def display_score(self) -> str:
"""Representación textual estándar: 'Ausente', '7.5' o '-'."""
if self.is_absent:
return 'Ausente'
if self.score is not None:
return f'{self.score:.1f}' if self.score % 1 != 0 else f'{int(self.score)}'
return '-'
def to_dict(self):
return {
'id': self.id,
'milestone_id': self.milestone_id,
'milestone_title': self.milestone.title if self.milestone else None,
'milestone_type': self.milestone.tipo if self.milestone else None,
'milestone_color': self.milestone.color if self.milestone else None,
'enrollment_id': self.enrollment_id,
'student_id': self.student_id,
'student_name': self.student.name if self.student else None,
'student_email': self.student.email if self.student else None,
'score': self.score,
'display_score': self.display_score,
'is_absent': bool(self.is_absent),
'is_passed': self.is_passed,
'feedback': self.feedback or '',
'graded_by_user_id': self.graded_by_user_id,
'graded_by_name': self.graded_by.name if self.graded_by else None,
'graded_at': self.graded_at.isoformat() if self.graded_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f'<MilestoneGrade milestone={self.milestone_id} student={self.student_id} score={self.display_score}>'
+13
View File
@@ -54,6 +54,13 @@ class Commission(db.Model):
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
virtual_link = db.Column(db.String(500), nullable=True) # Link Zoom, Meet, Teams, Moodle
active = db.Column(db.Boolean, default=True) # Changed from is_active to active
# Cierre de Actas y Libro de Calificaciones (Fase 3 MVP)
grades_closed = db.Column(db.Boolean, default=False, nullable=True)
grades_closed_at = db.Column(db.DateTime, nullable=True)
grades_closed_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
acta_number = db.Column(db.String(50), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -69,6 +76,7 @@ class Commission(db.Model):
# Relationships
pass # Reservation already defines the relationship back to commission
teacher = db.relationship('User', backref='teaching_commissions', foreign_keys=[teacher_id])
closed_by_user = db.relationship('User', foreign_keys=[grades_closed_by])
# Virtual field for teacher_name
@property
@@ -125,6 +133,11 @@ class Commission(db.Model):
'shift': self.shift,
'virtual_link': self.virtual_link,
'active': self.active,
'grades_closed': bool(self.grades_closed),
'grades_closed_at': self.grades_closed_at.isoformat() if self.grades_closed_at else None,
'grades_closed_by': self.grades_closed_by,
'closed_by_name': self.closed_by_user.name if self.closed_by_user else None,
'acta_number': self.acta_number,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'full_code': self.get_full_code(),
+266 -1
View File
@@ -1,4 +1,4 @@
from flask import Blueprint, jsonify, request
from flask import Blueprint, jsonify, request, g
from datetime import datetime
import re
from app.utils.jwt_decorators import jwt_required
@@ -6,11 +6,14 @@ from app.models.user import User
from app.models.role import Role, Permission, SYSTEM_MODULES
from app.models.subject import Subject, Commission, CommissionTeacher
from app.models.career import Career
from app.models.classroom import Classroom
from app.models.reservation import Reservation, ReservationStatus
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.services.gradebook_service import GradebookService
from app import db
api_admin_bp = Blueprint('api_admin', __name__)
@@ -1032,6 +1035,95 @@ def update_commission_enrollment(id, student_id):
'enrollment': enrollment.to_dict()
}), 200
# ---------------------------------------------------------
# COMMISSION GRADEBOOK & ACTAS (FASE 3 MVP)
# ---------------------------------------------------------
@api_admin_bp.route('/commissions/<int:id>/gradebook', methods=['GET'])
@jwt_required
def get_commission_gradebook(id):
"""Obtiene la matriz completa de calificaciones de la comisión."""
try:
data = GradebookService.get_commission_gradebook(id)
return jsonify({'status': 'success', 'data': data}), 200
except ValueError as e:
return jsonify({'error': 'NotFound', 'message': str(e)}), 404
except Exception as e:
return jsonify({'error': 'ServerError', 'message': str(e)}), 500
@api_admin_bp.route('/commissions/<int:id>/gradebook/grades', methods=['POST', 'PUT'])
@jwt_required
def save_commission_grades(id):
"""Carga masiva o individual de notas con autoguardado asíncrono."""
data = request.get_json(silent=True) or {}
grader_id = getattr(request, 'current_user_id', None)
try:
if 'grades' in data and isinstance(data['grades'], list):
res = GradebookService.bulk_save_grades(
commission_id=id,
grades_data=data['grades'],
grader_id=grader_id
)
return jsonify({'status': 'success', 'message': f"{res['saved_count']} calificaciones guardadas.", 'data': res}), 200
else:
milestone_id = data.get('milestone_id')
student_id = data.get('student_id')
score = data.get('score')
is_absent = bool(data.get('is_absent', False))
feedback = data.get('feedback', '')
if not milestone_id or not student_id:
return jsonify({'error': 'BadRequest', 'message': 'milestone_id y student_id son obligatorios.'}), 400
grade = GradebookService.save_single_grade(
commission_id=id,
milestone_id=int(milestone_id),
student_id=int(student_id),
score=float(score) if score is not None and str(score).strip() != '' else None,
is_absent=is_absent,
feedback=feedback,
grader_id=grader_id
)
return jsonify({'status': 'success', 'message': 'Calificación guardada.', 'grade': grade.to_dict()}), 200
except ValueError as e:
return jsonify({'error': 'ValidationError', 'message': str(e)}), 400
except Exception as e:
return jsonify({'error': 'ServerError', 'message': str(e)}), 500
@api_admin_bp.route('/commissions/<int:id>/gradebook/close', methods=['POST'])
@jwt_required
def close_commission_gradebook(id):
"""Cierre formal del acta de regularidad y promoción."""
data = request.get_json(silent=True) or {}
user_id = getattr(request, 'current_user_id', None) or 1
notes = data.get('notes', '')
try:
res = GradebookService.close_gradebook(commission_id=id, user_id=user_id, notes=notes)
return jsonify({'status': 'success', **res}), 200
except ValueError as e:
return jsonify({'error': 'BadRequest', 'message': str(e)}), 400
except Exception as e:
return jsonify({'error': 'ServerError', 'message': str(e)}), 500
@api_admin_bp.route('/commissions/<int:id>/gradebook/reopen', methods=['POST'])
@jwt_required
def reopen_commission_gradebook(id):
"""Reapertura administrativa del acta de calificaciones."""
data = request.get_json(silent=True) or {}
user_id = getattr(request, 'current_user_id', None) or 1
reason = data.get('reason', '')
try:
res = GradebookService.reopen_gradebook(commission_id=id, user_id=user_id, reason=reason)
return jsonify({'status': 'success', **res}), 200
except ValueError as e:
return jsonify({'error': 'BadRequest', 'message': str(e)}), 400
except Exception as e:
return jsonify({'error': 'ServerError', 'message': str(e)}), 500
# ---------------------------------------------------------
# ACADEMIC TERMS CRUD
# ---------------------------------------------------------
@@ -1376,4 +1468,177 @@ def reset_academic_data_api():
'message': result.get('error', 'Error durante la purga de datos.')
}), 500
# ---------------------------------------------------------
# FASE 4: MODO IMPERSONACIÓN & DRAG-AND-DROP (ESTADIO 4.1 & 4.2)
# ---------------------------------------------------------
@api_admin_bp.route('/admin/impersonate', methods=['POST'])
@jwt_required
def impersonate_user():
"""Iniciar sesión bajo la identidad de otro usuario (Superadmin / Bedelía)."""
admin_user = getattr(g, 'real_admin_user', g.jwt_user)
if not admin_user.is_admin():
return jsonify({'error': 'Forbidden', 'message': 'Solo administradores pueden impersonar usuarios.'}), 403
data = request.get_json(silent=True) or {}
target_id = data.get('target_user_id') or data.get('user_id')
target_email = data.get('target_email') or data.get('email')
target = None
if target_id:
target = db.session.get(User, int(target_id))
elif target_email:
target = User.query.filter_by(email=target_email.strip()).first()
if not target or not target.is_active:
return jsonify({'error': 'NotFound', 'message': 'Usuario objetivo no encontrado o inactivo.'}), 404
audit = AuditLog(
user_id=admin_user.id,
user_email=admin_user.email,
action='IMPERSONATE_START',
module='auth',
entity_id=target.id,
details=f"Administrador {admin_user.email} inició sesión temporal como {target.email} ({target.role})"
)
db.session.add(audit)
db.session.commit()
return jsonify({
'status': 'success',
'message': f"Impersonando exitosamente a {target.name or target.email}",
'target_user': target.to_dict(),
'real_admin_id': admin_user.id
}), 200
@api_admin_bp.route('/admin/stop-impersonating', methods=['POST'])
@jwt_required
def stop_impersonating():
"""Finalizar sesión de impersonación y restaurar cuenta de administrador."""
admin_user = getattr(g, 'real_admin_user', g.jwt_user)
current_target = g.jwt_user
audit = AuditLog(
user_id=admin_user.id,
user_email=admin_user.email,
action='IMPERSONATE_END',
module='auth',
entity_id=current_target.id if current_target else None,
details=f"Administrador {admin_user.email} finalizó la sesión de impersonación."
)
db.session.add(audit)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Sesión de impersonación finalizada.'
}), 200
@api_admin_bp.route('/reservations/drag-update', methods=['POST'])
@jwt_required
def drag_update_reservation():
"""Actualización o creación rápida de reserva mediante arrastre en la grilla semanal."""
data = request.get_json(silent=True) or {}
reservation_id = data.get('reservation_id')
commission_id = data.get('commission_id')
classroom_id = data.get('classroom_id')
start_time_str = data.get('start_time')
end_time_str = data.get('end_time')
if not classroom_id or not start_time_str or not end_time_str:
return jsonify({'error': 'ValidationError', 'message': 'Faltan parámetros obligatorios (aula, inicio, fin).'}), 400
try:
if isinstance(start_time_str, str):
start_dt = datetime.fromisoformat(start_time_str.replace('Z', '+00:00'))
else:
start_dt = start_time_str
if isinstance(end_time_str, str):
end_dt = datetime.fromisoformat(end_time_str.replace('Z', '+00:00'))
else:
end_dt = end_time_str
except Exception as e:
return jsonify({'error': 'ValidationError', 'message': f'Formato de fecha inválido: {str(e)}'}), 400
classroom = db.session.get(Classroom, classroom_id)
if not classroom:
return jsonify({'error': 'NotFound', 'message': 'Aula no encontrada.'}), 404
# Pre-validación de conflictos (físico, capacidad, docente)
from app.services.reservation_service import ReservationService
effective_comm_id = commission_id
if not effective_comm_id and reservation_id:
existing_res = db.session.get(Reservation, reservation_id)
if existing_res:
effective_comm_id = existing_res.commission_id
res_service = ReservationService()
matrix = res_service.check_full_conflicts_matrix(
classroom_id=classroom_id,
start_time=start_dt,
end_time=end_dt,
commission_id=effective_comm_id,
exclude_id=reservation_id
)
if matrix.get('is_blocked'):
return jsonify({
'status': 'conflict',
'error': 'ConflictDetected',
'message': ' '.join(matrix.get('block_reasons', ['Conflicto detectado en la asignación.'])),
'conflicts': matrix.get('block_reasons', [])
}), 409
admin_user = getattr(g, 'real_admin_user', g.jwt_user)
acting_user = g.jwt_user
if reservation_id:
res = db.session.get(Reservation, reservation_id)
if not res:
return jsonify({'error': 'NotFound', 'message': 'Reserva no encontrada.'}), 404
res.classroom_id = classroom_id
res.start_time = start_dt
res.end_time = end_dt
action = 'UPDATE_RESERVATION_DRAG'
msg = f"Reserva #{res.id} reprogramada al aula {classroom.code} ({start_dt.strftime('%H:%M')} a {end_dt.strftime('%H:%M')})"
else:
comm = db.session.get(Commission, commission_id) if commission_id else None
res = Reservation(
classroom_id=classroom_id,
commission_id=commission_id,
user_id=acting_user.id,
start_time=start_dt,
end_time=end_dt,
purpose=f"Cursada regular - {comm.subject.name if comm and comm.subject else 'Comisión'}",
expected_attendees=comm.max_students if comm else 30,
status=ReservationStatus.CONFIRMED.value
)
db.session.add(res)
action = 'CREATE_RESERVATION_DRAG'
msg = f"Comisión {comm.code if comm else ''} asignada al aula {classroom.code}"
details_str = msg
if getattr(g, 'is_impersonating', False):
details_str += f" [Ejecutado bajo impersonación por Admin #{admin_user.id}]"
audit = AuditLog(
user_id=admin_user.id,
user_email=admin_user.email,
action=action,
module='reservations',
entity_id=res.id,
details=details_str
)
db.session.add(audit)
db.session.commit()
return jsonify({
'status': 'success',
'message': msg,
'reservation': res.to_dict()
}), 200
+43
View File
@@ -0,0 +1,43 @@
from flask import Blueprint, jsonify, request, g
from app.utils.jwt_decorators import jwt_required
from app.services.gradebook_service import GradebookService
api_students_bp = Blueprint('api_students', __name__)
@api_students_bp.route('/students/my-grades', methods=['GET'])
@jwt_required
def get_my_grades():
"""Consulta individual de calificaciones y estado académico del alumno logueado."""
user = getattr(g, 'jwt_user', None)
user_id = user.id if user else getattr(request, 'current_user_id', None)
if not user_id:
return jsonify({'error': 'Unauthorized', 'message': 'Usuario no autenticado.'}), 401
try:
grades = GradebookService.get_student_grades(user_id)
return jsonify({
'status': 'success',
'student_id': user_id,
'is_impersonating': getattr(g, 'is_impersonating', False),
'data': grades
}), 200
except Exception as e:
return jsonify({'error': 'ServerError', 'message': str(e)}), 500
@api_students_bp.route('/students/my-upcoming-milestones', methods=['GET'])
@jwt_required
def get_my_upcoming_milestones():
"""Próximas evaluaciones e hitos académicos con cuenta regresiva para el dashboard."""
user = getattr(g, 'jwt_user', None)
user_id = user.id if user else getattr(request, 'current_user_id', None)
if not user_id:
return jsonify({'error': 'Unauthorized', 'message': 'Usuario no autenticado.'}), 401
limit = request.args.get('limit', default=5, type=int)
try:
milestones = GradebookService.get_student_upcoming_milestones(user_id, limit=limit)
return jsonify({'status': 'success', 'data': milestones}), 200
except Exception as e:
return jsonify({'error': 'ServerError', 'message': str(e)}), 500
+436
View File
@@ -0,0 +1,436 @@
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
from app import db
from app.models.subject import Commission, Subject
from app.models.milestone import AcademicMilestone, MilestoneType
from app.models.enrollment import StudentEnrollment
from app.models.grade import MilestoneGrade
from app.models.user import User
from app.models.audit_log import AuditLog
class GradebookService:
"""
Servicio de Libro de Calificaciones, Hitos Evaluativos y Cierre de Actas (Fase 3 MVP).
"""
@classmethod
def get_commission_gradebook(cls, commission_id: int) -> Dict[str, Any]:
"""Obtiene la matriz completa de calificaciones de una comisión."""
comm = Commission.query.get(commission_id)
if not comm:
raise ValueError(f'Comisión con ID {commission_id} no encontrada.')
# 1. Hitos evaluativos de la comisión
milestones = AcademicMilestone.query.filter_by(
commission_id=comm.id,
is_active=True
).order_by(AcademicMilestone.date.asc()).all()
# 2. Estudiantes inscriptos
enrollments = StudentEnrollment.query.filter_by(
commission_id=comm.id
).join(User).order_by(User.name.asc()).all()
# 3. Mapa de notas por (student_id, milestone_id)
grades = MilestoneGrade.query.filter(
MilestoneGrade.milestone_id.in_([m.id for m in milestones]) if milestones else False
).all() if milestones else []
grades_map: Dict[tuple, MilestoneGrade] = {
(g.student_id, g.milestone_id): g for g in grades
}
# 4. Construcción de filas por estudiante
rows = []
for enr in enrollments:
student = enr.student
student_grades = []
valid_scores = []
has_failed = False
total_milestones = len(milestones)
graded_count = 0
for m in milestones:
g = grades_map.get((student.id, m.id))
if g:
graded_count += 1
g_dict = g.to_dict()
student_grades.append(g_dict)
if g.is_absent:
has_failed = True
elif g.score is not None:
valid_scores.append(g.score)
if g.score < 4.0:
has_failed = True
else:
student_grades.append({
'id': None,
'milestone_id': m.id,
'student_id': student.id,
'score': None,
'display_score': '-',
'is_absent': False,
'is_passed': False,
'feedback': ''
})
# Cálculo de promedio y condición
avg_score = round(sum(valid_scores) / len(valid_scores), 2) if valid_scores else None
if graded_count == 0:
condition = 'Sin Calificar'
condition_badge = 'bg-secondary'
elif has_failed:
condition = 'Desaprobado'
condition_badge = 'bg-danger'
elif avg_score is not None and avg_score >= 7.0 and graded_count == total_milestones:
condition = 'Promociona'
condition_badge = 'bg-success'
elif avg_score is not None and avg_score >= 4.0:
condition = 'Regular'
condition_badge = 'bg-primary'
else:
condition = 'Pendiente'
condition_badge = 'bg-warning text-dark'
rows.append({
'enrollment_id': enr.id,
'student_id': student.id,
'student_name': student.name,
'student_email': student.email,
'enrollment_status': enr.status,
'grades': student_grades,
'average_score': avg_score,
'graded_count': graded_count,
'total_milestones': total_milestones,
'condition': condition,
'condition_badge': condition_badge
})
return {
'commission': comm.to_dict(),
'milestones': [m.to_dict() for m in milestones],
'students': rows,
'is_closed': bool(comm.grades_closed),
'acta_number': comm.acta_number,
'closed_at': comm.grades_closed_at.isoformat() if comm.grades_closed_at else None,
'closed_by_name': comm.closed_by_user.name if comm.closed_by_user else None,
'can_modify': not bool(comm.grades_closed)
}
@classmethod
def save_single_grade(
cls,
commission_id: int,
milestone_id: int,
student_id: int,
score: Optional[float],
is_absent: bool = False,
feedback: str = '',
grader_id: Optional[int] = None
) -> MilestoneGrade:
"""Guarda o actualiza una calificación individual."""
comm = Commission.query.get(commission_id)
if not comm:
raise ValueError(f'Comisión con ID {commission_id} no encontrada.')
if comm.grades_closed:
raise ValueError('El acta de calificaciones de esta comisión se encuentra cerrada y no admite modificaciones.')
milestone = AcademicMilestone.query.filter_by(id=milestone_id, commission_id=comm.id).first()
if not milestone:
raise ValueError(f'Hito evaluativo {milestone_id} no pertenece a la comisión {commission_id}.')
enrollment = StudentEnrollment.query.filter_by(commission_id=comm.id, student_id=student_id).first()
if not enrollment:
raise ValueError(f'El estudiante {student_id} no está matriculado en la comisión {commission_id}.')
# Validación de nota numérica
if is_absent:
final_score = None
final_absent = True
else:
final_absent = False
if score is not None:
try:
f_score = float(score)
except (ValueError, TypeError):
raise ValueError('La calificación debe ser un valor numérico válido entre 1.0 y 10.0.')
if f_score < 1.0 or f_score > 10.0:
raise ValueError('La calificación debe estar comprendida entre 1.0 y 10.0.')
final_score = round(f_score, 2)
else:
final_score = None
grade = MilestoneGrade.query.filter_by(
milestone_id=milestone.id,
student_id=student_id
).first()
if not grade:
grade = MilestoneGrade(
milestone_id=milestone.id,
enrollment_id=enrollment.id,
student_id=student_id,
score=final_score,
is_absent=final_absent,
feedback=feedback.strip() if feedback else '',
graded_by_user_id=grader_id,
graded_at=datetime.utcnow()
)
db.session.add(grade)
else:
grade.score = final_score
grade.is_absent = final_absent
if feedback is not None:
grade.feedback = feedback.strip()
grade.graded_by_user_id = grader_id
grade.updated_at = datetime.utcnow()
db.session.commit()
return grade
@classmethod
def bulk_save_grades(
cls,
commission_id: int,
grades_data: List[Dict[str, Any]],
grader_id: Optional[int] = None
) -> Dict[str, Any]:
"""Carga masiva / autoguardado asíncrono de calificaciones."""
comm = Commission.query.get(commission_id)
if not comm:
raise ValueError(f'Comisión {commission_id} no encontrada.')
if comm.grades_closed:
raise ValueError('El acta de calificaciones se encuentra cerrada y no permite modificaciones.')
saved_count = 0
for item in grades_data:
m_id = item.get('milestone_id')
s_id = item.get('student_id')
score = item.get('score')
is_absent = bool(item.get('is_absent', False))
feedback = item.get('feedback', '')
if m_id and s_id:
cls.save_single_grade(
commission_id=comm.id,
milestone_id=int(m_id),
student_id=int(s_id),
score=float(score) if score is not None and str(score).strip() != '' else None,
is_absent=is_absent,
feedback=feedback,
grader_id=grader_id
)
saved_count += 1
return {'success': True, 'saved_count': saved_count}
@classmethod
def close_gradebook(cls, commission_id: int, user_id: int, notes: str = '') -> Dict[str, Any]:
"""Cierre formal del acta de regularidad y promoción para Bedelía/Titular."""
comm = Commission.query.get(commission_id)
if not comm:
raise ValueError(f'Comisión {commission_id} no encontrada.')
if comm.grades_closed:
raise ValueError(f'El acta de la comisión {commission_id} ya fue cerrada anteriormente.')
acta_num = f'ACTA-{comm.year}-{comm.semester}-{comm.id:04d}'
comm.grades_closed = True
comm.grades_closed_at = datetime.utcnow()
comm.grades_closed_by = user_id
comm.acta_number = acta_num
# Auditoría
audit = AuditLog(
user_id=user_id,
action='CLOSE_ACTA',
module='gradebook',
entity_id=comm.id,
details=f'Cierre formal de acta de calificaciones. Nro: {acta_num}. Notas: {notes}'
)
db.session.add(audit)
db.session.commit()
return {
'success': True,
'acta_number': acta_num,
'closed_at': comm.grades_closed_at.isoformat(),
'message': f'Acta {acta_num} cerrada exitosamente. Las calificaciones han quedado selladas.'
}
@classmethod
def reopen_gradebook(cls, commission_id: int, user_id: int, reason: str) -> Dict[str, Any]:
"""Reapertura excepcional de acta con justificación administrativa."""
if not reason or len(reason.strip()) < 5:
raise ValueError('Debe especificar un motivo formal para reabrir el acta de calificaciones.')
comm = Commission.query.get(commission_id)
if not comm:
raise ValueError(f'Comisión {commission_id} no encontrada.')
if not comm.grades_closed:
raise ValueError('El acta no se encuentra cerrada.')
old_acta = comm.acta_number
comm.grades_closed = False
comm.grades_closed_at = None
comm.grades_closed_by = None
audit = AuditLog(
user_id=user_id,
action='REOPEN_ACTA',
module='gradebook',
entity_id=comm.id,
details=f'Reapertura excepcional de acta {old_acta}. Motivo: {reason.strip()}'
)
db.session.add(audit)
db.session.commit()
return {
'success': True,
'message': f'Acta {old_acta} reabierta para rectificación de calificaciones.'
}
@classmethod
def get_student_grades(cls, student_id: int) -> List[Dict[str, Any]]:
"""Consulta individual de calificaciones de todas las materias del alumno."""
enrollments = StudentEnrollment.query.filter_by(
student_id=student_id
).all()
results = []
for enr in enrollments:
comm = enr.commission
if not comm:
continue
milestones = AcademicMilestone.query.filter_by(
commission_id=comm.id,
is_active=True
).order_by(AcademicMilestone.date.asc()).all()
grades = MilestoneGrade.query.filter_by(
enrollment_id=enr.id
).all()
grades_by_milestone = {g.milestone_id: g for g in grades}
ms_results = []
valid_scores = []
has_failed = False
for m in milestones:
g = grades_by_milestone.get(m.id)
if g:
ms_results.append(g.to_dict())
if g.is_absent:
has_failed = True
elif g.score is not None:
valid_scores.append(g.score)
if g.score < 4.0:
has_failed = True
else:
ms_results.append({
'milestone_id': m.id,
'milestone_title': m.title or m.tipo,
'milestone_type': m.tipo,
'milestone_color': m.color,
'display_score': '-',
'score': None,
'is_absent': False,
'feedback': ''
})
avg_score = round(sum(valid_scores) / len(valid_scores), 2) if valid_scores else None
if not grades:
condition = 'Cursando'
condition_badge = 'bg-secondary'
elif has_failed:
condition = 'Desaprobado'
condition_badge = 'bg-danger'
elif avg_score is not None and avg_score >= 7.0 and len(grades) == len(milestones):
condition = 'Promociona'
condition_badge = 'bg-success'
elif avg_score is not None and avg_score >= 4.0:
condition = 'Regular'
condition_badge = 'bg-primary'
else:
condition = 'En curso'
condition_badge = 'bg-info text-dark'
results.append({
'enrollment_id': enr.id,
'commission_id': comm.id,
'commission_code': comm.get_full_code(),
'subject_name': comm.subject.name if comm.subject else 'Materia',
'subject_code': comm.subject.code if comm.subject else '',
'semester': comm.semester,
'year': comm.year,
'teacher_name': comm.teacher_name,
'milestones': ms_results,
'average_score': avg_score,
'condition': condition,
'condition_badge': condition_badge,
'is_acta_closed': bool(comm.grades_closed),
'acta_number': comm.acta_number
})
return results
@classmethod
def get_student_upcoming_milestones(cls, student_id: int, limit: int = 5) -> List[Dict[str, Any]]:
"""Obtiene las próximas evaluaciones con cálculo de cuenta regresiva para el dashboard."""
now = datetime.utcnow()
enrollments = StudentEnrollment.query.filter_by(student_id=student_id).all()
commission_ids = [e.commission_id for e in enrollments]
if not commission_ids:
return []
upcoming = AcademicMilestone.query.filter(
AcademicMilestone.commission_id.in_(commission_ids),
AcademicMilestone.date >= now - timedelta(hours=2),
AcademicMilestone.is_active == True
).order_by(AcademicMilestone.date.asc()).limit(limit).all()
results = []
for m in upcoming:
delta = m.date - now
days = delta.days
hours = int(delta.seconds // 3600)
mins = int((delta.seconds % 3600) // 60)
if days > 1:
countdown_str = f'Faltan {days} días'
badge_urgency = 'bg-info'
elif days == 1:
countdown_str = 'Mañana'
badge_urgency = 'bg-warning text-dark'
elif hours > 0:
countdown_str = f'Hoy en {hours} h {mins} min'
badge_urgency = 'bg-danger'
else:
countdown_str = 'En curso'
badge_urgency = 'bg-danger'
results.append({
'id': m.id,
'title': m.title or m.tipo,
'tipo': m.tipo,
'color': m.color,
'date': m.date.isoformat(),
'date_formatted': m.date.strftime('%d/%m/%Y %H:%M hs'),
'classroom': m.classroom or 'Campus Virtual',
'link': m.link,
'subject_name': m.subject.name if m.subject else m.materia_codigo,
'commission_code': m.commission.get_full_code() if m.commission else '',
'days_remaining': max(0, days),
'hours_remaining': max(0, hours),
'countdown_display': countdown_str,
'badge_urgency': badge_urgency
})
return results
+14
View File
@@ -40,6 +40,20 @@ def jwt_required(f):
g.jwt_user = user
g.jwt_payload = payload
g.jwt_token = token
g.real_admin_user = user
g.is_impersonating = False
impersonate_header = request.headers.get('X-Impersonate-User')
if impersonate_header and user.is_admin():
target_user = None
if impersonate_header.strip().isdigit():
target_user = db.session.get(User, int(impersonate_header.strip()))
else:
target_user = User.query.filter_by(email=impersonate_header.strip()).first()
if target_user and target_user.is_active:
g.jwt_user = target_user
g.is_impersonating = True
except jwt.ExpiredSignatureError:
return jsonify({
+69
View File
@@ -0,0 +1,69 @@
"""
Migration script for Phase 3 MVP fields & tables:
- commissions.grades_closed (BOOLEAN DEFAULT 0)
- commissions.grades_closed_at (DATETIME)
- commissions.grades_closed_by (INTEGER)
- commissions.acta_number (VARCHAR(50))
- milestone_grades table
"""
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 commissions columns
cursor.execute("PRAGMA table_info(commissions)")
columns = [row[1] for row in cursor.fetchall()]
if 'grades_closed' not in columns:
print("Adding column 'grades_closed' to commissions table...")
cursor.execute("ALTER TABLE commissions ADD COLUMN grades_closed BOOLEAN DEFAULT 0")
if 'grades_closed_at' not in columns:
print("Adding column 'grades_closed_at' to commissions table...")
cursor.execute("ALTER TABLE commissions ADD COLUMN grades_closed_at DATETIME")
if 'grades_closed_by' not in columns:
print("Adding column 'grades_closed_by' to commissions table...")
cursor.execute("ALTER TABLE commissions ADD COLUMN grades_closed_by INTEGER")
if 'acta_number' not in columns:
print("Adding column 'acta_number' to commissions table...")
cursor.execute("ALTER TABLE commissions ADD COLUMN acta_number VARCHAR(50)")
# Create milestone_grades table if not exists
cursor.execute("""
CREATE TABLE IF NOT EXISTS milestone_grades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
milestone_id INTEGER NOT NULL,
enrollment_id INTEGER NOT NULL,
student_id INTEGER NOT NULL,
score FLOAT,
is_absent BOOLEAN DEFAULT 0,
feedback TEXT,
graded_by_user_id INTEGER,
graded_at DATETIME,
updated_at DATETIME,
FOREIGN KEY (milestone_id) REFERENCES academic_milestones(id) ON DELETE CASCADE,
FOREIGN KEY (enrollment_id) REFERENCES student_enrollments(id) ON DELETE CASCADE,
FOREIGN KEY (student_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (graded_by_user_id) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT uq_milestone_student UNIQUE (milestone_id, student_id)
)
""")
cursor.execute("CREATE INDEX IF NOT EXISTS ix_milestone_grades_milestone_id ON milestone_grades (milestone_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS ix_milestone_grades_enrollment_id ON milestone_grades (enrollment_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS ix_milestone_grades_student_id ON milestone_grades (student_id)")
conn.commit()
conn.close()
print(f"Migration completed for {db_path}")
if __name__ == '__main__':
migrate()
+310
View File
@@ -0,0 +1,310 @@
import unittest
from datetime import datetime, timedelta
from app import create_app, db
from app.models.subject import Subject, Commission
from app.models.milestone import MilestoneType, AcademicMilestone
from app.models.user import User
from app.models.role import Role
from app.models.enrollment import StudentEnrollment
from app.models.grade import MilestoneGrade
from app.services.gradebook_service import GradebookService
class TestPhase3Gradebook(unittest.TestCase):
"""Pruebas unitarias e integrales para la Fase 3 del Roadmap MVP (UniCABA)."""
def setUp(self):
self.app = create_app()
self.app_context = self.app.app_context()
self.app_context.push()
db.create_all()
# Roles
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()
ts = int(datetime.utcnow().timestamp())
# Docente
self.docente = User(
name=f'Profesor Calificador {ts}',
email=f'docente_calif_{ts}@unicaba.edu.ar',
role_id=self.role_docente.id,
role='Docente',
is_active=True
)
self.docente.set_password('Secret123!')
db.session.add(self.docente)
# Alumnos
self.alumno1 = User(
name=f'Estudiante Uno {ts}',
email=f'alumno1_{ts}@unicaba.edu.ar',
role_id=self.role_alumno.id,
role='Alumno',
is_active=True
)
self.alumno1.set_password('Secret123!')
db.session.add(self.alumno1)
self.alumno2 = User(
name=f'Estudiante Dos {ts}',
email=f'alumno2_{ts}@unicaba.edu.ar',
role_id=self.role_alumno.id,
role='Alumno',
is_active=True
)
self.alumno2.set_password('Secret123!')
db.session.add(self.alumno2)
# Materia y Comisión
self.subject = Subject(
name=f'Evaluación y Métricas {ts}',
code=f'EVAL{ts % 10000:04d}',
is_active=True
)
db.session.add(self.subject)
db.session.flush()
self.commission = Commission(
subject_id=self.subject.id,
code='C1',
semester='1C',
year=2026,
teacher_id=self.docente.id,
max_students=40,
active=True
)
db.session.add(self.commission)
db.session.flush()
# Matrículas
self.enr1 = StudentEnrollment(
student_id=self.alumno1.id,
commission_id=self.commission.id,
status='activo'
)
self.enr2 = StudentEnrollment(
student_id=self.alumno2.id,
commission_id=self.commission.id,
status='activo'
)
db.session.add_all([self.enr1, self.enr2])
# Tipos de hito
self.mtype_parcial = MilestoneType.query.filter_by(code='parcial_1').first()
if not self.mtype_parcial:
self.mtype_parcial = MilestoneType(
name='Parcial 1',
code='parcial_1',
color='#0d6efd',
is_active=True
)
db.session.add(self.mtype_parcial)
self.mtype_tp = MilestoneType.query.filter_by(code='tp_1').first()
if not self.mtype_tp:
self.mtype_tp = MilestoneType(
name='Trabajo Práctico 1',
code='tp_1',
color='#198754',
is_active=True
)
db.session.add(self.mtype_tp)
db.session.commit()
# Hitos de la comisión
now = datetime.utcnow()
self.hito1 = AcademicMilestone(
title='Primer Parcial Teórico',
materia_codigo=self.subject.code,
subject_id=self.subject.id,
commission_id=self.commission.id,
milestone_type_id=self.mtype_parcial.id,
date=now + timedelta(days=5),
classroom='Aula 101',
is_active=True
)
self.hito2 = AcademicMilestone(
title='Entrega de TP Obligatorio',
materia_codigo=self.subject.code,
subject_id=self.subject.id,
commission_id=self.commission.id,
milestone_type_id=self.mtype_tp.id,
date=now + timedelta(days=12),
classroom='Campus Virtual',
is_active=True
)
db.session.add_all([self.hito1, self.hito2])
db.session.commit()
def tearDown(self):
db.session.rollback()
self.app_context.pop()
def test_gradebook_matrix_initial(self):
"""Verifica que la matriz de notas inicialice vacía pero con los alumnos e hitos correctos."""
gb = GradebookService.get_commission_gradebook(self.commission.id)
self.assertIn('students', gb)
self.assertEqual(len(gb['students']), 2)
self.assertEqual(len(gb['milestones']), 2)
self.assertFalse(gb['is_closed'])
self.assertIsNone(gb['acta_number'])
# Los alumnos deben figurar como 'Sin Calificar'
for s in gb['students']:
self.assertEqual(s['condition'], 'Sin Calificar')
self.assertIsNone(s['average_score'])
def test_save_grade_valid_and_absent(self):
"""Verifica la carga de notas numéricas y estados de ausente."""
# Alumno 1 saca 8.5 en hito 1
g1 = GradebookService.save_single_grade(
commission_id=self.commission.id,
milestone_id=self.hito1.id,
student_id=self.alumno1.id,
score=8.5,
is_absent=False,
feedback='Excelente razonamiento teórico.',
grader_id=self.docente.id
)
self.assertEqual(g1.score, 8.5)
self.assertTrue(g1.is_passed)
self.assertEqual(g1.display_score, '8.5')
# Alumno 2 estuvo ausente en hito 1
g2 = GradebookService.save_single_grade(
commission_id=self.commission.id,
milestone_id=self.hito1.id,
student_id=self.alumno2.id,
score=None,
is_absent=True,
feedback='Ausente con aviso.',
grader_id=self.docente.id
)
self.assertIsNone(g2.score)
self.assertTrue(g2.is_absent)
self.assertFalse(g2.is_passed)
self.assertEqual(g2.display_score, 'Ausente')
def test_save_grade_validation_range(self):
"""Bloqueo de notas fuera del rango estándar (1.00 a 10.00)."""
with self.assertRaises(ValueError):
GradebookService.save_single_grade(
commission_id=self.commission.id,
milestone_id=self.hito1.id,
student_id=self.alumno1.id,
score=10.5
)
with self.assertRaises(ValueError):
GradebookService.save_single_grade(
commission_id=self.commission.id,
milestone_id=self.hito1.id,
student_id=self.alumno1.id,
score=0.5
)
def test_academic_condition_calculation(self):
"""Verifica el cálculo de Promociona, Regular y Desaprobado."""
# Alumno 1: 8.0 en hito 1 y 9.0 en hito 2 -> Promedio 8.50 -> Promociona
GradebookService.save_single_grade(
commission_id=self.commission.id,
milestone_id=self.hito1.id,
student_id=self.alumno1.id,
score=8.0
)
GradebookService.save_single_grade(
commission_id=self.commission.id,
milestone_id=self.hito2.id,
student_id=self.alumno1.id,
score=9.0
)
# Alumno 2: 5.0 en hito 1 y 6.0 en hito 2 -> Promedio 5.50 -> Regular
GradebookService.save_single_grade(
commission_id=self.commission.id,
milestone_id=self.hito1.id,
student_id=self.alumno2.id,
score=5.0
)
GradebookService.save_single_grade(
commission_id=self.commission.id,
milestone_id=self.hito2.id,
student_id=self.alumno2.id,
score=6.0
)
gb = GradebookService.get_commission_gradebook(self.commission.id)
al1_data = next(s for s in gb['students'] if s['student_id'] == self.alumno1.id)
al2_data = next(s for s in gb['students'] if s['student_id'] == self.alumno2.id)
self.assertEqual(al1_data['average_score'], 8.5)
self.assertEqual(al1_data['condition'], 'Promociona')
self.assertEqual(al2_data['average_score'], 5.5)
self.assertEqual(al2_data['condition'], 'Regular')
def test_close_gradebook_and_immutability(self):
"""Cierre formal del acta de regularidad y bloqueo de modificaciones posteriores."""
# Calificar
GradebookService.save_single_grade(
commission_id=self.commission.id,
milestone_id=self.hito1.id,
student_id=self.alumno1.id,
score=7.0
)
# Cerrar acta
res = GradebookService.close_gradebook(
commission_id=self.commission.id,
user_id=self.docente.id,
notes='Cierre final de cursada primer cuatrimestre.'
)
self.assertTrue(res['success'])
self.assertIn('ACTA-2026-1C-', res['acta_number'])
# Intentar modificar nota en acta cerrada debe ser rechazado
with self.assertRaises(ValueError) as ctx:
GradebookService.save_single_grade(
commission_id=self.commission.id,
milestone_id=self.hito1.id,
student_id=self.alumno1.id,
score=10.0
)
self.assertIn('cerrada', str(ctx.exception).lower())
def test_student_grades_and_countdown(self):
"""Verifica la consulta individual de notas y la cuenta regresiva para el alumno."""
# Calificar al alumno 1
GradebookService.save_single_grade(
commission_id=self.commission.id,
milestone_id=self.hito1.id,
student_id=self.alumno1.id,
score=8.0,
feedback='Buen trabajo en el examen.'
)
# Consulta del alumno
grades = GradebookService.get_student_grades(self.alumno1.id)
self.assertEqual(len(grades), 1)
self.assertEqual(grades[0]['subject_name'], self.subject.name)
self.assertEqual(len(grades[0]['milestones']), 2)
# Próximos hitos con cuenta regresiva
upcoming = GradebookService.get_student_upcoming_milestones(self.alumno1.id)
self.assertGreaterEqual(len(upcoming), 1)
self.assertIn('countdown_display', upcoming[0])
self.assertIn('days_remaining', upcoming[0])
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,283 @@
import unittest
from datetime import datetime, timedelta
from app import create_app, db
from app.models.subject import Subject, Commission
from app.models.classroom import Classroom
from app.models.reservation import Reservation, ReservationStatus
from app.models.user import User
from app.models.role import Role
from app.models.audit_log import AuditLog
from app.models.genetic_algorithm import ReservationOptimizer
from app.services.jwt_service import JWTService
class TestPhase4InteractiveImpersonation(unittest.TestCase):
"""Pruebas unitarias e integrales para la Fase 4 del Roadmap MVP (UniCABA)."""
@classmethod
def setUpClass(cls):
cls.app = create_app()
cls.app.config['TESTING'] = True
cls.app_context = cls.app.app_context()
cls.app_context.push()
db.create_all()
# Roles
cls.role_admin = Role.query.filter_by(name='Admin').first()
if not cls.role_admin:
cls.role_admin = Role(name='Admin', description='Rol Administrador')
db.session.add(cls.role_admin)
cls.role_alumno = Role.query.filter_by(name='Alumno').first()
if not cls.role_alumno:
cls.role_alumno = Role(name='Alumno', description='Rol Alumno')
db.session.add(cls.role_alumno)
cls.role_docente = Role.query.filter_by(name='Docente').first()
if not cls.role_docente:
cls.role_docente = Role(name='Docente', description='Rol Docente')
db.session.add(cls.role_docente)
db.session.commit()
ts = int(datetime.utcnow().timestamp())
# Admin
cls.admin = User(
name=f'Super Administrador {ts}',
email=f'admin_p4_{ts}@unicaba.edu.ar',
role_id=cls.role_admin.id,
role='Admin',
is_active=True
)
cls.admin.set_password('AdminSecret123!')
db.session.add(cls.admin)
# Docente
cls.docente = User(
name=f'Profesor Fase 4 {ts}',
email=f'docente_p4_{ts}@unicaba.edu.ar',
role_id=cls.role_docente.id,
role='Docente',
is_active=True
)
cls.docente.set_password('DocSecret123!')
db.session.add(cls.docente)
# Alumno
cls.alumno = User(
name=f'Estudiante Fase 4 {ts}',
email=f'alumno_p4_{ts}@unicaba.edu.ar',
role_id=cls.role_alumno.id,
role='Alumno',
is_active=True
)
cls.alumno.set_password('AlumSecret123!')
db.session.add(cls.alumno)
# Aulas
cls.aula1 = Classroom(
code=f'AULA-P4-1-{ts}',
capacity=40,
floor='1',
building='Edificio Central',
is_active=True
)
cls.aula2 = Classroom(
code=f'AULA-P4-2-{ts}',
capacity=25,
floor='2',
building='Edificio Central',
is_active=True
)
db.session.add(cls.aula1)
db.session.add(cls.aula2)
# Materia y Comisión
cls.subject = Subject(
name=f'Gestión Interactiva {ts}',
code=f'GI4-{ts}',
is_active=True
)
db.session.add(cls.subject)
db.session.flush()
cls.commission = Commission(
subject_id=cls.subject.id,
teacher_id=cls.docente.id,
code='C1',
shift='Mañana',
schedule='Lunes 08:00 a 12:00',
year=2026,
semester='1C',
max_students=30,
active=True
)
db.session.add(cls.commission)
db.session.commit()
cls.admin_id = cls.admin.id
cls.docente_id = cls.docente.id
cls.alumno_id = cls.alumno.id
cls.aula1_id = cls.aula1.id
cls.aula2_id = cls.aula2.id
cls.commission_id = cls.commission.id
cls.client = cls.app.test_client()
# Tokens
cls.admin_token = JWTService.create_access_token(cls.admin)
cls.alumno_token = JWTService.create_access_token(cls.alumno)
cls.docente_token = JWTService.create_access_token(cls.docente)
@classmethod
def tearDownClass(cls):
db.session.remove()
cls.app_context.pop()
def tearDown(self):
db.session.remove()
def test_admin_impersonate_user_and_audit(self):
"""Superadmin inicia sesión como alumno y se genera bitácora inmutable."""
res = self.client.post(
'/api/v1/admin/impersonate',
headers={'Authorization': f'Bearer {self.admin_token}'},
json={'target_user_id': self.alumno_id}
)
self.assertEqual(res.status_code, 200)
data = res.get_json()
self.assertEqual(data.get('status'), 'success')
alumno = db.session.get(User, self.alumno_id)
self.assertEqual(data['target_user']['email'], alumno.email)
# Verificar auditoría
audit = AuditLog.query.filter_by(action='IMPERSONATE_START', entity_id=self.alumno_id).first()
self.assertIsNotNone(audit)
self.assertEqual(audit.user_id, self.admin_id)
self.assertIn('inició sesión temporal', audit.details)
def test_non_admin_cannot_impersonate(self):
"""Usuarios no administradores (docente/alumno) tienen denegado el modo impersonación."""
# Intento por alumno
res_alum = self.client.post(
'/api/v1/admin/impersonate',
headers={'Authorization': f'Bearer {self.alumno_token}'},
json={'target_user_id': self.docente_id}
)
self.assertEqual(res_alum.status_code, 403)
# Intento por docente
res_doc = self.client.post(
'/api/v1/admin/impersonate',
headers={'Authorization': f'Bearer {self.docente_token}'},
json={'target_user_id': self.alumno_id}
)
self.assertEqual(res_doc.status_code, 403)
def test_impersonated_request_context_with_header(self):
"""La cabecera X-Impersonate-User sustituye la identidad para las consultas pero preserva admin real."""
res = self.client.get(
'/api/v1/students/my-grades',
headers={
'Authorization': f'Bearer {self.admin_token}',
'X-Impersonate-User': str(self.alumno_id)
}
)
self.assertEqual(res.status_code, 200)
data = res.get_json()
self.assertEqual(data.get('student_id'), self.alumno_id)
self.assertTrue(data.get('is_impersonating'))
def test_stop_impersonating_and_audit(self):
"""Finalización formal de la impersonación con registro en bitácora."""
res = self.client.post(
'/api/v1/admin/stop-impersonating',
headers={
'Authorization': f'Bearer {self.admin_token}',
'X-Impersonate-User': str(self.alumno_id)
}
)
self.assertEqual(res.status_code, 200)
audit = AuditLog.query.filter_by(action='IMPERSONATE_END', user_id=self.admin_id).first()
self.assertIsNotNone(audit)
self.assertEqual(audit.user_id, self.admin_id)
def test_drag_update_reservation_and_conflict_detection(self):
"""Actualización interactiva de reserva por arrastre con detección de colisión física."""
now = datetime.utcnow().replace(hour=8, minute=0, second=0, microsecond=0) + timedelta(days=2)
end = now + timedelta(hours=4)
# 1. Crear reserva existente en aula1
res1 = Reservation(
classroom_id=self.aula1_id,
commission_id=self.commission_id,
user_id=self.docente_id,
start_time=now,
end_time=end,
purpose='Clase regular',
expected_attendees=25,
status=ReservationStatus.CONFIRMED.value
)
db.session.add(res1)
db.session.commit()
# 2. Arrastre exitoso hacia aula2 (vacía en ese horario)
res_drag_ok = self.client.post(
'/api/v1/reservations/drag-update',
headers={'Authorization': f'Bearer {self.admin_token}'},
json={
'reservation_id': res1.id,
'classroom_id': self.aula2_id,
'start_time': now.isoformat(),
'end_time': end.isoformat()
}
)
self.assertEqual(res_drag_ok.status_code, 200)
self.assertEqual(res_drag_ok.get_json()['reservation']['classroom_id'], self.aula2_id)
# 3. Intentar arrastrar una nueva reserva sobre aula2 en el mismo horario (colisión física)
res_drag_conflict = self.client.post(
'/api/v1/reservations/drag-update',
headers={'Authorization': f'Bearer {self.admin_token}'},
json={
'commission_id': self.commission_id,
'classroom_id': self.aula2_id,
'start_time': now.isoformat(),
'end_time': end.isoformat()
}
)
self.assertEqual(res_drag_conflict.status_code, 409)
data_err = res_drag_conflict.get_json()
self.assertEqual(data_err.get('status'), 'conflict')
def test_genetic_algorithm_shift_schedule_and_apply(self):
"""Optimizador genético calcula horarios según turno real y persiste en BD."""
optimizer = ReservationOptimizer()
comm = db.session.get(Commission, self.commission_id)
# Verificar que calcula según turno Mañana
pref = optimizer._calculate_preferred_time(comm)
self.assertEqual(pref['start'].hour, 8)
self.assertEqual(pref['end'].hour, 12)
# Probar aplicación masiva de resultados válidos
res_data = [{
'commission_id': self.commission_id,
'classroom_id': self.aula1_id,
'user_id': self.admin_id,
'start_time': (datetime.utcnow() + timedelta(days=10)).isoformat(),
'end_time': (datetime.utcnow() + timedelta(days=10, hours=3)).isoformat(),
'purpose': 'Asignación Óptima con IA',
'expected_attendees': 20,
'status': 'CONFIRMED'
}]
applied = optimizer.apply_optimized_reservations(res_data)
self.assertEqual(len(applied), 1)
self.assertEqual(applied[0].classroom_id, self.aula1_id)
if __name__ == '__main__':
unittest.main()
+97
View File
@@ -301,4 +301,101 @@
align-items: center;
justify-content: center;
border-radius: 12px;
}
/* =======================================================
OPTIMIZACIONES GLOBALES: 720p (1360x768 / 1366x768)
Dedicadas al personal operativo de Bedelía en Argentina
======================================================= */
@media (max-height: 820px), (max-width: 1400px) {
/* Densidad de Información en Tablas */
.table td, .table th {
padding: 0.45rem 0.65rem !important;
font-size: 0.84rem !important;
vertical-align: middle;
}
.badge {
font-size: 0.72rem !important;
padding: 0.3em 0.6em !important;
}
/* Cards y Contenedores */
.card {
margin-bottom: 0.75rem !important;
border-radius: 10px !important;
}
.card-header {
padding: 0.65rem 0.9rem !important;
}
.card-body {
padding: 0.75rem 0.9rem !important;
}
.stat-card {
padding: 0.75rem !important;
}
/* Formularios compactos */
.form-control, .form-select {
padding: 0.38rem 0.65rem !important;
font-size: 0.84rem !important;
}
.form-label {
margin-bottom: 0.2rem !important;
font-size: 0.78rem !important;
}
.btn {
padding: 0.35rem 0.75rem !important;
font-size: 0.83rem !important;
}
.btn-sm {
padding: 0.22rem 0.55rem !important;
font-size: 0.78rem !important;
}
/* Reducción de espaciado en FullCalendar */
.fc .fc-toolbar {
margin-bottom: 0.5rem !important;
}
.fc .fc-toolbar-title {
font-size: 1.15rem !important;
}
.fc .fc-button {
padding: 0.25rem 0.55rem !important;
font-size: 0.8rem !important;
}
.fc-event {
padding: 2px 4px !important;
font-size: 0.75rem !important;
margin: 1px 0 !important;
}
.fc-timegrid-slot {
height: 1.7em !important;
}
/* Role banner en Dashboard */
.dashboard-role-banner {
padding: 0.75rem 1.1rem !important;
border-radius: 0.75rem !important;
margin-bottom: 0.85rem !important;
}
.dashboard-role-banner h1 {
font-size: 1.25rem !important;
}
}
/* =======================================================
OPTIMIZACIONES GLOBALES: 1080p (1920x1080)
======================================================= */
@media (min-width: 1600px) and (min-height: 900px) {
.table td, .table th {
padding: 0.75rem 1rem !important;
font-size: 0.92rem !important;
}
.card-body {
padding: 1.25rem !important;
}
.fc-event {
font-size: 0.82rem !important;
padding: 3px 6px !important;
}
}
+20
View File
@@ -189,10 +189,27 @@ app.use((req, res, next) => {
};
} catch (e) {}
}
let isImpersonating = false;
let impersonatedUser = null;
if (user.is_admin() && req.cookies.impersonate_user_id) {
isImpersonating = true;
const targetId = req.cookies.impersonate_user_id;
try {
if (req.cookies.impersonate_user_data) {
impersonatedUser = JSON.parse(req.cookies.impersonate_user_data);
}
} catch (e) {}
if (!impersonatedUser) {
impersonatedUser = { id: targetId, name: `Usuario #${targetId}`, role: 'usuario' };
}
}
req.user = user;
res.locals.user = user;
res.locals.current_user = user;
res.locals.role = user.role;
res.locals.is_impersonating = isImpersonating;
res.locals.impersonated_user = impersonatedUser;
res.locals.session = { get: (k, d) => d };
res.locals.occupancy_metrics = { get: (k, d) => d };
next();
@@ -259,6 +276,9 @@ app.use('/api', async (req, res) => {
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
if (req.cookies.impersonate_user_id) {
headers['X-Impersonate-User'] = req.cookies.impersonate_user_id;
}
const flaskUrl = `http://127.0.0.1:5000/api${req.url}`;
const response = await axios({
method: req.method,
@@ -65,10 +65,38 @@ const requireAuth = (req, res, next) => {
console.error('Error decoding token payload:', e.message);
}
let isImpersonating = false;
let impersonatedUser = null;
if (user.is_admin() && req.cookies.impersonate_user_id) {
isImpersonating = true;
const targetId = req.cookies.impersonate_user_id;
req.apiClient.defaults.headers['X-Impersonate-User'] = targetId;
try {
if (req.cookies.impersonate_user_data) {
impersonatedUser = JSON.parse(req.cookies.impersonate_user_data);
}
} catch (e) {
console.error('Error parsing impersonate_user_data:', e.message);
}
if (!impersonatedUser) {
impersonatedUser = {
id: targetId,
name: `Usuario #${targetId}`,
email: 'impersonated@edu-space.com',
role: 'usuario'
};
}
}
req.user = user;
res.locals.user = user;
res.locals.current_user = user;
res.locals.role = user.role;
res.locals.is_impersonating = isImpersonating;
res.locals.impersonated_user = impersonatedUser;
next();
};
+49
View File
@@ -38,9 +38,58 @@ router.post('/login', async (req, res) => {
}
});
const { requireAuth } = require('../middlewares/authMiddleware');
router.get('/logout', (req, res) => {
res.clearCookie('auth_token');
res.clearCookie('impersonate_user_id');
res.clearCookie('impersonate_user_data');
res.redirect('/auth/login');
});
// Iniciar Modo Impersonación (Solo Admin)
router.get('/impersonate/:id', requireAuth, async (req, res) => {
try {
const targetUserId = parseInt(req.params.id, 10);
if (!req.user || !req.user.is_admin()) {
return res.status(403).send('Solo los administradores pueden activar el modo de impersonación.');
}
const response = await req.apiClient.post('/admin/impersonate', {
user_id: targetUserId
});
const targetUser = response.data && response.data.target_user;
if (targetUser) {
res.cookie('impersonate_user_id', targetUserId.toString(), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax'
});
res.cookie('impersonate_user_data', JSON.stringify(targetUser), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax'
});
}
res.redirect('/dashboard');
} catch (error) {
console.error('Error initiating impersonation:', error.response?.data || error.message);
res.redirect('/admin/users_list?error=impersonate_failed');
}
});
// Detener Modo Impersonación
router.get('/stop-impersonating', requireAuth, async (req, res) => {
try {
await req.apiClient.post('/admin/stop-impersonating', {});
} catch (error) {
console.error('Error stopping impersonation on backend:', error.response?.data || error.message);
} finally {
res.clearCookie('impersonate_user_id');
res.clearCookie('impersonate_user_data');
res.redirect('/admin/users_list');
}
});
module.exports = router;
+60 -36
View File
@@ -176,6 +176,65 @@ router.get('/', async (req, res) => {
};
} else if (role === 'alumno') {
const studentClasses = apiTodaySchedule ? apiTodaySchedule.slice(0, 4) : null;
let upcomingMilestonesList = [];
try {
const mResp = await req.apiClient.get('/students/my-upcoming-milestones');
if (mResp.data && Array.isArray(mResp.data.upcoming_milestones) && mResp.data.upcoming_milestones.length > 0) {
upcomingMilestonesList = mResp.data.upcoming_milestones.map(m => {
const isFinal = (m.type || '').includes('Final');
const isParcial = (m.type || '').includes('Parcial');
const color = isFinal ? '#dc3545' : (isParcial ? '#fd7e14' : '#0d6efd');
return {
...m,
materia_codigo: m.commission_code || m.subject_name,
type_obj: { name: m.type, color },
milestone_type: { name: m.type, color }
};
});
}
} catch (e) {
console.warn('Student milestones fetch notice:', e.message);
}
if (upcomingMilestonesList.length === 0) {
upcomingMilestonesList = [
{
title: 'Primer Examen Parcial',
materia_codigo: 'BDR-201',
subject_name: 'Bases de Datos Relacionales',
date: '25/09/2026',
days_remaining: 2,
type_obj: { name: 'Examen Parcial', color: '#dc3545' },
milestone_type: { name: 'Examen Parcial', color: '#dc3545' },
notes: 'Traer DNI y calculadora',
classroom: 'Piso 2 · Aula 204'
},
{
title: 'Entrega TP Grupal Nº 2',
materia_codigo: 'AED-102',
subject_name: 'Algoritmos y Estructuras de Datos',
date: '30/09/2026',
days_remaining: 7,
type_obj: { name: 'Trabajo Práctico', color: '#198754' },
milestone_type: { name: 'Trabajo Práctico', color: '#198754' },
notes: 'Subir código al repositorio',
classroom: 'Campus Virtual',
link: 'https://campus.edu-space.com/tps'
},
{
title: 'Recuperatorio Parcial 1',
materia_codigo: 'RYT-302',
subject_name: 'Redes y Telecomunicaciones',
date: '08/10/2026',
days_remaining: 15,
type_obj: { name: 'Recuperatorio', color: '#fd7e14' },
milestone_type: { name: 'Recuperatorio', color: '#fd7e14' },
notes: 'Piso 2 · Aula 204',
classroom: 'Piso 2 · Aula 204'
}
];
}
role_data = {
today_classes: studentClasses || [
{
@@ -197,42 +256,7 @@ router.get('/', async (req, res) => {
virtual_link: 'https://meet.google.com/edu-space-demo'
}
],
upcoming_milestones: apiMilestones ? apiMilestones.map(m => ({
...m,
milestone_type: m.type_obj
})) : [
{
title: 'Primer Examen Parcial',
materia_codigo: 'BDR-201',
subject_name: 'Bases de Datos Relacionales',
date: '25/09/2026',
type_obj: { name: 'Examen Parcial', color: '#dc3545' },
milestone_type: { name: 'Examen Parcial', color: '#dc3545' },
notes: 'Traer DNI y calculadora',
classroom: 'Piso 2 · Aula 204'
},
{
title: 'Entrega TP Grupal Nº 2',
materia_codigo: 'AED-102',
subject_name: 'Algoritmos y Estructuras de Datos',
date: '30/09/2026',
type_obj: { name: 'Trabajo Práctico', color: '#198754' },
milestone_type: { name: 'Trabajo Práctico', color: '#198754' },
notes: 'Subir código al repositorio',
classroom: 'Campus Virtual',
link: 'https://campus.edu-space.com/tps'
},
{
title: 'Recuperatorio Parcial 1',
materia_codigo: 'RYT-302',
subject_name: 'Redes y Telecomunicaciones',
date: '08/10/2026',
type_obj: { name: 'Recuperatorio', color: '#fd7e14' },
milestone_type: { name: 'Recuperatorio', color: '#fd7e14' },
notes: 'Piso 2 · Aula 204',
classroom: 'Piso 2 · Aula 204'
}
]
upcoming_milestones: upcomingMilestonesList
};
}
+2
View File
@@ -98,5 +98,7 @@ const handleMySubjectDetail = async (req, res) => {
router.get(['/', '/list', '/my_subjects_list'], handleMySubjectsList);
router.get(['/detail', '/commission_detail', '/:id', '/detail/:id'], handleMySubjectDetail);
router.get(['/gradebook/:id', '/:id/gradebook'], (req, res) => res.redirect(`/schedule/gradebook/${req.params.id}`));
router.get(['/grades', '/my_grades', '/mis-notas'], (req, res) => res.redirect('/schedule/my_grades'));
module.exports = router;
+54 -1
View File
@@ -21,14 +21,24 @@ const handleCalendarView = async (req, res) => {
}
});
let unassignedCommissions = [];
try {
const commsRes = await req.apiClient.get('/genetic/commissions');
unassignedCommissions = (commsRes.data && commsRes.data.commissions) || [];
} catch (e) {
console.warn('Could not fetch unassigned commissions for calendar:', e.message);
}
res.render('schedule/calendar', {
title: 'Reservations Calendar - Edu-Space Admin',
today_date: new Date().toISOString().split('T')[0],
classrooms: classrooms.map(c => ({
id: c.id,
code: c.code,
building: c.building || c.building_name || 'Edificio Central'
building: c.building || c.building_name || 'Edificio Central',
capacity: c.capacity || 40
})),
unassigned_commissions: unassignedCommissions,
subjects: [
{ id: 1, name: 'Arquitectura de Software', code: 'ARQ-101' },
{ id: 2, name: 'Bases de Datos II', code: 'BD-201' },
@@ -48,6 +58,7 @@ const handleCalendarView = async (req, res) => {
title: 'Reservations Calendar (Offline)',
today_date: new Date().toISOString().split('T')[0],
classrooms: [],
unassigned_commissions: [],
subjects: [],
careers: [],
buildings: [],
@@ -581,6 +592,48 @@ router.post(['/edit_reservation', '/edit_reservation/:id', '/reservation/:id/edi
router.post(['/confirm_reservation', '/confirm_reservation/:id', '/reservation/:id/confirm'], handleConfirmReservation);
router.post(['/cancel_reservation', '/cancel_reservation/:id', '/reservation/:id/cancel'], handleCancelReservation);
// 9. Libro de Calificaciones / Gradebook (Fase 3)
const handleGradebook = async (req, res) => {
const id = req.params.id || req.query.id;
try {
const resp = await req.apiClient.get(`/commissions/${id}/gradebook`);
const data = resp.data || {};
res.render('schedule/gradebook', {
title: `Libro de Calificaciones — Edu-Space`,
commission: data.commission || { id, code: 'C1' },
milestone_columns: data.milestone_columns || [],
students: data.students || [],
role: req.user?.role || 'docente'
});
} catch (err) {
console.error('Error fetching commission gradebook:', err.message);
res.redirect(`/admin/commission_detail?id=${id}`);
}
};
// 10. Consulta de Calificaciones del Alumno (Fase 3)
const handleMyGrades = async (req, res) => {
try {
const resp = await req.apiClient.get('/students/my-grades');
const subjects = resp.data?.subjects || [];
res.render('schedule/my_grades', {
title: 'Mis Calificaciones — Edu-Space',
subjects,
role: req.user?.role || 'alumno'
});
} catch (err) {
console.error('Error fetching student grades:', err.message);
res.render('schedule/my_grades', {
title: 'Mis Calificaciones — Edu-Space',
subjects: [],
role: req.user?.role || 'alumno'
});
}
};
router.get(['/gradebook', '/gradebook/:id', '/commissions/:id/gradebook'], handleGradebook);
router.get(['/my_grades', '/my-grades', '/mis_calificaciones', '/mis-notas'], handleMyGrades);
module.exports = router;
@@ -101,6 +101,18 @@
</li>
{% endif %}
</ul>
<div class="mt-3 pt-2 border-top">
<a href="/schedule/gradebook/{{ commission.id }}" class="btn btn-primary btn-sm w-100 shadow-sm d-flex align-items-center justify-content-center gap-2">
<i class="bi bi-journal-check fs-6"></i>
<span class="fw-semibold">Libro de Calificaciones (Actas)</span>
</a>
{% if commission.grades_closed %}
<div class="badge bg-danger-subtle text-danger border border-danger-subtle w-100 mt-2 py-2 text-center">
<i class="bi bi-lock-fill me-1"></i>Acta Cerrada: {{ commission.acta_number }}
</div>
{% endif %}
</div>
</div>
</div>
+3
View File
@@ -183,6 +183,9 @@
<i class="bi bi-pencil"></i>
</a>
{% if u.id != user.id %}
<a href="/auth/impersonate/{{ u.id }}" class="btn btn-outline-info" title="Impersonar usuario (Iniciar sesión como {{ u.name }})">
<i class="bi bi-incognito"></i>
</a>
<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 %}"
+519 -225
View File
@@ -188,258 +188,488 @@
-webkit-text-fill-color: transparent;
font-weight: 700;
}
/* =======================================================
SIDEBAR & TOGGLE BUTTON DESIGN (720p, 1360x768 & 1080p)
======================================================= */
.app-wrapper {
min-height: 100vh;
display: flex;
}
.sidebar {
width: 250px;
transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
flex-shrink: 0;
z-index: 1040;
display: flex;
flex-direction: column;
border-right: 1px solid rgba(0, 0, 0, 0.08);
background: var(--bs-body-bg);
position: sticky;
top: 0;
height: 100vh;
}
[data-bs-theme="dark"] .sidebar {
border-right: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(24, 32, 47, 0.98);
}
/* Header del Sidebar */
.sidebar-header {
height: 54px;
padding: 0.5rem 0.85rem;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
transition: all 0.25s ease;
}
[data-bs-theme="dark"] .sidebar-header {
border-bottom-color: rgba(255, 255, 255, 0.07);
}
/* Botón de Toggle Limpio & Estilizado */
.sidebar-toggle-btn {
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.08);
background: rgba(0, 0, 0, 0.03);
color: #475569;
transition: all 0.2s ease-in-out;
cursor: pointer;
padding: 0;
outline: none !important;
box-shadow: none !important;
}
.sidebar-toggle-btn:hover {
background: rgba(180, 62, 142, 0.1);
border-color: rgba(180, 62, 142, 0.4);
color: #B43E8E !important;
transform: scale(1.05);
}
[data-bs-theme="dark"] .sidebar-toggle-btn {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.1);
color: #94A3B8;
}
[data-bs-theme="dark"] .sidebar-toggle-btn:hover {
background: rgba(255, 138, 182, 0.15);
border-color: rgba(255, 138, 182, 0.4);
color: #FF8AB6 !important;
transform: scale(1.05);
}
/* Sidebar Colapsado (Compacto, ultra limpio para pantallas chicas) */
.sidebar.collapsed {
width: 68px;
}
.sidebar.collapsed .brand-link {
display: none !important;
}
.sidebar.collapsed .sidebar-header {
justify-content: center !important;
padding: 0.5rem !important;
}
.sidebar.collapsed .brand-text,
.sidebar.collapsed .nav-text,
.sidebar.collapsed .nav-caret,
.sidebar.collapsed .sidebar-footer-text,
.sidebar.collapsed .dropdown-header {
display: none !important;
}
.sidebar.collapsed .nav-link {
justify-content: center !important;
padding: 0.55rem 0 !important;
}
.sidebar.collapsed .nav-link i {
margin-right: 0 !important;
font-size: 1.25rem !important;
}
.sidebar.collapsed .sidebar-footer {
padding: 0.5rem !important;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.sidebar.collapsed .sidebar-footer .theme-toggle-btn {
margin-bottom: 0.5rem !important;
}
.sidebar.collapsed .collapse {
display: none !important;
}
/* Navegación del Sidebar */
.sidebar-nav {
padding: 0;
list-style: none;
width: 100%;
}
.sidebar-nav .nav-item {
margin-bottom: 2px;
width: 100%;
}
.sidebar-nav .nav-link {
display: flex;
align-items: center;
padding: 0.5rem 0.85rem;
color: var(--bs-body-color);
border-radius: 8px;
text-decoration: none;
white-space: nowrap;
font-size: 0.88rem;
font-weight: 500;
transition: all 0.18s ease;
width: 100%;
}
.sidebar-nav .nav-link:hover, .sidebar-nav .nav-link[aria-expanded="true"] {
background-color: rgba(180, 62, 142, 0.08);
color: #B43E8E !important;
}
[data-bs-theme="dark"] .sidebar-nav .nav-link:hover, [data-bs-theme="dark"] .sidebar-nav .nav-link[aria-expanded="true"] {
background-color: rgba(255, 138, 182, 0.1);
color: #FF8AB6 !important;
}
.sidebar-nav .nav-link i {
font-size: 1.15rem;
min-width: 26px;
text-align: center;
margin-right: 10px;
}
.sidebar-submenu {
list-style: none;
padding-left: 2.2rem;
margin-top: 0.2rem;
}
.sidebar-submenu .nav-link {
padding: 0.35rem 0.75rem;
font-size: 0.83rem;
color: var(--bs-secondary-color);
}
/* Contenido Principal & Eliminación de Desperdicio Superior */
.main-content {
flex-grow: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: transparent;
}
.app-main-container {
padding-top: 0.85rem !important;
padding-bottom: 1.5rem !important;
}
/* Anular margen excesivo heredado de templates antiguos */
.app-main-container > .container-fluid.pt-5.mt-4,
.app-main-container > .container.pt-5.mt-4,
.app-main-container .pt-5.mt-4,
.main-content .container-fluid.pt-5.mt-4,
.main-content .container.pt-5.mt-4 {
padding-top: 0 !important;
margin-top: 0 !important;
}
/* Mobile Header */
.mobile-header {
display: none;
}
@media (max-width: 991.98px) {
.sidebar {
position: fixed;
left: -260px;
}
.sidebar.mobile-open {
left: 0;
}
.mobile-header {
display: flex;
background: var(--bs-body-bg);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
padding: 0.5rem 1rem;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 1030;
height: 52px;
}
[data-bs-theme="dark"] .mobile-header {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
}
/* =======================================================
OPTIMIZACIONES PARA 720p / 1360x768 (Mercado Argentino / Bedelía)
======================================================= */
@media (max-height: 820px), (max-width: 1400px) {
.sidebar-header {
height: 48px;
padding: 0.4rem 0.65rem;
}
.sidebar-header .brand-text {
font-size: 1.05rem !important;
}
.sidebar-header .navbar-logo {
height: 24px;
}
.sidebar-nav .nav-link {
padding: 0.4rem 0.75rem;
font-size: 0.83rem;
}
.sidebar-nav .nav-link i {
font-size: 1.05rem;
min-width: 22px;
margin-right: 8px;
}
.sidebar-submenu .nav-link {
padding: 0.28rem 0.65rem;
font-size: 0.8rem;
}
.sidebar-footer {
padding: 0.4rem 0.65rem !important;
}
.app-main-container {
padding-top: 0.5rem !important;
padding-left: 0.85rem !important;
padding-right: 0.85rem !important;
}
/* Títulos y márgenes más compactos para ganar viewport útil */
h1.h3, .h3, h1.h4, .h4 {
font-size: 1.25rem !important;
margin-bottom: 0.2rem !important;
}
.dashboard-role-banner {
padding: 0.75rem 1rem !important;
margin-bottom: 0.85rem !important;
}
.card {
margin-bottom: 0.85rem !important;
}
.card-body {
padding: 0.85rem !important;
}
}
/* =======================================================
OPTIMIZACIONES PARA 1080p (1920x1080 / Monitores Grandes)
======================================================= */
@media (min-width: 1600px) and (min-height: 900px) {
.sidebar {
width: 270px;
}
.sidebar-header {
height: 60px;
padding: 0.75rem 1rem;
}
.sidebar-nav .nav-link {
padding: 0.58rem 1rem;
font-size: 0.92rem;
}
.app-main-container {
padding-top: 1.25rem !important;
padding-left: 2rem !important;
padding-right: 2rem !important;
}
}
.brand-link span {
background: linear-gradient(90deg, #B43E8E, #682CAD);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 700;
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body data-user-authenticated="{{ 'true' if (user and user.is_authenticated) else 'false' }}">
{% if user.is_authenticated %}
<!-- Navigation -->
<nav class="navbar navbar-expand-lg glass-navbar fixed-top">
<div class="container-fluid">
<a class="navbar-brand d-flex align-items-center" href="/dashboard">
<img src="/img/logo_unicaba_mini.png" alt="Logo UniCABA" class="navbar-logo me-2" height="30">
<span>Edu-Space Admin</span>
</a>
<div class="app-wrapper">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header border-bottom border-secondary-subtle">
<a href="/dashboard" class="text-decoration-none d-flex align-items-center brand-link">
<img src="/img/logo_unicaba_mini.png" alt="Logo UniCABA" class="navbar-logo me-2" height="26">
<span class="brand-text fs-5">Edu-Space</span>
</a>
<button class="sidebar-toggle-btn d-none d-lg-inline-flex" id="sidebarToggle" type="button" aria-label="Alternar menú lateral" title="Alternar menú lateral">
<i class="bi bi-layout-sidebar-inset fs-5"></i>
</button>
<button class="sidebar-toggle-btn d-lg-none" id="sidebarCloseMobile" type="button" aria-label="Cerrar menú">
<i class="bi bi-x-lg fs-5"></i>
</button>
</div>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
{% set user_role = 'admin' %}
<ul class="navbar-nav me-auto">
<div class="sidebar-body flex-grow-1 overflow-y-auto p-2">
<ul class="sidebar-nav" id="sidebarNav">
{% set user_role = role if role else 'admin' %}
{% if user_role == 'admin' %}
<!-- ================= ADMIN NAVIGATION ================= -->
<!-- ADMIN -->
<li class="nav-item">
<a class="nav-link" href="/dashboard">
<i class="bi bi-speedometer2 me-1"></i>Panel General
</a>
<a class="nav-link" href="/dashboard"><i class="bi bi-speedometer2 text-primary"></i> <span class="nav-text">Panel General</span></a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-door-open me-1"></i>Espacios & Sedes
<li class="nav-item">
<a class="nav-link collapsed" href="#menuEspacios" data-bs-toggle="collapse" role="button" aria-expanded="false">
<i class="bi bi-door-open text-info"></i> <span class="nav-text flex-grow-1">Espacios & Sedes</span> <i class="bi bi-chevron-down nav-caret ms-auto fs-6"></i>
</a>
<ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="/classrooms/list_classrooms?view_mode=physical"><i class="bi bi-door-open me-2 text-primary"></i>Aulas Presenciales</a></li>
<li><a class="dropdown-item" href="/classrooms/list_classrooms?view_mode=virtual"><i class="bi bi-camera-video me-2 text-info"></i>Aulas Virtuales (Reunión)</a></li>
<li><a class="dropdown-item" href="/classrooms/list_classrooms"><i class="bi bi-grid me-2 text-secondary"></i>Todas las Aulas</a></li>
<li><a class="dropdown-item" href="/classrooms/add_classroom"><i class="bi bi-plus-circle me-2 text-success"></i>Nueva Aula</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="/buildings/list_buildings"><i class="bi bi-buildings me-2 text-secondary"></i>Edificios y Sedes</a></li>
<li><a class="dropdown-item" href="/admin/occupancy_metrics"><i class="bi bi-graph-up-arrow me-2 text-success"></i>Métricas de Ocupación</a></li>
</ul>
<div class="collapse" id="menuEspacios" data-bs-parent="#sidebarNav">
<ul class="sidebar-submenu">
<li><a class="nav-link" href="/classrooms/list_classrooms?view_mode=physical">Presenciales</a></li>
<li><a class="nav-link" href="/classrooms/list_classrooms?view_mode=virtual">Virtuales</a></li>
<li><a class="nav-link" href="/classrooms/list_classrooms">Todas las Aulas</a></li>
<li><a class="nav-link text-success" href="/classrooms/add_classroom"><i class="bi bi-plus-circle me-1" style="min-width:auto; font-size:1rem;"></i>Nueva Aula</a></li>
<li><a class="nav-link" href="/buildings/list_buildings">Edificios</a></li>
<li><a class="nav-link" href="/admin/occupancy_metrics">Métricas</a></li>
</ul>
</div>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-calendar3 me-1"></i>Cronograma & Reservas
<li class="nav-item">
<a class="nav-link collapsed" href="#menuReservas" data-bs-toggle="collapse" role="button" aria-expanded="false">
<i class="bi bi-calendar3 text-warning"></i> <span class="nav-text flex-grow-1">Reservas</span> <i class="bi bi-chevron-down nav-caret ms-auto fs-6"></i>
</a>
<ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="/schedule/calendar_view"><i class="bi bi-calendar-week me-2 text-primary"></i>Calendario General</a></li>
<li><a class="dropdown-item" href="/schedule/today_schedule"><i class="bi bi-calendar-day me-2 text-info"></i>Cartelera del Día</a></li>
<li><a class="dropdown-item" href="/schedule/list_reservations"><i class="bi bi-list-check me-2 text-warning"></i>Gestión de Reservas</a></li>
<li><a class="dropdown-item" href="/schedule/add_reservation"><i class="bi bi-plus-circle me-2 text-success"></i>Nueva Reserva</a></li>
<li><a class="dropdown-item" href="/schedule/commissions_view"><i class="bi bi-diagram-3 me-2 text-primary"></i>Comisiones y Cursadas</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="/genetic_algorithm_web/genetic_optimizer"><i class="bi bi-cpu me-2 text-info"></i>Optimizador de Aulas (IA)</a></li>
</ul>
<div class="collapse" id="menuReservas" data-bs-parent="#sidebarNav">
<ul class="sidebar-submenu">
<li><a class="nav-link" href="/schedule/calendar_view">Calendario</a></li>
<li><a class="nav-link" href="/schedule/today_schedule">Cartelera</a></li>
<li><a class="nav-link" href="/schedule/list_reservations">Gestión</a></li>
<li><a class="nav-link text-success" href="/schedule/add_reservation"><i class="bi bi-plus-circle me-1" style="min-width:auto; font-size:1rem;"></i>Nueva</a></li>
<li><a class="nav-link" href="/schedule/commissions_view">Comisiones</a></li>
<li><a class="nav-link text-info fw-semibold" href="/genetic_algorithm_web/genetic_optimizer"><i class="bi bi-cpu me-1" style="min-width:auto; font-size:1rem;"></i>IA Optimizer</a></li>
</ul>
</div>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-mortarboard me-1"></i>Gestión Académica
<li class="nav-item">
<a class="nav-link collapsed" href="#menuAcademica" data-bs-toggle="collapse" role="button" aria-expanded="false">
<i class="bi bi-mortarboard text-success"></i> <span class="nav-text flex-grow-1">Académica</span> <i class="bi bi-chevron-down nav-caret ms-auto fs-6"></i>
</a>
<ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="/admin/careers_list"><i class="bi bi-mortarboard me-2 text-primary"></i>Carreras</a></li>
<li><a class="dropdown-item" href="/admin/subjects_list"><i class="bi bi-book me-2 text-info"></i>Asignaturas</a></li>
<li><a class="dropdown-item" href="/admin/commissions_list"><i class="bi bi-diagram-3 me-2 text-success"></i>Comisiones</a></li>
<li><a class="dropdown-item" href="/admin/academic_terms_list"><i class="bi bi-calendar-range me-2 text-warning"></i>Ciclo Lectivo</a></li>
</ul>
<div class="collapse" id="menuAcademica" data-bs-parent="#sidebarNav">
<ul class="sidebar-submenu">
<li><a class="nav-link" href="/admin/careers_list">Carreras</a></li>
<li><a class="nav-link" href="/admin/subjects_list">Asignaturas</a></li>
<li><a class="nav-link" href="/admin/commissions_list">Comisiones</a></li>
<li><a class="nav-link" href="/admin/academic_terms_list">Ciclo Lectivo</a></li>
</ul>
</div>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-shield-lock me-1"></i>Administración
<li class="nav-item">
<a class="nav-link collapsed" href="#menuAdmin" data-bs-toggle="collapse" role="button" aria-expanded="false">
<i class="bi bi-shield-lock text-secondary"></i> <span class="nav-text flex-grow-1">Administración</span> <i class="bi bi-chevron-down nav-caret ms-auto fs-6"></i>
</a>
<ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="/admin/users_list"><i class="bi bi-people me-2 text-primary"></i>Usuarios</a></li>
<li><a class="dropdown-item" href="/admin/roles_list"><i class="bi bi-person-badge me-2 text-info"></i>Roles y Permisos RBAC</a></li>
<li><a class="dropdown-item" href="/admin/milestone_types_list"><i class="bi bi-tags me-2 text-secondary"></i>Tipificaciones de Hitos</a></li>
<li><a class="dropdown-item" href="/admin/google_sheets_import"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>Sincronización Sheets</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="/admin/audit_logs"><i class="bi bi-shield-check me-2 text-warning"></i>Registro de Auditoría</a></li>
</ul>
<div class="collapse" id="menuAdmin" data-bs-parent="#sidebarNav">
<ul class="sidebar-submenu">
<li><a class="nav-link" href="/admin/users_list">Usuarios</a></li>
<li><a class="nav-link" href="/admin/roles_list">Roles RBAC</a></li>
<li><a class="nav-link" href="/admin/milestone_types_list">Tipos Hitos</a></li>
<li><a class="nav-link" href="/admin/google_sheets_import">Sincro Sheets</a></li>
<li><a class="nav-link" href="/admin/audit_logs">Auditoría</a></li>
</ul>
</div>
</li>
{% elif user_role == 'bedelia' %}
<!-- ================= BEDELÍA NAVIGATION ================= -->
<li class="nav-item">
<a class="nav-link" href="/dashboard">
<i class="bi bi-speedometer2 me-1"></i>Panel Bedelía
</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-door-open me-1"></i>Espacios & Aulas
</a>
<ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="/classrooms/list_classrooms?view_mode=physical"><i class="bi bi-door-open me-2 text-primary"></i>Aulas Presenciales</a></li>
<li><a class="dropdown-item" href="/classrooms/list_classrooms?view_mode=virtual"><i class="bi bi-camera-video me-2 text-info"></i>Aulas Virtuales (Reunión)</a></li>
<li><a class="dropdown-item" href="/classrooms/list_classrooms"><i class="bi bi-grid me-2 text-secondary"></i>Todas las Aulas</a></li>
<li><a class="dropdown-item" href="/classrooms/add_classroom"><i class="bi bi-plus-circle me-2 text-success"></i>Nueva Aula</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="/buildings/list_buildings"><i class="bi bi-buildings me-2 text-secondary"></i>Edificios y Sedes</a></li>
<li><a class="dropdown-item" href="/admin/occupancy_metrics"><i class="bi bi-graph-up-arrow me-2 text-success"></i>Métricas de Ocupación</a></li>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-calendar3 me-1"></i>Operaciones & Reservas
</a>
<ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="/schedule/today_schedule"><i class="bi bi-calendar-day me-2 text-info"></i>Cartelera del Día</a></li>
<li><a class="dropdown-item" href="/schedule/calendar_view"><i class="bi bi-calendar-week me-2 text-primary"></i>Calendario General</a></li>
<li><a class="dropdown-item" href="/schedule/list_reservations"><i class="bi bi-list-check me-2 text-warning"></i>Gestión de Reservas</a></li>
<li><a class="dropdown-item" href="/schedule/add_reservation"><i class="bi bi-plus-circle me-2 text-success"></i>Registrar Reserva</a></li>
<li><a class="dropdown-item" href="/schedule/commissions_view"><i class="bi bi-diagram-3 me-2 text-primary"></i>Comisiones y Cursadas</a></li>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-mortarboard me-1"></i>Académica & Cursadas
</a>
<ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="/admin/careers_list"><i class="bi bi-mortarboard me-2 text-primary"></i>Carreras</a></li>
<li><a class="dropdown-item" href="/admin/subjects_list"><i class="bi bi-book me-2 text-info"></i>Asignaturas</a></li>
<li><a class="dropdown-item" href="/admin/commissions_list"><i class="bi bi-diagram-3 me-2 text-success"></i>Gestión de Comisiones</a></li>
<li><a class="dropdown-item" href="/admin/academic_terms_list"><i class="bi bi-calendar-range me-2 text-warning"></i>Ciclo Lectivo</a></li>
<li><a class="dropdown-item" href="/admin/milestone_types_list"><i class="bi bi-tags me-2 text-secondary"></i>Tipificaciones de Hitos</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="/admin/google_sheets_import"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>Sincronización Sheets</a></li>
</ul>
</li>
<!-- BEDELIA -->
<li class="nav-item"><a class="nav-link" href="/dashboard"><i class="bi bi-speedometer2 text-warning"></i> <span class="nav-text">Panel Bedelía</span></a></li>
<li class="nav-item"><a class="nav-link" href="/classrooms/list_classrooms"><i class="bi bi-door-open text-primary"></i> <span class="nav-text">Aulas y Espacios</span></a></li>
<li class="nav-item"><a class="nav-link" href="/schedule/today_schedule"><i class="bi bi-calendar-day text-info"></i> <span class="nav-text">Cartelera Diaria</span></a></li>
<li class="nav-item"><a class="nav-link" href="/schedule/list_reservations"><i class="bi bi-list-check text-success"></i> <span class="nav-text">Gestión Reservas</span></a></li>
<li class="nav-item"><a class="nav-link" href="/schedule/add_reservation"><i class="bi bi-plus-circle text-success"></i> <span class="nav-text">Nueva Reserva</span></a></li>
<li class="nav-item"><a class="nav-link" href="/admin/commissions_list"><i class="bi bi-diagram-3 text-primary"></i> <span class="nav-text">Comisiones</span></a></li>
{% elif user_role == 'docente' %}
<!-- ================= DOCENTE / PROFESOR NAVIGATION ================= -->
<li class="nav-item">
<a class="nav-link" href="/dashboard">
<i class="bi bi-speedometer2 me-1"></i>Panel Docente
</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-calendar-check me-1"></i>Mis Clases & Reservas
</a>
<ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="/schedule/list_reservations"><i class="bi bi-calendar2-check me-2 text-primary"></i>Mis Reservas de Aulas</a></li>
<li><a class="dropdown-item" href="/schedule/add_reservation"><i class="bi bi-plus-circle me-2 text-success"></i>Solicitar Nueva Reserva</a></li>
<li><a class="dropdown-item" href="/schedule/commissions_view"><i class="bi bi-diagram-3 me-2 text-info"></i>Mis Comisiones & Cursadas</a></li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link fw-semibold" href="/my_subjects/my_subjects_list">
<i class="bi bi-mortarboard me-1 text-info"></i>Mis Materias
</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-building me-1"></i>Campus & Horarios
</a>
<ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="/schedule/today_schedule"><i class="bi bi-calendar-day me-2 text-info"></i>Cartelera del Día (¿Dónde curso hoy?)</a></li>
<li><a class="dropdown-item" href="/schedule/calendar_view"><i class="bi bi-calendar-week me-2 text-primary"></i>Calendario General de Aulas</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="/classrooms/list_classrooms"><i class="bi bi-camera-video me-2 text-info"></i>Aulas Virtuales (Reuniones)</a></li>
<li><a class="dropdown-item" href="/classrooms/list_classrooms"><i class="bi bi-door-open me-2 text-secondary"></i>Consulta de Aulas Presenciales</a></li>
</ul>
</li>
<!-- DOCENTE -->
<li class="nav-item"><a class="nav-link" href="/dashboard"><i class="bi bi-speedometer2 text-info"></i> <span class="nav-text">Panel Docente</span></a></li>
<li class="nav-item"><a class="nav-link" href="/schedule/list_reservations"><i class="bi bi-calendar2-check text-primary"></i> <span class="nav-text">Mis Reservas</span></a></li>
<li class="nav-item"><a class="nav-link" href="/schedule/add_reservation"><i class="bi bi-plus-circle text-success"></i> <span class="nav-text">Solicitar Reserva</span></a></li>
<li class="nav-item"><a class="nav-link" href="/schedule/commissions_view"><i class="bi bi-diagram-3 text-info"></i> <span class="nav-text">Mis Comisiones</span></a></li>
<li class="nav-item"><a class="nav-link" href="/my_subjects/my_subjects_list"><i class="bi bi-mortarboard text-warning"></i> <span class="nav-text">Mis Materias</span></a></li>
<li class="nav-item"><a class="nav-link" href="/schedule/today_schedule"><i class="bi bi-geo-alt text-danger"></i> <span class="nav-text">Cartelera Hoy</span></a></li>
{% else %}
<!-- ================= ALUMNO / ESTUDIANTE NAVIGATION ================= -->
<li class="nav-item">
<a class="nav-link" href="/dashboard">
<i class="bi bi-house-door me-1"></i>Mi Portal
</a>
</li>
<li class="nav-item">
<a class="nav-link fw-semibold" href="/my_subjects/my_subjects_list">
<i class="bi bi-mortarboard me-1 text-success"></i>Mis Materias
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/schedule/today_schedule">
<i class="bi bi-geo-alt me-1"></i>Cartelera de Hoy
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/schedule/calendar_view">
<i class="bi bi-calendar-week me-1"></i>Cronograma de Clases
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/classrooms/list_classrooms">
<i class="bi bi-camera-video me-1"></i>Aulas Virtuales
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/classrooms/list_classrooms">
<i class="bi bi-door-open me-1"></i>Espacios del Campus
</a>
</li>
<!-- ALUMNO -->
<li class="nav-item"><a class="nav-link" href="/dashboard"><i class="bi bi-house-door text-success"></i> <span class="nav-text">Mi Portal</span></a></li>
<li class="nav-item"><a class="nav-link" href="/my_subjects/my_subjects_list"><i class="bi bi-mortarboard text-primary"></i> <span class="nav-text">Mis Materias</span></a></li>
<li class="nav-item"><a class="nav-link" href="/schedule/today_schedule"><i class="bi bi-geo-alt text-danger"></i> <span class="nav-text">Cartelera Hoy</span></a></li>
<li class="nav-item"><a class="nav-link" href="/schedule/calendar_view"><i class="bi bi-calendar-week text-info"></i> <span class="nav-text">Cronograma</span></a></li>
<li class="nav-item"><a class="nav-link" href="/classrooms/list_classrooms"><i class="bi bi-camera-video text-secondary"></i> <span class="nav-text">Aulas Virtuales</span></a></li>
{% endif %}
</ul>
<ul class="navbar-nav align-items-center">
<!-- Theme Mode Toggle Button -->
<li class="nav-item me-2">
<button class="theme-toggle-btn" type="button" aria-label="Cambiar tema" title="Modo Claro/Oscuro">
<i class="bi bi-moon-stars-fill"></i>
</button>
</li>
<!-- Language Switcher -->
<li class="nav-item me-2">
{% include 'partials/language_switcher.html' %}
</li>
<!-- User Dropdown with Avatar and Institutional Role -->
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle d-flex align-items-center py-1" href="#" role="button" data-bs-toggle="dropdown">
<span class="user-avatar-small">{{ user.name[0]|upper if user.name else 'U' }}</span>
<div class="d-none d-lg-block text-start ms-2">
<div class="lh-1 small fw-semibold">{{ user.first_name }}</div>
<span class="badge {{ user.role_badge_display.class }} role-pill mt-1" style="font-size: 0.65rem; padding: 0.15rem 0.45rem;">
<i class="bi {{ user.role_badge_display.icon }}"></i> {{ user.role_badge_display.label }}
</span>
</div>
</a>
<ul class="dropdown-menu dropdown-menu-end shadow-sm border-0">
<li>
<div class="px-3 py-2 border-bottom">
<small class="text-muted d-block">Sesión iniciada como</small>
<span class="fw-bold small d-block">{{ user.email }}</span>
<span class="badge {{ user.role_badge_display.class }} role-pill mt-1">
<i class="bi {{ user.role_badge_display.icon }}"></i> {{ user.role_badge_display.label }}
</span>
</div>
</li>
<li><a class="dropdown-item" href="/auth/profile">
<i class="bi bi-person me-2"></i> Mi Perfil
</a></li>
<li><a class="dropdown-item" href="/auth/change_password">
<i class="bi bi-key me-2"></i> Cambiar Contraseña
</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="/auth/logout">
<i class="bi bi-box-arrow-right me-2"></i> Cerrar Sesión
</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
{% endif %}
<!-- Flash Messages -->
<div class="sidebar-footer p-3 border-top border-secondary-subtle">
<div class="d-flex align-items-center justify-content-between mb-3 sidebar-footer-text">
{% include 'partials/language_switcher.html' %}
<button class="theme-toggle-btn" type="button" aria-label="Cambiar tema" title="Modo Claro/Oscuro">
<i class="bi bi-moon-stars-fill"></i>
</button>
</div>
<div class="dropdown dropup">
<a href="#" class="d-flex align-items-center text-decoration-none dropdown-toggle text-body w-100" data-bs-toggle="dropdown" aria-expanded="false">
<div class="user-avatar-small bg-primary text-white rounded-circle d-flex align-items-center justify-content-center" style="width:32px; height:32px; font-weight:bold; flex-shrink:0;">
{{ user.name[0]|upper if user.name else 'U' }}
</div>
<div class="ms-2 sidebar-footer-text text-truncate flex-grow-1" style="min-width:0;">
<small class="d-block fw-semibold text-truncate">{{ user.first_name }}</small>
<small class="text-muted d-block text-truncate" style="font-size:0.7rem;">{{ user.role_badge_display.label }}</small>
</div>
</a>
<ul class="dropdown-menu shadow-sm border-0 mb-2">
<li><h6 class="dropdown-header">{{ user.email }}</h6></li>
<li><a class="dropdown-item" href="/auth/profile"><i class="bi bi-person me-2"></i>Mi Perfil</a></li>
<li><a class="dropdown-item" href="/auth/change_password"><i class="bi bi-key me-2"></i>Cambiar Contraseña</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="/auth/logout"><i class="bi bi-box-arrow-right me-2"></i>Cerrar Sesión</a></li>
</ul>
</div>
</div>
</aside>
<!-- Main Content Wrapper -->
<div class="main-content">
<!-- Mobile Header -->
<div class="mobile-header shadow-sm">
<a href="/dashboard" class="text-decoration-none brand-link d-flex align-items-center">
<img src="/img/logo_unicaba_mini.png" alt="Logo UniCABA" class="navbar-logo me-2" height="24">
<span class="brand-text fw-bold fs-6">Edu-Space</span>
</a>
<button class="sidebar-toggle-btn" id="mobileMenuBtn" type="button" aria-label="Abrir menú">
<i class="bi bi-list fs-4"></i>
</button>
</div>
{% if is_impersonating %}
<!-- Banner Impersonación Activa -->
<div class="impersonation-banner px-3 py-2 d-flex align-items-center justify-content-between text-white shadow-sm" style="background: linear-gradient(90deg, #d97706 0%, #b45309 100%); z-index: 1045; border-bottom: 2px solid #f59e0b;">
<div class="d-flex align-items-center gap-2">
<div class="spinner-grow spinner-grow-sm text-warning" role="status" style="width: 14px; height: 14px;">
<span class="visually-hidden">Impersonating...</span>
</div>
<div>
<strong class="me-1"><i class="bi bi-incognito me-1"></i>Modo Impersonación Activo:</strong>
<span>Estás navegando como</span>
<strong class="text-warning-emphasis bg-white bg-opacity-25 px-2 py-0.5 rounded">{{ impersonated_user.name or impersonated_user.first_name or impersonated_user.username }}</strong>
<span class="badge bg-dark bg-opacity-50 text-white ms-1">{{ impersonated_user.role|capitalize }}</span>
<small class="ms-2 opacity-75 d-none d-md-inline">· Registro de auditoría activo (todas las acciones se trazan a tu usuario admin)</small>
</div>
</div>
<div>
<a href="/auth/stop-impersonating" class="btn btn-sm btn-light text-dark fw-bold shadow-sm d-inline-flex align-items-center">
<i class="bi bi-box-arrow-left me-1"></i> Salir de Impersonación
</a>
</div>
</div>
{% endif %}
<main class="container-fluid app-main-container px-3 px-xl-4">
<!-- Flash Messages -->
<div class="toast-container position-fixed bottom-0 end-0 p-3" style="z-index: 1080;">
</div>
@@ -466,5 +696,69 @@
</script>
{% block extra_js %}{% endblock %}
</main>
</div><!-- main-content -->
</div><!-- app-wrapper -->
{% endif %}
<!-- Sidebar Toggle JS -->
<script>
document.addEventListener("DOMContentLoaded", function() {
const sidebar = document.getElementById("sidebar");
const toggleBtn = document.getElementById("sidebarToggle");
const mobileBtn = document.getElementById("mobileMenuBtn");
const closeMobileBtn = document.getElementById("sidebarCloseMobile");
if (!sidebar) return;
function updateToggleIcon() {
if (!toggleBtn) return;
const isCollapsed = sidebar.classList.contains("collapsed");
const icon = toggleBtn.querySelector("i");
if (icon) {
icon.className = isCollapsed ? "bi bi-layout-sidebar fs-5" : "bi bi-layout-sidebar-inset fs-5";
}
toggleBtn.setAttribute("title", isCollapsed ? "Expandir menú" : "Colapsar menú");
toggleBtn.setAttribute("aria-label", isCollapsed ? "Expandir menú" : "Colapsar menú");
}
// Restore state from localStorage (desktop only)
if (window.innerWidth >= 992) {
const isCollapsed = localStorage.getItem("sidebarCollapsed") === "true";
if (isCollapsed) {
sidebar.classList.add("collapsed");
}
}
updateToggleIcon();
if (toggleBtn) {
toggleBtn.addEventListener("click", function() {
sidebar.classList.toggle("collapsed");
localStorage.setItem("sidebarCollapsed", sidebar.classList.contains("collapsed"));
updateToggleIcon();
// Collapse any open submenus when collapsing sidebar
if (sidebar.classList.contains("collapsed")) {
document.querySelectorAll('.sidebar-submenu').forEach(function(submenu) {
const collapseInstance = bootstrap.Collapse.getInstance(submenu.parentElement);
if (collapseInstance) collapseInstance.hide();
});
}
});
}
if (mobileBtn) {
mobileBtn.addEventListener("click", function() {
sidebar.classList.add("mobile-open");
});
}
if (closeMobileBtn) {
closeMobileBtn.addEventListener("click", function() {
sidebar.classList.remove("mobile-open");
});
}
});
</script>
</body>
</html>
+7 -3
View File
@@ -3,10 +3,10 @@
{% block title %}Add Classroom - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<div class="container-fluid px-0">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1 small">
@@ -171,10 +171,14 @@
{% endblock %}
{% block extra_js %}
<script type="application/json" id="buildingFloorsData">
{{ building_floors|tojson|safe }}
</script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Map of existing floors per building
var buildingFloors = {{ building_floors|tojson }};
var buildingFloorsDataEl = document.getElementById('buildingFloorsData');
var buildingFloors = buildingFloorsDataEl ? JSON.parse(buildingFloorsDataEl.textContent || '{}') : {};
var buildingSelect = document.getElementById('building_id');
var newBuildingContainer = document.getElementById('newBuildingContainer');
+7 -3
View File
@@ -3,9 +3,9 @@
{% block title %}Edit Classroom - {{ classroom.code_display }}{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<div class="container-fluid px-0">
<!-- Header -->
<div class="row mb-4 align-items-center">
<div class="row mb-3 align-items-center">
<div class="col-md-7">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1 small">
@@ -165,9 +165,13 @@
{% endblock %}
{% block extra_js %}
<script type="application/json" id="buildingFloorsData">
{{ building_floors|tojson|safe }}
</script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var buildingFloors = {{ building_floors|tojson }};
var buildingFloorsDataEl = document.getElementById('buildingFloorsData');
var buildingFloors = buildingFloorsDataEl ? JSON.parse(buildingFloorsDataEl.textContent || '{}') : {};
var buildingSelect = document.getElementById('building_id');
var newBuildingContainer = document.getElementById('newBuildingContainer');
+1 -1
View File
@@ -129,7 +129,7 @@
{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<div class="container-fluid px-0">
<!-- 1. Encabezado y Selector de Entorno (Presencial vs Virtual) -->
<div class="row mb-3">
+25 -16
View File
@@ -14,13 +14,13 @@
{% block content %}
{% if user.is_authenticated %}
<div class="container-fluid pt-5 mt-4">
<div class="container-fluid px-0">
{% endif %}
<!-- ========================================================================= -->
<!-- 1. HEADER & ROLE PERSPECTIVE SWITCHER -->
<!-- ========================================================================= -->
<div class="dashboard-role-banner mb-4 shadow-sm">
<div class="dashboard-role-banner mb-3 shadow-sm">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
<div>
<div class="d-flex align-items-center gap-2 mb-1 flex-wrap">
@@ -252,12 +252,12 @@
</span>
</div>
<div class="progress mb-3" style="height: 14px;">
<div class="progress-bar bg-success progress-bar-striped" role="progressbar" style="width: {{ stats.assigned_classrooms_pct }}%;"
<div class="progress-bar bg-success progress-bar-striped" role="progressbar" {{ ('style="width: ' ~ (stats.assigned_classrooms_pct or 0) ~ '%;"') | safe }}
aria-valuenow="{{ stats.assigned_classrooms_pct }}" aria-valuemin="0" aria-valuemax="100"
title="Asignadas: {{ stats.assigned_classrooms }} ({{ stats.assigned_classrooms_pct }}%)">
{{ stats.assigned_classrooms_pct }}%
</div>
<div class="progress-bar bg-warning" role="progressbar" style="width: {{ stats.unassigned_classrooms_pct }}%;"
<div class="progress-bar bg-warning" role="progressbar" {{ ('style="width: ' ~ (stats.unassigned_classrooms_pct or 0) ~ '%;"') | safe }}
aria-valuenow="{{ stats.unassigned_classrooms_pct }}" aria-valuemin="0" aria-valuemax="100"
title="Sin Asignar: {{ stats.unassigned_classrooms }} ({{ stats.unassigned_classrooms_pct }}%)">
{{ stats.unassigned_classrooms_pct }}%
@@ -274,11 +274,11 @@
</span>
</div>
<div class="progress" style="height: 10px;">
<div class="progress-bar bg-info" role="progressbar" style="width: {{ stats.assigned_today_pct }}%;"
<div class="progress-bar bg-info" role="progressbar" {{ ('style="width: ' ~ (stats.assigned_today_pct or 0) ~ '%;"') | safe }}
aria-valuenow="{{ stats.assigned_today_pct }}" aria-valuemin="0" aria-valuemax="100"
title="En uso hoy: {{ stats.assigned_today_classrooms }}">
</div>
<div class="progress-bar bg-secondary-subtle" role="progressbar" style="width: {{ stats.unassigned_today_pct }}%;"
<div class="progress-bar bg-secondary-subtle" role="progressbar" {{ ('style="width: ' ~ (stats.unassigned_today_pct or 0) ~ '%;"') | safe }}
aria-valuenow="{{ stats.unassigned_today_pct }}" aria-valuemin="0" aria-valuemax="100"
title="Libres hoy: {{ stats.unassigned_today_classrooms }}">
</div>
@@ -596,12 +596,12 @@
</span>
</div>
<div class="progress mb-3" style="height: 14px;">
<div class="progress-bar bg-success progress-bar-striped" role="progressbar" style="width: {{ stats.assigned_classrooms_pct }}%;"
<div class="progress-bar bg-success progress-bar-striped" role="progressbar" {{ ('style="width: ' ~ (stats.assigned_classrooms_pct or 0) ~ '%;"') | safe }}
aria-valuenow="{{ stats.assigned_classrooms_pct }}" aria-valuemin="0" aria-valuemax="100"
title="Asignadas: {{ stats.assigned_classrooms }} ({{ stats.assigned_classrooms_pct }}%)">
{{ stats.assigned_classrooms_pct }}%
</div>
<div class="progress-bar bg-warning" role="progressbar" style="width: {{ stats.unassigned_classrooms_pct }}%;"
<div class="progress-bar bg-warning" role="progressbar" {{ ('style="width: ' ~ (stats.unassigned_classrooms_pct or 0) ~ '%;"') | safe }}
aria-valuenow="{{ stats.unassigned_classrooms_pct }}" aria-valuemin="0" aria-valuemax="100"
title="Sin Asignar: {{ stats.unassigned_classrooms }} ({{ stats.unassigned_classrooms_pct }}%)">
{{ stats.unassigned_classrooms_pct }}%
@@ -618,11 +618,11 @@
</span>
</div>
<div class="progress" style="height: 10px;">
<div class="progress-bar bg-info" role="progressbar" style="width: {{ stats.assigned_today_pct }}%;"
<div class="progress-bar bg-info" role="progressbar" {{ ('style="width: ' ~ (stats.assigned_today_pct or 0) ~ '%;"') | safe }}
aria-valuenow="{{ stats.assigned_today_pct }}" aria-valuemin="0" aria-valuemax="100"
title="En uso hoy: {{ stats.assigned_today_classrooms }}">
</div>
<div class="progress-bar bg-secondary-subtle" role="progressbar" style="width: {{ stats.unassigned_today_pct }}%;"
<div class="progress-bar bg-secondary-subtle" role="progressbar" {{ ('style="width: ' ~ (stats.unassigned_today_pct or 0) ~ '%;"') | safe }}
aria-valuenow="{{ stats.unassigned_today_pct }}" aria-valuemin="0" aria-valuemax="100"
title="Libres hoy: {{ stats.unassigned_today_classrooms }}">
</div>
@@ -1346,14 +1346,14 @@
</a>
</div>
<div class="col-md-6 col-lg-3">
<a href="/classrooms/list_classrooms" class="card h-100 p-3 action-menu-card bg-body">
<a href="/schedule/my_grades" class="card h-100 p-3 action-menu-card bg-body">
<div class="d-flex align-items-center">
<div class="quick-icon-circle bg-warning-subtle text-warning me-3">
<i class="bi bi-buildings fs-4"></i>
<i class="bi bi-journal-check fs-4"></i>
</div>
<div>
<h6 class="mb-1 fw-bold text-body">Espacios del Campus</h6>
<p class="text-muted small mb-0">Sedes, edificios y planos</p>
<h6 class="mb-1 fw-bold text-body">Mis Calificaciones</h6>
<p class="text-muted small mb-0">Notas, regularidad y actas</p>
</div>
</div>
</a>
@@ -1375,7 +1375,7 @@
<table class="table table-hover align-middle mb-0">
<thead class="table-light small">
<tr>
<th class="ps-3">Fecha</th>
<th class="ps-3">Fecha & Cuenta Regresiva</th>
<th>Tipo de Hito</th>
<th>Materia / Código</th>
<th>Aula / Enlace</th>
@@ -1386,7 +1386,16 @@
{% for m in role_data.upcoming_milestones %}
<tr>
<td class="ps-3 font-monospace fw-bold text-primary">
{{ m.date }}
<div>{{ m.date }}</div>
{% if m.days_remaining is not none and m.days_remaining is defined %}
{% if m.days_remaining == 0 %}
<span class="badge bg-danger text-white small">¡Hoy!</span>
{% elif m.days_remaining == 1 %}
<span class="badge bg-warning text-dark small">¡Mañana!</span>
{% elif m.days_remaining > 1 %}
<span class="badge bg-info-subtle text-info border small">Faltan {{ m.days_remaining }} días</span>
{% endif %}
{% endif %}
</td>
<td>
<span class="badge" data-color="{{ m.type_obj.color if m.type_obj else '#0d6efd' }}" style="background-color:var(--badge-bg,#0d6efd);color:#fff;">
+185 -28
View File
@@ -31,17 +31,35 @@
transition: background-color 0.25s ease, color 0.25s ease;
}
/* =============================================
NAVBAR COMPACTA — limpia y sin desperdicio
============================================= */
.glass-navbar {
padding: 0 !important; /* Bootstrap agrega padding, lo neutralizamos */
min-height: 48px;
}
.glass-navbar .container-fluid {
padding-top: 0.35rem;
padding-bottom: 0.35rem;
}
/* --- MODO CLARO ([data-bs-theme="light"]) --- */
[data-bs-theme="light"] body {
background: #F8FAFC !important;
color: #1E293B !important;
}
[data-bs-theme="light"] .glass-navbar {
background: rgba(255, 255, 255, 0.88) !important;
background: rgba(255, 255, 255, 0.92) !important;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(0, 0, 0, 0.08) !important;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04) !important;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05) !important;
}
[data-bs-theme="light"] .glass-navbar .navbar-brand span {
background: linear-gradient(90deg, #B43E8E, #682CAD);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
[data-bs-theme="light"] .glass-navbar .nav-link {
color: #334155 !important;
@@ -49,6 +67,8 @@
[data-bs-theme="light"] .glass-navbar .nav-link:hover,
[data-bs-theme="light"] .glass-navbar .nav-link.active {
color: #B43E8E !important;
background: rgba(180, 62, 142, 0.06);
border-radius: 7px;
}
[data-bs-theme="light"] .glass-card,
[data-bs-theme="light"] .card {
@@ -57,6 +77,16 @@
border: 1px solid rgba(0, 0, 0, 0.08) !important;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04) !important;
}
/* Toggle button - modo claro */
[data-bs-theme="light"] .navbar-toggle-custom {
color: #334155;
border-color: rgba(51, 65, 85, 0.25);
}
[data-bs-theme="light"] .navbar-toggle-custom:hover {
background: rgba(180, 62, 142, 0.08);
border-color: #B43E8E;
color: #B43E8E;
}
/* --- MODO OSCURO ([data-bs-theme="dark"]) --- */
[data-bs-theme="dark"] body {
@@ -64,11 +94,17 @@
color: #F8FAFC !important;
}
[data-bs-theme="dark"] .glass-navbar {
background: rgba(15, 23, 42, 0.78) !important;
background: rgba(15, 23, 42, 0.82) !important;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.3) !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.07) !important;
box-shadow: 0 2px 20px rgba(0, 0, 0, 0.35) !important;
}
[data-bs-theme="dark"] .glass-navbar .navbar-brand span {
background: linear-gradient(90deg, #FF8AB6, #AE7FFF);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
[data-bs-theme="dark"] .glass-navbar .nav-link {
color: #CBD5E1 !important;
@@ -76,6 +112,8 @@
[data-bs-theme="dark"] .glass-navbar .nav-link:hover,
[data-bs-theme="dark"] .glass-navbar .nav-link.active {
color: #FF8AB6 !important;
background: rgba(255, 138, 182, 0.08);
border-radius: 7px;
}
[data-bs-theme="dark"] .glass-card,
[data-bs-theme="dark"] .card {
@@ -84,18 +122,123 @@
border: 1px solid rgba(255, 255, 255, 0.08) !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3) !important;
}
.navbar-brand span {
font-weight: 700;
background: linear-gradient(90deg, #B43E8E, #682CAD);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
/* Toggle button - modo oscuro */
[data-bs-theme="dark"] .navbar-toggle-custom {
color: #CBD5E1;
border-color: rgba(203, 213, 225, 0.25);
}
[data-bs-theme="dark"] .navbar-toggle-custom:hover {
background: rgba(255, 138, 182, 0.1);
border-color: rgba(255, 138, 182, 0.5);
color: #FF8AB6;
}
/* =============================================
BRAND / LOGO
============================================= */
.navbar-brand {
font-weight: 700;
padding-top: 0;
padding-bottom: 0;
}
.navbar-brand span {
font-weight: 700;
font-size: 0.95rem;
letter-spacing: -0.01em;
}
.navbar-logo {
height: 26px;
width: auto;
}
/* =============================================
NAV LINKS — compactos
============================================= */
.glass-navbar .nav-link {
font-size: 0.84rem;
font-weight: 500;
padding: 0.3rem 0.65rem !important;
border-radius: 7px;
transition: color 0.15s ease, background 0.15s ease;
white-space: nowrap;
}
.glass-navbar .nav-link i {
font-size: 0.82rem;
}
/* =============================================
TOGGLE BUTTON — limpio, pill style
============================================= */
.navbar-toggle-custom {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 32px;
padding: 0;
border-radius: 8px;
border: 1px solid transparent;
background: transparent;
cursor: pointer;
transition: all 0.18s ease;
outline: none;
box-shadow: none !important;
}
.navbar-toggle-custom i {
font-size: 1.3rem;
line-height: 1;
}
/* =============================================
MAIN CONTAINER — espacio eficiente
============================================= */
.main-container {
flex: 1;
padding: 2rem 0;
padding: 1rem 0 1.5rem;
}
/* =============================================
RESPONSIVE — 720p (1360x768)
============================================= */
@media (max-height: 800px) {
.main-container {
padding: 0.65rem 0 1rem;
}
.glass-navbar .container-fluid {
padding-top: 0.25rem;
padding-bottom: 0.25rem;
}
}
/* =============================================
RESPONSIVE — 1920x1080 (pantallas grandes)
============================================= */
@media (min-width: 1600px) {
.main-container {
padding: 1.25rem 0 2rem;
max-width: 1600px;
}
}
/* =============================================
MENU COLAPSADO (mobile / tablet)
============================================= */
@media (max-width: 991.98px) {
.glass-navbar .navbar-collapse {
padding: 0.5rem 0;
border-top: 1px solid rgba(128, 128, 128, 0.12);
margin-top: 0.3rem;
}
.glass-navbar .nav-link {
padding: 0.45rem 0.75rem !important;
border-radius: 8px;
margin: 1px 0;
}
.glass-navbar .navbar-nav.ms-auto {
padding-top: 0.5rem;
border-top: 1px solid rgba(128, 128, 128, 0.1);
margin-top: 0.25rem !important;
}
}
.fade-in-up {
@@ -117,42 +260,56 @@
<body>
<!-- Navbar -->
<nav class="navbar navbar-expand-lg glass-navbar sticky-top">
<div class="container-fluid px-4">
<a class="navbar-brand d-flex align-items-center" href="/dashboard">
<img src="/img/logo_unicaba_mini.png" alt="Logo UniCABA" class="navbar-logo me-2" height="30">
<div class="container-fluid px-3 px-lg-4">
<!-- Brand -->
<a class="navbar-brand d-flex align-items-center gap-2" href="/dashboard">
<img src="/img/logo_unicaba_mini.png" alt="Logo UniCABA" class="navbar-logo">
<span>Edu-Space Admin</span>
</a>
<button class="navbar-toggler border-0" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<i class="bi bi-list text-light fs-2"></i>
<!-- Toggle button — limpio, sin foco feo de Bootstrap -->
<button
class="navbar-toggle-custom d-lg-none"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarNav"
aria-controls="navbarNav"
aria-expanded="false"
aria-label="Menú"
>
<i class="bi bi-list"></i>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto align-items-center ms-3">
<!-- Nav principal -->
<ul class="navbar-nav me-auto align-items-lg-center ms-lg-2 gap-lg-1">
<li class="nav-item">
<a class="nav-link px-3" href="/dashboard">
<a class="nav-link" href="/dashboard">
<i class="bi bi-speedometer2 me-1"></i>Panel General
</a>
</li>
<li class="nav-item">
<a class="nav-link px-3" href="/classrooms/list_classrooms">
<i class="bi bi-door-open me-1"></i>Espacios & Sedes
<a class="nav-link" href="/classrooms/list_classrooms">
<i class="bi bi-door-open me-1"></i>Espacios &amp; Sedes
</a>
</li>
<li class="nav-item">
<a class="nav-link px-3" href="/schedule/calendar_view">
<i class="bi bi-calendar3 me-1"></i>Cronograma & Reservas
<a class="nav-link" href="/schedule/calendar_view">
<i class="bi bi-calendar3 me-1"></i>Cronograma &amp; Reservas
</a>
</li>
<li class="nav-item">
<a class="nav-link px-3 active text-primary fw-semibold" href="/predictive">
<a class="nav-link" href="/predictive">
<i class="bi bi-cpu me-1"></i>Módulo Predictivo
</a>
</li>
</ul>
<ul class="navbar-nav ms-auto align-items-center">
<!-- Nav derecha -->
<ul class="navbar-nav align-items-lg-center ms-auto gap-2">
<li class="nav-item">
<a class="btn btn-outline-danger btn-sm rounded-pill px-3 fw-medium" href="/auth/logout">
<i class="bi bi-box-arrow-right me-1"></i> Cerrar Sesión
<i class="bi bi-box-arrow-right me-1"></i>Cerrar Sesión
</a>
</li>
</ul>
@@ -161,7 +318,7 @@
</nav>
<!-- Main Content -->
<main class="container main-container">
<main class="container-fluid container-xl main-container px-3 px-lg-4">
{% block content %}{% endblock %}
</main>
+240 -29
View File
@@ -3,29 +3,29 @@
{% block title %}Reservations Calendar - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<div class="container-fluid px-0">
<!-- Header -->
<div class="row mb-4 align-items-center">
<div class="row mb-3 align-items-center">
<div class="col-md-7">
<h1 class="h3 mb-1">
<h1 class="h4 mb-1 fw-bold">
<i class="bi bi-calendar3 text-primary me-2"></i>Reservations Calendar
</h1>
<p class="text-muted small mb-0">View & Manage Reservations — Horarios de cursada y asignación de aulas UniCABA</p>
<p class="text-muted small mb-0">Gestión de Reservas — Horarios de cursada y asignación de aulas UniCABA</p>
</div>
<div class="col-md-5 text-md-end mt-3 mt-md-0">
<div class="col-md-5 text-md-end mt-2 mt-md-0">
{% if user.has_permission('reservations', 'read_write') %}
<a href="/schedule/add_reservation" class="btn btn-primary me-2">
<a href="/schedule/add_reservation" class="btn btn-primary btn-sm me-2">
<i class="bi bi-plus-circle me-1"></i>Add Reservation
</a>
{% endif %}
<a href="/schedule/list_reservations" class="btn btn-outline-secondary">
<a href="/schedule/list_reservations" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-list me-1"></i>List View
</a>
</div>
</div>
<!-- Filters Toolbar (Classroom & Subject Filters to prevent overlapping) -->
<div class="card border-0 shadow-sm mb-4">
<div class="card border-0 shadow-sm mb-3">
<div class="card-body p-3">
<div class="row g-2 align-items-center">
<!-- Filter by Classroom -->
@@ -141,32 +141,81 @@
</div>
</div>
<!-- Calendar Card -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary border-0 py-3">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
<div class="d-flex align-items-center gap-3">
<span class="fw-bold small text-secondary text-uppercase tracking-wide">
<i class="bi bi-info-circle me-1"></i>Referencia:
<!-- Grid Layout: Panel Lateral de Comisiones Pendientes + Calendario Principal -->
<div class="row g-3">
<!-- Sidebar de Comisiones Pendientes de Aula (Drag & Drop) -->
<div class="col-xl-3 col-lg-4" id="unassignedCommissionsSidebar">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-body-tertiary border-0 py-2 d-flex justify-content-between align-items-center">
<span class="fw-bold small text-body">
<i class="bi bi-grip-vertical text-primary me-1"></i>Comisiones por Asignar
</span>
<div class="d-flex align-items-center">
<span class="badge badge-brand me-1" style="width: 14px; height: 14px; border-radius: 4px;"></span>
<small class="text-muted">Clase Confirmada</small>
</div>
<div class="d-flex align-items-center">
<span class="badge bg-warning me-1" style="width: 14px; height: 14px; border-radius: 4px;"></span>
<small class="text-muted">Pendiente</small>
</div>
<span class="badge bg-primary rounded-pill" id="unassignedCount">{{ unassigned_commissions|length }}</span>
</div>
<div class="text-muted small">
<i class="bi bi-lightning-charge-fill text-warning me-1"></i>Haz clic en cualquier clase para ver los detalles completos
<div class="card-body p-2" style="max-height: 800px; overflow-y: auto;">
<div class="alert alert-info border-0 py-2 px-2 small mb-2 d-flex align-items-center">
<i class="bi bi-info-circle-fill me-2 fs-6"></i>
<span>Arrastra una comisión a la grilla para asignarla al aula filtrada.</span>
</div>
<div id="externalEventsContainer" class="d-flex flex-column gap-2">
{% for c in unassigned_commissions %}
<div class="fc-event-drag-item p-2 rounded-2 border shadow-sm"
data-commission-id="{{ c.id }}"
data-subject-name="{{ c.subject.name if c.subject else 'Materia' }}"
data-code="{{ c.get_full_code or c.code }}"
data-students="{{ c.current_students or 0 }}"
data-shift="{{ c.shift or 'Regular' }}"
style="cursor: grab; background-color: var(--bs-body-bg); border-left: 4px solid #B43E8E !important; user-select: none;">
<div class="d-flex justify-content-between align-items-center">
<strong class="text-primary small font-monospace">{{ c.get_full_code or c.code }}</strong>
<span class="badge bg-secondary-subtle text-secondary small"><i class="bi bi-people me-1"></i>{{ c.current_students or 0 }} al.</span>
</div>
<div class="small fw-semibold text-truncate text-body mt-1">{{ c.subject.name if c.subject else 'Materia' }}</div>
<div class="d-flex justify-content-between align-items-center mt-1">
<small class="text-muted" style="font-size: 0.72rem;">Turno: {{ c.shift or 'Regular' }}</small>
<span class="badge bg-primary-subtle text-primary border" style="font-size: 0.65rem;">Arrastrar <i class="bi bi-arrows-move"></i></span>
</div>
</div>
{% else %}
<div class="text-center py-4 text-muted small" id="noUnassignedPlaceholder">
<i class="bi bi-check2-circle fs-3 text-success d-block mb-1"></i>
Todas las comisiones tienen aula asignada.
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<div class="card-body p-3">
<!-- FullCalendar Container -->
<div id="calendar"></div>
<!-- Columna del Calendario Principal -->
<div class="col-xl-9 col-lg-8" id="calendarMainCol">
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary border-0 py-3">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
<div class="d-flex align-items-center gap-3">
<span class="fw-bold small text-secondary text-uppercase tracking-wide">
<i class="bi bi-info-circle me-1"></i>Referencia:
</span>
<div class="d-flex align-items-center">
<span class="badge badge-brand me-1" style="width: 14px; height: 14px; border-radius: 4px;"></span>
<small class="text-muted">Clase Confirmada</small>
</div>
<div class="d-flex align-items-center">
<span class="badge bg-warning me-1" style="width: 14px; height: 14px; border-radius: 4px;"></span>
<small class="text-muted">Pendiente</small>
</div>
</div>
<div class="text-muted small">
<i class="bi bi-arrows-move text-primary me-1"></i><strong>Drag & Drop activo:</strong> Mueve clases en el calendario con validación de choques en tiempo real
</div>
</div>
</div>
<div class="card-body p-3">
<!-- FullCalendar Container -->
<div id="calendar"></div>
</div>
</div>
</div>
</div>
@@ -759,6 +808,10 @@ document.addEventListener('DOMContentLoaded', function() {
initialView: 'timeGridWeek',
initialDate: todayDate,
allDaySlot: false,
editable: true,
droppable: true,
eventDurationEditable: true,
eventResizableFromStart: false,
headerToolbar: {
left: 'prev,next today',
center: 'title',
@@ -784,6 +837,17 @@ document.addEventListener('DOMContentLoaded', function() {
slotEventOverlap: true,
expandRows: true,
stickyHeaderDates: true,
/* Drag and Drop Handlers con Validación de Colisión en Tiempo Real */
eventDrop: function(info) {
handleEventDragOrResize(info, false);
},
eventResize: function(info) {
handleEventDragOrResize(info, true);
},
eventReceive: function(info) {
handleExternalDrop(info);
},
/* Dynamic Server-Side Filtered Events */
events: function(info, successCallback, failureCallback) {
@@ -1018,6 +1082,153 @@ document.addEventListener('DOMContentLoaded', function() {
modal.show();
}
});
// Soporte Drag and Drop externo para Comisiones Pendientes
var containerEl = document.getElementById('externalEventsContainer');
if (containerEl && typeof FullCalendar.Draggable !== 'undefined') {
new FullCalendar.Draggable(containerEl, {
itemSelector: '.fc-event-drag-item',
eventData: function(eventEl) {
return {
title: (eventEl.getAttribute('data-code') || 'Comisión') + ' - ' + (eventEl.getAttribute('data-subject-name') || ''),
extendedProps: {
commission_id: eventEl.getAttribute('data-commission-id'),
subject_name: eventEl.getAttribute('data-subject-name'),
is_external_commission: true
},
duration: '02:00'
};
}
});
}
function showToast(title, message, details, type) {
var toastContainer = document.querySelector('.toast-container');
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.className = 'toast-container position-fixed bottom-0 end-0 p-3';
toastContainer.style.zIndex = '1090';
document.body.appendChild(toastContainer);
}
var bgClass = type === 'danger' ? 'bg-danger text-white' : (type === 'warning' ? 'bg-warning text-dark' : 'bg-success text-white');
var iconClass = type === 'danger' ? 'bi-exclamation-octagon-fill' : (type === 'warning' ? 'bi-exclamation-triangle-fill' : 'bi-check-circle-fill');
var detailsHtml = '';
if (details && details.length > 0) {
detailsHtml = '<ul class="mb-0 mt-1 ps-3 small">' + details.map(function(d) { return '<li>' + d + '</li>'; }).join('') + '</ul>';
}
var toastEl = document.createElement('div');
toastEl.className = 'toast align-items-center ' + bgClass + ' border-0 shadow-lg mb-2';
toastEl.setAttribute('role', 'alert');
toastEl.setAttribute('aria-live', 'assertive');
toastEl.setAttribute('aria-atomic', 'true');
toastEl.innerHTML = '<div class="d-flex"><div class="toast-body"><div class="fw-bold"><i class="bi ' + iconClass + ' me-2"></i>' + title + '</div><div class="small">' + message + '</div>' + detailsHtml + '</div><button type="button" class="btn-close ' + (type === 'warning' ? '' : 'btn-close-white') + ' me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button></div>';
toastContainer.appendChild(toastEl);
var bsToast = new bootstrap.Toast(toastEl, { delay: 6000 });
bsToast.show();
toastEl.addEventListener('hidden.bs.toast', function() {
toastEl.remove();
});
}
function handleEventDragOrResize(info, isResize) {
var startIso = info.event.start ? info.event.start.toISOString() : null;
var endIso = info.event.end ? info.event.end.toISOString() : (info.event.start ? new Date(info.event.start.getTime() + 2 * 3600 * 1000).toISOString() : null);
var ext = info.event.extendedProps || {};
var classroomId = filterRoom.value ? parseInt(filterRoom.value, 10) : (ext.classroom_id || null);
fetch('/api/v1/reservations/drag-update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
reservation_id: parseInt(info.event.id, 10),
start_time: startIso,
end_time: endIso,
classroom_id: classroomId
})
})
.then(function(res) {
return res.json().then(function(data) {
return { status: res.status, data: data };
});
})
.then(function(result) {
if (result.status === 409 || (result.data && result.data.status === 'conflict')) {
info.revert();
var reasons = (result.data.conflicts || []).concat(result.data.block_reasons || []);
showToast('Conflicto de Horario Detectado', result.data.message || 'No se puede reubicar la clase en ese espacio/horario.', reasons, 'danger');
} else if (result.status >= 400) {
info.revert();
showToast('Error', result.data.message || 'Ocurrió un error al actualizar la reserva.', [], 'danger');
} else {
showToast('Reserva Actualizada', result.data.message || 'Horario y aula confirmados con éxito.', [], 'success');
}
})
.catch(function(err) {
info.revert();
console.error('Drag update error:', err);
showToast('Error de Conexión', 'No se pudo comunicar con el servidor.', [], 'danger');
});
}
function handleExternalDrop(info) {
var ext = info.event.extendedProps || {};
var commissionId = ext.commission_id;
var startIso = info.event.start ? info.event.start.toISOString() : null;
var endIso = info.event.end ? info.event.end.toISOString() : (info.event.start ? new Date(info.event.start.getTime() + 2 * 3600 * 1000).toISOString() : null);
var classroomId = filterRoom.value ? parseInt(filterRoom.value, 10) : null;
if (!classroomId) {
info.revert();
showToast('Aula Requerida', 'Por favor selecciona un Aula en el filtro superior antes de arrastrar comisiones a la grilla.', [], 'warning');
return;
}
fetch('/api/v1/reservations/drag-update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
commission_id: parseInt(commissionId, 10),
start_time: startIso,
end_time: endIso,
classroom_id: classroomId
})
})
.then(function(res) {
return res.json().then(function(data) {
return { status: res.status, data: data };
});
})
.then(function(result) {
if (result.status === 409 || (result.data && result.data.status === 'conflict')) {
info.revert();
var reasons = (result.data.conflicts || []).concat(result.data.block_reasons || []);
showToast('Conflicto al Asignar Comisión', result.data.message || 'El aula o docente no están disponibles en este horario.', reasons, 'danger');
} else if (result.status >= 400) {
info.revert();
showToast('Error', result.data.message || 'No se pudo asignar el aula a la comisión.', [], 'danger');
} else {
showToast('Comisión Asignada', result.data.message || 'Comisión asignada con éxito al aula.', [], 'success');
var draggedItem = document.querySelector('.fc-event-drag-item[data-commission-id="' + commissionId + '"]');
if (draggedItem) {
draggedItem.remove();
var countBadge = document.getElementById('unassignedCount');
if (countBadge) {
var curr = parseInt(countBadge.textContent, 10) || 1;
countBadge.textContent = Math.max(0, curr - 1);
}
}
calendar.refetchEvents();
}
})
.catch(function(err) {
info.revert();
console.error('External drop error:', err);
showToast('Error de Conexión', 'No se pudo registrar la asignación.', [], 'danger');
});
}
calendar.render();
+4 -4
View File
@@ -512,9 +512,9 @@
{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<div class="container-fluid px-0">
<!-- Header -->
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-3">
<div>
<h1 class="h3 fw-bold mb-1">
<i class="bi bi-diagram-3 text-primary me-2"></i>Gestión de Comisiones y Cursadas
@@ -720,7 +720,7 @@
<div class="d-flex flex-wrap align-items-center gap-1">
{% for m in commission.academic_milestones %}
<span class="badge comm-milestone-chip text-white d-inline-flex align-items-center py-1 px-2"
style="background-color: {{ m.color }}; font-size: 0.72rem;"
{{ ('style="background-color: ' ~ (m.color or '#6c757d') ~ '; font-size: 0.72rem;"') | safe }}
title="{{ m.title }} - {{ m.date if m.date else '' }} ({{ m.classroom or 'Campus Virtual' }})">
<i class="bi bi-flag-fill me-1" style="font-size: 0.65rem;"></i>
{{ m.title or m.tipo }}
@@ -753,7 +753,7 @@
{% set fill_pct = (commission.current_students / commission.max_students * 100) if commission.max_students > 0 else 0 %}
<div class="progress mt-1 mx-auto" style="height: 4px; width: 50px;">
<div class="progress-bar {% if fill_pct > 90 %}bg-danger{% elif fill_pct > 70 %}bg-warning{% else %}bg-success{% endif %}"
style="width: {{ [fill_pct, 100]|min }}%;"></div>
{{ ('style="width: ' ~ ([fill_pct, 100]|min) ~ '%;"') | safe }}></div>
</div>
</td>
<td class="text-end pe-4">
+527
View File
@@ -0,0 +1,527 @@
{% extends 'base.html' %}
{% block title %}Libro de Calificaciones — {{ commission.full_code or commission.code }} — Edu-Space{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb small">
<li class="breadcrumb-item"><a href="/dashboard">Inicio</a></li>
<li class="breadcrumb-item"><a href="/admin/commissions_list">Comisiones</a></li>
<li class="breadcrumb-item"><a href="/admin/commission_detail?id={{ commission.id }}">{{ commission.full_code or commission.code }}</a></li>
<li class="breadcrumb-item active">Libro de Calificaciones</li>
</ol>
</nav>
<!-- Header Banner -->
<div class="card border-0 shadow-sm mb-4 bg-body">
<div class="card-body p-4">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3">
<div>
<div class="d-flex align-items-center gap-2 mb-1">
<span class="badge bg-primary-subtle text-primary border border-primary-subtle font-monospace px-2 py-1">
{{ commission.full_code or commission.code }}
</span>
<span class="badge bg-secondary-subtle text-secondary border font-monospace">
{{ commission.semester }} {{ commission.year }}
</span>
{% if commission.grades_closed %}
<span class="badge bg-danger text-white px-2 py-1 shadow-sm">
<i class="bi bi-lock-fill me-1"></i>ACTA CERRADA ({{ commission.acta_number }})
</span>
{% else %}
<span class="badge bg-success-subtle text-success border border-success-subtle px-2 py-1">
<i class="bi bi-unlock-fill me-1"></i>CALIFICACIONES ABIERTAS
</span>
{% endif %}
</div>
<h3 class="fw-bold mb-1">{{ commission.subject.name if commission.subject else 'Materia' }}</h3>
<p class="text-muted small mb-0">
<i class="bi bi-clock me-1"></i>Turno: <strong>{{ commission.shift or 'A definir' }}</strong>
{% if commission.teachers and commission.teachers|length > 0 %}
<span class="mx-2">·</span>
<i class="bi bi-person-badge me-1"></i>Cátedra:
{% for t in commission.teachers %}
<span class="badge bg-light text-dark border me-1">{{ t.name }} ({{ t.role_in_commission or 'Docente' }})</span>
{% endfor %}
{% endif %}
</p>
</div>
<div class="d-flex flex-wrap align-items-center gap-2">
<!-- Live Auto-save indicator -->
<div id="saveStatusIndicator" class="small px-3 py-1 rounded-pill bg-body-tertiary border text-muted d-flex align-items-center gap-2">
<i class="bi bi-cloud-check text-success fs-6" id="saveStatusIcon"></i>
<span id="saveStatusText">Todos los cambios guardados</span>
</div>
<!-- Print / Export -->
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="window.print()">
<i class="bi bi-printer me-1"></i>Imprimir Planilla
</button>
<!-- Acta Action Buttons -->
{% if commission.grades_closed %}
{% if role in ['admin', 'bedelia'] %}
<button type="button" class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#reopenActaModal">
<i class="bi bi-unlock me-1"></i>Reabrir Acta
</button>
{% endif %}
{% else %}
<button type="button" class="btn btn-danger btn-sm shadow-sm" data-bs-toggle="modal" data-bs-target="#closeActaModal">
<i class="bi bi-file-earmark-lock me-1"></i>Cerrar Acta Formal
</button>
{% endif %}
</div>
</div>
</div>
</div>
<!-- KPI Summary Cards -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm p-3 h-100">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small fw-semibold text-uppercase">Total Inscriptos</span>
<h3 class="fw-bold mb-0 mt-1" id="kpiTotalStudents">{{ students|length }}</h3>
</div>
<div class="bg-primary bg-opacity-10 text-primary rounded-circle p-3">
<i class="bi bi-people fs-4"></i>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm p-3 h-100">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small fw-semibold text-uppercase">Promocionados (≥ 7)</span>
<h3 class="fw-bold mb-0 mt-1 text-success" id="kpiPromoted">0</h3>
</div>
<div class="bg-success bg-opacity-10 text-success rounded-circle p-3">
<i class="bi bi-award fs-4"></i>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm p-3 h-100">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small fw-semibold text-uppercase">Regulares (4 a 6.9)</span>
<h3 class="fw-bold mb-0 mt-1 text-primary" id="kpiRegular">0</h3>
</div>
<div class="bg-info bg-opacity-10 text-info rounded-circle p-3">
<i class="bi bi-check2-circle fs-4"></i>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm p-3 h-100">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small fw-semibold text-uppercase">Libres (< 4)</span>
<h3 class="fw-bold mb-0 mt-1 text-danger" id="kpiLibre">0</h3>
</div>
<div class="bg-danger bg-opacity-10 text-danger rounded-circle p-3">
<i class="bi bi-x-circle fs-4"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Gradebook Matrix Table Card -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-table me-2 text-primary"></i>Matriz de Evaluaciones y Calificaciones
</h5>
<small class="text-muted">Las notas se guardan automáticamente al escribirlas. Escala: 1 a 10 (Ausente: A o vacío).</small>
</div>
<div class="d-flex align-items-center gap-2">
<input type="text" id="filterStudentInput" class="form-control form-control-sm" placeholder="Buscar alumno o legajo..." style="max-width: 220px;">
</div>
</div>
<div class="card-body p-0">
{% if students and students|length > 0 %}
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" id="gradebookTable">
<thead class="table-light small">
<tr>
<th class="ps-3" style="width: 60px;">#</th>
<th style="min-width: 220px;">Estudiante</th>
<th style="min-width: 110px;">Legajo</th>
{% for col in milestone_columns %}
<th class="text-center" style="min-width: 130px;">
<div>{{ col.title }}</div>
<small class="text-muted fw-normal font-monospace">({{ col.date }})</small>
</th>
{% endfor %}
<th class="text-center" style="min-width: 110px;">Promedio</th>
<th class="text-center pe-3" style="min-width: 140px;">Condición Final</th>
</tr>
</thead>
<tbody>
{% for s in students %}
<tr data-student-id="{{ s.id }}" data-student-name="{{ s.name|lower }}" data-student-dni="{{ s.dni or '' }}">
<td class="ps-3 font-monospace text-muted small">{{ loop.index }}</td>
<td>
<div class="fw-semibold text-body">{{ s.name }}</div>
<small class="text-muted font-monospace">{{ s.email }}</small>
</td>
<td>
<span class="badge bg-secondary-subtle text-secondary font-monospace">
{{ s.dni or ('LEG-' + s.id) }}
</span>
</td>
{% for col in milestone_columns %}
{% set grade_obj = s.grades[col.id] %}
<td class="text-center">
<div class="d-flex justify-content-center align-items-center">
<input
type="number"
step="0.5"
min="1"
max="10"
class="form-control form-control-sm text-center font-monospace fw-bold grade-input"
style="max-width: 85px; font-size: 0.95rem;"
data-milestone-id="{{ col.id }}"
data-student-id="{{ s.id }}"
value="{{ grade_obj.score if grade_obj and grade_obj.score is not none else '' }}"
placeholder="-"
{% if commission.grades_closed %}disabled{% endif %}
>
</div>
</td>
{% endfor %}
<!-- Promedio calculado en vivo -->
<td class="text-center">
<span class="font-monospace fw-bold fs-6 avg-cell">-</span>
</td>
<!-- Condición académica calculada en vivo -->
<td class="text-center pe-3">
<span class="badge condition-badge font-monospace py-2 px-2">Pendiente</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-people fs-1 text-muted d-block mb-3"></i>
<h5 class="fw-bold text-muted">No hay estudiantes inscriptos en esta comisión</h5>
<p class="text-muted small">Puede matricular alumnos desde el panel de detalle de la comisión.</p>
<a href="/admin/commission_detail?id={{ commission.id }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-person-plus me-1"></i>Ir a Matriculación
</a>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Modal: Cierre Formal de Acta -->
<div class="modal fade" id="closeActaModal" tabindex="-1" aria-labelledby="closeActaModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title h6 fw-bold" id="closeActaModalLabel">
<i class="bi bi-shield-lock me-1"></i>Cierre Formal de Acta de Regularidad
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning small mb-3">
<i class="bi bi-exclamation-triangle-fill me-1"></i>
<strong>Atención:</strong> Al cerrar el acta, todas las notas se congelan legalmente y se genera un número de acta único e inmutable. Sólo el personal de Bedelía o Administradores podrá reabrirla con justificación escrita.
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Observaciones o Notas de Cátedra (Opcional):</label>
<textarea id="closeActaNotes" class="form-control small" rows="3" placeholder="Ej: Cursada cerrada con 22 alumnos regulares y 5 promocionados."></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="button" class="btn btn-sm btn-danger" id="btnConfirmCloseActa">
<i class="bi bi-lock-fill me-1"></i>Confirmar y Sellar Acta
</button>
</div>
</div>
</div>
</div>
<!-- Modal: Reapertura de Acta (Admin / Bedelía) -->
<div class="modal fade" id="reopenActaModal" tabindex="-1" aria-labelledby="reopenActaModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-warning">
<h5 class="modal-title h6 fw-bold" id="reopenActaModalLabel">
<i class="bi bi-unlock-fill me-1"></i>Reapertura Excepcional de Acta
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="small text-muted mb-3">
Esta acción quedará asentada en la bitácora inmutable de auditoría institucional.
</p>
<div class="mb-3">
<label class="form-label small fw-semibold">Motivo formal de reapertura (Obligatorio):</label>
<textarea id="reopenActaReason" class="form-control small" rows="3" placeholder="Ej: Rectificación de nota de examen recuperatorio según resolución..."></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="button" class="btn btn-sm btn-warning" id="btnConfirmReopenActa">
<i class="bi bi-unlock me-1"></i>Reabrir Acta
</button>
</div>
</div>
</div>
</div>
<div id="gradebookConfig" data-commission-id="{{ commission.id }}" data-is-closed="{{ 'true' if commission.grades_closed else 'false' }}" class="d-none"></div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const config = document.getElementById('gradebookConfig');
const commissionId = config ? config.dataset.commissionId : '';
const isClosed = config ? config.dataset.isClosed === 'true' : false;
const statusIndicator = document.getElementById('saveStatusIndicator');
const statusIcon = document.getElementById('saveStatusIcon');
const statusText = document.getElementById('saveStatusText');
function updateStatus(state, msg) {
if (state === 'saving') {
statusIcon.className = 'spinner-border spinner-border-sm text-primary';
statusText.textContent = msg || 'Guardando calificación...';
} else if (state === 'saved') {
statusIcon.className = 'bi bi-cloud-check text-success fs-6';
statusText.textContent = msg || 'Todos los cambios guardados';
} else if (state === 'error') {
statusIcon.className = 'bi bi-exclamation-circle text-danger fs-6';
statusText.textContent = msg || 'Error al guardar';
}
}
// Recalculate row averages and condition
function recalculateAllRows() {
let countPromoted = 0;
let countRegular = 0;
let countLibre = 0;
const rows = document.querySelectorAll('#gradebookTable tbody tr');
rows.forEach(row => {
const inputs = row.querySelectorAll('.grade-input');
const avgCell = row.querySelector('.avg-cell');
const badgeCell = row.querySelector('.condition-badge');
let sum = 0;
let count = 0;
let hasGrades = false;
inputs.forEach(inp => {
const val = parseFloat(inp.value);
if (!isNaN(val) && val >= 1 && val <= 10) {
sum += val;
count++;
hasGrades = true;
inp.classList.remove('is-invalid');
} else if (inp.value.trim() !== '') {
inp.classList.add('is-invalid');
} else {
inp.classList.remove('is-invalid');
}
});
if (count > 0) {
const avg = sum / count;
avgCell.textContent = avg.toFixed(1);
if (avg >= 7) {
badgeCell.className = 'badge bg-success-subtle text-success border border-success-subtle font-monospace py-2 px-2';
badgeCell.textContent = 'PROMOCIONADO';
countPromoted++;
} else if (avg >= 4) {
badgeCell.className = 'badge bg-primary-subtle text-primary border border-primary-subtle font-monospace py-2 px-2';
badgeCell.textContent = 'REGULAR';
countRegular++;
} else {
badgeCell.className = 'badge bg-danger-subtle text-danger border border-danger-subtle font-monospace py-2 px-2';
badgeCell.textContent = 'LIBRE';
countLibre++;
}
} else {
avgCell.textContent = '-';
badgeCell.className = 'badge bg-secondary-subtle text-secondary border font-monospace py-2 px-2';
badgeCell.textContent = 'PENDIENTE';
}
});
document.getElementById('kpiPromoted').textContent = countPromoted;
document.getElementById('kpiRegular').textContent = countRegular;
document.getElementById('kpiLibre').textContent = countLibre;
}
recalculateAllRows();
// Debounced auto-save on input
let saveTimeouts = {};
document.querySelectorAll('.grade-input').forEach(input => {
input.addEventListener('input', function() {
if (isClosed) return;
recalculateAllRows();
const milestoneId = this.dataset.milestoneId;
const studentId = this.dataset.studentId;
const key = `${studentId}-${milestoneId}`;
const rawVal = this.value.trim();
updateStatus('saving', 'Escribiendo...');
if (saveTimeouts[key]) clearTimeout(saveTimeouts[key]);
saveTimeouts[key] = setTimeout(async () => {
let score = null;
let isAbsent = false;
if (rawVal === '') {
score = null;
isAbsent = false;
} else {
const num = parseFloat(rawVal);
if (isNaN(num) || num < 1 || num > 10) {
updateStatus('error', 'Nota debe ser entre 1 y 10');
return;
}
score = num;
}
try {
updateStatus('saving', 'Guardando nota...');
const resp = await fetch(`/api/v1/commissions/${commissionId}/gradebook/grades`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
grades: [{
student_id: parseInt(studentId),
reservation_id: parseInt(milestoneId),
score: score,
is_absent: isAbsent
}]
})
});
const data = await resp.json();
if (resp.ok && data.success) {
updateStatus('saved', '✓ Guardado exitosamente');
} else {
updateStatus('error', data.message || 'Error al guardar');
}
} catch (err) {
console.error('Error auto-saving grade:', err);
updateStatus('error', 'Error de red al guardar');
}
}, 500);
});
});
// Client-side search filter
const filterInput = document.getElementById('filterStudentInput');
if (filterInput) {
filterInput.addEventListener('input', function() {
const term = this.value.toLowerCase().trim();
document.querySelectorAll('#gradebookTable tbody tr').forEach(row => {
const name = row.dataset.studentName || '';
const dni = row.dataset.studentDni || '';
if (name.includes(term) || dni.includes(term)) {
row.style.display = '';
} else {
row.style.display = 'none';
}
});
});
}
// Close Acta
const btnClose = document.getElementById('btnConfirmCloseActa');
if (btnClose) {
btnClose.addEventListener('click', async function() {
const notes = document.getElementById('closeActaNotes').value;
this.disabled = true;
this.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Sellando...';
try {
const resp = await fetch(`/api/v1/commissions/${commissionId}/gradebook/close`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notes })
});
const data = await resp.json();
if (resp.ok && data.success) {
window.location.reload();
} else {
alert('Error al cerrar acta: ' + (data.message || 'Error del servidor'));
btnClose.disabled = false;
btnClose.innerHTML = '<i class="bi bi-lock-fill me-1"></i>Confirmar y Sellar Acta';
}
} catch (err) {
alert('Error de conexión: ' + err.message);
btnClose.disabled = false;
btnClose.innerHTML = '<i class="bi bi-lock-fill me-1"></i>Confirmar y Sellar Acta';
}
});
}
// Reopen Acta
const btnReopen = document.getElementById('btnConfirmReopenActa');
if (btnReopen) {
btnReopen.addEventListener('click', async function() {
const reason = document.getElementById('reopenActaReason').value;
if (!reason || reason.trim().length < 5) {
alert('Debe especificar un motivo formal para la reapertura.');
return;
}
this.disabled = true;
this.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Reabriendo...';
try {
const resp = await fetch(`/api/v1/commissions/${commissionId}/gradebook/reopen`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason })
});
const data = await resp.json();
if (resp.ok && data.success) {
window.location.reload();
} else {
alert('Error al reabrir acta: ' + (data.message || 'Error del servidor'));
btnReopen.disabled = false;
btnReopen.innerHTML = '<i class="bi bi-unlock me-1"></i>Reabrir Acta';
}
} catch (err) {
alert('Error de conexión: ' + err.message);
btnReopen.disabled = false;
btnReopen.innerHTML = '<i class="bi bi-unlock me-1"></i>Reabrir Acta';
}
});
}
});
</script>
{% endblock %}
+2 -2
View File
@@ -3,9 +3,9 @@
{% block title %}Listado de Reservas - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<div class="container-fluid px-0">
<!-- Header -->
<div class="row mb-4 align-items-center">
<div class="row mb-3 align-items-center">
<div class="col-md-7">
<h1 class="h3 mb-1">
<i class="bi bi-calendar-check text-primary me-2"></i>Listado de Reservas
+176
View File
@@ -0,0 +1,176 @@
{% extends 'base.html' %}
{% block title %}Mis Calificaciones y Estado Académico — Edu-Space{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb small">
<li class="breadcrumb-item"><a href="/dashboard">Inicio</a></li>
<li class="breadcrumb-item active">Mis Calificaciones</li>
</ol>
</nav>
<!-- Header Banner -->
<div class="card border-0 shadow-sm mb-4 bg-body">
<div class="card-body p-4">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3">
<div>
<span class="badge bg-success-subtle text-success border border-success-subtle font-monospace px-2 py-1 mb-2">
<i class="bi bi-mortarboard-fill me-1"></i>Portal del Estudiante
</span>
<h3 class="fw-bold mb-1">Mis Calificaciones y Regularidad</h3>
<p class="text-muted small mb-0">
Consulta oficial de notas por hito evaluativo, estado de cursada y actas selladas por Bedelía.
</p>
</div>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="window.print()">
<i class="bi bi-printer me-1"></i>Imprimir Certificado de Cursada
</button>
</div>
</div>
</div>
<!-- Subjects Grades Accordion / Cards -->
{% if subjects and subjects|length > 0 %}
<div class="row g-4">
{% for item in subjects %}
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<div class="d-flex align-items-center gap-2 mb-1">
<span class="badge bg-primary-subtle text-primary border font-monospace">
{{ item.commission.code }}
</span>
<span class="badge bg-secondary-subtle text-secondary border font-monospace">
{{ item.commission.semester }} {{ item.commission.year }}
</span>
{% if item.acta_number %}
<span class="badge bg-danger-subtle text-danger border border-danger-subtle font-monospace">
<i class="bi bi-lock-fill me-1"></i>Acta Cerrada: {{ item.acta_number }}
</span>
{% else %}
<span class="badge bg-info-subtle text-info border border-info-subtle">
<i class="bi bi-hourglass-split me-1"></i>Cursada en Curso
</span>
{% endif %}
</div>
<h5 class="fw-bold mb-0 text-body">{{ item.subject.name }}</h5>
<small class="text-muted">{{ item.subject.code }} · Carrera: {{ item.subject.career or 'Tecnicatura / Licenciatura' }}</small>
</div>
<div class="d-flex align-items-center gap-3">
<div class="text-end">
<span class="small text-muted d-block">Promedio General</span>
<span class="fw-bold fs-5 font-monospace text-primary">
{{ item.average if item.average is not none else '-' }}
</span>
</div>
<div>
{% if item.academic_condition == 'PROMOCIONADO' %}
<span class="badge bg-success text-white py-2 px-3 fs-6">
<i class="bi bi-award-fill me-1"></i>PROMOCIONADO
</span>
{% elif item.academic_condition == 'REGULAR' %}
<span class="badge bg-primary text-white py-2 px-3 fs-6">
<i class="bi bi-check-circle-fill me-1"></i>REGULAR
</span>
{% elif item.academic_condition == 'LIBRE' %}
<span class="badge bg-danger text-white py-2 px-3 fs-6">
<i class="bi bi-x-circle-fill me-1"></i>LIBRE
</span>
{% else %}
<span class="badge bg-secondary text-white py-2 px-3 fs-6">
PENDIENTE
</span>
{% endif %}
</div>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light small">
<tr>
<th class="ps-3">Hito Evaluativo</th>
<th>Fecha</th>
<th>Tipo</th>
<th class="text-center">Calificación</th>
<th class="text-center">Condición</th>
<th class="pe-3">Observaciones de Cátedra</th>
</tr>
</thead>
<tbody>
{% for g in item.grades %}
<tr>
<td class="ps-3 fw-semibold text-body">
{{ g.milestone_title }}
</td>
<td class="font-monospace text-muted small">
{{ g.milestone_date }}
</td>
<td>
<span class="badge bg-light text-dark border">
{{ g.milestone_type }}
</span>
</td>
<td class="text-center">
{% if g.is_absent %}
<span class="badge bg-warning text-dark font-monospace">AUSENTE</span>
{% elif g.score is not none %}
<span class="font-monospace fw-bold fs-6 {% if g.score >= 7 %}text-success{% elif g.score >= 4 %}text-primary{% else %}text-danger{% endif %}">
{{ g.score }}
</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td class="text-center">
{% if g.is_absent %}
<span class="badge bg-warning-subtle text-warning border">Ausente</span>
{% elif g.score is not none %}
{% if g.score >= 7 %}
<span class="badge bg-success-subtle text-success border">Aprobado / Promoción</span>
{% elif g.score >= 4 %}
<span class="badge bg-primary-subtle text-primary border">Aprobado</span>
{% else %}
<span class="badge bg-danger-subtle text-danger border">Desaprobado</span>
{% endif %}
{% else %}
<span class="badge bg-secondary-subtle text-secondary border">Sin Calificar</span>
{% endif %}
</td>
<td class="pe-3 small text-muted">
{{ g.feedback or '-' }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="card border-0 shadow-sm">
<div class="card-body text-center py-5">
<i class="bi bi-journal-x fs-1 text-muted d-block mb-3"></i>
<h5 class="fw-bold text-muted">Aún no se registran calificaciones cargadas</h5>
<p class="text-muted small">
Tus notas aparecerán aquí a medida que los equipos docentes carguen las evaluaciones de tus materias cursadas.
</p>
<a href="/schedule/today_schedule" class="btn btn-outline-primary btn-sm">
<i class="bi bi-calendar-day me-1"></i>Ver Cartelera de Clases
</a>
</div>
</div>
{% endif %}
</div>
{% endblock %}
+2 -2
View File
@@ -3,9 +3,9 @@
{% block title %}Cartelera de Hoy - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<div class="container-fluid px-0">
<!-- Header -->
<div class="row mb-4 align-items-center">
<div class="row mb-3 align-items-center">
<div class="col-md-7">
<h1 class="h3 mb-1">
<i class="bi bi-calendar-day text-info me-2"></i>Cartelera del Día
+1 -1
View File
@@ -204,7 +204,7 @@
{% set percentage = (reservation.expected_attendees / reservation.classroom.capacity * 100) %}
<div class="progress-bar bg-{% if percentage > 90 %}danger{% elif percentage > 75 %}warning{% else %}success{% endif %}"
role="progressbar"
style="{{ 'width: ' ~ ([percentage|round(1), 100]|min|string) ~ '%;' }}"></div>
{{ ('style="width: ' ~ ([percentage|round(1), 100]|min|string) ~ '%;"') | safe }}></div>
</div>
<small class="text-muted">
{{ reservation.expected_attendees }} / {{ reservation.classroom.capacity }} capacidad