feat(ui/rbac): ordenar navegacion segun estandares de la industria y personalizar dashboards por rol (Admin, Bedelia, Docente, Alumno)
This commit is contained in:
@@ -488,6 +488,43 @@
|
|||||||
* **Pruebas y Verificación:**
|
* **Pruebas y Verificación:**
|
||||||
* Incorporación de tests en `tests/test_view_switcher.py`, validando renderizado de selectores y contenedores en ambas rutas (20/20 pruebas aprobadas).
|
* Incorporación de tests en `tests/test_view_switcher.py`, validando renderizado de selectores y contenedores en ambas rutas (20/20 pruebas aprobadas).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 29. Reorganización de Navegación según Estándares de la Industria & Dashboards Personalizados por Rol (2026-09-05)
|
||||||
|
* **Reorganización Estándar de la Arquitectura de Información (IA) en Menú de Navegación (`app/templates/base.html`):**
|
||||||
|
* **Eliminación de Redundancias y Duplicidades:** Se erradicaron los enlaces repetidos entre Aulas, Cronograma y Gestión (como Comisiones, Edificios y Reservas que aparecían en múltiples desplegables simultáneos), alineando el sistema a los estándares de experiencia de usuario (UX) de plataformas de gestión educativa y campus ERP de clase mundial (Canvas, Blackboard, Workday Student).
|
||||||
|
* **Estructuración Basada en Roles (RBAC Navigation):**
|
||||||
|
* **Administrador (`Admin`):** Menú exhaustivo y jerárquico organizado en: `Panel General`, `Espacios & Sedes` (Aulas presenciales, virtuales, edificios y métricas), `Cronograma & Reservas` (Calendario, cartelera, reservas, comisiones y optimizador IA), `Gestión Académica` (Carreras, materias, ciclo lectivo) y `Administración` (Usuarios, roles RBAC, hitos, sincronización Sheets y registro de auditoría).
|
||||||
|
* **Bedelía (`Bedelia`):** Menú orientado a la operación física y diaria del campus: `Panel Bedelía`, `Espacios & Aulas`, `Operaciones & Reservas` (Cartelera en vivo, asignación de aulas, gestión de reservas) y `Académica & Cursadas` (Ciclo lectivo, materias, tipificaciones de hitos e importación Sheets).
|
||||||
|
* **Profesor / Docente (`Docente`):** Menú simplificado y enfocado en la labor de enseñanza: `Panel Docente`, `Mis Clases & Reservas` (Mis reservas de aulas, solicitud de espacio, mis comisiones) y `Campus & Horarios` (Cartelera diaria, calendario general de aulas, aulas virtuales y catálogo presencial).
|
||||||
|
* **Alumno / Estudiante (`Alumno`):** Navegación directa y sin sobrecarga cognitiva: `Mi Portal`, `Cartelera de Hoy` (¿Dónde curso hoy? Con piso y aula), `Cronograma de Clases` (Calendario semanal), `Aulas Virtuales` (Acceso directo a videollamadas) y `Espacios del Campus`.
|
||||||
|
* **Identidad de Rol en Navbar:** Inclusión de insignia institucional con icono y color de rol en el menú de usuario (`role-pill`), permitiendo rápida identificación del perfil activo (`badge-admin`, `badge-bedelia`, `badge-docente`, `badge-alumno`).
|
||||||
|
* **Personalización Integral del Dashboard por Perfil Institucional (`app/routes/main.py` y `app/templates/dashboard.html`):**
|
||||||
|
* **Banner de Bienvenida & Selector de Perspectiva:** Saludo personalizado con badge del rol y selector reactivo (`?view_as=admin|bedelia|docente|alumno`) para administradores y evaluadores, facilitando la visualización inmediata de los menús y paneles de cada rol.
|
||||||
|
* **Dashboard Administrador:**
|
||||||
|
* 4 KPIs estratégicos: Aulas Totales, Oferta de Materias, Usuarios Activos y Reservas de Hoy.
|
||||||
|
* Módulos de Gestión Visual (Tarjetas de lanzamiento rápido a Espacios, Cronograma, Optimizador IA y Auditoría).
|
||||||
|
* Gráfico mensual de tendencias de reservas (`Chart.js`) y tabla de los últimos eventos de la bitácora de auditoría.
|
||||||
|
* **Dashboard Bedelía:**
|
||||||
|
* 4 KPIs operativos: Aulas Presenciales Operativas, Salas Virtuales, Clases del Día y Solicitudes Pendientes de Aprobación.
|
||||||
|
* Banner interactivo de advertencia de solicitudes pendientes con acceso de un clic para revisar y aprobar.
|
||||||
|
* Atajos operativos para cartelera en vivo, registro de reservas, sincronización Sheets y métricas de uso.
|
||||||
|
* **Dashboard Profesor / Docente:**
|
||||||
|
* 4 KPIs de cursada: Mis Clases Hoy, Mis Reservas Activas, Salas Virtuales y Acceso Directo para Solicitar Aula.
|
||||||
|
* Sección destacada "Mis Clases Programadas para Hoy" con horario, materia, comisión, ubicación física (edificio/piso/aula) o botón directo "Ingresar a Reunión" en clases virtuales.
|
||||||
|
* Accesos directos a comisiones a cargo y cartelera.
|
||||||
|
* **Dashboard Alumno / Estudiante:**
|
||||||
|
* Brújula diaria "¿Dónde curso hoy?": Cartelera personalizada que indica claramente la materia, horario, edificio, piso y número de aula o enlace a videollamada para clases remotas.
|
||||||
|
* Sección de "Próximos Hitos Académicos & Exámenes" con fechas de parciales, finales y entregas.
|
||||||
|
* Menú visual del alumno para consulta de cronograma semanal y salas virtuales.
|
||||||
|
* **Módulo de Registro de Auditoría (`app/routes/admin.py` y `app/templates/admin/audit_logs.html`):**
|
||||||
|
* Creación del endpoint `@admin_bp.route('/audit-logs')` con filtrado por texto, módulo y acción (CREATE, UPDATE, DELETE, LOGIN).
|
||||||
|
* Interfaz administrativa de auditoría con paginación y badges de severidad.
|
||||||
|
* **Normalización de Roles en Modelo de Dominio (`app/models/user.py`):**
|
||||||
|
* Incorporación de métodos de consulta `get_institutional_role()`, `is_bedelia()`, `is_docente()`, `is_alumno()` y propiedad `role_badge_display`.
|
||||||
|
* **Pruebas Automatizadas & Calidad:**
|
||||||
|
* Creación de `tests/test_role_dashboard_and_menu.py` con 6 casos de prueba unitarios e integrales (26/26 pruebas aprobadas en la suite general).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,39 @@ La plataforma implementa una arquitectura desacoplada de visualización (`app/st
|
|||||||
* **Preservación de Filtros:** Inyección automática del parámetro `display` en formularios y sincronización de URL sin recarga (`history.replaceState`).
|
* **Preservación de Filtros:** Inyección automática del parámetro `display` en formularios y sincronización de URL sin recarga (`history.replaceState`).
|
||||||
* **Arquitectura Extensible:** Permite registrar futuras vistas (kanban, calendario, compacta) mediante atributos `data-view` y `data-view-container` sin alterar la lógica de negocio.
|
* **Arquitectura Extensible:** Permite registrar futuras vistas (kanban, calendario, compacta) mediante atributos `data-view` y `data-view-container` sin alterar la lógica de negocio.
|
||||||
|
|
||||||
|
### 🧭 10. Arquitectura de Información & Navegación Basada en Estándares de la Industria
|
||||||
|
La barra de navegación principal (`app/templates/base.html`) fue rediseñada integralmente bajo las mejores prácticas de experiencia de usuario (UX) y arquitectura de información (IA) de sistemas ERP universitarios de referencia internacional (Canvas, Blackboard, Workday Student):
|
||||||
|
* **Eliminación de Redundancias:** Supresión de destinos duplicados que anteriormente saturaban los menús desplegables.
|
||||||
|
* **Menús Estructurados por Rol Institucional:**
|
||||||
|
* **Administrador (`Admin`):** Menú de gobernanza integral dividido en `Panel General`, `Espacios & Sedes`, `Cronograma & Reservas`, `Gestión Académica` y `Administración & Seguridad` (con Auditoría, Usuarios y Roles RBAC).
|
||||||
|
* **Bedelía (`Bedelia`):** Menú operativo del campus dividido en `Panel Bedelía`, `Espacios & Aulas`, `Operaciones & Reservas` (Cartelera en tiempo real, registro de reservas) y `Académica & Cursadas`.
|
||||||
|
* **Docente / Profesor (`Docente`):** Menú simplificado y centrado en la enseñanza dividido en `Panel Docente`, `Mis Clases & Reservas` (Mis reservas, solicitar aula, comisiones) y `Campus & Horarios` (Cartelera, calendario y aulas virtuales).
|
||||||
|
* **Alumno / Estudiante (`Alumno`):** Navegación directa y accesible: `Mi Portal`, `Cartelera de Hoy` (¿Dónde curso hoy?), `Cronograma de Clases`, `Aulas Virtuales` y `Espacios del Campus`.
|
||||||
|
* **Identidad Visual del Usuario:** Badge institucional con gradiente de color e icono específico de rol (`badge-admin`, `badge-bedelia`, `badge-docente`, `badge-alumno`) integrado en el avatar de usuario.
|
||||||
|
|
||||||
|
### 🎯 11. Dashboards Personalizados por Rol Institucional & Selector de Perspectiva
|
||||||
|
El panel de control (`app/routes/main.py` y `app/templates/dashboard.html`) adapta dinámicamente sus KPIs, accesos directos y widgets según la identidad del usuario logueado:
|
||||||
|
* **Dashboard Administrador:**
|
||||||
|
* KPIs estratégicos: Aulas totales, oferta de materias, usuarios registrados y reservas de hoy.
|
||||||
|
* Módulos visuales de lanzamiento rápido a las áreas clave del sistema.
|
||||||
|
* Gráfico analítico de evolución mensual de reservas (`Chart.js`) y tabla de eventos recientes de auditoría.
|
||||||
|
* **Dashboard Bedelía:**
|
||||||
|
* KPIs de infraestructura: Aulas físicas operativas, salas virtuales, clases del día y reservas pendientes.
|
||||||
|
* **Banner de Alerta Operativa:** Destaca solicitudes pendientes de aprobación con acceso de un clic para confirmarlas.
|
||||||
|
* Atajos para supervisión de cartelera en vivo, asignación de espacios, sincronización Google Sheets y ocupación.
|
||||||
|
* **Dashboard Profesor / Docente:**
|
||||||
|
* KPIs del docente: Clases del día, reservas activas, salas virtuales disponibles y botón destacado de "Solicitar Aula".
|
||||||
|
* **Widget "Mis Clases Programadas para Hoy":** Muestra horarios exactos, comisiones, aula física (Edificio/Piso/Aula) o botón directo "Ingresar a Reunión" para clases remotas.
|
||||||
|
* **Dashboard Alumno / Estudiante:**
|
||||||
|
* **Brújula Diaria "¿Dónde curso hoy?":** Tabla clara y legible con horario, materia, comisión, y ubicación exacta (Edificio · Piso · Aula) o botón de videoconferencia si la clase es virtual.
|
||||||
|
* **Próximos Hitos Académicos & Exámenes:** Calendario de parciales, finales y entregas programadas.
|
||||||
|
* **Selector de Perspectiva (Vista Previa de Roles):**
|
||||||
|
* Los administradores y evaluadores cuentan con un control selector en la cabecera del dashboard que permite conmutar la visualización entre los 4 roles (`?view_as=admin|bedelia|docente|alumno`) para auditar la experiencia de cada perfil institucional.
|
||||||
|
|
||||||
|
### 🛡️ 12. Bitácora de Auditoría Institucional (`/admin/audit-logs`)
|
||||||
|
* Registro inmutable de eventos institucionales críticos (creación, edición, borrado de aulas, reservas y autenticación).
|
||||||
|
* Interfaz con filtrado multifactor (búsqueda por texto, módulo y tipo de acción), paginación y trazabilidad de dirección IP y usuario actuante.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛠️ Stack Tecnológico
|
## 🛠️ Stack Tecnológico
|
||||||
|
|||||||
@@ -45,6 +45,43 @@ class User(UserMixin, db.Model):
|
|||||||
return True
|
return True
|
||||||
return str(self.role).upper() in ['ADMIN', 'ADMINISTRADOR']
|
return str(self.role).upper() in ['ADMIN', 'ADMINISTRADOR']
|
||||||
|
|
||||||
|
def get_institutional_role(self):
|
||||||
|
"""Returns normalized institutional role: 'admin', 'bedelia', 'docente', 'alumno'"""
|
||||||
|
raw = ''
|
||||||
|
if self.role_obj and self.role_obj.name:
|
||||||
|
raw = self.role_obj.name.lower().strip()
|
||||||
|
elif self.role:
|
||||||
|
raw = str(self.role).lower().strip()
|
||||||
|
|
||||||
|
if 'admin' in raw:
|
||||||
|
return 'admin'
|
||||||
|
if 'bedel' in raw:
|
||||||
|
return 'bedelia'
|
||||||
|
if 'docent' in raw or 'prof' in raw or 'teacher' in raw:
|
||||||
|
return 'docente'
|
||||||
|
if 'alumn' in raw or 'estud' in raw or 'student' in raw:
|
||||||
|
return 'alumno'
|
||||||
|
return 'docente'
|
||||||
|
|
||||||
|
def is_bedelia(self):
|
||||||
|
return self.get_institutional_role() == 'bedelia'
|
||||||
|
|
||||||
|
def is_docente(self):
|
||||||
|
return self.get_institutional_role() == 'docente'
|
||||||
|
|
||||||
|
def is_alumno(self):
|
||||||
|
return self.get_institutional_role() == 'alumno'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def role_badge_display(self):
|
||||||
|
role_map = {
|
||||||
|
'admin': {'label': 'Administrador', 'class': 'badge-admin', 'icon': 'bi-shield-check', 'color': 'primary'},
|
||||||
|
'bedelia': {'label': 'Bedelía', 'class': 'badge-bedelia', 'icon': 'bi-building-gear', 'color': 'warning'},
|
||||||
|
'docente': {'label': 'Profesor / Docente', 'class': 'badge-docente', 'icon': 'bi-person-video3', 'color': 'info'},
|
||||||
|
'alumno': {'label': 'Alumno / Estudiante', 'class': 'badge-alumno', 'icon': 'bi-mortarboard', 'color': 'success'}
|
||||||
|
}
|
||||||
|
return role_map.get(self.get_institutional_role(), {'label': self.role or 'Docente', 'class': 'bg-secondary', 'icon': 'bi-person', 'color': 'secondary'})
|
||||||
|
|
||||||
def has_permission(self, module, min_level='read'):
|
def has_permission(self, module, min_level='read'):
|
||||||
"""Check if user has granular permission on a given module"""
|
"""Check if user has granular permission on a given module"""
|
||||||
if self.is_admin():
|
if self.is_admin():
|
||||||
@@ -57,6 +94,7 @@ class User(UserMixin, db.Model):
|
|||||||
"""Check if user can access the Management / Admin section"""
|
"""Check if user can access the Management / Admin section"""
|
||||||
return (
|
return (
|
||||||
self.is_admin() or
|
self.is_admin() or
|
||||||
|
self.is_bedelia() or
|
||||||
self.has_permission('users', 'read') or
|
self.has_permission('users', 'read') or
|
||||||
self.has_permission('milestone_types', 'read') or
|
self.has_permission('milestone_types', 'read') or
|
||||||
self.has_permission('import', 'read') or
|
self.has_permission('import', 'read') or
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from app import db
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.role import Role, Permission, SYSTEM_MODULES, ACCESS_LEVELS
|
from app.models.role import Role, Permission, SYSTEM_MODULES, ACCESS_LEVELS
|
||||||
from app.models.milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES
|
from app.models.milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
from app.utils.decorators import admin_required, permission_required
|
from app.utils.decorators import admin_required, permission_required
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
import re
|
import re
|
||||||
@@ -1116,4 +1117,51 @@ def occupancy_metrics():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# AUDIT LOGS
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
|
||||||
|
@admin_bp.route('/audit-logs')
|
||||||
|
@admin_required
|
||||||
|
def audit_logs():
|
||||||
|
"""Vista de bitácora de auditoría del sistema"""
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
module_filter = request.args.get('module', '').strip()
|
||||||
|
action_filter = request.args.get('action', '').strip()
|
||||||
|
search = request.args.get('search', '').strip()
|
||||||
|
per_page = 20
|
||||||
|
|
||||||
|
query = AuditLog.query
|
||||||
|
|
||||||
|
if module_filter:
|
||||||
|
query = query.filter(AuditLog.module == module_filter)
|
||||||
|
if action_filter:
|
||||||
|
query = query.filter(AuditLog.action == action_filter)
|
||||||
|
if search:
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
AuditLog.user_email.ilike(f'%{search}%'),
|
||||||
|
AuditLog.details.ilike(f'%{search}%'),
|
||||||
|
AuditLog.ip_address.ilike(f'%{search}%')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
pagination = query.order_by(AuditLog.created_at.desc()).paginate(page=page, per_page=per_page, error_out=False)
|
||||||
|
|
||||||
|
# Módulos y acciones únicas para los selectores de filtro
|
||||||
|
modules = [r[0] for r in db.session.query(AuditLog.module).distinct().all() if r[0]]
|
||||||
|
actions = [r[0] for r in db.session.query(AuditLog.action).distinct().all() if r[0]]
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'admin/audit_logs.html',
|
||||||
|
pagination=pagination,
|
||||||
|
audit_logs=pagination.items,
|
||||||
|
modules=modules,
|
||||||
|
actions=actions,
|
||||||
|
current_module=module_filter,
|
||||||
|
current_action=action_filter,
|
||||||
|
search=search
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+193
-18
@@ -14,28 +14,40 @@ main_bp = Blueprint('main', __name__)
|
|||||||
@main_bp.route('/')
|
@main_bp.route('/')
|
||||||
@login_required
|
@login_required
|
||||||
def dashboard():
|
def dashboard():
|
||||||
"""Main dashboard page"""
|
"""Role-personalized institutional dashboard page"""
|
||||||
# Get statistics
|
from app.models.user import User
|
||||||
|
from app.models.milestone import AcademicMilestone
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
|
||||||
|
actual_role = current_user.get_institutional_role()
|
||||||
|
view_as = request.args.get('view_as', '').lower().strip()
|
||||||
|
|
||||||
|
# Allow admins and management roles to preview dashboard perspectives
|
||||||
|
if view_as in ['admin', 'bedelia', 'docente', 'alumno'] and (current_user.is_admin() or current_user.can_manage()):
|
||||||
|
role = view_as
|
||||||
|
else:
|
||||||
|
role = actual_role
|
||||||
|
|
||||||
|
today = datetime.utcnow().date()
|
||||||
|
|
||||||
|
# Common core statistics
|
||||||
try:
|
try:
|
||||||
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
||||||
except:
|
except Exception:
|
||||||
total_classrooms = 0
|
total_classrooms = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
total_subjects = Subject.query.filter_by(is_active=True).count()
|
total_subjects = Subject.query.filter_by(is_active=True).count()
|
||||||
except:
|
except Exception:
|
||||||
total_subjects = 0
|
total_subjects = 0
|
||||||
|
|
||||||
# Get today's reservations
|
|
||||||
today = datetime.utcnow().date()
|
|
||||||
try:
|
try:
|
||||||
today_reservations = Reservation.query.filter(
|
today_reservations = Reservation.query.filter(
|
||||||
func.date(Reservation.start_time) == today
|
func.date(Reservation.start_time) == today
|
||||||
).count()
|
).count()
|
||||||
except:
|
except Exception:
|
||||||
today_reservations = 0
|
today_reservations = 0
|
||||||
|
|
||||||
# Get this week's reservations
|
|
||||||
week_start = today - timedelta(days=today.weekday())
|
week_start = today - timedelta(days=today.weekday())
|
||||||
week_end = week_start + timedelta(days=6)
|
week_end = week_start + timedelta(days=6)
|
||||||
try:
|
try:
|
||||||
@@ -43,18 +55,9 @@ def dashboard():
|
|||||||
func.date(Reservation.start_time) >= week_start,
|
func.date(Reservation.start_time) >= week_start,
|
||||||
func.date(Reservation.start_time) <= week_end
|
func.date(Reservation.start_time) <= week_end
|
||||||
).count()
|
).count()
|
||||||
except:
|
except Exception:
|
||||||
week_reservations = 0
|
week_reservations = 0
|
||||||
|
|
||||||
# Get upcoming reservations (next 7 days)
|
|
||||||
try:
|
|
||||||
upcoming_reservations = Reservation.query.filter(
|
|
||||||
Reservation.start_time >= datetime.utcnow(),
|
|
||||||
Reservation.start_time <= datetime.utcnow() + timedelta(days=7)
|
|
||||||
).limit(10).all()
|
|
||||||
except:
|
|
||||||
upcoming_reservations = []
|
|
||||||
|
|
||||||
stats = {
|
stats = {
|
||||||
'total_classrooms': total_classrooms,
|
'total_classrooms': total_classrooms,
|
||||||
'total_subjects': total_subjects,
|
'total_subjects': total_subjects,
|
||||||
@@ -62,8 +65,180 @@ def dashboard():
|
|||||||
'week_reservations': week_reservations
|
'week_reservations': week_reservations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
role_data = {}
|
||||||
|
|
||||||
|
if role == 'admin':
|
||||||
|
# 1. Admin Institutional Cockpit
|
||||||
|
try:
|
||||||
|
total_users = User.query.filter_by(is_active=True).count()
|
||||||
|
except Exception:
|
||||||
|
total_users = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
pending_count = Reservation.query.filter(
|
||||||
|
Reservation.status == 'PENDING'
|
||||||
|
).count()
|
||||||
|
except Exception:
|
||||||
|
pending_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
recent_audits = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(6).all()
|
||||||
|
except Exception:
|
||||||
|
recent_audits = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
upcoming_reservations = Reservation.query.filter(
|
||||||
|
Reservation.start_time >= datetime.utcnow(),
|
||||||
|
Reservation.start_time <= datetime.utcnow() + timedelta(days=7)
|
||||||
|
).order_by(Reservation.start_time.asc()).limit(8).all()
|
||||||
|
except Exception:
|
||||||
|
upcoming_reservations = []
|
||||||
|
|
||||||
|
stats.update({
|
||||||
|
'total_users': total_users,
|
||||||
|
'pending_reservations': pending_count
|
||||||
|
})
|
||||||
|
role_data = {
|
||||||
|
'recent_audits': recent_audits,
|
||||||
|
'upcoming_reservations': upcoming_reservations
|
||||||
|
}
|
||||||
|
|
||||||
|
elif role == 'bedelia':
|
||||||
|
# 2. Bedelía Operations & Facilities Control
|
||||||
|
try:
|
||||||
|
physical_classrooms = Classroom.query.filter_by(is_active=True).filter(
|
||||||
|
~Classroom.room_number.ilike('%VIRTUAL%') & ~Classroom.building.ilike('%VIRTUAL%')
|
||||||
|
).count()
|
||||||
|
virtual_classrooms = total_classrooms - physical_classrooms
|
||||||
|
if virtual_classrooms < 0:
|
||||||
|
virtual_classrooms = 0
|
||||||
|
except Exception:
|
||||||
|
physical_classrooms = total_classrooms
|
||||||
|
virtual_classrooms = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
pending_reservations = Reservation.query.filter(
|
||||||
|
Reservation.status == 'PENDING'
|
||||||
|
).order_by(Reservation.start_time.asc()).limit(10).all()
|
||||||
|
pending_count = len(pending_reservations)
|
||||||
|
except Exception:
|
||||||
|
pending_reservations = []
|
||||||
|
pending_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
today_schedule_list = Reservation.query.filter(
|
||||||
|
func.date(Reservation.start_time) == today
|
||||||
|
).order_by(Reservation.start_time.asc()).limit(12).all()
|
||||||
|
except Exception:
|
||||||
|
today_schedule_list = []
|
||||||
|
|
||||||
|
occupancy_rate = min(100, int((today_reservations / max(1, physical_classrooms * 3)) * 100)) if physical_classrooms > 0 else 0
|
||||||
|
|
||||||
|
stats.update({
|
||||||
|
'physical_classrooms': physical_classrooms,
|
||||||
|
'virtual_classrooms': virtual_classrooms,
|
||||||
|
'pending_count': pending_count,
|
||||||
|
'occupancy_rate': occupancy_rate
|
||||||
|
})
|
||||||
|
role_data = {
|
||||||
|
'pending_reservations': pending_reservations,
|
||||||
|
'today_schedule_list': today_schedule_list
|
||||||
|
}
|
||||||
|
|
||||||
|
elif role == 'docente':
|
||||||
|
# 3. Docente (Profesor) Academic & Classroom Workflow
|
||||||
|
try:
|
||||||
|
my_today_classes = Reservation.query.filter(
|
||||||
|
Reservation.user_id == current_user.id,
|
||||||
|
func.date(Reservation.start_time) == today
|
||||||
|
).order_by(Reservation.start_time.asc()).all()
|
||||||
|
except Exception:
|
||||||
|
my_today_classes = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
my_upcoming_reservations = Reservation.query.filter(
|
||||||
|
Reservation.user_id == current_user.id,
|
||||||
|
Reservation.start_time >= datetime.utcnow()
|
||||||
|
).order_by(Reservation.start_time.asc()).limit(8).all()
|
||||||
|
except Exception:
|
||||||
|
my_upcoming_reservations = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
my_total_reservations = Reservation.query.filter(
|
||||||
|
Reservation.user_id == current_user.id
|
||||||
|
).count()
|
||||||
|
except Exception:
|
||||||
|
my_total_reservations = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
virtual_classrooms = Classroom.query.filter_by(is_active=True).filter(
|
||||||
|
(Classroom.room_number.ilike('%VIRTUAL%')) | (Classroom.building.ilike('%VIRTUAL%'))
|
||||||
|
).limit(4).all()
|
||||||
|
except Exception:
|
||||||
|
virtual_classrooms = []
|
||||||
|
|
||||||
|
stats.update({
|
||||||
|
'my_today_count': len(my_today_classes),
|
||||||
|
'my_total_reservations': my_total_reservations,
|
||||||
|
'virtual_rooms_count': len(virtual_classrooms)
|
||||||
|
})
|
||||||
|
role_data = {
|
||||||
|
'my_today_classes': my_today_classes,
|
||||||
|
'my_upcoming_reservations': my_upcoming_reservations,
|
||||||
|
'virtual_classrooms': virtual_classrooms
|
||||||
|
}
|
||||||
|
|
||||||
|
elif role == 'alumno':
|
||||||
|
# 4. Alumno (Estudiante) Daily Guide & Timetable
|
||||||
|
try:
|
||||||
|
today_classes = Reservation.query.filter(
|
||||||
|
func.date(Reservation.start_time) == today,
|
||||||
|
Reservation.status == 'CONFIRMED'
|
||||||
|
).order_by(Reservation.start_time.asc()).limit(12).all()
|
||||||
|
except Exception:
|
||||||
|
today_classes = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
upcoming_milestones = AcademicMilestone.query.filter(
|
||||||
|
AcademicMilestone.date >= datetime.utcnow(),
|
||||||
|
AcademicMilestone.is_active == True
|
||||||
|
).order_by(AcademicMilestone.date.asc()).limit(6).all()
|
||||||
|
except Exception:
|
||||||
|
upcoming_milestones = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
virtual_rooms = Classroom.query.filter_by(is_active=True).filter(
|
||||||
|
(Classroom.room_number.ilike('%VIRTUAL%')) | (Classroom.building.ilike('%VIRTUAL%'))
|
||||||
|
).limit(4).all()
|
||||||
|
except Exception:
|
||||||
|
virtual_rooms = []
|
||||||
|
|
||||||
|
stats.update({
|
||||||
|
'today_classes_count': len(today_classes),
|
||||||
|
'milestones_count': len(upcoming_milestones)
|
||||||
|
})
|
||||||
|
role_data = {
|
||||||
|
'today_classes': today_classes,
|
||||||
|
'upcoming_milestones': upcoming_milestones,
|
||||||
|
'virtual_rooms': virtual_rooms
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backward compatibility for upcoming_reservations
|
||||||
|
upcoming_reservations = role_data.get('upcoming_reservations', [])
|
||||||
|
if not upcoming_reservations:
|
||||||
|
try:
|
||||||
|
upcoming_reservations = Reservation.query.filter(
|
||||||
|
Reservation.start_time >= datetime.utcnow(),
|
||||||
|
Reservation.start_time <= datetime.utcnow() + timedelta(days=7)
|
||||||
|
).order_by(Reservation.start_time.asc()).limit(6).all()
|
||||||
|
except Exception:
|
||||||
|
upcoming_reservations = []
|
||||||
|
|
||||||
return render_template('dashboard.html',
|
return render_template('dashboard.html',
|
||||||
|
role=role,
|
||||||
|
actual_role=actual_role,
|
||||||
stats=stats,
|
stats=stats,
|
||||||
|
role_data=role_data,
|
||||||
upcoming_reservations=upcoming_reservations)
|
upcoming_reservations=upcoming_reservations)
|
||||||
|
|
||||||
@main_bp.route('/api/dashboard-stats')
|
@main_bp.route('/api/dashboard-stats')
|
||||||
|
|||||||
@@ -207,3 +207,98 @@
|
|||||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.08) !important;
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.08) !important;
|
||||||
border-color: var(--bs-primary) !important;
|
border-color: var(--bs-primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Role Badges & Pills */
|
||||||
|
.badge-admin {
|
||||||
|
background: linear-gradient(135deg, #7C3AED 0%, #B43E8E 100%) !important;
|
||||||
|
color: #FFFFFF !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-bedelia {
|
||||||
|
background: linear-gradient(135deg, #D97706 0%, #B45309 100%) !important;
|
||||||
|
color: #FFFFFF !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-docente {
|
||||||
|
background: linear-gradient(135deg, #0284C7 0%, #0369A1 100%) !important;
|
||||||
|
color: #FFFFFF !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-alumno {
|
||||||
|
background: linear-gradient(135deg, #059669 0%, #047857 100%) !important;
|
||||||
|
color: #FFFFFF !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-pill {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.22rem 0.55rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Role Dashboard Hero & Cards */
|
||||||
|
.dashboard-role-banner {
|
||||||
|
border-radius: 1rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(135deg, rgba(180, 62, 142, 0.08) 0%, rgba(104, 44, 173, 0.12) 100%);
|
||||||
|
border: 1px solid rgba(180, 62, 142, 0.18);
|
||||||
|
padding: 1.5rem 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .dashboard-role-banner {
|
||||||
|
background: linear-gradient(135deg, rgba(180, 62, 142, 0.15) 0%, rgba(42, 27, 61, 0.5) 100%);
|
||||||
|
border-color: rgba(180, 62, 142, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-view-selector .btn {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-view-selector .btn.active {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-menu-card {
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-menu-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 0.5rem 1.2rem rgba(0, 0, 0, 0.08) !important;
|
||||||
|
border-color: var(--brand-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .action-menu-card {
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .action-menu-card:hover {
|
||||||
|
border-color: var(--brand-primary-tint) !important;
|
||||||
|
box-shadow: 0 0.5rem 1.2rem rgba(0, 0, 0, 0.4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-icon-circle {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% trans %}Bitácora de Auditoría - Edu-Space Admin{% endtrans %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid pt-5 mt-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-1">
|
||||||
|
<i class="bi bi-shield-check text-primary me-2"></i>{% trans %}Registro de Auditoría Institucional{% endtrans %}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted small mb-0">{% trans %}Trazabilidad completa de operaciones, modificaciones y eventos de seguridad{% endtrans %}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('main.dashboard') }}" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>{% trans %}Volver al Panel{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtros -->
|
||||||
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<form method="GET" action="{{ url_for('admin.audit_logs') }}" class="row g-2 align-items-center">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<span class="input-group-text bg-transparent"><i class="bi bi-search"></i></span>
|
||||||
|
<input type="text" name="search" class="form-control" placeholder="{% trans %}Buscar por email, IP o detalles...{% endtrans %}" value="{{ search }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<select name="module" class="form-select form-select-sm">
|
||||||
|
<option value="">{% trans %}Todos los módulos{% endtrans %}</option>
|
||||||
|
{% for mod in modules %}
|
||||||
|
<option value="{{ mod }}" {% if current_module == mod %}selected{% endif %}>{{ mod|capitalize }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<select name="action" class="form-select form-select-sm">
|
||||||
|
<option value="">{% trans %}Todas las acciones{% endtrans %}</option>
|
||||||
|
{% for act in actions %}
|
||||||
|
<option value="{{ act }}" {% if current_action == act %}selected{% endif %}>{{ act }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 d-flex gap-1">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm flex-grow-1">
|
||||||
|
<i class="bi bi-filter me-1"></i>{% trans %}Filtrar{% endtrans %}
|
||||||
|
</button>
|
||||||
|
{% if search or current_module or current_action %}
|
||||||
|
<a href="{{ url_for('admin.audit_logs') }}" class="btn btn-outline-secondary btn-sm" title="Limpiar filtros">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabla de Auditoría -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if audit_logs %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light small text-uppercase">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3">{% trans %}Fecha / Hora{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Usuario{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Acción{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Módulo{% endtrans %}</th>
|
||||||
|
<th>{% trans %}ID Entidad{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Detalles{% endtrans %}</th>
|
||||||
|
<th class="pe-3">{% trans %}IP{% endtrans %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for log in audit_logs %}
|
||||||
|
<tr>
|
||||||
|
<td class="ps-3 text-nowrap">
|
||||||
|
<span class="small font-monospace">{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="fw-semibold small">{{ log.user_email or 'Sistema' }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if log.action == 'CREATE' %}
|
||||||
|
<span class="badge bg-success-subtle text-success border border-success-subtle">{{ log.action }}</span>
|
||||||
|
{% elif log.action == 'UPDATE' %}
|
||||||
|
<span class="badge bg-primary-subtle text-primary border border-primary-subtle">{{ log.action }}</span>
|
||||||
|
{% elif log.action == 'DELETE' %}
|
||||||
|
<span class="badge bg-danger-subtle text-danger border border-danger-subtle">{{ log.action }}</span>
|
||||||
|
{% elif 'LOGIN' in log.action %}
|
||||||
|
<span class="badge bg-info-subtle text-info border border-info-subtle">{{ log.action }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary-subtle text-secondary border">{{ log.action }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-body-secondary text-body border font-monospace small">{{ log.module }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="font-monospace small text-muted">
|
||||||
|
{{ log.entity_id or '-' }}
|
||||||
|
</td>
|
||||||
|
<td class="small text-muted" style="max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="{{ log.details }}">
|
||||||
|
{{ log.details or '-' }}
|
||||||
|
</td>
|
||||||
|
<td class="pe-3 font-monospace small text-muted">
|
||||||
|
{{ log.ip_address or '-' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Paginación -->
|
||||||
|
{% if pagination.pages > 1 %}
|
||||||
|
<div class="p-3 border-top d-flex justify-content-between align-items-center">
|
||||||
|
<small class="text-muted">
|
||||||
|
{% trans %}Mostrando página{% endtrans %} {{ pagination.page }} {% trans %}de{% endtrans %} {{ pagination.pages }} ({{ pagination.total }} {% trans %}registros{% endtrans %})
|
||||||
|
</small>
|
||||||
|
<nav>
|
||||||
|
<ul class="pagination pagination-sm mb-0">
|
||||||
|
<li class="page-item {% if not pagination.has_prev %}disabled{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('admin.audit_logs', page=pagination.prev_num, search=search, module=current_module, action=current_action) }}">
|
||||||
|
«
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% for p in pagination.iter_pages(left_edge=2, left_current=2, right_current=3, right_edge=2) %}
|
||||||
|
{% if p %}
|
||||||
|
<li class="page-item {% if p == pagination.page %}active{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('admin.audit_logs', page=p, search=search, module=current_module, action=current_action) }}">{{ p }}</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<li class="page-item {% if not pagination.has_next %}disabled{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('admin.audit_logs', page=pagination.next_num, search=search, module=current_module, action=current_action) }}">
|
||||||
|
»
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-shield-slash text-muted fs-1 d-block mb-2"></i>
|
||||||
|
<h6 class="fw-bold">{% trans %}No se encontraron registros de auditoría{% endtrans %}</h6>
|
||||||
|
<p class="text-muted small">{% trans %}Los eventos del sistema se registrarán automáticamente a medida que ocurran cambios.{% endtrans %}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
+159
-72
@@ -46,92 +46,171 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
{% set user_role = current_user.get_institutional_role() %}
|
||||||
<ul class="navbar-nav me-auto">
|
<ul class="navbar-nav me-auto">
|
||||||
|
{% if user_role == 'admin' %}
|
||||||
|
<!-- ================= ADMIN NAVIGATION ================= -->
|
||||||
<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> {% trans %}Dashboard{% endtrans %}
|
<i class="bi bi-speedometer2 me-1"></i>{% trans %}Panel General{% 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> {% trans %}Classrooms{% endtrans %}
|
<i class="bi bi-door-open me-1"></i>{% trans %}Espacios & Sedes{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu shadow-sm">
|
||||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms') }}">
|
|
||||||
<i class="bi bi-list"></i> {% trans %}All Classrooms{% endtrans %}
|
|
||||||
</a></li>
|
|
||||||
{% if current_user.has_permission('classrooms', 'read_write') %}
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.add_classroom') }}">
|
|
||||||
<i class="bi bi-plus-circle"></i> {% trans %}Add Classroom{% endtrans %}
|
|
||||||
</a></li>
|
|
||||||
{% endif %}
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}">
|
|
||||||
<i class="bi bi-buildings"></i> {% trans %}Buildings{% endtrans %}
|
|
||||||
</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item dropdown">
|
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
|
||||||
<i class="bi bi-calendar3"></i> {% trans %}Schedule{% endtrans %}
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu">
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('schedule.calendar_view') }}">
|
|
||||||
<i class="bi bi-calendar-week"></i> {% trans %}Calendar View{% endtrans %}
|
|
||||||
</a></li>
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}">
|
|
||||||
<i class="bi bi-list-check"></i> {% trans %}All Reservations{% endtrans %}
|
|
||||||
</a></li>
|
|
||||||
{% if current_user.has_permission('reservations', 'read_write') %}
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}">
|
|
||||||
<i class="bi bi-plus-circle"></i> {% trans %}New Reservation{% endtrans %}
|
|
||||||
</a></li>
|
|
||||||
{% endif %}
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}">
|
|
||||||
<i class="bi bi-calendar-today"></i> {% trans %}Today's Schedule{% endtrans %}
|
|
||||||
</a></li>
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('schedule.commissions_view') }}">
|
|
||||||
<i class="bi bi-diagram-3 me-2 text-primary"></i>{% trans %}Comisiones y Cursadas{% endtrans %}
|
|
||||||
</a></li>
|
|
||||||
{% if current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role|upper == 'ADMIN') %}
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('genetic_algorithm_web.genetic_optimizer') }}">
|
|
||||||
<i class="bi bi-cpu text-info me-2"></i>{% trans %}Optimizador de Aulas (IA){% endtrans %}
|
|
||||||
</a></li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
{% if current_user.is_authenticated and current_user.can_manage() %}
|
|
||||||
<li class="nav-item dropdown">
|
|
||||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
|
||||||
<i class="bi bi-shield-lock"></i> {% trans %}Gestión{% endtrans %}
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu shadow-sm" style="min-width: 260px;">
|
|
||||||
<!-- Espacios & Infraestructura -->
|
|
||||||
<li class="dropdown-header text-uppercase small fw-bold text-muted py-1" style="font-size: 0.68rem; letter-spacing: 0.5px;">{% trans %}Espacios e Infraestructura{% endtrans %}</li>
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2 text-secondary"></i>{% trans %}Edificios y Sedes{% endtrans %}</a></li>
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}"><i class="bi bi-door-open me-2 text-primary"></i>{% trans %}Aulas Presenciales{% endtrans %}</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}"><i class="bi bi-door-open me-2 text-primary"></i>{% trans %}Aulas Presenciales{% endtrans %}</a></li>
|
||||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}"><i class="bi bi-camera-video me-2 text-info"></i>{% trans %}Aulas Virtuales (Reunión){% endtrans %}</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}"><i class="bi bi-camera-video me-2 text-info"></i>{% trans %}Aulas Virtuales (Reunión){% endtrans %}</a></li>
|
||||||
<li><a class="dropdown-item" href="{{ url_for('admin.occupancy_metrics') }}"><i class="bi bi-graph-up-arrow me-2 text-success"></i>{% trans %}Gestión de Ocupación, Métricas{% endtrans %}</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms') }}"><i class="bi bi-grid me-2 text-secondary"></i>{% trans %}Todas las Aulas{% endtrans %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('classrooms.add_classroom') }}"><i class="bi bi-plus-circle me-2 text-success"></i>{% trans %}Nueva Aula{% endtrans %}</a></li>
|
||||||
<li><hr class="dropdown-divider my-1"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<!-- Gestión Académica -->
|
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2 text-secondary"></i>{% trans %}Edificios y Sedes{% endtrans %}</a></li>
|
||||||
<li class="dropdown-header text-uppercase small fw-bold text-muted py-1" style="font-size: 0.68rem; letter-spacing: 0.5px;">{% trans %}Gestión Académica{% endtrans %}</li>
|
<li><a class="dropdown-item" href="{{ url_for('admin.occupancy_metrics') }}"><i class="bi bi-graph-up-arrow me-2 text-success"></i>{% trans %}Métricas de Ocupación{% endtrans %}</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="bi bi-calendar3 me-1"></i>{% trans %}Cronograma & Reservas{% endtrans %}
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu shadow-sm">
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('schedule.calendar_view') }}"><i class="bi bi-calendar-week me-2 text-primary"></i>{% trans %}Calendario General{% endtrans %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}"><i class="bi bi-calendar-day me-2 text-info"></i>{% trans %}Cartelera del Día{% endtrans %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}"><i class="bi bi-list-check me-2 text-warning"></i>{% trans %}Gestión de Reservas{% endtrans %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}"><i class="bi bi-plus-circle me-2 text-success"></i>{% trans %}Nueva Reserva{% endtrans %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('schedule.commissions_view') }}"><i class="bi bi-diagram-3 me-2 text-primary"></i>{% trans %}Comisiones y Cursadas{% endtrans %}</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('genetic_algorithm_web.genetic_optimizer') }}"><i class="bi bi-cpu me-2 text-info"></i>{% trans %}Optimizador de Aulas (IA){% endtrans %}</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="bi bi-mortarboard me-1"></i>{% trans %}Gestión Académica{% endtrans %}
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu shadow-sm">
|
||||||
<li><a class="dropdown-item" href="{{ url_for('admin.careers_list') }}"><i class="bi bi-mortarboard me-2 text-primary"></i>{% trans %}Carreras{% endtrans %}</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('admin.careers_list') }}"><i class="bi bi-mortarboard me-2 text-primary"></i>{% trans %}Carreras{% endtrans %}</a></li>
|
||||||
<li><a class="dropdown-item" href="{{ url_for('admin.subjects_list') }}"><i class="bi bi-book me-2 text-info"></i>{% trans %}Asignaturas{% endtrans %}</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('admin.subjects_list') }}"><i class="bi bi-book me-2 text-info"></i>{% trans %}Asignaturas{% endtrans %}</a></li>
|
||||||
<li><a class="dropdown-item" href="{{ url_for('schedule.commissions_view') }}"><i class="bi bi-diagram-3 me-2 text-primary"></i>{% trans %}Comisiones y Cursadas{% endtrans %}</a></li>
|
|
||||||
<li><a class="dropdown-item" href="{{ url_for('admin.academic_terms_list') }}"><i class="bi bi-calendar-range me-2 text-warning"></i>{% trans %}Ciclo Lectivo{% endtrans %}</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('admin.academic_terms_list') }}"><i class="bi bi-calendar-range me-2 text-warning"></i>{% trans %}Ciclo Lectivo{% endtrans %}</a></li>
|
||||||
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}"><i class="bi bi-calendar-check me-2 text-warning"></i>{% trans %}Gestión de Reservas{% endtrans %}</a></li>
|
</ul>
|
||||||
|
</li>
|
||||||
<li><hr class="dropdown-divider my-1"></li>
|
<li class="nav-item dropdown">
|
||||||
<!-- Administración & Sistema -->
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
<li class="dropdown-header text-uppercase small fw-bold text-muted py-1" style="font-size: 0.68rem; letter-spacing: 0.5px;">{% trans %}Administración & Seguridad{% endtrans %}</li>
|
<i class="bi bi-shield-lock me-1"></i>{% trans %}Administración{% endtrans %}
|
||||||
<li><a class="dropdown-item" href="{{ url_for('admin.users_list') }}"><i class="bi bi-people me-2"></i>{% trans %}Usuarios{% endtrans %}</a></li>
|
</a>
|
||||||
<li><a class="dropdown-item" href="{{ url_for('admin.roles_list') }}"><i class="bi bi-person-badge me-2"></i>{% trans %}Roles y Permisos{% endtrans %}</a></li>
|
<ul class="dropdown-menu shadow-sm">
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('admin.users_list') }}"><i class="bi bi-people me-2 text-primary"></i>{% trans %}Usuarios{% endtrans %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('admin.roles_list') }}"><i class="bi bi-person-badge me-2 text-info"></i>{% trans %}Roles y Permisos RBAC{% endtrans %}</a></li>
|
||||||
<li><a class="dropdown-item" href="{{ url_for('admin.milestone_types_list') }}"><i class="bi bi-tags me-2 text-secondary"></i>{% trans %}Tipificaciones de Hitos{% endtrans %}</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('admin.milestone_types_list') }}"><i class="bi bi-tags me-2 text-secondary"></i>{% trans %}Tipificaciones de Hitos{% endtrans %}</a></li>
|
||||||
<li><a class="dropdown-item" href="{{ url_for('admin.google_sheets_import') }}"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>{% trans %}Sincronización Sheets{% endtrans %}</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('admin.google_sheets_import') }}"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>{% trans %}Sincronización Sheets{% endtrans %}</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('admin.audit_logs') }}"><i class="bi bi-shield-check me-2 text-warning"></i>{% trans %}Registro de Auditoría{% endtrans %}</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
{% elif user_role == 'bedelia' %}
|
||||||
|
<!-- ================= BEDELÍA NAVIGATION ================= -->
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
|
||||||
|
<i class="bi bi-speedometer2 me-1"></i>{% trans %}Panel Bedelía{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="bi bi-door-open me-1"></i>{% trans %}Espacios & Aulas{% endtrans %}
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu shadow-sm">
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}"><i class="bi bi-door-open me-2 text-primary"></i>{% trans %}Aulas Presenciales{% endtrans %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}"><i class="bi bi-camera-video me-2 text-info"></i>{% trans %}Aulas Virtuales (Reunión){% endtrans %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms') }}"><i class="bi bi-grid me-2 text-secondary"></i>{% trans %}Todas las Aulas{% endtrans %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('classrooms.add_classroom') }}"><i class="bi bi-plus-circle me-2 text-success"></i>{% trans %}Nueva Aula{% endtrans %}</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2 text-secondary"></i>{% trans %}Edificios y Sedes{% endtrans %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('admin.occupancy_metrics') }}"><i class="bi bi-graph-up-arrow me-2 text-success"></i>{% trans %}Métricas de Ocupación{% endtrans %}</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="bi bi-calendar3 me-1"></i>{% trans %}Operaciones & Reservas{% endtrans %}
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu shadow-sm">
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}"><i class="bi bi-calendar-day me-2 text-info"></i>{% trans %}Cartelera del Día{% endtrans %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('schedule.calendar_view') }}"><i class="bi bi-calendar-week me-2 text-primary"></i>{% trans %}Calendario General{% endtrans %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}"><i class="bi bi-list-check me-2 text-warning"></i>{% trans %}Gestión de Reservas{% endtrans %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}"><i class="bi bi-plus-circle me-2 text-success"></i>{% trans %}Registrar Reserva{% endtrans %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('schedule.commissions_view') }}"><i class="bi bi-diagram-3 me-2 text-primary"></i>{% trans %}Comisiones y Cursadas{% endtrans %}</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="bi bi-mortarboard me-1"></i>{% trans %}Académica & Cursadas{% endtrans %}
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu shadow-sm">
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('admin.careers_list') }}"><i class="bi bi-mortarboard me-2 text-primary"></i>{% trans %}Carreras{% endtrans %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('admin.subjects_list') }}"><i class="bi bi-book me-2 text-info"></i>{% trans %}Asignaturas{% endtrans %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('admin.academic_terms_list') }}"><i class="bi bi-calendar-range me-2 text-warning"></i>{% trans %}Ciclo Lectivo{% endtrans %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('admin.milestone_types_list') }}"><i class="bi bi-tags me-2 text-secondary"></i>{% trans %}Tipificaciones de Hitos{% endtrans %}</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('admin.google_sheets_import') }}"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>{% trans %}Sincronización Sheets{% endtrans %}</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{% elif user_role == 'docente' %}
|
||||||
|
<!-- ================= DOCENTE / PROFESOR NAVIGATION ================= -->
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
|
||||||
|
<i class="bi bi-speedometer2 me-1"></i>{% trans %}Panel Docente{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="bi bi-calendar-check me-1"></i>{% trans %}Mis Clases & Reservas{% endtrans %}
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu shadow-sm">
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}"><i class="bi bi-calendar2-check me-2 text-primary"></i>{% trans %}Mis Reservas de Aulas{% endtrans %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}"><i class="bi bi-plus-circle me-2 text-success"></i>{% trans %}Solicitar Nueva Reserva{% endtrans %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('schedule.commissions_view') }}"><i class="bi bi-diagram-3 me-2 text-info"></i>{% trans %}Mis Comisiones & Cursadas{% endtrans %}</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="bi bi-building me-1"></i>{% trans %}Campus & Horarios{% endtrans %}
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu shadow-sm">
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}"><i class="bi bi-calendar-day me-2 text-info"></i>{% trans %}Cartelera del Día (¿Dónde curso hoy?){% endtrans %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('schedule.calendar_view') }}"><i class="bi bi-calendar-week me-2 text-primary"></i>{% trans %}Calendario General de Aulas{% endtrans %}</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}"><i class="bi bi-camera-video me-2 text-info"></i>{% trans %}Aulas Virtuales (Reuniones){% endtrans %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}"><i class="bi bi-door-open me-2 text-secondary"></i>{% trans %}Consulta de Aulas Presenciales{% endtrans %}</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<!-- ================= ALUMNO / ESTUDIANTE NAVIGATION ================= -->
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
|
||||||
|
<i class="bi bi-house-door me-1"></i>{% trans %}Mi Portal{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('schedule.today_schedule') }}">
|
||||||
|
<i class="bi bi-geo-alt me-1"></i>{% trans %}Cartelera de Hoy{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('schedule.calendar_view') }}">
|
||||||
|
<i class="bi bi-calendar-week me-1"></i>{% trans %}Cronograma de Clases{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('classrooms.list_classrooms', view_mode='virtual') }}">
|
||||||
|
<i class="bi bi-camera-video me-1"></i>{% trans %}Aulas Virtuales{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('classrooms.list_classrooms', view_mode='physical') }}">
|
||||||
|
<i class="bi bi-door-open me-1"></i>{% trans %}Espacios del Campus{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -148,17 +227,25 @@
|
|||||||
{% include 'partials/language_switcher.html' %}
|
{% include 'partials/language_switcher.html' %}
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<!-- User Dropdown with Avatar -->
|
<!-- User Dropdown with Avatar and Institutional Role -->
|
||||||
<li class="nav-item dropdown">
|
<li class="nav-item dropdown">
|
||||||
<a class="nav-link dropdown-toggle d-flex align-items-center py-1" href="#" role="button" data-bs-toggle="dropdown">
|
<a class="nav-link dropdown-toggle d-flex align-items-center py-1" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
<span class="user-avatar-small">{{ current_user.name[:1]|upper if current_user.name else 'U' }}</span>
|
<span class="user-avatar-small">{{ current_user.name[:1]|upper if current_user.name else 'U' }}</span>
|
||||||
<span>{{ current_user.first_name }}</span>
|
<div class="d-none d-lg-block text-start ms-2">
|
||||||
|
<div class="lh-1 small fw-semibold">{{ current_user.first_name }}</div>
|
||||||
|
<span class="badge {{ current_user.role_badge_display.class }} role-pill mt-1" style="font-size: 0.65rem; padding: 0.15rem 0.45rem;">
|
||||||
|
<i class="bi {{ current_user.role_badge_display.icon }}"></i> {{ current_user.role_badge_display.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm border-0">
|
<ul class="dropdown-menu dropdown-menu-end shadow-sm border-0">
|
||||||
<li>
|
<li>
|
||||||
<div class="px-3 py-2 border-bottom">
|
<div class="px-3 py-2 border-bottom">
|
||||||
<small class="text-muted d-block">{% trans %}Signed in as{% endtrans %}</small>
|
<small class="text-muted d-block">{% trans %}Signed in as{% endtrans %}</small>
|
||||||
<span class="fw-bold small">{{ current_user.email }}</span>
|
<span class="fw-bold small d-block">{{ current_user.email }}</span>
|
||||||
|
<span class="badge {{ current_user.role_badge_display.class }} role-pill mt-1">
|
||||||
|
<i class="bi {{ current_user.role_badge_display.icon }}"></i> {{ current_user.role_badge_display.label }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">
|
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">
|
||||||
|
|||||||
+897
-86
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
|||||||
|
import unittest
|
||||||
|
from app import create_app, db
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
class TestRoleDashboardAndMenu(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.app = create_app()
|
||||||
|
self.app.config['TESTING'] = True
|
||||||
|
self.app.config['WTF_CSRF_ENABLED'] = False
|
||||||
|
self.client = self.app.test_client()
|
||||||
|
self.app_context = self.app.app_context()
|
||||||
|
self.app_context.push()
|
||||||
|
|
||||||
|
# Usar usuario administrador para autenticación en sesión de pruebas
|
||||||
|
self.user = User.query.filter_by(is_active=True).first()
|
||||||
|
if self.user:
|
||||||
|
with self.client.session_transaction() as sess:
|
||||||
|
sess['_user_id'] = str(self.user.id)
|
||||||
|
sess['_fresh'] = True
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.app_context.pop()
|
||||||
|
|
||||||
|
def test_user_role_methods(self):
|
||||||
|
"""Verifica la normalización y detección de roles en el modelo User"""
|
||||||
|
u = User(name="Test Teacher", email="prof@test.edu", role="Docente")
|
||||||
|
self.assertEqual(u.get_institutional_role(), "docente")
|
||||||
|
self.assertTrue(u.is_docente())
|
||||||
|
self.assertFalse(u.is_admin())
|
||||||
|
self.assertFalse(u.is_alumno())
|
||||||
|
self.assertFalse(u.is_bedelia())
|
||||||
|
|
||||||
|
u_bedelia = User(name="Test Bedelia", email="bedelia@test.edu", role="Bedelia")
|
||||||
|
self.assertEqual(u_bedelia.get_institutional_role(), "bedelia")
|
||||||
|
self.assertTrue(u_bedelia.is_bedelia())
|
||||||
|
|
||||||
|
u_student = User(name="Test Student", email="student@test.edu", role="Alumno")
|
||||||
|
self.assertEqual(u_student.get_institutional_role(), "alumno")
|
||||||
|
self.assertTrue(u_student.is_alumno())
|
||||||
|
|
||||||
|
u_admin = User(name="Test Admin", email="admin@test.edu", role="ADMIN")
|
||||||
|
self.assertEqual(u_admin.get_institutional_role(), "admin")
|
||||||
|
self.assertTrue(u_admin.is_admin())
|
||||||
|
|
||||||
|
badge = u_admin.role_badge_display
|
||||||
|
self.assertIn("badge-admin", badge["class"])
|
||||||
|
self.assertIn("Administrador", badge["label"])
|
||||||
|
|
||||||
|
def test_admin_dashboard_rendered(self):
|
||||||
|
"""Verifica que el dashboard administrativo renderiza KPIs y accesos de administración"""
|
||||||
|
res = self.client.get('/?view_as=admin')
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
html = res.get_data(as_text=True)
|
||||||
|
self.assertIn('Aulas Totales', html)
|
||||||
|
self.assertIn('Oferta de Materias', html)
|
||||||
|
self.assertIn('Usuarios Activos', html)
|
||||||
|
self.assertIn('Módulos de Gestión & Visualización de Menús', html)
|
||||||
|
self.assertIn('Espacios & Sedes', html)
|
||||||
|
self.assertIn('Auditoría & RBAC', html)
|
||||||
|
|
||||||
|
def test_bedelia_dashboard_rendered(self):
|
||||||
|
"""Verifica que el dashboard de bedelía renderiza métricas operativas de campus"""
|
||||||
|
res = self.client.get('/?view_as=bedelia')
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
html = res.get_data(as_text=True)
|
||||||
|
self.assertIn('Aulas Presenciales', html)
|
||||||
|
self.assertIn('Salas Virtuales', html)
|
||||||
|
self.assertIn('Acciones Rápidas & Menús Operativos de Bedelía', html)
|
||||||
|
self.assertIn('Cartelera del Día', html)
|
||||||
|
self.assertIn('Sincronizar Sheets', html)
|
||||||
|
|
||||||
|
def test_docente_dashboard_rendered(self):
|
||||||
|
"""Verifica que el dashboard del docente renderiza sus clases y accesos directos"""
|
||||||
|
res = self.client.get('/?view_as=docente')
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
html = res.get_data(as_text=True)
|
||||||
|
self.assertIn('Mis Clases Hoy', html)
|
||||||
|
self.assertIn('Mis Reservas Activas', html)
|
||||||
|
self.assertIn('Reservar Aula', html)
|
||||||
|
self.assertIn('Accesos Directos de Docencia & Visualización de Menús', html)
|
||||||
|
self.assertIn('Mis Clases Programadas para Hoy', html)
|
||||||
|
|
||||||
|
def test_alumno_dashboard_rendered(self):
|
||||||
|
"""Verifica que el dashboard del alumno muestra la brújula diaria y herramientas de cursada"""
|
||||||
|
res = self.client.get('/?view_as=alumno')
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
html = res.get_data(as_text=True)
|
||||||
|
self.assertIn('¿Dónde curso hoy? - Cartelera del Alumno', html)
|
||||||
|
self.assertIn('Menú del Alumno & Herramientas de Cursada', html)
|
||||||
|
self.assertIn('Cartelera de Hoy', html)
|
||||||
|
self.assertIn('Cronograma Semanal', html)
|
||||||
|
self.assertIn('Aulas Virtuales', html)
|
||||||
|
|
||||||
|
def test_audit_logs_view(self):
|
||||||
|
"""Verifica que la vista de auditoría institucional responde correctamente"""
|
||||||
|
res = self.client.get('/admin/audit-logs')
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
html = res.get_data(as_text=True)
|
||||||
|
self.assertIn('Registro de Auditoría Institucional', html)
|
||||||
|
self.assertIn('Trazabilidad completa de operaciones', html)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user