"""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