Comparaison déterministe entre l'agenda réel (Lesson) et l'agenda théorique (TheoreticalLesson) produisant un AgendaDiff (ADDED/REMOVED/ MODIFIED). Matching par jour + tolérance ±15 min symétrique + matière normalisée ; tri des candidats par id stable. REMOVED par existence (non-appariement), pas par sélection. Comparaison ordre-insensible des enseignants et salles via set(). Détection MODIFIED incluant les horaires, la matière, les enseignants, les salles et le statut. Co-authored-by: opencode/coder <coder@agents.invalid>
207 lines
8.7 KiB
Python
207 lines
8.7 KiB
Python
"""Comparaison entre l'agenda réel et l'agenda théorique.
|
||
|
||
Ce module définit :class:`AgendaComparator`, responsable de produire un
|
||
:class:`AgendaDiff` en appariant les cours réels (:class:`Lesson`) aux cours
|
||
théoriques (:class:`TheoreticalLesson`) fournis par un
|
||
:class:`TheoreticalAgendaProvider`. L'appariement est tolérant sur les horaires
|
||
(±15 minutes) et normalise les matières. Le résultat est déterministe : il ne
|
||
dépend ni de l'ordre des entrées du fournisseur, ni de l'ordre des cours réels
|
||
pour le matching.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import date, datetime, time
|
||
|
||
from pronote_sync.models.agenda import Lesson, LessonStatus, TheoreticalLesson
|
||
from pronote_sync.models.diff import AgendaChange, AgendaChangeType, AgendaDiff
|
||
from pronote_sync.sources.theoretical.provider import TheoreticalAgendaProvider
|
||
from pronote_sync.utils.text import normalize_subject
|
||
|
||
#: Tolérance temporelle en minutes (valeur absolue) pour l'appariement.
|
||
_TOLERANCE_MINUTES = 15
|
||
|
||
|
||
def _minutes_since_midnight(dt: datetime) -> int:
|
||
"""Retourne le nombre de minutes écoulées depuis minuit pour un datetime.
|
||
|
||
Les secondes sont ignorées.
|
||
|
||
:param dt: Date/heure à convertir.
|
||
:return: Nombre de minutes (heure * 60 + minute).
|
||
:rtype: int
|
||
"""
|
||
return dt.hour * 60 + dt.minute
|
||
|
||
|
||
def _time_minutes(t: time) -> int:
|
||
"""Retourne le nombre de minutes écoulées depuis minuit pour un time.
|
||
|
||
Les secondes sont ignorées.
|
||
|
||
:param t: Heure à convertir.
|
||
:return: Nombre de minutes (heure * 60 + minute).
|
||
:rtype: int
|
||
"""
|
||
return t.hour * 60 + t.minute
|
||
|
||
|
||
class AgendaComparator:
|
||
"""Compare l'agenda réel à l'agenda théorique pour une date cible.
|
||
|
||
:class:`AgendaComparator` apparie chaque cours réel au cours théorique qui
|
||
lui correspond (tolérance temporelle ±15 minutes et matière normalisée),
|
||
détecte les cours ajoutés, supprimés et modifiés, puis produit un
|
||
:class:`AgendaDiff` ordonné de manière déterministe.
|
||
"""
|
||
|
||
def __init__(self, theoretical_provider: TheoreticalAgendaProvider) -> None:
|
||
"""Initialise le comparateur avec un fournisseur d'agenda théorique.
|
||
|
||
:param theoretical_provider: Fournisseur des cours théoriques.
|
||
:rtype: None
|
||
"""
|
||
self._theoretical_provider = theoretical_provider
|
||
|
||
def compare(self, real_lessons: list[Lesson], target_date: date) -> AgendaDiff:
|
||
"""Compare les cours réels aux cours théoriques pour la date cible.
|
||
|
||
Les changements sont émis dans un ordre déterministe : d'abord les cours
|
||
réels dans leur ordre d'entrée (ADDED ou MODIFIED), puis les cours
|
||
théoriques non appariés par existence (REMOVED) triés par identifiant.
|
||
|
||
:param real_lessons: Liste des cours réels (dans leur ordre d'entrée).
|
||
:param target_date: Date cible de la comparaison.
|
||
:return: Le diff entre l'agenda réel et l'agenda théorique.
|
||
:rtype: AgendaDiff
|
||
"""
|
||
theoretical_lessons = self._theoretical_provider.get_lessons(target_date)
|
||
|
||
#: Identifiants des cours théoriques candidats d'au moins un cours réel
|
||
#: (appariement par existence pour la détection des suppressions).
|
||
matched_by_existence: set[str] = set()
|
||
changes: list[AgendaChange] = []
|
||
|
||
for real in real_lessons:
|
||
candidates = [
|
||
theoretical
|
||
for theoretical in theoretical_lessons
|
||
if self._matches(real, theoretical, target_date)
|
||
]
|
||
matched_by_existence.update(candidate.id for candidate in candidates)
|
||
|
||
selected = min(candidates, key=lambda candidate: candidate.id) if candidates else None
|
||
if selected is None:
|
||
changes.append(
|
||
AgendaChange(
|
||
type=AgendaChangeType.ADDED,
|
||
lesson=real,
|
||
theoretical_lesson=None,
|
||
details="Cours ajouté par rapport à l'agenda théorique",
|
||
)
|
||
)
|
||
elif self._is_modified(real, selected):
|
||
changes.append(
|
||
AgendaChange(
|
||
type=AgendaChangeType.MODIFIED,
|
||
lesson=real,
|
||
theoretical_lesson=selected,
|
||
details=self._describe_changes(real, selected),
|
||
)
|
||
)
|
||
|
||
for theoretical in sorted(theoretical_lessons, key=lambda lesson: lesson.id):
|
||
if theoretical.id not in matched_by_existence:
|
||
changes.append(
|
||
AgendaChange(
|
||
type=AgendaChangeType.REMOVED,
|
||
lesson=None,
|
||
theoretical_lesson=theoretical,
|
||
details="Cours supprimé par rapport à l'agenda théorique",
|
||
)
|
||
)
|
||
|
||
return AgendaDiff(target_date=target_date, changes=tuple(changes))
|
||
|
||
def _matches(
|
||
self,
|
||
real: Lesson,
|
||
theoretical: TheoreticalLesson,
|
||
target_date: date,
|
||
) -> bool:
|
||
"""Détermine si un cours théorique est candidat d'un cours réel.
|
||
|
||
Un cours théorique est candidat d'un cours réel si le jour de la semaine
|
||
correspond, si les horaires de début et de fin coïncident à ±15 minutes
|
||
près et si les matières normalisées sont identiques.
|
||
|
||
:param real: Cours réel.
|
||
:param theoretical: Cours théorique candidat.
|
||
:param target_date: Date cible de la comparaison.
|
||
:return: ``True`` si le cours théorique correspond au cours réel.
|
||
:rtype: bool
|
||
"""
|
||
if theoretical.day_of_week != target_date.weekday():
|
||
return False
|
||
if abs(_time_minutes(theoretical.start_time) - _minutes_since_midnight(real.start)) > (
|
||
_TOLERANCE_MINUTES
|
||
):
|
||
return False
|
||
if abs(_time_minutes(theoretical.end_time) - _minutes_since_midnight(real.end)) > (
|
||
_TOLERANCE_MINUTES
|
||
):
|
||
return False
|
||
return normalize_subject(theoretical.subject) == normalize_subject(real.subject)
|
||
|
||
def _is_modified(self, real: Lesson, theoretical: TheoreticalLesson) -> bool:
|
||
"""Détermine si un cours réel apparié diffère de son cours théorique.
|
||
|
||
Un cours est considéré modifié si au moins un horaire diffère à la
|
||
minute près, si la matière normalisée diffère, si les professeurs ou les
|
||
salles diffèrent (comparaison par ensemble), ou si le statut n'est pas
|
||
``NORMAL``.
|
||
|
||
:param real: Cours réel apparié.
|
||
:param theoretical: Cours théorique apparié.
|
||
:return: ``True`` si le cours réel diffère du cours théorique.
|
||
:rtype: bool
|
||
"""
|
||
if real.start.time() != theoretical.start_time or real.end.time() != theoretical.end_time:
|
||
return True
|
||
if normalize_subject(real.subject) != normalize_subject(theoretical.subject):
|
||
return True
|
||
if set(real.teachers) != set(theoretical.teachers):
|
||
return True
|
||
if set(real.rooms) != set(theoretical.rooms):
|
||
return True
|
||
return real.status != LessonStatus.NORMAL
|
||
|
||
def _describe_changes(self, real: Lesson, theoretical: TheoreticalLesson) -> str:
|
||
"""Génère une description lisible des différences entre deux cours.
|
||
|
||
Les différences détectées sont décrites sous forme d'éléments séparés
|
||
par ``"; "``, en utilisant les valeurs originales (non normalisées) des
|
||
matières et des ensembles de professeurs/salles.
|
||
|
||
:param real: Cours réel apparié.
|
||
:param theoretical: Cours théorique apparié.
|
||
:return: Description lisible des différences.
|
||
:rtype: str
|
||
"""
|
||
parts: list[str] = []
|
||
if real.start.time() != theoretical.start_time or real.end.time() != theoretical.end_time:
|
||
parts.append(
|
||
f"horaires: {theoretical.start_time.strftime('%H:%M')}"
|
||
f"–{theoretical.end_time.strftime('%H:%M')}"
|
||
f" → {real.start.strftime('%H:%M')}–{real.end.strftime('%H:%M')}"
|
||
)
|
||
if normalize_subject(real.subject) != normalize_subject(theoretical.subject):
|
||
parts.append(f"matière: {theoretical.subject} → {real.subject}")
|
||
if set(real.teachers) != set(theoretical.teachers):
|
||
parts.append(f"professeurs: {set(theoretical.teachers)} → {set(real.teachers)}")
|
||
if set(real.rooms) != set(theoretical.rooms):
|
||
parts.append(f"salles: {set(theoretical.rooms)} → {set(real.rooms)}")
|
||
if real.status != LessonStatus.NORMAL:
|
||
parts.append(f"statut: {real.status.value}")
|
||
return "; ".join(parts)
|