feat: completar Fase 2 del Roadmap MVP (Co-docencia, Matriz de Conflictos y Regla Diaria) y auditoria de seguridad

This commit is contained in:
2026-09-19 14:05:16 -03:00
parent 05e197e49f
commit ada39248d7
36 changed files with 3037 additions and 443 deletions
+293
View File
@@ -0,0 +1,293 @@
import unittest
from datetime import datetime, timedelta
from app import create_app, db
from app.models.classroom import Classroom
from app.models.subject import Subject, Commission, CommissionTeacher
from app.models.user import User
from app.models.role import Role
from app.models.reservation import Reservation
from app.models.enrollment import StudentEnrollment
from app.services.reservation_service import ReservationService
from app.services.enrollment_service import EnrollmentService
from app.schemas.reservation_dto import ReservationCreateDTO
class TestPhase2Rules(unittest.TestCase):
"""Pruebas unitarias e integrales para la Fase 2 del Roadmap MVP (UniCABA)."""
def setUp(self):
self.app = create_app()
self.app_context = self.app.app_context()
self.app_context.push()
self.res_service = ReservationService()
# Obtener o crear rol alumno y docente
self.role_alumno = Role.query.filter_by(name='Alumno').first()
if not self.role_alumno:
self.role_alumno = Role(name='Alumno', description='Rol Alumno')
db.session.add(self.role_alumno)
self.role_docente = Role.query.filter_by(name='Docente').first()
if not self.role_docente:
self.role_docente = Role(name='Docente', description='Rol Docente')
db.session.add(self.role_docente)
db.session.commit()
# Crear usuarios para tests
ts = int(datetime.utcnow().timestamp())
self.docente1 = User(
name=f'Profesor Principal {ts}',
email=f'docente1_{ts}@unicaba.edu.ar',
role_id=self.role_docente.id,
role='Docente',
is_active=True
)
self.docente1.set_password('Secret123!')
self.docente2 = User(
name=f'Profesor Adjunto {ts}',
email=f'docente2_{ts}@unicaba.edu.ar',
role_id=self.role_docente.id,
role='Docente',
is_active=True
)
self.docente2.set_password('Secret123!')
self.alumno1 = User(
name=f'Estudiante Test {ts}',
email=f'alumno_{ts}@unicaba.edu.ar',
role_id=self.role_alumno.id,
role='Alumno',
is_active=True
)
self.alumno1.set_password('Secret123!')
# Aulas física y virtual
self.aula_fisica = Classroom(
code=f'A-501-{ts}',
capacity=30,
building='Sede Central',
floor='Piso 5',
is_active=True
)
self.aula_virtual = Classroom(
code=f'VIRTUAL-MEET-{ts}',
capacity=100,
building='Campus Virtual',
floor='Plataforma Digital',
is_active=True
)
# Asignaturas regulares y curso corto
self.materia_regular_a = Subject(
code=f'REG-A-{ts}',
name=f'Algoritmos y Estructuras {ts}',
is_short_course=False,
is_active=True
)
self.materia_regular_b = Subject(
code=f'REG-B-{ts}',
name=f'Sistemas Operativos {ts}',
is_short_course=False,
is_active=True
)
self.curso_corto = Subject(
code=f'TALLER-{ts}',
name=f'Taller de Herramientas Git {ts}',
is_short_course=True,
is_active=True
)
db.session.add_all([
self.docente1, self.docente2, self.alumno1,
self.aula_fisica, self.aula_virtual,
self.materia_regular_a, self.materia_regular_b, self.curso_corto
])
db.session.commit()
# Comisiones
self.comision_a = Commission(
subject_id=self.materia_regular_a.id,
code=f'COM-A-{ts}',
semester='1C',
year=2026,
teacher_id=self.docente1.id,
max_students=40,
current_students=0,
schedule='Lunes 08:00 - 12:00',
active=True
)
self.comision_b = Commission(
subject_id=self.materia_regular_b.id,
code=f'COM-B-{ts}',
semester='1C',
year=2026,
teacher_id=self.docente1.id,
max_students=40,
current_students=0,
schedule='Lunes 14:00 - 18:00',
active=True
)
self.comision_c_corto = Commission(
subject_id=self.curso_corto.id,
code=f'COM-CORTO-{ts}',
semester='1C',
year=2026,
teacher_id=self.docente2.id,
max_students=20,
current_students=0,
schedule='Lunes 18:00 - 20:00',
active=True
)
db.session.add_all([self.comision_a, self.comision_b, self.comision_c_corto])
db.session.commit()
def tearDown(self):
db.session.rollback()
self.app_context.pop()
def test_01_physical_conflict_blocks_double_booking(self):
"""Estadio 2.2: Debe bloquear doble asignación en misma aula física y horario."""
start = datetime(2026, 10, 5, 8, 0)
end = datetime(2026, 10, 5, 12, 0)
# Primera reserva exitosa
dto1 = ReservationCreateDTO(
classroom_id=self.aula_fisica.id,
commission_id=self.comision_a.id,
user_id=self.docente1.id,
start_time=start,
end_time=end,
expected_attendees=25
)
res1 = self.res_service.create_reservation(dto1)
self.assertIsNotNone(res1.id)
# Segunda reserva en la misma aula solapándose (09:00 a 11:00)
dto2 = ReservationCreateDTO(
classroom_id=self.aula_fisica.id,
commission_id=self.comision_b.id,
user_id=self.docente2.id,
start_time=start + timedelta(hours=1),
end_time=end - timedelta(hours=1),
expected_attendees=20
)
with self.assertRaises(ValueError) as ctx:
self.res_service.create_reservation(dto2)
self.assertIn("Conflicto de horario", str(ctx.exception))
def test_02_capacity_aforo_validation(self):
"""Estadio 2.2: Debe bloquear reserva si asistentes esperados superan capacidad física."""
start = datetime(2026, 10, 6, 8, 0)
end = datetime(2026, 10, 6, 12, 0)
dto = ReservationCreateDTO(
classroom_id=self.aula_fisica.id,
commission_id=self.comision_a.id,
user_id=self.docente1.id,
start_time=start,
end_time=end,
expected_attendees=self.aula_fisica.capacity + 15 # supera capacidad (30)
)
with self.assertRaises(ValueError) as ctx:
self.res_service.create_reservation(dto)
self.assertIn("Conflicto de aforo", str(ctx.exception))
def test_03_teacher_conflict_and_co_teaching_exception(self):
"""Estadio 2.2: Detección de conflicto docente y excepción justificada por co-docencia."""
start = datetime(2026, 10, 7, 14, 0)
end = datetime(2026, 10, 7, 18, 0)
# Docente 1 dicta clase en aula física
dto1 = ReservationCreateDTO(
classroom_id=self.aula_fisica.id,
commission_id=self.comision_a.id,
user_id=self.docente1.id,
start_time=start,
end_time=end,
expected_attendees=20
)
self.res_service.create_reservation(dto1)
# Mismo docente 1 intenta otra clase en sala virtual al mismo tiempo
dto_conflict = ReservationCreateDTO(
classroom_id=self.aula_virtual.id,
commission_id=self.comision_b.id,
user_id=self.docente1.id,
start_time=start,
end_time=end,
expected_attendees=20
)
# Sin co-docencia debe bloquear
with self.assertRaises(ValueError) as ctx:
self.res_service.create_reservation(dto_conflict)
self.assertIn("Conflicto de horario docente", str(ctx.exception))
# Ahora incorporamos a Docente 2 como co-docente en comision_b
ct = CommissionTeacher(
commission_id=self.comision_b.id,
user_id=self.docente2.id,
role='Adjunto'
)
db.session.add(ct)
db.session.commit()
# Con co-docencia registrada, la matriz detecta cobertura docente y permite la reserva
res_co = self.res_service.create_reservation(dto_conflict)
self.assertIsNotNone(res_co.id)
def test_04_student_same_day_restriction_rule(self):
"""Estadio 2.3: Un alumno no puede cursar dos asignaturas regulares el mismo día sin excepción."""
# Inscribir al alumno en comision_a (Lunes regular)
enr1 = EnrollmentService.enroll_student(
commission_id=self.comision_a.id,
student_id=self.alumno1.id
)
self.assertEqual(enr1.status, 'activo')
# Intentar inscribir en comision_b (también Lunes regular): debe BLOQUEAR
with self.assertRaises(ValueError) as ctx:
EnrollmentService.enroll_student(
commission_id=self.comision_b.id,
student_id=self.alumno1.id
)
self.assertIn("Restricción de cursada diaria", str(ctx.exception))
self.assertIn("Lunes", str(ctx.exception))
def test_05_student_short_course_bypass(self):
"""Estadio 2.3: Si una de las materias es curso corto, se autoriza automáticamente."""
# El alumno cursa comision_a los Lunes
EnrollmentService.enroll_student(
commission_id=self.comision_a.id,
student_id=self.alumno1.id
)
# Intentar inscribir en curso corto (Lunes taller): debe PERMITIR
enr_short = EnrollmentService.enroll_student(
commission_id=self.comision_c_corto.id,
student_id=self.alumno1.id
)
self.assertIsNotNone(enr_short.id)
self.assertEqual(enr_short.status, 'activo')
def test_06_student_bedelia_manual_exception_bypass(self):
"""Estadio 2.3: Con autorización expresa de Bedelía (allow_same_day_exception), se permite."""
# Alumno cursa comision_a los Lunes
EnrollmentService.enroll_student(
commission_id=self.comision_a.id,
student_id=self.alumno1.id
)
# Inscribir en comision_b con allow_same_day_exception=True
enr2 = EnrollmentService.enroll_student(
commission_id=self.comision_b.id,
student_id=self.alumno1.id,
allow_same_day_exception=True,
exception_reason="Autorización especial de Bedelía por cambio de plan de estudios"
)
self.assertIsNotNone(enr2.id)
self.assertTrue(enr2.allow_same_day_exception)
self.assertIn("cambio de plan", enr2.exception_reason)
if __name__ == '__main__':
unittest.main()