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>
245 lines
10 KiB
Python
245 lines
10 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
|
||
|
||
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)
|