chore(integration) : intégrer les PR Pronote critiques dans main #35
+2
-2
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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