feat(M7): synchronisation différentielle CalDAV

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>
This commit is contained in:
2026-09-07 09:24:18 +02:00
parent ebbe39f1f0
commit b4b0247919
21 changed files with 5042 additions and 151 deletions

View File

@@ -139,7 +139,7 @@ Le projet doit implémenter les fonctionnalités suivantes, dans l'ordre logique
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ Message XMPP (XmppMessage) │ │
│ │ - synthesis: Optional[str] │ │
│ │ - synthesis: str | None │ │
│ │ - homeworks: List[Homework] │ │
│ │ - changes: List[AgendaChange] │ │
│ │ - messages: List[Message] │ │
@@ -267,10 +267,11 @@ Le projet utilise **`pydantic-settings`** pour valider et charger la configurati
| `PRONOTE_USERNAME` | Identifiant Pronote (si `pronotepy` utilisé). | `parent.dupont` | `str` |
| `PRONOTE_PASSWORD` | Mot de passe Pronote (si `pronotepy` utilisé). | `SecretStr` (masqué) | `SecretStr` |
| `PRONOTE_ENT` | Slug ENT supporté, résolu vers une fonction de `pronotepy.ent`. | `monbureaunumerique` | `str` |
| `CALDAV_URL` | URL du serveur CalDAV. | `https://caldav.example.com/calendars/...` | `str` |
| `CALDAV_URL` | URL du serveur CalDAV (masquée en `SecretStr`). | `https://caldav.example.com/calendars/...` | `SecretStr` |
| `CALDAV_USERNAME` | Identifiant CalDAV. | `user@example.com` | `str` |
| `CALDAV_PASSWORD` | Mot de passe CalDAV. | `SecretStr` (masqué) | `SecretStr` |
| `CALDAV_CALENDAR_PATH` | Chemin du calendrier CalDAV de destination. | `/pronote-sync/` | `str` |
| `CALDAV_ALLOW_INSECURE_HTTP` | Autoriser HTTP (non sécurisé) uniquement pour localhost. | `false` | `bool` |
| `XMPP_JID` | Identifiant XMPP (ex: `user@example.com`). | `user@example.com` | `str` |
| `XMPP_PASSWORD` | Mot de passe XMPP. | `SecretStr` (masqué) | `SecretStr` |
| `XMPP_RECIPIENT` | Destinataire XMPP (ex: `parent@example.com`). | `parent@example.com` | `str` |
@@ -336,6 +337,7 @@ PRONOTE_MESSAGES_SOURCE=pronotepy
# --- CalDAV ---
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
CALDAV_ALLOW_INSECURE_HTTP=false
CALDAV_USERNAME=user@example.com
CALDAV_PASSWORD=your_caldav_password
CALDAV_CALENDAR_PATH=/pronote-sync/
@@ -372,12 +374,12 @@ LOG_LEVEL=INFO
> ⚠️ **Décision d'implémentation** :
> L'implémentation utilise le style moderne de Pydantic v2 : `model_config = ConfigDict(frozen=True)` au lieu de `class Config`, pas de `json_encoders` (la sérialisation ISO est native en v2), `str | None` au lieu de `Optional[str]`, `list[str]` au lieu de `List[str]`.
> `AISettings.enabled` a pour valeur par défaut `False` (et non `True` comme indiqué dans le bloc de code).
> `AISettings.enabled` a pour valeur par défaut `False`.
> `XmppSettings` est entièrement défini en §10.2.3 avec tous les champs optionnels (valeurs par défaut) pour que `Settings()` fonctionne sans `.env`.
> `BlogSettings` a été ajouté (§5 bis.9.2) avec `enabled=False` et `rss_url` par défaut.
> `sync_past_days` et `sync_future_days` sont dans `AppSettings`, et non `CalDAVSettings`.
> `CalDAVSettings.calendar_path` a pour valeur par défaut `"/pronote-sync/"` (et non `"/pronote-digest/"`).
> `XmppSettings.resource` a pour valeur par défaut `"pronote-sync"` (et non `"pronote-digest"`).
> `CalDAVSettings.calendar_path` a pour valeur par défaut `"/pronote-sync/"`.
> `XmppSettings.resource` a pour valeur par défaut `"pronote-sync"`.
```python
from typing import Literal
@@ -399,28 +401,29 @@ class PronoteSettings(BaseSettings):
class CalDAVSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="CALDAV_", env_file=".env", extra="ignore")
url: Optional[str] = None
username: Optional[str] = None
password: Optional[SecretStr] = None
calendar_path: str = "/pronote-digest/"
url: SecretStr | None = None
username: str | None = None
password: SecretStr | None = None
calendar_path: str = "/pronote-sync/"
allow_insecure_http: bool = False
sync_past_days: int = 7
sync_future_days: int = 30
class AISettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="AI_", env_file=".env", extra="ignore")
enabled: bool = True
enabled: bool = False
provider: Literal["openai", "litellm"] = "openai"
base_url: Optional[str] = None
api_key: Optional[SecretStr] = None
model: Optional[str] = None
base_url: str | None = None
api_key: SecretStr | None = None
model: str | None = None
class AppSettings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
dry_run: bool = False
log_level: str = "INFO"
theoretical_agenda_path: Optional[str] = None
theoretical_agenda_path: str | None = None
class Settings(BaseSettings):
@@ -537,7 +540,7 @@ def redact_secrets(text: str) -> str:
```python
import logging
import sys
from typing import Any
Any
from .redaction import redact_secrets
@@ -708,7 +711,7 @@ Un article du blog est représenté par le modèle Pydantic suivant :
```python
from datetime import datetime
from typing import Optional, List
Optional, List
from pydantic import BaseModel, Field
@@ -724,8 +727,8 @@ class BlogArticle(BaseModel):
updated_at: Optional[datetime] = Field(
None, description="Date de dernière mise à jour (si disponible)"
)
category: Optional[str] = Field(None, description="Catégorie de l'article")
author: Optional[str] = Field(None, description="Auteur (si disponible)")
category: str | None = Field(None, description="Catégorie de l'article")
author: str | None = Field(None, description="Auteur (si disponible)")
content_html: str = Field(..., description="Contenu HTML complet")
content_text: str = Field(..., description="Contenu en texte brut (pour XMPP)")
@@ -758,7 +761,7 @@ article = BlogArticle(
Les articles du blog sont agrégés avec d'autres sources externes (ex: messages Pronote) dans un modèle `ExternalInfo` :
```python
from typing import List
List
from datetime import datetime
from pydantic import BaseModel, Field
@@ -774,7 +777,7 @@ class ExternalInfo(BaseModel):
pronote_messages: List[Message] = Field(
default_factory=list, description="Liste des messages Pronote"
)
other_info: List[str] = Field(
other_info: list[str] = Field(
default_factory=list, description="Autres informations (extensible)"
)
@@ -1218,7 +1221,7 @@ blog_state.update_cache_headers(result.etag, result.last_modified)
#### 5 bis.8.1 Étape de récupération du blog (`pipeline/steps/fetch_blog.py`)
```python
from typing import List
List
from ..models.blog import BlogArticle
from ..sources.blog.rss import BlogRSSClient
from ..sources.blog.state import BlogRSSState
@@ -1227,7 +1230,7 @@ from ..sources.blog.state import BlogRSSState
def fetch_blog_step(
rss_client: BlogRSSClient,
blog_state: BlogRSSState,
enabled: bool = True,
enabled: bool = False,
) -> List[BlogArticle]:
"""
Étape de récupération des articles du blog.
@@ -1322,8 +1325,8 @@ class BlogSettings(BaseSettings):
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
pronote: PronoteSettings = PronoteSettings()
caldav: CalDAVSettings = CalDAVSettings()
xmpp: XmppSettings = XmppSettings()
caldav: CalDAVSettings = Field(default_factory=CalDAVSettings)
xmpp: XmppSettings = Field(default_factory=XmppSettings)
ai: AISettings = AISettings()
app: AppSettings = AppSettings()
blog: BlogSettings = BlogSettings() # Nouveau
@@ -1615,7 +1618,7 @@ Sinon :
**Exemple Python** :
```python
from typing import Optional, Tuple, List
Optional, Tuple, List
from datetime import date, timedelta
from ..models.agenda import Lesson, SchoolEvent
@@ -1624,7 +1627,7 @@ def resolve_target_day(
today: date,
lessons: List[Lesson],
school_events: List[SchoolEvent],
) -> Tuple[date, str, Optional[date], Optional[str]]:
) -> Tuple[date, str, Optional[date], str | None]:
"""
Détermine le jour cible pour le digest.
@@ -1685,7 +1688,7 @@ def resolve_target_day(
```python
import requests
from typing import Optional
Optional
from urllib.parse import urlparse
from .redaction import redact_url, redact_secrets
from ..models.agenda import RawCalendarData
@@ -1747,7 +1750,7 @@ def fetch_ical(url: str, timeout: int = 20) -> str:
return content
def get_calendar_name(raw_ical: str) -> Optional[str]:
def get_calendar_name(raw_ical: str) -> str | None:
"""
Extrait le nom du calendrier depuis X-WR-CALNAME.
@@ -1785,7 +1788,7 @@ Les devoirs apparaissent **deux fois** dans le flux iCal Pronote :
import re
import hashlib
from datetime import date
from typing import Optional, List
Optional, List
from ..models.agenda import Lesson
from ..models.homework import Homework as HomeworkModel
@@ -1918,7 +1921,7 @@ UID:Cours-16027-1-20260904T120218Z-Index-Education
import re
import hashlib
from datetime import datetime
from typing import Optional
Optional
def normalize_pronote_uid(uid: str) -> str:
@@ -1944,7 +1947,7 @@ def generate_deterministic_uid(
subject: str,
teachers: list[str],
rooms: list[str],
group: Optional[str] = None,
group: str | None = None,
) -> str:
"""
Génère un UID déterministe si aucun UID exploitable n'existe.
@@ -1975,7 +1978,7 @@ def generate_deterministic_uid(
#### 5.1.6 Parsing complet du flux iCal (`sources/pronote/ical.py`)
```python
from typing import List, Optional, Tuple
List, Optional, Tuple
from datetime import datetime, date
from icalendar import Calendar, Event
from ..models.agenda import Lesson, Homework, SchoolEvent, LessonStatus
@@ -2052,7 +2055,7 @@ def parse_header(header: str) -> dict:
return result
def parse_body(body: str) -> Tuple[Optional[str], List[dict]]:
def parse_body(body: str) -> Tuple[str | None, List[dict]]:
"""
Parse le corps HTML pour extraire le contenu pédagogique et les devoirs.
@@ -2398,7 +2401,7 @@ sélections le permettent.
```python
from datetime import datetime, date, time
from typing import List, Optional, Literal
List, Optional, Literal
from enum import Enum
from pydantic import BaseModel, Field, validator
@@ -2441,11 +2444,11 @@ class Lesson(BaseModel):
start: datetime = Field(..., description="Date/heure de début")
end: datetime = Field(..., description="Date/heure de fin")
subject: str = Field(..., description="Matière (ex: Mathématiques)")
teachers: List[str] = Field(default_factory=list, description="Liste des professeurs")
rooms: List[str] = Field(default_factory=list, description="Liste des salles")
group: Optional[str] = Field(None, description="Groupe (ex: Classe entière)")
teachers: list[str] = Field(default_factory=list, description="Liste des professeurs")
rooms: list[str] = Field(default_factory=list, description="Liste des salles")
group: str | None = Field(None, description="Groupe (ex: Classe entière)")
status: LessonStatus = Field(LessonStatus.NORMAL, description="Statut du cours")
content: Optional[str] = Field(None, description="Contenu pédagogique")
content: str | None = Field(None, description="Contenu pédagogique")
homework_blocks: List[HomeworkBlock] = Field(
default_factory=list, description="Blocs de devoirs extraits de la description"
)
@@ -2487,8 +2490,8 @@ class TheoreticalLesson(BaseModel):
start_time: time = Field(..., description="Heure de début")
end_time: time = Field(..., description="Heure de fin")
subject: str = Field(..., description="Matière")
teachers: List[str] = Field(default_factory=list, description="Liste des professeurs")
rooms: List[str] = Field(default_factory=list, description="Liste des salles")
teachers: list[str] = Field(default_factory=list, description="Liste des professeurs")
rooms: list[str] = Field(default_factory=list, description="Liste des salles")
class Config:
frozen = True
@@ -2503,7 +2506,7 @@ class Homework(BaseModel):
"""
id: str = Field(..., description="ID stable (hachage)")
subject: str = Field(..., description="Matière")
teachers: List[str] = Field(default_factory=list, description="Liste des professeurs")
teachers: list[str] = Field(default_factory=list, description="Liste des professeurs")
assigned_on: Optional[date] = Field(None, description="Date de distribution")
due_on: date = Field(..., description="Date d'échéance")
text: str = Field(..., description="Texte du devoir (brut)")
@@ -2581,7 +2584,7 @@ class XmppMessage(BaseModel):
**Sépare clairement la synthèse IA et la liste brute des devoirs** (décision [5](#5-synthèse-ia---protocole-pas-de-sdk-imposé)).
"""
target_date: date = Field(..., description="Date cible")
synthesis: Optional[str] = Field(
synthesis: str | None = Field(
None,
description="Synthèse IA (optionnelle). 3-5 phrases, ton chaleureux et sobre."
)
@@ -2651,13 +2654,13 @@ class CalDAVSyncPlan(BaseModel):
"""
lessons_to_add: List[Lesson] = Field(default_factory=list)
lessons_to_update: List[Lesson] = Field(default_factory=list)
lessons_to_remove: List[str] = Field(default_factory=list) # Liste d'UID
lessons_to_remove: list[str] = Field(default_factory=list) # Liste d'UID
homeworks_to_add: List[Homework] = Field(default_factory=list)
homeworks_to_update: List[Homework] = Field(default_factory=list)
homeworks_to_remove: List[str] = Field(default_factory=list) # Liste d'UID
homeworks_to_remove: list[str] = Field(default_factory=list) # Liste d'UID
school_events_to_add: List[SchoolEvent] = Field(default_factory=list)
school_events_to_update: List[SchoolEvent] = Field(default_factory=list)
school_events_to_remove: List[str] = Field(default_factory=list) # Liste d'UID
school_events_to_remove: list[str] = Field(default_factory=list) # Liste d'UID
class CalDAVSyncResult(BaseModel):
@@ -2669,7 +2672,7 @@ class CalDAVSyncResult(BaseModel):
added: int = Field(0, description="Nombre d'événements ajoutés")
updated: int = Field(0, description="Nombre d'événements mis à jour")
removed: int = Field(0, description="Nombre d'événements supprimés")
errors: List[str] = Field(default_factory=list, description="Liste des erreurs")
errors: list[str] = Field(default_factory=list, description="Liste des erreurs")
# --- Modèles de synthèse IA ---
@@ -2689,7 +2692,7 @@ class SynthesisResult(BaseModel):
"""
Résultat de la synthèse IA.
"""
text: Optional[str] = Field(None, description="Texte de la synthèse IA")
text: str | None = Field(None, description="Texte de la synthèse IA")
@@ -2720,6 +2723,8 @@ class SynthesisResult(BaseModel):
- **Plan explicite** : Le `CalDAVSyncPlan` (ajouts / mises à jour / suppressions) est **calculé explicitement avant l'exécution** de la synchronisation (voir §7.2).
- **Événements non gérés** : Les événements **non marqués** `X-PRONOTE-SYNC-MANAGED: v1` **ne sont jamais modifiés ni supprimés** : ils appartiennent à d'autres outils ou à l'utilisateur.
> **Note** : Les événements sont sérialisés sous forme de VCALENDAR complets (et non de VEVENT isolés), avec les en-têtes `VERSION:2.0` et `PRODID`, conformément à la RFC 5545.
### 7.2 Client CalDAV (`sync/caldav.py`)
Utilisation de la bibliothèque [`caldav`](https://pypi.org/project/caldav/) (Python 3.8+, maintenue).
@@ -2745,11 +2750,11 @@ Utilisation de la bibliothèque [`caldav`](https://pypi.org/project/caldav/) (Py
- **Contenu iCalendar** : chaque objet expose `event.icalendar_component` (un
`icalendar.Event`) donnant accès aux propriétés (`uid`, `summary`, `dtstart`, `dtend`,
`status`, `categories`, `X-PRONOTE-SYNC-MANAGED`).
- **Ajout** : `calendar.save_event(ical_text)` crée un événement (UID normalisé et
marqueur inclus).
- **Mise à jour** : modifier les propriétés de l'`icalendar_component` puis
`event.save()` (si la version installée le supporte), sinon supprimer puis recréer sur
le même UID via `save_event()`.
- **Ajout / mise à jour** : `upsert_event(vcalendar_text, uid)` applique la stratégie
suivante : `calendar.get_event_by_uid(uid)` pour récupérer l'événement existant ;
s'il existe, remplacer son contenu puis `event.save()` ; s'il est introuvable
(`NotFoundError`), créer un nouvel événement via `calendar.add_event(ical=vcalendar_text)`
(UID normalisé et marqueur inclus).
- **Suppression** : `event.delete()` — **uniquement** pour les événements marqués.
**Règles métier conservées** (indépendantes de la version de `caldav`) :
@@ -2770,27 +2775,34 @@ Utilisation de la bibliothèque [`caldav`](https://pypi.org/project/caldav/) (Py
les données Pronote (cours, devoirs, événements scolaires) et produire un
**`CalDAVSyncPlan` explicite** (`lessons_to_add`, `lessons_to_update`,
`lessons_to_remove`, etc.) — **aucune écriture** à ce stade.
3. **Exécution** : appliquer le plan (ajouts via `calendar.save_event()`, mises à jour si
les événements diffèrent, suppressions si l'UID est absent des données Pronote) ; en
mode `dry_run`, loguer le plan **sans rien écrire**.
3. **Exécution** : appliquer le plan (ajouts via `calendar.add_event(ical=...)`, mises à
jour des événements existants via `event.save()` après `get_event_by_uid()`,
suppressions si l'UID est absent des données Pronote) ; en mode `dry_run`, loguer le
plan **sans rien écrire**.
Exemple minimal (API réelle) :
```python
import caldav
# CalDAVSettings : url, username, password (SecretStr), calendar_path = "/pronote-sync/"
# CalDAVSettings : url (SecretStr), username, password (SecretStr), calendar_path = "/pronote-sync/", allow_insecure_http = False
settings = None # instance de CalDAVSettings (pydantic-settings)
from urllib.parse import urlparse
client = caldav.DAVClient(
url=settings.url,
username=settings.username,
password=settings.password.get_secret_value(),
)
principal = client.principal()
# Résolution du calendrier cible avec vérification stricte du chemin
cal_path = urlparse(str(c.url)).path.strip("/")
normalized_path = settings.calendar_path.strip("/")
calendar = next(
c for c in principal.calendars()
if str(c.url).rstrip("/").endswith(settings.calendar_path.rstrip("/"))
if cal_path == normalized_path or cal_path.endswith(f"/{normalized_path}")
)
for obj in calendar.objects():
@@ -2802,13 +2814,14 @@ for obj in calendar.objects():
**Exemple illustratif** (règles métier complètes — API partiellement ancienne) :
```python
from typing import List, Optional, Dict, Any
List, Optional, Dict, Any
from datetime import datetime, timedelta
import caldav
from caldav.elements import DAVCalendar, DAVEvent
from ..models.agenda import Lesson, Homework, SchoolEvent
from ..models.sync import CalDAVSyncResult, CalDAVSyncStatus
from ..utils.uid import normalize_pronote_uid
from pydantic import SecretStr
import logging
logger = logging.getLogger(__name__)
@@ -2826,16 +2839,18 @@ class CalDAVClient:
def __init__(
self,
url: str,
url: SecretStr,
username: str,
password: str,
password: SecretStr,
calendar_path: str = "/pronote-sync/",
allow_insecure_http: bool = False,
dry_run: bool = False,
):
self.url = url
self.username = username
self.password = password
self.calendar_path = calendar_path
self.allow_insecure_http = allow_insecure_http
self.dry_run = dry_run
self._client: Optional[caldav.DAVClient] = None
self._calendar: Optional[DAVCalendar] = None
@@ -2843,9 +2858,10 @@ class CalDAVClient:
def connect(self) -> None:
"""Établit la connexion au serveur CalDAV."""
self._client = caldav.DAVClient(
url=self.url,
url=self.url.get_secret_value(),
username=self.username,
password=self.password,
password=self.password.get_secret_value(),
allow_insecure_http=self.allow_insecure_http,
)
# Résoudre le calendrier via calendar_path (cf. CalDAVSettings) :
@@ -3029,14 +3045,22 @@ class CalDAVClient:
return True
def sync(
self,
lessons: List[Lesson],
homeworks: List[Homework],
school_events: List[SchoolEvent],
past_days: int = 7,
future_days: int = 30,
) -> CalDAVSyncResult:
def sync(
self,
lessons: List[Lesson],
homeworks: List[Homework],
school_events: List[SchoolEvent],
past_days: int = 7,
future_days: int = 30,
) -> CalDAVSyncResult:
"""
Synchronise les événements Pronote vers CalDAV.
Les événements sont sérialisés sous forme de VCALENDAR complets (et non de VEVENT isolés),
avec les en-têtes VERSION:2.0 et PRODID. L'upsert est réalisé par UID stable :
- Si l'UID existe, mise à jour uniquement si les champs gérés diffèrent.
- Si l'UID n'existe pas, ajout.
- Les événements non marqués X-PRONOTE-SYNC-MANAGED ne sont jamais modifiés ni supprimés.
"""
"""
Synchronise les événements Pronote vers CalDAV.
**Idempotent** : Deux exécutions identiques sans changement externe ne modifient pas le calendrier.
@@ -3088,24 +3112,27 @@ class CalDAVClient:
uid = self._get_event_uid(event)
existing_by_uid[uid] = event
# **Tests d'idempotence** : Deux exécutions consécutives avec les mêmes données
# ne doivent effectuer **aucune écriture** (result.added = 0, result.updated = 0, result.removed = 0).
# Voir les tests dans `tests/integration/test_caldav.py` (ex: `test_sync_idempotent`).
# **Tests d'idempotence** : Deux exécutions consécutives avec les mêmes données
# ne doivent effectuer **aucune écriture** (result.added = 0, result.updated = 0, result.removed = 0).
# Voir les tests dans `tests/integration/test_caldav.py` (ex: `test_sync_idempotent`).
# Synchroniser les cours
for lesson in lessons:
if not (start_date <= lesson.start.date() <= end_date):
continue
# **Sérialisation VCALENDAR** : Chaque événement est encapsulé dans un VCALENDAR
# complet avec VERSION:2.0 et PRODID, conformément à la RFC 5545.
uid = lesson.id
if uid in existing_by_uid:
# Comparer l'événement existant avec le nouvel événement
existing_event = existing_by_uid[uid]
new_event = self._build_event(lesson)
# Synchroniser les cours (upsert par UID stable)
for lesson in lessons:
if not (start_date <= lesson.start.date() <= end_date):
continue
# Ne mettre à jour que si les événements diffèrent
if not self._events_equal(existing_event, new_event):
if not self.dry_run:
uid = lesson.id
if uid in existing_by_uid:
# Comparer l'événement existant avec le nouvel événement
existing_event = existing_by_uid[uid]
new_event = self._build_event(lesson)
# Ne mettre à jour que si les événements diffèrent
if not self._events_equal(existing_event, new_event):
if not self.dry_run:
try:
existing_event.vobject_instance = new_event.vobject_instance
existing_event.save()
@@ -3134,20 +3161,20 @@ class CalDAVClient:
result.added += 1
logger.info(f"[DRY-RUN] Ajout de {uid}")
# Synchroniser les devoirs
for homework in homeworks:
if not (start_date <= homework.due_on <= end_date):
continue
# Synchroniser les devoirs (upsert par UID stable)
for homework in homeworks:
if not (start_date <= homework.due_on <= end_date):
continue
uid = f"homework-{homework.id}"
if uid in existing_by_uid:
# Comparer l'événement existant avec le nouvel événement
existing_event = existing_by_uid[uid]
new_event = self._build_homework_event(homework)
uid = f"homework-{homework.id}"
if uid in existing_by_uid:
# Comparer l'événement existant avec le nouvel événement
existing_event = existing_by_uid[uid]
new_event = self._build_homework_event(homework)
# Ne mettre à jour que si les événements diffèrent
if not self._events_equal(existing_event, new_event):
if not self.dry_run:
# Ne mettre à jour que si les événements diffèrent
if not self._events_equal(existing_event, new_event):
if not self.dry_run:
try:
existing_event.vobject_instance = new_event.vobject_instance
existing_event.save()
@@ -3175,20 +3202,20 @@ class CalDAVClient:
result.added += 1
logger.info(f"[DRY-RUN] Ajout du devoir {uid}")
# Synchroniser les événements scolaires
for school_event in school_events:
if not (school_event.from_date >= start_date and school_event.to_date <= end_date):
continue
# Synchroniser les événements scolaires (upsert par UID stable)
for school_event in school_events:
if not (school_event.from_date >= start_date and school_event.to_date <= end_date):
continue
uid = f"school-event-{school_event.label}-{school_event.from_date.isoformat()}"
if uid in existing_by_uid:
# Comparer l'événement existant avec le nouvel événement
existing_event = existing_by_uid[uid]
new_event = self._build_school_event_event(school_event)
uid = f"school-event-{school_event.label}-{school_event.from_date.isoformat()}"
if uid in existing_by_uid:
# Comparer l'événement existant avec le nouvel événement
existing_event = existing_by_uid[uid]
new_event = self._build_school_event_event(school_event)
# Ne mettre à jour que si les événements diffèrent
if not self._events_equal(existing_event, new_event):
if not self.dry_run:
# Ne mettre à jour que si les événements diffèrent
if not self._events_equal(existing_event, new_event):
if not self.dry_run:
try:
existing_event.vobject_instance = new_event.vobject_instance
existing_event.save()
@@ -3304,7 +3331,7 @@ class CalDAVClient:
### 8.2 Interface `TheoreticalAgendaProvider` (`sources/theoretical/provider.py`)
```python
from typing import Protocol, List, Optional
Protocol, List, Optional
from datetime import date, time
from ..models.agenda import TheoreticalLesson
@@ -3428,7 +3455,7 @@ La parité des semaines est configurée via deux paramètres :
```python
from datetime import date, timedelta
from typing import Literal
Literal
def week_parity(
@@ -3532,7 +3559,7 @@ def match_theoretical_lesson(
### 8.5 Logique de comparaison (`sync/diff.py`)
```python
from typing import List, Tuple, Optional
List, Tuple, Optional
from datetime import date, time, timedelta
from ..models.agenda import Lesson, TheoreticalLesson
from ..models.diff import AgendaDiff, AgendaChange, AgendaChangeType
@@ -3761,7 +3788,7 @@ class AgendaComparator:
### 9.2 Protocole `SynthesisProvider` (`synthesis/provider.py`)
```python
from typing import Protocol, Optional
Protocol, Optional
from ..models.synthesis import SynthesisInput, SynthesisResult
@@ -3789,7 +3816,7 @@ class SynthesisProvider(Protocol):
### 9.3 Adaptateur OpenAI (`synthesis/openai.py`)
```python
from typing import Optional
Optional
import httpx
from ..models.synthesis import SynthesisInput, SynthesisResult
from .provider import SynthesisProvider
@@ -3828,8 +3855,8 @@ Exemple de format attendu :
def __init__(
self,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
base_url: str | None = None,
api_key: str | None = None,
model: str = "gpt-4o-mini",
):
self.base_url = base_url.rstrip("/") if base_url else "https://api.openai.com/v1"
@@ -3937,7 +3964,7 @@ pip install .[ai-litellm]
```
```python
from typing import Optional
Optional
import litellm
from ..models.synthesis import SynthesisInput, SynthesisResult
from .provider import SynthesisProvider
@@ -3959,8 +3986,8 @@ class LiteLLMSynthesisProvider:
def __init__(
self,
model: str = "gpt-4o-mini",
api_key: Optional[str] = None,
base_url: Optional[str] = None,
api_key: str | None = None,
base_url: str | None = None,
):
self.model = model
self.api_key = api_key
@@ -4012,14 +4039,14 @@ class LiteLLMSynthesisProvider:
### 9.5 Factory pour les fournisseurs IA (`synthesis/__init__.py`)
```python
from typing import Optional
Optional
from .provider import SynthesisProvider
from .openai import OpenAISynthesisProvider
from .litellm import LiteLLMSynthesisProvider
from ..config.settings import AISettings
def get_synthesis_provider(settings: AISettings, provider: Optional[str] = None) -> Optional[SynthesisProvider]:
def get_synthesis_provider(settings: AISettings, provider: str | None = None) -> Optional[SynthesisProvider]:
"""
Fabrique un fournisseur de synthèse IA selon la configuration.
@@ -4177,7 +4204,7 @@ class XmppSettings(BaseSettings):
### 10.3 Protocole `Channel` (`channels/protocol.py`)
```python
from typing import Protocol
Protocol
from ..models.xmpp import XmppMessage
@@ -4209,7 +4236,7 @@ class Channel(Protocol):
```python
import asyncio
from typing import Optional, Awaitable
Optional, Awaitable
import slixmpp
from slixmpp.exceptions import IqError, IqTimeout
from ..models.xmpp import XmppMessage
@@ -4412,7 +4439,7 @@ class SyncXmppChannel:
### 10.4 Factory pour les canaux (`channels/__init__.py`)
```python
from typing import List, Dict, Type
List, Dict, Type
from .protocol import Channel
from .xmpp import SyncXmppChannel
from ..config.settings import Settings
@@ -4485,7 +4512,7 @@ nécessaire mais ne créent pas une seconde hiérarchie dans `pipeline/steps/err
```python
from enum import Enum, auto
from typing import Optional
Optional
class ErrorSeverity(Enum):
@@ -4503,7 +4530,7 @@ class PipelineError(Exception):
self,
message: str,
severity: ErrorSeverity = ErrorSeverity.ERROR,
step: Optional[str] = None,
step: str | None = None,
recoverable: bool = False,
):
super().__init__(message)
@@ -4516,14 +4543,14 @@ class PipelineError(Exception):
class PipelineWarning(PipelineError):
"""Avertissement dans le pipeline (non bloquant)."""
def __init__(self, message: str, step: Optional[str] = None):
def __init__(self, message: str, step: str | None = None):
super().__init__(message, ErrorSeverity.WARNING, step, recoverable=True)
class PipelineCriticalError(PipelineError):
"""Erreur critique dans le pipeline (bloquante)."""
def __init__(self, message: str, step: Optional[str] = None):
def __init__(self, message: str, step: str | None = None):
super().__init__(message, ErrorSeverity.CRITICAL, step, recoverable=False)
```
@@ -4531,7 +4558,7 @@ class PipelineCriticalError(PipelineError):
### 11.3 Gestion des erreurs dans le pipeline (`pipeline/run.py`)
```python
from typing import List, Optional, Tuple
List, Optional, Tuple
from ..models.agenda import Lesson, Homework, SchoolEvent
from ..models.xmpp import XmppMessage
from ..models.pronote import PronoteData
@@ -4574,6 +4601,7 @@ class PipelineRunner:
self,
pronote_fetcher: PronoteFetcher,
caldav_client: CalDAVClient,
allow_insecure_http: bool = False,
agenda_comparator: AgendaComparator,
synthesis_provider: Optional[SynthesisProvider],
channel: Channel,
@@ -4779,7 +4807,7 @@ from .errors import PipelineError, ErrorSeverity
def fetch_blog_step(
rss_client: BlogRSSClient,
blog_state: BlogRSSState,
enabled: bool = True,
enabled: bool = False,
) -> list[BlogArticle]:
"""
Étape de récupération des articles du blog du collège.
@@ -5284,7 +5312,8 @@ def test_pipeline_full(mock_requests_get, mock_caldav_client, mock_ai_provider,
caldav_client = CalDAVClient(
url=sample_settings.caldav.url,
username=sample_settings.caldav.username,
password=sample_settings.caldav.password.get_secret_value(),
password=sample_settings.caldav.password,
allow_insecure_http=sample_settings.caldav.allow_insecure_http,
dry_run=True,
)
@@ -5795,7 +5824,7 @@ Exemple de ligne cron (exécution tous les jours à 18h) :
|---------------------------------------|------------------------------------------------------------------------------------|------------------------------------------------------------------------------|
| Échec de la récupération iCal | Token `icalsecurise` expiré ou invalide. | Régénérer le token depuis Pronote. |
| Échec de la connexion Pronote (`pronotepy`) | Identifiants incorrects ou ENT non supporté. | Vérifier `PRONOTE_USERNAME`, `PRONOTE_PASSWORD`, `PRONOTE_ENT`. |
| Échec de la connexion CalDAV | URL, identifiant ou mot de passe CalDAV incorrect. | Vérifier `CALDAV_URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD`. |
| Échec de la connexion CalDAV | URL, identifiant ou mot de passe CalDAV incorrect, ou HTTP non autorisé pour l'hôte. | Vérifier `CALDAV_URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD`, `CALDAV_ALLOW_INSECURE_HTTP`. |
| Échec de la connexion XMPP | Identifiant ou mot de passe XMPP incorrect. | Vérifier `XMPP_JID`, `XMPP_PASSWORD`. |
| Échec de la synthèse IA | Clé API IA invalide ou modèle non disponible. | Vérifier `AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL`. |
| Aucun cours récupéré | Flux iCal vide ou `pronotepy` non configuré. | Vérifier `PRONOTE_ICAL_URL` ou les identifiants `pronotepy`. |