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
+133
View File
@@ -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
+74
View File
@@ -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'