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