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>
301 lines
13 KiB
Python
301 lines
13 KiB
Python
"""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()
|