chore(integration) : intégrer les PR Pronote critiques dans main #35
@@ -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``.
|
||||
|
||||
@@ -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<label>Mati(?:ère|ere)|Professeur(?:s|\(s\))?|Salle(?:s|\(s\))?"
|
||||
@@ -186,11 +187,12 @@ def parse_header(header: str) -> HeaderInfo:
|
||||
"group": 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):
|
||||
value_start = match.end()
|
||||
value_end = matches[index + 1].start() if index + 1 < len(matches) else len(header)
|
||||
value = unescape(header[value_start:value_end].strip())
|
||||
value_end = matches[index + 1].start() if index + 1 < len(matches) else len(header_text)
|
||||
value = unescape(header_text[value_start:value_end].strip())
|
||||
label = _normalize_label(match.group("label")).replace("(s)", "s")
|
||||
if label == "matiere":
|
||||
info["subject"] = value
|
||||
@@ -566,4 +568,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
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""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.
|
||||
Une annulation isolée est toujours conservée ; seule l'annulation associée
|
||||
à un exemplaire ``MOVED`` est retiré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[Lesson]] = defaultdict(list)
|
||||
for lesson in lessons:
|
||||
key = (
|
||||
lesson.start,
|
||||
lesson.end,
|
||||
lesson.subject.casefold(),
|
||||
tuple(teacher.casefold() for teacher in lesson.teachers),
|
||||
(lesson.group or "").casefold(),
|
||||
)
|
||||
grouped[key].append(lesson)
|
||||
|
||||
result: list[Lesson] = []
|
||||
for lesson in lessons:
|
||||
key = (
|
||||
lesson.start,
|
||||
lesson.end,
|
||||
lesson.subject.casefold(),
|
||||
tuple(teacher.casefold() for teacher in lesson.teachers),
|
||||
(lesson.group or "").casefold(),
|
||||
)
|
||||
siblings = grouped[key]
|
||||
has_moved = any(sibling.status == LessonStatus.MOVED for sibling in siblings)
|
||||
if has_moved and lesson.status == LessonStatus.CANCELLED:
|
||||
continue
|
||||
result.append(lesson)
|
||||
return result
|
||||
@@ -444,6 +444,39 @@ def test_parse_anonymized_variants_fixture() -> None:
|
||||
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:
|
||||
"""Les résultats sont triés par (subject.lower(), text.lower()).
|
||||
|
||||
|
||||
@@ -233,6 +233,24 @@ def test_get_lessons_success(
|
||||
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(
|
||||
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
||||
) -> None:
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""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]
|
||||
Reference in New Issue
Block a user