fix(ical): fiabiliser le parsing Pronote 2026

Revue Codex validée. Corrections de parsing iCal, conservation du contexte des devoirs et fixtures anonymisées.

Co-authored-by: Codex <codex@antoineve.me>
Co-committed-by: Codex <codex@antoineve.me>
This commit was merged in pull request #31.
This commit is contained in:
2026-09-12 14:54:55 +02:00
committed by Codex
parent f194ed985d
commit d45d38d365
9 changed files with 399 additions and 79 deletions
+2 -2
View File
@@ -140,7 +140,7 @@
"filename": "GUIDE_DEV_PYTHON.md",
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
"is_verified": false,
"line_number": 5103
"line_number": 5117
}
],
"tests/unit/test_caldav_gateway.py": [
@@ -185,5 +185,5 @@
}
]
},
"generated_at": "2026-09-11T14:59:15Z"
"generated_at": "2026-09-12T11:33:28Z"
}
+14
View File
@@ -1893,6 +1893,20 @@ def generate_deterministic_uid(
#### 5.1.6 Parsing complet du flux iCal (`sources/pronote/ical.py`)
Le parseur de production accepte les variantes contrôlées des libellés et des
balises `strong` générées par Pronote. Il distingue les catégories `Congés` /
`Vacances` (`HOLIDAY`) des catégories `Jour(s) férié(s)` (`PUBLIC_HOLIDAY`),
conserve la borne `DTEND` comme date exclusive et reconnaît les statuts
annulé, déplacé ou modifié. Les sections de devoirs conservent un HTML
nettoyé (scripts, styles et attributs exécutables supprimés) ainsi qu'un texte
lisible. La déduplication utilise la date, la matière, les enseignants et le
texte normalisé : une copie du même devoir est fusionnée, tandis qu'un devoir
homonyme d'une autre matière ou d'un autre enseignant est conservé.
Le champ `Partie(s) de classe` est conservé dans `Lesson.class_part` et dans
la description iCalendar sérialisée. Les catégories ou sections inconnues ne
doivent pas être interprétées comme des vacances, une annulation ou un devoir.
```python
List, Optional, Tuple
from datetime import datetime, date
+16
View File
@@ -0,0 +1,16 @@
# Corpus de fixtures Pronote
Le corpus versionné de `tests/fixtures/` est limité à des exemples iCalendar
minimaux et anonymisés. Il est rejouable sans connexion Pronote et sert à
vérifier les modèles métier finaux, pas seulement l'absence d'exception.
Chaque fichier `*.ics` de variante possède un manifeste JSON homonyme. Le
manifeste indique la source, la version connue (ou `unknown`), les scénarios
couverts et les invariants à préserver. Les réponses API Pronote ne sont pas
stockées tant qu'elles ne peuvent pas être réduites sans conserver de données
personnelles ou de secret.
Avant d'ajouter un export : supprimer les URL, jetons, identifiants, noms,
établissement et contenu personnel ; réduire le document aux propriétés
nécessaires ; exécuter `scripts/check_secrets.py` ; puis ajouter un test qui
vérifie les modèles `Lesson`, `Homework` et `SchoolEvent` obtenus.
+1
View File
@@ -52,6 +52,7 @@ class Lesson(BaseModel):
teachers: tuple[str, ...] = Field(default=(), description="Liste des professeurs")
rooms: tuple[str, ...] = Field(default=(), description="Liste des salles")
group: str | None = Field(None, description="Groupe (ex: Classe entière)")
class_part: str | None = None
status: LessonStatus = Field(default=LessonStatus.NORMAL, description="Statut du cours")
content: str | None = Field(None, description="Contenu pédagogique")
homework_blocks: tuple[HomeworkBlock, ...] = Field(
+174 -71
View File
@@ -14,13 +14,15 @@ from __future__ import annotations
import hashlib
import re
import unicodedata
import urllib.parse
from datetime import date, datetime
from html import unescape
from pathlib import Path
from typing import TypedDict
from typing import NamedTuple, TypedDict
import requests
from bs4 import BeautifulSoup
from icalendar import Calendar
from ...models.agenda import (
@@ -34,21 +36,13 @@ from ...models.homework import Homework
from ...utils.redaction import redact_exception, redact_url
from ...utils.uid import generate_deterministic_uid, normalize_pronote_uid
_HEADER_LABEL_PATTERN = re.compile(r"\b(Matière|Professeurs?|Salles?|Groupe)\s*:\s*")
_HEADER_LABEL_PATTERN = re.compile(
r"(?P<label>Mati(?:ère|ere)|Professeur(?:s|\(s\))?|Salle(?:s|\(s\))?"
r"|Groupe|Partie(?:s|\(s\))?\s+de\s+classe)\s*:\s*",
re.IGNORECASE,
)
_CALNAME_PATTERN = re.compile(r"^X-WR-CALNAME(?:;[^:]*)?:([^\r\n]*)", re.MULTILINE)
_TAG_PATTERN = re.compile(r"<[^>]+>")
_CONTENT_PATTERN = re.compile(
r"<strong>Contenu pédagogique\s*:\s*</strong>(.*?)(?=<strong>|</div>\s*$|\Z)",
re.DOTALL,
)
_DUE_PATTERN = re.compile(
r"<strong>Pour le (\d{2}/\d{2}/\d{4})\s*:\s*</strong>(.*?)(?=<strong>|</div>\s*$|\Z)",
re.DOTALL,
)
_ASSIGNED_PATTERN = re.compile(
r"<strong>Donné le (\d{2}/\d{2}/\d{4})\s*:\s*</strong>(.*?)(?=<strong>|</div>\s*$|\Z)",
re.DOTALL,
)
_STRONG_PATTERN = re.compile(r"<strong\b[^>]*>(?P<label>.*?)</strong>", re.IGNORECASE | re.DOTALL)
_HEADERS = {
"accept": "text/calendar",
@@ -63,6 +57,15 @@ class HeaderInfo(TypedDict):
teachers: list[str]
rooms: list[str]
group: str | None
class_part: str | None
class ParsedHomeworkBlock(NamedTuple):
"""Bloc de devoir parsé avec son texte nettoyé et son HTML sûr."""
date: date
text: str
html: str
def fetch_ical(url: str, timeout: int = 20) -> str:
@@ -156,11 +159,11 @@ def split_header_and_body(description: str) -> tuple[str, str]:
:return: Tuple ``(en-tête, corps)`` ; le corps est vide si aucun ``<strong>``.
:rtype: tuple[str, str]
"""
strong_start = description.find("<strong>")
if strong_start == -1:
strong_match = _STRONG_PATTERN.search(description)
if strong_match is None:
return description.strip(), ""
header = description[:strong_start].strip()
body = description[strong_start:]
header = description[: strong_match.start()].strip()
body = description[strong_match.start() :]
return header, body
@@ -168,7 +171,7 @@ def parse_header(header: str) -> HeaderInfo:
"""Parse l'en-tête texte pour extraire les métadonnées du cours.
Les labels reconnus sont : ``Matière :``, ``Professeur(s) :``,
``Salle(s) :`` et ``Groupe :``. La recherche se fait par position
``Salle(s) :``, ``Groupe :`` et ``Partie(s) de classe :``. La recherche se fait par position
des labels, ce qui supporte aussi bien un en-tête multi-lignes
qu'un en-tête dont les lignes sont jointes sur une seule ligne.
@@ -176,14 +179,20 @@ def parse_header(header: str) -> HeaderInfo:
:return: Dictionnaire typé avec les champs subject, teachers, rooms, group.
:rtype: HeaderInfo
"""
info: HeaderInfo = {"subject": "", "teachers": [], "rooms": [], "group": None}
info: HeaderInfo = {
"subject": "",
"teachers": [],
"rooms": [],
"group": None,
"class_part": None,
}
matches = list(_HEADER_LABEL_PATTERN.finditer(header))
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())
label = match.group(1).lower()
if label == "matière":
label = _normalize_label(match.group("label")).replace("(s)", "s")
if label == "matiere":
info["subject"] = value
elif label in ("professeur", "professeurs"):
info["teachers"] = [part.strip() for part in value.split(",") if part.strip()]
@@ -191,9 +200,44 @@ def parse_header(header: str) -> HeaderInfo:
info["rooms"] = [part.strip() for part in value.split(",") if part.strip()]
elif label == "groupe":
info["group"] = value
elif label in ("partie de classe", "parties de classe"):
info["class_part"] = value
return info
def _normalize_label(value: str) -> str:
"""Normalise un libellé iCal pour comparer des variantes contrôlées.
:param value: Libellé à normaliser.
:return: Libellé minuscule sans accents et avec des espaces unifiés.
:rtype: str
"""
decomposed = unicodedata.normalize("NFKD", value)
without_accents = "".join(char for char in decomposed if not unicodedata.combining(char))
return re.sub(r"\s+", " ", without_accents).strip().lower()
def _sanitize_html(fragment: str) -> str:
"""Nettoie un fragment HTML de description sans exécuter de contenu.
:param fragment: Fragment HTML extrait d'une section de devoir.
:return: HTML conservé sans scripts, styles ni attributs exécutables.
:rtype: str
"""
soup = BeautifulSoup(fragment, "html.parser")
for tag in soup.find_all(("script", "style")):
tag.decompose()
for tag in soup.find_all(True):
for attribute in list(tag.attrs):
lowered = attribute.lower()
value = tag.attrs[attribute]
if lowered.startswith("on") or (
lowered in ("href", "src") and str(value).lower().strip().startswith("javascript:")
):
del tag.attrs[attribute]
return soup.decode_contents().strip()
def _strip_html(text: str) -> str:
"""Retire les balises HTML d'un texte et nettoie les espaces.
@@ -201,8 +245,10 @@ def _strip_html(text: str) -> str:
:return: Texte brut sans balises, entités HTML décodées.
:rtype: str
"""
cleaned = _TAG_PATTERN.sub("", text)
return unescape(cleaned).strip()
soup = BeautifulSoup(text, "html.parser")
for tag in soup.find_all(("script", "style")):
tag.decompose()
return " ".join(unescape(soup.get_text(" ", strip=True)).split())
def _parse_french_date(value: str) -> date | None:
@@ -218,44 +264,57 @@ def _parse_french_date(value: str) -> date | None:
return None
def parse_body(body: str) -> tuple[str | None, list[tuple[date, str]], list[tuple[date, str]]]:
def parse_body(
body: str,
) -> tuple[str | None, list[ParsedHomeworkBlock], list[ParsedHomeworkBlock]]:
"""Parse le corps HTML pour extraire contenu pédagogique et devoirs.
Le contenu est extrait de la section ``<strong>Contenu pédagogique :</strong>``.
Les devoirs à faire sont extraits des sections ``<strong>Pour le JJ/MM/AAAA :</strong>``
(liste de tuples ``(date, texte)`` dans l'ordre du flux) et les devoirs donnés
des sections ``<strong>Donné le JJ/MM/AAAA :</strong>`` (liste de tuples
``(date, texte)``). Les listes préservent tous les blocs, même lorsque plusieurs
sections partagent la même date.
et les devoirs donnés des sections ``<strong>Donné le JJ/MM/AAAA :</strong>``.
Les listes préservent tous les blocs, même lorsque plusieurs sections partagent
la même date, avec le texte nettoyé et le HTML sûr de chaque bloc.
:param body: Corps HTML (à partir du premier ``<strong>``).
:return: Tuple ``(contenu pédagogique, devoirs dus, devoirs donnés)``.
:rtype: tuple[str | None, list[tuple[date, str]], list[tuple[date, str]]]
:rtype: tuple[str | None, list[ParsedHomeworkBlock], list[ParsedHomeworkBlock]]
"""
content: str | None = None
due_blocks: list[tuple[date, str]] = []
assigned_blocks: list[tuple[date, str]] = []
due_blocks: list[ParsedHomeworkBlock] = []
assigned_blocks: list[ParsedHomeworkBlock] = []
content_match = _CONTENT_PATTERN.search(body)
if content_match is not None:
content = _strip_html(content_match.group(1))
matches = list(_STRONG_PATTERN.finditer(body))
for index, match in enumerate(matches):
next_start = matches[index + 1].start() if index + 1 < len(matches) else len(body)
heading = _strip_html(match.group("label")).rstrip(":").strip()
fragment = body[match.end() : next_start]
safe_html = _sanitize_html(fragment)
text = _strip_html(fragment)
for match in _DUE_PATTERN.finditer(body):
due_date = _parse_french_date(match.group(1))
normalized_heading = _normalize_label(heading)
if normalized_heading == "contenu pedagogique":
content = text
continue
due_match = re.fullmatch(r"Pour\s+le\s+(\d{2}/\d{2}/\d{4})", heading, re.IGNORECASE)
assigned_match = re.fullmatch(
r"Donne\s+le\s+(\d{2}/\d{2}/\d{4})", normalized_heading, re.IGNORECASE
)
if due_match is not None:
due_date = _parse_french_date(due_match.group(1))
if due_date is not None:
due_blocks.append((due_date, _strip_html(match.group(2))))
for match in _ASSIGNED_PATTERN.finditer(body):
assigned_date = _parse_french_date(match.group(1))
due_blocks.append(ParsedHomeworkBlock(due_date, text, safe_html))
elif assigned_match is not None:
assigned_date = _parse_french_date(assigned_match.group(1))
if assigned_date is not None:
assigned_blocks.append((assigned_date, _strip_html(match.group(2))))
assigned_blocks.append(ParsedHomeworkBlock(assigned_date, text, safe_html))
return content, due_blocks, assigned_blocks
def parse_homework_blocks(
due_blocks: list[tuple[date, str]],
assigned_blocks: list[tuple[date, str]],
due_blocks: list[ParsedHomeworkBlock],
assigned_blocks: list[ParsedHomeworkBlock],
) -> tuple[HomeworkBlock, ...]:
"""Construit les :class:`HomeworkBlock` depuis les listes de devoirs.
@@ -269,10 +328,12 @@ def parse_homework_blocks(
:rtype: tuple[HomeworkBlock, ...]
"""
blocks: list[HomeworkBlock] = []
for due_date, text in due_blocks:
blocks.append(HomeworkBlock(kind="due", date=due_date, text=text, html=text))
for assigned_date, text in assigned_blocks:
blocks.append(HomeworkBlock(kind="assigned", date=assigned_date, text=text, html=text))
for block in due_blocks:
blocks.append(HomeworkBlock(kind="due", date=block.date, text=block.text, html=block.html))
for block in assigned_blocks:
blocks.append(
HomeworkBlock(kind="assigned", date=block.date, text=block.text, html=block.html)
)
return tuple(blocks)
@@ -286,24 +347,32 @@ def normalize_homework_text(text: str) -> str:
:return: Texte normalisé.
:rtype: str
"""
normalized = re.sub(r"\s+", " ", text)
normalized = _TAG_PATTERN.sub("", normalized)
return normalized.strip().lower()
return _strip_html(text).casefold()
def generate_homework_id(due_on: date, normalized_text: str) -> str:
def generate_homework_id(
due_on: date,
normalized_text: str,
subject: str = "",
teachers: tuple[str, ...] = (),
) -> str:
"""Génère un ID stable pour un devoir.
L'ID est la clé ``AAAA-MM-JJ|texte_normalisé`` hachée en SHA-1 dont
L'ID est la clé ``AAAA-MM-JJ|matière|enseignants|texte_normalisé`` hachée en SHA-1 dont
on garde les 12 premiers caractères hexadécimaux. Le hachage n'est
pas utilisé à des fins de sécurité (``usedforsecurity=False``).
:param due_on: Date d'échéance du devoir.
:param normalized_text: Texte normalisé du devoir.
:param subject: Matière du devoir, utile pour distinguer les homonymes.
:param teachers: Enseignants du devoir, triés pour garantir la stabilité.
:return: ID stable (12 caractères hexadécimaux).
:rtype: str
"""
payload = f"{due_on.isoformat()}|{normalized_text}".encode()
payload = (
f"{due_on.isoformat()}|{subject.casefold()}|"
f"{','.join(sorted(teacher.casefold() for teacher in teachers))}|{normalized_text}".encode()
)
return hashlib.sha1(payload, usedforsecurity=False).hexdigest()[:12]
@@ -313,7 +382,7 @@ def collect_homeworks(lessons: list[Lesson], target_date: date) -> list[Homework
Passe 1 : les blocs ``due`` (devoirs à faire pour ``target_date``)
de tous les cours. Passe 2 : les blocs ``assigned`` (devoirs donnés
le jour cible) des cours du jour ``target_date``. La déduplication
se fait par texte normalisé (premier venu, premier servi) et le
se fait par texte, matière et enseignants normalisés (premier venu, premier servi) et le
résultat est trié par matière puis texte.
:param lessons: Liste de tous les cours (VEVENT) parsés.
@@ -321,15 +390,22 @@ def collect_homeworks(lessons: list[Lesson], target_date: date) -> list[Homework
:return: Liste unique de devoirs, triée par matière puis texte.
:rtype: list[Homework]
"""
by_text: dict[str, Homework] = {}
by_context: dict[tuple[str, tuple[str, ...], str], Homework] = {}
for lesson in lessons:
for block in lesson.homework_blocks:
if block.kind == "due" and block.date == target_date:
key = normalize_homework_text(block.text)
if key not in by_text:
by_text[key] = Homework(
id=generate_homework_id(target_date, key),
normalized_text = normalize_homework_text(block.text)
key = (
lesson.subject.casefold(),
tuple(sorted(teacher.casefold() for teacher in lesson.teachers)),
normalized_text,
)
if key not in by_context:
by_context[key] = Homework(
id=generate_homework_id(
target_date, normalized_text, lesson.subject, lesson.teachers
),
subject=lesson.subject,
teachers=lesson.teachers,
assigned_on=lesson.start.date(),
@@ -343,10 +419,17 @@ def collect_homeworks(lessons: list[Lesson], target_date: date) -> list[Homework
continue
for block in lesson.homework_blocks:
if block.kind == "assigned":
key = normalize_homework_text(block.text)
if key not in by_text:
by_text[key] = Homework(
id=generate_homework_id(target_date, key),
normalized_text = normalize_homework_text(block.text)
key = (
lesson.subject.casefold(),
tuple(sorted(teacher.casefold() for teacher in lesson.teachers)),
normalized_text,
)
if key not in by_context:
by_context[key] = Homework(
id=generate_homework_id(
target_date, normalized_text, lesson.subject, lesson.teachers
),
subject=lesson.subject,
teachers=lesson.teachers,
assigned_on=block.date,
@@ -355,7 +438,7 @@ def collect_homeworks(lessons: list[Lesson], target_date: date) -> list[Homework
html=block.html,
)
return sorted(by_text.values(), key=lambda hw: (hw.subject.lower(), hw.text.lower()))
return sorted(by_context.values(), key=lambda hw: (hw.subject.lower(), hw.text.lower()))
def parse_ical(raw_ical: str) -> tuple[list[Lesson], list[Homework], list[SchoolEvent]]:
@@ -399,15 +482,28 @@ def parse_ical(raw_ical: str) -> tuple[list[Lesson], list[Homework], list[School
else:
categories = [str(category) for category in categories_obj.cats]
# Événements de type vacances/congés (tout le jour).
if any(cat in ("Congés", "Vacances") for cat in categories):
normalized_categories = [_normalize_label(category) for category in categories]
holiday_kind: SchoolEventKind | None = None
if any(
category in ("conges", "vacances", "vacances scolaires")
for category in normalized_categories
):
holiday_kind = SchoolEventKind.HOLIDAY
elif any(
category in ("jour ferie", "jours feries", "ferie", "feries")
for category in normalized_categories
):
holiday_kind = SchoolEventKind.PUBLIC_HOLIDAY
# Événements de type vacances/congés/jours fériés (tout le jour).
if holiday_kind is not None:
from_date = start.date() if isinstance(start, datetime) else start
to_date = end.date() if isinstance(end, datetime) else end
summary = component.get("summary")
label = str(summary) if summary is not None else ""
school_events.append(
SchoolEvent(
kind=SchoolEventKind.HOLIDAY,
kind=holiday_kind,
label=label,
from_date=from_date,
to_date=to_date,
@@ -422,9 +518,15 @@ def parse_ical(raw_ical: str) -> tuple[list[Lesson], list[Homework], list[School
status_obj = component.get("status")
status_value = str(status_obj).strip().upper() if status_obj is not None else ""
if status_value == "CANCELLED" or "Cours - Cours annulé" in categories:
normalized_status_categories = set(normalized_categories)
if status_value == "CANCELLED" or any(
"annul" in category for category in normalized_status_categories
):
status = LessonStatus.CANCELLED
elif "Cours - Cours déplacé" in categories:
elif any(
any(token in category for token in ("deplac", "changement de salle", "modifi"))
for category in normalized_status_categories
):
status = LessonStatus.MOVED
else:
status = LessonStatus.NORMAL
@@ -458,6 +560,7 @@ def parse_ical(raw_ical: str) -> tuple[list[Lesson], list[Homework], list[School
teachers=tuple(lesson_data["teachers"]),
rooms=tuple(lesson_data["rooms"]),
group=lesson_data["group"],
class_part=lesson_data["class_part"],
status=status,
content=content,
homework_blocks=homework_blocks,
+4
View File
@@ -61,6 +61,10 @@ def lesson_to_vevent(lesson: Lesson) -> Event:
parts.append(f"Professeur(s): {', '.join(lesson.teachers)}")
if lesson.rooms:
parts.append(f"Salle(s): {', '.join(lesson.rooms)}")
if lesson.group:
parts.append(f"Groupe: {lesson.group}")
if lesson.class_part:
parts.append(f"Partie(s) de classe: {lesson.class_part}")
if lesson.content:
parts.append(f"Contenu: {lesson.content}")
event.add("description", "\n".join(parts))
+53
View File
@@ -0,0 +1,53 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//pronote-sync tests//FR
X-WR-CALNAME:Fixture anonymisée
BEGIN:VEVENT
UID:variant-normal
DTSTART:20260910T080000Z
DTEND:20260910T090000Z
SUMMARY:Mathématiques
CATEGORIES:Cours
DESCRIPTION:<div>Matière : Mathématiques Professeur(s) : Enseignant A Salle(s) : 101 Groupe : Classe entière Partie(s) de classe : Groupe 1 <strong data-section="content">Contenu pédagogique :</strong><p>Leçon &amp; exemple</p><strong>Pour le 15/09/2026 :</strong><p>Lire le chapitre</p><strong>Donné le 10/09/2026 :</strong><p>Lire le chapitre</p></div>
END:VEVENT
BEGIN:VEVENT
UID:variant-duplicate
DTSTART:20260910T100000Z
DTEND:20260910T110000Z
SUMMARY:Mathématiques
CATEGORIES:Cours
DESCRIPTION:<div>Matière : Mathématiques Professeur(s) : Enseignant A Salle(s) : 102 <strong>Pour le 15/09/2026 :</strong><p>Lire le chapitre</p></div>
END:VEVENT
BEGIN:VEVENT
UID:variant-same-text-other-context
DTSTART:20260910T120000Z
DTEND:20260910T130000Z
SUMMARY:Sciences
CATEGORIES:Cours
DESCRIPTION:<div>Matière : Sciences Professeur(s) : Enseignant B Salle(s) : 103 <strong>Pour le 15/09/2026 :</strong><p>Lire le chapitre</p></div>
END:VEVENT
BEGIN:VEVENT
UID:variant-moved
DTSTART:20260911T080000Z
DTEND:20260911T090000Z
SUMMARY:Mathématiques
CATEGORIES:Cours - Cours modifié
DESCRIPTION:<div>Matière : Mathématiques Professeur : Enseignant A Salle : 204</div>
END:VEVENT
BEGIN:VEVENT
UID:variant-public-holiday
DTSTART;VALUE=DATE:20260914
DTEND;VALUE=DATE:20260915
SUMMARY:Journée fériée anonymisée
CATEGORIES:Jours fériés
DESCRIPTION:Journée sans cours.
END:VEVENT
BEGIN:VEVENT
UID:variant-holiday
DTSTART;VALUE=DATE:20261020
DTEND;VALUE=DATE:20261022
SUMMARY:Vacances anonymisées
CATEGORIES:Congés
DESCRIPTION:Période de vacances.
END:VEVENT
END:VCALENDAR
+19
View File
@@ -0,0 +1,19 @@
{
"version": "fixture-1",
"source": "ical",
"pronote_version": "2026-unknown",
"anonymized": true,
"scenarios": {
"normal_and_headers": "variant-normal",
"duplicate_homework_same_context": ["variant-normal", "variant-duplicate"],
"same_text_distinct_context": "variant-same-text-other-context",
"moved_lesson": "variant-moved",
"public_holiday": "variant-public-holiday",
"holiday_exclusive_end": "variant-holiday"
},
"invariants": [
"Aucune valeur d'authentification ou identité réelle n'est présente.",
"Les bornes de date d'un événement scolaire sont début inclusif et fin exclusive.",
"Les devoirs sont vérifiés après parsing dans les modèles métier finaux."
]
}
+115 -5
View File
@@ -294,8 +294,8 @@ def test_collect_homeworks_dedup() -> None:
id="lesson2",
start=datetime(2026, 9, 10, 10, 0),
end=datetime(2026, 9, 10, 11, 0),
subject="Physique",
teachers=("M. Martin",),
subject="Math",
teachers=("M. Dupont",),
rooms=("205",),
group=None,
status=LessonStatus.NORMAL,
@@ -343,8 +343,8 @@ def test_collect_homeworks_id_stability() -> None:
id="lesson2",
start=datetime(2026, 9, 10, 10, 0),
end=datetime(2026, 9, 10, 11, 0),
subject="Physique",
teachers=("M. Martin",),
subject="Math",
teachers=("M. Dupont",),
rooms=("205",),
group=None,
status=LessonStatus.NORMAL,
@@ -361,7 +361,117 @@ def test_collect_homeworks_id_stability() -> None:
homeworks = collect_homeworks([lesson1, lesson2], target_date=date(2026, 9, 10))
assert len(homeworks) == 1
assert homeworks[0].id == generate_homework_id(date(2026, 9, 10), "devoir commun")
assert homeworks[0].id == generate_homework_id(
date(2026, 9, 10), "devoir commun", "Math", ("M. Dupont",)
)
def test_collect_homeworks_keeps_distinct_subjects() -> None:
"""Deux matières différentes conservent deux devoirs homonymes.
:return: None
"""
lesson = Lesson(
id="lesson1",
start=datetime(2026, 9, 10, 8, 0),
end=datetime(2026, 9, 10, 9, 0),
subject="Math",
teachers=("M. Dupont",),
group=None,
content=None,
homework_blocks=(
HomeworkBlock(
kind="due",
date=date(2026, 9, 10),
text="Devoir commun",
html="<p>Devoir commun</p>",
),
),
)
other_lesson = lesson.model_copy(
update={"id": "lesson2", "subject": "Physique", "teachers": ("M. Martin",)}
)
homeworks = collect_homeworks([lesson, other_lesson], target_date=date(2026, 9, 10))
assert len(homeworks) == 2
assert {homework.subject for homework in homeworks} == {"Math", "Physique"}
def test_collect_homeworks_deduplicates_teacher_order_variants() -> None:
"""Deux ordres d'enseignants équivalents ne créent pas de doublon.
:return: None
"""
homework_block = HomeworkBlock(
kind="due",
date=date(2026, 9, 10),
text="Devoir commun",
html="<p>Devoir commun</p>",
)
lesson = Lesson(
id="lesson1",
start=datetime(2026, 9, 10, 8, 0),
end=datetime(2026, 9, 10, 9, 0),
subject="Math",
teachers=("M. Martin", "M. Dupont"),
group=None,
content=None,
homework_blocks=(homework_block,),
)
other_lesson = lesson.model_copy(
update={"id": "lesson2", "teachers": ("M. Dupont", "M. Martin")}
)
homeworks = collect_homeworks([lesson, other_lesson], target_date=date(2026, 9, 10))
assert len(homeworks) == 1
def test_parse_body_accepts_html_variants_and_sanitizes() -> None:
"""Les attributs HTML, les espaces et le contenu actif sont traités correctement.
:return: None
"""
body = (
'<strong class="label"> Contenu pédagogique : </strong><p>Leçon &amp; exemple</p>'
'<strong data-kind="homework"> Pour le 10/09/2026 : </strong>'
'<p onclick="evil()">Lire <em>le chapitre</em></p><script>alert(1)</script>'
"<strong> Donné le 05/09/2026 : </strong><p>Noter &amp; relire</p>"
)
content, due_blocks, assigned_blocks = parse_body(body)
assert content == "Leçon & exemple"
assert due_blocks[0].date == date(2026, 9, 10)
assert due_blocks[0].text == "Lire le chapitre"
assert "<em>le chapitre</em>" in due_blocks[0].html
assert "onclick" not in due_blocks[0].html
assert "script" not in due_blocks[0].html.lower()
assert assigned_blocks[0].date == date(2026, 9, 5)
def test_parse_anonymized_variants_fixture() -> None:
"""Vérifie le corpus iCal anonymisé sur les statuts et le contexte des devoirs.
:return: None
"""
fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-variants.ics"
lessons, _, school_events = parse_ical(fixture_path.read_text(encoding="utf-8"))
assert lessons[0].class_part == "Groupe 1"
assert lessons[0].homework_blocks[0].html == "<p>Lire le chapitre</p>"
assert any(lesson.status == LessonStatus.MOVED for lesson in lessons)
assert any(event.kind.value == "public_holiday" for event in school_events)
assert any(
event.from_date == date(2026, 10, 20) and event.to_date == date(2026, 10, 22)
for event in school_events
)
homeworks = collect_homeworks(lessons, target_date=date(2026, 9, 15))
assert len(homeworks) == 2
assert {homework.subject for homework in homeworks} == {"Mathématiques", "Sciences"}
def test_collect_homeworks_sorted() -> None: