fix(classrooms): blindar vistas presenciales y virtuales sin aulas + feat(admin): reinicializacion de sistema con clave de admin
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user