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:
@@ -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`. |
|
||||
|
||||
Reference in New Issue
Block a user