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:
+2
-2
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,32 @@ 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.casefold()}|"
|
||||||
|
f"{','.join(sorted(teacher.casefold() for teacher in teachers))}|{normalized_text}".encode()
|
||||||
|
)
|
||||||
return hashlib.sha1(payload, usedforsecurity=False).hexdigest()[:12]
|
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``)
|
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 +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.
|
: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(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,
|
subject=lesson.subject,
|
||||||
teachers=lesson.teachers,
|
teachers=lesson.teachers,
|
||||||
assigned_on=lesson.start.date(),
|
assigned_on=lesson.start.date(),
|
||||||
@@ -343,10 +419,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(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,
|
subject=lesson.subject,
|
||||||
teachers=lesson.teachers,
|
teachers=lesson.teachers,
|
||||||
assigned_on=block.date,
|
assigned_on=block.date,
|
||||||
@@ -355,7 +438,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 +482,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 +518,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 +560,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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Vendored
+53
@@ -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 & 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
@@ -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
@@ -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,117 @@ 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_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 & 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 & 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:
|
||||||
|
|||||||
Reference in New Issue
Block a user