From 1e6f493358165344ef15cd92842a80e4275dd00b Mon Sep 17 00:00:00 2001 From: Carlos Tello Date: Wed, 23 Sep 2026 21:03:40 -0300 Subject: [PATCH] fix(classrooms): blindar vistas presenciales y virtuales sin aulas + feat(admin): reinicializacion de sistema con clave de admin --- CHANGELOG.md | 19 ++ backend/app/routes/api/admin.py | 133 ++++++++++++ backend/tests/test_abm_endpoints.py | 74 +++++++ frontend/src/routes/admin.js | 2 + frontend/src/routes/classrooms.js | 160 +++++++++----- .../views/admin/settings/global_config.html | 199 ++++++++++++++++++ frontend/views/classrooms/add.html | 6 +- frontend/views/classrooms/list.html | 6 +- 8 files changed, 536 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b20ed90..99361ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### Añadido - **ABM Integral de Ciclos Lectivos (`AcademicTerm`):** diff --git a/backend/app/routes/api/admin.py b/backend/app/routes/api/admin.py index 1ec9621..775c12d 100644 --- a/backend/app/routes/api/admin.py +++ b/backend/app/routes/api/admin.py @@ -2565,3 +2565,136 @@ def delete_building(id): 'status': 'success', 'message': f'Edificio "{building.name}" eliminado correctamente.' }), 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 + diff --git a/backend/tests/test_abm_endpoints.py b/backend/tests/test_abm_endpoints.py index eb39488..4ce5bbd 100644 --- a/backend/tests/test_abm_endpoints.py +++ b/backend/tests/test_abm_endpoints.py @@ -154,3 +154,77 @@ def test_reservation_hard_delete(client, auth_headers): assert res_del.status_code == 200 assert 'eliminada' in res_del.get_json()['message'] 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' + diff --git a/frontend/src/routes/admin.js b/frontend/src/routes/admin.js index 098f5fa..5ca1654 100644 --- a/frontend/src/routes/admin.js +++ b/frontend/src/routes/admin.js @@ -1529,5 +1529,7 @@ router.post('/settings/moodle/test', forwardSettingsAction); router.post('/settings/moodle/sync', forwardSettingsAction); router.get('/settings/moodle/stats', forwardSettingsAction); router.post('/settings/ui', forwardSettingsAction); +router.post('/settings/reset_system', forwardSettingsAction); +router.post('/system/reset', forwardSettingsAction); module.exports = router; diff --git a/frontend/src/routes/classrooms.js b/frontend/src/routes/classrooms.js index 97cec81..891ee5e 100644 --- a/frontend/src/routes/classrooms.js +++ b/frontend/src/routes/classrooms.js @@ -6,8 +6,13 @@ router.use(requireAuth); const handleClassroomsList = async (req, res) => { try { - const response = await req.apiClient.get('/classrooms'); - const allClassrooms = (response.data && response.data.classrooms) || []; + let allClassrooms = []; + 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 search = (req.query.search || '').toLowerCase().trim(); @@ -17,7 +22,7 @@ const handleClassroomsList = async (req, res) => { // Filter based on view mode (physical vs virtual) 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; return !isVirt; }); @@ -25,9 +30,9 @@ const handleClassroomsList = async (req, res) => { // Additional filters if (search) { filtered = filtered.filter(c => - (c.code && c.code.toLowerCase().includes(search)) || - (c.description && c.description.toLowerCase().includes(search)) || - (c.building && c.building.toLowerCase().includes(search)) + ((c.code || '').toLowerCase().includes(search)) || + ((c.description || '').toLowerCase().includes(search)) || + ((c.building || '').toLowerCase().includes(search)) ); } @@ -43,9 +48,21 @@ const handleClassroomsList = async (req, res) => { 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 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 => { if (c.building_id && 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 virtual_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 => Boolean(c.is_virtual || (c.code || '').includes('VIRTUAL'))).length; const dateFilters = { 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 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(); c_res.forEach(r => { const cid = r.commission_id || (r.commission && r.commission.id) || r.id; @@ -88,15 +105,15 @@ const handleClassroomsList = async (req, res) => { commMap.set(cid, { commission_id: cid, 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 || '', 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 week_res = c_res.filter(r => r.start_time && r.start_time >= '2026-09-14' && r.start_time <= '2026-09-20'); + 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 || '') >= '2026-09-14' && (r.start_time || '') <= '2026-09-20'); 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 month_pct = Math.min(100, Math.round((c_res.length / 72) * 100)); @@ -129,8 +146,8 @@ const handleClassroomsList = async (req, res) => { total_count: 0 }; - const virtualClassroomObj = allClassrooms.find(c => Boolean(c.is_virtual || (c.code && c.code.includes('VIRTUAL')))); - const virtual_classroom_id = virtualClassroomObj ? virtualClassroomObj.id : 32; + const virtualClassroomObj = allClassrooms.find(c => Boolean(c.is_virtual || ((c.code || '').includes('VIRTUAL')))); + const virtual_classroom_id = virtualClassroomObj ? virtualClassroomObj.id : null; if (view_mode === 'virtual') { try { @@ -162,12 +179,14 @@ const handleClassroomsList = async (req, res) => { virtual_rooms = Array.from(commMap.values()).map(data => { 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 => { + if (!r.start_time) return false; const d = new Date(r.start_time); return d >= weekStart && d <= weekEnd; }).length; const monthCnt = resList.filter(r => { + if (!r.start_time) return false; const d = new Date(r.start_time); return d >= monthStart && d <= monthEnd; }).length; @@ -186,27 +205,29 @@ const handleClassroomsList = async (req, res) => { week_count: weekCnt, month_count: monthCnt, 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) { virtual_rooms = virtual_rooms.filter(room => - room.subject_name.toLowerCase().includes(search) || - room.commission_code.toLowerCase().includes(search) || - room.subject_code.toLowerCase().includes(search) + (room.subject_name || '').toLowerCase().includes(search) || + (room.commission_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 = { - 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 => { + if (!r.start_time) return false; const d = new Date(r.start_time); return d >= weekStart && d <= weekEnd; }).length, month_count: virtualReservations.filter(r => { + if (!r.start_time) return false; const d = new Date(r.start_time); return d >= monthStart && d <= monthEnd; }).length, @@ -247,10 +268,11 @@ const handleClassroomsList = async (req, res) => { } catch (error) { console.error('Error in classrooms list route:', error.message); + const view_mode = (req.query.view_mode || 'physical').toLowerCase(); 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] }, - view_mode: 'physical', + view_mode, search: '', building_filter: '', floor_filter: '', @@ -259,8 +281,17 @@ const handleClassroomsList = async (req, res) => { virtual_count: 0, buildings: [], floors: [], - occupancy_metrics: { get: (id, defVal) => defVal }, - date_filters: {} + occupancy_metrics: {}, + 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) => { try { let allClassrooms = []; + let allBuildings = []; try { - const response = await req.apiClient.get('/classrooms'); - allClassrooms = (response.data && response.data.classrooms) || []; + const [cRes, bRes] = await Promise.allSettled([ + 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) { - 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(); - buildingsMap.set('1', { id: 1, name: 'Edificio Central' }); - buildingsMap.set('2', { id: 2, name: 'Campus Virtual' }); + allBuildings.forEach(b => { + 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 => { - const bId = String(c.building_id || 1); - if (!buildingFloors[bId]) buildingFloors[bId] = []; - 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 }); + const bId = String(c.building_id || ''); + if (bId) { + if (!buildingFloors[bId]) buildingFloors[bId] = []; + if (c.floor && !buildingFloors[bId].includes(c.floor)) { + buildingFloors[bId].push(c.floor); + } + if (c.building) { + buildingsMap.set(bId, { id: c.building_id, name: c.building }); + } } }); const buildings = Array.from(buildingsMap.values()); 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 })), - { value: 'new', text: '+ Crear nuevo edificio' } + { value: 'new', text: '+ Crear nuevo edificio / sede' } ]; const form = createFormMock({ @@ -327,19 +373,12 @@ const handlePostClassroom = async (req, res) => { try { const { building_id, new_building_name, floor, room_number, capacity, description, is_active } = req.body; - let targetBuilding = building_id; - if (building_id === 'new' && new_building_name) { - targetBuilding = new_building_name; - } - - let bId = parseInt(targetBuilding, 10); - if (isNaN(bId) || bId <= 0) { - bId = (String(targetBuilding).toLowerCase().includes('virtual')) ? 2 : 1; - } + const isNewBuilding = building_id === 'new' || !building_id; + const bName = (new_building_name || '').trim(); + const bId = (!isNewBuilding && building_id) ? parseInt(building_id, 10) : null; const isVirtual = Boolean( - bId === 2 || - String(targetBuilding).toLowerCase().includes('virtual') || + (bName && bName.toLowerCase().includes('virtual')) || String(room_number).toUpperCase().includes('VIRTUAL') || String(floor).toUpperCase() === 'DIGITAL' ); @@ -348,12 +387,19 @@ const handlePostClassroom = async (req, res) => { code: room_number || `Aula-${Date.now()}`, capacity: (isVirtual || capacity === '∞' || capacity === '0' || !capacity) ? 0 : parseInt(capacity, 10), floor: floor || (isVirtual ? 'Digital' : 'PB'), - building_id: bId, description: description || '', is_virtual: isVirtual, 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 { await req.apiClient.post('/classrooms', payload); } catch (apiErr) { diff --git a/frontend/views/admin/settings/global_config.html b/frontend/views/admin/settings/global_config.html index a9ca8fa..11ce9c1 100644 --- a/frontend/views/admin/settings/global_config.html +++ b/frontend/views/admin/settings/global_config.html @@ -88,6 +88,11 @@ Interfaz & Dashboard + @@ -549,6 +554,93 @@ + + + +
+
+
+ +
+
+
+
+ +
+
Zona de Peligro: Reinicialización Total del Sistema
+ Depuración integral de datos operacionales para inicio de ciclo o migración en limpio +
+
+ Requiere Clave Admin +
+
+
+

+ 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. +

+ +
+
+
+
+ Datos que se eliminarán: +
+
    +
  • Historiales: Toda la bitácora histórica y registros previos de auditoría.
  • +
  • Reservas: Todas las reservas de aulas, asignaciones de espacios y estados.
  • +
  • Espacios y Sedes: Todas las aulas físicas, virtuales, recursos y edificios/sedes.
  • +
  • Académica: Carreras, materias, comisiones, docentes asignados, matrículas, notas y ciclos lectivos.
  • +
  • Colas de sincronización: Tareas pendientes o fallidas hacia sistemas externos.
  • +
+
+
+
+
+
+ Datos que se PRESERVAN intactos: +
+
    +
  • Cuentas de Usuarios: Todos los usuarios (Administradores, Bedeles, Docentes, Alumnos) con sus contraseñas activas.
  • +
  • Roles y Permisos (RBAC): Estructura completa de perfiles institucionales.
  • +
  • Configuración Global: Preferencias de correo SMTP, Single Sign-On, Moodle y opciones de interfaz.
  • +
  • Tipos de Hitos: Tipos maestros configurados (Parciales, Finales, TP, etc.).
  • +
  • Módulo de Administración: Todos los componentes de la pestaña Administración.
  • +
+
+
+
+ +
+ +
+ Por estrictas razones de seguridad institucional, para ejecutar esta acción deberá ingresar la contraseña del usuario Administrador actualmente autenticado. +
+
+ +
+ +
+
+
+
+ + +
+
+
¿Cuándo usar esta función?
+

+ 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. +

+

+ Al conservar intactos los usuarios y las configuraciones de seguridad, el personal administrativo y docente mantiene su acceso ininterrumpido a la plataforma. +

+
+
+
+
+ @@ -580,6 +672,56 @@ + + +