diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4f34eaa..bfda9b6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,7 +26,7 @@ repos: name: mypy entry: mypy language: python - additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0", "pytest>=8.0.0"] + additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0", "pytest>=8.0.0", "types-requests>=2.31.0", "icalendar>=5.0.0"] types: [python] pass_filenames: true diff --git a/pronote_sync/sources/pronote/ical.py b/pronote_sync/sources/pronote/ical.py new file mode 100644 index 0000000..d3252f3 --- /dev/null +++ b/pronote_sync/sources/pronote/ical.py @@ -0,0 +1,460 @@ +"""Récupération et parsing du flux iCal Pronote. + +Ce module fournit le téléchargement du flux iCal Pronote (via HTTP ou +``file://`` pour les tests) ainsi que son parsing en modèles : cours +(:class:`~pronote_sync.models.agenda.Lesson`), événements scolaires +(:class:`~pronote_sync.models.agenda.SchoolEvent`) et devoirs +(:class:`~pronote_sync.models.homework.Homework`). + +La collecte finale des devoirs est réalisée à part, une fois la date +cible connue, via :func:`collect_homeworks`. +""" + +from __future__ import annotations + +import hashlib +import re +import urllib.parse +from datetime import date, datetime +from html import unescape +from pathlib import Path +from typing import TypedDict + +import requests +from icalendar import Calendar + +from ...models.agenda import ( + HomeworkBlock, + Lesson, + LessonStatus, + SchoolEvent, + SchoolEventKind, +) +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*") +_CALNAME_PATTERN = re.compile(r"^X-WR-CALNAME(?:;[^:]*)?:([^\r\n]*)", re.MULTILINE) +_TAG_PATTERN = re.compile(r"<[^>]+>") +_CONTENT_PATTERN = re.compile( + r"Contenu pédagogique\s*:\s*(.*?)(?=|\s*$|\Z)", + re.DOTALL, +) +_DUE_PATTERN = re.compile( + r"Pour le (\d{2}/\d{2}/\d{4})\s*:\s*(.*?)(?=|\s*$|\Z)", + re.DOTALL, +) +_ASSIGNED_PATTERN = re.compile( + r"Donné le (\d{2}/\d{2}/\d{4})\s*:\s*(.*?)(?=|\s*$|\Z)", + re.DOTALL, +) + +_HEADERS = { + "accept": "text/calendar", + "user-agent": "pronote-sync", +} + + +class HeaderInfo(TypedDict): + """Métadonnées du cours extraites de l'en-tête de la DESCRIPTION.""" + + subject: str + teachers: list[str] + rooms: list[str] + group: str | None + + +def fetch_ical(url: str, timeout: int = 20) -> str: + """Récupère le contenu brut d'un flux iCal Pronote. + + Gère les URLs ``file://`` pour les tests locaux (le chemin est + décodé de l'échappement URI, ex. ``%20`` → espace) et valide que + le flux commence bien par ``BEGIN:VCALENDAR``. Toutes les erreurs + sont relancées avec un message dont les secrets (token + ``icalsecurise``) sont masqués. + + :param url: URL du flux iCal (avec token ``icalsecurise``) ou chemin ``file://``. + :param timeout: Timeout HTTP en secondes (défaut : 20). + :return: Contenu brut du flux iCal. + :rtype: str + :raises OSError: Si le fichier local ``file://`` est illisible. + :raises requests.RequestException: Si la récupération HTTP échoue. + :raises ValueError: Si le flux ne commence pas par ``BEGIN:VCALENDAR``. + """ + if url.startswith("file://"): + parsed_url = urllib.parse.urlparse(url) + path = Path(urllib.parse.unquote(parsed_url.path)) + try: + content = path.read_text(encoding="utf-8") + except OSError as exc: + raise OSError( + f"Impossible de lire le fichier iCal {redact_url(url)} : {redact_exception(exc)}" + ) from exc + if not content.lstrip().startswith("BEGIN:VCALENDAR"): + raise ValueError(f"Fichier iCal invalide (pas de BEGIN:VCALENDAR) : {redact_url(url)}") + return content + + try: + response = requests.get(url, headers=_HEADERS, timeout=timeout) + response.raise_for_status() + content = response.text + except Exception as exc: + raise requests.RequestException( + f"Échec de la récupération du flux iCal {redact_url(url)} : {redact_exception(exc)}" + ) from exc + + if not content.lstrip().startswith("BEGIN:VCALENDAR"): + raise ValueError(f"Flux iCal invalide (pas de BEGIN:VCALENDAR) : {redact_url(url)}") + return str(content) + + +def _unfold_ical(raw_ical: str) -> str: + """Déplie les lignes de continuation iCalendar. + + Une ligne commençant par un espace ou une tabulation prolonge la + ligne précédente : le caractère d'espacement initial est retiré et + la suite est jointe à la ligne précédente. + + :param raw_ical: Contenu brut du flux iCal. + :return: Contenu avec les lignes de continuation dépliées. + :rtype: str + """ + unfolded: list[str] = [] + for line in raw_ical.splitlines(): + if line.startswith((" ", "\t")) and unfolded: + unfolded[-1] += line[1:] + else: + unfolded.append(line) + return "\n".join(unfolded) + + +def get_calendar_name(raw_ical: str) -> str | None: + """Extrait le nom du calendrier depuis la propriété ``X-WR-CALNAME``. + + La propriété peut comporter des paramètres (par exemple + ``X-WR-CALNAME;LANGUAGE=fr:Nom``) et les lignes de continuation + iCalendar sont dépliées avant la recherche. + + :param raw_ical: Contenu brut du flux iCal. + :return: Nom du calendrier ou ``None`` si la propriété est absente. + :rtype: str | None + """ + match = _CALNAME_PATTERN.search(_unfold_ical(raw_ical)) + if match is None: + return None + return match.group(1).strip() + + +def split_header_and_body(description: str) -> tuple[str, str]: + """Sépare l'en-tête texte du corps HTML dans la DESCRIPTION. + + L'en-tête est la partie avant le premier ````, le corps + HTML commence à partir du premier ````. + + :param description: Contenu brut de la DESCRIPTION d'un VEVENT. + :return: Tuple ``(en-tête, corps)`` ; le corps est vide si aucun ````. + :rtype: tuple[str, str] + """ + strong_start = description.find("") + if strong_start == -1: + return description.strip(), "" + header = description[:strong_start].strip() + body = description[strong_start:] + return header, body + + +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 + 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. + + :param header: En-tête texte (avant le premier ````). + :return: Dictionnaire typé avec les champs subject, teachers, rooms, group. + :rtype: HeaderInfo + """ + info: HeaderInfo = {"subject": "", "teachers": [], "rooms": [], "group": 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": + info["subject"] = value + elif label in ("professeur", "professeurs"): + info["teachers"] = [part.strip() for part in value.split(",") if part.strip()] + elif label in ("salle", "salles"): + info["rooms"] = [part.strip() for part in value.split(",") if part.strip()] + elif label == "groupe": + info["group"] = value + return info + + +def _strip_html(text: str) -> str: + """Retire les balises HTML d'un texte et nettoie les espaces. + + :param text: Texte pouvant contenir des balises HTML. + :return: Texte brut sans balises, entités HTML décodées. + :rtype: str + """ + cleaned = _TAG_PATTERN.sub("", text) + return unescape(cleaned).strip() + + +def _parse_french_date(value: str) -> date | None: + """Convertit une date au format ``JJ/MM/AAAA`` en :class:`date`. + + :param value: Date au format ``JJ/MM/AAAA``. + :return: Date parsée, ou ``None`` si le format est invalide. + :rtype: date | None + """ + try: + return datetime.strptime(value, "%d/%m/%Y").date() + except ValueError: + return None + + +def parse_body(body: str) -> tuple[str | None, dict[date, str], dict[date, str]]: + """Parse le corps HTML pour extraire contenu pédagogique et devoirs. + + Le contenu est extrait de la section ``Contenu pédagogique :``. + Les devoirs à faire sont extraits des sections ``Pour le JJ/MM/AAAA :`` + (dict date → texte) et les devoirs donnés des sections + ``Donné le JJ/MM/AAAA :`` (dict date → texte). + + :param body: Corps HTML (à partir du premier ````). + :return: Tuple ``(contenu pédagogique, devoirs dus, devoirs donnés)``. + :rtype: tuple[str | None, dict[date, str], dict[date, str]] + """ + content: str | None = None + due_blocks: dict[date, str] = {} + assigned_blocks: dict[date, str] = {} + + content_match = _CONTENT_PATTERN.search(body) + if content_match is not None: + content = _strip_html(content_match.group(1)) + + for match in _DUE_PATTERN.finditer(body): + due_date = _parse_french_date(match.group(1)) + if due_date is not None: + due_blocks[due_date] = _strip_html(match.group(2)) + + for match in _ASSIGNED_PATTERN.finditer(body): + assigned_date = _parse_french_date(match.group(1)) + if assigned_date is not None: + assigned_blocks[assigned_date] = _strip_html(match.group(2)) + + return content, due_blocks, assigned_blocks + + +def parse_homework_blocks( + due_blocks: dict[date, str], + assigned_blocks: dict[date, str], +) -> tuple[HomeworkBlock, ...]: + """Construit les :class:`HomeworkBlock` depuis les dicts de devoirs. + + Les blocs dus (``kind="due"``) précèdent les blocs donnés + (``kind="assigned"``), dans l'ordre d'insertion des dicts. + + :param due_blocks: Dict date → texte des devoirs à faire. + :param assigned_blocks: Dict date → texte des devoirs donnés. + :return: Tuple de blocs de devoirs pour le cours. + :rtype: tuple[HomeworkBlock, ...] + """ + blocks: list[HomeworkBlock] = [] + for due_date, text in due_blocks.items(): + blocks.append(HomeworkBlock(kind="due", date=due_date, text=text, html=text)) + for assigned_date, text in assigned_blocks.items(): + blocks.append(HomeworkBlock(kind="assigned", date=assigned_date, text=text, html=text)) + return tuple(blocks) + + +def normalize_homework_text(text: str) -> str: + """Normalise le texte d'un devoir pour la déduplication. + + Unifie les espaces multiples, supprime les balises HTML, puis + applique trim et minuscules. + + :param text: Texte brut du devoir. + :return: Texte normalisé. + :rtype: str + """ + normalized = re.sub(r"\s+", " ", text) + normalized = _TAG_PATTERN.sub("", normalized) + return normalized.strip().lower() + + +def generate_homework_id(due_on: date, normalized_text: 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 + 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. + :return: ID stable (12 caractères hexadécimaux). + :rtype: str + """ + payload = f"{due_on.isoformat()}|{normalized_text}".encode() + return hashlib.sha1(payload, usedforsecurity=False).hexdigest()[:12] + + +def collect_homeworks(lessons: list[Lesson], target_date: date) -> list[Homework]: + """Collecte et déduplique les devoirs en deux passes globales. + + 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 + résultat est trié par matière puis texte. + + :param lessons: Liste de tous les cours (VEVENT) parsés. + :param target_date: Date cible pour laquelle collecter les devoirs. + :return: Liste unique de devoirs, triée par matière puis texte. + :rtype: list[Homework] + """ + by_text: dict[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), + subject=lesson.subject, + teachers=lesson.teachers, + assigned_on=lesson.start.date(), + due_on=block.date, + text=block.text, + html=block.html, + ) + + for lesson in lessons: + if lesson.start.date() != target_date: + 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), + subject=lesson.subject, + teachers=lesson.teachers, + assigned_on=block.date, + due_on=target_date, + text=block.text, + html=block.html, + ) + + return sorted(by_text.values(), key=lambda hw: (hw.subject.lower(), hw.text.lower())) + + +def parse_ical(raw_ical: str) -> tuple[list[Lesson], list[Homework], list[SchoolEvent]]: + """Parse un flux iCal Pronote en événements typés. + + Les VEVENT de vacances/congés (tout le jour) deviennent des + :class:`SchoolEvent` de type ``holiday``. Les VEVENT horodatés + deviennent des :class:`Lesson` dont le statut dérive de la + catégorie (``Cours - Cours annulé`` → ``CANCELLED``, + ``Cours - Cours déplacé`` → ``MOVED``). Les UID sont normalisés ; + un événement sans UID reçoit un UID déterministe généré à partir + de ses champs clés (début, fin, matière, enseignants, salles, groupe). + + :param raw_ical: Contenu brut du flux iCal. + :return: Tuple ``(lessons, homeworks, school_events)`` où ``homeworks`` + est **toujours vide** : la collecte/déduplication se fait plus tard + via :func:`collect_homeworks` une fois la date cible connue. + :rtype: tuple[list[Lesson], list[Homework], list[SchoolEvent]] + """ + calendar = Calendar.from_ical(raw_ical) + + lessons: list[Lesson] = [] + homeworks: list[Homework] = [] + school_events: list[SchoolEvent] = [] + + for component in calendar.walk(): + if component.name != "VEVENT": + continue + + dtstart = component.get("dtstart") + dtend = component.get("dtend") + if dtstart is None or dtend is None: + continue + start = dtstart.dt + end = dtend.dt + + categories_obj = component.get("categories") + if categories_obj is None: + categories: list[str] = [] + 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): + 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, + label=label, + from_date=from_date, + to_date=to_date, + ) + ) + continue + + # Les cours sont des événements horodatés ; les événements tout + # le jour non scolaires sont ignorés. + if not isinstance(start, datetime) or not isinstance(end, datetime): + continue + + status = LessonStatus.NORMAL + if "Cours - Cours annulé" in categories: + status = LessonStatus.CANCELLED + elif "Cours - Cours déplacé" in categories: + status = LessonStatus.MOVED + + description = component.get("description") + description_str = str(description) if description is not None else "" + header, body = split_header_and_body(description_str) + lesson_data = parse_header(header) + content, due_blocks, assigned_blocks = parse_body(body) + homework_blocks = parse_homework_blocks(due_blocks, assigned_blocks) + + uid_value = component.get("uid") + if uid_value is None or str(uid_value).strip() == "": + uid = generate_deterministic_uid( + start=start, + end=end, + subject=lesson_data["subject"], + teachers=lesson_data["teachers"], + rooms=lesson_data["rooms"], + group=lesson_data["group"], + ) + else: + uid = normalize_pronote_uid(str(uid_value)) + + lessons.append( + Lesson( + id=uid, + start=start, + end=end, + subject=lesson_data["subject"], + teachers=tuple(lesson_data["teachers"]), + rooms=tuple(lesson_data["rooms"]), + group=lesson_data["group"], + status=status, + content=content, + homework_blocks=homework_blocks, + ) + ) + + return lessons, homeworks, school_events