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>
This commit is contained in:
@@ -11,6 +11,7 @@ pour le matching.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime, time
|
||||
|
||||
from pronote_sync.models.agenda import Lesson, LessonStatus, TheoreticalLesson
|
||||
@@ -21,6 +22,9 @@ 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.
|
||||
@@ -66,9 +70,19 @@ class AgendaComparator:
|
||||
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.
|
||||
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.
|
||||
@@ -77,20 +91,37 @@ class AgendaComparator:
|
||||
"""
|
||||
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()
|
||||
#: 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 real_lessons:
|
||||
for real in sorted(filtered_real_lessons, key=lambda lesson: lesson.id):
|
||||
candidates = [
|
||||
theoretical
|
||||
for theoretical in theoretical_lessons
|
||||
if self._matches(real, theoretical, target_date)
|
||||
if theoretical.id in available_theoretical_ids
|
||||
and 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 not None:
|
||||
available_theoretical_ids.discard(selected.id)
|
||||
|
||||
if selected is None:
|
||||
changes.append(
|
||||
AgendaChange(
|
||||
@@ -111,7 +142,7 @@ class AgendaComparator:
|
||||
)
|
||||
|
||||
for theoretical in sorted(theoretical_lessons, key=lambda lesson: lesson.id):
|
||||
if theoretical.id not in matched_by_existence:
|
||||
if theoretical.id in available_theoretical_ids:
|
||||
changes.append(
|
||||
AgendaChange(
|
||||
type=AgendaChangeType.REMOVED,
|
||||
@@ -156,17 +187,22 @@ class AgendaComparator:
|
||||
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``.
|
||||
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 real.start.time() != theoretical.start_time or real.end.time() != theoretical.end_time:
|
||||
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
|
||||
@@ -198,9 +234,11 @@ class AgendaComparator:
|
||||
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)}")
|
||||
parts.append(
|
||||
f"professeurs: {sorted(set(theoretical.teachers))} → {sorted(set(real.teachers))}"
|
||||
)
|
||||
if set(real.rooms) != set(theoretical.rooms):
|
||||
parts.append(f"salles: {set(theoretical.rooms)} → {set(real.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)
|
||||
|
||||
Reference in New Issue
Block a user