Files
college-infos/pronote_sync/sync/diff.py
Antoine Van Elstraete 5907c9aeaf fix(M8): corrections d'audit FIXME_M8 — appariement, date, déterminisme, validateur
Quatre corrections bloquantes/majeures de l'audit FIXME_M8 :
- Appariement un-à-un déterministe (consommation du candidat sélectionné) ;
  1 réel / 2 théoriques → 1 REMOVED, 2 réels / 1 théorique → 1 ADDED.
- Filtrage strict par date : les cours réels hors target_date sont exclus
  du matching avec un warning logé (décision architecte : pas d'exception).
- Déterminisme des détails : formatage via sorted(set(...)) au lieu de
  set(...) brut, indépendant de PYTHONHASHSEED.
- Validateur AgendaChange strict : ADDED = lesson seule, REMOVED =
  theoretical_lesson seule, MODIFIED = les deux requis.
- Comparaison à la minute près dans _is_modified (cohérent avec _matches).
- Documentation §8.4/§8.5 alignée avec l'implémentation (tolérance 15 min,
  API compare(), normalize_subject référencé, appariement consommé).

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/tech-writer <tech-writer@agents.invalid>
2026-09-07 15:59:17 +02:00

245 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
import logging
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
#: Logger du module pour les avertissements de bornage.
_logger = logging.getLogger(__name__)
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.
L'appariement est un-à-un et déterministe : chaque cours théorique ne
peut être apparié qu'au plus un cours réel, et chaque cours réel ne
peut être apparié qu'au plus un cours théorique. Les changements sont
émis dans un ordre déterministe : d'abord les cours réels triés par
identifiant (ADDED ou MODIFIED), puis les cours théoriques restants non
appariés (REMOVED) triés par identifiant.
Seuls les cours réels dont la date de début est strictement égale à la
date cible :class:`target_date` sont pris en compte. Tout cours réel hors
de cette date est exclu du diff (il ne produit ni ``ADDED`` ni
``MODIFIED``) et un avertissement (``logging.warning``) est émis pour
chacun d'eux, sans divulguer de secret (seul l'identifiant du cours et
sa date sont logués).
: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)
#: Cours réels restreints à la date cible : les cours hors date sont
#: exclus du diff et signalés par un warning.
filtered_real_lessons: list[Lesson] = []
for real in real_lessons:
if real.start.date() == target_date:
filtered_real_lessons.append(real)
else:
_logger.warning(
"Cours réel %s ignoré : date %s != date cible %s",
real.id,
real.start.date(),
target_date,
)
#: Identifiants des cours théoriques encore disponibles pour appariement.
available_theoretical_ids: set[str] = {
theoretical.id for theoretical in theoretical_lessons
}
changes: list[AgendaChange] = []
for real in sorted(filtered_real_lessons, key=lambda lesson: lesson.id):
candidates = [
theoretical
for theoretical in theoretical_lessons
if theoretical.id in available_theoretical_ids
and self._matches(real, theoretical, target_date)
]
selected = min(candidates, key=lambda candidate: candidate.id) if candidates else None
if selected is not None:
available_theoretical_ids.discard(selected.id)
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 in available_theoretical_ids:
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.
Les horaires sont comparés à la minute près des deux côtés (les
secondes sont ignorées), cohérent avec les helpers
:func:`_minutes_since_midnight` et :func:`_time_minutes` utilisés par
:meth:`_matches`. 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 _minutes_since_midnight(real.start) != _time_minutes(
theoretical.start_time
) or _minutes_since_midnight(real.end) != _time_minutes(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: {sorted(set(theoretical.teachers))}{sorted(set(real.teachers))}"
)
if set(real.rooms) != set(theoretical.rooms):
parts.append(f"salles: {sorted(set(theoretical.rooms))}{sorted(set(real.rooms))}")
if real.status != LessonStatus.NORMAL:
parts.append(f"statut: {real.status.value}")
return "; ".join(parts)