feat: completar Fase 2 del Roadmap MVP (Co-docencia, Matriz de Conflictos y Regla Diaria) y auditoria de seguridad

This commit is contained in:
2026-09-19 14:05:16 -03:00
parent 05e197e49f
commit ada39248d7
36 changed files with 3037 additions and 443 deletions
+220
View File
@@ -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
+123 -25
View File
@@ -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
+3 -1
View File
@@ -76,11 +76,13 @@ class GoogleSheetsImporter:
"""Fetch CSV string for a specific sheet gid"""
separator = '&' if '?' in self.base_url else '?'
url = f"{self.base_url}{separator}gid={gid}"
if not (url.startswith('https://') or url.startswith('http://')):
raise ValueError(f"URL scheme not permitted: {url}")
req = urllib.request.Request(
url,
headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) EduSpace/2.0'}
)
with urllib.request.urlopen(req, timeout=25) as resp:
with urllib.request.urlopen(req, timeout=25) as resp: # nosec B310
return resp.read().decode('utf-8', errors='replace')
def parse_sheet_rows(self, csv_content, sheet_name):