Files
college-infos/pronote_sync/sync/synchronizer.py
T
2026-09-13 11:50:20 +02:00

149 lines
6.3 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, 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.endpoint 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