1 Commits
281 changed files with 14045 additions and 34125 deletions
+7
View File
@@ -0,0 +1,7 @@
FLASK_APP=app.py
FLASK_ENV=development
SECRET_KEY=your-secret-key-here-please-change-in-production
DATABASE_URL=postgresql://postgres:password@localhost:5432/classrooms_db
DB_USERNAME=postgres
DB_PASSWORD=password
API_BASE_URL=http://localhost:8080/api
+1 -1
View File
@@ -4,7 +4,7 @@
# ==============================================================================
# Entorno Flask
FLASK_APP=wsgi.py
FLASK_APP=app.py
FLASK_ENV=production
# Seguridad: Clave secreta para sesiones y protección CSRF
-73
View File
@@ -1,73 +0,0 @@
name: Edu-Space Automated Security Audit (Gitea Local)
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
security-scan:
# Usamos la etiqueta estándar para contenedores Ubuntu en Gitea Runner
runs-on: ubuntu-latest
steps:
# 1. Clonar el código del repositorio local
- name: Checkout Code
uses: actions/checkout@v4
# 2. Preparar el entorno de paquetes del sistema (Linux)
- name: Install System Dependencies (Python & Node.js)
run: |
apt-get update && apt-get install -y python3 python3-pip python3-venv nodejs npm curl
python3 -m pip install --upgrade pip
# 3. Instalar librerías de Python y herramientas de seguridad
- name: Install Python Dependencies & Security Tools
run: |
pip3 install -r backend/requirements.txt
pip3 install -r backend/requirements-dev.txt
# 4. Instalar librerías de Node.js (Express BFF)
- name: Install Node.js Dependencies
run: |
cd frontend
npm ci
# --- EJECUCIÓN DE CONTROLES (SAST & SCA) ---
- name: [1/5] Python SAST (Bandit)
run: bandit -r ./backend/app -ll -ii
- name: [2/5] Python SCA (Pip-Audit)
run: pip-audit -s osv --progress-spinner off -r backend/requirements.txt
- name: [3/5] Node.js SAST (Njsscan)
run: njsscan ./frontend/src
- name: [4/5] Node.js SCA (Npm Audit)
run: |
cd frontend
npm audit --audit-level=high
# --- CONTROL DINÁMICO DAST (Schemathesis) ---
- name: [5/5] Start Flask App & Run DAST (Schemathesis)
env:
PYTHONUTF8: 1
FLASK_APP: backend/app # Ajustar a la ruta de inicio de tu Flask si varía
run: |
# Levantar Flask en segundo plano dentro del contenedor local
# Usamos 'python3 -m flask run' para asegurar el bindeo local
cd backend
python3 -m flask run --host=127.0.0.1 --port=5000 &
# Esperar 5 segundos a que la API responda
echo "Esperando a que el backend de Flask inicie en el entorno local..."
sleep 5
# Verificar con un curl rápido si el JSON de OpenAPI está disponible
curl -s http://127.0.0.1:5000/api/v1/openapi.json > /dev/null
# Ejecutar Schemathesis contra la instancia local efímera
schemathesis run http://127.0.0.1:5000/api/v1/openapi.json --checks not_a_server_error --max-examples=10
-15
View File
@@ -57,9 +57,6 @@ cover/
*.mo
*.pot
# Examples
example/
# Django stuff:
*.log
local_settings.py
@@ -210,15 +207,3 @@ cython_debug/
marimo/_static/
marimo/_lsp/
__marimo__/
# Node.js / Frontend
node_modules/
npm-debug.log
.env
.env.*
!.env.example
frontend/.env
legacy_admin-edu-space/
.vscode/
.schemathesis/
-188
View File
@@ -1,188 +0,0 @@
# Changelog Oficial — Admin Edu-Space (UniCABA)
Todas las modificaciones notables a este proyecto serán documentadas en este archivo.
El formato está basado en [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/) y este proyecto se adhiere a [Semantic Versioning](https://semver.org/lang/es/).
---
## [Unreleased]
### Planificado (Fase 3 del Roadmap MVP)
- **Calendario de Evaluación por Comisión:** Programación de parciales, recuperatorios, entregas de trabajos prácticos y exámenes finales con cuenta regresiva.
- **Libro de Calificaciones (*Gradebook*):** Matriz de carga rápida de notas para docentes, consulta pedagógica individual para alumnos y cierre de actas para Bedelía.
- **Grilla Semanal Drag & Drop (Fase 4):** Asignación visual interactiva para la oficina de Bedelía.
---
## [2.6.0] - 2026-09-19
### Añadido
- **Fase 2 del Roadmap MVP — Co-docencia, Matriz de Conflictos y Regla Diaria:**
- **Estadio 2.1 — Modelo y Gestión Integral de Co-docencia:**
- Modelo relacional `CommissionTeacher` (`commission_teachers`) con roles de cátedra (`Titular`, `Adjunto`, `JTP`, `Ayudante`) y flags de docente principal.
- Endpoints REST de cátedra: `POST /api/v1/commissions/<id>/teachers` y `DELETE /api/v1/commissions/<id>/teachers/<user_id>` con control de duplicados y asignación dinámica.
- Integración visual en el detalle de la comisión (`/admin/commission_detail`) con tarjeta interactiva para vincular/desvincular integrantes del equipo docente y badges de rol en la grilla general (`/admin/commissions_list`).
- **Estadio 2.2 — Matriz de Conflictos y Validación Integral:**
- **Conflicto Físico Estricto:** Bloqueo estricto de superposición horaria en la misma aula física (`ReservationRepository.find_overlapping` & `check_physical_conflicts`).
- **Conflicto Docente Inteligente:** Detección de doble reserva simultánea para un mismo profesor (`ReservationRepository.find_teacher_conflicts` & `check_teacher_conflicts`).
- **Excepción Justificada por Co-docencia:** Si un docente asignado presenta solapamiento pero la comisión cuenta con co-docentes registrados en `CommissionTeacher`, el motor de conflictos autoriza la reserva validando la cobertura del equipo de cátedra.
- **Conflicto de Aforo:** Bloqueo estricto cuando la cantidad de asistentes esperados (`expected_attendees`) excede la capacidad física declarada del aula (`classroom.capacity`).
- Endpoint unificado `POST /api/v1/reservations/check-conflicts` para pre-validación asíncrona de conflictos físicos, docentes y de capacidad antes del guardado.
- **Estadio 2.3 — Regla de Restricción Diaria del Alumno y Motor de Excepciones:**
- Servicio académico `EnrollmentService` (`backend/app/services/enrollment_service.py`) para control de cupos, inscripciones activas y análisis de días de cursada.
- **Regla Diaria:** Un alumno no puede matricularse en dos materias regulares programadas para el mismo día de la semana.
- **Excepción Automática por Curso Corto:** Indicador `is_short_course` en modelo `Subject`; si una de las asignaturas es un taller, seminario o curso corto, el sistema autoriza automáticamente la cursada simultánea en el mismo día.
- **Excepción Expresa de Bedelía:** Parámetros `allow_same_day_exception` y `exception_reason` en el modelo `StudentEnrollment`, permitiendo a Bedelía inscribir alumnos con justificación administrativa expresa (cambio de plan, equivalencias, etc.).
- Endpoints de gestión de matrículas por comisión: `GET /api/v1/commissions/<id>/enrollments`, `POST /api/v1/commissions/<id>/enrollments`, `DELETE /api/v1/commissions/<id>/enrollments/<student_id>` y `PUT /api/v1/commissions/<id>/enrollments/<student_id>`.
- Adaptación completa de UI en `/admin/commission_detail` con selector de estudiantes, toggle interactivo de excepción de Bedelía, campo de motivo justificado, y tabla de estudiantes matriculados con badges de excepción.
- **Suite de Pruebas Unitarias e Integrales de Reglas de Negocio:**
- Archivo `backend/tests/test_phase2_rules.py` con 6 tests completos verificando: bloqueo físico, conflicto de aforo, detección docente con bypass de co-docencia, restricción diaria de cursada, excepción por curso corto y excepción por autorización manual de Bedelía. 100% de tests aprobados.
- **Mejoras UI/UX en Dashboard de Bedelía:**
- Paneles interactivos para visualizar **Comisiones Sin Reserva de Aula** y **Aulas Disponibles** con soporte de filtrado temporal (Semana/Mes) directamente consumiendo estadísticas en tiempo real del motor backend.
- **Fase 1: Auditoría y Seguridad Automatizada:**
- **Control de Entorno Dev:** Aislamiento de herramientas de seguridad en `requirements-dev.txt`.
- **Automatización Local e Integración Continua:** Correcciones de evaluación en `security_audit.bat` para Windows y actualización de `.gitea/workflows/security-audit.yaml` para asegurar la correcta ejecución del stack de auditoría (Bandit, pip-audit, njsscan y Schemathesis).
## [2.5.0] - 2026-09-19
### Añadido
- **Auditoría y Seguridad Automatizada Completa (Python & Node.js):**
- **Python SAST (`bandit`):** Escaneo estático del backend Flask de más de 8.400 líneas. Corrección de vulnerabilidad B310 en `sheets_importer.py` mediante validación estricta de esquema HTTP/HTTPS para prevenir ataques de tipo SSRF. Resultado: 0 fallos detectados.
- **Python SCA (`pip-audit`):** Análisis de dependencias con base OSV; detección de 24 CVEs históricas en paquetes desactualizados y actualización completa a versiones seguras (`Flask>=3.1.3`, `Werkzeug>=3.1.8`, `requests>=2.34.2`, `bleach>=6.4.0`, `python-dotenv>=1.2.3`). Resultado: 0 CVEs activas.
- **Python DAST / Fuzzing (`schemathesis`):** Especificación OpenAPI 3.0.3 en `backend/app/routes/api/openapi.py` (`/api/v1/openapi.json`). Ejecución de 114 casos de prueba dinámicos con mutación de datos. Detección y corrección de caída 500 por payload de tipo JSON inválido en `/auth/login`. Resultado: 114/114 pruebas aprobadas con 0 errores de servidor.
- **Node.js SAST (`njsscan`):** Análisis de patrones de código inseguro en el BFF Express (`frontend/src`). Resultado: 0 hallazgos.
- **Node.js SCA (`npm audit`):** Auditoría continua de paquetes npm con 0 vulnerabilidades reportadas.
- **Blindaje Activo (`helmet` & `express-rate-limit`):** Incorporación de cabeceras HTTP defensivas (`HSTS`, `nosniff`, `SAMEORIGIN`, `X-Permitted-Cross-Domain-Policies`) y limitadores de tasa ante fuerza bruta en `/auth/login` (30 req / 15 min) y `/api` (180 req / min).
- **Script Unificado de Auditoría (`security_audit.bat`):** Ejecutable centralizado para correr los 5 controles en un solo comando.
- **Documento Formal de Seguridad (`SECURITY_AUDIT_REPORT.md`):** Reporte ejecutivo de auditoría y controles continuos.
---
## [2.4.0] - 2026-09-19
### Añadido
- **Perfil Extendido de Usuarios y Tipificaciones de Documentos Oficiales (Argentina y Extranjeros):**
- Incorporación en el modelo `User` de los campos: `first_name` (Nombre), `last_name` (Apellido), `email` (Email Institucional de acceso), `personal_email` (Email Personal/Particular de contacto), `phone` (Teléfono con código de área), `address` (Domicilio/Dirección), `document_type` (Tipo de documento) y `document_number` (Número de documento validado).
- Catálogo formal de reglas de negocio en `backend/app/constants/document_types.py` con tipificaciones oficiales de Argentina y extranjeros (DNI, CUIL, Pasaporte Argentino, Pasaporte Extranjero, DNI Extranjero, Cédula de Identidad PFA, Cédula Mercosur, Libreta Cívica y Libreta de Enrolamiento).
- Validaciones de formato, expresiones regulares, y verificación de unicidad de documento por tipo en endpoints `POST /api/v1/users` y `PUT /api/v1/users/<id>`.
- Búsqueda dinámica en `/admin/users_list` habilitada para filtrar por número de documento o email personal/institucional además de nombre.
- Rediseño del formulario `/admin/users/form` estructurado en secciones: Identidad y Documentación, Contacto y Domicilio (con Teléfono, Email Personal y Dirección), y Cuenta y Rol Institucional (con Email Institucional y Clave), permitiendo al Administrador General gestionar la totalidad de los datos.
- Script de migración SQLite `backend/migrate_user_profile_fields.py` para agregar las columnas e inicializar nombres desglosados de los usuarios existentes.
- **Co-Docencia y Cátedras Múltiples en Comisiones:**
- Creación del modelo relacional `CommissionTeacher` (`commission_teachers`) para asociar múltiples docentes a una misma comisión con roles académicos diferenciados (`Titular`, `Adjunto`, `JTP`, `Ayudante`).
- Nuevos endpoints en la API REST: `POST /api/v1/commissions/<id>/teachers` y `DELETE /api/v1/commissions/<id>/teachers/<user_id>`, junto con la serialización completa de cátedras en `GET /api/v1/commissions`.
- Nueva tarjeta interactiva *"Equipo Docente / Cátedra"* en el detalle de la comisión (`/admin/commission_detail?id=...`) permitiendo agregar y desvincular docentes con sus respectivos roles.
- Actualización de la grilla de comisiones (`/admin/commissions_list`) para exhibir a todos los profesores asignados con badges distintivos de rol.
- **Eliminación y Gestión Segura de Asignaturas:**
- Incorporación de botón y modal interactivo de confirmación de eliminación de asignaturas en `/admin/subjects_list`.
- Implementación de regla de negocio de integridad académica: eliminación física si la materia no tiene comisiones asociadas, o desactivación automática si posee historial o cursadas vinculadas.
### Corregido
- **Bug Visual en Modificación de Asignaturas (`/admin/subjects_list`):**
- Solucionado el problema de modales recortados o cubiertos por el fondo oscuro (`modal-backdrop`), originado por la presencia de 206 modales inline dentro de celdas `<td>` en una tabla con `overflow` (`.table-responsive`). Se refactorizó hacia un único modal global `#modalEditSubject` instanciado fuera de la tabla e inicializado dinámicamente con JavaScript.
### Añadido
- **Monitoreo y Percentiles de Asignación de Aulas Físicas (Admin & Bedelía):**
- Panel analítico *"Estado y Percentiles de Asignación de Aulas Físicas"* incorporado en los dashboards de Administrador (`/dashboard?role=admin`) y Bedelía (`/dashboard?role=bedelia`).
- Cálculo dinámico en tiempo real de **Aulas Asignadas** en número y percentil (**26 aulas · 96.3%**) con enlace directo a cursadas activas.
- Cálculo dinámico en tiempo real de **Aulas Sin Asignar / Libres** en número y percentil (**1 aula · 3.7%**) con enlace directo al formulario de asignación de espacios.
- Barras de progreso visuales con desglose dual: **Planificación Cuatrimestral** (96.3% asignado vs 3.7% disponible) y **Ocupación Operativa del Día** (aulas con clases hoy vs aulas libres).
- Exposición de métricas de asignación y percentiles en el endpoint de la API REST `GET /api/v1/dashboard/stats`.
- **Módulo de Cursadas Asignadas y Métricas Reales en Catálogo Presencial (`/classrooms/list_classrooms?view_mode=physical`):**
- Incorporación de la sección *"Cursadas Asignadas"* dentro de cada tarjeta de aula física y columna en vista de tabla, detallando asignaturas, comisiones y turnos.
- Métricas de ocupación física real por período (Día, Semana, Mes) con porcentajes calculados dinámicamente (6%, 11%, 17%, 22% según la carga horaria del aula).
- Botones de acción directa con ID dinámico: **Ficha** (`/classrooms/view_classroom?id=...`), **Horarios en Calendario** (`/schedule/calendar_view?classroom_id=...`) y **Editar**.
- Conexión de la tabla *"Próximas Clases y Reservas"* en la ficha individual del aula (`/classrooms/view_classroom`) con las reservas reales del cuatrimestre y enlaces a cada reserva (`/schedule/view_reservation?id=...`).
### Modificado
- **Dashboard Docente (`/dashboard?role=docente`):**
- Eliminado el botón flotante *"Reservar Aula / Solicitar espacio para clase"* (`/schedule/add_reservation`) a petición de los requerimientos funcionales del cuerpo docente.
- Reorganizada la grilla superior a 3 columnas simétricas (`col-md-4`) para las tarjetas de *Mis Clases Hoy*, *Mis Reservas Activas* y *Aulas Virtuales*.
### Corregido
- **Fallo Crítico de Renderizado en Nunjucks (`memberLookup` con `.items`):**
- Solucionada la incompatibilidad de `nunjucks/src/jinja-compat` que interceptaba la propiedad `.items` convirtiendo `classrooms.items` en una función interna en lugar de un array, lo que impedía dibujar las tarjetas de aulas presenciales en `frontend/views/classrooms/list.html`.
- Implementado interceptor en `memberLookup` en `frontend/src/app.js` para preservar arrays intactos.
- **Clasificación Errónea de Reservas Físicas como Virtuales (`is_virtual`):**
- Corregida la propiedad `@property is_virtual` en `Reservation` (`backend/app/models/reservation.py`) para garantizar que si una reserva tiene aula física asignada (`self.classroom and not self.classroom.is_virtual`), siempre sea clasificada como presencial (`is_virtual = False`), evitando falsos positivos cuando la comisión posee un enlace virtual predeterminado.
- **Excepción de SQLAlchemy en Estadísticas de Dashboard:**
- Corregido el error de ejecución generado al invocar `.ilike()` sobre la propiedad Python `@property Classroom.room_number` en `backend/app/routes/api/dashboard.py`, migrando la clasificación de aulas presenciales y virtuales a una evaluación robusta en memoria Python (`Classroom.code` y `Classroom.building`).
---
## [2.2.0] - 2026-09-19
### Añadido
- **Herramienta de Purga y Reinicio de Datos Académicos (`reset_academic_data`):**
- Script CLI `backend/scripts/reset_academic_data.py` con argumentos de limpieza granular (`--all`, `--reservations`, `--classrooms`, `--careers`, `--yes`).
- Endpoint de API `POST /api/v1/admin/reset-academic-data` protegido por JWT (rol `admin`) con registro en `AuditLog`.
- Panel interactivo "Zona de Reinicio y Purga Académica" en `/admin/google_sheets_import` con checkboxes independientes para reservas, aulas, comisiones, asignaturas y carreras.
- Modal modal de confirmación con advertencias de seguridad para prevenir purgas accidentales.
- Preservación íntegra de usuarios del sistema, credenciales de acceso, roles RBAC y registros de auditoría.
- **Configuración Global Dinámica de Google Sheets:**
- Nuevo modelo `SystemSetting` (`backend/app/models/setting.py`) para almacenamiento persistente clave-valor en base de datos.
- Endpoints `GET /api/v1/admin/sheets-config` y `POST /api/v1/admin/sheets-config`.
- Normalizador de enlaces en `GoogleSheetsImporter` compatible con URLs compartidas de edición (`/spreadsheets/d/.../edit`) o enlaces publicados (`/pub?output=csv`).
- Interfaz web con formulario de edición de URL, botón de guardado en caliente, restauración a URL oficial predeterminada y acceso directo a Google Docs.
- **Visualización de Asignaturas Reales:**
- Tabla de asignaturas recientemente sincronizadas conectada a la base de datos real en lugar de datos de muestra (*mock*).
### Corregido
- **Fallo por Timeout en Google Sheets Academic Sync:**
- Solucionado el corte prematuro de conexión en el BFF (`frontend/src/services/apiClient.js`), aumentando el timeout base a 25s y el timeout específico de sincronización a 90s (anteriormente limitado a 5s en operaciones que toman ~6.7s).
- Eliminado el enmascaramiento silencioso de errores en el BFF, permitiendo reportar fallos reales o éxitos detallados (+carreras, +aulas, +materias, +comisiones, +reservas).
---
## [2.1.0] - 2026-09-19
### Añadido
- **Ficha Detallada de Reserva (`schedule.view_reservation`):**
- Implementación en BFF Node.js de la ruta `/schedule/view_reservation` (soportando parámetros `:id`, `?id=` o selección automática del primer registro).
- Vistas y controladores para modificar reservas con selectores en cascada (`/schedule/edit_reservation`).
- Endpoints de acción rápida en backend y BFF: `POST /schedule/confirm_reservation` y `POST /schedule/cancel_reservation`.
- Actualización de enlaces en `today.html`, `list.html`, `dashboard.html` y `view.html` para navegar directamente con el identificador de la reserva (`?id=...`).
- **Creación y Asociación de Comisiones a Asignaturas (`admin/commissions_list`):**
- Botón destacado `+ Nueva Comisión` en el encabezado y en el estado vacío de la tabla.
- Modal interactivo `#modalAddCommission` con selección obligatoria y clara de la **Asignatura / Materia Asociada** (`<select name="subject_id" required>`), mostrando código oficial, nombre y carrera.
- Campos completos de cursada: cuatrimestre (1C/2C/Anual), año lectivo, turno, docente a cargo, cupo máximo y días/horarios.
- Generación y configuración automática de enlace a Google Meet institucional cuando no se ingresa un enlace personalizado.
- Alerta de confirmación visual (`?created=1`) al registrar y vincular una comisión exitosamente.
- Tabla de comisiones actualizada con visualización explícita del código de materia, nombre y badge de carrera.
- **Enriquecimiento de la API REST:**
- Endpoint `GET /api/v1/reservations/<id>` ampliado con objetos relacionales anidados: `classroom` (código, edificio, piso, capacidad, descripción, virtualidad), `commission` (código, código compuesto, materia), `user` (docente, nombre, apellido, correo), duración en minutos y enlaces virtuales.
- Endpoint `POST /api/v1/admin/commissions` con validación estricta de existencia de asignatura y resolución inteligente de unicidad.
---
## [2.0.0] - 2026-09-18
### Añadido
- **Reingeniería de Arquitectura Fullstack (Desacople Monolito -> Backend + BFF):**
- Desacople completo del backend Python/Flask como una **API REST pura** bajo `/api/v1/` retornando exclusivamente JSON.
- Implementación del **Frontend BFF** en Node.js + Express con renderizado de servidor mediante Nunjucks.
- Autenticación sin estado (*Stateless*) basada en tokens JWT firmados con algoritmo HS256.
- Almacenamiento seguro del token de acceso en cookies del navegador con directivas `HttpOnly`, `SameSite=Lax` y `Secure`.
- Inyección transparente del token `Bearer` en peticiones internas mediante cliente Axios centralizado.
- **Dashboards Personalizados por Rol Institucional:**
- **Dashboard de Bedelía (`/dashboard?role=bedelia`):** Métricas operativas de campus físico y virtual, cartelera del día y panel de aprobación de solicitudes pendientes.
- **Dashboard de Docente (`/dashboard?role=docente`):** Sesiones del día con botones directos para ingresar a reuniones virtuales (Meet/Teams/Zoom) y formulario de solicitud de aulas.
- **Dashboard de Alumno (`/dashboard?role=alumno`):** Brújula de cursada *"¿Dónde curso hoy?"* con ubicación por piso/aula o enlace virtual, junto al calendario de exámenes y entregas.
- **Dashboard de Administrador (`/dashboard?role=admin`):** Cockpit institucional de capacidad, usuarios, auditoría RBAC y distribución de comisiones.
- Selector dinámico de vista previa de roles en el banner superior para administradores.
- **Cartelera del Día Interactiva (`/schedule/today_schedule`):**
- Grilla estructurada en bloques horarios (07:00 a 21:00 hs) con métricas en tiempo real de ocupación y reservas confirmadas/pendientes.
- Filtro dinámico para ocultar clases virtuales (`hide_virtual=1`) y segmentación por pisos o turnos.
- **Seguridad Enterprise (SecurityFilterChain):**
- Pipeline centralizado de cabeceras de blindaje OWASP (`nosniff`, `SAMEORIGIN`, `X-XSS-Protection`, `Strict-Transport-Security`).
- Rate limiting dinámico contra ataques de fuerza bruta en endpoints de autenticación.
### Corregido
- **Resolución de Colisión de Horarios en Clases Virtuales (Importador de Google Sheets):**
- Corrección de la búsqueda de duplicados en `sheets_importer.py` que limitaba indebidamente la unicidad a `(classroom_id, start_time, end_time)`.
- Habilitación de concurrencia ilimitada para el aula virtual (`Campus Virtual · VIRTUAL`) mediante discriminación por `commission_id`.
- El total de reservas registradas aumentó de **363 a 643** (incorporando **280 clases virtuales** que eran descartadas por falso conflicto).
- Generación y guardado automático de enlaces institucionales de Google Meet (`https://meet.google.com/edu-{materia}-{comision}`).
---
## [1.0.0] - 2026-09-04
### Añadido
- **Versión Monolítica Base (`legacy_admin-edu-space`):**
- Aplicación monolítica en Flask con plantillas Jinja2 y persistencia SQLAlchemy (SQLite / PostgreSQL).
- Modelado de datos inicial para sedes, aulas físicas, materias, comisiones, usuarios y reservas.
- Sistema de roles inicial (Admin, Bedelía, Docente, Alumno) basado en sesión tradicional de Flask-Login.
- Calendario de reservas mensual y semanal con FullCalendar.
- Prototipo experimental de algoritmo genético para optimización de ocupación áulica.
- Módulo de sincronización inicial con planillas de Google Sheets.
-52
View File
@@ -1,52 +0,0 @@
# CHANGELOG MVP — Admin Edu-Space (UniCABA)
Registro de hitos y versiones correspondientes a las Fases del [ROADMAP_MVP.md](./ROADMAP_MVP.md).
---
## [Fase 2: Co-docencia, Matriz de Conflictos y Regla Diaria] — v2.6.0 (2026-09-19)
**Estado:** ✅ 100% Completada
### Estadio 2.1 — Modelo y Gestión Integral de Co-docencia
* **Modelo `CommissionTeacher`:** Soporte para múltiples docentes por comisión en base de datos (`commission_teachers`), admitiendo roles académicos diferenciados (`Titular`, `Adjunto`, `JTP`, `Ayudante`) y flag `is_primary`.
* **API REST de Cátedras:** Endpoints `POST /api/v1/commissions/<id>/teachers` y `DELETE /api/v1/commissions/<id>/teachers/<user_id>` con serialización completa de cátedra en comisiones.
* **Interfaz de Gestión:** Tarjeta interactiva *"Equipo Docente / Cátedra"* en `/admin/commission_detail` para incorporar y desvincular profesores con rol dinámico, y visualización de badges en `/admin/commissions_list`.
### Estadio 2.2 — Matriz de Conflictos y Validación Integral
* **Conflicto Físico:** Bloqueo estricto e infranqueable de superposiciones de reservas en la misma aula física (`ReservationRepository.find_overlapping`).
* **Conflicto Docente:** Detección de doble asignación simultánea para un mismo docente en diferentes aulas o formatos.
* **Excepción Justificada por Co-docencia:** Si un docente asignado presenta solapamiento pero la comisión cuenta con co-docentes registrados en `CommissionTeacher`, la matriz de validación autoriza la reserva validando la cobertura del equipo de cátedra.
* **Conflicto de Aforo:** Bloqueo estricto cuando `expected_attendees > classroom.capacity` en espacios físicos.
* **Endpoint de Pre-validación:** `POST /api/v1/reservations/check-conflicts` para validación asíncrona de conflictos físicos, docentes y de aforo antes del commit.
### Estadio 2.3 — Regla de Restricción Diaria del Alumno y Motor de Excepciones
* **Servicio `EnrollmentService`:** Lógica de matriculación académica con análisis de días de cursada por comisión y verificación de cupos.
* **Regla Restrictiva:** Un alumno no puede cursar dos asignaturas regulares el mismo día de la semana.
* **Excepción Automática por Curso Corto:** Atributo `is_short_course` en modelo `Subject`; si una de las materias es un taller o curso corto, el sistema autoriza la cursada simultánea en el mismo día.
* **Excepción Expresa de Bedelía:** Campos `allow_same_day_exception` y `exception_reason` en `StudentEnrollment`. La oficina de Bedelía puede autorizar la matriculación simultánea mediante switch justificado en la interfaz.
* **Endpoints de Matrícula:** `GET/POST /api/v1/commissions/<id>/enrollments`, `DELETE/PUT /api/v1/commissions/<id>/enrollments/<student_id>`.
* **UI en Detalle de Comisión:** Formulario de matriculación con toggle de autorización de Bedelía, motivo de excepción y tabla de inscriptos con badges de estado.
### Cobertura de Pruebas
* Suite completa en `backend/tests/test_phase2_rules.py` validando los 6 criterios de aceptación (conflicto físico, aforo, co-docencia, restricción diaria, bypass de curso corto y bypass de Bedelía). 6/6 tests aprobados con 100% de éxito.
### Complementos y Seguridad Transversal
* **Dashboard de Bedelía:** Integración de paneles en tiempo real para análisis de **Comisiones Sin Reserva** y grilla de **Aulas Disponibles** (Filtros Semana/Mes) conectando frontend UI con el servicio analítico backend.
* **Auditoría Automatizada (Fase 1):** Implantación de un pipeline de seguridad local/Gitea que aísla dependencias de desarrollo (`requirements-dev.txt`) y ejecuta controles SAST, DAST y SCA (Bandit, pip-audit, njsscan y Schemathesis) sin vulnerabilidades detectadas.
* **Optimizador Genético de Reservas:** Reparación de enrutamiento 404, migración a JWT (`@jwt_required`), corrección de UI de métricas dinámicas de algoritmo, y refactorización de layout para superposición de herramientas.
* **UI/UX General:** Ajustes de CSS en modo claro (soporte dinámico de contraste de isologotipo blanco a rosa institucional mediante filtros) y traducciones de menús de usuario en `base.html`.
---
## [Fase 1: Paridad Legacy y Estabilización] — v2.5.0 / v2.4.0 (2026-09-19)
**Estado:** ✅ 100% Completada
* Seguridad automatizada SAST/DAST/SCA (Bandit, pip-audit, schemathesis, njsscan, npm audit).
* Tipificaciones de documentos argentinos y extranjeros, perfiles extendidos de usuarios con email personal e institucional.
* Gestión completa de aulas físicas vs virtuales con percentiles de asignación y métricas de ocupación real.
* Reingeniería del modal de edición y eliminación segura de asignaturas.
---
## [Fase 0: Desacople y Arquitectura Base] — v2.0.0 (2026-09-18)
**Estado:** ✅ 100% Completada
* Desacople arquitectónico completo: Backend Flask RESTful (`/api/v1/`) + Frontend BFF Node.js/Express con Nunjucks.
* Autenticación basada en tokens JWT con sincronización de estado de sesión.
+253
View File
@@ -0,0 +1,253 @@
# Resumen de Cambios - Sesión de Desarrollo (Rama `dev`)
**Fecha:** 2026-09-02 / 2026-09-03
**Proyecto:** Admin Edu-Space (Flask + PostgreSQL)
**Rama:** `dev`
---
## 1. Auditoría Git y Puesta en Producción
* **Informe Técnico Completo (`DEPLOYMENT_AUDIT.md`):**
* Análisis de ramas locales y remotas (`main`, `dev`, `origin/planning`, `origin/commissions`, `origin/spanish`).
* Manual de despliegue en producción con arquitectura WSGI (Gunicorn), Nginx como proxy inverso, Systemd unit y contenedorización Docker / Docker Compose.
* **Archivos Base:**
* Creación de `.env.example` con todas las variables requeridas (PostgreSQL, Flask, Secret Key, Babel).
* Creación y posterior actualización de `init_db.py` para bootstrap inicial seguro de base de datos, roles RBAC y usuario administrador por defecto (`admin@edu-space.com` / `admin123`).
---
## 2. Base de Datos y Modelos
* **Preferencia de Idioma Persistente:**
* Migración en PostgreSQL agregando la columna `preferred_language VARCHAR(10)` a la tabla `users`.
* Actualización del modelo `User` en `app/models/user.py` con el campo `preferred_language`.
* **Preferencia de Tema Visual Persistente:**
* Migración agregando la columna `theme_preference VARCHAR(10) DEFAULT 'auto' NOT NULL` a la tabla `users`.
* Soporte en el modelo `User` para valores `'auto'`, `'light'` y `'dark'`.
* **Control de Acceso Basado en Roles (RBAC):**
* Migración creando las tablas `roles` y `permissions`, y agregando la clave foránea `role_id` a la tabla `users`.
* Modelos `Role` y `Permission` en `app/models/role.py`.
* Métodos `has_permission(module, min_level)` y `can_manage()` en `User`.
---
## 3. Implementación de Vistas y Plantillas
* **Perfil de Usuario (`app/templates/auth/profile.html`):**
* Vista completa con datos del usuario, rol, estado, fecha de registro y reservas recientes.
* Formulario integral para configurar **Idioma de la interfaz** (*Detectar automáticamente del navegador*, *Español*, *English*) y **Tema visual** (*Automático*, *Modo Claro*, *Modo Oscuro*).
* **Cambio de Contraseña (`app/templates/auth/change_password.html`):**
* Formulario seguro para actualización de credenciales.
* **Manejo de Errores (`app/templates/auth/403.html`):**
* Pantalla de error 403 Forbidden estilizada e integrada con el sistema de diseño.
---
## 4. Correcciones de UX, Linter y Bugs de Rutas
* **Navbar Limpio (`app/templates/base.html`):**
* Eliminación del bloque duplicado del menú de usuario `System Administrator`.
* **Corrección de BuildError en Comisiones:**
* En `app/routes/classrooms.py`, se agregó el alias de ruta `@classrooms_bp.route('/', endpoint='index')` para resolver enlaces a `classrooms.index`.
* En `app/templates/schedule/commissions.html`, se reemplazó el filtro inválido de Django `truncatewords(20)` por el filtro estándar de Jinja2 `truncate(100)`.
* **Corrección Visual en `/genetic-optimizer` (`app/templates/genetic_optimizer.html`):**
* Se agregó el espaciado `pt-5 mt-4` evitando que la barra fija superior se monte sobre el contenido.
* Rediseño de tarjeta hero con contraste óptimo y reemplazo de iconos faltantes por Bootstrap Icons (`bi bi-...`).
* **Corrección de Errores del Linter en `schedule/add.html` y `schedule/view.html`:**
* En `add.html`: Desacople de iteraciones Jinja a `<script type="application/json" id="classroom-data">` y parseo con `JSON.parse()`, eliminando 19 errores sintácticos del editor.
* En `view.html`: Formateo de la propiedad inline `style` en barras de progreso para cumplir con el validador CSS.
---
## 5. Rediseño UI/UX, Assets Institucionales y Modo Claro/Oscuro
* **Exploración e Integración de Assets Gráficos:**
* Sincronización desde `app/resources` hacia `app/static/img/`:
* `logo_unicaba.png`: Logo institucional principal para el login.
* `logo_unicaba_mini.png`: Isotipo oficial para el navbar.
* `favicon.png`: Favicon vinculado en las plantillas base.
* `icons/`: Paquete de resoluciones WebApp/PWA.
* **Sistema de Diseño y Tokens de Color (`app/static/css/theme.css`):**
* Paleta institucional basada en Magenta/Uva (`#B43E8E`), acentos violetas (`#682CAD` / `#AE7FFF`) y secundarios magentas.
* Tokens CSS para `:root` (Modo Claro) y `[data-bs-theme="dark"]` (Modo Oscuro).
* Clases especializadas: `.auth-card`, `.navbar-app`, `.user-avatar-small`, `.theme-toggle-btn`, `.badge-brand`.
* **Modo Claro / Modo Oscuro Dinámico (`app/static/js/theme-toggle.js`):**
* Script anti-flickering en `<head>` para evitar parpadeos visuales al cargar la página.
* Botón toggle interactivo con persistencia en `localStorage`.
* En Modo Claro: Isotipo del navbar con filtro blanco puro (`brightness(0) invert(1)`) sobre el fondo magenta.
* Selector de idioma (`#languageDropdown`) con fondo y bordes de alto contraste y tilde de verificación blanca (`bi-check-lg`) visible **únicamente** en el idioma activo sobre gradiente magenta.
---
## 6. Internacionalización (i18n), Localización y Formateo de Fechas
* **Detección Inteligente de Subtags Regionales (`get_locale` en `app/__init__.py`):**
* Extracción del subtag primario (`es` a partir de `es-419`, `es-AR`, `es-ES`), resolviendo el fallo donde Werkzeug saltaba erróneamente a inglés.
* Configuración institucional: `BABEL_DEFAULT_LOCALE = 'es'` y `LANGUAGES = ['es', 'en']`.
* **Reglas de Idioma para la Pantalla de Login:**
* Resuelve según el navegador del visitante o recurre a español por defecto.
* Inmunidad contra cookies o sesiones residuales de otros usuarios.
* Flash messages traducidos (`¡Bienvenido de nuevo!`, `Has cerrado sesión correctamente`).
* **Formateador de Fechas Localizadas en Jinja2:**
* Registro de `format_date` y `format_datetime` de Flask-Babel.
* Fechas en español natural en `schedule/today.html` (`Jueves, 3 de septiembre de 2026`), `view.html` y `list.html`.
* **Traducción Integral de `schedule/add.html`:**
* Formulario y textos de ayuda 100% traducidos.
* Caja dinámica de resumen (JavaScript e `i18n-strings`) con cálculo de duración y validación de sobrecupo en español.
---
## 7. Módulo de Gestión de Accesos (RBAC) con Matriz FortiGate
* **Modelos y Persistencia:**
* Módulo `app/models/role.py` con `Role` y `Permission`.
* Roles iniciales sembrados: `Admin` (total, protegido), `Docente` (reservas y horarios), `Operador` (aulas y reservas), `Consulta` (solo lectura).
* Vinculación del usuario inicial `admin@edu-space.com` al rol `Admin`.
* **Matriz de Permisos Estilo FortiGate (`/admin/roles/edit/<id>`):**
* Tabla interactiva con columnas **Access Control** y **Permissions**.
* Segmented control por módulo con tres estados: `🚫 None`, `👁️ Read` y `✏️ Read/Write`.
* Color activo verde esmeralda FortiGate (`#2D9F6F`) con texto e icono en blanco puro.
* Menú desplegable masivo **Set All ▾** (*Set All to None*, *Set All to Read*, *Set All to Read/Write*).
* **Módulo de Administración (`app/routes/admin.py`):**
* `/admin/roles`: Listado de roles, conteo de miembros, visualización de permisos en badges (RW, R, -) y modal de borrado seguro.
* `/admin/users`: Listado de cuentas con filtros por nombre, rol y estado (Activo/Inactivo), paginación y switch rápido para alternar accesos.
* `/admin/users/add` y `/admin/users/edit/<id>`: Formularios de alta y edición con asignación de rol y contraseña opcional.
* **Seguridad y Decoradores (`app/utils/decorators.py`):**
* `@permission_required(module, min_level)` y `@admin_required`.
* Pestaña **Management** en el navbar visible exclusivamente para administradores.
* Botones de acción sensibles en el navbar (*Add Classroom*, *New Reservation*) condicionados a permisos de escritura.
---
## 8. Sincronización y Actualización de `init_db.py`
* Migraciones DDL automáticas (`ALTER TABLE ... ADD COLUMN IF NOT EXISTS`) para `preferred_language`, `theme_preference`, `role_id`, y para la tabla `careers` y columna `career_id` en `subjects`.
* Creación y siembra de roles institucionales y permisos en PostgreSQL.
* Configuración de salida con codificación UTF-8 segura en Windows para evitar excepciones de `cp1252`.
---
## 9. Sincronización e Importación de Datos Académicos (Google Sheets)
* **Modelo `Career` (`app/models/career.py`):**
* Entidad de carreras universitarias vinculada con `Subject` vía relación uno a muchos.
* Migración en PostgreSQL de la tabla `careers` y campo `career_id` en `subjects`.
* **Servicio de Extracción ([app/services/sheets_importer.py](file:///c:/Workspace/admin-edu-space/app/services/sheets_importer.py)):**
* Integración con la cartelera oficial UniCABA 2026 a través de 6 hojas CSV (`Lunes` a `Sábado`).
* Normalización inteligente de espacios áulicos físicos (`Aula 402`, `Aula 302`), espacios virtuales (`VIRTUAL`), auditorios y salas de reunión.
* Extracción y parseo de franjas horarias (`8:00 a 9:00hs`, `17:30 a 21:30hs`).
* Rutina de **upsert** transaccional e idempotente que previene la duplicación de datos.
* Generación de reservas/clases en el calendario para poblar instantáneamente las vistas de horario diario y semanal.
* **Interfaz de Gestión (`/admin/import`):**
* Vista interactiva con métricas en tiempo real (total de carreras, asignaturas, aulas, comisiones y clases).
* Botón de ejecución manual de sincronización con indicador visual de progreso (`spinner`).
* Reporte detallado de resultados y tabla con vista previa de asignaturas y comisiones sincronizadas.
* **Acceso Rápido en Navbar (`app/templates/base.html`):**
* Nueva opción **"Import from Google Sheets"** incorporada en el menú desplegable **Management**, restringida a roles con permisos administrativos.
---
## 10. Correcciones en la Vista de Calendario (FullCalendar) y Códigos de Asignatura
* **Carga de Eventos y Corrección de Error 500 (`/schedule/calendar-data`):**
* Se identificó y corrigió el error `AttributeError: 'str' object has no attribute 'value'` provocado por llamadas a `.status.value` sobre campos de estado textuales.
* Se implementó el TypeDecorator `StatusType` con la clase `StatusString` en `app/models/reservation.py` garantizando compatibilidad total tanto para `.status` como para `.status.value`.
* Sanitización de fechas ISO 8601 con zona horaria (`Z`, `-03:00`) enviadas por FullCalendar para comparación adecuada con campos `TIMESTAMP WITHOUT TIME ZONE` en PostgreSQL.
* Anclaje de fecha inicial con `initialDate: '{{ today_date }}'` para posicionar el calendario en la semana activa de cursada 2026.
* **Corrección del Código de Asignatura (`subject_code`):**
* Se reemplazó la concatenación de código extendido de comisión (`Commission.get_full_code()` que arrojaba cadenas distorsionadas como `INGA0003-C1-2026-22026`) por el **código de asignatura real** del Sheet (`Subject.code`, ej. `INGA0003`, `ASIG00165`).
* Propiedades de acceso directo añadidas en `Reservation`: `subject_code`, `subject_name` y `classroom_name`.
* **Jerarquía Visual y Renderizado de Tarjetas (`calendar.html`):**
* Implementación del hook `eventContent` en FullCalendar 5 para construir tarjetas personalizadas.
* **Nombre de la Asignatura** destacado como elemento principal con tipografía grande y en negrita (`font-weight: 700; font-size: 0.88rem`).
* **Código de Asignatura** y **Aula** mostrados como datos secundarios en badges de alto contraste (código con fondo oscuro translúcido y aula con isotipo y fondo blanco translúcido).
* Modal interactivo instantáneo al hacer clic en cualquier evento con todos los detalles académicos de la clase y enlace a la ficha completa.
---
## 11. Filtros por Aula y Materia en el Calendario de Reservas
* **Barra de Herramientas de Filtros Interactiva (`calendar.html`):**
* **Filtrar por Aula:** Desplegable dinámico con todas las aulas activas ordenadas por edificio y código (físicas y virtuales). Al seleccionar un aula (ej. `Aula 402`), se eliminan todas las superposiciones visuales mostrando el cronograma limpio de ese espacio.
* **Filtrar por Materia:** Desplegable ordenado alfabéticamente con formato `[Código] Nombre de la Asignatura`. Permite aislar todas las clases de una asignatura en particular.
* **Filtrar por Carrera:** Filtro adicional por plan académico/carrera.
* **Botón Limpiar Filtros:** Restablece instantáneamente todos los controles y refresca la vista.
* **Insignias de Estado:** Indicador en tiempo real de los filtros aplicados y contador de clases visibles (`X clases visibles`).
* **Soporte en Backend (`app/routes/schedule.py`):**
* `calendar_view`: Suministra las listas de `classrooms`, `subjects` y `careers` a la plantilla.
* `calendar_data`: Procesa `classroom_id`, `subject_id`, `career_id` y `search` aplicando cláusulas `filter()` con `JOIN` en SQLAlchemy.
* `refetchEvents()` de FullCalendar conectado a los eventos `change` de cada selector para recarga inmediata vía AJAX sin parpadeo de pantalla.
---
## 12. Módulo de Gestión de Edificios y Corrección de Vistas de Aulas
* **Corrección de Plantillas de Aulas Faltantes:**
* Se crearon las plantillas [app/templates/classrooms/edit.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/edit.html) y [app/templates/classrooms/view.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/view.html), solucionando el error HTTP 500 (`TemplateNotFound: classrooms/edit.html`) y la redirección 302 que ocurría al hacer clic en cualquier aula.
* La vista de ficha técnica del aula ahora muestra detalles de capacidad, piso, estado, edificio y la tabla interactiva de próximas reservas.
* **Modelo y Base de Datos de Edificios (`Building`):**
* Se creó el modelo [app/models/building.py](file:///c:/Workspace/admin-edu-space/app/models/building.py) (`id`, `name`, `code`, `address`, `floors`, `description`, `is_active`, timestamps y relación con `Classroom`).
* Migración DDL en PostgreSQL (`CREATE TABLE buildings`, `ALTER TABLE classrooms ADD COLUMN building_id`).
* Actualización de [init_db.py](file:///c:/Workspace/admin-edu-space/init_db.py) y siembra de edificios institucionales iniciales (`Edificio Central` y `Campus Virtual`) con vinculación de las 25 aulas existentes.
* **Rutas y Gestión Completa (`app/routes/buildings.py`):**
* `/buildings`: Listado con métricas de infraestructura (total de edificios, aulas activas, capacidad total y plantas/pisos), barra de búsqueda y filtros por estado.
* `/buildings/<id>`: Vista de detalle con distribución de aulas organizadas visualmente por piso (Planta Baja, Piso 1, Piso 2, etc.), capacidad por sala y acciones directas.
* `/buildings/add` y `/buildings/<id>/edit`: Formularios con validación WTForms para alta y actualización.
* `/buildings/<id>/toggle` y `/buildings/<id>/delete`: Control de activación y eliminación protegida (impide borrar edificios que contengan aulas asignadas).
* **Navegación (`app/templates/base.html`):**
* Enlace a **Edificios** (`/buildings`) incorporado en el menú **Classrooms** y en el menú administrativo **Management**.
---
## 13. Mapeo de Campos del Google Sheet y Concepto de Turno (Mañana / Tarde)
* **Uso de los 5 Campos de la Cartelera Oficial:**
* **Codigo (`Subject.code` y `Commission.code`):** Almacena el código académico real (`INGA0003`, `ASIG00165`). Se expone como badge monoespaciado en eventos de calendario, comisiones y vistas administrativas.
* **Asignatura (`Subject.name`):** Título principal de la materia destacado en texto grande y en negrita en FullCalendar y en la cartelera diaria.
* **Aula (`Classroom.code`, `Classroom.building`, `Classroom.floor`, `Classroom.capacity`):** Normalización de espacios físicos y virtuales vinculados al nuevo modelo `Building`.
* **Carrera (`Career.name` y `Subject.career_id`):** Entidad de carrera universitaria con relaciones relacionales normalizadas.
* **Horarios (`Commission.schedule`, `Reservation.start_time`, `Reservation.end_time`):** Rango horario parseado hacia objetos `datetime` nativos para el motor de reservas y el calendario.
* **Modelado y Captura de Turnos (`TURNO MAÑANA` y `TURNO VESPERTINO`):**
* Se agregó la columna `shift` en las tablas `commissions` y `reservations` de PostgreSQL y en sus respectivos modelos de SQLAlchemy.
* El parser de [app/services/sheets_importer.py](file:///c:/Workspace/admin-edu-space/app/services/sheets_importer.py) identifica las filas delimitadoras de bloque (`TURNO MAÑANA` vs `TURNO VESPERTINO / TARDE`) y corrobora la hora de inicio (< 13:00hs = Mañana, $\ge$ 14:00hs = Tarde).
* Se sincronizaron todas las comisiones y reservas de cursada: 15 comisiones y 48 reservas en `Turno Mañana`; 191 comisiones y 364 reservas en `Turno Tarde / Vespertino`.
* En el Calendario de Reservas (`/schedule/calendar`) se añadió el selector **Filtrar por Turno** (`Todos`, `Mañana`, `Tarde / Vespertino`), permitiendo a los usuarios aislar las franjas horarias y resolver la alta concentración de clases en el horario vespertino (17:30 a 21:30hs).
---
## 14. Accesos Directos en Panel, Ocultamiento de Virtuales, Filtro por Piso, Dirección Oficial y Traducción
* **Tarjetas Estadísticas como Accesos Directos Interactivos ([app/templates/dashboard.html](file:///c:/Workspace/admin-edu-space/app/templates/dashboard.html)):**
* **Total de Aulas (25):** Redirige a `/classrooms/list` con feedback visual de hover y elevación.
* **Total de Materias (206):** Redirige a `/schedule/commissions`.
* **Reservas de Hoy (18):** Redirige a `/schedule/today`.
* **Esta Semana (103):** Redirige a `/schedule/calendar`.
* Se añadieron microinteracciones CSS en [app/static/css/style.css](file:///c:/Workspace/admin-edu-space/app/static/css/style.css) (`.dashboard-stat-card`).
* **Ocultar Aulas Virtuales y Filtro por Piso:**
* **Calendario ([app/templates/schedule/calendar.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/calendar.html)):** Switch interactivo *Ocultar Aulas Virtuales* (indica capacidad ilimitada) y selector *Filtrar por Piso* (`Planta Baja`, `Piso 1`, `Piso 2`, `Piso 3`, `Piso 4`). El backend `/schedule/calendar-data` excluye las aulas virtuales o filtra por número de piso.
* **Cartelera del Día ([app/templates/schedule/today.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/today.html)):** Barra de filtros completa con soporte para ocultar virtuales, piso y turno.
* **Listado de Reservas ([app/templates/schedule/list.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/list.html)):** Endpoint `/schedule/list` totalmente operativo con paginación de 20 registros, preservación de query params y filtros de piso, turno, virtuales, estado y rango de fechas.
* **Dirección Oficial de la Sede Central:**
* Se configuró la dirección oficial de la Universidad de la Ciudad de Buenos Aires (UniCABA): **`Tte. Gral. Juan Domingo Perón 802, CABA`** en la base de datos, en [init_db.py](file:///c:/Workspace/admin-edu-space/init_db.py) y en el importador [app/services/sheets_importer.py](file:///c:/Workspace/admin-edu-space/app/services/sheets_importer.py).
* **Traducción Integral al Español y Catálogo Babel:**
* Se agregaron traducciones en [translations/es/LC_MESSAGES/messages.po](file:///c:/Workspace/admin-edu-space/translations/es/LC_MESSAGES/messages.po) y se recompiló el binario `.mo` con `pybabel`.
* Textos traducidos: `Buildings` $\rightarrow$ *Edificios*, `Management` $\rightarrow$ *Gestión*, `Timeline View` $\rightarrow$ *Vista Cronológica*, `Grid View` $\rightarrow$ *Vista en Tabla*, `Active Classrooms` $\rightarrow$ *Aulas Ocupadas*, `All Reservations` $\rightarrow$ *Todas las Reservas*, `AI Room Optimizer` $\rightarrow$ *Optimizador de Aulas*, etc.
---
## 15. Actualización Integral de Roles RBAC, Capacidad Infinita (∞) y Horarios de Próximas Clases
* **Actualización de Roles, Grupos de Acceso y Matriz de Permisos ([app/models/role.py](file:///c:/Workspace/admin-edu-space/app/models/role.py), [init_db.py](file:///c:/Workspace/admin-edu-space/init_db.py)):**
* Se amplió `SYSTEM_MODULES` para cubrir los 8 módulos del sistema:
1. `classrooms`: **Aulas y Espacios** (físicas, virtuales y capacidades).
2. `buildings`: **Edificios y Sedes** (Sede Central, Campus Virtual, plantas).
3. `reservations`: **Reservas de Aulas** (aprobación, confirmación, cancelación).
4. `schedule`: **Cartelera y Cronograma** (calendario, cartelera del día, comisiones).
5. `academic`: **Gestión Académica** (carreras, asignaturas, códigos).
6. `import`: **Sincronización Sheets** (importador Google Sheets).
7. `optimizer`: **Optimizador IA** (algoritmo genético áulico).
8. `users`: **Usuarios y Accesos RBAC** (cuentas, roles y matriz).
* Se actualizaron los perfiles estándar (`Admin`, `Docente`, `Operador`, `Consulta`) en PostgreSQL y en `init_db.py`.
* Se tradujeron completamente las plantillas [app/templates/admin/roles/list.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/roles/list.html) y [app/templates/admin/roles/edit.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/roles/edit.html) al español.
* **Soporte de Capacidad Infinita (∞) y Aclaración ([app/forms/classroom.py](file:///c:/Workspace/admin-edu-space/app/forms/classroom.py), [app/templates/classrooms/edit.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/edit.html)):**
* Se actualizó `ClassroomForm.capacity` con validador personalizado que admite el símbolo **`∞`**, `inf`, `0` o cualquier capacidad numérica positiva.
* Se corrigió la validación de `floor` con `InputRequired` y `NumberRange(min=0)` para aceptar Planta Baja (Piso 0).
* En la interfaz de edición y alta de aulas se incluyó el botón de acción rápida **`Ilimitada (∞)`** que rellena automáticamente el campo con el símbolo `∞`.
* Se añadió una leyenda explicativa directa: *"Para aulas virtuales o eventos sin aforo restringido, ingrese ∞ o 0 (Capacidad ilimitada)"*.
* Se tradujo el botón de guardado a *"Guardar Cambios"* (reemplazando *"Save Changes"*).
* El aula virtual institucional (`Campus Virtual - VIRTUAL`) se actualizó a capacidad `0` (`∞`), renderizando el badge `∞ Ilimitada` en listados y fichas.
* **Próximas Clases: Visualización Exclusiva del Horario ([app/templates/dashboard.html](file:///c:/Workspace/admin-edu-space/app/templates/dashboard.html), [app/templates/classrooms/view.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/view.html)):**
* En la tabla de *Próximas Reservas* del panel y en la ficha del aula se cambió la columna a **"Horario"**, removiendo la fecha del calendario y destacando únicamente el rango horario de cursada (ej. `17:30 - 21:30 hs`).
-223
View File
@@ -1,223 +0,0 @@
# Estado Actual de la Migración y Arquitectura Objetivo
## Sistema Integral de Gestión de Aulas, Cursadas y Seguimiento Académico — UniCABA
**Rol:** Arquitecto de Software y Analista Funcional Senior
**Institución:** Universidad de la Ciudad de Buenos Aires (UniCABA)
**Fecha de Actualización:** 19 de Septiembre de 2026
**Rama Activa:** `testing`
---
## 1. De Dónde Venimos: La Versión Monolítica (`legacy_admin-edu-space`)
La versión original del sistema correspondía a un monolito clásico desarrollado en Python (Flask) con renderizado del lado del servidor mediante plantillas Jinja2 y ORM SQLAlchemy:
```
[Cliente Web / Navegador]
│
▼ (HTTP / HTML)
┌────────────────────────────────────────────────────────┐
│ Monolito Legacy (legacy_admin-edu-space) │
│ - Rutas Flask con render_template (Jinja2) │
│ - Sesión en Cookies de Flask (Flask-Login con estado) │
│ - Consultas directas SQLAlchemy acopladas a vistas │
│ - Base de Datos (SQLite / PostgreSQL) │
└────────────────────────────────────────────────────────┘
```
### Fortalezas Heredadas del Monolito
1. **Modelado de Dominio Inicial:** Entidades base para Sedes (`Building`), Aulas (`Classroom`), Materias (`Subject`), Comisiones (`Commission`), Reservas (`Reservation`) y Usuarios (`User`).
2. **Importador de Planillas Oficiales:** Módulo de sincronización con Google Sheets para alimentar la cartelera con la oferta de comisiones y clases.
3. **Algoritmo Genético Experimental:** Prototipo de optimización predictiva para asignación de aulas físicas según aforo y equipamiento.
### Limitaciones Críticas que Forzaron la Migración
* **Acoplamiento Servidor-Vista:** Lógica de negocio mezclada dentro de controladores web y macros de plantillas Jinja2, impidiendo el consumo móvil (app institucional) o integraciones externas (SIU Guaraní, Moodle).
* **Fallas Graves en Virtualidad:** El importador de Google Sheets descartaba más del 75% de las clases virtuales por asumir que el aula `Campus Virtual · VIRTUAL` sufría colisiones físicas de horario ante comisiones simultáneas.
* **Falta de Perspectiva por Rol:** El dashboard era único y uniforme; no distinguía entre el trabajo administrativo de Bedelía, la consulta rápida de cursada del Alumno, o la gestión evaluativa del Docente.
* **Escalabilidad y Seguridad:** Autenticación dependiente de la sesión en memoria del servidor web, sin soporte de tokens JWT sin estado ni separación de responsabilidades (BFF pattern).
---
## 2. Dónde Estamos Actualmente: Arquitectura Desacoplada Backend-BFF
La infraestructura ha sido transformada en un modelo desacoplado y de alta disponibilidad:
```
[ Navegador Web / Cliente ]
│
│ (HTTP / HTML + Cookies HttpOnly)
▼
┌──────────────────────────────────────────────┐
│ Frontend BFF (Node.js + Express) │
│ Puerto: 3000 │
│ - Renderizado Nunjucks con herencia limpia │
│ - Orquestación y agregación de UI │
│ - Gestión de Cookie JWT y Contexto de Rol │
│ - Cliente Axios con Interceptor Bearer │
└──────────────────────┬───────────────────────┘
│
│ (REST JSON / JWT Bearer)
▼
┌──────────────────────────────────────────────┐
│ Backend Core API (Python + Flask) │
│ Puerto: 5000 │
│ - Endpoints RESTful bajo /api/v1/ │
│ - Autenticación Stateless JWT (HS256) │
│ - Pipeline Enterprise SecurityFilterChain │
│ - Capa de Servicios y DTOs (Pydantic) │
│ - Persistencia SQLAlchemy (Transaccional) │
└──────────────────────┬───────────────────────┘
│
▼
[ Base de Datos SQLite / PG ]
```
### Estado de Implementación al Día de la Fecha
| Componente / Módulo | Estado en Legacy | Estado Actual (Backend API + BFF) | Nivel de Paridad |
| :--- | :--- | :--- | :---: |
| **Autenticación & Sesión** | Flask-Login basado en sesión de servidor | Autenticación REST JWT con cookie HttpOnly segura y RBAC centralizado | **120% (Superado)** |
| **Dashboards por Rol** | Dashboard monolítico único para todos | 4 Dashboards especializados (`admin`, `bedelia`, `docente`, `alumno`) con selector dinámico | **150% (Superado)** |
| **Campus Virtual & Enlaces** | Colisión de clases virtuales; pérdida de enlaces Meet | Aulas virtuales concurrentes sin límite, auto-generación de enlaces Google Meet por comisión | **140% (Superado)** |
| **Importador Google Sheets** | 363 reservas importadas (colisiones masivas) | 643 reservas totales (388 virtuales) sin colisión horaria entre comisiones | **100% (Paridad Total)** |
| **Cartelera del Día (`today_schedule`)** | Vista Jinja con bloques rígidos | Timeline interactivo por horas (7 a 21 hs), filtros por piso/turno y switch `hide_virtual` | **110% (Superado)** |
| **Ficha de Reserva (`view_reservation`)** | Vista monolítica rígida | Acoplada al BFF con relaciones anidadas completas, edición en cascada, confirmación y cancelación | **100% (Paridad Total)** |
| **Gestión de Comisiones (`commissions_list`)** | Lista plana sin modal de creación en admin | Modal `#modalAddCommission` con vinculación obligatoria a Asignatura, docente, cupo, horarios y Meet | **125% (Superado)** |
| **Calendario FullCalendar** | Feed JSON básico | Endpoint `/api/v1/reservations` con discriminación cromática (`#6366F1` virtuales) | **100% (Paridad Total)** |
---
## 3. A Dónde Queremos Ir: El Sistema Integral UniCABA
El objetivo institucional es consolidar la plataforma como el **ERP Académico y Gestor de Espacios Oficial de UniCABA**, dando soporte a las dinámicas pedagógicas físicas y digitales de la universidad.
### A. Entorno Físico vs. Campus Virtual
1. **Edificio Físico (Central):**
- Espacios con aforo estrictamente limitado, asignación por piso/ala y equipamiento técnico (proyectores, laboratorios informáticos, acústica).
- Validación matemática de no-solapamiento temporal para evitar doble asignación de aula física.
2. **Campus Virtual:**
- Capacidad física infinita; asignación lógica de salas vinculadas a la plataforma oficial (Google Meet institucional, Zoom o Microsoft Teams).
- Control de concurrencia temporal de docentes y estudiantes (evitar que un docente o alumno deba estar en dos salas remotas al mismo tiempo).
---
## 4. Reglas de Negocio del Sistema Objetivo
### 1. Perfiles y Permisos (RBAC Avanzado)
* **Administrador General (Superadmin):**
* Gobernanza total del sistema, configuración de periodos y parametrización de reglas de conflicto.
* **Modo Impersonación / Vista Previa:** Visualización en tiempo real del sistema desde la perspectiva exacta de cualquier usuario (Bedelía, Docente, Alumno) para soporte y auditoría.
* Flexibilidad académica para autorizar excepciones de cursada simultánea y apertura de comisiones extraordinarias.
* **Bedelía / Gestión Operativa:**
* Administración integral del inventario físico (Sedes, Edificios, Pisos, Aulas) y virtual.
* Creación y parametrización de Ciclos Lectivos, Asignaturas y Comisiones.
* **Asignación Docente Co-docente:** Posibilidad de asignar más de un profesor a una misma comisión (equipo de cátedra: titulares, adjuntos, ayudantes).
* Gestión de cupos máximos, padrón de alumnos matriculados y resolución de conflictos de asignación.
* Supervisión de hitos evaluativos institucionales.
* **Docente:**
* Vista segmentada estrictamente a sus comisiones asignadas.
* Calendario de clases presenciales y accesos directos a sesiones de videoconferencia.
* Gestión del calendario de evaluación: carga de fechas de parciales, recuperatorios, entregas de trabajos prácticos y finales.
* Matriz ágil de carga y edición de calificaciones y estados de regularidad.
* **Alumno:**
* Acceso enfocado a sus materias cursadas en el periodo lectivo activo.
* Brújula de cursada diaria (*¿Dónde curso hoy?* con sede, piso y aula física o enlace virtual).
* Calendario unificado de entregas de TPs, parciales y finales con avisos preventivos.
* Consulta histórica de notas y retroalimentaciones pedagógicas.
### 2. Estructura Académica y Ciclo Lectivo
* **Ciclo Lectivo Flexible:** Estructurado por Año (ej. `2026`) y Periodos estándar (`1C`, `2C`, `Anual`), permitiendo el alta de "Cursos Cortos Intensivos" o trayectos formativos extracurriculares con fechas especiales.
* **Restricción Diaria de Cursada del Estudiante:**
* **Regla por defecto:** Un estudiante universitario no puede cursar dos asignaturas regulares el mismo día calendario.
* **Excepción parametrizable:** Se autoriza la coincidencia en el mismo día si una de las materias corresponde a un curso corto intensivo o mediante autorización explícita de Bedelía/Secretaría Académica.
### 3. Matriz de Conflictos y Validaciones de Solapamiento
```mermaid
flowchart TD
Req([Solicitud de Asignación / Reserva]) --> V1{¿Es Aula Física?}
V1 -- Sí --> C1{¿Aula ocupada en ese día y horario?}
C1 -- Sí --> Err1[BLOQUEO: Conflicto Físico Infranqueable]
C1 -- No --> C2{¿Aforo de alumnos <= Capacidad aula?}
C2 -- No --> Err2[BLOQUEO: Aforo Excedido]
C2 -- Sí --> V2
V1 -- No (Virtual) --> V2
V2{¿Docente en otra clase en el mismo horario?}
V2 -- Sí --> C3{¿Comisión cuenta con Co-Docencia?}
C3 -- No --> Err3[BLOQUEO: Conflicto Docente]
C3 -- Sí --> Warn1[ALERTA: Excepción Co-Docente Justificada]
C3 --> V3
V2 -- No --> V3
V3{¿Alumno cursa otra materia regular el mismo día?}
V3 -- Sí --> C4{¿Es Curso Corto o Autorizado?}
C4 -- No --> Err4[BLOQUEO: Regla Restricción Diaria]
C4 -- Sí --> Ok[ASIGNACIÓN EXITOSA]
V3 -- No --> Ok
```
---
## 5. Diseño Técnico: Base de Datos Relacional Normalizada
Para soportar las reglas de co-docencia, inscripciones, evaluaciones y restricciones de día, el esquema de datos evolucionará hacia la siguiente estructura:
### Entidades Principales
1. **`academic_terms` (Ciclos Lectivos):** `id`, `name`, `code` (ej. '2026-1C'), `year`, `term_type` ('1C', '2C', 'ANUAL', 'CORTO'), `start_date`, `end_date`, `is_active`.
2. **`subjects` (Materias / Asignaturas):** `id`, `code`, `name`, `career_id`, `is_short_course` (boolean), `credits`, `active`.
3. **`commissions` (Comisiones de Cursada):** `id`, `subject_id`, `term_id`, `code`, `shift`, `schedule_display`, `max_students`, `virtual_link`, `active`.
4. **`commission_teachers` (Co-docencia / Equipo Docente):** `id`, `commission_id`, `teacher_id`, `role` ('TITULAR', 'ADJUNTO', 'AYUDANTE'), `can_grade` (boolean).
5. **`commission_schedules` (Asignación Horaria y Espacial):**
* `id`, `commission_id`, `day_of_week` (1=Lunes .. 7=Domingo), `start_time`, `end_time`, `classroom_id` (física o virtual), `is_virtual`.
* *Constraint de Integridad:* `UNIQUE(classroom_id, day_of_week, start_time, end_time)` para aulas no virtuales.
6. **`enrollments` (Inscripciones de Alumnos):** `id`, `commission_id`, `student_id`, `status` ('REGULAR', 'CONDICIONAL', 'LIBRE', 'PROMOVIDO'), `enrolled_at`, `allow_same_day_exception` (boolean).
7. **`evaluation_milestones` (Hitos Evaluativos):** `id`, `commission_id`, `milestone_type_id`, `title`, `date`, `start_time`, `weight_percentage`, `is_mandatory`.
8. **`student_grades` (Calificaciones y Actas):** `id`, `milestone_id`, `student_id`, `numeric_score`, `concept_score`, `feedback`, `graded_by_teacher_id`, `graded_at`.
---
## 6. Diseño de Contratos de API RESTful (Backend Core)
### A. Módulo de Comisiones y Co-docencia
* `GET /api/v1/commissions`: Filtro por término académico, carrera, asignatura, turno y estado.
* `POST /api/v1/commissions`: Alta de comisión vinculada a una asignatura con generación automática de Meet.
* `POST /api/v1/commissions/{id}/teachers`: Asignación de docentes con rol en cátedra (co-docencia).
* `DELETE /api/v1/commissions/{id}/teachers/{teacher_id}`: Desvinculación de docente.
### B. Módulo de Matriz de Conflictos y Validación
* `POST /api/v1/schedules/validate`:
* **Payload:** `{ commission_id, classroom_id, day_of_week, start_time, end_time, teacher_ids, student_ids }`
* **Respuesta:** `{ valid: boolean, conflicts: [ { type: 'PHYSICAL'|'TEACHER'|'STUDENT_DAILY', message, details } ] }`
### C. Módulo de Hitos Evaluativos y Libro de Calificaciones
* `GET /api/v1/commissions/{id}/milestones`: Listado de exámenes, entregas y parciales.
* `POST /api/v1/commissions/{id}/milestones`: Creación de hito evaluativo.
* `GET /api/v1/commissions/{id}/gradebook`: Matriz completa de alumnos y notas de la comisión.
* `PUT /api/v1/commissions/{id}/gradebook`: Carga masiva o individual de calificaciones por docente.
### D. Modo Impersonación de Superadmin
* Cabecera HTTP: `X-Impersonate-User: {user_id}`
* Procesamiento en `authMiddleware`: Si el token emisor corresponde al rol `SUPERADMIN`, el contexto de ejecución adopta los permisos y vistas del usuario destino para auditoría, dejando registro en `audit_logs`.
---
## 7. Directrices UI/UX para el Frontend BFF
1. **Panel de Bedelía:**
- Grilla interactiva semanal (tipo TimeGrid) donde los bloques de comisiones se visualizan codificados por color según edificio, aula física o aula virtual.
- Detección inmediata de colisiones con carteles de advertencia flotantes antes de confirmar cambios.
2. **Matriz de Carga Rápida para Docentes:**
- Tabla editable en línea (*spreadsheet-like*) para volcar notas de parciales y entregas sin recargar la página, con autoguardado asíncrono.
3. **Perspectiva del Alumno:**
- Interfaz simplificada con diseño enfocado en dispositivos móviles, mostrando en la tarjeta de inicio la clase del momento con botón directo para ingresar a la videollamada o indicador de piso y aula física.
---
## 8. Conclusión del Diagnóstico
El sistema ha superado con éxito la fase de desacople inicial y estabilización de virtualidad:
- Se logró paridad total con `legacy_admin-edu-space` en importación de cronograma y cartelera.
- Se eliminaron las fallas de colisión virtual y se establecieron los 4 dashboards por rol.
- Se completó el acople de la ficha de reserva (`schedule.view_reservation`) y la creación asistida de comisiones en el BFF.
Las siguientes etapas se concentrarán en la consolidación de las reglas académicas complejas (co-docencia, restricción diaria de cursada, matriz de conflictos y libro de calificaciones) detalladas en el **`ROADMAP_MVP.md`**.
+230 -100
View File
@@ -1,119 +1,249 @@
# Panel de Administración Edu-Space (UniCABA)
# Edu-Space Admin Panel
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)**.
A comprehensive web-based administration interface for the Edu-Space Classroom Reservation System built with Flask.
El proyecto se basa en una arquitectura **desacoplada (Frontend / Backend)** en formato monorepo.
## Features
---
### 🔐 Authentication & Security
- Secure login system with hashed passwords
- Role-based access control
- Session management with Flask-Login
- Password change functionality
## 🏛️ Sede Institucional y Ubicaciones
* **Sede Central UniCABA:** Tte. Gral. Juan Domingo Perón 802, Ciudad Autónoma de Buenos Aires (CABA) — Aulas de grado, auditorio principal y laboratorios distribuidos desde Planta Baja (Piso 0) hasta el 4.° Piso.
* **Campus Virtual:** Espacio áulico digital integrado (Microsoft Teams / Moodle) configurado con **capacidad ilimitada ($\infty$)**.
### 🏫 Classroom Management
- Create, edit, and delete classrooms
- Campus and building organization
- Capacity and resource tracking
- Real-time availability checking
---
### 📅 Schedule Management
- Interactive calendar view (FullCalendar)
- Reservation creation and management
- Conflict detection and prevention
- Status tracking (Pending, Confirmed, Cancelled)
- Today's schedule overview
## 🏗️ Arquitectura del Proyecto (Monorepo)
### 📊 Dashboard & Analytics
- Real-time statistics
- Monthly reservation charts
- Quick actions interface
- Upcoming reservations overview
El repositorio está dividido en dos aplicaciones principales que interactúan mediante una API REST y un patrón Backend-For-Frontend (BFF).
### 🎨 Modern UI/UX
- Responsive Bootstrap 5 design
- Dark theme support
- Mobile-friendly interface
- Interactive components and charts
```text
/
├── backend/ # API REST Backend (Python / Flask)
│ ├── app/ # Lógica de negocio, base de datos y endpoints
│ ├── tests/ # Pruebas unitarias e integración
│ └── requirements.txt # Dependencias de Python
│
├── frontend/ # Backend-For-Frontend (Node.js / Express)
│ ├── src/ # Controladores, middleware, servicios (Axios)
│ ├── views/ # Plantillas Nunjucks (.html)
│ ├── public/ # Archivos estáticos (CSS, JS cliente)
│ └── package.json # Dependencias de Node.js
│
├── docs/ # Historial de cambios y documentación técnica
└── unicaba_prerequitos/ # Requisitos académicos y de negocio
## Tech Stack
- **Backend**: Flask (Python)
- **Database**: SQLAlchemy (SQLite/PostgreSQL)
- **Frontend**: Bootstrap 5, jQuery, Chart.js
- **Authentication**: Flask-Login, Werkzeug
- **Forms**: Flask-WTF
- **Calendar**: FullCalendar.js
## Installation
### Prerequisites
- Python 3.8+
- pip package manager
### Setup
1. **Clone or setup the project:**
```bash
cd /Users/alejandro.v/PycharmProjects/admin-edu-space
```
### 🐍 Backend (Python + Flask)
- Funciona exclusivamente como una **REST API** que expone endpoints bajo `/api/v1/` retornando **JSON**.
- Encargado de la persistencia de datos (SQLAlchemy / PostgreSQL), lógica de negocio dura, JWT auth service, seguridad, y algoritmos de optimización (Módulo Predictivo).
- **Prohibido** retornar HTML o utilizar Jinja2/`render_template` en este entorno.
### 🟢 Frontend BFF (Node.js + Express)
- Aplicación de servicio que renderiza las vistas para el cliente mediante **Nunjucks**.
- Gestiona la sesión segura del usuario almacenando el token JWT recibido de Flask en una **Cookie HttpOnly**.
- Actúa como proxy de conexión con el Backend de Python empleando **Axios** (inyectando el Bearer token interceptado).
- Utiliza **Chart.js** y JS vanilla en el lado del cliente para visualizaciones interactivas.
---
## 🎯 Dashboards Personalizados por Rol
La plataforma cuenta con paneles adaptados a las necesidades operativas de cada perfil institucional:
* **Administrador (`/dashboard?role=admin`):** Cockpit institucional con métricas globales, auditoría de seguridad RBAC, tendencias mensuales y panel interactivo de **Estado y Percentiles de Asignación de Aulas Físicas** (asignadas vs disponibles en número y porcentaje).
* **Bedelía (`/dashboard?role=bedelia`):** Control operativo del campus en tiempo real, balance de aulas presenciales asignadas (96.3%) y libres (3.7%), cartelera del día y bandeja de aprobación de reservas pendientes.
* **Docente / Profesor (`/dashboard?role=docente`):** Sesiones del día con accesos directos a videollamadas y gestión de comisiones.
* **Alumno / Estudiante (`/dashboard?role=alumno`):** Brújula de cursada *"¿Dónde curso hoy?"* con ubicación por piso y aula o enlace virtual, junto al cronograma de exámenes y entregas de TPs.
> **Modo Vista Previa:** Los usuarios con perfil administrador o directivo pueden previsualizar cualquier perspectiva utilizando el selector de roles en la barra superior o pasando el parámetro `?role={admin|bedelia|docente|alumno}` en la URL.
---
## ☁️ Sincronización Google Sheets, Configuración Global & Purga Académica
El sistema incluye sincronización automatizada con las planillas oficiales de cursada de UniCABA:
* **Configuración Global Dinámica:** Selección y persistencia de la URL de Google Sheets directamente desde la interfaz web (`/admin/google_sheets_import`) sin valores fijos en el código.
* **Herramienta de Purga y Reinicio (`reset_academic_data`):** Módulo CLI y web para limpiar selectivamente reservas, aulas, asignaturas, comisiones o carreras, garantizando pruebas limpias desde cero y preservando siempre usuarios y credenciales.
* **Concurrencia Virtual Ilimitada:** Reconocimiento de espacios áulicos remotos (`Campus Virtual · VIRTUAL`), permitiendo la coexistencia de múltiples comisiones en la misma franja horaria sin colisiones de agenda.
* **Catálogo Presencial con Cursadas Asignadas:** Listado de aulas físicas con cálculo de ocupación real (semana/mes), vista previa de asignaturas y enlaces directos a calendario y ficha del espacio.
* **Enlaces de Videollamada Automáticos:** Generación de enlaces seguros (Google Meet / Teams) para acceso instantáneo de docentes y estudiantes.
* **Filtros en Cartelera:** Opciones para filtrar por turno, piso u ocultar aulas virtuales mediante el switch dedicado en `/schedule/today_schedule`.
---
## 📅 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.
* **Gestión de Comisiones y Vinculación con Asignaturas (`/admin/commissions_list`):**
- Botón y modal interactivo **Nueva Comisión** (`#modalAddCommission`) para registrar comisiones y vincularlas directamente a su **Asignatura / Materia**, indicando cuatrimestre, año, turno, cupo y docente responsable.
- Generación automática de enlace institucional de Google Meet cuando no se especifica un enlace propio.
- Tabla operativa con visualización explícita del código de comisión, nombre de materia, código de asignatura y carrera.
---
## 🚀 Instalación y Despliegue Local
### Requisitos previos
- Node.js (v18 o superior)
- Python 3.10+
- PostgreSQL / SQLite
### 1. Iniciar el Backend (Flask)
2. **Create virtual environment:**
```bash
cd backend
python -m venv venv
# Activar venv (Windows: venv\Scripts\activate, Linux/Mac: source venv/bin/activate)
pip install -r requirements.txt
flask run --port=5000
source venv/bin/activate # On Windows: venv\Scripts\activate
```
*(El backend quedará escuchando en http://localhost:5000)*
### 2. Iniciar el Frontend (Node/Express)
3. **Install dependencies:**
```bash
cd frontend
npm install
npm run dev
# o en su defecto:
node src/app.js
pip install -r requirements.txt
```
*(El frontend quedará escuchando en http://localhost:3000)*
4. **Setup environment variables:**
```bash
cp .env.example .env
# Edit .env with your configuration
```
5. **Initialize database:**
```bash
python app.py
```
## Configuration
### Environment Variables (.env)
```env
FLASK_APP=app.py
FLASK_ENV=development
SECRET_KEY=your-secret-key-here-please-change-in-production
DATABASE_URL=sqlite:///edu_space.db
API_BASE_URL=http://localhost:8080/api
```
## Usage
### Start the Application
```bash
python app.py
```
The application will be available at: `http://localhost:5000`
### Default Login
- **Email**: admin@edu-space.com
- **Password**: admin123
### Main Features
1. **Dashboard**: Overview of system statistics and upcoming reservations
2. **Classrooms**: Manage classroom information, capacity, and resources
3. **Schedule**: View and manage reservations with calendar interface
4. **Profile**: User profile and password management
## API Integration
The admin panel integrates with the Spring Boot API at `http://localhost:8080/api`. Make sure the Java microservice is running for full functionality.
## Project Structure
```
admin-edu-space/
├── app/
│ ├── __init__.py # Flask app factory
│ ├── models/ # Database models
│ │ ├── user.py
│ │ ├── classroom.py
│ │ ├── subject.py
│ │ └── reservation.py
│ ├── routes/ # Application routes
│ │ ├── auth.py
│ │ ├── main.py
│ │ ├── classrooms.py
│ │ └── schedule.py
│ ├── forms/ # WTForms classes
│ │ ├── auth.py
│ │ ├── classroom.py
│ │ └── reservation.py
│ ├── templates/ # Jinja2 templates
│ ├── static/ # Static files
├── config/ # Configuration files
├── requirements.txt
├── .env
├── app.py
└── README.md
```
## Database Schema
### Users Table
- User authentication and authorization
- Role management (admin, staff)
### Classrooms Table
- Classroom information
- Capacity and location details
- Resource management
### Reservations Table
- Booking management
- Time slot scheduling
- Status tracking
### Subjects + Commissions Tables
- Academic information
- Teacher assignments
- Student enrollment tracking
## Security Features
- Password hashing with bcrypt
- Session management
- CSRF protection
- Input validation and sanitization
- SQL injection prevention with SQLAlchemy ORM
## Development
### Running in Development Mode
```bash
export FLASK_ENV=development
python app.py
```
### Adding New Features
1. Create models in `app/models/`
2. Add routes in `app/routes/`
3. Create forms if needed in `app/forms/`
4. Update templates in `app/templates/`
## Production Deployment
### Environment Setup
1. **Set production variables:**
```env
FLASK_ENV=production
SECRET_KEY=<strong-random-string>
DATABASE_URL=<production-database-url>
```
2. **Use production WSGI server:**
```bash
pip install gunicorn
gunicorn -w 4 -b 0.0.0.0:5000 app:app
```
3. **Configure reverse proxy** (nginx/Apache)
## Troubleshooting
### Common Issues
1. **Database connection errors:**
- Check DATABASE_URL string
- Ensure directory permissions
2. **Login issues:**
- Verify admin user exists
- Check password hash
3. **Static files not loading:**
- Ensure blueprint route registration
- Check file permissions
## Contributing
1. Fork the repository
2. Create feature branch
3. Make changes
4. Test thoroughly
5. Submit pull request
## License
This project is part of the Edu-Space educational management system.
## Support
For support and questions:
- Email: admin@edu-space.com
- Documentation: Check inline comments and README files
---
## 📚 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 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)
**Note**: This admin panel is designed to work alongside the Spring Boot microservice. Ensure both services are running for complete functionality.
-131
View File
@@ -1,131 +0,0 @@
# ROADMAP MVP — Sistema Integral UniCABA
## Gestión de Espacios Áulicos, Actividad de Cursada y Seguimiento Académico
**Institución:** Universidad de la Ciudad de Buenos Aires (UniCABA)
**Versión:** 2.6.0
**Fecha de Actualización:** 19 de Septiembre de 2026
**Rama Base:** `testing`
---
## 🗺️ Visión del Proyecto
Construir y evolucionar la plataforma de gestión académica y espacial de **UniCABA** hacia un sistema integral de clase universitaria, desacoplado en una arquitectura **Backend RESTful (Python/Flask) + Frontend BFF (Node.js/Express/Nunjucks)**, capaz de gobernar tanto los espacios físicos de la sede central como la dinámica áulica del campus virtual, garantizando una experiencia fluida para **Administradores**, **Bedelía**, **Docentes** y **Estudiantes**.
---
## 📊 Estado Global de Progreso
```
Fase 0: Desacople y Arquitectura Base [████████████████████] 100% (Completado)
Fase 1: Paridad Legacy y Estabilización [████████████████████] 100% (Completado)
Fase 2: Co-docencia y Matriz Conflictos [████████████████████] 100% (Completado)
Fase 3: Hitos Evaluativos y Calificaciones[░░░░░░░░░░░░░░░░░░░░] 0% (Próxima)
Fase 4: UI/UX Drag & Drop e Impersonación[░░░░░░░░░░░░░░░░░░░░] 0% (Planificada)
Fase 5: Despliegue Producción e Integrac.[░░░░░░░░░░░░░░░░░░░░] 0% (Planificada)
```
---
## 🚀 Detalle de Fases y Estadios
### Fase 0: Desacople y Arquitectura Base ✅ *(Completada)*
* **Objetivo:** Separar el monolito Jinja2 legacy en dos servicios independientes de alto rendimiento.
* **Entregables:**
* [x] Creación del Backend Flask como API REST pura bajo `/api/v1/` retornando exclusivamente JSON.
* [x] Implementación del Frontend BFF en Node.js + Express con renderizado de plantillas mediante Nunjucks.
* [x] Autenticación desacoplada mediante JWT (`access_token`) transportado en Cookie `HttpOnly` segura.
* [x] Pipeline de seguridad `SecurityFilterChain` con cabeceras de blindaje OWASP y rate limiting dinámico.
* [x] Cliente HTTP Axios centralizado con inyección automática de Bearer token y manejo de sesiones.
---
### Fase 1: Paridad Legacy, Estabilización de Virtualidad y BFF Core ✅ *(Completada)*
* **Objetivo:** Alcanzar paridad funcional total con la versión legacy, subsanando defectos críticos de virtualidad y dotando de interfaz específica a cada rol.
* **Entregables:**
* [x] **Importador de Google Sheets Corregido:**
* Eliminación de la colisión de horarios en el aula virtual única (`Campus Virtual · VIRTUAL`).
* Incremento de reservas registradas de 363 a 643 (incorporando 280 clases virtuales que se descartaban).
* Generación y persistencia automática de enlaces de Google Meet institucional por comisión y reserva.
* [x] **Dashboards Especializados por Rol:**
* Dashboard interactivo para **Bedelía** (aulas físicas, salas virtuales, aprobaciones pendientes).
* Dashboard interactivo para **Docente** (clases del día, comisiones, enlaces directos a Meet).
* Dashboard interactivo para **Alumno** (brújula de cursada *"¿Dónde curso hoy?"*, hitos y calendario).
* Cockpit institucional para **Admin** (métricas de capacidad, auditoría RBAC, demanda mensual).
* Selector dinámico de vista previa / roles en tiempo real (`?role=...`).
* [x] **Cartelera del Día (`today_schedule`):**
* Grilla temporal por bloques horarios (07:00 a 21:00 hs) con indicadores físicos y virtuales.
* Filtro dinámico para ocultar clases virtuales (`hide_virtual=1`) y segmentación por piso o turno.
* [x] **Acople de Ficha de Reserva (`schedule.view_reservation`):**
* Ruteo en BFF (`/schedule/view_reservation?id=...` y `/view/:id`).
* Endpoint REST `/api/v1/reservations/<id>` con enriquecimiento relacional (aula, comisión, docente).
* Soporte para modificar reserva (`edit_reservation`) con selectores en cascada, confirmación y cancelación.
* [x] **Gestión de Comisiones (`admin/commissions_list`):**
* Botón y modal interactivo `+ Nueva Comisión` (`#modalAddCommission`).
* Vinculación obligatoria y explícita a la **Asignatura / Materia**, indicando cuatrimestre, año lectivo, turno, docente a cargo, cupo y enlace virtual.
* Visualización en tabla de la materia vinculada con su código y carrera asociada.
---
### Fase 2: Co-docencia, Matriz de Conflictos y Regla Diaria ✅ *(100% Completado)*
* **Objetivo:** Implementar las reglas de negocio académicas complejas para asignación de cátedras y cursada de alumnos.
* **Estadios:**
1. **Estadio 2.1 — Modelo de Co-docencia:**
- [x] Tabla relacional `commission_teachers` para soportar múltiples docentes por comisión (titulares, adjuntos, ayudantes).
- [x] Endpoints `/api/v1/commissions/<id>/teachers` para asociar y desvincular docentes de cátedra.
- [x] Adaptación de vistas de Bedelía y Comisiones para visualizar el equipo docente completo.
2. **Estadio 2.2 — Matriz de Conflictos y Validación:**
- [x] Validación estricta de conflicto físico: bloqueo de doble asignación de aula en mismo día y horario.
- [x] Validación de conflicto docente: detección de superposición horaria del profesor, con soporte de excepción justificada cuando la comisión cuenta con co-docencia.
- [x] Validación de aforo: bloqueo cuando la cantidad de alumnos excede la capacidad del aula física.
3. **Estadio 2.3 — Regla de Restricción Diaria del Alumno:**
- [x] Validación al matricular: un alumno no puede cursar dos asignaturas regulares en el mismo día calendario.
- [x] Excepción parametrizable: autorización automática si al menos una de las materias es un "curso corto" (`is_short_course`) o si existe autorización manual de Bedelía (`allow_same_day_exception`).
---
### Fase 3: Gestión de Hitos Evaluativos y Libro de Calificaciones 📅 *(Planificada)*
* **Objetivo:** Proveer al cuerpo docente y estudiantil el seguimiento completo de instancias evaluativas.
* **Estadios:**
1. **Estadio 3.1 — Calendario de Evaluación por Comisión:**
- [ ] Programación de parciales, recuperatorios, entregas de trabajos prácticos y exámenes finales.
- [ ] Notificaciones preventivas en el dashboard del alumno con cuenta regresiva para exámenes.
2. **Estadio 3.2 — Libro de Calificaciones (*Gradebook*):**
- [ ] Matriz de carga rápida de notas para docentes con autoguardado asíncrono.
- [ ] Consulta individualizada de calificaciones y retroalimentaciones pedagógicas para alumnos.
- [ ] Cierre de actas de regularidad y promoción para Bedelía.
---
### Fase 4: UI/UX Avanzada para Bedelía & Modo Impersonación 🖥️ *(Planificada)*
* **Objetivo:** Dotar a la oficina de Bedelía y a la Administración de herramientas interactivas de última generación.
* **Estadios:**
1. **Estadio 4.1 — Grilla Semanal Interactiva (Drag-and-Drop):**
- [ ] Interfaz tipo TimeGrid interactiva con arrastre de comisiones para asignación rápida de aulas y turnos.
- [ ] Detección visual en tiempo real de aulas ocupadas o incompatibles.
2. **Estadio 4.2 — Modo Impersonación del Superadmin:**
- [ ] Funcionalidad de *login as* o vista previa con cabecera `X-Impersonate-User` para replicar exactamente lo que ve cualquier docente o alumno.
- [ ] Trazabilidad obligatoria de acciones bajo impersonación en el registro de auditoría (`audit_logs`).
3. **Estadio 4.3 — Integración del Optimizador Genético:**
- [ ] Acople del motor predictivo de algoritmos genéticos a la base de datos real para sugerir redistribución óptima de aulas según aforo.
---
### Fase 5: Producción Institucional, CI/CD e Integraciones 🏢 *(Planificada)*
* **Objetivo:** Puesta en marcha definitiva en la infraestructura de servidores de UniCABA.
* **Estadios:**
1. **Estadio 5.1 — Despliegue e Infraestructura:**
- [ ] `docker-compose.yml` productivo orquestando Flask (Gunicorn), Node.js BFF (PM2) y PostgreSQL 15.
- [ ] Proxy inverso Nginx con certificados SSL/TLS y compresión Brotli/Gzip.
2. **Estadio 5.2 — Integraciones Universitarias:**
- [ ] Módulo de exportación/importación compatible con SIU Guaraní 3.
- [ ] Sincronización automática de aulas virtuales con Moodle institucional.
3. **Estadio 5.3 — Pruebas de Carga y Hardening:**
- [ ] Pruebas de estrés de concurrencia en días pico de inicio de cuatrimestre (10.000 solicitudes simultáneas).
---
## 📈 Criterios de Aceptación del MVP
1. **Zero Colisiones Físicas:** Imposibilidad absoluta de asignar dos materias simultáneas a una misma aula física.
2. **Virtualidad Fluida:** Acceso con un clic a reuniones virtuales válidas para el 100% de las comisiones remotas.
3. **Roles Seguros:** Separación total de permisos donde ningún alumno o docente acceda a configuración global ni a datos privados de otras comisiones.
4. **Respuesta Sub-segundo:** Carga de carteleras y calendarios en menos de 300 ms en el BFF Node.js.
-99
View File
@@ -1,99 +0,0 @@
# Informe de Auditoría y Seguridad Automatizada (Python & Node.js)
**Proyecto:** Edu-Space Admin Architecture
**Fecha:** Septiembre 2026
**Entorno:** Backend Flask (REST API) + Node.js BFF (Express)
**Estado General:** Aprobado ✅ (0 vulnerabilidades conocidas, 0 fallos críticos SAST/DAST)
---
## 1. Resumen Ejecutivo
Se completó la implementación del **Plan de Auditoría y Seguridad Automatizada** que cubre el ciclo de vida completo de las dos capas backend de la plataforma:
- **Capa Core API (Python / Flask)**: Análisis Estático (SAST), Análisis de Dependencias de Terceros (SCA), Pruebas Dinámicas / Fuzzing contra especificación OpenAPI (DAST).
- **Capa BFF / Frontend Server (Node.js / Express)**: Análisis Estático (SAST), Análisis de Dependencias (SCA) y Blindaje Activo con cabeceras HTTP (`helmet`) y limitador de tasa (`express-rate-limit`).
---
## 2. Fase 1: Backend Python (Flask API)
### 2.1. Análisis Estático de Código Fuente (SAST) - `Bandit`
- **Herramienta:** Bandit v1.8.3 (`backend/venv/Scripts/bandit.exe`)
- **Comando:** `bandit -r ./app -ll -ii`
- **Líneas Escaneadas:** 8.456 líneas de código Python.
- **Hallazgo Inicial:** 1 advertencia de severidad Media (CWE-22 / B310 en `backend/app/services/sheets_importer.py` por uso de `urllib.request.urlopen` sin validación estricta del esquema URL).
- **Remediación Aplicada:**
- Se incorporó validación explícita de esquema (`url.startswith('https://')` o `url.startswith('http://')`) previo a la invocación de `urlopen`.
- Se colocó anotación `# nosec B310` justificando la protección criptográfica/red implementada.
- **Resultado Actual:** **0 problemas identificados** (Medium/High = 0).
### 2.2. Auditoría de Dependencias de Terceros (SCA) - `Pip-Audit`
- **Herramienta:** Pip-Audit v2.10.1 con base de datos OSV (`backend/venv/Scripts/pip-audit.exe`)
- **Comando:** `pip-audit -s osv --progress-spinner off -r requirements.txt`
- **Hallazgo Inicial:** 24 vulnerabilidades conocidas asociadas a dependencias desactualizadas (`Flask==3.0.0`, `Werkzeug==3.0.1`, `requests==2.31.0`, `bleach==6.1.0`, `python-dotenv==1.0.0`).
- **Remediaciones Aplicadas:**
- Actualización de `Flask` a `>=3.1.3`
- Actualización de `Werkzeug` a `>=3.1.6` (instalada v3.1.8)
- Actualización de `requests` a `>=2.32.4` (instalada v2.34.2)
- Actualización de `bleach` a `>=6.4.0`
- Actualización de `python-dotenv` a `>=1.2.2` (instalada v1.2.3)
- **Resultado Actual:** **No known vulnerabilities found** (0 CVEs pendientes).
### 2.3. Pruebas Dinámicas y Fuzzing de API REST (DAST) - `Schemathesis`
- **Herramienta:** Schemathesis v4.27.4 (`backend/venv/Scripts/st.exe`)
- **Especificación OpenAPI:** Implementada en `backend/app/routes/api/openapi.py` (`/api/v1/openapi.json`) bajo el estándar OpenAPI 3.0.3.
- **Comando:** `st run http://127.0.0.1:5000/api/v1/openapi.json --checks not_a_server_error --max-examples=10`
- **Hallazgo Inicial:** Fuzzing detectó un error 500 no controlado en `POST /api/v1/auth/login` cuando el payload enviado consistía en un arreglo JSON anómalo (`[null, null]`) en vez de un objeto/diccionario, provocando un `TypeError` en el constructor de Pydantic (`LoginDTO(**data)`).
- **Remediación Aplicada:**
- Se agregó validación de tipo `if not isinstance(data, dict): return jsonify({'error': 'BadRequest', 'message': '...'}), 400` tanto en `/login` como en `/refresh`.
- **Resultado Actual:** **114 casos de prueba generados y superados con éxito**, **0 errores 500 del servidor**.
---
## 3. Fase 2: Backend Node.js (Express BFF)
### 3.1. Análisis Estático de Código Fuente (SAST) - `njsscan`
- **Herramienta:** NodeJsScan (`backend/venv/Scripts/njsscan.exe`)
- **Comando:** `njsscan ./frontend/src`
- **Reglas Evaluadas:** Inyecciones de código, llamadas inseguras a `eval()`, omisión de headers de seguridad, credenciales hardcodeadas, CORS inseguro.
- **Resultado:** **No issues found** (0 vulnerabilidades).
### 3.2. Blindaje de Middleware (`helmet` & `express-rate-limit`)
- **Paquetes Instalados:** `helmet`, `express-rate-limit`.
- **Configuración en `frontend/src/app.js`:**
- `app.disable('x-powered-by')`: Oculta el motor Express para prevenir finger-printing de atacantes.
- `helmet`: Aplica cabeceras HTTP de protección:
- `X-Content-Type-Options: nosniff`
- `X-Frame-Options: SAMEORIGIN` (prevención de Clickjacking)
- `Strict-Transport-Security: max-age=31536000; includeSubDomains` (HSTS)
- `Cross-Origin-Opener-Policy: same-origin`
- `Cross-Origin-Resource-Policy: same-origin`
- `express-rate-limit`:
- Endpoint `/auth/login`: Límite estricto de 30 peticiones por ventana de 15 minutos por IP para mitigar ataques de fuerza bruta de credenciales.
- Endpoints `/api`: Límite de 180 peticiones por minuto por IP para mitigar saturación y scraping.
### 3.3. Auditoría de Dependencias (SCA) - `npm audit`
- **Herramienta:** `npm audit` nativo (v11.x)
- **Comando:** `cmd.exe /c npm audit` en `frontend/`
- **Resultado:** **found 0 vulnerabilities** en 108 paquetes analizados.
---
## 4. Script de Auditoría Automatizada Unificada
Se diseñó y probó el script automatizado [security_audit.bat](file:///c:/Users/Soporte%20IT/workspace/admin-edu-space/security_audit.bat) en la raíz del repositorio, ejecutable en un solo paso:
```cmd
security_audit.bat
```
El script ejecuta secuencialmente los 5 controles de seguridad y genera un reporte en consola para desarrolladores y pipelines de integración continua.
---
## 5. Matriz de Resultados
| Control | Capa | Herramienta | Estado Previo | Estado Final |
|---|---|---|---|---|
| **SAST (Python)** | Flask API | `bandit` | 1 Advertencia Media (B310) | **0 Problemas (100% Limpio)** ✅ |
| **SCA (Python)** | Flask API | `pip-audit` | 24 Vulnerabilidades (CVEs) | **0 Vulnerabilidades** ✅ |
| **DAST (Python)** | Flask API | `schemathesis` | 1 Fallo HTTP 500 (payload fuzzing) | **0 Fallos (114/114 Pasados)** ✅ |
| **SAST (Node.js)**| Express BFF| `njsscan` | Sin controles previos | **0 Problemas (100% Limpio)** ✅ |
| **SCA (Node.js)** | Express BFF| `npm audit` | No auditado con Helmet | **0 Vulnerabilidades** ✅ |
| **Blindaje HTTP** | Express BFF| `helmet` + `rate-limit` | Cabeceras por defecto | **Protegido con HSTS, nosniff, limiters** ✅ |
View File
+112
View File
@@ -0,0 +1,112 @@
from flask import Flask, request, session
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_cors import CORS
from flask_babel import Babel, gettext as _
from config.config import Config
# Initialize extensions
db = SQLAlchemy()
migrate = Migrate()
login_manager = LoginManager()
cors = CORS()
babel = Babel()
def create_app(config_class=Config):
app = Flask(__name__)
app.config.from_object(config_class)
# Initialize extensions with app
db.init_app(app)
migrate.init_app(app, db)
login_manager.init_app(app)
cors.init_app(app)
# Login manager configuration
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Por favor inicia sesión para acceder a esta página.'
login_manager.login_message_category = 'info'
# Import models to ensure they are registered
from app.models import user
# Register blueprints
from app.routes import auth_bp, classrooms_bp, buildings_bp, main_bp, schedule_bp, genetic_bp, genetic_web_bp, admin_bp
app.register_blueprint(auth_bp, url_prefix="/")
app.register_blueprint(classrooms_bp, url_prefix="/classrooms")
app.register_blueprint(buildings_bp)
app.register_blueprint(main_bp, url_prefix="/")
app.register_blueprint(schedule_bp, url_prefix="/schedule")
app.register_blueprint(genetic_bp)
app.register_blueprint(genetic_web_bp)
app.register_blueprint(admin_bp)
# Language selector function
def get_locale():
# 1. Parámetro explícito en la URL (?lang=es o ?lang=en)
lang = request.args.get('lang')
if lang and lang in app.config['LANGUAGES']:
session['language'] = lang
return lang
# 2. Si el usuario está autenticado y tiene idioma configurado en su perfil, prevalece en toda la sesión
from flask_login import current_user
if current_user.is_authenticated and getattr(current_user, 'preferred_language', None):
if current_user.preferred_language in app.config['LANGUAGES']:
return current_user.preferred_language
# 3. En la pantalla de Login (usuario no autenticado):
# El idioma predeterminado del login debe ser español o respetar el idioma del navegador.
# No debe verse afectado por cookies o sesiones residuales de otros usuarios.
is_login = (request.endpoint == 'auth.login' or request.path.rstrip('/') == '/login')
if is_login:
# Solo si el usuario cambió el idioma explícitamente en el selector del login
if session.get('login_lang_explicit') in app.config['LANGUAGES']:
return session['login_lang_explicit']
# Detectar según el navegador del usuario (soporta es-419, es-AR, es-ES, etc.)
if request.accept_languages:
for accepted_lang, _quality in request.accept_languages:
primary = accepted_lang.split('-')[0].lower()
if primary in app.config['LANGUAGES']:
return primary
# Idioma predeterminado por defecto del login: Español
return 'es'
# 4. Para otras rutas: verificar si hay idioma en sesión (ej. selector manual)
if 'language' in session and session['language'] in app.config['LANGUAGES']:
return session['language']
# 5. Detección automática del navegador por defecto (soporta subtags regionales)
if request.accept_languages:
for accepted_lang, _quality in request.accept_languages:
primary = accepted_lang.split('-')[0].lower()
if primary in app.config['LANGUAGES']:
return primary
# Fallback del sistema: Español
return app.config['BABEL_DEFAULT_LOCALE']
# Babel context processor
@app.context_processor
def inject_conf_vars():
return dict(
languages=app.config['LANGUAGES'],
current_lang=str(get_locale())
)
# Configure Babel
babel.init_app(app, locale_selector=get_locale)
# Register localized date and datetime formatters for Jinja2 templates
from flask_babel import format_date, format_datetime
app.jinja_env.globals['format_date'] = format_date
app.jinja_env.globals['format_datetime'] = format_datetime
app.jinja_env.filters['format_date'] = format_date
app.jinja_env.filters['format_datetime'] = format_datetime
return app
@@ -4,14 +4,11 @@ from wtforms.validators import DataRequired, Email, Length
from flask_babel import lazy_gettext as _l
class LoginForm(FlaskForm):
email = StringField(_l('Email'),
validators=[
DataRequired(),
Email(),
Length(max=120)
],
filters=[lambda x: x.strip().lower() if x else '']
)
email = StringField(_l('Email'), validators=[
DataRequired(),
Email(),
Length(max=120)
])
password = PasswordField(_l('Password'), validators=[DataRequired()])
remember_me = BooleanField(_l('Remember me'))
submit = SubmitField(_l('Sign In'))
@@ -19,25 +19,27 @@ def validate_capacity(form, field):
raise ValidationError(_l('Ingrese un número entero o el símbolo ∞ para capacidad ilimitada.'))
class ClassroomForm(FlaskForm):
building_id = SelectField(_l('Edificio'), validators=[DataRequired()])
new_building_name = StringField(_l('Nombre del Nuevo Edificio'), validators=[Optional(), Length(max=100)])
room_number = StringField(_l('Número / Nombre del Aula'), validators=[
building = StringField(_l('Building'), validators=[
DataRequired(),
Length(min=2, max=100)
])
room_number = StringField(_l('Room Number'), validators=[
DataRequired(),
Length(min=1, max=50)
])
floor = StringField(_l('Piso'), validators=[
DataRequired(message=_l('El piso es obligatorio (ej: PB, 1, 2, 4°P)')),
Length(min=1, max=50)
floor = IntegerField(_l('Floor'), validators=[
InputRequired(message=_l('El piso es obligatorio (0 para Planta Baja)')),
NumberRange(min=0, max=50, message=_l('El piso debe estar entre 0 (Planta Baja) y 50'))
])
capacity = StringField(_l('Capacidad'), validators=[
capacity = StringField(_l('Capacity'), validators=[
validate_capacity
])
description = TextAreaField(_l('Descripción'), validators=[
description = TextAreaField(_l('Description'), validators=[
Optional(),
Length(max=500)
])
is_active = BooleanField(_l('Activa'), default=True)
submit = SubmitField(_l('Guardar Aula'))
is_active = BooleanField(_l('Active'), default=True)
submit = SubmitField(_l('Save Classroom'))
class ClassroomResourceForm(FlaskForm):
resource_type = SelectField(_l('Resource Type'), choices=[
@@ -21,16 +21,8 @@ class ReservationForm(FlaskForm):
Optional(),
Length(max=1000)
])
virtual_link = StringField(_l('Virtual Classroom Link'), validators=[
Optional(),
Length(max=500)
])
submit = SubmitField(_l('Make Reservation'))
def __init__(self, *args, is_edit=False, **kwargs):
super().__init__(*args, **kwargs)
self.is_edit = is_edit
def validate_end_time(self, field):
if field.data <= self.start_time.data:
raise ValidationError('End time must be after start time.')
@@ -40,8 +32,8 @@ class ReservationForm(FlaskForm):
raise ValidationError('Reservation cannot exceed 8 hours.')
def validate_start_time(self, field):
# Don't allow new reservations in the past
if not self.is_edit and field.data < datetime.now() - timedelta(minutes=10):
# Don't allow reservations in the past
if field.data < datetime.now():
raise ValidationError('Start time cannot be in the past.')
# Don't allow reservations too far in the future (max 3 months)
@@ -2,15 +2,10 @@ from .user import User
from .building import Building
from .classroom import Classroom, ClassroomResource
from .career import Career
from .academic_term import AcademicTerm
from .subject import Subject, Commission, CommissionTeacher
from .subject import Subject, Commission
from .reservation import Reservation, ReservationStatus
from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer
from .role import Role, Permission, SYSTEM_MODULES
from .milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES
from .audit_log import AuditLog
from .enrollment import StudentEnrollment
from .setting import SystemSetting
__all__ = [
'User',
@@ -18,21 +13,13 @@ __all__ = [
'Classroom',
'ClassroomResource',
'Career',
'AcademicTerm',
'Subject',
'Commission',
'CommissionTeacher',
'Reservation',
'ReservationStatus',
'GeneticAlgorithm',
'ReservationOptimizer',
'Role',
'Permission',
'SYSTEM_MODULES',
'MilestoneType',
'AcademicMilestone',
'BASE_MILESTONE_TYPES',
'AuditLog',
'StudentEnrollment',
'SystemSetting'
'SYSTEM_MODULES'
]
@@ -9,57 +9,23 @@ class Classroom(db.Model):
building_id = db.Column(db.Integer, db.ForeignKey('buildings.id'), nullable=True)
building = db.Column(db.String(100), nullable=False)
code = db.Column('room_number', db.String(50), nullable=False) # Maps to room_number column in DB
floor = db.Column(db.String(50), nullable=False, default='1')
floor = db.Column(db.Integer, nullable=False)
capacity = db.Column(db.Integer, nullable=False)
description = db.Column(db.Text)
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relación con Building (mapeada vía backref 'building_entity' en Building.classrooms)
@property
def building_rel(self):
return self.building_entity
@property
def building_name(self):
"""Nombre del edificio desacoplado"""
if self.building_rel:
return self.building_rel.name
return self.building or 'Edificio Central'
@property
def floor_display(self):
"""Formato amigable para el piso: PB, Planta Baja, Piso X"""
f = str(self.floor).strip() if self.floor is not None else ''
if f in ['0', 'PB', 'pb', 'Planta Baja', 'planta baja']:
return 'Planta Baja'
if f.lower().startswith('piso') or '°' in f:
return f
return f"Piso {f}"
@property
def is_virtual(self):
"""Indica si el aula es virtual"""
return (self.code and 'VIRTUAL' in self.code.upper()) or (self.building and 'VIRTUAL' in self.building.upper())
@property
def location_display(self):
"""Ubicación completa y limpia: Edificio · Piso · Aula"""
if self.is_virtual:
return '🌐 Campus Virtual'
return f"{self.building_name} · {self.floor_display} · {self.code}"
# Virtual properties for compatibility
@property
def name(self):
"""Generate name from building and code"""
return f"{self.building_name}-{self.code}"
return f"{self.building}-{self.code}"
@property
def campus_location(self):
"""Placeholder for campus location"""
return self.building_rel.address if self.building_rel else None
"""Placeholder for campus location - can be expanded when campus model is implemented"""
return None
# Virtual field for compatibility - combines building and code
@property
@@ -73,34 +39,28 @@ class Classroom(db.Model):
# Override code property for consistency
@property
def code_display(self):
return f"{self.building_name} - {self.code}"
return f"{self.building}-{self.code}"
@property
def capacity_display(self):
"""Retorna '∞' para aulas virtuales o capacidad ilimitada, o el número entero."""
if self.capacity == 0 or self.is_virtual:
if self.capacity == 0 or (self.code and 'VIRTUAL' in self.code.upper()):
return '∞'
return str(self.capacity)
@property
def is_unlimited(self):
return self.capacity == 0 or self.is_virtual
return self.capacity == 0 or (self.code and 'VIRTUAL' in self.code.upper())
def to_dict(self):
"""Convert classroom to dictionary"""
return {
'id': self.id,
'building_id': self.building_id,
'building': self.building_name,
'code': self.code,
'code_display': self.code_display,
'code': self.code_display,
'building': self.building,
'name': self.name,
'floor': self.floor,
'floor_display': self.floor_display,
'location_display': self.location_display,
'capacity': self.capacity,
'capacity_display': self.capacity_display,
'is_virtual': self.is_virtual,
'description': self.description,
'campus_location': self.campus_location,
'is_active': self.is_active,
@@ -111,9 +71,10 @@ class Classroom(db.Model):
def get_full_location(self):
"""Get full classroom location"""
if self.is_virtual:
return 'Campus Virtual (Remoto)'
return f"{self.building_name} - {self.floor_display}"
location = f"{self.building} - Floor {self.floor}"
if self.campus_location:
location += f" ({self.campus_location})"
return location
@staticmethod
def find_best_classroom(max_students, start_time, end_time, exclude_reservation_id=None):
@@ -46,31 +46,10 @@ class Individual:
self.conflicts = []
def calculate_fitness(self, classrooms: Dict[int, Classroom], requests: Dict[int, ReservationRequest]) -> float:
"""Calculate fitness score based on multiple factors with fast O(K log K) interval conflict detection"""
"""Calculate fitness score based on multiple factors"""
score = 0.0
self.conflicts = []
# Pre-group genes by classroom to calculate conflicts in O(K log K)
by_classroom = {}
for g in self.genes:
by_classroom.setdefault(g.classroom_id, []).append(g)
conflict_count_by_id = {}
for cid, room_genes in by_classroom.items():
if len(room_genes) <= 1:
continue
# Sort chronologically
room_genes.sort(key=lambda x: x.start_time)
for i in range(len(room_genes)):
g_i = room_genes[i]
for j in range(i + 1, len(room_genes)):
g_j = room_genes[j]
if g_i.end_time > g_j.start_time:
conflict_count_by_id[id(g_i)] = conflict_count_by_id.get(id(g_i), 0) + 1
conflict_count_by_id[id(g_j)] = conflict_count_by_id.get(id(g_j), 0) + 1
else:
break # Gene j and subsequent ones start later than gene i ends
for gene in self.genes:
classroom = classrooms.get(gene.classroom_id)
request = requests.get(gene.commission_id)
@@ -87,20 +66,19 @@ class Individual:
score += time_score * 0.3
# Factor 3: Conflict penalty (20% weight)
c_count = conflict_count_by_id.get(id(gene), 0)
if c_count > 0:
conflict_score = -c_count * 0.5
self.conflicts.append({
'gene': gene,
'conflict_type': 'time_overlap'
})
else:
conflict_score = 1.0
conflict_score = self._calculate_conflict_penalty(gene, self.genes)
score += conflict_score * 0.2
# Factor 4: Resource matching (10% weight)
resource_score = self._calculate_resource_score(classroom, request.subject_requirements)
score += resource_score * 0.1
# Store conflicts for debugging
if conflict_score < 0:
self.conflicts.append({
'gene': gene,
'conflict_type': 'time_overlap'
})
self.fitness = score
return score
@@ -111,7 +89,7 @@ class Individual:
return 0.0 # Overfilled classroom
# Calculate efficiency: closer capacity match = higher score
waste_ratio = (classroom.capacity - expected_attendees) / max(1, expected_attendees)
waste_ratio = (classroom.capacity - expected_attendees) / expected_attendees
if waste_ratio <= 0.1: # Less than 10% waste
return 1.0
elif waste_ratio <= 0.3: # Less than 30% waste
@@ -129,12 +107,12 @@ class Individual:
if time_diff <= 0.5: # Within 30 minutes
return 1.0
elif time_diff <= request.flexibility_hours: # Within flexible window
return 0.8 - (time_diff / max(1, request.flexibility_hours)) * 0.3
return 0.8 - (time_diff / request.flexibility_hours) * 0.3
else:
return max(0.0, 0.5 - (time_diff - request.flexibility_hours) * 0.1)
def _calculate_conflict_penalty(self, gene: Gene, all_genes: List[Gene]) -> float:
"""Check for time conflicts in the same classroom (kept for backward compatibility)"""
"""Check for time conflicts in the same classroom"""
conflicts = 0
for other in all_genes:
if gene != other and gene.classroom_id == other.classroom_id:
@@ -149,6 +127,9 @@ class Individual:
"""Check if classroom meets subject requirements"""
if not requirements:
return 1.0
# This would need to be implemented based on actual classroom resources
# For now, return a default score
return 0.8
def _times_overlap(self, start1: datetime, end1: datetime, start2: datetime, end2: datetime) -> bool:
@@ -157,24 +138,19 @@ class Individual:
class GeneticAlgorithm:
"""Main genetic algorithm for room reservation optimization with concurrency and progress tracking"""
"""Main genetic algorithm for room reservation optimization"""
def __init__(self, population_size: int = 50, generations: int = 100,
mutation_rate: float = 0.1, crossover_rate: float = 0.8,
workers: int = 4):
mutation_rate: float = 0.1, crossover_rate: float = 0.8):
self.population_size = population_size
self.generations = generations
self.mutation_rate = mutation_rate
self.crossover_rate = crossover_rate
self.workers = max(1, workers)
def optimize_reservations(self, requests: List[ReservationRequest],
available_classrooms: List[Classroom],
start_date: datetime, end_date: datetime,
progress_callback=None) -> Individual:
"""Run genetic algorithm with high concurrency to find optimal reservation schedule"""
import concurrent.futures
start_date: datetime, end_date: datetime) -> Individual:
"""Run genetic algorithm to find optimal reservation schedule"""
classrooms_dict = {c.id: c for c in available_classrooms}
requests_dict = {r.commission_id: r for r in requests}
@@ -183,29 +159,18 @@ class GeneticAlgorithm:
# Evolve population
for generation in range(self.generations):
# Calculate fitness for all individuals concurrently when workers > 1
if self.workers > 1 and len(population) > 10:
with concurrent.futures.ThreadPoolExecutor(max_workers=self.workers) as executor:
futures = [executor.submit(ind.calculate_fitness, classrooms_dict, requests_dict) for ind in population]
concurrent.futures.wait(futures)
else:
for individual in population:
individual.calculate_fitness(classrooms_dict, requests_dict)
# Calculate fitness for all individuals
for individual in population:
individual.calculate_fitness(classrooms_dict, requests_dict)
# Sort by fitness (best first)
population.sort(key=lambda x: x.fitness, reverse=True)
# Report progress if callback is provided
if progress_callback:
pct = int(((generation + 1) / self.generations) * 100)
best_fit = population[0].fitness if population else 0.0
progress_callback(generation + 1, self.generations, pct, best_fit)
# Create new generation
new_population = []
# Elitism: keep best 10%
elite_size = max(2, int(self.population_size * 0.1))
elite_size = int(self.population_size * 0.1)
new_population.extend(population[:elite_size])
# Crossover and mutation for remaining
@@ -231,9 +196,7 @@ class GeneticAlgorithm:
population = new_population[:self.population_size]
# Calculate fitness for final generation
for individual in population:
individual.calculate_fitness(classrooms_dict, requests_dict)
# Return best individual from final generation
population.sort(key=lambda x: x.fitness, reverse=True)
return population[0]
@@ -321,14 +284,13 @@ class GeneticAlgorithm:
class ReservationOptimizer:
"""High-level interface for the reservation optimization system"""
def __init__(self, population_size: int = 50, generations: int = 100, workers: int = 4):
self.ga = GeneticAlgorithm(population_size=population_size, generations=generations, workers=workers)
def __init__(self):
self.ga = GeneticAlgorithm()
def optimize_schedule(self, commission_ids: List[int],
admin_user_id: int,
start_date: datetime = None,
end_date: datetime = None,
progress_callback = None) -> Dict:
end_date: datetime = None) -> Dict:
"""Optimize reservation schedule for given commissions"""
# Default date range if not provided
@@ -363,7 +325,7 @@ class ReservationOptimizer:
classrooms = Classroom.query.filter_by(is_active=True).all()
# Run genetic algorithm
best_individual = self.ga.optimize_reservations(requests, classrooms, start_date, end_date, progress_callback=progress_callback)
best_individual = self.ga.optimize_reservations(requests, classrooms, start_date, end_date)
# Convert to reservation format
optimized_reservations = []
@@ -47,7 +47,6 @@ class Reservation(db.Model):
status = db.Column(StatusType, default='PENDING', nullable=False) # Maps to VARCHAR(20) with StatusString
expected_attendees = db.Column(db.Integer, nullable=False)
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
virtual_link = db.Column(db.String(500), nullable=True) # Zoom, Meet, Teams, etc.
notes = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
@@ -82,24 +81,6 @@ class Reservation(db.Model):
if self.classroom:
return self.classroom.code
return ''
@property
def effective_virtual_link(self):
"""Devuelve el enlace explícito de la reserva o hereda el de la comisión"""
if self.virtual_link and self.virtual_link.strip():
return self.virtual_link.strip()
if self.commission and self.commission.virtual_link:
return self.commission.virtual_link.strip()
return None
@property
def is_virtual(self):
"""Verifica si la reserva es virtual por aula o por contar con enlace virtual"""
if self.classroom:
return bool(self.classroom.is_virtual)
if self.effective_virtual_link:
return True
return False
# Relationships
classroom = db.relationship('Classroom', backref=db.backref('reservation_list', lazy=True, cascade='all, delete-orphan'))
@@ -119,8 +100,6 @@ class Reservation(db.Model):
'status': self.status, # Already a string in DB
'expected_attendees': self.expected_attendees,
'notes': self.notes,
'virtual_link': self.effective_virtual_link,
'is_virtual': self.is_virtual,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'classroom': self.classroom.to_dict() if self.classroom else None,
@@ -22,7 +22,7 @@ SYSTEM_MODULES = [
'name': 'Reservas de Aulas',
'name_en': 'Classroom Reservations',
'icon': 'bi-calendar-check',
'description': 'Creación, modificación, aprobación, confirmación y cancelación de reservas.'
'description': 'Creación, aprobación, confirmación y cancelación de reservas.'
},
{
'id': 'schedule',
@@ -58,27 +58,6 @@ SYSTEM_MODULES = [
'name_en': 'Users & Access RBAC',
'icon': 'bi-shield-lock',
'description': 'Administración de cuentas, roles y matriz granular de permisos.'
},
{
'id': 'milestone_types',
'name': 'Tipificaciones de Hitos',
'name_en': 'Milestone Types',
'icon': 'bi-tags',
'description': 'Catálogo dinámico de tipos de evaluaciones, parciales, TPs y eventos.'
},
{
'id': 'metrics',
'name': 'Métricas de Ocupación',
'name_en': 'Occupancy Metrics',
'icon': 'bi-graph-up',
'description': 'Visualización y análisis de porcentajes de ocupación por aula (día, semana y mes).'
},
{
'id': 'enrollment',
'name': 'Inscripciones y Cursadas',
'name_en': 'Enrollments & Courses',
'icon': 'bi-diagram-3',
'description': 'Gestión de inscripciones de alumnos a comisiones y asignación de docentes.'
}
]
@@ -1,185 +1,101 @@
from app import db
from datetime import datetime
class Subject(db.Model):
__tablename__ = 'subjects'
id = db.Column(db.Integer, primary_key=True)
code = db.Column(db.String(50), unique=True, nullable=False, index=True)
name = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text)
department = db.Column(db.String(100), nullable=False, default='')
credits = db.Column(db.Integer, nullable=False, default=4)
career_id = db.Column(db.Integer, db.ForeignKey('careers.id', ondelete='SET NULL'), nullable=True)
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB
is_short_course = db.Column(db.Boolean, default=False, nullable=True) # True para talleres/cursos cortos
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
commissions = db.relationship('Commission', backref='subject', lazy=True, cascade='all, delete-orphan')
def to_dict(self):
"""Convert subject to dictionary"""
return {
'id': self.id,
'code': self.code,
'name': self.name,
'description': self.description,
'department': self.department,
'credits': self.credits,
'career_id': self.career_id,
'career_name': self.career_obj.name if self.career_obj else None,
'is_active': self.is_active,
'is_short_course': bool(self.is_short_course),
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}
def __repr__(self):
return f'<Subject {self.code}>'
class Commission(db.Model):
__tablename__ = 'commissions'
id = db.Column(db.Integer, primary_key=True)
subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=False)
code = db.Column(db.String(50), nullable=False)
semester = db.Column(db.String(20), nullable=False)
year = db.Column(db.Integer, nullable=False)
teacher_id = db.Column(db.Integer, db.ForeignKey('users.id'))
max_students = db.Column(db.Integer, nullable=False)
current_students = db.Column(db.Integer, default=0)
schedule = db.Column(db.String(255))
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
virtual_link = db.Column(db.String(500), nullable=True) # Link Zoom, Meet, Teams, Moodle
active = db.Column(db.Boolean, default=True) # Changed from is_active to active
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Virtual property for compatibility
@property
def is_active(self):
return self.active
@is_active.setter
def is_active(self, value):
self.active = value
# Relationships
pass # Reservation already defines the relationship back to commission
teacher = db.relationship('User', backref='teaching_commissions', foreign_keys=[teacher_id])
# Virtual field for teacher_name
@property
def teacher_name(self):
return f"{self.teacher.first_name} {self.teacher.last_name}" if self.teacher else "TBD"
@property
def teachers(self):
"""Retorna la lista de docentes asignados.
Si teachers_association tiene registros, los retorna ordenados (Titular primero).
Si está vacía pero teacher_id está configurado, devuelve el docente titular.
"""
if hasattr(self, 'teachers_association') and self.teachers_association and len(self.teachers_association) > 0:
res = [ta.to_dict() for ta in self.teachers_association]
res.sort(key=lambda x: (not x.get('is_primary', False), x.get('role', '') != 'Titular'))
return res
if self.teacher:
return [{
'id': 0,
'commission_id': self.id,
'user_id': self.teacher.id,
'name': self.teacher.name,
'email': self.teacher.email,
'role': 'Titular',
'is_primary': True
}]
return []
@property
def teacher_names(self):
t_list = self.teachers
if not t_list:
return "Sin asignar"
return ", ".join([f"{t['name']} ({t['role']})" for t in t_list])
# Unique constraint
__table_args__ = (db.UniqueConstraint('subject_id', 'code', 'semester', 'year'),)
def to_dict(self):
"""Convert commission to dictionary"""
return {
'id': self.id,
'subject_id': self.subject_id,
'code': self.code,
'semester': self.semester,
'year': self.year,
'teacher_id': self.teacher_id,
'teacher_name': self.teacher_name,
'teachers': self.teachers,
'teacher_names': self.teacher_names,
'max_students': self.max_students,
'current_students': self.current_students,
'schedule': self.schedule,
'shift': self.shift,
'virtual_link': self.virtual_link,
'active': self.active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'full_code': self.get_full_code(),
'subject': self.subject.to_dict() if self.subject else None
}
def get_full_code(self):
"""Get full commission code"""
subj = self.subject.code if self.subject else 'N/A'
code = self.code or ''
sem = str(self.semester or '').strip()
yr = str(self.year or '').strip()
if yr and yr in sem:
return f"{subj}-{code}-{sem}"
elif sem and yr:
return f"{subj}-{code}-{sem}{yr}"
elif sem:
return f"{subj}-{code}-{sem}"
elif yr:
return f"{subj}-{code}-{yr}"
return f"{subj}-{code}"
def __repr__(self):
return f'<Commission {self.get_full_code()}>'
class CommissionTeacher(db.Model):
"""Modelo de asignación de múltiples docentes a una comisión (Co-Docencia / Cátedra)."""
__tablename__ = 'commission_teachers'
id = db.Column(db.Integer, primary_key=True)
commission_id = db.Column(db.Integer, db.ForeignKey('commissions.id', ondelete='CASCADE'), nullable=False, index=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
role = db.Column(db.String(50), default='Titular', nullable=False) # Titular, Adjunto, JTP, Ayudante
is_primary = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Relationships
commission = db.relationship('Commission', backref=db.backref('teachers_association', cascade='all, delete-orphan', lazy='joined'))
teacher = db.relationship('User', backref=db.backref('commission_assignments', cascade='all, delete-orphan', lazy='joined'))
__table_args__ = (db.UniqueConstraint('commission_id', 'user_id'),)
def to_dict(self):
return {
'id': self.id,
'commission_id': self.commission_id,
'user_id': self.user_id,
'name': self.teacher.name if self.teacher else 'Docente',
'email': self.teacher.email if self.teacher else '',
'role': self.role or 'Titular',
'is_primary': self.is_primary,
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self):
return f'<CommissionTeacher Commission:{self.commission_id} User:{self.user_id} Role:{self.role}>'
from app import db
from datetime import datetime
class Subject(db.Model):
__tablename__ = 'subjects'
id = db.Column(db.Integer, primary_key=True)
code = db.Column(db.String(50), unique=True, nullable=False, index=True)
name = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text)
department = db.Column(db.String(100), nullable=False, default='')
credits = db.Column(db.Integer, nullable=False, default=4)
career_id = db.Column(db.Integer, db.ForeignKey('careers.id', ondelete='SET NULL'), nullable=True)
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
commissions = db.relationship('Commission', backref='subject', lazy=True, cascade='all, delete-orphan')
def to_dict(self):
"""Convert subject to dictionary"""
return {
'id': self.id,
'code': self.code,
'name': self.name,
'description': self.description,
'department': self.department,
'credits': self.credits,
'career_id': self.career_id,
'career_name': self.career_obj.name if self.career_obj else None,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}
def __repr__(self):
return f'<Subject {self.code}>'
class Commission(db.Model):
__tablename__ = 'commissions'
id = db.Column(db.Integer, primary_key=True)
subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=False)
code = db.Column(db.String(50), nullable=False)
semester = db.Column(db.String(20), nullable=False)
year = db.Column(db.Integer, nullable=False)
teacher_id = db.Column(db.Integer, db.ForeignKey('users.id'))
max_students = db.Column(db.Integer, nullable=False)
current_students = db.Column(db.Integer, default=0)
schedule = db.Column(db.String(255))
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
active = db.Column(db.Boolean, default=True) # Changed from is_active to active
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Virtual property for compatibility
@property
def is_active(self):
return self.active
@is_active.setter
def is_active(self, value):
self.active = value
# Relationships
pass # Reservation already defines the relationship back to commission
teacher = db.relationship('User', backref='teaching_commissions', foreign_keys=[teacher_id])
# Virtual field for teacher_name
@property
def teacher_name(self):
return f"{self.teacher.first_name} {self.teacher.last_name}" if self.teacher else "TBD"
# Unique constraint
__table_args__ = (db.UniqueConstraint('subject_id', 'code', 'semester', 'year'),)
def to_dict(self):
"""Convert commission to dictionary"""
return {
'id': self.id,
'subject_id': self.subject_id,
'code': self.code,
'semester': self.semester,
'year': self.year,
'teacher_name': self.teacher_name,
'max_students': self.max_students,
'current_students': self.current_students,
'schedule': self.schedule,
'active': self.active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'subject': self.subject.to_dict() if self.subject else None
}
def get_full_code(self):
"""Get full commission code"""
return f"{self.subject.code if self.subject else 'N/A'}-{self.code}-{self.semester.upper()}{self.year if self.year else ''}"
def __repr__(self):
return f'<Commission {self.get_full_code()}>'
+86
View File
@@ -0,0 +1,86 @@
from app import db, login_manager
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime
class User(UserMixin, db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
name = db.Column(db.String(100), nullable=False)
role = db.Column(db.String(50), nullable=False)
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
last_login = db.Column(db.DateTime)
preferred_language = db.Column(db.String(10), default=None, nullable=True)
theme_preference = db.Column(db.String(10), default='auto', nullable=False)
role_id = db.Column(db.Integer, db.ForeignKey('roles.id', ondelete='SET NULL'), nullable=True)
# Virtual fields for future compatibility
@property
def first_name(self):
return self.name.split()[0] if self.name else ''
@property
def last_name(self):
parts = self.name.split()
return ' '.join(parts[1:]) if len(parts) > 1 else ''
# Relationships
reservations = db.relationship('Reservation', backref='user', lazy=True, cascade='all, delete-orphan')
def set_password(self, password):
"""Set password hash"""
self.password_hash = generate_password_hash(password, method='pbkdf2:sha256')
def check_password(self, password):
"""Check password"""
return check_password_hash(self.password_hash, password)
def is_admin(self):
"""Check if user is admin"""
if self.role_obj and self.role_obj.name.lower() in ['admin', 'administrador']:
return True
return str(self.role).upper() in ['ADMIN', 'ADMINISTRADOR']
def has_permission(self, module, min_level='read'):
"""Check if user has granular permission on a given module"""
if self.is_admin():
return True
if self.role_obj:
return self.role_obj.has_permission(module, min_level)
return False
def can_manage(self):
"""Check if user can access the Management / Admin section"""
return self.is_admin() or self.has_permission('users', 'read')
def update_last_login(self):
"""Update last login timestamp"""
self.last_login = datetime.utcnow()
db.session.commit()
def to_dict(self):
"""Convert user to dictionary"""
return {
'id': self.id,
'email': self.email,
'name': self.name,
'role': self.role,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'last_login': self.last_login.isoformat() if self.last_login else None,
'preferred_language': self.preferred_language,
'theme_preference': self.theme_preference
}
def __repr__(self):
return f'<User {self.email}>'
@login_manager.user_loader
def load_user(user_id):
"""Load user from database by ID"""
return User.query.get(int(user_id))

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

@@ -5,6 +5,5 @@ from .buildings import buildings_bp
from .schedule import schedule_bp
from .genetic_algorithm import genetic_bp, genetic_web_bp
from .admin import admin_bp
from .my_subjects import my_subjects_bp
__all__ = ['main_bp', 'auth_bp', 'classrooms_bp', 'buildings_bp', 'schedule_bp', 'genetic_bp', 'genetic_web_bp', 'admin_bp', 'my_subjects_bp']
__all__ = ['main_bp', 'auth_bp', 'classrooms_bp', 'buildings_bp', 'schedule_bp', 'genetic_bp', 'genetic_web_bp', 'admin_bp']
+342
View File
@@ -0,0 +1,342 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, abort
from flask_login import login_required, current_user
from flask_babel import gettext as _
from app import db
from app.models.user import User
from app.models.role import Role, Permission, SYSTEM_MODULES, ACCESS_LEVELS
from app.utils.decorators import admin_required, permission_required
from sqlalchemy import or_
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
# ---------------------------------------------------------
# USERS MANAGEMENT
# ---------------------------------------------------------
@admin_bp.route('/users')
@admin_required
def users_list():
"""List system users with filtering and role information"""
search = request.args.get('search', '').strip()
role_filter = request.args.get('role', '').strip()
status_filter = request.args.get('status', '').strip()
page = request.args.get('page', 1, type=int)
per_page = 15
query = User.query
if search:
query = query.filter(
or_(
User.name.ilike(f'%{search}%'),
User.email.ilike(f'%{search}%')
)
)
if role_filter:
if role_filter.isdigit():
query = query.filter(User.role_id == int(role_filter))
else:
query = query.filter(User.role.ilike(role_filter))
if status_filter in ['active', 'inactive']:
query = query.filter(User.is_active == (status_filter == 'active'))
pagination = query.order_by(User.id.asc()).paginate(page=page, per_page=per_page, error_out=False)
roles = Role.query.order_by(Role.name.asc()).all()
return render_template(
'admin/users/list.html',
users=pagination.items,
pagination=pagination,
roles=roles,
search=search,
role_filter=role_filter,
status_filter=status_filter
)
@admin_bp.route('/users/add', methods=['GET', 'POST'])
@admin_required
def user_add():
"""Create a new user and assign a role"""
roles = Role.query.order_by(Role.name.asc()).all()
if request.method == 'POST':
name = request.form.get('name', '').strip()
email = request.form.get('email', '').strip().lower()
password = request.form.get('password', '')
role_id = request.form.get('role_id', type=int)
is_active = request.form.get('is_active') == 'on'
errors = []
if not name:
errors.append(_('El nombre es requerido.'))
if not email:
errors.append(_('El correo electrónico es requerido.'))
elif User.query.filter_by(email=email).first():
errors.append(_('Ya existe una cuenta con este correo electrónico.'))
if not password or len(password) < 6:
errors.append(_('La contraseña debe tener al menos 6 caracteres.'))
role_obj = Role.query.get(role_id) if role_id else None
if not role_obj:
errors.append(_('Debes seleccionar un rol válido.'))
if errors:
for err in errors:
flash(err, 'error')
return render_template('admin/users/form.html', user=None, roles=roles)
user = User(
name=name,
email=email,
role_id=role_obj.id,
role=role_obj.name,
is_active=is_active
)
user.set_password(password)
db.session.add(user)
db.session.commit()
flash(_('Usuario "%(name)s" creado exitosamente.', name=user.name), 'success')
return redirect(url_for('admin.users_list'))
return render_template('admin/users/form.html', user=None, roles=roles)
@admin_bp.route('/users/edit/<int:id>', methods=['GET', 'POST'])
@admin_required
def user_edit(id):
"""Edit user details, role and status"""
user = User.query.get_or_404(id)
roles = Role.query.order_by(Role.name.asc()).all()
if request.method == 'POST':
name = request.form.get('name', '').strip()
email = request.form.get('email', '').strip().lower()
password = request.form.get('password', '')
role_id = request.form.get('role_id', type=int)
is_active = request.form.get('is_active') == 'on'
errors = []
if not name:
errors.append(_('El nombre es requerido.'))
if not email:
errors.append(_('El correo electrónico es requerido.'))
else:
existing = User.query.filter_by(email=email).first()
if existing and existing.id != user.id:
errors.append(_('Ya existe otra cuenta con este correo electrónico.'))
role_obj = Role.query.get(role_id) if role_id else None
if not role_obj:
errors.append(_('Debes seleccionar un rol válido.'))
# Do not allow deactivating the currently logged in admin user
if user.id == current_user.id and not is_active:
errors.append(_('No puedes desactivar tu propia cuenta mientras estás en sesión.'))
if errors:
for err in errors:
flash(err, 'error')
return render_template('admin/users/form.html', user=user, roles=roles)
user.name = name
user.email = email
user.role_id = role_obj.id
user.role = role_obj.name
user.is_active = is_active
if password:
if len(password) < 6:
flash(_('La nueva contraseña debe tener al menos 6 caracteres.'), 'error')
return render_template('admin/users/form.html', user=user, roles=roles)
user.set_password(password)
db.session.commit()
flash(_('Usuario "%(name)s" actualizado correctamente.', name=user.name), 'success')
return redirect(url_for('admin.users_list'))
return render_template('admin/users/form.html', user=user, roles=roles)
@admin_bp.route('/users/toggle/<int:id>', methods=['POST'])
@admin_required
def user_toggle(id):
"""Toggle user active status"""
user = User.query.get_or_404(id)
if user.id == current_user.id:
flash(_('No puedes desactivar tu propia cuenta.'), 'error')
return redirect(url_for('admin.users_list'))
user.is_active = not user.is_active
db.session.commit()
status_str = _('activada') if user.is_active else _('desactivada')
flash(_('La cuenta de %(name)s ha sido %(status)s.', name=user.name, status=status_str), 'success')
return redirect(url_for('admin.users_list'))
# ---------------------------------------------------------
# ROLES & PERMISSIONS MANAGEMENT (FortiGate Matrix)
# ---------------------------------------------------------
@admin_bp.route('/roles')
@admin_required
def roles_list():
"""List all roles and permission summaries"""
roles = Role.query.order_by(Role.id.asc()).all()
return render_template(
'admin/roles/list.html',
roles=roles,
modules=SYSTEM_MODULES
)
@admin_bp.route('/roles/add', methods=['GET', 'POST'])
@admin_required
def role_add():
"""Create a new role with the FortiGate permission matrix"""
if request.method == 'POST':
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
if not name:
flash(_('El nombre del rol es requerido.'), 'error')
return render_template('admin/roles/edit.html', role=None, modules=SYSTEM_MODULES, current_perms={})
if Role.query.filter_by(name=name).first():
flash(_('Ya existe un rol con ese nombre.'), 'error')
return render_template('admin/roles/edit.html', role=None, modules=SYSTEM_MODULES, current_perms={})
new_role = Role(name=name, description=description, is_system=False)
db.session.add(new_role)
db.session.flush() # to obtain new_role.id
# Save permissions from matrix
for mod in SYSTEM_MODULES:
mod_id = mod['id']
level = request.form.get(f'perm_{mod_id}', 'none')
if level not in ACCESS_LEVELS:
level = 'none'
new_role.set_permission(mod_id, level)
db.session.commit()
flash(_('Rol "%(name)s" creado exitosamente con sus permisos.', name=new_role.name), 'success')
return redirect(url_for('admin.roles_list'))
return render_template('admin/roles/edit.html', role=None, modules=SYSTEM_MODULES, current_perms={})
@admin_bp.route('/roles/edit/<int:id>', methods=['GET', 'POST'])
@admin_required
def role_edit(id):
"""Edit an existing role and its FortiGate permission matrix"""
role = Role.query.get_or_404(id)
if request.method == 'POST':
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
if not name:
flash(_('El nombre del rol es requerido.'), 'error')
return render_template('admin/roles/edit.html', role=role, modules=SYSTEM_MODULES, current_perms={p.module: p.access_level for p in role.permissions})
existing = Role.query.filter_by(name=name).first()
if existing and existing.id != role.id:
flash(_('Ya existe otro rol con ese nombre.'), 'error')
return render_template('admin/roles/edit.html', role=role, modules=SYSTEM_MODULES, current_perms={p.module: p.access_level for p in role.permissions})
# If it's a system role, keep name unchanged or protected
if not role.is_system:
role.name = name
role.description = description
# Update permissions from the FortiGate matrix
for mod in SYSTEM_MODULES:
mod_id = mod['id']
level = request.form.get(f'perm_{mod_id}', 'none')
if level not in ACCESS_LEVELS:
level = 'none'
role.set_permission(mod_id, level)
db.session.commit()
flash(_('Rol "%(name)s" y permisos actualizados correctamente.', name=role.name), 'success')
return redirect(url_for('admin.roles_list'))
current_perms = {p.module: p.access_level for p in role.permissions}
return render_template(
'admin/roles/edit.html',
role=role,
modules=SYSTEM_MODULES,
current_perms=current_perms
)
@admin_bp.route('/roles/delete/<int:id>', methods=['POST'])
@admin_required
def role_delete(id):
"""Delete a non-system role if it has no assigned users"""
role = Role.query.get_or_404(id)
if role.is_system:
flash(_('No se pueden eliminar roles protegidos del sistema.'), 'error')
return redirect(url_for('admin.roles_list'))
user_count = len(role.users) if role.users else 0
if user_count > 0:
flash(_('No se puede eliminar el rol "%(name)s" porque tiene %(count)d usuario(s) asignado(s). Reasígnalos primero.', name=role.name, count=user_count), 'error')
return redirect(url_for('admin.roles_list'))
role_name = role.name
db.session.delete(role)
db.session.commit()
flash(_('El rol "%(name)s" ha sido eliminado.', name=role_name), 'success')
return redirect(url_for('admin.roles_list'))
# ---------------------------------------------------------
# GOOGLE SHEETS IMPORT & SYNCHRONIZATION
# ---------------------------------------------------------
@admin_bp.route('/import', methods=['GET', 'POST'])
@admin_required
def google_sheets_import():
"""Manual sync and status page for Google Sheets academic schedule data"""
from app.services.sheets_importer import GoogleSheetsImporter, SHEETS_CONFIG
from app.models.classroom import Classroom
from app.models.career import Career
from app.models.subject import Subject, Commission
from app.models.reservation import Reservation
stats = None
if request.method == 'POST':
try:
importer = GoogleSheetsImporter()
stats = importer.sync()
if stats['errors']:
flash(_('La sincronización se completó con algunas advertencias.'), 'warning')
else:
flash(_('Sincronización completada exitosamente. %(rows)d registros procesados.', rows=stats['total_rows_processed']), 'success')
except Exception as e:
flash(_('Error al sincronizar con Google Sheets: %(err)s', err=str(e)), 'error')
# Calculate current system metrics
metrics = {
'total_classrooms': Classroom.query.count(),
'total_careers': Career.query.count(),
'total_subjects': Subject.query.count(),
'total_commissions': Commission.query.count(),
'total_reservations': Reservation.query.count(),
'sheets': SHEETS_CONFIG
}
# Recent samples from DB if available
recent_subjects = Subject.query.order_by(Subject.id.desc()).limit(10).all()
return render_template(
'admin/import.html',
metrics=metrics,
stats=stats,
recent_subjects=recent_subjects
)
@@ -8,18 +8,15 @@ from datetime import datetime
auth_bp = Blueprint('auth', __name__)
from sqlalchemy import func
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
"""Handle user login with case-insensitive email lookup and whitespace trimming"""
"""Handle user login"""
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
form = LoginForm()
if form.validate_on_submit():
clean_email = (form.email.data or '').strip().lower()
user = User.query.filter(func.lower(User.email) == clean_email).first()
user = User.query.filter_by(email=form.email.data).first()
if user and user.check_password(form.password.data):
if not user.is_active:
@@ -28,19 +25,6 @@ def login():
login_user(user, remember=form.remember_me.data)
user.update_last_login()
# Registrar en bitácora de auditoría
try:
from app.services.audit_service import AuditService
AuditService.log(
action='LOGIN',
module='auth',
user_id=user.id,
user_email=user.email,
details=f"Inicio de sesión exitoso ({user.role})"
)
except Exception:
pass
# Si el usuario tiene idioma configurado en su perfil, sobrescribe el navegador en toda la sesión
if user.preferred_language and user.preferred_language in ['es', 'en']:
@@ -58,15 +42,6 @@ def login():
flash(_('¡Bienvenido de nuevo, %(name)s!', name=user_full_name), 'success')
return redirect(next_page)
else:
try:
from app.services.audit_service import AuditService
AuditService.log(
action='LOGIN_FAILED',
module='auth',
details=f"Intento fallido de login con email: {clean_email}"
)
except Exception:
pass
flash(_('Correo electrónico o contraseña inválidos.'), 'error')
return render_template('auth/login.html', form=form)
+248
View File
@@ -0,0 +1,248 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask_login import login_required, current_user
from datetime import datetime, date
from app.models.classroom import Classroom
from app.models.user import User
from app.forms.classroom import ClassroomForm
from app import db
from sqlalchemy import text
classrooms_bp = Blueprint('classrooms', __name__)
@classrooms_bp.route('/add', methods=['GET', 'POST'])
@login_required
def add_classroom():
"""Add a new classroom"""
form = ClassroomForm()
# TODO: Update when campus model is implemented
# form.campus_id.choices = [(1, 'Main Campus')]
if form.validate_on_submit():
try:
existing_classroom = Classroom.query.filter_by(
building=form.building.data,
code=form.room_number.data,
floor=form.floor.data
).first()
if existing_classroom:
flash('Classroom with this building, room number, and floor already exists.', 'error')
return render_template('classrooms/add.html', form=form, title='Add Classroom')
cap_raw = str(form.capacity.data or '').strip()
if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
cap_val = 0
else:
cap_val = int(cap_raw)
classroom = Classroom(
# campus_id=form.campus_id.data,
building=form.building.data,
code=form.room_number.data,
floor=form.floor.data,
capacity=cap_val,
description=form.description.data,
is_active=form.is_active.data
)
db.session.add(classroom)
db.session.commit()
flash(f'Classroom {classroom.code_display} has been created successfully!', 'success')
return redirect(url_for('classrooms.list_classrooms'))
except Exception as e:
db.session.rollback()
flash(f'Error creating classroom: {str(e)}', 'error')
return render_template('classrooms/add.html', form=form, title='Add Classroom')
@classrooms_bp.route('/', endpoint='index')
@classrooms_bp.route('/list')
@login_required
def list_classrooms():
"""List all classrooms"""
try:
# Get filters
page = request.args.get('page', 1, type=int)
active_only = request.args.get('active_only', 'true').lower() == 'true'
search = request.args.get('search', '', type=str)
# Build query
query = Classroom.query
if active_only:
query = query.filter_by(is_active=True)
if search:
query = query.filter(
db.or_(
Classroom.building.ilike(f'%{search}%'),
Classroom.code.ilike(f'%{search}%'),
Classroom.description.ilike(f'%{search}%')
)
)
# Paginate results
classrooms = query.order_by(Classroom.building, Classroom.floor, Classroom.code).paginate(
page=page, per_page=12, error_out=False
)
return render_template('classrooms/list.html',
classrooms=classrooms,
active_only=active_only,
search=search,
title='Classrooms')
except Exception as e:
flash(f'Error loading classrooms: {str(e)}', 'error')
return render_template('classrooms/list.html', classrooms=None, title='Classrooms')
@classrooms_bp.route('/<int:id>')
@login_required
def view_classroom(id):
"""View classroom details"""
try:
classroom = Classroom.query.get_or_404(id)
# Get upcoming reservations for this classroom
from app.models.reservation import Reservation, ReservationStatus
upcoming_reservations = Reservation.query.filter(
Reservation.classroom_id == id,
Reservation.start_time > datetime.utcnow(),
Reservation.status == ReservationStatus.CONFIRMED.value
).order_by(Reservation.start_time).limit(10).all()
return render_template('classrooms/view.html',
classroom=classroom,
upcoming_reservations=upcoming_reservations,
title=f'Classroom {classroom.code_display}')
except Exception as e:
flash(f'Error loading classroom: {str(e)}', 'error')
return redirect(url_for('classrooms.list_classrooms'))
@classrooms_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit_classroom(id):
"""Edit classroom"""
classroom = Classroom.query.get_or_404(id)
form = ClassroomForm(obj=classroom)
if request.method == 'GET':
if classroom.capacity == 0 or (classroom.code and 'VIRTUAL' in classroom.code.upper()):
form.capacity.data = '∞'
else:
form.capacity.data = str(classroom.capacity)
# TODO: Update when campus model is implemented
# form.campus_id.choices = [(1, 'Main Campus')]
if form.validate_on_submit():
try:
# Check for duplicates (excluding current classroom)
existing_classroom = Classroom.query.filter(
Classroom.building == form.building.data,
Classroom.code == form.room_number.data,
Classroom.floor == form.floor.data,
Classroom.id != id
).first()
if existing_classroom:
flash('Classroom with this building, room number, and floor already exists.', 'error')
return render_template('classrooms/edit.html', form=form, classroom=classroom, title='Edit Classroom')
cap_raw = str(form.capacity.data or '').strip()
if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
cap_val = 0
else:
cap_val = int(cap_raw)
# Update classroom
classroom.building = form.building.data
classroom.code = form.room_number.data
classroom.floor = form.floor.data
classroom.capacity = cap_val
classroom.description = form.description.data
classroom.is_active = form.is_active.data
classroom.updated_at = datetime.utcnow()
db.session.commit()
flash(f'Classroom {classroom.code_display} has been updated successfully!', 'success')
return redirect(url_for('classrooms.view_classroom', id=classroom.id))
except Exception as e:
db.session.rollback()
flash(f'Error updating classroom: {str(e)}', 'error')
return render_template('classrooms/edit.html', form=form, classroom=classroom, title='Edit Classroom')
@classrooms_bp.route('/<int:id>/toggle', methods=['POST'])
@login_required
def toggle_classroom(id):
"""Toggle classroom active status"""
classroom = Classroom.query.get_or_404(id)
try:
classroom.is_active = not classroom.is_active
classroom.updated_at = datetime.utcnow()
db.session.commit()
status = 'activated' if classroom.is_active else 'deactivated'
flash(f'Classroom {classroom.code_display} has been {status} successfully!', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error updating classroom status: {str(e)}', 'error')
return redirect(request.referrer or url_for('classrooms.list_classrooms'))
# API endpoints for AJAX requests
@classrooms_bp.route('/api/available')
@login_required
def get_available_classrooms():
"""Get available classrooms for a specific time slot"""
start_time = request.args.get('start_time')
end_time = request.args.get('end_time')
if not start_time or not end_time:
return jsonify({'error': 'Start time and end time are required'}), 400
try:
from datetime import datetime
from app.models.reservation import Reservation, ReservationStatus
start_dt = datetime.fromisoformat(start_time)
end_dt = datetime.fromisoformat(end_time)
# Find all classrooms
all_classrooms = Classroom.query.filter_by(is_active=True).all()
available_classrooms = []
for classroom in all_classrooms:
# Check if classroom has conflicting reservations
conflicts = Reservation.query.filter(
Reservation.classroom_id == classroom.id,
Reservation.status == ReservationStatus.CONFIRMED.value,
Reservation.start_time < end_dt,
Reservation.end_time > start_dt
).all()
if not conflicts:
available_classrooms.append({
'id': classroom.id,
'code': classroom.code,
'building': classroom.building,
'room_number': classroom.code,
'floor': classroom.floor,
'capacity': classroom.capacity,
'code_display': classroom.code_display
})
return jsonify({'classrooms': available_classrooms})
except Exception as e:
return jsonify({'error': str(e)}), 500
@@ -1,15 +1,15 @@
from flask import Blueprint, request, jsonify, current_app, render_template, g
from flask import Blueprint, request, jsonify, current_app, render_template
from datetime import datetime, timedelta
from app.models.genetic_algorithm import ReservationOptimizer
from app.models.reservation import Reservation, ReservationStatus
from app import db
from app.utils.jwt_decorators import jwt_required
from flask_login import login_required, current_user
genetic_bp = Blueprint('genetic_algorithm', __name__, url_prefix='/api/genetic')
@genetic_bp.route('/optimize', methods=['POST'])
@jwt_required
@login_required
def optimize_reservations():
"""Optimize reservations using genetic algorithm"""
try:
@@ -39,15 +39,15 @@ def optimize_reservations():
except ValueError:
return jsonify({'error': 'Invalid end_date format. Use ISO format.'}), 400
# Check if user has permission (admin, teacher or RBAC optimizer write)
if not (g.jwt_user.is_admin() or g.jwt_user.has_permission('optimizer', 'read_write') or g.jwt_user.role in ['ADMIN', 'teacher']):
# Check if user has permission (admin or teacher)
if current_user.role not in ['ADMIN', 'teacher']:
return jsonify({'error': 'Insufficient permissions to optimize reservations'}), 403
# Initialize optimizer and run optimization
optimizer = ReservationOptimizer()
result = optimizer.optimize_schedule(
commission_ids=commission_ids,
admin_user_id=g.jwt_user.id,
admin_user_id=current_user.id,
start_date=start_date,
end_date=end_date
)
@@ -69,7 +69,7 @@ def optimize_reservations():
@genetic_bp.route('/apply-optimization', methods=['POST'])
@jwt_required
@login_required
def apply_optimization():
"""Apply optimized reservations to the database"""
try:
@@ -84,7 +84,7 @@ def apply_optimization():
return jsonify({'error': 'reservations must be a list'}), 400
# Check if user has permission
if not (g.jwt_user.is_admin() or g.jwt_user.has_permission('optimizer', 'read_write') or g.jwt_user.role in ['ADMIN', 'teacher']):
if current_user.role not in ['ADMIN', 'teacher']:
return jsonify({'error': 'Insufficient permissions to apply reservations'}), 403
# Initialize optimizer and apply reservations
@@ -106,7 +106,7 @@ def apply_optimization():
@genetic_bp.route('/preview-optimization', methods=['POST'])
@jwt_required
@login_required
def preview_optimization():
"""Preview optimization without applying to database"""
try:
@@ -124,14 +124,14 @@ def preview_optimization():
return jsonify({'error': 'commission_ids must be a non-empty list'}), 400
# Check if user has permission
if g.jwt_user.role not in ['ADMIN', 'teacher']:
if current_user.role not in ['ADMIN', 'teacher']:
return jsonify({'error': 'Insufficient permissions to preview optimization'}), 403
# Initialize optimizer and run optimization
optimizer = ReservationOptimizer()
result = optimizer.optimize_schedule(
commission_ids=commission_ids,
admin_user_id=g.jwt_user.id
admin_user_id=current_user.id
)
# Add additional preview information
@@ -166,12 +166,12 @@ def preview_optimization():
@genetic_bp.route('/commissions', methods=['GET'])
@jwt_required
@login_required
def get_commissions():
"""Get available commissions for optimization"""
try:
# Check if user has permission
if g.jwt_user.role not in ['ADMIN', 'teacher']:
if current_user.role not in ['ADMIN', 'teacher']:
return jsonify({'error': 'Insufficient permissions'}), 403
from app.models.subject import Commission, Subject
@@ -196,12 +196,12 @@ def get_commissions():
@genetic_bp.route('/algorithm-status', methods=['GET'])
@jwt_required
@login_required
def get_algorithm_status():
"""Get genetic algorithm configuration status"""
try:
# Check if user has permission
if g.jwt_user.role not in ['ADMIN']:
if current_user.role not in ['ADMIN']:
return jsonify({'error': 'Insufficient permissions'}), 403
# Get system status
@@ -246,10 +246,10 @@ genetic_web_bp = Blueprint('genetic_algorithm_web', __name__)
@genetic_web_bp.route('/genetic-optimizer')
@jwt_required
@login_required
def genetic_optimizer():
"""Render the genetic algorithm optimizer interface"""
if not (g.jwt_user.has_permission('optimizer', 'read') or (g.jwt_user.role and g.jwt_user.role.upper() == 'ADMIN')):
if current_user.role not in ['ADMIN']:
return render_template('auth/403.html'), 403
return render_template('genetic_optimizer.html')
+137
View File
@@ -0,0 +1,137 @@
from flask import Blueprint, render_template, jsonify, redirect, url_for, session, request, flash
from flask_login import login_required, current_user
from app import db
from app.models.reservation import Reservation
from app.models.classroom import Classroom
from app.models.subject import Subject
from app.models.subject import Commission
from datetime import datetime, timedelta
from sqlalchemy import func
from flask_babel import gettext as _
main_bp = Blueprint('main', __name__)
@main_bp.route('/')
@login_required
def dashboard():
"""Main dashboard page"""
# Get statistics
try:
total_classrooms = Classroom.query.filter_by(is_active=True).count()
except:
total_classrooms = 0
try:
total_subjects = Subject.query.filter_by(is_active=True).count()
except:
total_subjects = 0
# Get today's reservations
today = datetime.utcnow().date()
try:
today_reservations = Reservation.query.filter(
func.date(Reservation.start_time) == today
).count()
except:
today_reservations = 0
# Get this week's reservations
week_start = today - timedelta(days=today.weekday())
week_end = week_start + timedelta(days=6)
try:
week_reservations = Reservation.query.filter(
func.date(Reservation.start_time) >= week_start,
func.date(Reservation.start_time) <= week_end
).count()
except:
week_reservations = 0
# Get upcoming reservations (next 7 days)
try:
upcoming_reservations = Reservation.query.filter(
Reservation.start_time >= datetime.utcnow(),
Reservation.start_time <= datetime.utcnow() + timedelta(days=7)
).limit(10).all()
except:
upcoming_reservations = []
stats = {
'total_classrooms': total_classrooms,
'total_subjects': total_subjects,
'today_reservations': today_reservations,
'week_reservations': week_reservations
}
return render_template('dashboard.html',
stats=stats,
upcoming_reservations=upcoming_reservations)
@main_bp.route('/api/dashboard-stats')
@login_required
def dashboard_stats_api():
"""API endpoint for dashboard statistics"""
# Get today's data
today = datetime.utcnow().date()
today_reservations = Reservation.query.filter(
func.date(Reservation.start_time) == today
).count()
# Get this week's data
week_start = today - timedelta(days=today.weekday())
week_end = week_start + timedelta(days=6)
week_reservations = Reservation.query.filter(
func.date(Reservation.start_time) >= week_start,
func.date(Reservation.start_time) <= week_end
).count()
# Get monthly data for chart
current_date = datetime.utcnow()
monthly_data = []
for i in range(12):
month_date = current_date - timedelta(days=30 * i)
month_start = month_date.replace(day=1)
month_end = (month_start + timedelta(days=32)).replace(day=1) - timedelta(days=1)
monthly_reservations = Reservation.query.filter(
func.date(Reservation.start_time) >= month_start.date(),
func.date(Reservation.start_time) <= month_end.date()
).count()
monthly_data.append({
'month': month_start.strftime('%Y-%m'),
'reservations': monthly_reservations
})
return jsonify({
'today_reservations': today_reservations,
'week_reservations': week_reservations,
'monthly_data': list(reversed(monthly_data))
})
@main_bp.route('/genetic-optimizer')
@login_required
def genetic_optimizer():
"""Serve the genetic algorithm optimization interface"""
if current_user.role not in ['ADMIN']:
flash('You do not have permission to access the optimization tool.', 'danger')
return redirect(url_for('main.dashboard'))
return render_template('genetic_optimizer.html')
@main_bp.route('/set_language/<language>')
def set_language(language=None):
if language not in ['en', 'es']:
language = 'es'
session['language'] = language
# Si se selecciona el idioma desde la pantalla de login, registrar la selección explícita
if request.referrer and 'login' in request.referrer:
session['login_lang_explicit'] = language
if current_user.is_authenticated:
current_user.preferred_language = language
db.session.commit()
return redirect(request.referrer or url_for('main.dashboard'))␍
@@ -40,12 +40,8 @@ def today_schedule():
~Classroom.building.ilike('%Virtual%')
)
if floor_filter:
flr = str(floor_filter).strip()
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
else:
query = query.filter(Classroom.floor == flr)
if floor_filter and floor_filter.isdigit():
query = query.filter(Classroom.floor == int(floor_filter))
if shift_filter:
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
@@ -139,102 +135,14 @@ def view_reservation(id):
flash(f'Error loading reservation details: {str(e)}', 'error')
return redirect(url_for('schedule.today_schedule'))
@schedule_bp.route('/reservation/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit_reservation(id):
"""Edit existing reservation"""
from app.models.classroom import Classroom
from app.models.subject import Commission
from app.models.building import Building
from flask_babel import gettext as _
reservation = Reservation.query.options(
joinedload(Reservation.classroom),
joinedload(Reservation.commission),
joinedload(Reservation.user)
).get_or_404(id)
if not current_user.can_edit_reservation(reservation):
flash(_('No tienes los permisos necesarios para modificar esta reserva.'), 'error')
return redirect(url_for('schedule.view_reservation', id=reservation.id))
form = ReservationForm(is_edit=True)
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
commissions = Commission.query.filter_by(active=True).all()
form.classroom_id.choices = [(c.id, f'{c.building_name} - {c.code} (Cap: {c.capacity_display})') for c in classrooms]
form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.subject.name if c.subject else "No Subject"} (Students: {c.max_students})') for c in commissions]
if request.method == 'GET':
form.classroom_id.data = reservation.classroom_id
form.commission_id.data = reservation.commission_id
form.start_time.data = reservation.start_time
form.end_time.data = reservation.end_time
form.purpose.data = reservation.purpose
form.expected_attendees.data = reservation.expected_attendees
form.notes.data = reservation.notes or ''
form.virtual_link.data = reservation.virtual_link or ''
elif request.method == 'POST':
if form.validate_on_submit():
try:
# Verificar colisión de horario si no es virtual
target_room = Classroom.query.get(form.classroom_id.data)
if target_room and not target_room.is_virtual:
conflict = Reservation.query.filter(
Reservation.id != reservation.id,
Reservation.classroom_id == form.classroom_id.data,
Reservation.status == ReservationStatus.CONFIRMED.value,
Reservation.start_time < form.end_time.data,
Reservation.end_time > form.start_time.data
).first()
if conflict:
flash(_('El aula seleccionada ya cuenta con una reserva confirmada en esa franja horaria.'), 'danger')
return render_template('schedule/edit.html', form=form, reservation=reservation, classroom_list=classrooms, commission_list=commissions, buildings=buildings)
# Asignar cambios
reservation.classroom_id = form.classroom_id.data
reservation.commission_id = form.commission_id.data
reservation.start_time = form.start_time.data
reservation.end_time = form.end_time.data
reservation.purpose = form.purpose.data
reservation.expected_attendees = form.expected_attendees.data
reservation.notes = form.notes.data
reservation.virtual_link = form.virtual_link.data.strip() if form.virtual_link.data else None
# Turno
h = reservation.start_time.hour
if h < 13:
reservation.shift = 'Mañana'
elif h < 18:
reservation.shift = 'Tarde'
elif h < 21:
reservation.shift = 'Vespertino'
else:
reservation.shift = 'Noche'
db.session.commit()
flash(_('Reserva #%(id)d modificada exitosamente.', id=reservation.id), 'success')
return redirect(url_for('schedule.view_reservation', id=reservation.id))
except Exception as e:
db.session.rollback()
flash(f'Error al modificar reserva: {str(e)}', 'danger')
return render_template('schedule/edit.html', form=form, reservation=reservation, classroom_list=classrooms, commission_list=commissions, buildings=buildings)
@schedule_bp.route('/calendar')
@login_required
def calendar_view():
"""Show calendar view with classroom, subject, career, shift, floor and virtual filtering"""
from app.models.career import Career
from app.models.building import Building
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building.asc(), Classroom.code.asc()).all()
subjects = Subject.query.filter_by(is_active=True).order_by(Subject.name.asc()).all()
careers = Career.query.order_by(Career.name.asc()).all()
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
available_floors = [f[0] for f in floors]
@@ -246,246 +154,22 @@ def calendar_view():
classrooms=classrooms,
subjects=subjects,
careers=careers,
buildings=buildings,
available_floors=available_floors
)
def user_can_manage_commission_milestones(user, commission=None):
"""Verifica si el usuario (Docente, Admin, Bedelía) puede gestionar hitos y links virtuales"""
if user.is_admin():
return True
role_name = (user.role_obj.name if user.role_obj else user.role or '').lower()
if role_name in ['admin', 'bedelia', 'bedelía', 'operador']:
return True
if role_name in ['docente', 'profesor']:
return True
return user.has_permission('schedule', 'read_write')
@schedule_bp.route('/commissions')
@login_required
def commissions_view():
"""Vista y ABM de Comisiones con soporte de enlace virtual e hitos académicos"""
from app.models.subject import Commission, Subject
from app.models.classroom import Classroom
from app.models.user import User
from app.models.milestone import MilestoneType
subjects = Subject.query.all()
subjects = Subject.query.order_by(Subject.code.asc()).all()
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.code.asc()).all()
teachers = User.query.filter_by(is_active=True).order_by(User.name.asc()).all()
milestone_types = MilestoneType.query.filter_by(is_active=True).order_by(MilestoneType.sort_order.asc()).all()
# Totales para indicadores superiores
# Calculamos el total de capacidad sumando todas las comisiones de todas las materias
total_capacity = sum(c.max_students for s in subjects for c in s.commissions)
# Calculamos el total de comisiones
total_commissions = sum(len(s.commissions) for s in subjects)
can_manage_milestones = user_can_manage_commission_milestones(current_user)
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
can_edit_commissions = current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')
return render_template('schedule/commissions.html',
subjects=subjects,
classrooms=classrooms,
teachers=teachers,
milestone_types=milestone_types,
total_capacity=total_capacity,
total_commissions=total_commissions,
can_manage_milestones=can_manage_milestones,
can_edit_commissions=can_edit_commissions)
@schedule_bp.route('/commissions/add', methods=['POST'])
@login_required
def commission_add():
"""Crear una nueva comisión de cursada"""
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
flash(_('No tienes permisos para crear comisiones.'), 'error')
return redirect(url_for('schedule.commissions_view'))
from app.models.subject import Commission
subject_id = request.form.get('subject_id', type=int)
code = request.form.get('code', '').strip()
semester = request.form.get('semester', '1C').strip()
year = request.form.get('year', datetime.now().year, type=int)
teacher_id = request.form.get('teacher_id', type=int) or None
max_students = request.form.get('max_students', 40, type=int)
schedule = request.form.get('schedule', '').strip()
shift = request.form.get('shift', 'Tarde').strip()
virtual_link = request.form.get('virtual_link', '').strip()
if not subject_id or not code:
flash(_('La asignatura y el código de comisión son obligatorios.'), 'error')
return redirect(url_for('schedule.commissions_view'))
# Verificar existencia duplicada
existing = Commission.query.filter_by(subject_id=subject_id, code=code, semester=semester, year=year).first()
if existing:
flash(_('Ya existe una comisión %(code)s para el ciclo %(sem)s %(yr)d.', code=code, sem=semester, yr=year), 'error')
return redirect(url_for('schedule.commissions_view'))
new_comm = Commission(
subject_id=subject_id,
code=code,
semester=semester,
year=year,
teacher_id=teacher_id,
max_students=max_students,
schedule=schedule,
shift=shift,
virtual_link=virtual_link or None,
active=True
)
db.session.add(new_comm)
db.session.commit()
flash(_('Comisión %(code)s creada exitosamente.', code=new_comm.get_full_code()), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:id>/edit', methods=['POST'])
@login_required
def commission_edit(id):
"""Editar datos de una comisión"""
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
flash(_('No tienes permisos para modificar comisiones.'), 'error')
return redirect(url_for('schedule.commissions_view'))
from app.models.subject import Commission
commission = Commission.query.get_or_404(id)
commission.code = request.form.get('code', commission.code).strip()
commission.semester = request.form.get('semester', commission.semester).strip()
commission.year = request.form.get('year', commission.year, type=int)
commission.teacher_id = request.form.get('teacher_id', type=int) or None
commission.max_students = request.form.get('max_students', commission.max_students, type=int)
commission.schedule = request.form.get('schedule', commission.schedule).strip()
commission.shift = request.form.get('shift', commission.shift).strip()
commission.virtual_link = request.form.get('virtual_link', '').strip() or None
commission.active = True if request.form.get('active') in ['1', 'true', 'on'] else False
db.session.commit()
flash(_('Comisión %(code)s actualizada con éxito.', code=commission.get_full_code()), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:id>/delete', methods=['POST'])
@login_required
def commission_delete(id):
"""Eliminar una comisión"""
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
flash(_('No tienes permisos para eliminar comisiones.'), 'error')
return redirect(url_for('schedule.commissions_view'))
from app.models.subject import Commission
commission = Commission.query.get_or_404(id)
full_code = commission.get_full_code()
db.session.delete(commission)
db.session.commit()
flash(_('Comisión %(code)s eliminada permanentemente.', code=full_code), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:id>/virtual-link', methods=['POST'])
@login_required
def commission_virtual_link(id):
"""Actualizar enlace del aula virtual (Zoom, Meet, Teams, Moodle) - Profesores, Admin y Bedelía"""
from app.models.subject import Commission
commission = Commission.query.get_or_404(id)
if not user_can_manage_commission_milestones(current_user, commission):
flash(_('No tienes permisos para actualizar el aula virtual de esta comisión.'), 'error')
return redirect(url_for('schedule.commissions_view'))
link = request.form.get('virtual_link', '').strip()
commission.virtual_link = link or None
db.session.commit()
flash(_('Enlace del aula virtual actualizado para la comisión %(code)s.', code=commission.get_full_code()), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:id>/milestones/add', methods=['POST'])
@login_required
def commission_milestone_add(id):
"""Agregar un hito académico (Parcial, TP, Recuperatorio) a una comisión - Profesores, Admin y Bedelía"""
from app.models.subject import Commission
from app.models.milestone import AcademicMilestone, MilestoneType
commission = Commission.query.get_or_404(id)
if not user_can_manage_commission_milestones(current_user, commission):
flash(_('No tienes permisos para agregar hitos a esta comisión.'), 'error')
return redirect(url_for('schedule.commissions_view'))
milestone_type_id = request.form.get('milestone_type_id', type=int)
title = request.form.get('title', '').strip()
date_str = request.form.get('date', '').strip()
time_str = request.form.get('time', '18:00').strip()
classroom = request.form.get('classroom', '').strip()
link = request.form.get('link', '').strip() or commission.virtual_link or ''
notes = request.form.get('notes', '').strip()
if not milestone_type_id or not date_str:
flash(_('Debes especificar el tipo de hito y la fecha.'), 'error')
return redirect(url_for('schedule.commissions_view'))
m_type = MilestoneType.query.get(milestone_type_id)
if not m_type:
flash(_('Tipo de hito inválido.'), 'error')
return redirect(url_for('schedule.commissions_view'))
try:
dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M")
except ValueError:
try:
dt = datetime.strptime(date_str, "%Y-%m-%d")
except ValueError:
flash(_('Formato de fecha inválido.'), 'error')
return redirect(url_for('schedule.commissions_view'))
milestone = AcademicMilestone(
title=title or m_type.name,
materia_codigo=commission.subject.code if commission.subject else 'ASIG',
subject_id=commission.subject_id,
commission_id=commission.id,
milestone_type_id=m_type.id,
date=dt,
classroom=classroom or 'Campus Virtual',
link=link or None,
notes=notes or None,
is_active=True
)
db.session.add(milestone)
db.session.commit()
flash(_('Hito "%(title)s" registrado correctamente en %(comm)s.', title=milestone.title, comm=commission.get_full_code()), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:commission_id>/milestones/<int:milestone_id>/delete', methods=['POST'])
@login_required
def commission_milestone_delete(commission_id, milestone_id):
"""Eliminar un hito académico de una comisión"""
from app.models.subject import Commission
from app.models.milestone import AcademicMilestone
commission = Commission.query.get_or_404(commission_id)
if not user_can_manage_commission_milestones(current_user, commission):
flash(_('No tienes permisos para eliminar hitos de esta comisión.'), 'error')
return redirect(url_for('schedule.commissions_view'))
milestone = AcademicMilestone.query.filter_by(id=milestone_id, commission_id=commission_id).first_or_404()
title = milestone.title
db.session.delete(milestone)
db.session.commit()
flash(_('Hito "%(title)s" eliminado de la comisión.', title=title), 'success')
return redirect(url_for('schedule.commissions_view'))
total_commissions=total_commissions)
@schedule_bp.route('/add', methods=['GET', 'POST'])
@login_required
@@ -493,23 +177,23 @@ def add_reservation():
"""Add new reservation"""
from app.models.classroom import Classroom
from app.models.subject import Commission
from app.models.building import Building
form = ReservationForm()
# Get active buildings and classrooms
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
# Get active classrooms for the dropdown
classrooms = Classroom.query.filter_by(is_active=True).all()
# Get active commissions for the dropdown
commissions = Commission.query.filter_by(active=True).all()
# Populate form choices
form.classroom_id.choices = [(c.id, f'{c.building_name} - {c.code} (Cap: {c.capacity_display})') for c in classrooms]
form.classroom_id.choices = [(c.id, f'{c.code_display} - {c.name} (Cap: {c.capacity})') for c in classrooms]
form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.subject.name if c.subject else "No Subject"} (Students: {c.max_students})') for c in commissions]
if request.method == 'POST':
if form.validate_on_submit():
try:
# Check for existing reservations at the same time
# Check for existing reservations at the same time
from app.models.reservation import ReservationStatus
existing_reservation = Reservation.query.filter_by(
classroom_id=form.classroom_id.data,
@@ -521,7 +205,7 @@ def add_reservation():
if existing_reservation:
flash('This classroom is already booked for the selected time slot.', 'danger')
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions, buildings=buildings)
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions)
# Create new reservation
reservation = Reservation(
@@ -532,7 +216,6 @@ def add_reservation():
end_time=form.end_time.data,
purpose=form.purpose.data,
notes=form.notes.data,
virtual_link=form.virtual_link.data.strip() if form.virtual_link.data else None,
status=ReservationStatus.PENDING.value,
expected_attendees=form.expected_attendees.data
)
@@ -545,53 +228,10 @@ def add_reservation():
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error creating reservation: {str(e)}")
flash(f'Error creating reservation: {str(e)}', 'danger')
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions, buildings=buildings)
@schedule_bp.route('/reservation/add', methods=['POST'])
@login_required
def quick_reservation_add():
"""Endpoint AJAX para crear reserva rápida desde el modal de comisiones"""
try:
commission_id = request.form.get('commission_id', type=int)
classroom_id = request.form.get('classroom_id', type=int)
purpose = request.form.get('purpose', '').strip() or 'Clase de cursada'
start_str = request.form.get('start_time', '').strip()
end_str = request.form.get('end_time', '').strip()
virtual_link = request.form.get('virtual_link', '').strip() or None
if not commission_id or not classroom_id or not start_str or not end_str:
return jsonify({'error': 'Faltan campos obligatorios'}), 400
start_time = datetime.strptime(start_str, '%Y-%m-%d %H:%M')
end_time = datetime.strptime(end_str, '%Y-%m-%d %H:%M')
from app.models.subject import Commission
from app.models.classroom import Classroom
comm = Commission.query.get_or_404(commission_id)
if not virtual_link and comm.virtual_link:
virtual_link = comm.virtual_link
new_res = Reservation(
classroom_id=classroom_id,
commission_id=commission_id,
user_id=comm.teacher_id or current_user.id,
start_time=start_time,
end_time=end_time,
purpose=purpose,
expected_attendees=comm.max_students or 30,
shift=comm.shift or 'Tarde',
virtual_link=virtual_link,
status=ReservationStatus.CONFIRMED.value if current_user.is_admin() else ReservationStatus.PENDING.value
)
db.session.add(new_res)
db.session.commit()
return jsonify({'success': True, 'id': new_res.id})
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions)
@schedule_bp.route('/list')
@login_required
@@ -604,7 +244,6 @@ def list_reservations():
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
floor_filter = request.args.get('floor', '').strip()
shift_filter = request.args.get('shift', '').strip()
classroom_id = request.args.get('classroom_id', type=int)
search = request.args.get('search', '').strip()
query = Reservation.query.options(
@@ -613,9 +252,6 @@ def list_reservations():
joinedload(Reservation.user)
).join(Reservation.classroom).outerjoin(Reservation.commission).outerjoin(Commission.subject)
if classroom_id:
query = query.filter(Reservation.classroom_id == classroom_id)
if status_filter and status_filter != 'all':
query = query.filter(Reservation.status == status_filter.upper())
@@ -639,12 +275,8 @@ def list_reservations():
~Classroom.building.ilike('%Virtual%')
)
if floor_filter:
flr = str(floor_filter).strip()
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
else:
query = query.filter(Classroom.floor == flr)
if floor_filter and floor_filter.isdigit():
query = query.filter(Classroom.floor == int(floor_filter))
if shift_filter:
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
@@ -654,10 +286,8 @@ def list_reservations():
or_(
Subject.name.ilike(f'%{search}%'),
Subject.code.ilike(f'%{search}%'),
Commission.code.ilike(f'%{search}%'),
Classroom.code.ilike(f'%{search}%'),
Reservation.purpose.ilike(f'%{search}%'),
Reservation.shift.ilike(f'%{search}%')
Reservation.purpose.ilike(f'%{search}%')
)
)
@@ -667,7 +297,6 @@ def list_reservations():
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
available_floors = [f[0] for f in floors]
all_classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
return render_template(
'schedule/list.html',
@@ -678,8 +307,6 @@ def list_reservations():
hide_virtual=hide_virtual,
floor_filter=floor_filter,
shift_filter=shift_filter,
classroom_id=classroom_id,
all_classrooms=all_classrooms,
search=search,
available_floors=available_floors
)
@@ -816,6 +443,7 @@ def calendar_data():
search = request.args.get('search', '').strip()
if start and end:
from datetime import datetime
# Clean up ISO timezone strings for PostgreSQL naive timestamp comparison
start_clean = start.replace('Z', '+00:00')
end_clean = end.replace('Z', '+00:00')
@@ -844,16 +472,6 @@ def calendar_data():
if career_id and career_id.isdigit():
query = query.filter(Subject.career_id == int(career_id))
building = request.args.get('building', '').strip()
if building:
from app.models.building import Building
query = query.filter(
or_(
Classroom.building.ilike(f'%{building}%'),
Classroom.building_entity.has(Building.name.ilike(f'%{building}%'))
)
)
if shift:
query = query.filter(Reservation.shift.ilike(f'%{shift}%'))
@@ -863,12 +481,8 @@ def calendar_data():
~Classroom.building.ilike('%Virtual%')
)
if floor:
flr = str(floor).strip()
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
else:
query = query.filter(Classroom.floor == flr)
if floor and floor.isdigit():
query = query.filter(Classroom.floor == int(floor))
if search:
query = query.filter(
@@ -888,9 +502,8 @@ def calendar_data():
subject_code = subject.code if subject else (reservation.commission.code if reservation.commission else '')
subject_name = subject.name if subject else (reservation.purpose or 'Clase')
classroom_name = reservation.classroom.code if reservation.classroom else 'Sin Aula'
classroom_floor = reservation.classroom.floor_display if reservation.classroom else None
classroom_building = reservation.classroom.building_name if reservation.classroom else ''
is_virtual = bool(reservation.classroom and reservation.classroom.is_virtual)
classroom_floor = reservation.classroom.floor if reservation.classroom else None
is_virtual = bool(reservation.classroom and ('VIRTUAL' in (reservation.classroom.code or '').upper() or 'VIRTUAL' in (reservation.classroom.building or '').upper()))
career_name = subject.career_obj.name if (subject and subject.career_obj) else (subject.department if subject else '')
status_val = str(reservation.status.value if hasattr(reservation.status, 'value') else reservation.status)
@@ -905,20 +518,16 @@ def calendar_data():
'subject_code': subject_code,
'subject_name': subject_name,
'classroom': classroom_name,
'building': classroom_building,
'building': reservation.classroom.building if reservation.classroom else '',
'floor': classroom_floor,
'is_virtual': is_virtual,
'location_display': reservation.classroom.location_display if reservation.classroom else '',
'career': career_name,
'commission': reservation.commission.code if reservation.commission else '',
'shift': reservation.shift or '',
'status': status_val,
'purpose': reservation.purpose or '',
'attendees': reservation.expected_attendees or 0,
'notes': reservation.notes or '',
'teacher': reservation.user.name if reservation.user else '',
'virtual_link': reservation.effective_virtual_link,
'cancel_url': url_for('schedule.cancel_reservation', id=reservation.id)
'notes': reservation.notes or ''
}
})
+3
View File
@@ -0,0 +1,3 @@
from .sheets_importer import GoogleSheetsImporter
__all__ = ['GoogleSheetsImporter']
@@ -2,7 +2,6 @@ import urllib.request
import csv
import io
import re
import os
from datetime import datetime, date, time, timedelta
from app import db
from app.models.classroom import Classroom
@@ -13,34 +12,6 @@ from app.models.user import User
BASE_CSV_URL = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vSc_T_BQjbn3uPelioCgx52UM5Py-qNhJN0TYPd1kmsN5jdb3Q8rAaIvNMF_2ZTzQt6bH--yIWQKrKR/pub?single=true&output=csv'
def normalize_sheets_url(url):
"""
Normaliza enlaces de Google Sheets (compartidos, de edición o publicados)
para obtener la URL base que acepta parámetro gid=<gid>&output=csv o format=csv.
"""
if not url:
return BASE_CSV_URL
url = url.strip()
# 1. Enlace publicado web (/d/e/2PACX-.../pub...)
if '/d/e/' in url:
base_match = re.match(r'^(https://docs\.google\.com/spreadsheets/d/e/[^/?#]+)/pub', url)
if base_match:
return f"{base_match.group(1)}/pub?single=true&output=csv"
clean = url.split('&gid=')[0].split('?gid=')[0]
sep = '&' if '?' in clean else '?'
if 'output=csv' not in clean:
clean = f"{clean}{sep}single=true&output=csv"
return clean
# 2. Enlace de edición o visualización (/d/<SPREADSHEET_ID>/edit...)
sheet_id_match = re.search(r'/spreadsheets/d/([a-zA-Z0-9-_]+)', url)
if sheet_id_match:
sheet_id = sheet_id_match.group(1)
return f"https://docs.google.com/spreadsheets/d/{sheet_id}/export?format=csv"
return url
SHEETS_CONFIG = [
{ 'name': 'Lunes', 'gid': '1915752353', 'day_idx': 0 },
{ 'name': 'Martes', 'gid': '466778263', 'day_idx': 1 },
@@ -59,30 +30,16 @@ class GoogleSheetsImporter:
"""
def __init__(self, base_url=None):
if base_url:
self.base_url = normalize_sheets_url(base_url)
else:
configured_url = None
try:
from app.models.setting import SystemSetting
configured_url = SystemSetting.get_value('google_sheets_url')
except Exception:
pass
if not configured_url:
configured_url = os.getenv('GOOGLE_SHEETS_URL')
self.base_url = normalize_sheets_url(configured_url) if configured_url else BASE_CSV_URL
self.base_url = base_url or BASE_CSV_URL
def fetch_sheet_csv(self, gid):
"""Fetch CSV string for a specific sheet gid"""
separator = '&' if '?' in self.base_url else '?'
url = f"{self.base_url}{separator}gid={gid}"
if not (url.startswith('https://') or url.startswith('http://')):
raise ValueError(f"URL scheme not permitted: {url}")
url = f"{self.base_url}&gid={gid}"
req = urllib.request.Request(
url,
headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) EduSpace/2.0'}
)
with urllib.request.urlopen(req, timeout=25) as resp: # nosec B310
with urllib.request.urlopen(req, timeout=15) as resp:
return resp.read().decode('utf-8', errors='replace')
def parse_sheet_rows(self, csv_content, sheet_name):
@@ -409,36 +366,16 @@ class GoogleSheetsImporter:
# 5. Generate Calendar Reservations
t_start, t_end = self.parse_time_range(horario_raw)
if classroom and t_start and t_end and day_idx is not None:
is_room_virtual = bool(classroom.is_virtual or 'VIRTUAL' in (classroom.code or '').upper())
# Generar enlace de videollamada para clases virtuales si no tienen uno asignado
v_link = None
if is_room_virtual:
clean_subj = re.sub(r'[^a-zA-Z0-9]', '', subject.code or 'cls').lower()
clean_comm = re.sub(r'[^a-zA-Z0-9]', '', commission.code or 'c1').lower()
v_link = f"https://meet.google.com/edu-{clean_subj}-{clean_comm}"
if not commission.virtual_link:
commission.virtual_link = v_link
for w in range(weeks_to_generate):
class_date = base_monday + timedelta(days=day_idx, weeks=w)
dt_start = datetime.combine(class_date, t_start)
dt_end = datetime.combine(class_date, t_end)
# En aulas virtuales la capacidad es infinita y pueden cursar múltiples comisiones en paralelo
if is_room_virtual:
existing_res = Reservation.query.filter_by(
classroom_id=classroom.id,
commission_id=commission.id,
start_time=dt_start,
end_time=dt_end
).first()
else:
existing_res = Reservation.query.filter_by(
classroom_id=classroom.id,
start_time=dt_start,
end_time=dt_end
).first()
existing_res = Reservation.query.filter_by(
classroom_id=classroom.id,
start_time=dt_start,
end_time=dt_end
).first()
if not existing_res:
res = Reservation(
@@ -449,9 +386,8 @@ class GoogleSheetsImporter:
end_time=dt_end,
purpose=f"{subject.code} - {subject.name}",
status='CONFIRMED',
expected_attendees=min(classroom.capacity, 30) if not is_room_virtual else 35,
expected_attendees=min(classroom.capacity, 30),
shift=turno,
virtual_link=v_link,
notes=f"Clase semanal {dia} ({turno}) - Carrera: {career.name}"
)
db.session.add(res)
@@ -459,8 +395,6 @@ class GoogleSheetsImporter:
else:
existing_res.shift = turno
existing_res.notes = f"Clase semanal {dia} ({turno}) - Carrera: {career.name}"
if is_room_virtual and not existing_res.virtual_link:
existing_res.virtual_link = v_link
if len(stats['sample_records']) < 10:
stats['sample_records'].append({
@@ -174,131 +174,4 @@
background-color: #FFFFFF !important;
color: #000000 !important;
}
}
/* View Switcher Controls */
.view-switcher-group {
flex-direction: row !important;
display: inline-flex !important;
}
.view-switcher-group .btn {
margin-bottom: 0 !important;
font-weight: 600;
font-size: 0.82rem;
padding: 0.35rem 0.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease-in-out;
}
.view-switcher-group .btn.active {
box-shadow: 0 2px 6px rgba(var(--brand-primary-rgb), 0.25);
}
.reservation-card {
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
border-radius: 0.75rem;
}
.reservation-card:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.08) !important;
border-color: var(--bs-primary) !important;
}
/* Role Badges & Pills */
.badge-admin {
background: linear-gradient(135deg, #7C3AED 0%, #B43E8E 100%) !important;
color: #FFFFFF !important;
border: 1px solid rgba(255, 255, 255, 0.25);
}
.badge-bedelia {
background: linear-gradient(135deg, #D97706 0%, #B45309 100%) !important;
color: #FFFFFF !important;
border: 1px solid rgba(255, 255, 255, 0.25);
}
.badge-docente {
background: linear-gradient(135deg, #0284C7 0%, #0369A1 100%) !important;
color: #FFFFFF !important;
border: 1px solid rgba(255, 255, 255, 0.25);
}
.badge-alumno {
background: linear-gradient(135deg, #059669 0%, #047857 100%) !important;
color: #FFFFFF !important;
border: 1px solid rgba(255, 255, 255, 0.25);
}
.role-pill {
font-size: 0.72rem;
font-weight: 600;
padding: 0.22rem 0.55rem;
border-radius: 9999px;
letter-spacing: 0.3px;
display: inline-flex;
align-items: center;
gap: 0.35rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.12);
}
/* Role Dashboard Hero & Cards */
.dashboard-role-banner {
border-radius: 1rem;
position: relative;
overflow: hidden;
background: linear-gradient(135deg, rgba(180, 62, 142, 0.08) 0%, rgba(104, 44, 173, 0.12) 100%);
border: 1px solid rgba(180, 62, 142, 0.18);
padding: 1.5rem 1.75rem;
}
[data-bs-theme="dark"] .dashboard-role-banner {
background: linear-gradient(135deg, rgba(180, 62, 142, 0.15) 0%, rgba(42, 27, 61, 0.5) 100%);
border-color: rgba(180, 62, 142, 0.25);
}
.role-view-selector .btn {
font-size: 0.78rem;
font-weight: 600;
padding: 0.3rem 0.75rem;
border-radius: 9999px;
transition: all 0.2s ease;
}
.role-view-selector .btn.active {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
}
.action-menu-card {
border-radius: 0.85rem;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
border: 1px solid rgba(0, 0, 0, 0.08);
text-decoration: none !important;
}
.action-menu-card:hover {
transform: translateY(-3px);
box-shadow: 0 0.5rem 1.2rem rgba(0, 0, 0, 0.08) !important;
border-color: var(--brand-primary) !important;
}
[data-bs-theme="dark"] .action-menu-card {
border-color: rgba(255, 255, 255, 0.08);
}
[data-bs-theme="dark"] .action-menu-card:hover {
border-color: var(--brand-primary-tint) !important;
box-shadow: 0 0.5rem 1.2rem rgba(0, 0, 0, 0.4) !important;
}
.quick-icon-circle {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
}
@@ -142,20 +142,6 @@ body {
transition: color 0.15s ease, transform 0.15s ease;
border-radius: 8px;
padding: 0.5rem 0.85rem !important;
white-space: nowrap;
}
@media (max-width: 1280px) and (min-width: 992px) {
.navbar-app {
padding: 0.5rem 0.75rem !important;
}
.navbar-app .nav-link {
padding: 0.4rem 0.5rem !important;
font-size: 0.85rem;
}
.navbar-app .navbar-brand span {
font-size: 0.95rem;
}
}
.navbar-app .nav-link:hover,

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

+242
View File
@@ -0,0 +1,242 @@
{% extends "base.html" %}
{% block title %}{% trans %}Import from Google Sheets{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-file-earmark-spreadsheet text-success me-2"></i>{% trans %}Google Sheets Academic Sync{% endtrans %}
</h1>
<p class="text-muted small mb-0">{% trans %}Extract and synchronize classrooms, careers, subjects, and weekly schedules directly from the official Google Sheet.{% endtrans %}</p>
</div>
<div>
<a href="{{ url_for('main.dashboard') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% trans %}Dashboard{% endtrans %}
</a>
</div>
</div>
<!-- Metrics Cards -->
<div class="row g-3 mb-4">
<div class="col-sm-6 col-xl-2">
<div class="card border-0 shadow-sm text-center p-3 h-100">
<div class="text-primary mb-1"><i class="bi bi-mortarboard fs-3"></i></div>
<div class="h4 mb-0 fw-bold">{{ metrics.total_careers }}</div>
<small class="text-muted">{% trans %}Careers{% endtrans %}</small>
</div>
</div>
<div class="col-sm-6 col-xl-2">
<div class="card border-0 shadow-sm text-center p-3 h-100">
<div class="text-info mb-1"><i class="bi bi-book fs-3"></i></div>
<div class="h4 mb-0 fw-bold">{{ metrics.total_subjects }}</div>
<small class="text-muted">{% trans %}Subjects{% endtrans %}</small>
</div>
</div>
<div class="col-sm-6 col-xl-2">
<div class="card border-0 shadow-sm text-center p-3 h-100">
<div class="text-success mb-1"><i class="bi bi-door-open fs-3"></i></div>
<div class="h4 mb-0 fw-bold">{{ metrics.total_classrooms }}</div>
<small class="text-muted">{% trans %}Classrooms{% endtrans %}</small>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm text-center p-3 h-100">
<div class="text-warning mb-1"><i class="bi bi-people fs-3"></i></div>
<div class="h4 mb-0 fw-bold">{{ metrics.total_commissions }}</div>
<small class="text-muted">{% trans %}Commissions{% endtrans %}</small>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm text-center p-3 h-100">
<div class="text-danger mb-1"><i class="bi bi-calendar-check fs-3"></i></div>
<div class="h4 mb-0 fw-bold">{{ metrics.total_reservations }}</div>
<small class="text-muted">{% trans %}Calendar Classes{% endtrans %}</small>
</div>
</div>
</div>
<!-- Sync Action Banner -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-4">
<div class="row align-items-center">
<div class="col-lg-8 mb-3 mb-lg-0">
<div class="d-flex align-items-center mb-2">
<span class="badge bg-success-subtle text-success border border-success-subtle px-3 py-1 me-2 fw-semibold">
<i class="bi bi-cloud-check me-1"></i>Google Sheets Live Integration
</span>
<span class="badge bg-light text-muted border">UniCABA 2026</span>
</div>
<h4 class="h5 mb-2 fw-bold">{% trans %}Official Schedule Board (Cartelera de Aulas){% endtrans %}</h4>
<p class="text-muted small mb-2">
{% trans %}Reads published CSV sheets for all 6 days of the week, automatically creating or updating academic careers, classrooms, subject codes, commissions, and weekly calendar entries without duplicating records.{% endtrans %}
</p>
<div class="d-flex flex-wrap gap-1">
{% for s in metrics.sheets %}
<span class="badge bg-body-tertiary text-secondary border small">
<i class="bi bi-calendar-event me-1 text-primary"></i>{{ s.name }}
</span>
{% endfor %}
</div>
</div>
<div class="col-lg-4 text-lg-end">
<form method="POST" id="syncForm" onsubmit="startSync()">
<button type="submit" id="syncBtn" class="btn btn-success btn-lg px-4 py-2 shadow fw-semibold">
<i class="bi bi-arrow-repeat me-2"></i>{% trans %}Sync Data Now{% endtrans %}
</button>
</form>
<div id="syncSpinner" class="d-none mt-2 text-muted small">
<div class="spinner-border spinner-border-sm text-success me-1" role="status"></div>
{% trans %}Connecting to Google Sheets and processing records...{% endtrans %}
</div>
</div>
</div>
</div>
</div>
<!-- Sync Results (if just completed) -->
{% if stats %}
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-success bg-opacity-10 text-success py-3">
<h5 class="card-title h6 mb-0 fw-bold">
<i class="bi bi-check-circle-fill me-2"></i>{% trans %}Synchronization Report{% endtrans %}
</h5>
</div>
<div class="card-body">
<div class="row g-3 text-center mb-3">
<div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary">
<div class="fw-bold fs-5">{{ stats.total_rows_processed }}</div>
<small class="text-muted">{% trans %}Rows Processed{% endtrans %}</small>
</div>
</div>
<div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary">
<div class="fw-bold fs-5 text-primary">+{{ stats.careers_created }}</div>
<small class="text-muted">{% trans %}Careers Created{% endtrans %}</small>
</div>
</div>
<div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary">
<div class="fw-bold fs-5 text-success">+{{ stats.classrooms_created }}</div>
<small class="text-muted">{% trans %}Classrooms Created{% endtrans %}</small>
</div>
</div>
<div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary">
<div class="fw-bold fs-5 text-info">+{{ stats.subjects_created }}</div>
<small class="text-muted">{% trans %}Subjects Created{% endtrans %}</small>
</div>
</div>
<div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary">
<div class="fw-bold fs-5 text-warning">+{{ stats.commissions_created }}</div>
<small class="text-muted">{% trans %}Commissions Created{% endtrans %}</small>
</div>
</div>
<div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary">
<div class="fw-bold fs-5 text-danger">+{{ stats.reservations_created }}</div>
<small class="text-muted">{% trans %}Reservations Created{% endtrans %}</small>
</div>
</div>
</div>
{% if stats.errors %}
<div class="alert alert-warning mb-0 small">
<h6 class="alert-heading fw-bold mb-1">{% trans %}Warnings / Errors detected:{% endtrans %}</h6>
<ul class="mb-0 ps-3">
{% for err in stats.errors %}
<li>{{ err }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Database Preview Table -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary d-flex justify-content-between align-items-center py-3">
<h5 class="card-title h6 mb-0">
<i class="bi bi-table text-primary me-2"></i>{% trans %}Recently Synchronized Subjects & Comissions{% endtrans %}
</h5>
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-calendar3 me-1"></i>{% trans %}View Calendar{% endtrans %}
</a>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-body-tertiary">
<tr>
<th class="ps-4">{% trans %}Code{% endtrans %}</th>
<th>{% trans %}Subject Name{% endtrans %}</th>
<th>{% trans %}Career{% endtrans %}</th>
<th>{% trans %}Commissions{% endtrans %}</th>
<th>{% trans %}Status{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for s in recent_subjects %}
<tr>
<td class="ps-4">
<span class="badge bg-secondary-subtle text-secondary font-monospace border">
{{ s.code }}
</span>
</td>
<td>
<div class="fw-semibold">{{ s.name }}</div>
</td>
<td>
<span class="badge badge-brand small">
{{ s.career_obj.name if s.career_obj else (s.department or _('General')) }}
</span>
</td>
<td>
<span class="small text-muted">
{{ s.commissions|length }} {% trans %}commission(s){% endtrans %}
</span>
</td>
<td>
{% if s.is_active %}
<span class="badge bg-success-subtle text-success border border-success-subtle small">
<i class="bi bi-check-circle me-1"></i>{% trans %}Active{% endtrans %}
</span>
{% else %}
<span class="badge bg-secondary small">{% trans %}Inactive{% endtrans %}</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="text-center py-5 text-muted">
<i class="bi bi-cloud-arrow-down fs-1 d-block mb-2 text-secondary"></i>
{% trans %}No subjects in database yet. Click "Sync Data Now" to import.{% endtrans %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function startSync() {
const btn = document.getElementById('syncBtn');
const spinner = document.getElementById('syncSpinner');
if (btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status"></span>Sincronizando...';
}
if (spinner) {
spinner.classList.remove('d-none');
}
}
</script>
{% endblock %}
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}{% if role %}Editar Rol: {{ role.name }}{% else %}Nuevo Rol{% endif %} - Edu-Space Admin{% endblock %}
{% block title %}{% if role %}{% trans %}Editar Rol{% endtrans %}: {{ role.name }}{% else %}{% trans %}Nuevo Rol{% endtrans %}{% endif %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
@@ -9,58 +9,55 @@
<div>
<h1 class="h3 mb-1">
<i class="bi bi-shield-lock text-primary me-2"></i>
{% if role %}Editar Rol y Permisos de Acceso{% else %}Crear Rol y Permisos de Acceso{% endif %}
{% if role %}{% trans %}Editar Rol y Permisos de Acceso{% endtrans %}{% else %}{% trans %}Crear Rol y Permisos de Acceso{% endtrans %}{% endif %}
</h1>
<p class="text-muted small mb-0">Configure permisos granulares por módulo con la matriz de acceso estilo FortiGate.</p>
<p class="text-muted small mb-0">{% trans %}Configure permisos granulares por módulo con la matriz de acceso estilo FortiGate.{% endtrans %}</p>
</div>
<div>
<a href="/admin/roles_list" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Volver a Roles
<a href="{{ url_for('admin.roles_list') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% trans %}Volver a Roles{% endtrans %}
</a>
</div>
</div>
<form method="POST">
{% if role %}
<input type="hidden" name="id" value="{{ role.id }}">
{% endif %}
<div class="row">
<!-- Left Column: Role Details -->
<div class="col-lg-4 mb-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-body-tertiary py-3">
<h5 class="card-title h6 mb-0">
<i class="bi bi-person-badge text-primary me-2"></i>Información del Rol
<i class="bi bi-person-badge text-primary me-2"></i>{% trans %}Información del Rol{% endtrans %}
</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label for="name" class="form-label fw-semibold">Nombre del Rol <span class="text-danger">*</span></label>
<label for="name" class="form-label fw-semibold">{% trans %}Nombre del Rol{% endtrans %} <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" name="name"
value="{{ role.name if role else '' }}" required
placeholder="ej. Docente, Operador, Bedelía"
{% if role and role.is_system %}readonly title="El nombre del rol del sistema no se puede modificar"{% endif %}>
placeholder="{% trans %}ej. Docente, Operador, Bedelía{% endtrans %}"
{% if role and role.is_system %}readonly title="{% trans %}El nombre del rol del sistema no se puede modificar{% endtrans %}"{% endif %}>
{% if role and role.is_system %}
<small class="text-muted d-block mt-1">
<i class="bi bi-info-circle me-1"></i>Rol del sistema protegido.
<i class="bi bi-info-circle me-1"></i>{% trans %}Rol del sistema protegido.{% endtrans %}
</small>
{% endif %}
</div>
<div class="mb-3">
<label for="description" class="form-label fw-semibold">Descripción</label>
<label for="description" class="form-label fw-semibold">{% trans %}Descripción{% endtrans %}</label>
<textarea class="form-control" id="description" name="description" rows="3"
placeholder="Describa las responsabilidades o alcance de este rol...">{{ role.description if role else '' }}</textarea>
placeholder="{% trans %}Describa las responsabilidades o alcance de este rol...{% endtrans %}">{{ role.description if role else '' }}</textarea>
</div>
<hr class="my-4">
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary py-2 fw-semibold">
<i class="bi bi-check-lg me-1"></i>Guardar Rol y Permisos
<i class="bi bi-check-lg me-1"></i>{% trans %}Guardar Rol y Permisos{% endtrans %}
</button>
<a href="/admin/roles_list" class="btn btn-outline-secondary">
Cancelar
<a href="{{ url_for('admin.roles_list') }}" class="btn btn-outline-secondary">
{% trans %}Cancelar{% endtrans %}
</a>
</div>
</div>
@@ -73,28 +70,28 @@
<!-- Matrix Header -->
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-bold text-primary">
<i class="bi bi-grid-3x3-gap-fill me-2"></i>Permisos de Acceso
<i class="bi bi-grid-3x3-gap-fill me-2"></i>{% trans %}Permisos de Acceso{% endtrans %}
</span>
<!-- Set All Action Dropdown (FortiGate style) -->
<div class="dropdown">
<button class="btn btn-fg-set-all dropdown-toggle" type="button" id="setAllDropdown" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-lightning-charge me-1"></i>Establecer Todos
<i class="bi bi-lightning-charge me-1"></i>{% trans %}Establecer Todos{% endtrans %}
</button>
<ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="setAllDropdown">
<li>
<button type="button" class="dropdown-item py-2" onclick="setAllPermissions('none')">
<i class="bi bi-slash-circle text-danger me-2"></i>Establecer Todos en Ninguno
<i class="bi bi-slash-circle text-danger me-2"></i>{% trans %}Establecer Todos en Ninguno{% endtrans %}
</button>
</li>
<li>
<button type="button" class="dropdown-item py-2" onclick="setAllPermissions('read')">
<i class="bi bi-eye text-info me-2"></i>Establecer Todos en Solo Lectura
<i class="bi bi-eye text-info me-2"></i>{% trans %}Establecer Todos en Solo Lectura{% endtrans %}
</button>
</li>
<li>
<button type="button" class="dropdown-item py-2" onclick="setAllPermissions('read_write')">
<i class="bi bi-pencil text-success me-2"></i>Establecer Todos en Lectura y Escritura
<i class="bi bi-pencil text-success me-2"></i>{% trans %}Establecer Todos en Lectura y Escritura{% endtrans %}
</button>
</li>
</ul>
@@ -106,13 +103,13 @@
<table class="fg-table">
<thead>
<tr>
<th style="width: 48%;">Módulo del Sistema</th>
<th style="width: 52%;" class="text-end pe-4">Nivel de Permiso</th>
<th style="width: 48%;">{% trans %}Módulo del Sistema{% endtrans %}</th>
<th style="width: 52%;" class="text-end pe-4">{% trans %}Nivel de Permiso{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for mod in modules %}
{% set cur_level = (current_perms[mod.id] if current_perms else (role.permissions[mod.id] if (role and role.permissions) else 'none')) or 'none' %}
{% set cur_level = current_perms.get(mod.id, 'none') %}
<tr data-module-row="{{ mod.id }}">
<td>
<div class="d-flex align-items-center">
@@ -136,21 +133,21 @@
data-val="none"
data-target="input_perm_{{ mod.id }}"
onclick="selectPerm(this, 'input_perm_{{ mod.id }}', 'none')">
<i class="bi bi-slash-circle"></i> Ninguno
<i class="bi bi-slash-circle"></i> {% trans %}Ninguno{% endtrans %}
</button>
<button type="button"
class="fg-seg-btn {% if cur_level == 'read' %}active{% endif %}"
data-val="read"
data-target="input_perm_{{ mod.id }}"
onclick="selectPerm(this, 'input_perm_{{ mod.id }}', 'read')">
<i class="bi bi-eye"></i> Lectura
<i class="bi bi-eye"></i> {% trans %}Lectura{% endtrans %}
</button>
<button type="button"
class="fg-seg-btn {% if cur_level == 'read_write' %}active{% endif %}"
data-val="read_write"
data-target="input_perm_{{ mod.id }}"
onclick="selectPerm(this, 'input_perm_{{ mod.id }}', 'read_write')">
<i class="bi bi-pencil"></i> Lectura/Escritura
<i class="bi bi-pencil"></i> {% trans %}Lectura/Escritura{% endtrans %}
</button>
</div>
</td>
+124
View File
@@ -0,0 +1,124 @@
{% extends "base.html" %}
{% block title %}{% trans %}Roles y Grupos de Acceso{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-person-badge text-primary me-2"></i>{% trans %}Roles y Grupos de Acceso{% endtrans %}
</h1>
<p class="text-muted small mb-0">{% trans %}Administración de perfiles de usuario y matriz granular de permisos por módulo del sistema.{% endtrans %}</p>
</div>
<div>
<a href="{{ url_for('admin.role_add') }}" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nuevo Rol{% endtrans %}
</a>
</div>
</div>
<!-- Roles Grid / Table -->
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-body-tertiary">
<tr>
<th class="ps-4">{% trans %}Nombre del Rol{% endtrans %}</th>
<th>{% trans %}Descripción{% endtrans %}</th>
<th>{% trans %}Integrantes{% endtrans %}</th>
<th>{% trans %}Resumen de Permisos{% endtrans %}</th>
<th class="text-end pe-4">{% trans %}Acciones{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for role in roles %}
<tr>
<td class="ps-4">
<div class="d-flex align-items-center">
<div class="badge-brand rounded-circle p-2 me-3 d-inline-flex align-items-center justify-content-center" style="width: 38px; height: 38px;">
<i class="bi bi-shield-check fs-6 text-white"></i>
</div>
<div>
<div class="fw-bold text-primary">{{ role.name }}</div>
{% if role.is_system %}
<span class="badge bg-secondary-subtle text-secondary border small">{% trans %}Protegido del Sistema{% endtrans %}</span>
{% endif %}
</div>
</div>
</td>
<td>
<span class="text-muted small">{{ role.description or _('Sin descripción provista.') }}</span>
</td>
<td>
<span class="badge bg-primary-subtle text-primary border px-2 py-1">
<i class="bi bi-people me-1"></i>{{ role.users|length }} {% trans %}usuarios{% endtrans %}
</span>
</td>
<td>
<div class="d-flex flex-wrap gap-1">
{% for mod in modules %}
{% set p_level = role.get_permission(mod.id) %}
{% if p_level == 'read_write' %}
<span class="badge bg-success-subtle text-success border small" title="{{ mod.name }}: {% trans %}Lectura y Escritura{% endtrans %}">
<i class="bi {{ mod.icon }} me-1"></i>{{ mod.name }}: RW
</span>
{% elif p_level == 'read' %}
<span class="badge bg-info-subtle text-info border small" title="{{ mod.name }}: {% trans %}Solo Lectura{% endtrans %}">
<i class="bi {{ mod.icon }} me-1"></i>{{ mod.name }}: R
</span>
{% else %}
<span class="badge bg-light text-muted border small opacity-75" title="{{ mod.name }}: {% trans %}Sin Acceso{% endtrans %}">
<i class="bi {{ mod.icon }} me-1"></i>{{ mod.name }}: -
</span>
{% endif %}
{% endfor %}
</div>
</td>
<td class="text-end pe-4">
<div class="btn-group btn-group-sm">
<a href="{{ url_for('admin.role_edit', id=role.id) }}" class="btn btn-outline-primary" title="{% trans %}Configurar Permisos{% endtrans %}">
<i class="bi bi-pencil-square me-1"></i>{% trans %}Permisos{% endtrans %}
</a>
{% if not role.is_system %}
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteModal{{ role.id }}" title="{% trans %}Eliminar Rol{% endtrans %}" {% if role.users|length > 0 %}disabled{% endif %}>
<i class="bi bi-trash"></i>
</button>
{% endif %}
</div>
{% if not role.is_system %}
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal{{ role.id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header border-0">
<h5 class="modal-title text-danger">
<i class="bi bi-exclamation-triangle-fill me-2"></i>{% trans %}Confirmar Eliminación de Rol{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-start">
<p>{% trans %}¿Está seguro de que desea eliminar el rol{% endtrans %} <strong>{{ role.name }}</strong>?</p>
<p class="text-muted small mb-0">{% trans %}Esta acción no se puede deshacer.{% endtrans %}</p>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<form action="{{ url_for('admin.role_delete', id=role.id) }}" method="POST" class="d-inline">
<button type="submit" class="btn btn-danger">{% trans %}Eliminar Rol{% endtrans %}</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+107
View File
@@ -0,0 +1,107 @@
{% extends "base.html" %}
{% block title %}{% if user %}{% trans %}Edit User{% endtrans %}: {{ user.name }}{% else %}{% trans %}New User{% endtrans %}{% endif %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-person-gear text-primary me-2"></i>
{% if user %}{% trans %}Edit User Account{% endtrans %}{% else %}{% trans %}Create User Account{% endtrans %}{% endif %}
</h1>
<p class="text-muted small mb-0">{% trans %}Configure user credentials, profile information, and role assignment.{% endtrans %}</p>
</div>
<div>
<a href="{{ url_for('admin.users_list') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% trans %}Back to Users{% endtrans %}
</a>
</div>
</div>
<div class="row">
<div class="col-lg-6 mx-auto">
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary py-3">
<h5 class="card-title h6 mb-0">
<i class="bi bi-person-vcard text-primary me-2"></i>{% trans %}Account Details{% endtrans %}
</h5>
</div>
<div class="card-body p-4">
<form method="POST">
<div class="mb-3">
<label for="name" class="form-label fw-semibold">{% trans %}Full Name{% endtrans %} <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" name="name"
value="{{ user.name if user else '' }}" required
placeholder="{% trans %}e.g. María González{% endtrans %}">
</div>
<div class="mb-3">
<label for="email" class="form-label fw-semibold">{% trans %}Email Address{% endtrans %} <span class="text-danger">*</span></label>
<input type="email" class="form-control" id="email" name="email"
value="{{ user.email if user else '' }}" required
placeholder="{% trans %}e.g. mgonzalez@edu-space.com{% endtrans %}">
</div>
<div class="mb-3">
<label for="password" class="form-label fw-semibold">
{% trans %}Password{% endtrans %} {% if not user %}<span class="text-danger">*</span>{% endif %}
</label>
<input type="password" class="form-control" id="password" name="password"
{% if not user %}required{% endif %}
placeholder="{% if user %}{% trans %}Leave empty to keep current password{% endtrans %}{% else %}{% trans %}At least 6 characters{% endtrans %}{% endif %}">
{% if user %}
<small class="text-muted d-block mt-1">
{% trans %}Only fill this in if you want to reset this user's password.{% endtrans %}
</small>
{% endif %}
</div>
<div class="mb-3">
<label for="role_id" class="form-label fw-semibold">{% trans %}Role / Access Group{% endtrans %} <span class="text-danger">*</span></label>
<select class="form-select" id="role_id" name="role_id" required>
<option value="" disabled {% if not user or not user.role_id %}selected{% endif %}>{% trans %}Select a role...{% endtrans %}</option>
{% for r in roles %}
<option value="{{ r.id }}" {% if user and user.role_id == r.id %}selected{% endif %}>
{{ r.name }} - {{ r.description or '' }}
</option>
{% endfor %}
</select>
<small class="text-muted d-block mt-1">
<a href="{{ url_for('admin.roles_list') }}" target="_blank" class="text-decoration-none">
<i class="bi bi-box-arrow-up-right me-1"></i>{% trans %}View or manage roles and permissions{% endtrans %}
</a>
</small>
</div>
<div class="mb-4 form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="is_active" name="is_active"
{% if not user or user.is_active %}checked{% endif %}
{% if user and user.id == current_user.id %}disabled{% endif %}>
<label class="form-check-label fw-semibold" for="is_active">
{% trans %}Active Account{% endtrans %}
</label>
<small class="text-muted d-block">
{% trans %}Inactive users are prevented from signing in to the platform.{% endtrans %}
</small>
{% if user and user.id == current_user.id %}
<input type="hidden" name="is_active" value="on">
{% endif %}
</div>
<div class="d-flex justify-content-between align-items-center pt-2">
<a href="{{ url_for('admin.users_list') }}" class="btn btn-outline-secondary">
{% trans %}Cancel{% endtrans %}
</a>
<button type="submit" class="btn btn-primary px-4 fw-semibold">
<i class="bi bi-check-lg me-1"></i>{% trans %}Save User{% endtrans %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+182
View File
@@ -0,0 +1,182 @@
{% extends "base.html" %}
{% block title %}{% trans %}User Management{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-people text-primary me-2"></i>{% trans %}User Management{% endtrans %}
</h1>
<p class="text-muted small mb-0">{% trans %}Manage user accounts, assign roles, and control active status.{% endtrans %}</p>
</div>
<div>
<a href="{{ url_for('admin.user_add') }}" class="btn btn-primary">
<i class="bi bi-person-plus me-1"></i>{% trans %}New User{% endtrans %}
</a>
</div>
</div>
<!-- Filters & Search -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-3">
<form method="GET" class="row g-2 align-items-center">
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary border-end-0">
<i class="bi bi-search text-muted"></i>
</span>
<input type="text" name="search" class="form-control border-start-0"
placeholder="{% trans %}Search by name or email...{% endtrans %}"
value="{{ search }}">
</div>
</div>
<div class="col-md-3">
<select name="role" class="form-select form-select-sm">
<option value="">{% trans %}All Roles{% endtrans %}</option>
{% for r in roles %}
<option value="{{ r.id }}" {% if role_filter == (r.id|string) %}selected{% endif %}>{{ r.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<select name="status" class="form-select form-select-sm">
<option value="">{% trans %}All Statuses{% endtrans %}</option>
<option value="active" {% if status_filter == 'active' %}selected{% endif %}>{% trans %}Active{% endtrans %}</option>
<option value="inactive" {% if status_filter == 'inactive' %}selected{% endif %}>{% trans %}Inactive{% endtrans %}</option>
</select>
</div>
<div class="col-md-2 d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm w-100">
<i class="bi bi-filter me-1"></i>{% trans %}Filter{% endtrans %}
</button>
{% if search or role_filter or status_filter %}
<a href="{{ url_for('admin.users_list') }}" class="btn btn-outline-secondary btn-sm" title="{% trans %}Clear filters{% endtrans %}">
<i class="bi bi-x-lg"></i>
</a>
{% endif %}
</div>
</form>
</div>
</div>
<!-- Users Table -->
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-body-tertiary">
<tr>
<th class="ps-4">{% trans %}User{% endtrans %}</th>
<th>{% trans %}Email{% endtrans %}</th>
<th>{% trans %}Role / Group{% endtrans %}</th>
<th>{% trans %}Status{% endtrans %}</th>
<th>{% trans %}Last Login{% endtrans %}</th>
<th class="text-end pe-4">{% trans %}Actions{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td class="ps-4">
<div class="d-flex align-items-center">
<div class="user-avatar-small me-2">
{{ (u.first_name[0] if u.first_name else 'U')|upper }}
</div>
<div>
<div class="fw-bold">{{ u.name }}</div>
<small class="text-muted">ID: #{{ u.id }}</small>
</div>
</div>
</td>
<td>
<span class="font-monospace small">{{ u.email }}</span>
</td>
<td>
{% if u.role_obj %}
<span class="badge badge-brand">
<i class="bi bi-shield-check me-1"></i>{{ u.role_obj.name }}
</span>
{% else %}
<span class="badge bg-secondary">
{{ u.role or _('No Role') }}
</span>
{% endif %}
</td>
<td>
{% if u.is_active %}
<span class="badge bg-success-subtle text-success border border-success-subtle px-2 py-1">
<i class="bi bi-check-circle me-1"></i>{% trans %}Active{% endtrans %}
</span>
{% else %}
<span class="badge bg-danger-subtle text-danger border border-danger-subtle px-2 py-1">
<i class="bi bi-dash-circle me-1"></i>{% trans %}Inactive{% endtrans %}
</span>
{% endif %}
</td>
<td>
<small class="text-muted">
{% if u.last_login %}
{{ format_datetime(u.last_login, format='short') }}
{% else %}
{% trans %}Never{% endtrans %}
{% endif %}
</small>
</td>
<td class="text-end pe-4">
<div class="btn-group btn-group-sm">
<a href="{{ url_for('admin.user_edit', id=u.id) }}" class="btn btn-outline-primary" title="{% trans %}Edit User{% endtrans %}">
<i class="bi bi-pencil"></i>
</a>
{% if u.id != current_user.id %}
<form action="{{ url_for('admin.user_toggle', id=u.id) }}" method="POST" class="d-inline">
<button type="submit" class="btn {% if u.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %}"
title="{% if u.is_active %}{% trans %}Deactivate User{% endtrans %}{% else %}{% trans %}Activate User{% endtrans %}{% endif %}">
<i class="bi {% if u.is_active %}bi-person-x{% else %}bi-person-check{% endif %}"></i>
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center py-5 text-muted">
<i class="bi bi-people fs-1 d-block mb-2 text-secondary"></i>
{% trans %}No users found matching the criteria.{% endtrans %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if pagination.pages > 1 %}
<div class="card-footer bg-body-tertiary d-flex justify-content-between align-items-center py-2 px-4">
<small class="text-muted">
{% trans %}Showing page{% endtrans %} {{ pagination.page }} {% trans %}of{% endtrans %} {{ pagination.pages }} ({{ pagination.total }} {% trans %}total{% endtrans %})
</small>
<ul class="pagination pagination-sm mb-0">
{% if pagination.has_prev %}
<li class="page-item"><a class="page-link" href="{{ url_for('admin.users_list', page=pagination.prev_num, search=search, role=role_filter, status=status_filter) }}">&laquo;</a></li>
{% endif %}
{% for p in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if p %}
<li class="page-item {% if p == pagination.page %}active{% endif %}">
<a class="page-link" href="{{ url_for('admin.users_list', page=p, search=search, role=role_filter, status=status_filter) }}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">…</span></li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item"><a class="page-link" href="{{ url_for('admin.users_list', page=pagination.next_num, search=search, role=role_filter, status=status_filter) }}">&raquo;</a></li>
{% endif %}
</ul>
</div>
{% endif %}
</div>
</div>
{% endblock %}
@@ -15,7 +15,7 @@
No tienes los permisos necesarios para acceder a este recurso. Esta sección está restringida exclusivamente a administradores.
</p>
<div>
<a href="/main/dashboard" class="btn btn-primary">
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">
<i class="bi bi-house me-1"></i>Ir al Dashboard
</a>
</div>
@@ -11,7 +11,7 @@
<h1 class="h3 mb-0">
<i class="bi bi-shield-lock text-primary me-2"></i>Cambiar Contraseña
</h1>
<a href="/auth/profile" class="btn btn-outline-secondary btn-sm">
<a href="{{ url_for('auth.profile') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Volver al Perfil
</a>
</div>
@@ -19,7 +19,7 @@
<!-- Form Card -->
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<form method="POST" action="/auth/change_password">
<form method="POST" action="{{ url_for('auth.change_password') }}">
<div class="mb-3">
<label for="current_password" class="form-label">Contraseña Actual</label>
<div class="input-group">
@@ -49,7 +49,7 @@
<button type="submit" class="btn btn-primary">
<i class="bi bi-save me-1"></i>Actualizar Contraseña
</button>
<a href="/auth/profile" class="btn btn-link text-muted">
<a href="{{ url_for('auth.profile') }}" class="btn btn-link text-muted">
Cancelar
</a>
</div>
+115
View File
@@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="{{ current_lang }}" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% trans %}Login - Edu-Space Admin{% endtrans %}</title>
<!-- Favicon -->
<link rel="icon" type="image/png" href="{{ url_for('static', filename='img/favicon.png') }}">
<!-- Script Anti-Flicker para Modo Claro / Oscuro -->
<script>
(function() {
const storedTheme = localStorage.getItem('theme');
const theme = storedTheme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-bs-theme', theme);
})();
</script>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
<!-- Design System Theme -->
<link href="{{ url_for('static', filename='css/theme.css') }}" rel="stylesheet">
</head>
<body>
<div class="auth-wrapper">
<div class="auth-card">
<!-- Header con Controles Superiores & Logo Oficial -->
<div class="auth-header">
<div class="auth-controls">
<button class="theme-toggle-btn" type="button" aria-label="Cambiar tema" title="Modo Claro/Oscuro">
<i class="bi bi-moon-stars-fill"></i>
</button>
{% include 'partials/language_switcher.html' %}
</div>
<img src="{{ url_for('static', filename='img/logo_unicaba.png') }}" alt="UniCABA" class="auth-logo">
<h4 class="fw-bold mb-1">Edu-Space Admin</h4>
<p class="text-muted small mb-0">{% trans %}Classroom Management System{% endtrans %}</p>
</div>
<div class="px-4 pb-4 pt-2">
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show rounded-3 py-2 small" role="alert">
{{ message }}
<button type="button" class="btn-close py-2" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
<label for="email" class="form-label small fw-semibold text-muted">{{ form.email.label.text }}</label>
<div class="input-group">
<span class="input-group-text bg-body-tertiary border-end-0 text-muted">
<i class="bi bi-envelope"></i>
</span>
<input type="email" class="form-control border-start-0 ps-0" id="email" name="email"
value="{{ form.email.data or '' }}" required
placeholder="{% trans %}Enter your email{% endtrans %}">
</div>
{% for error in form.email.errors %}
<div class="text-danger small mt-1">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3">
<label for="password" class="form-label small fw-semibold text-muted">{{ form.password.label.text }}</label>
<div class="input-group">
<span class="input-group-text bg-body-tertiary border-end-0 text-muted">
<i class="bi bi-lock"></i>
</span>
<input type="password" class="form-control border-start-0 ps-0" id="password" name="password"
required placeholder="{% trans %}Enter your password{% endtrans %}">
</div>
{% for error in form.password.errors %}
<div class="text-danger small mt-1">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-4 form-check">
{{ form.remember_me(class="form-check-input") }}
<label class="form-check-label text-muted small" for="remember_me">
{{ form.remember_me.label.text }}
</label>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg fw-semibold py-2">
<i class="bi bi-box-arrow-in-right me-1"></i> {{ form.submit.label.text }}
</button>
</div>
</form>
<div class="text-center mt-4">
<small class="text-muted d-inline-flex align-items-center">
<i class="bi bi-shield-check text-primary me-1"></i>
{% trans %}Secure Login • All access is logged{% endtrans %}
</small>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='js/theme-toggle.js') }}"></script>
</body>
</html>
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}User Profile - Edu-Space Admin{% endblock %}
{% block title %}{% trans %}User Profile{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container pt-5 mt-4">
@@ -9,10 +9,10 @@
<!-- Header -->
<div class="d-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0">
<i class="bi bi-person-badge text-primary me-2"></i>My Profile
<i class="bi bi-person-badge text-primary me-2"></i>{% trans %}My Profile{% endtrans %}
</h1>
<a href="/main/dashboard" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Dashboard
<a href="{{ url_for('main.dashboard') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>{% trans %}Dashboard{% endtrans %}
</a>
</div>
@@ -24,13 +24,13 @@
<i class="bi bi-person"></i>
</div>
<div>
<h4 class="mb-1">{{ user.name }}</h4>
<h4 class="mb-1">{{ current_user.name }}</h4>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-primary">{{ user.role }}</span>
{% if user.is_active %}
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Active</span>
<span class="badge bg-primary">{{ current_user.role }}</span>
{% if current_user.is_active %}
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>{% trans %}Active{% endtrans %}</span>
{% else %}
<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>Inactive</span>
<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>{% trans %}Inactive{% endtrans %}</span>
{% endif %}
</div>
</div>
@@ -41,32 +41,32 @@
<!-- User Details Grid -->
<div class="row g-3">
<div class="col-sm-6">
<label class="form-label text-muted small mb-1">Email</label>
<div class="fw-semibold">{{ user.email }}</div>
<label class="form-label text-muted small mb-1">{% trans %}Email{% endtrans %}</label>
<div class="fw-semibold">{{ current_user.email }}</div>
</div>
<div class="col-sm-6">
<label class="form-label text-muted small mb-1">Full Name</label>
<div class="fw-semibold">{{ user.name }}</div>
<label class="form-label text-muted small mb-1">{% trans %}Full Name{% endtrans %}</label>
<div class="fw-semibold">{{ current_user.name }}</div>
</div>
<div class="col-sm-6">
<label class="form-label text-muted small mb-1">Access Role</label>
<div class="fw-semibold">{{ user.role }}</div>
<label class="form-label text-muted small mb-1">{% trans %}Access Role{% endtrans %}</label>
<div class="fw-semibold">{{ current_user.role }}</div>
</div>
<div class="col-sm-6">
<label class="form-label text-muted small mb-1">Last Login</label>
<label class="form-label text-muted small mb-1">{% trans %}Last Login{% endtrans %}</label>
<div class="fw-semibold">
{% if user.last_login %}
{{ user.last_login }}
{% if current_user.last_login %}
{{ current_user.last_login.strftime('%Y-%m-%d %H:%M:%S') }}
{% else %}
<span class="text-muted">First login</span>
<span class="text-muted">{% trans %}First login{% endtrans %}</span>
{% endif %}
</div>
</div>
<div class="col-sm-6">
<label class="form-label text-muted small mb-1">Registration Date</label>
<label class="form-label text-muted small mb-1">{% trans %}Registration Date{% endtrans %}</label>
<div class="fw-semibold">
{% if user.created_at %}
{{ user.created_at }}
{% if current_user.created_at %}
{{ current_user.created_at.strftime('%Y-%m-%d') }}
{% else %}
<span class="text-muted">-</span>
{% endif %}
@@ -77,76 +77,76 @@
<hr class="my-4">
<!-- Preferences Section -->
<form method="POST" action="/auth/profile">
<form method="POST" action="{{ url_for('auth.profile') }}">
<h5 class="fw-bold mb-3">
<i class="bi bi-sliders text-primary me-2"></i>User Preferences
<i class="bi bi-sliders text-primary me-2"></i>{% trans %}User Preferences{% endtrans %}
</h5>
<div class="row g-3">
<!-- Preferred Language -->
<div class="col-md-6 mb-3">
<label for="preferred_language" class="form-label fw-semibold">
<i class="bi bi-translate text-primary me-1"></i>Interface Language
<i class="bi bi-translate text-primary me-1"></i>{% trans %}Interface Language{% endtrans %}
</label>
<select class="form-select" id="preferred_language" name="preferred_language">
<option value="" {% if not user.preferred_language %}selected{% endif %}>
Auto-detect from browser (Default)
<option value="" {% if not current_user.preferred_language %}selected{% endif %}>
{% trans %}Auto-detect from browser (Default){% endtrans %}
</option>
<option value="es" {% if user.preferred_language == 'es' %}selected{% endif %}>
<option value="es" {% if current_user.preferred_language == 'es' %}selected{% endif %}>
Español
</option>
<option value="en" {% if user.preferred_language == 'en' %}selected{% endif %}>
<option value="en" {% if current_user.preferred_language == 'en' %}selected{% endif %}>
English
</option>
</select>
<small class="text-muted d-block mt-1">
If set, this language overrides the browser configuration for all your sessions.
{% trans %}If set, this language overrides the browser configuration for all your sessions.{% endtrans %}
</small>
</div>
<!-- Theme Preference -->
<div class="col-md-6 mb-3">
<label for="theme_preference" class="form-label fw-semibold">
<i class="bi bi-palette text-primary me-1"></i>Visual Theme
<i class="bi bi-palette text-primary me-1"></i>{% trans %}Visual Theme{% endtrans %}
</label>
<select class="form-select" id="theme_preference" name="theme_preference">
<option value="auto" {% if user.theme_preference == 'auto' or not user.theme_preference %}selected{% endif %}>
Automatic (follow system)
<option value="auto" {% if current_user.theme_preference == 'auto' or not current_user.theme_preference %}selected{% endif %}>
{% trans %}Automatic (follow system){% endtrans %}
</option>
<option value="light" {% if user.theme_preference == 'light' %}selected{% endif %}>
Light Mode
<option value="light" {% if current_user.theme_preference == 'light' %}selected{% endif %}>
{% trans %}Light Mode{% endtrans %}
</option>
<option value="dark" {% if user.theme_preference == 'dark' %}selected{% endif %}>
Dark Mode
<option value="dark" {% if current_user.theme_preference == 'dark' %}selected{% endif %}>
{% trans %}Dark Mode{% endtrans %}
</option>
</select>
<small class="text-muted d-block mt-1">
Choose your preferred visual theme.
{% trans %}Choose your preferred visual theme.{% endtrans %}
</small>
</div>
</div>
<div class="text-end mt-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Save Preferences
<i class="bi bi-check-lg me-1"></i>{% trans %}Save Preferences{% endtrans %}
</button>
</div>
</form>
</div>
<div class="card-footer bg-body-tertiary px-4 py-3 d-flex justify-content-between align-items-center">
<span class="text-muted small">Account Security</span>
<a href="/auth/change_password" class="btn btn-primary btn-sm">
<i class="bi bi-key me-1"></i>Change Password
<span class="text-muted small">{% trans %}Account Security{% endtrans %}</span>
<a href="{{ url_for('auth.change_password') }}" class="btn btn-primary btn-sm">
<i class="bi bi-key me-1"></i>{% trans %}Change Password{% endtrans %}
</a>
</div>
</div>
<!-- User Reservations Overview (if any) -->
{% if user.reservations %}
{% if current_user.reservations %}
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-body-tertiary">
<h5 class="card-title mb-0 h6">
<i class="bi bi-calendar-event me-2"></i>My Recent Reservations
<i class="bi bi-calendar-event me-2"></i>{% trans %}My Recent Reservations{% endtrans %}
</h5>
</div>
<div class="card-body p-0">
@@ -154,22 +154,22 @@
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Classroom</th>
<th>Purpose</th>
<th>Date & Time</th>
<th>Status</th>
<th>{% trans %}Classroom{% endtrans %}</th>
<th>{% trans %}Purpose{% endtrans %}</th>
<th>{% trans %}Date & Time{% endtrans %}</th>
<th>{% trans %}Status{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for res in (user.reservations or []).slice(0, 5) %}
{% for res in current_user.reservations[:5] %}
<tr>
<td>
<span class="badge bg-secondary">{{ res.classroom.code_display if res.classroom else 'N/A' }}</span>
</td>
<td>{{ res.purpose }}</td>
<td>{{ res.start_time }}</td>
<td>{{ res.start_time.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
<span class="badge bg-{{ 'success' if res.status == 'CONFIRMED' else ('warning' if res.status == 'PENDING' else 'secondary') }}">
<span class="badge bg-{{ 'success' if res.status == 'CONFIRMED' else 'warning' if res.status == 'PENDING' else 'secondary' }}">
{{ res.status }}
</span>
</td>
+194
View File
@@ -0,0 +1,194 @@
<!DOCTYPE html>
<html lang="{{ current_lang }}" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{% trans %}Edu-Space Admin{% endtrans %}{% endblock %}</title>
<!-- Favicon -->
<link rel="icon" type="image/png" href="{{ url_for('static', filename='img/favicon.png') }}">
<!-- Script Anti-Flicker para Modo Claro / Oscuro -->
<script>
(function() {
let userTheme = "{{ current_user.theme_preference if (current_user and current_user.is_authenticated) else '' }}";
let theme = userTheme;
if (!theme || theme === 'auto') {
const storedTheme = localStorage.getItem('theme');
theme = storedTheme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
}
document.documentElement.setAttribute('data-bs-theme', theme);
})();
</script>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
<!-- Design System & Custom Theme -->
<link href="{{ url_for('static', filename='css/theme.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
{% block extra_css %}{% endblock %}
</head>
<body data-user-authenticated="{{ 'true' if (current_user and current_user.is_authenticated) else 'false' }}">
{% if current_user.is_authenticated %}
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark navbar-app fixed-top">
<div class="container-fluid">
<a class="navbar-brand d-flex align-items-center" href="{{ url_for('main.dashboard') }}">
<img src="{{ url_for('static', filename='img/logo_unicaba_mini.png') }}" alt="Logo UniCABA" class="navbar-logo me-2">
<span>Edu-Space Admin</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
<i class="bi bi-speedometer2"></i> {% trans %}Dashboard{% endtrans %}
</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-door-open"></i> {% trans %}Classrooms{% endtrans %}
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms') }}">
<i class="bi bi-list"></i> {% trans %}All Classrooms{% endtrans %}
</a></li>
{% if current_user.has_permission('classrooms', 'read_write') %}
<li><a class="dropdown-item" href="{{ url_for('classrooms.add_classroom') }}">
<i class="bi bi-plus-circle"></i> {% trans %}Add Classroom{% endtrans %}
</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}">
<i class="bi bi-buildings"></i> {% trans %}Buildings{% endtrans %}
</a></li>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-calendar3"></i> {% trans %}Schedule{% endtrans %}
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('schedule.calendar_view') }}">
<i class="bi bi-calendar-week"></i> {% trans %}Calendar View{% endtrans %}
</a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}">
<i class="bi bi-list-check"></i> {% trans %}All Reservations{% endtrans %}
</a></li>
{% if current_user.has_permission('reservations', 'read_write') %}
<li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}">
<i class="bi bi-plus-circle"></i> {% trans %}New Reservation{% endtrans %}
</a></li>
{% endif %}
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}">
<i class="bi bi-calendar-today"></i> {% trans %}Today's Schedule{% endtrans %}
</a></li>
</ul>
</li>
{% if current_user.is_authenticated and current_user.can_manage() %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-shield-lock"></i> {% trans %}Management{% endtrans %}
</a>
<ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="{{ url_for('admin.users_list') }}"><i class="bi bi-people me-2"></i>{% trans %}Users{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.roles_list') }}"><i class="bi bi-person-badge me-2"></i>{% trans %}Roles & Groups{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2"></i>{% trans %}Buildings{% endtrans %}</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('admin.google_sheets_import') }}"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>{% trans %}Import from Google Sheets{% endtrans %}</a></li>
</ul>
</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 -->
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle d-flex align-items-center py-1" href="#" role="button" data-bs-toggle="dropdown">
<span class="user-avatar-small">{{ current_user.name[:1]|upper if current_user.name else 'U' }}</span>
<span>{{ current_user.first_name }}</span>
</a>
<ul class="dropdown-menu dropdown-menu-end shadow-sm border-0">
<li>
<div class="px-3 py-2 border-bottom">
<small class="text-muted d-block">{% trans %}Signed in as{% endtrans %}</small>
<span class="fw-bold small">{{ current_user.email }}</span>
</div>
</li>
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">
<i class="bi bi-person me-2"></i> {% trans %}Profile{% endtrans %}
</a></li>
<li><a class="dropdown-item" href="{{ url_for('auth.change_password') }}">
<i class="bi bi-key me-2"></i> {% trans %}Change Password{% endtrans %}
</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="{{ url_for('auth.logout') }}">
<i class="bi bi-box-arrow-right me-2"></i> {% trans %}Logout{% endtrans %}
</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
{% endif %}
<!-- Flash Messages -->
<div class="toast-container position-fixed bottom-0 end-0 p-3" style="z-index: 1080;">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="toast align-items-center text-white bg-{{ 'danger' if category == 'error' else 'success' if category == 'success' else 'info' if category == 'info' else 'warning' }} border-0 shadow" role="alert">
<div class="d-flex">
<div class="toast-body">
{{ message }}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
<!-- Main Content -->
{% block content %}{% endblock %}
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<!-- Theme Toggle JS -->
<script src="{{ url_for('static', filename='js/theme-toggle.js') }}"></script>
<!-- Custom JS -->
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
<!-- Show flash messages -->
<script>
$(document).ready(function() {
$('.toast').toast('show');
});
</script>
{% block extra_js %}{% endblock %}
</body>
</html>
@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% block title %}
{% if building %}Editar Edificio - {{ building.name }}{% else %}Nuevo Edificio{% endif %} - Edu-Space Admin
{% if building %}{% trans %}Editar Edificio{% endtrans %} - {{ building.name }}{% else %}{% trans %}Nuevo Edificio{% endtrans %}{% endif %} - Edu-Space Admin
{% endblock %}
{% block content %}
@@ -11,21 +11,22 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">
<i class="bi bi-buildings text-primary me-2"></i>
{% if building %}Editar Edificio{% else %}Nuevo Edificio{% endif %}
{% if building %}{% trans %}Editar Edificio{% endtrans %}{% else %}{% trans %}Nuevo Edificio{% endtrans %}{% endif %}
</h1>
<a href="/buildings/list_buildings" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Volver a Edificios
<a href="{{ url_for('buildings.list_buildings') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% trans %}Volver a Edificios{% endtrans %}
</a>
</div>
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary border-0 py-3">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-info-circle text-primary me-2"></i>Información del Edificio / Sede
<i class="bi bi-info-circle text-primary me-2"></i>{% trans %}Información del Edificio / Sede{% endtrans %}
</h5>
</div>
<div class="card-body p-4">
<form method="POST" novalidate>
{{ form.hidden_tag() }}
<div class="row g-3">
<div class="col-md-8">
@@ -42,7 +43,7 @@
{% if form.code.errors %}
<div class="invalid-feedback">{{ form.code.errors[0] }}</div>
{% endif %}
<small class="text-muted">Código identificador corto</small>
<small class="text-muted">{% trans %}Código identificador corto{% endtrans %}</small>
</div>
<div class="col-md-8">
@@ -59,7 +60,7 @@
{% if form.floors.errors %}
<div class="invalid-feedback">{{ form.floors.errors[0] }}</div>
{% endif %}
<small class="text-muted">Cantidad de plantas/pisos</small>
<small class="text-muted">{% trans %}Cantidad de plantas/pisos{% endtrans %}</small>
</div>
<div class="col-12">
@@ -79,10 +80,10 @@
</div>
<div class="d-flex justify-content-end gap-2 mt-4 pt-3 border-top">
<a href="/buildings/list_buildings" class="btn btn-outline-secondary">Cancelar</a>
<a href="{{ url_for('buildings.list_buildings') }}" class="btn btn-outline-secondary">{% trans %}Cancelar{% endtrans %}</a>
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-check-circle me-1"></i>
{% if building %}Guardar Cambios{% else %}Crear Edificio{% endif %}
{% if building %}{% trans %}Guardar Cambios{% endtrans %}{% else %}{% trans %}Crear Edificio{% endtrans %}{% endif %}
</button>
</div>
</form>
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Gestión de Edificios - Edu-Space Admin{% endblock %}
{% block title %}{% trans %}Gestión de Edificios{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
@@ -8,18 +8,18 @@
<div class="row mb-4 align-items-center">
<div class="col-md-7">
<h1 class="h3 mb-1">
<i class="bi bi-buildings text-primary me-2"></i>Gestión de Edificios
<i class="bi bi-buildings text-primary me-2"></i>{% trans %}Gestión de Edificios{% endtrans %}
</h1>
<p class="text-muted small mb-0">Administra las sedes, edificios físicos y campus virtuales de UniCABA</p>
<p class="text-muted small mb-0">{% trans %}Administra las sedes, edificios físicos y campus virtuales de UniCABA{% endtrans %}</p>
</div>
<div class="col-md-5 text-md-end mt-3 mt-md-0">
{% if user.has_permission('classrooms', 'read_write') %}
<a href="/buildings/add_building" class="btn btn-primary me-2">
<i class="bi bi-plus-circle me-1"></i>Nuevo Edificio
{% if current_user.has_permission('classrooms', 'read_write') %}
<a href="{{ url_for('buildings.add_building') }}" class="btn btn-primary me-2">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nuevo Edificio{% endtrans %}
</a>
{% endif %}
<a href="/classrooms/list_classrooms" class="btn btn-outline-secondary">
<i class="bi bi-door-open me-1"></i>Ver Aulas
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-door-open me-1"></i>{% trans %}Ver Aulas{% endtrans %}
</a>
</div>
</div>
@@ -34,7 +34,7 @@
<i class="bi bi-buildings fs-3"></i>
</div>
<div>
<div class="text-muted small fw-semibold">Total Edificios</div>
<div class="text-muted small fw-semibold">{% trans %}Total Edificios{% endtrans %}</div>
<h3 class="fw-bold mb-0">{{ buildings|length }}</h3>
</div>
</div>
@@ -50,7 +50,7 @@
<i class="bi bi-door-open fs-3"></i>
</div>
<div>
<div class="text-muted small fw-semibold">Aulas Activas</div>
<div class="text-muted small fw-semibold">{% trans %}Aulas Activas{% endtrans %}</div>
<h3 class="fw-bold mb-0 text-success">{{ total_classrooms }}</h3>
</div>
</div>
@@ -66,7 +66,7 @@
<i class="bi bi-people fs-3"></i>
</div>
<div>
<div class="text-muted small fw-semibold">Capacidad Total</div>
<div class="text-muted small fw-semibold">{% trans %}Capacidad Total{% endtrans %}</div>
<h3 class="fw-bold mb-0 text-info">{{ total_capacity }}</h3>
</div>
</div>
@@ -82,7 +82,7 @@
<i class="bi bi-layers fs-3"></i>
</div>
<div>
<div class="text-muted small fw-semibold">Pisos Disponibles</div>
<div class="text-muted small fw-semibold">{% trans %}Pisos Disponibles{% endtrans %}</div>
<h3 class="fw-bold mb-0 text-warning">{{ buildings|sum(attribute='floors') }}</h3>
</div>
</div>
@@ -100,20 +100,20 @@
<span class="input-group-text bg-body-tertiary border-end-0">
<i class="bi bi-search text-muted"></i>
</span>
<input type="text" name="search" value="{{ search }}" class="form-control border-start-0" placeholder="Buscar por nombre, código o dirección...">
<input type="text" name="search" value="{{ search }}" class="form-control border-start-0" placeholder="{% trans %}Buscar por nombre, código o dirección...{% endtrans %}">
</div>
</div>
<div class="col-md-4">
<select name="status" class="form-select" onchange="this.form.submit()">
<option value="">Todos los estados</option>
<option value="active" {% if status_filter == 'active' %}selected{% endif %}>Activos únicamente</option>
<option value="inactive" {% if status_filter == 'inactive' %}selected{% endif %}>Inactivos</option>
<option value="">{% trans %}Todos los estados{% endtrans %}</option>
<option value="active" {% if status_filter == 'active' %}selected{% endif %}>{% trans %}Activos únicamente{% endtrans %}</option>
<option value="inactive" {% if status_filter == 'inactive' %}selected{% endif %}>{% trans %}Inactivos{% endtrans %}</option>
</select>
</div>
<div class="col-md-2 d-flex gap-2">
<button type="submit" class="btn btn-primary w-100">Buscar</button>
<button type="submit" class="btn btn-primary w-100">{% trans %}Buscar{% endtrans %}</button>
{% if search or status_filter %}
<a href="/buildings/list_buildings" class="btn btn-outline-secondary" title="Limpiar filtro">
<a href="{{ url_for('buildings.list_buildings') }}" class="btn btn-outline-secondary" title="{% trans %}Limpiar filtro{% endtrans %}">
<i class="bi bi-x-lg"></i>
</a>
{% endif %}
@@ -133,7 +133,7 @@
{{ b.code or 'EDIF' }}
</span>
<h5 class="card-title fw-bold mb-0">
<a href="/buildings/view_building" class="text-decoration-none text-primary">
<a href="{{ url_for('buildings.view_building', id=b.id) }}" class="text-decoration-none text-primary">
{{ b.name }}
</a>
</h5>
@@ -141,11 +141,11 @@
<div>
{% if b.is_active %}
<span class="badge bg-success-subtle text-success border border-success-subtle px-2 py-1">
<i class="bi bi-check-circle me-1"></i>Activo
<i class="bi bi-check-circle me-1"></i>{% trans %}Activo{% endtrans %}
</span>
{% else %}
<span class="badge bg-danger-subtle text-danger border border-danger-subtle px-2 py-1">
<i class="bi bi-x-circle me-1"></i>Inactivo
<i class="bi bi-x-circle me-1"></i>{% trans %}Inactivo{% endtrans %}
</span>
{% endif %}
</div>
@@ -164,36 +164,36 @@
<div class="row g-2 text-center pt-2 border-top">
<div class="col-4">
<div class="p-2 rounded bg-body-tertiary">
<small class="text-muted d-block">Pisos</small>
<small class="text-muted d-block">{% trans %}Pisos{% endtrans %}</small>
<span class="fw-bold fs-6">{{ b.floors }}</span>
</div>
</div>
<div class="col-4">
<div class="p-2 rounded bg-body-tertiary">
<small class="text-muted d-block">Aulas</small>
<small class="text-muted d-block">{% trans %}Aulas{% endtrans %}</small>
<span class="fw-bold fs-6 text-primary">{{ b.classrooms_count }}</span>
</div>
</div>
<div class="col-4">
<div class="p-2 rounded bg-body-tertiary">
<small class="text-muted d-block">Capacidad</small>
<small class="text-muted d-block">{% trans %}Capacidad{% endtrans %}</small>
<span class="fw-bold fs-6 text-success">{{ b.total_capacity }}</span>
</div>
</div>
</div>
</div>
<div class="card-footer bg-transparent border-0 py-3 d-flex justify-content-between align-items-center">
<a href="/buildings/view_building" class="btn btn-outline-primary btn-sm">
<i class="bi bi-eye me-1"></i>Ver Distribución de Aulas
<a href="{{ url_for('buildings.view_building', id=b.id) }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-eye me-1"></i>{% trans %}Ver Distribución de Aulas{% endtrans %}
</a>
{% if user.has_permission('classrooms', 'read_write') %}
{% if current_user.has_permission('classrooms', 'read_write') %}
<div class="btn-group">
<a href="/buildings/edit_building" class="btn btn-outline-secondary btn-sm" title="Editar Edificio">
<a href="{{ url_for('buildings.edit_building', id=b.id) }}" class="btn btn-outline-secondary btn-sm" title="{% trans %}Editar Edificio{% endtrans %}">
<i class="bi bi-pencil"></i>
</a>
<form method="POST" action="/buildings/toggle_building" class="d-inline">
<button type="submit" class="btn btn-outline-secondary btn-sm" title="{% if b.is_active %}Desactivar{% else %}Activar{% endif %}">
<form method="POST" action="{{ url_for('buildings.toggle_building', id=b.id) }}" class="d-inline">
<button type="submit" class="btn btn-outline-secondary btn-sm" title="{% if b.is_active %}{% trans %}Desactivar{% endtrans %}{% else %}{% trans %}Activar{% endtrans %}{% endif %}">
<i class="bi bi-power"></i>
</button>
</form>
@@ -206,12 +206,12 @@
<div class="col-12">
<div class="card border-0 shadow-sm p-5 text-center">
<i class="bi bi-buildings text-muted fs-1 mb-3"></i>
<h5 class="fw-bold">No se encontraron edificios</h5>
<p class="text-muted mb-3">No hay edificios que coincidan con los criterios de búsqueda.</p>
{% if user.has_permission('classrooms', 'read_write') %}
<h5 class="fw-bold">{% trans %}No se encontraron edificios{% endtrans %}</h5>
<p class="text-muted mb-3">{% trans %}No hay edificios que coincidan con los criterios de búsqueda.{% endtrans %}</p>
{% if current_user.has_permission('classrooms', 'read_write') %}
<div>
<a href="/buildings/add_building" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i>Crear Nuevo Edificio
<a href="{{ url_for('buildings.add_building') }}" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Crear Nuevo Edificio{% endtrans %}
</a>
</div>
{% endif %}
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}{{ building.name }} - Detalle del Edificio - Edu-Space Admin{% endblock %}
{% block title %}{{ building.name }} - {% trans %}Detalle del Edificio{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
@@ -9,7 +9,7 @@
<div class="col-md-7">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1 small">
<li class="breadcrumb-item"><a href="/buildings/list_buildings">Edificios</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('buildings.list_buildings') }}">{% trans %}Edificios{% endtrans %}</a></li>
<li class="breadcrumb-item active">{{ building.name }}</li>
</ol>
</nav>
@@ -18,22 +18,22 @@
<span class="badge bg-secondary-subtle text-secondary font-monospace border fs-6">{{ building.code or 'EDIF' }}</span>
{% if building.is_active %}
<span class="badge bg-success-subtle text-success border border-success-subtle fs-6">
<i class="bi bi-check-circle me-1"></i>Activo
<i class="bi bi-check-circle me-1"></i>{% trans %}Activo{% endtrans %}
</span>
{% else %}
<span class="badge bg-danger-subtle text-danger border border-danger-subtle fs-6">
<i class="bi bi-x-circle me-1"></i>Inactivo
<i class="bi bi-x-circle me-1"></i>{% trans %}Inactivo{% endtrans %}
</span>
{% endif %}
</h1>
</div>
<div class="col-md-5 text-md-end mt-3 mt-md-0">
{% if user.has_permission('classrooms', 'read_write') %}
<a href="/buildings/edit_building" class="btn btn-outline-secondary me-2">
<i class="bi bi-pencil me-1"></i>Editar Edificio
{% if current_user.has_permission('classrooms', 'read_write') %}
<a href="{{ url_for('buildings.edit_building', id=building.id) }}" class="btn btn-outline-secondary me-2">
<i class="bi bi-pencil me-1"></i>{% trans %}Editar Edificio{% endtrans %}
</a>
<a href="/classrooms/add_classroom" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i>Nueva Aula
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nueva Aula{% endtrans %}
</a>
{% endif %}
</div>
@@ -44,7 +44,7 @@
<div class="col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body p-3">
<div class="text-muted small fw-semibold">Ubicación / Dirección</div>
<div class="text-muted small fw-semibold">{% trans %}Ubicación / Dirección{% endtrans %}</div>
<p class="h6 fw-bold mb-2">
<i class="bi bi-geo-alt text-danger me-1"></i>{{ building.address or 'Sin dirección especificada' }}
</p>
@@ -58,19 +58,19 @@
<div class="row g-3">
<div class="col-sm-4">
<div class="card border-0 shadow-sm text-center p-3">
<small class="text-muted text-uppercase fw-bold">Pisos</small>
<small class="text-muted text-uppercase fw-bold">{% trans %}Pisos{% endtrans %}</small>
<span class="display-6 fw-bold text-primary">{{ building.floors }}</span>
</div>
</div>
<div class="col-sm-4">
<div class="card border-0 shadow-sm text-center p-3">
<small class="text-muted text-uppercase fw-bold">Aulas Asociadas</small>
<small class="text-muted text-uppercase fw-bold">{% trans %}Aulas Asociadas{% endtrans %}</small>
<span class="display-6 fw-bold text-success">{{ classrooms|length }}</span>
</div>
</div>
<div class="col-sm-4">
<div class="card border-0 shadow-sm text-center p-3">
<small class="text-muted text-uppercase fw-bold">Capacidad Total</small>
<small class="text-muted text-uppercase fw-bold">{% trans %}Capacidad Total{% endtrans %}</small>
<span class="display-6 fw-bold text-info">{{ building.total_capacity }}</span>
</div>
</div>
@@ -82,9 +82,9 @@
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-layers text-primary me-2"></i>Distribución de Aulas por Piso
<i class="bi bi-layers text-primary me-2"></i>{% trans %}Distribución de Aulas por Piso{% endtrans %}
</h5>
<span class="badge badge-brand">{{ classrooms|length }} aulas configuradas</span>
<span class="badge badge-brand">{{ classrooms|length }} {% trans %}aulas configuradas{% endtrans %}</span>
</div>
<div class="card-body p-4">
{% if classrooms_by_floor %}
@@ -106,7 +106,7 @@
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-start mb-2">
<h6 class="fw-bold mb-0">
<a href="/classrooms/view_classroom" class="text-decoration-none text-primary">
<a href="{{ url_for('classrooms.view_classroom', id=c.id) }}" class="text-decoration-none text-primary">
<i class="bi bi-door-open me-1"></i>{{ c.code }}
</a>
</h6>
@@ -126,12 +126,12 @@
{% endif %}
<div class="d-flex justify-content-between align-items-center pt-2 border-top">
<a href="/classrooms/view_classroom" class="btn btn-outline-primary btn-sm py-0 px-2">
<small>Ver Ficha</small>
<a href="{{ url_for('classrooms.view_classroom', id=c.id) }}" class="btn btn-outline-primary btn-sm py-0 px-2">
<small>{% trans %}Ver Ficha{% endtrans %}</small>
</a>
{% if user.has_permission('classrooms', 'read_write') %}
<a href="/classrooms/edit_classroom" class="btn btn-outline-secondary btn-sm py-0 px-2">
<small>Editar</small>
{% if current_user.has_permission('classrooms', 'read_write') %}
<a href="{{ url_for('classrooms.edit_classroom', id=c.id) }}" class="btn btn-outline-secondary btn-sm py-0 px-2">
<small>{% trans %}Editar{% endtrans %}</small>
</a>
{% endif %}
</div>
@@ -145,11 +145,11 @@
{% else %}
<div class="text-center py-5">
<i class="bi bi-door-closed text-muted fs-1 mb-2"></i>
<h6 class="fw-bold">No hay aulas asignadas a este edificio</h6>
<p class="text-muted small mb-3">Puedes crear aulas nuevas o editar las existentes para vincularlas aquí.</p>
{% if user.has_permission('classrooms', 'read_write') %}
<a href="/classrooms/add_classroom" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle me-1"></i>Agregar Primera Aula
<h6 class="fw-bold">{% trans %}No hay aulas asignadas a este edificio{% endtrans %}</h6>
<p class="text-muted small mb-3">{% trans %}Puedes crear aulas nuevas o editar las existentes para vincularlas aquí.{% endtrans %}</p>
{% if current_user.has_permission('classrooms', 'read_write') %}
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Agregar Primera Aula{% endtrans %}
</a>
{% endif %}
</div>
@@ -158,16 +158,16 @@
</div>
<!-- Danger Zone (Delete) -->
{% if user.has_permission('classrooms', 'read_write') %}
{% if current_user.has_permission('classrooms', 'read_write') %}
<div class="card border-danger border-opacity-25 shadow-sm mb-4">
<div class="card-body p-3 d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<h6 class="text-danger fw-bold mb-1">Eliminar Edificio</h6>
<small class="text-muted">Solo es posible eliminar edificios que no tengan aulas asignadas.</small>
<h6 class="text-danger fw-bold mb-1">{% trans %}Eliminar Edificio{% endtrans %}</h6>
<small class="text-muted">{% trans %}Solo es posible eliminar edificios que no tengan aulas asignadas.{% endtrans %}</small>
</div>
<form method="POST" action="/buildings/delete_building" onsubmit="return confirm('¿Estás seguro de que deseas eliminar este edificio?');">
<form method="POST" action="{{ url_for('buildings.delete_building', id=building.id) }}" onsubmit="return confirm('¿Estás seguro de que deseas eliminar este edificio?');">
<button type="submit" class="btn btn-outline-danger btn-sm" {% if classrooms|length > 0 %}disabled title="Tiene aulas asociadas"{% endif %}>
<i class="bi bi-trash me-1"></i>Eliminar Edificio
<i class="bi bi-trash me-1"></i>{% trans %}Eliminar Edificio{% endtrans %}
</button>
</form>
</div>
+218
View File
@@ -0,0 +1,218 @@
{% extends "base.html" %}
{% block title %}{% trans %}Add Classroom{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">
<i class="bi bi-plus-circle text-primary"></i> {% trans %}Add Classroom{% endtrans %}
</h1>
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> {% trans %}Back to Classrooms{% endtrans %}
</a>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-8 mx-auto">
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary border-0 py-3">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-door-open text-primary me-2"></i> {% trans %}Classroom Information{% endtrans %}
</h5>
</div>
<div class="card-body">
<form method="POST" id="classroom-form" novalidate>
{{ form.hidden_tag() }}
<div class="row">
<div class="col-md-4 mb-3">
{{ form.building.label(class="form-label") }}
{{ form.building(class="form-control", placeholder="A") }}
{% if form.building.errors %}
<div class="text-danger small mt-1">
{{ form.building.errors[0] }}
</div>
{% endif %}
</div>
<div class="col-md-4 mb-3">
{{ form.room_number.label(class="form-label") }}
{{ form.room_number(class="form-control", placeholder="101") }}
{% if form.room_number.errors %}
<div class="text-danger small mt-1">
{{ form.room_number.errors[0] }}
</div>
{% endif %}
</div>
<div class="col-md-4 mb-3">
{{ form.floor.label(class="form-label") }}
{{ form.floor(class="form-control") }}
{% if form.floor.errors %}
<div class="text-danger small mt-1">
{{ form.floor.errors[0] }}
</div>
{% endif %}
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
{{ form.capacity.label(class="form-label mb-0") }}
<button type="button" class="btn btn-sm btn-outline-primary py-0 px-2" id="btnInfinityAdd" style="font-size: 0.8rem;" title="{% trans %}Asignar capacidad ilimitada{% endtrans %}">
<i class="bi bi-infinity me-1"></i>{% trans %}Ilimitada (∞){% endtrans %}
</button>
</div>
<div class="input-group">
{{ form.capacity(class="form-control", id="capacity", placeholder="ej. 30 o ∞") }}
<span class="input-group-text bg-body-tertiary text-muted font-monospace"><i class="bi bi-people"></i></span>
</div>
<div class="form-text small text-muted mt-1">
<i class="bi bi-info-circle text-info me-1"></i>{% trans %}Para aulas virtuales o sin límite de aforo, ingrese <strong>∞</strong> o <strong>0</strong> (Capacidad ilimitada).{% endtrans %}
</div>
{% if form.capacity.errors %}
<div class="text-danger small mt-1">
{{ form.capacity.errors[0] }}
</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label class="form-label d-block">&nbsp;</label>
<div class="form-check pt-2">
{{ form.is_active(class="form-check-input") }}
{{ form.is_active.label(class="form-check-label") }}
</div>
</div>
</div>
<div class="mb-4">
{{ form.description.label(class="form-label") }}
{{ form.description(class="form-control", rows="3", placeholder=_("Optional: Add any additional details about this classroom...")) }}
{% if form.description.errors %}
<div class="text-danger small mt-1">
{{ form.description.errors[0] }}
</div>
{% endif %}
</div>
<!-- Preview Section -->
<div class="alert alert-info">
<h6 class="alert-heading">
<i class="bi bi-info-circle"></i> {% trans %}Preview{% endtrans %}
</h6>
<div id="classroom-preview">
<p class="mb-1">{% trans %}Fill in the form to see a preview of the classroom code:{% endtrans %}
<span class="fw-bold" id="preview-code">-</span>
</p>
<p class="mb-0">{% trans %}Full location:{% endtrans %}
<span class="fw-bold" id="preview-location">-</span>
</p>
</div>
</div>
<div class="d-flex justify-content-between">
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle"></i> {% trans %}Cancel{% endtrans %}
</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-save me-1"></i> {{ form.submit.label.text }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Resources Section -->
<div class="row mt-4 mb-4">
<div class="col-lg-8 mx-auto">
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary border-0 py-3">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-tools text-warning me-2"></i> {% trans %}Resources (Optional){% endtrans %}
</h5>
</div>
<div class="card-body">
<p class="text-muted mb-0">{% trans %}You can add classroom resources after creating the classroom.{% endtrans %}</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
$(document).ready(function() {
function updatePreview() {
const building = $('#building').val();
const roomNumber = $('#room_number').val();
const floor = $('#floor').val();
const floorLabel = '{{ _("Floor") }}';
if (building && roomNumber && floor) {
$('#preview-code').text(`${building}-${roomNumber}`);
$('#preview-location').text(`${building} - ${floorLabel} ${floor}`);
} else {
$('#preview-code').text('-');
$('#preview-location').text('-');
}
}
// Update preview when inputs change
$('#building, #room_number, #floor').on('input', updatePreview);
// Initialize preview
updatePreview();
// Form validation
$('#classroom-form').on('submit', function(e) {
let isValid = true;
if (!$('#building').val().trim()) {
$('#building').addClass('is-invalid');
isValid = false;
} else {
$('#building').removeClass('is-invalid');
}
if (!$('#room_number').val().trim()) {
$('#room_number').addClass('is-invalid');
isValid = false;
} else {
$('#room_number').removeClass('is-invalid');
}
var floorVal = $('#floor').val();
if (floorVal === '' || isNaN(floorVal)) {
$('#floor').addClass('is-invalid');
isValid = false;
} else {
$('#floor').removeClass('is-invalid');
}
var capVal = $('#capacity').val() ? $('#capacity').val().trim() : '';
if (!capVal || (capVal !== '∞' && capVal !== '0' && isNaN(capVal))) {
$('#capacity').addClass('is-invalid');
isValid = false;
} else {
$('#capacity').removeClass('is-invalid');
}
return isValid;
});
$('#btnInfinityAdd').on('click', function() {
$('#capacity').val('∞').focus();
});
});
</script>
{% endblock %}
+162
View File
@@ -0,0 +1,162 @@
{% extends "base.html" %}
{% block title %}{% trans %}Edit Classroom{% endtrans %} - {{ classroom.code_display }} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Edit Classroom{% endtrans %}
</h1>
<p class="text-muted small mb-0">{{ classroom.building }} — {{ classroom.code }}</p>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}" class="btn btn-outline-primary">
<i class="bi bi-eye me-1"></i>{% trans %}Ver Ficha{% endtrans %}
</a>
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% trans %}Back to Classrooms{% endtrans %}
</a>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-8 mx-auto">
<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">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-door-open text-primary me-2"></i>{% trans %}Classroom Information{% endtrans %}
</h5>
<span class="badge bg-secondary-subtle text-secondary font-monospace border">ID #{{ classroom.id }}</span>
</div>
<div class="card-body p-4">
<form method="POST" id="classroom-form" novalidate>
{{ form.hidden_tag() }}
<div class="row g-3">
<div class="col-md-4">
{{ form.building.label(class="form-label fw-semibold") }}
{{ form.building(class="form-control" + (" is-invalid" if form.building.errors else ""), placeholder="Edificio Central") }}
{% if form.building.errors %}
<div class="invalid-feedback">{{ form.building.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-4">
{{ form.room_number.label(class="form-label fw-semibold") }}
{{ form.room_number(class="form-control" + (" is-invalid" if form.room_number.errors else ""), placeholder="402") }}
{% if form.room_number.errors %}
<div class="invalid-feedback">{{ form.room_number.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-4">
{{ form.floor.label(class="form-label fw-semibold") }}
{{ form.floor(class="form-control" + (" is-invalid" if form.floor.errors else "")) }}
{% if form.floor.errors %}
<div class="invalid-feedback">{{ form.floor.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-1">
{{ form.capacity.label(class="form-label fw-semibold mb-0") }}
<button type="button" class="btn btn-sm btn-outline-primary py-0 px-2" id="btnInfinity" style="font-size: 0.8rem;" title="{% trans %}Asignar capacidad ilimitada{% endtrans %}">
<i class="bi bi-infinity me-1"></i>{% trans %}Ilimitada (∞){% endtrans %}
</button>
</div>
<div class="input-group">
{{ form.capacity(class="form-control" + (" is-invalid" if form.capacity.errors else ""), id="capacityInput", placeholder="ej. 35 o ∞") }}
<span class="input-group-text bg-body-tertiary text-muted font-monospace"><i class="bi bi-people"></i></span>
</div>
<div class="form-text small text-muted mt-1">
<i class="bi bi-info-circle text-info me-1"></i>{% trans %}Para aulas virtuales o eventos sin aforo restringido, ingrese <strong>∞</strong> o <strong>0</strong> (Capacidad ilimitada).{% endtrans %}
</div>
{% if form.capacity.errors %}
<div class="invalid-feedback d-block">{{ form.capacity.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-6 d-flex align-items-center pt-md-4">
<div class="form-check form-switch pt-2">
{{ form.is_active(class="form-check-input", role="switch") }}
{{ form.is_active.label(class="form-check-label fw-semibold") }}
</div>
</div>
<div class="col-12">
{{ form.description.label(class="form-label fw-semibold") }}
{{ form.description(class="form-control" + (" is-invalid" if form.description.errors else ""), rows="3", placeholder="Detalles sobre equipamiento o uso preferente del aula...") }}
{% if form.description.errors %}
<div class="invalid-feedback">{{ form.description.errors[0] }}</div>
{% endif %}
</div>
</div>
<!-- Live Preview Card -->
<div class="alert alert-info border-0 mt-4 mb-0">
<h6 class="alert-heading fw-bold mb-2">
<i class="bi bi-info-circle me-1"></i>{% trans %}Vista Previa de Identificador{% endtrans %}
</h6>
<div id="classroom-preview">
<p class="mb-0">
{% trans %}Código de visualización{% endtrans %}:
<strong id="preview-code" class="text-primary font-monospace fs-6">{{ classroom.code_display }}</strong>
</p>
</div>
</div>
<div class="d-flex justify-content-end gap-2 mt-4 pt-3 border-top">
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}" class="btn btn-outline-secondary">
{% trans %}Cancelar{% endtrans %}
</a>
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-check-circle me-1"></i>{% trans %}Guardar Cambios{% endtrans %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
var buildingInput = document.getElementById('building');
var roomInput = document.getElementById('room_number');
var previewCode = document.getElementById('preview-code');
var capacityInput = document.getElementById('capacityInput');
var btnInfinity = document.getElementById('btnInfinity');
function updatePreview() {
var b = buildingInput ? buildingInput.value.trim() : '';
var r = roomInput ? roomInput.value.trim() : '';
if (b && r) {
previewCode.textContent = b + '-' + r;
} else if (r) {
previewCode.textContent = r;
} else {
previewCode.textContent = '---';
}
}
if (buildingInput) buildingInput.addEventListener('input', updatePreview);
if (roomInput) roomInput.addEventListener('input', updatePreview);
if (btnInfinity && capacityInput) {
btnInfinity.addEventListener('click', function() {
capacityInput.value = '∞';
capacityInput.focus();
});
}
});
</script>
{% endblock %}
+207
View File
@@ -0,0 +1,207 @@
{% extends "base.html" %}
{% block title %}{% trans %}Classrooms{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">
<i class="bi bi-door-open text-primary"></i> {% trans %}Classrooms{% endtrans %}
</h1>
<div>
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> {% trans %}Add Classroom{% endtrans %}
</a>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="row">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-body">
<form method="GET" class="row g-3">
<div class="col-md-4">
<input type="text" name="search" class="form-control"
placeholder="{% trans %}Search by building, room, description...{% endtrans %}"
value="{{ search }}">
</div>
<div class="col-md-3">
<div class="form-check pt-2">
<input class="form-check-input" type="checkbox" name="active_only"
id="activeOnly" {% if active_only %}checked{% endif %}>
<label class="form-check-label" for="activeOnly">
{% trans %}Show active classrooms only{% endtrans %}
</label>
</div>
</div>
<div class="col-md-3">
<button type="submit" class="btn btn-outline-primary">
<i class="bi bi-search"></i> {% trans %}Search{% endtrans %}
</button>
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle"></i> {% trans %}Clear{% endtrans %}
</a>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Classrooms Grid -->
{% if classrooms and classrooms.items %}
<div class="row mt-4">
{% for classroom in classrooms.items %}
<div class="col-lg-4 col-md-6 mb-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h5 class="card-title mb-1">
{{ classroom.code_display }}
</h5>
<p class="text-muted small mb-2">
<i class="bi bi-geo-alt"></i> {{ classroom.get_full_location() }}
</p>
</div>
<span class="badge {% if classroom.is_active %}bg-success{% else %}bg-secondary{% endif %}">
{% if classroom.is_active %}{% trans %}Active{% endtrans %}{% else %}{% trans %}Inactive{% endtrans %}{% endif %}
</span>
</div>
<div class="row small">
<div class="col-6">
<span class="text-muted">{% trans %}Capacity{% endtrans %}:</span>
<strong>
{% if classroom.is_unlimited %}
<span class="badge bg-primary-subtle text-primary border"><i class="bi bi-infinity me-1"></i>∞</span>
{% else %}
{{ classroom.capacity }}
{% endif %}
</strong>
</div>
<div class="col-6">
<span class="text-muted">{% trans %}Building{% endtrans %}:</span>
<strong>{{ classroom.building }}</strong>
</div>
</div>
{% if classroom.description %}
<p class="card-text small text-muted mt-2">
{{ classroom.description[:100] }}{% if classroom.description|length > 100 %}...{% endif %}
</p>
{% endif %}
<!-- Resources indicator -->
<div class="mt-2">
<small class="text-muted">
<i class="bi bi-tools"></i>
{% if classroom.resources %}
{{ classroom.resources|length }} {% trans %}resources{% endtrans %}
{% else %}
{% trans %}No resources registered{% endtrans %}
{% endif %}
</small>
</div>
</div>
<div class="card-footer bg-body-tertiary border-0">
<div class="d-flex justify-content-between">
<div>
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> {% trans %}View{% endtrans %}
</a>
<a href="{{ url_for('classrooms.edit_classroom', id=classroom.id) }}"
class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil"></i> {% trans %}Edit{% endtrans %}
</a>
</div>
<form method="POST" action="{{ url_for('classrooms.toggle_classroom', id=classroom.id) }}"
class="d-inline">
<button type="submit" class="btn btn-sm
{% if classroom.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %}">
{% if classroom.is_active %}
<i class="bi bi-pause-circle"></i> {% trans %}Deactivate{% endtrans %}
{% else %}
<i class="bi bi-play-circle"></i> {% trans %}Activate{% endtrans %}
{% endif %}
</button>
</form>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Pagination -->
{% if classrooms.pages > 1 %}
<nav aria-label="Classroom pagination">
<ul class="pagination justify-content-center">
{% if classrooms.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.prev_num, search=search, active_only=active_only) }}">
{% trans %}Previous{% endtrans %}
</a>
</li>
{% endif %}
{% for page_num in classrooms.iter_pages() %}
{% if page_num %}
{% if page_num != classrooms.page %}
<li class="page-item">
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=page_num, search=search, active_only=active_only) }}">
{{ page_num }}
</a>
</li>
{% else %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% endif %}
{% else %}
<li class="page-item disabled">
<span class="page-link">…</span>
</li>
{% endif %}
{% endfor %}
{% if classrooms.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.next_num, search=search, active_only=active_only) }}">
{% trans %}Next{% endtrans %}
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="row mt-4">
<div class="col-12">
<div class="text-center py-5">
<i class="bi bi-door-open text-muted" style="font-size: 4rem;"></i>
<h4 class="mt-3 text-muted">{% trans %}No classrooms found{% endtrans %}</h4>
<p class="text-muted">
{% if search %}
{% trans %}No classrooms match your search criteria.{% endtrans %}
{% else %}
{% trans %}No classrooms have been registered yet.{% endtrans %}
{% endif %}
</p>
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> {% trans %}Add First Classroom{% endtrans %}
</a>
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}{{ classroom.code_display }} - Classroom Details - Edu-Space Admin{% endblock %}
{% block title %}{{ classroom.code_display }} - {% trans %}Classroom Details{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
@@ -9,7 +9,7 @@
<div class="col-md-7">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1 small">
<li class="breadcrumb-item"><a href="/classrooms/list_classrooms">Classrooms</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('classrooms.list_classrooms') }}">{% trans %}Classrooms{% endtrans %}</a></li>
<li class="breadcrumb-item active">{{ classroom.code_display }}</li>
</ol>
</nav>
@@ -17,23 +17,23 @@
<i class="bi bi-door-open text-primary"></i> {{ classroom.code_display }}
{% if classroom.is_active %}
<span class="badge bg-success-subtle text-success border border-success-subtle fs-6">
<i class="bi bi-check-circle me-1"></i>Active
<i class="bi bi-check-circle me-1"></i>{% trans %}Active{% endtrans %}
</span>
{% else %}
<span class="badge bg-danger-subtle text-danger border border-danger-subtle fs-6">
<i class="bi bi-x-circle me-1"></i>Inactive
<i class="bi bi-x-circle me-1"></i>{% trans %}Inactive{% endtrans %}
</span>
{% endif %}
</h1>
</div>
<div class="col-md-5 text-md-end mt-3 mt-md-0">
{% if user.has_permission('classrooms', 'read_write') %}
<a href="/classrooms/edit_classroom" class="btn btn-primary me-2">
<i class="bi bi-pencil me-1"></i>Edit Classroom
{% if current_user.has_permission('classrooms', 'read_write') %}
<a href="{{ url_for('classrooms.edit_classroom', id=classroom.id) }}" class="btn btn-primary me-2">
<i class="bi bi-pencil me-1"></i>{% trans %}Edit Classroom{% endtrans %}
</a>
{% endif %}
<a href="/classrooms/list_classrooms" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Classrooms
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% trans %}Back to Classrooms{% endtrans %}
</a>
</div>
</div>
@@ -44,16 +44,16 @@
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-body-tertiary border-0 py-3">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-info-circle text-primary me-2"></i>Ficha Técnica
<i class="bi bi-info-circle text-primary me-2"></i>{% trans %}Ficha Técnica{% endtrans %}
</h5>
</div>
<div class="card-body p-3">
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-building me-2"></i>Edificio / Sede</span>
<span class="text-muted"><i class="bi bi-building me-2"></i>{% trans %}Edificio / Sede{% endtrans %}</span>
<span class="fw-bold">
{% if classroom.building_entity %}
<a href="/buildings/view_building" class="text-decoration-none">
<a href="{{ url_for('buildings.view_building', id=classroom.building_entity.id) }}" class="text-decoration-none">
{{ classroom.building_entity.name }}
</a>
{% else %}
@@ -62,28 +62,28 @@
</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-door-closed me-2"></i>Número / Nombre</span>
<span class="text-muted"><i class="bi bi-door-closed me-2"></i>{% trans %}Número / Nombre{% endtrans %}</span>
<span class="fw-bold font-monospace">{{ classroom.code }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-layers me-2"></i>Piso / Planta</span>
<span class="text-muted"><i class="bi bi-layers me-2"></i>{% trans %}Piso / Planta{% endtrans %}</span>
<span class="fw-bold">{% if classroom.floor == 0 %}Planta Baja{% else %}Piso {{ classroom.floor }}{% endif %}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-people me-2"></i>Capacidad Máxima</span>
<span class="text-muted"><i class="bi bi-people me-2"></i>{% trans %}Capacidad Máxima{% endtrans %}</span>
{% if classroom.is_unlimited %}
<span class="badge bg-primary-subtle text-primary border px-2 py-1 fs-6">
<i class="bi bi-infinity me-1"></i>Ilimitada (∞)
<i class="bi bi-infinity me-1"></i>{% trans %}Ilimitada (∞){% endtrans %}
</span>
{% else %}
<span class="fw-bold text-success fs-6">{{ classroom.capacity }} estudiantes</span>
<span class="fw-bold text-success fs-6">{{ classroom.capacity }} {% trans %}estudiantes{% endtrans %}</span>
{% endif %}
</li>
</ul>
{% if classroom.description %}
<div class="mt-3 pt-3 border-top">
<small class="text-muted fw-bold d-block mb-1">Descripción / Notas:</small>
<small class="text-muted fw-bold d-block mb-1">{% trans %}Descripción / Notas{% endtrans %}:</small>
<p class="small text-secondary mb-0">{{ classroom.description }}</p>
</div>
{% endif %}
@@ -96,10 +96,10 @@
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-calendar-check text-primary me-2"></i>Próximas Clases y Reservas
<i class="bi bi-calendar-check text-primary me-2"></i>{% trans %}Próximas Clases y Reservas{% endtrans %}
</h5>
<a href="/schedule/calendar_view" class="btn btn-outline-primary btn-sm">
<i class="bi bi-calendar-week me-1"></i>Ver en Calendario
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-calendar-week me-1"></i>{% trans %}Ver en Calendario{% endtrans %}
</a>
</div>
<div class="card-body p-0">
@@ -108,22 +108,19 @@
<table class="table table-hover align-middle mb-0">
<thead class="table-light small text-uppercase">
<tr>
<th class="ps-3">Fecha y Horario</th>
<th>Materia / Motivo</th>
<th>Comisión</th>
<th>Asistentes</th>
<th class="text-end pe-3">Acciones</th>
<th class="ps-3">{% trans %}Horario{% endtrans %}</th>
<th>{% trans %}Materia / Motivo{% endtrans %}</th>
<th>{% trans %}Comisión{% endtrans %}</th>
<th>{% trans %}Asistentes{% endtrans %}</th>
<th class="text-end pe-3">{% trans %}Acciones{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for res in upcoming_reservations %}
<tr>
<td class="ps-3">
<div class="fw-bold font-monospace text-primary fs-6 mb-1">
<i class="bi bi-calendar-event text-primary me-1"></i>{{ res.start_time }}
</div>
<div class="small font-monospace text-body-secondary">
<i class="bi bi-clock text-warning me-1"></i>{{ res.start_time }} - {{ res.end_time }} hs
<div class="fw-bold font-monospace text-primary fs-6">
<i class="bi bi-clock text-warning me-1"></i>{{ res.start_time.strftime('%H:%M') }} - {{ res.end_time.strftime('%H:%M') }} hs
</div>
</td>
<td>
@@ -139,12 +136,7 @@
<span class="small">{{ res.expected_attendees }} / {{ classroom.capacity }}</span>
</td>
<td class="text-end pe-3">
{% if res.effective_virtual_link %}
<a href="{{ res.effective_virtual_link }}" target="_blank" class="btn btn-outline-success btn-sm py-0 px-2 me-1" title="Unirse a Clase Virtual (Zoom / Meet)">
<i class="bi bi-camera-video"></i>
</a>
{% endif %}
<a href="/schedule/view_reservation?id={{ res.id }}" class="btn btn-outline-secondary btn-sm py-0 px-2" title="Ver Ficha">
<a href="{{ url_for('schedule.view_reservation', id=res.id) }}" class="btn btn-outline-secondary btn-sm py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
<i class="bi bi-eye"></i>
</a>
</td>
@@ -156,11 +148,11 @@
{% else %}
<div class="text-center py-5">
<i class="bi bi-calendar-x text-muted fs-1 mb-2"></i>
<h6 class="fw-bold">Sin reservas programadas próximamente</h6>
<p class="text-muted small mb-3">Esta aula no tiene clases asignadas en las próximas fechas.</p>
{% if user.has_permission('reservations', 'read_write') %}
<a href="/schedule/add_reservation" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle me-1"></i>Asignar Nueva Reserva
<h6 class="fw-bold">{% trans %}Sin reservas programadas próximamente{% endtrans %}</h6>
<p class="text-muted small mb-3">{% trans %}Esta aula no tiene clases asignadas en las próximas fechas.{% endtrans %}</p>
{% if current_user.has_permission('reservations', 'read_write') %}
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Asignar Nueva Reserva{% endtrans %}
</a>
{% endif %}
</div>
+296
View File
@@ -0,0 +1,296 @@
{% extends "base.html" %}
{% block title %}{% trans %}Dashboard - Edu-Space Admin{% endtrans %}{% endblock %}
{% block content %}
{% if current_user.is_authenticated %}
<div class="container-fluid pt-5 mt-4">
{% endif %}
<div class="row mb-4">
<div class="col-12">
<h1 class="h3 mb-1">
<i class="bi bi-speedometer2 text-primary me-2"></i>{% trans %}Panel de Control{% endtrans %}
</h1>
<p class="text-muted small mb-0">{% trans %}Resumen general de espacios, materias y reservas académicas{% endtrans %}</p>
</div>
</div>
<!-- Statistics Cards with Direct Shortcuts -->
<div class="row mb-4 g-3">
<!-- 1. Total Aulas -->
<div class="col-md-6 col-xl-3">
<a href="{{ url_for('classrooms.list_classrooms') }}" class="text-decoration-none text-reset d-block h-100">
<div class="card border-0 shadow-sm h-100 dashboard-stat-card transition-all">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Total de Aulas{% endtrans %}</span>
<h2 class="mb-0 fw-bold mt-1 text-primary">{{ stats.total_classrooms }}</h2>
</div>
<div class="bg-primary bg-opacity-10 rounded-circle p-3 text-primary">
<i class="bi bi-door-open fs-3"></i>
</div>
</div>
<div class="d-flex align-items-center justify-content-between pt-3 mt-2 border-top text-primary small fw-semibold">
<span>{% trans %}Ver todas las aulas{% endtrans %}</span>
<i class="bi bi-arrow-right"></i>
</div>
</div>
</div>
</a>
</div>
<!-- 2. Total Materias -->
<div class="col-md-6 col-xl-3">
<a href="{{ url_for('schedule.commissions_view') }}" class="text-decoration-none text-reset d-block h-100">
<div class="card border-0 shadow-sm h-100 dashboard-stat-card transition-all">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Total de Materias{% endtrans %}</span>
<h2 class="mb-0 fw-bold mt-1 text-success">{{ stats.total_subjects }}</h2>
</div>
<div class="bg-success bg-opacity-10 rounded-circle p-3 text-success">
<i class="bi bi-book fs-3"></i>
</div>
</div>
<div class="d-flex align-items-center justify-content-between pt-3 mt-2 border-top text-success small fw-semibold">
<span>{% trans %}Gestionar materias{% endtrans %}</span>
<i class="bi bi-arrow-right"></i>
</div>
</div>
</div>
</a>
</div>
<!-- 3. Reservas de Hoy -->
<div class="col-md-6 col-xl-3">
<a href="{{ url_for('schedule.today_schedule') }}" class="text-decoration-none text-reset d-block h-100">
<div class="card border-0 shadow-sm h-100 dashboard-stat-card transition-all">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Reservas de Hoy{% endtrans %}</span>
<h2 class="mb-0 fw-bold mt-1 text-info">{{ stats.today_reservations }}</h2>
</div>
<div class="bg-info bg-opacity-10 rounded-circle p-3 text-info">
<i class="bi bi-calendar-check fs-3"></i>
</div>
</div>
<div class="d-flex align-items-center justify-content-between pt-3 mt-2 border-top text-info small fw-semibold">
<span>{% trans %}Ver cartelera de hoy{% endtrans %}</span>
<i class="bi bi-arrow-right"></i>
</div>
</div>
</div>
</a>
</div>
<!-- 4. Esta Semana -->
<div class="col-md-6 col-xl-3">
<a href="{{ url_for('schedule.calendar_view') }}" class="text-decoration-none text-reset d-block h-100">
<div class="card border-0 shadow-sm h-100 dashboard-stat-card transition-all">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Esta Semana{% endtrans %}</span>
<h2 class="mb-0 fw-bold mt-1 text-warning">{{ stats.week_reservations }}</h2>
</div>
<div class="bg-warning bg-opacity-10 rounded-circle p-3 text-warning">
<i class="bi bi-calendar-week fs-3"></i>
</div>
</div>
<div class="d-flex align-items-center justify-content-between pt-3 mt-2 border-top text-warning small fw-semibold">
<span>{% trans %}Abrir calendario semanal{% endtrans %}</span>
<i class="bi bi-arrow-right"></i>
</div>
</div>
</div>
</a>
</div>
</div>
<div class="row">
<!-- Monthly Chart -->
<div class="col-md-8 mb-4">
<div class="card border-0 shadow-sm">
<div class="card-header border-0 py-3 bg-body-tertiary">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-graph-up text-primary me-2"></i>{% trans %}Reservas Mensuales{% endtrans %}
</h5>
</div>
<div class="card-body">
<canvas id="monthlyChart" height="100"></canvas>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="col-md-4 mb-4">
<div class="card border-0 shadow-sm">
<div class="card-header border-0 py-3 bg-body-tertiary">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-lightning text-warning me-2"></i>{% trans %}Accesos Rápidos{% endtrans %}
</h5>
</div>
<div class="card-body p-3">
<div class="d-grid gap-2">
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary text-start">
<i class="bi bi-calendar3 me-2"></i>{% trans %}Calendario de Reservas{% endtrans %}
</a>
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info text-start">
<i class="bi bi-calendar-day me-2"></i>{% trans %}Cartelera del Día{% endtrans %}
</a>
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary text-start">
<i class="bi bi-door-open me-2"></i>{% trans %}Listado de Aulas{% endtrans %}
</a>
<a href="{{ url_for('buildings.list_buildings') }}" class="btn btn-outline-secondary text-start">
<i class="bi bi-buildings me-2"></i>{% trans %}Gestión de Edificios{% endtrans %}
</a>
{% if current_user.has_permission('reservations', 'read_write') %}
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-outline-success text-start">
<i class="bi bi-calendar-plus me-2"></i>{% trans %}Nueva Reserva{% endtrans %}
</a>
{% endif %}
{% if current_user.has_permission('classrooms', 'read_write') %}
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-outline-primary text-start">
<i class="bi bi-plus-circle me-2"></i>{% trans %}Crear Nueva Aula{% endtrans %}
</a>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Upcoming Reservations -->
<div class="row">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header border-0 py-3 bg-body-tertiary d-flex justify-content-between align-items-center">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-clock-history text-info me-2"></i>{% trans %}Próximas Reservas (Próximos 7 Días){% endtrans %}
</h5>
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-sm btn-outline-primary">
{% trans %}Ver en Calendario{% endtrans %}
</a>
</div>
<div class="card-body p-0">
{% if upcoming_reservations %}
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light small text-uppercase">
<tr>
<th class="ps-3">{% trans %}Horario{% endtrans %}</th>
<th>{% trans %}Aula{% endtrans %}</th>
<th>{% trans %}Asignatura / Código{% endtrans %}</th>
<th>{% trans %}Turno / Comisión{% endtrans %}</th>
<th>{% trans %}Estado{% endtrans %}</th>
<th class="text-end pe-3">{% trans %}Acciones{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for reservation in upcoming_reservations %}
<tr>
<td class="ps-3">
<div class="fw-bold font-monospace text-primary fs-6">
<i class="bi bi-clock text-warning me-1"></i>{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }} hs
</div>
</td>
<td>
<span class="badge bg-secondary-subtle text-secondary border font-monospace">
{{ reservation.classroom.code if reservation.classroom else 'Sin Aula' }}
</span>
</td>
<td>
<div class="fw-semibold text-primary">{{ reservation.subject_name }}</div>
<small class="text-muted font-monospace">{{ reservation.subject_code }}</small>
</td>
<td>
<span class="badge bg-info-subtle text-info border me-1">
{{ reservation.shift or 'Tarde' }}
</span>
<small class="text-muted">{{ reservation.commission.code if reservation.commission else 'C1' }}</small>
</td>
<td>
{% if reservation.status == 'CONFIRMED' or reservation.status.value == 'CONFIRMED' %}
<span class="badge bg-success-subtle text-success border border-success-subtle">
<i class="bi bi-check-circle me-1"></i>{% trans %}Confirmada{% endtrans %}
</span>
{% else %}
<span class="badge bg-warning-subtle text-warning border border-warning-subtle">
<i class="bi bi-clock me-1"></i>{% trans %}Pendiente{% endtrans %}
</span>
{% endif %}
</td>
<td class="text-end pe-3">
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}"
class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-calendar-x text-muted fs-1 d-block mb-2"></i>
<h6 class="fw-bold">{% trans %}No hay reservas para los próximos 7 días{% endtrans %}</h6>
<p class="text-muted small">{% trans %}Puedes programar nuevas reservas desde el calendario.{% endtrans %}</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% if current_user.is_authenticated %}
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
$(document).ready(function() {
// Fetch monthly data and create chart
$.get('/api/dashboard-stats')
.done(function(data) {
const ctx = document.getElementById('monthlyChart').getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: data.monthly_data.map(item => item.month),
datasets: [{
label: 'Reservations',
data: data.monthly_data.map(item => item.reservations),
borderColor: '#667eea',
backgroundColor: 'rgba(102, 126, 234, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1
}
}
}
}
});
});
});
</script>
{% endblock %}
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}AI Room Reservation Optimizer - Edu-Space Admin{% endblock %}
{% block title %}{% trans %}AI Room Reservation Optimizer{% endtrans %} - Edu-Space Admin{% endblock %}
{% block extra_css %}
<style>
@@ -67,10 +67,10 @@
</div>
<div>
<h1 class="h3 mb-1 fw-bold">
AI Room Reservation Optimizer
{% trans %}AI Room Reservation Optimizer{% endtrans %}
</h1>
<p class="text-muted mb-0">
Use genetic algorithms to automatically optimize room assignments based on capacity, availability, and student enrollment.
{% trans %}Use genetic algorithms to automatically optimize room assignments based on capacity, availability, and student enrollment.{% endtrans %}
</p>
</div>
</div>
@@ -83,115 +83,68 @@
<div class="row mb-4">
<!-- Algorithm Status Card -->
<div class="col-lg-4 mb-4">
<div class="algorithm-status" style="z-index: 10;">
<div class="card border-0 shadow-sm mb-4">
<div class="card border-0 shadow-sm algorithm-status">
<div class="card-header bg-body-tertiary border-0 py-3">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-gear-wide-connected text-primary me-2"></i>
Algorithm Status
{% trans %}Algorithm Status{% endtrans %}
</h5>
</div>
<div class="card-body">
<div class="mb-4">
<small class="text-muted text-uppercase fw-semibold d-block mb-2">System Status</small>
<small class="text-muted text-uppercase fw-semibold d-block mb-2">{% trans %}System Status{% endtrans %}</small>
<div class="d-flex justify-content-between align-items-center py-1 border-bottom border-body-secondary">
<span>Available Classrooms</span>
<span>{% trans %}Available Classrooms{% endtrans %}</span>
<span class="badge bg-success" id="available-classrooms">--</span>
</div>
<div class="d-flex justify-content-between align-items-center py-1 border-bottom border-body-secondary">
<span>Active Commissions</span>
<span>{% trans %}Active Commissions{% endtrans %}</span>
<span class="badge bg-info" id="active-commissions">--</span>
</div>
<div class="d-flex justify-content-between align-items-center py-1">
<span>Existing Reservations</span>
<span>{% trans %}Existing Reservations{% endtrans %}</span>
<span class="badge bg-secondary" id="existing-reservations">--</span>
</div>
</div>
<div class="mb-4">
<small class="text-muted text-uppercase fw-semibold d-block mb-2">Algorithm Configuration</small>
<small class="text-muted text-uppercase fw-semibold d-block mb-2">{% trans %}Algorithm Configuration{% endtrans %}</small>
<div class="d-flex justify-content-between align-items-center py-1 border-bottom border-body-secondary">
<span>Population Size</span>
<span>{% trans %}Population Size{% endtrans %}</span>
<span class="text-muted">50</span>
</div>
<div class="d-flex justify-content-between align-items-center py-1 border-bottom border-body-secondary">
<span>Generations</span>
<span>{% trans %}Generations{% endtrans %}</span>
<span class="text-muted">100</span>
</div>
<div class="d-flex justify-content-between align-items-center py-1">
<span>Mutation Rate</span>
<span>{% trans %}Mutation Rate{% endtrans %}</span>
<span class="text-muted">10%</span>
</div>
</div>
<button class="btn btn-sm btn-outline-primary w-100" onclick="window.refreshStatus()">
<button class="btn btn-sm btn-outline-primary w-100" onclick="geneticManager.getAlgorithmStatus()">
<i class="bi bi-arrow-clockwise me-1"></i>
Refresh Status
{% trans %}Refresh Status{% endtrans %}
</button>
</div>
</div>
</div>
<!-- Optimization Controls -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary border-0 py-3">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-sliders text-primary me-2"></i>
Optimization Controls
</h5>
</div>
<div class="card-body">
<div class="row align-items-center mb-3">
<div class="col-12">
<div class="mb-3">
<label for="start-date" class="form-label">Start Date</label>
<input type="datetime-local" class="form-control" id="start-date" name="start_date">
</div>
</div>
<div class="col-12">
<div class="mb-3">
<label for="end-date" class="form-label">End Date</label>
<input type="datetime-local" class="form-control" id="end-date" name="end_date">
</div>
</div>
</div>
<div class="d-grid gap-2">
<button class="btn btn-info text-white" id="preview-optimization-btn">
<i class="bi bi-eye me-1"></i>
Preview
</button>
<button class="btn btn-success" id="optimize-assignments-btn">
<i class="bi bi-play-circle me-1"></i>
Run Full Optimization
</button>
<button class="btn btn-warning" id="apply-optimization-btn" style="display: none;">
<i class="bi bi-check-circle me-1"></i>
Apply
</button>
<button class="btn btn-outline-secondary" onclick="resetOptimization()">
<i class="bi bi-arrow-counterclockwise me-1"></i>
Reset
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Commission Selection -->
<div class="col-lg-8">
<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">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-list-check text-primary me-2"></i>
Select Commissions to Optimize
{% trans %}Select Commissions to Optimize{% endtrans %}
</h5>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary" onclick="selectAllCommissions()">
Select All
{% trans %}Select All{% endtrans %}
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="deselectAllCommissions()">
Deselect All
{% trans %}Deselect All{% endtrans %}
</button>
</div>
</div>
@@ -200,7 +153,7 @@
<!-- Commissions will be loaded here -->
<div class="col-12 text-center text-muted py-5">
<div class="spinner-border spinner-border-sm me-2 text-primary" role="status"></div>
Loading commissions...
{% trans %}Loading commissions...{% endtrans %}
</div>
</div>
</div>
@@ -208,7 +161,54 @@
</div>
</div>
<!-- Optimization Controls -->
<div class="row mb-4">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary border-0 py-3">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-sliders text-primary me-2"></i>
{% trans %}Optimization Controls{% endtrans %}
</h5>
</div>
<div class="card-body">
<div class="row align-items-center mb-3">
<div class="col-md-6">
<div class="mb-3">
<label for="start-date" class="form-label">{% trans %}Start Date Range{% endtrans %}</label>
<input type="datetime-local" class="form-control" id="start-date" name="start_date">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="end-date" class="form-label">{% trans %}End Date Range{% endtrans %}</label>
<input type="datetime-local" class="form-control" id="end-date" name="end_date">
</div>
</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-info text-white" id="preview-optimization-btn">
<i class="bi bi-eye me-1"></i>
{% trans %}Preview Optimization{% endtrans %}
</button>
<button class="btn btn-success" id="optimize-assignments-btn">
<i class="bi bi-play-circle me-1"></i>
{% trans %}Run Full Optimization{% endtrans %}
</button>
<button class="btn btn-warning" id="apply-optimization-btn" style="display: none;">
<i class="bi bi-check-circle me-1"></i>
{% trans %}Apply Reservations{% endtrans %}
</button>
<button class="btn btn-outline-secondary" onclick="resetOptimization()">
<i class="bi bi-arrow-counterclockwise me-1"></i>
{% trans %}Reset{% endtrans %}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Results Section -->
<div class="row mb-4">
@@ -224,8 +224,8 @@
<div class="spinner-overlay" id="loading-spinner">
<div class="text-center text-white">
<div class="spinner-border text-primary" style="width: 3rem; height: 3rem;" role="status"></div>
<p class="mt-3 mb-1 fw-bold fs-5">Running genetic algorithm optimization...</p>
<small class="text-white-50">This may take a few moments</small>
<p class="mt-3 mb-1 fw-bold fs-5">{% trans %}Running genetic algorithm optimization...{% endtrans %}</p>
<small class="text-white-50">{% trans %}This may take a few moments{% endtrans %}</small>
</div>
</div>
@@ -234,17 +234,17 @@
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="detailsModalLabel">Reservation Details</h5>
<h5 class="modal-title" id="detailsModalLabel">{% trans %}Reservation Details{% endtrans %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<!-- Content populated dynamically -->
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans %}Close{% endtrans %}</button>
<button type="button" class="btn btn-primary" onclick="downloadReservations()">
<i class="bi bi-download me-1"></i>
Download
{% trans %}Download{% endtrans %}
</button>
</div>
</div>
@@ -253,7 +253,7 @@
{% endblock %}
{% block extra_js %}
<script src="/js/genetic-algorithm.js"></script>
<script src="{{ url_for('static', filename='js/genetic-algorithm.js') }}"></script>
<script>
function selectAllCommissions() {
const checkboxes = document.querySelectorAll('input[name="commission_ids"]');
@@ -292,25 +292,11 @@ function resetOptimization() {
document.getElementById('end-date').value = '';
}
window.refreshStatus = async function() {
if (window.geneticManager) {
try {
const statusResult = await geneticManager.getAlgorithmStatus();
if (statusResult && statusResult.success && statusResult.data) {
const sysStats = statusResult.data.system_status;
document.getElementById('available-classrooms').textContent = sysStats.available_classrooms;
document.getElementById('active-commissions').textContent = sysStats.active_commissions;
document.getElementById('existing-reservations').textContent = sysStats.confirmed_reservations;
}
} catch (e) {
console.error("Error refreshing status:", e);
}
}
}
document.addEventListener('DOMContentLoaded', async function() {
await loadCommissions();
await window.refreshStatus();
if (window.geneticManager) {
await geneticManager.getAlgorithmStatus();
}
});
async function loadCommissions() {
@@ -318,7 +304,7 @@ async function loadCommissions() {
const response = await fetch('/api/genetic/commissions');
const data = await response.json();
const commissionsList = document.getElementById('commissions-list');
const isSpanish = '{{ session.language or "es" }}' === 'es';
const isSpanish = '{{ session.get("language", "es") }}' === 'es';
if (data.success && data.commissions && data.commissions.length > 0) {
const commissionsHtml = data.commissions.map(commission => `
@@ -13,7 +13,7 @@
<ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="languageDropdown">
<li>
<a class="dropdown-item d-flex align-items-center justify-content-between {% if current_lang == 'es' %}active{% endif %}"
href="/main/set_language">
href="{{ url_for('main.set_language', language='es') }}">
<span>Español</span>
{% if current_lang == 'es' %}
<i class="bi bi-check-lg ms-2 text-white"></i>
@@ -22,7 +22,7 @@
</li>
<li>
<a class="dropdown-item d-flex align-items-center justify-content-between {% if current_lang == 'en' %}active{% endif %}"
href="/main/set_language">
href="{{ url_for('main.set_language', language='en') }}">
<span>English</span>
{% if current_lang == 'en' %}
<i class="bi bi-check-lg ms-2 text-white"></i>
+373
View File
@@ -0,0 +1,373 @@
{% extends "base.html" %}
{% block title %}{% trans %}New Reservation{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">
<i class="bi bi-calendar-plus text-success"></i> {% trans %}New Reservation{% endtrans %}
</h1>
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> {% trans %}Back to Reservations{% endtrans %}
</a>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-8 mx-auto">
<div class="card border-0 shadow-sm">
<div class="card-header border-0 py-3">
<h5 class="mb-0">
<i class="bi bi-calendar3 text-success"></i> {% trans %}Reservation Details{% endtrans %}
</h5>
</div>
<div class="card-body">
<form method="POST" id="reservation-form" novalidate>
{{ form.hidden_tag() }}
<div class="row">
<div class="col-md-6 mb-3">
{{ form.classroom_id.label(class="form-label") }}
{{ form.classroom_id(class="form-select", id="classroom-select") }}
{% if form.classroom_id.errors %}
<div class="text-danger small mt-1">
{{ form.classroom_id.errors[0] }}
</div>
{% endif %}
<small class="text-muted">{% trans %}Select a classroom for your reservation{% endtrans %}</small>
</div>
<div class="col-md-6 mb-3">
{{ form.commission_id.label(class="form-label") }}
{{ form.commission_id(class="form-select", id="commission-select") }}
{% if form.commission_id.errors %}
<div class="text-danger small mt-1">
{{ form.commission_id.errors[0] }}
</div>
{% endif %}
<small class="text-muted">{% trans %}Select the subject/commission{% endtrans %}</small>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
{{ form.start_time.label(class="form-label") }}
{{ form.start_time(class="form-control", id="start-time") }}
{% if form.start_time.errors %}
<div class="text-danger small mt-1">
{{ form.start_time.errors[0] }}
</div>
{% endif %}
<small class="text-muted">{% trans %}When will the class start?{% endtrans %}</small>
</div>
<div class="col-md-6 mb-3">
{{ form.end_time.label(class="form-label") }}
{{ form.end_time(class="form-control", id="end-time") }}
{% if form.end_time.errors %}
<div class="text-danger small mt-1">
{{ form.end_time.errors[0] }}
</div>
{% endif %}
<small class="text-muted">{% trans %}When will the class end?{% endtrans %}</small>
</div>
</div>
<div class="row">
<div class="col-md-8 mb-3">
{{ form.purpose.label(class="form-label") }}
{{ form.purpose(class="form-control", placeholder=_("Regular class, exam, study session, etc.")) }}
{% if form.purpose.errors %}
<div class="text-danger small mt-1">
{{ form.purpose.errors[0] }}
</div>
{% endif %}
</div>
<div class="col-md-4 mb-3">
{{ form.expected_attendees.label(class="form-label") }}
{{ form.expected_attendees(class="form-control", placeholder="30") }}
{% if form.expected_attendees.errors %}
<div class="text-danger small mt-1">
{{ form.expected_attendees.errors[0] }}
</div>
{% endif %}
<small class="text-muted">{% trans %}Number of expected attendees{% endtrans %}</small>
</div>
</div>
<div class="mb-4">
{{ form.notes.label(class="form-label") }}
{{ form.notes(class="form-control", rows="3", placeholder=_("Any additional notes or special requirements...")) }}
{% if form.notes.errors %}
<div class="text-danger small mt-1">
{{ form.notes.errors[0] }}
</div>
{% endif %}
</div>
<!-- Summary Section -->
<div class="alert alert-info" id="reservation-summary">
<h6 class="alert-heading">
<i class="bi bi-info-circle"></i> {% trans %}Reservation Summary{% endtrans %}
</h6>
<div id="summary-content">
<p class="mb-2">
<strong>{% trans %}Selected Classroom:{% endtrans %}</strong>
<span id="summary-classroom">{% trans %}Not selected{% endtrans %}</span>
</p>
<p class="mb-2">
<strong>{% trans %}Duration:{% endtrans %}</strong>
<span id="summary-duration">{% trans %}Not calculated{% endtrans %}</span>
</p>
<p class="mb-0">
<strong>{% trans %}Commission:{% endtrans %}</strong>
<span id="summary-commission">{% trans %}Not selected{% endtrans %}</span>
</p>
</div>
</div>
<div class="d-flex justify-content-between">
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle"></i> {% trans %}Cancel{% endtrans %}
</a>
{{ form.submit(class="btn btn-success") }}
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Instructions Card -->
<div class="row mt-4">
<div class="col-lg-8 mx-auto">
<div class="card border-0 shadow-sm">
<div class="card-header border-0 py-3">
<h5 class="mb-0">
<i class="bi bi-question-circle text-info"></i> {% trans %}Reservation Guidelines{% endtrans %}
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="text-primary">📅 {% trans %}Booking Rules{% endtrans %}</h6>
<ul class="small">
<li>{% trans %}Reservations can be made up to 3 months in advance{% endtrans %}</li>
<li>{% trans %}Cannot book in the past{% endtrans %}</li>
<li>{% trans %}Maximum duration: 8 hours per reservation{% endtrans %}</li>
<li>{% trans %}Cancellations must be 24 hours before start time{% endtrans %}</li>
</ul>
</div>
<div class="col-md-6">
<h6 class="text-warning">🚨 {% trans %}Important Notes{% endtrans %}</h6>
<ul class="small">
<li>{% trans %}Check classroom capacity before booking{% endtrans %}</li>
<li>{% trans %}Pending reservations require approval{% endtrans %}</li>
<li>{% trans %}Time slots must not overlap{% endtrans %}</li>
<li>{% trans %}System automatically detects conflicts{% endtrans %}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script type="application/json" id="classroom-data">
{
{% for classroom in classroom_list %}
"{{ classroom.id }}": {
"capacity": {{ classroom.capacity }},
"code_display": {{ classroom.code_display|tojson }},
"name": {{ classroom.name|tojson }}
}{% if not loop.last %},{% endif %}
{% endfor %}
}
</script>
<script type="application/json" id="i18n-strings">
{
"not_selected": "{% trans %}Not selected{% endtrans %}",
"not_calculated": "{% trans %}Not calculated{% endtrans %}",
"invalid_time": "{% trans %}Invalid time range{% endtrans %}",
"hour": "{% trans %}hour{% endtrans %}",
"hours": "{% trans %}hours{% endtrans %}",
"minute": "{% trans %}minute{% endtrans %}",
"minutes": "{% trans %}minutes{% endtrans %}",
"capacity": "{% trans %}Capacity{% endtrans %}",
"capacity_exceeded": "{% trans %}Expected attendees exceeds classroom capacity{% endtrans %}"
}
</script>
<script>
$(document).ready(function() {
// Classroom data for capacity checking
let classroomData = {};
try {
const rawData = document.getElementById('classroom-data')?.textContent;
if (rawData) {
classroomData = JSON.parse(rawData);
}
} catch (err) {
console.error('Error parsing classroomData:', err);
}
// I18n strings
let i18n = {};
try {
const rawI18n = document.getElementById('i18n-strings')?.textContent;
if (rawI18n) {
i18n = JSON.parse(rawI18n);
}
} catch (err) {
console.error('Error parsing i18n:', err);
}
function updateSummary() {
const classroomId = $('#classroom-select').val();
const commissionId = $('#commission-select').val();
const startTime = $('#start-time').val();
const endTime = $('#end-time').val();
// Update classroom summary
if (classroomId && classroomData[classroomId]) {
const capLabel = i18n.capacity || 'Capacidad';
$('#summary-classroom').text(`${classroomData[classroomId].code_display} - ${classroomData[classroomId].name} (${capLabel}: ${classroomData[classroomId].capacity})`);
} else {
$('#summary-classroom').text(i18n.not_selected || 'No seleccionada');
}
// Update commission summary
if (commissionId) {
const commissionText = $('#commission-select option:selected').text();
$('#summary-commission').text(commissionText);
} else {
$('#summary-commission').text(i18n.not_selected || 'No seleccionada');
}
// Calculate duration
if (startTime && endTime) {
const start = new Date(startTime);
const end = new Date(endTime);
if (end > start) {
const diffMs = end - start;
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
let durationText = '';
if (diffHours > 0) {
const hLabel = diffHours > 1 ? (i18n.hours || 'horas') : (i18n.hour || 'hora');
durationText += `${diffHours} ${hLabel}`;
}
if (diffMins > 0) {
if (durationText) durationText += ' ';
const mLabel = diffMins > 1 ? (i18n.minutes || 'minutos') : (i18n.minute || 'minuto');
durationText += `${diffMins} ${mLabel}`;
}
$('#summary-duration').text(durationText || '0 min');
} else {
$('#summary-duration').text(i18n.invalid_time || 'Rango horario inválido');
}
} else {
$('#summary-duration').text(i18n.not_calculated || 'No calculada');
}
}
// Check capacity vs expected attendees
function checkCapacity() {
const classroomId = $('#classroom-select').val();
const attendees = parseInt($('#expected_attendees').val()) || 0;
if (classroomId && classroomData[classroomId] && attendees > 0) {
const capacity = classroomData[classroomId].capacity;
if (attendees > capacity) {
$('#expected_attendees').addClass('is-invalid');
const msg = i18n.capacity_exceeded || 'Los asistentes esperados superan la capacidad del aula';
$('#expected_attendees').after(`
<div class="invalid-feedback">
${msg} (${attendees} > ${capacity})
</div>
`);
} else {
$('#expected_attendees').removeClass('is-invalid');
$('#expected_attendees').siblings('.invalid-feedback').remove();
}
}
}
// Update on change
$('#classroom-select, #commission-select, #start-time, #end-time, #expected_attendees').on('change', updateSummary);
$('#expected_attendees').on('input', checkCapacity);
// Initialize summary
updateSummary();
// Form validation
$('#reservation-form').on('submit', function(e) {
let isValid = true;
// Check required fields
if (!$('#classroom-select').val()) {
$('#classroom-select').addClass('is-invalid');
isValid = false;
} else {
$('#classroom-select').removeClass('is-invalid');
}
if (!$('#commission-select').val()) {
$('#commission-select').addClass('is-invalid');
isValid = false;
} else {
$('#commission-select').removeClass('is-invalid');
}
if (!$('#start-time').val()) {
$('#start-time').addClass('is-invalid');
isValid = false;
} else {
$('#start-time').removeClass('is-invalid');
}
if (!$('#end-time').val()) {
$('#end-time').addClass('is-invalid');
isValid = false;
} else {
$('#end-time').removeClass('is-invalid');
}
// Validate time range
const startTime = new Date($('#start-time').val());
const endTime = new Date($('#end-time').val());
if (endTime <= startTime) {
$('#end-time').addClass('is-invalid');
isValid = false;
}
// Check capacity
checkCapacity();
if ($('#expected_attendees').hasClass('is-invalid')) {
isValid = false;
}
return isValid;
});
// Set minimum datetime to current
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
$('#start-time, #end-time').attr('min', now.toISOString().slice(0, 16));
});
</script>
{% endblock %}
+585
View File
@@ -0,0 +1,585 @@
{% extends "base.html" %}
{% block title %}{% trans %}Reservations Calendar{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="row mb-4 align-items-center">
<div class="col-md-7">
<h1 class="h3 mb-1">
<i class="bi bi-calendar3 text-primary me-2"></i>{% trans %}Reservations Calendar{% endtrans %}
</h1>
<p class="text-muted small mb-0">{% trans %}View & Manage Reservations{% endtrans %} — {% trans %}Horarios de cursada y asignación de aulas UniCABA{% endtrans %}</p>
</div>
<div class="col-md-5 text-md-end mt-3 mt-md-0">
{% if current_user.has_permission('reservations', 'read_write') %}
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-primary me-2">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Add Reservation{% endtrans %}
</a>
{% endif %}
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
<i class="bi bi-list me-1"></i>{% trans %}List View{% endtrans %}
</a>
</div>
</div>
<!-- Filters Toolbar (Classroom & Subject Filters to prevent overlapping) -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-3">
<div class="row g-2 align-items-center">
<!-- Filter by Classroom -->
<div class="col-lg-3 col-md-6">
<label for="filterClassroom" class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-door-open text-success me-1"></i>{% trans %}Aula{% endtrans %}
</label>
<select id="filterClassroom" class="form-select form-select-sm">
<option value="">{% trans %}Todas las aulas{% endtrans %}</option>
{% for c in classrooms %}
<option value="{{ c.id }}">{{ c.code }} ({{ c.building }})</option>
{% endfor %}
</select>
</div>
<!-- Filter by Subject -->
<div class="col-lg-3 col-md-6">
<label for="filterSubject" class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-book text-primary me-1"></i>{% trans %}Materia{% endtrans %}
</label>
<select id="filterSubject" class="form-select form-select-sm">
<option value="">{% trans %}Todas las materias{% endtrans %}</option>
{% for s in subjects %}
<option value="{{ s.id }}">{{ s.name }} [{{ s.code }}]</option>
{% endfor %}
</select>
</div>
<!-- Filter by Career -->
<div class="col-lg-3 col-md-6">
<label for="filterCareer" class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-mortarboard text-info me-1"></i>{% trans %}Carrera{% endtrans %}
</label>
<select id="filterCareer" class="form-select form-select-sm">
<option value="">{% trans %}Todas las carreras{% endtrans %}</option>
{% for cr in careers %}
<option value="{{ cr.id }}">{{ cr.name }}</option>
{% endfor %}
</select>
</div>
<!-- Filter by Turno (Mañana / Tarde) -->
<div class="col-lg-3 col-md-6">
<label for="filterShift" class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-brightness-high text-warning me-1"></i>{% trans %}Turno{% endtrans %}
</label>
<select id="filterShift" class="form-select form-select-sm">
<option value="">{% trans %}Todos los turnos{% endtrans %}</option>
<option value="Mañana">{% trans %}Turno Mañana{% endtrans %}</option>
<option value="Tarde">{% trans %}Turno Tarde / Vespertino{% endtrans %}</option>
</select>
</div>
</div>
<!-- Second row of filters: Floor & Hide Virtual -->
<div class="row g-2 align-items-center mt-2 pt-2 border-top">
<!-- Filter by Floor -->
<div class="col-lg-3 col-md-4">
<label for="filterFloor" class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-layers text-secondary me-1"></i>{% trans %}Filtrar por Piso{% endtrans %}
</label>
<select id="filterFloor" class="form-select form-select-sm">
<option value="">{% trans %}Todos los pisos{% endtrans %}</option>
{% for f in available_floors %}
<option value="{{ f }}">{% if f == 0 %}{% trans %}Planta Baja (Piso 0){% endtrans %}{% else %}{% trans %}Piso{% endtrans %} {{ f }}{% endif %}</option>
{% endfor %}
</select>
</div>
<!-- Hide Virtual Classrooms Toggle -->
<div class="col-lg-6 col-md-5 pt-md-3">
<div class="form-check form-switch pt-1">
<input class="form-check-input" type="checkbox" role="switch" id="filterHideVirtual">
<label class="form-check-label fw-semibold small text-dark" for="filterHideVirtual">
<i class="bi bi-laptop me-1 text-primary"></i>{% trans %}Ocultar Aulas Virtuales{% endtrans %}
<span class="badge bg-secondary-subtle text-secondary border ms-1" style="font-size: 0.72rem;">{% trans %}Capacidad ilimitada{% endtrans %}</span>
</label>
</div>
</div>
<!-- Reset Filters Button -->
<div class="col-lg-3 col-md-3 text-md-end pt-md-3">
<button type="button" id="btnResetFilters" class="btn btn-outline-secondary btn-sm px-3" title="{% trans %}Restablecer filtros{% endtrans %}">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% trans %}Limpiar Filtros{% endtrans %}
</button>
</div>
</div>
<!-- Active filter indicator & counter -->
<div class="d-flex align-items-center flex-wrap gap-2 mt-2 pt-2 border-top">
<small class="text-muted fw-semibold">
<i class="bi bi-funnel-fill text-secondary me-1"></i>{% trans %}Estado{% endtrans %}:
</small>
<span id="activeFilterBadge" class="badge bg-secondary-subtle text-secondary border small">
{% trans %}Mostrando todas las aulas y materias{% endtrans %}
</span>
<span class="badge badge-brand ms-auto small">
<i class="bi bi-calendar-check me-1"></i><span id="eventCountNumber">0</span> {% trans %}clases visibles{% endtrans %}
</span>
</div>
</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>{% trans %}Referencia{% endtrans %}:
</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">{% trans %}Clase Confirmada{% endtrans %}</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">{% trans %}Pendiente{% endtrans %}</small>
</div>
</div>
<div class="text-muted small">
<i class="bi bi-lightning-charge-fill text-warning me-1"></i>{% trans %}Haz clic en cualquier clase para ver los detalles completos{% endtrans %}
</div>
</div>
</div>
<div class="card-body p-3">
<!-- FullCalendar Container -->
<div id="calendar"></div>
</div>
</div>
<!-- Reservation Details Modal -->
<div class="modal fade" id="reservationModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header border-0 pb-0">
<div>
<span id="modalCategoryBadge" class="badge badge-brand mb-1">Clase Académica</span>
<h4 class="modal-title fw-bold text-primary" id="modalSubjectTitle">Detalle de la Clase</h4>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body pt-3" id="reservationDetails">
<div class="row g-3">
<div class="col-md-6">
<div class="p-3 rounded bg-body-tertiary h-100">
<h6 class="text-muted small text-uppercase fw-bold mb-2">
<i class="bi bi-book text-primary me-1"></i>{% trans %}Materia y Código{% endtrans %}
</h6>
<p class="h6 fw-bold mb-1" id="modalSubjectName">-</p>
<p class="mb-2">
<span class="badge bg-dark bg-opacity-75 font-monospace text-white" id="modalSubjectCode">-</span>
</p>
<p class="small text-muted mb-0" id="modalCareerWrap">
<i class="bi bi-mortarboard me-1"></i><span id="modalCareer">-</span>
</p>
</div>
</div>
<div class="col-md-6">
<div class="p-3 rounded bg-body-tertiary h-100">
<h6 class="text-muted small text-uppercase fw-bold mb-2">
<i class="bi bi-door-open text-success me-1"></i>{% trans %}Espacio Asignado{% endtrans %}
</h6>
<p class="h5 fw-bold text-success mb-1" id="modalClassroom">-</p>
<p class="small text-muted mb-0" id="modalBuilding">-</p>
</div>
</div>
<div class="col-md-6">
<div class="p-3 rounded bg-body-tertiary h-100">
<h6 class="text-muted small text-uppercase fw-bold mb-2">
<i class="bi bi-clock text-warning me-1"></i>{% trans %}Horario Programado{% endtrans %}
</h6>
<p class="h6 fw-bold mb-0" id="modalTimeRange">-</p>
</div>
</div>
<div class="col-md-6">
<div class="p-3 rounded bg-body-tertiary h-100">
<h6 class="text-muted small text-uppercase fw-bold mb-2">
<i class="bi bi-shield-check text-info me-1"></i>{% trans %}Estado y Capacidad{% endtrans %}
</h6>
<p class="mb-1">
<span class="badge bg-success-subtle text-success border border-success-subtle px-2 py-1" id="modalStatus">-</span>
</p>
<small class="text-muted" id="modalAttendees">-</small>
</div>
</div>
</div>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Close{% endtrans %}</button>
<a href="#" id="modalFullLink" class="btn btn-primary">
<i class="bi bi-box-arrow-up-right me-1"></i>{% trans %}Ver Ficha Completa{% endtrans %}
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_css %}
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@5.11.0/main.min.css" rel="stylesheet">
<style>
/* FullCalendar Modern Overrides */
#calendar {
font-family: inherit;
min-height: 700px;
}
.fc .fc-toolbar-title {
font-size: 1.25rem !important;
font-weight: 700 !important;
color: var(--text-primary);
}
.fc .fc-button-primary {
background: var(--gradient-primary, linear-gradient(135deg, #B43E8E 0%, #9E367D 100%)) !important;
border: none !important;
border-radius: 6px !important;
font-weight: 600 !important;
font-size: 0.85rem !important;
padding: 0.35rem 0.75rem !important;
box-shadow: 0 2px 4px rgba(180, 62, 142, 0.25) !important;
}
.fc .fc-button-primary:hover {
background: linear-gradient(135deg, #9E367D 0%, #832765 100%) !important;
}
.fc .fc-button-primary:disabled {
background: #94A3B8 !important;
opacity: 0.6 !important;
}
.fc-theme-standard td, .fc-theme-standard th {
border-color: var(--card-border, #E2E8F0) !important;
}
.fc .fc-col-header-cell-cushion {
color: var(--text-primary);
font-weight: 600;
padding: 8px 4px !important;
text-decoration: none !important;
}
.fc .fc-timegrid-slot-label-cushion {
color: var(--text-secondary, #64748B);
font-size: 0.78rem;
}
/* Event Card Visual Hierarchy */
.fc-event {
cursor: pointer;
border: none !important;
border-radius: 8px !important;
overflow: hidden !important;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12) !important;
transition: transform 0.15s ease, box-shadow 0.15s ease !important;
}
.fc-event:hover {
transform: translateY(-2px) scale(1.01);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.22) !important;
z-index: 10 !important;
}
.fc-event-confirmed {
background: linear-gradient(135deg, #B43E8E 0%, #832765 100%) !important;
color: #FFFFFF !important;
}
.fc-event-pending {
background: linear-gradient(135deg, #F59E0B 0%, #D97706 100%) !important;
color: #FFFFFF !important;
}
.fc-event-custom-container {
padding: 5px 7px;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.fc-event-time-pill {
font-size: 0.72rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
margin-bottom: 2px;
display: flex;
align-items: center;
}
/* High Visual Hierarchy for Subject Name */
.fc-event-subject-title {
font-size: 0.88rem !important;
font-weight: 700 !important;
line-height: 1.22 !important;
color: #FFFFFF !important;
letter-spacing: -0.15px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
margin-bottom: 4px;
}
/* Secondary Badges */
.fc-event-badges {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 3px;
margin-top: auto;
}
.fc-badge-code {
background-color: rgba(0, 0, 0, 0.3) !important;
color: #FFFFFF !important;
font-size: 0.70rem !important;
font-weight: 600 !important;
padding: 2px 5px !important;
border-radius: 4px !important;
}
.fc-badge-room {
background-color: rgba(255, 255, 255, 0.28) !important;
color: #FFFFFF !important;
font-size: 0.70rem !important;
font-weight: 700 !important;
padding: 2px 5px !important;
border-radius: 4px !important;
display: inline-flex;
align-items: center;
}
/* Month View Optimization */
.fc-daygrid-event .fc-event-custom-container {
padding: 2px 4px;
}
.fc-daygrid-event .fc-event-subject-title {
font-size: 0.78rem !important;
-webkit-line-clamp: 1;
}
</style>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.11.0/main.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.11.0/locales-all.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var calendarEl = document.getElementById('calendar');
var currentLang = '{{ session.get("language", "es") }}';
var todayDate = '{{ today_date or "2026-09-03" }}';
var filterRoom = document.getElementById('filterClassroom');
var filterSubj = document.getElementById('filterSubject');
var filterCar = document.getElementById('filterCareer');
var filterShift = document.getElementById('filterShift');
var filterFloor = document.getElementById('filterFloor');
var filterHideVirtual = document.getElementById('filterHideVirtual');
var activeBadge = document.getElementById('activeFilterBadge');
var countEl = document.getElementById('eventCountNumber');
function updateFilterSummaryBadge() {
var parts = [];
if (filterRoom.value && filterRoom.selectedOptions[0]) {
parts.push('Aula: ' + filterRoom.selectedOptions[0].text.split('(')[0].trim());
}
if (filterSubj.value && filterSubj.selectedOptions[0]) {
parts.push('Materia: ' + filterSubj.selectedOptions[0].text.split('[')[0].trim());
}
if (filterCar.value && filterCar.selectedOptions[0]) {
parts.push('Carrera: ' + filterCar.selectedOptions[0].text.trim());
}
if (filterShift && filterShift.value) {
parts.push('Turno ' + filterShift.value);
}
if (filterFloor && filterFloor.value) {
parts.push(filterFloor.value === '0' ? 'Planta Baja' : 'Piso ' + filterFloor.value);
}
if (filterHideVirtual && filterHideVirtual.checked) {
parts.push('Sin Virtuales');
}
if (parts.length > 0) {
activeBadge.className = 'badge bg-primary text-white border small';
activeBadge.textContent = parts.join(' | ');
} else {
activeBadge.className = 'badge bg-secondary-subtle text-secondary border small';
activeBadge.textContent = 'Mostrando todas las aulas y materias';
}
}
var calendar = new FullCalendar.Calendar(calendarEl, {
locale: currentLang,
initialView: 'timeGridWeek',
initialDate: todayDate,
allDaySlot: false,
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
},
eventTimeFormat: {
hour: '2-digit',
minute: '2-digit',
hour12: false
},
slotMinTime: '07:30:00',
slotMaxTime: '22:30:00',
height: 'auto',
slotEventOverlap: true,
/* Dynamic Server-Side Filtered Events */
events: function(info, successCallback, failureCallback) {
var params = new URLSearchParams({
start: info.startStr,
end: info.endStr,
classroom_id: filterRoom.value || '',
subject_id: filterSubj.value || '',
career_id: filterCar.value || '',
shift: (filterShift ? filterShift.value : '') || '',
floor: (filterFloor ? filterFloor.value : '') || '',
hide_virtual: (filterHideVirtual && filterHideVirtual.checked) ? '1' : ''
});
updateFilterSummaryBadge();
fetch('{{ url_for("schedule.calendar_data") }}?' + params.toString())
.then(function(response) {
if (!response.ok) throw new Error('Network error');
return response.json();
})
.then(function(eventsData) {
if (countEl) {
countEl.textContent = eventsData.length;
}
successCallback(eventsData);
})
.catch(function(err) {
console.error('Error loading calendar events:', err);
failureCallback(err);
});
},
/* Custom Card Render with Visual Hierarchy */
eventContent: function(arg) {
var ext = arg.event.extendedProps || {};
var subjectName = ext.subject_name || arg.event.title || '';
var subjectCode = ext.subject_code || '';
var classroom = ext.classroom || '';
var timeText = arg.timeText || '';
var container = document.createElement('div');
container.className = 'fc-event-custom-container';
var timeHtml = timeText ? `<div class="fc-event-time-pill"><i class="bi bi-clock me-1"></i>${timeText}</div>` : '';
var titleHtml = `<div class="fc-event-subject-title" title="${subjectName}">${subjectName}</div>`;
var badgesHtml = `
<div class="fc-event-badges">
${subjectCode ? `<span class="fc-badge-code font-monospace">${subjectCode}</span>` : ''}
${classroom ? `<span class="fc-badge-room"><i class="bi bi-door-open me-1"></i>${classroom}</span>` : ''}
</div>`;
container.innerHTML = timeHtml + titleHtml + badgesHtml;
return { domNodes: [container] };
},
/* Interactive Instant Modal */
eventClick: function(info) {
info.jsEvent.preventDefault();
var ext = info.event.extendedProps || {};
var subjectName = ext.subject_name || info.event.title || '';
var subjectCode = ext.subject_code || '-';
var classroom = ext.classroom || 'Sin Aula';
var building = ext.building || 'Edificio Central';
var career = ext.career || 'General';
var status = ext.status || 'CONFIRMED';
var attendees = ext.attendees || 0;
var startStr = info.event.start ? info.event.start.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', hour12: false}) : '';
var endStr = info.event.end ? info.event.end.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', hour12: false}) : '';
var dateStr = info.event.start ? info.event.start.toLocaleDateString([], {weekday: 'long', day: 'numeric', month: 'long'}) : '';
document.getElementById('modalSubjectTitle').textContent = subjectName;
document.getElementById('modalSubjectName').textContent = subjectName;
document.getElementById('modalSubjectCode').textContent = subjectCode;
document.getElementById('modalCareer').textContent = career;
document.getElementById('modalClassroom').textContent = classroom;
document.getElementById('modalBuilding').textContent = building;
document.getElementById('modalTimeRange').textContent = `${dateStr} (${startStr} a ${endStr} hs)`;
document.getElementById('modalStatus').textContent = status;
document.getElementById('modalAttendees').textContent = `${attendees} estudiantes esperados`;
if (info.event.url) {
var btn = document.getElementById('modalFullLink');
btn.href = info.event.url;
btn.classList.remove('d-none');
} else {
document.getElementById('modalFullLink').classList.add('d-none');
}
var modal = new bootstrap.Modal(document.getElementById('reservationModal'));
modal.show();
}
});
calendar.render();
// Event listeners to trigger immediate calendar refetch on filter change
filterRoom.addEventListener('change', function() {
calendar.refetchEvents();
});
filterSubj.addEventListener('change', function() {
calendar.refetchEvents();
});
filterCar.addEventListener('change', function() {
calendar.refetchEvents();
});
if (filterShift) {
filterShift.addEventListener('change', function() {
calendar.refetchEvents();
});
}
if (filterFloor) {
filterFloor.addEventListener('change', function() {
calendar.refetchEvents();
});
}
if (filterHideVirtual) {
filterHideVirtual.addEventListener('change', function() {
calendar.refetchEvents();
});
}
document.getElementById('btnResetFilters').addEventListener('click', function() {
filterRoom.value = '';
filterSubj.value = '';
filterCar.value = '';
if (filterShift) filterShift.value = '';
if (filterFloor) filterFloor.value = '';
if (filterHideVirtual) filterHideVirtual.checked = false;
calendar.refetchEvents();
});
});
</script>
{% endblock %}
+390
View File
@@ -0,0 +1,390 @@
{% extends "base.html" %}
{% block title %}Commissions Management - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">
<i class="bi bi-diagram-3 text-info"></i> Commissions Management
</h1>
<div class="btn-group">
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success">
<i class="bi bi-plus-circle"></i> New Reservation
</a>
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-secondary">
<i class="bi bi-calendar-day"></i> Today's Schedule
</a>
</div>
</div>
</div>
</div>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body text-center">
<h2 class="text-primary">{{ subjects|length }}</h2>
<p class="mb-0">Active Subjects</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body text-center">
<h2 class="text-success">{{ subjects|map(attribute='commissions')|map('length')|sum }}</h2>
<p class="mb-0">Total Commissions</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body text-center">
<h2 class="text-success">{{ total_commissions }}</h2>
<h2 class="text-info">{{ total_capacity }}</h2>
<p class="mb-0">Total Student Capacity</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body text-center">
<h2 class="text-warning">{{ subjects|selectattr('commissions')|list|length }}</h2>
<p class="mb-0">Subjects with Commissions</p>
</div>
</div>
</div>
</div>
<!-- Subjects and Commissions -->
<div class="row">
{% for subject in subjects %}
<div class="col-lg-6 mb-4">
<div class="card border-0 shadow-sm">
<div class="card-header border-0 py-3">
<div class="d-flex justify-content-between align-items-start">
<div>
<h5 class="mb-1">
<span class="badge bg-primary me-2">{{ subject.code }}</span>
{{ subject.name }}
</h5>
<p class="text-muted mb-0 small">
<i class="bi bi-building"></i> {{ subject.department }} •
<i class="bi bi-award"></i> {{ subject.credits }} credits
</p>
</div>
<div class="text-end">
<span class="badge bg-info">{{ subject.commissions|length }} commission{{ 's' if subject.commissions|length != 1 else '' }}</span>
</div>
</div>
</div>
<div class="card-body">
{% if subject.commissions %}
<div class="table-responsive">
<table class="table table-sm table-hover">
<thead class="table-light">
<tr>
<th>Commission</th>
<th>Semester</th>
<th>Teacher</th>
<th>Students</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for commission in subject.commissions %}
<tr>
<td>
<strong>{{ commission.code }}</strong>
{% if commission.schedule %}
<br><small class="text-muted">{{ commission.schedule }}</small>
{% endif %}
</td>
<td>
<span class="badge bg-light text-dark">{{ commission.semester }} {{ commission.year }}</span>
</td>
<td>
{% if commission.teacher %}
<small>{{ commission.teacher.first_name }} {{ commission.teacher.last_name }}</small>
{% else %}
<small class="text-muted">Not assigned</small>
{% endif %}
</td>
<td>
<div class="d-flex align-items-center">
<small>{{ commission.current_students }}/{{ commission.max_students }}</small>
{% set fill_percentage = (commission.current_students / commission.max_students * 100) %}
<div class="progress ms-2" style="height: 4px; width: 30px;">
<div class="progress-bar
{% if fill_percentage > 90 %}bg-danger
{% elif fill_percentage > 75 %}bg-warning
{% else %}bg-success
{% endif %}"
style="width: {{ fill_percentage }}%"></div>
</div>
</div>
</td>
<td>
{% if commission.active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary quick-reserve-btn"
data-commission-id="{{ commission.id }}"
data-max-students="{{ commission.max_students }}"
data-commission-name="{{ commission.get_full_code() }}"
data-subject-name="{{ subject.name }}"
title="Quick Reservation">
<i class="bi bi-calendar-plus"></i>
</button>
<button class="btn btn-outline-info view-schedule-btn"
data-commission-id="{{ commission.id }}"
title="View Schedule">
<i class="bi bi-calendar-week"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-3">
<i class="bi bi-inbox fs-2 d-block mb-2"></i>
No commissions registered for this subject
</div>
{% endif %}
</div>
{% if subject.description %}
<div class="card-footer bg-light">
<small class="text-muted">
<i class="bi bi-info-circle"></i> {{ subject.description|truncate(100) }}
</small>
</div>
{% endif %}
</div>
</div>
{% else %}
<div class="col-12">
<div class="text-center py-5">
<i class="bi bi-inbox fs-1 text-muted d-block mb-3"></i>
<h4 class="text-muted">No Subjects Found</h4>
<p class="text-muted">There are no active subjects in the system.</p>
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-primary">
<i class="bi bi-door-open"></i> View Classrooms
</a>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Quick Reservation Modal -->
<div class="modal fade" id="quickReservationModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-calendar-plus text-success"></i> Quick Reservation
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="quick-reservation-form">
<input type="hidden" id="quick-commission-id" name="commission_id">
<div class="alert alert-info">
<h6 class="alert-heading">
<i class="bi bi-info-circle"></i> Commission Details
</h6>
<div id="commission-details"></div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="quick-date" class="form-label">Date</label>
<input type="date" class="form-control" id="quick-date" required>
</div>
<div class="col-md-3 mb-3">
<label for="quick-start-time" class="form-label">Start Time</label>
<input type="time" class="form-control" id="quick-start-time" required>
</div>
<div class="col-md-3 mb-3">
<label for="quick-end-time" class="form-label">End Time</label>
<input type="time" class="form-control" id="quick-end-time" required>
</div>
</div>
<div class="row">
<div class="col-md-12 mb-3">
<label for="quick-purpose" class="form-label">Purpose</label>
<input type="text" class="form-control" id="quick-purpose" placeholder="Regular class, exam, review, etc." required>
</div>
</div>
<!-- Classroom Selection & Availability -->
<div class="mb-3">
<label class="form-label">Select Classroom</label>
<div class="d-flex align-items-center mb-2">
<select class="form-select" id="quick-classroom-select" required>
<option value="">Choose date and time first</option>
</select>
<button type="button" class="btn btn-outline-primary ms-2" id="check-availability-btn" disabled>
<i class="bi bi-search"></i> Check
</button>
</div>
<div id="availability-results" class="mt-3"></div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" id="create-reservation-btn" disabled>
<i class="bi bi-plus-circle"></i> Create Reservation
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
$(document).ready(function() {
// Quick Reservation Button Click
$('.quick-reserve-btn').on('click', function() {
const commissionId = $(this).data('commission-id');
const maxStudents = $(this).data('max-students');
const commissionName = $(this).data('commission-name');
const subjectName = $(this).data('subject-name');
// Set commission details in modal
$('#quick-commission-id').val(commissionId);
$('#commission-details').html(`
<strong>${commissionName}</strong><br>
<small>Subject: ${subjectName}</small><br>
<small>Max Students: ${maxStudents}</small>
`);
// Reset form
$('#quick-date').val('');
$('#quick-start-time').val('');
$('#quick-end-time').val('');
$('#quick-purpose').val('');
$('#quick-classroom-select').html('<option value="">Choose date and time first</option>').prop('disabled', true);
$('#check-availability-btn').prop('disabled', true);
$('#create-reservation-btn').prop('disabled', true);
$('#availability-results').empty();
// Show modal
$('#quickReservationModal').modal('show');
});
// Enable date/time inputs to check availability
function validateDateTimeInputs() {
const date = $('#quick-date').val();
const startTime = $('#quick-start-time').val();
const endTime = $('#quick-end-time').val();
const isValid = date && startTime && endTime && startTime < endTime;
$('#check-availability-btn').prop('disabled', !isValid);
if (isValid) {
loadClassroomOptions();
}
}
$('#quick-date, #quick-start-time, #quick-end-time').on('change', validateDateTimeInputs);
function loadClassroomOptions() {
$.get('/schedule/api/classroom-availability', {
date: $('#quick-date').val(),
start_time: $('#quick-start-time').val(),
end_time: $('#quick-end-time').val(),
max_students: parseInt($('#quick-commission-id').data('max-students'))
}, function(response) {
const classroomSelect = $('#quick-classroom-select');
classroomSelect.empty().append('<option value="">Select a classroom</option>');
// Add available classrooms
response.availability.available.forEach(function(classroom) {
classroomSelect.append(`<option value="${classroom.id}">${classroom.code} - ${classroom.name} (Cap: ${classroom.capacity}) - ${classroom.location}</option>`);
});
// Enable selection
classroomSelect.prop('disabled', response.availability.available.length === 0);
// Show recommendation if available
if (response.recommendation) {
const rec = response.recommendation.classroom;
$('#availability-results').html(`
<div class="alert alert-success">
<h6><i class="bi bi-star-fill"></i> Recommended Classroom</h6>
<strong>${rec.code} - ${rec.name}</strong><br>
<small>Capacity: ${rec.capacity} | Location: ${rec.location}</small><br>
<small class="text-muted">${response.recommendation.reason}</small>
</div>
`);
}
// Show statistics
$('#availability-results').append(`
<div class="alert alert-info mt-2">
<small>
<strong>Availability:</strong> ${response.availability.stats.total_available} available,
${response.availability.stats.total_occupied} occupied classrooms
</small>
</div>
`);
});
});
}
// Check availability button click
$('#check-availability-btn').on('click', loadClassroomOptions);
// Classroom selection enables create button
$('#quick-classroom-select').on('change', function() {
const isValid = $(this).val() && $('#quick-purpose').val();
$('#create-reservation-btn').prop('disabled', !isValid);
});
// Purpose input validation
$('#quick-purpose').on('input', function() {
const isValid = $('#quick-classroom-select').val() && $(this).val();
$('#create-reservation-btn').prop('disabled', !isValid);
});
// Create reservation
$('#create-reservation-btn').on('click', function() {
const formData = {
commission_id: $('#quick-commission-id').val(),
classroom_id: $('#quick-classroom-select').val(),
purpose: $('#quick-purpose').val(),
start_time: `${$('#quick-date').val()} ${$('#quick-start-time').val()}`,
end_time: `${$('#quick-date').val()} ${$('#quick-end-time').val()}`,
expected_attendees: $('#quick-commission-id').data('max-students')
};
$.post('/schedule/reservation/add', formData)
.done(function() {
$('#quickReservationModal').modal('hide');
location.reload();
})
.fail(function(xhr) {
alert('Error creating reservation: ' + xhr.responseJSON.error);
});
});
});
</script>
{% endblock %}
+253
View File
@@ -0,0 +1,253 @@
{% extends "base.html" %}
{% block title %}{% trans %}Listado de Reservas{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="row mb-4 align-items-center">
<div class="col-md-7">
<h1 class="h3 mb-1">
<i class="bi bi-calendar-check text-primary me-2"></i>{% trans %}Listado de Reservas{% endtrans %}
</h1>
<p class="text-muted small mb-0">{% trans %}Registro histórico y programación completa de clases y asignación de aulas{% endtrans %}</p>
</div>
<div class="col-md-5 text-md-end mt-3 mt-md-0">
{% if current_user.has_permission('reservations', 'read_write') %}
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success me-2">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nueva Reserva{% endtrans %}
</a>
{% endif %}
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary me-2">
<i class="bi bi-calendar3 me-1"></i>{% trans %}Ver Calendario{% endtrans %}
</a>
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info">
<i class="bi bi-calendar-day me-1"></i>{% trans %}Cartelera de Hoy{% endtrans %}
</a>
</div>
</div>
<!-- Filters Card -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-3">
<form method="GET" action="{{ url_for('schedule.list_reservations') }}" class="row g-2 align-items-end">
<!-- Search text -->
<div class="col-lg-3 col-md-6">
<label class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-search me-1"></i>{% trans %}Buscar{% endtrans %}
</label>
<input type="text" name="search" class="form-control form-control-sm" placeholder="Materia, código o aula..." value="{{ search or '' }}">
</div>
<!-- Status Filter -->
<div class="col-lg-2 col-md-3">
<label class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-shield-check me-1"></i>{% trans %}Estado{% endtrans %}
</label>
<select name="status" class="form-select form-select-sm">
<option value="all" {% if status_filter == 'all' or not status_filter %}selected{% endif %}>{% trans %}Todos los estados{% endtrans %}</option>
<option value="confirmed" {% if status_filter == 'confirmed' %}selected{% endif %}>{% trans %}Confirmadas{% endtrans %}</option>
<option value="pending" {% if status_filter == 'pending' %}selected{% endif %}>{% trans %}Pendientes{% endtrans %}</option>
<option value="cancelled" {% if status_filter == 'cancelled' %}selected{% endif %}>{% trans %}Canceladas{% endtrans %}</option>
</select>
</div>
<!-- Floor Filter -->
<div class="col-lg-2 col-md-3">
<label class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-layers me-1"></i>{% trans %}Piso{% endtrans %}
</label>
<select name="floor" class="form-select form-select-sm">
<option value="">{% trans %}Todos los pisos{% endtrans %}</option>
{% for f in available_floors %}
<option value="{{ f }}" {% if floor_filter == f|string %}selected{% endif %}>
{% if f == 0 %}{% trans %}Planta Baja{% endtrans %}{% else %}{% trans %}Piso{% endtrans %} {{ f }}{% endif %}
</option>
{% endfor %}
</select>
</div>
<!-- Shift Filter -->
<div class="col-lg-2 col-md-4">
<label class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-brightness-high text-warning me-1"></i>{% trans %}Turno{% endtrans %}
</label>
<select name="shift" class="form-select form-select-sm">
<option value="">{% trans %}Todos los turnos{% endtrans %}</option>
<option value="Mañana" {% if shift_filter == 'Mañana' %}selected{% endif %}>{% trans %}Turno Mañana{% endtrans %}</option>
<option value="Tarde" {% if shift_filter == 'Tarde' %}selected{% endif %}>{% trans %}Turno Tarde{% endtrans %}</option>
</select>
</div>
<!-- Date Range -->
<div class="col-lg-3 col-md-4">
<label class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-calendar-event me-1"></i>{% trans %}Desde Fecha{% endtrans %}
</label>
<input type="date" name="date_from" class="form-control form-control-sm" value="{{ date_from or '' }}">
</div>
<!-- Row 2: Hide Virtual, Submit, Clear -->
<div class="col-lg-6 col-md-6 pt-2">
<div class="form-check form-switch pt-1">
<input class="form-check-input" type="checkbox" role="switch" name="hide_virtual" value="1" id="hideVirtualList" {% if hide_virtual %}checked{% endif %}>
<label class="form-check-label fw-semibold small text-dark" for="hideVirtualList">
<i class="bi bi-laptop me-1 text-primary"></i>{% trans %}Ocultar Aulas Virtuales{% endtrans %}
<span class="badge bg-secondary-subtle text-secondary border ms-1" style="font-size: 0.72rem;">{% trans %}Capacidad ilimitada{% endtrans %}</span>
</label>
</div>
</div>
<div class="col-lg-6 col-md-6 pt-2 text-md-end d-flex justify-content-md-end gap-2">
<button type="submit" class="btn btn-primary btn-sm px-3">
<i class="bi bi-funnel me-1"></i>{% trans %}Filtrar{% endtrans %}
</button>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary btn-sm px-3">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% trans %}Limpiar{% endtrans %}
</a>
</div>
</form>
</div>
</div>
<!-- Reservations Table -->
{% if reservations and reservations.items %}
<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">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-table text-primary me-2"></i>{% trans %}Registros Encontrados{% endtrans %}
<span class="badge bg-secondary-subtle text-secondary border ms-2">{{ reservations.total }} {% trans %}reservas{% endtrans %}</span>
</h5>
<small class="text-muted">{% trans %}Página{% endtrans %} {{ reservations.page }} {% trans %}de{% endtrans %} {{ reservations.pages }}</small>
</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 text-uppercase">
<tr>
<th class="ps-3">{% trans %}Fecha y Horario{% endtrans %}</th>
<th>{% trans %}Aula / Piso{% endtrans %}</th>
<th>{% trans %}Asignatura / Código{% endtrans %}</th>
<th>{% trans %}Turno / Comisión{% endtrans %}</th>
<th>{% trans %}Capacidad{% endtrans %}</th>
<th>{% trans %}Estado{% endtrans %}</th>
<th class="text-end pe-3">{% trans %}Acciones{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for reservation in reservations.items %}
<tr>
<td class="ps-3">
<div class="fw-bold">{{ reservation.start_time.strftime('%d/%m/%Y') }}</div>
<small class="text-muted font-monospace">{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }} hs</small>
</td>
<td>
<span class="badge bg-secondary-subtle text-secondary border font-monospace">
{{ reservation.classroom.code if reservation.classroom else 'Sin Aula' }}
</span>
<small class="text-muted d-block">
{% if reservation.classroom and reservation.classroom.floor == 0 %}PB{% elif reservation.classroom %}Piso {{ reservation.classroom.floor }}{% endif %}
{% if reservation.classroom and reservation.classroom.building %} · {{ reservation.classroom.building }}{% endif %}
</small>
</td>
<td>
<div class="fw-bold text-primary">{{ reservation.subject_name }}</div>
<small class="badge bg-secondary-subtle text-secondary font-monospace">{{ reservation.subject_code }}</small>
</td>
<td>
<span class="badge bg-info-subtle text-info border me-1">
{{ reservation.shift or 'Tarde' }}
</span>
<small class="text-muted">{{ reservation.commission.code if reservation.commission else 'C1' }}</small>
</td>
<td>
<span class="small">{{ reservation.expected_attendees }} / {{ reservation.classroom.capacity if reservation.classroom else '-' }}</span>
</td>
<td>
{% if reservation.status == 'CONFIRMED' or reservation.status.value == 'CONFIRMED' %}
<span class="badge bg-success-subtle text-success border border-success-subtle">
<i class="bi bi-check-circle me-1"></i>{% trans %}Confirmada{% endtrans %}
</span>
{% elif reservation.status == 'PENDING' or reservation.status.value == 'PENDING' %}
<span class="badge bg-warning-subtle text-warning border border-warning-subtle">
<i class="bi bi-clock me-1"></i>{% trans %}Pendiente{% endtrans %}
</span>
{% else %}
<span class="badge bg-danger-subtle text-danger border border-danger-subtle">
{% trans %}Cancelada{% endtrans %}
</span>
{% endif %}
</td>
<td class="text-end pe-3">
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}"
class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
<i class="bi bi-eye"></i>
</a>
{% if current_user.has_permission('reservations', 'read_write') and (reservation.status == 'PENDING' or reservation.status.value == 'PENDING') %}
<a href="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}"
class="btn btn-sm btn-outline-success py-0 px-2 ms-1" title="{% trans %}Confirmar{% endtrans %}">
<i class="bi bi-check-lg"></i>
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if reservations.pages > 1 %}
<div class="card-footer bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
<small class="text-muted">{% trans %}Mostrando 20 registros por página{% endtrans %}</small>
<nav>
<ul class="pagination pagination-sm mb-0">
{% if reservations.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.prev_num, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, hide_virtual='1' if hide_virtual else '', search=search) }}">
<i class="bi bi-chevron-left"></i>
</a>
</li>
{% endif %}
{% for p in reservations.iter_pages(left_edge=2, left_current=2, right_current=2, right_edge=2) %}
{% if p %}
{% if p == reservations.page %}
<li class="page-item active"><span class="page-link">{{ p }}</span></li>
{% else %}
<li class="page-item">
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=p, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, hide_virtual='1' if hide_virtual else '', search=search) }}">{{ p }}</a>
</li>
{% endif %}
{% else %}
<li class="page-item disabled"><span class="page-link">…</span></li>
{% endif %}
{% endfor %}
{% if reservations.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.next_num, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, hide_virtual='1' if hide_virtual else '', search=search) }}">
<i class="bi bi-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
</div>
</div>
{% else %}
<div class="card border-0 shadow-sm">
<div class="card-body text-center py-5">
<i class="bi bi-calendar-x text-muted fs-1 d-block mb-3"></i>
<h5 class="fw-bold">{% trans %}No se encontraron reservas{% endtrans %}</h5>
<p class="text-muted small mb-3">{% trans %}No hay registros que coincidan con los criterios de búsqueda o filtros seleccionados.{% endtrans %}</p>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% trans %}Restablecer Todos los Filtros{% endtrans %}
</a>
</div>
</div>
{% endif %}
</div>
{% endblock %}
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Cartelera de Hoy - Edu-Space Admin{% endblock %}
{% block title %}{% trans %}Cartelera de Hoy{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
@@ -8,22 +8,22 @@
<div class="row mb-4 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
<i class="bi bi-calendar-day text-info me-2"></i>{% trans %}Cartelera del Día{% endtrans %}
<small class="text-muted ms-2 fs-6">{{ format_date(today, format='full')|capitalize }}</small>
</h1>
<p class="text-muted small mb-0">Cronograma de clases y asignación de aulas activas para el día de hoy</p>
<p class="text-muted small mb-0">{% trans %}Cronograma de clases y asignación de aulas activas para el día de hoy{% endtrans %}</p>
</div>
<div class="col-md-5 text-md-end mt-3 mt-md-0">
{% if user.has_permission('reservations', 'read_write') %}
<a href="/schedule/add_reservation" class="btn btn-success me-2">
<i class="bi bi-plus-circle me-1"></i>Nueva Reserva
{% if current_user.has_permission('reservations', 'read_write') %}
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success me-2">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nueva Reserva{% endtrans %}
</a>
{% endif %}
<a href="/schedule/calendar_view" class="btn btn-outline-primary me-2">
<i class="bi bi-calendar3 me-1"></i>Ver Calendario
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary me-2">
<i class="bi bi-calendar3 me-1"></i>{% trans %}Ver Calendario{% endtrans %}
</a>
<a href="/schedule/list_reservations" class="btn btn-outline-secondary">
<i class="bi bi-list me-1"></i>Todas las Reservas
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
<i class="bi bi-list me-1"></i>{% trans %}Todas las Reservas{% endtrans %}
</a>
</div>
</div>
@@ -34,7 +34,7 @@
<div class="card border-0 shadow-sm h-100">
<div class="card-body p-3 d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small fw-semibold text-uppercase">Total de Reservas</span>
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Total de Reservas{% endtrans %}</span>
<h2 class="mb-0 fw-bold mt-1 text-info">{{ stats.total_reservations }}</h2>
</div>
<div class="bg-info bg-opacity-10 rounded-circle p-3 text-info">
@@ -47,7 +47,7 @@
<div class="card border-0 shadow-sm h-100">
<div class="card-body p-3 d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small fw-semibold text-uppercase">Confirmadas</span>
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Confirmadas{% endtrans %}</span>
<h2 class="mb-0 fw-bold mt-1 text-success">{{ stats.confirmed }}</h2>
</div>
<div class="bg-success bg-opacity-10 rounded-circle p-3 text-success">
@@ -60,7 +60,7 @@
<div class="card border-0 shadow-sm h-100">
<div class="card-body p-3 d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small fw-semibold text-uppercase">Pendientes</span>
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Pendientes{% endtrans %}</span>
<h2 class="mb-0 fw-bold mt-1 text-warning">{{ stats.pending }}</h2>
</div>
<div class="bg-warning bg-opacity-10 rounded-circle p-3 text-warning">
@@ -73,7 +73,7 @@
<div class="card border-0 shadow-sm h-100">
<div class="card-body p-3 d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small fw-semibold text-uppercase">Aulas Ocupadas</span>
<span class="text-muted small fw-semibold text-uppercase">{% trans %}Aulas Ocupadas{% endtrans %}</span>
<h2 class="mb-0 fw-bold mt-1 text-primary">{{ stats.occupied_classrooms }}/{{ stats.total_classrooms }}</h2>
</div>
<div class="bg-primary bg-opacity-10 rounded-circle p-3 text-primary">
@@ -87,17 +87,17 @@
<!-- Filters Toolbar (Piso, Turno y Ocultar Virtuales) -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-3">
<form method="GET" action="/schedule/today_schedule" class="row g-2 align-items-center">
<form method="GET" action="{{ url_for('schedule.today_schedule') }}" class="row g-2 align-items-center">
<!-- Filter by Floor -->
<div class="col-lg-3 col-md-4">
<label for="floorSelect" class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-layers text-secondary me-1"></i>Piso / Planta
<i class="bi bi-layers text-secondary me-1"></i>{% trans %}Piso / Planta{% endtrans %}
</label>
<select name="floor" id="floorSelect" class="form-select form-select-sm">
<option value="">Todos los pisos</option>
<option value="">{% trans %}Todos los pisos{% endtrans %}</option>
{% for f in available_floors %}
<option value="{{ f }}" {% if floor_filter == f|string %}selected{% endif %}>
{% if f == 0 %}Planta Baja (Piso 0){% else %}Piso {{ f }}{% endif %}
{% if f == 0 %}{% trans %}Planta Baja (Piso 0){% endtrans %}{% else %}{% trans %}Piso{% endtrans %} {{ f }}{% endif %}
</option>
{% endfor %}
</select>
@@ -106,12 +106,12 @@
<!-- Filter by Turno -->
<div class="col-lg-3 col-md-4">
<label for="shiftSelect" class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-brightness-high text-warning me-1"></i>Turno
<i class="bi bi-brightness-high text-warning me-1"></i>{% trans %}Turno{% endtrans %}
</label>
<select name="shift" id="shiftSelect" class="form-select form-select-sm">
<option value="">Todos los turnos</option>
<option value="Mañana" {% if shift_filter == 'Mañana' %}selected{% endif %}>Turno Mañana</option>
<option value="Tarde" {% if shift_filter == 'Tarde' %}selected{% endif %}>Turno Tarde / Vespertino</option>
<option value="">{% trans %}Todos los turnos{% endtrans %}</option>
<option value="Mañana" {% if shift_filter == 'Mañana' %}selected{% endif %}>{% trans %}Turno Mañana{% endtrans %}</option>
<option value="Tarde" {% if shift_filter == 'Tarde' %}selected{% endif %}>{% trans %}Turno Tarde / Vespertino{% endtrans %}</option>
</select>
</div>
@@ -120,8 +120,8 @@
<div class="form-check form-switch pt-1">
<input class="form-check-input" type="checkbox" role="switch" name="hide_virtual" value="1" id="hideVirtualToday" {% if hide_virtual %}checked{% endif %} onchange="this.form.submit()">
<label class="form-check-label fw-semibold small text-dark" for="hideVirtualToday">
<i class="bi bi-laptop me-1 text-primary"></i>Ocultar Aulas Virtuales
<span class="badge bg-secondary-subtle text-secondary border ms-1" style="font-size: 0.72rem;">Capacidad ilimitada</span>
<i class="bi bi-laptop me-1 text-primary"></i>{% trans %}Ocultar Aulas Virtuales{% endtrans %}
<span class="badge bg-secondary-subtle text-secondary border ms-1" style="font-size: 0.72rem;">{% trans %}Capacidad ilimitada{% endtrans %}</span>
</label>
</div>
</div>
@@ -129,9 +129,9 @@
<!-- Actions -->
<div class="col-lg-2 col-md-12 pt-md-3 text-lg-end d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm flex-fill">
<i class="bi bi-funnel me-1"></i>Filtrar
<i class="bi bi-funnel me-1"></i>{% trans %}Filtrar{% endtrans %}
</button>
<a href="/schedule/today_schedule" class="btn btn-outline-secondary btn-sm" title="Limpiar Filtros">
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-secondary btn-sm" title="{% trans %}Limpiar Filtros{% endtrans %}">
<i class="bi bi-arrow-counterclockwise"></i>
</a>
</div>
@@ -147,14 +147,14 @@
<div class="card-header border-0 py-3 bg-body-tertiary">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-clock-history text-info me-2"></i>Vista Cronológica por Horario
<i class="bi bi-clock-history text-info me-2"></i>{% trans %}Vista Cronológica por Horario{% endtrans %}
</h5>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary" id="refresh-btn">
<i class="bi bi-arrow-clockwise me-1"></i>Actualizar
<i class="bi bi-arrow-clockwise me-1"></i>{% trans %}Actualizar{% endtrans %}
</button>
<button class="btn btn-sm btn-outline-secondary" id="toggle-view-btn">
<i class="bi bi-grid me-1"></i>Vista en Tabla
<i class="bi bi-grid me-1"></i>{% trans %}Vista en Tabla{% endtrans %}
</button>
</div>
</div>
@@ -166,7 +166,7 @@
<div class="row border-bottom py-3 align-items-start {% if time_blocks[hour] %}bg-light-subtle{% endif %}">
<div class="col-md-2 col-3">
<div class="text-center p-2 rounded bg-body-tertiary border">
<strong class="h5 text-primary mb-0 font-monospace">{% if hour < 10 %}0{% endif %}{{ hour }}:00</strong>
<strong class="h5 text-primary mb-0 font-monospace">{{ "%02d:00"|format(hour) }}</strong>
<div class="small text-muted font-monospace">{{ hour+1 }}:00 hs</div>
</div>
</div>
@@ -191,9 +191,9 @@
</div>
<span class="badge bg-{% if item.reservation.status == 'CONFIRMED' or item.reservation.status.value == 'CONFIRMED' %}success-subtle text-success border border-success-subtle{% else %}warning-subtle text-warning border border-warning-subtle{% endif %}">
{% if item.reservation.status == 'CONFIRMED' or item.reservation.status.value == 'CONFIRMED' %}
Confirmada
{% trans %}Confirmada{% endtrans %}
{% else %}
Pendiente
{% trans %}Pendiente{% endtrans %}
{% endif %}
</span>
</div>
@@ -206,29 +206,17 @@
<i class="bi bi-layers me-1"></i>{% if item.classroom.floor == 0 %}PB{% else %}Piso {{ item.classroom.floor }}{% endif %}
</span>
<span>
<i class="bi bi-clock me-1 text-warning"></i>{{ item.reservation.start_time }} - {{ item.reservation.end_time }} hs
<i class="bi bi-clock me-1 text-warning"></i>{{ item.reservation.start_time.strftime('%H:%M') }} - {{ item.reservation.end_time.strftime('%H:%M') }} hs
</span>
</div>
<div class="d-flex justify-content-between align-items-center pt-2 border-top">
<span class="small text-muted">
<i class="bi bi-people me-1"></i>{{ item.reservation.expected_attendees }} estudiantes
<i class="bi bi-people me-1"></i>{{ item.reservation.expected_attendees }} {% trans %}estudiantes{% endtrans %}
</span>
<div class="d-flex gap-1">
{% if item.reservation.effective_virtual_link %}
<a href="{{ item.reservation.effective_virtual_link }}" target="_blank" class="btn btn-sm btn-outline-success py-0 px-2 d-inline-flex align-items-center" title="Unirse a Videollamada (Zoom / Meet)">
<i class="bi bi-camera-video me-1"></i>Zoom/Meet
</a>
{% endif %}
{% if user.can_edit_reservation(item.reservation) %}
<a href="/schedule/edit_reservation?id={{ item.reservation.id }}" class="btn btn-sm btn-outline-warning py-0 px-2" title="Modificar Reserva">
<i class="bi bi-pencil"></i>
</a>
{% endif %}
<a href="/schedule/view_reservation?id={{ item.reservation.id }}" class="btn btn-sm btn-outline-secondary py-0 px-2" title="Ver Ficha Detallada">
<i class="bi bi-eye me-1"></i>Detalles
</a>
</div>
<a href="{{ url_for('schedule.view_reservation', id=item.reservation.id) }}" class="btn btn-sm btn-outline-secondary py-0 px-2">
<i class="bi bi-eye me-1"></i>{% trans %}Detalles{% endtrans %}
</a>
</div>
</div>
</div>
@@ -237,7 +225,7 @@
</div>
{% else %}
<div class="text-muted py-2 small ps-2">
<i class="bi bi-dash-circle me-1"></i>Sin clases programadas en este horario
<i class="bi bi-dash-circle me-1"></i>{% trans %}Sin clases programadas en este horario{% endtrans %}
</div>
{% endif %}
</div>
@@ -251,20 +239,20 @@
<table class="table table-hover align-middle mb-0">
<thead class="table-light small text-uppercase">
<tr>
<th class="ps-3">Horario</th>
<th>Aula / Piso</th>
<th>Asignatura / Código</th>
<th>Turno</th>
<th>Asistentes</th>
<th>Estado</th>
<th class="text-end pe-3">Acciones</th>
<th class="ps-3">{% trans %}Horario{% endtrans %}</th>
<th>{% trans %}Aula / Piso{% endtrans %}</th>
<th>{% trans %}Asignatura / Código{% endtrans %}</th>
<th>{% trans %}Turno{% endtrans %}</th>
<th>{% trans %}Asistentes{% endtrans %}</th>
<th>{% trans %}Estado{% endtrans %}</th>
<th class="text-end pe-3">{% trans %}Acciones{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for reservation in reservations %}
<tr>
<td class="ps-3 font-monospace fw-bold">
{{ reservation.start_time }} - {{ reservation.end_time }} hs
{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }} hs
</td>
<td>
<span class="badge bg-secondary-subtle text-secondary font-monospace border">
@@ -289,29 +277,17 @@
<td>
{% if reservation.status == 'CONFIRMED' or reservation.status.value == 'CONFIRMED' %}
<span class="badge bg-success-subtle text-success border border-success-subtle">
<i class="bi bi-check-circle me-1"></i>Confirmada
<i class="bi bi-check-circle me-1"></i>{% trans %}Confirmada{% endtrans %}
</span>
{% else %}
<span class="badge bg-warning-subtle text-warning border border-warning-subtle">
<i class="bi bi-clock me-1"></i>Pendiente
<i class="bi bi-clock me-1"></i>{% trans %}Pendiente{% endtrans %}
</span>
{% endif %}
</td>
<td class="text-end pe-3">
{% if reservation.effective_virtual_link %}
<a href="{{ reservation.effective_virtual_link }}" target="_blank"
class="btn btn-sm btn-outline-success py-0 px-2 me-1" title="Unirse a Clase Virtual (Zoom/Meet)">
<i class="bi bi-camera-video"></i>
</a>
{% endif %}
{% if user.can_edit_reservation(reservation) %}
<a href="/schedule/edit_reservation?id={{ reservation.id }}"
class="btn btn-sm btn-outline-warning py-0 px-2 me-1" title="Modificar Reserva">
<i class="bi bi-pencil"></i>
</a>
{% endif %}
<a href="/schedule/view_reservation?id={{ reservation.id }}"
class="btn btn-sm btn-outline-secondary py-0 px-2" title="Ver Ficha">
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}"
class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
<i class="bi bi-eye"></i>
</a>
</td>
@@ -330,18 +306,18 @@
<div class="col-12">
<div class="text-center py-5">
<i class="bi bi-calendar-x text-muted" style="font-size: 4rem;"></i>
<h4 class="mt-3 text-muted">No hay reservas programadas para hoy</h4>
<h4 class="mt-3 text-muted">{% trans %}No hay reservas programadas para hoy{% endtrans %}</h4>
<p class="text-muted">
No se encontraron clases para los filtros seleccionados.
{% trans %}No se encontraron clases para los filtros seleccionados.{% endtrans %}
</p>
<div class="mt-3">
{% if user.has_permission('reservations', 'read_write') %}
<a href="/schedule/add_reservation" class="btn btn-success">
<i class="bi bi-plus-circle me-1"></i>Crear Primera Reserva
{% if current_user.has_permission('reservations', 'read_write') %}
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Crear Primera Reserva{% endtrans %}
</a>
{% endif %}
<a href="/schedule/calendar_view" class="btn btn-outline-primary ms-2">
<i class="bi bi-calendar-week me-1"></i>Ver Calendario Semanal
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary ms-2">
<i class="bi bi-calendar-week me-1"></i>{% trans %}Ver Calendario Semanal{% endtrans %}
</a>
</div>
</div>
@@ -363,11 +339,11 @@ document.addEventListener('DOMContentLoaded', function() {
if (timelineView.style.display === 'none') {
timelineView.style.display = 'block';
gridView.style.display = 'none';
toggleBtn.innerHTML = '<i class="bi bi-grid me-1"></i>Vista en Tabla';
toggleBtn.innerHTML = '<i class="bi bi-grid me-1"></i>{% trans %}Vista en Tabla{% endtrans %}';
} else {
timelineView.style.display = 'none';
gridView.style.display = 'block';
toggleBtn.innerHTML = '<i class="bi bi-clock-history me-1"></i>Vista Cronológica';
toggleBtn.innerHTML = '<i class="bi bi-clock-history me-1"></i>{% trans %}Vista Cronológica{% endtrans %}';
}
});
}
@@ -10,14 +10,9 @@
<h1 class="h3">
<i class="bi bi-calendar-check text-info"></i> Reservation Details
</h1>
<div class="d-flex gap-2">
{% if user.can_edit_reservation(reservation) %}
<a href="/schedule/edit_reservation?id={{ reservation.id }}" class="btn btn-warning">
<i class="bi bi-pencil-square me-1"></i>Modificar Reserva
</a>
{% endif %}
<a href="/schedule/list_reservations" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to List
<div>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to List
</a>
</div>
</div>
@@ -60,14 +55,14 @@
<p class="mb-0">
<i class="bi bi-clock"></i>
{{ format_date(reservation.start_time, format='long')|capitalize }}<br>
{{ reservation.start_time }} - {{ reservation.end_time }}
{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }}
</p>
</div>
<div class="col-md-6 mb-3">
<label class="text-muted small">Duration</label>
<p class="mb-0">
<i class="bi bi-hourglass"></i>
{% set duration = reservation.duration or 120 %}
{% set duration = (reservation.end_time - reservation.start_time).total_seconds() / 60 %}
{{ (duration / 60) | round(1) }} hours
</p>
</div>
@@ -91,68 +86,33 @@
<!-- Classroom Details -->
<div class="card border-0 shadow-sm">
<div class="card-header border-0 py-3 bg-body-tertiary">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-geo-alt text-primary me-2"></i>Ubicación y Espacio Asignado
<div class="card-header border-0 py-3">
<h5 class="mb-0">
<i class="bi bi-building text-info"></i> Classroom Information
</h5>
</div>
<div class="card-body p-4">
<div class="card-body">
{% if reservation.classroom %}
<div class="row g-3 mb-3">
<div class="col-md-4">
<div class="p-3 rounded bg-body-tertiary border">
<small class="text-muted d-block mb-1">🏢 Edificio / Sede</small>
<h6 class="mb-0 fw-bold text-primary">{{ reservation.classroom.building_name }}</h6>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="text-muted small">Classroom</label>
<p class="mb-0"><strong>{{ reservation.classroom.code_display }}</strong></p>
</div>
<div class="col-md-4">
<div class="p-3 rounded bg-body-tertiary border">
<small class="text-muted d-block mb-1">🪜 Piso / Nivel</small>
<h6 class="mb-0 fw-bold text-secondary">
{% if reservation.classroom.is_virtual %}
<span class="badge bg-primary-subtle text-primary border">Campus Virtual</span>
{% else %}
{{ reservation.classroom.floor_display }}
{% endif %}
</h6>
</div>
<div class="col-md-4 mb-3">
<label class="text-muted small">Building</label>
<p class="mb-0">{{ reservation.classroom.building }}</p>
</div>
<div class="col-md-4">
<div class="p-3 rounded bg-body-tertiary border">
<small class="text-muted d-block mb-1">🚪 Aula</small>
<h6 class="mb-0 fw-bold text-success">{{ reservation.classroom.code }}</h6>
<small class="text-muted">Capacidad: <strong>{{ reservation.classroom.capacity_display }}</strong></small>
</div>
<div class="col-md-4 mb-3">
<label class="text-muted small">Capacity</label>
<p class="mb-0">{{ reservation.classroom.capacity }} people</p>
</div>
</div>
{% if reservation.classroom.description %}
<div class="mt-2">
<label class="text-muted small">Descripción del Aula</label>
<p class="mb-0 small">{{ reservation.classroom.description }}</p>
</div>
{% endif %}
<!-- Banner de Aula Virtual (Zoom, Meet, Teams) -->
{% if reservation.effective_virtual_link or (reservation.classroom and reservation.classroom.is_virtual) %}
<div class="mt-3 p-3 rounded bg-primary bg-opacity-10 border border-primary border-opacity-25 d-flex flex-column flex-sm-row justify-content-between align-items-sm-center gap-3">
<div>
<div class="fw-bold text-primary mb-1">
<i class="bi bi-camera-video me-1"></i>Aula Virtual / Videoconferencia
</div>
{% if reservation.effective_virtual_link %}
<small class="text-secondary text-break font-monospace d-block">{{ reservation.effective_virtual_link }}</small>
{% else %}
<small class="text-muted fst-italic">Modalidad remota sin enlace específico registrado</small>
{% endif %}
{% if reservation.classroom.description %}
<div class="col-12">
<label class="text-muted small">Description</label>
<p class="mb-0">{{ reservation.classroom.description }}</p>
</div>
{% if reservation.effective_virtual_link %}
<a href="{{ reservation.effective_virtual_link }}" target="_blank" class="btn btn-success btn-sm px-3 text-nowrap">
<i class="bi bi-box-arrow-up-right me-1"></i>Unirse a Clase (Zoom / Meet)
</a>
{% endif %}
</div>
{% endif %}
{% else %}
<p class="text-muted mb-0">Classroom information not available</p>
{% endif %}
@@ -199,23 +159,15 @@
<h3 class="mb-1">{{ reservation.expected_attendees }}</h3>
<p class="text-muted small">Expected Attendees</p>
{% if reservation.classroom %}
{% if reservation.classroom.capacity and reservation.classroom.capacity > 0 %}
<div class="progress mt-3" style="height: 8px;">
{% 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>
</div>
<small class="text-muted">
{{ reservation.expected_attendees }} / {{ reservation.classroom.capacity }} capacidad
</small>
{% else %}
<div class="mt-2">
<span class="badge bg-primary-subtle text-primary border">
{% if reservation.classroom.is_virtual %}🌐 Campus Virtual (Sin límite físico){% else %}Capacidad no definida{% endif %}
</span>
</div>
{% endif %}
<div class="progress mt-3" style="height: 8px;">
{% 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)|string) ~ '%;' }}"></div>
</div>
<small class="text-muted">
{{ reservation.expected_attendees }} / {{ reservation.classroom.capacity }} capacity
</small>
{% endif %}
</div>
</div>
@@ -230,31 +182,23 @@
</div>
<div class="card-body">
<div class="d-grid gap-2">
{% if user.can_edit_reservation(reservation) %}
<a href="/schedule/edit_reservation?id={{ reservation.id }}" class="btn btn-warning">
<i class="bi bi-pencil-square me-1"></i>Modificar Reserva
</a>
{% endif %}
{% if reservation.status.value == 'PENDING' or reservation.status == 'PENDING' %}
<form method="POST" action="/schedule/confirm_reservation?id={{ reservation.id }}">
<input type="hidden" name="id" value="{{ reservation.id }}">
<button type="submit" class="btn btn-success" onclick="return confirm('¿Confirmar esta reserva?')">
<i class="bi bi-check-circle me-1"></i>Confirmar Reserva
{% if reservation.status.value == 'PENDING' %}
<form method="POST" action="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}">
<button type="submit" class="btn btn-success" onclick="return confirm('Confirm this reservation?')">
<i class="bi bi-check-circle"></i> Confirm Reservation
</button>
</form>
{% endif %}
{% if reservation.can_be_cancelled() %}
<form method="POST" action="/schedule/cancel_reservation?id={{ reservation.id }}">
<input type="hidden" name="id" value="{{ reservation.id }}">
<button type="submit" class="btn btn-danger" onclick="return confirm('¿Cancelar esta reserva?')">
<i class="bi bi-x-circle me-1"></i>Cancelar Reserva
<form method="POST" action="{{ url_for('schedule.cancel_reservation', id=reservation.id) }}">
<button type="submit" class="btn btn-danger" onclick="return confirm('Cancel this reservation?')">
<i class="bi bi-x-circle"></i> Cancel Reservation
</button>
</form>
{% endif %}
<a href="/schedule/list_reservations" class="btn btn-outline-secondary">
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
<i class="bi bi-list"></i> View All Reservations
</a>
</div>
@@ -271,12 +215,12 @@
<div class="row text-muted small">
<div class="col-md-4">
<i class="bi bi-clock-history"></i>
Created: {{ reservation.created_at }}
Created: {{ reservation.created_at.strftime('%Y-%m-%d %H:%M:%S') }}
</div>
{% if reservation.updated_at %}
<div class="col-md-4">
<i class="bi bi-arrow-repeat"></i>
Updated: {{ reservation.updated_at }}
Updated: {{ reservation.updated_at.strftime('%Y-%m-%d %H:%M:%S') }}
</div>
{% endif %}
<div class="col-md-4">

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