fix(classrooms): blindar vistas presenciales y virtuales sin aulas + feat(admin): reinicializacion de sistema con clave de admin

This commit is contained in:
2026-09-23 21:03:40 -03:00
parent 95d2aac323
commit 1e6f493358
8 changed files with 536 additions and 63 deletions
+19
View File
@@ -13,6 +13,25 @@ El formato está basado en [Keep a Changelog](https://keepachangelog.com/es-ES/1
--- ---
## [3.2.0] - 2026-09-23
### Añadido
- **Función de Reinicialización Total del Sistema (`POST /api/v1/admin/system/reset`):**
- **Módulo de Mantenimiento en Configuración Global (`/admin/settings`):** Nueva pestaña "Mantenimiento & Reinicio" con tarjeta de "Zona de Peligro" y advertencia sobre el alcance de la purga.
- **Requisito Estricto de Seguridad Institucional:** Exige obligatoriamente la validación de la **contraseña del usuario Administrador en sesión** antes de ejecutar la acción; retorna 401 y aborta ante claves incorrectas registrando el evento `SYSTEM_RESET_FAILED`.
- **Purga Referencial Completa:** Vacía de forma segura y en orden estricto de claves foráneas: historiales previos (`AuditLog`), reservas (`Reservation`), hitos evaluativos (`AcademicMilestone`), notas (`MilestoneGrade`), matrículas (`StudentEnrollment`), asignaciones docentes (`CommissionTeacher`), comisiones (`Commission`), materias (`Subject`), carreras (`Career`), recursos de aula (`ClassroomResource`), aulas (`Classroom`), sedes/edificios (`Building`), ciclos lectivos (`AcademicTerm`) y colas de sincronización Moodle (`MoodleSyncTask`).
- **Preservación Estricta de Administración y Cuentas:** Mantiene 100% intactas todas las cuentas de usuario (`User` con hashes de contraseñas), roles RBAC (`Role`), permisos (`Permission`), tipos de hitos (`MilestoneType`), configuraciones globales (`SystemSetting`) y todos los módulos de la pestaña Administración.
- **Trazabilidad Inmutable:** Genera automáticamente un nuevo registro en `AuditLog` con la acción `SYSTEM_RESET`, asociando el correo del administrador ejecutor, su IP y el recuento de entidades purgadas.
- **Interfaz de Usuario y Modal Interactivo:** Diálogo modal `#modalResetSystem` con campo de contraseña enmascarada, switch de visibilidad, casilla de confirmación explícita y botón de confirmación con estado de carga (spinner).
### Corregido
- **Blindaje de Espacios y Sedes Vacíos (Presenciales y Virtuales):**
- **Prevención de Excepciones TypeError:** Se corrigieron accesos inseguros a propiedades nulas o no definidas (`c.code.includes`, `r.start_time.startsWith`, `localeCompare`) en `frontend/src/routes/classrooms.js`, permitiendo navegar a `/classrooms/list_classrooms?view_mode=physical` y `/classrooms/list_classrooms?view_mode=virtual` sin caídas ante bases vacías.
- **Corrección de Evaluación de Longitud en Nunjucks:** Actualizadas las directivas condicionales en `frontend/views/classrooms/list.html` a `classrooms.items|length > 0` y `virtual_rooms|length > 0`, garantizando el despliegue de los *Empty States* institucionales con botones de alta cuando no existen aulas registradas.
- **Mapeo Dinámico de Sedes:** `handleAddClassroom` y `handleClassroomsList` ahora sincronizan con el catálogo `/buildings`, ofreciendo la creación guiada de sedes y vinculación dinámica en el alta de aulas físicas.
- **Blindaje del Fallback Offline:** En caso de fallas o demoras en la API, el manejador de captura provee valores predeterminados seguros para `global_virtual_metrics` y `virtual_rooms`, impidiendo errores 500 no controlados.
---
## [3.1.0] - 2026-09-23 ## [3.1.0] - 2026-09-23
### Añadido ### Añadido
- **ABM Integral de Ciclos Lectivos (`AcademicTerm`):** - **ABM Integral de Ciclos Lectivos (`AcademicTerm`):**
+133
View File
@@ -2565,3 +2565,136 @@ def delete_building(id):
'status': 'success', 'status': 'success',
'message': f'Edificio "{building.name}" eliminado correctamente.' 'message': f'Edificio "{building.name}" eliminado correctamente.'
}), 200 }), 200
@api_admin_bp.route('/system/reset', methods=['POST'])
@api_admin_bp.route('/settings/reset_system', methods=['POST'])
@jwt_required
def reset_entire_system():
"""
Reinicializa el sistema depurando historiales, reservas, espacios, sedes y académica.
PRESERVA: Todas las cuentas de usuario, roles RBAC, permisos, configuraciones globales
y tipos de hitos (pestaña Administración intacta).
REQUISITO CRÍTICO DE SEGURIDAD: Debe solicitar y validar la contraseña del perfil administrador en sesión.
"""
from app.models.grade import MilestoneGrade
from app.models.enrollment import StudentEnrollment
from app.models.classroom import ClassroomResource
from app.models.building import Building
from app.models.sync_task import MoodleSyncTask
acting_user = getattr(g, 'jwt_user', None)
if not (acting_user and (acting_user.is_admin() or getattr(acting_user, 'role', '').upper() == 'ADMIN')):
return jsonify({
'status': 'error',
'error': 'Forbidden',
'message': 'Acceso denegado. Solo un usuario con perfil Administrador puede solicitar la reinicialización del sistema.'
}), 403
data = request.get_json(silent=True) or request.form.to_dict() or {}
password = str(data.get('password') or '').strip()
if not password:
return jsonify({
'status': 'error',
'error': 'BadRequest',
'message': 'Debe ingresar la contraseña de su usuario administrador para autorizar la reinicialización.'
}), 400
if not acting_user.check_password(password):
# Registrar intento fallido de reinicio en bitácora de auditoría
try:
failed_audit = AuditLog(
user_id=acting_user.id,
user_email=acting_user.email,
action='SYSTEM_RESET_FAILED',
module='admin',
details='Intento de reinicialización del sistema cancelado: contraseña incorrecta.',
ip_address=request.remote_addr
)
db.session.add(failed_audit)
db.session.commit()
except Exception:
pass
return jsonify({
'status': 'error',
'error': 'Unauthorized',
'message': 'Contraseña de administrador incorrecta. Operación cancelada por seguridad.'
}), 401
try:
# Purga referencial ordenada de entidades operativas
# 1. Reservas
reservations_deleted = Reservation.query.delete()
# 2. Calificaciones e Hitos de cursadas
grades_deleted = MilestoneGrade.query.delete()
milestones_deleted = AcademicMilestone.query.delete()
# 3. Matrículas de alumnos y asignaciones docentes a comisiones
enrollments_deleted = StudentEnrollment.query.delete()
teachers_deleted = CommissionTeacher.query.delete()
# 4. Comisiones, Materias y Carreras
commissions_deleted = Commission.query.delete()
subjects_deleted = Subject.query.delete()
careers_deleted = Career.query.delete()
# 5. Recursos de aula, Aulas y Edificios/Sedes
resources_deleted = ClassroomResource.query.delete()
classrooms_deleted = Classroom.query.delete()
buildings_deleted = Building.query.delete()
# 6. Ciclos Lectivos / Términos Académicos
terms_deleted = AcademicTerm.query.delete()
# 7. Cola de eventos de sincronización Moodle huérfanos
moodle_tasks_deleted = MoodleSyncTask.query.delete()
# 8. Historiales: Limpiar auditorías anteriores
logs_deleted = AuditLog.query.delete()
# 9. Asentar registro oficial inmutable del reinicio en AuditLog
reset_audit = AuditLog(
user_id=acting_user.id,
user_email=acting_user.email,
action='SYSTEM_RESET',
module='admin',
details=(
f"Reinicialización total ejecutada por administrador {acting_user.email}. "
f"Entidades purgadas: {reservations_deleted} reservas, {classrooms_deleted} aulas, "
f"{buildings_deleted} sedes, {commissions_deleted} comisiones, {subjects_deleted} materias, "
f"{careers_deleted} carreras, {terms_deleted} ciclos lectivos, {enrollments_deleted} inscripciones, "
f"{grades_deleted} notas, {logs_deleted} logs históricos. Cuentas y configuración administrativa preservadas."
),
ip_address=request.remote_addr
)
db.session.add(reset_audit)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Sistema reinicializado correctamente. Se han depurado los historiales, reservas, espacios, sedes y académica. Las cuentas de usuario y configuraciones se encuentran intactas.',
'purged_stats': {
'reservations': reservations_deleted,
'classrooms': classrooms_deleted,
'buildings': buildings_deleted,
'commissions': commissions_deleted,
'subjects': subjects_deleted,
'careers': careers_deleted,
'academic_terms': terms_deleted,
'enrollments': enrollments_deleted,
'grades': grades_deleted,
'prior_audit_logs': logs_deleted
}
}), 200
except Exception as e:
db.session.rollback()
return jsonify({
'status': 'error',
'error': 'InternalServerError',
'message': f'Error durante la reinicialización de la base de datos: {str(e)}'
}), 500
+74
View File
@@ -154,3 +154,77 @@ def test_reservation_hard_delete(client, auth_headers):
assert res_del.status_code == 200 assert res_del.status_code == 200
assert 'eliminada' in res_del.get_json()['message'] assert 'eliminada' in res_del.get_json()['message']
assert db.session.get(Reservation, res_id) is None assert db.session.get(Reservation, res_id) is None
def test_system_reset_validation(client, auth_headers):
# 1. Sin contraseña -> 400
res_no_pass = client.post('/api/v1/admin/system/reset', headers=auth_headers, json={})
assert res_no_pass.status_code == 400
assert 'contraseña' in res_no_pass.get_json()['message'].lower()
# 2. Con contraseña errónea -> 401
res_bad_pass = client.post('/api/v1/admin/system/reset', headers=auth_headers, json={'password': 'PasswordIncorrecto123'})
assert res_bad_pass.status_code == 401
assert 'incorrecta' in res_bad_pass.get_json()['message'].lower()
def test_system_reset_success_preserves_users(client, auth_headers):
from app.models.career import Career
from app.models.audit_log import AuditLog
# Crear datos operativos
career = Career(name='Ingenieria en Software', code='ING-SOFT')
db.session.add(career)
building = Building(name='Pabellón Norte', code='PNORTE', is_active=True)
db.session.add(building)
db.session.flush()
classroom = Classroom(code='Aula 501', floor='5', capacity=40, building_id=building.id, building=building.name, is_active=True)
subject = Subject(name='Sistemas Operativos', code='SO-101', is_active=True, career_id=career.id)
term = AcademicTerm(name='2026-1C', code='2026-1C', start_date=datetime.now().date(), end_date=(datetime.now() + timedelta(days=120)).date(), is_active=True)
db.session.add_all([classroom, subject, term])
db.session.commit()
user = User.query.filter_by(email='admin_abm@unicaba.edu.ar').first()
comm = Commission(code='COM-SO-1', subject_id=subject.id, semester=1, year=2026, max_students=35, is_active=True)
db.session.add(comm)
db.session.commit()
resv = Reservation(classroom_id=classroom.id, commission_id=comm.id, user_id=user.id, purpose='Clase', expected_attendees=25, start_time=datetime.now(), end_time=datetime.now() + timedelta(hours=2))
db.session.add(resv)
db.session.commit()
# Verificar que los datos existen antes del reset
assert Classroom.query.count() >= 1
assert Building.query.count() >= 1
assert Subject.query.count() >= 1
assert Career.query.count() >= 1
assert Commission.query.count() >= 1
assert Reservation.query.count() >= 1
assert AcademicTerm.query.count() >= 1
# Ejecutar reset del sistema con la contraseña correcta del admin
res_reset = client.post('/api/v1/admin/system/reset', headers=auth_headers, json={'password': 'Admin1234!'})
assert res_reset.status_code == 200
data = res_reset.get_json()
assert data['status'] == 'success'
assert 'reinicializado' in data['message'].lower()
# Comprobar que los datos operativos fueron purgados
assert Classroom.query.count() == 0
assert Building.query.count() == 0
assert Subject.query.count() == 0
assert Career.query.count() == 0
assert Commission.query.count() == 0
assert Reservation.query.count() == 0
assert AcademicTerm.query.count() == 0
# Comprobar que los usuarios y roles se PRESERVAN
assert User.query.filter_by(email='admin_abm@unicaba.edu.ar').first() is not None
assert Role.query.count() >= 1
# Comprobar que se generó un nuevo registro de auditoría del evento SYSTEM_RESET
reset_logs = AuditLog.query.filter_by(action='SYSTEM_RESET').all()
assert len(reset_logs) == 1
assert reset_logs[0].user_email == 'admin_abm@unicaba.edu.ar'
+2
View File
@@ -1529,5 +1529,7 @@ router.post('/settings/moodle/test', forwardSettingsAction);
router.post('/settings/moodle/sync', forwardSettingsAction); router.post('/settings/moodle/sync', forwardSettingsAction);
router.get('/settings/moodle/stats', forwardSettingsAction); router.get('/settings/moodle/stats', forwardSettingsAction);
router.post('/settings/ui', forwardSettingsAction); router.post('/settings/ui', forwardSettingsAction);
router.post('/settings/reset_system', forwardSettingsAction);
router.post('/system/reset', forwardSettingsAction);
module.exports = router; module.exports = router;
+103 -57
View File
@@ -6,8 +6,13 @@ router.use(requireAuth);
const handleClassroomsList = async (req, res) => { const handleClassroomsList = async (req, res) => {
try { try {
const response = await req.apiClient.get('/classrooms'); let allClassrooms = [];
const allClassrooms = (response.data && response.data.classrooms) || []; try {
const response = await req.apiClient.get('/classrooms');
allClassrooms = (response.data && response.data.classrooms) || [];
} catch (cErr) {
console.warn('Notice fetching classrooms:', cErr.message);
}
const view_mode = (req.query.view_mode || 'physical').toLowerCase(); const view_mode = (req.query.view_mode || 'physical').toLowerCase();
const search = (req.query.search || '').toLowerCase().trim(); const search = (req.query.search || '').toLowerCase().trim();
@@ -17,7 +22,7 @@ const handleClassroomsList = async (req, res) => {
// Filter based on view mode (physical vs virtual) // Filter based on view mode (physical vs virtual)
let filtered = allClassrooms.filter(c => { let filtered = allClassrooms.filter(c => {
const isVirt = Boolean(c.is_virtual || (c.code && c.code.includes('VIRTUAL')) || (c.building && c.building.toLowerCase().includes('virtual'))); const isVirt = Boolean(c.is_virtual || ((c.code || '').includes('VIRTUAL')) || ((c.building || '').toLowerCase().includes('virtual')));
if (view_mode === 'virtual') return isVirt; if (view_mode === 'virtual') return isVirt;
return !isVirt; return !isVirt;
}); });
@@ -25,9 +30,9 @@ const handleClassroomsList = async (req, res) => {
// Additional filters // Additional filters
if (search) { if (search) {
filtered = filtered.filter(c => filtered = filtered.filter(c =>
(c.code && c.code.toLowerCase().includes(search)) || ((c.code || '').toLowerCase().includes(search)) ||
(c.description && c.description.toLowerCase().includes(search)) || ((c.description || '').toLowerCase().includes(search)) ||
(c.building && c.building.toLowerCase().includes(search)) ((c.building || '').toLowerCase().includes(search))
); );
} }
@@ -43,9 +48,21 @@ const handleClassroomsList = async (req, res) => {
filtered = filtered.filter(c => c.is_active); filtered = filtered.filter(c => c.is_active);
} }
// Extract unique buildings and floors // Extract unique buildings and floors (consultando también /buildings si está disponible)
const buildingsMap = new Map(); const buildingsMap = new Map();
const floorsSet = new Set(); const floorsSet = new Set();
try {
const bRes = await req.apiClient.get('/buildings');
const apiBuildings = (bRes.data && bRes.data.buildings) || [];
apiBuildings.forEach(b => {
if (b.id && b.name) {
buildingsMap.set(b.id, { id: b.id, name: b.name });
}
});
} catch (bErr) {
console.warn('Notice fetching buildings for classrooms filter:', bErr.message);
}
allClassrooms.forEach(c => { allClassrooms.forEach(c => {
if (c.building_id && c.building) { if (c.building_id && c.building) {
buildingsMap.set(c.building_id, { id: c.building_id, name: c.building }); buildingsMap.set(c.building_id, { id: c.building_id, name: c.building });
@@ -55,8 +72,8 @@ const handleClassroomsList = async (req, res) => {
} }
}); });
const physical_count = allClassrooms.filter(c => !c.is_virtual && !c.code.includes('VIRTUAL')).length; const physical_count = allClassrooms.filter(c => !c.is_virtual && !(c.code || '').includes('VIRTUAL')).length;
const virtual_count = allClassrooms.filter(c => c.is_virtual || c.code.includes('VIRTUAL')).length; const virtual_count = allClassrooms.filter(c => Boolean(c.is_virtual || (c.code || '').includes('VIRTUAL'))).length;
const dateFilters = { const dateFilters = {
today: new Date().toISOString().split('T')[0], today: new Date().toISOString().split('T')[0],
@@ -80,7 +97,7 @@ const handleClassroomsList = async (req, res) => {
// Format classrooms with fallback fields and assigned commissions/classes // Format classrooms with fallback fields and assigned commissions/classes
const formattedItems = filtered.map(c => { const formattedItems = filtered.map(c => {
const c_res = allReservations.filter(r => String(r.classroom_id) === String(c.id) || (r.classroom && String(r.classroom.id) === String(c.id))); const c_res = allReservations.filter(r => (c.id !== undefined && (String(r.classroom_id) === String(c.id) || (r.classroom && String(r.classroom.id) === String(c.id)))));
const commMap = new Map(); const commMap = new Map();
c_res.forEach(r => { c_res.forEach(r => {
const cid = r.commission_id || (r.commission && r.commission.id) || r.id; const cid = r.commission_id || (r.commission && r.commission.id) || r.id;
@@ -88,15 +105,15 @@ const handleClassroomsList = async (req, res) => {
commMap.set(cid, { commMap.set(cid, {
commission_id: cid, commission_id: cid,
commission_code: (r.commission && r.commission.code) || 'C1', commission_code: (r.commission && r.commission.code) || 'C1',
subject_name: r.subject_name || r.purpose, subject_name: r.subject_name || r.purpose || 'Clase Regular',
subject_code: r.subject_code || '', subject_code: r.subject_code || '',
shift: r.shift || 'Tarde' shift: r.shift || 'Tarde'
}); });
} }
}); });
const day_res = c_res.filter(r => r.start_time && (r.start_time.startsWith('2026-09-18') || r.start_time.startsWith('2026-09-15'))); const day_res = c_res.filter(r => (r.start_time || '').startsWith('2026-09-18') || (r.start_time || '').startsWith('2026-09-15'));
const week_res = c_res.filter(r => r.start_time && r.start_time >= '2026-09-14' && r.start_time <= '2026-09-20'); const week_res = c_res.filter(r => (r.start_time || '') >= '2026-09-14' && (r.start_time || '') <= '2026-09-20');
const day_pct = Math.min(100, Math.round((day_res.length / 3) * 100)); const day_pct = Math.min(100, Math.round((day_res.length / 3) * 100));
const week_pct = Math.min(100, Math.round((week_res.length / 18) * 100)); const week_pct = Math.min(100, Math.round((week_res.length / 18) * 100));
const month_pct = Math.min(100, Math.round((c_res.length / 72) * 100)); const month_pct = Math.min(100, Math.round((c_res.length / 72) * 100));
@@ -129,8 +146,8 @@ const handleClassroomsList = async (req, res) => {
total_count: 0 total_count: 0
}; };
const virtualClassroomObj = allClassrooms.find(c => Boolean(c.is_virtual || (c.code && c.code.includes('VIRTUAL')))); const virtualClassroomObj = allClassrooms.find(c => Boolean(c.is_virtual || ((c.code || '').includes('VIRTUAL'))));
const virtual_classroom_id = virtualClassroomObj ? virtualClassroomObj.id : 32; const virtual_classroom_id = virtualClassroomObj ? virtualClassroomObj.id : null;
if (view_mode === 'virtual') { if (view_mode === 'virtual') {
try { try {
@@ -162,12 +179,14 @@ const handleClassroomsList = async (req, res) => {
virtual_rooms = Array.from(commMap.values()).map(data => { virtual_rooms = Array.from(commMap.values()).map(data => {
const resList = data.reservations; const resList = data.reservations;
const dayCnt = resList.filter(r => r.start_time.startsWith(todayStr)).length; const dayCnt = resList.filter(r => (r.start_time || '').startsWith(todayStr)).length;
const weekCnt = resList.filter(r => { const weekCnt = resList.filter(r => {
if (!r.start_time) return false;
const d = new Date(r.start_time); const d = new Date(r.start_time);
return d >= weekStart && d <= weekEnd; return d >= weekStart && d <= weekEnd;
}).length; }).length;
const monthCnt = resList.filter(r => { const monthCnt = resList.filter(r => {
if (!r.start_time) return false;
const d = new Date(r.start_time); const d = new Date(r.start_time);
return d >= monthStart && d <= monthEnd; return d >= monthStart && d <= monthEnd;
}).length; }).length;
@@ -186,27 +205,29 @@ const handleClassroomsList = async (req, res) => {
week_count: weekCnt, week_count: weekCnt,
month_count: monthCnt, month_count: monthCnt,
total_count: resList.length, total_count: resList.length,
reservations: resList.sort((a, b) => a.start_time.localeCompare(b.start_time)) reservations: resList.sort((a, b) => (a.start_time || '').localeCompare(b.start_time || ''))
}; };
}); });
if (search) { if (search) {
virtual_rooms = virtual_rooms.filter(room => virtual_rooms = virtual_rooms.filter(room =>
room.subject_name.toLowerCase().includes(search) || (room.subject_name || '').toLowerCase().includes(search) ||
room.commission_code.toLowerCase().includes(search) || (room.commission_code || '').toLowerCase().includes(search) ||
room.subject_code.toLowerCase().includes(search) (room.subject_code || '').toLowerCase().includes(search)
); );
} }
virtual_rooms.sort((a, b) => a.subject_name.localeCompare(b.subject_name)); virtual_rooms.sort((a, b) => (a.subject_name || '').localeCompare(b.subject_name || ''));
global_virtual_metrics = { global_virtual_metrics = {
today_count: virtualReservations.filter(r => r.start_time.startsWith(todayStr)).length, today_count: virtualReservations.filter(r => (r.start_time || '').startsWith(todayStr)).length,
week_count: virtualReservations.filter(r => { week_count: virtualReservations.filter(r => {
if (!r.start_time) return false;
const d = new Date(r.start_time); const d = new Date(r.start_time);
return d >= weekStart && d <= weekEnd; return d >= weekStart && d <= weekEnd;
}).length, }).length,
month_count: virtualReservations.filter(r => { month_count: virtualReservations.filter(r => {
if (!r.start_time) return false;
const d = new Date(r.start_time); const d = new Date(r.start_time);
return d >= monthStart && d <= monthEnd; return d >= monthStart && d <= monthEnd;
}).length, }).length,
@@ -247,10 +268,11 @@ const handleClassroomsList = async (req, res) => {
} catch (error) { } catch (error) {
console.error('Error in classrooms list route:', error.message); console.error('Error in classrooms list route:', error.message);
const view_mode = (req.query.view_mode || 'physical').toLowerCase();
res.render('classrooms/list', { res.render('classrooms/list', {
title: 'Aulas y Espacios (Offline)', title: 'Aulas y Espacios - Edu-Space Admin',
classrooms: { items: [], total: 0, pages: 1, page: 1, has_prev: false, has_next: false, iter_pages: () => [1] }, classrooms: { items: [], total: 0, pages: 1, page: 1, has_prev: false, has_next: false, iter_pages: () => [1] },
view_mode: 'physical', view_mode,
search: '', search: '',
building_filter: '', building_filter: '',
floor_filter: '', floor_filter: '',
@@ -259,8 +281,17 @@ const handleClassroomsList = async (req, res) => {
virtual_count: 0, virtual_count: 0,
buildings: [], buildings: [],
floors: [], floors: [],
occupancy_metrics: { get: (id, defVal) => defVal }, occupancy_metrics: {},
date_filters: {} date_filters: {},
virtual_rooms: [],
global_virtual_metrics: {
today_count: 0,
week_count: 0,
month_count: 0,
commissions_count: 0,
total_count: 0
},
virtual_classroom_id: null
}); });
} }
}; };
@@ -270,38 +301,53 @@ const { createFormMock } = require('../utils/formHelper');
const handleAddClassroom = async (req, res) => { const handleAddClassroom = async (req, res) => {
try { try {
let allClassrooms = []; let allClassrooms = [];
let allBuildings = [];
try { try {
const response = await req.apiClient.get('/classrooms'); const [cRes, bRes] = await Promise.allSettled([
allClassrooms = (response.data && response.data.classrooms) || []; req.apiClient.get('/classrooms'),
req.apiClient.get('/buildings')
]);
if (cRes.status === 'fulfilled' && cRes.value.data?.classrooms) {
allClassrooms = cRes.value.data.classrooms;
}
if (bRes.status === 'fulfilled' && bRes.value.data?.buildings) {
allBuildings = bRes.value.data.buildings;
}
} catch (e) { } catch (e) {
console.warn('Could not fetch classrooms for building floors:', e.message); console.warn('Could not fetch classrooms/buildings for add page:', e.message);
} }
// Map floors per building
const buildingFloors = {
'1': ['PB', '1', '2', '3'],
'2': ['Virtual']
};
const buildingsMap = new Map(); const buildingsMap = new Map();
buildingsMap.set('1', { id: 1, name: 'Edificio Central' }); allBuildings.forEach(b => {
buildingsMap.set('2', { id: 2, name: 'Campus Virtual' }); if (b.id && b.name) {
buildingsMap.set(String(b.id), { id: b.id, name: b.name });
}
});
const buildingFloors = {};
allBuildings.forEach(b => {
const bId = String(b.id);
buildingFloors[bId] = b.is_virtual ? ['Digital'] : ['PB', '1', '2'];
});
allClassrooms.forEach(c => { allClassrooms.forEach(c => {
const bId = String(c.building_id || 1); const bId = String(c.building_id || '');
if (!buildingFloors[bId]) buildingFloors[bId] = []; if (bId) {
if (c.floor && !buildingFloors[bId].includes(c.floor)) { if (!buildingFloors[bId]) buildingFloors[bId] = [];
buildingFloors[bId].push(c.floor); if (c.floor && !buildingFloors[bId].includes(c.floor)) {
} buildingFloors[bId].push(c.floor);
if (c.building_id && c.building) { }
buildingsMap.set(bId, { id: c.building_id, name: c.building }); if (c.building) {
buildingsMap.set(bId, { id: c.building_id, name: c.building });
}
} }
}); });
const buildings = Array.from(buildingsMap.values()); const buildings = Array.from(buildingsMap.values());
const buildingOptions = [ const buildingOptions = [
{ value: '', text: '-- Selecciona Edificio / Sede --' }, { value: '', text: buildings.length > 0 ? '-- Selecciona Edificio / Sede --' : '-- Sin sedes registradas (Cree una abajo) --' },
...buildings.map(b => ({ value: b.id, text: b.name })), ...buildings.map(b => ({ value: b.id, text: b.name })),
{ value: 'new', text: '+ Crear nuevo edificio' } { value: 'new', text: '+ Crear nuevo edificio / sede' }
]; ];
const form = createFormMock({ const form = createFormMock({
@@ -327,19 +373,12 @@ const handlePostClassroom = async (req, res) => {
try { try {
const { building_id, new_building_name, floor, room_number, capacity, description, is_active } = req.body; const { building_id, new_building_name, floor, room_number, capacity, description, is_active } = req.body;
let targetBuilding = building_id; const isNewBuilding = building_id === 'new' || !building_id;
if (building_id === 'new' && new_building_name) { const bName = (new_building_name || '').trim();
targetBuilding = new_building_name; const bId = (!isNewBuilding && building_id) ? parseInt(building_id, 10) : null;
}
let bId = parseInt(targetBuilding, 10);
if (isNaN(bId) || bId <= 0) {
bId = (String(targetBuilding).toLowerCase().includes('virtual')) ? 2 : 1;
}
const isVirtual = Boolean( const isVirtual = Boolean(
bId === 2 || (bName && bName.toLowerCase().includes('virtual')) ||
String(targetBuilding).toLowerCase().includes('virtual') ||
String(room_number).toUpperCase().includes('VIRTUAL') || String(room_number).toUpperCase().includes('VIRTUAL') ||
String(floor).toUpperCase() === 'DIGITAL' String(floor).toUpperCase() === 'DIGITAL'
); );
@@ -348,12 +387,19 @@ const handlePostClassroom = async (req, res) => {
code: room_number || `Aula-${Date.now()}`, code: room_number || `Aula-${Date.now()}`,
capacity: (isVirtual || capacity === '∞' || capacity === '0' || !capacity) ? 0 : parseInt(capacity, 10), capacity: (isVirtual || capacity === '∞' || capacity === '0' || !capacity) ? 0 : parseInt(capacity, 10),
floor: floor || (isVirtual ? 'Digital' : 'PB'), floor: floor || (isVirtual ? 'Digital' : 'PB'),
building_id: bId,
description: description || '', description: description || '',
is_virtual: isVirtual, is_virtual: isVirtual,
is_active: is_active === 'true' || is_active === 'on' || is_active === true is_active: is_active === 'true' || is_active === 'on' || is_active === true
}; };
if (bId && !isNaN(bId) && bId > 0) {
payload.building_id = bId;
} else if (bName) {
payload.building_name = bName;
} else {
payload.building_name = isVirtual ? 'Campus Virtual' : 'Edificio Central';
}
try { try {
await req.apiClient.post('/classrooms', payload); await req.apiClient.post('/classrooms', payload);
} catch (apiErr) { } catch (apiErr) {
@@ -88,6 +88,11 @@
<i class="bi bi-window-desktop me-2 text-primary"></i>Interfaz & Dashboard <i class="bi bi-window-desktop me-2 text-primary"></i>Interfaz & Dashboard
</button> </button>
</li> </li>
<li class="nav-item" role="presentation">
<button class="nav-link text-danger" id="tab-maintenance-btn" data-bs-toggle="tab" data-bs-target="#tab-maintenance" type="button" role="tab">
<i class="bi bi-radioactive me-2 text-danger"></i>Mantenimiento &amp; Reinicio
</button>
</li>
</ul> </ul>
</div> </div>
@@ -549,6 +554,93 @@
</div> </div>
</div> </div>
<!-- ========================================== -->
<!-- PESTAÑA 5: MANTENIMIENTO & REINICIO -->
<!-- ========================================== -->
<div class="tab-pane fade" id="tab-maintenance" role="tabpanel">
<div class="row g-4">
<div class="col-lg-8">
<!-- Danger Zone Card -->
<div class="card border border-danger shadow-sm rounded-3 overflow-hidden">
<div class="card-header bg-danger text-white py-3">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div class="d-flex align-items-center">
<i class="bi bi-exclamation-triangle-fill fs-4 me-2"></i>
<div>
<h5 class="h6 fw-bold mb-0 text-white">Zona de Peligro: Reinicialización Total del Sistema</h5>
<small class="text-white-50">Depuración integral de datos operacionales para inicio de ciclo o migración en limpio</small>
</div>
</div>
<span class="badge bg-white text-danger font-monospace px-2 py-1 small">Requiere Clave Admin</span>
</div>
</div>
<div class="card-body p-4 bg-body">
<p class="mb-3 text-body">
Esta herramienta permite vaciar completamente los datos operacionales generados por la actividad académica y de uso diario en la plataforma, restableciendo el sistema a su estado inicial.
</p>
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="p-3 rounded-3 bg-danger-subtle border border-danger-subtle h-100">
<h6 class="fw-bold text-danger mb-2 d-flex align-items-center">
<i class="bi bi-trash3-fill me-2"></i>Datos que se eliminarán:
</h6>
<ul class="small mb-0 ps-3 text-danger-emphasis">
<li><strong>Historiales:</strong> Toda la bitácora histórica y registros previos de auditoría.</li>
<li><strong>Reservas:</strong> Todas las reservas de aulas, asignaciones de espacios y estados.</li>
<li><strong>Espacios y Sedes:</strong> Todas las aulas físicas, virtuales, recursos y edificios/sedes.</li>
<li><strong>Académica:</strong> Carreras, materias, comisiones, docentes asignados, matrículas, notas y ciclos lectivos.</li>
<li><strong>Colas de sincronización:</strong> Tareas pendientes o fallidas hacia sistemas externos.</li>
</ul>
</div>
</div>
<div class="col-md-6">
<div class="p-3 rounded-3 bg-success-subtle border border-success-subtle h-100">
<h6 class="fw-bold text-success mb-2 d-flex align-items-center">
<i class="bi bi-shield-check me-2"></i>Datos que se PRESERVAN intactos:
</h6>
<ul class="small mb-0 ps-3 text-success-emphasis">
<li><strong>Cuentas de Usuarios:</strong> Todos los usuarios (Administradores, Bedeles, Docentes, Alumnos) con sus contraseñas activas.</li>
<li><strong>Roles y Permisos (RBAC):</strong> Estructura completa de perfiles institucionales.</li>
<li><strong>Configuración Global:</strong> Preferencias de correo SMTP, Single Sign-On, Moodle y opciones de interfaz.</li>
<li><strong>Tipos de Hitos:</strong> Tipos maestros configurados (Parciales, Finales, TP, etc.).</li>
<li><strong>Módulo de Administración:</strong> Todos los componentes de la pestaña Administración.</li>
</ul>
</div>
</div>
</div>
<div class="alert alert-warning py-2 px-3 small d-flex align-items-center mb-4">
<i class="bi bi-shield-lock-fill text-warning fs-5 me-2 flex-shrink-0"></i>
<div>
Por estrictas razones de seguridad institucional, para ejecutar esta acción deberá ingresar la <strong>contraseña del usuario Administrador</strong> actualmente autenticado.
</div>
</div>
<div class="d-flex justify-content-end">
<button type="button" class="btn btn-danger btn-sm px-4 fw-semibold shadow-sm" data-bs-toggle="modal" data-bs-target="#modalResetSystem">
<i class="bi bi-radioactive me-2"></i>Reinicializar Sistema...
</button>
</div>
</div>
</div>
</div>
<!-- Info / Good Practices -->
<div class="col-lg-4">
<div class="card border-0 bg-body-tertiary p-3 rounded-3 mb-3">
<h6 class="fw-bold mb-2"><i class="bi bi-info-circle text-primary me-1"></i>¿Cuándo usar esta función?</h6>
<p class="small text-muted mb-2">
Utilice la reinicialización cuando desee comenzar un nuevo ciclo lectivo desde cero, tras finalizar períodos de prueba o capacitación, o para cargar una nueva grilla de sedes y carreras sin arrastrar información residual.
</p>
<p class="small text-muted mb-0">
Al conservar intactos los usuarios y las configuraciones de seguridad, el personal administrativo y docente mantiene su acceso ininterrumpido a la plataforma.
</p>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -580,6 +672,56 @@
</div> </div>
</div> </div>
<!-- Modal Confirmar Reinicialización del Sistema -->
<div class="modal fade" id="modalResetSystem" tabindex="-1" aria-labelledby="modalResetSystemLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header bg-danger text-white py-2">
<h6 class="modal-title fw-bold" id="modalResetSystemLabel">
<i class="bi bi-shield-exclamation me-2"></i>Confirmación Crítica: Reinicio Total del Sistema
</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-4">
<div class="alert alert-danger py-2 px-3 small mb-3">
<i class="bi bi-exclamation-triangle-fill me-1"></i>
<strong>¡Atención!</strong> Esta acción es irreversible. Se eliminarán todas las reservas, aulas, sedes, materias, carreras, comisiones, notas y registros de auditoría previa.
</div>
<p class="small text-muted mb-3">
Las cuentas de usuario y la configuración del sistema permanecerán intactas. Para autorizar la purga definitiva, ingrese su <strong>contraseña de Administrador</strong>:
</p>
<div id="resetFeedbackAlert"></div>
<div class="mb-3">
<label for="admin_reset_password" class="form-label small fw-bold">Contraseña del Administrador en sesión <span class="text-danger">*</span></label>
<div class="input-group input-group-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-key-fill text-danger"></i></span>
<input type="password" class="form-control" id="admin_reset_password" placeholder="Ingrese su contraseña institucional" required autocomplete="current-password">
<button class="btn btn-outline-secondary pass-toggle-btn" type="button" onclick="togglePassVisibility('admin_reset_password')" title="Mostrar/ocultar contraseña">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="confirm_reset_checkbox" onchange="document.getElementById('btnConfirmSystemReset').disabled = !this.checked">
<label class="form-check-label small text-body" for="confirm_reset_checkbox">
Entiendo las consecuencias irreversibles de esta purga y confirmo la reinicialización de los datos.
</label>
</div>
</div>
<div class="modal-footer py-2 bg-light">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal" id="btnCancelReset">Cancelar</button>
<button type="button" class="btn btn-sm btn-danger fw-semibold px-3" id="btnConfirmSystemReset" disabled onclick="executeSystemReset()">
<i class="bi bi-radioactive me-1"></i>Confirmar Reinicio Completo
</button>
</div>
</div>
</div>
</div>
<script> <script>
function showAlert(message, type = 'success') { function showAlert(message, type = 'success') {
const container = document.getElementById('settingsAlertContainer'); const container = document.getElementById('settingsAlertContainer');
@@ -1109,6 +1251,63 @@ async function saveUiConfig(e) {
} }
} }
// -------------------------------------------------------------
// Reinicialización Total del Sistema (Requiere Contraseña Admin)
// -------------------------------------------------------------
async function executeSystemReset() {
const passwordInput = document.getElementById('admin_reset_password');
const feedback = document.getElementById('resetFeedbackAlert');
const btnConfirm = document.getElementById('btnConfirmSystemReset');
const btnCancel = document.getElementById('btnCancelReset');
const check = document.getElementById('confirm_reset_checkbox');
const password = (passwordInput ? passwordInput.value : '').trim();
if (!password) {
feedback.innerHTML = '<div class="alert alert-warning py-2 px-3 small mb-3"><i class="bi bi-exclamation-circle me-1"></i>Por favor ingrese su contraseña de administrador.</div>';
if (passwordInput) passwordInput.focus();
return;
}
btnConfirm.disabled = true;
btnCancel.disabled = true;
btnConfirm.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Reinicializando sistema...';
feedback.innerHTML = '<div class="alert alert-info py-2 px-3 small mb-3"><span class="spinner-border spinner-border-sm me-1"></span>Procesando purga de datos operacionales...</div>';
try {
const res = await fetch('/admin/settings/reset_system', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
const data = await res.json();
if (res.ok && data.status === 'success') {
feedback.innerHTML = `<div class="alert alert-success py-2 px-3 small mb-3"><i class="bi bi-check-circle-fill me-1"></i>${data.message}</div>`;
showAlert(data.message, 'success');
passwordInput.value = '';
if (check) check.checked = false;
setTimeout(() => {
const modalEl = document.getElementById('modalResetSystem');
const modalInstance = bootstrap.Modal.getInstance(modalEl);
if (modalInstance) modalInstance.hide();
window.location.reload();
}, 2000);
} else {
feedback.innerHTML = `<div class="alert alert-danger py-2 px-3 small mb-3"><i class="bi bi-x-circle-fill me-1"></i>${data.message || 'Error al autorizar la reinicialización.'}</div>`;
btnConfirm.disabled = false;
btnCancel.disabled = false;
btnConfirm.innerHTML = '<i class="bi bi-radioactive me-1"></i>Confirmar Reinicio Completo';
if (passwordInput) passwordInput.focus();
}
} catch (err) {
feedback.innerHTML = `<div class="alert alert-danger py-2 px-3 small mb-3"><i class="bi bi-exclamation-triangle-fill me-1"></i>Error de comunicación: ${err.message}</div>`;
btnConfirm.disabled = false;
btnCancel.disabled = false;
btnConfirm.innerHTML = '<i class="bi bi-radioactive me-1"></i>Confirmar Reinicio Completo';
}
}
// Cargar estadísticas y tareas al iniciar // Cargar estadísticas y tareas al iniciar
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
refreshQueueStats(); refreshQueueStats();
+3 -3
View File
@@ -206,7 +206,7 @@ document.addEventListener('DOMContentLoaded', function() {
function updatePreview() { function updatePreview() {
var bText = '-'; var bText = '-';
if (buildingSelect) { if (buildingSelect) {
if (buildingSelect.value === '__new__') { if (buildingSelect.value === '__new__' || buildingSelect.value === 'new') {
bText = newBuildingNameInput.value.trim() || '{{ _("Nuevo Edificio") }}'; bText = newBuildingNameInput.value.trim() || '{{ _("Nuevo Edificio") }}';
} else if (buildingSelect.selectedIndex >= 0) { } else if (buildingSelect.selectedIndex >= 0) {
bText = buildingSelect.options[buildingSelect.selectedIndex].text; bText = buildingSelect.options[buildingSelect.selectedIndex].text;
@@ -222,7 +222,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (buildingSelect) { if (buildingSelect) {
buildingSelect.addEventListener('change', function() { buildingSelect.addEventListener('change', function() {
if (this.value === '__new__') { if (this.value === '__new__' || this.value === 'new') {
newBuildingContainer.style.display = 'block'; newBuildingContainer.style.display = 'block';
if (newBuildingNameInput) newBuildingNameInput.focus(); if (newBuildingNameInput) newBuildingNameInput.focus();
} else { } else {
@@ -232,7 +232,7 @@ document.addEventListener('DOMContentLoaded', function() {
updatePreview(); updatePreview();
}); });
// Initial call // Initial call
if (buildingSelect.value === '__new__') { if (buildingSelect.value === '__new__' || buildingSelect.value === 'new') {
newBuildingContainer.style.display = 'block'; newBuildingContainer.style.display = 'block';
} else if (buildingSelect.value) { } else if (buildingSelect.value) {
updateFloorsDatalist(buildingSelect.value); updateFloorsDatalist(buildingSelect.value);
+3 -3
View File
@@ -268,7 +268,7 @@
</div> </div>
<!-- Grilla y Tabla de Aulas Físicas --> <!-- Grilla y Tabla de Aulas Físicas -->
{% if classrooms and classrooms.items %} {% if classrooms and classrooms.items and (classrooms.items|length > 0) %}
<!-- 1. VISTA DE TARJETAS / MOSAICO --> <!-- 1. VISTA DE TARJETAS / MOSAICO -->
<div data-view-context="classrooms" data-view-container="cards"> <div data-view-context="classrooms" data-view-container="cards">
@@ -708,7 +708,7 @@
</div> </div>
<!-- Catálogo de Aulas Virtuales por Comisión --> <!-- Catálogo de Aulas Virtuales por Comisión -->
{% if virtual_rooms %} {% if virtual_rooms and (virtual_rooms|length > 0) %}
<div class="row g-3"> <div class="row g-3">
{% for room in virtual_rooms %} {% for room in virtual_rooms %}
<div class="col-xxl-3 col-xl-3 col-lg-4 col-md-6 col-sm-12 mb-2"> <div class="col-xxl-3 col-xl-3 col-lg-4 col-md-6 col-sm-12 mb-2">
@@ -1008,7 +1008,7 @@
{% endif %} {% endif %}
<!-- Modales de Eliminación de Aulas Físicas --> <!-- Modales de Eliminación de Aulas Físicas -->
{% if classrooms and classrooms.items %} {% if classrooms and classrooms.items and (classrooms.items|length > 0) %}
{% for classroom in classrooms.items %} {% for classroom in classrooms.items %}
<div class="modal fade text-start" id="modalDeleteClassroom{{ classroom.id }}" tabindex="-1" aria-hidden="true"> <div class="modal fade text-start" id="modalDeleteClassroom{{ classroom.id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">