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

@@ -32,6 +32,7 @@ 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
from pronote_sync.utils.uid import normalize_pronote_uid
logger = logging.getLogger(__name__)
@@ -158,33 +159,41 @@ class CalDAVGateway:
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]]:
def list_managed_events(self, start: datetime, end: datetime) -> list[tuple[str, 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`).
Pour chaque VEVENT, l'UID brut tel que stocké sur le serveur est
conservé ainsi que sa forme canonique obtenue via
:func:`~pronote_sync.utils.uid.normalize_pronote_uid` — la même
normalisation que celle appliquée aux événements locaux — afin que
le planificateur puisse apparier les événements distants suffixés aux
événements Pronote normalisés.
: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]]
:return: Triplets ``(raw_uid, canonical_uid, vevent)`` pour chaque
événement géré trouvé.
:rtype: list[tuple[str, 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]] = []
result: list[tuple[str, str, Any]] = []
error_msg: str | None = None
try:
events = self._calendar.date_search(start=start, end=end, expand=True)
events = self._calendar.search(start=start, end=end, event=True, 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))
raw_uid = str(vevent.get("UID"))
canonical_uid = normalize_pronote_uid(raw_uid)
result.append((raw_uid, canonical_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)
@@ -194,22 +203,46 @@ class CalDAVGateway:
) from None
return result
def _is_managed_event(self, event: Any) -> bool:
"""Détermine si un événement distant est géré par pronote-sync.
Vérifie la présence du marqueur de gestion (:data:`MANAGED_PROPERTY`
avec la valeur :data:`MANAGED_VALUE`) sur au moins un des composants
VEVENT de l'événement, selon le même motif que
:meth:`list_managed_events`.
:param event: Objet événement distant exposant la propriété
``icalendar_component`` retournant un ``icalendar.Calendar``.
:return: ``True`` si l'événement porte le marqueur de gestion,
``False`` sinon.
:rtype: bool
"""
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:
return True
return False
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.
``get_event_by_uid`` : s'il est introuvable (``NotFoundError``), un
nouvel événement est créé via ``add_event``. S'il existe, son contenu
est remplacé puis sauvegardé — uniquement si l'événement est géré par
l'outil (marqueur :data:`MANAGED_PROPERTY` avec la valeur
:data:`MANAGED_VALUE`). Un événement existant non géré provoque une
:class:`PronoteSyncError` explicite et n'est jamais modifié. Toute
autre exception est journalisée avec :func:`redact_exception` puis
re-levée en :class:`PronoteSyncError` — 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.
:raises PronoteSyncError: Si la passerelle n'est pas connectée, si
l'événement distant n'est pas géré par l'outil, ou si l'opération
échoue.
"""
if self._calendar is None:
raise PronoteSyncError("Passerelle CalDAV non connectée") from None
@@ -220,8 +253,14 @@ class CalDAVGateway:
except NotFoundError:
self._calendar.add_event(ical=vcalendar_text)
else:
if not self._is_managed_event(event):
raise PronoteSyncError(
"Conflit d'UID : l'événement distant n'est pas géré par pronote-sync"
) from None
event.data = vcalendar_text
event.save()
except PronoteSyncError:
raise
except Exception as exc:
error_msg = redact_exception(exc)
logger.error(
@@ -237,11 +276,15 @@ class CalDAVGateway:
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.
Récupère l'événement distant via ``get_event_by_uid`` : s'il est
introuvable (``NotFoundError``), la suppression est un succès
idempotent et la méthode retourne silencieusement. S'il existe, il
n'est supprimé que s'il est géré par l'outil (marqueur
:data:`MANAGED_PROPERTY` avec la valeur :data:`MANAGED_VALUE`) ; un
événement non géré est laissé intact, un avertissement est
journalisé et la méthode retourne sans erreur. Toute autre exception
est journalisée avec :func:`redact_exception` et re-levée en
:class:`PronoteSyncError` — 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
@@ -252,7 +295,16 @@ class CalDAVGateway:
error_msg: str | None = None
try:
event = self._calendar.get_event_by_uid(uid)
if not self._is_managed_event(event):
logger.warning(
"Suppression refusée : l'événement distant UID=%s n'est pas géré par "
"pronote-sync",
redact_secrets(uid),
)
return
event.delete()
except NotFoundError:
return
except Exception as exc:
error_msg = redact_exception(exc)
logger.error(

View File

@@ -13,6 +13,7 @@ mais le résultat reflète les opérations qui auraient été effectuées.
from __future__ import annotations
import logging
from collections.abc import Mapping
from pronote_sync.errors import PronoteSyncError
from pronote_sync.models.agenda import Lesson, SchoolEvent
@@ -67,7 +68,12 @@ class CalDAVSyncExecutor:
self._gateway = gateway
self._dry_run = dry_run
def execute(self, plan: CalDAVSyncPlan) -> CalDAVSyncResult:
def execute(
self,
plan: CalDAVSyncPlan,
*,
remote_raw_by_canonical: Mapping[str, str] | None = None,
) -> 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
@@ -80,6 +86,10 @@ class CalDAVSyncExecutor:
(reprise idempotente), sinon ``SUCCESS``.
:param plan: Plan de synchronisation à appliquer.
:param remote_raw_by_canonical: Mapping canonical_uid -> raw_uid des
événements distants gérés, requis pour cibler l'UID brut lors des
mises à jour. ``None`` ou une clé absente entraîne une erreur
consignée dans ``result.errors`` pour chaque mise à jour concernée.
:return: Résultat de la synchronisation (statut, compteurs, erreurs).
:rtype: CalDAVSyncResult
"""
@@ -89,7 +99,12 @@ class CalDAVSyncExecutor:
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)
self._do_save(
lesson,
result,
is_update=True,
remote_raw_by_canonical=remote_raw_by_canonical,
)
for uid in plan.lessons_to_remove:
self._do_delete(uid, result)
@@ -97,7 +112,12 @@ class CalDAVSyncExecutor:
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)
self._do_save(
homework,
result,
is_update=True,
remote_raw_by_canonical=remote_raw_by_canonical,
)
for uid in plan.homeworks_to_remove:
self._do_delete(uid, result)
@@ -105,7 +125,12 @@ class CalDAVSyncExecutor:
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)
self._do_save(
school_event,
result,
is_update=True,
remote_raw_by_canonical=remote_raw_by_canonical,
)
for uid in plan.school_events_to_remove:
self._do_delete(uid, result)
@@ -123,6 +148,7 @@ class CalDAVSyncExecutor:
model: Lesson | Homework | SchoolEvent,
result: CalDAVSyncResult,
is_update: bool,
remote_raw_by_canonical: Mapping[str, str] | None = None,
) -> None:
"""Écrit un événement sur la passerelle, ou simule l'écriture.
@@ -133,12 +159,18 @@ class CalDAVSyncExecutor:
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``.
``result.errors``. Pour une mise à jour, l'UID cible est l'UID brut
distant (via ``remote_raw_by_canonical``) afin de mettre à jour le
vrai événement distant au lieu d'en créer un doublon ; si le mapping
est absent, l'erreur est consignée dans ``result.errors`` sans
interrompre le lot.
: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.
:param remote_raw_by_canonical: Mapping canonical_uid -> raw_uid des
événements distants gérés, utilisé uniquement pour les mises à jour.
"""
action = "mise à jour" if is_update else "ajout"
uid = _model_uid(model)
@@ -151,7 +183,15 @@ class CalDAVSyncExecutor:
return
try:
vcalendar_text = model_to_vcalendar_text(model)
self._gateway.upsert_event(vcalendar_text, uid)
target_uid = uid
if is_update:
if remote_raw_by_canonical is None or uid not in remote_raw_by_canonical:
result.errors.append(
f"UID canonique sans correspondant distant : {redact_secrets(uid)}"
)
return
target_uid = remote_raw_by_canonical[uid]
self._gateway.upsert_event(vcalendar_text, target_uid)
except PronoteSyncError as exc:
result.errors.append(redact_secrets(str(exc)))
return

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

View File

@@ -21,6 +21,7 @@ from icalendar import Calendar, Component, Event, vDate, vDatetime
from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent
from pronote_sync.models.homework import Homework
from pronote_sync.utils.uid import normalize_datetime_to_utc
#: Propriété iCalendar marquant un événement géré par ``pronote-sync``.
MANAGED_PROPERTY = "X-PRONOTE-SYNC-MANAGED"
@@ -175,7 +176,10 @@ def component_to_signature(component: Component) -> str:
continue
value = getattr(raw, "dt", raw)
if hasattr(value, "isoformat"):
rendered = value.isoformat()
if isinstance(value, datetime):
rendered = normalize_datetime_to_utc(value).isoformat()
else:
rendered = value.isoformat()
else:
rendered = str(value).strip().lower()
props.append(f"{key.lower()}={rendered}")

View File

@@ -13,7 +13,7 @@ from __future__ import annotations
import logging
from collections.abc import Callable
from datetime import datetime, timedelta
from datetime import datetime, time, timedelta
from typing import Any
from pronote_sync.config.settings import Settings
@@ -31,28 +31,69 @@ def synchronize(
pronote_data: PronoteData,
settings: Settings,
client_factory: Callable[..., Any] | None = None,
*,
now: datetime | 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).
La fenêtre de synchronisation est calculée en **journées complètes** :
elle commence à minuit de ``aujourd'hui - sync_past_days`` (inclusive) et
se termine, de façon exclusive, à minuit de ``aujourd'hui +
sync_future_days + 1``, afin que le dernier jour de la fenêtre soit couvert
en entier. Cette fenêtre s'applique aux **deux** côtés de la
synchronisation : les événements distants gérés scannés sur la passerelle
et les données Pronote locales filtrées (cours filtrés sur ``start``,
devoirs sur ``due_on`` en jour, événements scolaires sur le chevauchement
de leur période) avant d'être transmises au planificateur. 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
l'instant de référence par défaut, remplaçable via ``now`` pour les tests.
: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).
:param now: Instant de référence pour le calcul de la fenêtre ; par défaut
``datetime.now()``.
: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)
now = now or datetime.now()
window_start = datetime.combine(
(now - timedelta(days=settings.app.sync_past_days)).date(),
time(0, 0),
)
window_end = datetime.combine(
(now + timedelta(days=settings.app.sync_future_days + 1)).date(),
time(0, 0),
)
# Application de la fenêtre aux données Pronote locales avant le passage au
# planificateur : les événements hors fenêtre ne doivent être ni écrits sur
# le calendrier ni déclencher de suppression d'un événement distant géré.
filtered_lessons = [
lesson for lesson in pronote_data.lessons if window_start <= lesson.start < window_end
]
filtered_homeworks = [
hw for hw in pronote_data.homeworks if window_start.date() <= hw.due_on < window_end.date()
]
filtered_school_events = [
se
for se in pronote_data.school_events
if se.from_date < window_end.date() and se.to_date > window_start.date()
]
filtered_pronote = PronoteData(
lessons=filtered_lessons,
homeworks=filtered_homeworks,
school_events=filtered_school_events,
messages=pronote_data.messages,
target_date=pronote_data.target_date,
generated_at=pronote_data.generated_at,
)
if (
settings.caldav.url is None
@@ -65,13 +106,13 @@ def synchronize(
gateway = CalDAVGateway(settings.caldav, client_factory=client_factory)
try:
with gateway:
remote_managed = gateway.list_managed_events(start=start, end=end)
remote_managed = gateway.list_managed_events(start=window_start, end=window_end)
logger.info(
"Synchronisation CalDAV : %d événements distants gérés trouvés",
len(remote_managed),
)
plan = compute_plan(pronote_data, remote_managed)
plan, remote_raw_by_canonical = compute_plan(filtered_pronote, remote_managed)
n_add = (
len(plan.lessons_to_add)
+ len(plan.homeworks_to_add)
@@ -97,7 +138,7 @@ def synchronize(
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)
return executor.execute(plan, remote_raw_by_canonical=remote_raw_by_canonical)
except PronoteSyncError:
logger.error("Échec de la synchronisation CalDAV")
# Re-lève la même exception de domaine sans en créer de nouvelle.