fix(pronote): préserver les cours déplacés

This commit is contained in:
2026-09-12 14:00:33 +02:00
parent e63000230b
commit d82bd8ffd8
6 changed files with 217 additions and 7 deletions
+4 -3
View File
@@ -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``.
+6 -4
View File
@@ -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
+79
View File
@@ -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