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>
113 lines
4.5 KiB
Python
113 lines
4.5 KiB
Python
"""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,
|
|
)
|