fix(ical): fiabiliser le parsing Pronote 2026

This commit is contained in:
2026-09-12 13:33:58 +02:00
parent f194ed985d
commit e63000230b
9 changed files with 368 additions and 79 deletions
+2 -2
View File
@@ -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": 5103 "line_number": 5117
} }
], ],
"tests/unit/test_caldav_gateway.py": [ "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`) #### 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 ```python
List, Optional, Tuple List, Optional, Tuple
from datetime import datetime, date 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") teachers: tuple[str, ...] = Field(default=(), description="Liste des professeurs")
rooms: tuple[str, ...] = Field(default=(), description="Liste des salles") rooms: tuple[str, ...] = Field(default=(), description="Liste des salles")
group: str | None = Field(None, description="Groupe (ex: Classe entière)") 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") status: LessonStatus = Field(default=LessonStatus.NORMAL, description="Statut du cours")
content: str | None = Field(None, description="Contenu pédagogique") content: str | None = Field(None, description="Contenu pédagogique")
homework_blocks: tuple[HomeworkBlock, ...] = Field( homework_blocks: tuple[HomeworkBlock, ...] = Field(
+173 -71
View File
@@ -14,13 +14,15 @@ from __future__ import annotations
import hashlib import hashlib
import re import re
import unicodedata
import urllib.parse import urllib.parse
from datetime import date, datetime from datetime import date, datetime
from html import unescape from html import unescape
from pathlib import Path from pathlib import Path
from typing import TypedDict from typing import NamedTuple, TypedDict
import requests import requests
from bs4 import BeautifulSoup
from icalendar import Calendar from icalendar import Calendar
from ...models.agenda import ( from ...models.agenda import (
@@ -34,21 +36,13 @@ 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
_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) _CALNAME_PATTERN = re.compile(r"^X-WR-CALNAME(?:;[^:]*)?:([^\r\n]*)", re.MULTILINE)
_TAG_PATTERN = re.compile(r"<[^>]+>") _STRONG_PATTERN = re.compile(r"<strong\b[^>]*>(?P<label>.*?)</strong>", re.IGNORECASE | re.DOTALL)
_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,
)
_HEADERS = { _HEADERS = {
"accept": "text/calendar", "accept": "text/calendar",
@@ -63,6 +57,15 @@ class HeaderInfo(TypedDict):
teachers: list[str] teachers: list[str]
rooms: list[str] rooms: list[str]
group: str | None 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: 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>``. :return: Tuple ``(en-tête, corps)`` ; le corps est vide si aucun ``<strong>``.
:rtype: tuple[str, str] :rtype: tuple[str, str]
""" """
strong_start = description.find("<strong>") strong_match = _STRONG_PATTERN.search(description)
if strong_start == -1: if strong_match is None:
return description.strip(), "" return description.strip(), ""
header = description[:strong_start].strip() header = description[: strong_match.start()].strip()
body = description[strong_start:] body = description[strong_match.start() :]
return header, body 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. """Parse l'en-tête texte pour extraire les métadonnées du cours.
Les labels reconnus sont : ``Matière :``, ``Professeur(s) :``, 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 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. 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. :return: Dictionnaire typé avec les champs subject, teachers, rooms, group.
:rtype: HeaderInfo :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)) matches = list(_HEADER_LABEL_PATTERN.finditer(header))
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)
value = unescape(header[value_start:value_end].strip()) value = unescape(header[value_start:value_end].strip())
label = match.group(1).lower() label = _normalize_label(match.group("label")).replace("(s)", "s")
if label == "matière": if label == "matiere":
info["subject"] = value info["subject"] = value
elif label in ("professeur", "professeurs"): elif label in ("professeur", "professeurs"):
info["teachers"] = [part.strip() for part in value.split(",") if part.strip()] 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()] info["rooms"] = [part.strip() for part in value.split(",") if part.strip()]
elif label == "groupe": elif label == "groupe":
info["group"] = value info["group"] = value
elif label in ("partie de classe", "parties de classe"):
info["class_part"] = value
return info 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: def _strip_html(text: str) -> str:
"""Retire les balises HTML d'un texte et nettoie les espaces. """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. :return: Texte brut sans balises, entités HTML décodées.
:rtype: str :rtype: str
""" """
cleaned = _TAG_PATTERN.sub("", text) soup = BeautifulSoup(text, "html.parser")
return unescape(cleaned).strip() 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: def _parse_french_date(value: str) -> date | None:
@@ -218,44 +264,57 @@ def _parse_french_date(value: str) -> date | None:
return 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. """Parse le corps HTML pour extraire contenu pédagogique et devoirs.
Le contenu est extrait de la section ``<strong>Contenu pédagogique :</strong>``. 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>`` 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 et les devoirs donnés des sections ``<strong>Donné le JJ/MM/AAAA :</strong>``.
des sections ``<strong>Donné le JJ/MM/AAAA :</strong>`` (liste de tuples Les listes préservent tous les blocs, même lorsque plusieurs sections partagent
``(date, texte)``). Les listes préservent tous les blocs, même lorsque plusieurs la même date, avec le texte nettoyé et le HTML sûr de chaque bloc.
sections partagent la même date.
:param body: Corps HTML (à partir du premier ``<strong>``). :param body: Corps HTML (à partir du premier ``<strong>``).
:return: Tuple ``(contenu pédagogique, devoirs dus, devoirs donnés)``. :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 content: str | None = None
due_blocks: list[tuple[date, str]] = [] due_blocks: list[ParsedHomeworkBlock] = []
assigned_blocks: list[tuple[date, str]] = [] assigned_blocks: list[ParsedHomeworkBlock] = []
content_match = _CONTENT_PATTERN.search(body) matches = list(_STRONG_PATTERN.finditer(body))
if content_match is not None: for index, match in enumerate(matches):
content = _strip_html(content_match.group(1)) 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): normalized_heading = _normalize_label(heading)
due_date = _parse_french_date(match.group(1)) 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: if due_date is not None:
due_blocks.append((due_date, _strip_html(match.group(2)))) due_blocks.append(ParsedHomeworkBlock(due_date, text, safe_html))
elif assigned_match is not None:
for match in _ASSIGNED_PATTERN.finditer(body): assigned_date = _parse_french_date(assigned_match.group(1))
assigned_date = _parse_french_date(match.group(1))
if assigned_date is not None: 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 return content, due_blocks, assigned_blocks
def parse_homework_blocks( def parse_homework_blocks(
due_blocks: list[tuple[date, str]], due_blocks: list[ParsedHomeworkBlock],
assigned_blocks: list[tuple[date, str]], assigned_blocks: list[ParsedHomeworkBlock],
) -> tuple[HomeworkBlock, ...]: ) -> tuple[HomeworkBlock, ...]:
"""Construit les :class:`HomeworkBlock` depuis les listes de devoirs. """Construit les :class:`HomeworkBlock` depuis les listes de devoirs.
@@ -269,10 +328,12 @@ def parse_homework_blocks(
:rtype: tuple[HomeworkBlock, ...] :rtype: tuple[HomeworkBlock, ...]
""" """
blocks: list[HomeworkBlock] = [] blocks: list[HomeworkBlock] = []
for due_date, text in due_blocks: for block in due_blocks:
blocks.append(HomeworkBlock(kind="due", date=due_date, text=text, html=text)) blocks.append(HomeworkBlock(kind="due", date=block.date, text=block.text, html=block.html))
for assigned_date, text in assigned_blocks: for block in assigned_blocks:
blocks.append(HomeworkBlock(kind="assigned", date=assigned_date, text=text, html=text)) blocks.append(
HomeworkBlock(kind="assigned", date=block.date, text=block.text, html=block.html)
)
return tuple(blocks) return tuple(blocks)
@@ -286,24 +347,31 @@ def normalize_homework_text(text: str) -> str:
:return: Texte normalisé. :return: Texte normalisé.
:rtype: str :rtype: str
""" """
normalized = re.sub(r"\s+", " ", text) return _strip_html(text).casefold()
normalized = _TAG_PATTERN.sub("", normalized)
return normalized.strip().lower()
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. """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 on garde les 12 premiers caractères hexadécimaux. Le hachage n'est
pas utilisé à des fins de sécurité (``usedforsecurity=False``). pas utilisé à des fins de sécurité (``usedforsecurity=False``).
:param due_on: Date d'échéance du devoir. :param due_on: Date d'échéance du devoir.
:param normalized_text: Texte normalisé 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). :return: ID stable (12 caractères hexadécimaux).
:rtype: str :rtype: str
""" """
payload = f"{due_on.isoformat()}|{normalized_text}".encode() payload = (
f"{due_on.isoformat()}|{subject}|{','.join(sorted(teachers))}|{normalized_text}".encode()
)
return hashlib.sha1(payload, usedforsecurity=False).hexdigest()[:12] return hashlib.sha1(payload, usedforsecurity=False).hexdigest()[:12]
@@ -313,7 +381,7 @@ def collect_homeworks(lessons: list[Lesson], target_date: date) -> list[Homework
Passe 1 : les blocs ``due`` (devoirs à faire pour ``target_date``) Passe 1 : les blocs ``due`` (devoirs à faire pour ``target_date``)
de tous les cours. Passe 2 : les blocs ``assigned`` (devoirs donnés de tous les cours. Passe 2 : les blocs ``assigned`` (devoirs donnés
le jour cible) des cours du jour ``target_date``. La déduplication 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. résultat est trié par matière puis texte.
:param lessons: Liste de tous les cours (VEVENT) parsés. :param lessons: Liste de tous les cours (VEVENT) parsés.
@@ -321,15 +389,22 @@ def collect_homeworks(lessons: list[Lesson], target_date: date) -> list[Homework
:return: Liste unique de devoirs, triée par matière puis texte. :return: Liste unique de devoirs, triée par matière puis texte.
:rtype: list[Homework] :rtype: list[Homework]
""" """
by_text: dict[str, Homework] = {} by_context: dict[tuple[str, tuple[str, ...], str], Homework] = {}
for lesson in lessons: for lesson in lessons:
for block in lesson.homework_blocks: for block in lesson.homework_blocks:
if block.kind == "due" and block.date == target_date: if block.kind == "due" and block.date == target_date:
key = normalize_homework_text(block.text) normalized_text = normalize_homework_text(block.text)
if key not in by_text: key = (
by_text[key] = Homework( lesson.subject.casefold(),
id=generate_homework_id(target_date, key), tuple(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, subject=lesson.subject,
teachers=lesson.teachers, teachers=lesson.teachers,
assigned_on=lesson.start.date(), assigned_on=lesson.start.date(),
@@ -343,10 +418,17 @@ def collect_homeworks(lessons: list[Lesson], target_date: date) -> list[Homework
continue continue
for block in lesson.homework_blocks: for block in lesson.homework_blocks:
if block.kind == "assigned": if block.kind == "assigned":
key = normalize_homework_text(block.text) normalized_text = normalize_homework_text(block.text)
if key not in by_text: key = (
by_text[key] = Homework( lesson.subject.casefold(),
id=generate_homework_id(target_date, key), tuple(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, subject=lesson.subject,
teachers=lesson.teachers, teachers=lesson.teachers,
assigned_on=block.date, assigned_on=block.date,
@@ -355,7 +437,7 @@ def collect_homeworks(lessons: list[Lesson], target_date: date) -> list[Homework
html=block.html, 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]]: def parse_ical(raw_ical: str) -> tuple[list[Lesson], list[Homework], list[SchoolEvent]]:
@@ -399,15 +481,28 @@ def parse_ical(raw_ical: str) -> tuple[list[Lesson], list[Homework], list[School
else: else:
categories = [str(category) for category in categories_obj.cats] categories = [str(category) for category in categories_obj.cats]
# Événements de type vacances/congés (tout le jour). normalized_categories = [_normalize_label(category) for category in categories]
if any(cat in ("Congés", "Vacances") for cat 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 from_date = start.date() if isinstance(start, datetime) else start
to_date = end.date() if isinstance(end, datetime) else end to_date = end.date() if isinstance(end, datetime) else end
summary = component.get("summary") summary = component.get("summary")
label = str(summary) if summary is not None else "" label = str(summary) if summary is not None else ""
school_events.append( school_events.append(
SchoolEvent( SchoolEvent(
kind=SchoolEventKind.HOLIDAY, kind=holiday_kind,
label=label, label=label,
from_date=from_date, from_date=from_date,
to_date=to_date, to_date=to_date,
@@ -422,9 +517,15 @@ def parse_ical(raw_ical: str) -> tuple[list[Lesson], list[Homework], list[School
status_obj = component.get("status") status_obj = component.get("status")
status_value = str(status_obj).strip().upper() if status_obj is not None else "" 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 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 status = LessonStatus.MOVED
else: else:
status = LessonStatus.NORMAL status = LessonStatus.NORMAL
@@ -458,6 +559,7 @@ def parse_ical(raw_ical: str) -> tuple[list[Lesson], list[Homework], list[School
teachers=tuple(lesson_data["teachers"]), teachers=tuple(lesson_data["teachers"]),
rooms=tuple(lesson_data["rooms"]), rooms=tuple(lesson_data["rooms"]),
group=lesson_data["group"], group=lesson_data["group"],
class_part=lesson_data["class_part"],
status=status, status=status,
content=content, content=content,
homework_blocks=homework_blocks, 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)}") parts.append(f"Professeur(s): {', '.join(lesson.teachers)}")
if lesson.rooms: if lesson.rooms:
parts.append(f"Salle(s): {', '.join(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: if lesson.content:
parts.append(f"Contenu: {lesson.content}") parts.append(f"Contenu: {lesson.content}")
event.add("description", "\n".join(parts)) 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."
]
}
+85 -5
View File
@@ -294,8 +294,8 @@ def test_collect_homeworks_dedup() -> None:
id="lesson2", id="lesson2",
start=datetime(2026, 9, 10, 10, 0), start=datetime(2026, 9, 10, 10, 0),
end=datetime(2026, 9, 10, 11, 0), end=datetime(2026, 9, 10, 11, 0),
subject="Physique", subject="Math",
teachers=("M. Martin",), teachers=("M. Dupont",),
rooms=("205",), rooms=("205",),
group=None, group=None,
status=LessonStatus.NORMAL, status=LessonStatus.NORMAL,
@@ -343,8 +343,8 @@ def test_collect_homeworks_id_stability() -> None:
id="lesson2", id="lesson2",
start=datetime(2026, 9, 10, 10, 0), start=datetime(2026, 9, 10, 10, 0),
end=datetime(2026, 9, 10, 11, 0), end=datetime(2026, 9, 10, 11, 0),
subject="Physique", subject="Math",
teachers=("M. Martin",), teachers=("M. Dupont",),
rooms=("205",), rooms=("205",),
group=None, group=None,
status=LessonStatus.NORMAL, status=LessonStatus.NORMAL,
@@ -361,7 +361,87 @@ def test_collect_homeworks_id_stability() -> None:
homeworks = collect_homeworks([lesson1, lesson2], target_date=date(2026, 9, 10)) homeworks = collect_homeworks([lesson1, lesson2], target_date=date(2026, 9, 10))
assert len(homeworks) == 1 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_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: def test_collect_homeworks_sorted() -> None: