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
+123 -25
View File
@@ -1,7 +1,8 @@
from typing import Optional, List
from typing import Optional, List, Dict, Any
from datetime import datetime
from app.models.reservation import Reservation, ReservationStatus
from app.models.classroom import Classroom
from app.models.subject import Commission, CommissionTeacher
from app.repositories.reservation_repository import ReservationRepository
from app.repositories.classroom_repository import ClassroomRepository
from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO
@@ -38,23 +39,116 @@ class ReservationService:
exclude_reservation_id=exclude_id
)
def check_teacher_conflicts(self, teacher_id: int, start_time: datetime, end_time: datetime,
exclude_id: Optional[int] = None) -> List[Reservation]:
"""Verifica si el docente ya tiene otra reserva en la misma franja horaria."""
if not teacher_id:
return []
return self.reservation_repo.find_teacher_conflicts(
teacher_id=teacher_id,
start_time=start_time,
end_time=end_time,
exclude_reservation_id=exclude_id
)
def check_full_conflicts_matrix(self, classroom_id: int, start_time: datetime, end_time: datetime,
teacher_id: Optional[int] = None,
commission_id: Optional[int] = None,
expected_attendees: Optional[int] = None,
allow_co_teaching_exception: bool = False,
exclude_id: Optional[int] = None) -> Dict[str, Any]:
"""
Matriz completa de validación de conflictos (Fase 2 MVP):
1. Conflicto físico de aula (bloqueo estricto para aulas físicas).
2. Conflicto docente (detección con soporte de co-docencia).
3. Conflicto de aforo (capacidad física).
"""
classroom = self.classroom_repo.get_by_id(classroom_id)
if not classroom or not classroom.is_active:
raise ValueError(f"El aula con ID {classroom_id} no existe o no se encuentra activa.")
commission = None
if commission_id:
commission = Commission.query.get(commission_id)
if not teacher_id and commission:
teacher_id = commission.teacher_id
# 1. Conflicto Físico
physical_conflicts = []
if not classroom.is_virtual:
physical_conflicts = self.check_conflicts(classroom_id, start_time, end_time, exclude_id=exclude_id)
# 2. Conflicto Docente y Co-docencia
teacher_conflicts = []
has_co_teaching_coverage = False
co_teachers_list = []
if teacher_id:
teacher_conflicts = self.check_teacher_conflicts(teacher_id, start_time, end_time, exclude_id=exclude_id)
if teacher_conflicts and commission:
# Comprobar si la comisión cuenta con equipo docente de respaldo
co_teachers = CommissionTeacher.query.filter_by(commission_id=commission.id).all()
other_teachers = [ct for ct in co_teachers if ct.user_id != teacher_id]
if other_teachers:
has_co_teaching_coverage = True
co_teachers_list = [
{'id': ct.user_id, 'name': ct.teacher.name if ct.teacher else 'Docente', 'role': ct.role}
for ct in other_teachers
]
# 3. Conflicto de Aforo
has_capacity_conflict = False
capacity_message = None
if not classroom.is_virtual and expected_attendees is not None:
if expected_attendees > classroom.capacity:
has_capacity_conflict = True
capacity_message = f"La cantidad esperada ({expected_attendees}) supera la capacidad del aula ({classroom.capacity})."
# Evaluación de Bloqueos
block_reasons = []
if physical_conflicts:
c_strs = [f"#{c.id} ({c.start_time.strftime('%H:%M')} a {c.end_time.strftime('%H:%M')})" for c in physical_conflicts]
block_reasons.append(f"Conflicto de horario en aula {classroom.code}: se solapa con las reservas {', '.join(c_strs)}.")
if has_capacity_conflict:
block_reasons.append(f"Conflicto de aforo: {capacity_message}")
if teacher_conflicts:
if has_co_teaching_coverage or allow_co_teaching_exception:
# Cobertura justificada por equipo de co-docencia
pass
else:
t_strs = [f"#{c.id} ({c.start_time.strftime('%H:%M')} a {c.end_time.strftime('%H:%M')})" for c in teacher_conflicts]
block_reasons.append(f"Conflicto de horario docente: el profesor ya tiene clases asignadas en {', '.join(t_strs)} y la comisión no cuenta con equipo de co-docencia.")
is_blocked = len(block_reasons) > 0
return {
'is_valid': not is_blocked,
'is_blocked': is_blocked,
'block_reasons': block_reasons,
'physical_conflicts': [c.to_dict() for c in physical_conflicts],
'teacher_conflicts': [c.to_dict() for c in teacher_conflicts],
'has_co_teaching_coverage': has_co_teaching_coverage,
'co_teachers': co_teachers_list,
'has_capacity_conflict': has_capacity_conflict,
'classroom': classroom.to_dict()
}
def create_reservation(self, dto: ReservationCreateDTO) -> Reservation:
"""
Crea una nueva reserva aplicando validaciones de aforo y detección de colisiones.
Crea una nueva reserva aplicando la matriz integral de conflictos.
"""
classroom = self.classroom_repo.get_by_id(dto.classroom_id)
if not classroom or not classroom.is_active:
raise ValueError(f"El aula con ID {dto.classroom_id} no existe o no se encuentra activa.")
# Detección de colisiones para aulas físicas
if not classroom.is_virtual:
conflicts = self.check_conflicts(dto.classroom_id, dto.start_time, dto.end_time)
if conflicts:
conflict_details = ", ".join([f"#{c.id} ({c.start_time.strftime('%H:%M')} a {c.end_time.strftime('%H:%M')})" for c in conflicts])
raise ValueError(f"Conflicto de horario en {classroom.code}: se solapa con las reservas {conflict_details}.")
if dto.expected_attendees > classroom.capacity:
raise ValueError(f"La cantidad de alumnos ({dto.expected_attendees}) supera la capacidad del aula ({classroom.capacity}).")
matrix = self.check_full_conflicts_matrix(
classroom_id=dto.classroom_id,
start_time=dto.start_time,
end_time=dto.end_time,
teacher_id=dto.user_id,
commission_id=dto.commission_id,
expected_attendees=dto.expected_attendees,
allow_co_teaching_exception=getattr(dto, 'allow_co_teaching_exception', False)
)
if matrix['is_blocked']:
raise ValueError(" | ".join(matrix['block_reasons']))
reservation = Reservation(
classroom_id=dto.classroom_id,
@@ -72,7 +166,7 @@ class ReservationService:
return self.reservation_repo.save(reservation)
def update_reservation(self, reservation_id: int, dto: ReservationUpdateDTO) -> Reservation:
"""Actualiza una reserva existente verificando disponibilidad horaria."""
"""Actualiza una reserva existente verificando disponibilidad horaria y matriz de conflictos."""
reservation = self.reservation_repo.get_by_id(reservation_id)
if not reservation:
raise ValueError(f"Reserva con ID {reservation_id} no encontrada.")
@@ -80,16 +174,20 @@ class ReservationService:
classroom_id = dto.classroom_id or reservation.classroom_id
start_time = dto.start_time or reservation.start_time
end_time = dto.end_time or reservation.end_time
expected_attendees = dto.expected_attendees if dto.expected_attendees is not None else reservation.expected_attendees
classroom = self.classroom_repo.get_by_id(classroom_id)
if not classroom or not classroom.is_active:
raise ValueError(f"El aula con ID {classroom_id} no existe o no se encuentra activa.")
if not classroom.is_virtual:
conflicts = self.check_conflicts(classroom_id, start_time, end_time, exclude_id=reservation.id)
if conflicts:
conflict_details = ", ".join([f"#{c.id} ({c.start_time.strftime('%H:%M')} - {c.end_time.strftime('%H:%M')})" for c in conflicts])
raise ValueError(f"Conflicto de horario en {classroom.code} con: {conflict_details}.")
matrix = self.check_full_conflicts_matrix(
classroom_id=classroom_id,
start_time=start_time,
end_time=end_time,
teacher_id=reservation.user_id,
commission_id=reservation.commission_id,
expected_attendees=expected_attendees,
allow_co_teaching_exception=getattr(dto, 'allow_co_teaching_exception', False),
exclude_id=reservation.id
)
if matrix['is_blocked']:
raise ValueError(" | ".join(matrix['block_reasons']))
if dto.classroom_id is not None:
reservation.classroom_id = dto.classroom_id