fix(pipeline) : sélectionner le prochain jour de cours #33

Merged
Codex merged 3 commits from fix/target-school-day-30 into fix/moved-lessons-27 2026-09-12 14:55:33 +02:00
4 changed files with 131 additions and 20 deletions
Showing only changes of commit 42bc7b8f4a - Show all commits
+2 -2
View File
@@ -140,7 +140,7 @@
"filename": "GUIDE_DEV_PYTHON.md", "filename": "GUIDE_DEV_PYTHON.md",
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
"is_verified": false, "is_verified": false,
"line_number": 5117 "line_number": 5125
} }
], ],
"tests/unit/test_caldav_gateway.py": [ "tests/unit/test_caldav_gateway.py": [
@@ -185,5 +185,5 @@
} }
] ]
}, },
"generated_at": "2026-09-12T11:33:28Z" "generated_at": "2026-09-12T12:04:07Z"
} }
+11 -3
View File
@@ -1532,6 +1532,14 @@ Sinon :
retourner J+1 (no-school, avec libellé de vacances si applicable) retourner J+1 (no-school, avec libellé de vacances si applicable)
``` ```
Dans l'implémentation Python actuelle (`pronote_sync.pipeline.steps.fetch.resolve_target_date`),
seuls les cours non annulés sont considérés comme effectifs ; un cours déplacé reste donc
éligible. La recherche du prochain cours connu ne dépend pas de la présence d'un cours à J.
Lorsqu'aucun cours futur n'est fourni et qu'un événement scolaire couvre J+1, sa borne
`to_date` (exclusive) est utilisée comme reprise connue. Sinon, J+1 est conservé comme
valeur de repli explicite. La fonction journalise la règle choisie avec la date et une raison
non personnelle.
**Exemple Python** : **Exemple Python** :
```python ```python
Optional, Tuple, List Optional, Tuple, List
@@ -2158,9 +2166,9 @@ def parse_ical(raw_ical: str) -> tuple[List[Lesson], List[HomeworkModel], List[S
- school_events : Liste des événements scolaires (vacances). - school_events : Liste des événements scolaires (vacances).
**Note importante** : **Note importante** :
La déduplication globale des devoirs est effectuée **après le parsing** de tous les VEVENT, La déduplication globale des devoirs est effectuée **après le parsing** de tous les VEVENT,
une fois que `target_date` est connu (via `resolve_target_day`). une fois que `target_date` est connu (via `resolve_target_day`).
Voir la section [5.1.4 Déduplication des devoirs](#514-déduplication-des-devoirs) pour plus de détails. Voir la section [5.1.4 Déduplication des devoirs](#514-déduplication-des-devoirs) pour plus de détails.
""" """
cal = Calendar.from_ical(raw_ical) cal = Calendar.from_ical(raw_ical)
+32 -15
View File
@@ -2,16 +2,19 @@
from __future__ import annotations from __future__ import annotations
import logging
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date
from pronote_sync.errors import PipelineCriticalError, PipelineWarning, PronoteAuthRotationError from pronote_sync.errors import PipelineCriticalError, PipelineWarning, PronoteAuthRotationError
from pronote_sync.models.agenda import Lesson, SchoolEvent from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent
from pronote_sync.models.homework import Homework from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message from pronote_sync.models.message import Message
from pronote_sync.sources.pronote.fallback import PronoteFetcherProtocol from pronote_sync.sources.pronote.fallback import PronoteFetcherProtocol
from pronote_sync.utils.redaction import redact_exception from pronote_sync.utils.redaction import redact_exception
logger = logging.getLogger(__name__)
@dataclass(frozen=True) @dataclass(frozen=True)
class FetchedPronoteData: class FetchedPronoteData:
@@ -36,27 +39,41 @@ def resolve_target_date(
) -> date: ) -> date:
"""Détermine la date cible du digest à partir de l'agenda disponible. """Détermine la date cible du digest à partir de l'agenda disponible.
La règle privilégie J+1 lorsqu'il contient des cours. Si la journée en La règle privilégie J+1 lorsqu'il contient un cours non annulé. Sinon,
cours contient des cours mais pas J+1, le prochain cours connu est choisi. elle choisit le prochain jour possédant un cours effectif dans la fenêtre.
Sans cours correspondant, J+1 est conservé, y compris pendant les vacances. Si aucune reprise n'est visible, la fin exclusive d'un événement scolaire
couvrant J+1 est utilisée ; à défaut, J+1 reste le meilleur choix connu.
:param today: Date de référence de l'exécution. :param today: Date de référence de l'exécution.
:param lessons: Cours récupérés pour la fenêtre de synchronisation. :param lessons: Cours récupérés pour la fenêtre de synchronisation.
:param school_events: Événements scolaires récupérés (réservés aux évolutions :param school_events: Événements scolaires récupérés, avec borne de fin exclusive.
du libellé de jour sans cours).
:return: Date cible du digest. :return: Date cible du digest.
:rtype: date :rtype: date
""" """
del school_events
tomorrow = date.fromordinal(today.toordinal() + 1) tomorrow = date.fromordinal(today.toordinal() + 1)
lesson_dates = {lesson.start.date() for lesson in lessons} effective_dates = {
if tomorrow in lesson_dates: lesson.start.date()
return tomorrow for lesson in lessons
if today in lesson_dates: if lesson.status != LessonStatus.CANCELLED and lesson.start.date() > today
future_dates = sorted(day for day in lesson_dates if day > today) }
if future_dates: if tomorrow in effective_dates:
return future_dates[0] reason = "cours effectif demain"
return tomorrow target = tomorrow
elif effective_dates:
reason = "prochain cours effectif connu"
target = min(effective_dates)
else:
event_ends = [
event.to_date for event in school_events if event.from_date <= tomorrow < event.to_date
]
if event_ends:
reason = "fin exclusive de l'événement couvrant demain"
target = max(event_ends)
else:
reason = "aucune reprise connue"
target = tomorrow
logger.info("Date cible %s : %s", target.isoformat(), reason)
return target
def _fetch_optional_messages( def _fetch_optional_messages(
+86
View File
@@ -0,0 +1,86 @@
"""Tests de sélection de la date cible du digest Pronote."""
from __future__ import annotations
from datetime import date, datetime
from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent, SchoolEventKind
from pronote_sync.pipeline.steps.fetch import resolve_target_date
def _lesson(day: date, status: LessonStatus = LessonStatus.NORMAL) -> Lesson:
"""Construit un cours minimal à une date donnée.
:param day: Date du cours.
:param status: Statut du cours.
:return: Cours de test.
:rtype: Lesson
"""
return Lesson(
id=day.isoformat(),
start=datetime.combine(day, datetime.min.time()),
end=datetime.combine(day, datetime.min.time().replace(hour=1)),
subject="Mathématiques",
group=None,
status=status,
content=None,
)
def _event(from_date: date, to_date: date) -> SchoolEvent:
"""Construit un événement scolaire à borne de fin exclusive.
:param from_date: Début inclusif.
:param to_date: Fin exclusive.
:return: Événement de vacances de test.
:rtype: SchoolEvent
"""
return SchoolEvent(
kind=SchoolEventKind.HOLIDAY,
label="Vacances anonymisées",
from_date=from_date,
to_date=to_date,
)
def test_target_date_prefers_tomorrow_effective_lesson() -> None:
"""Un cours effectif demain est prioritaire.
:return: None
"""
today = date(2026, 9, 11)
assert resolve_target_date(today, [_lesson(date(2026, 9, 12))], []) == date(2026, 9, 12)
def test_target_date_skips_empty_days_and_cancelled_lessons() -> None:
"""Le prochain cours effectif remplace un lendemain vide ou annulé.
:return: None
"""
today = date(2026, 9, 11)
lessons = [
_lesson(date(2026, 9, 12), LessonStatus.CANCELLED),
_lesson(date(2026, 9, 14)),
]
assert resolve_target_date(today, lessons, []) == date(2026, 9, 14)
def test_target_date_uses_exclusive_holiday_end_when_no_reprise_is_visible() -> None:
"""La fin exclusive d'une période couvrant demain devient la reprise connue.
:return: None
"""
today = date(2026, 10, 19)
holiday = _event(date(2026, 10, 20), date(2026, 11, 2))
assert resolve_target_date(today, [], [holiday]) == date(2026, 11, 2)
def test_target_date_falls_back_to_tomorrow_without_data() -> None:
"""Une liste vide valide sans événement conserve J+1 explicitement.
:return: None
"""
assert resolve_target_date(date(2026, 9, 11), [], []) == date(2026, 9, 12)