Files
Antoine Van Elstraete a1bae41be8 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>
2026-09-07 12:24:22 +02:00

84 lines
3.0 KiB
Python

"""Utilitaires de gestion des identifiants uniques (UID) des événements.
Ce module fournit la normalisation des UIDs Pronote (suppression des
suffixes temporels) et la génération d'UIDs déterministes par hachage
des champs clés d'un événement, garantissant l'idempotence de la
synchronisation.
"""
from __future__ import annotations
import hashlib
import re
from datetime import datetime
from zoneinfo import ZoneInfo
_TEMPORAL_SUFFIX_PATTERN = re.compile(r"-\d{8}T\d{6}Z-Index-Education$")
_EDUCATION_SUFFIX_PATTERN = re.compile(r"-Index-Education$")
def normalize_pronote_uid(uid: str) -> str:
"""Normalise un UID Pronote en supprimant ses suffixes temporels.
Les suffixes de type ``-AAAAMMJJTHHMMSSZ-Index-Education`` puis
``-Index-Education`` sont retirés. La fonction est idempotente :
appliquée à un UID déjà normalisé, elle retourne la même valeur.
:param uid: UID brut provenant de Pronote (ex: ``L-1234-20250901T080000Z-Index-Education``).
:return: UID normalisé, sans suffixe temporel ni marque ``Index-Education``.
:rtype: str
"""
normalized = _TEMPORAL_SUFFIX_PATTERN.sub("", uid)
return _EDUCATION_SUFFIX_PATTERN.sub("", normalized)
def normalize_datetime_to_utc(dt: datetime) -> datetime:
"""Normalise une datetime vers UTC pour les signatures et hachages.
Les datetimes naïves sont interprétées comme Europe/Paris puis converties
vers UTC. Les datetimes conscientes sont converties vers UTC.
:param dt: Datetime à normaliser (naïve ou consciente).
:return: Datetime en UTC.
:rtype: datetime
"""
if dt.tzinfo is None:
return dt.replace(tzinfo=ZoneInfo("Europe/Paris")).astimezone(ZoneInfo("UTC"))
return dt.astimezone(ZoneInfo("UTC"))
def generate_deterministic_uid(
start: datetime,
end: datetime,
subject: str,
teachers: list[str],
rooms: list[str],
group: str | None = None,
) -> str:
"""Génère un UID déterministe par hachage des champs clés d'un événement.
Utilisé lorsqu'aucun UID exploitable n'est disponible : deux appels avec
des champs identiques produisent le même identifiant, ce qui garantit
l'idempotence de la synchronisation.
:param start: Début de l'événement.
:param end: Fin de l'événement.
:param subject: Intitulé de la matière.
:param teachers: Liste des enseignants (triée avant hachage).
:param rooms: Liste des salles (triée avant hachage).
:param group: Groupe éventuel ; traité comme chaîne vide si absent.
:return: Identifiant déterministe : 12 premiers caractères hexadécimaux
du SHA-1 des champs clés joints par ``|``.
:rtype: str
"""
parts = [
normalize_datetime_to_utc(start).isoformat(),
normalize_datetime_to_utc(end).isoformat(),
subject,
",".join(sorted(teachers)),
",".join(sorted(rooms)),
group or "",
]
payload = "|".join(parts).encode("utf-8")
return hashlib.sha1(payload, usedforsecurity=False).hexdigest()[:12]