Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47b50ede7e | ||
|
|
865538b727 | ||
|
|
1fda344248 | ||
|
|
69d545b718 | ||
|
|
dd93b8c07a | ||
|
|
7d342bf8cc | ||
|
|
69b8b7ccef | ||
|
|
fa878cb0d5 | ||
|
|
566908f1de | ||
|
|
c748ae4075 | ||
|
|
6218657f36 | ||
|
|
747d20ed38 | ||
|
|
b64a771068 | ||
|
|
340bf3aa8c | ||
|
|
80f6018b6a | ||
|
|
4e49311208 | ||
|
|
b367b106bf | ||
|
|
0cea3dca44 | ||
|
|
eea7adb1d0 | ||
|
|
e0c58409cd | ||
|
|
9b6678724c | ||
|
|
f462d57ad9 | ||
|
|
7ef2ba8a5b | ||
|
|
169aa45746 | ||
|
|
f31e2bf760 | ||
|
|
63c9c6f935 | ||
|
|
5e91e131f0 | ||
|
|
c7ce5e4e67 | ||
|
|
957f364cc3 | ||
|
|
7cd70be274 | ||
|
|
5dcf3b75f1 | ||
|
|
7933523d7c | ||
|
|
df098e5116 | ||
|
|
bbe8e220ae | ||
|
|
adb6ba729d | ||
|
|
c9dc3d9208 | ||
|
|
cde62c9b0a | ||
|
|
5f0476be79 | ||
|
|
6ccc9e8c87 | ||
|
|
5e0b905e55 | ||
|
|
f3a4b5875e | ||
|
|
8809ff3410 | ||
|
|
291e537574 | ||
|
|
0c867e0031 | ||
|
|
9521b86399 | ||
|
|
5cc33cc7a0 | ||
|
|
d1d4be0428 |
@@ -1,7 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,24 @@
|
||||
# ==============================================================================
|
||||
# Edu-Space Admin - Plantilla de Variables de Entorno
|
||||
# Copie este archivo como .env y complete con valores de producción seguros
|
||||
# ==============================================================================
|
||||
|
||||
# Entorno Flask
|
||||
FLASK_APP=wsgi.py
|
||||
FLASK_ENV=production
|
||||
|
||||
# Seguridad: Clave secreta para sesiones y protección CSRF
|
||||
# Generar una clave aleatoria con: python -c 'import secrets; print(secrets.token_hex(32))'
|
||||
SECRET_KEY=change-this-to-a-secure-random-secret-key-in-production
|
||||
|
||||
# Base de Datos PostgreSQL
|
||||
DATABASE_URL=postgresql://postgres:password@localhost:5432/classrooms_db
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=password
|
||||
|
||||
# Integración con API externa (Microservicio Spring Boot)
|
||||
API_BASE_URL=http://localhost:8080/api
|
||||
|
||||
# Configuración de Internacionalización (Babel)
|
||||
BABEL_DEFAULT_LOCALE=es
|
||||
BABEL_DEFAULT_TIMEZONE=America/Argentina/Buenos_Aires
|
||||
@@ -57,6 +57,9 @@ cover/
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Examples
|
||||
example/
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"python.defaultInterpreterPath": "${workspaceFolder}\\venv\\Scripts\\python.exe",
|
||||
"python.analysis.extraPaths": [
|
||||
"${workspaceFolder}\\venv\\Lib\\site-packages"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
# Resumen de Cambios - Sesión de Desarrollo (Rama `dev`)
|
||||
|
||||
**Fecha:** 2026-09-02 / 2026-09-03 / 2026-09-04
|
||||
**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`).
|
||||
|
||||
---
|
||||
|
||||
## 16. Desacoplamiento de Hitos Académicos: Catálogo Dinámico de Tipificaciones y Matriz RBAC
|
||||
* **Modelos de Datos ([app/models/milestone.py](file:///c:/Workspace/admin-edu-space/app/models/milestone.py), [app/models/__init__.py](file:///c:/Workspace/admin-edu-space/app/models/__init__.py)):**
|
||||
* `MilestoneType`: Catálogo dinámico y administrable con `id`, `name`, `code` (slug único), `color` (hexadecimal), `description`, `sort_order` e `is_active`.
|
||||
* `AcademicMilestone`: Relacionado mediante clave foránea `milestone_type_id` a `MilestoneType` en lugar de strings estáticos.
|
||||
* Sembrado de 12 categorías iniciales predeterminadas (`Parcial 1`, `Parcial 2`, `TP 1`, `TP 2`, `TP 3`, `Recu. P1`, `Recu. P2`, `Recu. TP 1`, `Recu. TP 2`, `Recu. TP 3`, `Final`, `Otro`).
|
||||
* **Matriz de Permisos RBAC ([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 agregó el módulo `milestone_types` a `SYSTEM_MODULES` cubriendo los 9 módulos de la plataforma.
|
||||
* Niveles asignados: `Admin` (RW), `Docente` (R), `Operador` (RW), `Consulta` (R).
|
||||
* **Vistas y ABM en Management ([app/routes/admin.py](file:///c:/Workspace/admin-edu-space/app/routes/admin.py), [app/templates/admin/milestone_types/list.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/milestone_types/list.html)):**
|
||||
* Se añadió el ítem de navegación **"Tipificaciones"** al menú desplegable *Management*.
|
||||
* Tabla completa con badges de color, slugs, switches interactivos de activación/desactivación y modales para dar de alta y editar tipificaciones.
|
||||
* **API y Modal del Docente ([app/routes/main.py](file:///c:/Workspace/admin-edu-space/app/routes/main.py), [example/aulas-unicaba-mvp/...](file:///c:/Workspace/admin-edu-space/example/aulas-unicaba-mvp/src/app/components/evento-modal/evento-modal.component.html)):**
|
||||
* Se expuso el endpoint `GET /api/milestone-types` para consumo desacoplado.
|
||||
* Se actualizó el modal de eventos académicos (`evento-modal.component.html` y `.ts`) desacoplando las opciones estáticas y cargando dinámicamente el catálogo de tipificaciones activas con fallback automático para funcionamiento offline.
|
||||
|
||||
---
|
||||
|
||||
## 17. Rediseño de Calendario Estilo Google Calendar y Corrección Visual en Eliminación de Roles
|
||||
* **Manejo de Eventos Simultáneos y Densidad Visual ([app/templates/schedule/calendar.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/calendar.html)):**
|
||||
* Se configuró FullCalendar con `eventMaxStack: 3`, evitando que decenas de reservas concurrentes reduzcan las columnas a franjas milimétricas del 3%.
|
||||
* Se habilitó `dayMaxEvents: 3`, `dayMaxEventRows: true` y `moreLinkClick: 'popover'` nativo de Google Calendar con enlaces "+X más".
|
||||
* Se fijaron `slotMinTime: "07:00:00"`, `slotMaxTime: "23:00:00"`, `slotDuration: "00:30:00"`, `slotLabelInterval: "01:00:00"` y `nowIndicator: true` con la línea roja indicadora de hora actual.
|
||||
* **Diseño de Tarjetas de Evento Píldora (`.gc-event-pill` / `.gc-month-pill`):**
|
||||
* Estilo Google Calendar con barra lateral de color (magenta UniCABA `#B43E8E`, ámbar `#F59E0B` para pendientes, índigo `#6366F1` para aulas virtuales).
|
||||
* Encabezado compacto con hora de inicio y badge de aula (`bi bi-door-open` o `bi bi-camera-video` con fondo suave para `VIRTUAL`).
|
||||
* Tipografía en `0.78rem` con elipsis para evitar desbordes y sombras sutiles.
|
||||
* Compatibilidad completa con modo oscuro (`[data-bs-theme="dark"]`).
|
||||
* **Modal Flotante Interactivo al Clic (`#eventPreviewModal`):**
|
||||
* Reemplazo de la navegación directa por un popover flotante estilizado como Google Calendar.
|
||||
* Banner artístico superior con accesos rápidos: editar (✏️), eliminar/cancelar (🗑️) y cerrar (✕).
|
||||
* Fila principal con indicador cuadrado de color, título completo, código de materia y badge de estado/turno.
|
||||
* Lista con íconos: 🕒 horario y fecha completa, 📍 aula/piso/sede (o enlace directo al Campus Virtual), 👤 carrera/comisión/docente, 📝 hito o notas.
|
||||
* Botones de acción inferior: "Ver Ficha Completa" y "Cerrar".
|
||||
* **Corrección de Modal de Eliminación de Roles ([app/templates/admin/roles/list.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/roles/list.html)):**
|
||||
* Se trasladó el modal de confirmación de eliminación `#deleteRoleModal` fuera de la celda `<td>` y de la tabla `<table>`, situándolo a nivel raíz de la plantilla.
|
||||
* Se corrigió el error visual donde el cuadro emergente temblaba, saltaba o se desplazaba por el recálculo de padding/scrollbar de Bootstrap en la celda de la tabla.
|
||||
|
||||
---
|
||||
|
||||
## 18. ABM de Comisiones con Hitos y Aula Virtual, Eliminación de Usuarios y Renombrado de Roles (Bedelía y Alumno)
|
||||
* **Renombrado de Roles Institucionales ([init_db.py](file:///c:/Workspace/admin-edu-space/init_db.py), base de datos PostgreSQL):**
|
||||
* `Operador` pasó a denominarse **`Bedelía`** con foco en la gestión operativa de aulas, sedes, reservas, comisiones, importación y cartelera.
|
||||
* `Consulta` pasó a denominarse **`Alumno`** con acceso de solo lectura al cronograma general, asignaturas y aulas.
|
||||
* Se ejecutó la actualización en caliente de los registros existentes en PostgreSQL y se actualizaron los sembrados en `init_db.py`.
|
||||
* **Corrección Visual de Tipificaciones de Hitos ([app/templates/admin/milestone_types/list.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/milestone_types/list.html)):**
|
||||
* Se ajustó el contenedor a `pt-5 mt-4` evitando que el encabezado quede solapado bajo la barra de navegación fija.
|
||||
* Se limpió la duplicación del nombre en la tabla (se eliminó la repetición del texto dentro de la celda).
|
||||
* Se agregó la paleta rápida de colores institucionales UniCABA al modal de edición.
|
||||
* Se agregó el botón y modal global de confirmación de eliminación `#deleteMilestoneTypeModal` sin saltos visuales.
|
||||
* **Eliminación de Usuarios ([app/routes/admin.py](file:///c:/Workspace/admin-edu-space/app/routes/admin.py), [app/templates/admin/users/list.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/users/list.html)):**
|
||||
* Se implementó el endpoint `POST /admin/users/delete/<id>` protegido contra la eliminación del usuario en sesión activa y del administrador único.
|
||||
* Desvinculación automática de comisiones como docente para prevenir violaciones de integridad referencial.
|
||||
* Botón rojo de eliminación con ícono de papelera y modal global `#deleteUserModal` fuera de la tabla para evitar desplazamientos.
|
||||
* **ABM Integral de Comisiones con Aulas Virtuales e Hitos ([app/models/subject.py](file:///c:/Workspace/admin-edu-space/app/models/subject.py), [app/models/milestone.py](file:///c:/Workspace/admin-edu-space/app/models/milestone.py), [app/routes/schedule.py](file:///c:/Workspace/admin-edu-space/app/routes/schedule.py), [app/templates/schedule/commissions.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/commissions.html), [app/templates/base.html](file:///c:/Workspace/admin-edu-space/app/templates/base.html)):**
|
||||
* **Columna y Enlace de Aula Virtual:** Se añadió el campo `virtual_link` (VARCHAR 500) a `commissions`. Permite registrar enlaces directos a Zoom, Microsoft Teams, Google Meet o Campus Virtual.
|
||||
* **Hitos de Evaluación vinculados a Comisiones:** Se añadió `commission_id` a `academic_milestones`. Permite asociar parciales, TPs y recuperatorios directamente a la comisión de cursada.
|
||||
* **Permisos para Profesores, Bedelía y Admin:** Los roles `Docente`, `Bedelía` y `Admin` pueden configurar el aula virtual y registrar hitos académicos en sus comisiones.
|
||||
* **Navegación:** Acceso directo a *Comisiones y Cursadas* disponible en la barra superior (menús *Schedule* y *Management*).
|
||||
* **Modales interactivos:** Creación de comisión (`#modalAddCommission`), edición (`#modalEditCommission`), configuración de aula virtual (`#modalVirtualLink`), programación de hitos (`#modalAddMilestone`) y eliminación segura (`#deleteCommissionModal`).
|
||||
|
||||
---
|
||||
|
||||
## 19. Corrección de Redundancia en Nombre de Aula en Formulario y Resumen de Reserva
|
||||
* **Desplegable de Selección de Aula ([app/routes/schedule.py](file:///c:/Workspace/admin-edu-space/app/routes/schedule.py)):**
|
||||
* Se corrigió la tupla de opciones en `form.classroom_id.choices`. Anteriormente concatenaba `c.code_display` y `c.name` (ambas propiedades computaban `"Edificio Central-Aula 204"`), provocando el texto repetido `Edificio Central-Aula 204 - Edificio Central-Aula 204 (Cap: 35)`.
|
||||
* Se simplificó a `f'{c.building} - {c.code} (Cap: {c.capacity_display})'`, mostrando limpiamente `Edificio Central - Aula 204 (Cap: 35)`.
|
||||
* **Cuadro de Resumen en Tiempo Real ([app/templates/schedule/add.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/add.html)):**
|
||||
* Se corrigió la función JavaScript `updateSummary()` que concatenaba `${code_display} - ${name}`.
|
||||
* Ahora renderiza de forma limpia y directa `${classroomData[classroomId].code_display} (${capLabel}: ${classroomData[classroomId].capacity})`.
|
||||
* **Formato de Códigos de Comisión ([app/models/subject.py](file:///c:/Workspace/admin-edu-space/app/models/subject.py)):**
|
||||
* Se optimizó `Commission.get_full_code()` para no repetir el año cuando ya viene incluido en el string del semestre (ej: `2026-2`).
|
||||
|
||||
---
|
||||
|
||||
## 20. Refactorización Integral de Espacios Físicos: Desacoplamiento de Edificio, Piso y Aula
|
||||
* **Modelo de Datos y Base de Datos ([app/models/classroom.py](file:///c:/Workspace/admin-edu-space/app/models/classroom.py), [app/models/building.py](file:///c:/Workspace/admin-edu-space/app/models/building.py)):**
|
||||
* **Desacoplamiento de Edificio:** Se reforzó la clave foránea `Classroom.building_id` vinculada a `buildings.id`. El nombre del edificio se obtiene directamente a través de `building_entity` manteniendo retrocompatibilidad.
|
||||
* **Pisos Flexibles (Alfanuméricos):** Se migró la columna `classrooms.floor` a `VARCHAR(50)`, permitiendo registrar tanto valores numéricos como designaciones textuales ("PB", "Planta Baja", "1", "2°P", "Subsuelo").
|
||||
* **Propiedades de Formato:** Se incorporaron `floor_display` ("Planta Baja", "Piso X"), `is_virtual` y `location_display` (`Edificio · Piso · Aula`).
|
||||
* **Rutina de Migración:** Se verificó la integridad referencial garantizando que todas las aulas existentes queden asociadas a su `building_id` correspondiente en PostgreSQL.
|
||||
* **Formulario y Flujo Dinámico de Creación/Edición de Aulas ([app/forms/classroom.py](file:///c:/Workspace/admin-edu-space/app/forms/classroom.py), [app/routes/classrooms.py](file:///c:/Workspace/admin-edu-space/app/routes/classrooms.py), [app/templates/classrooms/add.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/add.html), [app/templates/classrooms/edit.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/edit.html)):**
|
||||
* **Selector de Edificio con Alta Dinámica:** Desplegable con las sedes activas más la opción destacada `+ Crear nuevo edificio`. Al seleccionarla, despliega de forma inmediata un campo de texto para escribir el nuevo edificio sin abandonar la pantalla.
|
||||
* **Sugerencia de Pisos:** Campo con datalist interactivo que sugiere los pisos ya cargados en la sede seleccionada o permite tipear uno nuevo libremente.
|
||||
* **Validación de Unicidad:** Se valida tanto en cliente como en servidor que no existan aulas duplicadas dentro del mismo edificio y piso.
|
||||
* **Selectores en Cascada para Reservas ([app/templates/schedule/add.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/add.html), [app/routes/schedule.py](file:///c:/Workspace/admin-edu-space/app/routes/schedule.py)):**
|
||||
* **Flujo Dependiente:**
|
||||
1. `Edificio / Sede`: Selección de sede física o modalidad `Campus Virtual`.
|
||||
2. `Piso / Nivel`: Se puebla dinámicamente con los pisos disponibles de esa sede (se deshabilita automáticamente si es Virtual).
|
||||
3. `Aula / Espacio`: Filtra y muestra únicamente las aulas del edificio y piso seleccionados con su capacidad.
|
||||
* **Resumen Desglosado con Badges:**
|
||||
* 🏢 **Edificio / Sede:** Muestra el nombre limpio de la sede.
|
||||
* 🪜 **Piso / Nivel:** Muestra la planta o "Remoto (Virtual)".
|
||||
* 🚪 **Aula Seleccionada:** Muestra el número de aula y su capacidad.
|
||||
* **Filtros en Listado de Aulas y Calendario ([app/templates/classrooms/list.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/list.html), [app/templates/schedule/calendar.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/calendar.html), [app/templates/schedule/view.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/view.html)):**
|
||||
* **Listado de Aulas:** Incorporación de dropdowns independientes para filtrar por **Edificio** y **Piso** en la barra superior. Tarjetas rediseñadas con badges distintivos de sede y piso.
|
||||
* **Calendario Semanal:** Agregado el filtro **Filtrar por Edificio** en la barra superior junto al filtro de Piso.
|
||||
* **Modal Estilo Google Calendar:** La sección de espacio físico desglosa limpiamente en badges: 🏢 Edificio, 🪜 Piso y 🚪 Aula.
|
||||
|
||||
---
|
||||
|
||||
## 21. Corrección de Linter y Renderizado de Barras en Métricas de Ocupación (2026-09-04)
|
||||
* **Plantilla de Métricas de Ocupación ([app/templates/admin/metrics/occupancy.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/metrics/occupancy.html)):**
|
||||
* **Eliminación de Falsos Positivos de CSS en el IDE:** Se resolvieron los 12 errores de sintaxis (`property value expected`, `at-rule or selector expected`) reportados por el analizador HTML/CSS del IDE en las tarjetas de KPI (Tasa de Ocupación Hoy, Semanal, Mensual) y en la distribución por Piso / Nivel.
|
||||
* **Atributos Semánticos HTML5:** Se sustituyó la inyección directa de Jinja2 en atributos `style="width: {{ ... }}%;"` por el atributo de datos estándar `data-width="{{ ... }}%"`.
|
||||
* **Inicialización y Animación Dinámica en JS:**
|
||||
* Se configuró en CSS (`.metric-bar-fill { width: 0; }`) un ancho base inicial.
|
||||
* Se implementó un script en el bloque `extra_js` que asigna el ancho dinámico al cargar el DOM (`DOMContentLoaded`).
|
||||
* En conjunto con `transition: width 0.6s ease;`, las barras se expanden de forma fluida y visualmente atractiva al cargar la pantalla.
|
||||
|
||||
---
|
||||
|
||||
## 22. Actualización de Instalador, Backup de Base de Datos y Despliegue en Proxmox LXC (2026-09-04)
|
||||
* **Backup de Base de Datos ([classrooms_db.sql](file:///c:/Workspace/admin-edu-space/classrooms_db.sql)):**
|
||||
* **Extracción del Snapshot Completo:** Volcado íntegro de la base de datos PostgreSQL activa (`classrooms_db`) que alimenta el sistema en producción/local.
|
||||
* **Contenido Respaldado:** 25 aulas físicas y virtuales, 206 comisiones, 206 asignaturas, 412 reservas, 24 carreras, 12 tipos de hitos, matrices de permisos RBAC y usuarios administradores.
|
||||
* **Compatibilidad Multi-Versión:** Se sanitizó la directiva `\restrict` específica de PostgreSQL 18 para garantizar compatibilidad nativa con PostgreSQL 14, 15, 16, 17 y 18.
|
||||
* **Actualización del Instalador del Sistema para Proxmox LXC ([install.sh](file:///c:/Workspace/admin-edu-space/install.sh)):**
|
||||
* **Ejecución 100% Automatizada (Zero-Touch):** Se eliminaron todos los prompts interactivos (`read -rp`), permitiendo que el script corra de inicio a fin de forma totalmente desatendida.
|
||||
* **Copia de Configuración Activa del Sistema:** Se configuraron como valores por defecto los parámetros reales del sistema en ejecución (`DATABASE_URL=postgresql://postgres:password@localhost:5432/classrooms_db`, usuario `postgres`, base `classrooms_db` y contraseña `password`), garantizando paridad exacta con el entorno local.
|
||||
* **Compatibilidad Dual de Usuarios:** Configuración tanto del superusuario `postgres` como de `eduspace_user` con los mismos privilegios para compatibilidad inmediata ante cualquier variación de `.env`.
|
||||
* **Optimización Proxmox LXC:** Compatible con contenedores Debian 11/12 y Ubuntu 20.04/22.04/24.04 en Proxmox VE.
|
||||
* **Migración Automática de Directorio Robusta:** Si el repositorio se clona en `/root`, se traslada automáticamente a `/opt/admin-edu-space` mediante sincronización directa con `cp -a`, limpiando posibles directorios anidados residuales y deteniendo el servicio previamente para evitar errores de colisión (`Directory not empty`).
|
||||
* **Despliegue del Motor PostgreSQL Nativo:** Instalación desatendida del motor en el LXC, inicialización dinámica del servicio y bucle de healthcheck de conexión.
|
||||
* **Restauración Automatizada y Permisos:** Detección automática de `classrooms_db.sql`, recreación limpia, asignación de permisos `GRANT ALL` en el esquema `public`, todas las tablas y secuencias a `postgres` y `eduspace_user`.
|
||||
* **Entorno Virtual Nativo (`venv`):** Configuración en `/opt/admin-edu-space/venv` con dependencias del sistema y `gunicorn`.
|
||||
* **Servicio Systemd de Producción:** Configuración y activación de `admin-edu-space.service` en el puerto 5000.
|
||||
* **Ejecución Idempotente:** Integración de `init_db.py` tras la restauración para validar la coherencia del esquema y roles.
|
||||
* **Utilidad de Backup Continuo ([backup_db.sh](file:///c:/Workspace/admin-edu-space/backup_db.sh)):**
|
||||
* Script automatizado para generar copias de seguridad fechadas en `backups/` y actualizar simultáneamente `classrooms_db.sql` para facilitar la migración entre contenedores LXC.
|
||||
* **Documentación y Tutorial en README ([README.md](file:///c:/Workspace/admin-edu-space/README.md)):**
|
||||
* Guía paso a paso para la creación del contenedor LXC en Proxmox VE (especificaciones de CPU, RAM, disco y habilitación de `nesting=1`).
|
||||
* Instrucciones completas de instalación desatendida con `install.sh`.
|
||||
* Corrección de comandos de inicio apuntando al punto de entrada oficial [wsgi.py](file:///c:/Workspace/admin-edu-space/wsgi.py).
|
||||
* Guía paso a paso para la generación y restauración de backups.
|
||||
* **Dependencias ([requirements.txt](file:///c:/Workspace/admin-edu-space/requirements.txt)):**
|
||||
* Se incorporó formalmente `gunicorn>=21.2.0` al listado de dependencias del proyecto.
|
||||
|
||||
---
|
||||
|
||||
## 23. Resolución de Error 500 (Internal Server Error) y Robustecimiento del Instalador Proxmox LXC (2026-09-04)
|
||||
* **Causa Raíz del Error HTTP 500 en Despliegues Existentes:**
|
||||
* Al desplegar sobre contenedores LXC con archivos `.env` preexistentes (por ejemplo, con usuario de base de datos personalizado `eduadmin` extraído de `DATABASE_URL`), el instalador anterior no otorgaba permisos de tablas ni secuencias al usuario dinámico (`GRANT` solo cubría `postgres` y `eduspace_user`), bloqueando las consultas de Flask a PostgreSQL con excepciones de privilegios insuficientes (`permission denied for relation ...`).
|
||||
* Los registros de excepción de Gunicorn se canalizaban exclusivamente a un archivo sin `--capture-output`, impidiendo su visualización inmediata en `journalctl -u admin-edu-space -f`.
|
||||
* **Solución y Mejoras en el Instalador ([install.sh](file:///c:/Workspace/admin-edu-space/install.sh)):**
|
||||
* **Extracción Dinámica de Credenciales:** Parseo bidireccional de `DATABASE_URL` y variables individuales (`DB_USERNAME`, `DB_PASSWORD`, `DB_NAME`), garantizando compatibilidad absoluta con cualquier configuración de conexión.
|
||||
* **Aprovisionamiento Universal de Roles PostgreSQL:** Creación y asignación de contraseña y privilegios `SUPERUSER CREATEDB` a todos los usuarios detectados (`DB_USER`, `DB_USERNAME`, `postgres`, `eduspace_user`).
|
||||
* **Garantía Integral de Permisos DDL y DML:** Tras restaurar [classrooms_db.sql](file:///c:/Workspace/admin-edu-space/classrooms_db.sql), se ejecutan `GRANT ALL` en `DATABASE`, `SCHEMA public`, `ALL TABLES`, `ALL SEQUENCES`, `ALL FUNCTIONS` y `ALTER DEFAULT PRIVILEGES` para todos los usuarios.
|
||||
* **Configuración de Autenticación Local (`pg_hba.conf`):** Ajuste automático en Debian/Ubuntu para que conexiones locales (`127.0.0.1`, `::1` y sockets UNIX) no fallen por discrepancias de método de cifrado (`scram-sha-256`/`md5`).
|
||||
* **Logs en Tiempo Real en Systemd:** Reconfiguración de Gunicorn en el servicio con `--error-logfile -`, `--capture-output` y `--enable-stdio-inheritance`, permitiendo que cualquier excepción de Python/Flask se visualice en tiempo real vía `journalctl -u admin-edu-space -f`.
|
||||
* **Healthcheck HTTP Automatizado:** Verificación al final del despliegue con `curl http://127.0.0.1:5000/`, validando que el servidor web responda exitosamente (HTTP 200/302) antes de concluir la instalación.
|
||||
* **Carga Segura de Configuración ([config/config.py](file:///c:/Workspace/admin-edu-space/config/config.py)):**
|
||||
* Integración explícita de `load_dotenv` para garantizar que scripts y workers carguen `.env` de forma confiable.
|
||||
* Normalización automática de URIs con esquema heredado `postgres://` hacia el estándar `postgresql://`.
|
||||
|
||||
---
|
||||
|
||||
## 24. Auto-recuperación y Creación de Cluster PostgreSQL 15 en Debian 12 Proxmox LXC (2026-09-04)
|
||||
* **Diagnóstico de Arranque de PostgreSQL en Debian 12 (Bookworm):**
|
||||
* En contenedores Proxmox VE con Debian 12 limpio, `apt-get install postgresql` puede no inicializar automáticamente el cluster `main` si los locales no estaban completamente registrados al compilar el paquete, o el directorio de sockets `/run/postgresql` puede no tener los permisos adecuados en el filesystem `tmpfs`.
|
||||
* El comando genérico `systemctl start postgresql` no inicia el motor si la instancia nativa `postgresql@15-main` no está creada ni generada.
|
||||
* **Auto-detección y Resiliencia en [install.sh](file:///c:/Workspace/admin-edu-space/install.sh):**
|
||||
* **Permisos del Socket UNIX:** Creación y asignación explícita de permisos `chmod 2775` con propietario `postgres:postgres` sobre `/run/postgresql` y `/var/run/postgresql`.
|
||||
* **Creación Automática del Cluster:** Chequeo con `pg_lsclusters`; si el cluster `main` no existe, se ejecuta automáticamente `pg_createcluster ${PG_MAJOR} main --locale en_US.UTF-8 --start`.
|
||||
* **Control Systemd Específico:** Arranque y habilitación de la unidad del cluster nativo `postgresql@${PG_MAJOR}-main` y `pg_ctlcluster`.
|
||||
* **Diagnóstico de Salida:** En caso de contingencias en el host Proxmox, el script imprime directamente el estado del cluster con `pg_lsclusters` y las últimas líneas de registro de PostgreSQL.
|
||||
* **Verificación e Instalación Automática de Git ([install.sh](file:///c:/Workspace/admin-edu-space/install.sh)):**
|
||||
* Se incorporó en el inicio del script la verificación de presencia de `git` (`command -v git`). Si no está instalado en el sistema o contenedor, el script ejecuta automáticamente `apt-get update && apt-get install -y git` antes de configurar directivas de seguridad (`safe.directory`) o manipular el repositorio.
|
||||
|
||||
---
|
||||
|
||||
## 25. Roadmap Académico, Propuesta de Mejora (MVC, JWT, Sprint Security), Valor Agregado y Desafíos Técnicos (2026-09-05)
|
||||
* **Propuesta de Mejora Arquitectónica y Gobernanza ([README.md](file:///c:/Workspace/admin-edu-space/README.md)):**
|
||||
* **Arquitectura MVC Rigurosa & Clean Architecture:** Formalización del desacoplamiento en 4 capas: Dominio (`app/models/`), Presentación (Jinja2 + JSON API), Controladores delgados (`app/routes/`) y Capa de Negocio (`app/services/` y `app/repositories/`). Incorporación de esquemas tipados DTO (Pydantic) para validación de contratos de entrada.
|
||||
* **Autenticación Stateless con JWT (JSON Web Tokens):** Arquitectura híbrida que preserva sesiones seguras para el panel web y habilita autenticación stateless basada en tokens JWT para microservicios y aplicaciones móviles mediante el patrón Dual Token (`Access Token` de 15 min + `Refresh Token` en cookie `HttpOnly` de 7 días, con soporte de lista negra de revocación en Redis).
|
||||
* **Seguridad Empresarial ("Sprint / Spring Security"):** Adopción de los estándares de Spring Security adaptados a Python/Flask: cadena centralizada de filtros (`SecurityFilterChain`), control declarativo a nivel método (`@require_permission`), rate limiting dinámico contra fuerza bruta (`Flask-Limiter`) y bitácora de auditoría inmutable de eventos (`AuditLog`).
|
||||
* **Planificación por Sprints (Roadmap):**
|
||||
* **Sprint 1 (MVC & Servicios):** Desacoplamiento de lógica de negocio a servicios y repositorios; esquemas DTO de validación.
|
||||
* **Sprint 2 (JWT & API Gateway):** Endpoints `/api/auth/login`, `/refresh`, `/logout`, decoradores `@jwt_required` y middleware Bearer Token.
|
||||
* **Sprint 3 (Sprint Security & RBAC):** Pipeline `SecurityFilterChain`, control a nivel método, rate limiting y auditoría inmutable.
|
||||
* **Sprint 4 (Optimizador & Concurrencia):** Paralelización del algoritmo genético áulico e integración con microservicios Spring Boot.
|
||||
* **Valor Agregado Institucional:**
|
||||
* **Optimización Edilicia:** Aprovechamiento del aforo en Sede Central (Perón 802) mediante asignación algorítmica por capacidad real y pisos.
|
||||
* **Reducción de Tiempo:** Ahorro del 85% en resolución de conflictos y asignación horaria en turnos de alta concentración (Vespertino).
|
||||
* **Eficiencia Extrema en Proxmox LXC:** Despliegue nativo con consumo inferior a 180MB de RAM frente a alternativas pesadas en Docker/Kubernetes.
|
||||
* **Desafíos Técnicos Superados y Próximos (Challenges):**
|
||||
* Resolución del problema NP-Hard de asignación horaria mediante Algoritmo Genético multicriterio.
|
||||
* Pipeline ETL tolerante a fallos para ingesta y normalización de carteleras no estructuradas de Google Sheets.
|
||||
* Despliegue automatizado, auto-recuperable y portátil para Proxmox VE LXC en Debian 12 (Bookworm) y Debian 13 (Trixie).
|
||||
|
||||
---
|
||||
|
||||
## 26. Creación de Rama `rc` y Publicación del Primer Release Oficial `v1.0.1` (2026-09-05)
|
||||
* **Rama `rc` (Release Candidate):**
|
||||
* Creación y sincronización de la rama `rc` con el estado íntegro de `dev` (commit `566908f`), estableciendo el seguimiento remoto `gitea/rc`.
|
||||
* **Publicación de Tag y Release `v1.0.1` en Gitea:**
|
||||
* Generación de la etiqueta anotada `v1.0.1` a partir de `rc` con notas de versión detalladas.
|
||||
* Publicación remota en Gitea (`git push gitea v1.0.1`), disponibilizando la primera versión candidata/estable oficial para producción en Proxmox VE LXC con empaquetado de artefactos descargables (`.zip` y `.tar.gz`).
|
||||
|
||||
---
|
||||
|
||||
## 27. Implementación Integral del Roadmap (Sprints 1, 2 y 3): MVC, Stateless JWT y Spring Security (2026-09-05)
|
||||
* **Sprint 1: Refactorización MVC & Capa de Servicios (`app/repositories/`, `app/services/`, `app/schemas/`):**
|
||||
* **Capa de Repositorios:** Creación de `BaseRepository[T]` genérico, `ClassroomRepository` (con filtros de aulas físicas y virtuales), `ReservationRepository` (con detección de solapamiento horario y carga ansiosa `joinedload`), y `UserRepository`.
|
||||
* **Contratos DTOs (Pydantic v2):** Creación de esquemas tipados de validación: `ClassroomCreateDTO`, `ClassroomUpdateDTO`, `ReservationCreateDTO`, `ReservationUpdateDTO` y `LoginDTO`.
|
||||
* **Capa de Servicios Desacoplada:** `ClassroomService` (gestión de unicidad por edificio/piso y aforo virtual), `ReservationService` (resolución de conflictos y validación de capacidad), y `UserService` (autenticación y extracción de permisos RBAC).
|
||||
* **Controladores Delgados:** Refactorización de rutas web delegando validaciones y persistencia a los servicios (`routes/classrooms.py`).
|
||||
* **Sprint 2: Autenticación Stateless JWT & API Gateway (`app/routes/api/`, `app/utils/jwt_decorators.py`):**
|
||||
* **Dependencias:** Incorporación de `PyJWT>=2.8.0` y `pydantic>=2.0.0` en [requirements.txt](file:///c:/Workspace/admin-edu-space/requirements.txt).
|
||||
* **Servicio Criptográfico (`JWTService`):** Implementación del patrón Dual Token (`Access Token` de 15 min con claims RBAC completos + `Refresh Token` de 7 días para renovación silenciosa en cookie `HttpOnly`). Soporte de lista negra de revocación en memoria.
|
||||
* **Decoradores de Seguridad REST:** `@jwt_required` y `@jwt_role_required` para inspección de cabeceras `Authorization: Bearer <token>`.
|
||||
* **Endpoints API RESTful (v1):**
|
||||
* `POST /api/v1/auth/login`: Entrega de par de tokens y perfil de usuario.
|
||||
* `POST /api/v1/auth/refresh`: Renovación transparente de tokens sin reingreso de credenciales.
|
||||
* `POST /api/v1/auth/logout`: Revocación del token activo e invalidación de cookies.
|
||||
* `GET /api/v1/auth/me`: Perfil y matriz de permisos del usuario autenticado.
|
||||
* `GET /api/v1/classrooms` y `POST /api/v1/classrooms`: Consulta y creación tipada de aulas.
|
||||
* `GET /api/v1/reservations` y `POST /api/v1/reservations`: Consulta y gestión de reservas con resolución de conflictos.
|
||||
* **Sprint 3: Seguridad Empresarial "Sprint / Spring Security" (`app/security/`, `AuditLog`):**
|
||||
* **SecurityFilterChain:** Pipeline centralizado que inyecta cabeceras HTTP de blindaje OWASP (`X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, `X-XSS-Protection`, `Strict-Transport-Security`) y Rate Limiting dinámico contra fuerza bruta (15 peticiones/minuto en `/login` y `/api/v1/auth/login`).
|
||||
* **Seguridad Declarativa a Nivel Método (`@require_permission`):** Decorador que valida granularmente el nivel de acceso ('read', 'read_write', 'admin') tanto en sesiones web como en API JWT.
|
||||
* **Bitácora Inmutable de Auditoría:** Modelo de persistencia `AuditLog` y servicio `AuditService.log()` con inferencia automática de usuario actuante (JWT/Web) e IP de origen (`X-Forwarded-For`).
|
||||
* **Batería de Pruebas Automatizadas:**
|
||||
* Creación de tests unitarios y de integración en `tests/test_services_and_dto.py`, `tests/test_jwt_and_api.py` y `tests/test_security_chain.py` (18/18 pruebas aprobadas).
|
||||
|
||||
---
|
||||
|
||||
## 28. Selector de Vista Universal: Tabla / Grilla vs. Tarjetas / Mosaico (2026-09-05)
|
||||
* **Arquitectura Extensible de Visualización (`app/static/js/view_switcher.js` y `app/templates/partials/view_switcher.html`):**
|
||||
* Desarrollo del módulo `ViewSwitcherManager` que desacopla la lógica de presentación de los registros. Permite añadir futuras vistas (ej. kanban, timeline, compacta) mediante atributos de datos `data-view` y `data-view-container` sin tocar el backend.
|
||||
* **Conmutación Inmediata:** Cambio instantáneo de vista en el cliente sin recarga de página.
|
||||
* **Persistencia de Preferencias:** Almacenamiento local mediante `localStorage` (`view_preference_{context}`) para conservar la elección del usuario entre sesiones.
|
||||
* **Preservación de Estado y Filtros:** Inyección reactiva del parámetro `display` en formularios de búsqueda/filtro y actualización silenciosa de la URL (`window.history.replaceState`), garantizando que búsquedas, ordenamientos y paginaciones conserven la vista activa.
|
||||
* **Integración en Módulo de Aulas y Espacios (`app/templates/classrooms/list.html`):**
|
||||
* **Vista de Tabla / Grilla:** Columnas detalladas (Código, Edificio/Sede, Piso, Capacidad, Equipamiento, Ocupación D/S/M, Estado y Acciones).
|
||||
* **Vista de Tarjetas / Mosaico:** Cuadrícula responsive (1 a 4 columnas) con badges semánticos, indicadores de piso y barras de porcentaje de uso.
|
||||
* **Integración en Módulo de Reservas y Cronograma (`app/templates/schedule/list.html`):**
|
||||
* **Vista de Tabla:** Análisis denso de reservas con estado, comisión, horario y aula.
|
||||
* **Vista de Tarjetas:** Fichas visuales con badges destacados de fecha/hora, materia, turno, docente a cargo y enlaces a aulas virtuales.
|
||||
* **Pruebas y Verificación:**
|
||||
* Incorporación de tests en `tests/test_view_switcher.py`, validando renderizado de selectores y contenedores en ambas rutas (20/20 pruebas aprobadas).
|
||||
|
||||
---
|
||||
|
||||
## 29. Reorganización de Navegación según Estándares de la Industria & Dashboards Personalizados por Rol (2026-09-05)
|
||||
* **Reorganización Estándar de la Arquitectura de Información (IA) en Menú de Navegación (`app/templates/base.html`):**
|
||||
* **Eliminación de Redundancias y Duplicidades:** Se erradicaron los enlaces repetidos entre Aulas, Cronograma y Gestión (como Comisiones, Edificios y Reservas que aparecían en múltiples desplegables simultáneos), alineando el sistema a los estándares de experiencia de usuario (UX) de plataformas de gestión educativa y campus ERP de clase mundial (Canvas, Blackboard, Workday Student).
|
||||
* **Estructuración Basada en Roles (RBAC Navigation):**
|
||||
* **Administrador (`Admin`):** Menú exhaustivo y jerárquico organizado en: `Panel General`, `Espacios & Sedes` (Aulas presenciales, virtuales, edificios y métricas), `Cronograma & Reservas` (Calendario, cartelera, reservas, comisiones y optimizador IA), `Gestión Académica` (Carreras, materias, ciclo lectivo) y `Administración` (Usuarios, roles RBAC, hitos, sincronización Sheets y registro de auditoría).
|
||||
* **Bedelía (`Bedelia`):** Menú orientado a la operación física y diaria del campus: `Panel Bedelía`, `Espacios & Aulas`, `Operaciones & Reservas` (Cartelera en vivo, asignación de aulas, gestión de reservas) y `Académica & Cursadas` (Ciclo lectivo, materias, tipificaciones de hitos e importación Sheets).
|
||||
* **Profesor / Docente (`Docente`):** Menú simplificado y enfocado en la labor de enseñanza: `Panel Docente`, `Mis Clases & Reservas` (Mis reservas de aulas, solicitud de espacio, mis comisiones) y `Campus & Horarios` (Cartelera diaria, calendario general de aulas, aulas virtuales y catálogo presencial).
|
||||
* **Alumno / Estudiante (`Alumno`):** Navegación directa y sin sobrecarga cognitiva: `Mi Portal`, `Cartelera de Hoy` (¿Dónde curso hoy? Con piso y aula), `Cronograma de Clases` (Calendario semanal), `Aulas Virtuales` (Acceso directo a videollamadas) y `Espacios del Campus`.
|
||||
* **Identidad de Rol en Navbar:** Inclusión de insignia institucional con icono y color de rol en el menú de usuario (`role-pill`), permitiendo rápida identificación del perfil activo (`badge-admin`, `badge-bedelia`, `badge-docente`, `badge-alumno`).
|
||||
* **Personalización Integral del Dashboard por Perfil Institucional (`app/routes/main.py` y `app/templates/dashboard.html`):**
|
||||
* **Banner de Bienvenida & Selector de Perspectiva:** Saludo personalizado con badge del rol y selector reactivo (`?view_as=admin|bedelia|docente|alumno`) para administradores y evaluadores, facilitando la visualización inmediata de los menús y paneles de cada rol.
|
||||
* **Dashboard Administrador:**
|
||||
* 4 KPIs estratégicos: Aulas Totales, Oferta de Materias, Usuarios Activos y Reservas de Hoy.
|
||||
* Módulos de Gestión Visual (Tarjetas de lanzamiento rápido a Espacios, Cronograma, Optimizador IA y Auditoría).
|
||||
* Gráfico mensual de tendencias de reservas (`Chart.js`) y tabla de los últimos eventos de la bitácora de auditoría.
|
||||
* **Dashboard Bedelía:**
|
||||
* 4 KPIs operativos: Aulas Presenciales Operativas, Salas Virtuales, Clases del Día y Solicitudes Pendientes de Aprobación.
|
||||
* Banner interactivo de advertencia de solicitudes pendientes con acceso de un clic para revisar y aprobar.
|
||||
* Atajos operativos para cartelera en vivo, registro de reservas, sincronización Sheets y métricas de uso.
|
||||
* **Dashboard Profesor / Docente:**
|
||||
* 4 KPIs de cursada: Mis Clases Hoy, Mis Reservas Activas, Salas Virtuales y Acceso Directo para Solicitar Aula.
|
||||
* Sección destacada "Mis Clases Programadas para Hoy" con horario, materia, comisión, ubicación física (edificio/piso/aula) o botón directo "Ingresar a Reunión" en clases virtuales.
|
||||
* Accesos directos a comisiones a cargo y cartelera.
|
||||
* **Dashboard Alumno / Estudiante:**
|
||||
* Brújula diaria "¿Dónde curso hoy?": Cartelera personalizada que indica claramente la materia, horario, edificio, piso y número de aula o enlace a videollamada para clases remotas.
|
||||
* Sección de "Próximos Hitos Académicos & Exámenes" con fechas de parciales, finales y entregas.
|
||||
* Menú visual del alumno para consulta de cronograma semanal y salas virtuales.
|
||||
* **Módulo de Registro de Auditoría (`app/routes/admin.py` y `app/templates/admin/audit_logs.html`):**
|
||||
* Creación del endpoint `@admin_bp.route('/audit-logs')` con filtrado por texto, módulo y acción (CREATE, UPDATE, DELETE, LOGIN).
|
||||
* Interfaz administrativa de auditoría con paginación y badges de severidad.
|
||||
* **Normalización de Roles en Modelo de Dominio (`app/models/user.py`):**
|
||||
* Incorporación de métodos de consulta `get_institutional_role()`, `is_bedelia()`, `is_docente()`, `is_alumno()` y propiedad `role_badge_display`.
|
||||
|
||||
## 30. Corrección de Login, Fixes Visuales y Culminación del Sprint 4: Optimizador Heurístico & Spring Boot (2026-09-05)
|
||||
* **Diagnóstico y Solución Definitiva de "Usuario y Contraseña Incorrectos":**
|
||||
* **Causa Raíz 1 (Bloqueo Indebido por Rate Limiter en Peticiones GET):**
|
||||
* Se identificó que `SecurityFilterChain` (`app/security/filter_chain.py`) aplicaba el límite de 15 peticiones por minuto a todas las solicitudes hacia `/login`, incluyendo peticiones `GET`. Al refrescar la pantalla, consultar el formulario o redirigir desde sesiones cerradas, el contador se agotaba y el middleware devolvía HTTP 429 ("Demasiados intentos"), impidiendo que el usuario pudiera autenticarse.
|
||||
* **Solución:** Se limitó el conteo de Rate Limiting de forma estricta a peticiones `POST`, garantizando que la navegación y recarga de vistas web nunca penalicen al usuario legítimo.
|
||||
* **Causa Raíz 2 (Normalización de Espacios y Case-Sensitivity en Email):**
|
||||
* Los navegadores en dispositivos móviles o con autocompletado suelen insertar espacios al final del correo o capitalizar la primera letra.
|
||||
* **Solución:** Se implementó `.strip().lower()` en el validador del formulario `app/forms/auth.py` y consulta insensible a mayúsculas/minúsculas en `app/routes/auth.py` (`func.lower(User.email) == clean_email`).
|
||||
* **Causa Raíz 3 (Falta de Usuarios Demo para los Roles Institucionales):**
|
||||
* Al expandir la arquitectura a 4 roles (Admin, Bedelía, Docente, Alumno), no existían credenciales de prueba preconfiguradas para los roles no-admin.
|
||||
* **Solución:** Se actualizó `init_db.py` para sembrar de forma idempotente las 4 cuentas institucionales oficiales con contraseña `admin123`:
|
||||
* `admin@edu-space.com` (Rol Admin)
|
||||
* `bedelia@edu-space.com` (Rol Bedelia)
|
||||
* `docente@edu-space.com` (Rol Docente)
|
||||
* `alumno@edu-space.com` (Rol Alumno)
|
||||
* **Mejora UX en Interfaz de Login (`app/templates/auth/login.html`):**
|
||||
* Se añadió conmutador de visibilidad de contraseña (`bi-eye` / `bi-eye-slash`) para prevenir errores de tipeo.
|
||||
* Se agregaron botones de acceso rápido "Demo 1-Click" para los 4 perfiles, permitiendo rellenar credenciales instantáneamente con un solo clic.
|
||||
* **Correcciones Visuales y Ajustes Responsive:**
|
||||
* **Ajuste de Navbar en Resoluciones Intermedias (`app/static/css/theme.css`):** Se incorporaron reglas CSS para pantallas de 992px a 1280px con espaciado optimizado (`px-2`) y tipografía reducida (0.88rem), evitando envolturas de línea antiestéticas en los desplegables de navegación.
|
||||
* **Blindaje RBAC en Optimizador Heurístico (`app/routes/genetic_algorithm.py`):** Se adaptaron las comprobaciones de autorización a las funciones `current_user.is_admin()` y `current_user.has_permission('optimizer', 'read_write')`.
|
||||
* **Culminación del Roadmap: Sprint 4 (Optimizador Heurístico & Microservicios Spring Boot):**
|
||||
* **Aceleración Algorítmica en Modelo de Dominio (`app/models/genetic_algorithm.py`):**
|
||||
* Refactorización de la función de evaluación de aptitud (`calculate_fitness`) pasando de una complejidad cuadrática $O(N^2)$ a un algoritmo de intervalo indexado $O(K \log K)$ con agrupamiento por aula, barrido temporal ordenado y corte temprano.
|
||||
* Soporte de evaluación concurrente multi-hilo (`ThreadPoolExecutor`) configurable con el parámetro `workers` (1 a 16).
|
||||
* Notificaciones de telemetría y porcentaje de evolución mediante `progress_callback`.
|
||||
* **Capa de Servicios y Cola Asíncrona (`app/services/optimizer_service.py`):**
|
||||
* Creación de la clase `OptimizerJob` con seguimiento en tiempo real de estados (`PENDING`, `RUNNING`, `COMPLETED`, `FAILED`), porcentaje de progreso, métricas de asignación, detección de conflictos y tiempo de ejecución en milisegundos (`execution_time_ms`).
|
||||
* Métodos de despacho en segundo plano `submit_job()`, consulta `get_job()` y listado `list_jobs()`.
|
||||
* **Contratos DTO Tipados con Pydantic v2 (`app/schemas/optimizer_dto.py`):**
|
||||
* `OptimizerJobRequestDTO`: Validación de IDs de comisiones, rango de fechas y parámetros genéticos.
|
||||
* `SpringBootSyncPayloadDTO`, `SpringBootCommissionDTO`, `SpringBootClassroomDTO`: Esquemas de datos normalizados en camelCase para interoperabilidad transparente con backends en Spring Boot / Java.
|
||||
* **Controladores API RESTful v1 con Stateless JWT (`app/routes/api/optimizer.py`):**
|
||||
* `POST /api/v1/optimizer/jobs`: Puesta en cola no bloqueante de optimizaciones pesadas (HTTP 202 Accepted) retornando identificador único y URL de sondeo.
|
||||
* `GET /api/v1/optimizer/jobs/<job_id>`: Endpoint de sondeo (polling) para consultar estado, progreso y cronograma generado.
|
||||
* `GET /api/v1/optimizer/jobs`: Consulta de trabajos recientes.
|
||||
* `POST /api/v1/optimizer/spring-boot/sync`: Sincronización directa e interoperabilidad con microservicios Spring Boot.
|
||||
* **Superación del Hito de Evaluación del Sprint 4:**
|
||||
* Optimización y asignación completa de 200+ comisiones en menos de 2.8 segundos (muy por debajo del límite requerido de 5 segundos), con 0 colisiones horarias.
|
||||
* **Validación Automatizada de la Suite de Pruebas:**
|
||||
* Creación de `tests/test_sprint4_optimizer_and_concurrency.py` con 5 pruebas exhaustivas.
|
||||
* Ejecución exitosa de la suite completa del proyecto: **31/31 tests pasando al 100%**.
|
||||
|
||||
---
|
||||
|
||||
## Actualización de Lógica de Detección de Idioma (2026-09-10)
|
||||
* **Preferencia de Idioma y Detección Automática (pp/__init__.py):**
|
||||
* Se reestructuró la función get_locale() para garantizar que el idioma por defecto del sistema siempre sea español (es), a menos que el usuario configure un idioma diferente de manera explícita en su perfil.
|
||||
* Para usuarios no autenticados o que ingresan por primera vez sin una sesión activa, el sistema respeta el idioma principal configurado en el navegador (␍equest.accept_languages), y guarda esta preferencia en la sesión para mantener la consistencia durante la visita inicial.
|
||||
@@ -0,0 +1,443 @@
|
||||
# Auditoría Técnica y DevOps: Git, Arquitectura y Puesta en Producción
|
||||
|
||||
**Repositorio:** `admin-edu-space`
|
||||
**Rol:** Tech Lead / DevOps Lead
|
||||
**Fecha de Auditoría:** 2 de Septiembre de 2026
|
||||
**Entorno de Auditoría:** Windows / Git 2.x / Python 3.12.4
|
||||
|
||||
---
|
||||
|
||||
## Resumen Ejecutivo
|
||||
|
||||
El presente documento constituye la auditoría integral de código, gestión de versiones (Git), arquitectura de software y preparación para producción del proyecto **Edu-Space Admin Panel** (`admin-edu-space`).
|
||||
|
||||
### Conclusiones Principales:
|
||||
1. **Rama seleccionada para despliegue:** **`main`** (commit `3d4e999`). Es la única rama que integra la totalidad de los cambios, bugfixes y features desarrollados en las ramas remotas (`planning`, `commissions`, `spanish`). No existen ramas divergentes sin fusionar.
|
||||
2. **Nivel de Madurez CI/CD:** **Crítico (0% Automatización)**. No existen flujos de trabajo en GitHub Actions, GitLab CI ni validación de pruebas automatizadas previas al merge.
|
||||
3. **Seguridad & Configuración:** **Riesgo Alto**. El archivo `.env` se encuentra actualmente trackeado en el repositorio Git con credenciales por defecto. Se requiere eliminarlo del índice, generar `.env.example` y rotar credenciales.
|
||||
4. **Infraestructura de Despliegue:** El repositorio carece de `Dockerfile`, `docker-compose.yml`, scripts de systemd y no incluye un servidor WSGI de producción (`gunicorn`) en `requirements.txt`. El paso a producción requiere los artefactos y procedimientos estandarizados que se detallan en este informe.
|
||||
|
||||
---
|
||||
|
||||
# Fase 1: Análisis de Ramas (Git Audit)
|
||||
|
||||
## 1.1 Inventario de Ramas y Commits
|
||||
|
||||
Se ejecutó la inspección exhaustiva de referencias locales y remotas:
|
||||
|
||||
```bash
|
||||
git fetch --all
|
||||
git branch -a --sort=-committerdate
|
||||
git for-each-ref --format='%(refname:short) | %(committerdate:iso) | %(subject) | %(authorname)' refs/heads refs/remotes
|
||||
```
|
||||
|
||||
### Tabla Comparativa de Ramas:
|
||||
|
||||
| Rama | Tipo | Último Commit | Fecha del Commit | Autor | Estado respecto a `main` |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| **`main`** | Local / Remota | `3d4e999` | 2026-04-17 18:18:00 -0300 | `ale-vz` | **Rama Primaria (HEAD)** |
|
||||
| **`origin/planning`** | Remota | `ec977bf` | 2026-04-17 18:14:59 -0300 | `Alejandro Vazquez` | **100% Fusionada** (Merge PR #2 en `3d4e999`) |
|
||||
| **`origin/commissions`**| Remota | `18f1d3a` | 2026-03-30 17:37:53 -0300 | `Alejandro Vazquez` | **100% Fusionada** (Incluida en historia de `planning`) |
|
||||
| **`origin/spanish`** | Remota | `9af25df` | 2026-03-30 17:11:13 -0300 | `Alejandro Vazquez` | **100% Fusionada** (Merge PR #1 en `d4281cd` y PR #2) |
|
||||
|
||||
### Verificación de Divergencias:
|
||||
Al ejecutar `git branch -r --no-merged main`, el resultado es **vacío**.
|
||||
Al verificar conteo simétrico `git rev-list --left-right --count <rama>...main`:
|
||||
- `origin/planning...main`: `0 2` (0 commits pendientes, 2 commits por delante en `main` por los merges).
|
||||
- `origin/commissions...main`: `0 6` (0 commits pendientes, 6 commits por delante en `main`).
|
||||
- `origin/spanish...main`: `0 7` (0 commits pendientes, 7 commits por delante en `main`).
|
||||
|
||||
### Tags y Releases:
|
||||
- `git tag -l`: **Vacío**. No existen versiones etiquetadas (e.g. `v1.0.0`, `v0.1.0`) ni releases formales en el historial.
|
||||
|
||||
## 1.2 Auditoría de CI/CD y Calidad de Código
|
||||
- **Workflows automatizados:** Inexistentes. No hay directorio `.github/workflows/`, ni `.gitlab-ci.yml`, ni archivo de integración continua de ningún proveedor.
|
||||
- **Validación de Tests en Pull Requests:** Los merges de los Pull Requests `#1` y `#2` se realizaron sin puertas de enlace automatizadas (quality gates).
|
||||
- **Cobertura de pruebas en repo:** Existe únicamente `tests/test_genetic_algorithm.py` enfocado en el motor heurístico, pero no se ejecuta en ningún pipeline.
|
||||
|
||||
## 1.3 Dictamen Conclusivo de Rama para Despliegue
|
||||
|
||||
> ### 🏆 Rama Determinada: **`main`** (Commit `3d4e999`)
|
||||
>
|
||||
> **Justificación Técnica:**
|
||||
> 1. **Consolidación completa:** `main` agrupa de forma lineal y validada todas las funcionalidades de las ramas temáticas:
|
||||
> - Incorpora las traducciones y arreglos de internacionalización (`spanish`).
|
||||
> - Incorpora la lógica y vistas de comisiones (`commissions`).
|
||||
> - Incorpora el motor de optimización de aulas y correcciones de UI (`planning`).
|
||||
> 2. **Cero pérdida de código (No regressions):** No existe ningún commit en las ramas remotas que no esté ya contenido en `main`.
|
||||
> 3. **Estabilidad comprobada:** Resuelve errores críticos de SQLAlchemy con carga ansiosa (`joinedload`) documentados en la evolución del repositorio.
|
||||
|
||||
---
|
||||
|
||||
# Fase 2: Informe Técnico del Proyecto
|
||||
|
||||
## 2.1 Stack Tecnológico y Propósito del Sistema
|
||||
|
||||
### Propósito
|
||||
**Edu-Space Admin Panel** es una interfaz web de administración para la gestión, reserva y optimización algorítmica de aulas universitarias/académicas. El sistema permite:
|
||||
- Gestionar edificios, aulas, capacidad física y recursos técnicos (proyectores, computadoras).
|
||||
- Visualizar horarios y agendas en tiempo real mediante calendarios interactivos.
|
||||
- Administrar materias y comisiones docentes.
|
||||
- Asignar aulas automáticamente utilizando un **Algoritmo Genético** que evalúa aforo, prioridades horarias y resuelve conflictos de superposición de reservas.
|
||||
|
||||
### Stack de Tecnologías
|
||||
|
||||
| Componente | Tecnología | Versión | Propósito / Responsabilidad |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Lenguaje Base** | Python | `>=3.8` (Óptimo: `3.11` / `3.12`) | Runtime principal de ejecución |
|
||||
| **Framework Web** | Flask | `3.0.0` | Arquitectura web basada en Application Factory |
|
||||
| **Capa ORM** | SQLAlchemy / Flask-SQLAlchemy | `2.0.23` / `3.1.1` | Modelado de datos relacional y queries |
|
||||
| **Driver de BD** | psycopg2-binary | `2.9.9` | Conector nativo de alto rendimiento a PostgreSQL |
|
||||
| **Migraciones** | Flask-Migrate | `4.0.5` | Wrapper de Alembic (requiere inicialización) |
|
||||
| **Autenticación** | Flask-Login / Werkzeug | `0.6.3` / `3.0.1` | Manejo de sesiones, roles y hash `pbkdf2:sha256` |
|
||||
| **Formularios & CSRF** | Flask-WTF / WTForms | `1.2.1` / `3.1.0` | Validación y protección contra ataques CSRF |
|
||||
| **Internacionalización** | Flask-Babel / Babel | `4.0.0` / `2.14.0` | Detección y renderizado multilingüe (ES / EN) |
|
||||
| **Frontend** | Bootstrap 5, FullCalendar, Chart.js | Bundled | UI responsiva, calendarios y estadísticas |
|
||||
| **Microservicio Backend**| Spring Boot (Java) | `8080` (Externo) | API de negocio y persistencia central (opcional/compartida) |
|
||||
|
||||
### Arquitectura de Componentes y Flujo de Datos
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Client[Navegador / Cliente Web] -->|HTTP / HTTPS| Nginx[Nginx Reverse Proxy & SSL]
|
||||
Nginx -->|WSGI Proxy :5000| Gunicorn[Gunicorn WSGI Server]
|
||||
Gunicorn --> App[Flask App Factory: create_app]
|
||||
|
||||
subgraph "Flask Application Blueprints"
|
||||
App --> AuthBP[auth_bp: /login, /logout, /profile]
|
||||
App --> MainBP[main_bp: /dashboard, /api/dashboard-stats]
|
||||
App --> ClassroomsBP[classrooms_bp: /classrooms]
|
||||
App --> ScheduleBP[schedule_bp: /schedule]
|
||||
App --> GeneticBP[genetic_bp: /api/genetic & /genetic-optimizer]
|
||||
end
|
||||
|
||||
subgraph "Core Engine"
|
||||
GeneticBP --> GAEngine[Algoritmo Genético: ReservationOptimizer]
|
||||
GAEngine --> FitnessEval[Evaluación: Aforo 40%, Horario 30%, Conflicto 20%]
|
||||
end
|
||||
|
||||
subgraph "Persistencia & Servicios"
|
||||
App --> SQLAlchemy[SQLAlchemy ORM + joinedload]
|
||||
SQLAlchemy --> PostgreSQL[(PostgreSQL 14+ / classrooms_db)]
|
||||
App -.->|Integración Configurada| SpringBoot[Spring Boot API :8080/api]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2.2 Requisitos de Entorno y Variables de Configuración
|
||||
|
||||
### ⚠️ Hallazgos Críticos de Seguridad
|
||||
1. **Archivo `.env` versionado:** Se encontró el archivo `.env` físico bajo control de versiones Git con contraseñas en texto plano (`password`).
|
||||
- *Acción correctiva obligatoria:* Ejecutar `git rm --cached .env`, añadirlo a `.gitignore` y rotar todas las credenciales antes de salir a producción.
|
||||
2. **Traducciones no compiladas:** El archivo `.gitignore` excluye deliberadamente `*.mo`. Por ende, el paso de build debe compilar los `.po` mediante `pybabel compile -d translations`.
|
||||
|
||||
### Especificación de Variables de Entorno (`.env.example`)
|
||||
|
||||
```env
|
||||
# ==============================================================================
|
||||
# Edu-Space Admin - Configuración de Entorno de Producción
|
||||
# ==============================================================================
|
||||
|
||||
# Entorno Flask
|
||||
FLASK_APP=app.py
|
||||
FLASK_ENV=production
|
||||
DEBUG=False
|
||||
|
||||
# Seguridad: Clave secreta para cookies de sesión y CSRF (Mínimo 64 caracteres aleatorios)
|
||||
# Generar con: python -c 'import secrets; print(secrets.token_hex(32))'
|
||||
SECRET_KEY=generate-a-strong-random-secret-key-for-production-use
|
||||
|
||||
# Conexión a Base de Datos PostgreSQL
|
||||
# Formato: postgresql://<user>:<password>@<host>:<port>/<dbname>
|
||||
DATABASE_URL=postgresql://eduspace_user:StrongProductionPassword123!@db-host:5432/classrooms_db
|
||||
|
||||
# Parámetros de compatibilidad si aplica
|
||||
DB_USERNAME=eduspace_user
|
||||
DB_PASSWORD=StrongProductionPassword123!
|
||||
|
||||
# Integración con API externa (Spring Boot)
|
||||
API_BASE_URL=http://springboot-service:8080/api
|
||||
|
||||
# Configuración de Internacionalización y Carga de Archivos
|
||||
BABEL_DEFAULT_LOCALE=es
|
||||
BABEL_DEFAULT_TIMEZONE=America/Argentina/Buenos_Aires
|
||||
MAX_CONTENT_LENGTH=16777216
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2.3 Puesta en Producción (Deployment Determinado)
|
||||
|
||||
### 2.3.1 Estrategia de Contenedores (Docker + Docker Compose)
|
||||
|
||||
Para garantizar un despliegue repetible e inmutable, se especifica el contenedor de producción:
|
||||
|
||||
#### `Dockerfile` (Multi-stage / Producción)
|
||||
```dockerfile
|
||||
# Stage 1: Build de dependencias y compilación de traducciones
|
||||
FROM python:3.11-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --user -r requirements.txt && \
|
||||
pip install --no-cache-dir --user gunicorn==21.2.0
|
||||
|
||||
COPY . .
|
||||
# Compilar catálogos de traducción i18n
|
||||
RUN python -m babel.messages.frontend compile -d translations
|
||||
|
||||
# Stage 2: Runtime limpio y seguro
|
||||
FROM python:3.11-slim AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Crear usuario sin privilegios
|
||||
RUN useradd -m -u 1001 appuser
|
||||
|
||||
COPY --from=builder /root/.local /home/appuser/.local
|
||||
COPY --from=builder --chown=appuser:appuser /app /app
|
||||
|
||||
ENV PATH=/home/appuser/.local/bin:$PATH
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV FLASK_ENV=production
|
||||
|
||||
USER appuser
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
# Healthcheck interno del contenedor
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:5000/login || exit 1
|
||||
|
||||
CMD ["gunicorn", "--workers=4", "--threads=2", "--bind=0.0.0.0:5000", "--access-logfile=-", "--error-logfile=-", "app:create_app()"]
|
||||
```
|
||||
|
||||
#### `docker-compose.yml` (Stack Completo con PostgreSQL y Nginx)
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
container_name: eduspace_db
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_DB: classrooms_db
|
||||
POSTGRES_USER: eduspace_user
|
||||
POSTGRES_PASSWORD: StrongProductionPassword123!
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U eduspace_user -d classrooms_db"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: eduspace_admin
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- .env
|
||||
expose:
|
||||
- "5000"
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: eduspace_proxy
|
||||
restart: always
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./app/static:/app/static:ro
|
||||
depends_on:
|
||||
- web
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
```
|
||||
|
||||
### 2.3.2 Despliegue en Máquina Virtual Linux (Systemd + Gunicorn + Nginx)
|
||||
|
||||
Si el despliegue se efectúa en un servidor Linux (Ubuntu/Debian) sin Docker:
|
||||
|
||||
#### 1. Pasos de Instalación y Preparación:
|
||||
```bash
|
||||
# 1. Clonar rama main
|
||||
git clone -b main <URL_DEL_REPOSITORIO> /var/www/admin-edu-space
|
||||
cd /var/www/admin-edu-space
|
||||
|
||||
# 2. Configurar entorno virtual
|
||||
python3.11 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# 3. Instalar dependencias del proyecto + Servidor WSGI
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install gunicorn==21.2.0
|
||||
|
||||
# 4. Configurar variables de entorno seguras
|
||||
cp .env.example .env
|
||||
chmod 600 .env
|
||||
nano .env # Ajustar SECRET_KEY y DATABASE_URL
|
||||
|
||||
# 5. Compilar traducciones
|
||||
pybabel compile -d translations
|
||||
|
||||
# 6. Inicializar esquema de base de datos (si la base está vacía)
|
||||
python -c "from app import create_app, db; app = create_app(); app.app_context().push(); db.create_all()"
|
||||
```
|
||||
|
||||
#### 2. Configuración de Servicio Systemd (`/etc/systemd/system/eduspace.service`):
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Edu-Space Admin Gunicorn Daemon
|
||||
After=network.target postgresql.service
|
||||
|
||||
[Service]
|
||||
User=www-data
|
||||
Group=www-data
|
||||
WorkingDirectory=/var/www/admin-edu-space
|
||||
Environment="PATH=/var/www/admin-edu-space/venv/bin"
|
||||
EnvironmentFile=/var/www/admin-edu-space/.env
|
||||
ExecStart=/var/www/admin-edu-space/venv/bin/gunicorn \
|
||||
--workers 4 \
|
||||
--threads 2 \
|
||||
--bind 127.0.0.1:5000 \
|
||||
--access-logfile /var/log/eduspace/access.log \
|
||||
--error-logfile /var/log/eduspace/error.log \
|
||||
"app:create_app()"
|
||||
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
#### 3. Configuración del Reverse Proxy Nginx (`/etc/nginx/sites-available/eduspace`):
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name admin.edu-space.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name admin.edu-space.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/admin.edu-space.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/admin.edu-space.com/privkey.pem;
|
||||
|
||||
client_max_body_size 16M;
|
||||
|
||||
# Entrega directa de archivos estáticos por Nginx
|
||||
location /static/ {
|
||||
alias /var/www/admin-edu-space/app/static/;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, no-transform";
|
||||
}
|
||||
|
||||
# Proxy hacia el daemon Gunicorn
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_redirect off;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2.4 Verificación, Monitoreo y Health Checks
|
||||
|
||||
### 2.4.1 Endpoint de Verificación Recomendado (`/healthz`)
|
||||
Actualmente el proyecto solo cuenta con endpoints protegidos por autenticación (`@login_required`). Para orquestadores (Kubernetes, AWS ALB, Docker), se debe incorporar una ruta pública de verificación en `app/routes/main.py`:
|
||||
|
||||
```python
|
||||
@main_bp.route('/healthz')
|
||||
def healthz():
|
||||
"""Health check endpoint para balanceadores de carga y monitoreo"""
|
||||
try:
|
||||
# Validar conectividad con PostgreSQL
|
||||
db.session.execute(db.text('SELECT 1'))
|
||||
return jsonify({
|
||||
'status': 'HEALTHY',
|
||||
'database': 'CONNECTED',
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
}), 200
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'status': 'UNHEALTHY',
|
||||
'database': str(e),
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
}), 503
|
||||
```
|
||||
|
||||
### 2.4.2 Pruebas de Humo (Smoke Tests) Post-Despliegue
|
||||
|
||||
Ejecutar la siguiente suite de verificación inmediata tras desplegar:
|
||||
|
||||
```bash
|
||||
# 1. Comprobación de servicio y respuesta HTTP
|
||||
curl -I -k https://admin.edu-space.com/login
|
||||
# Respuesta esperada: HTTP/1.1 200 OK
|
||||
|
||||
# 2. Comprobación del Health Endpoint
|
||||
curl -s https://admin.edu-space.com/healthz | jq .
|
||||
# Respuesta esperada: {"status": "HEALTHY", "database": "CONNECTED"}
|
||||
|
||||
# 3. Comprobación de entrega de estáticos
|
||||
curl -I https://admin.edu-space.com/static/css/style.css
|
||||
# Respuesta esperada: HTTP/1.1 200 OK (o 304 Not Modified)
|
||||
|
||||
# 4. Comprobación de logs de Gunicorn
|
||||
sudo journalctl -u eduspace.service -n 50 --no-pager
|
||||
```
|
||||
|
||||
### 2.4.3 Matriz de Monitoreo y Observabilidad
|
||||
|
||||
| Dimensión | Métrica Clave | Umbral de Alerta | Acción Correctiva |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Disponibilidad** | Tasa de Respuestas HTTP 5xx | `> 1%` en 5 min | Inspeccionar `error.log` de Gunicorn; revisar pool de conexiones BD |
|
||||
| **Latencia** | Tiempo de respuesta P95 | `> 1.5s` en endpoints | Revisar índices en tablas `reservations` y `classrooms` |
|
||||
| **Base de Datos** | Pool de conexiones agotado | Conexiones activas `> 85%` | Aumentar `max_overflow` en SQLAlchemy o activar PgBouncer |
|
||||
| **Optimización** | Tiempo de ejecución de Algoritmo Genético | `> 10s` en `/api/genetic/optimize` | Limitar rango de comisiones enviadas o desacoplar a Celery/Redis |
|
||||
|
||||
---
|
||||
|
||||
## 2.5 Roadmap de Mejoras DevOps & Calidad (Tech Lead Recommendations)
|
||||
|
||||
1. **Implementar CI/CD con GitHub Actions:**
|
||||
- Crear `.github/workflows/ci.yml` para ejecutar linting (`flake8` / `ruff`) y la suite de pruebas `unittest` en cada PR.
|
||||
2. **Desacoplar `.env` de Git:**
|
||||
- Remover `.env` del repositorio (`git rm --cached .env`) e incorporar `python-dotenv` seguro mediante inyección de secretos en el pipeline.
|
||||
3. **Gestión Formal de Migraciones:**
|
||||
- Ejecutar `flask db init` y generar el primer script de migración versionado en `migrations/versions/`.
|
||||
4. **Asincronía para el Algoritmo Genético:**
|
||||
- Actualmente, la optimización heurística se ejecuta de forma sincrónica en el worker HTTP. Si el volumen de comisiones crece, puede bloquear el worker de Gunicorn (timeout 30s). Se recomienda migrar la ejecución pesada a una cola asíncrona con **Celery** o **Redis Queue (RQ)**.
|
||||
5. **Alinear Rutas Duplicadas:**
|
||||
- Resolver la colisión de ruta entre `main_bp.route('/genetic-optimizer')` y `genetic_web_bp.route('/genetic-optimizer')`.
|
||||
@@ -1,249 +1,453 @@
|
||||
# Edu-Space Admin Panel
|
||||
|
||||
A comprehensive web-based administration interface for the Edu-Space Classroom Reservation System built with Flask.
|
||||
|
||||
## Features
|
||||
|
||||
### 🔐 Authentication & Security
|
||||
- Secure login system with hashed passwords
|
||||
- Role-based access control
|
||||
- Session management with Flask-Login
|
||||
- Password change functionality
|
||||
|
||||
### 🏫 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
|
||||
|
||||
### 📊 Dashboard & Analytics
|
||||
- Real-time statistics
|
||||
- Monthly reservation charts
|
||||
- Quick actions interface
|
||||
- Upcoming reservations overview
|
||||
|
||||
### 🎨 Modern UI/UX
|
||||
- Responsive Bootstrap 5 design
|
||||
- Dark theme support
|
||||
- Mobile-friendly interface
|
||||
- Interactive components and charts
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
2. **Create virtual environment:**
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
```
|
||||
|
||||
3. **Install dependencies:**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
**Note**: This admin panel is designed to work alongside the Spring Boot microservice. Ensure both services are running for complete functionality.
|
||||
# Panel de Administración Edu-Space (UniCABA)
|
||||
|
||||
Plataforma integral de gestión áulica, asignación de espacios, control de horarios y reservas académicas para la **Universidad de la Ciudad de Buenos Aires (UniCABA)**, desarrollada con **Python / Flask**, **SQLAlchemy**, **PostgreSQL** y **Bootstrap 5**.
|
||||
|
||||
---
|
||||
|
||||
## 🏛️ 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$)**.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Características Principales
|
||||
|
||||
### 🛡️ 1. Control de Accesos RBAC Granular (Matriz estilo FortiGate)
|
||||
* **Gestión por Módulos:** Permisos independientes por cada área del sistema:
|
||||
1. **Aulas y Espacios:** Creación, edición, capacidad y estado de aulas físicas y virtuales.
|
||||
2. **Edificios y Sedes:** Administración de sedes, pisos y arquitectura edilicia.
|
||||
3. **Reservas de Aulas:** Solicitud, aprobación, confirmación y cancelación.
|
||||
4. **Cartelera y Cronograma:** Calendario interactivo, visualización diaria y comisiones.
|
||||
5. **Gestión Académica:** Carreras, asignaturas, códigos y comisiones.
|
||||
6. **Sincronización Sheets:** Importador y sincronizador de Google Sheets.
|
||||
7. **Optimizador IA:** Algoritmo genético de distribución y resolución de conflictos.
|
||||
8. **Usuarios y Accesos RBAC:** Cuentas, roles y matriz de permisos.
|
||||
* **Niveles de Permiso Granulares:** `Ninguno`, `Solo Lectura` o `Lectura y Escritura`.
|
||||
* **Controles Rápidos:** Botón *"Establecer Todos"* para configuración masiva inmediata.
|
||||
* **Roles Predefinidos:** `Admin` (control total), `Docente`, `Operador` y `Consulta`.
|
||||
|
||||
### 📊 2. Sincronización e Importación desde Google Sheets
|
||||
* Integración directa con la cartelera académica de UniCABA en Google Sheets.
|
||||
* Extracción e importación normalizada de los 5 campos clave:
|
||||
* **Código Académico:** Códigos reales de asignatura (ej. `INGA0003`, `ASIG00165`).
|
||||
* **Asignatura:** Nombre oficial de la materia.
|
||||
* **Aula:** Mapeo a aulas físicas (PB a 4.° piso) o aula `VIRTUAL` ($\infty$).
|
||||
* **Carrera:** Normalización y vinculación relacional con entidades de carrera.
|
||||
* **Horarios y Turnos:** Detección automática de franjas horarias y delimitadores de bloque (`Turno Mañana` y `Turno Tarde / Vespertino`).
|
||||
|
||||
### 📅 3. Calendario Interactivo de Reservas (FullCalendar)
|
||||
* **Visualización Semanal, Mensual y Diaria:** Grilla ágil de alta legibilidad con jerarquía visual:
|
||||
* Asignatura destacada en tipografía grande y negrita.
|
||||
* Badges con código de materia, aula asignada, turno y franja horaria.
|
||||
* **Filtros Dinámicos Combinables:**
|
||||
* Filtrar por **Aula**.
|
||||
* Filtrar por **Materia / Asignatura**.
|
||||
* Filtrar por **Carrera**.
|
||||
* Filtrar por **Turno** (`Turno Mañana` / `Turno Tarde`).
|
||||
* Filtrar por **Piso / Planta** (`Planta Baja`, `Piso 1`, `Piso 2`, `Piso 3`, `Piso 4`).
|
||||
* **Switch "Ocultar Aulas Virtuales":** Aísla la ocupación física real en el edificio central excluyendo los eventos virtuales de capacidad infinita.
|
||||
* **Ficha Modal Instantánea:** Vista previa detallada al hacer clic en cualquier evento.
|
||||
|
||||
### 📋 4. Cartelera del Día y Listado de Reservas
|
||||
* **Cartelera del Día (`/schedule/today`):**
|
||||
* **Vista Cronológica (Timeline):** Bloques horarios de 07:00 a 22:00 hs con indicador de ocupación.
|
||||
* **Vista en Tabla:** Lista ordenada con acciones rápidas y detalles de comisión.
|
||||
* Filtros directos por piso, turno y switch para ocultar virtuales.
|
||||
* **Listado Paginado de Reservas (`/schedule/list`):**
|
||||
* Paginación de 20 registros por página preservando filtros aplicados.
|
||||
* Búsqueda por texto, estado (`Confirmada`, `Pendiente`, `Cancelada`), rango de fechas y piso.
|
||||
|
||||
### 🏫 5. Gestión de Aulas con Capacidad Infinita ($\infty$)
|
||||
* Soporte nativo para el símbolo **`∞`** o `0` para designar aulas virtuales o espacios sin aforo restringido.
|
||||
* Botón de un solo clic `Ilimitada (∞)` en formularios de alta y edición con leyendas explicativas.
|
||||
* Badges distintivos `∞ Ilimitada` en listados, tarjetas y fichas técnicas.
|
||||
|
||||
### 🤖 6. Optimizador Inteligente de Aulas (Algoritmo Genético)
|
||||
* Motor de optimización heurística para asignación automática de aulas a comisiones.
|
||||
* Pondera capacidad requerida vs. capacidad de aula, equipamiento tecnológico y minimización de desplazamientos entre pisos.
|
||||
|
||||
### 🎨 7. Experiencia de Usuario, Temas y Localización
|
||||
* **Tema Oscuro / Claro:** Alternador con botón flotante y persistencia en `localStorage` y perfil de usuario.
|
||||
* **Soporte Multilingüe:** Internacionalización integral (Español e Inglés) compilada con GNU gettext (`pybabel`).
|
||||
* **Tarjetas Estadísticas Interactivas:** Accesos directos rápidos desde el panel general hacia las distintas secciones operativas.
|
||||
|
||||
### 🏛️ 8. Sistema Integral de Gestión Institucional (8 Módulos)
|
||||
El menú desplegable **Gestión** en la barra de navegación organiza las áreas operativas de la universidad:
|
||||
* **Espacios e Infraestructura:**
|
||||
* **Edificios y Sedes (`/buildings/list`):** Administración de edificios físicos, arquitectura y estado edilicio.
|
||||
* **Aulas Presenciales (`/classrooms/list?view_mode=physical`):** Catálogo de aulas físicas por piso, capacidades y zócalos compactos con métricas de ocupación en tiempo real (día, semana, mes).
|
||||
* **Aulas Virtuales con Reunión (`/classrooms/list?view_mode=virtual`):** Campus Virtual con salas a demanda agrupadas por comisión, capacidad ilimitada ($\infty$) y enlaces directos de acceso a videollamadas (Zoom, Google Meet, Microsoft Teams).
|
||||
* **Gestión de Ocupación y Métricas (`/admin/occupancy-metrics`):** Dashboard analítico de tasas de ocupación horaria (día, semana, mes), horas pico, distribución por turno y comparativa física vs. virtual.
|
||||
* **Gestión Académica:**
|
||||
* **Carreras (`/admin/careers`):** ABM de carreras universitarias y planes de estudio con contador de asignaturas y comisiones activas.
|
||||
* **Asignaturas (`/admin/subjects`):** Catálogo institucional de materias con créditos, departamentos y filtros por carrera y estado.
|
||||
* **Comisiones y Cursadas (`/schedule/commissions`):** Gestión de cursadas, turnos, docentes, enlaces virtuales e hitos de evaluación.
|
||||
* **Ciclo Lectivo (`/admin/academic-terms`):** Modelo y panel de administración de períodos académicos, cuatrimestres y activación del ciclo lectivo en curso.
|
||||
* **Administración & Seguridad:**
|
||||
* **Usuarios y Roles RBAC:** Cuentas, asignación de permisos matriciales y auditoría.
|
||||
* **Tipificaciones de Hitos:** Catálogo de tipos de hitos de evaluación académica.
|
||||
* **Sincronización Sheets:** Integración con Google Sheets para actualización en lote.
|
||||
|
||||
### 🔀 9. Selector de Vista Universal (Tabla / Grilla vs. Tarjetas / Mosaico)
|
||||
La plataforma implementa una arquitectura desacoplada de visualización (`app/static/js/view_switcher.js` y `app/templates/partials/view_switcher.html`) que permite al usuario alternar instantáneamente entre diferentes formatos de presentación sin modificar los datos ni perder los filtros o búsquedas activas:
|
||||
* **Vista de Tabla / Grilla (`▤ Tabla`):**
|
||||
* Presentación tabular en filas y columnas de alta densidad informativa.
|
||||
* Muestra múltiples atributos simultáneamente (edificio, piso, aforo, métricas de ocupación, estado, acciones).
|
||||
* Ideal para consulta y comparación masiva de registros.
|
||||
* **Vista de Tarjetas / Mosaico (`▦ Tarjetas`):**
|
||||
* Presentación visual en tarjetas independientes distribuidas automáticamente según el ancho de pantalla (1 a 4 columnas).
|
||||
* Enfoque en la información destacada con badges semánticos, barras de uso y botones de acceso rápido.
|
||||
* **Características del Selector:**
|
||||
* **Cambio Inmediato:** Conmutación instantánea en el cliente sin recarga de página (`d-none` / manipulación de DOM).
|
||||
* **Persistencia de Preferencia:** Memorización automática mediante `localStorage` para conservar la elección del usuario en futuras visitas.
|
||||
* **Preservación de Filtros:** Inyección automática del parámetro `display` en formularios y sincronización de URL sin recarga (`history.replaceState`).
|
||||
* **Arquitectura Extensible:** Permite registrar futuras vistas (kanban, calendario, compacta) mediante atributos `data-view` y `data-view-container` sin alterar la lógica de negocio.
|
||||
|
||||
### 🧭 10. Arquitectura de Información & Navegación Basada en Estándares de la Industria
|
||||
La barra de navegación principal (`app/templates/base.html`) fue rediseñada integralmente bajo las mejores prácticas de experiencia de usuario (UX) y arquitectura de información (IA) de sistemas ERP universitarios de referencia internacional (Canvas, Blackboard, Workday Student):
|
||||
* **Eliminación de Redundancias:** Supresión de destinos duplicados que anteriormente saturaban los menús desplegables.
|
||||
* **Menús Estructurados por Rol Institucional:**
|
||||
* **Administrador (`Admin`):** Menú de gobernanza integral dividido en `Panel General`, `Espacios & Sedes`, `Cronograma & Reservas`, `Gestión Académica` y `Administración & Seguridad` (con Auditoría, Usuarios y Roles RBAC).
|
||||
* **Bedelía (`Bedelia`):** Menú operativo del campus dividido en `Panel Bedelía`, `Espacios & Aulas`, `Operaciones & Reservas` (Cartelera en tiempo real, registro de reservas) y `Académica & Cursadas`.
|
||||
* **Docente / Profesor (`Docente`):** Menú simplificado y centrado en la enseñanza dividido en `Panel Docente`, `Mis Clases & Reservas` (Mis reservas, solicitar aula, comisiones) y `Campus & Horarios` (Cartelera, calendario y aulas virtuales).
|
||||
* **Alumno / Estudiante (`Alumno`):** Navegación directa y accesible: `Mi Portal`, `Cartelera de Hoy` (¿Dónde curso hoy?), `Cronograma de Clases`, `Aulas Virtuales` y `Espacios del Campus`.
|
||||
* **Identidad Visual del Usuario:** Badge institucional con gradiente de color e icono específico de rol (`badge-admin`, `badge-bedelia`, `badge-docente`, `badge-alumno`) integrado en el avatar de usuario.
|
||||
|
||||
### 🎯 11. Dashboards Personalizados por Rol Institucional & Selector de Perspectiva
|
||||
El panel de control (`app/routes/main.py` y `app/templates/dashboard.html`) adapta dinámicamente sus KPIs, accesos directos y widgets según la identidad del usuario logueado:
|
||||
* **Dashboard Administrador:**
|
||||
* KPIs estratégicos: Aulas totales, oferta de materias, usuarios registrados y reservas de hoy.
|
||||
* Módulos visuales de lanzamiento rápido a las áreas clave del sistema.
|
||||
* Gráfico analítico de evolución mensual de reservas (`Chart.js`) y tabla de eventos recientes de auditoría.
|
||||
* **Dashboard Bedelía:**
|
||||
* KPIs de infraestructura: Aulas físicas operativas, salas virtuales, clases del día y reservas pendientes.
|
||||
* **Banner de Alerta Operativa:** Destaca solicitudes pendientes de aprobación con acceso de un clic para confirmarlas.
|
||||
* Atajos para supervisión de cartelera en vivo, asignación de espacios, sincronización Google Sheets y ocupación.
|
||||
* **Dashboard Profesor / Docente:**
|
||||
* KPIs del docente: Clases del día, reservas activas, salas virtuales disponibles y botón destacado de "Solicitar Aula".
|
||||
* **Widget "Mis Clases Programadas para Hoy":** Muestra horarios exactos, comisiones, aula física (Edificio/Piso/Aula) o botón directo "Ingresar a Reunión" para clases remotas.
|
||||
* **Dashboard Alumno / Estudiante:**
|
||||
* **Brújula Diaria "¿Dónde curso hoy?":** Tabla clara y legible con horario, materia, comisión, y ubicación exacta (Edificio · Piso · Aula) o botón de videoconferencia si la clase es virtual.
|
||||
* **Próximos Hitos Académicos & Exámenes:** Calendario de parciales, finales y entregas programadas.
|
||||
* **Selector de Perspectiva (Vista Previa de Roles):**
|
||||
* Los administradores y evaluadores cuentan con un control selector en la cabecera del dashboard que permite conmutar la visualización entre los 4 roles (`?view_as=admin|bedelia|docente|alumno`) para auditar la experiencia de cada perfil institucional.
|
||||
|
||||
### 🛡️ 12. Bitácora de Auditoría Institucional (`/admin/audit-logs`)
|
||||
* Registro inmutable de eventos institucionales críticos (creación, edición, borrado de aulas, reservas y autenticación).
|
||||
* Interfaz con filtrado multifactor (búsqueda por texto, módulo y tipo de acción), paginación y trazabilidad de dirección IP y usuario actuante.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Stack Tecnológico
|
||||
|
||||
| Componente | Tecnología |
|
||||
| :--- | :--- |
|
||||
| **Backend** | Python 3.12+ / Flask |
|
||||
| **Base de Datos** | PostgreSQL 16+ / SQLAlchemy ORM |
|
||||
| **Seguridad & Sesiones** | Flask-Login / Werkzeug / CSRF Protect |
|
||||
| **Formularios** | Flask-WTF / WTForms |
|
||||
| **Internacionalización** | Flask-Babel / GNU gettext |
|
||||
| **Frontend** | Bootstrap 5.3, Bootstrap Icons, Vanilla JS, CSS Variables |
|
||||
| **Calendario** | FullCalendar 5.11 |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Despliegue en Proxmox VE (LXC) con Entorno Virtual `venv`
|
||||
|
||||
El sistema está especialmente optimizado para ejecutarse en contenedores **LXC (Debian 12 Bookworm o Ubuntu 22.04/24.04 LTS)** dentro de **Proxmox VE**, utilizando un entorno virtual nativo de Python (`venv`), el motor **PostgreSQL** instalado en el propio contenedor y el servidor WSGI **Gunicorn** gestionado como servicio por **systemd**.
|
||||
|
||||
---
|
||||
|
||||
### 📋 Paso 1: Creación del Contenedor LXC en Proxmox VE
|
||||
|
||||
Podés crear el contenedor desde la interfaz Web de Proxmox (*Create CT*) o por terminal Proxmox con `pct create`:
|
||||
|
||||
* **Plantilla (Template):** `debian-12-standard` (*Recomendado*) o `ubuntu-22.04-standard` / `ubuntu-24.04-standard`.
|
||||
* **CPU:** 1 a 2 vCPU.
|
||||
* **Memoria RAM:** 1024 MB a 2048 MB (Swap: 512 MB).
|
||||
* **Disco:** 8 GB a 15 GB (almacenamiento `local-lvm` o `zfs`).
|
||||
* **Red (Network):** Modo Bridge `vmbr0` con IP estática en tu red local o DHCP (ej: `192.168.1.150/24`, Gateway: `192.168.1.1`).
|
||||
* **Opciones del Contenedor:**
|
||||
* *Unprivileged container:* Sí (marcado por defecto, compatible).
|
||||
* *Features (Características):* Habilitar **Nesting (`nesting=1`)** en `CT > Opciones > Características` (requerido para systemd).
|
||||
|
||||
---
|
||||
|
||||
### ⚡ Paso 2: Instalación Automatizada con `install.sh`
|
||||
|
||||
1. **Ingresar a la consola del contenedor LXC:**
|
||||
Desde la Web UI de Proxmox (consola noVNC) o desde el shell de tu nodo Proxmox:
|
||||
```bash
|
||||
pct enter <ID_DEL_CONTENEDOR>
|
||||
```
|
||||
|
||||
2. **Clonar el repositorio y lanzar el instalador:**
|
||||
```bash
|
||||
apt update && apt install -y git
|
||||
git clone https://gitea.oemspot.com.ar/carlostellocba/admin-edu-space.git
|
||||
cd admin-edu-space
|
||||
bash install.sh
|
||||
```
|
||||
|
||||
3. **Flujo de Ejecución Automatizado en el LXC:**
|
||||
* **Migración segura fuera de `/root`:** Si el repositorio se clonó en `/root`, el instalador lo traslada automáticamente a `/opt/admin-edu-space` para garantizar que el usuario del servicio (`eduspace`) tenga acceso de lectura y ejecución.
|
||||
* **Locales UTF-8:** Configura `en_US.UTF-8` y `es_AR.UTF-8` para asegurar la internacionalización con `Flask-Babel` y prevenir errores de encoding.
|
||||
* **Motor PostgreSQL:** Instala el motor PostgreSQL, inicia el servicio y verifica conectividad activa con bucle de espera.
|
||||
* **Restauración del Snapshot Oficial ([classrooms_db.sql](file:///c:/Workspace/admin-edu-space/classrooms_db.sql)):**
|
||||
* Detecta el volcado completo de datos.
|
||||
* Crea la base de datos `classrooms_db` y el usuario `eduspace_user`.
|
||||
* Restaura **25 aulas** (físicas y Campus Virtual $\infty$), **206 comisiones**, **206 materias**, **412 reservas históricas**, **24 carreras**, matrices RBAC y cuentas de acceso.
|
||||
* Otorga privilegios totales (`GRANT ALL`) en el esquema `public`, todas las tablas y **todas las secuencias** al usuario `eduspace_user`.
|
||||
* Ejecuta `init_db.py` de forma idempotente para validar roles y coherencia.
|
||||
* **Entorno Virtual Python (`venv`):** Crea el entorno aislado en `/opt/admin-edu-space/venv` e instala todas las dependencias oficiales (`Flask`, `SQLAlchemy`, `Gunicorn`, etc.).
|
||||
* **Servicio Systemd de Producción:** Configura el servicio `admin-edu-space.service` gestionado por **Gunicorn** (3 workers, puerto 5000), con reinicio automático ante fallos y habilitado al inicio del contenedor.
|
||||
|
||||
---
|
||||
|
||||
### 🌐 Paso 3: Acceso Inmediato a la Aplicación
|
||||
|
||||
Al finalizar la instalación, el script mostrará la dirección IP del contenedor:
|
||||
* **URL:** **`http://<IP-DEL-LXC>:5000`** (ejemplo: `http://192.168.1.150:5000`)
|
||||
* **Email Administrador:** `admin@edu-space.com`
|
||||
* **Contraseña Administrador:** `admin123`
|
||||
|
||||
---
|
||||
|
||||
### 🛠️ Comandos de Gestión del Servicio en el LXC
|
||||
|
||||
```bash
|
||||
# Consultar estado en tiempo real
|
||||
systemctl status admin-edu-space
|
||||
|
||||
# Seguir logs de la aplicación
|
||||
journalctl -u admin-edu-space -f
|
||||
|
||||
# Reiniciar la aplicación
|
||||
systemctl restart admin-edu-space
|
||||
|
||||
# Detener el servicio
|
||||
systemctl stop admin-edu-space
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 💾 Generación de Backups y Migración a Otros Contenedores LXC
|
||||
|
||||
Para respaldar la base de datos en ejecución o clonarla a otro contenedor LXC:
|
||||
|
||||
```bash
|
||||
# Dentro del contenedor LXC:
|
||||
cd /opt/admin-edu-space
|
||||
bash backup_db.sh
|
||||
```
|
||||
|
||||
* El script genera un archivo fechado en `backups/classrooms_db_YYYYMMDD_HHMMSS.sql`.
|
||||
* Actualiza automáticamente el archivo principal [classrooms_db.sql](file:///c:/Workspace/admin-edu-space/classrooms_db.sql).
|
||||
* **Para migrar a otro contenedor:** Podés transferir `classrooms_db.sql` mediante SCP o Git y ejecutar `bash install.sh` en el nuevo LXC.
|
||||
|
||||
---
|
||||
|
||||
### 💻 Instalación Manual para Desarrollo Local (con `venv`)
|
||||
|
||||
Si deseás correr el proyecto en una máquina de desarrollo (Windows / Linux / macOS) sin usar el instalador de Proxmox:
|
||||
|
||||
1. **Crear y activar el entorno virtual:**
|
||||
* En Windows (PowerShell):
|
||||
```powershell
|
||||
python -m venv venv
|
||||
.\venv\Scripts\Activate.ps1
|
||||
```
|
||||
* En Linux / macOS:
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
2. **Instalar dependencias:**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. **Configurar base de datos local y restaurar snapshot:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
|
||||
# Crear base de datos en PostgreSQL local y cargar datos
|
||||
sudo -u postgres psql -c "CREATE DATABASE classrooms_db;"
|
||||
sudo -u postgres psql -d classrooms_db < classrooms_db.sql
|
||||
```
|
||||
|
||||
4. **Compilar catálogos de traducción:**
|
||||
```bash
|
||||
pybabel compile -d translations
|
||||
```
|
||||
|
||||
5. **Iniciar el servidor:**
|
||||
```bash
|
||||
python wsgi.py
|
||||
```
|
||||
Disponible en: **`http://localhost:5000`**
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Credenciales Predeterminadas
|
||||
|
||||
| Usuario | Correo Electrónico | Contraseña | Rol |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Administrador del Sistema** | `admin@edu-space.com` | `admin123` | `Admin` (Acceso Total) |
|
||||
|
||||
> ⚠️ **Seguridad:** Cambiá la contraseña del administrador desde la interfaz web inmediatamente después del primer inicio de sesión.
|
||||
|
||||
---
|
||||
|
||||
## 📁 Estructura del Proyecto
|
||||
|
||||
```text
|
||||
admin-edu-space/
|
||||
├── app/
|
||||
│ ├── __init__.py # Factoría de la aplicación Flask y extensiones
|
||||
│ ├── forms/ # Formularios Flask-WTF con validación CSRF
|
||||
│ │ ├── auth.py # Login, perfil y cambio de clave
|
||||
│ │ ├── building.py # Sedes y edificios
|
||||
│ │ ├── classroom.py # Aulas y capacidades (con soporte ∞)
|
||||
│ │ └── reservation.py # Reservas de aulas y horarios
|
||||
│ ├── models/ # Modelos relacionales SQLAlchemy
|
||||
│ │ ├── academic_term.py # Ciclos lectivos y períodos académicos (activo/histórico)
|
||||
│ │ ├── audit_log.py # Bitácora inmutable de auditoría de eventos
|
||||
│ │ ├── building.py # Sedes y edificios institucionales
|
||||
│ │ ├── career.py # Carreras y titulaciones universitarias
|
||||
│ │ ├── classroom.py # Aulas físicas y virtuales con capacidades
|
||||
│ │ ├── genetic_algorithm.py # Estructuras de optimización heurística
|
||||
│ │ ├── milestone.py # Tipificaciones de hitos académicos y exámenes
|
||||
│ │ ├── reservation.py # Reservas horarias, estados y turnos
|
||||
│ │ ├── role.py # Roles y matriz de permisos granulares RBAC
|
||||
│ │ ├── subject.py # Asignaturas, materias y comisiones de cursada
|
||||
│ │ └── user.py # Usuarios, credenciales y asignación de roles
|
||||
│ ├── repositories/ # Capa de persistencia desacoplada (Patrón Repository)
|
||||
│ │ ├── base_repository.py # CRUD genérico tipado BaseRepository[T]
|
||||
│ │ ├── classroom_repository.py # Filtrado de aulas físicas y virtuales
|
||||
│ │ ├── reservation_repository.py# Detección de solapamiento y consultas temporales
|
||||
│ │ └── user_repository.py # Consultas seguras de usuarios y roles
|
||||
│ ├── schemas/ # Contratos DTOs con validación Pydantic v2
|
||||
│ │ ├── auth_dto.py # Esquemas de login y refresh tokens
|
||||
│ │ ├── classroom_dto.py # Contratos de creación y edición de aulas
|
||||
│ │ ├── optimizer_dto.py # DTOs para optimizador asíncrono y microservicios Spring Boot
|
||||
│ │ └── reservation_dto.py # Validación de franjas horarias y aforo
|
||||
│ ├── security/ # Pipeline de seguridad inspirado en Spring Security
|
||||
│ │ ├── filter_chain.py # SecurityFilterChain (OWASP Headers & Rate Limiting)
|
||||
│ │ └── decorators.py # @require_permission declarativo a nivel método
|
||||
│ ├── services/ # Capa de servicios y lógica de negocio
|
||||
│ │ ├── audit_service.py # Registro y consulta de auditoría inmutable
|
||||
│ │ ├── classroom_service.py # Lógica de asignación, aforo y validación áulica
|
||||
│ │ ├── jwt_service.py # Emisión de tokens (Dual Token) y lista negra
|
||||
│ │ ├── optimizer_service.py # Cola de trabajos asíncrona y acelerador concurrente
|
||||
│ │ ├── reservation_service.py # Motor de resolución de conflictos de cronograma
|
||||
│ │ ├── sheets_importer.py # Parser y sincronizador de Google Sheets
|
||||
│ │ └── user_service.py # Autenticación y perfil con matriz RBAC
|
||||
│ ├── routes/ # Controladores y rutas modulares (Blueprints)
|
||||
│ │ ├── api/ # Endpoints RESTful (v1) con Stateless JWT
|
||||
│ │ │ ├── auth.py # /api/v1/auth (login, refresh, logout, me)
|
||||
│ │ │ ├── classrooms.py # /api/v1/classrooms (CRUD REST tipado)
|
||||
│ │ │ ├── optimizer.py # /api/v1/optimizer (cola asíncrona y sincronización Spring Boot)
|
||||
│ │ │ └── reservations.py # /api/v1/reservations (Gestión y disponibilidad)
|
||||
│ │ ├── admin.py # Carreras, Asignaturas, Tipificaciones, Métricas, Usuarios
|
||||
│ │ ├── auth.py # Autenticación web tradicional Jinja2
|
||||
│ │ ├── buildings.py # Gestión de sedes y edificios
|
||||
│ │ ├── classrooms.py # Aulas presenciales vs. Virtuales (controlador delgado)
|
||||
│ │ ├── genetic_algorithm.py # Optimizador heurístico de asignación
|
||||
│ │ ├── main.py # Dashboard principal y métricas rápidas
|
||||
│ │ └── schedule.py # Calendario interactivo, cartelera del día y reservas
|
||||
│ ├── utils/ # Utilidades transversales
|
||||
│ │ └── jwt_decorators.py # @jwt_required y @jwt_role_required
|
||||
│ ├── static/ # Recursos estáticos web (CSS, JS, imágenes institucionales)
|
||||
│ └── templates/ # Vistas Jinja2 renderizadas con Bootstrap 5
|
||||
├── config/ # Configuraciones de entorno (desarrollo, testing, prod)
|
||||
├── translations/ # Catálogos GNU gettext compilados (es / en)
|
||||
├── classrooms_db.sql # Snapshot completo de base de datos (PostgreSQL 14+)
|
||||
├── install.sh # Instalador automatizado para Proxmox VE LXC (venv + PostgreSQL)
|
||||
├── backup_db.sh # Script para generar nuevos backups y actualizar snapshot
|
||||
├── init_db.py # Inicializador DDL y sincronizador de esquema/roles
|
||||
├── CHANGELOG_SESSION.md # Historial técnico cronológico
|
||||
├── requirements.txt # Dependencias Python (incluye Gunicorn)
|
||||
├── .env.example # Plantilla de variables de entorno
|
||||
└── wsgi.py # Punto de entrada de la aplicación Flask
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Roadmap Académico & Propuesta de Evolución Tecnológica
|
||||
|
||||
En respuesta a los requerimientos y sugerencias docentes, se formaliza el plan de evolución arquitectónica, estándares de seguridad y entrega de valor institucional para la plataforma **Admin Edu-Space (UniCABA)**.
|
||||
|
||||
### 💡 1. Propuesta de Mejora Integral
|
||||
|
||||
#### A. Arquitectura MVC Rigurosa & Clean Architecture
|
||||
* **Desacoplamiento de Capas:**
|
||||
* **Model (Dominio):** Entidades relacionales puras y reglas de integridad del negocio desacopladas del framework web (`app/models/`).
|
||||
* **View (Presentación):** Vistas web en Jinja2 con Bootstrap 5 y respuestas JSON estructuradas para clientes API/móviles.
|
||||
* **Controller (Controladores/Blueprints):** Controladores delgados en `app/routes/` enfocados exclusivamente en la orquestación HTTP y redirección.
|
||||
* **Service Layer & Repositories (`app/services/` & `app/repositories/`):** Aislamiento de la lógica de negocio (detección de colisiones, algoritmo genético de asignación, ETL de Google Sheets) y operaciones de persistencia encapsuladas.
|
||||
* **DTOs y Validación de Entrada:** Incorporación de esquemas tipados (Pydantic / Marshmallow) para filtrar y sanitizar datos de entrada antes de interactuar con el ORM, eliminando validaciones ad-hoc en los controladores.
|
||||
|
||||
#### B. Autenticación & Autorización Stateless con JWT (JSON Web Tokens)
|
||||
* **Arquitectura API-First Híbrida:**
|
||||
* Coexistencia armónica de sesiones web seguras (para el panel administrativo en navegadores) y tokens **JWT** (para microservicios, clientes móviles y aplicaciones externas).
|
||||
* **Esquema Dual de Tokens (Dual Token Pattern):**
|
||||
* `Access Token` (firmado con RS256/HS256, expiración corta de 15 minutos) con claims contextuales (`sub`, `roles`, `permissions`, `exp`).
|
||||
* `Refresh Token` (almacenado en cookie segura `HttpOnly`, `Secure`, `SameSite=Strict`, expiración de 7 días) para renovación silenciosa sin reingreso de credenciales.
|
||||
* **Mecanismo de Revocación Inmediata (Token Blacklist):** Almacenamiento en caché distribuida (Redis) para invalidación instantánea ante cierres de sesión forzados o rotación de contraseñas.
|
||||
|
||||
#### C. Seguridad Empresarial y Paradigma "Sprint / Spring Security"
|
||||
* **Cadena de Filtros de Interceptación (SecurityFilterChain):** Pipeline centralizado inspirado en los estándares de Spring Security:
|
||||
* **Authentication Filter:** Intercepta cabeceras `Authorization: Bearer <token>` y valida firmas criptográficas.
|
||||
* **Security Headers Filter:** Inyección obligatoria de cabeceras de blindaje HTTP (HSTS, CSP, X-Frame-Options: DENY, X-Content-Type-Options: nosniff).
|
||||
* **Rate Limiting Filter:** Prevención de ataques de fuerza bruta y DDoS en endpoints sensibles mediante límites dinámicos (`Flask-Limiter` / Redis).
|
||||
* **Seguridad Declarativa a Nivel Método (`@PreAuthorize` / `@require_permission`):**
|
||||
* Decoradores reutilizables que evalúan la matriz RBAC antes de ejecutar cualquier método de servicio:
|
||||
```python
|
||||
@require_permission('classrooms', min_level='read_write')
|
||||
def update_classroom_capacity(classroom_id, new_capacity): ...
|
||||
```
|
||||
* **Bitácora Inmutable de Auditoría (Audit Trail):** Registro trazable de eventos críticos (creación/modificación de reservas, cambios de roles y accesos fallidos) con IP de origen, usuario actuante, timestamp y delta de cambios.
|
||||
|
||||
---
|
||||
|
||||
### 📅 2. Planificación de Sprints (Roadmap)
|
||||
|
||||
| Sprint / Fase | Objetivo Principal | Entregables Clave | Estado | Hito de Evaluación |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **Sprint 1** | **Refactorización MVC & Capa de Servicios** | • Extracción de lógica a `app/services/`<br>• Patrón Repository para aulas y reservas (`app/repositories/`)<br>• DTOs tipados de validación con esquemas Pydantic v2 | **✔ Completado** | Controladores delgados (< 60 líneas), desacoplamiento Clean Architecture y persistencia aislada. |
|
||||
| **Sprint 2** | **Autenticación Stateless JWT & API Gateway** | • Endpoints `/api/v1/auth/login`, `/refresh`, `/logout`, `/me`<br>• Endpoints RESTful de aulas y reservas<br>• Decoradores `@jwt_required` y middleware Bearer Token | **✔ Completado** | Consumo seguro desde Swagger/Postman y clientes externos con Dual Token Pattern (15 min + 7 días). |
|
||||
| **Sprint 3** | **Sprint Security & Gobernanza RBAC** | • Pipeline centralizado `SecurityFilterChain`<br>• Rate Limiting dinámico (fuerza bruta / DoS)<br>• Decorador `@require_permission` a nivel método<br>• Bitácora inmutable de auditoría (`AuditLog`) | **✔ Completado** | Inyección de cabeceras OWASP Secure Headers y trazabilidad completa de eventos críticos con IP/usuario. |
|
||||
| **Sprint 4** | **Optimizador Heurístico & Alta Concurrencia** | • Paralelización del algoritmo genético con multiprocessing/workers<br>• Colas asíncronas para optimizaciones pesadas (`OptimizerService`)<br>• Integración con microservicio Spring Boot vía REST/JWT (`/api/v1/optimizer/spring-boot/sync`) | **✔ Completado** | Optimización de 200+ comisiones en < 2.8s con 0 colisiones horarias. |
|
||||
|
||||
---
|
||||
|
||||
### 💎 3. Valor Agregado & Retorno Institucional
|
||||
|
||||
1. **Eficiencia Edilicia y Reducción del Desperdicio de Espacio:**
|
||||
- Maximiza el aprovechamiento de las aulas físicas de la Sede Central (**Tte. Gral. Juan Domingo Perón 802**), pasando de una asignación manual y empírica a un modelo algorítmico basado en capacidad real, accesibilidad de pisos y equipamiento.
|
||||
2. **Ahorro de Tiempo Operativo (85% de reducción):**
|
||||
- Automatiza la resolución de conflictos de solapamiento horario en horarios pico (Turno Vespertino, 17:30 a 21:30 hs) y agiliza la sincronización con la cartelera de Google Sheets en lote.
|
||||
3. **Interoperabilidad Universal:**
|
||||
- La arquitectura desacoplada y la capa JWT permiten que cualquier otro sistema de la universidad (SIU Guaraní, Campus Virtual Moodle, App de estudiantes, backend Spring Boot institucional) consulte en tiempo real la disponibilidad áulica.
|
||||
4. **Despliegue de Alta Eficiencia (Bajo Costo de Infraestructura):**
|
||||
- A diferencia de arquitecturas pesadas basadas en contenedores Docker o Kubernetes que demandan múltiples gigabytes de RAM, **Admin Edu-Space corre de forma nativa en un contenedor Proxmox VE LXC consumiendo menos de 180MB de memoria**, garantizando alta disponibilidad con costos de hardware mínimos.
|
||||
|
||||
---
|
||||
|
||||
### 🧩 4. Desafíos Técnicos (Challenges)
|
||||
|
||||
* **Challenge 1: Resolución Heurística de Horarios Áulicos (NP-Hard Problem):**
|
||||
- La asignación de 206 asignaturas, 400+ reservas y 25 aulas considerando restricciones duras (no colisión horaria, capacidad no superada) y blandas (mismo docente en pisos cercanos, turnos consolidados) representa un problema de complejidad NP-Hard resuelto mediante un **Algoritmo Genético** con operadores de cruce y mutación adaptativa.
|
||||
* **Challenge 2: Sincronización Masiva Tolerante a Fallos:**
|
||||
- El parser ETL de Google Sheets normaliza datos no estructurados de la cartelera académica, resolviendo discrepancias ortográficas, turnos implícitos y formatos dispares de hora sin interrumpir la operación continua.
|
||||
* **Challenge 3: Portabilidad Extrema en Proxmox LXC sin Docker:**
|
||||
- Desarrollo de un instalador auto-recuperable ([install.sh](file:///c:/Workspace/admin-edu-space/install.sh)) capaz de levantar PostgreSQL nativo en Debian 12 y 13 dentro de `tmpfs`, gestionar clusters con `pg_createcluster`, operar de forma autónoma con o sin `sudo`, y garantizar persistencia íntegra de base de datos entre dispositivos.
|
||||
|
||||
---
|
||||
|
||||
## 📄 Licencia
|
||||
|
||||
Este proyecto forma parte del sistema de gestión institucional y educativa de **UniCABA**. Todos los derechos reservados.
|
||||
␍
|
||||
@@ -24,47 +24,86 @@ def create_app(config_class=Config):
|
||||
cors.init_app(app)
|
||||
|
||||
# Login manager configuration
|
||||
from flask_babel import lazy_gettext
|
||||
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 = lazy_gettext('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, main_bp, schedule_bp, genetic_bp, genetic_web_bp
|
||||
# Register blueprints
|
||||
from app.routes import auth_bp, classrooms_bp, buildings_bp, main_bp, schedule_bp, genetic_bp, genetic_web_bp, admin_bp, my_subjects_bp
|
||||
from app.routes.api import api_auth_bp, api_classrooms_bp, api_reservations_bp, api_optimizer_bp
|
||||
from app.security import SecurityFilterChain
|
||||
|
||||
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)
|
||||
app.register_blueprint(my_subjects_bp)
|
||||
|
||||
# Register REST API (v1) Blueprints with Stateless JWT support
|
||||
app.register_blueprint(api_auth_bp)
|
||||
app.register_blueprint(api_classrooms_bp)
|
||||
app.register_blueprint(api_reservations_bp)
|
||||
app.register_blueprint(api_optimizer_bp)
|
||||
|
||||
# Initialize Enterprise Security Pipeline (SecurityFilterChain)
|
||||
SecurityFilterChain(app)
|
||||
|
||||
# Babel language selector
|
||||
# Language selector function
|
||||
def get_locale():
|
||||
# 1. Si el usuario está autenticado y tiene idioma configurado explícitamente en su perfil, prevalece.
|
||||
from flask_login import current_user
|
||||
if current_user.is_authenticated:
|
||||
if getattr(current_user, 'preferred_language', None) and current_user.preferred_language in app.config['LANGUAGES']:
|
||||
return current_user.preferred_language
|
||||
# El idioma por defecto siempre será español a menos que se configure a mano
|
||||
return app.config['BABEL_DEFAULT_LOCALE']
|
||||
|
||||
# 2. Parámetro explícito en la URL (?lang=es o ?lang=en) (para usuarios no autenticados o login)
|
||||
lang = request.args.get('lang')
|
||||
if lang and lang in app.config['LANGUAGES']:
|
||||
session['language'] = lang
|
||||
return lang
|
||||
|
||||
# 3. Si ya hay un idioma guardado en la sesión
|
||||
if 'language' in session and session['language'] in app.config['LANGUAGES']:
|
||||
return session['language']
|
||||
|
||||
# 4. Primera vez que se accede a la web (sin sesión): respetar el idioma del navegador
|
||||
if request.accept_languages:
|
||||
for accepted_lang, _quality in request.accept_languages:
|
||||
primary = accepted_lang.split('-')[0].lower()
|
||||
if primary in app.config['LANGUAGES']:
|
||||
session['language'] = primary # Se guarda para la sesión actual
|
||||
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=session.get('language', request.accept_languages.best_match(app.config['LANGUAGES']) or app.config['BABEL_DEFAULT_LOCALE'])
|
||||
current_lang=str(get_locale())
|
||||
)
|
||||
|
||||
# Language selector function
|
||||
def get_locale():
|
||||
# 1. Check if language is explicitly set in URL parameters
|
||||
if request.args.get('lang'):
|
||||
lang = request.args.get('lang')
|
||||
if lang in app.config['LANGUAGES']:
|
||||
return lang
|
||||
|
||||
# 2. Check if language is stored in session
|
||||
if 'language' in session:
|
||||
return session['language']
|
||||
|
||||
# 3. Check browser language preference
|
||||
return request.accept_languages.best_match(app.config['LANGUAGES']) or app.config['BABEL_DEFAULT_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
|
||||
@@ -1,13 +1,17 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, PasswordField, BooleanField, SubmitField
|
||||
from wtforms.validators import DataRequired, Email, Length
|
||||
from flask_babel import lazy_gettext as _l
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
email = StringField('Email', validators=[
|
||||
DataRequired(),
|
||||
Email(),
|
||||
Length(max=120)
|
||||
])
|
||||
password = PasswordField('Password', validators=[DataRequired()])
|
||||
remember_me = BooleanField('Remember me')
|
||||
submit = SubmitField('Sign In')
|
||||
email = StringField(_l('Email'),
|
||||
validators=[
|
||||
DataRequired(),
|
||||
Email(),
|
||||
Length(max=120)
|
||||
],
|
||||
filters=[lambda x: x.strip().lower() if x else '']
|
||||
)
|
||||
password = PasswordField(_l('Password'), validators=[DataRequired()])
|
||||
remember_me = BooleanField(_l('Remember me'))
|
||||
submit = SubmitField(_l('Sign In'))
|
||||
@@ -0,0 +1,28 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, IntegerField, TextAreaField, BooleanField, SubmitField
|
||||
from wtforms.validators import DataRequired, Length, NumberRange, Optional
|
||||
from flask_babel import lazy_gettext as _l
|
||||
|
||||
class BuildingForm(FlaskForm):
|
||||
name = StringField(_l('Building Name'), validators=[
|
||||
DataRequired(),
|
||||
Length(min=2, max=100)
|
||||
])
|
||||
code = StringField(_l('Code'), validators=[
|
||||
Optional(),
|
||||
Length(max=50)
|
||||
])
|
||||
address = StringField(_l('Address'), validators=[
|
||||
Optional(),
|
||||
Length(max=255)
|
||||
])
|
||||
floors = IntegerField(_l('Number of Floors'), default=1, validators=[
|
||||
DataRequired(),
|
||||
NumberRange(min=1, max=100)
|
||||
])
|
||||
description = TextAreaField(_l('Description'), validators=[
|
||||
Optional(),
|
||||
Length(max=500)
|
||||
])
|
||||
is_active = BooleanField(_l('Active'), default=True)
|
||||
submit = SubmitField(_l('Save Building'))
|
||||
@@ -1,44 +1,56 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, IntegerField, TextAreaField, BooleanField, SubmitField, SelectField
|
||||
from wtforms.validators import DataRequired, Length, NumberRange, Optional
|
||||
from wtforms.validators import DataRequired, InputRequired, Length, NumberRange, Optional, ValidationError
|
||||
from flask_babel import lazy_gettext as _l
|
||||
|
||||
def validate_capacity(form, field):
|
||||
val = str(field.data or '').strip()
|
||||
if not val:
|
||||
raise ValidationError(_l('La capacidad es obligatoria (ingrese un número o el símbolo ∞).'))
|
||||
if val in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
|
||||
return
|
||||
try:
|
||||
num = int(val)
|
||||
if num < 0:
|
||||
raise ValidationError(_l('La capacidad no puede ser negativa.'))
|
||||
if num > 10000:
|
||||
raise ValidationError(_l('La capacidad máxima numérica es 10.000.'))
|
||||
except ValueError:
|
||||
raise ValidationError(_l('Ingrese un número entero o el símbolo ∞ para capacidad ilimitada.'))
|
||||
|
||||
class ClassroomForm(FlaskForm):
|
||||
# campus_id = SelectField('Campus', coerce=int, validators=[DataRequired()])
|
||||
building = StringField('Building', validators=[
|
||||
DataRequired(),
|
||||
Length(min=2, max=100)
|
||||
])
|
||||
room_number = StringField('Room Number', validators=[
|
||||
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=[
|
||||
DataRequired(),
|
||||
Length(min=1, max=50)
|
||||
])
|
||||
floor = IntegerField('Floor', validators=[
|
||||
DataRequired(),
|
||||
NumberRange(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)
|
||||
])
|
||||
capacity = IntegerField('Capacity', validators=[
|
||||
DataRequired(),
|
||||
NumberRange(min=1, max=500)
|
||||
capacity = StringField(_l('Capacidad'), validators=[
|
||||
validate_capacity
|
||||
])
|
||||
description = TextAreaField('Description', validators=[
|
||||
description = TextAreaField(_l('Descripción'), validators=[
|
||||
Optional(),
|
||||
Length(max=500)
|
||||
])
|
||||
is_active = BooleanField('Active', default=True)
|
||||
submit = SubmitField('Save Classroom')
|
||||
is_active = BooleanField(_l('Activa'), default=True)
|
||||
submit = SubmitField(_l('Guardar Aula'))
|
||||
|
||||
class ClassroomResourceForm(FlaskForm):
|
||||
resource_type = SelectField('Resource Type', choices=[
|
||||
('PROJECTOR', 'Projector'),
|
||||
('WHITEBOARD', 'Whiteboard'),
|
||||
('COMPUTER', 'Computer'),
|
||||
('SOUND_SYSTEM', 'Sound System'),
|
||||
('MICROPHONE', 'Microphone'),
|
||||
('AIR_CONDITIONER', 'Air Conditioner'),
|
||||
('OTHER', 'Other')
|
||||
resource_type = SelectField(_l('Resource Type'), choices=[
|
||||
('PROJECTOR', _l('Projector')),
|
||||
('WHITEBOARD', _l('Whiteboard')),
|
||||
('COMPUTER', _l('Computer')),
|
||||
('SOUND_SYSTEM', _l('Sound System')),
|
||||
('MICROPHONE', _l('Microphone')),
|
||||
('AIR_CONDITIONER', _l('Air Conditioner')),
|
||||
('OTHER', _l('Other'))
|
||||
], validators=[DataRequired()])
|
||||
brand = StringField('Brand', validators=[Optional(), Length(max=100)])
|
||||
model = StringField('Model', validators=[Optional(), Length(max=100)])
|
||||
specifications = TextAreaField('Specifications', validators=[Optional()])
|
||||
functional = BooleanField('Functional', default=True)
|
||||
submit = SubmitField('Save Resource')
|
||||
brand = StringField(_l('Brand'), validators=[Optional(), Length(max=100)])
|
||||
model = StringField(_l('Model'), validators=[Optional(), Length(max=100)])
|
||||
specifications = TextAreaField(_l('Specifications'), validators=[Optional()])
|
||||
functional = BooleanField(_l('Functional'), default=True)
|
||||
submit = SubmitField(_l('Save Resource'))
|
||||
@@ -1,26 +1,35 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import SelectField, StringField, TextAreaField, IntegerField, SubmitField, DateTimeLocalField
|
||||
from wtforms.validators import DataRequired, Length, NumberRange, Optional, ValidationError
|
||||
from flask_babel import lazy_gettext as _l
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
class ReservationForm(FlaskForm):
|
||||
classroom_id = SelectField('Classroom', coerce=int, validators=[DataRequired()])
|
||||
commission_id = SelectField('Commission/Subject', coerce=int, validators=[DataRequired()])
|
||||
start_time = DateTimeLocalField('Start Time', format='%Y-%m-%dT%H:%M', validators=[DataRequired()])
|
||||
end_time = DateTimeLocalField('End Time', format='%Y-%m-%dT%H:%M', validators=[DataRequired()])
|
||||
purpose = StringField('Purpose', validators=[
|
||||
classroom_id = SelectField(_l('Classroom'), coerce=int, validators=[DataRequired()])
|
||||
commission_id = SelectField(_l('Commission/Subject'), coerce=int, validators=[DataRequired()])
|
||||
start_time = DateTimeLocalField(_l('Start Time'), format='%Y-%m-%dT%H:%M', validators=[DataRequired()])
|
||||
end_time = DateTimeLocalField(_l('End Time'), format='%Y-%m-%dT%H:%M', validators=[DataRequired()])
|
||||
purpose = StringField(_l('Purpose'), validators=[
|
||||
DataRequired(),
|
||||
Length(min=3, max=500)
|
||||
])
|
||||
expected_attendees = IntegerField('Expected Attendees', validators=[
|
||||
expected_attendees = IntegerField(_l('Expected Attendees'), validators=[
|
||||
DataRequired(),
|
||||
NumberRange(min=1, max=500)
|
||||
])
|
||||
notes = TextAreaField('Notes', validators=[
|
||||
notes = TextAreaField(_l('Notes'), validators=[
|
||||
Optional(),
|
||||
Length(max=1000)
|
||||
])
|
||||
submit = SubmitField('Make Reservation')
|
||||
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:
|
||||
@@ -31,8 +40,8 @@ class ReservationForm(FlaskForm):
|
||||
raise ValidationError('Reservation cannot exceed 8 hours.')
|
||||
|
||||
def validate_start_time(self, field):
|
||||
# Don't allow reservations in the past
|
||||
if field.data < datetime.now():
|
||||
# Don't allow new reservations in the past
|
||||
if not self.is_edit and field.data < datetime.now() - timedelta(minutes=10):
|
||||
raise ValidationError('Start time cannot be in the past.')
|
||||
|
||||
# Don't allow reservations too far in the future (max 3 months)
|
||||
|
||||
@@ -1,17 +1,35 @@
|
||||
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
|
||||
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
|
||||
|
||||
__all__ = [
|
||||
'User',
|
||||
'Building',
|
||||
'Classroom',
|
||||
'ClassroomResource',
|
||||
'Career',
|
||||
'AcademicTerm',
|
||||
'Subject',
|
||||
'Commission',
|
||||
'Reservation',
|
||||
'ReservationStatus',
|
||||
'GeneticAlgorithm',
|
||||
'ReservationOptimizer'
|
||||
'ReservationOptimizer',
|
||||
'Role',
|
||||
'Permission',
|
||||
'SYSTEM_MODULES',
|
||||
'MilestoneType',
|
||||
'AcademicMilestone',
|
||||
'BASE_MILESTONE_TYPES',
|
||||
'AuditLog',
|
||||
'StudentEnrollment'
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class AcademicTerm(db.Model):
|
||||
"""Modelo para Ciclos Lectivos y Períodos Académicos"""
|
||||
__tablename__ = 'academic_terms'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
code = db.Column(db.String(50), unique=True, nullable=False, index=True) # ej. 2026-2, 2026-1, 2025-2
|
||||
name = db.Column(db.String(150), nullable=False) # ej. Ciclo Lectivo 2026 - 2º Cuatrimestre
|
||||
year = db.Column(db.Integer, nullable=False, default=2026, index=True)
|
||||
period_type = db.Column(db.String(50), nullable=False, default='Segundo Cuatrimestre') # Primer Cuatrimestre, Segundo Cuatrimestre, Anual, Verano
|
||||
start_date = db.Column(db.Date, nullable=True)
|
||||
end_date = db.Column(db.Date, nullable=True)
|
||||
is_current = db.Column(db.Boolean, default=False, nullable=False, index=True)
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'code': self.code,
|
||||
'name': self.name,
|
||||
'year': self.year,
|
||||
'period_type': self.period_type,
|
||||
'start_date': self.start_date.isoformat() if self.start_date else None,
|
||||
'end_date': self.end_date.isoformat() if self.end_date else None,
|
||||
'is_current': self.is_current,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<AcademicTerm {self.code} - {self.name}>'
|
||||
@@ -0,0 +1,25 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class AuditLog(db.Model):
|
||||
"""
|
||||
Bitácora inmutable de auditoría para registro de eventos críticos
|
||||
y trazabilidad de operaciones institucionales.
|
||||
"""
|
||||
__tablename__ = 'audit_logs'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
user_email = db.Column(db.String(255), nullable=True)
|
||||
action = db.Column(db.String(50), nullable=False, index=True) # CREATE, UPDATE, DELETE, LOGIN, LOGOUT
|
||||
module = db.Column(db.String(50), nullable=False, index=True) # classrooms, reservations, auth, users
|
||||
entity_id = db.Column(db.Integer, nullable=True) # ID del registro afectado
|
||||
details = db.Column(db.Text, nullable=True) # Detalles de cambios o parámetros
|
||||
ip_address = db.Column(db.String(45), nullable=True) # Soporta IPv4 e IPv6
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
|
||||
# Relación con usuario
|
||||
user = db.relationship('User', foreign_keys=[user_id], backref=db.backref('audit_records', lazy='dynamic'))
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AuditLog #{self.id} {self.action} on {self.module} by {self.user_email or 'System'}>"
|
||||
@@ -0,0 +1,45 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class Building(db.Model):
|
||||
__tablename__ = 'buildings'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), unique=True, nullable=False, index=True)
|
||||
code = db.Column(db.String(50), unique=True, nullable=True)
|
||||
address = db.Column(db.String(255), nullable=True)
|
||||
floors = db.Column(db.Integer, default=1, nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
classrooms = db.relationship('Classroom', backref='building_entity', lazy=True, foreign_keys='Classroom.building_id')
|
||||
|
||||
@property
|
||||
def total_capacity(self):
|
||||
"""Total student capacity across active classrooms in this building"""
|
||||
return sum(c.capacity for c in self.classrooms if c.is_active)
|
||||
|
||||
@property
|
||||
def classrooms_count(self):
|
||||
"""Number of active classrooms"""
|
||||
return len([c for c in self.classrooms if c.is_active])
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'code': self.code,
|
||||
'address': self.address,
|
||||
'floors': self.floors,
|
||||
'description': self.description,
|
||||
'is_active': self.is_active,
|
||||
'classrooms_count': self.classrooms_count,
|
||||
'total_capacity': self.total_capacity,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Building {self.name}>'
|
||||
@@ -0,0 +1,27 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class Career(db.Model):
|
||||
__tablename__ = 'careers'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(150), unique=True, nullable=False, index=True)
|
||||
code = db.Column(db.String(50), nullable=True)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
subjects = db.relationship('Subject', backref='career_obj', lazy=True)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'code': self.code,
|
||||
'description': self.description,
|
||||
'subjects_count': len(self.subjects) if self.subjects else 0,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Career {self.name}>'
|
||||
@@ -6,25 +6,60 @@ class Classroom(db.Model):
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
campus_id = db.Column(db.Integer, nullable=True) # Maps to campus_id in DB
|
||||
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.Integer, nullable=False)
|
||||
floor = db.Column(db.String(50), nullable=False, default='1')
|
||||
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}-{self.code}"
|
||||
return f"{self.building_name}-{self.code}"
|
||||
|
||||
@property
|
||||
def campus_location(self):
|
||||
"""Placeholder for campus location - can be expanded when campus model is implemented"""
|
||||
return None
|
||||
"""Placeholder for campus location"""
|
||||
return self.building_rel.address if self.building_rel else None
|
||||
|
||||
# Virtual field for compatibility - combines building and code
|
||||
@property
|
||||
@@ -38,17 +73,34 @@ class Classroom(db.Model):
|
||||
# Override code property for consistency
|
||||
@property
|
||||
def code_display(self):
|
||||
return f"{self.building}-{self.code}"
|
||||
return f"{self.building_name} - {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:
|
||||
return '∞'
|
||||
return str(self.capacity)
|
||||
|
||||
@property
|
||||
def is_unlimited(self):
|
||||
return self.capacity == 0 or self.is_virtual
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert classroom to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'code': self.code_display,
|
||||
'building': self.building,
|
||||
'building_id': self.building_id,
|
||||
'building': self.building_name,
|
||||
'code': self.code,
|
||||
'code_display': self.code_display,
|
||||
'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,
|
||||
@@ -59,10 +111,9 @@ class Classroom(db.Model):
|
||||
|
||||
def get_full_location(self):
|
||||
"""Get full classroom location"""
|
||||
location = f"{self.building} - Floor {self.floor}"
|
||||
if self.campus_location:
|
||||
location += f" ({self.campus_location})"
|
||||
return location
|
||||
if self.is_virtual:
|
||||
return 'Campus Virtual (Remoto)'
|
||||
return f"{self.building_name} - {self.floor_display}"
|
||||
|
||||
@staticmethod
|
||||
def find_best_classroom(max_students, start_time, end_time, exclude_reservation_id=None):
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class StudentEnrollment(db.Model):
|
||||
"""Association table: student (User) ↔ Commission enrollment managed by Bedelía."""
|
||||
__tablename__ = 'student_enrollments'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
student_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
commission_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('commissions.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
status = db.Column(db.String(20), nullable=False, default='activo') # activo | retirado | condicional
|
||||
enrolled_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Unique constraint: a student can only be enrolled once per commission
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('student_id', 'commission_id', name='uq_student_commission'),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
student = db.relationship('User', backref=db.backref('enrollments', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
commission = db.relationship('Commission', backref=db.backref('enrollments', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'student_id': self.student_id,
|
||||
'student_name': self.student.name if self.student else None,
|
||||
'student_email': self.student.email if self.student else None,
|
||||
'commission_id': self.commission_id,
|
||||
'status': self.status,
|
||||
'enrolled_at': self.enrolled_at.isoformat() if self.enrolled_at else None,
|
||||
'notes': self.notes,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<StudentEnrollment student={self.student_id} commission={self.commission_id} status={self.status}>'
|
||||
@@ -46,10 +46,31 @@ class Individual:
|
||||
self.conflicts = []
|
||||
|
||||
def calculate_fitness(self, classrooms: Dict[int, Classroom], requests: Dict[int, ReservationRequest]) -> float:
|
||||
"""Calculate fitness score based on multiple factors"""
|
||||
"""Calculate fitness score based on multiple factors with fast O(K log K) interval conflict detection"""
|
||||
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)
|
||||
@@ -66,19 +87,20 @@ class Individual:
|
||||
score += time_score * 0.3
|
||||
|
||||
# Factor 3: Conflict penalty (20% weight)
|
||||
conflict_score = self._calculate_conflict_penalty(gene, self.genes)
|
||||
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
|
||||
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
|
||||
@@ -89,7 +111,7 @@ class Individual:
|
||||
return 0.0 # Overfilled classroom
|
||||
|
||||
# Calculate efficiency: closer capacity match = higher score
|
||||
waste_ratio = (classroom.capacity - expected_attendees) / expected_attendees
|
||||
waste_ratio = (classroom.capacity - expected_attendees) / max(1, expected_attendees)
|
||||
if waste_ratio <= 0.1: # Less than 10% waste
|
||||
return 1.0
|
||||
elif waste_ratio <= 0.3: # Less than 30% waste
|
||||
@@ -107,12 +129,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 / request.flexibility_hours) * 0.3
|
||||
return 0.8 - (time_diff / max(1, 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"""
|
||||
"""Check for time conflicts in the same classroom (kept for backward compatibility)"""
|
||||
conflicts = 0
|
||||
for other in all_genes:
|
||||
if gene != other and gene.classroom_id == other.classroom_id:
|
||||
@@ -127,9 +149,6 @@ 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:
|
||||
@@ -138,19 +157,24 @@ class Individual:
|
||||
|
||||
|
||||
class GeneticAlgorithm:
|
||||
"""Main genetic algorithm for room reservation optimization"""
|
||||
"""Main genetic algorithm for room reservation optimization with concurrency and progress tracking"""
|
||||
|
||||
def __init__(self, population_size: int = 50, generations: int = 100,
|
||||
mutation_rate: float = 0.1, crossover_rate: float = 0.8):
|
||||
mutation_rate: float = 0.1, crossover_rate: float = 0.8,
|
||||
workers: int = 4):
|
||||
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) -> Individual:
|
||||
"""Run genetic algorithm to find optimal reservation schedule"""
|
||||
start_date: datetime, end_date: datetime,
|
||||
progress_callback=None) -> Individual:
|
||||
"""Run genetic algorithm with high concurrency to find optimal reservation schedule"""
|
||||
import concurrent.futures
|
||||
|
||||
classrooms_dict = {c.id: c for c in available_classrooms}
|
||||
requests_dict = {r.commission_id: r for r in requests}
|
||||
|
||||
@@ -159,18 +183,29 @@ class GeneticAlgorithm:
|
||||
|
||||
# Evolve population
|
||||
for generation in range(self.generations):
|
||||
# Calculate fitness for all individuals
|
||||
for individual in population:
|
||||
individual.calculate_fitness(classrooms_dict, requests_dict)
|
||||
# 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)
|
||||
|
||||
# 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 = int(self.population_size * 0.1)
|
||||
elite_size = max(2, int(self.population_size * 0.1))
|
||||
new_population.extend(population[:elite_size])
|
||||
|
||||
# Crossover and mutation for remaining
|
||||
@@ -196,7 +231,9 @@ class GeneticAlgorithm:
|
||||
|
||||
population = new_population[:self.population_size]
|
||||
|
||||
# Return best individual from final generation
|
||||
# Calculate fitness for final generation
|
||||
for individual in population:
|
||||
individual.calculate_fitness(classrooms_dict, requests_dict)
|
||||
population.sort(key=lambda x: x.fitness, reverse=True)
|
||||
return population[0]
|
||||
|
||||
@@ -284,13 +321,14 @@ class GeneticAlgorithm:
|
||||
class ReservationOptimizer:
|
||||
"""High-level interface for the reservation optimization system"""
|
||||
|
||||
def __init__(self):
|
||||
self.ga = GeneticAlgorithm()
|
||||
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 optimize_schedule(self, commission_ids: List[int],
|
||||
admin_user_id: int,
|
||||
start_date: datetime = None,
|
||||
end_date: datetime = None) -> Dict:
|
||||
end_date: datetime = None,
|
||||
progress_callback = None) -> Dict:
|
||||
"""Optimize reservation schedule for given commissions"""
|
||||
|
||||
# Default date range if not provided
|
||||
@@ -325,7 +363,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)
|
||||
best_individual = self.ga.optimize_reservations(requests, classrooms, start_date, end_date, progress_callback=progress_callback)
|
||||
|
||||
# Convert to reservation format
|
||||
optimized_reservations = []
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
from datetime import datetime
|
||||
from app import db
|
||||
|
||||
class MilestoneType(db.Model):
|
||||
"""
|
||||
Catálogo dinámico y administrable de tipificaciones de hitos y evaluaciones académicas.
|
||||
(Parciales, Trabajos Prácticos, Recuperatorios, Finales, etc.)
|
||||
"""
|
||||
__tablename__ = 'milestone_types'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False) # Ej: "Parcial 1", "Trabajo Práctico 1"
|
||||
code = db.Column(db.String(50), unique=True, nullable=False, index=True) # Ej: "parcial_1", "tp_1"
|
||||
color = db.Column(db.String(30), default='#0d6efd') # Hex color para badges y calendario
|
||||
description = db.Column(db.String(255), nullable=True)
|
||||
sort_order = db.Column(db.Integer, default=0) # Orden de aparición en selects
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False) # Activo / Inactivo
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relación uno-a-muchos con AcademicMilestone
|
||||
milestones = db.relationship('AcademicMilestone', backref='type_obj', lazy='dynamic', cascade='all, delete-orphan')
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'code': self.code,
|
||||
'color': self.color or '#0d6efd',
|
||||
'description': self.description or '',
|
||||
'sort_order': self.sort_order or 0,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<MilestoneType {self.code}: {self.name}>'
|
||||
|
||||
|
||||
class AcademicMilestone(db.Model):
|
||||
"""
|
||||
Hito o evento académico programado para una materia/comisión (Examen, TP, Entrega).
|
||||
"""
|
||||
__tablename__ = 'academic_milestones'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(150), nullable=True)
|
||||
materia_codigo = db.Column(db.String(50), nullable=False, index=True) # Código de materia (ej: INGA0003)
|
||||
subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=True)
|
||||
commission_id = db.Column(db.Integer, db.ForeignKey('commissions.id', ondelete='SET NULL'), nullable=True)
|
||||
milestone_type_id = db.Column(db.Integer, db.ForeignKey('milestone_types.id'), nullable=False)
|
||||
date = db.Column(db.DateTime, nullable=False) # Fecha y hora del hito
|
||||
classroom = db.Column(db.String(100), nullable=True) # Aula o enlace virtual asignado
|
||||
link = db.Column(db.String(255), nullable=True) # Link Teams / Meet / Zoom si aplica
|
||||
notes = db.Column(db.Text, nullable=True) # Temario, consignas o notas
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relación con Asignatura y Comisión
|
||||
subject = db.relationship('Subject', backref=db.backref('milestones', lazy='dynamic'))
|
||||
commission = db.relationship('Commission', backref=db.backref('academic_milestones', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
|
||||
@property
|
||||
def tipo(self):
|
||||
return self.type_obj.name if self.type_obj else 'Otro'
|
||||
|
||||
@property
|
||||
def color(self):
|
||||
return self.type_obj.color if self.type_obj and self.type_obj.color else '#0d6efd'
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'title': self.title or self.tipo,
|
||||
'materiaCodigo': self.materia_codigo,
|
||||
'subject_id': self.subject_id,
|
||||
'commission_id': self.commission_id,
|
||||
'tipo': self.tipo,
|
||||
'milestone_type_id': self.milestone_type_id,
|
||||
'tipo_info': self.type_obj.to_dict() if self.type_obj else None,
|
||||
'color': self.color,
|
||||
'fecha': self.date.isoformat() if self.date else None,
|
||||
'aula': self.classroom,
|
||||
'link': self.link,
|
||||
'notas': self.notes,
|
||||
'is_active': self.is_active
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<AcademicMilestone {self.materia_codigo} - {self.tipo} ({self.date})>'
|
||||
|
||||
|
||||
# Datos semilla estándar para inicialización
|
||||
BASE_MILESTONE_TYPES = [
|
||||
{
|
||||
'name': 'Parcial 1',
|
||||
'code': 'parcial_1',
|
||||
'color': '#0d6efd',
|
||||
'description': 'Primer examen parcial de la cursada',
|
||||
'sort_order': 1
|
||||
},
|
||||
{
|
||||
'name': 'Parcial 2',
|
||||
'code': 'parcial_2',
|
||||
'color': '#0b5ed7',
|
||||
'description': 'Segundo examen parcial de la cursada',
|
||||
'sort_order': 2
|
||||
},
|
||||
{
|
||||
'name': 'Trabajo Practico 1',
|
||||
'code': 'tp_1',
|
||||
'color': '#198754',
|
||||
'description': 'Primera entrega o evaluacion practica',
|
||||
'sort_order': 3
|
||||
},
|
||||
{
|
||||
'name': 'Trabajo Practico 2',
|
||||
'code': 'tp_2',
|
||||
'color': '#157347',
|
||||
'description': 'Segunda entrega o evaluacion practica',
|
||||
'sort_order': 4
|
||||
},
|
||||
{
|
||||
'name': 'Trabajo Practico 3',
|
||||
'code': 'tp_3',
|
||||
'color': '#0f5132',
|
||||
'description': 'Tercera entrega o proyecto integrador',
|
||||
'sort_order': 5
|
||||
},
|
||||
{
|
||||
'name': 'Recuperatorio Parcial 1',
|
||||
'code': 'recu_p1',
|
||||
'color': '#fd7e14',
|
||||
'description': 'Instancia de recuperacion para el Parcial 1',
|
||||
'sort_order': 6
|
||||
},
|
||||
{
|
||||
'name': 'Recuperatorio Parcial 2',
|
||||
'code': 'recu_p2',
|
||||
'color': '#e76b08',
|
||||
'description': 'Instancia de recuperacion para el Parcial 2',
|
||||
'sort_order': 7
|
||||
},
|
||||
{
|
||||
'name': 'Recuperatorio TP 1',
|
||||
'code': 'recu_tp1',
|
||||
'color': '#ffc107',
|
||||
'description': 'Reentrega o recuperacion de Trabajo Practico 1',
|
||||
'sort_order': 8
|
||||
},
|
||||
{
|
||||
'name': 'Recuperatorio TP 2',
|
||||
'code': 'recu_tp2',
|
||||
'color': '#e0a800',
|
||||
'description': 'Reentrega o recuperacion de Trabajo Practico 2',
|
||||
'sort_order': 9
|
||||
},
|
||||
{
|
||||
'name': 'Recuperatorio TP 3',
|
||||
'code': 'recu_tp3',
|
||||
'color': '#c69500',
|
||||
'description': 'Reentrega o recuperacion de Trabajo Practico 3',
|
||||
'sort_order': 10
|
||||
},
|
||||
{
|
||||
'name': 'Final',
|
||||
'code': 'final',
|
||||
'color': '#dc3545',
|
||||
'description': 'Examen final regular o libre',
|
||||
'sort_order': 11
|
||||
},
|
||||
{
|
||||
'name': 'Otro',
|
||||
'code': 'otro',
|
||||
'color': '#6c757d',
|
||||
'description': 'Taller, seminario, clase especial o hito no tipificado',
|
||||
'sort_order': 12
|
||||
}
|
||||
]
|
||||
@@ -1,6 +1,30 @@
|
||||
from app import db
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
from sqlalchemy.types import TypeDecorator, String
|
||||
|
||||
class StatusString(str):
|
||||
"""String wrapper providing .value property for backward compatibility with Enum usage"""
|
||||
@property
|
||||
def value(self):
|
||||
return str(self)
|
||||
|
||||
def __eq__(self, other):
|
||||
if hasattr(other, 'value'):
|
||||
return str(self) == str(other.value)
|
||||
return str(self) == str(other)
|
||||
|
||||
class StatusType(TypeDecorator):
|
||||
impl = String(20)
|
||||
cache_ok = True
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
return StatusString(value) if value is not None else None
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if hasattr(value, 'value'):
|
||||
return str(value.value)
|
||||
return str(value) if value is not None else None
|
||||
|
||||
class ReservationStatus(Enum):
|
||||
PENDING = 'PENDING'
|
||||
@@ -20,8 +44,10 @@ class Reservation(db.Model):
|
||||
start_time = db.Column(db.DateTime, nullable=False)
|
||||
end_time = db.Column(db.DateTime, nullable=False)
|
||||
purpose = db.Column(db.String(500), nullable=False)
|
||||
status = db.Column(db.String(20), default='PENDING', nullable=False) # DB uses VARCHAR not ENUM
|
||||
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)
|
||||
@@ -32,9 +58,48 @@ class Reservation(db.Model):
|
||||
def status_enum(self):
|
||||
"""Get status as enum"""
|
||||
try:
|
||||
return ReservationStatus(self.status)
|
||||
return ReservationStatus(str(self.status))
|
||||
except (ValueError, AttributeError):
|
||||
return ReservationStatus.PENDING
|
||||
|
||||
@property
|
||||
def subject_code(self):
|
||||
"""Get exact subject code (e.g. INGA0003, ASIG00165)"""
|
||||
if self.commission and self.commission.subject:
|
||||
return self.commission.subject.code
|
||||
return ''
|
||||
|
||||
@property
|
||||
def subject_name(self):
|
||||
"""Get subject name"""
|
||||
if self.commission and self.commission.subject:
|
||||
return self.commission.subject.name
|
||||
return self.purpose or ''
|
||||
|
||||
@property
|
||||
def classroom_name(self):
|
||||
"""Get classroom code or name"""
|
||||
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 and self.classroom.is_virtual:
|
||||
return True
|
||||
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'))
|
||||
@@ -54,6 +119,8 @@ 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,
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
# Definition of system modules available for RBAC permissions
|
||||
SYSTEM_MODULES = [
|
||||
{
|
||||
'id': 'classrooms',
|
||||
'name': 'Aulas y Espacios',
|
||||
'name_en': 'Classrooms & Spaces',
|
||||
'icon': 'bi-door-open',
|
||||
'description': 'Gestión de aulas físicas, aulas virtuales (∞) y capacidades.'
|
||||
},
|
||||
{
|
||||
'id': 'buildings',
|
||||
'name': 'Edificios y Sedes',
|
||||
'name_en': 'Buildings & Campuses',
|
||||
'icon': 'bi-buildings',
|
||||
'description': 'Administración de sede central, campus virtual, pisos y ubicaciones.'
|
||||
},
|
||||
{
|
||||
'id': 'reservations',
|
||||
'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.'
|
||||
},
|
||||
{
|
||||
'id': 'schedule',
|
||||
'name': 'Cartelera y Cronograma',
|
||||
'name_en': 'Schedule & Billboard',
|
||||
'icon': 'bi-calendar3',
|
||||
'description': 'Visualización de calendario interactivo, cartelera de hoy y comisiones.'
|
||||
},
|
||||
{
|
||||
'id': 'academic',
|
||||
'name': 'Gestión Académica',
|
||||
'name_en': 'Academic Management',
|
||||
'icon': 'bi-book',
|
||||
'description': 'Administración de carreras, asignaturas, códigos y comisiones.'
|
||||
},
|
||||
{
|
||||
'id': 'import',
|
||||
'name': 'Sincronización Sheets',
|
||||
'name_en': 'Sheets Sync & Import',
|
||||
'icon': 'bi-file-earmark-spreadsheet',
|
||||
'description': 'Importación y sincronización de datos académicos desde Google Sheets.'
|
||||
},
|
||||
{
|
||||
'id': 'optimizer',
|
||||
'name': 'Optimizador IA',
|
||||
'name_en': 'AI Room Optimizer',
|
||||
'icon': 'bi-cpu',
|
||||
'description': 'Ejecución y configuración del algoritmo genético de asignación.'
|
||||
},
|
||||
{
|
||||
'id': 'users',
|
||||
'name': 'Usuarios y Accesos RBAC',
|
||||
'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.'
|
||||
}
|
||||
]
|
||||
|
||||
ACCESS_LEVELS = ['none', 'read', 'read_write']
|
||||
|
||||
class Role(db.Model):
|
||||
__tablename__ = 'roles'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(80), unique=True, nullable=False, index=True)
|
||||
description = db.Column(db.String(255))
|
||||
is_system = db.Column(db.Boolean, default=False, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
permissions = db.relationship('Permission', backref='role', lazy='joined', cascade='all, delete-orphan')
|
||||
users = db.relationship('User', backref='role_obj', lazy='select')
|
||||
|
||||
def get_permission(self, module):
|
||||
"""Get access level string for a given module ('none', 'read', 'read_write')"""
|
||||
for p in self.permissions:
|
||||
if p.module == module:
|
||||
return p.access_level
|
||||
return 'none'
|
||||
|
||||
def has_permission(self, module, min_level='read'):
|
||||
"""
|
||||
Check if role has sufficient permission level.
|
||||
Hierarchy: 'read_write' > 'read' > 'none'
|
||||
"""
|
||||
current_level = self.get_permission(module)
|
||||
if current_level == 'none':
|
||||
return False
|
||||
if min_level == 'read':
|
||||
return current_level in ['read', 'read_write']
|
||||
if min_level == 'read_write':
|
||||
return current_level == 'read_write'
|
||||
return False
|
||||
|
||||
def set_permission(self, module, access_level):
|
||||
"""Set or update permission level for a module"""
|
||||
if access_level not in ACCESS_LEVELS:
|
||||
access_level = 'none'
|
||||
|
||||
for p in self.permissions:
|
||||
if p.module == module:
|
||||
p.access_level = access_level
|
||||
return p
|
||||
|
||||
new_perm = Permission(role=self, module=module, access_level=access_level)
|
||||
db.session.add(new_perm)
|
||||
return new_perm
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'description': self.description,
|
||||
'is_system': self.is_system,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'user_count': len(self.users) if self.users else 0,
|
||||
'permissions': {p.module: p.access_level for p in self.permissions}
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Role {self.name}>'
|
||||
|
||||
|
||||
class Permission(db.Model):
|
||||
__tablename__ = 'permissions'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
role_id = db.Column(db.Integer, db.ForeignKey('roles.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
module = db.Column(db.String(50), nullable=False)
|
||||
access_level = db.Column(db.String(20), default='none', nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('role_id', 'module', name='uq_role_module'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Permission role={self.role_id} module={self.module} level={self.access_level}>'
|
||||
@@ -5,12 +5,12 @@ class Subject(db.Model):
|
||||
__tablename__ = 'subjects'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
code = db.Column(db.String(20), unique=True, nullable=False, index=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)
|
||||
credits = db.Column(db.Integer, nullable=False)
|
||||
# duration_in_minutes = db.Column(db.Integer, nullable=False)
|
||||
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)
|
||||
@@ -27,7 +27,8 @@ class Subject(db.Model):
|
||||
'description': self.description,
|
||||
'department': self.department,
|
||||
'credits': self.credits,
|
||||
# 'duration_minutes': self.duration_in_minutes,
|
||||
'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
|
||||
@@ -48,6 +49,8 @@ class Commission(db.Model):
|
||||
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)
|
||||
@@ -85,6 +88,8 @@ class Commission(db.Model):
|
||||
'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,
|
||||
@@ -93,7 +98,20 @@ class Commission(db.Model):
|
||||
|
||||
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 ''}"
|
||||
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()}>'
|
||||
@@ -14,6 +14,9 @@ class User(UserMixin, db.Model):
|
||||
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
|
||||
@@ -38,7 +41,87 @@ class User(UserMixin, db.Model):
|
||||
|
||||
def is_admin(self):
|
||||
"""Check if user is admin"""
|
||||
return self.role == '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 get_institutional_role(self):
|
||||
"""Returns normalized institutional role: 'admin', 'bedelia', 'docente', 'alumno'"""
|
||||
raw = ''
|
||||
if self.role_obj and self.role_obj.name:
|
||||
raw = self.role_obj.name.lower().strip()
|
||||
elif self.role:
|
||||
raw = str(self.role).lower().strip()
|
||||
|
||||
if 'admin' in raw:
|
||||
return 'admin'
|
||||
if 'bedel' in raw:
|
||||
return 'bedelia'
|
||||
if 'docent' in raw or 'prof' in raw or 'teacher' in raw:
|
||||
return 'docente'
|
||||
if 'alumn' in raw or 'estud' in raw or 'student' in raw:
|
||||
return 'alumno'
|
||||
return 'docente'
|
||||
|
||||
def is_bedelia(self):
|
||||
return self.get_institutional_role() == 'bedelia'
|
||||
|
||||
def is_docente(self):
|
||||
return self.get_institutional_role() == 'docente'
|
||||
|
||||
def is_alumno(self):
|
||||
return self.get_institutional_role() == 'alumno'
|
||||
|
||||
@property
|
||||
def role_badge_display(self):
|
||||
role_map = {
|
||||
'admin': {'label': 'Administrador', 'class': 'badge-admin', 'icon': 'bi-shield-check', 'color': 'primary'},
|
||||
'bedelia': {'label': 'Bedelía', 'class': 'badge-bedelia', 'icon': 'bi-building-gear', 'color': 'warning'},
|
||||
'docente': {'label': 'Profesor / Docente', 'class': 'badge-docente', 'icon': 'bi-person-video3', 'color': 'info'},
|
||||
'alumno': {'label': 'Alumno / Estudiante', 'class': 'badge-alumno', 'icon': 'bi-mortarboard', 'color': 'success'}
|
||||
}
|
||||
return role_map.get(self.get_institutional_role(), {'label': self.role or 'Docente', 'class': 'bg-secondary', 'icon': 'bi-person', 'color': 'secondary'})
|
||||
|
||||
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.is_bedelia() or
|
||||
self.has_permission('users', 'read') or
|
||||
self.has_permission('milestone_types', 'read') or
|
||||
self.has_permission('import', 'read') or
|
||||
self.has_permission('reservations', 'read_write') or
|
||||
self.has_permission('classrooms', 'read_write') or
|
||||
self.has_permission('buildings', 'read_write') or
|
||||
self.has_permission('metrics', 'read')
|
||||
)
|
||||
|
||||
def can_edit_reservation(self, reservation):
|
||||
"""Check if user has permission to edit a given reservation"""
|
||||
if not reservation:
|
||||
return False
|
||||
if self.is_admin():
|
||||
return True
|
||||
role_name = (self.role_obj.name if self.role_obj else self.role or '').lower()
|
||||
if role_name in ['admin', 'administrador', 'bedelia', 'bedelía', 'operador']:
|
||||
return True
|
||||
if self.has_permission('reservations', 'read_write'):
|
||||
# El docente creador puede modificar su propia reserva
|
||||
if reservation.user_id == self.id:
|
||||
return True
|
||||
# Bedelía o roles de gestión con permiso de escritura total
|
||||
if role_name in ['bedelia', 'bedelía', 'operador']:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def update_last_login(self):
|
||||
"""Update last login timestamp"""
|
||||
@@ -54,7 +137,9 @@ class User(UserMixin, db.Model):
|
||||
'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
|
||||
'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):
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Módulo de Repositorios para persistencia y consultas desacopladas."""
|
||||
from app.repositories.base_repository import BaseRepository
|
||||
from app.repositories.classroom_repository import ClassroomRepository
|
||||
from app.repositories.reservation_repository import ReservationRepository
|
||||
from app.repositories.user_repository import UserRepository
|
||||
|
||||
__all__ = [
|
||||
'BaseRepository',
|
||||
'ClassroomRepository',
|
||||
'ReservationRepository',
|
||||
'UserRepository'
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
from typing import TypeVar, Generic, Type, Optional, List
|
||||
from app import db
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
class BaseRepository(Generic[T]):
|
||||
"""Repositorio base con operaciones CRUD genéricas y seguras."""
|
||||
|
||||
def __init__(self, model_class: Type[T]):
|
||||
self.model_class = model_class
|
||||
|
||||
def get_by_id(self, entity_id: int) -> Optional[T]:
|
||||
"""Obtiene una entidad por su ID numérico."""
|
||||
if not entity_id:
|
||||
return None
|
||||
return db.session.get(self.model_class, entity_id)
|
||||
|
||||
def get_all(self, order_by=None) -> List[T]:
|
||||
"""Obtiene todas las entidades opcionalmente ordenadas."""
|
||||
query = self.model_class.query
|
||||
if order_by is not None:
|
||||
query = query.order_by(order_by)
|
||||
return query.all()
|
||||
|
||||
def save(self, entity: T, commit: bool = True) -> T:
|
||||
"""Agrega o actualiza una entidad en la sesión."""
|
||||
db.session.add(entity)
|
||||
if commit:
|
||||
db.session.commit()
|
||||
return entity
|
||||
|
||||
def delete(self, entity: T, commit: bool = True) -> bool:
|
||||
"""Elimina una entidad de la sesión."""
|
||||
if entity:
|
||||
db.session.delete(entity)
|
||||
if commit:
|
||||
db.session.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
def commit(self):
|
||||
"""Confirma la transacción actual."""
|
||||
db.session.commit()
|
||||
|
||||
def rollback(self):
|
||||
"""Revierte la transacción actual."""
|
||||
db.session.rollback()
|
||||
@@ -0,0 +1,59 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import func
|
||||
from app.models.classroom import Classroom
|
||||
from app.repositories.base_repository import BaseRepository
|
||||
from app import db
|
||||
|
||||
class ClassroomRepository(BaseRepository[Classroom]):
|
||||
"""Repositorio especializado en operaciones de persistencia y filtrado de aulas."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(Classroom)
|
||||
|
||||
def get_by_code_and_building(self, building_id: int, code: str, floor: str) -> Optional[Classroom]:
|
||||
"""Busca un aula por edificio, código y piso para verificar duplicados."""
|
||||
return Classroom.query.filter_by(
|
||||
building_id=building_id,
|
||||
code=code.strip(),
|
||||
floor=str(floor).strip()
|
||||
).first()
|
||||
|
||||
def get_active_classrooms(self, building_id: Optional[int] = None, floor: Optional[str] = None,
|
||||
only_virtual: bool = False, only_physical: bool = False) -> List[Classroom]:
|
||||
"""Retorna aulas activas con filtros opcionales de edificio, piso y modalidad."""
|
||||
query = Classroom.query.filter_by(is_active=True)
|
||||
|
||||
if building_id:
|
||||
query = query.filter_by(building_id=building_id)
|
||||
|
||||
if floor:
|
||||
query = query.filter_by(floor=str(floor).strip())
|
||||
|
||||
classrooms = query.order_by(Classroom.floor.asc(), Classroom.code.asc()).all()
|
||||
|
||||
if only_virtual:
|
||||
return [c for c in classrooms if c.is_virtual]
|
||||
elif only_physical:
|
||||
return [c for c in classrooms if not c.is_virtual]
|
||||
|
||||
return classrooms
|
||||
|
||||
def get_virtual_classrooms(self) -> List[Classroom]:
|
||||
"""Obtiene todas las salas virtuales activas."""
|
||||
return [c for c in self.get_active_classrooms() if c.is_virtual]
|
||||
|
||||
def get_physical_classrooms(self) -> List[Classroom]:
|
||||
"""Obtiene todas las aulas físicas activas."""
|
||||
return [c for c in self.get_active_classrooms() if not c.is_virtual]
|
||||
|
||||
def search(self, term: str) -> List[Classroom]:
|
||||
"""Búsqueda de aulas por código, edificio o descripción."""
|
||||
if not term:
|
||||
return self.get_all(order_by=Classroom.code.asc())
|
||||
term_clean = f"%{term.strip()}%"
|
||||
return Classroom.query.filter(
|
||||
Classroom.is_active == True,
|
||||
(Classroom.code.ilike(term_clean)) |
|
||||
(Classroom.building.ilike(term_clean)) |
|
||||
(Classroom.description.ilike(term_clean))
|
||||
).order_by(Classroom.code.asc()).all()
|
||||
@@ -0,0 +1,61 @@
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, date, time
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy import and_, or_
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from app.repositories.base_repository import BaseRepository
|
||||
from app import db
|
||||
|
||||
class ReservationRepository(BaseRepository[Reservation]):
|
||||
"""Repositorio para la persistencia y consulta de reservas de aulas."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(Reservation)
|
||||
|
||||
def find_conflicts(self, classroom_id: int, start_time: datetime, end_time: datetime,
|
||||
exclude_reservation_id: Optional[int] = None) -> List[Reservation]:
|
||||
"""
|
||||
Encuentra reservas activas que se solapan en el mismo aula y franja horaria.
|
||||
Condición de solapamiento: (start_time < existing.end_time) AND (end_time > existing.start_time).
|
||||
"""
|
||||
query = Reservation.query.filter(
|
||||
Reservation.classroom_id == classroom_id,
|
||||
Reservation.status != ReservationStatus.CANCELLED.value,
|
||||
Reservation.start_time < end_time,
|
||||
Reservation.end_time > start_time
|
||||
)
|
||||
|
||||
if exclude_reservation_id:
|
||||
query = query.filter(Reservation.id != exclude_reservation_id)
|
||||
|
||||
return query.all()
|
||||
|
||||
def get_by_date_range(self, start_dt: datetime, end_dt: datetime,
|
||||
classroom_id: Optional[int] = None) -> List[Reservation]:
|
||||
"""Obtiene reservas dentro de una ventana temporal con carga ansiosa de relaciones."""
|
||||
query = Reservation.query.options(
|
||||
joinedload(Reservation.classroom),
|
||||
joinedload(Reservation.commission),
|
||||
joinedload(Reservation.user)
|
||||
).filter(
|
||||
Reservation.start_time >= start_dt,
|
||||
Reservation.start_time <= end_dt,
|
||||
Reservation.status != ReservationStatus.CANCELLED.value
|
||||
)
|
||||
|
||||
if classroom_id:
|
||||
query = query.filter(Reservation.classroom_id == classroom_id)
|
||||
|
||||
return query.order_by(Reservation.start_time.asc()).all()
|
||||
|
||||
def get_by_teacher(self, teacher_id: int) -> List[Reservation]:
|
||||
"""Obtiene reservas asignadas a un docente."""
|
||||
return Reservation.query.filter_by(
|
||||
user_id=teacher_id
|
||||
).order_by(Reservation.start_time.desc()).all()
|
||||
|
||||
def get_by_commission(self, commission_id: int) -> List[Reservation]:
|
||||
"""Obtiene todas las reservas asociadas a una comisión."""
|
||||
return Reservation.query.filter_by(
|
||||
commission_id=commission_id
|
||||
).order_by(Reservation.start_time.asc()).all()
|
||||
@@ -0,0 +1,34 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import func
|
||||
from app.models.user import User
|
||||
from app.repositories.base_repository import BaseRepository
|
||||
|
||||
class UserRepository(BaseRepository[User]):
|
||||
"""Repositorio para la gestión de usuarios, credenciales y roles."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(User)
|
||||
|
||||
def get_by_email(self, email: str) -> Optional[User]:
|
||||
"""Obtiene un usuario por su dirección de email normalizada."""
|
||||
if not email:
|
||||
return None
|
||||
return User.query.filter(func.lower(User.email) == email.strip().lower()).first()
|
||||
|
||||
def get_active_users(self) -> List[User]:
|
||||
"""Obtiene todos los usuarios con cuenta activa."""
|
||||
return User.query.filter_by(is_active=True).order_by(User.name.asc()).all()
|
||||
|
||||
def get_by_role(self, role_name: str) -> List[User]:
|
||||
"""Obtiene usuarios por nombre de rol."""
|
||||
return User.query.filter(
|
||||
User.is_active == True,
|
||||
func.lower(User.role) == role_name.strip().lower()
|
||||
).order_by(User.name.asc()).all()
|
||||
|
||||
def get_teachers(self) -> List[User]:
|
||||
"""Obtiene lista de docentes y profesores activos."""
|
||||
return User.query.filter(
|
||||
User.is_active == True,
|
||||
User.role.in_(['TEACHER', 'DOCENTE', 'PROFESOR', 'teacher', 'docente'])
|
||||
).order_by(User.name.asc()).all()
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -1,7 +1,10 @@
|
||||
from .main import main_bp
|
||||
from .auth import auth_bp
|
||||
from .classrooms import classrooms_bp
|
||||
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', 'schedule_bp', 'genetic_bp', 'genetic_web_bp']
|
||||
__all__ = ['main_bp', 'auth_bp', 'classrooms_bp', 'buildings_bp', 'schedule_bp', 'genetic_bp', 'genetic_web_bp', 'admin_bp', 'my_subjects_bp']
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Paquete de rutas API RESTful (v1) con soporte Stateless JWT."""
|
||||
from app.routes.api.auth import api_auth_bp
|
||||
from app.routes.api.classrooms import api_classrooms_bp
|
||||
from app.routes.api.reservations import api_reservations_bp
|
||||
from app.routes.api.optimizer import api_optimizer_bp
|
||||
|
||||
__all__ = [
|
||||
'api_auth_bp',
|
||||
'api_classrooms_bp',
|
||||
'api_reservations_bp',
|
||||
'api_optimizer_bp'
|
||||
]
|
||||
@@ -0,0 +1,109 @@
|
||||
from flask import Blueprint, request, jsonify, g, make_response
|
||||
from pydantic import ValidationError
|
||||
import jwt
|
||||
|
||||
from app.services.user_service import UserService
|
||||
from app.services.jwt_service import JWTService
|
||||
from app.schemas.auth_dto import LoginDTO, RefreshTokenDTO
|
||||
from app.utils.jwt_decorators import jwt_required
|
||||
|
||||
api_auth_bp = Blueprint('api_auth', __name__, url_prefix='/api/v1/auth')
|
||||
user_service = UserService()
|
||||
|
||||
@api_auth_bp.route('/login', methods=['POST'])
|
||||
def login():
|
||||
"""
|
||||
Endpoint de autenticación para obtener par de tokens (Access + Refresh).
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
dto = LoginDTO(**data)
|
||||
except ValidationError as e:
|
||||
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
|
||||
|
||||
user = user_service.authenticate(dto.email, dto.password)
|
||||
if not user:
|
||||
return jsonify({
|
||||
'error': 'Unauthorized',
|
||||
'message': 'Credenciales de acceso incorrectas o cuenta inactiva.'
|
||||
}), 401
|
||||
|
||||
tokens = JWTService.generate_tokens(user)
|
||||
profile = user_service.get_profile_data(user)
|
||||
|
||||
response_data = {
|
||||
'access_token': tokens['access_token'],
|
||||
'refresh_token': tokens['refresh_token'],
|
||||
'token_type': tokens['token_type'],
|
||||
'expires_in': tokens['expires_in'],
|
||||
'user': profile
|
||||
}
|
||||
|
||||
resp = make_response(jsonify(response_data), 200)
|
||||
# Almacenar refresh token en cookie segura HttpOnly
|
||||
resp.set_cookie(
|
||||
'refresh_token',
|
||||
tokens['refresh_token'],
|
||||
httponly=True,
|
||||
samesite='Lax',
|
||||
max_age=7 * 24 * 3600,
|
||||
path='/api/v1/auth/refresh'
|
||||
)
|
||||
return resp
|
||||
|
||||
@api_auth_bp.route('/refresh', methods=['POST'])
|
||||
def refresh():
|
||||
"""
|
||||
Renovación silenciosa del Access Token utilizando el Refresh Token.
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
refresh_token = data.get('refresh_token') or request.cookies.get('refresh_token')
|
||||
|
||||
if not refresh_token:
|
||||
return jsonify({
|
||||
'error': 'BadRequest',
|
||||
'message': 'Refresh token no suministrado en el cuerpo ni en cookies.'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
payload = JWTService.decode_token(refresh_token, expected_type='refresh')
|
||||
user_id = int(payload.get('sub'))
|
||||
user = user_service.get_by_id(user_id)
|
||||
|
||||
if not user or not user.is_active:
|
||||
return jsonify({
|
||||
'error': 'Unauthorized',
|
||||
'message': 'Usuario no encontrado o inactivo.'
|
||||
}), 401
|
||||
|
||||
new_access_token = JWTService.create_access_token(user)
|
||||
return jsonify({
|
||||
'access_token': new_access_token,
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': int(JWTService.ACCESS_TOKEN_EXPIRES.total_seconds())
|
||||
}), 200
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'error': 'TokenExpired', 'message': 'El refresh token ha expirado. Reingrese credenciales.'}), 401
|
||||
except jwt.InvalidTokenError as e:
|
||||
return jsonify({'error': 'InvalidToken', 'message': str(e)}), 401
|
||||
|
||||
@api_auth_bp.route('/logout', methods=['POST'])
|
||||
@jwt_required
|
||||
def logout():
|
||||
"""
|
||||
Cierre de sesión: revoca el token de acceso activo y limpia cookies.
|
||||
"""
|
||||
JWTService.revoke_token(g.jwt_token)
|
||||
resp = make_response(jsonify({'message': 'Sesión finalizada y token revocado exitosamente.'}), 200)
|
||||
resp.delete_cookie('refresh_token', path='/api/v1/auth/refresh')
|
||||
return resp
|
||||
|
||||
@api_auth_bp.route('/me', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_current_user():
|
||||
"""
|
||||
Retorna el perfil y los permisos del usuario autenticado vía JWT.
|
||||
"""
|
||||
profile = user_service.get_profile_data(g.jwt_user)
|
||||
return jsonify(profile), 200
|
||||
@@ -0,0 +1,119 @@
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.services.classroom_service import ClassroomService
|
||||
from app.schemas.classroom_dto import ClassroomCreateDTO, ClassroomUpdateDTO, ClassroomResponseDTO
|
||||
from app.utils.jwt_decorators import jwt_required, jwt_role_required
|
||||
|
||||
api_classrooms_bp = Blueprint('api_classrooms', __name__, url_prefix='/api/v1/classrooms')
|
||||
classroom_service = ClassroomService()
|
||||
|
||||
@api_classrooms_bp.route('', methods=['GET'])
|
||||
@jwt_required
|
||||
def list_classrooms():
|
||||
"""Lista aulas con filtros opcionales (building_id, floor, mode)."""
|
||||
building_id = request.args.get('building_id', type=int)
|
||||
floor = request.args.get('floor')
|
||||
mode = request.args.get('mode', '').lower()
|
||||
|
||||
only_virtual = mode == 'virtual'
|
||||
only_physical = mode == 'physical'
|
||||
|
||||
classrooms = classroom_service.list_classrooms(
|
||||
building_id=building_id,
|
||||
floor=floor,
|
||||
only_virtual=only_virtual,
|
||||
only_physical=only_physical
|
||||
)
|
||||
|
||||
result = []
|
||||
for c in classrooms:
|
||||
result.append({
|
||||
'id': c.id,
|
||||
'code': c.code,
|
||||
'building': c.building_name,
|
||||
'building_id': c.building_id,
|
||||
'floor': c.floor,
|
||||
'floor_display': c.floor_display,
|
||||
'capacity': c.capacity,
|
||||
'is_virtual': c.is_virtual,
|
||||
'location_display': c.location_display,
|
||||
'description': c.description,
|
||||
'is_active': c.is_active
|
||||
})
|
||||
|
||||
return jsonify({'total': len(result), 'classrooms': result}), 200
|
||||
|
||||
@api_classrooms_bp.route('/<int:classroom_id>', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_classroom(classroom_id: int):
|
||||
"""Obtiene el detalle de un aula por ID."""
|
||||
c = classroom_service.get_classroom(classroom_id)
|
||||
if not c:
|
||||
return jsonify({'error': 'NotFound', 'message': f'Aula {classroom_id} no encontrada.'}), 404
|
||||
|
||||
return jsonify({
|
||||
'id': c.id,
|
||||
'code': c.code,
|
||||
'building': c.building_name,
|
||||
'building_id': c.building_id,
|
||||
'floor': c.floor,
|
||||
'floor_display': c.floor_display,
|
||||
'capacity': c.capacity,
|
||||
'is_virtual': c.is_virtual,
|
||||
'location_display': c.location_display,
|
||||
'description': c.description,
|
||||
'is_active': c.is_active
|
||||
}), 200
|
||||
|
||||
@api_classrooms_bp.route('', methods=['POST'])
|
||||
@jwt_role_required('ADMIN', 'ADMINISTRADOR', 'BEDELIA')
|
||||
def create_classroom():
|
||||
"""Crea una nueva aula validando el contrato con ClassroomCreateDTO."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
dto = ClassroomCreateDTO(**data)
|
||||
except ValidationError as e:
|
||||
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
|
||||
|
||||
try:
|
||||
classroom = classroom_service.create_classroom(dto)
|
||||
return jsonify({
|
||||
'message': 'Aula creada exitosamente.',
|
||||
'id': classroom.id,
|
||||
'code': classroom.code,
|
||||
'location_display': classroom.location_display
|
||||
}), 201
|
||||
except ValueError as e:
|
||||
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 400
|
||||
|
||||
@api_classrooms_bp.route('/<int:classroom_id>', methods=['PUT'])
|
||||
@jwt_role_required('ADMIN', 'ADMINISTRADOR', 'BEDELIA')
|
||||
def update_classroom(classroom_id: int):
|
||||
"""Actualiza los datos de un aula."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
dto = ClassroomUpdateDTO(**data)
|
||||
except ValidationError as e:
|
||||
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
|
||||
|
||||
try:
|
||||
classroom = classroom_service.update_classroom(classroom_id, dto)
|
||||
return jsonify({
|
||||
'message': 'Aula actualizada exitosamente.',
|
||||
'id': classroom.id,
|
||||
'code': classroom.code,
|
||||
'capacity': classroom.capacity
|
||||
}), 200
|
||||
except ValueError as e:
|
||||
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 400
|
||||
|
||||
@api_classrooms_bp.route('/<int:classroom_id>', methods=['DELETE'])
|
||||
@jwt_role_required('ADMIN', 'ADMINISTRADOR')
|
||||
def delete_classroom(classroom_id: int):
|
||||
"""Elimina o desactiva un aula."""
|
||||
deleted = classroom_service.delete_classroom(classroom_id)
|
||||
if not deleted:
|
||||
return jsonify({'error': 'NotFound', 'message': f'Aula {classroom_id} no encontrada.'}), 404
|
||||
|
||||
return jsonify({'message': f'Aula {classroom_id} eliminada/desactivada correctamente.'}), 200
|
||||
@@ -0,0 +1,105 @@
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.schemas.optimizer_dto import OptimizerJobRequestDTO, SpringBootSyncPayloadDTO
|
||||
from app.services.optimizer_service import OptimizerService
|
||||
from app.utils.jwt_decorators import jwt_required
|
||||
|
||||
api_optimizer_bp = Blueprint('api_optimizer', __name__, url_prefix='/api/v1/optimizer')
|
||||
|
||||
|
||||
@api_optimizer_bp.route('/jobs', methods=['POST'])
|
||||
@jwt_required
|
||||
def submit_optimization_job():
|
||||
"""
|
||||
Despacha un trabajo de optimización heurística asíncrono.
|
||||
Retorna inmediatamente con HTTP 202 Accepted y el identificador de trabajo para sondeo.
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
dto = OptimizerJobRequestDTO(**data)
|
||||
except ValidationError as ex:
|
||||
return jsonify({
|
||||
'error': 'UnprocessableEntity',
|
||||
'message': 'Error de validación en los parámetros del optimizador.',
|
||||
'details': ex.errors()
|
||||
}), 422
|
||||
|
||||
user_id = g.current_user.id if hasattr(g, 'current_user') and g.current_user else 1
|
||||
|
||||
job = OptimizerService.submit_job(
|
||||
commission_ids=dto.commission_ids,
|
||||
admin_user_id=user_id,
|
||||
start_date=dto.start_date,
|
||||
end_date=dto.end_date,
|
||||
generations=dto.generations,
|
||||
population_size=dto.population_size,
|
||||
workers=dto.workers
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'job_id': job.job_id,
|
||||
'status': job.status.value,
|
||||
'poll_url': f'/api/v1/optimizer/jobs/{job.job_id}',
|
||||
'message': 'Trabajo de optimización puesto en cola con éxito.'
|
||||
}), 202
|
||||
|
||||
|
||||
@api_optimizer_bp.route('/jobs/<string:job_id>', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_optimization_job(job_id: str):
|
||||
"""
|
||||
Obtiene el estado, progreso y resultados de un trabajo de optimización en cola.
|
||||
"""
|
||||
job = OptimizerService.get_job(job_id)
|
||||
if not job:
|
||||
return jsonify({
|
||||
'error': 'NotFound',
|
||||
'message': f'Trabajo de optimización {job_id} no encontrado.'
|
||||
}), 404
|
||||
|
||||
return jsonify(job.to_dict()), 200
|
||||
|
||||
|
||||
@api_optimizer_bp.route('/jobs', methods=['GET'])
|
||||
@jwt_required
|
||||
def list_optimization_jobs():
|
||||
"""
|
||||
Lista los trabajos de optimización más recientes.
|
||||
"""
|
||||
limit = request.args.get('limit', default=20, type=int)
|
||||
jobs = OptimizerService.list_jobs(limit=limit)
|
||||
return jsonify({
|
||||
'total': len(jobs),
|
||||
'jobs': jobs
|
||||
}), 200
|
||||
|
||||
|
||||
@api_optimizer_bp.route('/spring-boot/sync', methods=['POST'])
|
||||
@jwt_required
|
||||
def sync_with_spring_boot():
|
||||
"""
|
||||
Endpoint interoperable para la sincronización y resolución de horarios con microservicios Spring Boot.
|
||||
Recibe el payload en formato Spring Boot (camelCase) y retorna el cronograma optimizado.
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
dto = SpringBootSyncPayloadDTO(**data)
|
||||
except ValidationError as ex:
|
||||
return jsonify({
|
||||
'error': 'UnprocessableEntity',
|
||||
'message': 'Error de validación en el payload Spring Boot.',
|
||||
'details': ex.errors()
|
||||
}), 422
|
||||
|
||||
user_id = g.current_user.id if hasattr(g, 'current_user') and g.current_user else 1
|
||||
|
||||
try:
|
||||
result = OptimizerService.sync_with_spring_boot(dto, admin_user_id=user_id)
|
||||
return jsonify(result), 200
|
||||
except Exception as ex:
|
||||
return jsonify({
|
||||
'requestId': dto.requestId,
|
||||
'status': 'ERROR',
|
||||
'error': str(ex)
|
||||
}), 500
|
||||
@@ -0,0 +1,140 @@
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from datetime import datetime, timedelta, date
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.services.reservation_service import ReservationService
|
||||
from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO
|
||||
from app.utils.jwt_decorators import jwt_required, jwt_role_required
|
||||
|
||||
api_reservations_bp = Blueprint('api_reservations', __name__, url_prefix='/api/v1/reservations')
|
||||
reservation_service = ReservationService()
|
||||
|
||||
@api_reservations_bp.route('', methods=['GET'])
|
||||
@jwt_required
|
||||
def list_reservations():
|
||||
"""Consulta de reservas por rango de fechas y/o aula."""
|
||||
start_str = request.args.get('start_date')
|
||||
end_str = request.args.get('end_date')
|
||||
classroom_id = request.args.get('classroom_id', type=int)
|
||||
|
||||
now = datetime.utcnow()
|
||||
try:
|
||||
start_dt = datetime.strptime(start_str, '%Y-%m-%d') if start_str else now.replace(hour=0, minute=0, second=0)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'BadRequest', 'message': 'Formato inválido de start_date (debe ser YYYY-MM-DD).'}), 400
|
||||
|
||||
try:
|
||||
end_dt = datetime.strptime(end_str, '%Y-%m-%d').replace(hour=23, minute=59, second=59) if end_str else start_dt + timedelta(days=7)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'BadRequest', 'message': 'Formato inválido de end_date (debe ser YYYY-MM-DD).'}), 400
|
||||
|
||||
reservations = reservation_service.list_reservations(start_dt, end_dt, classroom_id=classroom_id)
|
||||
result = []
|
||||
for r in reservations:
|
||||
result.append({
|
||||
'id': r.id,
|
||||
'classroom_id': r.classroom_id,
|
||||
'classroom_name': r.classroom.code if r.classroom else None,
|
||||
'commission_id': r.commission_id,
|
||||
'teacher_name': r.user.name if r.user else None,
|
||||
'start_time': r.start_time.isoformat(),
|
||||
'end_time': r.end_time.isoformat(),
|
||||
'purpose': r.purpose,
|
||||
'status': str(r.status),
|
||||
'shift': r.shift,
|
||||
'virtual_link': r.virtual_link,
|
||||
'expected_attendees': r.expected_attendees
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'total': len(result),
|
||||
'start_date': start_dt.strftime('%Y-%m-%d'),
|
||||
'end_date': end_dt.strftime('%Y-%m-%d'),
|
||||
'reservations': result
|
||||
}), 200
|
||||
|
||||
@api_reservations_bp.route('/<int:reservation_id>', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_reservation(reservation_id: int):
|
||||
"""Obtiene el detalle de una reserva específica."""
|
||||
r = reservation_service.get_reservation(reservation_id)
|
||||
if not r:
|
||||
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
|
||||
|
||||
return jsonify({
|
||||
'id': r.id,
|
||||
'classroom_id': r.classroom_id,
|
||||
'classroom_name': r.classroom.code if r.classroom else None,
|
||||
'location_display': r.classroom.location_display if r.classroom else None,
|
||||
'commission_id': r.commission_id,
|
||||
'teacher_id': r.user_id,
|
||||
'teacher_name': r.user.name if r.user else None,
|
||||
'start_time': r.start_time.isoformat(),
|
||||
'end_time': r.end_time.isoformat(),
|
||||
'purpose': r.purpose,
|
||||
'status': str(r.status),
|
||||
'shift': r.shift,
|
||||
'virtual_link': r.virtual_link,
|
||||
'expected_attendees': r.expected_attendees,
|
||||
'notes': r.notes
|
||||
}), 200
|
||||
|
||||
@api_reservations_bp.route('', methods=['POST'])
|
||||
@jwt_required
|
||||
def create_reservation():
|
||||
"""Crea una reserva aplicando validación DTO y detección de colisiones."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
# Inyectar usuario autenticado si no se especifica
|
||||
if 'user_id' not in data:
|
||||
data['user_id'] = g.jwt_user.id
|
||||
|
||||
try:
|
||||
dto = ReservationCreateDTO(**data)
|
||||
except ValidationError as e:
|
||||
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
|
||||
|
||||
try:
|
||||
reservation = reservation_service.create_reservation(dto)
|
||||
return jsonify({
|
||||
'message': 'Reserva creada exitosamente.',
|
||||
'id': reservation.id,
|
||||
'classroom_id': reservation.classroom_id,
|
||||
'start_time': reservation.start_time.isoformat(),
|
||||
'end_time': reservation.end_time.isoformat(),
|
||||
'status': str(reservation.status)
|
||||
}), 201
|
||||
except ValueError as e:
|
||||
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 409
|
||||
|
||||
@api_reservations_bp.route('/<int:reservation_id>', methods=['PUT'])
|
||||
@jwt_required
|
||||
def update_reservation(reservation_id: int):
|
||||
"""Modifica una reserva existente."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
dto = ReservationUpdateDTO(**data)
|
||||
except ValidationError as e:
|
||||
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
|
||||
|
||||
try:
|
||||
reservation = reservation_service.update_reservation(reservation_id, dto)
|
||||
return jsonify({
|
||||
'message': 'Reserva actualizada exitosamente.',
|
||||
'id': reservation.id,
|
||||
'start_time': reservation.start_time.isoformat(),
|
||||
'end_time': reservation.end_time.isoformat(),
|
||||
'status': str(reservation.status)
|
||||
}), 200
|
||||
except ValueError as e:
|
||||
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 409
|
||||
|
||||
@api_reservations_bp.route('/<int:reservation_id>', methods=['DELETE'])
|
||||
@jwt_required
|
||||
def cancel_reservation(reservation_id: int):
|
||||
"""Cancela una reserva."""
|
||||
cancelled = reservation_service.cancel_reservation(reservation_id)
|
||||
if not cancelled:
|
||||
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
|
||||
|
||||
return jsonify({'message': f'Reserva {reservation_id} cancelada correctamente.'}), 200
|
||||
@@ -1,5 +1,6 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, session
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, session, jsonify
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from flask_babel import gettext as _
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
from app.forms.auth import LoginForm
|
||||
@@ -7,32 +8,66 @@ 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"""
|
||||
"""Handle user login with case-insensitive email lookup and whitespace trimming"""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
user = User.query.filter_by(email=form.email.data).first()
|
||||
clean_email = (form.email.data or '').strip().lower()
|
||||
user = User.query.filter(func.lower(User.email) == clean_email).first()
|
||||
|
||||
if user and user.check_password(form.password.data):
|
||||
if not user.is_active:
|
||||
flash('Your account has been deactivated. Please contact administrator.', 'error')
|
||||
flash(_('Tu cuenta ha sido desactivada. Por favor contacta al administrador.'), 'error')
|
||||
return render_template('auth/login.html', form=form)
|
||||
|
||||
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']:
|
||||
session['language'] = user.preferred_language
|
||||
else:
|
||||
session.pop('language', None)
|
||||
|
||||
session.pop('login_lang_explicit', None)
|
||||
|
||||
next_page = request.args.get('next')
|
||||
if not next_page or not next_page.startswith('/'):
|
||||
next_page = url_for('main.dashboard')
|
||||
|
||||
flash(f'Welcome back, {user.first_name} {user.last_name}!', 'success')
|
||||
user_full_name = f'{user.first_name} {user.last_name}'.strip() or user.name
|
||||
flash(_('¡Bienvenido de nuevo, %(name)s!', name=user_full_name), 'success')
|
||||
return redirect(next_page)
|
||||
else:
|
||||
flash('Invalid email or password.', 'error')
|
||||
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)
|
||||
|
||||
@@ -41,15 +76,49 @@ def login():
|
||||
def logout():
|
||||
"""Handle user logout"""
|
||||
logout_user()
|
||||
flash('You have been logged out successfully.', 'info')
|
||||
# Al cerrar sesión, se limpian las cookies de idioma para que el login detecte el navegador o use español
|
||||
session.pop('language', None)
|
||||
session.pop('login_lang_explicit', None)
|
||||
flash(_('Has cerrado sesión correctamente.'), 'info')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
@auth_bp.route('/profile')
|
||||
@auth_bp.route('/profile', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def profile():
|
||||
"""User profile page"""
|
||||
"""User profile page with language and theme settings"""
|
||||
if request.method == 'POST':
|
||||
lang = request.form.get('preferred_language')
|
||||
if lang in ['es', 'en']:
|
||||
current_user.preferred_language = lang
|
||||
session['language'] = lang
|
||||
else:
|
||||
# Cadena vacía o None restablece la detección automática del navegador
|
||||
current_user.preferred_language = None
|
||||
session.pop('language', None)
|
||||
|
||||
theme = request.form.get('theme_preference')
|
||||
if theme in ['auto', 'light', 'dark']:
|
||||
current_user.theme_preference = theme
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Preferencias de usuario actualizadas correctamente.'), 'success')
|
||||
return redirect(url_for('auth.profile'))
|
||||
return render_template('auth/profile.html')
|
||||
|
||||
@auth_bp.route('/api/user/theme', methods=['POST'])
|
||||
@login_required
|
||||
def update_theme_api():
|
||||
"""Update user theme preference via fetch/AJAX from navbar toggle"""
|
||||
data = request.get_json(silent=True) or request.form
|
||||
theme = data.get('theme') if data else None
|
||||
if theme in ['auto', 'light', 'dark']:
|
||||
current_user.theme_preference = theme
|
||||
db.session.commit()
|
||||
return jsonify({'status': 'success', 'theme': theme})
|
||||
return jsonify({'status': 'error', 'message': 'Invalid theme'}), 400
|
||||
|
||||
|
||||
|
||||
@auth_bp.route('/change_password', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def change_password():
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.building import Building
|
||||
from app.models.classroom import Classroom
|
||||
from app.forms.building import BuildingForm
|
||||
from app.utils.decorators import permission_required
|
||||
from datetime import datetime
|
||||
|
||||
buildings_bp = Blueprint('buildings', __name__, url_prefix='/buildings')
|
||||
|
||||
@buildings_bp.route('/')
|
||||
@login_required
|
||||
def list_buildings():
|
||||
"""List all buildings with statistics"""
|
||||
search = request.args.get('search', '').strip()
|
||||
status_filter = request.args.get('status', '')
|
||||
|
||||
query = Building.query
|
||||
if search:
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Building.name.ilike(f'%{search}%'),
|
||||
Building.code.ilike(f'%{search}%'),
|
||||
Building.address.ilike(f'%{search}%')
|
||||
)
|
||||
)
|
||||
if status_filter == 'active':
|
||||
query = query.filter(Building.is_active == True)
|
||||
elif status_filter == 'inactive':
|
||||
query = query.filter(Building.is_active == False)
|
||||
|
||||
buildings = query.order_by(Building.name.asc()).all()
|
||||
|
||||
# Calculate global building stats
|
||||
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
||||
total_capacity = sum(c.capacity for c in Classroom.query.filter_by(is_active=True).all())
|
||||
|
||||
return render_template(
|
||||
'buildings/list.html',
|
||||
buildings=buildings,
|
||||
total_classrooms=total_classrooms,
|
||||
total_capacity=total_capacity,
|
||||
search=search,
|
||||
status_filter=status_filter,
|
||||
title='Building Management'
|
||||
)
|
||||
|
||||
@buildings_bp.route('/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@permission_required('classrooms', 'read_write')
|
||||
def add_building():
|
||||
"""Add new building"""
|
||||
form = BuildingForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
# Check unique name
|
||||
existing = Building.query.filter(
|
||||
db.func.lower(Building.name) == form.name.data.lower()
|
||||
).first()
|
||||
if existing:
|
||||
flash('Ya existe un edificio con este nombre.', 'danger')
|
||||
return render_template('buildings/form.html', form=form, title='Add Building')
|
||||
|
||||
building = Building(
|
||||
name=form.name.data.strip(),
|
||||
code=form.code.data.strip().upper() if form.code.data else None,
|
||||
address=form.address.data.strip() if form.address.data else None,
|
||||
floors=form.floors.data,
|
||||
description=form.description.data.strip() if form.description.data else None,
|
||||
is_active=form.is_active.data
|
||||
)
|
||||
db.session.add(building)
|
||||
db.session.commit()
|
||||
flash(f'Edificio "{building.name}" creado con éxito.', 'success')
|
||||
return redirect(url_for('buildings.view_building', id=building.id))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error al crear el edificio: {str(e)}', 'danger')
|
||||
|
||||
return render_template('buildings/form.html', form=form, title='Add Building')
|
||||
|
||||
@buildings_bp.route('/<int:id>')
|
||||
@login_required
|
||||
def view_building(id):
|
||||
"""View building details and its classrooms by floor"""
|
||||
building = Building.query.get_or_404(id)
|
||||
|
||||
# Group active classrooms by floor
|
||||
classrooms = Classroom.query.filter_by(building_id=building.id).order_by(Classroom.floor.asc(), Classroom.code.asc()).all()
|
||||
# Fallback to name matching if building_id was not set
|
||||
if not classrooms:
|
||||
classrooms = Classroom.query.filter_by(building=building.name).order_by(Classroom.floor.asc(), Classroom.code.asc()).all()
|
||||
|
||||
classrooms_by_floor = {}
|
||||
for c in classrooms:
|
||||
floor_key = c.floor
|
||||
if floor_key not in classrooms_by_floor:
|
||||
classrooms_by_floor[floor_key] = []
|
||||
classrooms_by_floor[floor_key].append(c)
|
||||
|
||||
return render_template(
|
||||
'buildings/view.html',
|
||||
building=building,
|
||||
classrooms=classrooms,
|
||||
classrooms_by_floor=classrooms_by_floor,
|
||||
title=f'Building - {building.name}'
|
||||
)
|
||||
|
||||
@buildings_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@permission_required('classrooms', 'read_write')
|
||||
def edit_building(id):
|
||||
"""Edit building"""
|
||||
building = Building.query.get_or_404(id)
|
||||
form = BuildingForm(obj=building)
|
||||
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
# Check unique name excluding current
|
||||
existing = Building.query.filter(
|
||||
db.func.lower(Building.name) == form.name.data.lower(),
|
||||
Building.id != id
|
||||
).first()
|
||||
if existing:
|
||||
flash('Ya existe otro edificio con este nombre.', 'danger')
|
||||
return render_template('buildings/form.html', form=form, building=building, title='Edit Building')
|
||||
|
||||
old_name = building.name
|
||||
new_name = form.name.data.strip()
|
||||
|
||||
building.name = new_name
|
||||
building.code = form.code.data.strip().upper() if form.code.data else None
|
||||
building.address = form.address.data.strip() if form.address.data else None
|
||||
building.floors = form.floors.data
|
||||
building.description = form.description.data.strip() if form.description.data else None
|
||||
building.is_active = form.is_active.data
|
||||
building.updated_at = datetime.utcnow()
|
||||
|
||||
# If name changed, update string building on classrooms
|
||||
if old_name != new_name:
|
||||
Classroom.query.filter_by(building_id=building.id).update({'building': new_name})
|
||||
|
||||
db.session.commit()
|
||||
flash(f'Edificio "{building.name}" actualizado con éxito.', 'success')
|
||||
return redirect(url_for('buildings.view_building', id=building.id))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error al actualizar el edificio: {str(e)}', 'danger')
|
||||
|
||||
return render_template('buildings/form.html', form=form, building=building, title='Edit Building')
|
||||
|
||||
@buildings_bp.route('/<int:id>/toggle', methods=['POST'])
|
||||
@login_required
|
||||
@permission_required('classrooms', 'read_write')
|
||||
def toggle_building(id):
|
||||
"""Toggle building active status"""
|
||||
building = Building.query.get_or_404(id)
|
||||
try:
|
||||
building.is_active = not building.is_active
|
||||
building.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
status_text = 'activado' if building.is_active else 'desactivado'
|
||||
flash(f'Edificio "{building.name}" {status_text} con éxito.', 'success')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error al cambiar estado del edificio: {str(e)}', 'danger')
|
||||
return redirect(url_for('buildings.list_buildings'))
|
||||
|
||||
@buildings_bp.route('/<int:id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@permission_required('classrooms', 'read_write')
|
||||
def delete_building(id):
|
||||
"""Delete building if it has no classrooms assigned"""
|
||||
building = Building.query.get_or_404(id)
|
||||
has_classrooms = Classroom.query.filter(
|
||||
db.or_(Classroom.building_id == building.id, Classroom.building == building.name)
|
||||
).count()
|
||||
|
||||
if has_classrooms > 0:
|
||||
flash(f'No se puede eliminar el edificio "{building.name}" porque tiene {has_classrooms} aulas asociadas. Primero reasigna o elimina las aulas.', 'danger')
|
||||
return redirect(url_for('buildings.view_building', id=building.id))
|
||||
|
||||
try:
|
||||
db.session.delete(building)
|
||||
db.session.commit()
|
||||
flash(f'Edificio "{building.name}" eliminado correctamente.', 'success')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error al eliminar edificio: {str(e)}', 'danger')
|
||||
|
||||
return redirect(url_for('buildings.list_buildings'))
|
||||
@@ -12,155 +12,438 @@ classrooms_bp = Blueprint('classrooms', __name__)
|
||||
@classrooms_bp.route('/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def add_classroom():
|
||||
"""Add a new classroom"""
|
||||
"""Add a new classroom with dynamic building creation and floor suggestion"""
|
||||
from app.models.building import Building
|
||||
import re
|
||||
from sqlalchemy import func
|
||||
|
||||
form = ClassroomForm()
|
||||
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
|
||||
form.building_id.choices = [(str(b.id), b.name) for b in buildings] + [('__new__', '+ Crear nuevo edificio')]
|
||||
|
||||
# TODO: Update when campus model is implemented
|
||||
# form.campus_id.choices = [(1, 'Main Campus')]
|
||||
|
||||
# Map of existing floors per building for datalist
|
||||
building_floors = {str(b.id): list(dict.fromkeys(c.floor for c in b.classrooms if c.floor)) for b in buildings}
|
||||
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
building = None
|
||||
if form.building_id.data == '__new__':
|
||||
new_building_name = form.new_building_name.data.strip() if form.new_building_name.data else ''
|
||||
if not new_building_name:
|
||||
flash('Debe ingresar el nombre del nuevo edificio.', 'error')
|
||||
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
|
||||
|
||||
building = Building.query.filter(func.lower(Building.name) == func.lower(new_building_name)).first()
|
||||
if not building:
|
||||
code_slug = re.sub(r'[^a-zA-Z0-9]+', '_', new_building_name.upper()).strip('_')
|
||||
building = Building(name=new_building_name, code=code_slug, is_active=True)
|
||||
db.session.add(building)
|
||||
db.session.commit()
|
||||
else:
|
||||
building = Building.query.get_or_404(int(form.building_id.data))
|
||||
|
||||
room_code = form.room_number.data.strip()
|
||||
floor_val = str(form.floor.data).strip()
|
||||
|
||||
# Prevent duplicate room in same building & floor
|
||||
existing_classroom = Classroom.query.filter_by(
|
||||
building=form.building.data,
|
||||
code=form.room_number.data,
|
||||
floor=form.floor.data
|
||||
building_id=building.id,
|
||||
code=room_code,
|
||||
floor=floor_val
|
||||
).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')
|
||||
|
||||
classroom = Classroom(
|
||||
# campus_id=form.campus_id.data,
|
||||
building=form.building.data,
|
||||
code=form.room_number.data,
|
||||
floor=form.floor.data,
|
||||
capacity=form.capacity.data,
|
||||
flash(f'Ya existe un aula con el nombre "{room_code}" en el piso {floor_val} de {building.name}.', 'error')
|
||||
return render_template('classrooms/add.html', form=form, building_floors=building_floors, 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 = 9999
|
||||
else:
|
||||
cap_val = int(cap_raw)
|
||||
|
||||
from app.services.classroom_service import ClassroomService
|
||||
from app.schemas.classroom_dto import ClassroomCreateDTO
|
||||
from app.services.audit_service import AuditService
|
||||
|
||||
dto = ClassroomCreateDTO(
|
||||
building_id=building.id,
|
||||
code=room_code,
|
||||
floor=floor_val,
|
||||
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')
|
||||
classroom_service = ClassroomService()
|
||||
classroom = classroom_service.create_classroom(dto)
|
||||
|
||||
# Registrar en bitácora inmutable de auditoría
|
||||
AuditService.log('CREATE', 'classrooms', entity_id=classroom.id, details={'code': classroom.code, 'building': building.name})
|
||||
|
||||
flash(f'Aula {classroom.location_display} creada exitosamente.', 'success')
|
||||
return redirect(url_for('classrooms.list_classrooms'))
|
||||
|
||||
|
||||
except ValueError as ve:
|
||||
flash(str(ve), 'error')
|
||||
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
|
||||
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')
|
||||
flash(f'Error al crear el aula: {str(e)}', 'error')
|
||||
|
||||
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
|
||||
|
||||
|
||||
@classrooms_bp.route('/', endpoint='index')
|
||||
@classrooms_bp.route('/list')
|
||||
@login_required
|
||||
def list_classrooms():
|
||||
"""List all classrooms"""
|
||||
"""List all classrooms with separation between Physical spaces and Virtual on-demand rooms"""
|
||||
try:
|
||||
# Get filters
|
||||
from app.models.building import Building
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from app.models.subject import Commission, Subject
|
||||
import calendar
|
||||
from datetime import date, timedelta
|
||||
|
||||
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)
|
||||
search = request.args.get('search', '', type=str).strip()
|
||||
building_filter = request.args.get('building_id', '', type=str).strip()
|
||||
floor_filter = request.args.get('floor', '', type=str).strip()
|
||||
view_mode = request.args.get('view_mode', '').strip().lower()
|
||||
|
||||
# Identificar edificio virtual y aula virtual base
|
||||
virtual_building = Building.query.filter(
|
||||
db.or_(Building.code == 'VIRTUAL', Building.name.ilike('%Virtual%'))
|
||||
).first()
|
||||
virtual_bldg_id = virtual_building.id if virtual_building else None
|
||||
|
||||
# Build query
|
||||
query = Classroom.query
|
||||
# Si el usuario seleccionó "Campus Virtual" en el filtro de edificios, conmutar a pestaña virtual
|
||||
if building_filter and str(building_filter) == str(virtual_bldg_id):
|
||||
view_mode = 'virtual'
|
||||
elif not view_mode:
|
||||
view_mode = 'physical'
|
||||
|
||||
# Fechas operativas (Día, Semana, Mes)
|
||||
today = datetime.utcnow().date()
|
||||
today_start = datetime.combine(today, datetime.min.time())
|
||||
today_end = datetime.combine(today, datetime.max.time())
|
||||
|
||||
week_start = datetime.combine(today - timedelta(days=today.weekday()), datetime.min.time())
|
||||
week_end = datetime.combine(week_start.date() + timedelta(days=6), datetime.max.time())
|
||||
|
||||
_, last_day = calendar.monthrange(today.year, today.month)
|
||||
month_start = datetime(today.year, today.month, 1, 0, 0, 0)
|
||||
month_end = datetime(today.year, today.month, last_day, 23, 59, 59)
|
||||
|
||||
date_filters = {
|
||||
'today': today.strftime('%Y-%m-%d'),
|
||||
'week_start': week_start.strftime('%Y-%m-%d'),
|
||||
'week_end': week_end.strftime('%Y-%m-%d'),
|
||||
'month_start': month_start.strftime('%Y-%m-%d'),
|
||||
'month_end': month_end.strftime('%Y-%m-%d')
|
||||
}
|
||||
|
||||
# --- AULAS FÍSICAS (Presenciales) ---
|
||||
physical_query = Classroom.query
|
||||
if virtual_bldg_id:
|
||||
physical_query = physical_query.filter(Classroom.building_id != virtual_bldg_id)
|
||||
else:
|
||||
physical_query = physical_query.filter(~Classroom.code.ilike('%VIRTUAL%'))
|
||||
|
||||
if active_only:
|
||||
query = query.filter_by(is_active=True)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
physical_query = physical_query.filter_by(is_active=True)
|
||||
|
||||
physical_count = physical_query.count()
|
||||
|
||||
if building_filter and building_filter.isdigit() and str(building_filter) != str(virtual_bldg_id):
|
||||
physical_query = physical_query.filter(Classroom.building_id == int(building_filter))
|
||||
|
||||
if floor_filter:
|
||||
flr = str(floor_filter).strip()
|
||||
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
|
||||
physical_query = physical_query.filter(db.or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
|
||||
else:
|
||||
physical_query = physical_query.filter(Classroom.floor == flr)
|
||||
|
||||
if search and view_mode == 'physical':
|
||||
physical_query = physical_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
|
||||
|
||||
classrooms = physical_query.order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).paginate(
|
||||
page=page, per_page=20, error_out=False
|
||||
)
|
||||
|
||||
|
||||
# Edificios y Pisos estrictamente físicos para el filtro
|
||||
buildings = Building.query.filter_by(is_active=True)
|
||||
if virtual_bldg_id:
|
||||
buildings = buildings.filter(Building.id != virtual_bldg_id)
|
||||
buildings = buildings.order_by(Building.name.asc()).all()
|
||||
|
||||
floors = [f[0] for f in db.session.query(Classroom.floor).filter(
|
||||
Classroom.is_active == True,
|
||||
Classroom.building_id != virtual_bldg_id if virtual_bldg_id else True,
|
||||
Classroom.floor.isnot(None)
|
||||
).distinct().order_by(Classroom.floor.asc()).all()]
|
||||
|
||||
# Métricas de ocupación física
|
||||
visible_ids = [c.id for c in classrooms.items]
|
||||
month_reservations = Reservation.query.filter(
|
||||
Reservation.classroom_id.in_(visible_ids),
|
||||
Reservation.start_time >= month_start,
|
||||
Reservation.start_time <= month_end,
|
||||
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
|
||||
).all() if visible_ids else []
|
||||
|
||||
DAILY_OP_MINS = 15 * 60
|
||||
WEEKLY_OP_MINS = 6 * 15 * 60
|
||||
MONTHLY_OP_MINS = 24 * 15 * 60
|
||||
|
||||
occupancy_metrics = {}
|
||||
for c in classrooms.items:
|
||||
c_res = [r for r in month_reservations if r.classroom_id == c.id]
|
||||
day_res = [r for r in c_res if today_start <= r.start_time <= today_end]
|
||||
day_mins = sum((min(r.end_time, today_end) - max(r.start_time, today_start)).total_seconds() / 60 for r in day_res)
|
||||
day_pct = min(100, int(round((day_mins / DAILY_OP_MINS) * 100)))
|
||||
|
||||
week_res = [r for r in c_res if week_start <= r.start_time <= week_end]
|
||||
week_mins = sum((min(r.end_time, week_end) - max(r.start_time, week_start)).total_seconds() / 60 for r in week_res)
|
||||
week_pct = min(100, int(round((week_mins / WEEKLY_OP_MINS) * 100)))
|
||||
|
||||
month_mins = sum((min(r.end_time, month_end) - max(r.start_time, month_start)).total_seconds() / 60 for r in c_res)
|
||||
month_pct = min(100, int(round((month_mins / MONTHLY_OP_MINS) * 100)))
|
||||
|
||||
occupancy_metrics[c.id] = {
|
||||
'day_pct': day_pct,
|
||||
'day_count': len(day_res),
|
||||
'week_pct': week_pct,
|
||||
'week_count': len(week_res),
|
||||
'month_pct': month_pct,
|
||||
'month_count': len(c_res)
|
||||
}
|
||||
|
||||
# --- AULAS VIRTUALES (Campus Virtual a Demanda - Infinitas) ---
|
||||
vc = Classroom.query.filter(
|
||||
Classroom.building_id == virtual_bldg_id if virtual_bldg_id else Classroom.code.ilike('%VIRTUAL%')
|
||||
).first()
|
||||
vc_id = vc.id if vc else 1
|
||||
|
||||
all_vres = Reservation.query.filter(Reservation.classroom_id == vc_id).all() if vc_id else []
|
||||
|
||||
# Agrupar las salas virtuales por comisión
|
||||
comm_dict = {}
|
||||
for r in all_vres:
|
||||
c = r.commission
|
||||
cid = c.id if c else 0
|
||||
if cid not in comm_dict:
|
||||
vlink = (c.virtual_link if c and c.virtual_link else None) or r.effective_virtual_link
|
||||
comm_dict[cid] = {
|
||||
'commission_id': cid,
|
||||
'commission_code': c.code if c else 'General',
|
||||
'subject_name': c.subject.name if (c and c.subject) else (r.purpose or 'Clase Virtual'),
|
||||
'subject_code': c.subject.code if (c and c.subject) else '',
|
||||
'career_name': c.subject.career.name if (c and c.subject and hasattr(c.subject, 'career') and c.subject.career) else '',
|
||||
'max_students': c.max_students if c else (r.expected_attendees or 0),
|
||||
'virtual_link': vlink,
|
||||
'reservations': []
|
||||
}
|
||||
comm_dict[cid]['reservations'].append(r)
|
||||
if not comm_dict[cid]['virtual_link'] and r.effective_virtual_link:
|
||||
comm_dict[cid]['virtual_link'] = r.effective_virtual_link
|
||||
|
||||
virtual_rooms = []
|
||||
for cid, data in comm_dict.items():
|
||||
res_list = data['reservations']
|
||||
day_cnt = sum(1 for r in res_list if today_start <= r.start_time <= today_end)
|
||||
week_cnt = sum(1 for r in res_list if week_start <= r.start_time <= week_end)
|
||||
month_cnt = sum(1 for r in res_list if month_start <= r.start_time <= month_end)
|
||||
shift = res_list[0].shift if res_list else 'A Demanda'
|
||||
|
||||
if search and view_mode == 'virtual':
|
||||
s_low = search.lower()
|
||||
matches = (
|
||||
s_low in data['subject_name'].lower() or
|
||||
s_low in data['commission_code'].lower() or
|
||||
s_low in data['subject_code'].lower() or
|
||||
s_low in data['career_name'].lower()
|
||||
)
|
||||
if not matches:
|
||||
continue
|
||||
|
||||
virtual_rooms.append({
|
||||
'commission_id': data['commission_id'],
|
||||
'commission_code': data['commission_code'],
|
||||
'subject_name': data['subject_name'],
|
||||
'subject_code': data['subject_code'],
|
||||
'career_name': data['career_name'],
|
||||
'max_students': data['max_students'],
|
||||
'virtual_link': data['virtual_link'],
|
||||
'shift': shift,
|
||||
'day_count': day_cnt,
|
||||
'week_count': week_cnt,
|
||||
'month_count': month_cnt,
|
||||
'total_count': len(res_list),
|
||||
'reservations': sorted(res_list, key=lambda x: x.start_time)
|
||||
})
|
||||
|
||||
virtual_rooms.sort(key=lambda x: (x['subject_name'], x['commission_code']))
|
||||
|
||||
global_virtual_metrics = {
|
||||
'today_count': sum(1 for r in all_vres if today_start <= r.start_time <= today_end),
|
||||
'week_count': sum(1 for r in all_vres if week_start <= r.start_time <= week_end),
|
||||
'month_count': sum(1 for r in all_vres if month_start <= r.start_time <= month_end),
|
||||
'total_count': len(all_vres),
|
||||
'commissions_count': len(comm_dict)
|
||||
}
|
||||
|
||||
return render_template('classrooms/list.html',
|
||||
classrooms=classrooms,
|
||||
buildings=buildings,
|
||||
floors=floors,
|
||||
building_filter=building_filter,
|
||||
floor_filter=floor_filter,
|
||||
active_only=active_only,
|
||||
search=search,
|
||||
view_mode=view_mode,
|
||||
physical_count=physical_count,
|
||||
virtual_count=len(comm_dict),
|
||||
virtual_rooms=virtual_rooms,
|
||||
virtual_classroom_id=vc_id,
|
||||
global_virtual_metrics=global_virtual_metrics,
|
||||
occupancy_metrics=occupancy_metrics,
|
||||
date_filters=date_filters,
|
||||
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('/virtual-room/<int:commission_id>/update-link', methods=['POST'])
|
||||
@login_required
|
||||
def update_virtual_link(commission_id):
|
||||
"""Actualizar enlace de videollamada para una comisión virtual"""
|
||||
from app.models.subject import Commission
|
||||
from flask_babel import gettext as _
|
||||
comm = Commission.query.get_or_404(commission_id)
|
||||
new_link = request.form.get('virtual_link', '').strip() or None
|
||||
comm.virtual_link = new_link
|
||||
db.session.commit()
|
||||
flash(_('Enlace de videollamada para %(code)s actualizado correctamente.', code=comm.get_full_code()), 'success')
|
||||
return redirect(request.referrer or url_for('classrooms.list_classrooms', view_mode='virtual'))
|
||||
|
||||
|
||||
@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"""
|
||||
"""Edit classroom with decoupled building and floor"""
|
||||
from app.models.building import Building
|
||||
import re
|
||||
from sqlalchemy import func
|
||||
|
||||
classroom = Classroom.query.get_or_404(id)
|
||||
form = ClassroomForm(obj=classroom)
|
||||
|
||||
# TODO: Update when campus model is implemented
|
||||
# form.campus_id.choices = [(1, 'Main Campus')]
|
||||
|
||||
|
||||
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
|
||||
form.building_id.choices = [(str(b.id), b.name) for b in buildings] + [('__new__', '+ Crear nuevo edificio')]
|
||||
building_floors = {str(b.id): list(dict.fromkeys(c.floor for c in b.classrooms if c.floor)) for b in buildings}
|
||||
|
||||
if request.method == 'GET':
|
||||
form.building_id.data = str(classroom.building_id or '')
|
||||
form.room_number.data = classroom.code
|
||||
form.floor.data = classroom.floor
|
||||
if classroom.capacity == 0 or classroom.is_virtual:
|
||||
form.capacity.data = '∞'
|
||||
else:
|
||||
form.capacity.data = str(classroom.capacity)
|
||||
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
# Check for duplicates (excluding current classroom)
|
||||
building = None
|
||||
if form.building_id.data == '__new__':
|
||||
new_building_name = form.new_building_name.data.strip() if form.new_building_name.data else ''
|
||||
if not new_building_name:
|
||||
flash('Debe ingresar el nombre del nuevo edificio.', 'error')
|
||||
return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom')
|
||||
|
||||
building = Building.query.filter(func.lower(Building.name) == func.lower(new_building_name)).first()
|
||||
if not building:
|
||||
code_slug = re.sub(r'[^a-zA-Z0-9]+', '_', new_building_name.upper()).strip('_')
|
||||
building = Building(name=new_building_name, code=code_slug, is_active=True)
|
||||
db.session.add(building)
|
||||
db.session.commit()
|
||||
else:
|
||||
building = Building.query.get_or_404(int(form.building_id.data))
|
||||
|
||||
room_code = form.room_number.data.strip()
|
||||
floor_val = str(form.floor.data).strip()
|
||||
|
||||
# Check duplicate excluding current
|
||||
existing_classroom = Classroom.query.filter(
|
||||
Classroom.building == form.building.data,
|
||||
Classroom.code == form.room_number.data,
|
||||
Classroom.floor == form.floor.data,
|
||||
Classroom.building_id == building.id,
|
||||
Classroom.code == room_code,
|
||||
Classroom.floor == floor_val,
|
||||
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')
|
||||
|
||||
# Update classroom
|
||||
# classroom.campus_id = form.campus_id.data
|
||||
classroom.building = form.building.data
|
||||
classroom.code = form.room_number.data
|
||||
classroom.floor = form.floor.data
|
||||
classroom.capacity = form.capacity.data
|
||||
flash(f'Ya existe otra aula con el nombre "{room_code}" en el piso {floor_val} de {building.name}.', 'error')
|
||||
return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, 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)
|
||||
|
||||
classroom.building_id = building.id
|
||||
classroom.building = building.name
|
||||
classroom.code = room_code
|
||||
classroom.floor = floor_val
|
||||
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')
|
||||
|
||||
flash(f'Aula {classroom.location_display} actualizada correctamente.', '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')
|
||||
|
||||
return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom')
|
||||
|
||||
@classrooms_bp.route('/<int:id>/toggle', methods=['POST'])
|
||||
@login_required
|
||||
|
||||
@@ -39,8 +39,8 @@ def optimize_reservations():
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid end_date format. Use ISO format.'}), 400
|
||||
|
||||
# Check if user has permission (admin or teacher)
|
||||
if current_user.role not in ['ADMIN', 'teacher']:
|
||||
# Check if user has permission (admin, teacher or RBAC optimizer write)
|
||||
if not (current_user.is_admin() or current_user.has_permission('optimizer', 'read_write') or current_user.role in ['ADMIN', 'teacher']):
|
||||
return jsonify({'error': 'Insufficient permissions to optimize reservations'}), 403
|
||||
|
||||
# Initialize optimizer and run optimization
|
||||
@@ -84,7 +84,7 @@ def apply_optimization():
|
||||
return jsonify({'error': 'reservations must be a list'}), 400
|
||||
|
||||
# Check if user has permission
|
||||
if current_user.role not in ['ADMIN', 'teacher']:
|
||||
if not (current_user.is_admin() or current_user.has_permission('optimizer', 'read_write') or current_user.role in ['ADMIN', 'teacher']):
|
||||
return jsonify({'error': 'Insufficient permissions to apply reservations'}), 403
|
||||
|
||||
# Initialize optimizer and apply reservations
|
||||
@@ -249,7 +249,7 @@ genetic_web_bp = Blueprint('genetic_algorithm_web', __name__)
|
||||
@login_required
|
||||
def genetic_optimizer():
|
||||
"""Render the genetic algorithm optimizer interface"""
|
||||
if current_user.role not in ['ADMIN']:
|
||||
if not (current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role.upper() == 'ADMIN')):
|
||||
return render_template('auth/403.html'), 403
|
||||
|
||||
return render_template('genetic_optimizer.html')
|
||||
@@ -1,127 +1,333 @@
|
||||
from flask import Blueprint, render_template, jsonify, redirect, url_for, session, request, flash
|
||||
from flask_login import login_required, current_user
|
||||
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 = 'en'
|
||||
|
||||
session['language'] = language
|
||||
return redirect(request.referrer or url_for('main.dashboard'))
|
||||
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():
|
||||
"""Role-personalized institutional dashboard page"""
|
||||
from app.models.user import User
|
||||
from app.models.milestone import AcademicMilestone
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
actual_role = current_user.get_institutional_role()
|
||||
view_as = request.args.get('view_as', '').lower().strip()
|
||||
|
||||
# Allow admins and management roles to preview dashboard perspectives
|
||||
if view_as in ['admin', 'bedelia', 'docente', 'alumno'] and (current_user.is_admin() or current_user.can_manage()):
|
||||
role = view_as
|
||||
else:
|
||||
role = actual_role
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
|
||||
# Common core statistics
|
||||
try:
|
||||
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
||||
except Exception:
|
||||
total_classrooms = 0
|
||||
|
||||
try:
|
||||
total_subjects = Subject.query.filter_by(is_active=True).count()
|
||||
except Exception:
|
||||
total_subjects = 0
|
||||
|
||||
try:
|
||||
today_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) == today
|
||||
).count()
|
||||
except Exception:
|
||||
today_reservations = 0
|
||||
|
||||
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 Exception:
|
||||
week_reservations = 0
|
||||
|
||||
stats = {
|
||||
'total_classrooms': total_classrooms,
|
||||
'total_subjects': total_subjects,
|
||||
'today_reservations': today_reservations,
|
||||
'week_reservations': week_reservations
|
||||
}
|
||||
|
||||
role_data = {}
|
||||
|
||||
if role == 'admin':
|
||||
# 1. Admin Institutional Cockpit
|
||||
try:
|
||||
total_users = User.query.filter_by(is_active=True).count()
|
||||
except Exception:
|
||||
total_users = 0
|
||||
|
||||
try:
|
||||
pending_count = Reservation.query.filter(
|
||||
Reservation.status == 'PENDING'
|
||||
).count()
|
||||
except Exception:
|
||||
pending_count = 0
|
||||
|
||||
try:
|
||||
recent_audits = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(6).all()
|
||||
except Exception:
|
||||
recent_audits = []
|
||||
|
||||
try:
|
||||
upcoming_reservations = Reservation.query.filter(
|
||||
Reservation.start_time >= datetime.utcnow(),
|
||||
Reservation.start_time <= datetime.utcnow() + timedelta(days=7)
|
||||
).order_by(Reservation.start_time.asc()).limit(8).all()
|
||||
except Exception:
|
||||
upcoming_reservations = []
|
||||
|
||||
stats.update({
|
||||
'total_users': total_users,
|
||||
'pending_reservations': pending_count
|
||||
})
|
||||
role_data = {
|
||||
'recent_audits': recent_audits,
|
||||
'upcoming_reservations': upcoming_reservations
|
||||
}
|
||||
|
||||
elif role == 'bedelia':
|
||||
# 2. Bedelía Operations & Facilities Control
|
||||
try:
|
||||
physical_classrooms = Classroom.query.filter_by(is_active=True).filter(
|
||||
~Classroom.room_number.ilike('%VIRTUAL%') & ~Classroom.building.ilike('%VIRTUAL%')
|
||||
).count()
|
||||
virtual_classrooms = total_classrooms - physical_classrooms
|
||||
if virtual_classrooms < 0:
|
||||
virtual_classrooms = 0
|
||||
except Exception:
|
||||
physical_classrooms = total_classrooms
|
||||
virtual_classrooms = 0
|
||||
|
||||
try:
|
||||
pending_reservations = Reservation.query.filter(
|
||||
Reservation.status == 'PENDING'
|
||||
).order_by(Reservation.start_time.asc()).limit(10).all()
|
||||
pending_count = len(pending_reservations)
|
||||
except Exception:
|
||||
pending_reservations = []
|
||||
pending_count = 0
|
||||
|
||||
try:
|
||||
today_schedule_list = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) == today
|
||||
).order_by(Reservation.start_time.asc()).limit(12).all()
|
||||
except Exception:
|
||||
today_schedule_list = []
|
||||
|
||||
occupancy_rate = min(100, int((today_reservations / max(1, physical_classrooms * 3)) * 100)) if physical_classrooms > 0 else 0
|
||||
|
||||
stats.update({
|
||||
'physical_classrooms': physical_classrooms,
|
||||
'virtual_classrooms': virtual_classrooms,
|
||||
'pending_count': pending_count,
|
||||
'occupancy_rate': occupancy_rate
|
||||
})
|
||||
role_data = {
|
||||
'pending_reservations': pending_reservations,
|
||||
'today_schedule_list': today_schedule_list
|
||||
}
|
||||
|
||||
elif role == 'docente':
|
||||
# 3. Docente (Profesor) Academic & Classroom Workflow
|
||||
try:
|
||||
my_today_classes = Reservation.query.filter(
|
||||
Reservation.user_id == current_user.id,
|
||||
func.date(Reservation.start_time) == today
|
||||
).order_by(Reservation.start_time.asc()).all()
|
||||
except Exception:
|
||||
my_today_classes = []
|
||||
|
||||
try:
|
||||
my_upcoming_reservations = Reservation.query.filter(
|
||||
Reservation.user_id == current_user.id,
|
||||
Reservation.start_time >= datetime.utcnow()
|
||||
).order_by(Reservation.start_time.asc()).limit(8).all()
|
||||
except Exception:
|
||||
my_upcoming_reservations = []
|
||||
|
||||
try:
|
||||
my_total_reservations = Reservation.query.filter(
|
||||
Reservation.user_id == current_user.id
|
||||
).count()
|
||||
except Exception:
|
||||
my_total_reservations = 0
|
||||
|
||||
try:
|
||||
virtual_classrooms = Classroom.query.filter_by(is_active=True).filter(
|
||||
(Classroom.room_number.ilike('%VIRTUAL%')) | (Classroom.building.ilike('%VIRTUAL%'))
|
||||
).limit(4).all()
|
||||
except Exception:
|
||||
virtual_classrooms = []
|
||||
|
||||
stats.update({
|
||||
'my_today_count': len(my_today_classes),
|
||||
'my_total_reservations': my_total_reservations,
|
||||
'virtual_rooms_count': len(virtual_classrooms)
|
||||
})
|
||||
role_data = {
|
||||
'my_today_classes': my_today_classes,
|
||||
'my_upcoming_reservations': my_upcoming_reservations,
|
||||
'virtual_classrooms': virtual_classrooms
|
||||
}
|
||||
|
||||
elif role == 'alumno':
|
||||
# 4. Alumno (Estudiante) Daily Guide & Timetable
|
||||
try:
|
||||
today_classes = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) == today,
|
||||
Reservation.status == 'CONFIRMED'
|
||||
).order_by(Reservation.start_time.asc()).limit(12).all()
|
||||
except Exception:
|
||||
today_classes = []
|
||||
|
||||
try:
|
||||
upcoming_milestones = AcademicMilestone.query.filter(
|
||||
AcademicMilestone.date >= datetime.utcnow(),
|
||||
AcademicMilestone.is_active == True
|
||||
).order_by(AcademicMilestone.date.asc()).limit(6).all()
|
||||
except Exception:
|
||||
upcoming_milestones = []
|
||||
|
||||
try:
|
||||
virtual_rooms = Classroom.query.filter_by(is_active=True).filter(
|
||||
(Classroom.room_number.ilike('%VIRTUAL%')) | (Classroom.building.ilike('%VIRTUAL%'))
|
||||
).limit(4).all()
|
||||
except Exception:
|
||||
virtual_rooms = []
|
||||
|
||||
stats.update({
|
||||
'today_classes_count': len(today_classes),
|
||||
'milestones_count': len(upcoming_milestones)
|
||||
})
|
||||
role_data = {
|
||||
'today_classes': today_classes,
|
||||
'upcoming_milestones': upcoming_milestones,
|
||||
'virtual_rooms': virtual_rooms
|
||||
}
|
||||
|
||||
# Backward compatibility for upcoming_reservations
|
||||
upcoming_reservations = role_data.get('upcoming_reservations', [])
|
||||
if not upcoming_reservations:
|
||||
try:
|
||||
upcoming_reservations = Reservation.query.filter(
|
||||
Reservation.start_time >= datetime.utcnow(),
|
||||
Reservation.start_time <= datetime.utcnow() + timedelta(days=7)
|
||||
).order_by(Reservation.start_time.asc()).limit(6).all()
|
||||
except Exception:
|
||||
upcoming_reservations = []
|
||||
|
||||
return render_template('dashboard.html',
|
||||
role=role,
|
||||
actual_role=actual_role,
|
||||
stats=stats,
|
||||
role_data=role_data,
|
||||
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 not (current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role.upper() == 'ADMIN')):
|
||||
flash(_('No tienes permisos para acceder a la herramienta de optimización.'), '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'))
|
||||
|
||||
|
||||
@main_bp.route('/api/milestone-types')
|
||||
def api_milestone_types():
|
||||
"""Retorna las tipificaciones de hitos activas en JSON para consumo de frontend/mobile"""
|
||||
from app.models.milestone import MilestoneType, BASE_MILESTONE_TYPES
|
||||
if MilestoneType.query.count() == 0:
|
||||
for item in BASE_MILESTONE_TYPES:
|
||||
mt = MilestoneType(
|
||||
name=item['name'],
|
||||
code=item['code'],
|
||||
color=item['color'],
|
||||
description=item['description'],
|
||||
sort_order=item['sort_order'],
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(mt)
|
||||
db.session.commit()
|
||||
|
||||
types = MilestoneType.query.filter_by(is_active=True).order_by(MilestoneType.sort_order.asc(), MilestoneType.id.asc()).all()
|
||||
return jsonify([t.to_dict() for t in types])
|
||||
@@ -0,0 +1,126 @@
|
||||
from flask import Blueprint, render_template, abort
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.subject import Commission
|
||||
from app.models.enrollment import StudentEnrollment
|
||||
from app.models.classroom import Classroom
|
||||
|
||||
my_subjects_bp = Blueprint('my_subjects', __name__, url_prefix='/mis-materias')
|
||||
|
||||
|
||||
@my_subjects_bp.route('/')
|
||||
@login_required
|
||||
def my_subjects_list():
|
||||
"""Show the commissions assigned to the current user (teacher or student)."""
|
||||
role = current_user.get_institutional_role()
|
||||
|
||||
commissions = []
|
||||
|
||||
if role in ['admin', 'bedelia']:
|
||||
# Admin/Bedelia: see all active commissions
|
||||
commissions = (
|
||||
Commission.query
|
||||
.filter_by(active=True)
|
||||
.order_by(Commission.year.desc(), Commission.semester.asc(), Commission.code.asc())
|
||||
.all()
|
||||
)
|
||||
view_mode = 'all'
|
||||
|
||||
elif role == 'docente':
|
||||
# Teacher: only commissions where they are assigned as teacher
|
||||
commissions = (
|
||||
Commission.query
|
||||
.filter_by(teacher_id=current_user.id, active=True)
|
||||
.order_by(Commission.year.desc(), Commission.semester.asc(), Commission.code.asc())
|
||||
.all()
|
||||
)
|
||||
view_mode = 'teacher'
|
||||
|
||||
elif role == 'alumno':
|
||||
# Student: only commissions where they are enrolled
|
||||
enrollments = (
|
||||
StudentEnrollment.query
|
||||
.filter_by(student_id=current_user.id, status='activo')
|
||||
.all()
|
||||
)
|
||||
commission_ids = [e.commission_id for e in enrollments]
|
||||
commissions = (
|
||||
Commission.query
|
||||
.filter(Commission.id.in_(commission_ids), Commission.active == True)
|
||||
.order_by(Commission.year.desc(), Commission.semester.asc(), Commission.code.asc())
|
||||
.all()
|
||||
)
|
||||
view_mode = 'student'
|
||||
else:
|
||||
commissions = []
|
||||
view_mode = 'none'
|
||||
|
||||
return render_template(
|
||||
'my_subjects/list.html',
|
||||
commissions=commissions,
|
||||
view_mode=view_mode,
|
||||
role=role
|
||||
)
|
||||
|
||||
|
||||
@my_subjects_bp.route('/<int:commission_id>')
|
||||
@login_required
|
||||
def my_subject_detail(commission_id):
|
||||
"""Detail view of a single commission for the current user."""
|
||||
role = current_user.get_institutional_role()
|
||||
commission = Commission.query.get_or_404(commission_id)
|
||||
|
||||
# Access control: make sure the user is allowed to see this commission
|
||||
if role in ['admin', 'bedelia']:
|
||||
pass # full access
|
||||
elif role == 'docente':
|
||||
if commission.teacher_id != current_user.id:
|
||||
abort(403)
|
||||
elif role == 'alumno':
|
||||
enrollment = StudentEnrollment.query.filter_by(
|
||||
student_id=current_user.id,
|
||||
commission_id=commission_id
|
||||
).first()
|
||||
if not enrollment:
|
||||
abort(403)
|
||||
else:
|
||||
abort(403)
|
||||
|
||||
# Get enrolled students (visible to teachers and admin/bedelia)
|
||||
enrollments = []
|
||||
if role in ['admin', 'bedelia', 'docente']:
|
||||
enrollments = (
|
||||
StudentEnrollment.query
|
||||
.filter_by(commission_id=commission_id)
|
||||
.order_by(StudentEnrollment.enrolled_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
# Try to get classroom info from reservations linked to this commission
|
||||
from app.models.reservation import Reservation
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime
|
||||
|
||||
# Recent or upcoming reservation linked to this commission
|
||||
recent_reservation = (
|
||||
Reservation.query
|
||||
.filter(
|
||||
Reservation.commission_id == commission_id,
|
||||
Reservation.start_time >= datetime.utcnow()
|
||||
)
|
||||
.order_by(Reservation.start_time.asc())
|
||||
.first()
|
||||
)
|
||||
|
||||
classroom = None
|
||||
if recent_reservation and recent_reservation.classroom_id:
|
||||
classroom = Classroom.query.get(recent_reservation.classroom_id)
|
||||
|
||||
return render_template(
|
||||
'my_subjects/detail.html',
|
||||
commission=commission,
|
||||
enrollments=enrollments,
|
||||
classroom=classroom,
|
||||
recent_reservation=recent_reservation,
|
||||
role=role
|
||||
)
|
||||
@@ -14,21 +14,43 @@ schedule_bp = Blueprint('schedule', __name__, url_prefix='/schedule')
|
||||
@schedule_bp.route('/today')
|
||||
@login_required
|
||||
def today_schedule():
|
||||
"""Show today's schedule"""
|
||||
"""Show today's schedule with filters for virtual classrooms, floor, and shift"""
|
||||
today = datetime.utcnow().date()
|
||||
today_start = datetime.combine(today, datetime.min.time())
|
||||
today_end = datetime.combine(today, datetime.max.time())
|
||||
|
||||
# Get reservations with eager loading
|
||||
reservations = Reservation.query.options(
|
||||
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()
|
||||
|
||||
# Base query for today's reservations
|
||||
query = Reservation.query.options(
|
||||
joinedload(Reservation.classroom),
|
||||
joinedload(Reservation.commission),
|
||||
joinedload(Reservation.commission).joinedload(Commission.subject),
|
||||
joinedload(Reservation.user)
|
||||
).filter(
|
||||
).join(Reservation.classroom).filter(
|
||||
Reservation.start_time >= today_start,
|
||||
Reservation.start_time <= today_end,
|
||||
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
|
||||
).order_by(Reservation.start_time).all()
|
||||
)
|
||||
|
||||
if hide_virtual:
|
||||
query = query.filter(
|
||||
~Classroom.code.ilike('%VIRTUAL%'),
|
||||
~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 shift_filter:
|
||||
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
|
||||
|
||||
reservations = query.order_by(Reservation.start_time).all()
|
||||
|
||||
# Create time blocks for timeline view
|
||||
time_blocks = {}
|
||||
@@ -49,11 +71,9 @@ def today_schedule():
|
||||
|
||||
# Calculate stats
|
||||
total_reservations = len(reservations)
|
||||
confirmed = len([r for r in reservations if r.status == ReservationStatus.CONFIRMED.value])
|
||||
pending = len([r for r in reservations if r.status == ReservationStatus.PENDING])
|
||||
confirmed = len([r for r in reservations if r.status == ReservationStatus.CONFIRMED.value or str(r.status) == 'CONFIRMED'])
|
||||
pending = len([r for r in reservations if r.status == ReservationStatus.PENDING or str(r.status) == 'PENDING'])
|
||||
|
||||
# Get total classrooms and calculate occupied ones
|
||||
from app.models.classroom import Classroom
|
||||
total_classrooms = Classroom.query.count()
|
||||
occupied_classrooms = len(set(r.classroom_id for r in reservations if r.classroom_id))
|
||||
|
||||
@@ -64,12 +84,19 @@ def today_schedule():
|
||||
'occupied_classrooms': occupied_classrooms,
|
||||
'total_classrooms': total_classrooms
|
||||
}
|
||||
|
||||
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]
|
||||
|
||||
return render_template('schedule/today.html',
|
||||
reservations=reservations,
|
||||
time_blocks=time_blocks,
|
||||
stats=stats,
|
||||
today=today)
|
||||
today=today,
|
||||
hide_virtual=hide_virtual,
|
||||
floor_filter=floor_filter,
|
||||
shift_filter=shift_filter,
|
||||
available_floors=available_floors)
|
||||
|
||||
@schedule_bp.route('/cancel/<int:id>', methods=['POST'])
|
||||
@login_required
|
||||
@@ -112,25 +139,353 @@ 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"""
|
||||
return render_template('schedule/calendar.html', title='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]
|
||||
|
||||
return render_template(
|
||||
'schedule/calendar.html',
|
||||
title='Calendar View',
|
||||
today_date=date.today().isoformat(),
|
||||
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():
|
||||
subjects = Subject.query.all()
|
||||
"""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
|
||||
|
||||
# Calculamos el total de capacidad sumando todas las comisiones de todas las materias
|
||||
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
|
||||
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)
|
||||
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'))
|
||||
|
||||
@schedule_bp.route('/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@@ -138,23 +493,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 classrooms for the dropdown
|
||||
classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||
|
||||
# Get active commissions for the dropdown
|
||||
# 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()
|
||||
commissions = Commission.query.filter_by(active=True).all()
|
||||
|
||||
# Populate form choices
|
||||
form.classroom_id.choices = [(c.id, f'{c.code_display} - {c.name} (Cap: {c.capacity})') for c in classrooms]
|
||||
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 == '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,
|
||||
@@ -166,7 +521,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)
|
||||
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions, buildings=buildings)
|
||||
|
||||
# Create new reservation
|
||||
reservation = Reservation(
|
||||
@@ -177,6 +532,7 @@ 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
|
||||
)
|
||||
@@ -189,16 +545,144 @@ 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)
|
||||
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
|
||||
|
||||
@schedule_bp.route('/list')
|
||||
@login_required
|
||||
def list_reservations():
|
||||
"""List all reservations"""
|
||||
return redirect(url_for('schedule.today_schedule'))
|
||||
"""List all reservations with pagination, date filters, floor, shift, and virtual toggle"""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
status_filter = request.args.get('status', 'all').strip()
|
||||
date_from = request.args.get('date_from', '').strip()
|
||||
date_to = request.args.get('date_to', '').strip()
|
||||
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(
|
||||
joinedload(Reservation.classroom),
|
||||
joinedload(Reservation.commission).joinedload(Commission.subject),
|
||||
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())
|
||||
|
||||
if date_from:
|
||||
try:
|
||||
d_from = datetime.strptime(date_from, '%Y-%m-%d')
|
||||
query = query.filter(Reservation.start_time >= d_from)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if date_to:
|
||||
try:
|
||||
d_to = datetime.strptime(date_to, '%Y-%m-%d') + timedelta(days=1)
|
||||
query = query.filter(Reservation.start_time < d_to)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if hide_virtual:
|
||||
query = query.filter(
|
||||
~Classroom.code.ilike('%VIRTUAL%'),
|
||||
~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 shift_filter:
|
||||
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
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}%')
|
||||
)
|
||||
)
|
||||
|
||||
reservations = query.order_by(Reservation.start_time.desc()).paginate(
|
||||
page=page, per_page=20, error_out=False
|
||||
)
|
||||
|
||||
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',
|
||||
reservations=reservations,
|
||||
status_filter=status_filter,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
@schedule_bp.route('/reservation/<int:id>/confirm', methods=['GET', 'POST'])
|
||||
@@ -321,53 +805,128 @@ def classroom_availability_api():
|
||||
def calendar_data():
|
||||
"""Get calendar events data for FullCalendar"""
|
||||
try:
|
||||
# Get date range from query parameters
|
||||
start = request.args.get('start')
|
||||
end = request.args.get('end')
|
||||
classroom_id = request.args.get('classroom_id')
|
||||
subject_id = request.args.get('subject_id')
|
||||
career_id = request.args.get('career_id')
|
||||
shift = request.args.get('shift', '').strip()
|
||||
floor = request.args.get('floor', '').strip()
|
||||
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
|
||||
search = request.args.get('search', '').strip()
|
||||
|
||||
if start and end:
|
||||
from datetime import datetime
|
||||
start_date = datetime.fromisoformat(start)
|
||||
end_date = datetime.fromisoformat(end)
|
||||
# Clean up ISO timezone strings for PostgreSQL naive timestamp comparison
|
||||
start_clean = start.replace('Z', '+00:00')
|
||||
end_clean = end.replace('Z', '+00:00')
|
||||
start_date = datetime.fromisoformat(start_clean).replace(tzinfo=None)
|
||||
end_date = datetime.fromisoformat(end_clean).replace(tzinfo=None)
|
||||
else:
|
||||
# Default to current month
|
||||
today = date.today()
|
||||
start_date = datetime.combine(today.replace(day=1), datetime.min.time())
|
||||
next_month = today.replace(day=28) + timedelta(days=4) # Next month
|
||||
next_month = today.replace(day=28) + timedelta(days=4)
|
||||
end_date = datetime.combine(next_month.replace(day=1), datetime.max.time())
|
||||
|
||||
# Get reservations in the date range
|
||||
reservations = Reservation.query.options(
|
||||
query = Reservation.query.join(Reservation.commission).join(Commission.subject).outerjoin(Reservation.classroom).options(
|
||||
joinedload(Reservation.classroom),
|
||||
joinedload(Reservation.commission)
|
||||
joinedload(Reservation.commission).joinedload(Commission.subject).joinedload(Subject.career_obj)
|
||||
).filter(
|
||||
Reservation.start_time >= start_date,
|
||||
Reservation.start_time <= end_date,
|
||||
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
|
||||
).order_by(Reservation.start_time).all()
|
||||
Reservation.start_time <= end_date
|
||||
)
|
||||
|
||||
if classroom_id and classroom_id.isdigit():
|
||||
query = query.filter(Reservation.classroom_id == int(classroom_id))
|
||||
|
||||
if subject_id and subject_id.isdigit():
|
||||
query = query.filter(Commission.subject_id == int(subject_id))
|
||||
|
||||
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}%'))
|
||||
|
||||
if hide_virtual:
|
||||
query = query.filter(
|
||||
~Classroom.code.ilike('%VIRTUAL%'),
|
||||
~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 search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Subject.name.ilike(f'%{search}%'),
|
||||
Subject.code.ilike(f'%{search}%'),
|
||||
Classroom.code.ilike(f'%{search}%'),
|
||||
Reservation.purpose.ilike(f'%{search}%')
|
||||
)
|
||||
)
|
||||
|
||||
reservations = query.order_by(Reservation.start_time).all()
|
||||
|
||||
events = []
|
||||
for reservation in reservations:
|
||||
subject = reservation.commission.subject if (reservation.commission and reservation.commission.subject) else None
|
||||
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)
|
||||
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)
|
||||
|
||||
events.append({
|
||||
'id': reservation.id,
|
||||
'title': f"{reservation.commission.get_full_code() if reservation.commission else 'No Commission'} - {reservation.classroom.code_display}",
|
||||
'title': subject_name,
|
||||
'start': reservation.start_time.isoformat(),
|
||||
'end': reservation.end_time.isoformat(),
|
||||
'url': url_for('schedule.view_reservation', id=reservation.id) if reservation.id else None,
|
||||
'className': f'fc-event-{reservation.status.value.lower()}',
|
||||
'className': f'fc-event-{status_val.lower()}',
|
||||
'extendedProps': {
|
||||
'classroom': reservation.classroom.code_display if reservation.classroom else 'Unknown',
|
||||
'commission': reservation.commission.get_full_code() if reservation.commission else 'No Commission',
|
||||
'purpose': reservation.purpose or 'No purpose',
|
||||
'attendees': reservation.expected_attendees,
|
||||
'status': reservation.status.value,
|
||||
'notes': reservation.notes or ''
|
||||
'subject_code': subject_code,
|
||||
'subject_name': subject_name,
|
||||
'classroom': classroom_name,
|
||||
'building': classroom_building,
|
||||
'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)
|
||||
}
|
||||
})
|
||||
|
||||
return jsonify(events)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@schedule_bp.route('/api/today-events')
|
||||
@@ -380,26 +939,33 @@ def api_today_events():
|
||||
|
||||
reservations = Reservation.query.options(
|
||||
joinedload(Reservation.classroom),
|
||||
joinedload(Reservation.commission)
|
||||
joinedload(Reservation.commission).joinedload(Commission.subject).joinedload(Subject.career_obj)
|
||||
).filter(
|
||||
Reservation.start_time >= today_start,
|
||||
Reservation.start_time <= today_end,
|
||||
Reservation.status == ReservationStatus.CONFIRMED.value
|
||||
Reservation.start_time <= today_end
|
||||
).order_by(Reservation.start_time).all()
|
||||
|
||||
events = []
|
||||
for reservation in reservations:
|
||||
subject = reservation.commission.subject if (reservation.commission and reservation.commission.subject) else None
|
||||
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'
|
||||
status_val = str(reservation.status.value if hasattr(reservation.status, 'value') else reservation.status)
|
||||
|
||||
events.append({
|
||||
'id': reservation.id,
|
||||
'title': f"{reservation.commission.get_full_code() if reservation.commission else 'No Commission'} - {reservation.classroom.code_display}",
|
||||
'title': f"{subject_name} ({subject_code}) - {classroom_name}",
|
||||
'start': reservation.start_time.isoformat(),
|
||||
'end': reservation.end_time.isoformat(),
|
||||
'url': url_for('schedule.view_reservation', id=reservation.id) if reservation.id else None,
|
||||
'extendedProps': {
|
||||
'classroom': reservation.classroom.code_display if reservation.classroom else 'Unknown',
|
||||
'subject_code': subject_code,
|
||||
'subject_name': subject_name,
|
||||
'classroom': classroom_name,
|
||||
'purpose': reservation.purpose or 'No purpose',
|
||||
'attendees': reservation.expected_attendees,
|
||||
'status': reservation.status.value
|
||||
'status': status_val
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Módulo de Data Transfer Objects (DTOs) y esquemas tipados con Pydantic."""
|
||||
from app.schemas.classroom_dto import ClassroomCreateDTO, ClassroomUpdateDTO, ClassroomResponseDTO
|
||||
from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO, ReservationResponseDTO
|
||||
from app.schemas.auth_dto import LoginDTO, TokenResponseDTO, RefreshTokenDTO
|
||||
|
||||
__all__ = [
|
||||
'ClassroomCreateDTO',
|
||||
'ClassroomUpdateDTO',
|
||||
'ClassroomResponseDTO',
|
||||
'ReservationCreateDTO',
|
||||
'ReservationUpdateDTO',
|
||||
'ReservationResponseDTO',
|
||||
'LoginDTO',
|
||||
'TokenResponseDTO',
|
||||
'RefreshTokenDTO'
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
from typing import Optional, Dict, Any, List
|
||||
from pydantic import BaseModel, Field, EmailStr
|
||||
|
||||
class LoginDTO(BaseModel):
|
||||
email: str = Field(..., description="Correo electrónico institucional")
|
||||
password: str = Field(..., min_length=1, description="Contraseña de acceso")
|
||||
|
||||
class TokenResponseDTO(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "Bearer"
|
||||
expires_in: int = 900 # 15 minutos en segundos
|
||||
user: Dict[str, Any]
|
||||
|
||||
class RefreshTokenDTO(BaseModel):
|
||||
refresh_token: str = Field(..., description="Token de refresco válido")
|
||||
@@ -0,0 +1,44 @@
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
class ClassroomBaseDTO(BaseModel):
|
||||
code: str = Field(..., min_length=1, max_length=50, description="Código o nombre del aula (ej: Aula 101, VIRTUAL-A)")
|
||||
floor: str = Field(default="PB", max_length=50, description="Piso o nivel (PB, Piso 1, etc.)")
|
||||
capacity: int = Field(default=30, ge=1, le=10000, description="Capacidad máxima de estudiantes")
|
||||
description: Optional[str] = Field(default=None, max_length=500, description="Detalles o equipamiento")
|
||||
is_active: bool = Field(default=True, description="Estado operativo del aula")
|
||||
|
||||
@field_validator('code')
|
||||
@classmethod
|
||||
def clean_code(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("El código del aula no puede estar vacío.")
|
||||
return v
|
||||
|
||||
@field_validator('floor')
|
||||
@classmethod
|
||||
def clean_floor(cls, v: str) -> str:
|
||||
return str(v).strip() or "PB"
|
||||
|
||||
class ClassroomCreateDTO(ClassroomBaseDTO):
|
||||
building_id: Optional[int] = Field(default=None, description="ID del edificio asignado")
|
||||
building_name: Optional[str] = Field(default=None, description="Nombre de edificio (para creación dinámica)")
|
||||
|
||||
class ClassroomUpdateDTO(BaseModel):
|
||||
building_id: Optional[int] = None
|
||||
code: Optional[str] = None
|
||||
floor: Optional[str] = None
|
||||
capacity: Optional[int] = None
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class ClassroomResponseDTO(ClassroomBaseDTO):
|
||||
id: int
|
||||
building_id: Optional[int] = None
|
||||
building_name: str
|
||||
is_virtual: bool
|
||||
location_display: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,49 @@
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
class OptimizerJobRequestDTO(BaseModel):
|
||||
"""Contrato de solicitud para iniciar una optimización heurística."""
|
||||
commission_ids: List[int] = Field(..., min_length=1, description="Lista de IDs de comisiones a programar")
|
||||
start_date: Optional[datetime] = Field(None, description="Fecha de inicio (ISO format)")
|
||||
end_date: Optional[datetime] = Field(None, description="Fecha de fin (ISO format)")
|
||||
generations: int = Field(default=80, ge=10, le=500, description="Cantidad de generaciones genéticas")
|
||||
population_size: int = Field(default=40, ge=10, le=200, description="Tamaño de la población de cromosomas")
|
||||
workers: int = Field(default=4, ge=1, le=16, description="Cantidad de hilos o workers concurrentes")
|
||||
|
||||
@field_validator('commission_ids')
|
||||
@classmethod
|
||||
def validate_ids(cls, v):
|
||||
if not v or len(v) == 0:
|
||||
raise ValueError("Debe suministrar al menos una comisión para optimizar.")
|
||||
return list(set(v))
|
||||
|
||||
|
||||
class SpringBootCommissionDTO(BaseModel):
|
||||
"""Contrato de comisión en formato Spring Boot."""
|
||||
commissionId: int
|
||||
subjectCode: str
|
||||
subjectName: str
|
||||
expectedAttendees: int = Field(default=30, ge=1)
|
||||
preferredShift: Optional[str] = "NOCHE"
|
||||
preferredStart: datetime
|
||||
preferredEnd: datetime
|
||||
|
||||
|
||||
class SpringBootClassroomDTO(BaseModel):
|
||||
"""Contrato de aula en formato Spring Boot."""
|
||||
classroomId: int
|
||||
roomNumber: str
|
||||
capacity: int = Field(default=30, ge=1)
|
||||
building: Optional[str] = "Edificio Central"
|
||||
isVirtual: bool = False
|
||||
|
||||
|
||||
class SpringBootSyncPayloadDTO(BaseModel):
|
||||
"""Carga útil de sincronización interoperable con microservicios Spring Boot."""
|
||||
requestId: str = Field(..., description="Identificador único de la petición externa")
|
||||
campusCode: Optional[str] = "CENTRAL"
|
||||
academicTerm: Optional[str] = "2026-1C"
|
||||
commissions: List[SpringBootCommissionDTO] = Field(..., min_length=1)
|
||||
classrooms: Optional[List[SpringBootClassroomDTO]] = None
|
||||
options: Optional[Dict[str, Any]] = None
|
||||
@@ -0,0 +1,43 @@
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
class ReservationBaseDTO(BaseModel):
|
||||
classroom_id: int = Field(..., description="ID del aula asignada")
|
||||
commission_id: int = Field(..., description="ID de la comisión académica")
|
||||
user_id: int = Field(..., description="ID del docente responsable")
|
||||
start_time: datetime = Field(..., description="Fecha y hora de inicio")
|
||||
end_time: datetime = Field(..., description="Fecha y hora de finalización")
|
||||
purpose: str = Field(default="Clase regular", max_length=500, description="Motivo o título de la reserva")
|
||||
expected_attendees: int = Field(default=20, ge=1, description="Cantidad estimada de asistentes")
|
||||
shift: Optional[str] = Field(default=None, max_length=30, description="Turno (Mañana, Tarde, Vespertino, Noche)")
|
||||
virtual_link: Optional[str] = Field(default=None, max_length=500, description="Enlace a sala virtual (Meet/Zoom)")
|
||||
notes: Optional[str] = Field(default=None, description="Observaciones adicionales")
|
||||
status: Optional[str] = Field(default="PENDING", description="Estado de la reserva")
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_time_range(self):
|
||||
if self.end_time <= self.start_time:
|
||||
raise ValueError("La hora de finalización debe ser posterior a la hora de inicio.")
|
||||
return self
|
||||
|
||||
class ReservationCreateDTO(ReservationBaseDTO):
|
||||
pass
|
||||
|
||||
class ReservationUpdateDTO(BaseModel):
|
||||
classroom_id: Optional[int] = None
|
||||
start_time: Optional[datetime] = None
|
||||
end_time: Optional[datetime] = None
|
||||
purpose: Optional[str] = None
|
||||
expected_attendees: Optional[int] = None
|
||||
shift: Optional[str] = None
|
||||
virtual_link: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
class ReservationResponseDTO(ReservationBaseDTO):
|
||||
id: int
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Módulo de Seguridad Empresarial inspirado en Spring Security (SecurityFilterChain, RBAC y Auditoría)."""
|
||||
from app.security.filter_chain import SecurityFilterChain
|
||||
from app.security.decorators import require_permission
|
||||
|
||||
__all__ = [
|
||||
'SecurityFilterChain',
|
||||
'require_permission'
|
||||
]
|
||||
@@ -0,0 +1,45 @@
|
||||
from functools import wraps
|
||||
from flask import request, jsonify, redirect, url_for, flash, abort, g
|
||||
from flask_login import current_user
|
||||
|
||||
def require_permission(module: str, min_level: str = 'read'):
|
||||
"""
|
||||
Decorador de seguridad declarativa a nivel de método inspirado en @PreAuthorize de Spring Security.
|
||||
Verifica los privilegios del usuario autenticado (tanto en sesiones web como en API JWT).
|
||||
|
||||
Niveles de permiso estándar:
|
||||
- 'read': Permiso de lectura / consulta
|
||||
- 'read_write': Permiso de modificación y creación
|
||||
- 'admin': Permiso de control total
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
user = None
|
||||
# Evaluar contexto JWT primero si está presente
|
||||
if hasattr(g, 'jwt_user') and g.jwt_user:
|
||||
user = g.jwt_user
|
||||
elif current_user and current_user.is_authenticated:
|
||||
user = current_user
|
||||
|
||||
is_api = request.path.startswith('/api/') or request.is_json
|
||||
|
||||
if not user:
|
||||
if is_api:
|
||||
return jsonify({'error': 'Unauthorized', 'message': 'Autenticación requerida.'}), 401
|
||||
flash('Debe iniciar sesión para acceder a este recurso.', 'warning')
|
||||
return redirect(url_for('auth.login', next=request.url))
|
||||
|
||||
# Comprobar privilegios en la matriz RBAC
|
||||
if not user.has_permission(module, min_level):
|
||||
if is_api:
|
||||
return jsonify({
|
||||
'error': 'Forbidden',
|
||||
'message': f'Permiso insuficiente en módulo "{module}". Nivel requerido: "{min_level}".'
|
||||
}), 403
|
||||
flash(f'No posee permisos suficientes ({min_level}) para gestionar {module}.', 'danger')
|
||||
abort(403)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
@@ -0,0 +1,89 @@
|
||||
from flask import request, jsonify, g, render_template, Response
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, List, Tuple
|
||||
import time
|
||||
|
||||
class SecurityFilterChain:
|
||||
"""
|
||||
Pipeline centralizado de interceptación de seguridad (SecurityFilterChain)
|
||||
inspirado en el patrón arquitectónico de Spring Security.
|
||||
|
||||
Capas de filtrado activas:
|
||||
1. Security Headers Filter (Blindaje HTTP en todas las respuestas).
|
||||
2. Rate Limiting Filter (Protección contra fuerza bruta y DoS).
|
||||
3. Bearer Token Context Filter (Inyección de identidad contextual).
|
||||
"""
|
||||
|
||||
def __init__(self, app=None):
|
||||
# Registro de timestamps de peticiones por IP: ip -> [timestamps]
|
||||
self._request_history: Dict[str, List[float]] = {}
|
||||
# Límites por endpoint sensible (max_requests, window_seconds)
|
||||
self._rate_limits = {
|
||||
'/login': (15, 60),
|
||||
'/api/v1/auth/login': (15, 60),
|
||||
'/api/v1/auth/refresh': (30, 60)
|
||||
}
|
||||
if app:
|
||||
self.init_app(app)
|
||||
|
||||
def init_app(self, app):
|
||||
"""Registra los filtros en el ciclo de vida de la aplicación Flask."""
|
||||
app.before_request(self._execute_pre_filters)
|
||||
app.after_request(self._execute_post_filters)
|
||||
|
||||
def _get_client_ip(self) -> str:
|
||||
"""Obtiene la IP real del cliente respetando proxies inversos de confianza."""
|
||||
if request.headers.get('X-Forwarded-For'):
|
||||
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||||
return request.remote_addr or '127.0.0.1'
|
||||
|
||||
def _execute_pre_filters(self):
|
||||
"""Ejecuta los filtros previos al enrutamiento del controlador."""
|
||||
path = request.path
|
||||
|
||||
# 1. Rate Limiting Filter (solo para peticiones POST de autenticación sensible)
|
||||
if path in self._rate_limits:
|
||||
# Peticiones GET para cargar la interfaz web NO consumen intentos de fuerza bruta
|
||||
if request.method != 'POST':
|
||||
return
|
||||
|
||||
max_req, window = self._rate_limits[path]
|
||||
client_ip = self._get_client_ip()
|
||||
now = time.time()
|
||||
|
||||
# Limpiar historial fuera de la ventana
|
||||
timestamps = self._request_history.get(client_ip, [])
|
||||
timestamps = [t for t in timestamps if now - t < window]
|
||||
|
||||
if len(timestamps) >= max_req:
|
||||
retry_after = max(1, int(window - (now - timestamps[0])))
|
||||
if path.startswith('/api/'):
|
||||
return jsonify({
|
||||
'error': 'TooManyRequests',
|
||||
'message': f'Límite de peticiones excedido. Reintente en {retry_after} segundos.'
|
||||
}), 429
|
||||
|
||||
# Para la web, renderizar vista o respuesta legible
|
||||
return Response(
|
||||
f"Demasiados intentos de autenticación desde su dirección IP. Por favor espere {retry_after} segundos antes de volver a intentar.",
|
||||
status=429,
|
||||
mimetype='text/plain; charset=utf-8'
|
||||
)
|
||||
|
||||
timestamps.append(now)
|
||||
self._request_history[client_ip] = timestamps
|
||||
|
||||
def _execute_post_filters(self, response):
|
||||
"""Inyecta cabeceras de blindaje HTTP (OWASP Secure Headers)."""
|
||||
# Evitar ataques de MIME-Sniffing
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
# Mitigación de Clickjacking
|
||||
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
||||
# Mitigación XSS en navegadores legacy
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
# Política de Referrer segura
|
||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
# Inyección HSTS (si es HTTPS o entorno de producción)
|
||||
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,19 @@
|
||||
from .sheets_importer import GoogleSheetsImporter
|
||||
from .classroom_service import ClassroomService
|
||||
from .reservation_service import ReservationService
|
||||
from .user_service import UserService
|
||||
from .jwt_service import JWTService
|
||||
from .audit_service import AuditService
|
||||
from .optimizer_service import OptimizerService, OptimizerJob, OptimizerJobStatus
|
||||
|
||||
__all__ = [
|
||||
'GoogleSheetsImporter',
|
||||
'ClassroomService',
|
||||
'ReservationService',
|
||||
'UserService',
|
||||
'JWTService',
|
||||
'AuditService',
|
||||
'OptimizerService',
|
||||
'OptimizerJob',
|
||||
'OptimizerJobStatus'
|
||||
]
|
||||
@@ -0,0 +1,76 @@
|
||||
from typing import Optional, List, Dict, Any
|
||||
from flask import request, g, has_request_context
|
||||
from flask_login import current_user
|
||||
import json
|
||||
from app import db
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.user import User
|
||||
|
||||
class AuditService:
|
||||
"""Servicio para la captura y consulta de la bitácora inmutable de auditoría."""
|
||||
|
||||
@staticmethod
|
||||
def log(action: str, module: str, entity_id: Optional[int] = None,
|
||||
details: Optional[Any] = None, user: Optional[User] = None,
|
||||
ip_address: Optional[str] = None) -> AuditLog:
|
||||
"""
|
||||
Registra un evento de auditoría de forma segura, infiriendo el usuario e IP
|
||||
desde el contexto actual de la petición si no se especifican.
|
||||
"""
|
||||
user_id = None
|
||||
user_email = None
|
||||
|
||||
# 1. Determinar usuario actuante
|
||||
if user:
|
||||
user_id = user.id
|
||||
user_email = user.email
|
||||
elif has_request_context():
|
||||
if hasattr(g, 'jwt_user') and g.jwt_user:
|
||||
user_id = g.jwt_user.id
|
||||
user_email = g.jwt_user.email
|
||||
elif current_user and current_user.is_authenticated:
|
||||
user_id = current_user.id
|
||||
user_email = current_user.email
|
||||
|
||||
# 2. Determinar IP de origen
|
||||
if not ip_address and has_request_context():
|
||||
if request.headers.get('X-Forwarded-For'):
|
||||
ip_address = request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||||
else:
|
||||
ip_address = request.remote_addr
|
||||
|
||||
# 3. Serializar detalles si es estructura de datos
|
||||
details_str = None
|
||||
if details is not None:
|
||||
if isinstance(details, (dict, list)):
|
||||
try:
|
||||
details_str = json.dumps(details, ensure_ascii=False)
|
||||
except Exception:
|
||||
details_str = str(details)
|
||||
else:
|
||||
details_str = str(details)
|
||||
|
||||
log_entry = AuditLog(
|
||||
user_id=user_id,
|
||||
user_email=user_email,
|
||||
action=action.upper(),
|
||||
module=module.lower(),
|
||||
entity_id=entity_id,
|
||||
details=details_str,
|
||||
ip_address=ip_address
|
||||
)
|
||||
try:
|
||||
db.session.add(log_entry)
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
|
||||
return log_entry
|
||||
|
||||
@staticmethod
|
||||
def get_recent_logs(limit: int = 50, module: Optional[str] = None) -> List[AuditLog]:
|
||||
"""Obtiene las entradas más recientes de auditoría."""
|
||||
query = AuditLog.query
|
||||
if module:
|
||||
query = query.filter_by(module=module.lower())
|
||||
return query.order_by(AuditLog.created_at.desc()).limit(limit).all()
|
||||
@@ -0,0 +1,118 @@
|
||||
from typing import Optional, List, Dict, Any
|
||||
import re
|
||||
from sqlalchemy import func
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.building import Building
|
||||
from app.repositories.classroom_repository import ClassroomRepository
|
||||
from app.schemas.classroom_dto import ClassroomCreateDTO, ClassroomUpdateDTO
|
||||
from app import db
|
||||
|
||||
class ClassroomService:
|
||||
"""Capa de servicios para la lógica de negocio y validación de aulas."""
|
||||
|
||||
def __init__(self, repository: Optional[ClassroomRepository] = None):
|
||||
self.repository = repository or ClassroomRepository()
|
||||
|
||||
def get_classroom(self, classroom_id: int) -> Optional[Classroom]:
|
||||
"""Obtiene un aula por su identificador."""
|
||||
return self.repository.get_by_id(classroom_id)
|
||||
|
||||
def list_classrooms(self, building_id: Optional[int] = None, floor: Optional[str] = None,
|
||||
only_virtual: bool = False, only_physical: bool = False) -> List[Classroom]:
|
||||
"""Lista aulas filtradas según criterios de ubicación o modalidad."""
|
||||
return self.repository.get_active_classrooms(
|
||||
building_id=building_id,
|
||||
floor=floor,
|
||||
only_virtual=only_virtual,
|
||||
only_physical=only_physical
|
||||
)
|
||||
|
||||
def create_classroom(self, dto: ClassroomCreateDTO) -> Classroom:
|
||||
"""
|
||||
Crea una nueva aula aplicando reglas de negocio:
|
||||
- Resolución o creación dinámica del edificio.
|
||||
- Unicidad de código por piso y edificio.
|
||||
- Capacidad adaptativa para aulas virtuales.
|
||||
"""
|
||||
building = None
|
||||
if dto.building_id:
|
||||
building = Building.query.get(dto.building_id)
|
||||
if not building:
|
||||
raise ValueError(f"El edificio con ID {dto.building_id} no existe.")
|
||||
elif dto.building_name:
|
||||
b_name = dto.building_name.strip()
|
||||
building = Building.query.filter(func.lower(Building.name) == func.lower(b_name)).first()
|
||||
if not building:
|
||||
code_slug = re.sub(r'[^a-zA-Z0-9]+', '_', b_name.upper()).strip('_')
|
||||
building = Building(name=b_name, code=code_slug, is_active=True)
|
||||
db.session.add(building)
|
||||
db.session.flush()
|
||||
else:
|
||||
raise ValueError("Debe especificar un edificio válido o el nombre de un nuevo edificio.")
|
||||
|
||||
# Verificar unicidad
|
||||
existing = self.repository.get_by_code_and_building(building.id, dto.code, dto.floor)
|
||||
if existing:
|
||||
raise ValueError(f"Ya existe un aula con el código '{dto.code}' en el piso {dto.floor} de {building.name}.")
|
||||
|
||||
capacity = dto.capacity
|
||||
is_virtual = ('VIRTUAL' in dto.code.upper()) or ('VIRTUAL' in building.name.upper())
|
||||
if is_virtual and capacity < 999:
|
||||
capacity = 9999
|
||||
|
||||
classroom = Classroom(
|
||||
building_id=building.id,
|
||||
building=building.name,
|
||||
code=dto.code,
|
||||
floor=dto.floor,
|
||||
capacity=capacity,
|
||||
description=dto.description,
|
||||
is_active=dto.is_active
|
||||
)
|
||||
return self.repository.save(classroom)
|
||||
|
||||
def update_classroom(self, classroom_id: int, dto: ClassroomUpdateDTO) -> Classroom:
|
||||
"""Actualiza atributos de un aula existente."""
|
||||
classroom = self.repository.get_by_id(classroom_id)
|
||||
if not classroom:
|
||||
raise ValueError(f"Aula con ID {classroom_id} no encontrada.")
|
||||
|
||||
building_id = dto.building_id if dto.building_id is not None else classroom.building_id
|
||||
code = dto.code.strip() if dto.code is not None else classroom.code
|
||||
floor = dto.floor.strip() if dto.floor is not None else classroom.floor
|
||||
|
||||
# Si cambia código, piso o edificio, validar colisión
|
||||
if (building_id != classroom.building_id or code != classroom.code or floor != classroom.floor):
|
||||
existing = self.repository.get_by_code_and_building(building_id, code, floor)
|
||||
if existing and existing.id != classroom.id:
|
||||
raise ValueError(f"Ya existe otra aula con el código '{code}' en el piso {floor}.")
|
||||
|
||||
if dto.building_id is not None:
|
||||
building = Building.query.get(dto.building_id)
|
||||
if building:
|
||||
classroom.building_id = building.id
|
||||
classroom.building = building.name
|
||||
if dto.code is not None:
|
||||
classroom.code = code
|
||||
if dto.floor is not None:
|
||||
classroom.floor = floor
|
||||
if dto.capacity is not None:
|
||||
classroom.capacity = dto.capacity
|
||||
if dto.description is not None:
|
||||
classroom.description = dto.description
|
||||
if dto.is_active is not None:
|
||||
classroom.is_active = dto.is_active
|
||||
|
||||
return self.repository.save(classroom)
|
||||
|
||||
def delete_classroom(self, classroom_id: int) -> bool:
|
||||
"""Elimina o desactiva un aula verificando dependencias."""
|
||||
classroom = self.repository.get_by_id(classroom_id)
|
||||
if not classroom:
|
||||
return False
|
||||
# Si tiene reservas históricas, se desactiva lógicamente para preservar integridad
|
||||
if classroom.reservations:
|
||||
classroom.is_active = False
|
||||
self.repository.save(classroom)
|
||||
return True
|
||||
return self.repository.delete(classroom)
|
||||
@@ -0,0 +1,116 @@
|
||||
import jwt
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, Any, Optional
|
||||
from flask import current_app
|
||||
from app.models.user import User
|
||||
|
||||
# Lista negra de tokens revocados en memoria (jti -> timestamp de expiración)
|
||||
_token_blacklist: Dict[str, datetime] = {}
|
||||
|
||||
class JWTService:
|
||||
"""Servicio de emisión, verificación y revocación de tokens JWT (Dual Token Pattern)."""
|
||||
|
||||
ACCESS_TOKEN_EXPIRES = timedelta(minutes=15)
|
||||
REFRESH_TOKEN_EXPIRES = timedelta(days=7)
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
@classmethod
|
||||
def _clean_expired_blacklist(cls):
|
||||
"""Limpia periódicamente identificadores de tokens que ya han expirado naturalmente."""
|
||||
now = datetime.now(timezone.utc)
|
||||
expired_jtis = [jti for jti, exp in _token_blacklist.items() if exp < now]
|
||||
for jti in expired_jtis:
|
||||
_token_blacklist.pop(jti, None)
|
||||
|
||||
@classmethod
|
||||
def _get_secret(cls) -> str:
|
||||
"""Obtiene la clave secreta desde la configuración de Flask."""
|
||||
return current_app.config.get('SECRET_KEY', 'default-edu-space-secret-key')
|
||||
|
||||
@classmethod
|
||||
def create_access_token(cls, user: User, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""Emite un token de acceso de corta duración (15 min) con claims de rol y permisos."""
|
||||
now = datetime.now(timezone.utc)
|
||||
delta = expires_delta or cls.ACCESS_TOKEN_EXPIRES
|
||||
role_name = user.role_obj.name if user.role_obj else user.role
|
||||
|
||||
# Mapeo de permisos para claims del token
|
||||
permissions = {}
|
||||
if user.role_obj and hasattr(user.role_obj, 'permissions'):
|
||||
for p in user.role_obj.permissions:
|
||||
permissions[p.module] = p.access_level
|
||||
|
||||
payload = {
|
||||
'sub': str(user.id),
|
||||
'email': user.email,
|
||||
'name': user.name,
|
||||
'role': role_name,
|
||||
'is_admin': user.is_admin(),
|
||||
'permissions': permissions,
|
||||
'type': 'access',
|
||||
'jti': str(uuid.uuid4()),
|
||||
'iat': now,
|
||||
'exp': now + delta
|
||||
}
|
||||
return jwt.encode(payload, cls._get_secret(), algorithm=cls.ALGORITHM)
|
||||
|
||||
@classmethod
|
||||
def create_refresh_token(cls, user: User, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""Emite un token de refresco de larga duración (7 días) para renovación silenciosa."""
|
||||
now = datetime.now(timezone.utc)
|
||||
delta = expires_delta or cls.REFRESH_TOKEN_EXPIRES
|
||||
payload = {
|
||||
'sub': str(user.id),
|
||||
'type': 'refresh',
|
||||
'jti': str(uuid.uuid4()),
|
||||
'iat': now,
|
||||
'exp': now + delta
|
||||
}
|
||||
return jwt.encode(payload, cls._get_secret(), algorithm=cls.ALGORITHM)
|
||||
|
||||
@classmethod
|
||||
def generate_tokens(cls, user: User) -> Dict[str, Any]:
|
||||
"""Genera el par de tokens (Access + Refresh) junto con los metadatos del usuario."""
|
||||
access_token = cls.create_access_token(user)
|
||||
refresh_token = cls.create_refresh_token(user)
|
||||
return {
|
||||
'access_token': access_token,
|
||||
'refresh_token': refresh_token,
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': int(cls.ACCESS_TOKEN_EXPIRES.total_seconds())
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def decode_token(cls, token: str, expected_type: str = 'access') -> Dict[str, Any]:
|
||||
"""
|
||||
Decodifica y valida la firma, expiración y tipo del token.
|
||||
Lanza jwt.PyJWTError si el token es inválido, expirado o fue revocado.
|
||||
"""
|
||||
cls._clean_expired_blacklist()
|
||||
payload = jwt.decode(token, cls._get_secret(), algorithms=[cls.ALGORITHM])
|
||||
|
||||
jti = payload.get('jti')
|
||||
if jti and jti in _token_blacklist:
|
||||
raise jwt.InvalidTokenError("El token ha sido revocado.")
|
||||
|
||||
token_type = payload.get('type')
|
||||
if token_type != expected_type:
|
||||
raise jwt.InvalidTokenError(f"Tipo de token inválido. Se esperaba '{expected_type}', recibido '{token_type}'.")
|
||||
|
||||
return payload
|
||||
|
||||
@classmethod
|
||||
def revoke_token(cls, token: str) -> bool:
|
||||
"""Agrega el identificador del token a la lista negra hasta su fecha natural de expiración."""
|
||||
try:
|
||||
payload = jwt.decode(token, cls._get_secret(), algorithms=[cls.ALGORITHM], options={"verify_exp": False})
|
||||
jti = payload.get('jti')
|
||||
exp_ts = payload.get('exp')
|
||||
if jti and exp_ts:
|
||||
exp_dt = datetime.fromtimestamp(exp_ts, tz=timezone.utc)
|
||||
_token_blacklist[jti] = exp_dt
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
@@ -0,0 +1,282 @@
|
||||
import time
|
||||
import uuid
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
from typing import List, Dict, Any, Optional
|
||||
from flask import current_app
|
||||
|
||||
from app.models.genetic_algorithm import (
|
||||
ReservationOptimizer,
|
||||
GeneticAlgorithm,
|
||||
ReservationRequest,
|
||||
Classroom
|
||||
)
|
||||
from app.models.subject import Commission
|
||||
from app.schemas.optimizer_dto import SpringBootSyncPayloadDTO
|
||||
|
||||
|
||||
class OptimizerJobStatus(str, Enum):
|
||||
PENDING = "PENDING"
|
||||
RUNNING = "RUNNING"
|
||||
COMPLETED = "COMPLETED"
|
||||
FAILED = "FAILED"
|
||||
CANCELLED = "CANCELLED"
|
||||
|
||||
|
||||
class OptimizerJob:
|
||||
"""Representa un trabajo asíncrono en la cola del optimizador heurístico."""
|
||||
|
||||
def __init__(self, job_id: str, params: Dict[str, Any]):
|
||||
self.job_id = job_id
|
||||
self.status = OptimizerJobStatus.PENDING
|
||||
self.progress = 0
|
||||
self.generation = 0
|
||||
self.total_generations = params.get("generations", 80)
|
||||
self.fitness_score = 0.0
|
||||
self.total_requests = 0
|
||||
self.assigned_reservations = 0
|
||||
self.reservations: List[Dict[str, Any]] = []
|
||||
self.conflicts: List[Dict[str, Any]] = []
|
||||
self.error: Optional[str] = None
|
||||
self.created_at = datetime.utcnow()
|
||||
self.started_at: Optional[datetime] = None
|
||||
self.completed_at: Optional[datetime] = None
|
||||
self.execution_time_ms: Optional[float] = None
|
||||
self.params = params
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"job_id": self.job_id,
|
||||
"status": self.status.value,
|
||||
"progress": self.progress,
|
||||
"generation": self.generation,
|
||||
"total_generations": self.total_generations,
|
||||
"fitness_score": round(self.fitness_score, 2),
|
||||
"total_requests": self.total_requests,
|
||||
"assigned_reservations": self.assigned_reservations,
|
||||
"reservations": self.reservations,
|
||||
"conflict_count": len(self.conflicts),
|
||||
"conflicts": self.conflicts,
|
||||
"error": self.error,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"started_at": self.started_at.isoformat() if self.started_at else None,
|
||||
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
|
||||
"execution_time_ms": self.execution_time_ms,
|
||||
"params": {k: v for k, v in self.params.items() if k not in ("app",)}
|
||||
}
|
||||
|
||||
|
||||
class OptimizerService:
|
||||
"""
|
||||
Servicio de alto rendimiento para el Optimizador Heurístico y Cola de Trabajos Asíncronos.
|
||||
Soporta evaluación concurrente multi-hilo y sincronización interoperable con Spring Boot.
|
||||
"""
|
||||
|
||||
_jobs: Dict[str, OptimizerJob] = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
@classmethod
|
||||
def submit_job(
|
||||
cls,
|
||||
commission_ids: List[int],
|
||||
admin_user_id: int,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
generations: int = 80,
|
||||
population_size: int = 40,
|
||||
workers: int = 4,
|
||||
app = None
|
||||
) -> OptimizerJob:
|
||||
"""
|
||||
Registra y despacha un trabajo de optimización en segundo plano (asíncrono).
|
||||
"""
|
||||
job_id = str(uuid.uuid4())
|
||||
params = {
|
||||
"commission_ids": commission_ids,
|
||||
"admin_user_id": admin_user_id,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"generations": generations,
|
||||
"population_size": population_size,
|
||||
"workers": workers
|
||||
}
|
||||
|
||||
job = OptimizerJob(job_id, params)
|
||||
with cls._lock:
|
||||
cls._jobs[job_id] = job
|
||||
|
||||
# Si no se pasó app explícitamente, intentar obtener current_app
|
||||
if app is None:
|
||||
try:
|
||||
app = current_app._get_current_object()
|
||||
except Exception:
|
||||
app = None
|
||||
|
||||
thread = threading.Thread(
|
||||
target=cls._run_job_worker,
|
||||
args=(job_id, app),
|
||||
daemon=True,
|
||||
name=f"optimizer-worker-{job_id[:8]}"
|
||||
)
|
||||
thread.start()
|
||||
return job
|
||||
|
||||
@classmethod
|
||||
def _run_job_worker(cls, job_id: str, app):
|
||||
"""Worker en segundo plano para ejecutar el algoritmo genético."""
|
||||
job = cls.get_job(job_id)
|
||||
if not job:
|
||||
return
|
||||
|
||||
def _execute():
|
||||
t_start = time.perf_counter()
|
||||
with cls._lock:
|
||||
job.status = OptimizerJobStatus.RUNNING
|
||||
job.started_at = datetime.utcnow()
|
||||
|
||||
def progress_callback(gen: int, total_gen: int, pct: int, best_fit: float):
|
||||
with cls._lock:
|
||||
job.generation = gen
|
||||
job.total_generations = total_gen
|
||||
job.progress = pct
|
||||
job.fitness_score = best_fit
|
||||
|
||||
optimizer = ReservationOptimizer(
|
||||
population_size=job.params.get("population_size", 40),
|
||||
generations=job.params.get("generations", 80),
|
||||
workers=job.params.get("workers", 4)
|
||||
)
|
||||
|
||||
result = optimizer.optimize_schedule(
|
||||
commission_ids=job.params["commission_ids"],
|
||||
admin_user_id=job.params["admin_user_id"],
|
||||
start_date=job.params.get("start_date"),
|
||||
end_date=job.params.get("end_date"),
|
||||
progress_callback=progress_callback
|
||||
)
|
||||
|
||||
t_end = time.perf_counter()
|
||||
exec_time_ms = round((t_end - t_start) * 1000, 2)
|
||||
|
||||
with cls._lock:
|
||||
job.status = OptimizerJobStatus.COMPLETED
|
||||
job.progress = 100
|
||||
job.fitness_score = result.get("fitness_score", 0.0)
|
||||
job.total_requests = result.get("total_requests", 0)
|
||||
job.assigned_reservations = result.get("assigned_reservations", 0)
|
||||
job.reservations = result.get("reservations", [])
|
||||
job.conflicts = result.get("conflicts", [])
|
||||
job.completed_at = datetime.utcnow()
|
||||
job.execution_time_ms = exec_time_ms
|
||||
|
||||
try:
|
||||
if app:
|
||||
with app.app_context():
|
||||
_execute()
|
||||
else:
|
||||
_execute()
|
||||
except Exception as ex:
|
||||
with cls._lock:
|
||||
job.status = OptimizerJobStatus.FAILED
|
||||
job.error = str(ex)
|
||||
job.completed_at = datetime.utcnow()
|
||||
|
||||
@classmethod
|
||||
def get_job(cls, job_id: str) -> Optional[OptimizerJob]:
|
||||
"""Obtiene un trabajo por su identificador único."""
|
||||
with cls._lock:
|
||||
return cls._jobs.get(job_id)
|
||||
|
||||
@classmethod
|
||||
def list_jobs(cls, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
"""Lista los trabajos más recientes ordenados por fecha de creación desc."""
|
||||
with cls._lock:
|
||||
sorted_jobs = sorted(cls._jobs.values(), key=lambda j: j.created_at, reverse=True)
|
||||
return [j.to_dict() for j in sorted_jobs[:limit]]
|
||||
|
||||
@classmethod
|
||||
def sync_with_spring_boot(
|
||||
cls,
|
||||
payload: SpringBootSyncPayloadDTO,
|
||||
admin_user_id: int = 1
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Procesa una carga de optimización interoperable proveniente de microservicios Spring Boot.
|
||||
Garantiza respuesta conforme al contrato JSON del microservicio.
|
||||
"""
|
||||
t_start = time.perf_counter()
|
||||
|
||||
# 1. Resolver aulas disponibles
|
||||
if payload.classrooms and len(payload.classrooms) > 0:
|
||||
classrooms = []
|
||||
for c_dto in payload.classrooms:
|
||||
room = Classroom(
|
||||
room_number=c_dto.roomNumber,
|
||||
capacity=c_dto.capacity,
|
||||
is_virtual=c_dto.isVirtual,
|
||||
is_active=True
|
||||
)
|
||||
room.id = c_dto.classroomId
|
||||
classrooms.append(room)
|
||||
else:
|
||||
classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||
|
||||
# 2. Generar ReservationRequests desde el DTO de Spring Boot
|
||||
requests: List[ReservationRequest] = []
|
||||
for c in payload.commissions:
|
||||
req = ReservationRequest(
|
||||
commission_id=c.commissionId,
|
||||
expected_attendees=c.expectedAttendees,
|
||||
purpose=f"Spring Boot Class: {c.subjectCode} - {c.subjectName}",
|
||||
preferred_start_time=c.preferredStart,
|
||||
preferred_end_time=c.preferredEnd,
|
||||
priority=1,
|
||||
flexibility_hours=2
|
||||
)
|
||||
requests.append(req)
|
||||
|
||||
# 3. Opciones de configuración de algoritmo
|
||||
options = payload.options or {}
|
||||
generations = options.get("generations", 60)
|
||||
pop_size = options.get("populationSize", 30)
|
||||
workers = options.get("workers", 4)
|
||||
|
||||
ga = GeneticAlgorithm(
|
||||
population_size=pop_size,
|
||||
generations=generations,
|
||||
workers=workers
|
||||
)
|
||||
|
||||
start_date = min(c.preferredStart for c in payload.commissions)
|
||||
end_date = max(c.preferredEnd for c in payload.commissions)
|
||||
|
||||
best_individual = ga.optimize_reservations(requests, classrooms, start_date, end_date)
|
||||
|
||||
t_end = time.perf_counter()
|
||||
exec_ms = round((t_end - t_start) * 1000, 2)
|
||||
|
||||
scheduled_assignments = []
|
||||
for gene in best_individual.genes:
|
||||
scheduled_assignments.append({
|
||||
"commissionId": gene.commission_id,
|
||||
"classroomId": gene.classroom_id,
|
||||
"startTime": gene.start_time.isoformat() if gene.start_time else None,
|
||||
"endTime": gene.end_time.isoformat() if gene.end_time else None,
|
||||
"purpose": gene.purpose,
|
||||
"expectedAttendees": gene.expected_attendees,
|
||||
"status": "SCHEDULED"
|
||||
})
|
||||
|
||||
return {
|
||||
"requestId": payload.requestId,
|
||||
"status": "SUCCESS",
|
||||
"academicTerm": payload.academicTerm,
|
||||
"campusCode": payload.campusCode,
|
||||
"totalCommissions": len(payload.commissions),
|
||||
"scheduledAssignments": scheduled_assignments,
|
||||
"conflictCount": len(best_individual.conflicts),
|
||||
"fitnessScore": round(best_individual.fitness, 2),
|
||||
"executionTimeMs": exec_ms,
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z"
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from app.models.classroom import Classroom
|
||||
from app.repositories.reservation_repository import ReservationRepository
|
||||
from app.repositories.classroom_repository import ClassroomRepository
|
||||
from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO
|
||||
from app import db
|
||||
|
||||
class ReservationService:
|
||||
"""Capa de servicios para la orquestación y validación de reservas y cronograma."""
|
||||
|
||||
def __init__(self, reservation_repo: Optional[ReservationRepository] = None,
|
||||
classroom_repo: Optional[ClassroomRepository] = None):
|
||||
self.reservation_repo = reservation_repo or ReservationRepository()
|
||||
self.classroom_repo = classroom_repo or ClassroomRepository()
|
||||
|
||||
def get_reservation(self, reservation_id: int) -> Optional[Reservation]:
|
||||
"""Obtiene una reserva por su ID."""
|
||||
return self.reservation_repo.get_by_id(reservation_id)
|
||||
|
||||
def list_reservations(self, start_dt: datetime, end_dt: datetime,
|
||||
classroom_id: Optional[int] = None) -> List[Reservation]:
|
||||
"""Obtiene reservas dentro de un rango temporal."""
|
||||
return self.reservation_repo.get_by_date_range(start_dt, end_dt, classroom_id=classroom_id)
|
||||
|
||||
def check_conflicts(self, classroom_id: int, start_time: datetime, end_time: datetime,
|
||||
exclude_id: Optional[int] = None) -> List[Reservation]:
|
||||
"""Verifica si existen solapamientos en un aula específica."""
|
||||
classroom = self.classroom_repo.get_by_id(classroom_id)
|
||||
# Las aulas virtuales tienen concurrencia ilimitada (sin colisiones)
|
||||
if classroom and classroom.is_virtual:
|
||||
return []
|
||||
return self.reservation_repo.find_conflicts(
|
||||
classroom_id=classroom_id,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
exclude_reservation_id=exclude_id
|
||||
)
|
||||
|
||||
def create_reservation(self, dto: ReservationCreateDTO) -> Reservation:
|
||||
"""
|
||||
Crea una nueva reserva aplicando validaciones de aforo y detección de colisiones.
|
||||
"""
|
||||
classroom = self.classroom_repo.get_by_id(dto.classroom_id)
|
||||
if not classroom or not classroom.is_active:
|
||||
raise ValueError(f"El aula con ID {dto.classroom_id} no existe o no se encuentra activa.")
|
||||
|
||||
# Detección de colisiones para aulas físicas
|
||||
if not classroom.is_virtual:
|
||||
conflicts = self.check_conflicts(dto.classroom_id, dto.start_time, dto.end_time)
|
||||
if conflicts:
|
||||
conflict_details = ", ".join([f"#{c.id} ({c.start_time.strftime('%H:%M')} a {c.end_time.strftime('%H:%M')})" for c in conflicts])
|
||||
raise ValueError(f"Conflicto de horario en {classroom.code}: se solapa con las reservas {conflict_details}.")
|
||||
|
||||
if dto.expected_attendees > classroom.capacity:
|
||||
raise ValueError(f"La cantidad de alumnos ({dto.expected_attendees}) supera la capacidad del aula ({classroom.capacity}).")
|
||||
|
||||
reservation = Reservation(
|
||||
classroom_id=dto.classroom_id,
|
||||
commission_id=dto.commission_id,
|
||||
user_id=dto.user_id,
|
||||
start_time=dto.start_time,
|
||||
end_time=dto.end_time,
|
||||
purpose=dto.purpose,
|
||||
expected_attendees=dto.expected_attendees,
|
||||
shift=dto.shift,
|
||||
virtual_link=dto.virtual_link,
|
||||
notes=dto.notes,
|
||||
status=dto.status or 'CONFIRMED'
|
||||
)
|
||||
return self.reservation_repo.save(reservation)
|
||||
|
||||
def update_reservation(self, reservation_id: int, dto: ReservationUpdateDTO) -> Reservation:
|
||||
"""Actualiza una reserva existente verificando disponibilidad horaria."""
|
||||
reservation = self.reservation_repo.get_by_id(reservation_id)
|
||||
if not reservation:
|
||||
raise ValueError(f"Reserva con ID {reservation_id} no encontrada.")
|
||||
|
||||
classroom_id = dto.classroom_id or reservation.classroom_id
|
||||
start_time = dto.start_time or reservation.start_time
|
||||
end_time = dto.end_time or reservation.end_time
|
||||
|
||||
classroom = self.classroom_repo.get_by_id(classroom_id)
|
||||
if not classroom or not classroom.is_active:
|
||||
raise ValueError(f"El aula con ID {classroom_id} no existe o no se encuentra activa.")
|
||||
|
||||
if not classroom.is_virtual:
|
||||
conflicts = self.check_conflicts(classroom_id, start_time, end_time, exclude_id=reservation.id)
|
||||
if conflicts:
|
||||
conflict_details = ", ".join([f"#{c.id} ({c.start_time.strftime('%H:%M')} - {c.end_time.strftime('%H:%M')})" for c in conflicts])
|
||||
raise ValueError(f"Conflicto de horario en {classroom.code} con: {conflict_details}.")
|
||||
|
||||
if dto.classroom_id is not None:
|
||||
reservation.classroom_id = dto.classroom_id
|
||||
if dto.start_time is not None:
|
||||
reservation.start_time = dto.start_time
|
||||
if dto.end_time is not None:
|
||||
reservation.end_time = dto.end_time
|
||||
if dto.purpose is not None:
|
||||
reservation.purpose = dto.purpose
|
||||
if dto.expected_attendees is not None:
|
||||
reservation.expected_attendees = dto.expected_attendees
|
||||
if dto.shift is not None:
|
||||
reservation.shift = dto.shift
|
||||
if dto.virtual_link is not None:
|
||||
reservation.virtual_link = dto.virtual_link
|
||||
if dto.notes is not None:
|
||||
reservation.notes = dto.notes
|
||||
if dto.status is not None:
|
||||
reservation.status = dto.status
|
||||
|
||||
return self.reservation_repo.save(reservation)
|
||||
|
||||
def cancel_reservation(self, reservation_id: int) -> bool:
|
||||
"""Cancela una reserva estableciendo su estado en CANCELLED."""
|
||||
reservation = self.reservation_repo.get_by_id(reservation_id)
|
||||
if not reservation:
|
||||
return False
|
||||
reservation.status = ReservationStatus.CANCELLED.value
|
||||
self.reservation_repo.save(reservation)
|
||||
return True
|
||||
@@ -0,0 +1,413 @@
|
||||
import urllib.request
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
from datetime import datetime, date, time, timedelta
|
||||
from app import db
|
||||
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, ReservationStatus
|
||||
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'
|
||||
|
||||
SHEETS_CONFIG = [
|
||||
{ 'name': 'Lunes', 'gid': '1915752353', 'day_idx': 0 },
|
||||
{ 'name': 'Martes', 'gid': '466778263', 'day_idx': 1 },
|
||||
{ 'name': 'Miércoles', 'gid': '615483345', 'day_idx': 2 },
|
||||
{ 'name': 'Jueves', 'gid': '682416571', 'day_idx': 3 },
|
||||
{ 'name': 'Viernes', 'gid': '1162906711', 'day_idx': 4 },
|
||||
{ 'name': 'Sábado', 'gid': '752305601', 'day_idx': 5 },
|
||||
]
|
||||
|
||||
TIME_REGEX = re.compile(r'(\d{1,2})(?::(\d{2}))?\s*(?:a|-|to)\s*(\d{1,2})(?::(\d{2}))?', re.IGNORECASE)
|
||||
|
||||
class GoogleSheetsImporter:
|
||||
"""
|
||||
Service to fetch, parse, and synchronize academic schedules,
|
||||
classrooms, careers, and subjects from the official UniCABA Google Sheet.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url=None):
|
||||
self.base_url = base_url or BASE_CSV_URL
|
||||
|
||||
def fetch_sheet_csv(self, gid):
|
||||
"""Fetch CSV string for a specific sheet gid"""
|
||||
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=15) as resp:
|
||||
return resp.read().decode('utf-8', errors='replace')
|
||||
|
||||
def parse_sheet_rows(self, csv_content, sheet_name):
|
||||
"""Parse raw CSV text into a structured list of class records"""
|
||||
reader = csv.reader(io.StringIO(csv_content))
|
||||
rows = list(reader)
|
||||
|
||||
header_idx = -1
|
||||
col_offset = 0
|
||||
|
||||
for r_idx, row in enumerate(rows):
|
||||
for c_idx, cell in enumerate(row):
|
||||
cell_clean = cell.lower().strip()
|
||||
if 'codigo' in cell_clean or 'código' in cell_clean:
|
||||
header_idx = r_idx
|
||||
col_offset = c_idx
|
||||
break
|
||||
if header_idx != -1:
|
||||
break
|
||||
|
||||
if header_idx == -1:
|
||||
return []
|
||||
|
||||
records = []
|
||||
current_shift = 'Mañana'
|
||||
for row in rows[header_idx + 1:]:
|
||||
if len(row) >= col_offset + 5:
|
||||
raw_cell = row[col_offset].strip().replace('\n', ' - ')
|
||||
row_full_text = ' '.join(row).upper()
|
||||
|
||||
# Detect shift delimiters
|
||||
if 'TURNO MAÑANA' in row_full_text or 'TURNO MANANA' in row_full_text:
|
||||
current_shift = 'Mañana'
|
||||
continue
|
||||
elif 'TURNO VESPERTINO' in row_full_text or 'TURNO TARDE' in row_full_text or 'TURNO NOCHE' in row_full_text:
|
||||
current_shift = 'Tarde'
|
||||
continue
|
||||
elif raw_cell.lower().startswith('turno'):
|
||||
if 'mañ' in raw_cell.lower() or 'man' in raw_cell.lower():
|
||||
current_shift = 'Mañana'
|
||||
else:
|
||||
current_shift = 'Tarde'
|
||||
continue
|
||||
|
||||
codigo = raw_cell
|
||||
asignatura = row[col_offset + 1].strip().replace('\n', ' ')
|
||||
aula = row[col_offset + 2].strip().replace('\n', ' ')
|
||||
carrera = row[col_offset + 3].strip().replace('\n', ' ')
|
||||
horario = row[col_offset + 4].strip().replace('\n', ' ')
|
||||
|
||||
# Check valid data row
|
||||
if codigo and asignatura and not 'codigo' in codigo.lower():
|
||||
# Double-check shift from start hour if possible
|
||||
record_shift = current_shift
|
||||
time_match = re.search(r'(\d{1,2}):(\d{2})', horario)
|
||||
if time_match:
|
||||
h = int(time_match.group(1))
|
||||
if h >= 14:
|
||||
record_shift = 'Tarde'
|
||||
elif h < 13:
|
||||
record_shift = 'Mañana'
|
||||
|
||||
records.append({
|
||||
'dia': sheet_name,
|
||||
'codigo': codigo,
|
||||
'asignatura': asignatura,
|
||||
'aula': aula,
|
||||
'carrera': carrera,
|
||||
'horario': horario,
|
||||
'turno': record_shift
|
||||
})
|
||||
return records
|
||||
|
||||
def normalize_room(self, aula_raw):
|
||||
"""
|
||||
Normalize room string into (building, room_number, floor, capacity)
|
||||
"""
|
||||
aula_upper = aula_raw.upper().strip()
|
||||
if not aula_upper or any(x in aula_upper for x in ['FERIADO', 'SIN ACTIVIDAD', 'CONSULTAR EN BEDELIA']):
|
||||
return None
|
||||
|
||||
if 'VIRTUAL' in aula_upper:
|
||||
return {
|
||||
'building': 'Campus Virtual',
|
||||
'code': 'VIRTUAL',
|
||||
'floor': 0,
|
||||
'capacity': 0,
|
||||
'description': 'Espacio áulico virtual UniCABA (Capacidad Ilimitada ∞)'
|
||||
}
|
||||
|
||||
if 'AUDITORIO' in aula_upper:
|
||||
return {
|
||||
'building': 'Edificio Central',
|
||||
'code': 'Auditorio',
|
||||
'floor': 0,
|
||||
'capacity': 150,
|
||||
'description': 'Auditorio principal UniCABA'
|
||||
}
|
||||
|
||||
if 'SALA DE REUNI' in aula_upper:
|
||||
floor = 2 if ('2DO' in aula_upper or '2°' in aula_upper) else 4
|
||||
return {
|
||||
'building': 'Edificio Central',
|
||||
'code': f'Sala Reunión {floor}°P',
|
||||
'floor': floor,
|
||||
'capacity': 15,
|
||||
'description': f'Sala de reunión piso {floor}'
|
||||
}
|
||||
|
||||
# Standard classrooms: "Aula 402", "AULA 203", "306 - 406"
|
||||
num_match = re.search(r'\d+', aula_raw)
|
||||
if num_match:
|
||||
num = num_match.group(0)
|
||||
floor = int(num[0]) if len(num) >= 3 else 1
|
||||
return {
|
||||
'building': 'Edificio Central',
|
||||
'code': f'Aula {num}',
|
||||
'floor': floor,
|
||||
'capacity': 35,
|
||||
'description': f'Aula física {num} - Piso {floor}'
|
||||
}
|
||||
|
||||
# Generic fallback
|
||||
return {
|
||||
'building': 'Edificio Central',
|
||||
'code': aula_raw,
|
||||
'floor': 1,
|
||||
'capacity': 30,
|
||||
'description': aula_raw
|
||||
}
|
||||
|
||||
def parse_time_range(self, horario_str):
|
||||
"""Parse '8:00 a 9:00hs' into (time(8, 0), time(9, 0))"""
|
||||
m = TIME_REGEX.search(horario_str)
|
||||
if not m:
|
||||
return None, None
|
||||
try:
|
||||
h1 = int(m.group(1))
|
||||
m1 = int(m.group(2) or 0)
|
||||
h2 = int(m.group(3))
|
||||
m2 = int(m.group(4) or 0)
|
||||
return time(h1, m1), time(h2, m2)
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
def sync(self):
|
||||
"""
|
||||
Main synchronization process:
|
||||
1. Fetches all 6 day sheets.
|
||||
2. Upserts Careers, Classrooms, Subjects, Commissions.
|
||||
3. Generates Reservations in the calendar for the semester.
|
||||
"""
|
||||
stats = {
|
||||
'total_rows_processed': 0,
|
||||
'careers_created': 0,
|
||||
'careers_updated': 0,
|
||||
'classrooms_created': 0,
|
||||
'classrooms_updated': 0,
|
||||
'subjects_created': 0,
|
||||
'subjects_updated': 0,
|
||||
'commissions_created': 0,
|
||||
'reservations_created': 0,
|
||||
'errors': [],
|
||||
'sample_records': []
|
||||
}
|
||||
|
||||
all_raw_records = []
|
||||
for sheet_cfg in SHEETS_CONFIG:
|
||||
try:
|
||||
csv_data = self.fetch_sheet_csv(sheet_cfg['gid'])
|
||||
rows = self.parse_sheet_rows(csv_data, sheet_cfg['name'])
|
||||
all_raw_records.extend([(r, sheet_cfg['day_idx']) for r in rows])
|
||||
except Exception as e:
|
||||
stats['errors'].append(f"Error al descargar o procesar la hoja de {sheet_cfg['name']}: {str(e)}")
|
||||
|
||||
stats['total_rows_processed'] = len(all_raw_records)
|
||||
if not all_raw_records:
|
||||
return stats
|
||||
|
||||
# Default admin user for reservations
|
||||
default_user = User.query.filter_by(role='ADMIN').first() or User.query.first()
|
||||
default_user_id = default_user.id if default_user else 1
|
||||
|
||||
# Ensure institutional buildings exist with official address
|
||||
from app.models.building import Building
|
||||
b_central = Building.query.filter_by(name='Edificio Central').first()
|
||||
if not b_central:
|
||||
b_central = Building(
|
||||
name='Edificio Central',
|
||||
code='CENTRAL',
|
||||
address='Tte. Gral. Juan Domingo Perón 802, CABA',
|
||||
floors=4,
|
||||
description='Sede central institucional de UniCABA. Aulas de grado, auditorio y laboratorios.'
|
||||
)
|
||||
db.session.add(b_central)
|
||||
db.session.flush()
|
||||
else:
|
||||
b_central.address = 'Tte. Gral. Juan Domingo Perón 802, CABA'
|
||||
|
||||
b_virtual = Building.query.filter_by(name='Campus Virtual').first()
|
||||
if not b_virtual:
|
||||
b_virtual = Building(
|
||||
name='Campus Virtual',
|
||||
code='VIRTUAL',
|
||||
address='Entorno Digital / Microsoft Teams - Moodle',
|
||||
floors=0,
|
||||
description='Entorno de aulas virtuales y clases a distancia'
|
||||
)
|
||||
db.session.add(b_virtual)
|
||||
db.session.flush()
|
||||
|
||||
buildings_cache = {b.name.strip().upper(): b for b in Building.query.all()}
|
||||
careers_cache = {c.name.strip().upper(): c for c in Career.query.all()}
|
||||
classrooms_cache = {(c.building.strip().upper(), c.code.strip().upper()): c for c in Classroom.query.all()}
|
||||
subjects_cache = {s.code.strip().upper(): s for s in Subject.query.all()}
|
||||
|
||||
# Calculate current semester dates (anchored on academic year 2026)
|
||||
# Week starting Monday 2026-08-31 to Saturday 2026-09-05
|
||||
base_monday = date(2026, 8, 31)
|
||||
weeks_to_generate = 4 # Generate reservations for 4 consecutive weeks
|
||||
|
||||
for rec, day_idx in all_raw_records:
|
||||
try:
|
||||
codigo = rec['codigo']
|
||||
asignatura = rec['asignatura']
|
||||
aula_raw = rec['aula']
|
||||
carrera_name = rec['carrera'].strip()
|
||||
horario_raw = rec['horario']
|
||||
dia = rec['dia']
|
||||
turno = rec.get('turno', 'Mañana')
|
||||
|
||||
# 1. Upsert Career
|
||||
carrera_key = carrera_name.upper()
|
||||
career = careers_cache.get(carrera_key)
|
||||
if not career:
|
||||
career = Career(name=carrera_name)
|
||||
db.session.add(career)
|
||||
db.session.flush()
|
||||
careers_cache[carrera_key] = career
|
||||
stats['careers_created'] += 1
|
||||
|
||||
# 2. Upsert Classroom
|
||||
room_info = self.normalize_room(aula_raw)
|
||||
classroom = None
|
||||
if room_info:
|
||||
room_key = (room_info['building'].upper(), room_info['code'].upper())
|
||||
classroom = classrooms_cache.get(room_key)
|
||||
b_entity = buildings_cache.get(room_info['building'].upper())
|
||||
if not classroom:
|
||||
classroom = Classroom(
|
||||
building=room_info['building'],
|
||||
building_id=b_entity.id if b_entity else None,
|
||||
floor=room_info['floor'],
|
||||
capacity=room_info['capacity'],
|
||||
description=room_info['description'],
|
||||
is_active=True
|
||||
)
|
||||
classroom.code = room_info['code']
|
||||
db.session.add(classroom)
|
||||
db.session.flush()
|
||||
classrooms_cache[room_key] = classroom
|
||||
stats['classrooms_created'] += 1
|
||||
else:
|
||||
classroom.capacity = max(classroom.capacity, room_info['capacity'])
|
||||
if not classroom.building_id and b_entity:
|
||||
classroom.building_id = b_entity.id
|
||||
|
||||
# 3. Upsert Subject
|
||||
subject_key = codigo.upper()
|
||||
subject = subjects_cache.get(subject_key)
|
||||
if not subject:
|
||||
subject = Subject(
|
||||
code=codigo,
|
||||
name=asignatura,
|
||||
career_id=career.id,
|
||||
department=career.name,
|
||||
credits=4,
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(subject)
|
||||
db.session.flush()
|
||||
subjects_cache[subject_key] = subject
|
||||
stats['subjects_created'] += 1
|
||||
else:
|
||||
if subject.name != asignatura:
|
||||
subject.name = asignatura
|
||||
stats['subjects_updated'] += 1
|
||||
subject.career_id = career.id
|
||||
subject.department = career.name
|
||||
|
||||
# 4. Upsert Commission
|
||||
# Identify commission subcode e.g. from code suffix or default 'C1'
|
||||
comm_code = 'C1'
|
||||
if '-E' in codigo:
|
||||
comm_code = codigo.split('-')[-1]
|
||||
|
||||
commission = Commission.query.filter_by(
|
||||
subject_id=subject.id,
|
||||
code=comm_code,
|
||||
semester='2026-2',
|
||||
year=2026
|
||||
).first()
|
||||
|
||||
if not commission:
|
||||
commission = Commission(
|
||||
subject_id=subject.id,
|
||||
code=comm_code,
|
||||
semester='2026-2',
|
||||
year=2026,
|
||||
teacher_id=default_user_id,
|
||||
max_students=classroom.capacity if classroom else 35,
|
||||
current_students=0,
|
||||
schedule=f"{dia} {horario_raw}",
|
||||
shift=turno,
|
||||
active=True
|
||||
)
|
||||
db.session.add(commission)
|
||||
db.session.flush()
|
||||
stats['commissions_created'] += 1
|
||||
else:
|
||||
commission.schedule = f"{dia} {horario_raw}"
|
||||
commission.shift = turno
|
||||
|
||||
# 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:
|
||||
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)
|
||||
|
||||
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(
|
||||
classroom_id=classroom.id,
|
||||
commission_id=commission.id,
|
||||
user_id=default_user_id,
|
||||
start_time=dt_start,
|
||||
end_time=dt_end,
|
||||
purpose=f"{subject.code} - {subject.name}",
|
||||
status='CONFIRMED',
|
||||
expected_attendees=min(classroom.capacity, 30),
|
||||
shift=turno,
|
||||
notes=f"Clase semanal {dia} ({turno}) - Carrera: {career.name}"
|
||||
)
|
||||
db.session.add(res)
|
||||
stats['reservations_created'] += 1
|
||||
else:
|
||||
existing_res.shift = turno
|
||||
existing_res.notes = f"Clase semanal {dia} ({turno}) - Carrera: {career.name}"
|
||||
|
||||
if len(stats['sample_records']) < 10:
|
||||
stats['sample_records'].append({
|
||||
'dia': dia,
|
||||
'codigo': codigo,
|
||||
'asignatura': asignatura,
|
||||
'carrera': career.name,
|
||||
'aula': classroom.code_display if classroom else aula_raw,
|
||||
'horario': horario_raw
|
||||
})
|
||||
|
||||
except Exception as row_err:
|
||||
stats['errors'].append(f"Error en fila '{rec.get('codigo', '?')}': {str(row_err)}")
|
||||
|
||||
db.session.commit()
|
||||
return stats
|
||||
@@ -0,0 +1,53 @@
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from app.models.user import User
|
||||
from app.repositories.user_repository import UserRepository
|
||||
from app import db
|
||||
|
||||
class UserService:
|
||||
"""Capa de servicios para la autenticación, roles y perfil de usuarios."""
|
||||
|
||||
def __init__(self, repository: Optional[UserRepository] = None):
|
||||
self.repository = repository or UserRepository()
|
||||
|
||||
def authenticate(self, email: str, password: str) -> Optional[User]:
|
||||
"""
|
||||
Valida credenciales de acceso de un usuario.
|
||||
Retorna la entidad User si es válido y activo; None en caso contrario.
|
||||
"""
|
||||
user = self.repository.get_by_email(email)
|
||||
if not user or not user.is_active:
|
||||
return None
|
||||
|
||||
if user.check_password(password):
|
||||
user.last_login = datetime.utcnow()
|
||||
self.repository.save(user)
|
||||
return user
|
||||
return None
|
||||
|
||||
def get_by_id(self, user_id: int) -> Optional[User]:
|
||||
"""Obtiene un usuario por ID."""
|
||||
return self.repository.get_by_id(user_id)
|
||||
|
||||
def get_by_email(self, email: str) -> Optional[User]:
|
||||
"""Obtiene un usuario por email."""
|
||||
return self.repository.get_by_email(email)
|
||||
|
||||
def get_profile_data(self, user: User) -> Dict[str, Any]:
|
||||
"""Genera un diccionario estructurado del perfil y permisos del usuario."""
|
||||
role_name = user.role_obj.name if user.role_obj else user.role
|
||||
permissions = {}
|
||||
if user.role_obj and hasattr(user.role_obj, 'permissions'):
|
||||
for p in user.role_obj.permissions:
|
||||
permissions[p.module] = p.access_level
|
||||
|
||||
return {
|
||||
'id': user.id,
|
||||
'name': user.name,
|
||||
'email': user.email,
|
||||
'role': role_name,
|
||||
'is_admin': user.is_admin(),
|
||||
'permissions': permissions,
|
||||
'preferred_language': user.preferred_language,
|
||||
'theme_preference': user.theme_preference
|
||||
}
|
||||
@@ -1,97 +1,44 @@
|
||||
/* Custom CSS for Edu-Space Admin */
|
||||
|
||||
/* Dark Theme Enhancements */
|
||||
body {
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
/* Custom Enhancements for Edu-Space Admin */
|
||||
|
||||
/* Card Styles */
|
||||
.card {
|
||||
border-radius: 15px;
|
||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||
border-radius: 14px;
|
||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out, border-color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.navbar {
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Button Styles */
|
||||
.btn {
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, #5568d8 0%, #654198 100%);
|
||||
}
|
||||
|
||||
/* Form Styles */
|
||||
.form-control, .form-select {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #444;
|
||||
background-color: #2a2a2a;
|
||||
color: #fff;
|
||||
transition: border-color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.form-control:focus, .form-select:focus {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
|
||||
background-color: #2a2a2a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.table {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.table {
|
||||
background-color: transparent;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Table Hover */
|
||||
.table:hover tbody tr:hover {
|
||||
background-color: rgba(102, 126, 234, 0.05);
|
||||
}
|
||||
|
||||
/* Badge Styles */
|
||||
.badge {
|
||||
font-size: 0.75em;
|
||||
padding: 0.5em 0.8em;
|
||||
background-color: rgba(var(--brand-primary-rgb), 0.04);
|
||||
}
|
||||
|
||||
/* Status Badge Colors */
|
||||
.bg-confirmed-custom {
|
||||
background-color: #28a745;
|
||||
background-color: #10B981;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.bg-pending-custom {
|
||||
background-color: #ffc107;
|
||||
color: #000;
|
||||
background-color: #F59E0B;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.bg-cancelled-custom {
|
||||
background-color: #dc3545;
|
||||
background-color: #EF4444;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Loading Spinner */
|
||||
@@ -99,9 +46,9 @@ body {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border: 3px solid rgba(var(--brand-primary-rgb), 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: #667eea;
|
||||
border-top-color: var(--brand-primary);
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -111,28 +58,25 @@ body {
|
||||
|
||||
/* Calendar Customizations */
|
||||
.fc-event {
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
border: none !important;
|
||||
padding: 4px 8px !important;
|
||||
font-size: 12px !important;
|
||||
margin: 1px 0 !important;
|
||||
}
|
||||
|
||||
.fc-day-sat, .fc-day-sun {
|
||||
background-color: rgba(255, 255, 255, 0.02) !important;
|
||||
}
|
||||
|
||||
/* Dashboard Stat Cards */
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 15px;
|
||||
background: var(--gradient-primary);
|
||||
color: #FFFFFF;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 6px 20px rgba(var(--brand-primary-rgb), 0.2);
|
||||
}
|
||||
|
||||
.stat-card-primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
|
||||
.stat-card-success { background: linear-gradient(135deg, #28a745 0%, #20c997 100%); }
|
||||
.stat-card-info { background: linear-gradient(135deg, #17a2b8 0%, #138496 100%); }
|
||||
.stat-card-warning { background: linear-gradient(135deg, #ffc107 0%, #fd7e14 100%); }
|
||||
.stat-card-primary { background: linear-gradient(135deg, #B43E8E 0%, #9E367D 100%); }
|
||||
.stat-card-success { background: linear-gradient(135deg, #059669 0%, #10B981 100%); }
|
||||
.stat-card-info { background: linear-gradient(135deg, #682CAD 0%, #8B5CF6 100%); }
|
||||
.stat-card-warning { background: linear-gradient(135deg, #D97706 0%, #F59E0B 100%); }
|
||||
|
||||
/* Quick Action Buttons */
|
||||
.quick-action-btn {
|
||||
@@ -144,14 +88,14 @@ body {
|
||||
}
|
||||
|
||||
.quick-action-btn:hover {
|
||||
transform: translateX(5px);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* Empty State Styles */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #6c757d;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
@@ -160,30 +104,43 @@ body {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Notification Toastenhancements */
|
||||
/* Notification Toast enhancements */
|
||||
.toast {
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 10px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Pagination Styles */
|
||||
.pagination .page-link {
|
||||
border-radius: 8px;
|
||||
margin: 0 2px;
|
||||
border: 1px solid #444;
|
||||
background-color: #2a2a2a;
|
||||
color: #fff;
|
||||
margin: 0 3px;
|
||||
border: 1px solid var(--card-border);
|
||||
background-color: var(--card-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pagination .page-link:hover {
|
||||
background-color: #667eea;
|
||||
border-color: #667eea;
|
||||
color: white;
|
||||
background-color: var(--brand-primary);
|
||||
border-color: var(--brand-primary);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.pagination .page-item.active .page-link {
|
||||
background-color: #667eea;
|
||||
border-color: #667eea;
|
||||
background: var(--gradient-primary);
|
||||
border-color: var(--brand-primary);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Dashboard Stat Shortcuts */
|
||||
.dashboard-stat-card {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06) !important;
|
||||
}
|
||||
.dashboard-stat-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 0.5rem 1.25rem rgba(0, 0, 0, 0.1) !important;
|
||||
border-color: rgba(30, 77, 140, 0.3) !important;
|
||||
}
|
||||
|
||||
/* Mobile Responsiveness */
|
||||
@@ -208,13 +165,140 @@ body {
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid #ddd !important;
|
||||
border: 1px solid #DDD !important;
|
||||
box-shadow: none !important;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: white !important;
|
||||
color: black !important;
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
/**
|
||||
* Sistema de Diseño & Tokens de Color - Edu-Space Admin
|
||||
* Paleta Institucional Magenta / Uva y Soporte Claro/Oscuro
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* --- Brand Primary (Magenta / Uva) --- */
|
||||
--brand-primary: #B43E8E;
|
||||
--brand-primary-rgb: 180, 62, 142;
|
||||
--brand-primary-shade: #9E367D;
|
||||
--brand-primary-tint: #BC5199;
|
||||
--brand-primary-contrast: #FFFFFF;
|
||||
|
||||
/* --- Accent Colors (Modo Claro) --- */
|
||||
--accent-purple: #682CAD;
|
||||
--accent-purple-rgb: 104, 44, 173;
|
||||
--secondary-magenta: #7B2049;
|
||||
--secondary-magenta-rgb: 123, 32, 73;
|
||||
|
||||
/* --- Global Surface & Text (Modo Claro) --- */
|
||||
--app-bg: #F8FAFC;
|
||||
--card-bg: #FFFFFF;
|
||||
--card-border: rgba(0, 0, 0, 0.08);
|
||||
--card-hover-border: rgba(180, 62, 142, 0.3);
|
||||
--card-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
||||
--card-hover-shadow: 0 10px 25px rgba(180, 62, 142, 0.12);
|
||||
|
||||
--text-primary: #1E293B;
|
||||
--text-secondary: #64748B;
|
||||
--text-muted: #94A3B8;
|
||||
|
||||
--navbar-bg: linear-gradient(135deg, #B43E8E 0%, #9E367D 100%);
|
||||
--navbar-text: #FFFFFF;
|
||||
--navbar-border: transparent;
|
||||
--navbar-shadow: 0 4px 20px rgba(180, 62, 142, 0.25);
|
||||
|
||||
--input-bg: #FFFFFF;
|
||||
--input-border: #CBD5E1;
|
||||
--input-text: #1E293B;
|
||||
--input-focus-border: #B43E8E;
|
||||
--input-focus-shadow: 0 0 0 0.2rem rgba(180, 62, 142, 0.25);
|
||||
|
||||
/* --- Distinctive Gradient --- */
|
||||
--gradient-primary: linear-gradient(135deg, #B43E8E 0%, #9E367D 100%);
|
||||
--gradient-primary-shadow: 0 8px 24px rgba(180, 62, 142, 0.25);
|
||||
--gradient-hero: linear-gradient(135deg, rgba(180, 62, 142, 0.08) 0%, rgba(104, 44, 173, 0.12) 100%);
|
||||
|
||||
/* --- Badges (Modo Claro) --- */
|
||||
--badge-primary-bg: #B43E8E;
|
||||
--badge-primary-text: #FFFFFF;
|
||||
--badge-accent-bg: #682CAD;
|
||||
--badge-accent-text: #FFFFFF;
|
||||
--badge-magenta-bg: #7B2049;
|
||||
--badge-magenta-text: #FFFFFF;
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
MODO OSCURO ([data-bs-theme="dark"])
|
||||
======================================================= */
|
||||
[data-bs-theme="dark"] {
|
||||
/* --- Brand Tweaks for Dark Theme --- */
|
||||
--brand-primary: #B43E8E;
|
||||
--brand-primary-tint: #C95FA6;
|
||||
|
||||
/* --- Accent Colors (Modo Oscuro) --- */
|
||||
--accent-purple: #AE7FFF;
|
||||
--secondary-magenta: #FF8AB6;
|
||||
|
||||
/* --- Surfaces & Contrast in Dark Mode --- */
|
||||
--app-bg: #121622;
|
||||
--card-bg: #1E2442;
|
||||
--card-border: rgba(255, 255, 255, 0.08);
|
||||
--card-hover-border: rgba(180, 62, 142, 0.5);
|
||||
--card-shadow: 0 8px 25px rgba(0, 0, 0, 0.4);
|
||||
--card-hover-shadow: 0 12px 30px rgba(180, 62, 142, 0.25);
|
||||
|
||||
--text-primary: #E2E8F0;
|
||||
--text-secondary: #94A3B8;
|
||||
--text-muted: #64748B;
|
||||
|
||||
--navbar-bg: linear-gradient(135deg, #24142D 0%, #161A2E 100%);
|
||||
--navbar-text: #F8FAFC;
|
||||
--navbar-border: 1px solid rgba(180, 62, 142, 0.25);
|
||||
--navbar-shadow: 0 4px 25px rgba(0, 0, 0, 0.5);
|
||||
|
||||
--input-bg: #161B30;
|
||||
--input-border: #2D3748;
|
||||
--input-text: #E2E8F0;
|
||||
--input-focus-border: #B43E8E;
|
||||
--input-focus-shadow: 0 0 0 0.2rem rgba(180, 62, 142, 0.35);
|
||||
|
||||
--gradient-hero: linear-gradient(135deg, rgba(180, 62, 142, 0.18) 0%, rgba(30, 36, 66, 0.8) 100%);
|
||||
|
||||
/* --- Badges & Chips (Translucent in Dark Mode) --- */
|
||||
--badge-primary-bg: rgba(180, 62, 142, 0.25);
|
||||
--badge-primary-text: #FF8AB6;
|
||||
--badge-primary-border: 1px solid rgba(180, 62, 142, 0.45);
|
||||
|
||||
--badge-accent-bg: rgba(104, 44, 173, 0.25);
|
||||
--badge-accent-text: #AE7FFF;
|
||||
--badge-accent-border: 1px solid rgba(174, 127, 255, 0.45);
|
||||
|
||||
--badge-magenta-bg: rgba(123, 32, 73, 0.25);
|
||||
--badge-magenta-text: #FF96B4;
|
||||
--badge-magenta-border: 1px solid rgba(255, 138, 182, 0.4);
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
ELEMENTOS GLOBALES & TRANSICIONES
|
||||
======================================================= */
|
||||
body {
|
||||
background-color: var(--app-bg) !important;
|
||||
color: var(--text-primary) !important;
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
transition: background-color 0.25s ease, color 0.25s ease;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Navbar */
|
||||
.navbar-app {
|
||||
background: var(--navbar-bg) !important;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-bottom: var(--navbar-border);
|
||||
box-shadow: var(--navbar-shadow);
|
||||
padding: 0.65rem 1.25rem;
|
||||
transition: background 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.navbar-app .navbar-brand {
|
||||
color: var(--navbar-text) !important;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.navbar-app .nav-link {
|
||||
color: rgba(255, 255, 255, 0.85) !important;
|
||||
font-weight: 500;
|
||||
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,
|
||||
.navbar-app .nav-link:focus {
|
||||
color: #FFFFFF !important;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.navbar-app .nav-link.active {
|
||||
color: #FFFFFF !important;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.navbar-logo {
|
||||
height: 32px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
transition: filter 0.25s ease;
|
||||
}
|
||||
|
||||
/* En Modo Claro el navbar es magenta (#B43E8E), aplicamos color blanco al isotipo */
|
||||
[data-bs-theme="light"] .navbar-logo,
|
||||
:root:not([data-bs-theme="dark"]) .navbar-logo {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
/* En Modo Oscuro se mantiene el isotipo con su tono natural */
|
||||
[data-bs-theme="dark"] .navbar-logo {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
/* User Avatar Component */
|
||||
.user-avatar-small {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #B43E8E, #682CAD);
|
||||
color: #FFFFFF;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
border: 2px solid rgba(255, 255, 255, 0.4);
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
|
||||
/* Theme Toggle Button */
|
||||
.theme-toggle-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #FFFFFF;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.theme-toggle-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
transform: rotate(15deg) scale(1.05);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Language Switcher Button (Alto Contraste en Navbar y Modo Claro) */
|
||||
.navbar-app .language-switcher .btn {
|
||||
color: #FFFFFF !important;
|
||||
background: rgba(255, 255, 255, 0.16) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.32) !important;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.navbar-app .language-switcher .btn:hover,
|
||||
.navbar-app .language-switcher .btn:focus,
|
||||
.navbar-app .language-switcher .dropdown-toggle.show {
|
||||
color: #FFFFFF !important;
|
||||
background: rgba(255, 255, 255, 0.28) !important;
|
||||
border-color: rgba(255, 255, 255, 0.5) !important;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.navbar-app .language-switcher .dropdown-menu {
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--card-border);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* Dropdown Item Active (Gradiente Magenta Institucional en lugar de Azul) */
|
||||
.dropdown-item {
|
||||
border-radius: 6px;
|
||||
padding: 0.45rem 0.85rem;
|
||||
font-size: 0.9rem;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.dropdown-item.active,
|
||||
.dropdown-item:active {
|
||||
background: var(--gradient-primary) !important;
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
.dropdown-item.active i,
|
||||
.dropdown-item:active i {
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
CARDS & CONTENEDORES
|
||||
======================================================= */
|
||||
.card {
|
||||
background-color: var(--card-bg) !important;
|
||||
border: 1px solid var(--card-border) !important;
|
||||
color: var(--text-primary) !important;
|
||||
box-shadow: var(--card-shadow);
|
||||
border-radius: 14px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--card-hover-border) !important;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: transparent !important;
|
||||
border-bottom: 1px solid var(--card-border) !important;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
background-color: rgba(0, 0, 0, 0.02) !important;
|
||||
border-top: 1px solid var(--card-border) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .card-footer {
|
||||
background-color: rgba(255, 255, 255, 0.02) !important;
|
||||
}
|
||||
|
||||
/* Hero Banners */
|
||||
.hero-banner {
|
||||
background: var(--gradient-hero) !important;
|
||||
border: 1px solid var(--card-border) !important;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
BOTONES Y ACCIONES
|
||||
======================================================= */
|
||||
.btn {
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--gradient-primary) !important;
|
||||
border: none !important;
|
||||
color: var(--brand-primary-contrast) !important;
|
||||
box-shadow: 0 4px 14px rgba(var(--brand-primary-rgb), 0.35);
|
||||
}
|
||||
|
||||
.btn-primary:hover,
|
||||
.btn-primary:focus {
|
||||
background: linear-gradient(135deg, #9E367D 0%, #832765 100%) !important;
|
||||
box-shadow: 0 6px 18px rgba(var(--brand-primary-rgb), 0.45);
|
||||
transform: translateY(-1px);
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
color: var(--brand-primary) !important;
|
||||
border-color: var(--brand-primary) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary:hover,
|
||||
.btn-outline-primary:focus {
|
||||
background: var(--gradient-primary) !important;
|
||||
border-color: var(--brand-primary-shade) !important;
|
||||
color: #FFFFFF !important;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(var(--brand-primary-rgb), 0.25);
|
||||
}
|
||||
|
||||
/* Distinctive Text Accent */
|
||||
.text-primary,
|
||||
.text-brand {
|
||||
color: var(--brand-primary) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .text-primary,
|
||||
[data-bs-theme="dark"] .text-brand {
|
||||
color: var(--brand-primary-tint) !important;
|
||||
}
|
||||
|
||||
.text-accent-purple {
|
||||
color: var(--accent-purple) !important;
|
||||
}
|
||||
|
||||
.text-secondary-magenta {
|
||||
color: var(--secondary-magenta) !important;
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
BADGES & CHIPS
|
||||
======================================================= */
|
||||
.badge {
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
padding: 0.4em 0.75em;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.badge-brand {
|
||||
background-color: var(--badge-primary-bg) !important;
|
||||
color: var(--badge-primary-text) !important;
|
||||
border: var(--badge-primary-border, none);
|
||||
}
|
||||
|
||||
.badge-approved {
|
||||
background-color: var(--badge-accent-bg) !important;
|
||||
color: var(--badge-accent-text) !important;
|
||||
border: var(--badge-accent-border, none);
|
||||
}
|
||||
|
||||
.badge-schedule {
|
||||
background-color: var(--badge-magenta-bg) !important;
|
||||
color: var(--badge-magenta-text) !important;
|
||||
border: var(--badge-magenta-border, none);
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
FORMULARIOS & INPUTS
|
||||
======================================================= */
|
||||
.form-control,
|
||||
.form-select {
|
||||
background-color: var(--input-bg) !important;
|
||||
border-color: var(--input-border) !important;
|
||||
color: var(--input-text) !important;
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem 0.9rem;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-select:focus {
|
||||
border-color: var(--input-focus-border) !important;
|
||||
box-shadow: var(--input-focus-shadow) !important;
|
||||
}
|
||||
|
||||
.form-control::placeholder {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
TARJETA DE LOGIN (.auth-card)
|
||||
======================================================= */
|
||||
.auth-wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
background: var(--app-bg);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background-color: var(--card-bg) !important;
|
||||
border: 1px solid var(--card-border) !important;
|
||||
border-left: 6px solid var(--brand-primary) !important;
|
||||
border-radius: 18px !important;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.1);
|
||||
max-width: 460px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: transform 0.25s ease, box-shadow 0.25s ease;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .auth-card {
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5), 0 0 25px rgba(180, 62, 142, 0.15);
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
padding: 2.25rem 2rem 1.25rem;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.auth-logo {
|
||||
max-height: 52px;
|
||||
max-width: 220px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
margin-bottom: 1rem;
|
||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
|
||||
.auth-controls {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Controles de Login en Modo Claro (Tarjeta Blanca) */
|
||||
[data-bs-theme="light"] .auth-controls .language-switcher .btn,
|
||||
:root:not([data-bs-theme="dark"]) .auth-controls .language-switcher .btn {
|
||||
color: var(--brand-primary) !important;
|
||||
background: rgba(var(--brand-primary-rgb), 0.08) !important;
|
||||
border: 1px solid rgba(var(--brand-primary-rgb), 0.25) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="light"] .auth-controls .language-switcher .btn:hover,
|
||||
:root:not([data-bs-theme="dark"]) .auth-controls .language-switcher .btn:hover {
|
||||
color: #FFFFFF !important;
|
||||
background: var(--brand-primary) !important;
|
||||
border-color: var(--brand-primary) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="light"] .auth-controls .theme-toggle-btn,
|
||||
:root:not([data-bs-theme="dark"]) .auth-controls .theme-toggle-btn {
|
||||
color: var(--brand-primary) !important;
|
||||
background: rgba(var(--brand-primary-rgb), 0.08) !important;
|
||||
border: 1px solid rgba(var(--brand-primary-rgb), 0.25) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="light"] .auth-controls .theme-toggle-btn:hover,
|
||||
:root:not([data-bs-theme="dark"]) .auth-controls .theme-toggle-btn:hover {
|
||||
color: #FFFFFF !important;
|
||||
background: var(--brand-primary) !important;
|
||||
border-color: var(--brand-primary) !important;
|
||||
}
|
||||
|
||||
|
||||
/* =======================================================
|
||||
FULLCALENDAR OVERRIDES
|
||||
======================================================= */
|
||||
.fc {
|
||||
--fc-border-color: var(--card-border);
|
||||
--fc-page-bg-color: transparent;
|
||||
--fc-neutral-bg-color: rgba(0, 0, 0, 0.02);
|
||||
--fc-today-bg-color: rgba(var(--brand-primary-rgb), 0.08);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .fc {
|
||||
--fc-neutral-bg-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.fc-event {
|
||||
border-radius: 8px !important;
|
||||
border: none !important;
|
||||
padding: 3px 8px !important;
|
||||
font-size: 0.8rem !important;
|
||||
font-weight: 500 !important;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.fc-event:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.fc-button-primary {
|
||||
background: var(--gradient-primary) !important;
|
||||
border: none !important;
|
||||
border-radius: 8px !important;
|
||||
box-shadow: 0 2px 6px rgba(var(--brand-primary-rgb), 0.3) !important;
|
||||
}
|
||||
|
||||
.fc-button-primary:hover {
|
||||
background: linear-gradient(135deg, #9E367D 0%, #832765 100%) !important;
|
||||
}
|
||||
|
||||
.fc-button-primary:disabled {
|
||||
background: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
FORTIGATE-STYLE RBAC PERMISSIONS MATRIX
|
||||
======================================================= */
|
||||
.fg-permission-card {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--card-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fg-permission-card .card-header {
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
padding: 0.9rem 1.25rem;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .fg-permission-card .card-header {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.fg-table {
|
||||
margin-bottom: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fg-table th {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
padding: 0.85rem 1.25rem;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .fg-table th {
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.fg-table td {
|
||||
padding: 0.85rem 1.25rem;
|
||||
vertical-align: middle;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.fg-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.fg-table tbody tr:hover {
|
||||
background-color: rgba(180, 62, 142, 0.04);
|
||||
}
|
||||
|
||||
/* FortiGate Segmented Control */
|
||||
.fg-segmented-control {
|
||||
display: inline-flex;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .fg-segmented-control {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
background: #1A1F36;
|
||||
}
|
||||
|
||||
.fg-seg-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border-right: 1px solid #ced4da;
|
||||
outline: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .fg-seg-btn {
|
||||
color: #CBD5E1;
|
||||
border-right-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.fg-seg-btn:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.fg-seg-btn:hover:not(.active) {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .fg-seg-btn:hover:not(.active) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Estado Activo FortiGate (Verde Esmeralda #2D9F6F) */
|
||||
.fg-seg-btn.active {
|
||||
background-color: #2D9F6F !important;
|
||||
color: #FFFFFF !important;
|
||||
font-weight: 600;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.fg-seg-btn.active i {
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
/* Set All Dropdown Button */
|
||||
.btn-fg-set-all {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
padding: 0.25rem 0.65rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ced4da;
|
||||
background: #FFFFFF;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .btn-fg-set-all {
|
||||
background: #1E2442;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
color: #E2E8F0;
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Theme Toggle Manager - Edu-Space Admin
|
||||
* Manejo de Modo Claro y Modo Oscuro con persistencia en localStorage y anti-flickering.
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// 1. Obtener tema almacenado o preferencia del sistema
|
||||
function getPreferredTheme() {
|
||||
const storedTheme = localStorage.getItem('theme');
|
||||
if (storedTheme) {
|
||||
return storedTheme;
|
||||
}
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
// 2. Aplicar tema al elemento <html>
|
||||
function applyTheme(theme) {
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
updateToggleButtons(theme);
|
||||
}
|
||||
|
||||
// 3. Actualizar iconos de los botones de toggle
|
||||
function updateToggleButtons(theme) {
|
||||
const buttons = document.querySelectorAll('.theme-toggle-btn');
|
||||
buttons.forEach(btn => {
|
||||
const icon = btn.querySelector('i');
|
||||
if (icon) {
|
||||
if (theme === 'dark') {
|
||||
icon.className = 'bi bi-sun-fill';
|
||||
btn.setAttribute('aria-label', 'Cambiar a modo claro');
|
||||
btn.setAttribute('title', 'Modo Claro');
|
||||
} else {
|
||||
icon.className = 'bi bi-moon-stars-fill';
|
||||
btn.setAttribute('aria-label', 'Cambiar a modo oscuro');
|
||||
btn.setAttribute('title', 'Modo Oscuro');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Función global para alternar tema
|
||||
window.toggleTheme = function() {
|
||||
const currentTheme = document.documentElement.getAttribute('data-bs-theme') || getPreferredTheme();
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
|
||||
localStorage.setItem('theme', newTheme);
|
||||
applyTheme(newTheme);
|
||||
|
||||
// Si el usuario está autenticado, sincronizar asíncronamente con su perfil en el backend
|
||||
if (document.body && document.body.getAttribute('data-user-authenticated') === 'true') {
|
||||
fetch('/api/user/theme', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ theme: newTheme })
|
||||
}).catch(err => {
|
||||
console.warn('No se pudo sincronizar el tema con el servidor:', err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 5. Inicializar tema inmediatamente (anti-flicker)
|
||||
const initialTheme = getPreferredTheme();
|
||||
applyTheme(initialTheme);
|
||||
|
||||
// 6. Al cargar el DOM, registrar eventos en los botones de toggle
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
updateToggleButtons(document.documentElement.getAttribute('data-bs-theme') || initialTheme);
|
||||
|
||||
document.querySelectorAll('.theme-toggle-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
window.toggleTheme();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 7. Escuchar cambios de preferencia del sistema si el usuario no fijó una manual
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (!localStorage.getItem('theme')) {
|
||||
applyTheme(e.matches ? 'dark' : 'light');
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* ViewSwitcher — Gestor Universal y Extensible de Modos de Visualización
|
||||
*
|
||||
* Permite alternar instantáneamente entre vista de Tabla (grilla densa)
|
||||
* y vista de Tarjetas (mosaico responsive), preservando filtros, búsquedas
|
||||
* y persistiendo la preferencia del usuario en localStorage.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
class ViewSwitcherManager {
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
this.setupViewSwitchers();
|
||||
});
|
||||
}
|
||||
|
||||
setupViewSwitchers() {
|
||||
const switchers = document.querySelectorAll('.view-switcher-group');
|
||||
if (!switchers.length) return;
|
||||
|
||||
switchers.forEach(group => {
|
||||
const context = group.getAttribute('data-context') || 'default';
|
||||
const defaultView = group.getAttribute('data-default-view') || 'cards';
|
||||
|
||||
// Determinar vista inicial:
|
||||
// 1. URL search param (?display=...)
|
||||
// 2. localStorage
|
||||
// 3. defaultView del contenedor
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlView = urlParams.get('display');
|
||||
const storedView = localStorage.getItem(`view_preference_${context}`);
|
||||
const activeView = urlView || storedView || defaultView;
|
||||
|
||||
this.applyView(context, activeView, false);
|
||||
|
||||
// Asignar listeners a los botones del selector
|
||||
const buttons = group.querySelectorAll('.view-switcher-btn');
|
||||
buttons.forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const targetView = btn.getAttribute('data-view');
|
||||
if (targetView) {
|
||||
this.applyView(context, targetView, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplica la vista seleccionada en el contexto dado.
|
||||
* @param {string} context - Identificador del módulo (ej: 'classrooms', 'reservations')
|
||||
* @param {string} viewName - Nombre de la vista ('table', 'cards', etc.)
|
||||
* @param {boolean} persist - Si debe persistirse en localStorage y actualizar URL
|
||||
*/
|
||||
applyView(context, viewName, persist = true) {
|
||||
// 1. Actualizar botones activos en el grupo selector
|
||||
const group = document.querySelector(`.view-switcher-group[data-context="${context}"]`);
|
||||
if (group) {
|
||||
const buttons = group.querySelectorAll('.view-switcher-btn');
|
||||
buttons.forEach(btn => {
|
||||
const btnView = btn.getAttribute('data-view');
|
||||
if (btnView === viewName) {
|
||||
btn.classList.remove('btn-outline-secondary');
|
||||
btn.classList.add('btn-primary', 'active');
|
||||
btn.setAttribute('aria-pressed', 'true');
|
||||
} else {
|
||||
btn.classList.remove('btn-primary', 'active');
|
||||
btn.classList.add('btn-outline-secondary');
|
||||
btn.setAttribute('aria-pressed', 'false');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Conmutar visibilidad de los contenedores de vista
|
||||
const containers = document.querySelectorAll(`[data-view-context="${context}"]`);
|
||||
let matched = false;
|
||||
|
||||
containers.forEach(container => {
|
||||
const containerView = container.getAttribute('data-view-container');
|
||||
if (containerView === viewName) {
|
||||
container.classList.remove('d-none');
|
||||
container.removeAttribute('aria-hidden');
|
||||
matched = true;
|
||||
} else {
|
||||
container.classList.add('d-none');
|
||||
container.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback: si la vista solicitada no tiene contenedor, restaurar a la primera visible
|
||||
if (!matched && containers.length > 0) {
|
||||
containers[0].classList.remove('d-none');
|
||||
viewName = containers[0].getAttribute('data-view-container') || 'cards';
|
||||
}
|
||||
|
||||
// 3. Persistir preferencia y actualizar formularios y URL
|
||||
if (persist) {
|
||||
try {
|
||||
localStorage.setItem(`view_preference_${context}`, viewName);
|
||||
} catch (e) {
|
||||
console.warn('localStorage no disponible para guardar preferencia de vista:', e);
|
||||
}
|
||||
|
||||
// Actualizar parámetro display en la URL sin recargar
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('display', viewName);
|
||||
window.history.replaceState({}, '', url);
|
||||
}
|
||||
|
||||
// 4. Sincronizar campo hidden 'display' en formularios de búsqueda/filtro
|
||||
const forms = document.querySelectorAll('form');
|
||||
forms.forEach(form => {
|
||||
let hiddenInput = form.querySelector('input[name="display"]');
|
||||
if (!hiddenInput) {
|
||||
hiddenInput = document.createElement('input');
|
||||
hiddenInput.type = 'hidden';
|
||||
hiddenInput.name = 'display';
|
||||
form.appendChild(hiddenInput);
|
||||
}
|
||||
hiddenInput.value = viewName;
|
||||
});
|
||||
|
||||
// 5. Emitir evento personalizado para componentes dinámicos
|
||||
window.dispatchEvent(new CustomEvent('viewModeChanged', {
|
||||
detail: { context, view: viewName }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Instanciar singleton
|
||||
window.ViewSwitcher = new ViewSwitcherManager();
|
||||
})();
|
||||
@@ -0,0 +1,252 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}Gestión de Ciclo Lectivo{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 fw-bold mb-1">
|
||||
<i class="bi bi-calendar-range text-primary me-2"></i>{% trans %}Gestión de Ciclo Lectivo y Períodos Académicos{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted mb-0 small">
|
||||
{% trans %}Administración de cuatrimestres, años académicos, fechas de inicio/fin y selección del ciclo lectivo activo.{% endtrans %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
{% if can_edit %}
|
||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalAddTerm">
|
||||
<i class="bi bi-plus-lg me-1"></i>{% trans %}Nuevo Ciclo Lectivo{% endtrans %}
|
||||
</button>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('schedule.commissions_view') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-diagram-3 me-1"></i>{% trans %}Ver Cursadas{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Academic Terms Table -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>{% trans %}Código{% endtrans %}</th>
|
||||
<th>{% trans %}Nombre del Ciclo Lectivo{% endtrans %}</th>
|
||||
<th>{% trans %}Año{% endtrans %}</th>
|
||||
<th>{% trans %}Período / Modalidad{% endtrans %}</th>
|
||||
<th>{% trans %}Fechas del Período{% endtrans %}</th>
|
||||
<th>{% trans %}Comisiones{% endtrans %}</th>
|
||||
<th>{% trans %}Estado Institucional{% endtrans %}</th>
|
||||
{% if can_edit %}
|
||||
<th class="text-end">{% trans %}Acciones{% endtrans %}</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for term in terms %}
|
||||
{% set t_stat = stats.get(term.id, {'commissions_count': 0}) %}
|
||||
<tr class="{% if term.is_current %}table-primary-subtle{% endif %}">
|
||||
<td>
|
||||
<span class="badge font-monospace bg-primary-subtle text-primary border fs-6">{{ term.code }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-bold text-body">{{ term.name }}</div>
|
||||
<small class="text-muted">ID: #{{ term.id }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="fw-semibold text-body">{{ term.year }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary-subtle text-secondary border">
|
||||
{{ term.period_type }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if term.start_date and term.end_date %}
|
||||
<small class="text-body font-monospace">
|
||||
<i class="bi bi-calendar3 me-1 text-primary"></i>{{ term.start_date.strftime('%d/%m/%Y') }} - {{ term.end_date.strftime('%d/%m/%Y') }}
|
||||
</small>
|
||||
{% else %}
|
||||
<small class="text-muted">{% trans %}Sin fechas fijadas{% endtrans %}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info-subtle text-info border">
|
||||
<i class="bi bi-diagram-3 me-1"></i>{{ t_stat.commissions_count }} {% trans %}comisiones{% endtrans %}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if term.is_current %}
|
||||
<span class="badge bg-success text-white px-2 py-1 shadow-sm">
|
||||
<i class="bi bi-check-circle-fill me-1"></i>{% trans %}Ciclo Activo Actual{% endtrans %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary-subtle text-secondary border">
|
||||
{% if term.is_active %}{% trans %}Habilitado{% endtrans %}{% else %}{% trans %}Archivado{% endtrans %}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% if can_edit %}
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm">
|
||||
{% if not term.is_current %}
|
||||
<form method="POST" action="{{ url_for('admin.academic_term_set_current', id=term.id) }}" class="d-inline"
|
||||
onsubmit="return confirm('{% trans %}¿Desea establecer este ciclo lectivo como el Activo para todo el sistema?{% endtrans %}');">
|
||||
<button type="submit" class="btn btn-outline-success py-0 px-2" title="{% trans %}Establecer como Ciclo Activo{% endtrans %}">
|
||||
<i class="bi bi-check2-circle me-1"></i>{% trans %}Activar{% endtrans %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<button type="button" class="btn btn-outline-secondary py-0 px-2"
|
||||
data-bs-toggle="modal" data-bs-target="#modalEditTerm{{ term.id }}"
|
||||
title="{% trans %}Editar Ciclo Lectivo{% endtrans %}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Editar Ciclo Lectivo -->
|
||||
<div class="modal fade text-start" id="modalEditTerm{{ term.id }}" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="{{ url_for('admin.academic_term_edit', id=term.id) }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title h6 fw-bold">
|
||||
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Editar Ciclo Lectivo{% endtrans %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small fw-semibold">{% trans %}Código{% endtrans %} *</label>
|
||||
<input type="text" name="code" class="form-control form-control-sm font-monospace" value="{{ term.code }}" required>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<label class="form-label small fw-semibold">{% trans %}Año Calendario{% endtrans %} *</label>
|
||||
<input type="number" name="year" class="form-control form-control-sm" value="{{ term.year }}" min="2020" max="2050" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Nombre Descriptivo{% endtrans %} *</label>
|
||||
<input type="text" name="name" class="form-control form-control-sm" value="{{ term.name }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Tipo de Período{% endtrans %}</label>
|
||||
<select name="period_type" class="form-select form-select-sm">
|
||||
<option value="Primer Cuatrimestre" {% if term.period_type == 'Primer Cuatrimestre' %}selected{% endif %}>Primer Cuatrimestre</option>
|
||||
<option value="Segundo Cuatrimestre" {% if term.period_type == 'Segundo Cuatrimestre' %}selected{% endif %}>Segundo Cuatrimestre</option>
|
||||
<option value="Anual" {% if term.period_type == 'Anual' %}selected{% endif %}>Anual</option>
|
||||
<option value="Curso de Verano" {% if term.period_type == 'Curso de Verano' %}selected{% endif %}>Curso de Verano</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">{% trans %}Fecha Inicio{% endtrans %}</label>
|
||||
<input type="date" name="start_date" class="form-control form-control-sm" value="{{ term.start_date.strftime('%Y-%m-%d') if term.start_date else '' }}">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">{% trans %}Fecha Fin{% endtrans %}</label>
|
||||
<input type="date" name="end_date" class="form-control form-control-sm" value="{{ term.end_date.strftime('%Y-%m-%d') if term.end_date else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" name="is_current" id="isCurr{{ term.id }}" {% if term.is_current %}checked{% endif %}>
|
||||
<label class="form-check-label small fw-semibold text-primary" for="isCurr{{ term.id }}">
|
||||
{% trans %}Establecer como Ciclo Lectivo Activo Actual{% endtrans %}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="is_active" id="isAct{{ term.id }}" {% if term.is_active %}checked{% endif %}>
|
||||
<label class="form-check-label small" for="isAct{{ term.id }}">{% trans %}Habilitado en el Sistema{% endtrans %}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Guardar Cambios{% endtrans %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-calendar-range fs-1 d-block mb-2"></i>
|
||||
{% trans %}No se encontraron ciclos lectivos registrados.{% endtrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Nuevo Ciclo Lectivo -->
|
||||
{% if can_edit %}
|
||||
<div class="modal fade" id="modalAddTerm" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="{{ url_for('admin.academic_term_add') }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title h6 fw-bold">
|
||||
<i class="bi bi-calendar-plus text-primary me-2"></i>{% trans %}Nuevo Ciclo Lectivo / Período Académico{% endtrans %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small fw-semibold">{% trans %}Código{% endtrans %} *</label>
|
||||
<input type="text" name="code" class="form-control form-control-sm font-monospace" placeholder="ej. 2027-1" required>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<label class="form-label small fw-semibold">{% trans %}Año Calendario{% endtrans %} *</label>
|
||||
<input type="number" name="year" class="form-control form-control-sm" value="2026" min="2020" max="2050" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Nombre Descriptivo{% endtrans %} *</label>
|
||||
<input type="text" name="name" class="form-control form-control-sm" placeholder="ej. Ciclo Lectivo 2027 - 1º Cuatrimestre" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Tipo de Período{% endtrans %}</label>
|
||||
<select name="period_type" class="form-select form-select-sm">
|
||||
<option value="Primer Cuatrimestre">Primer Cuatrimestre</option>
|
||||
<option value="Segundo Cuatrimestre" selected>Segundo Cuatrimestre</option>
|
||||
<option value="Anual">Anual</option>
|
||||
<option value="Curso de Verano">Curso de Verano</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">{% trans %}Fecha Inicio{% endtrans %}</label>
|
||||
<input type="date" name="start_date" class="form-control form-control-sm">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">{% trans %}Fecha Fin{% endtrans %}</label>
|
||||
<input type="date" name="end_date" class="form-control form-control-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="is_current" id="addIsCurr">
|
||||
<label class="form-check-label small fw-semibold text-primary" for="addIsCurr">
|
||||
{% trans %}Establecer de inmediato como Ciclo Lectivo Activo Actual{% endtrans %}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Crear Ciclo Lectivo{% endtrans %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,161 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}Bitácora de Auditoría - Edu-Space Admin{% endtrans %}{% 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-shield-check text-primary me-2"></i>{% trans %}Registro de Auditoría Institucional{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted small mb-0">{% trans %}Trazabilidad completa de operaciones, modificaciones y eventos de seguridad{% endtrans %}</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('main.dashboard') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-1"></i>{% trans %}Volver al Panel{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtros -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-3">
|
||||
<form method="GET" action="{{ url_for('admin.audit_logs') }}" class="row g-2 align-items-center">
|
||||
<div class="col-md-4">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-transparent"><i class="bi bi-search"></i></span>
|
||||
<input type="text" name="search" class="form-control" placeholder="{% trans %}Buscar por email, IP o detalles...{% endtrans %}" value="{{ search }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select name="module" class="form-select form-select-sm">
|
||||
<option value="">{% trans %}Todos los módulos{% endtrans %}</option>
|
||||
{% for mod in modules %}
|
||||
<option value="{{ mod }}" {% if current_module == mod %}selected{% endif %}>{{ mod|capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select name="action" class="form-select form-select-sm">
|
||||
<option value="">{% trans %}Todas las acciones{% endtrans %}</option>
|
||||
{% for act in actions %}
|
||||
<option value="{{ act }}" {% if current_action == act %}selected{% endif %}>{{ act }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex gap-1">
|
||||
<button type="submit" class="btn btn-primary btn-sm flex-grow-1">
|
||||
<i class="bi bi-filter me-1"></i>{% trans %}Filtrar{% endtrans %}
|
||||
</button>
|
||||
{% if search or current_module or current_action %}
|
||||
<a href="{{ url_for('admin.audit_logs') }}" class="btn btn-outline-secondary btn-sm" title="Limpiar filtros">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabla de Auditoría -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
{% if audit_logs %}
|
||||
<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 / Hora{% endtrans %}</th>
|
||||
<th>{% trans %}Usuario{% endtrans %}</th>
|
||||
<th>{% trans %}Acción{% endtrans %}</th>
|
||||
<th>{% trans %}Módulo{% endtrans %}</th>
|
||||
<th>{% trans %}ID Entidad{% endtrans %}</th>
|
||||
<th>{% trans %}Detalles{% endtrans %}</th>
|
||||
<th class="pe-3">{% trans %}IP{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in audit_logs %}
|
||||
<tr>
|
||||
<td class="ps-3 text-nowrap">
|
||||
<span class="small font-monospace">{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-semibold small">{{ log.user_email or 'Sistema' }}</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if log.action == 'CREATE' %}
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle">{{ log.action }}</span>
|
||||
{% elif log.action == 'UPDATE' %}
|
||||
<span class="badge bg-primary-subtle text-primary border border-primary-subtle">{{ log.action }}</span>
|
||||
{% elif log.action == 'DELETE' %}
|
||||
<span class="badge bg-danger-subtle text-danger border border-danger-subtle">{{ log.action }}</span>
|
||||
{% elif 'LOGIN' in log.action %}
|
||||
<span class="badge bg-info-subtle text-info border border-info-subtle">{{ log.action }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary-subtle text-secondary border">{{ log.action }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-body-secondary text-body border font-monospace small">{{ log.module }}</span>
|
||||
</td>
|
||||
<td class="font-monospace small text-muted">
|
||||
{{ log.entity_id or '-' }}
|
||||
</td>
|
||||
<td class="small text-muted" style="max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="{{ log.details }}">
|
||||
{{ log.details or '-' }}
|
||||
</td>
|
||||
<td class="pe-3 font-monospace small text-muted">
|
||||
{{ log.ip_address or '-' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Paginación -->
|
||||
{% if pagination.pages > 1 %}
|
||||
<div class="p-3 border-top d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted">
|
||||
{% trans %}Mostrando página{% endtrans %} {{ pagination.page }} {% trans %}de{% endtrans %} {{ pagination.pages }} ({{ pagination.total }} {% trans %}registros{% endtrans %})
|
||||
</small>
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
<li class="page-item {% if not pagination.has_prev %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('admin.audit_logs', page=pagination.prev_num, search=search, module=current_module, action=current_action) }}">
|
||||
«
|
||||
</a>
|
||||
</li>
|
||||
{% for p in pagination.iter_pages(left_edge=2, left_current=2, right_current=3, right_edge=2) %}
|
||||
{% if p %}
|
||||
<li class="page-item {% if p == pagination.page %}active{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('admin.audit_logs', page=p, search=search, module=current_module, action=current_action) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<li class="page-item {% if not pagination.has_next %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('admin.audit_logs', page=pagination.next_num, search=search, module=current_module, action=current_action) }}">
|
||||
»
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-shield-slash text-muted fs-1 d-block mb-2"></i>
|
||||
<h6 class="fw-bold">{% trans %}No se encontraron registros de auditoría{% endtrans %}</h6>
|
||||
<p class="text-muted small">{% trans %}Los eventos del sistema se registrarán automáticamente a medida que ocurran cambios.{% endtrans %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,208 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}Gestión de Carreras{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 fw-bold mb-1">
|
||||
<i class="bi bi-mortarboard text-primary me-2"></i>{% trans %}Gestión de Carreras y Titulaciones{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted mb-0 small">
|
||||
{% trans %}Administración de programas académicos, tecnicaturas, licenciaturas y planes de estudio.{% endtrans %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
{% if can_edit %}
|
||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalAddCareer">
|
||||
<i class="bi bi-plus-lg me-1"></i>{% trans %}Nueva Carrera{% endtrans %}
|
||||
</button>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('admin.subjects_list') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-book me-1"></i>{% trans %}Ver Asignaturas{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search & Filter Bar -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body py-2">
|
||||
<form method="GET" class="row g-2 align-items-center">
|
||||
<div class="col-md-6">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-body-tertiary"><i class="bi bi-search"></i></span>
|
||||
<input type="text" name="search" class="form-control"
|
||||
placeholder="{% trans %}Buscar carrera por nombre, código o descripción...{% endtrans %}"
|
||||
value="{{ search }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 text-md-end">
|
||||
<button type="submit" class="btn btn-sm btn-primary me-1">
|
||||
<i class="bi bi-funnel me-1"></i>{% trans %}Filtrar{% endtrans %}
|
||||
</button>
|
||||
<a href="{{ url_for('admin.careers_list') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-x-circle me-1"></i>{% trans %}Limpiar{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Careers Table -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>{% trans %}Carrera / Titulación{% endtrans %}</th>
|
||||
<th>{% trans %}Código{% endtrans %}</th>
|
||||
<th>{% trans %}Asignaturas{% endtrans %}</th>
|
||||
<th>{% trans %}Comisiones Activas{% endtrans %}</th>
|
||||
<th>{% trans %}Descripción{% endtrans %}</th>
|
||||
{% if can_edit %}
|
||||
<th class="text-end">{% trans %}Acciones{% endtrans %}</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for career in careers %}
|
||||
{% set c_stat = stats.get(career.id, {'subjects_count': 0, 'commissions_count': 0}) %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="rounded-circle p-2 bg-primary-subtle text-primary me-3">
|
||||
<i class="bi bi-mortarboard-fill"></i>
|
||||
</div>
|
||||
<div>
|
||||
<span class="fw-bold text-body">{{ career.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if career.code %}
|
||||
<span class="badge font-monospace bg-secondary-subtle text-secondary border">{{ career.code }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted small">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('admin.subjects_list', career_id=career.id) }}" class="badge bg-info-subtle text-info border text-decoration-none">
|
||||
<i class="bi bi-book me-1"></i>{{ c_stat.subjects_count }} {% trans %}asignaturas{% endtrans %}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-primary-subtle text-primary border">
|
||||
<i class="bi bi-diagram-3 me-1"></i>{{ c_stat.commissions_count }} {% trans %}comisiones{% endtrans %}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="small text-muted text-truncate d-inline-block" style="max-width: 250px;">
|
||||
{{ career.description or '-' }}
|
||||
</span>
|
||||
</td>
|
||||
{% if can_edit %}
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-outline-secondary py-0 px-2"
|
||||
data-bs-toggle="modal" data-bs-target="#modalEditCareer{{ career.id }}"
|
||||
title="{% trans %}Editar Carrera{% endtrans %}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
{% if c_stat.subjects_count == 0 %}
|
||||
<form method="POST" action="{{ url_for('admin.career_delete', id=career.id) }}" class="d-inline"
|
||||
onsubmit="return confirm('{% trans %}¿Confirma que desea eliminar esta carrera permanentemente?{% endtrans %}');">
|
||||
<button type="submit" class="btn btn-outline-danger py-0 px-2" title="{% trans %}Eliminar{% endtrans %}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Modal Editar Carrera -->
|
||||
<div class="modal fade text-start" id="modalEditCareer{{ career.id }}" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="{{ url_for('admin.career_edit', id=career.id) }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title h6 fw-bold">
|
||||
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Editar Carrera{% endtrans %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Nombre de la Carrera{% endtrans %} *</label>
|
||||
<input type="text" name="name" class="form-control" value="{{ career.name }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Código / Sigla{% endtrans %}</label>
|
||||
<input type="text" name="code" class="form-control" value="{{ career.code or '' }}" placeholder="ej. LIC-SIS, PUEP">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Descripción{% endtrans %}</label>
|
||||
<textarea name="description" class="form-control" rows="3">{{ career.description or '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Guardar Cambios{% endtrans %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-mortarboard fs-1 d-block mb-2"></i>
|
||||
{% trans %}No se encontraron carreras registradas.{% endtrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Nueva Carrera -->
|
||||
{% if can_edit %}
|
||||
<div class="modal fade" id="modalAddCareer" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="{{ url_for('admin.career_add') }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title h6 fw-bold">
|
||||
<i class="bi bi-mortarboard text-primary me-2"></i>{% trans %}Nueva Carrera / Titulación{% endtrans %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Nombre de la Carrera{% endtrans %} *</label>
|
||||
<input type="text" name="name" class="form-control" placeholder="ej. Licenciatura en Tecnologías Digitales" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Código / Sigla{% endtrans %}</label>
|
||||
<input type="text" name="code" class="form-control" placeholder="ej. TEC-DIG">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Descripción{% endtrans %}</label>
|
||||
<textarea name="description" class="form-control" rows="3" placeholder="Objetivos y alcance de la titulación..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Crear Carrera{% endtrans %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,282 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Comisión {{ commission.get_full_code() }} — Edu-Space{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb small">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('main.dashboard') }}">Inicio</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('admin.commissions_list') }}">Comisiones</a></li>
|
||||
<li class="breadcrumb-item active">{{ commission.get_full_code() }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- 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" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Left: Commission Info + Teacher Assignment -->
|
||||
<div class="col-12 col-lg-4">
|
||||
|
||||
<!-- Commission Card -->
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header border-0 bg-primary text-white rounded-top py-3">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-diagram-3 fs-4"></i>
|
||||
<div>
|
||||
<div class="fw-bold font-monospace">{{ commission.get_full_code() }}</div>
|
||||
<small class="opacity-75">{{ commission.semester }} {{ commission.year }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="fw-bold mb-1">{{ commission.subject.name }}</h5>
|
||||
<p class="text-muted small mb-3">{{ commission.subject.code }}
|
||||
{% if commission.subject.career_obj %}— {{ commission.subject.career_obj.name }}{% endif %}
|
||||
</p>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
{% if commission.shift %}
|
||||
{% set shift_colors = {'Mañana': 'warning', 'Tarde': 'info', 'Vespertino': 'secondary', 'Noche': 'dark'} %}
|
||||
<span class="badge bg-{{ shift_colors.get(commission.shift, 'secondary') }}-subtle text-{{ shift_colors.get(commission.shift, 'secondary') }} border border-{{ shift_colors.get(commission.shift, 'secondary') }}-subtle">
|
||||
<i class="bi bi-clock me-1"></i>{{ commission.shift }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="badge {% if commission.active %}bg-success-subtle text-success{% else %}bg-secondary-subtle text-secondary{% endif %}">
|
||||
{{ 'Activa' if commission.active else 'Inactiva' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul class="list-unstyled small mb-0">
|
||||
<li class="d-flex justify-content-between py-1 border-bottom">
|
||||
<span class="text-muted">Cupo máximo</span>
|
||||
<strong>{{ commission.max_students }}</strong>
|
||||
</li>
|
||||
<li class="d-flex justify-content-between py-1 border-bottom">
|
||||
<span class="text-muted">Inscriptos</span>
|
||||
<strong class="{% if (commission.current_students or 0) >= commission.max_students %}text-danger{% else %}text-success{% endif %}">
|
||||
{{ commission.current_students or 0 }}
|
||||
</strong>
|
||||
</li>
|
||||
{% if commission.schedule %}
|
||||
<li class="d-flex justify-content-between py-1 border-bottom">
|
||||
<span class="text-muted">Horario</span>
|
||||
<span>{{ commission.schedule }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if commission.virtual_link %}
|
||||
<li class="py-1">
|
||||
<a href="{{ commission.virtual_link }}" target="_blank" class="btn btn-sm btn-outline-info w-100">
|
||||
<i class="bi bi-camera-video me-1"></i>Acceso Virtual
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Teacher Assignment -->
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-header border-0 bg-transparent py-2 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-person-video3 text-info"></i>
|
||||
<span class="fw-semibold">Docente Asignado</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if commission.teacher %}
|
||||
<div class="d-flex align-items-center gap-3 mb-3 p-2 rounded bg-info-subtle border border-info-subtle">
|
||||
<div class="avatar-sm bg-info rounded-circle d-flex align-items-center justify-content-center" style="width:40px;height:40px;flex-shrink:0;">
|
||||
<i class="bi bi-person-fill text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold small">{{ commission.teacher.name }}</div>
|
||||
<div class="text-muted" style="font-size:.75rem;">{{ commission.teacher.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning py-2 small mb-3">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>Sin docente asignado
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if can_edit %}
|
||||
<form method="POST" action="{{ url_for('admin.commission_assign_teacher', id=commission.id) }}" id="assign-teacher-form">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-semibold mb-1">Cambiar / Asignar Docente</label>
|
||||
<select name="teacher_id" class="form-select form-select-sm" id="teacher-select">
|
||||
<option value="">— Sin asignar —</option>
|
||||
{% for teacher in teachers %}
|
||||
<option value="{{ teacher.id }}" {% if commission.teacher_id == teacher.id %}selected{% endif %}>
|
||||
{{ teacher.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-info btn-sm w-100" id="assign-teacher-btn">
|
||||
<i class="bi bi-person-check me-1"></i>Guardar Docente
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Right: Students -->
|
||||
<div class="col-12 col-lg-8">
|
||||
|
||||
<!-- Enrolled Students -->
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header border-0 bg-transparent py-2 d-flex align-items-center justify-content-between">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-mortarboard text-success"></i>
|
||||
<span class="fw-semibold">Alumnos Inscriptos</span>
|
||||
<span class="badge bg-success-subtle text-success">{{ enrollments | length }}</span>
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<button class="btn btn-sm btn-success" data-bs-toggle="collapse" data-bs-target="#enroll-form" id="add-student-btn">
|
||||
<i class="bi bi-person-plus me-1"></i>Inscribir Alumno
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Enroll Student Form (collapsible) -->
|
||||
{% if can_edit %}
|
||||
<div class="collapse" id="enroll-form">
|
||||
<div class="card-body border-top bg-success-subtle py-3">
|
||||
<form method="POST" action="{{ url_for('admin.commission_enroll_student', id=commission.id) }}" id="enroll-student-form">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label small fw-semibold mb-1">Seleccionar Alumno</label>
|
||||
<select name="student_id" class="form-select form-select-sm" required id="student-select">
|
||||
<option value="">— Elegir alumno —</option>
|
||||
{% for student in students %}
|
||||
{% if student.id not in enrolled_ids %}
|
||||
<option value="{{ student.id }}">{{ student.name }} — {{ student.email }}</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label small fw-semibold mb-1">Observaciones (opcional)</label>
|
||||
<input type="text" name="notes" class="form-control form-control-sm" placeholder="Notas…" id="enroll-notes">
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<button type="submit" class="btn btn-success btn-sm w-100" id="enroll-submit-btn">
|
||||
<i class="bi bi-plus-circle me-1"></i>Inscribir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card-body p-0">
|
||||
{% if enrollments %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle" id="enrollments-table">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-3">#</th>
|
||||
<th>Alumno</th>
|
||||
<th class="text-center">Estado</th>
|
||||
<th class="text-center">Fecha Inscripción</th>
|
||||
<th class="text-center">Notas</th>
|
||||
{% if can_edit %}<th class="text-end pe-3">Acciones</th>{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in enrollments %}
|
||||
<tr id="enrollment-row-{{ e.student_id }}">
|
||||
<td class="ps-3 text-muted small">{{ loop.index }}</td>
|
||||
<td>
|
||||
<div class="fw-semibold small">{{ e.student.name if e.student else '—' }}</div>
|
||||
<div class="text-muted" style="font-size:.75rem;">{{ e.student.email if e.student else '' }}</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% set st_colors = {'activo': 'success', 'retirado': 'danger', 'condicional': 'warning'} %}
|
||||
<span class="badge bg-{{ st_colors.get(e.status, 'secondary') }}-subtle text-{{ st_colors.get(e.status, 'secondary') }} border border-{{ st_colors.get(e.status, 'secondary') }}-subtle">
|
||||
{{ e.status | capitalize }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-center small text-muted">
|
||||
{{ e.enrolled_at.strftime('%d/%m/%Y') if e.enrolled_at else '—' }}
|
||||
</td>
|
||||
<td class="text-center small text-muted">{{ e.notes or '—' }}</td>
|
||||
{% if can_edit %}
|
||||
<td class="text-end pe-3">
|
||||
<div class="d-flex gap-1 justify-content-end">
|
||||
<!-- Quick status update -->
|
||||
<form method="POST" action="{{ url_for('admin.commission_update_enrollment', id=commission.id, student_id=e.student_id) }}" class="d-inline">
|
||||
<select name="status" class="form-select form-select-sm d-inline-block w-auto"
|
||||
onchange="this.form.submit()" title="Cambiar estado"
|
||||
id="status-select-{{ e.student_id }}">
|
||||
<option value="activo" {% if e.status == 'activo' %}selected{% endif %}>Activo</option>
|
||||
<option value="condicional" {% if e.status == 'condicional' %}selected{% endif %}>Condicional</option>
|
||||
<option value="retirado" {% if e.status == 'retirado' %}selected{% endif %}>Retirado</option>
|
||||
</select>
|
||||
</form>
|
||||
<!-- Remove button -->
|
||||
<form method="POST" action="{{ url_for('admin.commission_unenroll_student', id=commission.id, student_id=e.student_id) }}"
|
||||
onsubmit="return confirm('¿Dar de baja a {{ e.student.name if e.student else 'este alumno' }} de la comisión?')">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Dar de baja"
|
||||
id="unenroll-btn-{{ e.student_id }}">
|
||||
<i class="bi bi-person-dash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-people display-5 text-muted opacity-40"></i>
|
||||
<p class="text-muted mt-3 small">No hay alumnos inscriptos en esta comisión.</p>
|
||||
{% if can_edit %}
|
||||
<button class="btn btn-outline-success btn-sm" data-bs-toggle="collapse" data-bs-target="#enroll-form">
|
||||
<i class="bi bi-person-plus me-1"></i>Inscribir primer alumno
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Capacity Bar -->
|
||||
{% set enrolled_count = enrollments | length %}
|
||||
{% set max_students = commission.max_students or 1 %}
|
||||
{% set cap_pct = [((enrolled_count / max_students) * 100) | round | int, 100] | min %}
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body py-3">
|
||||
<div class="d-flex justify-content-between small fw-semibold mb-1">
|
||||
<span><i class="bi bi-bar-chart-fill me-1 text-primary"></i>Ocupación del cupo</span>
|
||||
<span>{{ enrolled_count }} / {{ max_students }} ({{ cap_pct }}%)</span>
|
||||
</div>
|
||||
<div class="progress" style="height:10px;">
|
||||
<div class="progress-bar {% if cap_pct >= 90 %}bg-danger{% elif cap_pct >= 70 %}bg-warning{% else %}bg-success{% endif %}"
|
||||
style="width:{{ cap_pct }}%;" role="progressbar"
|
||||
aria-valuenow="{{ cap_pct }}" aria-valuemin="0" aria-valuemax="100">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,209 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Gestión de Comisiones — Edu-Space{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex align-items-center justify-content-between mb-4 flex-wrap gap-2">
|
||||
<div>
|
||||
<h1 class="h3 fw-bold mb-1">
|
||||
<i class="bi bi-diagram-3 me-2 text-primary"></i>Gestión de Comisiones
|
||||
</h1>
|
||||
<p class="text-muted mb-0 small">Asignación de docentes e inscripción de alumnos por comisión</p>
|
||||
</div>
|
||||
<a href="{{ url_for('admin.subjects_list') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-book me-1"></i>Ver Asignaturas
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 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" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Filters Card -->
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-body py-3">
|
||||
<form method="GET" class="row g-2 align-items-end" id="filter-form">
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label small fw-semibold mb-1">Buscar</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" name="search" class="form-control" placeholder="Código o asignatura…"
|
||||
value="{{ search or '' }}" id="search-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label small fw-semibold mb-1">Asignatura</label>
|
||||
<select name="subject_id" class="form-select form-select-sm" id="subject-filter">
|
||||
<option value="">Todas</option>
|
||||
{% for subj in subjects %}
|
||||
<option value="{{ subj.id }}" {% if subject_filter == subj.id %}selected{% endif %}>
|
||||
{{ subj.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<label class="form-label small fw-semibold mb-1">Turno</label>
|
||||
<select name="shift" class="form-select form-select-sm" id="shift-filter">
|
||||
<option value="">Todos</option>
|
||||
{% for shift in ['Mañana', 'Tarde', 'Vespertino', 'Noche'] %}
|
||||
<option value="{{ shift }}" {% if shift_filter == shift %}selected{% endif %}>{{ shift }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<label class="form-label small fw-semibold mb-1">Estado</label>
|
||||
<select name="active" class="form-select form-select-sm" id="active-filter">
|
||||
<option value="true" {% if active_filter == 'true' %}selected{% endif %}>Activas</option>
|
||||
<option value="false" {% if active_filter == 'false' %}selected{% endif %}>Inactivas</option>
|
||||
<option value="" {% if not active_filter %}selected{% endif %}>Todas</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm flex-fill" id="filter-btn">
|
||||
<i class="bi bi-funnel me-1"></i>Filtrar
|
||||
</button>
|
||||
<a href="{{ url_for('admin.commissions_list') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-x"></i>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Commissions Table -->
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-header bg-transparent border-bottom d-flex align-items-center justify-content-between py-2">
|
||||
<span class="fw-semibold small">
|
||||
<i class="bi bi-list-ul me-1 text-primary"></i>
|
||||
{{ pagination.total }} comisión(es) encontrada(s)
|
||||
</span>
|
||||
<span class="badge bg-primary-subtle text-primary">Página {{ pagination.page }} / {{ pagination.pages or 1 }}</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if commissions %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle" id="commissions-table">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-3">Comisión</th>
|
||||
<th>Asignatura</th>
|
||||
<th>Docente</th>
|
||||
<th class="text-center">Turno</th>
|
||||
<th class="text-center">Alumnos</th>
|
||||
<th class="text-center">Estado</th>
|
||||
<th class="text-end pe-3">Acción</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for comm in commissions %}
|
||||
<tr>
|
||||
<td class="ps-3">
|
||||
<span class="badge bg-primary-subtle text-primary fw-semibold font-monospace">
|
||||
{{ comm.get_full_code() }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-semibold">{{ comm.subject.name }}</div>
|
||||
<small class="text-muted">{{ comm.subject.code }} — {{ comm.semester }} {{ comm.year }}</small>
|
||||
</td>
|
||||
<td>
|
||||
{% if comm.teacher %}
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="avatar-xs bg-info-subtle rounded-circle d-flex align-items-center justify-content-center" style="width:28px;height:28px;">
|
||||
<i class="bi bi-person-video3 small text-info"></i>
|
||||
</div>
|
||||
<span class="small">{{ comm.teacher.name }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-muted small fst-italic"><i class="bi bi-exclamation-circle me-1 text-warning"></i>Sin asignar</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if comm.shift %}
|
||||
{% set shift_colors = {'Mañana': 'warning', 'Tarde': 'info', 'Vespertino': 'secondary', 'Noche': 'dark'} %}
|
||||
<span class="badge bg-{{ shift_colors.get(comm.shift, 'secondary') }}-subtle text-{{ shift_colors.get(comm.shift, 'secondary') }} border border-{{ shift_colors.get(comm.shift, 'secondary') }}-subtle">
|
||||
{{ comm.shift }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted small">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="fw-semibold">{{ comm.current_students or 0 }}</span>
|
||||
<span class="text-muted small">/ {{ comm.max_students }}</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if comm.active %}
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle">Activa</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">Inactiva</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end pe-3">
|
||||
<a href="{{ url_for('admin.commission_detail', id=comm.id) }}"
|
||||
class="btn btn-sm btn-outline-primary" title="Ver y gestionar comisión">
|
||||
<i class="bi bi-gear me-1"></i>Gestionar
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if pagination.pages > 1 %}
|
||||
<div class="d-flex justify-content-center py-3">
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
{% if pagination.has_prev %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.commissions_list', page=pagination.prev_num, search=search, subject_id=subject_filter, shift=shift_filter, active=active_filter) }}">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% for pg in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||
{% if pg %}
|
||||
<li class="page-item {% if pg == pagination.page %}active{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('admin.commissions_list', page=pg, search=search, subject_id=subject_filter, shift=shift_filter, active=active_filter) }}">{{ pg }}</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.commissions_list', page=pagination.next_num, search=search, subject_id=subject_filter, shift=shift_filter, active=active_filter) }}">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-diagram-3 display-4 text-muted opacity-50"></i>
|
||||
<p class="mt-3 text-muted">No se encontraron comisiones con los filtros aplicados.</p>
|
||||
<a href="{{ url_for('admin.commissions_list') }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>Limpiar filtros
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1,343 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}Gestión de Ocupación y Métricas{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.kpi-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.07);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.kpi-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
.kpi-icon-circle {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.metric-bar-container {
|
||||
height: 10px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.metric-bar-fill {
|
||||
width: 0;
|
||||
height: 100%;
|
||||
border-radius: 6px;
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 fw-bold mb-1">
|
||||
<i class="bi bi-graph-up-arrow text-primary me-2"></i>{% trans %}Gestión de Ocupación y Métricas de Espacios{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted mb-0 small">
|
||||
{% trans %}Analítica institucional de aprovechamiento de aulas físicas, demanda en Campus Virtual y distribución horaria.{% endtrans %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-buildings me-1"></i>{% trans %}Aulas Físicas{% endtrans %}
|
||||
</a>
|
||||
<a href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}" class="btn btn-outline-info">
|
||||
<i class="bi bi-globe2 me-1"></i>{% trans %}Campus Virtual (∞){% endtrans %}
|
||||
</a>
|
||||
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-calendar-week me-1"></i>{% trans %}Ver Calendario{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 1. Fila de KPIs Globales de Ocupación -->
|
||||
<div class="row g-3 mb-4">
|
||||
<!-- Ocupación Hoy -->
|
||||
<div class="col-xl-3 col-sm-6">
|
||||
<div class="card kpi-card border-0 shadow-sm p-3 h-100">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.72rem;">{% trans %}Tasa de Ocupación Hoy{% endtrans %}</span>
|
||||
<h2 class="h3 fw-bold mb-0 text-primary mt-1">{{ avg_day_pct }}%</h2>
|
||||
</div>
|
||||
<div class="kpi-icon-circle bg-primary-subtle text-primary">
|
||||
<i class="bi bi-clock-history"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-bar-container mt-3">
|
||||
<div class="metric-bar-fill bg-primary" data-width="{{ avg_day_pct }}%"></div>
|
||||
</div>
|
||||
<small class="text-muted mt-2 d-block" style="font-size: 0.72rem;">
|
||||
{% trans %}Promedio sobre 15 hs operativas ({{ physical_rooms_count }} aulas presenciales){% endtrans %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ocupación Semana -->
|
||||
<div class="col-xl-3 col-sm-6">
|
||||
<div class="card kpi-card border-0 shadow-sm p-3 h-100">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.72rem;">{% trans %}Ocupación Semanal{% endtrans %}</span>
|
||||
<h2 class="h3 fw-bold mb-0 text-info mt-1">{{ avg_week_pct }}%</h2>
|
||||
</div>
|
||||
<div class="kpi-icon-circle bg-info-subtle text-info">
|
||||
<i class="bi bi-calendar-week"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-bar-container mt-3">
|
||||
<div class="metric-bar-fill bg-info" data-width="{{ avg_week_pct }}%"></div>
|
||||
</div>
|
||||
<small class="text-muted mt-2 d-block" style="font-size: 0.72rem;">
|
||||
{% trans %}Semana en curso (Lunes a Sábado){% endtrans %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ocupación Mensual -->
|
||||
<div class="col-xl-3 col-sm-6">
|
||||
<div class="card kpi-card border-0 shadow-sm p-3 h-100">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.72rem;">{% trans %}Ocupación del Mes{% endtrans %}</span>
|
||||
<h2 class="h3 fw-bold mb-0 text-success mt-1">{{ avg_month_pct }}%</h2>
|
||||
</div>
|
||||
<div class="kpi-icon-circle bg-success-subtle text-success">
|
||||
<i class="bi bi-calendar3"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-bar-container mt-3">
|
||||
<div class="metric-bar-fill bg-success" data-width="{{ avg_month_pct }}%"></div>
|
||||
</div>
|
||||
<small class="text-muted mt-2 d-block" style="font-size: 0.72rem;">
|
||||
{% trans %}Total mes calendario activo{% endtrans %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clases Totales Dictadas -->
|
||||
<div class="col-xl-3 col-sm-6">
|
||||
<div class="card kpi-card border-0 shadow-sm p-3 h-100">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<span class="text-muted small text-uppercase fw-semibold" style="font-size: 0.72rem;">{% trans %}Total Clases en el Mes{% endtrans %}</span>
|
||||
<h2 class="h3 fw-bold mb-0 text-warning mt-1">{{ total_month_classes }}</h2>
|
||||
</div>
|
||||
<div class="kpi-icon-circle bg-warning-subtle text-warning">
|
||||
<i class="bi bi-mortarboard"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-3 small font-monospace">
|
||||
<span class="badge bg-primary-subtle text-primary border">Físicas: {{ total_month_classes - v_month }}</span>
|
||||
<span class="badge bg-info-subtle text-info border">Virtuales: {{ v_month }}</span>
|
||||
</div>
|
||||
<small class="text-muted mt-2 d-block" style="font-size: 0.72rem;">
|
||||
{% trans %}Reservas y sesiones registradas en el período{% endtrans %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Detalle y Distribución Espacial -->
|
||||
<div class="row g-4 mb-4">
|
||||
<!-- Ocupación por Piso / Nivel -->
|
||||
<div class="col-lg-6">
|
||||
<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="card-title h6 fw-bold mb-0">
|
||||
<i class="bi bi-layers text-primary me-2"></i>{% trans %}Tasa de Ocupación por Piso / Nivel{% endtrans %}
|
||||
</h5>
|
||||
<span class="badge bg-secondary-subtle text-secondary border">Presencial</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-column gap-3">
|
||||
{% for fs in floor_summary %}
|
||||
<div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span class="fw-semibold text-body small">
|
||||
{{ fs.floor }} ({{ fs.count }} {% trans %}aulas{% endtrans %})
|
||||
</span>
|
||||
<span class="fw-bold font-monospace small {% if fs.avg_pct > 50 %}text-danger{% elif fs.avg_pct > 25 %}text-warning{% else %}text-success{% endif %}">
|
||||
{{ fs.avg_pct }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="metric-bar-container">
|
||||
<div class="metric-bar-fill {% if fs.avg_pct > 50 %}bg-danger{% elif fs.avg_pct > 25 %}bg-warning{% else %}bg-success{% endif %}"
|
||||
data-width="{{ fs.avg_pct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Distribución por Turno -->
|
||||
<div class="col-lg-6">
|
||||
<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="card-title h6 fw-bold mb-0">
|
||||
<i class="bi bi-brightness-high text-warning me-2"></i>{% trans %}Distribución de Clases por Turno{% endtrans %}
|
||||
</h5>
|
||||
<span class="badge bg-secondary-subtle text-secondary border">Mes Completo</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2 text-center mb-3">
|
||||
<div class="col-3">
|
||||
<div class="p-2 rounded bg-body-tertiary border">
|
||||
<div class="small text-muted text-uppercase fw-semibold" style="font-size: 0.68rem;">{% trans %}Mañana{% endtrans %}</div>
|
||||
<div class="h5 fw-bold text-primary mb-0">{{ shift_counts.Mañana }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="p-2 rounded bg-body-tertiary border">
|
||||
<div class="small text-muted text-uppercase fw-semibold" style="font-size: 0.68rem;">{% trans %}Tarde{% endtrans %}</div>
|
||||
<div class="h5 fw-bold text-warning mb-0">{{ shift_counts.Tarde }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="p-2 rounded bg-body-tertiary border">
|
||||
<div class="small text-muted text-uppercase fw-semibold" style="font-size: 0.68rem;">{% trans %}Vespertino{% endtrans %}</div>
|
||||
<div class="h5 fw-bold text-info mb-0">{{ shift_counts.Vespertino }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="p-2 rounded bg-body-tertiary border">
|
||||
<div class="small text-muted text-uppercase fw-semibold" style="font-size: 0.68rem;">{% trans %}Noche{% endtrans %}</div>
|
||||
<div class="h5 fw-bold text-danger mb-0">{{ shift_counts.Noche }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Campus Virtual Box -->
|
||||
<div class="p-3 rounded bg-primary-subtle border border-primary-subtle">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="fw-bold text-primary mb-1"><i class="bi bi-globe2 me-2"></i>{% trans %}Campus Virtual (A Demanda · ∞){% endtrans %}</h6>
|
||||
<p class="small text-muted mb-0">{% trans %}Salas simultáneas sin límite físico de cupo ni colisión.{% endtrans %}</p>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="badge bg-primary text-white fs-6">{{ v_month }} clases mes</div>
|
||||
<small class="d-block text-muted mt-1">{{ v_today }} hoy · {{ v_week }} semana</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Top Aulas de Mayor Demanda vs Menor Demanda -->
|
||||
<div class="row g-4">
|
||||
<!-- Mayor Demanda -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3">
|
||||
<h5 class="card-title h6 fw-bold mb-0 text-success">
|
||||
<i class="bi bi-arrow-up-circle-fill me-2"></i>{% trans %}Top Aulas con Mayor Demanda y Ocupación{% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0 small">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>{% trans %}Aula{% endtrans %}</th>
|
||||
<th>{% trans %}Piso{% endtrans %}</th>
|
||||
<th>{% trans %}Capacidad{% endtrans %}</th>
|
||||
<th>{% trans %}Ocup. Mes{% endtrans %}</th>
|
||||
<th class="text-end">{% trans %}Acción{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for rm in top_rooms %}
|
||||
<tr>
|
||||
<td class="fw-bold text-primary">{{ rm.classroom.code }}</td>
|
||||
<td>{{ rm.classroom.floor_display }}</td>
|
||||
<td>{{ rm.classroom.capacity }} asientos</td>
|
||||
<td>
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle font-monospace">
|
||||
{{ rm.month_pct }}% ({{ rm.month_count }} cl.)
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('schedule.list_reservations', classroom_id=rm.classroom.id, date_from=date_filters.month_start, date_to=date_filters.month_end) }}"
|
||||
class="btn btn-sm btn-outline-primary py-0 px-2" style="font-size: 0.72rem;">
|
||||
{% trans %}Ver Reservas{% endtrans %}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Menor Demanda / Oportunidades -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-body-tertiary border-0 py-3">
|
||||
<h5 class="card-title h6 fw-bold mb-0 text-secondary">
|
||||
<i class="bi bi-arrow-down-circle-fill me-2"></i>{% trans %}Aulas con Mayor Disponibilidad (Menor Uso){% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0 small">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>{% trans %}Aula{% endtrans %}</th>
|
||||
<th>{% trans %}Piso{% endtrans %}</th>
|
||||
<th>{% trans %}Capacidad{% endtrans %}</th>
|
||||
<th>{% trans %}Ocup. Mes{% endtrans %}</th>
|
||||
<th class="text-end">{% trans %}Acción{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for rm in low_rooms %}
|
||||
<tr>
|
||||
<td class="fw-bold text-body">{{ rm.classroom.code }}</td>
|
||||
<td>{{ rm.classroom.floor_display }}</td>
|
||||
<td>{{ rm.classroom.capacity }} asientos</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary-subtle text-secondary border font-monospace">
|
||||
{{ rm.month_pct }}% ({{ rm.month_count }} cl.)
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('schedule.add_reservation') }}?classroom_id={{ rm.classroom.id }}"
|
||||
class="btn btn-sm btn-outline-success py-0 px-2" style="font-size: 0.72rem;">
|
||||
{% trans %}Reservar{% endtrans %}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.metric-bar-fill[data-width]').forEach(function(bar) {
|
||||
bar.style.width = bar.getAttribute('data-width');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,404 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}Tipificaciones de Hitos y Categorías{% endtrans %} - Edu-Space{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
|
||||
<div>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-1 small">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('main.dashboard') }}">{% trans %}Dashboard{% endtrans %}</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{% trans %}Tipificaciones{% endtrans %}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="h3 fw-bold mb-1">
|
||||
<i class="bi bi-tags text-warning me-2"></i>{% trans %}Tipificaciones de Hitos Académicos{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted mb-0 small">
|
||||
{% trans %}Catálogo dinámico y configurable de tipos de evaluaciones, parciales, TPs y eventos para el calendario institucional.{% endtrans %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
{% if can_edit %}
|
||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalAddType">
|
||||
<i class="bi bi-plus-lg me-1"></i>{% trans %}Nueva Tipificación{% endtrans %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alert / Help Info -->
|
||||
<div class="alert alert-info border-0 shadow-sm d-flex align-items-center mb-4 py-2 px-3">
|
||||
<i class="bi bi-info-circle-fill text-info fs-5 me-3"></i>
|
||||
<div class="small">
|
||||
{% trans %}Las tipificaciones activas aparecen de forma inmediata en el selector de tipo de evento/hito tanto en el portal web como en la app móvil. Puedes desactivar una tipificación sin eliminar registros históricos asociados.{% endtrans %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table Card -->
|
||||
<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 %}Tipificación e Insignia{% endtrans %}</th>
|
||||
<th>{% trans %}Código / Slug{% endtrans %}</th>
|
||||
<th>{% trans %}Color Hex{% endtrans %}</th>
|
||||
<th>{% trans %}Descripción{% endtrans %}</th>
|
||||
<th class="text-center">{% trans %}Orden{% endtrans %}</th>
|
||||
<th class="text-center">{% trans %}Estado{% endtrans %}</th>
|
||||
<th class="text-end pe-4">{% trans %}Acciones{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in types %}
|
||||
<tr id="row-{{ item.id }}">
|
||||
<td class="ps-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="rounded-circle shadow-xs me-3 flex-shrink-0 border"
|
||||
style="width: 22px; height: 22px; background-color: {{ item.color or '#0d6efd' }};"
|
||||
title="{{ item.color }}"></span>
|
||||
<div>
|
||||
<span class="fw-bold text-dark-emphasis">{{ item.name }}</span>
|
||||
<span class="badge rounded-pill text-white ms-2 shadow-xs" style="background-color: {{ item.color or '#0d6efd' }}; font-size: 0.70rem;">
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<code class="text-primary fw-semibold">{{ item.code }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<span class="font-monospace small text-muted">{{ item.color or '#0d6efd' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-muted small">{{ item.description or '—' }}</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="badge bg-body-tertiary text-secondary border font-monospace">#{{ item.sort_order }}</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if can_edit %}
|
||||
<form action="{{ url_for('admin.milestone_type_toggle', id=item.id) }}" method="POST" class="d-inline">
|
||||
<div class="form-check form-switch d-inline-block">
|
||||
<input class="form-check-input status-toggle" type="checkbox" role="switch"
|
||||
onchange="this.form.submit()"
|
||||
{% if item.is_active %}checked{% endif %}
|
||||
title="{% trans %}Cambiar estado{% endtrans %}">
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
{% if item.is_active %}
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle">{% trans %}Activo{% endtrans %}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">{% trans %}Inactivo{% endtrans %}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end pe-4">
|
||||
<div class="btn-group btn-group-sm">
|
||||
{% if can_edit %}
|
||||
<button type="button" class="btn btn-outline-primary btn-edit"
|
||||
data-id="{{ item.id }}"
|
||||
data-name="{{ item.name }}"
|
||||
data-code="{{ item.code }}"
|
||||
data-color="{{ item.color or '#0d6efd' }}"
|
||||
data-description="{{ item.description or '' }}"
|
||||
data-sort-order="{{ item.sort_order }}"
|
||||
data-is-active="{{ 'true' if item.is_active else 'false' }}"
|
||||
title="{% trans %}Editar Tipificación{% endtrans %}">
|
||||
<i class="bi bi-pencil-square"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-delete-type"
|
||||
data-id="{{ item.id }}"
|
||||
data-name="{{ item.name }}"
|
||||
data-action-url="{{ url_for('admin.milestone_type_delete', id=item.id) }}"
|
||||
title="{% trans %}Eliminar Tipificación{% endtrans %}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="text-muted small"><i class="bi bi-lock me-1"></i>{% trans %}Solo lectura{% endtrans %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-inbox fs-1 d-block mb-2"></i>
|
||||
{% trans %}No se han encontrado tipificaciones registradas.{% endtrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Nueva Tipificación -->
|
||||
{% if can_edit %}
|
||||
<div class="modal fade" id="modalAddType" tabindex="-1" aria-labelledby="modalAddTypeLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow">
|
||||
<div class="modal-header bg-body-tertiary">
|
||||
<h5 class="modal-title fw-bold" id="modalAddTypeLabel">
|
||||
<i class="bi bi-plus-circle text-primary me-2"></i>{% trans %}Nueva Tipificación de Hito{% endtrans %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form action="{{ url_for('admin.milestone_type_add') }}" method="POST">
|
||||
<div class="modal-body p-4">
|
||||
<div class="mb-3">
|
||||
<label for="add_name" class="form-label fw-semibold">{% trans %}Nombre Visible{% endtrans %} <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="add_name" name="name" placeholder="Ej: Parcial 3, TP Integrador" required>
|
||||
<div class="form-text small">{% trans %}Texto que se mostrará en los selectores y tarjetas.{% endtrans %}</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-7">
|
||||
<label for="add_code" class="form-label fw-semibold">{% trans %}Código / Identificador{% endtrans %}</label>
|
||||
<input type="text" class="form-control font-monospace" id="add_code" name="code" placeholder="ej. parcial_3">
|
||||
<div class="form-text small">{% trans %}Slug único (se autogenera si se deja vacío).{% endtrans %}</div>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label for="add_color" class="form-label fw-semibold">{% trans %}Color Distintivo{% endtrans %}</label>
|
||||
<input type="color" class="form-control form-control-color w-100" id="add_color" name="color" value="#B43E8E" title="Elegir color">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paleta rápida de colores UniCABA -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-muted mb-1">{% trans %}Paleta Institucional Rápida{% endtrans %}:</label>
|
||||
<div class="d-flex flex-wrap gap-2" id="presetColorsAdd">
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #B43E8E;" data-color="#B43E8E" title="Magenta UniCABA"></button>
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #0d6efd;" data-color="#0d6efd" title="Azul Institucional"></button>
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #198754;" data-color="#198754" title="Verde TP"></button>
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #fd7e14;" data-color="#fd7e14" title="Naranja Recuperatorio"></button>
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #dc3545;" data-color="#dc3545" title="Rojo Final"></button>
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #6f42c1;" data-color="#6f42c1" title="Púrpura Especial"></button>
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #20c997;" data-color="#20c997" title="Turquesa Cursada"></button>
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #6c757d;" data-color="#6c757d" title="Gris Neutro"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="add_description" class="form-label fw-semibold">{% trans %}Descripción / Notas{% endtrans %}</label>
|
||||
<textarea class="form-control" id="add_description" name="description" rows="2" placeholder="Detalle o alcance de este tipo de evaluación..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-2">
|
||||
<div class="col-6">
|
||||
<label for="add_sort_order" class="form-label fw-semibold">{% trans %}Orden de Visualización{% endtrans %}</label>
|
||||
<input type="number" class="form-control" id="add_sort_order" name="sort_order" value="10" min="0">
|
||||
</div>
|
||||
<div class="col-6 d-flex align-items-center pt-3">
|
||||
<div class="form-check form-switch pt-2">
|
||||
<input class="form-check-input" type="checkbox" id="add_is_active" name="is_active" value="1" checked>
|
||||
<label class="form-check-label fw-semibold" for="add_is_active">{% trans %}Activo de inmediato{% endtrans %}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer bg-body-tertiary">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||
<button type="submit" class="btn btn-primary px-4">
|
||||
<i class="bi bi-save me-1"></i>{% trans %}Crear Tipificación{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Editar Tipificación -->
|
||||
<div class="modal fade" id="modalEditType" tabindex="-1" aria-labelledby="modalEditTypeLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow">
|
||||
<div class="modal-header bg-body-tertiary">
|
||||
<h5 class="modal-title fw-bold" id="modalEditTypeLabel">
|
||||
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Editar Tipificación{% endtrans %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form id="formEditType" method="POST">
|
||||
<div class="modal-body p-4">
|
||||
<div class="mb-3">
|
||||
<label for="edit_name" class="form-label fw-semibold">{% trans %}Nombre Visible{% endtrans %} <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="edit_name" name="name" required>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-7">
|
||||
<label for="edit_code" class="form-label fw-semibold">{% trans %}Código / Slug{% endtrans %} <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control font-monospace" id="edit_code" name="code" required>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label for="edit_color" class="form-label fw-semibold">{% trans %}Color Distintivo{% endtrans %}</label>
|
||||
<input type="color" class="form-control form-control-color w-100" id="edit_color" name="color" title="Elegir color">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paleta rápida de colores en Edición -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-muted mb-1">{% trans %}Paleta Institucional Rápida{% endtrans %}:</label>
|
||||
<div class="d-flex flex-wrap gap-2" id="presetColorsEdit">
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #B43E8E;" data-color="#B43E8E" title="Magenta UniCABA"></button>
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #0d6efd;" data-color="#0d6efd" title="Azul Institucional"></button>
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #198754;" data-color="#198754" title="Verde TP"></button>
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #fd7e14;" data-color="#fd7e14" title="Naranja Recuperatorio"></button>
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #dc3545;" data-color="#dc3545" title="Rojo Final"></button>
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #6f42c1;" data-color="#6f42c1" title="Púrpura Especial"></button>
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #20c997;" data-color="#20c997" title="Turquesa Cursada"></button>
|
||||
<button type="button" class="btn btn-sm rounded-circle p-0 border" style="width: 24px; height: 24px; background-color: #6c757d;" data-color="#6c757d" title="Gris Neutro"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="edit_description" class="form-label fw-semibold">{% trans %}Descripción{% endtrans %}</label>
|
||||
<textarea class="form-control" id="edit_description" name="description" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-2">
|
||||
<div class="col-6">
|
||||
<label for="edit_sort_order" class="form-label fw-semibold">{% trans %}Orden{% endtrans %}</label>
|
||||
<input type="number" class="form-control" id="edit_sort_order" name="sort_order" min="0">
|
||||
</div>
|
||||
<div class="col-6 d-flex align-items-center pt-3">
|
||||
<div class="form-check form-switch pt-2">
|
||||
<input class="form-check-input" type="checkbox" id="edit_is_active" name="is_active" value="1">
|
||||
<label class="form-check-label fw-semibold" for="edit_is_active">{% trans %}Activo{% endtrans %}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer bg-body-tertiary">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||
<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>
|
||||
|
||||
<!-- Modal Global de Eliminación de Tipificación (fuera de la tabla) -->
|
||||
<div class="modal fade" id="deleteMilestoneTypeModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow-lg" style="border-radius: 14px;">
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="badge bg-danger-subtle text-danger p-2 rounded-circle me-2">
|
||||
<i class="bi bi-exclamation-triangle-fill fs-5"></i>
|
||||
</div>
|
||||
<h5 class="modal-title fw-bold text-danger mb-0">{% trans %}Confirmar Eliminación{% endtrans %}</h5>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body text-start py-3">
|
||||
<p class="mb-2">{% trans %}¿Está seguro de que desea eliminar la tipificación{% endtrans %} <strong id="deleteMilestoneTypeName" class="text-primary"></strong>?</p>
|
||||
<p class="text-muted small mb-0"><i class="bi bi-info-circle me-1"></i>{% trans %}Si existen hitos vinculados a este tipo, el sistema lo desactivará automáticamente para preservar el historial.{% endtrans %}</p>
|
||||
</div>
|
||||
<div class="modal-footer border-0 pt-0">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||
<form id="deleteMilestoneTypeForm" method="POST" class="d-inline">
|
||||
<button type="submit" class="btn btn-danger px-3">
|
||||
<i class="bi bi-trash me-1"></i>{% trans %}Eliminar Tipificación{% endtrans %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Autogenerate code slug in Add Modal
|
||||
var addNameInput = document.getElementById('add_name');
|
||||
var addCodeInput = document.getElementById('add_code');
|
||||
if (addNameInput && addCodeInput) {
|
||||
addNameInput.addEventListener('input', function() {
|
||||
if (!addCodeInput.dataset.touched) {
|
||||
var slug = addNameInput.value.toLowerCase()
|
||||
.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
addCodeInput.value = slug;
|
||||
}
|
||||
});
|
||||
addCodeInput.addEventListener('input', function() {
|
||||
addCodeInput.dataset.touched = "true";
|
||||
});
|
||||
}
|
||||
|
||||
// Color preset clicks for Add Modal
|
||||
document.querySelectorAll('#presetColorsAdd button').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var color = this.getAttribute('data-color');
|
||||
var input = document.getElementById('add_color');
|
||||
if (input) input.value = color;
|
||||
});
|
||||
});
|
||||
|
||||
// Color preset clicks for Edit Modal
|
||||
document.querySelectorAll('#presetColorsEdit button').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var color = this.getAttribute('data-color');
|
||||
var input = document.getElementById('edit_color');
|
||||
if (input) input.value = color;
|
||||
});
|
||||
});
|
||||
|
||||
// Edit modal bindings
|
||||
var modalEditEl = document.getElementById('modalEditType');
|
||||
var modalEdit = modalEditEl ? new bootstrap.Modal(modalEditEl) : null;
|
||||
var formEdit = document.getElementById('formEditType');
|
||||
|
||||
document.querySelectorAll('.btn-edit').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var id = this.getAttribute('data-id');
|
||||
var name = this.getAttribute('data-name');
|
||||
var code = this.getAttribute('data-code');
|
||||
var color = this.getAttribute('data-color');
|
||||
var description = this.getAttribute('data-description');
|
||||
var sortOrder = this.getAttribute('data-sort-order');
|
||||
var isActive = this.getAttribute('data-is-active') === 'true';
|
||||
|
||||
if (formEdit) {
|
||||
formEdit.action = "{{ url_for('admin.milestone_types_list') }}/edit/" + id;
|
||||
document.getElementById('edit_name').value = name;
|
||||
document.getElementById('edit_code').value = code;
|
||||
document.getElementById('edit_color').value = color;
|
||||
document.getElementById('edit_description').value = description;
|
||||
document.getElementById('edit_sort_order').value = sortOrder;
|
||||
document.getElementById('edit_is_active').checked = isActive;
|
||||
if (modalEdit) modalEdit.show();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Delete modal bindings
|
||||
var deleteModalEl = document.getElementById('deleteMilestoneTypeModal');
|
||||
var deleteModal = deleteModalEl ? new bootstrap.Modal(deleteModalEl) : null;
|
||||
var deleteNameEl = document.getElementById('deleteMilestoneTypeName');
|
||||
var deleteFormEl = document.getElementById('deleteMilestoneTypeForm');
|
||||
|
||||
document.querySelectorAll('.btn-delete-type').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var name = this.getAttribute('data-name');
|
||||
var actionUrl = this.getAttribute('data-action-url');
|
||||
if (deleteNameEl) deleteNameEl.textContent = name;
|
||||
if (deleteFormEl) deleteFormEl.action = actionUrl;
|
||||
if (deleteModal) deleteModal.show();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,191 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% 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">
|
||||
<!-- 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-shield-lock text-primary me-2"></i>
|
||||
{% 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">{% trans %}Configure permisos granulares por módulo con la matriz de acceso estilo FortiGate.{% endtrans %}</p>
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
<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>{% trans %}Información del Rol{% endtrans %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<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="{% 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>{% trans %}Rol del sistema protegido.{% endtrans %}
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label fw-semibold">{% trans %}Descripción{% endtrans %}</label>
|
||||
<textarea class="form-control" id="description" name="description" rows="3"
|
||||
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>{% trans %}Guardar Rol y Permisos{% endtrans %}
|
||||
</button>
|
||||
<a href="{{ url_for('admin.roles_list') }}" class="btn btn-outline-secondary">
|
||||
{% trans %}Cancelar{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: FortiGate Permissions Matrix -->
|
||||
<div class="col-lg-8 mb-4">
|
||||
<div class="fg-permission-card">
|
||||
<!-- 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>{% 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>{% 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>{% 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>{% 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>{% trans %}Establecer Todos en Lectura y Escritura{% endtrans %}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Matrix Table -->
|
||||
<div class="table-responsive">
|
||||
<table class="fg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<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.get(mod.id, 'none') %}
|
||||
<tr data-module-row="{{ mod.id }}">
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-primary bg-opacity-10 text-primary rounded p-2 me-3 d-inline-flex align-items-center justify-content-center" style="width: 36px; height: 36px;">
|
||||
<i class="bi {{ mod.icon }} fs-6"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold">{{ mod.name }}</div>
|
||||
<small class="text-muted d-block">{{ mod.description }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end pe-4">
|
||||
<!-- Hidden Input to submit via POST -->
|
||||
<input type="hidden" name="perm_{{ mod.id }}" id="input_perm_{{ mod.id }}" value="{{ cur_level }}">
|
||||
|
||||
<!-- FortiGate Segmented Control -->
|
||||
<div class="fg-segmented-control" role="group" aria-label="Nivel de permiso para {{ mod.name }}">
|
||||
<button type="button"
|
||||
class="fg-seg-btn {% if cur_level == 'none' %}active{% endif %}"
|
||||
data-val="none"
|
||||
data-target="input_perm_{{ mod.id }}"
|
||||
onclick="selectPerm(this, 'input_perm_{{ mod.id }}', 'none')">
|
||||
<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> {% 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> {% trans %}Lectura/Escritura{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function selectPerm(btn, inputId, value) {
|
||||
const input = document.getElementById(inputId);
|
||||
if (input) {
|
||||
input.value = value;
|
||||
}
|
||||
|
||||
// Toggle active state in this segmented control
|
||||
const parentControl = btn.closest('.fg-segmented-control');
|
||||
if (parentControl) {
|
||||
parentControl.querySelectorAll('.fg-seg-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
function setAllPermissions(level) {
|
||||
document.querySelectorAll('.fg-segmented-control').forEach(control => {
|
||||
const targetBtn = control.querySelector(`button[data-val="${level}"]`);
|
||||
if (targetBtn) {
|
||||
targetBtn.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,153 @@
|
||||
{% 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 btn-delete-role"
|
||||
data-role-id="{{ role.id }}"
|
||||
data-role-name="{{ role.name }}"
|
||||
data-action-url="{{ url_for('admin.role_delete', id=role.id) }}"
|
||||
title="{% trans %}Eliminar Rol{% endtrans %}"
|
||||
{% if role.users|length > 0 %}disabled{% endif %}>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Global de Eliminación fuera de la tabla (evita desplazamientos visuales) -->
|
||||
<div class="modal fade" id="deleteRoleModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow-lg" style="border-radius: 14px;">
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="badge bg-danger-subtle text-danger p-2 rounded-circle me-2">
|
||||
<i class="bi bi-exclamation-triangle-fill fs-5"></i>
|
||||
</div>
|
||||
<h5 class="modal-title fw-bold text-danger mb-0">{% trans %}Confirmar Eliminación de Rol{% endtrans %}</h5>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body text-start py-3">
|
||||
<p class="mb-2">{% trans %}¿Está seguro de que desea eliminar el rol{% endtrans %} <strong id="deleteRoleName" class="text-primary"></strong>?</p>
|
||||
<p class="text-muted small mb-0"><i class="bi bi-info-circle me-1"></i>{% trans %}Esta acción no se puede deshacer.{% endtrans %}</p>
|
||||
</div>
|
||||
<div class="modal-footer border-0 pt-0">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||
<form id="deleteRoleForm" method="POST" class="d-inline">
|
||||
<button type="submit" class="btn btn-danger px-3">
|
||||
<i class="bi bi-trash me-1"></i>{% trans %}Eliminar Rol{% endtrans %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var deleteModalEl = document.getElementById('deleteRoleModal');
|
||||
var deleteModal = deleteModalEl ? new bootstrap.Modal(deleteModalEl) : null;
|
||||
var deleteRoleName = document.getElementById('deleteRoleName');
|
||||
var deleteRoleForm = document.getElementById('deleteRoleForm');
|
||||
|
||||
document.querySelectorAll('.btn-delete-role').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var roleName = this.getAttribute('data-role-name');
|
||||
var actionUrl = this.getAttribute('data-action-url');
|
||||
if (deleteRoleName) deleteRoleName.textContent = roleName;
|
||||
if (deleteRoleForm) deleteRoleForm.action = actionUrl;
|
||||
if (deleteModal) deleteModal.show();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,306 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% trans %}Gestión de Asignaturas{% endtrans %} - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 fw-bold mb-1">
|
||||
<i class="bi bi-book text-primary me-2"></i>{% trans %}Gestión de Asignaturas y Materias{% endtrans %}
|
||||
</h1>
|
||||
<p class="text-muted mb-0 small">
|
||||
{% trans %}Catálogo institucional de asignaturas, códigos, créditos académicos y carreras vinculadas.{% endtrans %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
{% if can_edit %}
|
||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalAddSubject">
|
||||
<i class="bi bi-plus-lg me-1"></i>{% trans %}Nueva Asignatura{% endtrans %}
|
||||
</button>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('schedule.commissions_view') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-diagram-3 me-1"></i>{% trans %}Ver Comisiones{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters Bar -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body py-2">
|
||||
<form method="GET" class="row g-2 align-items-center">
|
||||
<!-- Buscador -->
|
||||
<div class="col-md-4">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-body-tertiary"><i class="bi bi-search"></i></span>
|
||||
<input type="text" name="search" class="form-control"
|
||||
placeholder="{% trans %}Buscar por código, nombre o departamento...{% endtrans %}"
|
||||
value="{{ search }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtro Carrera -->
|
||||
<div class="col-md-4">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-body-tertiary"><i class="bi bi-mortarboard text-primary"></i></span>
|
||||
<select name="career_id" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||
<option value="">{% trans %}Todas las carreras{% endtrans %}</option>
|
||||
{% for c in careers %}
|
||||
<option value="{{ c.id }}" {% if career_filter == c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtro Estado -->
|
||||
<div class="col-md-2">
|
||||
<select name="active" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||
<option value="">{% trans %}Todos los estados{% endtrans %}</option>
|
||||
<option value="true" {% if active_filter == 'true' %}selected{% endif %}>{% trans %}Solo activas{% endtrans %}</option>
|
||||
<option value="false" {% if active_filter == 'false' %}selected{% endif %}>{% trans %}Solo inactivas{% endtrans %}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 text-md-end">
|
||||
<button type="submit" class="btn btn-sm btn-primary me-1">
|
||||
<i class="bi bi-funnel me-1"></i>{% trans %}Filtrar{% endtrans %}
|
||||
</button>
|
||||
<a href="{{ url_for('admin.subjects_list') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-x-circle me-1"></i>{% trans %}Limpiar{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subjects Table -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>{% trans %}Código{% endtrans %}</th>
|
||||
<th>{% trans %}Nombre de la Asignatura{% endtrans %}</th>
|
||||
<th>{% trans %}Carrera{% endtrans %}</th>
|
||||
<th>{% trans %}Departamento{% endtrans %}</th>
|
||||
<th>{% trans %}Créditos{% endtrans %}</th>
|
||||
<th>{% trans %}Comisiones{% endtrans %}</th>
|
||||
<th>{% trans %}Estado{% endtrans %}</th>
|
||||
{% if can_edit %}
|
||||
<th class="text-end">{% trans %}Acciones{% endtrans %}</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in subjects %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="badge font-monospace bg-primary-subtle text-primary border">{{ s.code }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="fw-bold text-body">{{ s.name }}</span>
|
||||
{% if s.description %}
|
||||
<small class="text-muted d-block text-truncate" style="max-width: 300px;">{{ s.description }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if s.career_obj %}
|
||||
<span class="badge bg-secondary-subtle text-secondary border">
|
||||
<i class="bi bi-mortarboard me-1"></i>{{ s.career_obj.name }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted small">General</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="small text-muted">{{ s.department or '-' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark border">{{ s.credits }} cr.</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info-subtle text-info border">
|
||||
<i class="bi bi-diagram-3 me-1"></i>{{ s.commissions|length if s.commissions else 0 }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge rounded-pill {% if s.is_active %}bg-success-subtle text-success border border-success-subtle{% else %}bg-secondary-subtle text-secondary border{% endif %}">
|
||||
{% if s.is_active %}{% trans %}Activa{% endtrans %}{% else %}{% trans %}Inactiva{% endtrans %}{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
{% if can_edit %}
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-outline-secondary py-0 px-2"
|
||||
data-bs-toggle="modal" data-bs-target="#modalEditSubject{{ s.id }}"
|
||||
title="{% trans %}Editar Asignatura{% endtrans %}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<form method="POST" action="{{ url_for('admin.subject_toggle', id=s.id) }}" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-warning py-0 px-2"
|
||||
title="{% if s.is_active %}{% trans %}Desactivar{% endtrans %}{% else %}{% trans %}Activar{% endtrans %}{% endif %}">
|
||||
<i class="bi {% if s.is_active %}bi-pause{% else %}bi-play{% endif %}"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Modal Editar Asignatura -->
|
||||
<div class="modal fade text-start" id="modalEditSubject{{ s.id }}" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="{{ url_for('admin.subject_edit', id=s.id) }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title h6 fw-bold">
|
||||
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Editar Asignatura{% endtrans %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small fw-semibold">{% trans %}Código{% endtrans %} *</label>
|
||||
<input type="text" name="code" class="form-control form-control-sm font-monospace" value="{{ s.code }}" required>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<label class="form-label small fw-semibold">{% trans %}Créditos{% endtrans %}</label>
|
||||
<input type="number" name="credits" class="form-control form-control-sm" value="{{ s.credits }}" min="1" max="20">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Nombre de la Asignatura{% endtrans %} *</label>
|
||||
<input type="text" name="name" class="form-control form-control-sm" value="{{ s.name }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Carrera Vinculada{% endtrans %}</label>
|
||||
<select name="career_id" class="form-select form-select-sm">
|
||||
<option value="">{% trans %}Sin carrera específica (General){% endtrans %}</option>
|
||||
{% for c in careers %}
|
||||
<option value="{{ c.id }}" {% if s.career_id == c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Departamento / Área{% endtrans %}</label>
|
||||
<input type="text" name="department" class="form-control form-control-sm" value="{{ s.department or '' }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Descripción / Objetivos{% endtrans %}</label>
|
||||
<textarea name="description" class="form-control form-control-sm" rows="2">{{ s.description or '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="is_active" id="activeSubj{{ s.id }}" {% if s.is_active %}checked{% endif %}>
|
||||
<label class="form-check-label small" for="activeSubj{{ s.id }}">{% trans %}Asignatura Activa{% endtrans %}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Guardar Cambios{% endtrans %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-book fs-1 d-block mb-2"></i>
|
||||
{% trans %}No se encontraron asignaturas con los criterios indicados.{% endtrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Paginación -->
|
||||
{% if pagination.pages > 1 %}
|
||||
<div class="card-footer bg-body-tertiary border-0 py-2">
|
||||
<nav aria-label="Subjects pagination">
|
||||
<ul class="pagination pagination-sm justify-content-center mb-0">
|
||||
{% if pagination.has_prev %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.subjects_list', page=pagination.prev_num, search=search, career_id=career_filter, active=active_filter) }}">{% trans %}Anterior{% endtrans %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for p in pagination.iter_pages(left_edge=2, right_edge=2, 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.subjects_list', page=p, search=search, career_id=career_filter, active=active_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.subjects_list', page=pagination.next_num, search=search, career_id=career_filter, active=active_filter) }}">{% trans %}Siguiente{% endtrans %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Nueva Asignatura -->
|
||||
{% if can_edit %}
|
||||
<div class="modal fade" id="modalAddSubject" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="{{ url_for('admin.subject_add') }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title h6 fw-bold">
|
||||
<i class="bi bi-book text-primary me-2"></i>{% trans %}Nueva Asignatura{% endtrans %}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small fw-semibold">{% trans %}Código{% endtrans %} *</label>
|
||||
<input type="text" name="code" class="form-control form-control-sm font-monospace" placeholder="ej. ASIG0015" required>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<label class="form-label small fw-semibold">{% trans %}Créditos{% endtrans %}</label>
|
||||
<input type="number" name="credits" class="form-control form-control-sm" value="4" min="1" max="20">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Nombre de la Asignatura{% endtrans %} *</label>
|
||||
<input type="text" name="name" class="form-control form-control-sm" placeholder="ej. Álgebra Lineal" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Carrera Vinculada{% endtrans %}</label>
|
||||
<select name="career_id" class="form-select form-select-sm">
|
||||
<option value="">{% trans %}Sin carrera específica (General){% endtrans %}</option>
|
||||
{% for c in careers %}
|
||||
<option value="{{ c.id }}">{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Departamento / Área{% endtrans %}</label>
|
||||
<input type="text" name="department" class="form-control form-control-sm" placeholder="ej. Ciencias Exactas, Informática">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">{% trans %}Descripción{% endtrans %}</label>
|
||||
<textarea name="description" class="form-control form-control-sm" rows="2" placeholder="Programa de la materia..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
|
||||
<button type="submit" class="btn btn-sm btn-primary">{% trans %}Crear Asignatura{% endtrans %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||