fix(pronote) : préserver les cours déplacés et changements de salle #32

Merged
Codex merged 3 commits from fix/moved-lessons-27 into fix/ical-pronote-2026 2026-09-12 14:55:21 +02:00
6 changed files with 224 additions and 7 deletions
+4 -3
View File
@@ -25,10 +25,11 @@ import requests
from pronote_sync.config.settings import PronoteSettings from pronote_sync.config.settings import PronoteSettings
from pronote_sync.errors import PronoteAuthRotationError 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.homework import Homework
from pronote_sync.models.message import Message, MessageType from pronote_sync.models.message import Message, MessageType
from pronote_sync.sources.pronote.auth_state import PronoteAuthState 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.redaction import redact_exception, redact_secrets
from pronote_sync.utils.uid import generate_deterministic_uid, normalize_pronote_uid from pronote_sync.utils.uid import generate_deterministic_uid, normalize_pronote_uid
@@ -573,12 +574,12 @@ class PronoteClient:
teachers=tuple(lesson.teacher_names or ()), teachers=tuple(lesson.teacher_names or ()),
rooms=tuple(lesson.classrooms or ()), rooms=tuple(lesson.classrooms or ()),
group=lesson.group_name, 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, content=content.description if content is not None else None,
) )
) )
self._persist_credentials() self._persist_credentials()
return lessons return collapse_replaced_lessons(lessons)
def get_homeworks(self, start: date, end: date) -> list[Homework]: def get_homeworks(self, start: date, end: date) -> list[Homework]:
"""Récupère les devoirs via ``pronotepy``. """Récupère les devoirs via ``pronotepy``.
+6 -4
View File
@@ -35,6 +35,7 @@ from ...models.agenda import (
from ...models.homework import Homework from ...models.homework import Homework
from ...utils.redaction import redact_exception, redact_url from ...utils.redaction import redact_exception, redact_url
from ...utils.uid import generate_deterministic_uid, normalize_pronote_uid from ...utils.uid import generate_deterministic_uid, normalize_pronote_uid
from .lessons import collapse_replaced_lessons
_HEADER_LABEL_PATTERN = re.compile( _HEADER_LABEL_PATTERN = re.compile(
r"(?P<label>Mati(?:ère|ere)|Professeur(?:s|\(s\))?|Salle(?:s|\(s\))?" r"(?P<label>Mati(?:ère|ere)|Professeur(?:s|\(s\))?|Salle(?:s|\(s\))?"
@@ -186,11 +187,12 @@ def parse_header(header: str) -> HeaderInfo:
"group": None, "group": None,
"class_part": None, "class_part": None,
} }
matches = list(_HEADER_LABEL_PATTERN.finditer(header)) header_text = _strip_html(header)
matches = list(_HEADER_LABEL_PATTERN.finditer(header_text))
for index, match in enumerate(matches): for index, match in enumerate(matches):
value_start = match.end() value_start = match.end()
value_end = matches[index + 1].start() if index + 1 < len(matches) else len(header) value_end = matches[index + 1].start() if index + 1 < len(matches) else len(header_text)
value = unescape(header[value_start:value_end].strip()) value = unescape(header_text[value_start:value_end].strip())
label = _normalize_label(match.group("label")).replace("(s)", "s") label = _normalize_label(match.group("label")).replace("(s)", "s")
if label == "matiere": if label == "matiere":
info["subject"] = value info["subject"] = value
@@ -567,4 +569,4 @@ def parse_ical(raw_ical: str) -> tuple[list[Lesson], list[Homework], list[School
) )
) )
return lessons, homeworks, school_events return collapse_replaced_lessons(lessons), homeworks, school_events
+72
View File
@@ -0,0 +1,72 @@
"""Règles communes de statut et de consolidation des cours Pronote."""
from __future__ import annotations
import unicodedata
from collections import defaultdict
from ...models.agenda import Lesson, LessonStatus
def _normalize_status(value: str) -> str:
"""Normalise un statut Pronote pour une comparaison tolérante.
:param value: Statut brut fourni par Pronote.
:return: Statut minuscule sans accents.
:rtype: str
"""
decomposed = unicodedata.normalize("NFKD", value)
without_accents = "".join(char for char in decomposed if not unicodedata.combining(char))
return " ".join(without_accents.casefold().split())
def lesson_status(raw_status: object, canceled: bool = False) -> LessonStatus:
"""Convertit le statut Pronote en statut métier canonique.
L'indicateur d'annulation et les libellés annulé sont prioritaires sur un
éventuel libellé de déplacement. Les libellés inconnus restent normaux.
:param raw_status: Valeur du champ ``Lesson.status`` de pronotepy.
:param canceled: Indicateur ``Lesson.canceled`` de pronotepy.
:return: Statut métier canonique.
:rtype: LessonStatus
"""
status = _normalize_status(raw_status) if isinstance(raw_status, str) else ""
if canceled or "annul" in status:
return LessonStatus.CANCELLED
if any(token in status for token in ("deplac", "changement de salle", "modifi")):
return LessonStatus.MOVED
return LessonStatus.NORMAL
def collapse_replaced_lessons(lessons: list[Lesson]) -> list[Lesson]:
"""Supprime l'ancien exemplaire annulé lorsqu'un cours déplacé est présent.
Deux cours sont considérés comme les exemplaires d'une même modification
lorsqu'ils partagent les dates, la matière, les enseignants et le groupe,
et qu'une seule annulation et un seul déplacement se distinguent par leur
salle. Une annulation isolée ou ambiguë est toujours conservée.
:param lessons: Cours issus d'une source Pronote.
:return: Cours consolidés dans leur ordre d'origine.
:rtype: list[Lesson]
"""
grouped: dict[tuple[object, ...], list[tuple[int, Lesson]]] = defaultdict(list)
for index, lesson in enumerate(lessons):
key = (
lesson.start,
lesson.end,
lesson.subject.casefold(),
tuple(teacher.casefold() for teacher in lesson.teachers),
(lesson.group or "").casefold(),
)
grouped[key].append((index, lesson))
replaced_indexes: set[int] = set()
for siblings in grouped.values():
cancelled = [item for item in siblings if item[1].status == LessonStatus.CANCELLED]
moved = [item for item in siblings if item[1].status == LessonStatus.MOVED]
if len(cancelled) == 1 and len(moved) == 1 and cancelled[0][1].rooms != moved[0][1].rooms:
replaced_indexes.add(cancelled[0][0])
return [lesson for index, lesson in enumerate(lessons) if index not in replaced_indexes]
+33
View File
@@ -474,6 +474,39 @@ def test_parse_anonymized_variants_fixture() -> None:
assert {homework.subject for homework in homeworks} == {"Mathématiques", "Sciences"} assert {homework.subject for homework in homeworks} == {"Mathématiques", "Sciences"}
def test_parse_ical_removes_old_replaced_lesson() -> None:
"""Un ancien cours annulé est retiré lorsqu'un cours déplacé le remplace.
:return: None
"""
raw_ical = """BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
UID:old
DTSTART:20260911T080000Z
DTEND:20260911T090000Z
SUMMARY:Mathématiques
CATEGORIES:Cours - Cours annulé
STATUS:CANCELLED
DESCRIPTION:<div>Matière : Mathématiques Professeur : Enseignant A Salle : 101</div>
END:VEVENT
BEGIN:VEVENT
UID:new
DTSTART:20260911T080000Z
DTEND:20260911T090000Z
SUMMARY:Mathématiques
CATEGORIES:Cours - Changement de salle
DESCRIPTION:<div>Matière : Mathématiques Professeur : Enseignant A Salle : 204</div>
END:VEVENT
END:VCALENDAR"""
lessons, _, _ = parse_ical(raw_ical)
assert len(lessons) == 1
assert lessons[0].status == LessonStatus.MOVED
assert lessons[0].rooms == ("204",)
def test_collect_homeworks_sorted() -> None: def test_collect_homeworks_sorted() -> None:
"""Les résultats sont triés par (subject.lower(), text.lower()). """Les résultats sont triés par (subject.lower(), text.lower()).
+18
View File
@@ -233,6 +233,24 @@ def test_get_lessons_success(
assert lesson.content == "Lesson content" assert lesson.content == "Lesson content"
def test_get_lessons_maps_moved_status(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Mappe le statut ``Changement de salle`` de pronotepy vers ``MOVED``.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = _make_lessons_mock_client(mocker)
mock_client.lessons.return_value[0].status = "Changement de salle"
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
lessons = PronoteClient(pronote_settings).get_lessons(date(2024, 9, 1), date(2024, 9, 30))
assert lessons[0].status == LessonStatus.MOVED
def test_get_homeworks_success( def test_get_homeworks_success(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None: ) -> None:
+91
View File
@@ -0,0 +1,91 @@
"""Tests des règles communes de statut et de consolidation des cours."""
from __future__ import annotations
from datetime import datetime
import pytest
from pronote_sync.models.agenda import Lesson, LessonStatus
from pronote_sync.sources.pronote.lessons import collapse_replaced_lessons, lesson_status
def _lesson(status: LessonStatus, room: str) -> Lesson:
"""Construit un cours minimal pour les tests de consolidation.
:param status: Statut métier du cours.
:param room: Salle du cours.
:return: Cours de test.
:rtype: Lesson
"""
return Lesson(
id=f"lesson-{room}",
start=datetime(2026, 9, 11, 8, 0),
end=datetime(2026, 9, 11, 9, 0),
subject="Mathématiques",
teachers=("Enseignant A",),
rooms=(room,),
group="Classe entière",
status=status,
content=None,
)
@pytest.mark.parametrize(
("raw_status", "canceled", "expected"),
[
("Changement de salle", False, LessonStatus.MOVED),
("Cours déplacé", False, LessonStatus.MOVED),
("Cours annulé", False, LessonStatus.CANCELLED),
(None, True, LessonStatus.CANCELLED),
("Statut inconnu", False, LessonStatus.NORMAL),
],
)
def test_lesson_status_maps_pronotepy_variants(
raw_status: object, canceled: bool, expected: LessonStatus
) -> None:
"""Convertit les statuts Pronote connus sans interpréter les inconnus.
:param raw_status: Statut brut simulé.
:param canceled: Indicateur d'annulation simulé.
:param expected: Statut métier attendu.
:return: None
"""
assert lesson_status(raw_status, canceled) == expected
def test_collapse_replaced_lessons_keeps_effective_lesson() -> None:
"""Retire l'ancien exemplaire annulé d'un changement de salle.
:return: None
"""
cancelled = _lesson(LessonStatus.CANCELLED, "101")
moved = _lesson(LessonStatus.MOVED, "204")
result = collapse_replaced_lessons([cancelled, moved])
assert result == [moved]
def test_collapse_replaced_lessons_keeps_real_cancellation() -> None:
"""Conserve une annulation lorsqu'aucun cours déplacé ne la remplace.
:return: None
"""
cancelled = _lesson(LessonStatus.CANCELLED, "101")
assert collapse_replaced_lessons([cancelled]) == [cancelled]
def test_collapse_replaced_lessons_keeps_ambiguous_cancellations() -> None:
"""Conserve les annulations lorsqu'une paire de remplacement est ambiguë.
:return: None
"""
cancelled_old = _lesson(LessonStatus.CANCELLED, "101")
cancelled_real = _lesson(LessonStatus.CANCELLED, "305")
moved = _lesson(LessonStatus.MOVED, "204")
result = collapse_replaced_lessons([cancelled_old, cancelled_real, moved])
assert result == [cancelled_old, cancelled_real, moved]