From 10e5f2250147f841d372643cf38ca931922d6731 Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Mon, 7 Sep 2026 13:39:22 +0200 Subject: [PATCH] feat(M8): comparateur d'agenda (AgendaComparator) dans sync/diff.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pronote_sync/sync/diff.py | 206 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 pronote_sync/sync/diff.py diff --git a/pronote_sync/sync/diff.py b/pronote_sync/sync/diff.py new file mode 100644 index 0000000..8be70c5 --- /dev/null +++ b/pronote_sync/sync/diff.py @@ -0,0 +1,206 @@ +"""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)