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>
108 lines
4.4 KiB
Python
108 lines
4.4 KiB
Python
"""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
|