"""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, time, 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, *, now: datetime | 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). La fenêtre de synchronisation est calculée en **journées complètes** : elle commence à minuit de ``aujourd'hui - sync_past_days`` (inclusive) et se termine, de façon exclusive, à minuit de ``aujourd'hui + sync_future_days + 1``, afin que le dernier jour de la fenêtre soit couvert en entier. Cette fenêtre s'applique aux **deux** côtés de la synchronisation : les événements distants gérés scannés sur la passerelle et les données Pronote locales filtrées (cours filtrés sur ``start``, devoirs sur ``due_on`` en jour, événements scolaires sur le chevauchement de leur période) avant d'être transmises au planificateur. 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 l'instant de référence par défaut, remplaçable via ``now`` pour les tests. :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). :param now: Instant de référence pour le calcul de la fenêtre ; par défaut ``datetime.now()``. :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. """ now = now or datetime.now() window_start = datetime.combine( (now - timedelta(days=settings.app.sync_past_days)).date(), time(0, 0), ) window_end = datetime.combine( (now + timedelta(days=settings.app.sync_future_days + 1)).date(), time(0, 0), ) # Application de la fenêtre aux données Pronote locales avant le passage au # planificateur : les événements hors fenêtre ne doivent être ni écrits sur # le calendrier ni déclencher de suppression d'un événement distant géré. filtered_lessons = [ lesson for lesson in pronote_data.lessons if window_start <= lesson.start < window_end ] filtered_homeworks = [ hw for hw in pronote_data.homeworks if window_start.date() <= hw.due_on < window_end.date() ] filtered_school_events = [ se for se in pronote_data.school_events if se.from_date < window_end.date() and se.to_date > window_start.date() ] filtered_pronote = PronoteData( lessons=filtered_lessons, homeworks=filtered_homeworks, school_events=filtered_school_events, messages=pronote_data.messages, target_date=pronote_data.target_date, generated_at=pronote_data.generated_at, ) 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=window_start, end=window_end) logger.info( "Synchronisation CalDAV : %d événements distants gérés trouvés", len(remote_managed), ) plan, remote_raw_by_canonical = compute_plan(filtered_pronote, 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, remote_raw_by_canonical=remote_raw_by_canonical) 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