feat: completar Fase 2 del Roadmap MVP (Co-docencia, Matriz de Conflictos y Regla Diaria) y auditoria de seguridad
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user