feat(M8): comparateur d'agenda (AgendaComparator) dans sync/diff.py

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>
This commit is contained in:
2026-09-07 13:39:22 +02:00
parent 557555c65b
commit 10e5f22501

206
pronote_sync/sync/diff.py Normal file
View File

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