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/pronote_sync/sources/pronote/client.py b/pronote_sync/sources/pronote/client.py index 7f5c2ab..48670b1 100644 --- a/pronote_sync/sources/pronote/client.py +++ b/pronote_sync/sources/pronote/client.py @@ -25,10 +25,11 @@ import requests from pronote_sync.config.settings import PronoteSettings from pronote_sync.errors import PronoteAuthRotationError -from pronote_sync.models.agenda import Lesson, LessonStatus +from pronote_sync.models.agenda import Lesson from pronote_sync.models.homework import Homework from pronote_sync.models.message import Message, MessageType from pronote_sync.sources.pronote.auth_state import PronoteAuthState +from pronote_sync.sources.pronote.lessons import collapse_replaced_lessons, lesson_status from pronote_sync.utils.redaction import redact_exception, redact_secrets from pronote_sync.utils.uid import generate_deterministic_uid, normalize_pronote_uid @@ -573,12 +574,12 @@ class PronoteClient: teachers=tuple(lesson.teacher_names or ()), rooms=tuple(lesson.classrooms or ()), group=lesson.group_name, - status=(LessonStatus.CANCELLED if lesson.canceled else LessonStatus.NORMAL), + status=lesson_status(getattr(lesson, "status", None), lesson.canceled), content=content.description if content is not None else None, ) ) self._persist_credentials() - return lessons + return collapse_replaced_lessons(lessons) def get_homeworks(self, start: date, end: date) -> list[Homework]: """Récupère les devoirs via ``pronotepy``. diff --git a/pronote_sync/sources/pronote/ical.py b/pronote_sync/sources/pronote/ical.py index 0bf16e7..f03183f 100644 --- a/pronote_sync/sources/pronote/ical.py +++ b/pronote_sync/sources/pronote/ical.py @@ -35,6 +35,7 @@ from ...models.agenda import ( from ...models.homework import Homework from ...utils.redaction import redact_exception, redact_url from ...utils.uid import generate_deterministic_uid, normalize_pronote_uid +from .lessons import collapse_replaced_lessons _HEADER_LABEL_PATTERN = re.compile( r"(?P