feat(ui/rbac): ordenar navegacion segun estandares de la industria y personalizar dashboards por rol (Admin, Bedelia, Docente, Alumno)

This commit is contained in:
Carlos Tello
2026-09-05 02:03:57 -03:00
parent 7d342bf8cc
commit dd93b8c07a
10 changed files with 1939 additions and 351 deletions
+37
View File
@@ -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).
+33
View File
@@ -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
+38
View File
@@ -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
+48
View File
@@ -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
View File
@@ -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')
+95
View File
@@ -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;
}
+161
View File
@@ -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) }}">
&laquo;
</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) }}">
&raquo;
</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 %}
+173 -86
View File
@@ -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">
<li class="nav-item"> {% if user_role == 'admin' %}
<a class="nav-link" href="{{ url_for('main.dashboard') }}"> <!-- ================= ADMIN NAVIGATION ================= -->
<i class="bi bi-speedometer2"></i> {% trans %}Dashboard{% endtrans %} <li class="nav-item">
</a> <a class="nav-link" href="{{ url_for('main.dashboard') }}">
</li> <i class="bi bi-speedometer2 me-1"></i>{% trans %}Panel General{% endtrans %}
<li class="nav-item dropdown"> </a>
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"> </li>
<i class="bi bi-door-open"></i> {% trans %}Classrooms{% endtrans %} <li class="nav-item dropdown">
</a> <a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<ul class="dropdown-menu"> <i class="bi bi-door-open me-1"></i>{% trans %}Espacios & Sedes{% endtrans %}
<li><a class="dropdown-item" href="{{ url_for('classrooms.list_classrooms') }}"> </a>
<i class="bi bi-list"></i> {% trans %}All Classrooms{% endtrans %} <ul class="dropdown-menu shadow-sm">
</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>
{% if current_user.has_permission('classrooms', 'read_write') %} <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.add_classroom') }}"> <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>
<i class="bi bi-plus-circle"></i> {% trans %}Add Classroom{% endtrans %} <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>
</a></li> <li><hr class="dropdown-divider"></li>
{% endif %} <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><hr class="dropdown-divider"></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>
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"> </ul>
<i class="bi bi-buildings"></i> {% trans %}Buildings{% endtrans %} </li>
</a></li> <li class="nav-item dropdown">
</ul> <a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
</li> <i class="bi bi-calendar3 me-1"></i>{% trans %}Cronograma & Reservas{% endtrans %}
<li class="nav-item dropdown"> </a>
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"> <ul class="dropdown-menu shadow-sm">
<i class="bi bi-calendar3"></i> {% trans %}Schedule{% endtrans %} <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>
</a> <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>
<ul class="dropdown-menu"> <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.calendar_view') }}"> <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>
<i class="bi bi-calendar-week"></i> {% trans %}Calendar View{% endtrans %} <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>
</a></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('schedule.list_reservations') }}"> <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>
<i class="bi bi-list-check"></i> {% trans %}All Reservations{% endtrans %} </ul>
</a></li> </li>
{% if current_user.has_permission('reservations', 'read_write') %} <li class="nav-item dropdown">
<li><a class="dropdown-item" href="{{ url_for('schedule.add_reservation') }}"> <a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="bi bi-plus-circle"></i> {% trans %}New Reservation{% endtrans %} <i class="bi bi-mortarboard me-1"></i>{% trans %}Gestión Académica{% endtrans %}
</a></li> </a>
{% endif %} <ul class="dropdown-menu shadow-sm">
<li><a class="dropdown-item" href="{{ url_for('schedule.today_schedule') }}"> <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>
<i class="bi bi-calendar-today"></i> {% trans %}Today's Schedule{% endtrans %} <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>
</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.commissions_view') }}"> </ul>
<i class="bi bi-diagram-3 me-2 text-primary"></i>{% trans %}Comisiones y Cursadas{% endtrans %} </li>
</a></li> <li class="nav-item dropdown">
{% if current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role|upper == 'ADMIN') %} <a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<li><hr class="dropdown-divider"></li> <i class="bi bi-shield-lock me-1"></i>{% trans %}Administración{% endtrans %}
<li><a class="dropdown-item" href="{{ url_for('genetic_algorithm_web.genetic_optimizer') }}"> </a>
<i class="bi bi-cpu text-info me-2"></i>{% trans %}Optimizador de Aulas (IA){% endtrans %} <ul class="dropdown-menu shadow-sm">
</a></li> <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>
{% endif %} <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>
</ul> <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> <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>
{% if current_user.is_authenticated and current_user.can_manage() %} <li><hr class="dropdown-divider"></li>
<li class="nav-item dropdown"> <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>
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"> </ul>
<i class="bi bi-shield-lock"></i> {% trans %}Gestión{% endtrans %} </li>
</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='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><hr class="dropdown-divider my-1"></li> {% elif user_role == 'bedelia' %}
<!-- Gestión Académica --> <!-- ================= BEDELÍA NAVIGATION ================= -->
<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 class="nav-item">
<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> <a class="nav-link" href="{{ url_for('main.dashboard') }}">
<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> <i class="bi bi-speedometer2 me-1"></i>{% trans %}Panel Bedelía{% endtrans %}
<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> </a>
<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>
<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> <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>
<li><hr class="dropdown-divider my-1"></li> {% elif user_role == 'docente' %}
<!-- Administración & Sistema --> <!-- ================= DOCENTE / PROFESOR NAVIGATION ================= -->
<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> <li class="nav-item">
<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 class="nav-link" href="{{ url_for('main.dashboard') }}">
<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> <i class="bi bi-speedometer2 me-1"></i>{% trans %}Panel Docente{% endtrans %}
<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> </a>
<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>
</ul> <li class="nav-item dropdown">
</li> <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') }}">
+1041 -230
View File
File diff suppressed because it is too large Load Diff
+103
View File
@@ -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()