Files
admin-edu-space/backend/app/services/reservation_service.py
T

230 lines
11 KiB
Python

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
from app import db
class ReservationService:
"""Capa de servicios para la orquestación y validación de reservas y cronograma."""
def __init__(self, reservation_repo: Optional[ReservationRepository] = None,
classroom_repo: Optional[ClassroomRepository] = None):
self.reservation_repo = reservation_repo or ReservationRepository()
self.classroom_repo = classroom_repo or ClassroomRepository()
def get_reservation(self, reservation_id: int) -> Optional[Reservation]:
"""Obtiene una reserva por su ID."""
return self.reservation_repo.get_by_id(reservation_id)
def list_reservations(self, start_dt: datetime, end_dt: datetime,
classroom_id: Optional[int] = None) -> List[Reservation]:
"""Obtiene reservas dentro de un rango temporal."""
return self.reservation_repo.get_by_date_range(start_dt, end_dt, classroom_id=classroom_id)
def check_conflicts(self, classroom_id: int, start_time: datetime, end_time: datetime,
exclude_id: Optional[int] = None) -> List[Reservation]:
"""Verifica si existen solapamientos en un aula específica."""
classroom = self.classroom_repo.get_by_id(classroom_id)
# Las aulas virtuales tienen concurrencia ilimitada (sin colisiones)
if classroom and classroom.is_virtual:
return []
return self.reservation_repo.find_conflicts(
classroom_id=classroom_id,
start_time=start_time,
end_time=end_time,
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 la matriz integral de conflictos.
"""
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,
commission_id=dto.commission_id,
user_id=dto.user_id,
start_time=dto.start_time,
end_time=dto.end_time,
purpose=dto.purpose,
expected_attendees=dto.expected_attendees,
shift=dto.shift,
virtual_link=dto.virtual_link,
notes=dto.notes,
status=dto.status or 'CONFIRMED'
)
return self.reservation_repo.save(reservation)
def update_reservation(self, reservation_id: int, dto: ReservationUpdateDTO) -> Reservation:
"""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.")
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
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
if dto.start_time is not None:
reservation.start_time = dto.start_time
if dto.end_time is not None:
reservation.end_time = dto.end_time
if dto.purpose is not None:
reservation.purpose = dto.purpose
if dto.expected_attendees is not None:
reservation.expected_attendees = dto.expected_attendees
if dto.shift is not None:
reservation.shift = dto.shift
if dto.virtual_link is not None:
reservation.virtual_link = dto.virtual_link
if dto.notes is not None:
reservation.notes = dto.notes
if dto.status is not None:
reservation.status = dto.status
return self.reservation_repo.save(reservation)
def cancel_reservation(self, reservation_id: int) -> bool:
"""Cancela una reserva estableciendo su estado en CANCELLED."""
reservation = self.reservation_repo.get_by_id(reservation_id)
if not reservation:
return False
reservation.status = ReservationStatus.CANCELLED.value
self.reservation_repo.save(reservation)
return True
def confirm_reservation(self, reservation_id: int) -> bool:
"""Confirma una reserva estableciendo su estado en CONFIRMED."""
reservation = self.reservation_repo.get_by_id(reservation_id)
if not reservation:
return False
reservation.status = ReservationStatus.CONFIRMED.value
self.reservation_repo.save(reservation)
return True