Corrige les 5 constats de l'audit FIXME_M7 : #1 (Bloquant) — Protection des événements non marqués : - upsert_event() vérifie le marqueur X-PRONOTE-SYNC-MANAGED avant modification ; lève PronoteSyncError en cas de collision avec un événement non géré (aucune écriture) - delete_event() vérifie le marqueur ; no-op avec warning si non géré - Méthode privée _is_managed_event() factorisant le contrôle #2 (Bloquant) — Fenêtre de synchronisation : - Calcul en journées entières (minuit à minuit exclusif) - Filtrage des données locales (lessons, homeworks, school_events) avant passage au planner - Paramètre now injectable pour les tests #3 (Bloquant) — UID canonique vs brut : - list_managed_events() retourne (raw_uid, canonical_uid, vevent) - compute_plan() matche par UID canonique, route les raw UID vers *_to_remove, retourne le mapping remote_raw_by_canonical - executor.execute() utilise le raw UID pour les mises à jour (pas de doublon) - Pas de migration destructive des UID distants existants #4 (Correction) — Normalisation temporelle UTC : - normalize_datetime_to_utc() dans utils/uid.py : naïve → Europe/Paris → UTC ; consciente → UTC - Utilisée par generate_deterministic_uid() et component_to_signature() - Deux représentations du même instant → même UID et même signature #5 (Compatibilité) — date_search déprécié : - Remplacement par calendar.search(start, end, event=True, expand=True) Documentation : - GUIDE_DEV_PYTHON.md : suppression des références obsolètes à sync/state.py et état SQLite/JSON ; mise à jour de l'API CalDAV (search au lieu de date_search, upsert par UID) - TODO.md : M7 décoché (corrections en cours de validation) Tests : 390 passés, couverture 95.61% 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>
149 lines
6.3 KiB
Python
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.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
|