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>
226 lines
9.4 KiB
Python
226 lines
9.4 KiB
Python
"""Exécuteur du plan de synchronisation CalDAV.
|
|
|
|
Ce module fournit :class:`CalDAVSyncExecutor`, qui applique un
|
|
:class:`~pronote_sync.models.sync.CalDAVSyncPlan` contre une
|
|
:class:`~pronote_sync.sync.caldav.CalDAVGateway` et produit un
|
|
:class:`~pronote_sync.models.sync.CalDAVSyncResult` avec des compteurs et
|
|
les éventuelles erreurs expurgées. Chaque opération (ajout, mise à jour,
|
|
suppression) est indépendante : l'échec d'un événement n'interrompt pas le
|
|
lot. En mode ``dry_run``, aucune écriture n'est envoyée à la passerelle,
|
|
mais le résultat reflète les opérations qui auraient été effectuées.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from collections.abc import Mapping
|
|
|
|
from pronote_sync.errors import PronoteSyncError
|
|
from pronote_sync.models.agenda import Lesson, SchoolEvent
|
|
from pronote_sync.models.homework import Homework
|
|
from pronote_sync.models.sync import (
|
|
CalDAVSyncPlan,
|
|
CalDAVSyncResult,
|
|
CalDAVSyncStatus,
|
|
)
|
|
from pronote_sync.sync.caldav import CalDAVGateway
|
|
from pronote_sync.sync.serialization import model_to_vcalendar_text
|
|
from pronote_sync.utils.redaction import redact_secrets
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _model_uid(model: Lesson | Homework | SchoolEvent) -> str:
|
|
"""Retourne l'UID iCalendar correspondant à un modèle Pronote.
|
|
|
|
L'UID reproduit la convention de :mod:`pronote_sync.sync.serialization`
|
|
(préfixes ``homework-`` et ``school-event-``) afin de journaliser des
|
|
identifiants stables, identiques à ceux envoyés à la passerelle.
|
|
|
|
:param model: Modèle Pronote concerné.
|
|
:return: UID iCalendar du modèle.
|
|
:rtype: str
|
|
"""
|
|
if isinstance(model, Lesson):
|
|
return str(model.id)
|
|
if isinstance(model, Homework):
|
|
return f"homework-{model.id}"
|
|
return f"school-event-{model.label}-{model.from_date.isoformat()}"
|
|
|
|
|
|
class CalDAVSyncExecutor:
|
|
"""Exécute un plan de synchronisation CalDAV contre une passerelle distante.
|
|
|
|
Les opérations du plan sont traitées une à une, indépendamment : une
|
|
erreur ``PronoteSyncError`` sur un événement est consignée dans le
|
|
résultat (message expurgé) sans interrompre le traitement du lot. En
|
|
mode ``dry_run``, la passerelle n'est jamais appelée en écriture ;
|
|
les compteurs du résultat reflètent néanmoins ce qui aurait été fait.
|
|
"""
|
|
|
|
def __init__(self, gateway: CalDAVGateway, dry_run: bool = False) -> None:
|
|
"""Initialise l'exécuteur avec la passerelle CalDAV.
|
|
|
|
:param gateway: Passerelle CalDAV connectée.
|
|
:param dry_run: Si ``True``, aucune écriture n'est effectuée ; seuls
|
|
les logs et le résultat sont renseignés.
|
|
"""
|
|
self._gateway = gateway
|
|
self._dry_run = dry_run
|
|
|
|
def execute(
|
|
self,
|
|
plan: CalDAVSyncPlan,
|
|
*,
|
|
remote_raw_by_canonical: Mapping[str, str] | None = None,
|
|
) -> CalDAVSyncResult:
|
|
"""Exécute le plan de synchronisation et retourne le résultat.
|
|
|
|
Les cours, devoirs et événements scolaires sont traités dans l'ordre
|
|
« ajouts, mises à jour, suppressions ». Une erreur
|
|
``PronoteSyncError`` sur une opération est consignée dans
|
|
``result.errors`` (message expurgé) sans stopper les autres
|
|
opérations ; toute autre exception (erreur de programmation) se
|
|
propage. Le statut final vaut ``FAILED`` si au moins une erreur a été
|
|
consignée, ``SKIPPED`` si aucune opération n'était à effectuer
|
|
(reprise idempotente), sinon ``SUCCESS``.
|
|
|
|
:param plan: Plan de synchronisation à appliquer.
|
|
:param remote_raw_by_canonical: Mapping canonical_uid -> raw_uid des
|
|
événements distants gérés, requis pour cibler l'UID brut lors des
|
|
mises à jour. ``None`` ou une clé absente entraîne une erreur
|
|
consignée dans ``result.errors`` pour chaque mise à jour concernée.
|
|
:return: Résultat de la synchronisation (statut, compteurs, erreurs).
|
|
:rtype: CalDAVSyncResult
|
|
"""
|
|
result = CalDAVSyncResult(status=CalDAVSyncStatus.SUCCESS, added=0, updated=0, removed=0)
|
|
|
|
lesson: Lesson
|
|
for lesson in plan.lessons_to_add:
|
|
self._do_save(lesson, result, is_update=False)
|
|
for lesson in plan.lessons_to_update:
|
|
self._do_save(
|
|
lesson,
|
|
result,
|
|
is_update=True,
|
|
remote_raw_by_canonical=remote_raw_by_canonical,
|
|
)
|
|
for uid in plan.lessons_to_remove:
|
|
self._do_delete(uid, result)
|
|
|
|
homework: Homework
|
|
for homework in plan.homeworks_to_add:
|
|
self._do_save(homework, result, is_update=False)
|
|
for homework in plan.homeworks_to_update:
|
|
self._do_save(
|
|
homework,
|
|
result,
|
|
is_update=True,
|
|
remote_raw_by_canonical=remote_raw_by_canonical,
|
|
)
|
|
for uid in plan.homeworks_to_remove:
|
|
self._do_delete(uid, result)
|
|
|
|
school_event: SchoolEvent
|
|
for school_event in plan.school_events_to_add:
|
|
self._do_save(school_event, result, is_update=False)
|
|
for school_event in plan.school_events_to_update:
|
|
self._do_save(
|
|
school_event,
|
|
result,
|
|
is_update=True,
|
|
remote_raw_by_canonical=remote_raw_by_canonical,
|
|
)
|
|
for uid in plan.school_events_to_remove:
|
|
self._do_delete(uid, result)
|
|
|
|
total = result.added + result.updated + result.removed
|
|
if result.errors:
|
|
result.status = CalDAVSyncStatus.FAILED
|
|
elif total == 0:
|
|
result.status = CalDAVSyncStatus.SKIPPED
|
|
else:
|
|
result.status = CalDAVSyncStatus.SUCCESS
|
|
return result
|
|
|
|
def _do_save(
|
|
self,
|
|
model: Lesson | Homework | SchoolEvent,
|
|
result: CalDAVSyncResult,
|
|
is_update: bool,
|
|
remote_raw_by_canonical: Mapping[str, str] | None = None,
|
|
) -> None:
|
|
"""Écrit un événement sur la passerelle, ou simule l'écriture.
|
|
|
|
En mode ``dry_run``, l'action est uniquement journalisée et le
|
|
compteur correspondant est incrémenté. Sinon, le modèle est
|
|
sérialisé en document iCalendar complet (``VCALENDAR``) via
|
|
:func:`model_to_vcalendar_text`, puis envoyé à la passerelle avec
|
|
l'UID dérivé via :func:`_model_uid` (l'upsert par UID permet la
|
|
création ou la mise à jour de l'événement) ; en cas d'erreur
|
|
``PronoteSyncError``, le message expurgé est ajouté à
|
|
``result.errors``. Pour une mise à jour, l'UID cible est l'UID brut
|
|
distant (via ``remote_raw_by_canonical``) afin de mettre à jour le
|
|
vrai événement distant au lieu d'en créer un doublon ; si le mapping
|
|
est absent, l'erreur est consignée dans ``result.errors`` sans
|
|
interrompre le lot.
|
|
|
|
:param model: Modèle Pronote à écrire (Lesson, Homework ou SchoolEvent).
|
|
:param result: Résultat à mettre à jour (compteurs et erreurs).
|
|
:param is_update: Si ``True``, l'opération est une mise à jour,
|
|
sinon un ajout.
|
|
:param remote_raw_by_canonical: Mapping canonical_uid -> raw_uid des
|
|
événements distants gérés, utilisé uniquement pour les mises à jour.
|
|
"""
|
|
action = "mise à jour" if is_update else "ajout"
|
|
uid = _model_uid(model)
|
|
if self._dry_run:
|
|
logger.info("DRY-RUN: %s de l'événement UID=%s", action, redact_secrets(uid))
|
|
if is_update:
|
|
result.updated += 1
|
|
else:
|
|
result.added += 1
|
|
return
|
|
try:
|
|
vcalendar_text = model_to_vcalendar_text(model)
|
|
target_uid = uid
|
|
if is_update:
|
|
if remote_raw_by_canonical is None or uid not in remote_raw_by_canonical:
|
|
result.errors.append(
|
|
f"UID canonique sans correspondant distant : {redact_secrets(uid)}"
|
|
)
|
|
return
|
|
target_uid = remote_raw_by_canonical[uid]
|
|
self._gateway.upsert_event(vcalendar_text, target_uid)
|
|
except PronoteSyncError as exc:
|
|
result.errors.append(redact_secrets(str(exc)))
|
|
return
|
|
logger.info("%s de l'événement UID=%s", action, redact_secrets(uid))
|
|
if is_update:
|
|
result.updated += 1
|
|
else:
|
|
result.added += 1
|
|
|
|
def _do_delete(self, uid: str, result: CalDAVSyncResult) -> None:
|
|
"""Supprime un événement de la passerelle, ou simule la suppression.
|
|
|
|
En mode ``dry_run``, l'action est uniquement journalisée et le
|
|
compteur des suppressions est incrémenté. Sinon, la passerelle est
|
|
appelée avec l'UID ; en cas d'erreur ``PronoteSyncError``, le
|
|
message expurgé est ajouté à ``result.errors``.
|
|
|
|
:param uid: Identifiant UID de l'événement à supprimer.
|
|
:param result: Résultat à mettre à jour (compteurs et erreurs).
|
|
"""
|
|
if self._dry_run:
|
|
logger.info("DRY-RUN: suppression de l'événement UID=%s", redact_secrets(uid))
|
|
result.removed += 1
|
|
return
|
|
try:
|
|
self._gateway.delete_event(uid)
|
|
except PronoteSyncError as exc:
|
|
result.errors.append(redact_secrets(str(exc)))
|
|
return
|
|
logger.info("suppression de l'événement UID=%s", redact_secrets(uid))
|
|
result.removed += 1
|