diff --git a/.secrets.baseline b/.secrets.baseline index dfc147d..0ed8438 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -140,7 +140,7 @@ "filename": "GUIDE_DEV_PYTHON.md", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "is_verified": false, - "line_number": 5117 + "line_number": 5125 } ], "tests/unit/test_caldav_gateway.py": [ @@ -185,5 +185,5 @@ } ] }, - "generated_at": "2026-09-12T11:33:28Z" + "generated_at": "2026-09-12T12:04:07Z" } diff --git a/GUIDE_DEV_PYTHON.md b/GUIDE_DEV_PYTHON.md index 5ffb434..96f2116 100644 --- a/GUIDE_DEV_PYTHON.md +++ b/GUIDE_DEV_PYTHON.md @@ -1532,6 +1532,14 @@ Sinon : 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** : ```python 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). **Note importante** : - 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`). - Voir la section [5.1.4 Déduplication des devoirs](#514-déduplication-des-devoirs) pour plus de détails. +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`). +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) diff --git a/pronote_sync/pipeline/steps/fetch.py b/pronote_sync/pipeline/steps/fetch.py index 8c0f270..b6d7eac 100644 --- a/pronote_sync/pipeline/steps/fetch.py +++ b/pronote_sync/pipeline/steps/fetch.py @@ -2,16 +2,19 @@ from __future__ import annotations +import logging from dataclasses import dataclass from datetime import date 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.message import Message from pronote_sync.sources.pronote.fallback import PronoteFetcherProtocol from pronote_sync.utils.redaction import redact_exception +logger = logging.getLogger(__name__) + @dataclass(frozen=True) class FetchedPronoteData: @@ -36,27 +39,41 @@ def resolve_target_date( ) -> date: """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 - cours contient des cours mais pas J+1, le prochain cours connu est choisi. - Sans cours correspondant, J+1 est conservé, y compris pendant les vacances. + La règle privilégie J+1 lorsqu'il contient un cours non annulé. Sinon, + elle choisit le prochain jour possédant un cours effectif dans la fenêtre. + 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 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 - du libellé de jour sans cours). + :param school_events: Événements scolaires récupérés, avec borne de fin exclusive. :return: Date cible du digest. :rtype: date """ - del school_events tomorrow = date.fromordinal(today.toordinal() + 1) - lesson_dates = {lesson.start.date() for lesson in lessons} - if tomorrow in lesson_dates: - return tomorrow - if today in lesson_dates: - future_dates = sorted(day for day in lesson_dates if day > today) - if future_dates: - return future_dates[0] - return tomorrow + effective_dates = { + lesson.start.date() + for lesson in lessons + if lesson.status != LessonStatus.CANCELLED and lesson.start.date() > today + } + if tomorrow in effective_dates: + reason = "cours effectif demain" + 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( diff --git a/tests/unit/test_fetch_target_date.py b/tests/unit/test_fetch_target_date.py new file mode 100644 index 0000000..5c8139c --- /dev/null +++ b/tests/unit/test_fetch_target_date.py @@ -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)