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