Files
college-infos/pronote_sync/sync/executor.py
Antoine Van Elstraete b4b0247919 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>
2026-09-07 09:24:18 +02:00

186 lines
7.7 KiB
Python

"""Exécuteur du plan de synchronisation CalDAV.
Ce module fournit :class:`CalDAVSyncExecutor`, qui applique un
:class:`~pronote_sync.models.sync.CalDAVSyncPlan` contre une
:class:`~pronote_sync.sync.caldav.CalDAVGateway` et produit un
:class:`~pronote_sync.models.sync.CalDAVSyncResult` avec des compteurs et
les éventuelles erreurs expurgées. Chaque opération (ajout, mise à jour,
suppression) est indépendante : l'échec d'un événement n'interrompt pas le
lot. En mode ``dry_run``, aucune écriture n'est envoyée à la passerelle,
mais le résultat reflète les opérations qui auraient été effectuées.
"""
from __future__ import annotations
import logging
from pronote_sync.errors import PronoteSyncError
from pronote_sync.models.agenda import Lesson, SchoolEvent
from pronote_sync.models.homework import Homework
from pronote_sync.models.sync import (
CalDAVSyncPlan,
CalDAVSyncResult,
CalDAVSyncStatus,
)
from pronote_sync.sync.caldav import CalDAVGateway
from pronote_sync.sync.serialization import model_to_vcalendar_text
from pronote_sync.utils.redaction import redact_secrets
logger = logging.getLogger(__name__)
def _model_uid(model: Lesson | Homework | SchoolEvent) -> str:
"""Retourne l'UID iCalendar correspondant à un modèle Pronote.
L'UID reproduit la convention de :mod:`pronote_sync.sync.serialization`
(préfixes ``homework-`` et ``school-event-``) afin de journaliser des
identifiants stables, identiques à ceux envoyés à la passerelle.
:param model: Modèle Pronote concerné.
:return: UID iCalendar du modèle.
:rtype: str
"""
if isinstance(model, Lesson):
return str(model.id)
if isinstance(model, Homework):
return f"homework-{model.id}"
return f"school-event-{model.label}-{model.from_date.isoformat()}"
class CalDAVSyncExecutor:
"""Exécute un plan de synchronisation CalDAV contre une passerelle distante.
Les opérations du plan sont traitées une à une, indépendamment : une
erreur ``PronoteSyncError`` sur un événement est consignée dans le
résultat (message expurgé) sans interrompre le traitement du lot. En
mode ``dry_run``, la passerelle n'est jamais appelée en écriture ;
les compteurs du résultat reflètent néanmoins ce qui aurait été fait.
"""
def __init__(self, gateway: CalDAVGateway, dry_run: bool = False) -> None:
"""Initialise l'exécuteur avec la passerelle CalDAV.
:param gateway: Passerelle CalDAV connectée.
:param dry_run: Si ``True``, aucune écriture n'est effectuée ; seuls
les logs et le résultat sont renseignés.
"""
self._gateway = gateway
self._dry_run = dry_run
def execute(self, plan: CalDAVSyncPlan) -> CalDAVSyncResult:
"""Exécute le plan de synchronisation et retourne le résultat.
Les cours, devoirs et événements scolaires sont traités dans l'ordre
« ajouts, mises à jour, suppressions ». Une erreur
``PronoteSyncError`` sur une opération est consignée dans
``result.errors`` (message expurgé) sans stopper les autres
opérations ; toute autre exception (erreur de programmation) se
propage. Le statut final vaut ``FAILED`` si au moins une erreur a été
consignée, ``SKIPPED`` si aucune opération n'était à effectuer
(reprise idempotente), sinon ``SUCCESS``.
:param plan: Plan de synchronisation à appliquer.
:return: Résultat de la synchronisation (statut, compteurs, erreurs).
:rtype: CalDAVSyncResult
"""
result = CalDAVSyncResult(status=CalDAVSyncStatus.SUCCESS, added=0, updated=0, removed=0)
lesson: Lesson
for lesson in plan.lessons_to_add:
self._do_save(lesson, result, is_update=False)
for lesson in plan.lessons_to_update:
self._do_save(lesson, result, is_update=True)
for uid in plan.lessons_to_remove:
self._do_delete(uid, result)
homework: Homework
for homework in plan.homeworks_to_add:
self._do_save(homework, result, is_update=False)
for homework in plan.homeworks_to_update:
self._do_save(homework, result, is_update=True)
for uid in plan.homeworks_to_remove:
self._do_delete(uid, result)
school_event: SchoolEvent
for school_event in plan.school_events_to_add:
self._do_save(school_event, result, is_update=False)
for school_event in plan.school_events_to_update:
self._do_save(school_event, result, is_update=True)
for uid in plan.school_events_to_remove:
self._do_delete(uid, result)
total = result.added + result.updated + result.removed
if result.errors:
result.status = CalDAVSyncStatus.FAILED
elif total == 0:
result.status = CalDAVSyncStatus.SKIPPED
else:
result.status = CalDAVSyncStatus.SUCCESS
return result
def _do_save(
self,
model: Lesson | Homework | SchoolEvent,
result: CalDAVSyncResult,
is_update: bool,
) -> None:
"""Écrit un événement sur la passerelle, ou simule l'écriture.
En mode ``dry_run``, l'action est uniquement journalisée et le
compteur correspondant est incrémenté. Sinon, le modèle est
sérialisé en document iCalendar complet (``VCALENDAR``) via
:func:`model_to_vcalendar_text`, puis envoyé à la passerelle avec
l'UID dérivé via :func:`_model_uid` (l'upsert par UID permet la
création ou la mise à jour de l'événement) ; en cas d'erreur
``PronoteSyncError``, le message expurgé est ajouté à
``result.errors``.
:param model: Modèle Pronote à écrire (Lesson, Homework ou SchoolEvent).
:param result: Résultat à mettre à jour (compteurs et erreurs).
:param is_update: Si ``True``, l'opération est une mise à jour,
sinon un ajout.
"""
action = "mise à jour" if is_update else "ajout"
uid = _model_uid(model)
if self._dry_run:
logger.info("DRY-RUN: %s de l'événement UID=%s", action, redact_secrets(uid))
if is_update:
result.updated += 1
else:
result.added += 1
return
try:
vcalendar_text = model_to_vcalendar_text(model)
self._gateway.upsert_event(vcalendar_text, uid)
except PronoteSyncError as exc:
result.errors.append(redact_secrets(str(exc)))
return
logger.info("%s de l'événement UID=%s", action, redact_secrets(uid))
if is_update:
result.updated += 1
else:
result.added += 1
def _do_delete(self, uid: str, result: CalDAVSyncResult) -> None:
"""Supprime un événement de la passerelle, ou simule la suppression.
En mode ``dry_run``, l'action est uniquement journalisée et le
compteur des suppressions est incrémenté. Sinon, la passerelle est
appelée avec l'UID ; en cas d'erreur ``PronoteSyncError``, le
message expurgé est ajouté à ``result.errors``.
:param uid: Identifiant UID de l'événement à supprimer.
:param result: Résultat à mettre à jour (compteurs et erreurs).
"""
if self._dry_run:
logger.info("DRY-RUN: suppression de l'événement UID=%s", redact_secrets(uid))
result.removed += 1
return
try:
self._gateway.delete_event(uid)
except PronoteSyncError as exc:
result.errors.append(redact_secrets(str(exc)))
return
logger.info("suppression de l'événement UID=%s", redact_secrets(uid))
result.removed += 1