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:
192
pronote_sync/sync/serialization.py
Normal file
192
pronote_sync/sync/serialization.py
Normal 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))
|
||||
Reference in New Issue
Block a user