"""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)