fix(pipeline): sélectionner le prochain jour de cours
Revue Codex validée. La date cible privilégie le prochain jour de cours effectif et documente le repli. Co-authored-by: Codex <codex@antoineve.me> Co-committed-by: Codex <codex@antoineve.me>
This commit was merged in pull request #33.
This commit is contained in:
+2
-2
@@ -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"
|
||||
}
|
||||
|
||||
+11
-3
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user