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