"""Sérialisation des modèles Pronote en composants iCalendar (VEVENT). Ce module convertit les modèles métier (:class:`Lesson`, :class:`Homework`, :class:`SchoolEvent`) en composants :class:`icalendar.Event` destinés à la synchronisation CalDAV, fournit un enveloppement en document ``VCALENDAR`` complet (avec ``VERSION`` et ``PRODID``), et extrait une signature sémantique déterministe d'un composant distant pour permettre une comparaison idempotente. Les composants produits portent le marqueur :data:`MANAGED_PROPERTY` avec la valeur :data:`MANAGED_VALUE` afin d'identifier les événements gérés par l'outil et de ne jamais toucher aux événements étrangers du calendrier. """ from __future__ import annotations from datetime import datetime, time from typing import cast from icalendar import Calendar, Component, Event, vDate, vDatetime from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent from pronote_sync.models.homework import Homework from pronote_sync.utils.uid import normalize_datetime_to_utc #: Propriété iCalendar marquant un événement géré par ``pronote-sync``. MANAGED_PROPERTY = "X-PRONOTE-SYNC-MANAGED" #: Valeur du marqueur de gestion (version du format de signature). MANAGED_VALUE = "v1" #: Identifiant du produit pour la propriété ``PRODID`` des documents CalDAV. PRODID = "-//pronote-sync//NONSGML v1.0//EN" #: Propriétés prises en compte dans la signature sémantique d'un composant. _SIGNATURE_KEYS: tuple[str, ...] = ("UID", "SUMMARY", "DTSTART", "DTEND", "STATUS", "DESCRIPTION") def lesson_to_vevent(lesson: Lesson) -> Event: """Convertit un cours Pronote en composant VEVENT iCalendar. La description contient une ligne par champ renseigné (matière, professeur(s), salle(s), contenu). Un cours annulé est marqué ``STATUS:CANCELLED`` et classé dans la catégorie « Annulé », un cours déplacé dans la catégorie « Déplacé ». :param lesson: Cours Pronote à sérialiser. :return: Composant :class:`icalendar.Event` marqué comme géré par l'outil. :rtype: icalendar.Event """ event = Event() event.add("uid", lesson.id) event.add("summary", lesson.subject) event.add("dtstart", vDatetime(lesson.start)) event.add("dtend", vDatetime(lesson.end)) parts: list[str] = [] if lesson.subject: parts.append(f"Matière: {lesson.subject}") if lesson.teachers: parts.append(f"Professeur(s): {', '.join(lesson.teachers)}") if lesson.rooms: parts.append(f"Salle(s): {', '.join(lesson.rooms)}") if lesson.content: parts.append(f"Contenu: {lesson.content}") event.add("description", "\n".join(parts)) if lesson.status == LessonStatus.CANCELLED: event.add("status", "CANCELLED") else: event.add("status", "CONFIRMED") categories = ["Pronote"] if lesson.status == LessonStatus.CANCELLED: categories.append("Annulé") elif lesson.status == LessonStatus.MOVED: categories.append("Déplacé") event.add("categories", categories) event.add(MANAGED_PROPERTY, MANAGED_VALUE) return event def homework_to_vevent(homework: Homework) -> Event: """Convertit un devoir Pronote en composant VEVENT iCalendar. Le devoir est représenté comme une tâche (``STATUS:NEEDS-ACTION``) sur la journée d'échéance, entre 08:00 et 18:00. :param homework: Devoir Pronote à sérialiser. :return: Composant :class:`icalendar.Event` marqué comme géré par l'outil. :rtype: icalendar.Event """ event = Event() event.add("uid", f"homework-{homework.id}") event.add("summary", f"Devoir: {homework.subject}") event.add("dtstart", vDatetime(datetime.combine(homework.due_on, time(8, 0)))) event.add("dtend", vDatetime(datetime.combine(homework.due_on, time(18, 0)))) event.add("description", homework.text) event.add("status", "NEEDS-ACTION") event.add("categories", ["Pronote", "Devoir"]) event.add(MANAGED_PROPERTY, MANAGED_VALUE) return event def school_event_to_vevent(school_event: SchoolEvent) -> Event: """Convertit un événement scolaire en composant VEVENT iCalendar. :param school_event: Événement scolaire (vacances, jour férié) à sérialiser. :return: Composant :class:`icalendar.Event` marqué comme géré par l'outil. :rtype: icalendar.Event """ event = Event() event.add("uid", f"school-event-{school_event.label}-{school_event.from_date.isoformat()}") event.add("summary", school_event.label) event.add("dtstart", vDate(school_event.from_date)) event.add("dtend", vDate(school_event.to_date)) event.add("status", "CONFIRMED") event.add("categories", ["Pronote", school_event.kind.value]) event.add(MANAGED_PROPERTY, MANAGED_VALUE) return event def model_to_vcalendar_text(model: Lesson | Homework | SchoolEvent) -> str: """Sérialise un modèle Pronote en document iCalendar complet (VCALENDAR). Produit un document ``VCALENDAR`` valide contenant un seul ``VEVENT``, avec les propriétés ``VERSION:2.0`` et ``PRODID`` requises par le protocole CalDAV. :param model: Modèle Pronote à sérialiser (Lesson, Homework ou SchoolEvent). :return: Document iCalendar complet en texte. :rtype: str :raises ValueError: Si le type de modèle n'est pas supporté. """ if isinstance(model, Lesson): vevent = lesson_to_vevent(model) elif isinstance(model, Homework): vevent = homework_to_vevent(model) elif isinstance(model, SchoolEvent): vevent = school_event_to_vevent(model) else: raise ValueError(f"Type de modèle non supporté : {type(model).__name__}") cal = Calendar() cal.add("prodid", PRODID) cal.add("version", "2.0") cal.add_component(vevent) # ``to_ical()`` n'est pas typé dans icalendar : le cast documente le # décodage UTF-8 en texte et satisfait mypy strict. return cast(str, cal.to_ical().decode("utf-8")) def component_to_signature(component: Component) -> str: """Extrait une signature sémantique déterministe d'un composant iCalendar. La signature couvre l'UID, le résumé, les dates de début et de fin, le statut, la description, les catégories (triées) et le marqueur de gestion. Les propriétés volatiles (``DTSTAMP``, ``CREATED``, ``LAST-MODIFIED``, ``SEQUENCE``) sont volontairement exclues : elles changent à chaque écriture serveur et ne reflètent aucun changement des données Pronote. Les valeurs textuelles sont normalisées (espaces rognés, minuscules) et les dates/heures sérialisées via ``isoformat()``, de sorte que deux composants au contenu sémantiquement identique produisent la même signature. :param component: Composant iCalendar (généralement un VEVENT distant). :return: Paires ``clé=valeur`` triées et jointes par ``|``. :rtype: str """ props: list[str] = [] for key in _SIGNATURE_KEYS: raw = component.get(key) if raw is None: continue value = getattr(raw, "dt", raw) if hasattr(value, "isoformat"): if isinstance(value, datetime): rendered = normalize_datetime_to_utc(value).isoformat() else: rendered = value.isoformat() else: rendered = str(value).strip().lower() props.append(f"{key.lower()}={rendered}") categories = component.get("CATEGORIES") if categories is not None: cats = sorted(str(c).strip().lower() for c in categories.cats) props.append(f"categories={','.join(cats)}") managed = component.get(MANAGED_PROPERTY) if managed is not None: props.append(f"managed={str(managed).strip().lower()}") return "|".join(sorted(props))