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,107 @@
"""Orchestrateur de la synchronisation CalDAV.
Ce module fournit :func:`synchronize`, le point d'entrée haut niveau qui
enchaîne les trois phases de la synchronisation : connexion à la passerelle
CalDAV, scan des événements distants gérés et calcul du plan, puis exécution
du plan (ou simulation en mode ``dry_run``). Il s'appuie sur
:class:`~pronote_sync.sync.caldav.CalDAVGateway`,
:func:`~pronote_sync.sync.planner.compute_plan` et
:class:`~pronote_sync.sync.executor.CalDAVSyncExecutor`.
"""
from __future__ import annotations
import logging
from collections.abc import Callable
from datetime import datetime, timedelta
from typing import Any
from pronote_sync.config.settings import Settings
from pronote_sync.errors import PronoteSyncError
from pronote_sync.models.pronote import PronoteData
from pronote_sync.models.sync import CalDAVSyncResult, CalDAVSyncStatus
from pronote_sync.sync.caldav import CalDAVGateway
from pronote_sync.sync.executor import CalDAVSyncExecutor
from pronote_sync.sync.planner import compute_plan
logger = logging.getLogger(__name__)
def synchronize(
pronote_data: PronoteData,
settings: Settings,
client_factory: Callable[..., Any] | None = None,
) -> CalDAVSyncResult:
"""Synchronise les données Pronote vers le calendrier CalDAV.
Enchaîne les trois phases : connexion à la passerelle, scan distant et
calcul du plan, puis exécution (ou simulation dry-run).
:param pronote_data: Données Pronote normalisées à synchroniser.
:param settings: Configuration racine du pipeline.
:param client_factory: Fabrique optionnelle de client DAV (pour les tests).
:return: Résultat de la synchronisation (statut, compteurs, erreurs).
:rtype: CalDAVSyncResult
:raises PronoteSyncError: Si la configuration CalDAV est incomplète ou si la
connexion échoue.
"""
# Fenêtre temporelle fondée sur l'instant courant. Aucune abstraction
# d'horloge n'existe encore dans le dépôt (les données Pronote sont par
# convention naïves en heure locale) : ``datetime.now()`` est utilisé ici,
# point d'entrée de l'orchestration, et pourrait être refactoré plus tard
# vers une horloge injectable sans changer le contrat.
now = datetime.now()
start = now - timedelta(days=settings.app.sync_past_days)
end = now + timedelta(days=settings.app.sync_future_days)
if (
settings.caldav.url is None
or settings.caldav.username is None
or settings.caldav.password is None
):
logger.info("CalDAV non configuré — synchronisation ignorée")
return CalDAVSyncResult(status=CalDAVSyncStatus.SKIPPED, added=0, updated=0, removed=0)
gateway = CalDAVGateway(settings.caldav, client_factory=client_factory)
try:
with gateway:
remote_managed = gateway.list_managed_events(start=start, end=end)
logger.info(
"Synchronisation CalDAV : %d événements distants gérés trouvés",
len(remote_managed),
)
plan = compute_plan(pronote_data, remote_managed)
n_add = (
len(plan.lessons_to_add)
+ len(plan.homeworks_to_add)
+ len(plan.school_events_to_add)
)
n_update = (
len(plan.lessons_to_update)
+ len(plan.homeworks_to_update)
+ len(plan.school_events_to_update)
)
n_remove = (
len(plan.lessons_to_remove)
+ len(plan.homeworks_to_remove)
+ len(plan.school_events_to_remove)
)
logger.info(
"Plan : %d ajouts, %d mises à jour, %d suppressions",
n_add,
n_update,
n_remove,
)
if settings.app.dry_run:
logger.info("DRY-RUN : aucune écriture ne sera effectuée sur le calendrier")
executor = CalDAVSyncExecutor(gateway, dry_run=settings.app.dry_run)
return executor.execute(plan)
except PronoteSyncError:
logger.error("Échec de la synchronisation CalDAV")
# Re-lève la même exception de domaine sans en créer de nouvelle.
# ``PronoteSyncError`` a déjà été levée avec ``from None`` en amont
# (passerelle CalDAV), donc ``__cause__`` et ``__context__`` restent
# propres : un ``raise`` nu préserve cet état sans ajouter de chaînage.
raise