221 lines
9.3 KiB
Python
221 lines
9.3 KiB
Python
import re
|
|
from typing import Optional, List, Set, Dict, Any
|
|
from datetime import datetime
|
|
from app import db
|
|
from app.models.user import User
|
|
from app.models.subject import Commission, Subject
|
|
from app.models.enrollment import StudentEnrollment
|
|
from app.models.reservation import Reservation
|
|
|
|
WEEKDAYS_MAP = {
|
|
0: 'lunes',
|
|
1: 'martes',
|
|
2: 'miercoles',
|
|
3: 'jueves',
|
|
4: 'viernes',
|
|
5: 'sabado',
|
|
6: 'domingo'
|
|
}
|
|
|
|
SPANISH_DAYS = ['lunes', 'martes', 'miercoles', 'miércoles', 'jueves', 'viernes', 'sabado', 'sábado', 'domingo']
|
|
|
|
class EnrollmentService:
|
|
"""
|
|
Servicio de matriculación y aplicación de reglas de negocio académicas (Fase 2 MVP):
|
|
- Control estricto de aforo y cupo de comisión.
|
|
- Prevención de doble inscripción.
|
|
- Regla de Restricción Diaria del Alumno (máximo 1 asignatura regular por día calendario).
|
|
- Soporte de excepciones automáticas por curso corto y excepciones autorizadas de Bedelía.
|
|
"""
|
|
|
|
@staticmethod
|
|
def normalize_day(day_str: str) -> str:
|
|
d = day_str.lower().strip()
|
|
if 'miér' in d or 'mier' in d:
|
|
return 'miercoles'
|
|
if 'sáb' in d or 'sab' in d:
|
|
return 'sabado'
|
|
return d
|
|
|
|
@classmethod
|
|
def get_commission_days(cls, commission: Commission) -> Set[str]:
|
|
"""Extrae el conjunto de días de cursada de la comisión desde schedule y reservas activas."""
|
|
days: Set[str] = set()
|
|
|
|
# 1. Analizar texto del campo schedule
|
|
if commission.schedule:
|
|
sched_lower = commission.schedule.lower()
|
|
for d in SPANISH_DAYS:
|
|
if re.search(r'\b' + re.escape(d) + r'\b', sched_lower):
|
|
days.add(cls.normalize_day(d))
|
|
|
|
# 2. Analizar reservas reales de la comisión
|
|
reservations = Reservation.query.filter(
|
|
Reservation.commission_id == commission.id,
|
|
Reservation.status != 'CANCELLED'
|
|
).all()
|
|
for r in reservations:
|
|
if r.start_time:
|
|
weekday_idx = r.start_time.weekday()
|
|
if weekday_idx in WEEKDAYS_MAP:
|
|
days.add(WEEKDAYS_MAP[weekday_idx])
|
|
|
|
return days
|
|
|
|
@classmethod
|
|
def list_enrollments(cls, commission_id: int) -> List[Dict[str, Any]]:
|
|
"""Lista todos los alumnos matriculados en una comisión con información extendida."""
|
|
enrollments = StudentEnrollment.query.filter_by(commission_id=commission_id).all()
|
|
result = []
|
|
for enr in enrollments:
|
|
data = enr.to_dict()
|
|
if enr.student:
|
|
data['student'] = {
|
|
'id': enr.student.id,
|
|
'name': enr.student.name,
|
|
'email': enr.student.email,
|
|
'personal_email': getattr(enr.student, 'personal_email', None),
|
|
'phone': getattr(enr.student, 'phone', None),
|
|
'document_type': getattr(enr.student, 'document_type', None),
|
|
'document_number': getattr(enr.student, 'document_number', None)
|
|
}
|
|
result.append(data)
|
|
return result
|
|
|
|
@classmethod
|
|
def enroll_student(cls, commission_id: int, student_id: int,
|
|
notes: Optional[str] = None,
|
|
allow_same_day_exception: bool = False,
|
|
exception_reason: Optional[str] = None) -> StudentEnrollment:
|
|
"""
|
|
Matricula a un estudiante en una comisión aplicando la regla de restricción diaria.
|
|
"""
|
|
commission = Commission.query.get(commission_id)
|
|
if not commission or not commission.active:
|
|
raise ValueError(f"La comisión #{commission_id} no existe o no se encuentra activa.")
|
|
|
|
student = User.query.get(student_id)
|
|
if not student or not student.is_active:
|
|
raise ValueError(f"El alumno #{student_id} no existe o su cuenta se encuentra inactiva.")
|
|
|
|
# Verificar si ya está matriculado
|
|
existing = StudentEnrollment.query.filter_by(commission_id=commission_id, student_id=student_id).first()
|
|
if existing:
|
|
if existing.status == 'activo':
|
|
raise ValueError(f"El alumno {student.name} ya está matriculado activamente en esta comisión.")
|
|
# Si estaba retirado, se reactiva
|
|
existing.status = 'activo'
|
|
existing.notes = notes or existing.notes
|
|
existing.allow_same_day_exception = allow_same_day_exception
|
|
existing.exception_reason = exception_reason if allow_same_day_exception else None
|
|
commission.current_students = (commission.current_students or 0) + 1
|
|
db.session.commit()
|
|
return existing
|
|
|
|
# Verificar cupo máximo
|
|
if commission.max_students and (commission.current_students or 0) >= commission.max_students:
|
|
raise ValueError(f"La comisión ha alcanzado su cupo máximo de {commission.max_students} estudiantes.")
|
|
|
|
# ─── REGLA DE RESTRICCIÓN DIARIA DE CURSADA (ESTADIO 2.3) ───
|
|
target_subject = commission.subject
|
|
is_target_short_course = bool(target_subject and target_subject.is_short_course)
|
|
target_days = cls.get_commission_days(commission)
|
|
|
|
# Buscar otras inscripciones activas del estudiante
|
|
active_enrollments = StudentEnrollment.query.filter_by(
|
|
student_id=student_id,
|
|
status='activo'
|
|
).all()
|
|
|
|
for enr in active_enrollments:
|
|
other_comm = enr.commission
|
|
if not other_comm or not other_comm.active:
|
|
continue
|
|
|
|
# Verificar si coinciden en el mismo ciclo/semestre/año
|
|
same_term = (
|
|
other_comm.year == commission.year and
|
|
other_comm.semester == commission.semester
|
|
)
|
|
if not same_term:
|
|
continue
|
|
|
|
other_subject = other_comm.subject
|
|
is_other_short_course = bool(other_subject and other_subject.is_short_course)
|
|
|
|
# Ambas son materias regulares: no pueden cursarse el mismo día sin excepción
|
|
if not is_target_short_course and not is_other_short_course:
|
|
other_days = cls.get_commission_days(other_comm)
|
|
overlapping_days = target_days.intersection(other_days)
|
|
|
|
if overlapping_days:
|
|
if allow_same_day_exception:
|
|
# Excepción otorgada expresamente por Bedelía
|
|
if not exception_reason:
|
|
exception_reason = "Autorización expresa de Bedelía para cursada simultánea en el mismo día."
|
|
else:
|
|
days_display = ", ".join([d.capitalize() for d in overlapping_days])
|
|
other_name = other_subject.name if other_subject else other_comm.code
|
|
raise ValueError(
|
|
f"Restricción de cursada diaria: El alumno ya cursa la materia regular '{other_name}' "
|
|
f"el día {days_display}. La normativa de UniCABA prohíbe cursar dos materias regulares "
|
|
f"el mismo día calendario salvo curso corto o autorización explícita de Bedelía."
|
|
)
|
|
|
|
enrollment = StudentEnrollment(
|
|
student_id=student_id,
|
|
commission_id=commission_id,
|
|
status='activo',
|
|
notes=notes,
|
|
allow_same_day_exception=allow_same_day_exception,
|
|
exception_reason=exception_reason if allow_same_day_exception else None
|
|
)
|
|
db.session.add(enrollment)
|
|
commission.current_students = (commission.current_students or 0) + 1
|
|
db.session.commit()
|
|
return enrollment
|
|
|
|
@classmethod
|
|
def unenroll_student(cls, commission_id: int, student_id: int) -> bool:
|
|
"""Da de baja o retira a un alumno de una comisión."""
|
|
enrollment = StudentEnrollment.query.filter_by(
|
|
commission_id=commission_id,
|
|
student_id=student_id
|
|
).first()
|
|
if not enrollment:
|
|
return False
|
|
|
|
commission = Commission.query.get(commission_id)
|
|
if commission and (commission.current_students or 0) > 0:
|
|
commission.current_students -= 1
|
|
|
|
db.session.delete(enrollment)
|
|
db.session.commit()
|
|
return True
|
|
|
|
@classmethod
|
|
def update_enrollment_status(cls, commission_id: int, student_id: int, status: str,
|
|
notes: Optional[str] = None) -> Optional[StudentEnrollment]:
|
|
"""Actualiza el estado de la matrícula (activo, condicional, retirado)."""
|
|
enrollment = StudentEnrollment.query.filter_by(
|
|
commission_id=commission_id,
|
|
student_id=student_id
|
|
).first()
|
|
if not enrollment:
|
|
return None
|
|
|
|
old_status = enrollment.status
|
|
enrollment.status = status
|
|
if notes is not None:
|
|
enrollment.notes = notes
|
|
|
|
commission = Commission.query.get(commission_id)
|
|
if commission:
|
|
if old_status == 'activo' and status in ['retirado']:
|
|
commission.current_students = max(0, (commission.current_students or 1) - 1)
|
|
elif old_status in ['retirado'] and status == 'activo':
|
|
commission.current_students = (commission.current_students or 0) + 1
|
|
|
|
db.session.commit()
|
|
return enrollment
|