fix(M7): corrections d'audit FIXME_M7 — sécurité, fenêtre, UID, timezone

Corrige les 5 constats de l'audit FIXME_M7 :

#1 (Bloquant) — Protection des événements non marqués :
- upsert_event() vérifie le marqueur X-PRONOTE-SYNC-MANAGED avant
  modification ; lève PronoteSyncError en cas de collision avec un
  événement non géré (aucune écriture)
- delete_event() vérifie le marqueur ; no-op avec warning si non géré
- Méthode privée _is_managed_event() factorisant le contrôle

#2 (Bloquant) — Fenêtre de synchronisation :
- Calcul en journées entières (minuit à minuit exclusif)
- Filtrage des données locales (lessons, homeworks, school_events) avant
  passage au planner
- Paramètre now injectable pour les tests

#3 (Bloquant) — UID canonique vs brut :
- list_managed_events() retourne (raw_uid, canonical_uid, vevent)
- compute_plan() matche par UID canonique, route les raw UID vers
  *_to_remove, retourne le mapping remote_raw_by_canonical
- executor.execute() utilise le raw UID pour les mises à jour (pas de
  doublon)
- Pas de migration destructive des UID distants existants

#4 (Correction) — Normalisation temporelle UTC :
- normalize_datetime_to_utc() dans utils/uid.py : naïve → Europe/Paris →
  UTC ; consciente → UTC
- Utilisée par generate_deterministic_uid() et component_to_signature()
- Deux représentations du même instant → même UID et même signature

#5 (Compatibilité) — date_search déprécié :
- Remplacement par calendar.search(start, end, event=True, expand=True)

Documentation :
- GUIDE_DEV_PYTHON.md : suppression des références obsolètes à
  sync/state.py et état SQLite/JSON ; mise à jour de l'API CalDAV
  (search au lieu de date_search, upsert par UID)
- TODO.md : M7 décoché (corrections en cours de validation)

Tests : 390 passés, couverture 95.61%

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 12:24:22 +02:00
parent b4b0247919
commit a1bae41be8
14 changed files with 735 additions and 298 deletions

View File

@@ -28,20 +28,32 @@ from pronote_sync.sync.serialization import (
def compute_plan(
pronote_data: PronoteData,
remote_managed: list[tuple[str, Any]],
) -> CalDAVSyncPlan:
"""Calcule le plan de synchronisation CalDAV.
remote_managed: list[tuple[str, str, Any]],
) -> tuple[CalDAVSyncPlan, dict[str, str]]:
"""Calcule le plan de synchronisation CalDAV et le mapping des UID distants.
L'appariement entre les événements locaux et distants se fait sur l'UID
canonique (forme normalisée, identique pour une même source Pronote,
suffixe temporel retiré) tandis que les mutations (suppressions, mises à
jour) ciblent l'UID brut tel que stocké sur le serveur. Le mapping
``canonical_uid -> raw_uid`` retourné permet à l'exécuteur de cibler le
bon objet distant lors des mises à jour.
: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
:param remote_managed: Liste de tuples (raw_uid, canonical_uid, vevent)
pour les événements distants marqués comme gérés par pronote-sync.
:return: Tuple (plan de synchronisation, mapping canonical_uid -> raw_uid).
Les listes ``*_to_remove`` contiennent l'UID brut distant, les autres
listes contiennent les modèles Pronote locaux.
:rtype: tuple[CalDAVSyncPlan, dict[str, str]]
"""
remote_signatures: dict[str, str] = {}
for uid, vevent in remote_managed:
remote_signatures[uid] = component_to_signature(vevent)
#: canonical_uid -> signature sémantique du VEVENT distant (pour l'appariement).
remote_signatures_by_canonical: dict[str, str] = {}
#: canonical_uid -> UID brut distant (pour cibler le bon objet lors des mutations).
remote_raw_by_canonical: dict[str, str] = {}
for raw_uid, canonical_uid, vevent in remote_managed:
remote_signatures_by_canonical[canonical_uid] = component_to_signature(vevent)
remote_raw_by_canonical[canonical_uid] = raw_uid
lessons_to_add: list[Lesson] = []
lessons_to_update: list[Lesson] = []
@@ -49,18 +61,12 @@ def compute_plan(
local_lessons_by_uid: dict[str, Lesson] = {lesson.id: lesson for lesson in pronote_data.lessons}
for lesson in pronote_data.lessons:
local_canonical = lesson.id
local_sig = component_to_signature(lesson_to_vevent(lesson))
if lesson.id not in remote_signatures:
if local_canonical not in remote_signatures_by_canonical:
lessons_to_add.append(lesson)
elif remote_signatures[lesson.id] != local_sig:
elif remote_signatures_by_canonical[local_canonical] != 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] = []
@@ -70,15 +76,12 @@ def compute_plan(
f"homework-{homework.id}": homework for homework in pronote_data.homeworks
}
for homework in pronote_data.homeworks:
uid = f"homework-{homework.id}"
local_canonical = f"homework-{homework.id}"
local_sig = component_to_signature(homework_to_vevent(homework))
if uid not in remote_signatures:
if local_canonical not in remote_signatures_by_canonical:
homeworks_to_add.append(homework)
elif remote_signatures[uid] != local_sig:
elif remote_signatures_by_canonical[local_canonical] != 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] = []
@@ -89,24 +92,39 @@ def compute_plan(
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_canonical = 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:
if local_canonical not in remote_signatures_by_canonical:
school_events_to_add.append(school_event)
elif remote_signatures[uid] != local_sig:
elif remote_signatures_by_canonical[local_canonical] != 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,
# Détection des événements distants orphelins : un UID canonique distant
# absent des données locales est supprimé en ciblant l'UID brut stocké sur
# le serveur. L'acheminement vers la bonne liste de suppression se fait sur
# le préfixe de l'UID canonique.
for canonical_uid in remote_signatures_by_canonical:
raw_uid = remote_raw_by_canonical[canonical_uid]
if canonical_uid.startswith("homework-"):
if canonical_uid not in local_homeworks_by_uid:
homeworks_to_remove.append(raw_uid)
elif canonical_uid.startswith("school-event-"):
if canonical_uid not in local_school_events_by_uid:
school_events_to_remove.append(raw_uid)
elif canonical_uid not in local_lessons_by_uid:
lessons_to_remove.append(raw_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,
),
remote_raw_by_canonical,
)