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:
2026-09-07 09:24:18 +02:00
parent ebbe39f1f0
commit b4b0247919
21 changed files with 5042 additions and 151 deletions

View File

@@ -10,10 +10,13 @@ from __future__ import annotations
from datetime import date
from typing import Literal
from urllib.parse import urlparse
from pydantic import Field, SecretStr, field_serializer
from pydantic import Field, SecretStr, ValidationInfo, field_serializer, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from pronote_sync.utils.redaction import redact_url
class PronoteSettings(BaseSettings):
"""Paramètres d'accès à Pronote (flux iCal et API ``pronotepy``).
@@ -51,16 +54,75 @@ class CalDAVSettings(BaseSettings):
"""Paramètres d'accès au serveur CalDAV de destination.
Les variables d'environnement correspondantes sont préfixées par
``CALDAV_``.
``CALDAV_``. L'URL est traitée comme potentiellement sensible (au même
titre que ``PRONOTE_ICAL_URL``) : elle est de type ``SecretStr`` et
masquée lors de la sérialisation. Par défaut, seul HTTPS est accepté ;
HTTP n'est toléré que pour un hôte de boucle locale (``localhost``,
``127.0.0.1``, ``::1``) lorsque ``allow_insecure_http`` vaut ``True``.
"""
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="CALDAV_")
url: str | None = None
allow_insecure_http: bool = False
url: SecretStr | None = None
username: str | None = None
password: SecretStr | None = None
calendar_path: str = "/pronote-sync/"
@field_serializer("url")
def _serialize_url(self, value: SecretStr | None) -> str | None:
"""Masque l'URL CalDAV lors de la sérialisation (repr, str, JSON).
:param value: Valeur du champ ``url`` (secret potentiel).
:return: URL avec les éléments sensibles remplacés par ``REDACTED``,
ou ``None`` si la valeur est absente.
:rtype: str | None
"""
if value is None:
return None
return redact_url(value.get_secret_value())
@field_validator("url")
@classmethod
def _validate_url_https(cls, v: SecretStr | None, info: ValidationInfo) -> SecretStr | None:
"""Valide le schéma de l'URL CalDAV (HTTPS obligatoire par défaut).
HTTPS est toujours accepté. HTTP n'est accepté que pour un hôte de
boucle locale (``localhost``, ``127.0.0.1``, ``::1``) et uniquement
lorsque ``allow_insecure_http`` vaut ``True``. Les messages d'erreur
ne contiennent jamais l'URL brute (susceptible de contenir des
identifiants).
:param v: Valeur du champ ``url`` à valider.
:param info: Contexte de validation (accès aux autres champs).
:return: La valeur validée inchangée.
:rtype: SecretStr | None
:raises ValueError: Si le schéma n'est pas supporté ou si l'URL HTTP
n'est pas autorisée.
"""
if v is None:
return v
raw_url = v.get_secret_value()
parsed = urlparse(raw_url)
if parsed.scheme not in ("http", "https"):
raise ValueError("URL CalDAV invalide : schéma non supporté") from None
if parsed.scheme == "https":
return v
# HTTP — check allow_insecure_http flag and loopback
allow_insecure = info.data.get("allow_insecure_http", False)
if not allow_insecure:
raise ValueError(
"URL CalDAV non sécurisée : HTTPS requis (ou activer "
"CALDAV_ALLOW_INSECURE_HTTP pour localhost)"
) from None
hostname = parsed.hostname or ""
loopback_hosts = {"localhost", "127.0.0.1", "::1"}
if hostname not in loopback_hosts:
raise ValueError(
"URL CalDAV non sécurisée : HTTP autorisé uniquement pour localhost"
) from None
return v
class XmppSettings(BaseSettings):
"""Paramètres du canal de notifications XMPP (désactivé par défaut).

View File

@@ -23,6 +23,7 @@ from pronote_sync.models.agenda import Lesson, LessonStatus
from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message, MessageType
from pronote_sync.utils.redaction import redact_exception
from pronote_sync.utils.uid import generate_deterministic_uid, normalize_pronote_uid
logger = logging.getLogger(__name__)
@@ -270,6 +271,12 @@ class PronoteClient:
def get_lessons(self, start: date, end: date) -> list[Lesson]:
"""Récupère les cours via ``pronotepy`` (repli iCal).
Les UIDs des cours sont normalisés comme ceux du flux iCal via
:func:`normalize_pronote_uid` afin que la même leçon produise le
même identifiant quelle que soit la source ; en l'absence d'UID
exploitable, un UID déterministe est généré via
:func:`generate_deterministic_uid`.
Les exceptions ne sont pas attrapées : elles se propagent afin que
l'appelant puisse détecter l'échec et déclencher le repli (ou une
erreur explicite).
@@ -288,9 +295,21 @@ class PronoteClient:
lessons: list[Lesson] = []
for lesson in client.lessons(start, end):
content = lesson.content
raw_uid = lesson.id
if raw_uid:
uid = normalize_pronote_uid(raw_uid)
else:
uid = generate_deterministic_uid(
start=lesson.start,
end=lesson.end,
subject=lesson.subject.name if lesson.subject is not None else "",
teachers=list(lesson.teacher_names or ()),
rooms=list(lesson.classrooms or ()),
group=lesson.group_name,
)
lessons.append(
Lesson(
id=lesson.id,
id=uid,
start=lesson.start,
end=lesson.end,
subject=lesson.subject.name if lesson.subject is not None else "",

View File

@@ -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
View 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()

View 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

View 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,
)

View 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))

View 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