Corrige les 5 constats de l'audit FIXME_M7 : #1 (Bloquant) — Protection des événements non marqués : - upsert_event() vérifie le marqueur X-PRONOTE-SYNC-MANAGED avant modification ; lève PronoteSyncError en cas de collision avec un événement non géré (aucune écriture) - delete_event() vérifie le marqueur ; no-op avec warning si non géré - Méthode privée _is_managed_event() factorisant le contrôle #2 (Bloquant) — Fenêtre de synchronisation : - Calcul en journées entières (minuit à minuit exclusif) - Filtrage des données locales (lessons, homeworks, school_events) avant passage au planner - Paramètre now injectable pour les tests #3 (Bloquant) — UID canonique vs brut : - list_managed_events() retourne (raw_uid, canonical_uid, vevent) - compute_plan() matche par UID canonique, route les raw UID vers *_to_remove, retourne le mapping remote_raw_by_canonical - executor.execute() utilise le raw UID pour les mises à jour (pas de doublon) - Pas de migration destructive des UID distants existants #4 (Correction) — Normalisation temporelle UTC : - normalize_datetime_to_utc() dans utils/uid.py : naïve → Europe/Paris → UTC ; consciente → UTC - Utilisée par generate_deterministic_uid() et component_to_signature() - Deux représentations du même instant → même UID et même signature #5 (Compatibilité) — date_search déprécié : - Remplacement par calendar.search(start, end, event=True, expand=True) Documentation : - GUIDE_DEV_PYTHON.md : suppression des références obsolètes à sync/state.py et état SQLite/JSON ; mise à jour de l'API CalDAV (search au lieu de date_search, upsert par UID) - TODO.md : M7 décoché (corrections en cours de validation) Tests : 390 passés, couverture 95.61% Co-authored-by: opencode/coder <coder@agents.invalid> Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid> Co-authored-by: opencode/tech-writer <tech-writer@agents.invalid>
197 lines
7.7 KiB
Python
197 lines
7.7 KiB
Python
"""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))
|