feat(M7): synchronisation différentielle CalDAV

Implémente la synchronisation des événements Pronote vers un calendrier
CalDAV (Nextcloud) de façon idempotente et sécurisée.

Production :
- sync/serialization.py : sérialisation Lesson/Homework/SchoolEvent vers
  VEVENT, signature sémantique (exclut DTSTAMP/CREATED/LAST-MODIFIED),
  enveloppe VCALENDAR complète avec VERSION:2.0 et PRODID
- sync/caldav.py : passerelle CalDAV isolant caldav>=1.3.0, résolution du
  calendrier via principal().calendars() avec boundary matching, upsert par
  UID (fetch-then-save), exceptions expurgées et __context__ propre, mot de
  passe non stocké en clair, context manager
- sync/planner.py : calcul explicite du CalDAVSyncPlan (add/update/remove
  par comparaison de signatures sémantiques, routage par préfixe d'UID)
- sync/executor.py : exécution du plan avec dry-run (aucune écriture),
  isolation des erreurs par événement, statut FAILED/SKIPPED/SUCCESS
- sync/synchronizer.py : orchestration en trois phases (scan, plan,
  exécution), SKIPPED si CalDAV non configuré
- sync/__init__.py : export synchronize()
- sources/pronote/client.py : normalisation UID via normalize_pronote_uid/
  generate_deterministic_uid (parité avec ical.py)
- config/settings.py : CalDAVSettings durci (url SecretStr, validation
  HTTPS, allow_insecure_http pour localhost, serializer redact_url)

Tests (381 passés, couverture 95.58%) :
- tests/unit/test_sync_serialization.py (21 tests)
- tests/unit/test_caldav_planner.py (16 tests)
- tests/unit/test_caldav_executor.py (18 tests)
- tests/unit/test_caldav_gateway.py (24 tests)
- tests/unit/test_caldav_security.py (18 tests)
- tests/unit/test_uid_equivalence.py (8 tests)
- tests/integration/test_caldav_sync.py (11 tests, faux serveur en mémoire)
- tests/conftest.py : fixtures partagées

Documentation :
- GUIDE_DEV_PYTHON.md §7 : API réelle caldav>=1.3.0, principal().calendars(),
  VCALENDAR complet, upsert par UID, pas d'état local, événements non gérés
  protégés, CalDAVSettings durci (SecretStr, HTTPS, allow_insecure_http)
- TODO.md : M7 coché
- .env.example : CALDAV_ALLOW_INSECURE_HTTP=false

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>
This commit is contained in:
2026-09-07 09:24:18 +02:00
parent ebbe39f1f0
commit b4b0247919
21 changed files with 5042 additions and 151 deletions

View File

@@ -0,0 +1,192 @@
"""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
#: 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"):
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))