feat: completar fases 3 y 4 del roadmap mvp (gradebook, drag-and-drop, impersonacion) y actualizar README

This commit is contained in:
2026-09-23 12:10:31 -03:00
parent 4ebc5df4fd
commit 7ad4e3f099
38 changed files with 3804 additions and 390 deletions
@@ -0,0 +1,283 @@
import unittest
from datetime import datetime, timedelta
from app import create_app, db
from app.models.subject import Subject, Commission
from app.models.classroom import Classroom
from app.models.reservation import Reservation, ReservationStatus
from app.models.user import User
from app.models.role import Role
from app.models.audit_log import AuditLog
from app.models.genetic_algorithm import ReservationOptimizer
from app.services.jwt_service import JWTService
class TestPhase4InteractiveImpersonation(unittest.TestCase):
"""Pruebas unitarias e integrales para la Fase 4 del Roadmap MVP (UniCABA)."""
@classmethod
def setUpClass(cls):
cls.app = create_app()
cls.app.config['TESTING'] = True
cls.app_context = cls.app.app_context()
cls.app_context.push()
db.create_all()
# Roles
cls.role_admin = Role.query.filter_by(name='Admin').first()
if not cls.role_admin:
cls.role_admin = Role(name='Admin', description='Rol Administrador')
db.session.add(cls.role_admin)
cls.role_alumno = Role.query.filter_by(name='Alumno').first()
if not cls.role_alumno:
cls.role_alumno = Role(name='Alumno', description='Rol Alumno')
db.session.add(cls.role_alumno)
cls.role_docente = Role.query.filter_by(name='Docente').first()
if not cls.role_docente:
cls.role_docente = Role(name='Docente', description='Rol Docente')
db.session.add(cls.role_docente)
db.session.commit()
ts = int(datetime.utcnow().timestamp())
# Admin
cls.admin = User(
name=f'Super Administrador {ts}',
email=f'admin_p4_{ts}@unicaba.edu.ar',
role_id=cls.role_admin.id,
role='Admin',
is_active=True
)
cls.admin.set_password('AdminSecret123!')
db.session.add(cls.admin)
# Docente
cls.docente = User(
name=f'Profesor Fase 4 {ts}',
email=f'docente_p4_{ts}@unicaba.edu.ar',
role_id=cls.role_docente.id,
role='Docente',
is_active=True
)
cls.docente.set_password('DocSecret123!')
db.session.add(cls.docente)
# Alumno
cls.alumno = User(
name=f'Estudiante Fase 4 {ts}',
email=f'alumno_p4_{ts}@unicaba.edu.ar',
role_id=cls.role_alumno.id,
role='Alumno',
is_active=True
)
cls.alumno.set_password('AlumSecret123!')
db.session.add(cls.alumno)
# Aulas
cls.aula1 = Classroom(
code=f'AULA-P4-1-{ts}',
capacity=40,
floor='1',
building='Edificio Central',
is_active=True
)
cls.aula2 = Classroom(
code=f'AULA-P4-2-{ts}',
capacity=25,
floor='2',
building='Edificio Central',
is_active=True
)
db.session.add(cls.aula1)
db.session.add(cls.aula2)
# Materia y Comisión
cls.subject = Subject(
name=f'Gestión Interactiva {ts}',
code=f'GI4-{ts}',
is_active=True
)
db.session.add(cls.subject)
db.session.flush()
cls.commission = Commission(
subject_id=cls.subject.id,
teacher_id=cls.docente.id,
code='C1',
shift='Mañana',
schedule='Lunes 08:00 a 12:00',
year=2026,
semester='1C',
max_students=30,
active=True
)
db.session.add(cls.commission)
db.session.commit()
cls.admin_id = cls.admin.id
cls.docente_id = cls.docente.id
cls.alumno_id = cls.alumno.id
cls.aula1_id = cls.aula1.id
cls.aula2_id = cls.aula2.id
cls.commission_id = cls.commission.id
cls.client = cls.app.test_client()
# Tokens
cls.admin_token = JWTService.create_access_token(cls.admin)
cls.alumno_token = JWTService.create_access_token(cls.alumno)
cls.docente_token = JWTService.create_access_token(cls.docente)
@classmethod
def tearDownClass(cls):
db.session.remove()
cls.app_context.pop()
def tearDown(self):
db.session.remove()
def test_admin_impersonate_user_and_audit(self):
"""Superadmin inicia sesión como alumno y se genera bitácora inmutable."""
res = self.client.post(
'/api/v1/admin/impersonate',
headers={'Authorization': f'Bearer {self.admin_token}'},
json={'target_user_id': self.alumno_id}
)
self.assertEqual(res.status_code, 200)
data = res.get_json()
self.assertEqual(data.get('status'), 'success')
alumno = db.session.get(User, self.alumno_id)
self.assertEqual(data['target_user']['email'], alumno.email)
# Verificar auditoría
audit = AuditLog.query.filter_by(action='IMPERSONATE_START', entity_id=self.alumno_id).first()
self.assertIsNotNone(audit)
self.assertEqual(audit.user_id, self.admin_id)
self.assertIn('inició sesión temporal', audit.details)
def test_non_admin_cannot_impersonate(self):
"""Usuarios no administradores (docente/alumno) tienen denegado el modo impersonación."""
# Intento por alumno
res_alum = self.client.post(
'/api/v1/admin/impersonate',
headers={'Authorization': f'Bearer {self.alumno_token}'},
json={'target_user_id': self.docente_id}
)
self.assertEqual(res_alum.status_code, 403)
# Intento por docente
res_doc = self.client.post(
'/api/v1/admin/impersonate',
headers={'Authorization': f'Bearer {self.docente_token}'},
json={'target_user_id': self.alumno_id}
)
self.assertEqual(res_doc.status_code, 403)
def test_impersonated_request_context_with_header(self):
"""La cabecera X-Impersonate-User sustituye la identidad para las consultas pero preserva admin real."""
res = self.client.get(
'/api/v1/students/my-grades',
headers={
'Authorization': f'Bearer {self.admin_token}',
'X-Impersonate-User': str(self.alumno_id)
}
)
self.assertEqual(res.status_code, 200)
data = res.get_json()
self.assertEqual(data.get('student_id'), self.alumno_id)
self.assertTrue(data.get('is_impersonating'))
def test_stop_impersonating_and_audit(self):
"""Finalización formal de la impersonación con registro en bitácora."""
res = self.client.post(
'/api/v1/admin/stop-impersonating',
headers={
'Authorization': f'Bearer {self.admin_token}',
'X-Impersonate-User': str(self.alumno_id)
}
)
self.assertEqual(res.status_code, 200)
audit = AuditLog.query.filter_by(action='IMPERSONATE_END', user_id=self.admin_id).first()
self.assertIsNotNone(audit)
self.assertEqual(audit.user_id, self.admin_id)
def test_drag_update_reservation_and_conflict_detection(self):
"""Actualización interactiva de reserva por arrastre con detección de colisión física."""
now = datetime.utcnow().replace(hour=8, minute=0, second=0, microsecond=0) + timedelta(days=2)
end = now + timedelta(hours=4)
# 1. Crear reserva existente en aula1
res1 = Reservation(
classroom_id=self.aula1_id,
commission_id=self.commission_id,
user_id=self.docente_id,
start_time=now,
end_time=end,
purpose='Clase regular',
expected_attendees=25,
status=ReservationStatus.CONFIRMED.value
)
db.session.add(res1)
db.session.commit()
# 2. Arrastre exitoso hacia aula2 (vacía en ese horario)
res_drag_ok = self.client.post(
'/api/v1/reservations/drag-update',
headers={'Authorization': f'Bearer {self.admin_token}'},
json={
'reservation_id': res1.id,
'classroom_id': self.aula2_id,
'start_time': now.isoformat(),
'end_time': end.isoformat()
}
)
self.assertEqual(res_drag_ok.status_code, 200)
self.assertEqual(res_drag_ok.get_json()['reservation']['classroom_id'], self.aula2_id)
# 3. Intentar arrastrar una nueva reserva sobre aula2 en el mismo horario (colisión física)
res_drag_conflict = self.client.post(
'/api/v1/reservations/drag-update',
headers={'Authorization': f'Bearer {self.admin_token}'},
json={
'commission_id': self.commission_id,
'classroom_id': self.aula2_id,
'start_time': now.isoformat(),
'end_time': end.isoformat()
}
)
self.assertEqual(res_drag_conflict.status_code, 409)
data_err = res_drag_conflict.get_json()
self.assertEqual(data_err.get('status'), 'conflict')
def test_genetic_algorithm_shift_schedule_and_apply(self):
"""Optimizador genético calcula horarios según turno real y persiste en BD."""
optimizer = ReservationOptimizer()
comm = db.session.get(Commission, self.commission_id)
# Verificar que calcula según turno Mañana
pref = optimizer._calculate_preferred_time(comm)
self.assertEqual(pref['start'].hour, 8)
self.assertEqual(pref['end'].hour, 12)
# Probar aplicación masiva de resultados válidos
res_data = [{
'commission_id': self.commission_id,
'classroom_id': self.aula1_id,
'user_id': self.admin_id,
'start_time': (datetime.utcnow() + timedelta(days=10)).isoformat(),
'end_time': (datetime.utcnow() + timedelta(days=10, hours=3)).isoformat(),
'purpose': 'Asignación Óptima con IA',
'expected_attendees': 20,
'status': 'CONFIRMED'
}]
applied = optimizer.apply_optimized_reservations(res_data)
self.assertEqual(len(applied), 1)
self.assertEqual(applied[0].classroom_id, self.aula1_id)
if __name__ == '__main__':
unittest.main()