feat(M7): synchronisation différentielle CalDAV
Implémente la synchronisation des événements Pronote vers un calendrier CalDAV (Nextcloud) de façon idempotente et sécurisée. Production : - sync/serialization.py : sérialisation Lesson/Homework/SchoolEvent vers VEVENT, signature sémantique (exclut DTSTAMP/CREATED/LAST-MODIFIED), enveloppe VCALENDAR complète avec VERSION:2.0 et PRODID - sync/caldav.py : passerelle CalDAV isolant caldav>=1.3.0, résolution du calendrier via principal().calendars() avec boundary matching, upsert par UID (fetch-then-save), exceptions expurgées et __context__ propre, mot de passe non stocké en clair, context manager - sync/planner.py : calcul explicite du CalDAVSyncPlan (add/update/remove par comparaison de signatures sémantiques, routage par préfixe d'UID) - sync/executor.py : exécution du plan avec dry-run (aucune écriture), isolation des erreurs par événement, statut FAILED/SKIPPED/SUCCESS - sync/synchronizer.py : orchestration en trois phases (scan, plan, exécution), SKIPPED si CalDAV non configuré - sync/__init__.py : export synchronize() - sources/pronote/client.py : normalisation UID via normalize_pronote_uid/ generate_deterministic_uid (parité avec ical.py) - config/settings.py : CalDAVSettings durci (url SecretStr, validation HTTPS, allow_insecure_http pour localhost, serializer redact_url) Tests (381 passés, couverture 95.58%) : - tests/unit/test_sync_serialization.py (21 tests) - tests/unit/test_caldav_planner.py (16 tests) - tests/unit/test_caldav_executor.py (18 tests) - tests/unit/test_caldav_gateway.py (24 tests) - tests/unit/test_caldav_security.py (18 tests) - tests/unit/test_uid_equivalence.py (8 tests) - tests/integration/test_caldav_sync.py (11 tests, faux serveur en mémoire) - tests/conftest.py : fixtures partagées Documentation : - GUIDE_DEV_PYTHON.md §7 : API réelle caldav>=1.3.0, principal().calendars(), VCALENDAR complet, upsert par UID, pas d'état local, événements non gérés protégés, CalDAVSettings durci (SecretStr, HTTPS, allow_insecure_http) - TODO.md : M7 coché - .env.example : CALDAV_ALLOW_INSECURE_HTTP=false 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>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""Module de synchronisation CalDAV."""
|
||||
|
||||
from pronote_sync.sync.synchronizer import synchronize
|
||||
|
||||
__all__ = ["synchronize"]
|
||||
|
||||
300
pronote_sync/sync/caldav.py
Normal file
300
pronote_sync/sync/caldav.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""Passerelle d'accès au calendrier CalDAV.
|
||||
|
||||
Ce module fournit :class:`CalDAVGateway`, une passerelle qui isole la
|
||||
bibliothèque ``caldav`` du reste du pipeline de synchronisation. Elle gère
|
||||
la connexion au serveur CalDAV, la résolution du calendrier de destination,
|
||||
la liste des événements gérés par l'outil, ainsi que l'écriture et la
|
||||
suppression d'événements.
|
||||
|
||||
La passerelle applique des contraintes de sécurité strictes : le mot de
|
||||
passe n'est extrait de son ``SecretStr`` que localement, au moment de créer
|
||||
le client, et aucun secret (mot de passe, URL brute) n'est conservé sur
|
||||
l'instance après la connexion. Toute exception de la bibliothèque ``caldav``
|
||||
est interceptée puis re-levée sous la forme d'une
|
||||
:class:`~pronote_sync.errors.PronoteSyncError` — sans chaînage — dont le
|
||||
message ne contient aucune donnée sensible.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from typing import Any, cast
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import caldav
|
||||
from caldav.lib.error import NotFoundError
|
||||
from icalendar import Component
|
||||
from pydantic import SecretStr
|
||||
|
||||
from pronote_sync.config.settings import CalDAVSettings
|
||||
from pronote_sync.errors import PronoteSyncError
|
||||
from pronote_sync.sync.serialization import MANAGED_PROPERTY, MANAGED_VALUE
|
||||
from pronote_sync.utils.redaction import redact_exception, redact_secrets, redact_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CalDAVGateway:
|
||||
"""Passerelle d'accès au calendrier CalDAV, isolant la bibliothèque caldav.
|
||||
|
||||
La passerelle gère la connexion, la résolution du calendrier de
|
||||
destination, la récupération des événements portant le marqueur de
|
||||
gestion (:data:`MANAGED_PROPERTY`), leur écriture et leur suppression.
|
||||
Seuls les paramètres non sensibles nécessaires (``calendar_path``,
|
||||
``username``) ainsi que l'URL rédigée sont mémorisés sur l'instance ; le
|
||||
mot de passe et l'URL brute ne sont jamais conservés en clair.
|
||||
|
||||
L'usage typique se fait via le gestionnaire de contexte ::
|
||||
|
||||
with CalDAVGateway(settings) as gateway:
|
||||
gateway.upsert_event(vcalendar_text, uid)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: CalDAVSettings,
|
||||
client_factory: Callable[..., Any] | None = None,
|
||||
) -> None:
|
||||
"""Initialise la passerelle avec la configuration CalDAV.
|
||||
|
||||
Extrait uniquement les paramètres non sensibles nécessaires
|
||||
(``calendar_path``, ``username``) ainsi que l'URL rédigée pour la
|
||||
journalisation. Le mot de passe reste encapsulé dans son
|
||||
``SecretStr`` et n'est jamais stocké en clair sur l'instance.
|
||||
|
||||
:param settings: Configuration CalDAV (url, username, password,
|
||||
calendar_path).
|
||||
:param client_factory: Appelable optionnel créant une instance
|
||||
``DAVClient`` (permet l'injection de dépendances en test). Si
|
||||
``None``, utilise ``caldav.DAVClient``.
|
||||
"""
|
||||
# ``cast`` nécessaire : mypy ne résout pas le ré-export du module
|
||||
# ``caldav`` (le type de ``caldav.DAVClient`` est vu comme ``object``).
|
||||
self._client_factory: Callable[..., Any] = (
|
||||
client_factory
|
||||
if client_factory is not None
|
||||
else cast(Callable[..., Any], caldav.DAVClient)
|
||||
)
|
||||
self._calendar_path: str = settings.calendar_path
|
||||
self._redacted_url: str | None = (
|
||||
redact_url(settings.url.get_secret_value()) if settings.url else None
|
||||
)
|
||||
self._username: str | None = settings.username
|
||||
self._url_secret: SecretStr | None = settings.url
|
||||
self._password_secret: SecretStr | None = settings.password
|
||||
self._client: Any = None
|
||||
self._calendar: Any = None
|
||||
|
||||
def _resolve_calendar(self) -> Any:
|
||||
"""Résout le calendrier cible via la découverte CalDAV.
|
||||
|
||||
Interroge le principal CalDAV puis sa liste de calendriers, et
|
||||
sélectionne celui dont le chemin d'URL correspond au
|
||||
``calendar_path`` configuré à la frontière d'un composant de
|
||||
chemin (barres obliques finales ignorées, préfixe ``/`` garanti par
|
||||
la normalisation).
|
||||
|
||||
:return: Le calendrier CalDAV correspondant au chemin configuré.
|
||||
:rtype: Any
|
||||
:raises PronoteSyncError: Si aucun calendrier ne correspond ou si
|
||||
plusieurs calendriers correspondent au chemin configuré.
|
||||
"""
|
||||
principal = self._client.principal()
|
||||
calendars = principal.calendars()
|
||||
normalized_path = self._calendar_path.strip("/")
|
||||
matches: list[Any] = []
|
||||
for cal in calendars:
|
||||
cal_url = str(cal.url) if hasattr(cal, "url") and cal.url else ""
|
||||
cal_path = urlparse(cal_url).path.strip("/")
|
||||
if cal_path == normalized_path or cal_path.endswith(f"/{normalized_path}"):
|
||||
matches.append(cal)
|
||||
if len(matches) == 0:
|
||||
raise PronoteSyncError(
|
||||
f"Calendrier CalDAV introuvable : {self._redacted_url}"
|
||||
) from None
|
||||
if len(matches) > 1:
|
||||
raise PronoteSyncError(
|
||||
f"Calendrier CalDAV ambigu : plusieurs calendriers "
|
||||
f"correspondent à '{self._calendar_path}'"
|
||||
) from None
|
||||
return matches[0]
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Établit la connexion au serveur CalDAV et résout le calendrier.
|
||||
|
||||
Le mot de passe est extrait de son ``SecretStr`` uniquement pour la
|
||||
création du ``DAVClient``, en variable locale, puis abandonné. Le
|
||||
calendrier cible est résolu par découverte
|
||||
(:meth:`_resolve_calendar`) plutôt que par concaténation d'URL. Toute
|
||||
exception de la bibliothèque ``caldav`` est interceptée, journalisée
|
||||
avec :func:`redact_exception` et re-levée en
|
||||
:class:`PronoteSyncError` — hors du bloc ``except``, afin que
|
||||
``__context__`` ne retienne aucune exception brute — sans chaînage ni
|
||||
donnée sensible. Les erreurs de résolution du calendrier
|
||||
(message « introuvable » ou « ambigu ») sont propagées telles quelles.
|
||||
|
||||
:raises PronoteSyncError: Si la configuration est incomplète ou si la
|
||||
connexion au serveur CalDAV échoue.
|
||||
"""
|
||||
if self._url_secret is None or self._username is None or self._password_secret is None:
|
||||
raise PronoteSyncError(
|
||||
"Configuration CalDAV incomplète : url, username et password sont requis"
|
||||
) from None
|
||||
raw_url = self._url_secret.get_secret_value()
|
||||
password = self._password_secret.get_secret_value()
|
||||
error_msg: str | None = None
|
||||
try:
|
||||
self._client = self._client_factory(
|
||||
url=raw_url, username=self._username, password=password
|
||||
)
|
||||
self._calendar = self._resolve_calendar()
|
||||
except PronoteSyncError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
error_msg = redact_exception(exc)
|
||||
logger.error("Échec de la connexion CalDAV : %s", error_msg)
|
||||
if error_msg is not None:
|
||||
raise PronoteSyncError(f"Échec de la connexion CalDAV : {self._redacted_url}") from None
|
||||
|
||||
def list_managed_events(self, start: datetime, end: datetime) -> list[tuple[str, Any]]:
|
||||
"""Liste les événements gérés par l'outil dans la fenêtre donnée.
|
||||
|
||||
Interroge le serveur CalDAV sur la fenêtre ``[start, end]`` et ne
|
||||
conserve que les VEVENT portant le marqueur de gestion
|
||||
(:data:`MANAGED_PROPERTY` avec la valeur :data:`MANAGED_VALUE`).
|
||||
|
||||
:param start: Début de la fenêtre de recherche.
|
||||
:param end: Fin de la fenêtre de recherche.
|
||||
:return: Couples ``(uid, vevent)`` pour chaque événement géré trouvé.
|
||||
:rtype: list[tuple[str, Any]]
|
||||
:raises PronoteSyncError: Si la passerelle n'est pas connectée ou si
|
||||
la récupération échoue.
|
||||
"""
|
||||
if self._calendar is None:
|
||||
raise PronoteSyncError("Passerelle CalDAV non connectée") from None
|
||||
result: list[tuple[str, Any]] = []
|
||||
error_msg: str | None = None
|
||||
try:
|
||||
events = self._calendar.date_search(start=start, end=end, expand=True)
|
||||
for event in events:
|
||||
component: Component = event.icalendar_component
|
||||
for vevent in component.walk("VEVENT"):
|
||||
managed = vevent.get(MANAGED_PROPERTY)
|
||||
if managed is not None and str(managed) == MANAGED_VALUE:
|
||||
uid = str(vevent.get("UID"))
|
||||
result.append((uid, vevent))
|
||||
except Exception as exc:
|
||||
error_msg = redact_exception(exc)
|
||||
logger.error("Échec de la récupération des événements CalDAV : %s", error_msg)
|
||||
if error_msg is not None:
|
||||
raise PronoteSyncError(
|
||||
f"Échec de la récupération des événements CalDAV : {self._redacted_url}"
|
||||
) from None
|
||||
return result
|
||||
|
||||
def upsert_event(self, vcalendar_text: str, uid: str) -> None:
|
||||
"""Crée ou met à jour un événement CalDAV identifié par son UID.
|
||||
|
||||
Recherche d'abord l'événement existant par UID via
|
||||
``get_event_by_uid`` : s'il existe, son contenu est remplacé puis
|
||||
sauvegardé ; s'il est introuvable (``NotFoundError``), un nouvel
|
||||
événement est créé via ``add_event``. Toute autre exception est
|
||||
journalisée avec :func:`redact_exception` puis re-levée en
|
||||
:class:`PronoteSyncError` — hors du bloc ``except``, afin que
|
||||
``__context__`` ne retienne aucune exception brute — sans chaînage ni
|
||||
donnée sensible.
|
||||
|
||||
:param vcalendar_text: Document iCalendar complet (VCALENDAR + VEVENT).
|
||||
:param uid: UID stable de l'événement à créer ou mettre à jour.
|
||||
:raises PronoteSyncError: Si la passerelle n'est pas connectée ou si
|
||||
l'opération échoue.
|
||||
"""
|
||||
if self._calendar is None:
|
||||
raise PronoteSyncError("Passerelle CalDAV non connectée") from None
|
||||
error_msg: str | None = None
|
||||
try:
|
||||
try:
|
||||
event = self._calendar.get_event_by_uid(uid)
|
||||
except NotFoundError:
|
||||
self._calendar.add_event(ical=vcalendar_text)
|
||||
else:
|
||||
event.data = vcalendar_text
|
||||
event.save()
|
||||
except Exception as exc:
|
||||
error_msg = redact_exception(exc)
|
||||
logger.error(
|
||||
"Échec de l'écriture d'un événement CalDAV (uid=%s) : %s",
|
||||
redact_secrets(uid),
|
||||
error_msg,
|
||||
)
|
||||
if error_msg is not None:
|
||||
raise PronoteSyncError(
|
||||
f"Échec de l'écriture d'un événement CalDAV : {self._redacted_url}"
|
||||
) from None
|
||||
|
||||
def delete_event(self, uid: str) -> None:
|
||||
"""Supprime un événement du calendrier, identifié par son UID.
|
||||
|
||||
Récupère l'événement distant via ``get_event_by_uid`` puis le
|
||||
supprime. Toute exception est journalisée avec
|
||||
:func:`redact_exception` et re-levée en :class:`PronoteSyncError` —
|
||||
hors du bloc ``except``, afin que ``__context__`` ne retienne aucune
|
||||
exception brute — sans chaînage ni donnée sensible.
|
||||
|
||||
:param uid: Identifiant UID de l'événement à supprimer.
|
||||
:raises PronoteSyncError: Si la passerelle n'est pas connectée ou si
|
||||
la suppression échoue.
|
||||
"""
|
||||
if self._calendar is None:
|
||||
raise PronoteSyncError("Passerelle CalDAV non connectée") from None
|
||||
error_msg: str | None = None
|
||||
try:
|
||||
event = self._calendar.get_event_by_uid(uid)
|
||||
event.delete()
|
||||
except Exception as exc:
|
||||
error_msg = redact_exception(exc)
|
||||
logger.error(
|
||||
"Échec de la suppression d'un événement CalDAV (uid=%s) : %s",
|
||||
redact_secrets(uid),
|
||||
error_msg,
|
||||
)
|
||||
if error_msg is not None:
|
||||
raise PronoteSyncError(
|
||||
f"Échec de la suppression d'un événement CalDAV : {self._redacted_url}"
|
||||
) from None
|
||||
|
||||
def close(self) -> None:
|
||||
"""Libère les ressources : client, calendrier et secrets.
|
||||
|
||||
Réinitialise le client, le calendrier et les ``SecretStr`` conservés
|
||||
afin de ne laisser aucune référence à des données sensibles sur
|
||||
l'instance.
|
||||
"""
|
||||
self._client = None
|
||||
self._calendar = None
|
||||
self._url_secret = None
|
||||
self._password_secret = None
|
||||
|
||||
def __enter__(self) -> CalDAVGateway:
|
||||
"""Entre dans le contexte en établissant la connexion.
|
||||
|
||||
:return: La passerelle connectée.
|
||||
:rtype: CalDAVGateway
|
||||
:raises PronoteSyncError: Si la connexion échoue.
|
||||
"""
|
||||
self.connect()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
||||
"""Quitte le contexte en libérant les ressources.
|
||||
|
||||
Les exceptions éventuellement en cours ne sont pas interceptées et
|
||||
continuent leur propagation normale.
|
||||
|
||||
:param exc_type: Type de l'exception en cours, le cas échéant.
|
||||
:param exc_val: Instance de l'exception en cours, le cas échéant.
|
||||
:param exc_tb: Traceback de l'exception en cours, le cas échéant.
|
||||
"""
|
||||
self.close()
|
||||
185
pronote_sync/sync/executor.py
Normal file
185
pronote_sync/sync/executor.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""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 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) -> 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.
|
||||
: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)
|
||||
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)
|
||||
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)
|
||||
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,
|
||||
) -> 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``.
|
||||
|
||||
: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.
|
||||
"""
|
||||
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)
|
||||
self._gateway.upsert_event(vcalendar_text, 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
|
||||
112
pronote_sync/sync/planner.py
Normal file
112
pronote_sync/sync/planner.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Planification de la synchronisation CalDAV.
|
||||
|
||||
Ce module compare les données Pronote normalisées aux événements distants
|
||||
marqués comme gérés par ``pronote-sync`` et produit un plan de synchronisation
|
||||
CalDAV (ajouts, mises à jour, suppressions) pour chaque catégorie d'événement :
|
||||
cours, devoirs et événements scolaires.
|
||||
|
||||
Le plan est calculé de manière pure et déterministe : deux entrées identiques
|
||||
produisent un plan identique, et un événement dont la signature sémantique
|
||||
n'a pas changé n'apparaît dans aucune liste du plan (idempotence).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pronote_sync.models.agenda import Lesson, SchoolEvent
|
||||
from pronote_sync.models.homework import Homework
|
||||
from pronote_sync.models.pronote import PronoteData
|
||||
from pronote_sync.models.sync import CalDAVSyncPlan
|
||||
from pronote_sync.sync.serialization import (
|
||||
component_to_signature,
|
||||
homework_to_vevent,
|
||||
lesson_to_vevent,
|
||||
school_event_to_vevent,
|
||||
)
|
||||
|
||||
|
||||
def compute_plan(
|
||||
pronote_data: PronoteData,
|
||||
remote_managed: list[tuple[str, Any]],
|
||||
) -> CalDAVSyncPlan:
|
||||
"""Calcule le plan de synchronisation CalDAV.
|
||||
|
||||
:param pronote_data: Données Pronote normalisées (cours, devoirs, événements).
|
||||
:param remote_managed: Liste de couples (uid, vevent) pour les événements
|
||||
distants marqués comme gérés par pronote-sync.
|
||||
:return: Plan de synchronisation avec les listes d'ajouts, mises à jour et
|
||||
suppressions pour chaque type d'événement.
|
||||
:rtype: CalDAVSyncPlan
|
||||
"""
|
||||
remote_signatures: dict[str, str] = {}
|
||||
for uid, vevent in remote_managed:
|
||||
remote_signatures[uid] = component_to_signature(vevent)
|
||||
|
||||
lessons_to_add: list[Lesson] = []
|
||||
lessons_to_update: list[Lesson] = []
|
||||
lessons_to_remove: list[str] = []
|
||||
|
||||
local_lessons_by_uid: dict[str, Lesson] = {lesson.id: lesson for lesson in pronote_data.lessons}
|
||||
for lesson in pronote_data.lessons:
|
||||
local_sig = component_to_signature(lesson_to_vevent(lesson))
|
||||
if lesson.id not in remote_signatures:
|
||||
lessons_to_add.append(lesson)
|
||||
elif remote_signatures[lesson.id] != local_sig:
|
||||
lessons_to_update.append(lesson)
|
||||
for uid in remote_signatures:
|
||||
if (
|
||||
uid not in local_lessons_by_uid
|
||||
and not uid.startswith("homework-")
|
||||
and not uid.startswith("school-event-")
|
||||
):
|
||||
lessons_to_remove.append(uid)
|
||||
|
||||
homeworks_to_add: list[Homework] = []
|
||||
homeworks_to_update: list[Homework] = []
|
||||
homeworks_to_remove: list[str] = []
|
||||
|
||||
local_homeworks_by_uid: dict[str, Homework] = {
|
||||
f"homework-{homework.id}": homework for homework in pronote_data.homeworks
|
||||
}
|
||||
for homework in pronote_data.homeworks:
|
||||
uid = f"homework-{homework.id}"
|
||||
local_sig = component_to_signature(homework_to_vevent(homework))
|
||||
if uid not in remote_signatures:
|
||||
homeworks_to_add.append(homework)
|
||||
elif remote_signatures[uid] != local_sig:
|
||||
homeworks_to_update.append(homework)
|
||||
for uid in remote_signatures:
|
||||
if uid.startswith("homework-") and uid not in local_homeworks_by_uid:
|
||||
homeworks_to_remove.append(uid)
|
||||
|
||||
school_events_to_add: list[SchoolEvent] = []
|
||||
school_events_to_update: list[SchoolEvent] = []
|
||||
school_events_to_remove: list[str] = []
|
||||
|
||||
local_school_events_by_uid: dict[str, SchoolEvent] = {
|
||||
f"school-event-{event.label}-{event.from_date.isoformat()}": event
|
||||
for event in pronote_data.school_events
|
||||
}
|
||||
for school_event in pronote_data.school_events:
|
||||
uid = f"school-event-{school_event.label}-{school_event.from_date.isoformat()}"
|
||||
local_sig = component_to_signature(school_event_to_vevent(school_event))
|
||||
if uid not in remote_signatures:
|
||||
school_events_to_add.append(school_event)
|
||||
elif remote_signatures[uid] != local_sig:
|
||||
school_events_to_update.append(school_event)
|
||||
for uid in remote_signatures:
|
||||
if uid.startswith("school-event-") and uid not in local_school_events_by_uid:
|
||||
school_events_to_remove.append(uid)
|
||||
|
||||
return CalDAVSyncPlan(
|
||||
lessons_to_add=lessons_to_add,
|
||||
lessons_to_update=lessons_to_update,
|
||||
lessons_to_remove=lessons_to_remove,
|
||||
homeworks_to_add=homeworks_to_add,
|
||||
homeworks_to_update=homeworks_to_update,
|
||||
homeworks_to_remove=homeworks_to_remove,
|
||||
school_events_to_add=school_events_to_add,
|
||||
school_events_to_update=school_events_to_update,
|
||||
school_events_to_remove=school_events_to_remove,
|
||||
)
|
||||
192
pronote_sync/sync/serialization.py
Normal file
192
pronote_sync/sync/serialization.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""Sérialisation des modèles Pronote en composants iCalendar (VEVENT).
|
||||
|
||||
Ce module convertit les modèles métier (:class:`Lesson`, :class:`Homework`,
|
||||
:class:`SchoolEvent`) en composants :class:`icalendar.Event` destinés à la
|
||||
synchronisation CalDAV, fournit un enveloppement en document ``VCALENDAR``
|
||||
complet (avec ``VERSION`` et ``PRODID``), et extrait une signature sémantique
|
||||
déterministe d'un composant distant pour permettre une comparaison
|
||||
idempotente.
|
||||
|
||||
Les composants produits portent le marqueur :data:`MANAGED_PROPERTY` avec la
|
||||
valeur :data:`MANAGED_VALUE` afin d'identifier les événements gérés par
|
||||
l'outil et de ne jamais toucher aux événements étrangers du calendrier.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, time
|
||||
from typing import cast
|
||||
|
||||
from icalendar import Calendar, Component, Event, vDate, vDatetime
|
||||
|
||||
from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent
|
||||
from pronote_sync.models.homework import Homework
|
||||
|
||||
#: Propriété iCalendar marquant un événement géré par ``pronote-sync``.
|
||||
MANAGED_PROPERTY = "X-PRONOTE-SYNC-MANAGED"
|
||||
|
||||
#: Valeur du marqueur de gestion (version du format de signature).
|
||||
MANAGED_VALUE = "v1"
|
||||
|
||||
#: Identifiant du produit pour la propriété ``PRODID`` des documents CalDAV.
|
||||
PRODID = "-//pronote-sync//NONSGML v1.0//EN"
|
||||
|
||||
#: Propriétés prises en compte dans la signature sémantique d'un composant.
|
||||
_SIGNATURE_KEYS: tuple[str, ...] = ("UID", "SUMMARY", "DTSTART", "DTEND", "STATUS", "DESCRIPTION")
|
||||
|
||||
|
||||
def lesson_to_vevent(lesson: Lesson) -> Event:
|
||||
"""Convertit un cours Pronote en composant VEVENT iCalendar.
|
||||
|
||||
La description contient une ligne par champ renseigné (matière,
|
||||
professeur(s), salle(s), contenu). Un cours annulé est marqué
|
||||
``STATUS:CANCELLED`` et classé dans la catégorie « Annulé », un cours
|
||||
déplacé dans la catégorie « Déplacé ».
|
||||
|
||||
:param lesson: Cours Pronote à sérialiser.
|
||||
:return: Composant :class:`icalendar.Event` marqué comme géré par l'outil.
|
||||
:rtype: icalendar.Event
|
||||
"""
|
||||
event = Event()
|
||||
event.add("uid", lesson.id)
|
||||
event.add("summary", lesson.subject)
|
||||
event.add("dtstart", vDatetime(lesson.start))
|
||||
event.add("dtend", vDatetime(lesson.end))
|
||||
|
||||
parts: list[str] = []
|
||||
if lesson.subject:
|
||||
parts.append(f"Matière: {lesson.subject}")
|
||||
if lesson.teachers:
|
||||
parts.append(f"Professeur(s): {', '.join(lesson.teachers)}")
|
||||
if lesson.rooms:
|
||||
parts.append(f"Salle(s): {', '.join(lesson.rooms)}")
|
||||
if lesson.content:
|
||||
parts.append(f"Contenu: {lesson.content}")
|
||||
event.add("description", "\n".join(parts))
|
||||
|
||||
if lesson.status == LessonStatus.CANCELLED:
|
||||
event.add("status", "CANCELLED")
|
||||
else:
|
||||
event.add("status", "CONFIRMED")
|
||||
|
||||
categories = ["Pronote"]
|
||||
if lesson.status == LessonStatus.CANCELLED:
|
||||
categories.append("Annulé")
|
||||
elif lesson.status == LessonStatus.MOVED:
|
||||
categories.append("Déplacé")
|
||||
event.add("categories", categories)
|
||||
|
||||
event.add(MANAGED_PROPERTY, MANAGED_VALUE)
|
||||
return event
|
||||
|
||||
|
||||
def homework_to_vevent(homework: Homework) -> Event:
|
||||
"""Convertit un devoir Pronote en composant VEVENT iCalendar.
|
||||
|
||||
Le devoir est représenté comme une tâche (``STATUS:NEEDS-ACTION``) sur la
|
||||
journée d'échéance, entre 08:00 et 18:00.
|
||||
|
||||
:param homework: Devoir Pronote à sérialiser.
|
||||
:return: Composant :class:`icalendar.Event` marqué comme géré par l'outil.
|
||||
:rtype: icalendar.Event
|
||||
"""
|
||||
event = Event()
|
||||
event.add("uid", f"homework-{homework.id}")
|
||||
event.add("summary", f"Devoir: {homework.subject}")
|
||||
event.add("dtstart", vDatetime(datetime.combine(homework.due_on, time(8, 0))))
|
||||
event.add("dtend", vDatetime(datetime.combine(homework.due_on, time(18, 0))))
|
||||
event.add("description", homework.text)
|
||||
event.add("status", "NEEDS-ACTION")
|
||||
event.add("categories", ["Pronote", "Devoir"])
|
||||
event.add(MANAGED_PROPERTY, MANAGED_VALUE)
|
||||
return event
|
||||
|
||||
|
||||
def school_event_to_vevent(school_event: SchoolEvent) -> Event:
|
||||
"""Convertit un événement scolaire en composant VEVENT iCalendar.
|
||||
|
||||
:param school_event: Événement scolaire (vacances, jour férié) à sérialiser.
|
||||
:return: Composant :class:`icalendar.Event` marqué comme géré par l'outil.
|
||||
:rtype: icalendar.Event
|
||||
"""
|
||||
event = Event()
|
||||
event.add("uid", f"school-event-{school_event.label}-{school_event.from_date.isoformat()}")
|
||||
event.add("summary", school_event.label)
|
||||
event.add("dtstart", vDate(school_event.from_date))
|
||||
event.add("dtend", vDate(school_event.to_date))
|
||||
event.add("status", "CONFIRMED")
|
||||
event.add("categories", ["Pronote", school_event.kind.value])
|
||||
event.add(MANAGED_PROPERTY, MANAGED_VALUE)
|
||||
return event
|
||||
|
||||
|
||||
def model_to_vcalendar_text(model: Lesson | Homework | SchoolEvent) -> str:
|
||||
"""Sérialise un modèle Pronote en document iCalendar complet (VCALENDAR).
|
||||
|
||||
Produit un document ``VCALENDAR`` valide contenant un seul ``VEVENT``,
|
||||
avec les propriétés ``VERSION:2.0`` et ``PRODID`` requises par le protocole
|
||||
CalDAV.
|
||||
|
||||
:param model: Modèle Pronote à sérialiser (Lesson, Homework ou SchoolEvent).
|
||||
:return: Document iCalendar complet en texte.
|
||||
:rtype: str
|
||||
:raises ValueError: Si le type de modèle n'est pas supporté.
|
||||
"""
|
||||
if isinstance(model, Lesson):
|
||||
vevent = lesson_to_vevent(model)
|
||||
elif isinstance(model, Homework):
|
||||
vevent = homework_to_vevent(model)
|
||||
elif isinstance(model, SchoolEvent):
|
||||
vevent = school_event_to_vevent(model)
|
||||
else:
|
||||
raise ValueError(f"Type de modèle non supporté : {type(model).__name__}")
|
||||
|
||||
cal = Calendar()
|
||||
cal.add("prodid", PRODID)
|
||||
cal.add("version", "2.0")
|
||||
cal.add_component(vevent)
|
||||
# ``to_ical()`` n'est pas typé dans icalendar : le cast documente le
|
||||
# décodage UTF-8 en texte et satisfait mypy strict.
|
||||
return cast(str, cal.to_ical().decode("utf-8"))
|
||||
|
||||
|
||||
def component_to_signature(component: Component) -> str:
|
||||
"""Extrait une signature sémantique déterministe d'un composant iCalendar.
|
||||
|
||||
La signature couvre l'UID, le résumé, les dates de début et de fin, le
|
||||
statut, la description, les catégories (triées) et le marqueur de gestion.
|
||||
Les propriétés volatiles (``DTSTAMP``, ``CREATED``, ``LAST-MODIFIED``,
|
||||
``SEQUENCE``) sont volontairement exclues : elles changent à chaque
|
||||
écriture serveur et ne reflètent aucun changement des données Pronote.
|
||||
|
||||
Les valeurs textuelles sont normalisées (espaces rognés, minuscules) et
|
||||
les dates/heures sérialisées via ``isoformat()``, de sorte que deux
|
||||
composants au contenu sémantiquement identique produisent la même
|
||||
signature.
|
||||
|
||||
:param component: Composant iCalendar (généralement un VEVENT distant).
|
||||
:return: Paires ``clé=valeur`` triées et jointes par ``|``.
|
||||
:rtype: str
|
||||
"""
|
||||
props: list[str] = []
|
||||
for key in _SIGNATURE_KEYS:
|
||||
raw = component.get(key)
|
||||
if raw is None:
|
||||
continue
|
||||
value = getattr(raw, "dt", raw)
|
||||
if hasattr(value, "isoformat"):
|
||||
rendered = value.isoformat()
|
||||
else:
|
||||
rendered = str(value).strip().lower()
|
||||
props.append(f"{key.lower()}={rendered}")
|
||||
|
||||
categories = component.get("CATEGORIES")
|
||||
if categories is not None:
|
||||
cats = sorted(str(c).strip().lower() for c in categories.cats)
|
||||
props.append(f"categories={','.join(cats)}")
|
||||
|
||||
managed = component.get(MANAGED_PROPERTY)
|
||||
if managed is not None:
|
||||
props.append(f"managed={str(managed).strip().lower()}")
|
||||
|
||||
return "|".join(sorted(props))
|
||||
107
pronote_sync/sync/synchronizer.py
Normal file
107
pronote_sync/sync/synchronizer.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""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, 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,
|
||||
) -> 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).
|
||||
|
||||
: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).
|
||||
: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.
|
||||
"""
|
||||
# Fenêtre temporelle fondée sur l'instant courant. 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 utilisé ici,
|
||||
# point d'entrée de l'orchestration, et pourrait être refactoré plus tard
|
||||
# vers une horloge injectable sans changer le contrat.
|
||||
now = datetime.now()
|
||||
start = now - timedelta(days=settings.app.sync_past_days)
|
||||
end = now + timedelta(days=settings.app.sync_future_days)
|
||||
|
||||
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=start, end=end)
|
||||
logger.info(
|
||||
"Synchronisation CalDAV : %d événements distants gérés trouvés",
|
||||
len(remote_managed),
|
||||
)
|
||||
|
||||
plan = compute_plan(pronote_data, 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)
|
||||
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
|
||||
Reference in New Issue
Block a user