2 Commits
147 changed files with 16479 additions and 1682 deletions
+24
View File
@@ -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=app.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
+6
View File
@@ -0,0 +1,6 @@
{
"python.defaultInterpreterPath": "${workspaceFolder}\\venv\\Scripts\\python.exe",
"python.analysis.extraPaths": [
"${workspaceFolder}\\venv\\Lib\\site-packages"
]
}
+253
View File
@@ -0,0 +1,253 @@
# Resumen de Cambios - Sesión de Desarrollo (Rama `dev`)
**Fecha:** 2026-09-02 / 2026-09-03
**Proyecto:** Admin Edu-Space (Flask + PostgreSQL)
**Rama:** `dev`
---
## 1. Auditoría Git y Puesta en Producción
* **Informe Técnico Completo (`DEPLOYMENT_AUDIT.md`):**
* Análisis de ramas locales y remotas (`main`, `dev`, `origin/planning`, `origin/commissions`, `origin/spanish`).
* Manual de despliegue en producción con arquitectura WSGI (Gunicorn), Nginx como proxy inverso, Systemd unit y contenedorización Docker / Docker Compose.
* **Archivos Base:**
* Creación de `.env.example` con todas las variables requeridas (PostgreSQL, Flask, Secret Key, Babel).
* Creación y posterior actualización de `init_db.py` para bootstrap inicial seguro de base de datos, roles RBAC y usuario administrador por defecto (`admin@edu-space.com` / `admin123`).
---
## 2. Base de Datos y Modelos
* **Preferencia de Idioma Persistente:**
* Migración en PostgreSQL agregando la columna `preferred_language VARCHAR(10)` a la tabla `users`.
* Actualización del modelo `User` en `app/models/user.py` con el campo `preferred_language`.
* **Preferencia de Tema Visual Persistente:**
* Migración agregando la columna `theme_preference VARCHAR(10) DEFAULT 'auto' NOT NULL` a la tabla `users`.
* Soporte en el modelo `User` para valores `'auto'`, `'light'` y `'dark'`.
* **Control de Acceso Basado en Roles (RBAC):**
* Migración creando las tablas `roles` y `permissions`, y agregando la clave foránea `role_id` a la tabla `users`.
* Modelos `Role` y `Permission` en `app/models/role.py`.
* Métodos `has_permission(module, min_level)` y `can_manage()` en `User`.
---
## 3. Implementación de Vistas y Plantillas
* **Perfil de Usuario (`app/templates/auth/profile.html`):**
* Vista completa con datos del usuario, rol, estado, fecha de registro y reservas recientes.
* Formulario integral para configurar **Idioma de la interfaz** (*Detectar automáticamente del navegador*, *Español*, *English*) y **Tema visual** (*Automático*, *Modo Claro*, *Modo Oscuro*).
* **Cambio de Contraseña (`app/templates/auth/change_password.html`):**
* Formulario seguro para actualización de credenciales.
* **Manejo de Errores (`app/templates/auth/403.html`):**
* Pantalla de error 403 Forbidden estilizada e integrada con el sistema de diseño.
---
## 4. Correcciones de UX, Linter y Bugs de Rutas
* **Navbar Limpio (`app/templates/base.html`):**
* Eliminación del bloque duplicado del menú de usuario `System Administrator`.
* **Corrección de BuildError en Comisiones:**
* En `app/routes/classrooms.py`, se agregó el alias de ruta `@classrooms_bp.route('/', endpoint='index')` para resolver enlaces a `classrooms.index`.
* En `app/templates/schedule/commissions.html`, se reemplazó el filtro inválido de Django `truncatewords(20)` por el filtro estándar de Jinja2 `truncate(100)`.
* **Corrección Visual en `/genetic-optimizer` (`app/templates/genetic_optimizer.html`):**
* Se agregó el espaciado `pt-5 mt-4` evitando que la barra fija superior se monte sobre el contenido.
* Rediseño de tarjeta hero con contraste óptimo y reemplazo de iconos faltantes por Bootstrap Icons (`bi bi-...`).
* **Corrección de Errores del Linter en `schedule/add.html` y `schedule/view.html`:**
* En `add.html`: Desacople de iteraciones Jinja a `<script type="application/json" id="classroom-data">` y parseo con `JSON.parse()`, eliminando 19 errores sintácticos del editor.
* En `view.html`: Formateo de la propiedad inline `style` en barras de progreso para cumplir con el validador CSS.
---
## 5. Rediseño UI/UX, Assets Institucionales y Modo Claro/Oscuro
* **Exploración e Integración de Assets Gráficos:**
* Sincronización desde `app/resources` hacia `app/static/img/`:
* `logo_unicaba.png`: Logo institucional principal para el login.
* `logo_unicaba_mini.png`: Isotipo oficial para el navbar.
* `favicon.png`: Favicon vinculado en las plantillas base.
* `icons/`: Paquete de resoluciones WebApp/PWA.
* **Sistema de Diseño y Tokens de Color (`app/static/css/theme.css`):**
* Paleta institucional basada en Magenta/Uva (`#B43E8E`), acentos violetas (`#682CAD` / `#AE7FFF`) y secundarios magentas.
* Tokens CSS para `:root` (Modo Claro) y `[data-bs-theme="dark"]` (Modo Oscuro).
* Clases especializadas: `.auth-card`, `.navbar-app`, `.user-avatar-small`, `.theme-toggle-btn`, `.badge-brand`.
* **Modo Claro / Modo Oscuro Dinámico (`app/static/js/theme-toggle.js`):**
* Script anti-flickering en `<head>` para evitar parpadeos visuales al cargar la página.
* Botón toggle interactivo con persistencia en `localStorage`.
* En Modo Claro: Isotipo del navbar con filtro blanco puro (`brightness(0) invert(1)`) sobre el fondo magenta.
* Selector de idioma (`#languageDropdown`) con fondo y bordes de alto contraste y tilde de verificación blanca (`bi-check-lg`) visible **únicamente** en el idioma activo sobre gradiente magenta.
---
## 6. Internacionalización (i18n), Localización y Formateo de Fechas
* **Detección Inteligente de Subtags Regionales (`get_locale` en `app/__init__.py`):**
* Extracción del subtag primario (`es` a partir de `es-419`, `es-AR`, `es-ES`), resolviendo el fallo donde Werkzeug saltaba erróneamente a inglés.
* Configuración institucional: `BABEL_DEFAULT_LOCALE = 'es'` y `LANGUAGES = ['es', 'en']`.
* **Reglas de Idioma para la Pantalla de Login:**
* Resuelve según el navegador del visitante o recurre a español por defecto.
* Inmunidad contra cookies o sesiones residuales de otros usuarios.
* Flash messages traducidos (`¡Bienvenido de nuevo!`, `Has cerrado sesión correctamente`).
* **Formateador de Fechas Localizadas en Jinja2:**
* Registro de `format_date` y `format_datetime` de Flask-Babel.
* Fechas en español natural en `schedule/today.html` (`Jueves, 3 de septiembre de 2026`), `view.html` y `list.html`.
* **Traducción Integral de `schedule/add.html`:**
* Formulario y textos de ayuda 100% traducidos.
* Caja dinámica de resumen (JavaScript e `i18n-strings`) con cálculo de duración y validación de sobrecupo en español.
---
## 7. Módulo de Gestión de Accesos (RBAC) con Matriz FortiGate
* **Modelos y Persistencia:**
* Módulo `app/models/role.py` con `Role` y `Permission`.
* Roles iniciales sembrados: `Admin` (total, protegido), `Docente` (reservas y horarios), `Operador` (aulas y reservas), `Consulta` (solo lectura).
* Vinculación del usuario inicial `admin@edu-space.com` al rol `Admin`.
* **Matriz de Permisos Estilo FortiGate (`/admin/roles/edit/<id>`):**
* Tabla interactiva con columnas **Access Control** y **Permissions**.
* Segmented control por módulo con tres estados: `🚫 None`, `👁️ Read` y `✏️ Read/Write`.
* Color activo verde esmeralda FortiGate (`#2D9F6F`) con texto e icono en blanco puro.
* Menú desplegable masivo **Set All ▾** (*Set All to None*, *Set All to Read*, *Set All to Read/Write*).
* **Módulo de Administración (`app/routes/admin.py`):**
* `/admin/roles`: Listado de roles, conteo de miembros, visualización de permisos en badges (RW, R, -) y modal de borrado seguro.
* `/admin/users`: Listado de cuentas con filtros por nombre, rol y estado (Activo/Inactivo), paginación y switch rápido para alternar accesos.
* `/admin/users/add` y `/admin/users/edit/<id>`: Formularios de alta y edición con asignación de rol y contraseña opcional.
* **Seguridad y Decoradores (`app/utils/decorators.py`):**
* `@permission_required(module, min_level)` y `@admin_required`.
* Pestaña **Management** en el navbar visible exclusivamente para administradores.
* Botones de acción sensibles en el navbar (*Add Classroom*, *New Reservation*) condicionados a permisos de escritura.
---
## 8. Sincronización y Actualización de `init_db.py`
* Migraciones DDL automáticas (`ALTER TABLE ... ADD COLUMN IF NOT EXISTS`) para `preferred_language`, `theme_preference`, `role_id`, y para la tabla `careers` y columna `career_id` en `subjects`.
* Creación y siembra de roles institucionales y permisos en PostgreSQL.
* Configuración de salida con codificación UTF-8 segura en Windows para evitar excepciones de `cp1252`.
---
## 9. Sincronización e Importación de Datos Académicos (Google Sheets)
* **Modelo `Career` (`app/models/career.py`):**
* Entidad de carreras universitarias vinculada con `Subject` vía relación uno a muchos.
* Migración en PostgreSQL de la tabla `careers` y campo `career_id` en `subjects`.
* **Servicio de Extracción ([app/services/sheets_importer.py](file:///c:/Workspace/admin-edu-space/app/services/sheets_importer.py)):**
* Integración con la cartelera oficial UniCABA 2026 a través de 6 hojas CSV (`Lunes` a `Sábado`).
* Normalización inteligente de espacios áulicos físicos (`Aula 402`, `Aula 302`), espacios virtuales (`VIRTUAL`), auditorios y salas de reunión.
* Extracción y parseo de franjas horarias (`8:00 a 9:00hs`, `17:30 a 21:30hs`).
* Rutina de **upsert** transaccional e idempotente que previene la duplicación de datos.
* Generación de reservas/clases en el calendario para poblar instantáneamente las vistas de horario diario y semanal.
* **Interfaz de Gestión (`/admin/import`):**
* Vista interactiva con métricas en tiempo real (total de carreras, asignaturas, aulas, comisiones y clases).
* Botón de ejecución manual de sincronización con indicador visual de progreso (`spinner`).
* Reporte detallado de resultados y tabla con vista previa de asignaturas y comisiones sincronizadas.
* **Acceso Rápido en Navbar (`app/templates/base.html`):**
* Nueva opción **"Import from Google Sheets"** incorporada en el menú desplegable **Management**, restringida a roles con permisos administrativos.
---
## 10. Correcciones en la Vista de Calendario (FullCalendar) y Códigos de Asignatura
* **Carga de Eventos y Corrección de Error 500 (`/schedule/calendar-data`):**
* Se identificó y corrigió el error `AttributeError: 'str' object has no attribute 'value'` provocado por llamadas a `.status.value` sobre campos de estado textuales.
* Se implementó el TypeDecorator `StatusType` con la clase `StatusString` en `app/models/reservation.py` garantizando compatibilidad total tanto para `.status` como para `.status.value`.
* Sanitización de fechas ISO 8601 con zona horaria (`Z`, `-03:00`) enviadas por FullCalendar para comparación adecuada con campos `TIMESTAMP WITHOUT TIME ZONE` en PostgreSQL.
* Anclaje de fecha inicial con `initialDate: '{{ today_date }}'` para posicionar el calendario en la semana activa de cursada 2026.
* **Corrección del Código de Asignatura (`subject_code`):**
* Se reemplazó la concatenación de código extendido de comisión (`Commission.get_full_code()` que arrojaba cadenas distorsionadas como `INGA0003-C1-2026-22026`) por el **código de asignatura real** del Sheet (`Subject.code`, ej. `INGA0003`, `ASIG00165`).
* Propiedades de acceso directo añadidas en `Reservation`: `subject_code`, `subject_name` y `classroom_name`.
* **Jerarquía Visual y Renderizado de Tarjetas (`calendar.html`):**
* Implementación del hook `eventContent` en FullCalendar 5 para construir tarjetas personalizadas.
* **Nombre de la Asignatura** destacado como elemento principal con tipografía grande y en negrita (`font-weight: 700; font-size: 0.88rem`).
* **Código de Asignatura** y **Aula** mostrados como datos secundarios en badges de alto contraste (código con fondo oscuro translúcido y aula con isotipo y fondo blanco translúcido).
* Modal interactivo instantáneo al hacer clic en cualquier evento con todos los detalles académicos de la clase y enlace a la ficha completa.
---
## 11. Filtros por Aula y Materia en el Calendario de Reservas
* **Barra de Herramientas de Filtros Interactiva (`calendar.html`):**
* **Filtrar por Aula:** Desplegable dinámico con todas las aulas activas ordenadas por edificio y código (físicas y virtuales). Al seleccionar un aula (ej. `Aula 402`), se eliminan todas las superposiciones visuales mostrando el cronograma limpio de ese espacio.
* **Filtrar por Materia:** Desplegable ordenado alfabéticamente con formato `[Código] Nombre de la Asignatura`. Permite aislar todas las clases de una asignatura en particular.
* **Filtrar por Carrera:** Filtro adicional por plan académico/carrera.
* **Botón Limpiar Filtros:** Restablece instantáneamente todos los controles y refresca la vista.
* **Insignias de Estado:** Indicador en tiempo real de los filtros aplicados y contador de clases visibles (`X clases visibles`).
* **Soporte en Backend (`app/routes/schedule.py`):**
* `calendar_view`: Suministra las listas de `classrooms`, `subjects` y `careers` a la plantilla.
* `calendar_data`: Procesa `classroom_id`, `subject_id`, `career_id` y `search` aplicando cláusulas `filter()` con `JOIN` en SQLAlchemy.
* `refetchEvents()` de FullCalendar conectado a los eventos `change` de cada selector para recarga inmediata vía AJAX sin parpadeo de pantalla.
---
## 12. Módulo de Gestión de Edificios y Corrección de Vistas de Aulas
* **Corrección de Plantillas de Aulas Faltantes:**
* Se crearon las plantillas [app/templates/classrooms/edit.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/edit.html) y [app/templates/classrooms/view.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/view.html), solucionando el error HTTP 500 (`TemplateNotFound: classrooms/edit.html`) y la redirección 302 que ocurría al hacer clic en cualquier aula.
* La vista de ficha técnica del aula ahora muestra detalles de capacidad, piso, estado, edificio y la tabla interactiva de próximas reservas.
* **Modelo y Base de Datos de Edificios (`Building`):**
* Se creó el modelo [app/models/building.py](file:///c:/Workspace/admin-edu-space/app/models/building.py) (`id`, `name`, `code`, `address`, `floors`, `description`, `is_active`, timestamps y relación con `Classroom`).
* Migración DDL en PostgreSQL (`CREATE TABLE buildings`, `ALTER TABLE classrooms ADD COLUMN building_id`).
* Actualización de [init_db.py](file:///c:/Workspace/admin-edu-space/init_db.py) y siembra de edificios institucionales iniciales (`Edificio Central` y `Campus Virtual`) con vinculación de las 25 aulas existentes.
* **Rutas y Gestión Completa (`app/routes/buildings.py`):**
* `/buildings`: Listado con métricas de infraestructura (total de edificios, aulas activas, capacidad total y plantas/pisos), barra de búsqueda y filtros por estado.
* `/buildings/<id>`: Vista de detalle con distribución de aulas organizadas visualmente por piso (Planta Baja, Piso 1, Piso 2, etc.), capacidad por sala y acciones directas.
* `/buildings/add` y `/buildings/<id>/edit`: Formularios con validación WTForms para alta y actualización.
* `/buildings/<id>/toggle` y `/buildings/<id>/delete`: Control de activación y eliminación protegida (impide borrar edificios que contengan aulas asignadas).
* **Navegación (`app/templates/base.html`):**
* Enlace a **Edificios** (`/buildings`) incorporado en el menú **Classrooms** y en el menú administrativo **Management**.
---
## 13. Mapeo de Campos del Google Sheet y Concepto de Turno (Mañana / Tarde)
* **Uso de los 5 Campos de la Cartelera Oficial:**
* **Codigo (`Subject.code` y `Commission.code`):** Almacena el código académico real (`INGA0003`, `ASIG00165`). Se expone como badge monoespaciado en eventos de calendario, comisiones y vistas administrativas.
* **Asignatura (`Subject.name`):** Título principal de la materia destacado en texto grande y en negrita en FullCalendar y en la cartelera diaria.
* **Aula (`Classroom.code`, `Classroom.building`, `Classroom.floor`, `Classroom.capacity`):** Normalización de espacios físicos y virtuales vinculados al nuevo modelo `Building`.
* **Carrera (`Career.name` y `Subject.career_id`):** Entidad de carrera universitaria con relaciones relacionales normalizadas.
* **Horarios (`Commission.schedule`, `Reservation.start_time`, `Reservation.end_time`):** Rango horario parseado hacia objetos `datetime` nativos para el motor de reservas y el calendario.
* **Modelado y Captura de Turnos (`TURNO MAÑANA` y `TURNO VESPERTINO`):**
* Se agregó la columna `shift` en las tablas `commissions` y `reservations` de PostgreSQL y en sus respectivos modelos de SQLAlchemy.
* El parser de [app/services/sheets_importer.py](file:///c:/Workspace/admin-edu-space/app/services/sheets_importer.py) identifica las filas delimitadoras de bloque (`TURNO MAÑANA` vs `TURNO VESPERTINO / TARDE`) y corrobora la hora de inicio (< 13:00hs = Mañana, $\ge$ 14:00hs = Tarde).
* Se sincronizaron todas las comisiones y reservas de cursada: 15 comisiones y 48 reservas en `Turno Mañana`; 191 comisiones y 364 reservas en `Turno Tarde / Vespertino`.
* En el Calendario de Reservas (`/schedule/calendar`) se añadió el selector **Filtrar por Turno** (`Todos`, `Mañana`, `Tarde / Vespertino`), permitiendo a los usuarios aislar las franjas horarias y resolver la alta concentración de clases en el horario vespertino (17:30 a 21:30hs).
---
## 14. Accesos Directos en Panel, Ocultamiento de Virtuales, Filtro por Piso, Dirección Oficial y Traducción
* **Tarjetas Estadísticas como Accesos Directos Interactivos ([app/templates/dashboard.html](file:///c:/Workspace/admin-edu-space/app/templates/dashboard.html)):**
* **Total de Aulas (25):** Redirige a `/classrooms/list` con feedback visual de hover y elevación.
* **Total de Materias (206):** Redirige a `/schedule/commissions`.
* **Reservas de Hoy (18):** Redirige a `/schedule/today`.
* **Esta Semana (103):** Redirige a `/schedule/calendar`.
* Se añadieron microinteracciones CSS en [app/static/css/style.css](file:///c:/Workspace/admin-edu-space/app/static/css/style.css) (`.dashboard-stat-card`).
* **Ocultar Aulas Virtuales y Filtro por Piso:**
* **Calendario ([app/templates/schedule/calendar.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/calendar.html)):** Switch interactivo *Ocultar Aulas Virtuales* (indica capacidad ilimitada) y selector *Filtrar por Piso* (`Planta Baja`, `Piso 1`, `Piso 2`, `Piso 3`, `Piso 4`). El backend `/schedule/calendar-data` excluye las aulas virtuales o filtra por número de piso.
* **Cartelera del Día ([app/templates/schedule/today.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/today.html)):** Barra de filtros completa con soporte para ocultar virtuales, piso y turno.
* **Listado de Reservas ([app/templates/schedule/list.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/list.html)):** Endpoint `/schedule/list` totalmente operativo con paginación de 20 registros, preservación de query params y filtros de piso, turno, virtuales, estado y rango de fechas.
* **Dirección Oficial de la Sede Central:**
* Se configuró la dirección oficial de la Universidad de la Ciudad de Buenos Aires (UniCABA): **`Tte. Gral. Juan Domingo Perón 802, CABA`** en la base de datos, en [init_db.py](file:///c:/Workspace/admin-edu-space/init_db.py) y en el importador [app/services/sheets_importer.py](file:///c:/Workspace/admin-edu-space/app/services/sheets_importer.py).
* **Traducción Integral al Español y Catálogo Babel:**
* Se agregaron traducciones en [translations/es/LC_MESSAGES/messages.po](file:///c:/Workspace/admin-edu-space/translations/es/LC_MESSAGES/messages.po) y se recompiló el binario `.mo` con `pybabel`.
* Textos traducidos: `Buildings` $\rightarrow$ *Edificios*, `Management` $\rightarrow$ *Gestión*, `Timeline View` $\rightarrow$ *Vista Cronológica*, `Grid View` $\rightarrow$ *Vista en Tabla*, `Active Classrooms` $\rightarrow$ *Aulas Ocupadas*, `All Reservations` $\rightarrow$ *Todas las Reservas*, `AI Room Optimizer` $\rightarrow$ *Optimizador de Aulas*, etc.
---
## 15. Actualización Integral de Roles RBAC, Capacidad Infinita (∞) y Horarios de Próximas Clases
* **Actualización de Roles, Grupos de Acceso y Matriz de Permisos ([app/models/role.py](file:///c:/Workspace/admin-edu-space/app/models/role.py), [init_db.py](file:///c:/Workspace/admin-edu-space/init_db.py)):**
* Se amplió `SYSTEM_MODULES` para cubrir los 8 módulos del sistema:
1. `classrooms`: **Aulas y Espacios** (físicas, virtuales y capacidades).
2. `buildings`: **Edificios y Sedes** (Sede Central, Campus Virtual, plantas).
3. `reservations`: **Reservas de Aulas** (aprobación, confirmación, cancelación).
4. `schedule`: **Cartelera y Cronograma** (calendario, cartelera del día, comisiones).
5. `academic`: **Gestión Académica** (carreras, asignaturas, códigos).
6. `import`: **Sincronización Sheets** (importador Google Sheets).
7. `optimizer`: **Optimizador IA** (algoritmo genético áulico).
8. `users`: **Usuarios y Accesos RBAC** (cuentas, roles y matriz).
* Se actualizaron los perfiles estándar (`Admin`, `Docente`, `Operador`, `Consulta`) en PostgreSQL y en `init_db.py`.
* Se tradujeron completamente las plantillas [app/templates/admin/roles/list.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/roles/list.html) y [app/templates/admin/roles/edit.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/roles/edit.html) al español.
* **Soporte de Capacidad Infinita (∞) y Aclaración ([app/forms/classroom.py](file:///c:/Workspace/admin-edu-space/app/forms/classroom.py), [app/templates/classrooms/edit.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/edit.html)):**
* Se actualizó `ClassroomForm.capacity` con validador personalizado que admite el símbolo **`∞`**, `inf`, `0` o cualquier capacidad numérica positiva.
* Se corrigió la validación de `floor` con `InputRequired` y `NumberRange(min=0)` para aceptar Planta Baja (Piso 0).
* En la interfaz de edición y alta de aulas se incluyó el botón de acción rápida **`Ilimitada (∞)`** que rellena automáticamente el campo con el símbolo `∞`.
* Se añadió una leyenda explicativa directa: *"Para aulas virtuales o eventos sin aforo restringido, ingrese ∞ o 0 (Capacidad ilimitada)"*.
* Se tradujo el botón de guardado a *"Guardar Cambios"* (reemplazando *"Save Changes"*).
* El aula virtual institucional (`Campus Virtual - VIRTUAL`) se actualizó a capacidad `0` (`∞`), renderizando el badge `∞ Ilimitada` en listados y fichas.
* **Próximas Clases: Visualización Exclusiva del Horario ([app/templates/dashboard.html](file:///c:/Workspace/admin-edu-space/app/templates/dashboard.html), [app/templates/classrooms/view.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/view.html)):**
* En la tabla de *Próximas Reservas* del panel y en la ficha del aula se cambió la columna a **"Horario"**, removiendo la fecha del calendario y destacando únicamente el rango horario de cursada (ej. `17:30 - 21:30 hs`).
+443
View File
@@ -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
View File
@@ -3,4 +3,5 @@ from app import create_app
app = create_app() app = create_app()
if __name__ == '__main__': if __name__ == '__main__':
# Server entrypoint with auto-reload (RBAC 8 modules, ∞ Infinite Capacity & Clean Class Hours active)
app.run(debug=True, host='0.0.0.0', port=5000) app.run(debug=True, host='0.0.0.0', port=5000)
+61 -19
View File
@@ -30,41 +30,83 @@ def create_app(config_class=Config):
# Import models to ensure they are registered # Import models to ensure they are registered
from app.models import user from app.models import user
# Register blueprints
# Register blueprints from app.routes import auth_bp, classrooms_bp, buildings_bp, main_bp, schedule_bp, genetic_bp, genetic_web_bp, admin_bp
from app.routes import auth_bp, classrooms_bp, main_bp, schedule_bp, genetic_bp, genetic_web_bp
app.register_blueprint(auth_bp, url_prefix="/") app.register_blueprint(auth_bp, url_prefix="/")
app.register_blueprint(classrooms_bp, url_prefix="/classrooms") app.register_blueprint(classrooms_bp, url_prefix="/classrooms")
app.register_blueprint(buildings_bp)
app.register_blueprint(main_bp, url_prefix="/") app.register_blueprint(main_bp, url_prefix="/")
app.register_blueprint(schedule_bp, url_prefix="/schedule") app.register_blueprint(schedule_bp, url_prefix="/schedule")
app.register_blueprint(genetic_bp) app.register_blueprint(genetic_bp)
app.register_blueprint(genetic_web_bp) app.register_blueprint(genetic_web_bp)
app.register_blueprint(admin_bp)
# Babel language selector # Language selector function
def get_locale():
# 1. Parámetro explícito en la URL (?lang=es o ?lang=en)
lang = request.args.get('lang')
if lang and lang in app.config['LANGUAGES']:
session['language'] = lang
return lang
# 2. Si el usuario está autenticado y tiene idioma configurado en su perfil, prevalece en toda la sesión
from flask_login import current_user
if current_user.is_authenticated and getattr(current_user, 'preferred_language', None):
if current_user.preferred_language in app.config['LANGUAGES']:
return current_user.preferred_language
# 3. En la pantalla de Login (usuario no autenticado):
# El idioma predeterminado del login debe ser español o respetar el idioma del navegador.
# No debe verse afectado por cookies o sesiones residuales de otros usuarios.
is_login = (request.endpoint == 'auth.login' or request.path.rstrip('/') == '/login')
if is_login:
# Solo si el usuario cambió el idioma explícitamente en el selector del login
if session.get('login_lang_explicit') in app.config['LANGUAGES']:
return session['login_lang_explicit']
# Detectar según el navegador del usuario (soporta es-419, es-AR, es-ES, etc.)
if request.accept_languages:
for accepted_lang, _quality in request.accept_languages:
primary = accepted_lang.split('-')[0].lower()
if primary in app.config['LANGUAGES']:
return primary
# Idioma predeterminado por defecto del login: Español
return 'es'
# 4. Para otras rutas: verificar si hay idioma en sesión (ej. selector manual)
if 'language' in session and session['language'] in app.config['LANGUAGES']:
return session['language']
# 5. Detección automática del navegador por defecto (soporta subtags regionales)
if request.accept_languages:
for accepted_lang, _quality in request.accept_languages:
primary = accepted_lang.split('-')[0].lower()
if primary in app.config['LANGUAGES']:
return primary
# Fallback del sistema: Español
return app.config['BABEL_DEFAULT_LOCALE']
# Babel context processor
@app.context_processor @app.context_processor
def inject_conf_vars(): def inject_conf_vars():
return dict( return dict(
languages=app.config['LANGUAGES'], 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 # Configure Babel
babel.init_app(app, locale_selector=get_locale) 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 return app
+5 -4
View File
@@ -1,13 +1,14 @@
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Email, Length from wtforms.validators import DataRequired, Email, Length
from flask_babel import lazy_gettext as _l
class LoginForm(FlaskForm): class LoginForm(FlaskForm):
email = StringField('Email', validators=[ email = StringField(_l('Email'), validators=[
DataRequired(), DataRequired(),
Email(), Email(),
Length(max=120) Length(max=120)
]) ])
password = PasswordField('Password', validators=[DataRequired()]) password = PasswordField(_l('Password'), validators=[DataRequired()])
remember_me = BooleanField('Remember me') remember_me = BooleanField(_l('Remember me'))
submit = SubmitField('Sign In') submit = SubmitField(_l('Sign In'))
+28
View File
@@ -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'))
+40 -26
View File
@@ -1,44 +1,58 @@
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, IntegerField, TextAreaField, BooleanField, SubmitField, SelectField 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): class ClassroomForm(FlaskForm):
# campus_id = SelectField('Campus', coerce=int, validators=[DataRequired()]) building = StringField(_l('Building'), validators=[
building = StringField('Building', validators=[
DataRequired(), DataRequired(),
Length(min=2, max=100) Length(min=2, max=100)
]) ])
room_number = StringField('Room Number', validators=[ room_number = StringField(_l('Room Number'), validators=[
DataRequired(), DataRequired(),
Length(min=1, max=50) Length(min=1, max=50)
]) ])
floor = IntegerField('Floor', validators=[ floor = IntegerField(_l('Floor'), validators=[
DataRequired(), InputRequired(message=_l('El piso es obligatorio (0 para Planta Baja)')),
NumberRange(min=1, max=50) NumberRange(min=0, max=50, message=_l('El piso debe estar entre 0 (Planta Baja) y 50'))
]) ])
capacity = IntegerField('Capacity', validators=[ capacity = StringField(_l('Capacity'), validators=[
DataRequired(), validate_capacity
NumberRange(min=1, max=500)
]) ])
description = TextAreaField('Description', validators=[ description = TextAreaField(_l('Description'), validators=[
Optional(), Optional(),
Length(max=500) Length(max=500)
]) ])
is_active = BooleanField('Active', default=True) is_active = BooleanField(_l('Active'), default=True)
submit = SubmitField('Save Classroom') submit = SubmitField(_l('Save Classroom'))
class ClassroomResourceForm(FlaskForm): class ClassroomResourceForm(FlaskForm):
resource_type = SelectField('Resource Type', choices=[ resource_type = SelectField(_l('Resource Type'), choices=[
('PROJECTOR', 'Projector'), ('PROJECTOR', _l('Projector')),
('WHITEBOARD', 'Whiteboard'), ('WHITEBOARD', _l('Whiteboard')),
('COMPUTER', 'Computer'), ('COMPUTER', _l('Computer')),
('SOUND_SYSTEM', 'Sound System'), ('SOUND_SYSTEM', _l('Sound System')),
('MICROPHONE', 'Microphone'), ('MICROPHONE', _l('Microphone')),
('AIR_CONDITIONER', 'Air Conditioner'), ('AIR_CONDITIONER', _l('Air Conditioner')),
('OTHER', 'Other') ('OTHER', _l('Other'))
], validators=[DataRequired()]) ], validators=[DataRequired()])
brand = StringField('Brand', validators=[Optional(), Length(max=100)]) brand = StringField(_l('Brand'), validators=[Optional(), Length(max=100)])
model = StringField('Model', validators=[Optional(), Length(max=100)]) model = StringField(_l('Model'), validators=[Optional(), Length(max=100)])
specifications = TextAreaField('Specifications', validators=[Optional()]) specifications = TextAreaField(_l('Specifications'), validators=[Optional()])
functional = BooleanField('Functional', default=True) functional = BooleanField(_l('Functional'), default=True)
submit = SubmitField('Save Resource') submit = SubmitField(_l('Save Resource'))
+9 -8
View File
@@ -1,26 +1,27 @@
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import SelectField, StringField, TextAreaField, IntegerField, SubmitField, DateTimeLocalField from wtforms import SelectField, StringField, TextAreaField, IntegerField, SubmitField, DateTimeLocalField
from wtforms.validators import DataRequired, Length, NumberRange, Optional, ValidationError from wtforms.validators import DataRequired, Length, NumberRange, Optional, ValidationError
from flask_babel import lazy_gettext as _l
from datetime import datetime, timedelta from datetime import datetime, timedelta
class ReservationForm(FlaskForm): class ReservationForm(FlaskForm):
classroom_id = SelectField('Classroom', coerce=int, validators=[DataRequired()]) classroom_id = SelectField(_l('Classroom'), coerce=int, validators=[DataRequired()])
commission_id = SelectField('Commission/Subject', coerce=int, validators=[DataRequired()]) commission_id = SelectField(_l('Commission/Subject'), coerce=int, validators=[DataRequired()])
start_time = DateTimeLocalField('Start Time', format='%Y-%m-%dT%H:%M', validators=[DataRequired()]) start_time = DateTimeLocalField(_l('Start Time'), format='%Y-%m-%dT%H:%M', validators=[DataRequired()])
end_time = DateTimeLocalField('End 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('Purpose', validators=[ purpose = StringField(_l('Purpose'), validators=[
DataRequired(), DataRequired(),
Length(min=3, max=500) Length(min=3, max=500)
]) ])
expected_attendees = IntegerField('Expected Attendees', validators=[ expected_attendees = IntegerField(_l('Expected Attendees'), validators=[
DataRequired(), DataRequired(),
NumberRange(min=1, max=500) NumberRange(min=1, max=500)
]) ])
notes = TextAreaField('Notes', validators=[ notes = TextAreaField(_l('Notes'), validators=[
Optional(), Optional(),
Length(max=1000) Length(max=1000)
]) ])
submit = SubmitField('Make Reservation') submit = SubmitField(_l('Make Reservation'))
def validate_end_time(self, field): def validate_end_time(self, field):
if field.data <= self.start_time.data: if field.data <= self.start_time.data:
+9 -1
View File
@@ -1,17 +1,25 @@
from .user import User from .user import User
from .building import Building
from .classroom import Classroom, ClassroomResource from .classroom import Classroom, ClassroomResource
from .career import Career
from .subject import Subject, Commission from .subject import Subject, Commission
from .reservation import Reservation, ReservationStatus from .reservation import Reservation, ReservationStatus
from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer
from .role import Role, Permission, SYSTEM_MODULES
__all__ = [ __all__ = [
'User', 'User',
'Building',
'Classroom', 'Classroom',
'ClassroomResource', 'ClassroomResource',
'Career',
'Subject', 'Subject',
'Commission', 'Commission',
'Reservation', 'Reservation',
'ReservationStatus', 'ReservationStatus',
'GeneticAlgorithm', 'GeneticAlgorithm',
'ReservationOptimizer' 'ReservationOptimizer',
'Role',
'Permission',
'SYSTEM_MODULES'
] ]
+45
View File
@@ -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}>'
+27
View File
@@ -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}>'
+12
View File
@@ -6,6 +6,7 @@ class Classroom(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
campus_id = db.Column(db.Integer, nullable=True) # Maps to campus_id in DB 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) 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 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.Integer, nullable=False)
@@ -40,6 +41,17 @@ class Classroom(db.Model):
def code_display(self): def code_display(self):
return f"{self.building}-{self.code}" return f"{self.building}-{self.code}"
@property
def capacity_display(self):
"""Retorna '∞' para aulas virtuales o capacidad ilimitada, o el número entero."""
if self.capacity == 0 or (self.code and 'VIRTUAL' in self.code.upper()):
return '∞'
return str(self.capacity)
@property
def is_unlimited(self):
return self.capacity == 0 or (self.code and 'VIRTUAL' in self.code.upper())
def to_dict(self): def to_dict(self):
"""Convert classroom to dictionary""" """Convert classroom to dictionary"""
return { return {
+48 -2
View File
@@ -1,6 +1,30 @@
from app import db from app import db
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import Enum 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): class ReservationStatus(Enum):
PENDING = 'PENDING' PENDING = 'PENDING'
@@ -20,8 +44,9 @@ class Reservation(db.Model):
start_time = db.Column(db.DateTime, nullable=False) start_time = db.Column(db.DateTime, nullable=False)
end_time = db.Column(db.DateTime, nullable=False) end_time = db.Column(db.DateTime, nullable=False)
purpose = db.Column(db.String(500), 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) expected_attendees = db.Column(db.Integer, nullable=False)
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
notes = db.Column(db.Text) notes = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
@@ -32,10 +57,31 @@ class Reservation(db.Model):
def status_enum(self): def status_enum(self):
"""Get status as enum""" """Get status as enum"""
try: try:
return ReservationStatus(self.status) return ReservationStatus(str(self.status))
except (ValueError, AttributeError): except (ValueError, AttributeError):
return ReservationStatus.PENDING 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 ''
# Relationships # Relationships
classroom = db.relationship('Classroom', backref=db.backref('reservation_list', lazy=True, cascade='all, delete-orphan')) classroom = db.relationship('Classroom', backref=db.backref('reservation_list', lazy=True, cascade='all, delete-orphan'))
commission = db.relationship('Commission', backref=db.backref('reservation_list', lazy=True, cascade='all, delete-orphan')) commission = db.relationship('Commission', backref=db.backref('reservation_list', lazy=True, cascade='all, delete-orphan'))
+142
View File
@@ -0,0 +1,142 @@
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, 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.'
}
]
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}>'
+7 -5
View File
@@ -5,12 +5,12 @@ class Subject(db.Model):
__tablename__ = 'subjects' __tablename__ = 'subjects'
id = db.Column(db.Integer, primary_key=True) 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) name = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text) description = db.Column(db.Text)
department = db.Column(db.String(100), nullable=False) department = db.Column(db.String(100), nullable=False, default='')
credits = db.Column(db.Integer, nullable=False) credits = db.Column(db.Integer, nullable=False, default=4)
# duration_in_minutes = db.Column(db.Integer, nullable=False) career_id = db.Column(db.Integer, db.ForeignKey('careers.id', ondelete='SET NULL'), nullable=True)
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB is_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) created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=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, 'description': self.description,
'department': self.department, 'department': self.department,
'credits': self.credits, '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, 'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None, 'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None 'updated_at': self.updated_at.isoformat() if self.updated_at else None
@@ -48,6 +49,7 @@ class Commission(db.Model):
max_students = db.Column(db.Integer, nullable=False) max_students = db.Column(db.Integer, nullable=False)
current_students = db.Column(db.Integer, default=0) current_students = db.Column(db.Integer, default=0)
schedule = db.Column(db.String(255)) schedule = db.Column(db.String(255))
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
active = db.Column(db.Boolean, default=True) # Changed from is_active to active active = db.Column(db.Boolean, default=True) # Changed from is_active to active
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+22 -2
View File
@@ -14,6 +14,9 @@ class User(UserMixin, db.Model):
is_active = db.Column(db.Boolean, default=True, nullable=False) is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
last_login = db.Column(db.DateTime) 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 # Virtual fields for future compatibility
@property @property
@@ -38,7 +41,22 @@ class User(UserMixin, db.Model):
def is_admin(self): def is_admin(self):
"""Check if user is admin""" """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 has_permission(self, module, min_level='read'):
"""Check if user has granular permission on a given module"""
if self.is_admin():
return True
if self.role_obj:
return self.role_obj.has_permission(module, min_level)
return False
def can_manage(self):
"""Check if user can access the Management / Admin section"""
return self.is_admin() or self.has_permission('users', 'read')
def update_last_login(self): def update_last_login(self):
"""Update last login timestamp""" """Update last login timestamp"""
@@ -54,7 +72,9 @@ class User(UserMixin, db.Model):
'role': self.role, 'role': self.role,
'is_active': self.is_active, 'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None, '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): def __repr__(self):
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+3 -1
View File
@@ -1,7 +1,9 @@
from .main import main_bp from .main import main_bp
from .auth import auth_bp from .auth import auth_bp
from .classrooms import classrooms_bp from .classrooms import classrooms_bp
from .buildings import buildings_bp
from .schedule import schedule_bp from .schedule import schedule_bp
from .genetic_algorithm import genetic_bp, genetic_web_bp from .genetic_algorithm import genetic_bp, genetic_web_bp
from .admin import admin_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']
+342
View File
@@ -0,0 +1,342 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, abort
from flask_login import login_required, current_user
from flask_babel import gettext as _
from app import db
from app.models.user import User
from app.models.role import Role, Permission, SYSTEM_MODULES, ACCESS_LEVELS
from app.utils.decorators import admin_required, permission_required
from sqlalchemy import or_
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
# ---------------------------------------------------------
# USERS MANAGEMENT
# ---------------------------------------------------------
@admin_bp.route('/users')
@admin_required
def users_list():
"""List system users with filtering and role information"""
search = request.args.get('search', '').strip()
role_filter = request.args.get('role', '').strip()
status_filter = request.args.get('status', '').strip()
page = request.args.get('page', 1, type=int)
per_page = 15
query = User.query
if search:
query = query.filter(
or_(
User.name.ilike(f'%{search}%'),
User.email.ilike(f'%{search}%')
)
)
if role_filter:
if role_filter.isdigit():
query = query.filter(User.role_id == int(role_filter))
else:
query = query.filter(User.role.ilike(role_filter))
if status_filter in ['active', 'inactive']:
query = query.filter(User.is_active == (status_filter == 'active'))
pagination = query.order_by(User.id.asc()).paginate(page=page, per_page=per_page, error_out=False)
roles = Role.query.order_by(Role.name.asc()).all()
return render_template(
'admin/users/list.html',
users=pagination.items,
pagination=pagination,
roles=roles,
search=search,
role_filter=role_filter,
status_filter=status_filter
)
@admin_bp.route('/users/add', methods=['GET', 'POST'])
@admin_required
def user_add():
"""Create a new user and assign a role"""
roles = Role.query.order_by(Role.name.asc()).all()
if request.method == 'POST':
name = request.form.get('name', '').strip()
email = request.form.get('email', '').strip().lower()
password = request.form.get('password', '')
role_id = request.form.get('role_id', type=int)
is_active = request.form.get('is_active') == 'on'
errors = []
if not name:
errors.append(_('El nombre es requerido.'))
if not email:
errors.append(_('El correo electrónico es requerido.'))
elif User.query.filter_by(email=email).first():
errors.append(_('Ya existe una cuenta con este correo electrónico.'))
if not password or len(password) < 6:
errors.append(_('La contraseña debe tener al menos 6 caracteres.'))
role_obj = Role.query.get(role_id) if role_id else None
if not role_obj:
errors.append(_('Debes seleccionar un rol válido.'))
if errors:
for err in errors:
flash(err, 'error')
return render_template('admin/users/form.html', user=None, roles=roles)
user = User(
name=name,
email=email,
role_id=role_obj.id,
role=role_obj.name,
is_active=is_active
)
user.set_password(password)
db.session.add(user)
db.session.commit()
flash(_('Usuario "%(name)s" creado exitosamente.', name=user.name), 'success')
return redirect(url_for('admin.users_list'))
return render_template('admin/users/form.html', user=None, roles=roles)
@admin_bp.route('/users/edit/<int:id>', methods=['GET', 'POST'])
@admin_required
def user_edit(id):
"""Edit user details, role and status"""
user = User.query.get_or_404(id)
roles = Role.query.order_by(Role.name.asc()).all()
if request.method == 'POST':
name = request.form.get('name', '').strip()
email = request.form.get('email', '').strip().lower()
password = request.form.get('password', '')
role_id = request.form.get('role_id', type=int)
is_active = request.form.get('is_active') == 'on'
errors = []
if not name:
errors.append(_('El nombre es requerido.'))
if not email:
errors.append(_('El correo electrónico es requerido.'))
else:
existing = User.query.filter_by(email=email).first()
if existing and existing.id != user.id:
errors.append(_('Ya existe otra cuenta con este correo electrónico.'))
role_obj = Role.query.get(role_id) if role_id else None
if not role_obj:
errors.append(_('Debes seleccionar un rol válido.'))
# Do not allow deactivating the currently logged in admin user
if user.id == current_user.id and not is_active:
errors.append(_('No puedes desactivar tu propia cuenta mientras estás en sesión.'))
if errors:
for err in errors:
flash(err, 'error')
return render_template('admin/users/form.html', user=user, roles=roles)
user.name = name
user.email = email
user.role_id = role_obj.id
user.role = role_obj.name
user.is_active = is_active
if password:
if len(password) < 6:
flash(_('La nueva contraseña debe tener al menos 6 caracteres.'), 'error')
return render_template('admin/users/form.html', user=user, roles=roles)
user.set_password(password)
db.session.commit()
flash(_('Usuario "%(name)s" actualizado correctamente.', name=user.name), 'success')
return redirect(url_for('admin.users_list'))
return render_template('admin/users/form.html', user=user, roles=roles)
@admin_bp.route('/users/toggle/<int:id>', methods=['POST'])
@admin_required
def user_toggle(id):
"""Toggle user active status"""
user = User.query.get_or_404(id)
if user.id == current_user.id:
flash(_('No puedes desactivar tu propia cuenta.'), 'error')
return redirect(url_for('admin.users_list'))
user.is_active = not user.is_active
db.session.commit()
status_str = _('activada') if user.is_active else _('desactivada')
flash(_('La cuenta de %(name)s ha sido %(status)s.', name=user.name, status=status_str), 'success')
return redirect(url_for('admin.users_list'))
# ---------------------------------------------------------
# ROLES & PERMISSIONS MANAGEMENT (FortiGate Matrix)
# ---------------------------------------------------------
@admin_bp.route('/roles')
@admin_required
def roles_list():
"""List all roles and permission summaries"""
roles = Role.query.order_by(Role.id.asc()).all()
return render_template(
'admin/roles/list.html',
roles=roles,
modules=SYSTEM_MODULES
)
@admin_bp.route('/roles/add', methods=['GET', 'POST'])
@admin_required
def role_add():
"""Create a new role with the FortiGate permission matrix"""
if request.method == 'POST':
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
if not name:
flash(_('El nombre del rol es requerido.'), 'error')
return render_template('admin/roles/edit.html', role=None, modules=SYSTEM_MODULES, current_perms={})
if Role.query.filter_by(name=name).first():
flash(_('Ya existe un rol con ese nombre.'), 'error')
return render_template('admin/roles/edit.html', role=None, modules=SYSTEM_MODULES, current_perms={})
new_role = Role(name=name, description=description, is_system=False)
db.session.add(new_role)
db.session.flush() # to obtain new_role.id
# Save permissions from matrix
for mod in SYSTEM_MODULES:
mod_id = mod['id']
level = request.form.get(f'perm_{mod_id}', 'none')
if level not in ACCESS_LEVELS:
level = 'none'
new_role.set_permission(mod_id, level)
db.session.commit()
flash(_('Rol "%(name)s" creado exitosamente con sus permisos.', name=new_role.name), 'success')
return redirect(url_for('admin.roles_list'))
return render_template('admin/roles/edit.html', role=None, modules=SYSTEM_MODULES, current_perms={})
@admin_bp.route('/roles/edit/<int:id>', methods=['GET', 'POST'])
@admin_required
def role_edit(id):
"""Edit an existing role and its FortiGate permission matrix"""
role = Role.query.get_or_404(id)
if request.method == 'POST':
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
if not name:
flash(_('El nombre del rol es requerido.'), 'error')
return render_template('admin/roles/edit.html', role=role, modules=SYSTEM_MODULES, current_perms={p.module: p.access_level for p in role.permissions})
existing = Role.query.filter_by(name=name).first()
if existing and existing.id != role.id:
flash(_('Ya existe otro rol con ese nombre.'), 'error')
return render_template('admin/roles/edit.html', role=role, modules=SYSTEM_MODULES, current_perms={p.module: p.access_level for p in role.permissions})
# If it's a system role, keep name unchanged or protected
if not role.is_system:
role.name = name
role.description = description
# Update permissions from the FortiGate matrix
for mod in SYSTEM_MODULES:
mod_id = mod['id']
level = request.form.get(f'perm_{mod_id}', 'none')
if level not in ACCESS_LEVELS:
level = 'none'
role.set_permission(mod_id, level)
db.session.commit()
flash(_('Rol "%(name)s" y permisos actualizados correctamente.', name=role.name), 'success')
return redirect(url_for('admin.roles_list'))
current_perms = {p.module: p.access_level for p in role.permissions}
return render_template(
'admin/roles/edit.html',
role=role,
modules=SYSTEM_MODULES,
current_perms=current_perms
)
@admin_bp.route('/roles/delete/<int:id>', methods=['POST'])
@admin_required
def role_delete(id):
"""Delete a non-system role if it has no assigned users"""
role = Role.query.get_or_404(id)
if role.is_system:
flash(_('No se pueden eliminar roles protegidos del sistema.'), 'error')
return redirect(url_for('admin.roles_list'))
user_count = len(role.users) if role.users else 0
if user_count > 0:
flash(_('No se puede eliminar el rol "%(name)s" porque tiene %(count)d usuario(s) asignado(s). Reasígnalos primero.', name=role.name, count=user_count), 'error')
return redirect(url_for('admin.roles_list'))
role_name = role.name
db.session.delete(role)
db.session.commit()
flash(_('El rol "%(name)s" ha sido eliminado.', name=role_name), 'success')
return redirect(url_for('admin.roles_list'))
# ---------------------------------------------------------
# GOOGLE SHEETS IMPORT & SYNCHRONIZATION
# ---------------------------------------------------------
@admin_bp.route('/import', methods=['GET', 'POST'])
@admin_required
def google_sheets_import():
"""Manual sync and status page for Google Sheets academic schedule data"""
from app.services.sheets_importer import GoogleSheetsImporter, SHEETS_CONFIG
from app.models.classroom import Classroom
from app.models.career import Career
from app.models.subject import Subject, Commission
from app.models.reservation import Reservation
stats = None
if request.method == 'POST':
try:
importer = GoogleSheetsImporter()
stats = importer.sync()
if stats['errors']:
flash(_('La sincronización se completó con algunas advertencias.'), 'warning')
else:
flash(_('Sincronización completada exitosamente. %(rows)d registros procesados.', rows=stats['total_rows_processed']), 'success')
except Exception as e:
flash(_('Error al sincronizar con Google Sheets: %(err)s', err=str(e)), 'error')
# Calculate current system metrics
metrics = {
'total_classrooms': Classroom.query.count(),
'total_careers': Career.query.count(),
'total_subjects': Subject.query.count(),
'total_commissions': Commission.query.count(),
'total_reservations': Reservation.query.count(),
'sheets': SHEETS_CONFIG
}
# Recent samples from DB if available
recent_subjects = Subject.query.order_by(Subject.id.desc()).limit(10).all()
return render_template(
'admin/import.html',
metrics=metrics,
stats=stats,
recent_subjects=recent_subjects
)
+51 -7
View File
@@ -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_login import login_user, logout_user, login_required, current_user
from flask_babel import gettext as _
from app import db from app import db
from app.models.user import User from app.models.user import User
from app.forms.auth import LoginForm from app.forms.auth import LoginForm
@@ -19,20 +20,29 @@ def login():
if user and user.check_password(form.password.data): if user and user.check_password(form.password.data):
if not user.is_active: 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) return render_template('auth/login.html', form=form)
login_user(user, remember=form.remember_me.data) login_user(user, remember=form.remember_me.data)
user.update_last_login() user.update_last_login()
# 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') next_page = request.args.get('next')
if not next_page or not next_page.startswith('/'): if not next_page or not next_page.startswith('/'):
next_page = url_for('main.dashboard') 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) return redirect(next_page)
else: else:
flash('Invalid email or password.', 'error') flash(_('Correo electrónico o contraseña inválidos.'), 'error')
return render_template('auth/login.html', form=form) return render_template('auth/login.html', form=form)
@@ -41,15 +51,49 @@ def login():
def logout(): def logout():
"""Handle user logout""" """Handle user logout"""
logout_user() 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')) return redirect(url_for('auth.login'))
@auth_bp.route('/profile') @auth_bp.route('/profile', methods=['GET', 'POST'])
@login_required @login_required
def profile(): 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') 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']) @auth_bp.route('/change_password', methods=['GET', 'POST'])
@login_required @login_required
def change_password(): def change_password():
+193
View File
@@ -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'))
+21 -3
View File
@@ -30,12 +30,18 @@ def add_classroom():
flash('Classroom with this building, room number, and floor already exists.', 'error') flash('Classroom with this building, room number, and floor already exists.', 'error')
return render_template('classrooms/add.html', form=form, title='Add Classroom') return render_template('classrooms/add.html', form=form, title='Add Classroom')
cap_raw = str(form.capacity.data or '').strip()
if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
cap_val = 0
else:
cap_val = int(cap_raw)
classroom = Classroom( classroom = Classroom(
# campus_id=form.campus_id.data, # campus_id=form.campus_id.data,
building=form.building.data, building=form.building.data,
code=form.room_number.data, code=form.room_number.data,
floor=form.floor.data, floor=form.floor.data,
capacity=form.capacity.data, capacity=cap_val,
description=form.description.data, description=form.description.data,
is_active=form.is_active.data is_active=form.is_active.data
) )
@@ -52,6 +58,7 @@ def add_classroom():
return render_template('classrooms/add.html', form=form, title='Add Classroom') return render_template('classrooms/add.html', form=form, title='Add Classroom')
@classrooms_bp.route('/', endpoint='index')
@classrooms_bp.route('/list') @classrooms_bp.route('/list')
@login_required @login_required
def list_classrooms(): def list_classrooms():
@@ -124,6 +131,12 @@ def edit_classroom(id):
classroom = Classroom.query.get_or_404(id) classroom = Classroom.query.get_or_404(id)
form = ClassroomForm(obj=classroom) form = ClassroomForm(obj=classroom)
if request.method == 'GET':
if classroom.capacity == 0 or (classroom.code and 'VIRTUAL' in classroom.code.upper()):
form.capacity.data = '∞'
else:
form.capacity.data = str(classroom.capacity)
# TODO: Update when campus model is implemented # TODO: Update when campus model is implemented
# form.campus_id.choices = [(1, 'Main Campus')] # form.campus_id.choices = [(1, 'Main Campus')]
@@ -141,12 +154,17 @@ def edit_classroom(id):
flash('Classroom with this building, room number, and floor already exists.', 'error') 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') return render_template('classrooms/edit.html', form=form, classroom=classroom, title='Edit Classroom')
cap_raw = str(form.capacity.data or '').strip()
if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
cap_val = 0
else:
cap_val = int(cap_raw)
# Update classroom # Update classroom
# classroom.campus_id = form.campus_id.data
classroom.building = form.building.data classroom.building = form.building.data
classroom.code = form.room_number.data classroom.code = form.room_number.data
classroom.floor = form.floor.data classroom.floor = form.floor.data
classroom.capacity = form.capacity.data classroom.capacity = cap_val
classroom.description = form.description.data classroom.description = form.description.data
classroom.is_active = form.is_active.data classroom.is_active = form.is_active.data
classroom.updated_at = datetime.utcnow() classroom.updated_at = datetime.utcnow()
+11 -1
View File
@@ -1,5 +1,6 @@
from flask import Blueprint, render_template, jsonify, redirect, url_for, session, request, flash from flask import Blueprint, render_template, jsonify, redirect, url_for, session, request, flash
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import db
from app.models.reservation import Reservation from app.models.reservation import Reservation
from app.models.classroom import Classroom from app.models.classroom import Classroom
from app.models.subject import Subject from app.models.subject import Subject
@@ -121,7 +122,16 @@ def genetic_optimizer():
@main_bp.route('/set_language/<language>') @main_bp.route('/set_language/<language>')
def set_language(language=None): def set_language(language=None):
if language not in ['en', 'es']: if language not in ['en', 'es']:
language = 'en' language = 'es'
session['language'] = language 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'))␍ return redirect(request.referrer or url_for('main.dashboard'))␍
+214 -39
View File
@@ -14,21 +14,39 @@ schedule_bp = Blueprint('schedule', __name__, url_prefix='/schedule')
@schedule_bp.route('/today') @schedule_bp.route('/today')
@login_required @login_required
def today_schedule(): def today_schedule():
"""Show today's schedule""" """Show today's schedule with filters for virtual classrooms, floor, and shift"""
today = datetime.utcnow().date() today = datetime.utcnow().date()
today_start = datetime.combine(today, datetime.min.time()) today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today, datetime.max.time()) today_end = datetime.combine(today, datetime.max.time())
# Get reservations with eager loading hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
reservations = Reservation.query.options( 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.classroom),
joinedload(Reservation.commission), joinedload(Reservation.commission).joinedload(Commission.subject),
joinedload(Reservation.user) joinedload(Reservation.user)
).filter( ).join(Reservation.classroom).filter(
Reservation.start_time >= today_start, Reservation.start_time >= today_start,
Reservation.start_time <= today_end, Reservation.start_time <= today_end,
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value]) 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 and floor_filter.isdigit():
query = query.filter(Classroom.floor == int(floor_filter))
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 # Create time blocks for timeline view
time_blocks = {} time_blocks = {}
@@ -49,11 +67,9 @@ def today_schedule():
# Calculate stats # Calculate stats
total_reservations = len(reservations) total_reservations = len(reservations)
confirmed = len([r for r in reservations if r.status == ReservationStatus.CONFIRMED.value]) 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]) 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() total_classrooms = Classroom.query.count()
occupied_classrooms = len(set(r.classroom_id for r in reservations if r.classroom_id)) occupied_classrooms = len(set(r.classroom_id for r in reservations if r.classroom_id))
@@ -65,11 +81,18 @@ def today_schedule():
'total_classrooms': total_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', return render_template('schedule/today.html',
reservations=reservations, reservations=reservations,
time_blocks=time_blocks, time_blocks=time_blocks,
stats=stats, 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']) @schedule_bp.route('/cancel/<int:id>', methods=['POST'])
@login_required @login_required
@@ -115,8 +138,24 @@ def view_reservation(id):
@schedule_bp.route('/calendar') @schedule_bp.route('/calendar')
@login_required @login_required
def calendar_view(): def calendar_view():
"""Show calendar view""" """Show calendar view with classroom, subject, career, shift, floor and virtual filtering"""
return render_template('schedule/calendar.html', title='Calendar View') from app.models.career import Career
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()
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,
available_floors=available_floors
)
@schedule_bp.route('/commissions') @schedule_bp.route('/commissions')
def commissions_view(): def commissions_view():
@@ -197,8 +236,80 @@ def add_reservation():
@schedule_bp.route('/list') @schedule_bp.route('/list')
@login_required @login_required
def list_reservations(): def list_reservations():
"""List all reservations""" """List all reservations with pagination, date filters, floor, shift, and virtual toggle"""
return redirect(url_for('schedule.today_schedule')) 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()
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 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 and floor_filter.isdigit():
query = query.filter(Classroom.floor == int(floor_filter))
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}%'),
Classroom.code.ilike(f'%{search}%'),
Reservation.purpose.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]
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,
search=search,
available_floors=available_floors
)
@schedule_bp.route('/reservation/<int:id>/confirm', methods=['GET', 'POST']) @schedule_bp.route('/reservation/<int:id>/confirm', methods=['GET', 'POST'])
@@ -321,46 +432,101 @@ def classroom_availability_api():
def calendar_data(): def calendar_data():
"""Get calendar events data for FullCalendar""" """Get calendar events data for FullCalendar"""
try: try:
# Get date range from query parameters
start = request.args.get('start') start = request.args.get('start')
end = request.args.get('end') 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: if start and end:
from datetime import datetime from datetime import datetime
start_date = datetime.fromisoformat(start) # Clean up ISO timezone strings for PostgreSQL naive timestamp comparison
end_date = datetime.fromisoformat(end) 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: else:
# Default to current month
today = date.today() today = date.today()
start_date = datetime.combine(today.replace(day=1), datetime.min.time()) 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()) end_date = datetime.combine(next_month.replace(day=1), datetime.max.time())
# Get reservations in the date range query = Reservation.query.join(Reservation.commission).join(Commission.subject).outerjoin(Reservation.classroom).options(
reservations = Reservation.query.options(
joinedload(Reservation.classroom), joinedload(Reservation.classroom),
joinedload(Reservation.commission) joinedload(Reservation.commission).joinedload(Commission.subject).joinedload(Subject.career_obj)
).filter( ).filter(
Reservation.start_time >= start_date, Reservation.start_time >= start_date,
Reservation.start_time <= end_date, Reservation.start_time <= end_date
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value]) )
).order_by(Reservation.start_time).all()
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))
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 and floor.isdigit():
query = query.filter(Classroom.floor == int(floor))
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 = [] events = []
for reservation in reservations: 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 if reservation.classroom else None
is_virtual = bool(reservation.classroom and ('VIRTUAL' in (reservation.classroom.code or '').upper() or 'VIRTUAL' in (reservation.classroom.building or '').upper()))
career_name = subject.career_obj.name if (subject and subject.career_obj) else (subject.department if subject else '')
status_val = str(reservation.status.value if hasattr(reservation.status, 'value') else reservation.status)
events.append({ events.append({
'id': reservation.id, '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(), 'start': reservation.start_time.isoformat(),
'end': reservation.end_time.isoformat(), 'end': reservation.end_time.isoformat(),
'url': url_for('schedule.view_reservation', id=reservation.id) if reservation.id else None, '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': { 'extendedProps': {
'classroom': reservation.classroom.code_display if reservation.classroom else 'Unknown', 'subject_code': subject_code,
'commission': reservation.commission.get_full_code() if reservation.commission else 'No Commission', 'subject_name': subject_name,
'purpose': reservation.purpose or 'No purpose', 'classroom': classroom_name,
'attendees': reservation.expected_attendees, 'building': reservation.classroom.building if reservation.classroom else '',
'status': reservation.status.value, 'floor': classroom_floor,
'is_virtual': is_virtual,
'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 '' 'notes': reservation.notes or ''
} }
}) })
@@ -368,6 +534,8 @@ def calendar_data():
return jsonify(events) return jsonify(events)
except Exception as e: except Exception as e:
import traceback
traceback.print_exc()
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@schedule_bp.route('/api/today-events') @schedule_bp.route('/api/today-events')
@@ -380,26 +548,33 @@ def api_today_events():
reservations = Reservation.query.options( reservations = Reservation.query.options(
joinedload(Reservation.classroom), joinedload(Reservation.classroom),
joinedload(Reservation.commission) joinedload(Reservation.commission).joinedload(Commission.subject).joinedload(Subject.career_obj)
).filter( ).filter(
Reservation.start_time >= today_start, Reservation.start_time >= today_start,
Reservation.start_time <= today_end, Reservation.start_time <= today_end
Reservation.status == ReservationStatus.CONFIRMED.value
).order_by(Reservation.start_time).all() ).order_by(Reservation.start_time).all()
events = [] events = []
for reservation in reservations: 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({ events.append({
'id': reservation.id, '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(), 'start': reservation.start_time.isoformat(),
'end': reservation.end_time.isoformat(), 'end': reservation.end_time.isoformat(),
'url': url_for('schedule.view_reservation', id=reservation.id) if reservation.id else None, 'url': url_for('schedule.view_reservation', id=reservation.id) if reservation.id else None,
'extendedProps': { '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', 'purpose': reservation.purpose or 'No purpose',
'attendees': reservation.expected_attendees, 'attendees': reservation.expected_attendees,
'status': reservation.status.value 'status': status_val
} }
}) })
+3
View File
@@ -0,0 +1,3 @@
from .sheets_importer import GoogleSheetsImporter
__all__ = ['GoogleSheetsImporter']
+413
View File
@@ -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
+54 -97
View File
@@ -1,97 +1,44 @@
/* Custom CSS for Edu-Space Admin */ /* Custom Enhancements for Edu-Space Admin */
/* Dark Theme Enhancements */
body {
background-color: #1a1a1a;
}
/* Card Styles */ /* Card Styles */
.card { .card {
border-radius: 15px; border-radius: 14px;
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out; transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out, border-color 0.2s ease-in-out;
} }
.card:hover { .card:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
} }
/* Navigation */ /* Navigation */
.navbar { .navbar {
backdrop-filter: blur(10px); backdrop-filter: blur(12px);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); -webkit-backdrop-filter: blur(12px);
} }
.navbar-brand { .navbar-brand {
font-weight: 600; font-weight: 700;
}
/* 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;
} }
/* Table Hover */
.table:hover tbody tr:hover { .table:hover tbody tr:hover {
background-color: rgba(102, 126, 234, 0.05); background-color: rgba(var(--brand-primary-rgb), 0.04);
}
/* Badge Styles */
.badge {
font-size: 0.75em;
padding: 0.5em 0.8em;
} }
/* Status Badge Colors */ /* Status Badge Colors */
.bg-confirmed-custom { .bg-confirmed-custom {
background-color: #28a745; background-color: #10B981;
color: #FFFFFF;
} }
.bg-pending-custom { .bg-pending-custom {
background-color: #ffc107; background-color: #F59E0B;
color: #000; color: #000000;
} }
.bg-cancelled-custom { .bg-cancelled-custom {
background-color: #dc3545; background-color: #EF4444;
color: #FFFFFF;
} }
/* Loading Spinner */ /* Loading Spinner */
@@ -99,9 +46,9 @@ body {
display: inline-block; display: inline-block;
width: 20px; width: 20px;
height: 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-radius: 50%;
border-top-color: #667eea; border-top-color: var(--brand-primary);
animation: spin 1s ease-in-out infinite; animation: spin 1s ease-in-out infinite;
} }
@@ -111,28 +58,25 @@ body {
/* Calendar Customizations */ /* Calendar Customizations */
.fc-event { .fc-event {
border-radius: 6px; border-radius: 8px;
border: none !important; border: none !important;
padding: 4px 8px !important; padding: 4px 8px !important;
font-size: 12px !important; font-size: 12px !important;
margin: 1px 0 !important; margin: 1px 0 !important;
} }
.fc-day-sat, .fc-day-sun {
background-color: rgba(255, 255, 255, 0.02) !important;
}
/* Dashboard Stat Cards */ /* Dashboard Stat Cards */
.stat-card { .stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: var(--gradient-primary);
color: white; color: #FFFFFF;
border-radius: 15px; 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-primary { background: linear-gradient(135deg, #B43E8E 0%, #9E367D 100%); }
.stat-card-success { background: linear-gradient(135deg, #28a745 0%, #20c997 100%); } .stat-card-success { background: linear-gradient(135deg, #059669 0%, #10B981 100%); }
.stat-card-info { background: linear-gradient(135deg, #17a2b8 0%, #138496 100%); } .stat-card-info { background: linear-gradient(135deg, #682CAD 0%, #8B5CF6 100%); }
.stat-card-warning { background: linear-gradient(135deg, #ffc107 0%, #fd7e14 100%); } .stat-card-warning { background: linear-gradient(135deg, #D97706 0%, #F59E0B 100%); }
/* Quick Action Buttons */ /* Quick Action Buttons */
.quick-action-btn { .quick-action-btn {
@@ -144,14 +88,14 @@ body {
} }
.quick-action-btn:hover { .quick-action-btn:hover {
transform: translateX(5px); transform: translateX(4px);
} }
/* Empty State Styles */ /* Empty State Styles */
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: 3rem 1rem; padding: 3rem 1rem;
color: #6c757d; color: var(--text-muted);
} }
.empty-state i { .empty-state i {
@@ -160,30 +104,43 @@ body {
opacity: 0.5; opacity: 0.5;
} }
/* Notification Toastenhancements */ /* Notification Toast enhancements */
.toast { .toast {
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border-radius: 10px; border-radius: 12px;
} }
/* Pagination Styles */ /* Pagination Styles */
.pagination .page-link { .pagination .page-link {
border-radius: 8px; border-radius: 8px;
margin: 0 2px; margin: 0 3px;
border: 1px solid #444; border: 1px solid var(--card-border);
background-color: #2a2a2a; background-color: var(--card-bg);
color: #fff; color: var(--text-primary);
} }
.pagination .page-link:hover { .pagination .page-link:hover {
background-color: #667eea; background-color: var(--brand-primary);
border-color: #667eea; border-color: var(--brand-primary);
color: white; color: #FFFFFF;
} }
.pagination .page-item.active .page-link { .pagination .page-item.active .page-link {
background-color: #667eea; background: var(--gradient-primary);
border-color: #667eea; 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 */ /* Mobile Responsiveness */
@@ -208,13 +165,13 @@ body {
} }
.card { .card {
border: 1px solid #ddd !important; border: 1px solid #DDD !important;
box-shadow: none !important; box-shadow: none !important;
break-inside: avoid; break-inside: avoid;
} }
body { body {
background-color: white !important; background-color: #FFFFFF !important;
color: black !important; color: #000000 !important;
} }
} }
+668
View File
@@ -0,0 +1,668 @@
/**
* 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;
}
.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;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+88
View File
@@ -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');
}
});
})();
+242
View File
@@ -0,0 +1,242 @@
{% extends "base.html" %}
{% block title %}{% trans %}Import from Google Sheets{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-file-earmark-spreadsheet text-success me-2"></i>{% trans %}Google Sheets Academic Sync{% endtrans %}
</h1>
<p class="text-muted small mb-0">{% trans %}Extract and synchronize classrooms, careers, subjects, and weekly schedules directly from the official Google Sheet.{% endtrans %}</p>
</div>
<div>
<a href="{{ url_for('main.dashboard') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% trans %}Dashboard{% endtrans %}
</a>
</div>
</div>
<!-- Metrics Cards -->
<div class="row g-3 mb-4">
<div class="col-sm-6 col-xl-2">
<div class="card border-0 shadow-sm text-center p-3 h-100">
<div class="text-primary mb-1"><i class="bi bi-mortarboard fs-3"></i></div>
<div class="h4 mb-0 fw-bold">{{ metrics.total_careers }}</div>
<small class="text-muted">{% trans %}Careers{% endtrans %}</small>
</div>
</div>
<div class="col-sm-6 col-xl-2">
<div class="card border-0 shadow-sm text-center p-3 h-100">
<div class="text-info mb-1"><i class="bi bi-book fs-3"></i></div>
<div class="h4 mb-0 fw-bold">{{ metrics.total_subjects }}</div>
<small class="text-muted">{% trans %}Subjects{% endtrans %}</small>
</div>
</div>
<div class="col-sm-6 col-xl-2">
<div class="card border-0 shadow-sm text-center p-3 h-100">
<div class="text-success mb-1"><i class="bi bi-door-open fs-3"></i></div>
<div class="h4 mb-0 fw-bold">{{ metrics.total_classrooms }}</div>
<small class="text-muted">{% trans %}Classrooms{% endtrans %}</small>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm text-center p-3 h-100">
<div class="text-warning mb-1"><i class="bi bi-people fs-3"></i></div>
<div class="h4 mb-0 fw-bold">{{ metrics.total_commissions }}</div>
<small class="text-muted">{% trans %}Commissions{% endtrans %}</small>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm text-center p-3 h-100">
<div class="text-danger mb-1"><i class="bi bi-calendar-check fs-3"></i></div>
<div class="h4 mb-0 fw-bold">{{ metrics.total_reservations }}</div>
<small class="text-muted">{% trans %}Calendar Classes{% endtrans %}</small>
</div>
</div>
</div>
<!-- Sync Action Banner -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-4">
<div class="row align-items-center">
<div class="col-lg-8 mb-3 mb-lg-0">
<div class="d-flex align-items-center mb-2">
<span class="badge bg-success-subtle text-success border border-success-subtle px-3 py-1 me-2 fw-semibold">
<i class="bi bi-cloud-check me-1"></i>Google Sheets Live Integration
</span>
<span class="badge bg-light text-muted border">UniCABA 2026</span>
</div>
<h4 class="h5 mb-2 fw-bold">{% trans %}Official Schedule Board (Cartelera de Aulas){% endtrans %}</h4>
<p class="text-muted small mb-2">
{% trans %}Reads published CSV sheets for all 6 days of the week, automatically creating or updating academic careers, classrooms, subject codes, commissions, and weekly calendar entries without duplicating records.{% endtrans %}
</p>
<div class="d-flex flex-wrap gap-1">
{% for s in metrics.sheets %}
<span class="badge bg-body-tertiary text-secondary border small">
<i class="bi bi-calendar-event me-1 text-primary"></i>{{ s.name }}
</span>
{% endfor %}
</div>
</div>
<div class="col-lg-4 text-lg-end">
<form method="POST" id="syncForm" onsubmit="startSync()">
<button type="submit" id="syncBtn" class="btn btn-success btn-lg px-4 py-2 shadow fw-semibold">
<i class="bi bi-arrow-repeat me-2"></i>{% trans %}Sync Data Now{% endtrans %}
</button>
</form>
<div id="syncSpinner" class="d-none mt-2 text-muted small">
<div class="spinner-border spinner-border-sm text-success me-1" role="status"></div>
{% trans %}Connecting to Google Sheets and processing records...{% endtrans %}
</div>
</div>
</div>
</div>
</div>
<!-- Sync Results (if just completed) -->
{% if stats %}
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-success bg-opacity-10 text-success py-3">
<h5 class="card-title h6 mb-0 fw-bold">
<i class="bi bi-check-circle-fill me-2"></i>{% trans %}Synchronization Report{% endtrans %}
</h5>
</div>
<div class="card-body">
<div class="row g-3 text-center mb-3">
<div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary">
<div class="fw-bold fs-5">{{ stats.total_rows_processed }}</div>
<small class="text-muted">{% trans %}Rows Processed{% endtrans %}</small>
</div>
</div>
<div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary">
<div class="fw-bold fs-5 text-primary">+{{ stats.careers_created }}</div>
<small class="text-muted">{% trans %}Careers Created{% endtrans %}</small>
</div>
</div>
<div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary">
<div class="fw-bold fs-5 text-success">+{{ stats.classrooms_created }}</div>
<small class="text-muted">{% trans %}Classrooms Created{% endtrans %}</small>
</div>
</div>
<div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary">
<div class="fw-bold fs-5 text-info">+{{ stats.subjects_created }}</div>
<small class="text-muted">{% trans %}Subjects Created{% endtrans %}</small>
</div>
</div>
<div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary">
<div class="fw-bold fs-5 text-warning">+{{ stats.commissions_created }}</div>
<small class="text-muted">{% trans %}Commissions Created{% endtrans %}</small>
</div>
</div>
<div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary">
<div class="fw-bold fs-5 text-danger">+{{ stats.reservations_created }}</div>
<small class="text-muted">{% trans %}Reservations Created{% endtrans %}</small>
</div>
</div>
</div>
{% if stats.errors %}
<div class="alert alert-warning mb-0 small">
<h6 class="alert-heading fw-bold mb-1">{% trans %}Warnings / Errors detected:{% endtrans %}</h6>
<ul class="mb-0 ps-3">
{% for err in stats.errors %}
<li>{{ err }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Database Preview Table -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary d-flex justify-content-between align-items-center py-3">
<h5 class="card-title h6 mb-0">
<i class="bi bi-table text-primary me-2"></i>{% trans %}Recently Synchronized Subjects & Comissions{% endtrans %}
</h5>
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-calendar3 me-1"></i>{% trans %}View Calendar{% endtrans %}
</a>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-body-tertiary">
<tr>
<th class="ps-4">{% trans %}Code{% endtrans %}</th>
<th>{% trans %}Subject Name{% endtrans %}</th>
<th>{% trans %}Career{% endtrans %}</th>
<th>{% trans %}Commissions{% endtrans %}</th>
<th>{% trans %}Status{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for s in recent_subjects %}
<tr>
<td class="ps-4">
<span class="badge bg-secondary-subtle text-secondary font-monospace border">
{{ s.code }}
</span>
</td>
<td>
<div class="fw-semibold">{{ s.name }}</div>
</td>
<td>
<span class="badge badge-brand small">
{{ s.career_obj.name if s.career_obj else (s.department or _('General')) }}
</span>
</td>
<td>
<span class="small text-muted">
{{ s.commissions|length }} {% trans %}commission(s){% endtrans %}
</span>
</td>
<td>
{% if s.is_active %}
<span class="badge bg-success-subtle text-success border border-success-subtle small">
<i class="bi bi-check-circle me-1"></i>{% trans %}Active{% endtrans %}
</span>
{% else %}
<span class="badge bg-secondary small">{% trans %}Inactive{% endtrans %}</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="text-center py-5 text-muted">
<i class="bi bi-cloud-arrow-down fs-1 d-block mb-2 text-secondary"></i>
{% trans %}No subjects in database yet. Click "Sync Data Now" to import.{% endtrans %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function startSync() {
const btn = document.getElementById('syncBtn');
const spinner = document.getElementById('syncSpinner');
if (btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status"></span>Sincronizando...';
}
if (spinner) {
spinner.classList.remove('d-none');
}
}
</script>
{% endblock %}
+191
View File
@@ -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 %}
+124
View File
@@ -0,0 +1,124 @@
{% extends "base.html" %}
{% block title %}{% trans %}Roles y Grupos de Acceso{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-person-badge text-primary me-2"></i>{% trans %}Roles y Grupos de Acceso{% endtrans %}
</h1>
<p class="text-muted small mb-0">{% trans %}Administración de perfiles de usuario y matriz granular de permisos por módulo del sistema.{% endtrans %}</p>
</div>
<div>
<a href="{{ url_for('admin.role_add') }}" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nuevo Rol{% endtrans %}
</a>
</div>
</div>
<!-- Roles Grid / Table -->
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-body-tertiary">
<tr>
<th class="ps-4">{% trans %}Nombre del Rol{% endtrans %}</th>
<th>{% trans %}Descripción{% endtrans %}</th>
<th>{% trans %}Integrantes{% endtrans %}</th>
<th>{% trans %}Resumen de Permisos{% endtrans %}</th>
<th class="text-end pe-4">{% trans %}Acciones{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for role in roles %}
<tr>
<td class="ps-4">
<div class="d-flex align-items-center">
<div class="badge-brand rounded-circle p-2 me-3 d-inline-flex align-items-center justify-content-center" style="width: 38px; height: 38px;">
<i class="bi bi-shield-check fs-6 text-white"></i>
</div>
<div>
<div class="fw-bold text-primary">{{ role.name }}</div>
{% if role.is_system %}
<span class="badge bg-secondary-subtle text-secondary border small">{% trans %}Protegido del Sistema{% endtrans %}</span>
{% endif %}
</div>
</div>
</td>
<td>
<span class="text-muted small">{{ role.description or _('Sin descripción provista.') }}</span>
</td>
<td>
<span class="badge bg-primary-subtle text-primary border px-2 py-1">
<i class="bi bi-people me-1"></i>{{ role.users|length }} {% trans %}usuarios{% endtrans %}
</span>
</td>
<td>
<div class="d-flex flex-wrap gap-1">
{% for mod in modules %}
{% set p_level = role.get_permission(mod.id) %}
{% if p_level == 'read_write' %}
<span class="badge bg-success-subtle text-success border small" title="{{ mod.name }}: {% trans %}Lectura y Escritura{% endtrans %}">
<i class="bi {{ mod.icon }} me-1"></i>{{ mod.name }}: RW
</span>
{% elif p_level == 'read' %}
<span class="badge bg-info-subtle text-info border small" title="{{ mod.name }}: {% trans %}Solo Lectura{% endtrans %}">
<i class="bi {{ mod.icon }} me-1"></i>{{ mod.name }}: R
</span>
{% else %}
<span class="badge bg-light text-muted border small opacity-75" title="{{ mod.name }}: {% trans %}Sin Acceso{% endtrans %}">
<i class="bi {{ mod.icon }} me-1"></i>{{ mod.name }}: -
</span>
{% endif %}
{% endfor %}
</div>
</td>
<td class="text-end pe-4">
<div class="btn-group btn-group-sm">
<a href="{{ url_for('admin.role_edit', id=role.id) }}" class="btn btn-outline-primary" title="{% trans %}Configurar Permisos{% endtrans %}">
<i class="bi bi-pencil-square me-1"></i>{% trans %}Permisos{% endtrans %}
</a>
{% if not role.is_system %}
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteModal{{ role.id }}" title="{% trans %}Eliminar Rol{% endtrans %}" {% if role.users|length > 0 %}disabled{% endif %}>
<i class="bi bi-trash"></i>
</button>
{% endif %}
</div>
{% if not role.is_system %}
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal{{ role.id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header border-0">
<h5 class="modal-title text-danger">
<i class="bi bi-exclamation-triangle-fill me-2"></i>{% trans %}Confirmar Eliminación de Rol{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-start">
<p>{% trans %}¿Está seguro de que desea eliminar el rol{% endtrans %} <strong>{{ role.name }}</strong>?</p>
<p class="text-muted small mb-0">{% trans %}Esta acción no se puede deshacer.{% endtrans %}</p>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<form action="{{ url_for('admin.role_delete', id=role.id) }}" method="POST" class="d-inline">
<button type="submit" class="btn btn-danger">{% trans %}Eliminar Rol{% endtrans %}</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+107
View File
@@ -0,0 +1,107 @@
{% extends "base.html" %}
{% block title %}{% if user %}{% trans %}Edit User{% endtrans %}: {{ user.name }}{% else %}{% trans %}New User{% endtrans %}{% endif %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-person-gear text-primary me-2"></i>
{% if user %}{% trans %}Edit User Account{% endtrans %}{% else %}{% trans %}Create User Account{% endtrans %}{% endif %}
</h1>
<p class="text-muted small mb-0">{% trans %}Configure user credentials, profile information, and role assignment.{% endtrans %}</p>
</div>
<div>
<a href="{{ url_for('admin.users_list') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% trans %}Back to Users{% endtrans %}
</a>
</div>
</div>
<div class="row">
<div class="col-lg-6 mx-auto">
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary py-3">
<h5 class="card-title h6 mb-0">
<i class="bi bi-person-vcard text-primary me-2"></i>{% trans %}Account Details{% endtrans %}
</h5>
</div>
<div class="card-body p-4">
<form method="POST">
<div class="mb-3">
<label for="name" class="form-label fw-semibold">{% trans %}Full Name{% endtrans %} <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" name="name"
value="{{ user.name if user else '' }}" required
placeholder="{% trans %}e.g. María González{% endtrans %}">
</div>
<div class="mb-3">
<label for="email" class="form-label fw-semibold">{% trans %}Email Address{% endtrans %} <span class="text-danger">*</span></label>
<input type="email" class="form-control" id="email" name="email"
value="{{ user.email if user else '' }}" required
placeholder="{% trans %}e.g. mgonzalez@edu-space.com{% endtrans %}">
</div>
<div class="mb-3">
<label for="password" class="form-label fw-semibold">
{% trans %}Password{% endtrans %} {% if not user %}<span class="text-danger">*</span>{% endif %}
</label>
<input type="password" class="form-control" id="password" name="password"
{% if not user %}required{% endif %}
placeholder="{% if user %}{% trans %}Leave empty to keep current password{% endtrans %}{% else %}{% trans %}At least 6 characters{% endtrans %}{% endif %}">
{% if user %}
<small class="text-muted d-block mt-1">
{% trans %}Only fill this in if you want to reset this user's password.{% endtrans %}
</small>
{% endif %}
</div>
<div class="mb-3">
<label for="role_id" class="form-label fw-semibold">{% trans %}Role / Access Group{% endtrans %} <span class="text-danger">*</span></label>
<select class="form-select" id="role_id" name="role_id" required>
<option value="" disabled {% if not user or not user.role_id %}selected{% endif %}>{% trans %}Select a role...{% endtrans %}</option>
{% for r in roles %}
<option value="{{ r.id }}" {% if user and user.role_id == r.id %}selected{% endif %}>
{{ r.name }} - {{ r.description or '' }}
</option>
{% endfor %}
</select>
<small class="text-muted d-block mt-1">
<a href="{{ url_for('admin.roles_list') }}" target="_blank" class="text-decoration-none">
<i class="bi bi-box-arrow-up-right me-1"></i>{% trans %}View or manage roles and permissions{% endtrans %}
</a>
</small>
</div>
<div class="mb-4 form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="is_active" name="is_active"
{% if not user or user.is_active %}checked{% endif %}
{% if user and user.id == current_user.id %}disabled{% endif %}>
<label class="form-check-label fw-semibold" for="is_active">
{% trans %}Active Account{% endtrans %}
</label>
<small class="text-muted d-block">
{% trans %}Inactive users are prevented from signing in to the platform.{% endtrans %}
</small>
{% if user and user.id == current_user.id %}
<input type="hidden" name="is_active" value="on">
{% endif %}
</div>
<div class="d-flex justify-content-between align-items-center pt-2">
<a href="{{ url_for('admin.users_list') }}" class="btn btn-outline-secondary">
{% trans %}Cancel{% endtrans %}
</a>
<button type="submit" class="btn btn-primary px-4 fw-semibold">
<i class="bi bi-check-lg me-1"></i>{% trans %}Save User{% endtrans %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+182
View File
@@ -0,0 +1,182 @@
{% extends "base.html" %}
{% block title %}{% trans %}User Management{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-people text-primary me-2"></i>{% trans %}User Management{% endtrans %}
</h1>
<p class="text-muted small mb-0">{% trans %}Manage user accounts, assign roles, and control active status.{% endtrans %}</p>
</div>
<div>
<a href="{{ url_for('admin.user_add') }}" class="btn btn-primary">
<i class="bi bi-person-plus me-1"></i>{% trans %}New User{% endtrans %}
</a>
</div>
</div>
<!-- Filters & Search -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-3">
<form method="GET" class="row g-2 align-items-center">
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary border-end-0">
<i class="bi bi-search text-muted"></i>
</span>
<input type="text" name="search" class="form-control border-start-0"
placeholder="{% trans %}Search by name or email...{% endtrans %}"
value="{{ search }}">
</div>
</div>
<div class="col-md-3">
<select name="role" class="form-select form-select-sm">
<option value="">{% trans %}All Roles{% endtrans %}</option>
{% for r in roles %}
<option value="{{ r.id }}" {% if role_filter == (r.id|string) %}selected{% endif %}>{{ r.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<select name="status" class="form-select form-select-sm">
<option value="">{% trans %}All Statuses{% endtrans %}</option>
<option value="active" {% if status_filter == 'active' %}selected{% endif %}>{% trans %}Active{% endtrans %}</option>
<option value="inactive" {% if status_filter == 'inactive' %}selected{% endif %}>{% trans %}Inactive{% endtrans %}</option>
</select>
</div>
<div class="col-md-2 d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm w-100">
<i class="bi bi-filter me-1"></i>{% trans %}Filter{% endtrans %}
</button>
{% if search or role_filter or status_filter %}
<a href="{{ url_for('admin.users_list') }}" class="btn btn-outline-secondary btn-sm" title="{% trans %}Clear filters{% endtrans %}">
<i class="bi bi-x-lg"></i>
</a>
{% endif %}
</div>
</form>
</div>
</div>
<!-- Users Table -->
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-body-tertiary">
<tr>
<th class="ps-4">{% trans %}User{% endtrans %}</th>
<th>{% trans %}Email{% endtrans %}</th>
<th>{% trans %}Role / Group{% endtrans %}</th>
<th>{% trans %}Status{% endtrans %}</th>
<th>{% trans %}Last Login{% endtrans %}</th>
<th class="text-end pe-4">{% trans %}Actions{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td class="ps-4">
<div class="d-flex align-items-center">
<div class="user-avatar-small me-2">
{{ (u.first_name[0] if u.first_name else 'U')|upper }}
</div>
<div>
<div class="fw-bold">{{ u.name }}</div>
<small class="text-muted">ID: #{{ u.id }}</small>
</div>
</div>
</td>
<td>
<span class="font-monospace small">{{ u.email }}</span>
</td>
<td>
{% if u.role_obj %}
<span class="badge badge-brand">
<i class="bi bi-shield-check me-1"></i>{{ u.role_obj.name }}
</span>
{% else %}
<span class="badge bg-secondary">
{{ u.role or _('No Role') }}
</span>
{% endif %}
</td>
<td>
{% if u.is_active %}
<span class="badge bg-success-subtle text-success border border-success-subtle px-2 py-1">
<i class="bi bi-check-circle me-1"></i>{% trans %}Active{% endtrans %}
</span>
{% else %}
<span class="badge bg-danger-subtle text-danger border border-danger-subtle px-2 py-1">
<i class="bi bi-dash-circle me-1"></i>{% trans %}Inactive{% endtrans %}
</span>
{% endif %}
</td>
<td>
<small class="text-muted">
{% if u.last_login %}
{{ format_datetime(u.last_login, format='short') }}
{% else %}
{% trans %}Never{% endtrans %}
{% endif %}
</small>
</td>
<td class="text-end pe-4">
<div class="btn-group btn-group-sm">
<a href="{{ url_for('admin.user_edit', id=u.id) }}" class="btn btn-outline-primary" title="{% trans %}Edit User{% endtrans %}">
<i class="bi bi-pencil"></i>
</a>
{% if u.id != current_user.id %}
<form action="{{ url_for('admin.user_toggle', id=u.id) }}" method="POST" class="d-inline">
<button type="submit" class="btn {% if u.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %}"
title="{% if u.is_active %}{% trans %}Deactivate User{% endtrans %}{% else %}{% trans %}Activate User{% endtrans %}{% endif %}">
<i class="bi {% if u.is_active %}bi-person-x{% else %}bi-person-check{% endif %}"></i>
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center py-5 text-muted">
<i class="bi bi-people fs-1 d-block mb-2 text-secondary"></i>
{% trans %}No users found matching the criteria.{% endtrans %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if pagination.pages > 1 %}
<div class="card-footer bg-body-tertiary d-flex justify-content-between align-items-center py-2 px-4">
<small class="text-muted">
{% trans %}Showing page{% endtrans %} {{ pagination.page }} {% trans %}of{% endtrans %} {{ pagination.pages }} ({{ pagination.total }} {% trans %}total{% endtrans %})
</small>
<ul class="pagination pagination-sm mb-0">
{% if pagination.has_prev %}
<li class="page-item"><a class="page-link" href="{{ url_for('admin.users_list', page=pagination.prev_num, search=search, role=role_filter, status=status_filter) }}">&laquo;</a></li>
{% endif %}
{% for p in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if p %}
<li class="page-item {% if p == pagination.page %}active{% endif %}">
<a class="page-link" href="{{ url_for('admin.users_list', page=p, search=search, role=role_filter, status=status_filter) }}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">…</span></li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item"><a class="page-link" href="{{ url_for('admin.users_list', page=pagination.next_num, search=search, role=role_filter, status=status_filter) }}">&raquo;</a></li>
{% endif %}
</ul>
</div>
{% endif %}
</div>
</div>
{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}Acceso Denegado (403) - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container pt-5 mt-5 text-center">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card border-0 shadow-sm p-5">
<div class="text-danger mb-3" style="font-size: 4rem;">
<i class="bi bi-shield-x"></i>
</div>
<h1 class="h2 mb-2">403 - Acceso Denegado</h1>
<p class="text-muted mb-4">
No tienes los permisos necesarios para acceder a este recurso. Esta sección está restringida exclusivamente a administradores.
</p>
<div>
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">
<i class="bi bi-house me-1"></i>Ir al Dashboard
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+62
View File
@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block title %}Cambiar Contraseña - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container pt-5 mt-4">
<div class="row justify-content-center">
<div class="col-lg-6 col-md-8">
<!-- Header -->
<div class="d-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0">
<i class="bi bi-shield-lock text-primary me-2"></i>Cambiar Contraseña
</h1>
<a href="{{ url_for('auth.profile') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Volver al Perfil
</a>
</div>
<!-- Form Card -->
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<form method="POST" action="{{ url_for('auth.change_password') }}">
<div class="mb-3">
<label for="current_password" class="form-label">Contraseña Actual</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-lock"></i></span>
<input type="password" class="form-control" id="current_password" name="current_password" required placeholder="Ingresa tu contraseña actual">
</div>
</div>
<div class="mb-3">
<label for="new_password" class="form-label">Nueva Contraseña</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-key"></i></span>
<input type="password" class="form-control" id="new_password" name="new_password" required minlength="6" placeholder="Mínimo 6 caracteres">
</div>
<div class="form-text">Debe tener al menos 6 caracteres.</div>
</div>
<div class="mb-4">
<label for="confirm_password" class="form-label">Confirmar Nueva Contraseña</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-check2-circle"></i></span>
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required minlength="6" placeholder="Repite la nueva contraseña">
</div>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-save me-1"></i>Actualizar Contraseña
</button>
<a href="{{ url_for('auth.profile') }}" class="btn btn-link text-muted">
Cancelar
</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+63 -48
View File
@@ -1,64 +1,70 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="{{ current_lang }}" data-bs-theme="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Edu-Space Admin</title> <title>{% trans %}Login - Edu-Space Admin{% endtrans %}</title>
<!-- Favicon -->
<link rel="icon" type="image/png" href="{{ url_for('static', filename='img/favicon.png') }}">
<!-- Script Anti-Flicker para Modo Claro / Oscuro -->
<script>
(function() {
const storedTheme = localStorage.getItem('theme');
const theme = storedTheme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-bs-theme', theme);
})();
</script>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
<style> <!-- Design System Theme -->
body { <link href="{{ url_for('static', filename='css/theme.css') }}" rel="stylesheet">
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
max-width: 450px;
width: 100%;
}
.logo-section {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 20px 20px 0 0;
padding: 2rem;
text-align: center;
color: white;
}
</style>
</head> </head>
<body> <body>
<div class="login-card"> <div class="auth-wrapper">
<div class="logo-section"> <div class="auth-card">
<i class="bi bi-building" style="font-size: 3rem;"></i> <!-- Header con Controles Superiores & Logo Oficial -->
<h3 class="mt-3">Edu-Space Admin</h3> <div class="auth-header">
<p class="mb-0">Classroom Management System</p> <div class="auth-controls">
<button class="theme-toggle-btn" type="button" aria-label="Cambiar tema" title="Modo Claro/Oscuro">
<i class="bi bi-moon-stars-fill"></i>
</button>
{% include 'partials/language_switcher.html' %}
</div> </div>
<div class="p-4"> <img src="{{ url_for('static', filename='img/logo_unicaba.png') }}" alt="UniCABA" class="auth-logo">
<h4 class="fw-bold mb-1">Edu-Space Admin</h4>
<p class="text-muted small mb-0">{% trans %}Classroom Management System{% endtrans %}</p>
</div>
<div class="px-4 pb-4 pt-2">
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
{% for category, message in messages %} {% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert"> <div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show rounded-3 py-2 small" role="alert">
{{ message }} {{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button> <button type="button" class="btn-close py-2" data-bs-dismiss="alert"></button>
</div> </div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% endwith %} {% endwith %}
<form method="POST"> <form method="POST" novalidate>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<div class="mb-3"> <div class="mb-3">
<label for="email" class="form-label">{{ form.email.label.text }}</label> <label for="email" class="form-label small fw-semibold text-muted">{{ form.email.label.text }}</label>
<div class="input-group"> <div class="input-group">
<span class="input-group-text"><i class="bi bi-envelope"></i></span> <span class="input-group-text bg-body-tertiary border-end-0 text-muted">
{{ form.email(class="form-control", placeholder="Enter your email") }} <i class="bi bi-envelope"></i>
</span>
<input type="email" class="form-control border-start-0 ps-0" id="email" name="email"
value="{{ form.email.data or '' }}" required
placeholder="{% trans %}Enter your email{% endtrans %}">
</div> </div>
{% for error in form.email.errors %} {% for error in form.email.errors %}
<div class="text-danger small mt-1">{{ error }}</div> <div class="text-danger small mt-1">{{ error }}</div>
@@ -66,35 +72,44 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="password" class="form-label">{{ form.password.label.text }}</label> <label for="password" class="form-label small fw-semibold text-muted">{{ form.password.label.text }}</label>
<div class="input-group"> <div class="input-group">
<span class="input-group-text"><i class="bi bi-lock"></i></span> <span class="input-group-text bg-body-tertiary border-end-0 text-muted">
{{ form.password(class="form-control", placeholder="Enter your password") }} <i class="bi bi-lock"></i>
</span>
<input type="password" class="form-control border-start-0 ps-0" id="password" name="password"
required placeholder="{% trans %}Enter your password{% endtrans %}">
</div> </div>
{% for error in form.password.errors %} {% for error in form.password.errors %}
<div class="text-danger small mt-1">{{ error }}</div> <div class="text-danger small mt-1">{{ error }}</div>
{% endfor %} {% endfor %}
</div> </div>
<div class="mb-3 form-check"> <div class="mb-4 form-check">
{{ form.remember_me(class="form-check-input") }} {{ form.remember_me(class="form-check-input") }}
{{ form.remember_me.label(class="form-check-label") }} <label class="form-check-label text-muted small" for="remember_me">
{{ form.remember_me.label.text }}
</label>
</div> </div>
<div class="d-grid"> <div class="d-grid">
{{ form.submit(class="btn btn-primary btn-lg") }} <button type="submit" class="btn btn-primary btn-lg fw-semibold py-2">
<i class="bi bi-box-arrow-in-right me-1"></i> {{ form.submit.label.text }}
</button>
</div> </div>
</form> </form>
<div class="text-center mt-3"> <div class="text-center mt-4">
<small class="text-muted"> <small class="text-muted d-inline-flex align-items-center">
<i class="bi bi-shield-lock"></i> <i class="bi bi-shield-check text-primary me-1"></i>
Secure Login • All access is logged {% trans %}Secure Login • All access is logged{% endtrans %}
</small> </small>
</div> </div>
</div> </div>
</div> </div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='js/theme-toggle.js') }}"></script>
</body> </body>
</html> </html>
+188
View File
@@ -0,0 +1,188 @@
{% extends "base.html" %}
{% block title %}{% trans %}User Profile{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container pt-5 mt-4">
<div class="row justify-content-center">
<div class="col-lg-8">
<!-- Header -->
<div class="d-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0">
<i class="bi bi-person-badge text-primary me-2"></i>{% trans %}My Profile{% endtrans %}
</h1>
<a href="{{ url_for('main.dashboard') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>{% trans %}Dashboard{% endtrans %}
</a>
</div>
<!-- Profile Card -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-4">
<div class="d-flex align-items-center mb-4">
<div class="bg-primary text-white rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 72px; height: 72px; font-size: 2rem;">
<i class="bi bi-person"></i>
</div>
<div>
<h4 class="mb-1">{{ current_user.name }}</h4>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-primary">{{ current_user.role }}</span>
{% if current_user.is_active %}
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>{% trans %}Active{% endtrans %}</span>
{% else %}
<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>{% trans %}Inactive{% endtrans %}</span>
{% endif %}
</div>
</div>
</div>
<hr class="my-4">
<!-- User Details Grid -->
<div class="row g-3">
<div class="col-sm-6">
<label class="form-label text-muted small mb-1">{% trans %}Email{% endtrans %}</label>
<div class="fw-semibold">{{ current_user.email }}</div>
</div>
<div class="col-sm-6">
<label class="form-label text-muted small mb-1">{% trans %}Full Name{% endtrans %}</label>
<div class="fw-semibold">{{ current_user.name }}</div>
</div>
<div class="col-sm-6">
<label class="form-label text-muted small mb-1">{% trans %}Access Role{% endtrans %}</label>
<div class="fw-semibold">{{ current_user.role }}</div>
</div>
<div class="col-sm-6">
<label class="form-label text-muted small mb-1">{% trans %}Last Login{% endtrans %}</label>
<div class="fw-semibold">
{% if current_user.last_login %}
{{ current_user.last_login.strftime('%Y-%m-%d %H:%M:%S') }}
{% else %}
<span class="text-muted">{% trans %}First login{% endtrans %}</span>
{% endif %}
</div>
</div>
<div class="col-sm-6">
<label class="form-label text-muted small mb-1">{% trans %}Registration Date{% endtrans %}</label>
<div class="fw-semibold">
{% if current_user.created_at %}
{{ current_user.created_at.strftime('%Y-%m-%d') }}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</div>
</div>
</div>
<hr class="my-4">
<!-- Preferences Section -->
<form method="POST" action="{{ url_for('auth.profile') }}">
<h5 class="fw-bold mb-3">
<i class="bi bi-sliders text-primary me-2"></i>{% trans %}User Preferences{% endtrans %}
</h5>
<div class="row g-3">
<!-- Preferred Language -->
<div class="col-md-6 mb-3">
<label for="preferred_language" class="form-label fw-semibold">
<i class="bi bi-translate text-primary me-1"></i>{% trans %}Interface Language{% endtrans %}
</label>
<select class="form-select" id="preferred_language" name="preferred_language">
<option value="" {% if not current_user.preferred_language %}selected{% endif %}>
{% trans %}Auto-detect from browser (Default){% endtrans %}
</option>
<option value="es" {% if current_user.preferred_language == 'es' %}selected{% endif %}>
Español
</option>
<option value="en" {% if current_user.preferred_language == 'en' %}selected{% endif %}>
English
</option>
</select>
<small class="text-muted d-block mt-1">
{% trans %}If set, this language overrides the browser configuration for all your sessions.{% endtrans %}
</small>
</div>
<!-- Theme Preference -->
<div class="col-md-6 mb-3">
<label for="theme_preference" class="form-label fw-semibold">
<i class="bi bi-palette text-primary me-1"></i>{% trans %}Visual Theme{% endtrans %}
</label>
<select class="form-select" id="theme_preference" name="theme_preference">
<option value="auto" {% if current_user.theme_preference == 'auto' or not current_user.theme_preference %}selected{% endif %}>
{% trans %}Automatic (follow system){% endtrans %}
</option>
<option value="light" {% if current_user.theme_preference == 'light' %}selected{% endif %}>
{% trans %}Light Mode{% endtrans %}
</option>
<option value="dark" {% if current_user.theme_preference == 'dark' %}selected{% endif %}>
{% trans %}Dark Mode{% endtrans %}
</option>
</select>
<small class="text-muted d-block mt-1">
{% trans %}Choose your preferred visual theme.{% endtrans %}
</small>
</div>
</div>
<div class="text-end mt-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>{% trans %}Save Preferences{% endtrans %}
</button>
</div>
</form>
</div>
<div class="card-footer bg-body-tertiary px-4 py-3 d-flex justify-content-between align-items-center">
<span class="text-muted small">{% trans %}Account Security{% endtrans %}</span>
<a href="{{ url_for('auth.change_password') }}" class="btn btn-primary btn-sm">
<i class="bi bi-key me-1"></i>{% trans %}Change Password{% endtrans %}
</a>
</div>
</div>
<!-- User Reservations Overview (if any) -->
{% if current_user.reservations %}
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-body-tertiary">
<h5 class="card-title mb-0 h6">
<i class="bi bi-calendar-event me-2"></i>{% trans %}My Recent Reservations{% endtrans %}
</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>{% trans %}Classroom{% endtrans %}</th>
<th>{% trans %}Purpose{% endtrans %}</th>
<th>{% trans %}Date & Time{% endtrans %}</th>
<th>{% trans %}Status{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for res in current_user.reservations[:5] %}
<tr>
<td>
<span class="badge bg-secondary">{{ res.classroom.code_display if res.classroom else 'N/A' }}</span>
</td>
<td>{{ res.purpose }}</td>
<td>{{ res.start_time.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
<span class="badge bg-{{ 'success' if res.status == 'CONFIRMED' else 'warning' if res.status == 'PENDING' else 'secondary' }}">
{{ res.status }}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+78 -41
View File
@@ -1,26 +1,44 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ session.language or request.accept_languages.best_match(['es', 'en']) or 'en' }}" data-bs-theme="dark"> <html lang="{{ current_lang }}" data-bs-theme="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{% trans %}Edu-Space Admin{% endtrans %}{% endblock %}</title> <title>{% block title %}{% trans %}Edu-Space Admin{% endtrans %}{% endblock %}</title>
<!-- Favicon -->
<link rel="icon" type="image/png" href="{{ url_for('static', filename='img/favicon.png') }}">
<!-- Script Anti-Flicker para Modo Claro / Oscuro -->
<script>
(function() {
let userTheme = "{{ current_user.theme_preference if (current_user and current_user.is_authenticated) else '' }}";
let theme = userTheme;
if (!theme || theme === 'auto') {
const storedTheme = localStorage.getItem('theme');
theme = storedTheme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
}
document.documentElement.setAttribute('data-bs-theme', theme);
})();
</script>
<!-- Bootstrap 5 CSS --> <!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons --> <!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
<!-- Custom CSS --> <!-- Design System & Custom Theme -->
<link href="{{ url_for('static', filename='css/theme.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet"> <link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
{% block extra_css %}{% endblock %} {% block extra_css %}{% endblock %}
</head> </head>
<body> <body data-user-authenticated="{{ 'true' if (current_user and current_user.is_authenticated) else 'false' }}">
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<!-- Navigation --> <!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary fixed-top"> <nav class="navbar navbar-expand-lg navbar-dark navbar-app fixed-top">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('main.dashboard') }}"> <a class="navbar-brand d-flex align-items-center" href="{{ url_for('main.dashboard') }}">
<i class="bi bi-building"></i> Edu-Space Admin <img src="{{ url_for('static', filename='img/logo_unicaba_mini.png') }}" alt="Logo UniCABA" class="navbar-logo me-2">
<span>Edu-Space Admin</span>
</a> </a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
@@ -31,83 +49,100 @@
<ul class="navbar-nav me-auto"> <ul class="navbar-nav me-auto">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('main.dashboard') }}"> <a class="nav-link" href="{{ url_for('main.dashboard') }}">
<i class="bi bi-speedometer2"></i> Dashboard <i class="bi bi-speedometer2"></i> {% trans %}Dashboard{% endtrans %}
</a> </a>
</li> </li>
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"> <a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-door-open"></i> Classrooms <i class="bi bi-door-open"></i> {% trans %}Classrooms{% endtrans %}
</a> </a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms') }}"> <li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms') }}">
<i class="bi bi-list"></i> All Classrooms <i class="bi bi-list"></i> {% trans %}All Classrooms{% endtrans %}
</a></li> </a></li>
{% if current_user.has_permission('classrooms', 'read_write') %}
<li><a class="dropdown-item" href="{{ url_for('classrooms.add_classroom') }}"> <li><a class="dropdown-item" href="{{ url_for('classrooms.add_classroom') }}">
<i class="bi bi-plus-circle"></i> Add Classroom <i class="bi bi-plus-circle"></i> {% trans %}Add Classroom{% endtrans %}
</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}">
<i class="bi bi-buildings"></i> {% trans %}Buildings{% endtrans %}
</a></li> </a></li>
</ul> </ul>
</li> </li>
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"> <a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-calendar3"></i> Schedule <i class="bi bi-calendar3"></i> {% trans %}Schedule{% endtrans %}
</a> </a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('schedule.calendar_view') }}"> <li><a class="dropdown-item" href="{{ url_for('schedule.calendar_view') }}">
<i class="bi bi-calendar-week"></i> Calendar View <i class="bi bi-calendar-week"></i> {% trans %}Calendar View{% endtrans %}
</a></li> </a></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}"> <li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}">
<i class="bi bi-list-check"></i> All Reservations <i class="bi bi-list-check"></i> {% trans %}All Reservations{% endtrans %}
</a></li> </a></li>
{% if current_user.has_permission('reservations', 'read_write') %}
<li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}"> <li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}">
<i class="bi bi-plus-circle"></i> New Reservation <i class="bi bi-plus-circle"></i> {% trans %}New Reservation{% endtrans %}
</a></li> </a></li>
{% endif %}
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}"> <li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}">
<i class="bi bi-calendar-today"></i> Today's Schedule <i class="bi bi-calendar-today"></i> {% trans %}Today's Schedule{% endtrans %}
</a></li> </a></li>
</ul> </ul>
</li> </li>
</ul> {% if current_user.is_authenticated and current_user.can_manage() %}
<ul class="navbar-nav">
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"> <a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-person-circle"></i> {{ current_user.first_name }} {{ current_user.last_name }} <i class="bi bi-shield-lock"></i> {% trans %}Management{% endtrans %}
</a> </a>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}"> <li><a class="dropdown-item" href="{{ url_for('admin.users_list') }}"><i class="bi bi-people me-2"></i>{% trans %}Users{% endtrans %}</a></li>
<i class="bi bi-person"></i> Profile <li><a class="dropdown-item" href="{{ url_for('admin.roles_list') }}"><i class="bi bi-person-badge me-2"></i>{% trans %}Roles & Groups{% endtrans %}</a></li>
</a></li> <li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2"></i>{% trans %}Buildings{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('auth.change_password') }}">
<i class="bi bi-key"></i> Change Password
</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}"> <li><a class="dropdown-item" href="{{ url_for('admin.google_sheets_import') }}"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>{% trans %}Import from Google Sheets{% endtrans %}</a></li>
<i class="bi bi-box-arrow-right"></i> Logout
</a></li>
</ul> </ul>
</li> </li>
</ul> {% endif %}
</ul>
<ul class="navbar-nav align-items-center">
<!-- Theme Mode Toggle Button -->
<li class="nav-item me-2">
<button class="theme-toggle-btn" type="button" aria-label="Cambiar tema" title="Modo Claro/Oscuro">
<i class="bi bi-moon-stars-fill"></i>
</button>
</li>
<ul class="navbar-nav">
<!-- Language Switcher --> <!-- Language Switcher -->
<li class="nav-item"> <li class="nav-item me-2">
{% include 'partials/language_switcher.html' %} {% include 'partials/language_switcher.html' %}
</li> </li>
<!-- User Dropdown with Avatar -->
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"> <a class="nav-link dropdown-toggle d-flex align-items-center py-1" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-person-circle"></i> {{ current_user.first_name }} {{ current_user.last_name }} <span class="user-avatar-small">{{ current_user.name[:1]|upper if current_user.name else 'U' }}</span>
<span>{{ current_user.first_name }}</span>
</a> </a>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end shadow-sm border-0">
<li>
<div class="px-3 py-2 border-bottom">
<small class="text-muted d-block">{% trans %}Signed in as{% endtrans %}</small>
<span class="fw-bold small">{{ current_user.email }}</span>
</div>
</li>
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}"> <li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">
<i class="bi bi-person"></i> Profile <i class="bi bi-person me-2"></i> {% trans %}Profile{% endtrans %}
</a></li> </a></li>
<li><a class="dropdown-item" href="{{ url_for('auth.change_password') }}"> <li><a class="dropdown-item" href="{{ url_for('auth.change_password') }}">
<i class="bi bi-key"></i> Change Password <i class="bi bi-key me-2"></i> {% trans %}Change Password{% endtrans %}
</a></li> </a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}"> <li><a class="dropdown-item text-danger" href="{{ url_for('auth.logout') }}">
<i class="bi bi-box-arrow-right"></i> Logout <i class="bi bi-box-arrow-right me-2"></i> {% trans %}Logout{% endtrans %}
</a></li> </a></li>
</ul> </ul>
</li> </li>
@@ -118,11 +153,11 @@
{% endif %} {% endif %}
<!-- Flash Messages --> <!-- Flash Messages -->
<div class="toast-container position-fixed bottom-0 end-0 p-3"> <div class="toast-container position-fixed bottom-0 end-0 p-3" style="z-index: 1080;">
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
{% for category, message in messages %} {% for category, message in messages %}
<div class="toast align-items-center text-white bg-{{ 'danger' if category == 'error' else 'success' if category == 'success' else 'info' if category == 'info' else 'warning' }} border-0" role="alert"> <div class="toast align-items-center text-white bg-{{ 'danger' if category == 'error' else 'success' if category == 'success' else 'info' if category == 'info' else 'warning' }} border-0 shadow" role="alert">
<div class="d-flex"> <div class="d-flex">
<div class="toast-body"> <div class="toast-body">
{{ message }} {{ message }}
@@ -142,6 +177,8 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- jQuery --> <!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script> <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<!-- Theme Toggle JS -->
<script src="{{ url_for('static', filename='js/theme-toggle.js') }}"></script>
<!-- Custom JS --> <!-- Custom JS -->
<script src="{{ url_for('static', filename='js/main.js') }}"></script> <script src="{{ url_for('static', filename='js/main.js') }}"></script>
+95
View File
@@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block title %}
{% if building %}{% trans %}Editar Edificio{% endtrans %} - {{ building.name }}{% else %}{% trans %}Nuevo Edificio{% endtrans %}{% endif %} - Edu-Space Admin
{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<div class="row">
<div class="col-lg-8 mx-auto">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">
<i class="bi bi-buildings text-primary me-2"></i>
{% if building %}{% trans %}Editar Edificio{% endtrans %}{% else %}{% trans %}Nuevo Edificio{% endtrans %}{% endif %}
</h1>
<a href="{{ url_for('buildings.list_buildings') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% trans %}Volver a Edificios{% endtrans %}
</a>
</div>
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary border-0 py-3">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-info-circle text-primary me-2"></i>{% trans %}Información del Edificio / Sede{% endtrans %}
</h5>
</div>
<div class="card-body p-4">
<form method="POST" novalidate>
{{ form.hidden_tag() }}
<div class="row g-3">
<div class="col-md-8">
{{ form.name.label(class="form-label fw-semibold") }}
{{ form.name(class="form-control" + (" is-invalid" if form.name.errors else ""), placeholder="ej. Edificio Central, Pabellón de Ciencias, Sede Norte") }}
{% if form.name.errors %}
<div class="invalid-feedback">{{ form.name.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-4">
{{ form.code.label(class="form-label fw-semibold") }}
{{ form.code(class="form-control" + (" is-invalid" if form.code.errors else ""), placeholder="ej. CENTRAL, VIRTUAL, PAB-A") }}
{% if form.code.errors %}
<div class="invalid-feedback">{{ form.code.errors[0] }}</div>
{% endif %}
<small class="text-muted">{% trans %}Código identificador corto{% endtrans %}</small>
</div>
<div class="col-md-8">
{{ form.address.label(class="form-label fw-semibold") }}
{{ form.address(class="form-control" + (" is-invalid" if form.address.errors else ""), placeholder="ej. Av. Paseo Colón 255, CABA") }}
{% if form.address.errors %}
<div class="invalid-feedback">{{ form.address.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-4">
{{ form.floors.label(class="form-label fw-semibold") }}
{{ form.floors(class="form-control" + (" is-invalid" if form.floors.errors else ""), min=1, max=100) }}
{% if form.floors.errors %}
<div class="invalid-feedback">{{ form.floors.errors[0] }}</div>
{% endif %}
<small class="text-muted">{% trans %}Cantidad de plantas/pisos{% endtrans %}</small>
</div>
<div class="col-12">
{{ form.description.label(class="form-label fw-semibold") }}
{{ form.description(class="form-control", rows=3, placeholder="Información relevante, accesos, comodidades o departamentos alojados...") }}
{% if form.description.errors %}
<div class="invalid-feedback">{{ form.description.errors[0] }}</div>
{% endif %}
</div>
<div class="col-12">
<div class="form-check form-switch pt-2">
{{ form.is_active(class="form-check-input", role="switch") }}
{{ form.is_active.label(class="form-check-label fw-semibold") }}
</div>
</div>
</div>
<div class="d-flex justify-content-end gap-2 mt-4 pt-3 border-top">
<a href="{{ url_for('buildings.list_buildings') }}" class="btn btn-outline-secondary">{% trans %}Cancelar{% endtrans %}</a>
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-check-circle me-1"></i>
{% if building %}{% trans %}Guardar Cambios{% endtrans %}{% else %}{% trans %}Crear Edificio{% endtrans %}{% endif %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+223
View File
@@ -0,0 +1,223 @@
{% extends "base.html" %}
{% block title %}{% trans %}Gestión de Edificios{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Header -->
<div class="row mb-4 align-items-center">
<div class="col-md-7">
<h1 class="h3 mb-1">
<i class="bi bi-buildings text-primary me-2"></i>{% trans %}Gestión de Edificios{% endtrans %}
</h1>
<p class="text-muted small mb-0">{% trans %}Administra las sedes, edificios físicos y campus virtuales de UniCABA{% endtrans %}</p>
</div>
<div class="col-md-5 text-md-end mt-3 mt-md-0">
{% if current_user.has_permission('classrooms', 'read_write') %}
<a href="{{ url_for('buildings.add_building') }}" class="btn btn-primary me-2">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nuevo Edificio{% endtrans %}
</a>
{% endif %}
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-door-open me-1"></i>{% trans %}Ver Aulas{% endtrans %}
</a>
</div>
</div>
<!-- Statistics Cards -->
<div class="row g-3 mb-4">
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="rounded-3 p-3 bg-primary bg-opacity-10 text-primary me-3">
<i class="bi bi-buildings fs-3"></i>
</div>
<div>
<div class="text-muted small fw-semibold">{% trans %}Total Edificios{% endtrans %}</div>
<h3 class="fw-bold mb-0">{{ buildings|length }}</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="rounded-3 p-3 bg-success bg-opacity-10 text-success me-3">
<i class="bi bi-door-open fs-3"></i>
</div>
<div>
<div class="text-muted small fw-semibold">{% trans %}Aulas Activas{% endtrans %}</div>
<h3 class="fw-bold mb-0 text-success">{{ total_classrooms }}</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="rounded-3 p-3 bg-info bg-opacity-10 text-info me-3">
<i class="bi bi-people fs-3"></i>
</div>
<div>
<div class="text-muted small fw-semibold">{% trans %}Capacidad Total{% endtrans %}</div>
<h3 class="fw-bold mb-0 text-info">{{ total_capacity }}</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="rounded-3 p-3 bg-warning bg-opacity-10 text-warning me-3">
<i class="bi bi-layers fs-3"></i>
</div>
<div>
<div class="text-muted small fw-semibold">{% trans %}Pisos Disponibles{% endtrans %}</div>
<h3 class="fw-bold mb-0 text-warning">{{ buildings|sum(attribute='floors') }}</h3>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Filter & Search Toolbar -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-3">
<form method="GET" class="row g-2 align-items-center">
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text bg-body-tertiary border-end-0">
<i class="bi bi-search text-muted"></i>
</span>
<input type="text" name="search" value="{{ search }}" class="form-control border-start-0" placeholder="{% trans %}Buscar por nombre, código o dirección...{% endtrans %}">
</div>
</div>
<div class="col-md-4">
<select name="status" class="form-select" onchange="this.form.submit()">
<option value="">{% trans %}Todos los estados{% endtrans %}</option>
<option value="active" {% if status_filter == 'active' %}selected{% endif %}>{% trans %}Activos únicamente{% endtrans %}</option>
<option value="inactive" {% if status_filter == 'inactive' %}selected{% endif %}>{% trans %}Inactivos{% endtrans %}</option>
</select>
</div>
<div class="col-md-2 d-flex gap-2">
<button type="submit" class="btn btn-primary w-100">{% trans %}Buscar{% endtrans %}</button>
{% if search or status_filter %}
<a href="{{ url_for('buildings.list_buildings') }}" class="btn btn-outline-secondary" title="{% trans %}Limpiar filtro{% endtrans %}">
<i class="bi bi-x-lg"></i>
</a>
{% endif %}
</div>
</form>
</div>
</div>
<!-- Buildings Cards List -->
<div class="row g-3">
{% for b in buildings %}
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100 position-relative">
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
<div>
<span class="badge bg-secondary-subtle text-secondary font-monospace border mb-1">
{{ b.code or 'EDIF' }}
</span>
<h5 class="card-title fw-bold mb-0">
<a href="{{ url_for('buildings.view_building', id=b.id) }}" class="text-decoration-none text-primary">
{{ b.name }}
</a>
</h5>
</div>
<div>
{% if b.is_active %}
<span class="badge bg-success-subtle text-success border border-success-subtle px-2 py-1">
<i class="bi bi-check-circle me-1"></i>{% trans %}Activo{% endtrans %}
</span>
{% else %}
<span class="badge bg-danger-subtle text-danger border border-danger-subtle px-2 py-1">
<i class="bi bi-x-circle me-1"></i>{% trans %}Inactivo{% endtrans %}
</span>
{% endif %}
</div>
</div>
<div class="card-body">
{% if b.address %}
<p class="text-muted small mb-3">
<i class="bi bi-geo-alt-fill text-danger me-1"></i>{{ b.address }}
</p>
{% endif %}
{% if b.description %}
<p class="small text-secondary mb-3">{{ b.description }}</p>
{% endif %}
<div class="row g-2 text-center pt-2 border-top">
<div class="col-4">
<div class="p-2 rounded bg-body-tertiary">
<small class="text-muted d-block">{% trans %}Pisos{% endtrans %}</small>
<span class="fw-bold fs-6">{{ b.floors }}</span>
</div>
</div>
<div class="col-4">
<div class="p-2 rounded bg-body-tertiary">
<small class="text-muted d-block">{% trans %}Aulas{% endtrans %}</small>
<span class="fw-bold fs-6 text-primary">{{ b.classrooms_count }}</span>
</div>
</div>
<div class="col-4">
<div class="p-2 rounded bg-body-tertiary">
<small class="text-muted d-block">{% trans %}Capacidad{% endtrans %}</small>
<span class="fw-bold fs-6 text-success">{{ b.total_capacity }}</span>
</div>
</div>
</div>
</div>
<div class="card-footer bg-transparent border-0 py-3 d-flex justify-content-between align-items-center">
<a href="{{ url_for('buildings.view_building', id=b.id) }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-eye me-1"></i>{% trans %}Ver Distribución de Aulas{% endtrans %}
</a>
{% if current_user.has_permission('classrooms', 'read_write') %}
<div class="btn-group">
<a href="{{ url_for('buildings.edit_building', id=b.id) }}" class="btn btn-outline-secondary btn-sm" title="{% trans %}Editar Edificio{% endtrans %}">
<i class="bi bi-pencil"></i>
</a>
<form method="POST" action="{{ url_for('buildings.toggle_building', id=b.id) }}" class="d-inline">
<button type="submit" class="btn btn-outline-secondary btn-sm" title="{% if b.is_active %}{% trans %}Desactivar{% endtrans %}{% else %}{% trans %}Activar{% endtrans %}{% endif %}">
<i class="bi bi-power"></i>
</button>
</form>
</div>
{% endif %}
</div>
</div>
</div>
{% else %}
<div class="col-12">
<div class="card border-0 shadow-sm p-5 text-center">
<i class="bi bi-buildings text-muted fs-1 mb-3"></i>
<h5 class="fw-bold">{% trans %}No se encontraron edificios{% endtrans %}</h5>
<p class="text-muted mb-3">{% trans %}No hay edificios que coincidan con los criterios de búsqueda.{% endtrans %}</p>
{% if current_user.has_permission('classrooms', 'read_write') %}
<div>
<a href="{{ url_for('buildings.add_building') }}" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Crear Nuevo Edificio{% endtrans %}
</a>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endblock %}
+177
View File
@@ -0,0 +1,177 @@
{% extends "base.html" %}
{% block title %}{{ building.name }} - {% trans %}Detalle del Edificio{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Breadcrumb & Header -->
<div class="row mb-4 align-items-center">
<div class="col-md-7">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1 small">
<li class="breadcrumb-item"><a href="{{ url_for('buildings.list_buildings') }}">{% trans %}Edificios{% endtrans %}</a></li>
<li class="breadcrumb-item active">{{ building.name }}</li>
</ol>
</nav>
<h1 class="h3 mb-0 d-flex align-items-center flex-wrap gap-2">
<i class="bi bi-building text-primary"></i> {{ building.name }}
<span class="badge bg-secondary-subtle text-secondary font-monospace border fs-6">{{ building.code or 'EDIF' }}</span>
{% if building.is_active %}
<span class="badge bg-success-subtle text-success border border-success-subtle fs-6">
<i class="bi bi-check-circle me-1"></i>{% trans %}Activo{% endtrans %}
</span>
{% else %}
<span class="badge bg-danger-subtle text-danger border border-danger-subtle fs-6">
<i class="bi bi-x-circle me-1"></i>{% trans %}Inactivo{% endtrans %}
</span>
{% endif %}
</h1>
</div>
<div class="col-md-5 text-md-end mt-3 mt-md-0">
{% if current_user.has_permission('classrooms', 'read_write') %}
<a href="{{ url_for('buildings.edit_building', id=building.id) }}" class="btn btn-outline-secondary me-2">
<i class="bi bi-pencil me-1"></i>{% trans %}Editar Edificio{% endtrans %}
</a>
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nueva Aula{% endtrans %}
</a>
{% endif %}
</div>
</div>
<!-- Overview Cards -->
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body p-3">
<div class="text-muted small fw-semibold">{% trans %}Ubicación / Dirección{% endtrans %}</div>
<p class="h6 fw-bold mb-2">
<i class="bi bi-geo-alt text-danger me-1"></i>{{ building.address or 'Sin dirección especificada' }}
</p>
{% if building.description %}
<small class="text-secondary">{{ building.description }}</small>
{% endif %}
</div>
</div>
</div>
<div class="col-md-8">
<div class="row g-3">
<div class="col-sm-4">
<div class="card border-0 shadow-sm text-center p-3">
<small class="text-muted text-uppercase fw-bold">{% trans %}Pisos{% endtrans %}</small>
<span class="display-6 fw-bold text-primary">{{ building.floors }}</span>
</div>
</div>
<div class="col-sm-4">
<div class="card border-0 shadow-sm text-center p-3">
<small class="text-muted text-uppercase fw-bold">{% trans %}Aulas Asociadas{% endtrans %}</small>
<span class="display-6 fw-bold text-success">{{ classrooms|length }}</span>
</div>
</div>
<div class="col-sm-4">
<div class="card border-0 shadow-sm text-center p-3">
<small class="text-muted text-uppercase fw-bold">{% trans %}Capacidad Total{% endtrans %}</small>
<span class="display-6 fw-bold text-info">{{ building.total_capacity }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Classrooms by Floor -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-layers text-primary me-2"></i>{% trans %}Distribución de Aulas por Piso{% endtrans %}
</h5>
<span class="badge badge-brand">{{ classrooms|length }} {% trans %}aulas configuradas{% endtrans %}</span>
</div>
<div class="card-body p-4">
{% if classrooms_by_floor %}
{% for floor_num in classrooms_by_floor|sort %}
<div class="mb-4">
<div class="d-flex align-items-center mb-3">
<span class="badge bg-dark bg-opacity-75 fs-6 px-3 py-2 me-2">
<i class="bi bi-layer-forward me-1"></i>
{% if floor_num == 0 %}Planta Baja{% else %}Piso {{ floor_num }}{% endif %}
</span>
<div class="border-bottom flex-grow-1"></div>
<small class="text-muted ms-2">{{ classrooms_by_floor[floor_num]|length }} aulas</small>
</div>
<div class="row g-3">
{% for c in classrooms_by_floor[floor_num] %}
<div class="col-md-6 col-xl-4">
<div class="card border border-light-subtle shadow-sm h-100">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-start mb-2">
<h6 class="fw-bold mb-0">
<a href="{{ url_for('classrooms.view_classroom', id=c.id) }}" class="text-decoration-none text-primary">
<i class="bi bi-door-open me-1"></i>{{ c.code }}
</a>
</h6>
{% if c.is_active %}
<span class="badge bg-success-subtle text-success border border-success-subtle small">Activa</span>
{% else %}
<span class="badge bg-danger-subtle text-danger border border-danger-subtle small">Inactiva</span>
{% endif %}
</div>
<div class="small text-muted mb-2">
<i class="bi bi-people me-1"></i>Capacidad: <strong>{{ c.capacity }}</strong> estudiantes
</div>
{% if c.description %}
<p class="small text-secondary mb-2 text-truncate">{{ c.description }}</p>
{% endif %}
<div class="d-flex justify-content-between align-items-center pt-2 border-top">
<a href="{{ url_for('classrooms.view_classroom', id=c.id) }}" class="btn btn-outline-primary btn-sm py-0 px-2">
<small>{% trans %}Ver Ficha{% endtrans %}</small>
</a>
{% if current_user.has_permission('classrooms', 'read_write') %}
<a href="{{ url_for('classrooms.edit_classroom', id=c.id) }}" class="btn btn-outline-secondary btn-sm py-0 px-2">
<small>{% trans %}Editar{% endtrans %}</small>
</a>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center py-5">
<i class="bi bi-door-closed text-muted fs-1 mb-2"></i>
<h6 class="fw-bold">{% trans %}No hay aulas asignadas a este edificio{% endtrans %}</h6>
<p class="text-muted small mb-3">{% trans %}Puedes crear aulas nuevas o editar las existentes para vincularlas aquí.{% endtrans %}</p>
{% if current_user.has_permission('classrooms', 'read_write') %}
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Agregar Primera Aula{% endtrans %}
</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
<!-- Danger Zone (Delete) -->
{% if current_user.has_permission('classrooms', 'read_write') %}
<div class="card border-danger border-opacity-25 shadow-sm mb-4">
<div class="card-body p-3 d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<h6 class="text-danger fw-bold mb-1">{% trans %}Eliminar Edificio{% endtrans %}</h6>
<small class="text-muted">{% trans %}Solo es posible eliminar edificios que no tengan aulas asignadas.{% endtrans %}</small>
</div>
<form method="POST" action="{{ url_for('buildings.delete_building', id=building.id) }}" onsubmit="return confirm('¿Estás seguro de que deseas eliminar este edificio?');">
<button type="submit" class="btn btn-outline-danger btn-sm" {% if classrooms|length > 0 %}disabled title="Tiene aulas asociadas"{% endif %}>
<i class="bi bi-trash me-1"></i>{% trans %}Eliminar Edificio{% endtrans %}
</button>
</form>
</div>
</div>
{% endif %}
</div>
{% endblock %}
+45 -25
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Add Classroom - Edu-Space Admin{% endblock %} {% block title %}{% trans %}Add Classroom{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid pt-5 mt-4"> <div class="container-fluid pt-5 mt-4">
@@ -8,10 +8,10 @@
<div class="col-12"> <div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3"> <h1 class="h3">
<i class="bi bi-plus-circle text-primary"></i> Add Classroom <i class="bi bi-plus-circle text-primary"></i> {% trans %}Add Classroom{% endtrans %}
</h1> </h1>
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Classrooms <i class="bi bi-arrow-left"></i> {% trans %}Back to Classrooms{% endtrans %}
</a> </a>
</div> </div>
</div> </div>
@@ -20,9 +20,9 @@
<div class="row"> <div class="row">
<div class="col-lg-8 mx-auto"> <div class="col-lg-8 mx-auto">
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0"> <div class="card-header bg-body-tertiary border-0 py-3">
<h5 class="mb-0"> <h5 class="mb-0 h6 fw-bold">
<i class="bi bi-door-open text-primary"></i> Classroom Information <i class="bi bi-door-open text-primary me-2"></i> {% trans %}Classroom Information{% endtrans %}
</h5> </h5>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -63,8 +63,19 @@
<div class="row"> <div class="row">
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
{{ form.capacity.label(class="form-label") }} <div class="d-flex justify-content-between align-items-center mb-1">
{{ form.capacity(class="form-control", placeholder="30") }} {{ form.capacity.label(class="form-label mb-0") }}
<button type="button" class="btn btn-sm btn-outline-primary py-0 px-2" id="btnInfinityAdd" style="font-size: 0.8rem;" title="{% trans %}Asignar capacidad ilimitada{% endtrans %}">
<i class="bi bi-infinity me-1"></i>{% trans %}Ilimitada (∞){% endtrans %}
</button>
</div>
<div class="input-group">
{{ form.capacity(class="form-control", id="capacity", placeholder="ej. 30 o ∞") }}
<span class="input-group-text bg-body-tertiary text-muted font-monospace"><i class="bi bi-people"></i></span>
</div>
<div class="form-text small text-muted mt-1">
<i class="bi bi-info-circle text-info me-1"></i>{% trans %}Para aulas virtuales o sin límite de aforo, ingrese <strong>∞</strong> o <strong>0</strong> (Capacidad ilimitada).{% endtrans %}
</div>
{% if form.capacity.errors %} {% if form.capacity.errors %}
<div class="text-danger small mt-1"> <div class="text-danger small mt-1">
{{ form.capacity.errors[0] }} {{ form.capacity.errors[0] }}
@@ -73,16 +84,17 @@
</div> </div>
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
{{ form.is_active.label(class="form-check-label") }} <label class="form-label d-block">&nbsp;</label>
<div class="form-check"> <div class="form-check pt-2">
{{ form.is_active(class="form-check-input") }} {{ form.is_active(class="form-check-input") }}
{{ form.is_active.label(class="form-check-label") }}
</div> </div>
</div> </div>
</div> </div>
<div class="mb-4"> <div class="mb-4">
{{ form.description.label(class="form-label") }} {{ form.description.label(class="form-label") }}
{{ form.description(class="form-control", rows="3", placeholder="Optional: Add any additional details about this classroom...") }} {{ form.description(class="form-control", rows="3", placeholder=_("Optional: Add any additional details about this classroom...")) }}
{% if form.description.errors %} {% if form.description.errors %}
<div class="text-danger small mt-1"> <div class="text-danger small mt-1">
{{ form.description.errors[0] }} {{ form.description.errors[0] }}
@@ -93,13 +105,13 @@
<!-- Preview Section --> <!-- Preview Section -->
<div class="alert alert-info"> <div class="alert alert-info">
<h6 class="alert-heading"> <h6 class="alert-heading">
<i class="bi bi-info-circle"></i> Preview <i class="bi bi-info-circle"></i> {% trans %}Preview{% endtrans %}
</h6> </h6>
<div id="classroom-preview"> <div id="classroom-preview">
<p class="mb-0">Fill in the form to see a preview of the classroom code: <p class="mb-1">{% trans %}Fill in the form to see a preview of the classroom code:{% endtrans %}
<span class="fw-bold" id="preview-code">-</span> <span class="fw-bold" id="preview-code">-</span>
</p> </p>
<p class="mb-0">Full location: <p class="mb-0">{% trans %}Full location:{% endtrans %}
<span class="fw-bold" id="preview-location">-</span> <span class="fw-bold" id="preview-location">-</span>
</p> </p>
</div> </div>
@@ -107,9 +119,11 @@
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle"></i> Cancel <i class="bi bi-x-circle"></i> {% trans %}Cancel{% endtrans %}
</a> </a>
{{ form.submit(class="btn btn-primary") }} <button type="submit" class="btn btn-primary">
<i class="bi bi-save me-1"></i> {{ form.submit.label.text }}
</button>
</div> </div>
</form> </form>
</div> </div>
@@ -118,16 +132,16 @@
</div> </div>
<!-- Resources Section --> <!-- Resources Section -->
<div class="row mt-4"> <div class="row mt-4 mb-4">
<div class="col-lg-8 mx-auto"> <div class="col-lg-8 mx-auto">
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0"> <div class="card-header bg-body-tertiary border-0 py-3">
<h5 class="mb-0"> <h5 class="mb-0 h6 fw-bold">
<i class="bi bi-tools text-warning"></i> Resources (Optional) <i class="bi bi-tools text-warning me-2"></i> {% trans %}Resources (Optional){% endtrans %}
</h5> </h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<p class="text-muted">You can add classroom resources after creating the classroom.</p> <p class="text-muted mb-0">{% trans %}You can add classroom resources after creating the classroom.{% endtrans %}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -142,10 +156,11 @@ $(document).ready(function() {
const building = $('#building').val(); const building = $('#building').val();
const roomNumber = $('#room_number').val(); const roomNumber = $('#room_number').val();
const floor = $('#floor').val(); const floor = $('#floor').val();
const floorLabel = '{{ _("Floor") }}';
if (building && roomNumber && floor) { if (building && roomNumber && floor) {
$('#preview-code').text(`${building}-${roomNumber}`); $('#preview-code').text(`${building}-${roomNumber}`);
$('#preview-location').text(`${building} - Floor ${floor}`); $('#preview-location').text(`${building} - ${floorLabel} ${floor}`);
} else { } else {
$('#preview-code').text('-'); $('#preview-code').text('-');
$('#preview-location').text('-'); $('#preview-location').text('-');
@@ -162,7 +177,6 @@ $(document).ready(function() {
$('#classroom-form').on('submit', function(e) { $('#classroom-form').on('submit', function(e) {
let isValid = true; let isValid = true;
// Required fields validation
if (!$('#building').val().trim()) { if (!$('#building').val().trim()) {
$('#building').addClass('is-invalid'); $('#building').addClass('is-invalid');
isValid = false; isValid = false;
@@ -177,14 +191,16 @@ $(document).ready(function() {
$('#room_number').removeClass('is-invalid'); $('#room_number').removeClass('is-invalid');
} }
if (!$('#floor').val()) { var floorVal = $('#floor').val();
if (floorVal === '' || isNaN(floorVal)) {
$('#floor').addClass('is-invalid'); $('#floor').addClass('is-invalid');
isValid = false; isValid = false;
} else { } else {
$('#floor').removeClass('is-invalid'); $('#floor').removeClass('is-invalid');
} }
if (!$('#capacity').val() || $('#capacity').val() < 1) { var capVal = $('#capacity').val() ? $('#capacity').val().trim() : '';
if (!capVal || (capVal !== '∞' && capVal !== '0' && isNaN(capVal))) {
$('#capacity').addClass('is-invalid'); $('#capacity').addClass('is-invalid');
isValid = false; isValid = false;
} else { } else {
@@ -193,6 +209,10 @@ $(document).ready(function() {
return isValid; return isValid;
}); });
$('#btnInfinityAdd').on('click', function() {
$('#capacity').val('∞').focus();
});
}); });
</script> </script>
{% endblock %} {% endblock %}
+162
View File
@@ -0,0 +1,162 @@
{% extends "base.html" %}
{% block title %}{% trans %}Edit Classroom{% endtrans %} - {{ classroom.code_display }} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Edit Classroom{% endtrans %}
</h1>
<p class="text-muted small mb-0">{{ classroom.building }} — {{ classroom.code }}</p>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}" class="btn btn-outline-primary">
<i class="bi bi-eye me-1"></i>{% trans %}Ver Ficha{% endtrans %}
</a>
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% trans %}Back to Classrooms{% endtrans %}
</a>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-8 mx-auto">
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-door-open text-primary me-2"></i>{% trans %}Classroom Information{% endtrans %}
</h5>
<span class="badge bg-secondary-subtle text-secondary font-monospace border">ID #{{ classroom.id }}</span>
</div>
<div class="card-body p-4">
<form method="POST" id="classroom-form" novalidate>
{{ form.hidden_tag() }}
<div class="row g-3">
<div class="col-md-4">
{{ form.building.label(class="form-label fw-semibold") }}
{{ form.building(class="form-control" + (" is-invalid" if form.building.errors else ""), placeholder="Edificio Central") }}
{% if form.building.errors %}
<div class="invalid-feedback">{{ form.building.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-4">
{{ form.room_number.label(class="form-label fw-semibold") }}
{{ form.room_number(class="form-control" + (" is-invalid" if form.room_number.errors else ""), placeholder="402") }}
{% if form.room_number.errors %}
<div class="invalid-feedback">{{ form.room_number.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-4">
{{ form.floor.label(class="form-label fw-semibold") }}
{{ form.floor(class="form-control" + (" is-invalid" if form.floor.errors else "")) }}
{% if form.floor.errors %}
<div class="invalid-feedback">{{ form.floor.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-1">
{{ form.capacity.label(class="form-label fw-semibold mb-0") }}
<button type="button" class="btn btn-sm btn-outline-primary py-0 px-2" id="btnInfinity" style="font-size: 0.8rem;" title="{% trans %}Asignar capacidad ilimitada{% endtrans %}">
<i class="bi bi-infinity me-1"></i>{% trans %}Ilimitada (∞){% endtrans %}
</button>
</div>
<div class="input-group">
{{ form.capacity(class="form-control" + (" is-invalid" if form.capacity.errors else ""), id="capacityInput", placeholder="ej. 35 o ∞") }}
<span class="input-group-text bg-body-tertiary text-muted font-monospace"><i class="bi bi-people"></i></span>
</div>
<div class="form-text small text-muted mt-1">
<i class="bi bi-info-circle text-info me-1"></i>{% trans %}Para aulas virtuales o eventos sin aforo restringido, ingrese <strong>∞</strong> o <strong>0</strong> (Capacidad ilimitada).{% endtrans %}
</div>
{% if form.capacity.errors %}
<div class="invalid-feedback d-block">{{ form.capacity.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-6 d-flex align-items-center pt-md-4">
<div class="form-check form-switch pt-2">
{{ form.is_active(class="form-check-input", role="switch") }}
{{ form.is_active.label(class="form-check-label fw-semibold") }}
</div>
</div>
<div class="col-12">
{{ form.description.label(class="form-label fw-semibold") }}
{{ form.description(class="form-control" + (" is-invalid" if form.description.errors else ""), rows="3", placeholder="Detalles sobre equipamiento o uso preferente del aula...") }}
{% if form.description.errors %}
<div class="invalid-feedback">{{ form.description.errors[0] }}</div>
{% endif %}
</div>
</div>
<!-- Live Preview Card -->
<div class="alert alert-info border-0 mt-4 mb-0">
<h6 class="alert-heading fw-bold mb-2">
<i class="bi bi-info-circle me-1"></i>{% trans %}Vista Previa de Identificador{% endtrans %}
</h6>
<div id="classroom-preview">
<p class="mb-0">
{% trans %}Código de visualización{% endtrans %}:
<strong id="preview-code" class="text-primary font-monospace fs-6">{{ classroom.code_display }}</strong>
</p>
</div>
</div>
<div class="d-flex justify-content-end gap-2 mt-4 pt-3 border-top">
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}" class="btn btn-outline-secondary">
{% trans %}Cancelar{% endtrans %}
</a>
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-check-circle me-1"></i>{% trans %}Guardar Cambios{% endtrans %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
var buildingInput = document.getElementById('building');
var roomInput = document.getElementById('room_number');
var previewCode = document.getElementById('preview-code');
var capacityInput = document.getElementById('capacityInput');
var btnInfinity = document.getElementById('btnInfinity');
function updatePreview() {
var b = buildingInput ? buildingInput.value.trim() : '';
var r = roomInput ? roomInput.value.trim() : '';
if (b && r) {
previewCode.textContent = b + '-' + r;
} else if (r) {
previewCode.textContent = r;
} else {
previewCode.textContent = '---';
}
}
if (buildingInput) buildingInput.addEventListener('input', updatePreview);
if (roomInput) roomInput.addEventListener('input', updatePreview);
if (btnInfinity && capacityInput) {
btnInfinity.addEventListener('click', function() {
capacityInput.value = '∞';
capacityInput.focus();
});
}
});
</script>
{% endblock %}
+32 -26
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Classrooms - Edu-Space Admin{% endblock %} {% block title %}{% trans %}Classrooms{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid pt-5 mt-4"> <div class="container-fluid pt-5 mt-4">
@@ -8,11 +8,11 @@
<div class="col-12"> <div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3"> <h1 class="h3">
<i class="bi bi-door-open text-primary"></i> Classrooms <i class="bi bi-door-open text-primary"></i> {% trans %}Classrooms{% endtrans %}
</h1> </h1>
<div> <div>
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary"> <a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add Classroom <i class="bi bi-plus-circle"></i> {% trans %}Add Classroom{% endtrans %}
</a> </a>
</div> </div>
</div> </div>
@@ -27,24 +27,24 @@
<form method="GET" class="row g-3"> <form method="GET" class="row g-3">
<div class="col-md-4"> <div class="col-md-4">
<input type="text" name="search" class="form-control" <input type="text" name="search" class="form-control"
placeholder="Search by building, room, description..." placeholder="{% trans %}Search by building, room, description...{% endtrans %}"
value="{{ search }}"> value="{{ search }}">
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<div class="form-check"> <div class="form-check pt-2">
<input class="form-check-input" type="checkbox" name="active_only" <input class="form-check-input" type="checkbox" name="active_only"
id="activeOnly" {% if active_only %}checked{% endif %}> id="activeOnly" {% if active_only %}checked{% endif %}>
<label class="form-check-label" for="activeOnly"> <label class="form-check-label" for="activeOnly">
Show active classrooms only {% trans %}Show active classrooms only{% endtrans %}
</label> </label>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<button type="submit" class="btn btn-outline-primary"> <button type="submit" class="btn btn-outline-primary">
<i class="bi bi-search"></i> Search <i class="bi bi-search"></i> {% trans %}Search{% endtrans %}
</button> </button>
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle"></i> Clear <i class="bi bi-x-circle"></i> {% trans %}Clear{% endtrans %}
</a> </a>
</div> </div>
</form> </form>
@@ -63,24 +63,30 @@
<div class="d-flex justify-content-between align-items-start mb-3"> <div class="d-flex justify-content-between align-items-start mb-3">
<div> <div>
<h5 class="card-title mb-1"> <h5 class="card-title mb-1">
{{ classroom.code }} {{ classroom.code_display }}
</h5> </h5>
<p class="text-muted small mb-2"> <p class="text-muted small mb-2">
<i class="bi bi-geo-alt"></i> {{ classroom.get_full_location() }} <i class="bi bi-geo-alt"></i> {{ classroom.get_full_location() }}
</p> </p>
</div> </div>
<span class="badge {% if classroom.is_active %}bg-success{% else %}bg-secondary{% endif %}"> <span class="badge {% if classroom.is_active %}bg-success{% else %}bg-secondary{% endif %}">
{% if classroom.is_active %}Active{% else %}Inactive{% endif %} {% if classroom.is_active %}{% trans %}Active{% endtrans %}{% else %}{% trans %}Inactive{% endtrans %}{% endif %}
</span> </span>
</div> </div>
<div class="row small"> <div class="row small">
<div class="col-6"> <div class="col-6">
<span class="text-muted">Capacity:</span> <span class="text-muted">{% trans %}Capacity{% endtrans %}:</span>
<strong>{{ classroom.capacity }}</strong> <strong>
{% if classroom.is_unlimited %}
<span class="badge bg-primary-subtle text-primary border"><i class="bi bi-infinity me-1"></i>∞</span>
{% else %}
{{ classroom.capacity }}
{% endif %}
</strong>
</div> </div>
<div class="col-6"> <div class="col-6">
<span class="text-muted">Building:</span> <span class="text-muted">{% trans %}Building{% endtrans %}:</span>
<strong>{{ classroom.building }}</strong> <strong>{{ classroom.building }}</strong>
</div> </div>
</div> </div>
@@ -96,24 +102,24 @@
<small class="text-muted"> <small class="text-muted">
<i class="bi bi-tools"></i> <i class="bi bi-tools"></i>
{% if classroom.resources %} {% if classroom.resources %}
{{ classroom.resources|length }} resources {{ classroom.resources|length }} {% trans %}resources{% endtrans %}
{% else %} {% else %}
No resources registered {% trans %}No resources registered{% endtrans %}
{% endif %} {% endif %}
</small> </small>
</div> </div>
</div> </div>
<div class="card-footer bg-white border-0"> <div class="card-footer bg-body-tertiary border-0">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div> <div>
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}" <a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}"
class="btn btn-sm btn-outline-primary"> class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> View <i class="bi bi-eye"></i> {% trans %}View{% endtrans %}
</a> </a>
<a href="{{ url_for('classrooms.edit_classroom', id=classroom.id) }}" <a href="{{ url_for('classrooms.edit_classroom', id=classroom.id) }}"
class="btn btn-sm btn-outline-secondary"> class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil"></i> Edit <i class="bi bi-pencil"></i> {% trans %}Edit{% endtrans %}
</a> </a>
</div> </div>
<form method="POST" action="{{ url_for('classrooms.toggle_classroom', id=classroom.id) }}" <form method="POST" action="{{ url_for('classrooms.toggle_classroom', id=classroom.id) }}"
@@ -121,9 +127,9 @@
<button type="submit" class="btn btn-sm <button type="submit" class="btn btn-sm
{% if classroom.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %}"> {% if classroom.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %}">
{% if classroom.is_active %} {% if classroom.is_active %}
<i class="bi bi-pause-circle"></i> Deactivate <i class="bi bi-pause-circle"></i> {% trans %}Deactivate{% endtrans %}
{% else %} {% else %}
<i class="bi bi-play-circle"></i> Activate <i class="bi bi-play-circle"></i> {% trans %}Activate{% endtrans %}
{% endif %} {% endif %}
</button> </button>
</form> </form>
@@ -141,7 +147,7 @@
{% if classrooms.has_prev %} {% if classrooms.has_prev %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.prev_num, search=search, active_only=active_only) }}"> <a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.prev_num, search=search, active_only=active_only) }}">
Previous {% trans %}Previous{% endtrans %}
</a> </a>
</li> </li>
{% endif %} {% endif %}
@@ -169,7 +175,7 @@
{% if classrooms.has_next %} {% if classrooms.has_next %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.next_num, search=search, active_only=active_only) }}"> <a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.next_num, search=search, active_only=active_only) }}">
Next {% trans %}Next{% endtrans %}
</a> </a>
</li> </li>
{% endif %} {% endif %}
@@ -182,16 +188,16 @@
<div class="col-12"> <div class="col-12">
<div class="text-center py-5"> <div class="text-center py-5">
<i class="bi bi-door-open text-muted" style="font-size: 4rem;"></i> <i class="bi bi-door-open text-muted" style="font-size: 4rem;"></i>
<h4 class="mt-3 text-muted">No classrooms found</h4> <h4 class="mt-3 text-muted">{% trans %}No classrooms found{% endtrans %}</h4>
<p class="text-muted"> <p class="text-muted">
{% if search %} {% if search %}
No classrooms match your search criteria. {% trans %}No classrooms match your search criteria.{% endtrans %}
{% else %} {% else %}
No classrooms have been registered yet. {% trans %}No classrooms have been registered yet.{% endtrans %}
{% endif %} {% endif %}
</p> </p>
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary"> <a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add First Classroom <i class="bi bi-plus-circle"></i> {% trans %}Add First Classroom{% endtrans %}
</a> </a>
</div> </div>
</div> </div>
+165
View File
@@ -0,0 +1,165 @@
{% extends "base.html" %}
{% block title %}{{ classroom.code_display }} - {% trans %}Classroom Details{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- Breadcrumb & Header -->
<div class="row mb-4 align-items-center">
<div class="col-md-7">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1 small">
<li class="breadcrumb-item"><a href="{{ url_for('classrooms.list_classrooms') }}">{% trans %}Classrooms{% endtrans %}</a></li>
<li class="breadcrumb-item active">{{ classroom.code_display }}</li>
</ol>
</nav>
<h1 class="h3 mb-0 d-flex align-items-center flex-wrap gap-2">
<i class="bi bi-door-open text-primary"></i> {{ classroom.code_display }}
{% if classroom.is_active %}
<span class="badge bg-success-subtle text-success border border-success-subtle fs-6">
<i class="bi bi-check-circle me-1"></i>{% trans %}Active{% endtrans %}
</span>
{% else %}
<span class="badge bg-danger-subtle text-danger border border-danger-subtle fs-6">
<i class="bi bi-x-circle me-1"></i>{% trans %}Inactive{% endtrans %}
</span>
{% endif %}
</h1>
</div>
<div class="col-md-5 text-md-end mt-3 mt-md-0">
{% if current_user.has_permission('classrooms', 'read_write') %}
<a href="{{ url_for('classrooms.edit_classroom', id=classroom.id) }}" class="btn btn-primary me-2">
<i class="bi bi-pencil me-1"></i>{% trans %}Edit Classroom{% endtrans %}
</a>
{% endif %}
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% trans %}Back to Classrooms{% endtrans %}
</a>
</div>
</div>
<!-- Classroom Details Cards -->
<div class="row g-3 mb-4">
<div class="col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-body-tertiary border-0 py-3">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-info-circle text-primary me-2"></i>{% trans %}Ficha Técnica{% endtrans %}
</h5>
</div>
<div class="card-body p-3">
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-building me-2"></i>{% trans %}Edificio / Sede{% endtrans %}</span>
<span class="fw-bold">
{% if classroom.building_entity %}
<a href="{{ url_for('buildings.view_building', id=classroom.building_entity.id) }}" class="text-decoration-none">
{{ classroom.building_entity.name }}
</a>
{% else %}
{{ classroom.building }}
{% endif %}
</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-door-closed me-2"></i>{% trans %}Número / Nombre{% endtrans %}</span>
<span class="fw-bold font-monospace">{{ classroom.code }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-layers me-2"></i>{% trans %}Piso / Planta{% endtrans %}</span>
<span class="fw-bold">{% if classroom.floor == 0 %}Planta Baja{% else %}Piso {{ classroom.floor }}{% endif %}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-people me-2"></i>{% trans %}Capacidad Máxima{% endtrans %}</span>
{% if classroom.is_unlimited %}
<span class="badge bg-primary-subtle text-primary border px-2 py-1 fs-6">
<i class="bi bi-infinity me-1"></i>{% trans %}Ilimitada (∞){% endtrans %}
</span>
{% else %}
<span class="fw-bold text-success fs-6">{{ classroom.capacity }} {% trans %}estudiantes{% endtrans %}</span>
{% endif %}
</li>
</ul>
{% if classroom.description %}
<div class="mt-3 pt-3 border-top">
<small class="text-muted fw-bold d-block mb-1">{% trans %}Descripción / Notas{% endtrans %}:</small>
<p class="small text-secondary mb-0">{{ classroom.description }}</p>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-lg-8">
<!-- Upcoming Schedule / Reservations -->
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-calendar-check text-primary me-2"></i>{% trans %}Próximas Clases y Reservas{% endtrans %}
</h5>
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-calendar-week me-1"></i>{% trans %}Ver en Calendario{% endtrans %}
</a>
</div>
<div class="card-body p-0">
{% if upcoming_reservations %}
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light small text-uppercase">
<tr>
<th class="ps-3">{% trans %}Horario{% endtrans %}</th>
<th>{% trans %}Materia / Motivo{% endtrans %}</th>
<th>{% trans %}Comisión{% endtrans %}</th>
<th>{% trans %}Asistentes{% endtrans %}</th>
<th class="text-end pe-3">{% trans %}Acciones{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for res in upcoming_reservations %}
<tr>
<td class="ps-3">
<div class="fw-bold font-monospace text-primary fs-6">
<i class="bi bi-clock text-warning me-1"></i>{{ res.start_time.strftime('%H:%M') }} - {{ res.end_time.strftime('%H:%M') }} hs
</div>
</td>
<td>
<div class="fw-bold text-primary">{{ res.subject_name }}</div>
<small class="badge bg-secondary-subtle text-secondary font-monospace">{{ res.subject_code }}</small>
</td>
<td>
<span class="badge bg-info-subtle text-info border">
{{ res.commission.code if res.commission else 'S/C' }}
</span>
</td>
<td>
<span class="small">{{ res.expected_attendees }} / {{ classroom.capacity }}</span>
</td>
<td class="text-end pe-3">
<a href="{{ url_for('schedule.view_reservation', id=res.id) }}" class="btn btn-outline-secondary btn-sm py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-calendar-x text-muted fs-1 mb-2"></i>
<h6 class="fw-bold">{% trans %}Sin reservas programadas próximamente{% endtrans %}</h6>
<p class="text-muted small mb-3">{% trans %}Esta aula no tiene clases asignadas en las próximas fechas.{% endtrans %}</p>
{% if current_user.has_permission('reservations', 'read_write') %}
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Asignar Nueva Reserva{% endtrans %}
</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+137 -84
View File
@@ -8,77 +8,105 @@
{% endif %} {% endif %}
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12"> <div class="col-12">
<h1 class="h3 mb-0"> <h1 class="h3 mb-1">
<i class="bi bi-speedometer2 text-primary"></i> {% trans %}Dashboard{% endtrans %} <i class="bi bi-speedometer2 text-primary me-2"></i>{% trans %}Panel de Control{% endtrans %}
<small class="text-muted ms-2">{% trans %}Overview{% endtrans %}</small>
</h1> </h1>
<p class="text-muted small mb-0">{% trans %}Resumen general de espacios, materias y reservas académicas{% endtrans %}</p>
</div> </div>
</div> </div>
<!-- Statistics Cards --> <!-- Statistics Cards with Direct Shortcuts -->
<div class="row mb-4"> <div class="row mb-4 g-3">
<div class="col-md-3 mb-3"> <!-- 1. Total Aulas -->
<div class="card border-0 shadow-sm"> <div class="col-md-6 col-xl-3">
<div class="card-body"> <a href="{{ url_for('classrooms.list_classrooms') }}" class="text-decoration-none text-reset d-block h-100">
<div class="card border-0 shadow-sm h-100 dashboard-stat-card transition-all">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div> <div>
<h6 class="text-muted mb-2">{% trans %}Total Classrooms{% endtrans %}</h6> <span class="text-muted small fw-semibold text-uppercase">{% trans %}Total de Aulas{% endtrans %}</span>
<h2 class="mb-0">{{ stats.total_classrooms }}</h2> <h2 class="mb-0 fw-bold mt-1 text-primary">{{ stats.total_classrooms }}</h2>
</div>
<div class="bg-primary bg-opacity-10 rounded-circle p-3">
<i class="bi bi-door-open text-primary fs-4"></i>
</div> </div>
<div class="bg-primary bg-opacity-10 rounded-circle p-3 text-primary">
<i class="bi bi-door-open fs-3"></i>
</div>
</div>
<div class="d-flex align-items-center justify-content-between pt-3 mt-2 border-top text-primary small fw-semibold">
<span>{% trans %}Ver todas las aulas{% endtrans %}</span>
<i class="bi bi-arrow-right"></i>
</div> </div>
</div> </div>
</div> </div>
</a>
</div> </div>
<div class="col-md-3 mb-3"> <!-- 2. Total Materias -->
<div class="card border-0 shadow-sm"> <div class="col-md-6 col-xl-3">
<div class="card-body"> <a href="{{ url_for('schedule.commissions_view') }}" class="text-decoration-none text-reset d-block h-100">
<div class="card border-0 shadow-sm h-100 dashboard-stat-card transition-all">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div> <div>
<h6 class="text-muted mb-2">{% trans %}Total Subjects{% endtrans %}</h6> <span class="text-muted small fw-semibold text-uppercase">{% trans %}Total de Materias{% endtrans %}</span>
<h2 class="mb-0">{{ stats.total_subjects }}</h2> <h2 class="mb-0 fw-bold mt-1 text-success">{{ stats.total_subjects }}</h2>
</div>
<div class="bg-success bg-opacity-10 rounded-circle p-3">
<i class="bi bi-book text-success fs-4"></i>
</div> </div>
<div class="bg-success bg-opacity-10 rounded-circle p-3 text-success">
<i class="bi bi-book fs-3"></i>
</div>
</div>
<div class="d-flex align-items-center justify-content-between pt-3 mt-2 border-top text-success small fw-semibold">
<span>{% trans %}Gestionar materias{% endtrans %}</span>
<i class="bi bi-arrow-right"></i>
</div> </div>
</div> </div>
</div> </div>
</a>
</div> </div>
<div class="col-md-3 mb-3"> <!-- 3. Reservas de Hoy -->
<div class="card border-0 shadow-sm"> <div class="col-md-6 col-xl-3">
<div class="card-body"> <a href="{{ url_for('schedule.today_schedule') }}" class="text-decoration-none text-reset d-block h-100">
<div class="card border-0 shadow-sm h-100 dashboard-stat-card transition-all">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div> <div>
<h6 class="text-muted mb-2">{% trans %}Today's Reservations{% endtrans %}</h6> <span class="text-muted small fw-semibold text-uppercase">{% trans %}Reservas de Hoy{% endtrans %}</span>
<h2 class="mb-0">{{ stats.today_reservations }}</h2> <h2 class="mb-0 fw-bold mt-1 text-info">{{ stats.today_reservations }}</h2>
</div>
<div class="bg-info bg-opacity-10 rounded-circle p-3">
<i class="bi bi-calendar-today text-info fs-4"></i>
</div> </div>
<div class="bg-info bg-opacity-10 rounded-circle p-3 text-info">
<i class="bi bi-calendar-check fs-3"></i>
</div>
</div>
<div class="d-flex align-items-center justify-content-between pt-3 mt-2 border-top text-info small fw-semibold">
<span>{% trans %}Ver cartelera de hoy{% endtrans %}</span>
<i class="bi bi-arrow-right"></i>
</div> </div>
</div> </div>
</div> </div>
</a>
</div> </div>
<div class="col-md-3 mb-3"> <!-- 4. Esta Semana -->
<div class="card border-0 shadow-sm"> <div class="col-md-6 col-xl-3">
<div class="card-body"> <a href="{{ url_for('schedule.calendar_view') }}" class="text-decoration-none text-reset d-block h-100">
<div class="card border-0 shadow-sm h-100 dashboard-stat-card transition-all">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div> <div>
<h6 class="text-muted mb-2">{% trans %}This Week{% endtrans %}</h6> <span class="text-muted small fw-semibold text-uppercase">{% trans %}Esta Semana{% endtrans %}</span>
<h2 class="mb-0">{{ stats.week_reservations }}</h2> <h2 class="mb-0 fw-bold mt-1 text-warning">{{ stats.week_reservations }}</h2>
</div>
<div class="bg-warning bg-opacity-10 rounded-circle p-3">
<i class="bi bi-calendar-week text-warning fs-4"></i>
</div> </div>
<div class="bg-warning bg-opacity-10 rounded-circle p-3 text-warning">
<i class="bi bi-calendar-week fs-3"></i>
</div>
</div>
<div class="d-flex align-items-center justify-content-between pt-3 mt-2 border-top text-warning small fw-semibold">
<span>{% trans %}Abrir calendario semanal{% endtrans %}</span>
<i class="bi bi-arrow-right"></i>
</div> </div>
</div> </div>
</div> </div>
</a>
</div> </div>
</div> </div>
@@ -86,9 +114,9 @@
<!-- Monthly Chart --> <!-- Monthly Chart -->
<div class="col-md-8 mb-4"> <div class="col-md-8 mb-4">
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0"> <div class="card-header border-0 py-3 bg-body-tertiary">
<h5 class="mb-0"> <h5 class="mb-0 h6 fw-bold">
<i class="bi bi-graph-up text-primary"></i> Monthly Reservations <i class="bi bi-graph-up text-primary me-2"></i>{% trans %}Reservas Mensuales{% endtrans %}
</h5> </h5>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -100,31 +128,35 @@
<!-- Quick Actions --> <!-- Quick Actions -->
<div class="col-md-4 mb-4"> <div class="col-md-4 mb-4">
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0"> <div class="card-header border-0 py-3 bg-body-tertiary">
<h5 class="mb-0"> <h5 class="mb-0 h6 fw-bold">
<i class="bi bi-lightning text-warning"></i> Quick Actions <i class="bi bi-lightning text-warning me-2"></i>{% trans %}Accesos Rápidos{% endtrans %}
</h5> </h5>
</div> </div>
<div class="card-body"> <div class="card-body p-3">
<div class="d-grid gap-2"> <div class="d-grid gap-2">
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-outline-primary"> <a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary text-start">
<i class="bi bi-plus-circle"></i> Add Classroom <i class="bi bi-calendar3 me-2"></i>{% trans %}Calendario de Reservas{% endtrans %}
</a> </a>
<a href="{{ url_for('schedule.commissions_view') }}" class="btn btn-outline-warning"> <a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info text-start">
<i class="bi bi-people"></i> Manage Commissions <i class="bi bi-calendar-day me-2"></i>{% trans %}Cartelera del Día{% endtrans %}
</a> </a>
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-outline-success"> <a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary text-start">
<i class="bi bi-calendar-plus"></i> New Reservation <i class="bi bi-door-open me-2"></i>{% trans %}Listado de Aulas{% endtrans %}
</a> </a>
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info"> <a href="{{ url_for('buildings.list_buildings') }}" class="btn btn-outline-secondary text-start">
<i class="bi bi-calendar-today"></i> Today's Schedule <i class="bi bi-buildings me-2"></i>{% trans %}Gestión de Edificios{% endtrans %}
</a> </a>
<a href="{{ url_for('genetic_algorithm_web.genetic_optimizer') }}" class="btn btn-info text-white"> {% if current_user.has_permission('reservations', 'read_write') %}
<i class="bi bi-cpu"></i> AI Room Optimizer <a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-outline-success text-start">
<i class="bi bi-calendar-plus me-2"></i>{% trans %}Nueva Reserva{% endtrans %}
</a> </a>
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-secondary"> {% endif %}
<i class="bi bi-calendar3"></i> View Calendar {% if current_user.has_permission('classrooms', 'read_write') %}
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-outline-primary text-start">
<i class="bi bi-plus-circle me-2"></i>{% trans %}Crear Nueva Aula{% endtrans %}
</a> </a>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@@ -135,46 +167,66 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0"> <div class="card-header border-0 py-3 bg-body-tertiary d-flex justify-content-between align-items-center">
<div class="d-flex justify-content-between align-items-center"> <h5 class="mb-0 h6 fw-bold">
<h5 class="mb-0"> <i class="bi bi-clock-history text-info me-2"></i>{% trans %}Próximas Reservas (Próximos 7 Días){% endtrans %}
<i class="bi bi-clock-history text-info"></i> Upcoming Reservations (Next 7 Days)
</h5> </h5>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-sm btn-outline-primary"> <a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-sm btn-outline-primary">
View All {% trans %}Ver en Calendario{% endtrans %}
</a> </a>
</div> </div>
</div> <div class="card-body p-0">
<div class="card-body">
{% if upcoming_reservations %} {% if upcoming_reservations %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover"> <table class="table table-hover align-middle mb-0">
<thead> <thead class="table-light small text-uppercase">
<tr> <tr>
<th>Date & Time</th> <th class="ps-3">{% trans %}Horario{% endtrans %}</th>
<th>Classroom</th> <th>{% trans %}Aula{% endtrans %}</th>
<th>Subject</th> <th>{% trans %}Asignatura / Código{% endtrans %}</th>
<th>Teacher</th> <th>{% trans %}Turno / Comisión{% endtrans %}</th>
<th>Status</th> <th>{% trans %}Estado{% endtrans %}</th>
<th>Actions</th> <th class="text-end pe-3">{% trans %}Acciones{% endtrans %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for reservation in upcoming_reservations %} {% for reservation in upcoming_reservations %}
<tr> <tr>
<td>{{ reservation.start_time.strftime('%Y-%m-%d %H:%M') }}</td> <td class="ps-3">
<td>{{ reservation.classroom.code }}</td> <div class="fw-bold font-monospace text-primary fs-6">
<td>{{ reservation.commission.get_full_code() }}</td> <i class="bi bi-clock text-warning me-1"></i>{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }} hs
<td>{{ reservation.commission.teacher_name }}</td> </div>
</td>
<td> <td>
<span class="badge bg-{{ 'success' if reservation.status.value == 'CONFIRMED' else 'warning' }}"> <span class="badge bg-secondary-subtle text-secondary border font-monospace">
{{ reservation.status.value }} {{ reservation.classroom.code if reservation.classroom else 'Sin Aula' }}
</span> </span>
</td> </td>
<td> <td>
<a href="{{ url_for('schedule.list_reservations') }}" <div class="fw-semibold text-primary">{{ reservation.subject_name }}</div>
class="btn btn-sm btn-outline-primary"> <small class="text-muted font-monospace">{{ reservation.subject_code }}</small>
<i class="bi bi-eye"></i> View </td>
<td>
<span class="badge bg-info-subtle text-info border me-1">
{{ reservation.shift or 'Tarde' }}
</span>
<small class="text-muted">{{ reservation.commission.code if reservation.commission else 'C1' }}</small>
</td>
<td>
{% if reservation.status == 'CONFIRMED' or reservation.status.value == 'CONFIRMED' %}
<span class="badge bg-success-subtle text-success border border-success-subtle">
<i class="bi bi-check-circle me-1"></i>{% trans %}Confirmada{% endtrans %}
</span>
{% else %}
<span class="badge bg-warning-subtle text-warning border border-warning-subtle">
<i class="bi bi-clock me-1"></i>{% trans %}Pendiente{% endtrans %}
</span>
{% endif %}
</td>
<td class="text-end pe-3">
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}"
class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
<i class="bi bi-eye"></i>
</a> </a>
</td> </td>
</tr> </tr>
@@ -183,9 +235,10 @@
</table> </table>
</div> </div>
{% else %} {% else %}
<div class="text-center py-4"> <div class="text-center py-5">
<i class="bi bi-calendar-x text-muted" style="font-size: 3rem;"></i> <i class="bi bi-calendar-x text-muted fs-1 d-block mb-2"></i>
<p class="text-muted mt-3">No upcoming reservations in the next 7 days</p> <h6 class="fw-bold">{% trans %}No hay reservas para los próximos 7 días{% endtrans %}</h6>
<p class="text-muted small">{% trans %}Puedes programar nuevas reservas desde el calendario.{% endtrans %}</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
+108 -190
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}AI Room Reservation Optimizer{% endblock %} {% block title %}{% trans %}AI Room Reservation Optimizer{% endtrans %} - Edu-Space Admin{% endblock %}
{% block extra_css %} {% block extra_css %}
<style> <style>
@@ -13,27 +13,24 @@
} }
.commission-item { .commission-item {
border-left: 4px solid #007bff; border-left: 4px solid var(--bs-primary);
transition: all 0.2s ease; transition: all 0.2s ease;
background-color: var(--bs-body-bg);
} }
.commission-item:hover { .commission-item:hover {
border-left-color: #0056b3; border-left-color: var(--bs-info);
background-color: #f8f9fa; background-color: rgba(var(--bs-primary-rgb), 0.05);
} }
.commission-item.selected { .commission-item.selected {
border-left-color: #28a745; border-left-color: var(--bs-success);
background-color: #d4edda; background-color: rgba(var(--bs-success-rgb), 0.1);
} }
.algorithm-status { .algorithm-status {
position: sticky; position: sticky;
top: 20px; top: 90px;
}
.loading-spinner {
display: none;
} }
.spinner-overlay { .spinner-overlay {
@@ -42,99 +39,93 @@
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
background-color: rgba(0, 0, 0, 0.5); background-color: rgba(0, 0, 0, 0.7);
display: none; display: none;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
z-index: 9999; z-index: 9999;
} }
.fitness-meter { .hero-banner {
height: 20px; background: linear-gradient(135deg, rgba(37, 99, 235, 0.2) 0%, rgba(30, 41, 59, 0.9) 100%);
background: linear-gradient(to right, #dc3545, #ffc107, #28a745); border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px; border-radius: 12px;
position: relative;
}
.fitness-indicator {
position: absolute;
top: -5px;
width: 30px;
height: 30px;
background: white;
border: 3px solid #007bff;
border-radius: 50%;
transform: translateX(-50%);
transition: left 0.5s ease;
} }
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="container-fluid"> <div class="container-fluid pt-5 mt-4">
<!-- Header --> <!-- Header -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12"> <div class="col-12">
<div class="card bg-primary text-white"> <div class="card hero-banner border-0 shadow-sm p-3">
<div class="card-body"> <div class="card-body">
<h1 class="card-title mb-3"> <div class="d-flex align-items-center mb-2">
<i class="fas fa-brain me-2"></i> <div class="bg-primary bg-opacity-25 rounded-3 p-3 me-3 text-primary">
AI Room Reservation Optimizer <i class="bi bi-cpu fs-2"></i>
</div>
<div>
<h1 class="h3 mb-1 fw-bold">
{% trans %}AI Room Reservation Optimizer{% endtrans %}
</h1> </h1>
<p class="card-text mb-0"> <p class="text-muted mb-0">
Use genetic algorithms to automatically optimize room assignments based on capacity, availability, and student enrollment. {% trans %}Use genetic algorithms to automatically optimize room assignments based on capacity, availability, and student enrollment.{% endtrans %}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<!-- Algorithm Status Card --> <!-- Algorithm Status & Commission Selection -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col-lg-4"> <!-- Algorithm Status Card -->
<div class="card algorithm-status"> <div class="col-lg-4 mb-4">
<div class="card-header bg-info text-white"> <div class="card border-0 shadow-sm algorithm-status">
<h5 class="mb-0"> <div class="card-header bg-body-tertiary border-0 py-3">
<i class="fas fa-cogs me-2"></i> <h5 class="mb-0 h6 fw-bold">
Algorithm Status <i class="bi bi-gear-wide-connected text-primary me-2"></i>
{% trans %}Algorithm Status{% endtrans %}
</h5> </h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="mb-3"> <div class="mb-4">
<small class="text-muted">System Status</small> <small class="text-muted text-uppercase fw-semibold d-block mb-2">{% trans %}System Status{% endtrans %}</small>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center py-1 border-bottom border-body-secondary">
<span>Available Classrooms</span> <span>{% trans %}Available Classrooms{% endtrans %}</span>
<span class="badge bg-success" id="available-classrooms">--</span> <span class="badge bg-success" id="available-classrooms">--</span>
</div> </div>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center py-1 border-bottom border-body-secondary">
<span>Active Commissions</span> <span>{% trans %}Active Commissions{% endtrans %}</span>
<span class="badge bg-info" id="active-commissions">--</span> <span class="badge bg-info" id="active-commissions">--</span>
</div> </div>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center py-1">
<span>Existing Reservations</span> <span>{% trans %}Existing Reservations{% endtrans %}</span>
<span class="badge bg-secondary" id="existing-reservations">--</span> <span class="badge bg-secondary" id="existing-reservations">--</span>
</div> </div>
</div> </div>
<div class="mb-3"> <div class="mb-4">
<small class="text-muted">Algorithm Configuration</small> <small class="text-muted text-uppercase fw-semibold d-block mb-2">{% trans %}Algorithm Configuration{% endtrans %}</small>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center py-1 border-bottom border-body-secondary">
<span>Population Size</span> <span>{% trans %}Population Size{% endtrans %}</span>
<span class="text-muted">50</span> <span class="text-muted">50</span>
</div> </div>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center py-1 border-bottom border-body-secondary">
<span>Generations</span> <span>{% trans %}Generations{% endtrans %}</span>
<span class="text-muted">100</span> <span class="text-muted">100</span>
</div> </div>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center py-1">
<span>Mutation Rate</span> <span>{% trans %}Mutation Rate{% endtrans %}</span>
<span class="text-muted">10%</span> <span class="text-muted">10%</span>
</div> </div>
</div> </div>
<button class="btn btn-sm btn-outline-primary w-100" onclick="geneticManager.getAlgorithmStatus()"> <button class="btn btn-sm btn-outline-primary w-100" onclick="geneticManager.getAlgorithmStatus()">
<i class="fas fa-sync-alt me-1"></i> <i class="bi bi-arrow-clockwise me-1"></i>
Refresh Status {% trans %}Refresh Status{% endtrans %}
</button> </button>
</div> </div>
</div> </div>
@@ -142,34 +133,27 @@
<!-- Commission Selection --> <!-- Commission Selection -->
<div class="col-lg-8"> <div class="col-lg-8">
<div class="card"> <div class="card border-0 shadow-sm">
<div class="card-header bg-light"> <div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0"> <h5 class="mb-0 h6 fw-bold">
<i class="fas fa-list-check me-2"></i> <i class="bi bi-list-check text-primary me-2"></i>
Select Commissions to Optimize {% trans %}Select Commissions to Optimize{% endtrans %}
</h5> </h5>
</div> <div class="d-flex gap-2">
<div class="card-body">
<div class="mb-3">
<div class="d-flex gap-2 mb-2">
<button class="btn btn-sm btn-outline-primary" onclick="selectAllCommissions()"> <button class="btn btn-sm btn-outline-primary" onclick="selectAllCommissions()">
Select All {% trans %}Select All{% endtrans %}
</button> </button>
<button class="btn btn-sm btn-outline-secondary" onclick="deselectAllCommissions()"> <button class="btn btn-sm btn-outline-secondary" onclick="deselectAllCommissions()">
Deselect All {% trans %}Deselect All{% endtrans %}
</button>
<button class="btn btn-sm btn-outline-info" onclick="filterCommissions()">
<i class="fas fa-filter me-1"></i>
Filter
</button> </button>
</div> </div>
</div> </div>
<div class="card-body">
<div id="commissions-list" class="row"> <div id="commissions-list" class="row">
<!-- Commissions will be loaded here --> <!-- Commissions will be loaded here -->
<div class="col-12 text-center text-muted"> <div class="col-12 text-center text-muted py-5">
<i class="fas fa-spinner fa-spin me-2"></i> <div class="spinner-border spinner-border-sm me-2 text-primary" role="status"></div>
Loading commissions... {% trans %}Loading commissions...{% endtrans %}
</div> </div>
</div> </div>
</div> </div>
@@ -180,45 +164,45 @@
<!-- Optimization Controls --> <!-- Optimization Controls -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12"> <div class="col-12">
<div class="card"> <div class="card border-0 shadow-sm">
<div class="card-header bg-primary text-white"> <div class="card-header bg-body-tertiary border-0 py-3">
<h5 class="mb-0"> <h5 class="mb-0 h6 fw-bold">
<i class="fas fa-play-circle me-2"></i> <i class="bi bi-sliders text-primary me-2"></i>
Optimization Controls {% trans %}Optimization Controls{% endtrans %}
</h5> </h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row align-items-center"> <div class="row align-items-center mb-3">
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
<label for="start-date" class="form-label">Start Date Range</label> <label for="start-date" class="form-label">{% trans %}Start Date Range{% endtrans %}</label>
<input type="datetime-local" class="form-control" id="start-date" name="start_date"> <input type="datetime-local" class="form-control" id="start-date" name="start_date">
</div> </div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
<label for="end-date" class="form-label">End Date Range</label> <label for="end-date" class="form-label">{% trans %}End Date Range{% endtrans %}</label>
<input type="datetime-local" class="form-control" id="end-date" name="end_date"> <input type="datetime-local" class="form-control" id="end-date" name="end_date">
</div> </div>
</div> </div>
</div> </div>
<div class="d-flex gap-2 flex-wrap"> <div class="d-flex gap-2 flex-wrap">
<button class="btn btn-info" id="preview-optimization-btn"> <button class="btn btn-info text-white" id="preview-optimization-btn">
<i class="fas fa-eye me-2"></i> <i class="bi bi-eye me-1"></i>
Preview Optimization {% trans %}Preview Optimization{% endtrans %}
</button> </button>
<button class="btn btn-success" id="optimize-assignments-btn"> <button class="btn btn-success" id="optimize-assignments-btn">
<i class="fas fa-rocket me-2"></i> <i class="bi bi-play-circle me-1"></i>
Run Full Optimization {% trans %}Run Full Optimization{% endtrans %}
</button> </button>
<button class="btn btn-warning" id="apply-optimization-btn" style="display: none;"> <button class="btn btn-warning" id="apply-optimization-btn" style="display: none;">
<i class="fas fa-check me-2"></i> <i class="bi bi-check-circle me-1"></i>
Apply Reservations {% trans %}Apply Reservations{% endtrans %}
</button> </button>
<button class="btn btn-secondary" onclick="resetOptimization()"> <button class="btn btn-outline-secondary" onclick="resetOptimization()">
<i class="fas fa-undo me-2"></i> <i class="bi bi-arrow-counterclockwise me-1"></i>
Reset {% trans %}Reset{% endtrans %}
</button> </button>
</div> </div>
</div> </div>
@@ -229,13 +213,8 @@
<!-- Results Section --> <!-- Results Section -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12"> <div class="col-12">
<!-- Preview Results -->
<div id="preview-results"></div> <div id="preview-results"></div>
<!-- Optimization Results -->
<div id="optimization-results"></div> <div id="optimization-results"></div>
<!-- Alert Container -->
<div id="alert-container"></div> <div id="alert-container"></div>
</div> </div>
</div> </div>
@@ -244,11 +223,9 @@
<!-- Loading Spinner Overlay --> <!-- Loading Spinner Overlay -->
<div class="spinner-overlay" id="loading-spinner"> <div class="spinner-overlay" id="loading-spinner">
<div class="text-center text-white"> <div class="text-center text-white">
<div class="spinner-border" style="width: 3rem; height: 3rem;" role="status"> <div class="spinner-border text-primary" style="width: 3rem; height: 3rem;" role="status"></div>
<span class="visually-hidden">Optimizing...</span> <p class="mt-3 mb-1 fw-bold fs-5">{% trans %}Running genetic algorithm optimization...{% endtrans %}</p>
</div> <small class="text-white-50">{% trans %}This may take a few moments{% endtrans %}</small>
<p class="mt-3 mb-0">Running genetic algorithm optimization...</p>
<small class="d-block mt-2">This may take a few moments</small>
</div> </div>
</div> </div>
@@ -257,17 +234,17 @@
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="detailsModalLabel">Reservation Details</h5> <h5 class="modal-title" id="detailsModalLabel">{% trans %}Reservation Details{% endtrans %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<!-- Content will be populated dynamically --> <!-- Content populated dynamically -->
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans %}Close{% endtrans %}</button>
<button type="button" class="btn btn-primary" onclick="downloadReservations()"> <button type="button" class="btn btn-primary" onclick="downloadReservations()">
<i class="fas fa-download me-2"></i> <i class="bi bi-download me-1"></i>
Download {% trans %}Download{% endtrans %}
</button> </button>
</div> </div>
</div> </div>
@@ -278,7 +255,6 @@
{% block extra_js %} {% block extra_js %}
<script src="{{ url_for('static', filename='js/genetic-algorithm.js') }}"></script> <script src="{{ url_for('static', filename='js/genetic-algorithm.js') }}"></script>
<script> <script>
// Additional utility functions for the genetic optimizer interface
function selectAllCommissions() { function selectAllCommissions() {
const checkboxes = document.querySelectorAll('input[name="commission_ids"]'); const checkboxes = document.querySelectorAll('input[name="commission_ids"]');
checkboxes.forEach(cb => { checkboxes.forEach(cb => {
@@ -306,78 +282,45 @@ function updateCommissionCard(commissionId, selected) {
} }
} }
function filterCommissions() {
// Implement filtering logic
console.log('Filter commissions feature not implemented yet');
}
function resetOptimization() { function resetOptimization() {
deselectAllCommissions(); deselectAllCommissions();
geneticManager.clearResults(); if (window.geneticManager) {
geneticManager.clearPreview(); geneticManager.clearResults && geneticManager.clearResults();
geneticManager.clearPreview && geneticManager.clearPreview();
}
document.getElementById('start-date').value = ''; document.getElementById('start-date').value = '';
document.getElementById('end-date').value = ''; document.getElementById('end-date').value = '';
} }
function downloadReservations() {
const reservations = geneticManager.getPendingReservations();
if (!reservations || reservations.length === 0) {
alert('No reservations to download');
return;
}
// Create CSV content
const headers = ['Commission ID', 'Classroom ID', 'Purpose', 'Start Time', 'End Time', 'Expected Attendees'];
const csvContent = [
headers.join(','),
...reservations.map(res => [
res.commission_id,
res.classroom_id,
`"${res.purpose}"`,
new Date(res.start_time).toISOString(),
new Date(res.end_time).toISOString(),
res.expected_attendees
].join(','))
].join('\n');
// Download file
const blob = new Blob([csvContent], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `optimization_results_${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
}
// Load commissions on page load
document.addEventListener('DOMContentLoaded', async function() { document.addEventListener('DOMContentLoaded', async function() {
await loadCommissions(); await loadCommissions();
if (window.geneticManager) {
await geneticManager.getAlgorithmStatus(); await geneticManager.getAlgorithmStatus();
}
}); });
async function loadCommissions() { async function loadCommissions() {
try { try {
const response = await fetch('/api/genetic/commissions'); const response = await fetch('/api/genetic/commissions');
const data = await response.json(); const data = await response.json();
const commissionsList = document.getElementById('commissions-list'); const commissionsList = document.getElementById('commissions-list');
const isSpanish = '{{ session.get("language", "es") }}' === 'es';
if (data.success && data.commissions && data.commissions.length > 0) { if (data.success && data.commissions && data.commissions.length > 0) {
const commissionsHtml = data.commissions.map(commission => ` const commissionsHtml = data.commissions.map(commission => `
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<div class="card commission-item" data-commission-id="${commission.id}"> <div class="card commission-item border-0 shadow-sm" data-commission-id="${commission.id}">
<div class="card-body"> <div class="card-body">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" name="commission_ids" <input class="form-check-input" type="checkbox" name="commission_ids"
value="${commission.id}" id="commission-${commission.id}" value="${commission.id}" id="commission-${commission.id}"
onchange="updateCommissionCard('${commission.id}', this.checked)"> onchange="updateCommissionCard('${commission.id}', this.checked)">
<label class="form-check-label" for="commission-${commission.id}"> <label class="form-check-label" for="commission-${commission.id}">
<strong>${commission.get_full_code || commission.code}</strong> <strong class="text-primary">${commission.get_full_code || commission.code}</strong>
<br> <br>
<small class="text-muted">${commission.subject?.name || 'Unknown Subject'}</small> <small class="text-muted">${commission.subject?.name || (isSpanish ? 'Materia Desconocida' : 'Unknown Subject')}</small>
<br> <br>
<span class="badge bg-info">${commission.current_students || 0}/${commission.max_students} students</span> <span class="badge bg-info mt-1">${commission.current_students || 0}/${commission.max_students} ${isSpanish ? 'estudiantes' : 'students'}</span>
${commission.teacher_name ? `<span class="badge bg-secondary ms-2">${commission.teacher_name}</span>` : ''} ${commission.teacher_name ? `<span class="badge bg-secondary ms-2">${commission.teacher_name}</span>` : ''}
</label> </label>
</div> </div>
@@ -389,46 +332,21 @@ const response = await fetch('/api/genetic/commissions');
commissionsList.innerHTML = commissionsHtml; commissionsList.innerHTML = commissionsHtml;
} else { } else {
commissionsList.innerHTML = ` commissionsList.innerHTML = `
<div class="col-12 text-center text-muted"> <div class="col-12 text-center text-muted py-5">
<i class="fas fa-info-circle me-2"></i> <i class="bi bi-inbox fs-1 d-block mb-2"></i>
No active commissions found. ${isSpanish ? 'No se encontraron comisiones activas.' : 'No active commissions found.'}
</div> </div>
`; `;
} }
} catch (error) { } catch (error) {
console.error('Error loading commissions:', error); console.error('Error loading commissions:', error);
document.getElementById('commissions-list').innerHTML = ` document.getElementById('commissions-list').innerHTML = `
<div class="col-12 text-center text-danger"> <div class="col-12 text-center text-danger py-4">
<i class="fas fa-exclamation-triangle me-2"></i> <i class="bi bi-exclamation-triangle fs-2 d-block mb-2"></i>
Error loading commissions: ${error.message} Error al cargar comisiones: ${error.message}
</div> </div>
`; `;
} }
} }
// Override the genetic algorithm status handler
const originalGetAlgorithmStatus = geneticManager.getAlgorithmStatus;
geneticManager.getAlgorithmStatus = async function() {
try {
const result = await originalGetAlgorithmStatus.call(this);
if (result.success) {
const data = result.data;
document.getElementById('available-classrooms').textContent = data.system_status.available_classrooms;
document.getElementById('active-commissions').textContent = data.system_status.active_commissions;
document.getElementById('existing-reservations').textContent = data.system_status.confirmed_reservations;
}
} catch (error) {
console.error('Error updating algorithm status:', error);
}
};
// Override the loading function to use our custom spinner
geneticManager.showLoading = function(show) {
const spinner = document.getElementById('loading-spinner');
if (spinner) {
spinner.style.display = show ? 'flex' : 'none';
}
};
</script> </script>
{% endblock %} {% endblock %}
+19 -12
View File
@@ -1,25 +1,32 @@
{% if config.LANGUAGES|length > 1 %} {% if config.LANGUAGES|length > 1 %}
<div class="dropdown language-switcher"> <div class="dropdown language-switcher">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" id="languageDropdown" data-bs-toggle="dropdown" aria-expanded="false"> <button class="btn btn-outline-secondary btn-sm dropdown-toggle d-flex align-items-center" type="button" id="languageDropdown" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-globe"></i> <i class="bi bi-globe me-1"></i>
{% if session.language == 'es' or (session.language is none and request.accept_languages.best_match(['es', 'en']) == 'es') %} <span>
Spanish {% if current_lang == 'es' %}
Español
{% else %} {% else %}
English English
{% endif %} {% endif %}
</span>
</button> </button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="languageDropdown"> <ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="languageDropdown">
{% set current_lang = session.language or request.accept_languages.best_match(['es', 'en']) or 'en' %}
<li> <li>
<a class="dropdown-item {% if current_lang == 'en' %}active{% endif %}" <a class="dropdown-item d-flex align-items-center justify-content-between {% if current_lang == 'es' %}active{% endif %}"
href="{{ url_for('main.set_language', language='en') }}"> href="{{ url_for('main.set_language', language='es') }}">
<i class="bi bi-check-circle-fill text-primary me-2"></i>English <span>Español</span>
{% if current_lang == 'es' %}
<i class="bi bi-check-lg ms-2 text-white"></i>
{% endif %}
</a> </a>
</li> </li>
<li> <li>
<a class="dropdown-item {% if current_lang == 'es' %}active{% endif %}" <a class="dropdown-item d-flex align-items-center justify-content-between {% if current_lang == 'en' %}active{% endif %}"
href="{{ url_for('main.set_language', language='es') }}"> href="{{ url_for('main.set_language', language='en') }}">
<i class="bi bi-check-circle-fill text-primary me-2"></i>Español <span>English</span>
{% if current_lang == 'en' %}
<i class="bi bi-check-lg ms-2 text-white"></i>
{% endif %}
</a> </a>
</li> </li>
</ul> </ul>
+95 -53
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Add Reservation - Edu-Space Admin{% endblock %} {% block title %}{% trans %}New Reservation{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid pt-5 mt-4"> <div class="container-fluid pt-5 mt-4">
@@ -8,10 +8,10 @@
<div class="col-12"> <div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3"> <h1 class="h3">
<i class="bi bi-calendar-plus text-success"></i> New Reservation <i class="bi bi-calendar-plus text-success"></i> {% trans %}New Reservation{% endtrans %}
</h1> </h1>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Reservations <i class="bi bi-arrow-left"></i> {% trans %}Back to Reservations{% endtrans %}
</a> </a>
</div> </div>
</div> </div>
@@ -20,9 +20,9 @@
<div class="row"> <div class="row">
<div class="col-lg-8 mx-auto"> <div class="col-lg-8 mx-auto">
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0"> <div class="card-header border-0 py-3">
<h5 class="mb-0"> <h5 class="mb-0">
<i class="bi bi-calendar3 text-success"></i> Reservation Details <i class="bi bi-calendar3 text-success"></i> {% trans %}Reservation Details{% endtrans %}
</h5> </h5>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -38,7 +38,7 @@
{{ form.classroom_id.errors[0] }} {{ form.classroom_id.errors[0] }}
</div> </div>
{% endif %} {% endif %}
<small class="text-muted">Select a classroom for your reservation</small> <small class="text-muted">{% trans %}Select a classroom for your reservation{% endtrans %}</small>
</div> </div>
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
@@ -49,7 +49,7 @@
{{ form.commission_id.errors[0] }} {{ form.commission_id.errors[0] }}
</div> </div>
{% endif %} {% endif %}
<small class="text-muted">Select the subject/commission</small> <small class="text-muted">{% trans %}Select the subject/commission{% endtrans %}</small>
</div> </div>
</div> </div>
@@ -62,7 +62,7 @@
{{ form.start_time.errors[0] }} {{ form.start_time.errors[0] }}
</div> </div>
{% endif %} {% endif %}
<small class="text-muted">When will the class start?</small> <small class="text-muted">{% trans %}When will the class start?{% endtrans %}</small>
</div> </div>
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
@@ -73,14 +73,14 @@
{{ form.end_time.errors[0] }} {{ form.end_time.errors[0] }}
</div> </div>
{% endif %} {% endif %}
<small class="text-muted">When will the class end?</small> <small class="text-muted">{% trans %}When will the class end?{% endtrans %}</small>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-8 mb-3"> <div class="col-md-8 mb-3">
{{ form.purpose.label(class="form-label") }} {{ form.purpose.label(class="form-label") }}
{{ form.purpose(class="form-control", placeholder="Regular class, exam, study session, etc.") }} {{ form.purpose(class="form-control", placeholder=_("Regular class, exam, study session, etc.")) }}
{% if form.purpose.errors %} {% if form.purpose.errors %}
<div class="text-danger small mt-1"> <div class="text-danger small mt-1">
{{ form.purpose.errors[0] }} {{ form.purpose.errors[0] }}
@@ -96,13 +96,13 @@
{{ form.expected_attendees.errors[0] }} {{ form.expected_attendees.errors[0] }}
</div> </div>
{% endif %} {% endif %}
<small class="text-muted">Number of expected attendees</small> <small class="text-muted">{% trans %}Number of expected attendees{% endtrans %}</small>
</div> </div>
</div> </div>
<div class="mb-4"> <div class="mb-4">
{{ form.notes.label(class="form-label") }} {{ form.notes.label(class="form-label") }}
{{ form.notes(class="form-control", rows="3", placeholder="Any additional notes or special requirements...") }} {{ form.notes(class="form-control", rows="3", placeholder=_("Any additional notes or special requirements...")) }}
{% if form.notes.errors %} {% if form.notes.errors %}
<div class="text-danger small mt-1"> <div class="text-danger small mt-1">
{{ form.notes.errors[0] }} {{ form.notes.errors[0] }}
@@ -113,27 +113,27 @@
<!-- Summary Section --> <!-- Summary Section -->
<div class="alert alert-info" id="reservation-summary"> <div class="alert alert-info" id="reservation-summary">
<h6 class="alert-heading"> <h6 class="alert-heading">
<i class="bi bi-info-circle"></i> Reservation Summary <i class="bi bi-info-circle"></i> {% trans %}Reservation Summary{% endtrans %}
</h6> </h6>
<div id="summary-content"> <div id="summary-content">
<p class="mb-2"> <p class="mb-2">
<strong>Selected Classroom:</strong> <strong>{% trans %}Selected Classroom:{% endtrans %}</strong>
<span id="summary-classroom">Not selected</span> <span id="summary-classroom">{% trans %}Not selected{% endtrans %}</span>
</p> </p>
<p class="mb-2"> <p class="mb-2">
<strong>Duration:</strong> <strong>{% trans %}Duration:{% endtrans %}</strong>
<span id="summary-duration">Not calculated</span> <span id="summary-duration">{% trans %}Not calculated{% endtrans %}</span>
</p> </p>
<p class="mb-0"> <p class="mb-0">
<strong>Commission:</strong> <strong>{% trans %}Commission:{% endtrans %}</strong>
<span id="summary-commission">Not selected</span> <span id="summary-commission">{% trans %}Not selected{% endtrans %}</span>
</p> </p>
</div> </div>
</div> </div>
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle"></i> Cancel <i class="bi bi-x-circle"></i> {% trans %}Cancel{% endtrans %}
</a> </a>
{{ form.submit(class="btn btn-success") }} {{ form.submit(class="btn btn-success") }}
</div> </div>
@@ -147,29 +147,29 @@
<div class="row mt-4"> <div class="row mt-4">
<div class="col-lg-8 mx-auto"> <div class="col-lg-8 mx-auto">
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0"> <div class="card-header border-0 py-3">
<h5 class="mb-0"> <h5 class="mb-0">
<i class="bi bi-question-circle text-info"></i> Reservation Guidelines <i class="bi bi-question-circle text-info"></i> {% trans %}Reservation Guidelines{% endtrans %}
</h5> </h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<h6 class="text-primary">📅 Booking Rules</h6> <h6 class="text-primary">📅 {% trans %}Booking Rules{% endtrans %}</h6>
<ul class="small"> <ul class="small">
<li>Reservations can be made up to 3 months in advance</li> <li>{% trans %}Reservations can be made up to 3 months in advance{% endtrans %}</li>
<li>Cannot book in the past</li> <li>{% trans %}Cannot book in the past{% endtrans %}</li>
<li>Maximum duration: 8 hours per reservation</li> <li>{% trans %}Maximum duration: 8 hours per reservation{% endtrans %}</li>
<li>Cancellations must be 24 hours before start time</li> <li>{% trans %}Cancellations must be 24 hours before start time{% endtrans %}</li>
</ul> </ul>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<h6 class="text-warning">🚨 Important Notes</h6> <h6 class="text-warning">🚨 {% trans %}Important Notes{% endtrans %}</h6>
<ul class="small"> <ul class="small">
<li>Check classroom capacity before booking</li> <li>{% trans %}Check classroom capacity before booking{% endtrans %}</li>
<li>Pending reservations require approval</li> <li>{% trans %}Pending reservations require approval{% endtrans %}</li>
<li>Time slots must not overlap</li> <li>{% trans %}Time slots must not overlap{% endtrans %}</li>
<li>System automatically detects conflicts</li> <li>{% trans %}System automatically detects conflicts{% endtrans %}</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -181,17 +181,55 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script type="application/json" id="classroom-data">
{
{% for classroom in classroom_list %}
"{{ classroom.id }}": {
"capacity": {{ classroom.capacity }},
"code_display": {{ classroom.code_display|tojson }},
"name": {{ classroom.name|tojson }}
}{% if not loop.last %},{% endif %}
{% endfor %}
}
</script>
<script type="application/json" id="i18n-strings">
{
"not_selected": "{% trans %}Not selected{% endtrans %}",
"not_calculated": "{% trans %}Not calculated{% endtrans %}",
"invalid_time": "{% trans %}Invalid time range{% endtrans %}",
"hour": "{% trans %}hour{% endtrans %}",
"hours": "{% trans %}hours{% endtrans %}",
"minute": "{% trans %}minute{% endtrans %}",
"minutes": "{% trans %}minutes{% endtrans %}",
"capacity": "{% trans %}Capacity{% endtrans %}",
"capacity_exceeded": "{% trans %}Expected attendees exceeds classroom capacity{% endtrans %}"
}
</script>
<script> <script>
$(document).ready(function() { $(document).ready(function() {
// Classroom data for capacity checking // Classroom data for capacity checking
const classroomData = {}; let classroomData = {};
{% for classroom in classroom_list %} try {
classroomData[{{ classroom.id }}] = { const rawData = document.getElementById('classroom-data')?.textContent;
capacity: {{ classroom.capacity }}, if (rawData) {
code_display: "{{ classroom.code_display }}", classroomData = JSON.parse(rawData);
name: "{{ classroom.name }}" }
}; } catch (err) {
{% endfor %} console.error('Error parsing classroomData:', err);
}
// I18n strings
let i18n = {};
try {
const rawI18n = document.getElementById('i18n-strings')?.textContent;
if (rawI18n) {
i18n = JSON.parse(rawI18n);
}
} catch (err) {
console.error('Error parsing i18n:', err);
}
function updateSummary() { function updateSummary() {
const classroomId = $('#classroom-select').val(); const classroomId = $('#classroom-select').val();
@@ -199,11 +237,12 @@ $(document).ready(function() {
const startTime = $('#start-time').val(); const startTime = $('#start-time').val();
const endTime = $('#end-time').val(); const endTime = $('#end-time').val();
// Update classroom summary // Update classroom summary
if (classroomId && classroomData[classroomId]) { if (classroomId && classroomData[classroomId]) {
$('#summary-classroom').text(`${classroomData[classroomId].code_display} - ${classroomData[classroomId].name} (Capacity: ${classroomData[classroomId].capacity})`); const capLabel = i18n.capacity || 'Capacidad';
$('#summary-classroom').text(`${classroomData[classroomId].code_display} - ${classroomData[classroomId].name} (${capLabel}: ${classroomData[classroomId].capacity})`);
} else { } else {
$('#summary-classroom').text('Not selected'); $('#summary-classroom').text(i18n.not_selected || 'No seleccionada');
} }
// Update commission summary // Update commission summary
@@ -211,7 +250,7 @@ $(document).ready(function() {
const commissionText = $('#commission-select option:selected').text(); const commissionText = $('#commission-select option:selected').text();
$('#summary-commission').text(commissionText); $('#summary-commission').text(commissionText);
} else { } else {
$('#summary-commission').text('Not selected'); $('#summary-commission').text(i18n.not_selected || 'No seleccionada');
} }
// Calculate duration // Calculate duration
@@ -226,19 +265,21 @@ $(document).ready(function() {
let durationText = ''; let durationText = '';
if (diffHours > 0) { if (diffHours > 0) {
durationText += `${diffHours} hour${diffHours > 1 ? 's' : ''}`; const hLabel = diffHours > 1 ? (i18n.hours || 'horas') : (i18n.hour || 'hora');
durationText += `${diffHours} ${hLabel}`;
} }
if (diffMins > 0) { if (diffMins > 0) {
if (durationText) durationText += ' '; if (durationText) durationText += ' ';
durationText += `${diffMins} minute${diffMins > 1 ? 's' : ''}`; const mLabel = diffMins > 1 ? (i18n.minutes || 'minutos') : (i18n.minute || 'minuto');
durationText += `${diffMins} ${mLabel}`;
} }
$('#summary-duration').text(durationText); $('#summary-duration').text(durationText || '0 min');
} else { } else {
$('#summary-duration').text('Invalid time range'); $('#summary-duration').text(i18n.invalid_time || 'Rango horario inválido');
} }
} else { } else {
$('#summary-duration').text('Not calculated'); $('#summary-duration').text(i18n.not_calculated || 'No calculada');
} }
} }
@@ -252,9 +293,10 @@ $(document).ready(function() {
if (attendees > capacity) { if (attendees > capacity) {
$('#expected_attendees').addClass('is-invalid'); $('#expected_attendees').addClass('is-invalid');
const msg = i18n.capacity_exceeded || 'Los asistentes esperados superan la capacidad del aula';
$('#expected_attendees').after(` $('#expected_attendees').after(`
<div class="invalid-feedback"> <div class="invalid-feedback">
Expected attendees (${attendees}) exceeds classroom capacity (${capacity}) ${msg} (${attendees} > ${capacity})
</div> </div>
`); `);
} else { } else {
+510 -98
View File
@@ -1,68 +1,226 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Schedule Calendar - Edu-Space Admin{% endblock %} {% block title %}{% trans %}Reservations Calendar{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %} {% block content %}
<div class="row mb-4"> <div class="container-fluid pt-5 mt-4">
<div class="col-12"> <!-- Header -->
<h1 class="h3 mb-0"> <div class="row mb-4 align-items-center">
<i class="bi bi-calendar3 text-primary"></i> Schedule Calendar <div class="col-md-7">
<small class="text-muted ms-2">View & Manage Reservations</small> <h1 class="h3 mb-1">
<i class="bi bi-calendar3 text-primary me-2"></i>{% trans %}Reservations Calendar{% endtrans %}
</h1> </h1>
<p class="text-muted small mb-0">{% trans %}View & Manage Reservations{% endtrans %} — {% trans %}Horarios de cursada y asignación de aulas UniCABA{% endtrans %}</p>
</div> </div>
</div> <div class="col-md-5 text-md-end mt-3 mt-md-0">
{% if current_user.has_permission('reservations', 'read_write') %}
<div class="card border-0 shadow-sm"> <a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-primary me-2">
<div class="card-header bg-white border-0"> <i class="bi bi-plus-circle me-1"></i>{% trans %}Add Reservation{% endtrans %}
<div class="row align-items-center">
<div class="col-md-6">
<h5 class="mb-0">
<i class="bi bi-calendar-week text-info"></i> Reservations Calendar
</h5>
</div>
<div class="col-md-6 text-end">
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add Reservation
</a> </a>
{% endif %}
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
<i class="bi bi-list"></i> List View <i class="bi bi-list me-1"></i>{% trans %}List View{% endtrans %}
</a> </a>
</div> </div>
</div> </div>
<!-- Filters Toolbar (Classroom & Subject Filters to prevent overlapping) -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-3">
<div class="row g-2 align-items-center">
<!-- Filter by Classroom -->
<div class="col-lg-3 col-md-6">
<label for="filterClassroom" class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-door-open text-success me-1"></i>{% trans %}Aula{% endtrans %}
</label>
<select id="filterClassroom" class="form-select form-select-sm">
<option value="">{% trans %}Todas las aulas{% endtrans %}</option>
{% for c in classrooms %}
<option value="{{ c.id }}">{{ c.code }} ({{ c.building }})</option>
{% endfor %}
</select>
</div> </div>
<div class="card-body">
<!-- Legend --> <!-- Filter by Subject -->
<div class="mb-3"> <div class="col-lg-3 col-md-6">
<div class="d-flex gap-3 flex-wrap"> <label for="filterSubject" class="form-label small fw-bold text-muted mb-1">
<div class="d-flex align-items-center"> <i class="bi bi-book text-primary me-1"></i>{% trans %}Materia{% endtrans %}
<span class="badge bg-success me-2" style="width: 20px; height: 20px;"></span> </label>
<small class="text-muted">Confirmed</small> <select id="filterSubject" class="form-select form-select-sm">
<option value="">{% trans %}Todas las materias{% endtrans %}</option>
{% for s in subjects %}
<option value="{{ s.id }}">{{ s.name }} [{{ s.code }}]</option>
{% endfor %}
</select>
</div> </div>
<div class="d-flex align-items-center">
<span class="badge bg-warning me-2" style="width: 20px; height: 20px;"></span> <!-- Filter by Career -->
<small class="text-muted">Pending</small> <div class="col-lg-3 col-md-6">
<label for="filterCareer" class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-mortarboard text-info me-1"></i>{% trans %}Carrera{% endtrans %}
</label>
<select id="filterCareer" class="form-select form-select-sm">
<option value="">{% trans %}Todas las carreras{% endtrans %}</option>
{% for cr in careers %}
<option value="{{ cr.id }}">{{ cr.name }}</option>
{% endfor %}
</select>
</div>
<!-- Filter by Turno (Mañana / Tarde) -->
<div class="col-lg-3 col-md-6">
<label for="filterShift" class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-brightness-high text-warning me-1"></i>{% trans %}Turno{% endtrans %}
</label>
<select id="filterShift" class="form-select form-select-sm">
<option value="">{% trans %}Todos los turnos{% endtrans %}</option>
<option value="Mañana">{% trans %}Turno Mañana{% endtrans %}</option>
<option value="Tarde">{% trans %}Turno Tarde / Vespertino{% endtrans %}</option>
</select>
</div>
</div>
<!-- Second row of filters: Floor & Hide Virtual -->
<div class="row g-2 align-items-center mt-2 pt-2 border-top">
<!-- Filter by Floor -->
<div class="col-lg-3 col-md-4">
<label for="filterFloor" class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-layers text-secondary me-1"></i>{% trans %}Filtrar por Piso{% endtrans %}
</label>
<select id="filterFloor" class="form-select form-select-sm">
<option value="">{% trans %}Todos los pisos{% endtrans %}</option>
{% for f in available_floors %}
<option value="{{ f }}">{% if f == 0 %}{% trans %}Planta Baja (Piso 0){% endtrans %}{% else %}{% trans %}Piso{% endtrans %} {{ f }}{% endif %}</option>
{% endfor %}
</select>
</div>
<!-- Hide Virtual Classrooms Toggle -->
<div class="col-lg-6 col-md-5 pt-md-3">
<div class="form-check form-switch pt-1">
<input class="form-check-input" type="checkbox" role="switch" id="filterHideVirtual">
<label class="form-check-label fw-semibold small text-dark" for="filterHideVirtual">
<i class="bi bi-laptop me-1 text-primary"></i>{% trans %}Ocultar Aulas Virtuales{% endtrans %}
<span class="badge bg-secondary-subtle text-secondary border ms-1" style="font-size: 0.72rem;">{% trans %}Capacidad ilimitada{% endtrans %}</span>
</label>
</div>
</div>
<!-- Reset Filters Button -->
<div class="col-lg-3 col-md-3 text-md-end pt-md-3">
<button type="button" id="btnResetFilters" class="btn btn-outline-secondary btn-sm px-3" title="{% trans %}Restablecer filtros{% endtrans %}">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% trans %}Limpiar Filtros{% endtrans %}
</button>
</div>
</div>
<!-- Active filter indicator & counter -->
<div class="d-flex align-items-center flex-wrap gap-2 mt-2 pt-2 border-top">
<small class="text-muted fw-semibold">
<i class="bi bi-funnel-fill text-secondary me-1"></i>{% trans %}Estado{% endtrans %}:
</small>
<span id="activeFilterBadge" class="badge bg-secondary-subtle text-secondary border small">
{% trans %}Mostrando todas las aulas y materias{% endtrans %}
</span>
<span class="badge badge-brand ms-auto small">
<i class="bi bi-calendar-check me-1"></i><span id="eventCountNumber">0</span> {% trans %}clases visibles{% endtrans %}
</span>
</div> </div>
</div> </div>
</div> </div>
<!-- Calendar --> <!-- Calendar Card -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary border-0 py-3">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
<div class="d-flex align-items-center gap-3">
<span class="fw-bold small text-secondary text-uppercase tracking-wide">
<i class="bi bi-info-circle me-1"></i>{% trans %}Referencia{% endtrans %}:
</span>
<div class="d-flex align-items-center">
<span class="badge badge-brand me-1" style="width: 14px; height: 14px; border-radius: 4px;"></span>
<small class="text-muted">{% trans %}Clase Confirmada{% endtrans %}</small>
</div>
<div class="d-flex align-items-center">
<span class="badge bg-warning me-1" style="width: 14px; height: 14px; border-radius: 4px;"></span>
<small class="text-muted">{% trans %}Pendiente{% endtrans %}</small>
</div>
</div>
<div class="text-muted small">
<i class="bi bi-lightning-charge-fill text-warning me-1"></i>{% trans %}Haz clic en cualquier clase para ver los detalles completos{% endtrans %}
</div>
</div>
</div>
<div class="card-body p-3">
<!-- FullCalendar Container -->
<div id="calendar"></div> <div id="calendar"></div>
</div> </div>
</div> </div>
<!-- Reservation Details Modal --> <!-- Reservation Details Modal -->
<div class="modal fade" id="reservationModal" tabindex="-1"> <div class="modal fade" id="reservationModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content"> <div class="modal-content border-0 shadow-lg">
<div class="modal-header"> <div class="modal-header border-0 pb-0">
<h5 class="modal-title">Reservation Details</h5> <div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <span id="modalCategoryBadge" class="badge badge-brand mb-1">Clase Académica</span>
<h4 class="modal-title fw-bold text-primary" id="modalSubjectTitle">Detalle de la Clase</h4>
</div> </div>
<div class="modal-body" id="reservationDetails"> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
<!-- Content will be loaded dynamically --> </div>
<div class="modal-body pt-3" id="reservationDetails">
<div class="row g-3">
<div class="col-md-6">
<div class="p-3 rounded bg-body-tertiary h-100">
<h6 class="text-muted small text-uppercase fw-bold mb-2">
<i class="bi bi-book text-primary me-1"></i>{% trans %}Materia y Código{% endtrans %}
</h6>
<p class="h6 fw-bold mb-1" id="modalSubjectName">-</p>
<p class="mb-2">
<span class="badge bg-dark bg-opacity-75 font-monospace text-white" id="modalSubjectCode">-</span>
</p>
<p class="small text-muted mb-0" id="modalCareerWrap">
<i class="bi bi-mortarboard me-1"></i><span id="modalCareer">-</span>
</p>
</div>
</div>
<div class="col-md-6">
<div class="p-3 rounded bg-body-tertiary h-100">
<h6 class="text-muted small text-uppercase fw-bold mb-2">
<i class="bi bi-door-open text-success me-1"></i>{% trans %}Espacio Asignado{% endtrans %}
</h6>
<p class="h5 fw-bold text-success mb-1" id="modalClassroom">-</p>
<p class="small text-muted mb-0" id="modalBuilding">-</p>
</div>
</div>
<div class="col-md-6">
<div class="p-3 rounded bg-body-tertiary h-100">
<h6 class="text-muted small text-uppercase fw-bold mb-2">
<i class="bi bi-clock text-warning me-1"></i>{% trans %}Horario Programado{% endtrans %}
</h6>
<p class="h6 fw-bold mb-0" id="modalTimeRange">-</p>
</div>
</div>
<div class="col-md-6">
<div class="p-3 rounded bg-body-tertiary h-100">
<h6 class="text-muted small text-uppercase fw-bold mb-2">
<i class="bi bi-shield-check text-info me-1"></i>{% trans %}Estado y Capacidad{% endtrans %}
</h6>
<p class="mb-1">
<span class="badge bg-success-subtle text-success border border-success-subtle px-2 py-1" id="modalStatus">-</span>
</p>
<small class="text-muted" id="modalAttendees">-</small>
</div>
</div>
</div>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Close{% endtrans %}</button>
<a href="#" id="modalFullLink" class="btn btn-primary">
<i class="bi bi-box-arrow-up-right me-1"></i>{% trans %}Ver Ficha Completa{% endtrans %}
</a>
</div> </div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div> </div>
</div> </div>
</div> </div>
@@ -72,35 +230,205 @@
{% block extra_css %} {% block extra_css %}
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@5.11.0/main.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/fullcalendar@5.11.0/main.min.css" rel="stylesheet">
<style> <style>
/* FullCalendar Modern Overrides */
#calendar { #calendar {
margin-top: 20px; font-family: inherit;
min-height: 700px;
} }
.fc .fc-toolbar-title {
font-size: 1.25rem !important;
font-weight: 700 !important;
color: var(--text-primary);
}
.fc .fc-button-primary {
background: var(--gradient-primary, linear-gradient(135deg, #B43E8E 0%, #9E367D 100%)) !important;
border: none !important;
border-radius: 6px !important;
font-weight: 600 !important;
font-size: 0.85rem !important;
padding: 0.35rem 0.75rem !important;
box-shadow: 0 2px 4px rgba(180, 62, 142, 0.25) !important;
}
.fc .fc-button-primary:hover {
background: linear-gradient(135deg, #9E367D 0%, #832765 100%) !important;
}
.fc .fc-button-primary:disabled {
background: #94A3B8 !important;
opacity: 0.6 !important;
}
.fc-theme-standard td, .fc-theme-standard th {
border-color: var(--card-border, #E2E8F0) !important;
}
.fc .fc-col-header-cell-cushion {
color: var(--text-primary);
font-weight: 600;
padding: 8px 4px !important;
text-decoration: none !important;
}
.fc .fc-timegrid-slot-label-cushion {
color: var(--text-secondary, #64748B);
font-size: 0.78rem;
}
/* Event Card Visual Hierarchy */
.fc-event { .fc-event {
cursor: pointer; cursor: pointer;
border: none !important; border: none !important;
padding: 2px 6px !important; border-radius: 8px !important;
font-size: 12px !important; overflow: hidden !important;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12) !important;
transition: transform 0.15s ease, box-shadow 0.15s ease !important;
} }
.fc-event-pending {
background-color: #ffc107 !important; .fc-event:hover {
transform: translateY(-2px) scale(1.01);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.22) !important;
z-index: 10 !important;
} }
.fc-event-confirmed { .fc-event-confirmed {
background-color: #28a745 !important; background: linear-gradient(135deg, #B43E8E 0%, #832765 100%) !important;
color: #FFFFFF !important;
} }
.fc-daygrid-day.fc-day-today {
background-color: rgba(102, 126, 234, 0.1) !important; .fc-event-pending {
background: linear-gradient(135deg, #F59E0B 0%, #D97706 100%) !important;
color: #FFFFFF !important;
}
.fc-event-custom-container {
padding: 5px 7px;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.fc-event-time-pill {
font-size: 0.72rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
margin-bottom: 2px;
display: flex;
align-items: center;
}
/* High Visual Hierarchy for Subject Name */
.fc-event-subject-title {
font-size: 0.88rem !important;
font-weight: 700 !important;
line-height: 1.22 !important;
color: #FFFFFF !important;
letter-spacing: -0.15px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
margin-bottom: 4px;
}
/* Secondary Badges */
.fc-event-badges {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 3px;
margin-top: auto;
}
.fc-badge-code {
background-color: rgba(0, 0, 0, 0.3) !important;
color: #FFFFFF !important;
font-size: 0.70rem !important;
font-weight: 600 !important;
padding: 2px 5px !important;
border-radius: 4px !important;
}
.fc-badge-room {
background-color: rgba(255, 255, 255, 0.28) !important;
color: #FFFFFF !important;
font-size: 0.70rem !important;
font-weight: 700 !important;
padding: 2px 5px !important;
border-radius: 4px !important;
display: inline-flex;
align-items: center;
}
/* Month View Optimization */
.fc-daygrid-event .fc-event-custom-container {
padding: 2px 4px;
}
.fc-daygrid-event .fc-event-subject-title {
font-size: 0.78rem !important;
-webkit-line-clamp: 1;
} }
</style> </style>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.11.0/main.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.11.0/main.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.11.0/locales-all.min.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
var calendarEl = document.getElementById('calendar'); var calendarEl = document.getElementById('calendar');
var currentLang = '{{ session.get("language", "es") }}';
var todayDate = '{{ today_date or "2026-09-03" }}';
var filterRoom = document.getElementById('filterClassroom');
var filterSubj = document.getElementById('filterSubject');
var filterCar = document.getElementById('filterCareer');
var filterShift = document.getElementById('filterShift');
var filterFloor = document.getElementById('filterFloor');
var filterHideVirtual = document.getElementById('filterHideVirtual');
var activeBadge = document.getElementById('activeFilterBadge');
var countEl = document.getElementById('eventCountNumber');
function updateFilterSummaryBadge() {
var parts = [];
if (filterRoom.value && filterRoom.selectedOptions[0]) {
parts.push('Aula: ' + filterRoom.selectedOptions[0].text.split('(')[0].trim());
}
if (filterSubj.value && filterSubj.selectedOptions[0]) {
parts.push('Materia: ' + filterSubj.selectedOptions[0].text.split('[')[0].trim());
}
if (filterCar.value && filterCar.selectedOptions[0]) {
parts.push('Carrera: ' + filterCar.selectedOptions[0].text.trim());
}
if (filterShift && filterShift.value) {
parts.push('Turno ' + filterShift.value);
}
if (filterFloor && filterFloor.value) {
parts.push(filterFloor.value === '0' ? 'Planta Baja' : 'Piso ' + filterFloor.value);
}
if (filterHideVirtual && filterHideVirtual.checked) {
parts.push('Sin Virtuales');
}
if (parts.length > 0) {
activeBadge.className = 'badge bg-primary text-white border small';
activeBadge.textContent = parts.join(' | ');
} else {
activeBadge.className = 'badge bg-secondary-subtle text-secondary border small';
activeBadge.textContent = 'Mostrando todas las aulas y materias';
}
}
var calendar = new FullCalendar.Calendar(calendarEl, { var calendar = new FullCalendar.Calendar(calendarEl, {
locale: currentLang,
initialView: 'timeGridWeek', initialView: 'timeGridWeek',
initialDate: todayDate,
allDaySlot: false,
headerToolbar: { headerToolbar: {
left: 'prev,next today', left: 'prev,next today',
center: 'title', center: 'title',
@@ -111,63 +439,147 @@ document.addEventListener('DOMContentLoaded', function() {
minute: '2-digit', minute: '2-digit',
hour12: false hour12: false
}, },
slotMinTime: '07:00:00', slotMinTime: '07:30:00',
slotMaxTime: '22:00:00', slotMaxTime: '22:30:00',
height: 'auto', height: 'auto',
eventClick: function(info) { slotEventOverlap: true,
// Prevent default link behavior
if (info.event.url) { /* Dynamic Server-Side Filtered Events */
// Load reservation details in modal instead of navigation events: function(info, successCallback, failureCallback) {
loadReservationDetails(info.event.url); var params = new URLSearchParams({
return false; start: info.startStr,
end: info.endStr,
classroom_id: filterRoom.value || '',
subject_id: filterSubj.value || '',
career_id: filterCar.value || '',
shift: (filterShift ? filterShift.value : '') || '',
floor: (filterFloor ? filterFloor.value : '') || '',
hide_virtual: (filterHideVirtual && filterHideVirtual.checked) ? '1' : ''
});
updateFilterSummaryBadge();
fetch('{{ url_for("schedule.calendar_data") }}?' + params.toString())
.then(function(response) {
if (!response.ok) throw new Error('Network error');
return response.json();
})
.then(function(eventsData) {
if (countEl) {
countEl.textContent = eventsData.length;
} }
successCallback(eventsData);
})
.catch(function(err) {
console.error('Error loading calendar events:', err);
failureCallback(err);
});
}, },
events: '{{ url_for("schedule.calendar_data") }}',
loading: function(bool) { /* Custom Card Render with Visual Hierarchy */
// Show loading indicator if needed eventContent: function(arg) {
var ext = arg.event.extendedProps || {};
var subjectName = ext.subject_name || arg.event.title || '';
var subjectCode = ext.subject_code || '';
var classroom = ext.classroom || '';
var timeText = arg.timeText || '';
var container = document.createElement('div');
container.className = 'fc-event-custom-container';
var timeHtml = timeText ? `<div class="fc-event-time-pill"><i class="bi bi-clock me-1"></i>${timeText}</div>` : '';
var titleHtml = `<div class="fc-event-subject-title" title="${subjectName}">${subjectName}</div>`;
var badgesHtml = `
<div class="fc-event-badges">
${subjectCode ? `<span class="fc-badge-code font-monospace">${subjectCode}</span>` : ''}
${classroom ? `<span class="fc-badge-room"><i class="bi bi-door-open me-1"></i>${classroom}</span>` : ''}
</div>`;
container.innerHTML = timeHtml + titleHtml + badgesHtml;
return { domNodes: [container] };
},
/* Interactive Instant Modal */
eventClick: function(info) {
info.jsEvent.preventDefault();
var ext = info.event.extendedProps || {};
var subjectName = ext.subject_name || info.event.title || '';
var subjectCode = ext.subject_code || '-';
var classroom = ext.classroom || 'Sin Aula';
var building = ext.building || 'Edificio Central';
var career = ext.career || 'General';
var status = ext.status || 'CONFIRMED';
var attendees = ext.attendees || 0;
var startStr = info.event.start ? info.event.start.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', hour12: false}) : '';
var endStr = info.event.end ? info.event.end.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', hour12: false}) : '';
var dateStr = info.event.start ? info.event.start.toLocaleDateString([], {weekday: 'long', day: 'numeric', month: 'long'}) : '';
document.getElementById('modalSubjectTitle').textContent = subjectName;
document.getElementById('modalSubjectName').textContent = subjectName;
document.getElementById('modalSubjectCode').textContent = subjectCode;
document.getElementById('modalCareer').textContent = career;
document.getElementById('modalClassroom').textContent = classroom;
document.getElementById('modalBuilding').textContent = building;
document.getElementById('modalTimeRange').textContent = `${dateStr} (${startStr} a ${endStr} hs)`;
document.getElementById('modalStatus').textContent = status;
document.getElementById('modalAttendees').textContent = `${attendees} estudiantes esperados`;
if (info.event.url) {
var btn = document.getElementById('modalFullLink');
btn.href = info.event.url;
btn.classList.remove('d-none');
} else {
document.getElementById('modalFullLink').classList.add('d-none');
}
var modal = new bootstrap.Modal(document.getElementById('reservationModal'));
modal.show();
} }
}); });
calendar.render(); calendar.render();
function loadReservationDetails(url) { // Event listeners to trigger immediate calendar refetch on filter change
$.get(url) filterRoom.addEventListener('change', function() {
.done(function(data) { calendar.refetchEvents();
var html = ` });
<div class="row">
<div class="col-md-6"> filterSubj.addEventListener('change', function() {
<h6 class="text-muted mb-2">Classroom</h6> calendar.refetchEvents();
<p class="fw-bold">${data.classroom.code} - ${data.classroom.name}</p> });
<p><i class="bi bi-building text-primary"></i> ${data.classroom.building} - Floor ${data.classroom.floor}</p>
</div> filterCar.addEventListener('change', function() {
<div class="col-md-6"> calendar.refetchEvents();
<h6 class="text-muted mb-2">Subject</h6> });
<p class="fw-bold">${data.commission.subject.code} - ${data.commission.subject.name}</p>
<p><i class="bi bi-person text-info"></i> ${data.commission.teacher_name}</p> if (filterShift) {
</div> filterShift.addEventListener('change', function() {
</div> calendar.refetchEvents();
<div class="row mt-3">
<div class="col-md-6">
<h6 class="text-muted mb-2">Schedule</h6>
<p><i class="bi bi-clock text-warning"></i> ${new Date(data.start_time).toLocaleString()}</p>
<p><i class="bi bi-clock text-success"></i> ${new Date(data.end_time).toLocaleString()}</p>
</div>
<div class="col-md-6">
<h6 class="text-muted mb-2">Details</h6>
<p><i class="bi bi-people text-primary"></i> ${data.expected_attendees} attendees</p>
<p><span class="badge bg-${data.status === 'CONFIRMED' ? 'success' : 'warning'}">${data.status}</span></p>
</div>
</div>
${data.notes ? `<div class="row mt-3"><div class="col-12"><h6 class="text-muted mb-2">Notes</h6><p>${data.notes}</p></div></div>` : ''}
`;
$('#reservationDetails').html(html);
$('#reservationModal').modal('show');
})
.fail(function() {
$('#reservationDetails').html('<p class="text-danger">Error loading reservation details.</p>');
$('#reservationModal').modal('show');
}); });
} }
if (filterFloor) {
filterFloor.addEventListener('change', function() {
calendar.refetchEvents();
});
}
if (filterHideVirtual) {
filterHideVirtual.addEventListener('change', function() {
calendar.refetchEvents();
});
}
document.getElementById('btnResetFilters').addEventListener('click', function() {
filterRoom.value = '';
filterSubj.value = '';
filterCar.value = '';
if (filterShift) filterShift.value = '';
if (filterFloor) filterFloor.value = '';
if (filterHideVirtual) filterHideVirtual.checked = false;
calendar.refetchEvents();
});
}); });
</script> </script>
{% endblock %} {% endblock %}
+4 -4
View File
@@ -65,7 +65,7 @@
{% for subject in subjects %} {% for subject in subjects %}
<div class="col-lg-6 mb-4"> <div class="col-lg-6 mb-4">
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0"> <div class="card-header border-0 py-3">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div> <div>
<h5 class="mb-1"> <h5 class="mb-1">
@@ -168,7 +168,7 @@
{% if subject.description %} {% if subject.description %}
<div class="card-footer bg-light"> <div class="card-footer bg-light">
<small class="text-muted"> <small class="text-muted">
<i class="bi bi-info-circle"></i> {{ subject.description|truncatewords(20) }} <i class="bi bi-info-circle"></i> {{ subject.description|truncate(100) }}
</small> </small>
</div> </div>
{% endif %} {% endif %}
@@ -180,8 +180,8 @@
<i class="bi bi-inbox fs-1 text-muted d-block mb-3"></i> <i class="bi bi-inbox fs-1 text-muted d-block mb-3"></i>
<h4 class="text-muted">No Subjects Found</h4> <h4 class="text-muted">No Subjects Found</h4>
<p class="text-muted">There are no active subjects in the system.</p> <p class="text-muted">There are no active subjects in the system.</p>
<a href="{{ url_for('classrooms.index') }}" class="btn btn-primary"> <a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add New Subject <i class="bi bi-door-open"></i> View Classrooms
</a> </a>
</div> </div>
</div> </div>
+170 -208
View File
@@ -1,228 +1,232 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Reservations List - Edu-Space Admin{% endblock %} {% block title %}{% trans %}Listado de Reservas{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %} {% block content %}
<div class="row mb-4"> <div class="container-fluid pt-5 mt-4">
<div class="col-12"> <!-- Header -->
<div class="d-flex justify-content-between align-items-center"> <div class="row mb-4 align-items-center">
<h1 class="h3 mb-0"> <div class="col-md-7">
<i class="bi bi-calendar-list text-primary"></i> Reservations <h1 class="h3 mb-1">
<small class="text-muted ms-2">Manage & View All Reservations</small> <i class="bi bi-calendar-check text-primary me-2"></i>{% trans %}Listado de Reservas{% endtrans %}
</h1> </h1>
<div class="d-flex gap-2"> <p class="text-muted small mb-0">{% trans %}Registro histórico y programación completa de clases y asignación de aulas{% endtrans %}</p>
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary"> </div>
<i class="bi bi-calendar3"></i> Calendar View <div class="col-md-5 text-md-end mt-3 mt-md-0">
{% if current_user.has_permission('reservations', 'read_write') %}
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success me-2">
<i class="bi bi-plus-circle me-1"></i>{% trans %}Nueva Reserva{% endtrans %}
</a>
{% endif %}
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary me-2">
<i class="bi bi-calendar3 me-1"></i>{% trans %}Ver Calendario{% endtrans %}
</a> </a>
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info"> <a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info">
<i class="bi bi-calendar-day"></i> Today <i class="bi bi-calendar-day me-1"></i>{% trans %}Cartelera de Hoy{% endtrans %}
</a>
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add Reservation
</a> </a>
</div> </div>
</div> </div>
</div>
</div>
<!-- Filters --> <!-- Filters Card -->
<div class="card border-0 shadow-sm mb-4"> <div class="card border-0 shadow-sm mb-4">
<div class="card-body"> <div class="card-body p-3">
<form method="GET" class="row g-3"> <form method="GET" action="{{ url_for('schedule.list_reservations') }}" class="row g-2 align-items-end">
<div class="col-md-3"> <!-- Search text -->
<label class="form-label">Status</label> <div class="col-lg-3 col-md-6">
<select name="status" class="form-select"> <label class="form-label small fw-bold text-muted mb-1">
<option value="all" {% if status_filter == 'all' %}selected{% endif %}>All Status</option> <i class="bi bi-search me-1"></i>{% trans %}Buscar{% endtrans %}
<option value="pending" {% if status_filter == 'pending' %}selected{% endif %}>Pending</option> </label>
<option value="confirmed" {% if status_filter == 'confirmed' %}selected{% endif %}>Confirmed</option> <input type="text" name="search" class="form-control form-control-sm" placeholder="Materia, código o aula..." value="{{ search or '' }}">
<option value="cancelled" {% if status_filter == 'cancelled' %}selected{% endif %}>Cancelled</option> </div>
<!-- Status Filter -->
<div class="col-lg-2 col-md-3">
<label class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-shield-check me-1"></i>{% trans %}Estado{% endtrans %}
</label>
<select name="status" class="form-select form-select-sm">
<option value="all" {% if status_filter == 'all' or not status_filter %}selected{% endif %}>{% trans %}Todos los estados{% endtrans %}</option>
<option value="confirmed" {% if status_filter == 'confirmed' %}selected{% endif %}>{% trans %}Confirmadas{% endtrans %}</option>
<option value="pending" {% if status_filter == 'pending' %}selected{% endif %}>{% trans %}Pendientes{% endtrans %}</option>
<option value="cancelled" {% if status_filter == 'cancelled' %}selected{% endif %}>{% trans %}Canceladas{% endtrans %}</option>
</select> </select>
</div> </div>
<div class="col-md-3"> <!-- Floor Filter -->
<label class="form-label">From Date</label> <div class="col-lg-2 col-md-3">
<input type="date" name="date_from" class="form-control" value="{{ date_from or '' }}"> <label class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-layers me-1"></i>{% trans %}Piso{% endtrans %}
</label>
<select name="floor" class="form-select form-select-sm">
<option value="">{% trans %}Todos los pisos{% endtrans %}</option>
{% for f in available_floors %}
<option value="{{ f }}" {% if floor_filter == f|string %}selected{% endif %}>
{% if f == 0 %}{% trans %}Planta Baja{% endtrans %}{% else %}{% trans %}Piso{% endtrans %} {{ f }}{% endif %}
</option>
{% endfor %}
</select>
</div> </div>
<div class="col-md-3"> <!-- Shift Filter -->
<label class="form-label">To Date</label> <div class="col-lg-2 col-md-4">
<input type="date" name="date_to" class="form-control" value="{{ date_to or '' }}"> <label class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-brightness-high text-warning me-1"></i>{% trans %}Turno{% endtrans %}
</label>
<select name="shift" class="form-select form-select-sm">
<option value="">{% trans %}Todos los turnos{% endtrans %}</option>
<option value="Mañana" {% if shift_filter == 'Mañana' %}selected{% endif %}>{% trans %}Turno Mañana{% endtrans %}</option>
<option value="Tarde" {% if shift_filter == 'Tarde' %}selected{% endif %}>{% trans %}Turno Tarde{% endtrans %}</option>
</select>
</div> </div>
<div class="col-md-3"> <!-- Date Range -->
<label class="form-label">&nbsp;</label> <div class="col-lg-3 col-md-4">
<div class="d-flex gap-2"> <label class="form-label small fw-bold text-muted mb-1">
<button type="submit" class="btn btn-outline-primary"> <i class="bi bi-calendar-event me-1"></i>{% trans %}Desde Fecha{% endtrans %}
<i class="bi bi-funnel"></i> Filter </label>
<input type="date" name="date_from" class="form-control form-control-sm" value="{{ date_from or '' }}">
</div>
<!-- Row 2: Hide Virtual, Submit, Clear -->
<div class="col-lg-6 col-md-6 pt-2">
<div class="form-check form-switch pt-1">
<input class="form-check-input" type="checkbox" role="switch" name="hide_virtual" value="1" id="hideVirtualList" {% if hide_virtual %}checked{% endif %}>
<label class="form-check-label fw-semibold small text-dark" for="hideVirtualList">
<i class="bi bi-laptop me-1 text-primary"></i>{% trans %}Ocultar Aulas Virtuales{% endtrans %}
<span class="badge bg-secondary-subtle text-secondary border ms-1" style="font-size: 0.72rem;">{% trans %}Capacidad ilimitada{% endtrans %}</span>
</label>
</div>
</div>
<div class="col-lg-6 col-md-6 pt-2 text-md-end d-flex justify-content-md-end gap-2">
<button type="submit" class="btn btn-primary btn-sm px-3">
<i class="bi bi-funnel me-1"></i>{% trans %}Filtrar{% endtrans %}
</button> </button>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary btn-sm px-3">
<i class="bi bi-x-circle"></i> Clear <i class="bi bi-arrow-counterclockwise me-1"></i>{% trans %}Limpiar{% endtrans %}
</a> </a>
</div> </div>
</div>
</form> </form>
</div> </div>
</div> </div>
<!-- Reservations List --> <!-- Reservations Table -->
{% if reservations and reservations.items %} {% if reservations and reservations.items %}
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 h6 fw-bold">
<i class="bi bi-table text-primary me-2"></i>{% trans %}Registros Encontrados{% endtrans %}
<span class="badge bg-secondary-subtle text-secondary border ms-2">{{ reservations.total }} {% trans %}reservas{% endtrans %}</span>
</h5>
<small class="text-muted">{% trans %}Página{% endtrans %} {{ reservations.page }} {% trans %}de{% endtrans %} {{ reservations.pages }}</small>
</div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover align-middle mb-0">
<thead class="table-light"> <thead class="table-light small text-uppercase">
<tr> <tr>
<th class="border-0">Classroom</th> <th class="ps-3">{% trans %}Fecha y Horario{% endtrans %}</th>
<th class="border-0">Commission</th> <th>{% trans %}Aula / Piso{% endtrans %}</th>
<th class="border-0">Date & Time</th> <th>{% trans %}Asignatura / Código{% endtrans %}</th>
<th class="border-0">Purpose</th> <th>{% trans %}Turno / Comisión{% endtrans %}</th>
<th class="border-0">Attendees</th> <th>{% trans %}Capacidad{% endtrans %}</th>
<th class="border-0">Status</th> <th>{% trans %}Estado{% endtrans %}</th>
<th class="border-0">Actions</th> <th class="text-end pe-3">{% trans %}Acciones{% endtrans %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for reservation in reservations.items %} {% for reservation in reservations.items %}
<tr> <tr>
<td> <td class="ps-3">
<div class="d-flex align-items-center"> <div class="fw-bold">{{ reservation.start_time.strftime('%d/%m/%Y') }}</div>
<div class="avatar avatar-sm bg-primary text-white rounded-circle d-flex align-items-center justify-content-center me-2"> <small class="text-muted font-monospace">{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }} hs</small>
<i class="bi bi-door-open"></i>
</div>
<div>
<div class="fw-bold">{{ reservation.classroom.code_display }}</div>
<small class="text-muted">Capacity: {{ reservation.classroom.capacity }}</small>
</div>
</div>
</td> </td>
<td> <td>
<div> <span class="badge bg-secondary-subtle text-secondary border font-monospace">
<div class="fw-bold"> {{ reservation.classroom.code if reservation.classroom else 'Sin Aula' }}
{% if reservation.commission %}
{{ reservation.commission.get_full_code() or 'N/A' }}
{% else %}
No Commission
{% endif %}
</div>
<small class="text-muted">
{% if reservation.commission and reservation.commission.teacher_name %}
{{ reservation.commission.teacher_name }}
{% else %}
No Teacher
{% endif %}
</small>
</div>
</td>
<td>
<div>
<div class="fw-bold">{{ reservation.start_time.strftime('%b %d, %Y') }}</div>
<small class="text-muted">
{{ reservation.start_time.strftime('%I:%M %p') }} - {{ reservation.end_time.strftime('%I:%M %p') }}
</small>
</div>
</td>
<td>
<span class="text-truncate" style="max-width: 150px; display: inline-block;">
{{ reservation.purpose or 'No purpose specified' }}
</span> </span>
<small class="text-muted d-block">
{% if reservation.classroom and reservation.classroom.floor == 0 %}PB{% elif reservation.classroom %}Piso {{ reservation.classroom.floor }}{% endif %}
{% if reservation.classroom and reservation.classroom.building %} · {{ reservation.classroom.building }}{% endif %}
</small>
</td> </td>
<td> <td>
<div class="d-flex align-items-center"> <div class="fw-bold text-primary">{{ reservation.subject_name }}</div>
<i class="bi bi-people text-muted me-1"></i> <small class="badge bg-secondary-subtle text-secondary font-monospace">{{ reservation.subject_code }}</small>
{{ reservation.expected_attendees }}
</div>
</td> </td>
<td> <td>
{% if reservation.status.value == 'confirmed' %} <span class="badge bg-info-subtle text-info border me-1">
<span class="badge bg-success">Confirmed</span> {{ reservation.shift or 'Tarde' }}
{% elif reservation.status.value == 'pending' %} </span>
<span class="badge bg-warning">Pending</span> <small class="text-muted">{{ reservation.commission.code if reservation.commission else 'C1' }}</small>
{% elif reservation.status.value == 'cancelled' %} </td>
<span class="badge bg-danger">Cancelled</span> <td>
<span class="small">{{ reservation.expected_attendees }} / {{ reservation.classroom.capacity if reservation.classroom else '-' }}</span>
</td>
<td>
{% if reservation.status == 'CONFIRMED' or reservation.status.value == 'CONFIRMED' %}
<span class="badge bg-success-subtle text-success border border-success-subtle">
<i class="bi bi-check-circle me-1"></i>{% trans %}Confirmada{% endtrans %}
</span>
{% elif reservation.status == 'PENDING' or reservation.status.value == 'PENDING' %}
<span class="badge bg-warning-subtle text-warning border border-warning-subtle">
<i class="bi bi-clock me-1"></i>{% trans %}Pendiente{% endtrans %}
</span>
{% else %} {% else %}
<span class="badge bg-secondary">{{ reservation.status.value }}</span> <span class="badge bg-danger-subtle text-danger border border-danger-subtle">
{% trans %}Cancelada{% endtrans %}
</span>
{% endif %} {% endif %}
</td> </td>
<td class="text-end pe-3">
<td> <a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}"
<div class="btn-group btn-group-sm" role="group"> class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
{% if reservation.status.value == 'pending' %} <i class="bi bi-eye"></i>
<form method="POST" action="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}" style="display: inline;"> </a>
<button type="submit" class="btn btn-outline-success btn-sm" title="Confirm"> {% if current_user.has_permission('reservations', 'read_write') and (reservation.status == 'PENDING' or reservation.status.value == 'PENDING') %}
<i class="bi bi-check-circle"></i> <a href="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}"
</button> class="btn btn-sm btn-outline-success py-0 px-2 ms-1" title="{% trans %}Confirmar{% endtrans %}">
</form> <i class="bi bi-check-lg"></i>
</a>
{% endif %} {% endif %}
{% if reservation.status.value in ['pending', 'confirmed'] %}
<form method="POST" action="{{ url_for('schedule.cancel_reservation', id=reservation.id) }}" style="display: inline;">
<button type="submit" class="btn btn-outline-danger btn-sm" title="Cancel">
<i class="bi bi-x-circle"></i>
</button>
</form>
{% endif %}
{% if reservation.notes %}
<button type="button" class="btn btn-outline-info btn-sm"
title="{{ reservation.notes }}" data-bs-toggle="tooltip">
<i class="bi bi-info-circle"></i>
</button>
{% endif %}
</div>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<!-- Pagination --> <!-- Pagination -->
{% if reservations.pages > 1 %} {% if reservations.pages > 1 %}
<div class="card-footer bg-white border-0"> <div class="card-footer bg-body-tertiary border-0 py-3 d-flex justify-content-between align-items-center">
<div class="d-flex justify-content-between align-items-center"> <small class="text-muted">{% trans %}Mostrando 20 registros por página{% endtrans %}</small>
<span class="text-muted small">
Showing {{ reservations.items|length }} of {{ reservations.total }} reservations
</span>
<nav> <nav>
<ul class="pagination pagination-sm mb-0"> <ul class="pagination pagination-sm mb-0">
{% if reservations.has_prev %} {% if reservations.has_prev %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.prev_num, status=status_filter, date_from=date_from, date_to=date_to) }}"> <a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.prev_num, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, hide_virtual='1' if hide_virtual else '', search=search) }}">
<i class="bi bi-chevron-left"></i> <i class="bi bi-chevron-left"></i>
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% for page_num in reservations.iter_pages() %} {% for p in reservations.iter_pages(left_edge=2, left_current=2, right_current=2, right_edge=2) %}
{% if page_num %} {% if p %}
{% if page_num != reservations.page %} {% if p == reservations.page %}
<li class="page-item"> <li class="page-item active"><span class="page-link">{{ p }}</span></li>
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=page_num, status=status_filter, date_from=date_from, date_to=date_to) }}">
{{ page_num }}
</a>
</li>
{% else %} {% else %}
<li class="page-item active"> <li class="page-item">
<span class="page-link">{{ page_num }}</span> <a class="page-link" href="{{ url_for('schedule.list_reservations', page=p, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, hide_virtual='1' if hide_virtual else '', search=search) }}">{{ p }}</a>
</li> </li>
{% endif %} {% endif %}
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled"><span class="page-link">…</span></li>
<span class="page-link">…</span>
</li>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if reservations.has_next %} {% if reservations.has_next %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.next_num, status=status_filter, date_from=date_from, date_to=date_to) }}"> <a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.next_num, status=status_filter, date_from=date_from, date_to=date_to, floor=floor_filter, shift=shift_filter, hide_virtual='1' if hide_virtual else '', search=search) }}">
<i class="bi bi-chevron-right"></i> <i class="bi bi-chevron-right"></i>
</a> </a>
</li> </li>
@@ -230,62 +234,20 @@
</ul> </ul>
</nav> </nav>
</div> </div>
</div>
{% endif %} {% endif %}
</div>
{% else %}
<div class="card border-0 shadow-sm">
<div class="card-body text-center py-5">
<div class="mb-4">
<i class="bi bi-calendar-x display-1 text-muted"></i>
</div> </div>
<h5 class="text-muted">No reservations found</h5> </div>
<p class="text-muted">
{% if status_filter != 'all' or date_from or date_to %}
No reservations match your current filters.
<a href="{{ url_for('schedule.list_reservations') }}">Clear filters</a> to see all reservations.
{% else %} {% else %}
There are no reservations in the system yet. <div class="card border-0 shadow-sm">
<a href="{{ url_for('schedule.add_reservation') }}">Create your first reservation</a>. <div class="card-body text-center py-5">
<i class="bi bi-calendar-x text-muted fs-1 d-block mb-3"></i>
<h5 class="fw-bold">{% trans %}No se encontraron reservas{% endtrans %}</h5>
<p class="text-muted small mb-3">{% trans %}No hay registros que coincidan con los criterios de búsqueda o filtros seleccionados.{% endtrans %}</p>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% trans %}Restablecer Todos los Filtros{% endtrans %}
</a>
</div>
</div>
{% endif %} {% endif %}
</p>
<div class="d-flex justify-content-center gap-2 mt-3">
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add Reservation
</a>
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info">
<i class="bi bi-calendar-day"></i> Today's Schedule
</a>
</div>
</div>
</div> </div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
// Initialize tooltips
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
});
// Form submission confirmation
document.querySelectorAll('form[action*="cancel"]').forEach(function(form) {
form.addEventListener('submit', function(e) {
if (!confirm('Are you sure you want to cancel this reservation?')) {
e.preventDefault();
}
});
});
document.querySelectorAll('form[action*="confirm"]').forEach(function(form) {
form.addEventListener('submit', function(e) {
if (!confirm('Are you sure you want to confirm this reservation?')) {
e.preventDefault();
}
});
});
</script>
{% endblock %} {% endblock %}
+223 -270
View File
@@ -1,183 +1,222 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Today's Schedule - Edu-Space Admin{% endblock %} {% block title %}{% trans %}Cartelera de Hoy{% endtrans %} - Edu-Space Admin{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid pt-5 mt-4"> <div class="container-fluid pt-5 mt-4">
<div class="row"> <!-- Header -->
<div class="col-12"> <div class="row mb-4 align-items-center">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="col-md-7">
<h1 class="h3"> <h1 class="h3 mb-1">
<i class="bi bi-calendar-today text-info"></i> Today's Schedule <i class="bi bi-calendar-day text-info me-2"></i>{% trans %}Cartelera del Día{% endtrans %}
<small class="text-muted ms-2">{{ today.strftime('%B %d, %Y') }}</small> <small class="text-muted ms-2 fs-6">{{ format_date(today, format='full')|capitalize }}</small>
</h1> </h1>
<div> <p class="text-muted small mb-0">{% trans %}Cronograma de clases y asignación de aulas activas para el día de hoy{% endtrans %}</p>
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success"> </div>
<i class="bi bi-plus-circle"></i> New Reservation <div class="col-md-5 text-md-end mt-3 mt-md-0">
</a> {% if current_user.has_permission('reservations', 'read_write') %}
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-primary ms-2"> <a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success me-2">
<i class="bi bi-list"></i> All Reservations <i class="bi bi-plus-circle me-1"></i>{% trans %}Nueva Reserva{% endtrans %}
</a>
{# Access to Genetic Algorithm Optimizer - Only for admin/teacher roles #}
{% if current_user and current_user.role in ['admin', 'teacher'] %}
<a href="{{ url_for('main.genetic_optimizer') }}" class="btn btn-info text-white ms-2">
<i class="bi bi-cpu"></i> AI Optimizer
</a> </a>
{% endif %} {% endif %}
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary me-2">
<i class="bi bi-calendar3 me-1"></i>{% trans %}Ver Calendario{% endtrans %}
</a>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
<i class="bi bi-list me-1"></i>{% trans %}Todas las Reservas{% endtrans %}
</a>
</div> </div>
</div> </div>
</div>
</div>
<!-- Quick Stats --> <!-- Quick Stats Cards with Shortcuts -->
<div class="row mb-4"> <div class="row mb-4 g-3">
<div class="col-md-3 mb-3"> <div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm h-100">
<div class="card-body"> <div class="card-body p-3 d-flex justify-content-between align-items-center">
<div class="d-flex justify-content-between align-items-center">
<div> <div>
<h6 class="text-muted mb-2">Total Reservations</h6> <span class="text-muted small fw-semibold text-uppercase">{% trans %}Total de Reservas{% endtrans %}</span>
<h2 class="mb-0">{{ stats.total_reservations }}</h2> <h2 class="mb-0 fw-bold mt-1 text-info">{{ stats.total_reservations }}</h2>
</div> </div>
<div class="bg-info bg-opacity-10 rounded-circle p-3"> <div class="bg-info bg-opacity-10 rounded-circle p-3 text-info">
<i class="bi bi-calendar3 text-info fs-4"></i> <i class="bi bi-calendar3 fs-3"></i>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> <div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="col-md-3 mb-3"> <div class="card-body p-3 d-flex justify-content-between align-items-center">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div> <div>
<h6 class="text-muted mb-2">Confirmed</h6> <span class="text-muted small fw-semibold text-uppercase">{% trans %}Confirmadas{% endtrans %}</span>
<h2 class="mb-0">{{ stats.confirmed }}</h2> <h2 class="mb-0 fw-bold mt-1 text-success">{{ stats.confirmed }}</h2>
</div> </div>
<div class="bg-success bg-opacity-10 rounded-circle p-3"> <div class="bg-success bg-opacity-10 rounded-circle p-3 text-success">
<i class="bi bi-check-circle text-success fs-4"></i> <i class="bi bi-check-circle fs-3"></i>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> <div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="col-md-3 mb-3"> <div class="card-body p-3 d-flex justify-content-between align-items-center">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div> <div>
<h6 class="text-muted mb-2">Pending</h6> <span class="text-muted small fw-semibold text-uppercase">{% trans %}Pendientes{% endtrans %}</span>
<h2 class="mb-0">{{ stats.pending }}</h2> <h2 class="mb-0 fw-bold mt-1 text-warning">{{ stats.pending }}</h2>
</div> </div>
<div class="bg-warning bg-opacity-10 rounded-circle p-3"> <div class="bg-warning bg-opacity-10 rounded-circle p-3 text-warning">
<i class="bi bi-clock text-warning fs-4"></i> <i class="bi bi-clock fs-3"></i>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> <div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm h-100">
<div class="col-md-3 mb-3"> <div class="card-body p-3 d-flex justify-content-between align-items-center">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div> <div>
<h6 class="text-muted mb-2">Active Classrooms</h6> <span class="text-muted small fw-semibold text-uppercase">{% trans %}Aulas Ocupadas{% endtrans %}</span>
<h2 class="mb-0">{{ stats.occupied_classrooms }}/{{ stats.total_classrooms }}</h2> <h2 class="mb-0 fw-bold mt-1 text-primary">{{ stats.occupied_classrooms }}/{{ stats.total_classrooms }}</h2>
</div> </div>
<div class="bg-primary bg-opacity-10 rounded-circle p-3"> <div class="bg-primary bg-opacity-10 rounded-circle p-3 text-primary">
<i class="bi bi-door-open text-primary fs-4"></i> <i class="bi bi-door-open fs-3"></i>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Today's Schedule --> <!-- Filters Toolbar (Piso, Turno y Ocultar Virtuales) -->
{% if time_blocks %} <div class="card border-0 shadow-sm mb-4">
<div class="row"> <div class="card-body p-3">
<form method="GET" action="{{ url_for('schedule.today_schedule') }}" class="row g-2 align-items-center">
<!-- Filter by Floor -->
<div class="col-lg-3 col-md-4">
<label for="floorSelect" class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-layers text-secondary me-1"></i>{% trans %}Piso / Planta{% endtrans %}
</label>
<select name="floor" id="floorSelect" class="form-select form-select-sm">
<option value="">{% trans %}Todos los pisos{% endtrans %}</option>
{% for f in available_floors %}
<option value="{{ f }}" {% if floor_filter == f|string %}selected{% endif %}>
{% if f == 0 %}{% trans %}Planta Baja (Piso 0){% endtrans %}{% else %}{% trans %}Piso{% endtrans %} {{ f }}{% endif %}
</option>
{% endfor %}
</select>
</div>
<!-- Filter by Turno -->
<div class="col-lg-3 col-md-4">
<label for="shiftSelect" class="form-label small fw-bold text-muted mb-1">
<i class="bi bi-brightness-high text-warning me-1"></i>{% trans %}Turno{% endtrans %}
</label>
<select name="shift" id="shiftSelect" class="form-select form-select-sm">
<option value="">{% trans %}Todos los turnos{% endtrans %}</option>
<option value="Mañana" {% if shift_filter == 'Mañana' %}selected{% endif %}>{% trans %}Turno Mañana{% endtrans %}</option>
<option value="Tarde" {% if shift_filter == 'Tarde' %}selected{% endif %}>{% trans %}Turno Tarde / Vespertino{% endtrans %}</option>
</select>
</div>
<!-- Hide Virtual Classrooms Toggle -->
<div class="col-lg-4 col-md-4 pt-md-3">
<div class="form-check form-switch pt-1">
<input class="form-check-input" type="checkbox" role="switch" name="hide_virtual" value="1" id="hideVirtualToday" {% if hide_virtual %}checked{% endif %} onchange="this.form.submit()">
<label class="form-check-label fw-semibold small text-dark" for="hideVirtualToday">
<i class="bi bi-laptop me-1 text-primary"></i>{% trans %}Ocultar Aulas Virtuales{% endtrans %}
<span class="badge bg-secondary-subtle text-secondary border ms-1" style="font-size: 0.72rem;">{% trans %}Capacidad ilimitada{% endtrans %}</span>
</label>
</div>
</div>
<!-- Actions -->
<div class="col-lg-2 col-md-12 pt-md-3 text-lg-end d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm flex-fill">
<i class="bi bi-funnel me-1"></i>{% trans %}Filtrar{% endtrans %}
</button>
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-secondary btn-sm" title="{% trans %}Limpiar Filtros{% endtrans %}">
<i class="bi bi-arrow-counterclockwise"></i>
</a>
</div>
</form>
</div>
</div>
<!-- Today's Schedule -->
{% if time_blocks %}
<div class="row">
<div class="col-12"> <div class="col-12">
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0"> <div class="card-header border-0 py-3 bg-body-tertiary">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
<h5 class="mb-0"> <h5 class="mb-0 h6 fw-bold">
<i class="bi bi-clock-history text-info"></i> Timeline View <i class="bi bi-clock-history text-info me-2"></i>{% trans %}Vista Cronológica por Horario{% endtrans %}
</h5> </h5>
<div> <div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary" id="refresh-btn"> <button class="btn btn-sm btn-outline-primary" id="refresh-btn">
<i class="bi bi-arrow-clockwise"></i> Refresh <i class="bi bi-arrow-clockwise me-1"></i>{% trans %}Actualizar{% endtrans %}
</button> </button>
<button class="btn btn-sm btn-outline-secondary ms-1" id="toggle-view-btn"> <button class="btn btn-sm btn-outline-secondary" id="toggle-view-btn">
<i class="bi bi-grid"></i> Grid View <i class="bi bi-grid me-1"></i>{% trans %}Vista en Tabla{% endtrans %}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<div class="card-body"> <div class="card-body p-3">
<!-- Timeline View -->
<div id="timeline-view"> <div id="timeline-view">
{% for hour in range(7, 22) %} {% for hour in range(7, 22) %}
<div class="row border-bottom py-3 align-items-start"> <div class="row border-bottom py-3 align-items-start {% if time_blocks[hour] %}bg-light-subtle{% endif %}">
<div class="col-md-2 col-3"> <div class="col-md-2 col-3">
<div class="text-center"> <div class="text-center p-2 rounded bg-body-tertiary border">
<strong>{{ "%02d:00"|format(hour) }}</strong> <strong class="h5 text-primary mb-0 font-monospace">{{ "%02d:00"|format(hour) }}</strong>
<br> <div class="small text-muted font-monospace">{{ hour+1 }}:00 hs</div>
<small class="text-muted">{{ hour+1 }}:00</small>
</div> </div>
</div> </div>
<div class="col-md-10 col-9"> <div class="col-md-10 col-9">
{% if time_blocks[hour] %} {% if time_blocks[hour] %}
<div class="row"> <div class="row g-2">
{% for item in time_blocks[hour] %} {% for item in time_blocks[hour] %}
<div class="col-md-6 mb-3"> <div class="col-md-6">
<div class="card border-0 shadow-sm h-100"> <div class="card border-0 shadow-sm h-100 border-start border-4 border-primary">
<div class="card-body p-3"> <div class="card-body p-3">
<div class="d-flex justify-content-between align-items-start mb-2"> <div class="d-flex justify-content-between align-items-start mb-2">
<div> <div>
<h6 class="card-title mb-1"> <h6 class="card-title fw-bold mb-1 text-primary">
{{ item.commission.get_full_code() }} {{ item.reservation.subject_name }}
</h6> </h6>
<p class="text-muted small mb-2 mb-md-0"> <span class="badge bg-secondary-subtle text-secondary font-monospace border me-1">
<i class="bi bi-geo-alt"></i> {{ item.classroom.code }} {{ item.reservation.subject_code }}
<br class="d-md-none"> </span>
<i class="bi bi-person ms-md-2"></i> {{ item.user.first_name }} {{ item.user.last_name }} <span class="badge bg-info-subtle text-info border">
</p> {{ item.reservation.shift or 'Tarde' }}
</span>
</div> </div>
<span class="badge bg-{% if item.reservation.status.value == 'CONFIRMED' %}success{% elif item.reservation.status.value == 'PENDING' %}warning{% else %}secondary{% endif %}"> <span class="badge bg-{% if item.reservation.status == 'CONFIRMED' or item.reservation.status.value == 'CONFIRMED' %}success-subtle text-success border border-success-subtle{% else %}warning-subtle text-warning border border-warning-subtle{% endif %}">
{{ item.reservation.status.value }} {% if item.reservation.status == 'CONFIRMED' or item.reservation.status.value == 'CONFIRMED' %}
{% trans %}Confirmada{% endtrans %}
{% else %}
{% trans %}Pendiente{% endtrans %}
{% endif %}
</span> </span>
</div> </div>
<div class="row small"> <div class="small text-muted mb-2">
<div class="col-md-6"> <span class="fw-semibold text-dark me-2">
<i class="bi bi-clock"></i> <i class="bi bi-door-open me-1 text-success"></i>{{ item.classroom.code }}
{{ item.reservation.start_time.strftime('%H:%M') }} - {{ item.reservation.end_time.strftime('%H:%M') }} </span>
</div> <span class="me-2">
<div class="col-md-6"> <i class="bi bi-layers me-1"></i>{% if item.classroom.floor == 0 %}PB{% else %}Piso {{ item.classroom.floor }}{% endif %}
<i class="bi bi-people"></i> </span>
{{ item.reservation.expected_attendees }} attendees <span>
</div> <i class="bi bi-clock me-1 text-warning"></i>{{ item.reservation.start_time.strftime('%H:%M') }} - {{ item.reservation.end_time.strftime('%H:%M') }} hs
</span>
</div> </div>
{% if item.reservation.purpose %} <div class="d-flex justify-content-between align-items-center pt-2 border-top">
<p class="card-text small text-muted mt-2"> <span class="small text-muted">
{{ item.reservation.purpose }} <i class="bi bi-people me-1"></i>{{ item.reservation.expected_attendees }} {% trans %}estudiantes{% endtrans %}
</p> </span>
{% endif %} <a href="{{ url_for('schedule.view_reservation', id=item.reservation.id) }}" class="btn btn-sm btn-outline-secondary py-0 px-2">
<i class="bi bi-eye me-1"></i>{% trans %}Detalles{% endtrans %}
<div class="mt-2">
<a href="{{ url_for('schedule.view_reservation', id=item.reservation.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> Details
</a> </a>
{% if item.reservation.status.value == 'PENDING' %}
<a href="{{ url_for('schedule.confirm_reservation', id=item.reservation.id) }}"
class="btn btn-sm btn-outline-success ms-1"
onclick="return confirm('Confirm this reservation?')">
<i class="bi bi-check-circle"></i> Confirm
</a>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@@ -185,8 +224,8 @@
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<div class="text-center text-muted py-3"> <div class="text-muted py-2 small ps-2">
<i class="bi bi-circle"></i> No reservations scheduled <i class="bi bi-dash-circle me-1"></i>{% trans %}Sin clases programadas en este horario{% endtrans %}
</div> </div>
{% endif %} {% endif %}
</div> </div>
@@ -194,54 +233,63 @@
{% endfor %} {% endfor %}
</div> </div>
<!-- Grid View (hidden by default) --> <!-- Table / Grid View (hidden by default) -->
<div id="grid-view" style="display: none;"> <div id="grid-view" style="display: none;">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover"> <table class="table table-hover align-middle mb-0">
<thead> <thead class="table-light small text-uppercase">
<tr> <tr>
<th>Time</th> <th class="ps-3">{% trans %}Horario{% endtrans %}</th>
<th>Classroom</th> <th>{% trans %}Aula / Piso{% endtrans %}</th>
<th>Subject</th> <th>{% trans %}Asignatura / Código{% endtrans %}</th>
<th>Teacher</th> <th>{% trans %}Turno{% endtrans %}</th>
<th>Purpose</th> <th>{% trans %}Asistentes{% endtrans %}</th>
<th>Attendees</th> <th>{% trans %}Estado{% endtrans %}</th>
<th>Status</th> <th class="text-end pe-3">{% trans %}Acciones{% endtrans %}</th>
<th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for reservation in reservations %} {% for reservation in reservations %}
<tr> <tr>
<td> <td class="ps-3 font-monospace fw-bold">
{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }} hs
{{ reservation.end_time.strftime('%H:%M') }}
</td> </td>
<td> <td>
<i class="bi bi-geo-alt"></i> <span class="badge bg-secondary-subtle text-secondary font-monospace border">
{{ reservation.classroom.code }} {{ reservation.classroom.code if reservation.classroom else 'Sin Aula' }}
</span>
<small class="text-muted d-block">
{% if reservation.classroom and reservation.classroom.floor == 0 %}Planta Baja{% elif reservation.classroom %}Piso {{ reservation.classroom.floor }}{% endif %}
</small>
</td> </td>
<td>{{ reservation.commission.get_full_code() }}</td>
<td>{{ reservation.user.first_name }} {{ reservation.user.last_name }}</td>
<td>{{ reservation.purpose }}</td>
<td>{{ reservation.expected_attendees }}</td>
<td> <td>
<span class="badge bg-{% if reservation.status.value == 'CONFIRMED' %}success{% elif reservation.status.value == 'PENDING' %}warning{% else %}secondary{% endif %}"> <div class="fw-semibold text-primary">{{ reservation.subject_name }}</div>
{{ reservation.status.value }} <small class="text-muted font-monospace">{{ reservation.subject_code }}</small>
</td>
<td>
<span class="badge bg-info-subtle text-info border">
{{ reservation.shift or 'Tarde' }}
</span> </span>
</td> </td>
<td> <td>
<span class="small">{{ reservation.expected_attendees }} estudiantes</span>
</td>
<td>
{% if reservation.status == 'CONFIRMED' or reservation.status.value == 'CONFIRMED' %}
<span class="badge bg-success-subtle text-success border border-success-subtle">
<i class="bi bi-check-circle me-1"></i>{% trans %}Confirmada{% endtrans %}
</span>
{% else %}
<span class="badge bg-warning-subtle text-warning border border-warning-subtle">
<i class="bi bi-clock me-1"></i>{% trans %}Pendiente{% endtrans %}
</span>
{% endif %}
</td>
<td class="text-end pe-3">
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}" <a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}"
class="btn btn-sm btn-outline-primary"> class="btn btn-sm btn-outline-secondary py-0 px-2" title="{% trans %}Ver Ficha{% endtrans %}">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</a> </a>
{% if reservation.status.value == 'PENDING' %}
<a href="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}"
class="btn btn-sm btn-outline-success ms-1"
onclick="return confirm('Confirm this reservation?')">
<i class="bi bi-check-circle"></i>
</a>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -252,154 +300,59 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Daily Summary -->
<div class="row mt-4">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0">
<h5 class="mb-0">
<i class="bi bi-graph-up text-warning"></i> Daily Analytics
</h5>
</div> </div>
<div class="card-body"> {% else %}
<div class="row"> <div class="row">
<div class="col-md-4">
<div class="text-center">
<div class="mb-2">
<i class="bi bi-door-open-fill text-success" style="font-size: 2rem;"></i>
</div>
<h6>Most Active Hours</h6>
<!-- Calculate peak hours from data -->
{% set peak_hours = [] %}
{% for item in reservations %}
{% set slot = item.start_time.hour %}
{% if slot not in peak_hours %}
{% set _ = peak_hours.append(slot) %}
{% endif %}
{% endfor %}
<p class="text-muted">
{% if peak_hours %}
{% for hour in peak_hours[:3]|sort %}
{{ "%02d:00"|format(hour) }}{% if not loop.last %}, {% endif %}
{% endfor %}
{% else %}
No data available
{% endif %}
</p>
</div>
</div>
<div class="col-md-4">
<div class="text-center">
<div class="mb-2">
<i class="bi bi-building text-info" style="font-size: 2rem;"></i>
</div>
<h6>Busiest Classrooms</h6>
<p class="text-muted">
{% if reservations %}
{% set classroom_codes = [] %}
{% for item in reservations %}
{% if item.classroom.code not in classroom_codes %}
{% set _ = classroom_codes.append(item.classroom.code) %}
{% endif %}
{% endfor %}
{% for code in classroom_codes[:3] %}
{{ code }}{% if not loop.last %}, {% endif %}
{% endfor %}
{% else %}
No data available
{% endif %}
</p>
</div>
</div>
<div class="col-md-4">
<div class="text-center">
<div class="mb-2">
<i class="bi bi-clock-fill text-primary" style="font-size: 2rem;"></i>
</div>
<h6>Total Hours Booked</h6>
<p class="text-muted">
{% set total_hours = 0 %}
{% for item in reservations %}
{% set duration = (item.end_time - item.start_time).total_seconds() / 3600 %}
{% set total_hours = (total_hours + duration) %}
{% endfor %}
{{ total_hours|round(1) }} hours total
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% else %}
<div class="row">
<div class="col-12"> <div class="col-12">
<div class="text-center py-5"> <div class="text-center py-5">
<i class="bi bi-calendar-x text-muted" style="font-size: 4rem;"></i> <i class="bi bi-calendar-x text-muted" style="font-size: 4rem;"></i>
<h4 class="mt-3 text-muted">No reservations scheduled for today</h4> <h4 class="mt-3 text-muted">{% trans %}No hay reservas programadas para hoy{% endtrans %}</h4>
<p class="text-muted"> <p class="text-muted">
There are no classroom reservations for {{ today.strftime('%B %d, %Y') }}. {% trans %}No se encontraron clases para los filtros seleccionados.{% endtrans %}
</p> </p>
<div class="mt-3"> <div class="mt-3">
{% if current_user.has_permission('reservations', 'read_write') %}
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success"> <a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success">
<i class="bi bi-plus-circle"></i> Schedule First Class <i class="bi bi-plus-circle me-1"></i>{% trans %}Crear Primera Reserva{% endtrans %}
</a>
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-primary ms-2">
<i class="bi bi-calendar-week"></i>View Week Schedule
</a>
{# Access to Genetic Algorithm Optimizer - Only for admin/teacher roles #}
{% if current_user and current_user.role in ['admin', 'teacher'] %}
<a href="{{ url_for('main.genetic_optimizer') }}" class="btn btn-info text-white ms-2">
<i class="bi bi-cpu"></i> AI Room Optimizer
</a> </a>
{% endif %} {% endif %}
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary ms-2">
<i class="bi bi-calendar-week me-1"></i>{% trans %}Ver Calendario Semanal{% endtrans %}
</a>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script> <script>
$(document).ready(function() { document.addEventListener('DOMContentLoaded', function() {
// Toggle between timeline and grid view var toggleBtn = document.getElementById('toggle-view-btn');
$('#toggle-view-btn').on('click', function() { var timelineView = document.getElementById('timeline-view');
const timelineView = $('#timeline-view'); var gridView = document.getElementById('grid-view');
const gridView = $('#grid-view');
const btn = $(this);
if (timelineView.is(':visible')) { if (toggleBtn && timelineView && gridView) {
timelineView.hide(); toggleBtn.addEventListener('click', function() {
gridView.show(); if (timelineView.style.display === 'none') {
btn.html('<i class="bi bi-list"></i> Timeline View'); timelineView.style.display = 'block';
gridView.style.display = 'none';
toggleBtn.innerHTML = '<i class="bi bi-grid me-1"></i>{% trans %}Vista en Tabla{% endtrans %}';
} else { } else {
timelineView.show(); timelineView.style.display = 'none';
gridView.hide(); gridView.style.display = 'block';
btn.html('<i class="bi bi-grid"></i> Grid View'); toggleBtn.innerHTML = '<i class="bi bi-clock-history me-1"></i>{% trans %}Vista Cronológica{% endtrans %}';
} }
}); });
}
// Refresh the page var refreshBtn = document.getElementById('refresh-btn');
$('#refresh-btn').on('click', function() { if (refreshBtn) {
refreshBtn.addEventListener('click', function() {
location.reload(); location.reload();
}); });
// Auto-refresh every 5 minutes
setInterval(function() {
location.reload();
}, 300000); // 5 minutes = 300000 milliseconds
// Highlight current time
const currentHour = new Date().getHours();
if (currentHour >= 7 && currentHour < 22) {
const timeBlockIndex = currentHour - 7;
$('.row.border-bottom').eq(timeBlockIndex).addClass('bg-primary bg-opacity-10');
} }
}); });
</script> </script>
+8 -7
View File
@@ -23,7 +23,7 @@
<!-- Main Reservation Info --> <!-- Main Reservation Info -->
<div class="col-lg-8 mb-4"> <div class="col-lg-8 mb-4">
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0"> <div class="card-header border-0 py-3">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0"> <h5 class="mb-0">
<i class="bi bi-info-circle text-primary"></i> Reservation Information <i class="bi bi-info-circle text-primary"></i> Reservation Information
@@ -54,7 +54,7 @@
<label class="text-muted small">Date & Time</label> <label class="text-muted small">Date & Time</label>
<p class="mb-0"> <p class="mb-0">
<i class="bi bi-clock"></i> <i class="bi bi-clock"></i>
{{ reservation.start_time.strftime('%B %d, %Y') }}<br> {{ format_date(reservation.start_time, format='long')|capitalize }}<br>
{{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }} {{ reservation.start_time.strftime('%H:%M') }} - {{ reservation.end_time.strftime('%H:%M') }}
</p> </p>
</div> </div>
@@ -86,7 +86,7 @@
<!-- Classroom Details --> <!-- Classroom Details -->
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0"> <div class="card-header border-0 py-3">
<h5 class="mb-0"> <h5 class="mb-0">
<i class="bi bi-building text-info"></i> Classroom Information <i class="bi bi-building text-info"></i> Classroom Information
</h5> </h5>
@@ -124,7 +124,7 @@
<div class="col-lg-4"> <div class="col-lg-4">
<!-- Subject & Commission --> <!-- Subject & Commission -->
<div class="card border-0 shadow-sm mb-4"> <div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0"> <div class="card-header border-0 py-3">
<h5 class="mb-0"> <h5 class="mb-0">
<i class="bi bi-book text-warning"></i> Subject & Commission <i class="bi bi-book text-warning"></i> Subject & Commission
</h5> </h5>
@@ -149,7 +149,7 @@
<!-- Attendees --> <!-- Attendees -->
<div class="card border-0 shadow-sm mb-4"> <div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0"> <div class="card-header border-0 py-3">
<h5 class="mb-0"> <h5 class="mb-0">
<i class="bi bi-people text-success"></i> Attendance <i class="bi bi-people text-success"></i> Attendance
</h5> </h5>
@@ -162,7 +162,8 @@
<div class="progress mt-3" style="height: 8px;"> <div class="progress mt-3" style="height: 8px;">
{% set percentage = (reservation.expected_attendees / reservation.classroom.capacity * 100) %} {% set percentage = (reservation.expected_attendees / reservation.classroom.capacity * 100) %}
<div class="progress-bar bg-{% if percentage > 90 %}danger{% elif percentage > 75 %}warning{% else %}success{% endif %}" <div class="progress-bar bg-{% if percentage > 90 %}danger{% elif percentage > 75 %}warning{% else %}success{% endif %}"
style="width: {{ percentage }}%"></div> role="progressbar"
style="{{ 'width: ' ~ (percentage|round(1)|string) ~ '%;' }}"></div>
</div> </div>
<small class="text-muted"> <small class="text-muted">
{{ reservation.expected_attendees }} / {{ reservation.classroom.capacity }} capacity {{ reservation.expected_attendees }} / {{ reservation.classroom.capacity }} capacity
@@ -174,7 +175,7 @@
<!-- Actions --> <!-- Actions -->
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0"> <div class="card-header border-0 py-3">
<h5 class="mb-0"> <h5 class="mb-0">
<i class="bi bi-gear text-secondary"></i> Actions <i class="bi bi-gear text-secondary"></i> Actions
</h5> </h5>
+3
View File
@@ -0,0 +1,3 @@
from .decorators import permission_required, admin_required
__all__ = ['permission_required', 'admin_required']
+41
View File
@@ -0,0 +1,41 @@
from functools import wraps
from flask import abort, redirect, url_for, request, flash
from flask_login import current_user
from flask_babel import gettext as _
def permission_required(module, min_level='read'):
"""
Decorator to restrict view access based on RBAC permissions.
Hierarchy: 'read_write' > 'read' > 'none'.
Administrators have implicit access to all modules.
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login', next=request.url))
if not current_user.has_permission(module, min_level):
flash(_('No tienes los permisos necesarios para acceder a esta sección.'), 'error')
abort(403)
return f(*args, **kwargs)
return decorated_function
return decorator
def admin_required(f):
"""
Decorator requiring administrative privileges or 'users' management access.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login', next=request.url))
if not current_user.can_manage():
flash(_('Esta sección requiere privilegios de administrador.'), 'error')
abort(403)
return f(*args, **kwargs)
return decorated_function
+2 -2
View File
@@ -1,3 +1,3 @@
[python: **.py] [python: app/**.py]
[jinja2: **/templates/**.html] [jinja2.ext.babel_extract: app/templates/**.html]
extensions=jinja2.ext.i18n extensions=jinja2.ext.i18n
+5 -3
View File
@@ -1,5 +1,7 @@
import os import os
basedir = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
class Config: class Config:
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production' SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'postgresql://postgres:password@localhost:5432/classrooms_db' SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'postgresql://postgres:password@localhost:5432/classrooms_db'
@@ -15,7 +17,7 @@ class Config:
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
# Internationalization # Internationalization
LANGUAGES = ['en', 'es'] LANGUAGES = ['es', 'en']
BABEL_DEFAULT_LOCALE = 'en' BABEL_DEFAULT_LOCALE = 'es'
BABEL_DEFAULT_TIMEZONE = 'UTC' BABEL_DEFAULT_TIMEZONE = 'UTC'
BABEL_TRANSLATION_DIRECTORIES = 'translations' BABEL_TRANSLATION_DIRECTORIES = os.path.join(basedir, 'translations')
@@ -0,0 +1,26 @@
import { NgModule } from '@angular/core';
import { PreloadAllModules, RouterModule, Routes } from '@angular/router';
const routes: Routes = [
{
path: 'home',
loadChildren: () => import('./home/home.module').then( m => m.HomePageModule)
},
{
path: 'perfil',
loadChildren: () => import('./perfil/perfil.module').then( m => m.PerfilPageModule)
},
{
path: '',
redirectTo: 'home',
pathMatch: 'full'
},
];
@NgModule({
imports: [
RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })
],
exports: [RouterModule]
})
export class AppRoutingModule { }
@@ -0,0 +1,3 @@
<ion-app>
<ion-router-outlet></ion-router-outlet>
</ion-app>
@@ -0,0 +1,21 @@
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [AppComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
});
@@ -0,0 +1,70 @@
import { Component, OnInit, NgZone } from '@angular/core';
import { LocalNotifications } from '@capacitor/local-notifications';
import { BackgroundFetch } from '@transistorsoft/capacitor-background-fetch';
import { Network } from '@capacitor/network';
import { DataService } from './services/data';
import { Router } from '@angular/router';
@Component({
selector: 'app-root',
templateUrl: 'app.component.html',
styleUrls: ['app.component.scss'],
standalone: false,
})
export class AppComponent implements OnInit {
constructor(
private dataService: DataService,
private router: Router,
private zone: NgZone
) {}
async ngOnInit() {
this.setupBackgroundFetch();
this.setupNotificationAction();
}
private async setupBackgroundFetch() {
const status = await Network.getStatus();
// WiFi: 15 min, Mobile: 60 min
const interval = status.connectionType === 'wifi' ? 15 : 60;
BackgroundFetch.configure({
minimumFetchInterval: interval,
stopOnTerminate: false,
startOnBoot: true,
enableHeadless: true
}, async (taskId: string) => {
console.log('[BackgroundFetch] Event received: ', taskId);
await this.performSync();
BackgroundFetch.finish(taskId);
}, (error: string) => {
console.error('[BackgroundFetch] FAILED: ', error);
});
}
private async performSync() {
try {
// 1. Obtener favoritos
const favs = await this.dataService.getFavorites();
// 2. Fetch y Sync (esto ya dispara notificaciones si hay cambios)
this.dataService.getHorarios().subscribe(async (newData) => {
await this.dataService.syncWithCache(newData, favs || []);
});
} catch (e) {
console.error('[BackgroundSync] Error:', e);
}
}
private setupNotificationAction() {
LocalNotifications.addListener('localNotificationActionPerformed', (notification) => {
const codigo = notification.notification.extra?.codigo;
if (codigo) {
this.zone.run(() => {
// Navegar a home y pasar el código para resaltar/scroll
this.router.navigate(['/home'], { queryParams: { highlight: codigo } });
});
}
});
}
}
@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { RouteReuseStrategy } from '@angular/router';
import { HttpClientModule } from '@angular/common/http';
import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule, IonicModule.forRoot(), AppRoutingModule, HttpClientModule],
providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }],
bootstrap: [AppComponent],
})
export class AppModule { }
@@ -0,0 +1,48 @@
<ion-header>
<ion-toolbar color="primary">
<ion-title>Agenda Académica</ion-title>
<ion-buttons slot="end">
<ion-button (click)="dismiss()">Cerrar</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding" color="light">
<div class="materia-info">
<h2>{{ materia.asignatura }}</h2>
<p>Código: {{ materia.codigo }}</p>
</div>
<ion-button expand="block" (click)="abrirEditor()" class="add-btn">
<ion-icon name="add-circle-outline" slot="start"></ion-icon>
Agendar Nuevo Evento
</ion-button>
<div class="empty-state" *ngIf="eventos.length === 0">
<ion-icon name="calendar-clear-outline"></ion-icon>
<p>No hay eventos programados.</p>
</div>
<ion-list *ngIf="eventos.length > 0" class="agenda-list">
<ion-item *ngFor="let ev of eventos" button (click)="abrirEditor(ev)" lines="full" class="agenda-item">
<div class="item-content">
<div class="item-header">
<ion-badge color="secondary">{{ ev.tipo }}</ion-badge>
</div>
<div class="item-details">
<p class="fecha-hora">
<ion-icon name="calendar-outline"></ion-icon> {{ formatFecha(ev.fecha) }}
<ion-icon name="time-outline" class="icon-space"></ion-icon> {{ formatHora(ev.fecha) }} hs
</p>
<p class="aula" *ngIf="ev.aula">
<ion-icon name="location-outline"></ion-icon> Aula: {{ ev.aula }}
</p>
<p class="notas" *ngIf="ev.notas">
{{ ev.notas }}
</p>
</div>
</div>
<ion-icon name="chevron-forward-outline" slot="end" color="medium"></ion-icon>
</ion-item>
</ion-list>
</ion-content>
@@ -0,0 +1,77 @@
.materia-info {
margin-bottom: 20px;
h2 {
font-size: 1.2rem;
font-weight: 700;
color: var(--ion-text-color);
margin-bottom: 4px;
}
p {
font-size: 0.9rem;
color: var(--ion-color-medium);
margin: 0;
}
}
.add-btn {
margin-bottom: 20px;
--border-radius: 8px;
}
.empty-state {
text-align: center;
margin-top: 40px;
color: var(--ion-color-medium);
ion-icon {
font-size: 3rem;
margin-bottom: 10px;
opacity: 0.5;
}
}
.agenda-list {
background: transparent;
}
.agenda-item {
--background: var(--ion-card-background, #ffffff);
--border-radius: 8px;
margin-bottom: 10px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
.item-content {
width: 100%;
padding: 10px 0;
}
.item-header {
margin-bottom: 8px;
}
.item-details {
p {
margin: 4px 0;
font-size: 0.9rem;
color: var(--ion-text-color);
display: flex;
align-items: center;
ion-icon {
margin-right: 6px;
color: var(--ion-color-primary);
}
&.notas {
color: var(--ion-color-medium);
font-style: italic;
margin-top: 8px;
font-size: 0.85rem;
}
}
.icon-space {
margin-left: 12px;
}
}
}
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { AgendaModalComponent } from './agenda-modal.component';
describe('AgendaModalComponent', () => {
let component: AgendaModalComponent;
let fixture: ComponentFixture<AgendaModalComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [AgendaModalComponent],
}).compileComponents();
fixture = TestBed.createComponent(AgendaModalComponent);
component = fixture.componentInstance;
fixture.detectChanges();
}));
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,68 @@
import { Component, Input, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule, ModalController } from '@ionic/angular';
import { EventoAcademico, ClaseHorario } from '../../services/data';
import { EventosService } from '../../services/eventos.service';
import { EventoModalComponent } from '../evento-modal/evento-modal.component';
@Component({
selector: 'app-agenda-modal',
templateUrl: './agenda-modal.component.html',
styleUrls: ['./agenda-modal.component.scss'],
standalone: true,
imports: [CommonModule, IonicModule]
})
export class AgendaModalComponent implements OnInit {
@Input() materia!: ClaseHorario;
eventos: EventoAcademico[] = [];
constructor(
private modalCtrl: ModalController,
private eventosService: EventosService
) {}
ngOnInit() {
this.cargarEventos();
}
ionViewWillEnter() {
this.cargarEventos();
}
cargarEventos() {
this.eventos = this.eventosService.getEventosByMateria(this.materia.codigo)
.sort((a, b) => new Date(a.fecha).getTime() - new Date(b.fecha).getTime());
}
dismiss() {
this.modalCtrl.dismiss();
}
formatFecha(fechaIso: string): string {
const d = new Date(fechaIso);
return d.toLocaleDateString([], { day: '2-digit', month: '2-digit', year: 'numeric' });
}
formatHora(fechaIso: string): string {
const d = new Date(fechaIso);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
async abrirEditor(evento?: EventoAcademico) {
const modal = await this.modalCtrl.create({
component: EventoModalComponent,
componentProps: {
materiaCodigo: this.materia.codigo,
materiaNombre: this.materia.asignatura,
eventoExistente: evento
}
});
await modal.present();
const { role } = await modal.onWillDismiss();
if (role === 'saved' || role === 'deleted') {
this.cargarEventos();
}
}
}
@@ -0,0 +1,74 @@
<ion-header>
<ion-toolbar color="primary">
<ion-title>Credencial Digital</ion-title>
<ion-buttons slot="end">
<ion-button (click)="close()">Cerrar</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="credential-content">
<!-- Loading State -->
<div class="state-container" *ngIf="state === 'loading'">
<ion-spinner name="crescent" color="primary"></ion-spinner>
<div class="loading-wrapper ion-text-center">
<p class="loading-text">{{ loadingStatus }}</p>
<p class="loading-subtext" style="font-size: 0.8rem; color: var(--ion-color-medium); margin-top: 5px;">
Por favor no cierres la aplicación
</p>
</div>
</div>
<!-- Error State -->
<div class="state-container error-container" *ngIf="state === 'error'">
<ion-icon name="alert-circle-outline" color="danger"></ion-icon>
<p class="error-text">{{ errorMessage }}</p>
<ion-button color="primary" fill="outline" (click)="resetState()">Intentar nuevamente</ion-button>
</div>
<!-- Login State -->
<div class="state-container login-container" *ngIf="state === 'login'">
<div class="login-card">
<h2 class="ion-text-center">Mi Credencial</h2>
<p class="ion-text-center text-muted">Ingresa con tus datos del campus virtual para generar tu credencial digital. Los datos se procesan de forma segura en tu dispositivo.</p>
<ion-item>
<ion-label position="floating">DNI</ion-label>
<ion-input type="number" [(ngModel)]="dni"></ion-input>
</ion-item>
<ion-item>
<ion-label position="floating">Contraseña</ion-label>
<ion-input [type]="showPassword ? 'text' : 'password'" [(ngModel)]="password"></ion-input>
<ion-button fill="clear" slot="end" class="ion-align-self-end" (click)="showPassword = !showPassword" style="margin-top: 15px;">
<ion-icon slot="icon-only" [name]="showPassword ? 'eye-off-outline' : 'eye-outline'" color="medium"></ion-icon>
</ion-button>
</ion-item>
<ion-button expand="block" class="ion-margin-top" (click)="generate()" [disabled]="!dni || !password">
Generar / Ver
</ion-button>
</div>
</div>
<!-- Success Credential State -->
<div class="state-container credential-container" *ngIf="state === 'success'">
<div class="credential-wrapper">
<img [src]="'data:image/png;base64,' + credentialImage" class="credential-img" alt="Credencial UNICABA" />
</div>
<div class="credential-actions">
<ion-button expand="block" color="primary" fill="outline" (click)="generate(true)">
<ion-icon name="refresh-outline" slot="start"></ion-icon>
Actualizar Credencial
</ion-button>
<ion-button expand="block" color="danger" fill="clear" (click)="logout()">
<ion-icon name="log-out-outline" slot="start"></ion-icon>
Cerrar Sesión
</ion-button>
</div>
</div>
</ion-content>
@@ -0,0 +1,93 @@
.state-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 80vh;
padding: 20px 16px;
}
.loading-text {
margin-top: 20px;
color: var(--ion-color-medium);
font-size: 1.1rem;
text-align: center;
}
.error-container {
ion-icon {
font-size: 64px;
margin-bottom: 20px;
}
.error-text {
color: var(--ion-color-danger);
text-align: center;
margin-bottom: 30px;
font-weight: 500;
}
}
.login-container {
.login-card {
background: var(--ion-card-background, #ffffff);
color: var(--ion-text-color, #000000);
padding: 30px 20px;
border-radius: 15px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
width: 100%;
max-width: 400px;
h2 {
color: var(--ion-color-primary);
margin-bottom: 10px;
}
.text-muted {
color: var(--ion-color-medium);
font-size: 0.9rem;
margin-bottom: 20px;
}
ion-item {
--background: transparent;
margin-bottom: 10px;
}
}
}
.credential-container {
justify-content: flex-start;
min-height: auto;
padding: 8px 8px 24px 8px;
width: 100%;
.credential-wrapper {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.credential-img {
width: 100%;
max-width: 450px;
border-radius: 14px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
border: 1px solid #efefef;
margin-bottom: 18px;
display: block;
object-fit: contain;
}
.credential-actions {
width: 100%;
max-width: 450px;
padding: 0 4px;
ion-button {
margin-top: 6px;
font-weight: 600;
letter-spacing: 0.5px;
}
}
}
@@ -0,0 +1,132 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule, ModalController, ToastController } from '@ionic/angular';
import { FormsModule } from '@angular/forms';
import { CredencialService } from '../../services/credencial.service';
import { AuthService } from '../../services/auth.service';
import { ScreenBrightness } from '@capacitor-community/screen-brightness';
type CredencialState = 'loading' | 'login' | 'success' | 'error';
@Component({
selector: 'app-credencial-modal',
templateUrl: './credencial-modal.component.html',
styleUrls: ['./credencial-modal.component.scss'],
standalone: true,
imports: [CommonModule, IonicModule, FormsModule]
})
export class CredencialModalComponent implements OnInit, OnDestroy {
state: CredencialState = 'loading';
errorMessage: string = '';
loadingStatus: string = 'Iniciando...';
dni: string = '';
password: string = '';
credentialImage: string = '';
showPassword: boolean = false;
originalBrightness: number | null = null;
constructor(
private modalCtrl: ModalController,
private credencialService: CredencialService,
private authService: AuthService,
private toastCtrl: ToastController
) { }
async ngOnInit() {
try {
const { brightness } = await ScreenBrightness.getBrightness();
this.originalBrightness = brightness;
await ScreenBrightness.setBrightness({ brightness: 1 });
} catch (e) {
console.warn('Brightness not supported on the web platform', e);
}
await this.checkExistingCredential();
}
ngOnDestroy() {
if (this.originalBrightness !== null) {
ScreenBrightness.setBrightness({ brightness: this.originalBrightness }).catch(() => { });
}
}
async checkExistingCredential() {
this.state = 'loading';
this.loadingStatus = 'Comprobando caché...';
try {
// 1. Try to load cached image first for instant load
const cachedImg = await this.credencialService.getCachedImage();
if (cachedImg) {
this.credentialImage = cachedImg;
this.state = 'success';
// Even if we show cache, load credentials to fields in background in case they want to update
const stored = await this.authService.getMoodleCredentials();
this.dni = stored.dni || '';
this.password = stored.pass || '';
return;
}
// 2. If no cached image, check for credentials to auto-login
const stored = await this.authService.getMoodleCredentials();
if (stored.dni && stored.pass) {
this.dni = stored.dni;
this.password = stored.pass;
await this.generate(false);
} else {
// 3. Need user to enter credentials
this.state = 'login';
}
} catch (error) {
console.error('Error on init:', error);
this.state = 'login';
}
}
async generate(forceUpdate: boolean = false) {
if (!this.dni || !this.password) return;
this.state = 'loading';
try {
this.credentialImage = await this.credencialService.generateCredential(
this.dni,
this.password,
(status) => { this.loadingStatus = status; }
);
this.state = 'success';
if (forceUpdate) {
const toast = await this.toastCtrl.create({
message: 'Credencial actualizada correctamente',
duration: 2000,
color: 'success'
});
await toast.present();
}
} catch (err: any) {
console.error('Error generating credential:', err);
this.errorMessage = err.message || 'Error al conectar con el campus.';
this.state = 'error';
}
}
async logout() {
await this.authService.logoutMoodle();
this.dni = '';
this.password = '';
this.credentialImage = '';
this.state = 'login';
}
resetState() {
// Determine where to back out: usually to login
this.state = 'login';
}
close() {
this.modalCtrl.dismiss();
}
}
@@ -0,0 +1,101 @@
<ion-header>
<ion-toolbar color="primary">
<ion-title>{{ isEdit ? 'Editar' : 'Programar' }} Hito</ion-title>
<ion-buttons slot="end">
<ion-button (click)="dismiss()">Cerrar</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="materia-info">
<small>Materia:</small>
<h3>{{ materiaNombre }}</h3>
</div>
<ion-list lines="inset" class="compact-list">
<ion-item class="input-item">
<ion-label position="stacked">Tipo e Hito</ion-label>
<ion-select [(ngModel)]="tipo" interface="popover">
<ion-select-option value="Parcial 1">Parcial 1</ion-select-option>
<ion-select-option value="Parcial 2">Parcial 2</ion-select-option>
<ion-select-option value="TP 1">Trabajo Práctico 1</ion-select-option>
<ion-select-option value="TP 2">Trabajo Práctico 2</ion-select-option>
<ion-select-option value="TP 3">Trabajo Práctico 3</ion-select-option>
<ion-select-option value="Recu. P1">Recuperatorio Parcial 1</ion-select-option>
<ion-select-option value="Recu. P2">Recuperatorio Parcial 2</ion-select-option>
<ion-select-option value="Recu. TP 1">Recuperatorio TP 1</ion-select-option>
<ion-select-option value="Recu. TP 2">Recuperatorio TP 2</ion-select-option>
<ion-select-option value="Recu. TP 3">Recuperatorio TP 3</ion-select-option>
<ion-select-option value="Final">Final</ion-select-option>
<ion-select-option value="Otro">Otro</ion-select-option>
</ion-select>
</ion-item>
<ion-grid class="ion-no-padding">
<ion-row>
<ion-col size="6">
<ion-item class="input-item side-item">
<ion-label position="stacked">Día del Hito</ion-label>
<ion-datetime-button datetime="date-input"></ion-datetime-button>
<ion-popover [keepContentsMounted]="true">
<ng-template>
<ion-datetime id="date-input" [(ngModel)]="fecha" presentation="date" prefer-wheel="true" [showDefaultButtons]="true" doneText="OK" cancelText="Atrás"></ion-datetime>
</ng-template>
</ion-popover>
</ion-item>
</ion-col>
<ion-col size="6">
<ion-item class="input-item side-item">
<ion-label position="stacked">Hora aprox.</ion-label>
<ion-datetime-button datetime="time-input"></ion-datetime-button>
<ion-popover [keepContentsMounted]="true">
<ng-template>
<ion-datetime id="time-input" [(ngModel)]="fecha" presentation="time" prefer-wheel="true" [showDefaultButtons]="true" doneText="OK" cancelText="Atrás"></ion-datetime>
</ng-template>
</ion-popover>
</ion-item>
</ion-col>
</ion-row>
</ion-grid>
<ion-grid class="ion-no-padding">
<ion-row>
<ion-col size="6">
<ion-item class="input-item side-item">
<ion-label position="stacked">Aula / Zoom</ion-label>
<ion-input type="text" [(ngModel)]="aula" placeholder="Ej: 102"></ion-input>
</ion-item>
</ion-col>
<ion-col size="6">
<ion-item class="input-item side-item">
<ion-label position="stacked">Link</ion-label>
<ion-input type="url" [(ngModel)]="link" placeholder="https://"></ion-input>
</ion-item>
</ion-col>
</ion-row>
</ion-grid>
<ion-item class="input-item">
<ion-label position="stacked">Notas</ion-label>
<ion-textarea [(ngModel)]="notas" placeholder="Breve descripción..." rows="2"></ion-textarea>
</ion-item>
</ion-list>
<div class="actions-container">
<ion-button expand="block" color="secondary" (click)="guardar()" class="main-btn">
{{ isEdit ? 'Actualizar Agenda' : 'Agendar Hito' }}
</ion-button>
<ion-button expand="block" fill="outline" color="dark" (click)="exportarCalendario()" class="export-btn" style="margin-top: 10px;">
<ion-icon name="calendar-outline" slot="start"></ion-icon>
Añadir a Google Calendar
</ion-button>
<ion-button *ngIf="isEdit" expand="block" fill="clear" color="danger" (click)="eliminar()" class="delete-btn">
<ion-icon name="trash-outline" slot="start"></ion-icon>
Eliminar Evento
</ion-button>
</div>
</ion-content>
@@ -0,0 +1,52 @@
.materia-info {
margin-top: 10px;
h3 {
margin: 5px 0 20px;
font-size: 1.4rem;
font-weight: 700;
}
}
.input-item {
--padding-start: 10px;
--padding-end: 10px;
--padding-top: 5px;
--padding-bottom: 5px;
--min-height: 45px;
margin-bottom: 10px;
border-radius: 8px;
background: var(--ion-color-light);
ion-label {
font-weight: 600;
margin-bottom: 4px !important;
font-size: 0.85rem;
}
}
.side-item {
margin-left: 2px;
margin-right: 2px;
}
.compact-list {
padding-top: 0;
}
ion-datetime-button {
margin-top: 8px;
margin-bottom: 8px;
}
.actions-container {
margin-top: 30px;
}
.main-btn {
margin-bottom: 15px;
}
.delete-btn {
--color: var(--ion-color-danger);
}
@@ -0,0 +1,117 @@
import { Component, Input, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule, ModalController, AlertController, ToastController } from '@ionic/angular';
import { EventoAcademico } from '../../services/data';
import { EventosService } from '../../services/eventos.service';
import { CapacitorCalendar } from '@ebarooni/capacitor-calendar';
@Component({
selector: 'app-evento-modal',
templateUrl: './evento-modal.component.html',
styleUrls: ['./evento-modal.component.scss'],
standalone: true,
imports: [CommonModule, IonicModule, FormsModule]
})
export class EventoModalComponent implements OnInit {
@Input() materiaCodigo!: string;
@Input() materiaNombre!: string;
@Input() eventoExistente?: EventoAcademico;
tipo: 'Parcial' | 'TP' | 'Final' | 'Otro' = 'Parcial';
fecha: string = new Date().toISOString();
aula: string = '';
link: string = '';
notas: string = '';
isEdit: boolean = false;
constructor(
private modalCtrl: ModalController,
private eventosService: EventosService,
private alertCtrl: AlertController,
private toastCtrl: ToastController
) { }
ngOnInit() {
if (this.eventoExistente) {
this.isEdit = true;
this.tipo = this.eventoExistente.tipo;
this.fecha = this.eventoExistente.fecha;
this.aula = this.eventoExistente.aula || '';
this.link = this.eventoExistente.link || '';
this.notas = this.eventoExistente.notas || '';
}
}
dismiss() {
this.modalCtrl.dismiss();
}
async guardar() {
const nuevoEvento: EventoAcademico = {
id: this.isEdit ? this.eventoExistente!.id : Date.now().toString(),
materiaCodigo: this.materiaCodigo,
tipo: this.tipo,
fecha: this.fecha,
aula: this.aula,
link: this.link,
notas: this.notas
};
await this.eventosService.saveEvento(nuevoEvento);
this.modalCtrl.dismiss(nuevoEvento, 'saved');
}
async eliminar() {
if (this.eventoExistente) {
const alert = await this.alertCtrl.create({
header: 'Eliminar Evento',
message: '¿Estás seguro que querés eliminar este hito de tu agenda?',
buttons: [
{
text: 'Cancelar',
role: 'cancel'
},
{
text: 'Eliminar',
role: 'destructive',
handler: async () => {
await this.eventosService.deleteEvento(this.eventoExistente!.id);
this.modalCtrl.dismiss(null, 'deleted');
}
}
]
});
await alert.present();
}
}
async exportarCalendario() {
try {
const startDate = new Date(this.fecha);
const endDate = new Date(startDate.getTime() + 2 * 60 * 60 * 1000); // +2 horas por defecto
let descString = this.notas || '';
if (this.link) descString += `\n\nLink de cursada: ${this.link}`;
await CapacitorCalendar.createEventWithPrompt({
title: `${this.tipo} de ${this.materiaNombre}`,
description: descString,
location: this.aula ? `Aula ${this.aula}, Universidad de la Ciudad` : 'Universidad de la Ciudad',
startDate: startDate.getTime(),
endDate: endDate.getTime(),
isAllDay: false
});
} catch (e) {
console.error('Error abriendo enlace de Calendario:', e);
const toast = await this.toastCtrl.create({
message: 'No pudimos abrir tu Calendario. Verificá si tenés una app instalada.',
duration: 2500, color: 'warning'
});
toast.present();
}
}
}

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