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:
@@ -140,7 +140,7 @@
|
||||
"filename": "GUIDE_DEV_PYTHON.md",
|
||||
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
||||
"is_verified": true,
|
||||
"line_number": 5062,
|
||||
"line_number": 4940,
|
||||
"is_secret": false
|
||||
}
|
||||
],
|
||||
@@ -150,14 +150,14 @@
|
||||
"filename": "tests/unit/test_caldav_gateway.py",
|
||||
"hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f",
|
||||
"is_verified": false,
|
||||
"line_number": 151
|
||||
"line_number": 152
|
||||
},
|
||||
{
|
||||
"type": "Basic Auth Credentials",
|
||||
"filename": "tests/unit/test_caldav_gateway.py",
|
||||
"hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f",
|
||||
"is_verified": false,
|
||||
"line_number": 664
|
||||
"line_number": 763
|
||||
}
|
||||
],
|
||||
"tests/unit/test_caldav_security.py": [
|
||||
@@ -177,5 +177,5 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"generated_at": "2026-09-07T07:23:43Z"
|
||||
"generated_at": "2026-09-07T10:24:08Z"
|
||||
}
|
||||
|
||||
@@ -103,15 +103,14 @@ Le projet doit implémenter les fonctionnalités suivantes, dans l'ordre logique
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────────────────────────┐
|
||||
│ SYNCHRONISATION CALDAV │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ ┌───────────────────────────┐ │
|
||||
│ │ Plan de sync │ │ Sync │ │ État local │ │
|
||||
│ │ (CalDavSyncPlan)│ │ différentielle │ │ (SQLite/JSON) │ │
|
||||
│ └────────┬────────┘ └────────┬────────┘ └──────────────┬────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └───────────────────────┼────────────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Résultat de sync (CalDavSyncResult) │ │
|
||||
│ │ - La synchronisation CalDAV est **différentielle et idempotente** : │
|
||||
│ │ le scan du calendrier distant est la source de vérité. │
|
||||
│ │ Aucun état local (SQLite/JSON) n'est utilisé. │
|
||||
│ └─────────────────────────────────────────────────────────────────────────┘ │
|
||||
└───────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
@@ -202,8 +201,7 @@ pronote_sync/
|
||||
│ │ └── fallback.py # Logique de repli
|
||||
│ ├── blog/ # Blog (RSS)
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── rss.py # Client RSS (feedparser)
|
||||
│ │ └── state.py # État local (déduplication, cache HTTP)
|
||||
│ │ └── rss.py # Client RSS (feedparser)
|
||||
│ └── theoretical/ # Agenda théorique
|
||||
│ ├── __init__.py
|
||||
│ ├── file.py # Lecture fichier JSON (parité + vacances)
|
||||
@@ -211,8 +209,6 @@ pronote_sync/
|
||||
├── sync/ # Synchronisation CalDAV + Blog
|
||||
│ ├── __init__.py
|
||||
│ ├── caldav.py # Client CalDAV (caldav)
|
||||
│ ├── state.py # État de sync CalDAV (SQLite/JSON)
|
||||
│ ├── blog_state.py # État de sync Blog (déduplication, cache HTTP)
|
||||
│ └── diff.py # Logique de comparaison
|
||||
├── synthesis/ # Synthèse IA
|
||||
│ ├── __init__.py
|
||||
@@ -1070,135 +1066,18 @@ class BlogRSSClient:
|
||||
return text
|
||||
```
|
||||
|
||||
#### 5 bis.7.2 Déduplication et état local
|
||||
#### 5 bis.7.2 Déduplication et cache HTTP
|
||||
|
||||
La déduplication des articles du blog repose sur leur **GUID** (ou leur URL si le GUID est vide).
|
||||
|
||||
**Stratégie** :
|
||||
1. Stocker un **fichier d'état local** (ex: `.blog_rss_state.json`) contenant la version du
|
||||
format, l'**ensemble des GUID déjà traités** et les en-têtes de cache HTTP (`ETag` /
|
||||
`Last-Modified`) de la dernière réponse.
|
||||
2. À chaque récupération, ignorer les articles dont le GUID est **déjà présent** dans
|
||||
l'ensemble des GUID connus.
|
||||
3. Utiliser le **cache HTTP** (`If-Modified-Since` / `If-None-Match`) via `feedparser` pour
|
||||
éviter les requêtes inutiles.
|
||||
- Conserver en mémoire, **au sein du run**, l'ensemble des GUID déjà traités pour la déduplication.
|
||||
- Utiliser le **cache HTTP** (`ETag` / `Last-Modified`) via `feedparser` pour éviter les requêtes inutiles.
|
||||
|
||||
**Exemple de fichier d'état** :
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"known_guids": [
|
||||
"https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1625",
|
||||
"https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1626"
|
||||
],
|
||||
"etag": "abc123",
|
||||
"last_modified": "Wed, 01 Sep 2026 00:00:00 GMT"
|
||||
}
|
||||
```
|
||||
|
||||
Les GUID sont triés alphabétiquement pour une sortie JSON déterministe.
|
||||
|
||||
**Gestionnaire d'état** (`sources/blog/state.py`) :
|
||||
|
||||
```python
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_STATE_VERSION = 1
|
||||
|
||||
|
||||
class BlogRSSState:
|
||||
"""
|
||||
Gère l'état local pour la déduplication des articles du blog et le cache HTTP.
|
||||
"""
|
||||
|
||||
def __init__(self, state_file: str = ".blog_rss_state.json"):
|
||||
self._state_file = Path(state_file)
|
||||
self._known_guids: set[str] = set()
|
||||
self._etag: str | None = None
|
||||
self._last_modified: str | None = None
|
||||
self._load()
|
||||
|
||||
def _load(self) -> None:
|
||||
"""Charge l'état depuis le fichier."""
|
||||
if not self._state_file.exists():
|
||||
return
|
||||
try:
|
||||
data = json.loads(self._state_file.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict) or data.get("version") != _STATE_VERSION:
|
||||
logger.warning(
|
||||
"Fichier d'état blog RSS : version absente ou non supportée, "
|
||||
"démarrage avec un état vide."
|
||||
)
|
||||
return
|
||||
guids_data = data.get("known_guids", [])
|
||||
if isinstance(guids_data, list):
|
||||
self._known_guids = {guid for guid in guids_data if isinstance(guid, str)}
|
||||
etag_data = data.get("etag")
|
||||
if isinstance(etag_data, str):
|
||||
self._etag = etag_data
|
||||
last_modified_data = data.get("last_modified")
|
||||
if isinstance(last_modified_data, str):
|
||||
self._last_modified = last_modified_data
|
||||
except Exception as e:
|
||||
logger.warning(f"Échec du chargement de l'état du blog: {e}")
|
||||
self._known_guids = set()
|
||||
self._etag = None
|
||||
self._last_modified = None
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Sauvegarde l'état dans le fichier."""
|
||||
payload = {
|
||||
"version": _STATE_VERSION,
|
||||
"known_guids": sorted(self._known_guids),
|
||||
"etag": self._etag,
|
||||
"last_modified": self._last_modified,
|
||||
}
|
||||
try:
|
||||
with self._state_file.open("w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"Échec de la sauvegarde de l'état du blog: {e}")
|
||||
|
||||
def get_known_guids(self) -> frozenset[str]:
|
||||
"""Retourne une copie immuable des GUID connus."""
|
||||
return frozenset(self._known_guids)
|
||||
|
||||
def add_guids(self, guids: Iterable[str]) -> None:
|
||||
"""Ajoute des GUID à l'ensemble des GUID connus et sauvegarde."""
|
||||
new_guids = set(guids)
|
||||
if not new_guids:
|
||||
return
|
||||
self._known_guids.update(new_guids)
|
||||
self._save()
|
||||
|
||||
def get_cache_headers(self) -> tuple[str | None, str | None]:
|
||||
"""Retourne les en-têtes de cache HTTP mémorisés (etag, last_modified)."""
|
||||
return self._etag, self._last_modified
|
||||
|
||||
def update_cache_headers(self, etag: str | None, last_modified: str | None) -> None:
|
||||
"""Met à jour les en-têtes de cache HTTP et sauvegarde."""
|
||||
self._etag = etag
|
||||
self._last_modified = last_modified
|
||||
self._save()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Efface l'état (GUID et en-têtes de cache) et sauvegarde."""
|
||||
self._known_guids = set()
|
||||
self._etag = None
|
||||
self._last_modified = None
|
||||
self._save()
|
||||
```
|
||||
|
||||
**Utilisation dans le pipeline** :
|
||||
```python
|
||||
Aucun fichier d'état local n'est utilisé : l'état est géré en mémoire par run.
|
||||
# Initialisation
|
||||
rss_client = BlogRSSClient(rss_url=settings.blog.rss_url)
|
||||
blog_state = BlogRSSState()
|
||||
blog_state = ## (section obsolète supprimée)()
|
||||
|
||||
# Récupération des nouveaux articles
|
||||
known_guids = blog_state.get_known_guids()
|
||||
@@ -1224,12 +1103,12 @@ blog_state.update_cache_headers(result.etag, result.last_modified)
|
||||
List
|
||||
from ..models.blog import BlogArticle
|
||||
from ..sources.blog.rss import BlogRSSClient
|
||||
from ..sources.blog.state import BlogRSSState
|
||||
from ..sources.blog.state import ## (section obsolète supprimée)
|
||||
|
||||
|
||||
def fetch_blog_step(
|
||||
rss_client: BlogRSSClient,
|
||||
blog_state: BlogRSSState,
|
||||
blog_state: ## (section obsolète supprimée),
|
||||
enabled: bool = False,
|
||||
) -> List[BlogArticle]:
|
||||
"""
|
||||
@@ -1430,11 +1309,11 @@ def test_parse_blog_rss(mock_blog_rss_client):
|
||||
@pytest.mark.unittest
|
||||
def test_blog_deduplication(tmp_path):
|
||||
"""Test la déduplication des articles du blog."""
|
||||
from pronote_sync.sources.blog.state import BlogRSSState
|
||||
from pronote_sync.sources.blog.state import ## (section obsolète supprimée)
|
||||
|
||||
# Créer un fichier d'état temporaire
|
||||
state_file = tmp_path / "blog_state.json"
|
||||
state = BlogRSSState(state_file=str(state_file))
|
||||
state = ## (section obsolète supprimée)(state_file=str(state_file))
|
||||
|
||||
# Initialement, aucun article connu
|
||||
assert state.get_known_guids() == frozenset()
|
||||
@@ -3300,9 +3179,8 @@ class CalDAVClient:
|
||||
sauvegardes).
|
||||
|
||||
> **Évolution future** : si les performances l'exigent (calendrier très chargé, scans
|
||||
> trop coûteux), un **état local** (fichier JSON, SQLite ou sync-token CalDAV) pourra
|
||||
> être ajouté dans un jalon ultérieur, sans changer le contrat de la synchronisation
|
||||
> (§7.1, §7.2 et §7.4 restent valables).
|
||||
> trop coûteux), un **état local** (sync-token CalDAV) pourra être ajouté dans un jalon
|
||||
> ultérieur, sans changer le contrat de la synchronisation (§7.1, §7.2 et §7.4 restent valables).
|
||||
|
||||
### 7.4 Points clés
|
||||
- **Différentielle** : La synchronisation compare les UID existants avec ceux à synchroniser.
|
||||
@@ -4606,7 +4484,7 @@ class PipelineRunner:
|
||||
synthesis_provider: Optional[SynthesisProvider],
|
||||
channel: Channel,
|
||||
blog_rss_client: Optional["BlogRSSClient"] = None,
|
||||
blog_state: Optional["BlogRSSState"] = None,
|
||||
blog_state: Optional["## (section obsolète supprimée)"] = None,
|
||||
dry_run: bool = False,
|
||||
):
|
||||
self.pronote_fetcher = pronote_fetcher
|
||||
@@ -4800,13 +4678,13 @@ exception externe brute susceptible de contenir un secret.
|
||||
from pronote_sync.models.blog import BlogArticle
|
||||
from pronote_sync.sources.blog.result import BlogRSSFetchResult
|
||||
from pronote_sync.sources.blog.rss import BlogRSSClient
|
||||
from pronote_sync.sources.blog.state import BlogRSSState
|
||||
from pronote_sync.sources.blog.state import ## (section obsolète supprimée)
|
||||
from .errors import PipelineError, ErrorSeverity
|
||||
|
||||
|
||||
def fetch_blog_step(
|
||||
rss_client: BlogRSSClient,
|
||||
blog_state: BlogRSSState,
|
||||
blog_state: ## (section obsolète supprimée),
|
||||
enabled: bool = False,
|
||||
) -> list[BlogArticle]:
|
||||
"""
|
||||
@@ -5511,13 +5389,13 @@ TOTAL 1000 10 99%
|
||||
| Tokens dans les commits Git | Utiliser `.gitignore` pour `.env` et `pre-commit` pour bloquer les secrets. | `git grep "icalsecurise\|password" -- .` (contenu suivi courant) | ❌ Interdit |
|
||||
| Clés API dans le code | Toujours charger depuis les variables d'environnement. | `grep -r "api_key\s*=" src/` | ❌ Interdit |
|
||||
| Mots de passe en clair | Toujours utiliser `SecretStr` ou `getpass`. | `grep -r "password\s*=" src/` | ❌ Interdit |
|
||||
| Fichiers d'état non protégés | Appliquer `chmod 600` et exclure du Git (`.gitignore`). | `ls -la .pronote_sync_state.json` (doit être `-rw-------`) | ✅ Obligatoire |
|
||||
| Fichiers d'état non protégés | Appliquer `chmod 600` et exclure du Git (`.gitignore`). | Vérification manuelle des fichiers locaux sensibles | ✅ Obligatoire |
|
||||
|
||||
### 13.2 Validation des entrées
|
||||
|
||||
| **Risque** | **Mesure de mitigation** | **Vérification** | **Statut** |
|
||||
|-------------------------------------|----------------------------------------------------------------------------------------|-------------------------------------------|------------|
|
||||
| Injection SQL (si SQLite) | Utiliser des requêtes paramétrées (pas de string formatting). | Revue du code utilisant SQLite. | ✅ Obligatoire |
|
||||
| Validation des entrées SQL | Utiliser des requêtes paramétrées (pas de string formatting). | Revue du code utilisant des requêtes SQL. | ✅ Obligatoire |
|
||||
| Injection XMPP | Échapper les messages XMPP (slixmpp le fait automatiquement). | Tests avec des messages contenant `<`, `>`, `&`. | ✅ Obligatoire |
|
||||
| Parsing iCal malveillant | Valider que le flux contient `BEGIN:VCALENDAR` avant parsing. | Tests avec des flux invalides. | ✅ Obligatoire |
|
||||
| URLs malveillantes | Valider les URLs avec `urllib.parse` avant utilisation. | Tests avec des URLs malformées. | ✅ Obligatoire |
|
||||
@@ -5860,7 +5738,7 @@ Exemple de ligne cron (exécution tous les jours à 18h) :
|
||||
| **Flux iCal incomplet** | Certains établissements désactivent l'export des devoirs dans iCal. | Impossible de récupérer les devoirs via iCal. | Basculer sur `pronotepy` pour les devoirs. |
|
||||
| **`pronotepy` en maintenance** | `pronotepy` est en mode maintenance (bugfixes uniquement). | Risque de cassure si Pronote met à jour son protocole. | Surveiller les issues GitHub de `pronotepy`. |
|
||||
| **Messages non disponibles dans iCal** | Les messages, discussions et informations ne sont **pas** dans le flux iCal. | Impossible de récupérer ces données sans `pronotepy`. | Utiliser `pronotepy` pour les messages. |
|
||||
| **CalDAV : support variable** | Certains serveurs CalDAV ont des limitations (ex: pas de sync-token). | Synchronisation moins efficace. | Utiliser un état local (SQLite/JSON) pour compenser. |
|
||||
| **CalDAV : support variable** | Certains serveurs CalDAV ont des limitations (ex: pas de sync-token). | Synchronisation moins efficace. | Utiliser un sync-token CalDAV pour compenser si nécessaire. |
|
||||
| **XMPP : serveurs variés** | Les serveurs XMPP ont des configurations différentes (ex: authentification, TLS). | Problèmes de compatibilité possibles. | Tester avec le serveur XMPP cible avant déploiement. |
|
||||
| **IA : coûts et latence** | Les API IA peuvent être coûteuses et lentes. | Synthèse IA peut être désactivée ou lente. | Limiter la taille du prompt et utiliser un timeout. |
|
||||
| **Python 3.13.5+** | Le projet nécessite Python ≥ 3.13.5. | Incompatibilité avec les anciennes versions de Python. | Documenter clairement la version requise. |
|
||||
|
||||
16
TODO.md
16
TODO.md
@@ -131,20 +131,20 @@ Lire l'agenda théorique (JSON) via une interface de provider extensible, avec g
|
||||
- Une configuration incomplète (ancre de parité manquante alors que des leçons `even`/`odd` existent) produit une erreur explicite.
|
||||
|
||||
---
|
||||
|
||||
## M7. Synchronisation CalDAV — Priorité : Haute
|
||||
|
||||
Synchroniser différentiellement les événements Pronote vers le calendrier CalDAV, de façon idempotente.
|
||||
|
||||
- [x] Créer `sync/caldav.py` : passerelle CalDAV isolant la bibliothèque `caldav>=1.3.0` (connexion via `DAVClient`, résolution du calendrier via `calendar_path`, récupération/ajout/MAJ/suppression des événements, marqueur `X-PRONOTE-SYNC-MANAGED: v1`).
|
||||
- [x] Calculer le `CalDAVSyncPlan` (to_add / to_update / to_remove) par UID stable, explicitement avant l'exécution de la sync.
|
||||
- [x] Implémenter l'exécution du plan : ajout, mise à jour (si modifié), suppression (si absent). En mode `dry_run`, loguer le plan sans écrire.
|
||||
- [x] Vérifier sur fixture anonymisée que le même cours provenant d'iCal et de `pronotepy` possède le même identifiant canonique ; corriger la normalisation des UID dans `sources/pronote/client.py` à la frontière des sources si nécessaire.
|
||||
- [x] Implémenter la sync différentielle : conserver les cours annulés (`STATUS:CANCELLED`), ne pas supprimer.
|
||||
- [x] Garantir l'idempotence (2 exécutions identiques → même `CalDAVSyncResult`), sans état local persistant (scan du calendrier distant).
|
||||
- [x] Ne jamais modifier ou supprimer les événements non marqués `X-PRONOTE-SYNC-MANAGED`.
|
||||
- [ ] Créer `sync/caldav.py` : passerelle CalDAV isolant la bibliothèque `caldav>=1.3.0` (connexion via `DAVClient`, résolution du calendrier via `calendar_path`, récupération/ajout/MAJ/suppression des événements, marqueur `X-PRONOTE-SYNC-MANAGED: v1`).
|
||||
- [ ] Calculer le `CalDAVSyncPlan` (to_add / to_update / to_remove) par UID stable, explicitement avant l'exécution de la sync.
|
||||
- [ ] Implémenter l'exécution du plan : ajout, mise à jour (si modifié), suppression (si absent). En mode `dry_run`, loguer le plan sans écrire.
|
||||
- [ ] Vérifier sur fixture anonymisée que le même cours provenant d'iCal et de `pronotepy` possède le même identifiant canonique ; corriger la normalisation des UID dans `sources/pronote/client.py` à la frontière des sources si nécessaire.
|
||||
- [ ] Implémenter la sync différentielle : conserver les cours annulés (`STATUS:CANCELLED`), ne pas supprimer.
|
||||
- [ ] Garantir l'idempotence (2 exécutions identiques → même `CalDAVSyncResult`), sans état local persistant (scan du calendrier distant).
|
||||
- [ ] Ne jamais modifier ou supprimer les événements non marqués `X-PRONOTE-SYNC-MANAGED`.
|
||||
|
||||
### Critères d'acceptation
|
||||
|
||||
- Le plan de sync est correctement calculé (données Pronote vs événements distants gérés).
|
||||
- Un changement de source iCal ↔ `pronotepy` ne crée ni doublon ni suppression/ajout artificiel pour un cours équivalent.
|
||||
- Un run dry-run n'écrit rien ; deux runs identiques donnent un résultat identique.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import re
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
_TEMPORAL_SUFFIX_PATTERN = re.compile(r"-\d{8}T\d{6}Z-Index-Education$")
|
||||
_EDUCATION_SUFFIX_PATTERN = re.compile(r"-Index-Education$")
|
||||
@@ -31,6 +32,21 @@ def normalize_pronote_uid(uid: str) -> str:
|
||||
return _EDUCATION_SUFFIX_PATTERN.sub("", normalized)
|
||||
|
||||
|
||||
def normalize_datetime_to_utc(dt: datetime) -> datetime:
|
||||
"""Normalise une datetime vers UTC pour les signatures et hachages.
|
||||
|
||||
Les datetimes naïves sont interprétées comme Europe/Paris puis converties
|
||||
vers UTC. Les datetimes conscientes sont converties vers UTC.
|
||||
|
||||
:param dt: Datetime à normaliser (naïve ou consciente).
|
||||
:return: Datetime en UTC.
|
||||
:rtype: datetime
|
||||
"""
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=ZoneInfo("Europe/Paris")).astimezone(ZoneInfo("UTC"))
|
||||
return dt.astimezone(ZoneInfo("UTC"))
|
||||
|
||||
|
||||
def generate_deterministic_uid(
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
@@ -56,8 +72,8 @@ def generate_deterministic_uid(
|
||||
:rtype: str
|
||||
"""
|
||||
parts = [
|
||||
start.isoformat(),
|
||||
end.isoformat(),
|
||||
normalize_datetime_to_utc(start).isoformat(),
|
||||
normalize_datetime_to_utc(end).isoformat(),
|
||||
subject,
|
||||
",".join(sorted(teachers)),
|
||||
",".join(sorted(rooms)),
|
||||
|
||||
@@ -85,13 +85,50 @@ class FakeCalendar:
|
||||
self._server = server
|
||||
self.url = "https://caldav.example.com/remote.php/dav/calendars/test-user/pronote-sync/"
|
||||
|
||||
def date_search(
|
||||
self, start: datetime, end: datetime, expand: bool = True
|
||||
def search(
|
||||
self,
|
||||
*,
|
||||
start: datetime | None = None,
|
||||
end: datetime | None = None,
|
||||
event: bool = True,
|
||||
expand: bool = True,
|
||||
**kwargs: Any,
|
||||
) -> list[FakeCalendarEvent]:
|
||||
"""Retourne tous les événements comme de faux objets calendrier."""
|
||||
"""Retourne les événements dont la période chevauche la fenêtre donnée.
|
||||
|
||||
Reproduit le comportement d'un vrai serveur CalDAV : seuls les
|
||||
événements dont la période chevauche la fenêtre ``[start, end)`` sont
|
||||
retournés, comme de faux objets calendrier.
|
||||
|
||||
:param start: Début de la fenêtre de recherche (``None`` : pas de
|
||||
borne inférieure).
|
||||
:param end: Fin de la fenêtre de recherche (``None`` : pas de borne
|
||||
supérieure).
|
||||
:param event: Non utilisé (le faux ne traite que des VEVENT).
|
||||
:param expand: Non utilisé (compatibilité avec la passerelle).
|
||||
:param kwargs: Paramètres additionnels ignorés (compatibilité avec
|
||||
l'API ``caldav``).
|
||||
:return: Liste des événements chevauchant la fenêtre.
|
||||
:rtype: list[FakeCalendarEvent]
|
||||
"""
|
||||
results: list[FakeCalendarEvent] = []
|
||||
for uid, ical_text in self._server._events.items():
|
||||
results.append(FakeCalendarEvent(ical_text, uid=uid, server=self._server))
|
||||
cal = Calendar.from_ical(ical_text)
|
||||
for component in cal.walk("VEVENT"):
|
||||
raw_start = component.get("dtstart")
|
||||
raw_end = component.get("dtend")
|
||||
if raw_start is None:
|
||||
continue
|
||||
event_start = raw_start.dt
|
||||
event_end = raw_end.dt if raw_end is not None else event_start
|
||||
overlaps = True
|
||||
if start is not None:
|
||||
overlaps = overlaps and event_end > start
|
||||
if end is not None:
|
||||
overlaps = overlaps and event_start < end
|
||||
if overlaps:
|
||||
results.append(FakeCalendarEvent(ical_text, uid=uid, server=self._server))
|
||||
break
|
||||
return results
|
||||
|
||||
def get_event_by_uid(self, uid: str) -> FakeCalendarEvent:
|
||||
@@ -266,6 +303,7 @@ class TestCalDAVSynchronize:
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 1, 14, 12, 0),
|
||||
)
|
||||
|
||||
# Vérifications
|
||||
@@ -308,6 +346,7 @@ class TestCalDAVSynchronize:
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 1, 14, 12, 0),
|
||||
)
|
||||
assert result1.added == 1
|
||||
|
||||
@@ -316,6 +355,7 @@ class TestCalDAVSynchronize:
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 1, 14, 12, 0),
|
||||
)
|
||||
assert result2.status == CalDAVSyncStatus.SKIPPED
|
||||
assert result2.added == 0
|
||||
@@ -366,6 +406,7 @@ class TestCalDAVSynchronize:
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 1, 14, 12, 0),
|
||||
)
|
||||
|
||||
# Vérifications : le résultat indique un ajout, mais le serveur reste vide
|
||||
@@ -415,6 +456,7 @@ class TestCalDAVSynchronize:
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 1, 14, 12, 0),
|
||||
)
|
||||
|
||||
# Vérifications
|
||||
@@ -454,6 +496,7 @@ class TestCalDAVSynchronize:
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 1, 14, 12, 0),
|
||||
)
|
||||
|
||||
# Vérifications
|
||||
@@ -496,6 +539,7 @@ class TestCalDAVSynchronize:
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 1, 14, 12, 0),
|
||||
)
|
||||
|
||||
# Vérifications
|
||||
@@ -538,6 +582,7 @@ class TestCalDAVSynchronize:
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 1, 14, 12, 0),
|
||||
)
|
||||
|
||||
# Vérifications : l'événement non géré est toujours présent
|
||||
@@ -593,6 +638,7 @@ class TestCalDAVSynchronize:
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 1, 14, 12, 0),
|
||||
)
|
||||
|
||||
# Vérifications
|
||||
@@ -632,6 +678,7 @@ class TestCalDAVSynchronize:
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 1, 14, 12, 0),
|
||||
)
|
||||
|
||||
# Vérifications
|
||||
@@ -667,6 +714,7 @@ class TestCalDAVSynchronize:
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 12, 20, 12, 0),
|
||||
)
|
||||
|
||||
# Vérifications
|
||||
@@ -758,6 +806,7 @@ class TestCalDAVSynchronize:
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 1, 14, 12, 0),
|
||||
)
|
||||
|
||||
# Vérifications
|
||||
@@ -770,3 +819,111 @@ class TestCalDAVSynchronize:
|
||||
events = fake_caldav_server.get_events()
|
||||
assert "UNMANAGED-001" in events
|
||||
assert len(events) == 4 # L-001 (mis à jour), L-002, hw-002, UNMANAGED-001
|
||||
|
||||
def test_sync_filters_out_of_window_events(
|
||||
self,
|
||||
fake_caldav_server: FakeCalDAVServer,
|
||||
full_settings: Settings,
|
||||
) -> None:
|
||||
"""Teste qu'un cours hors fenêtre n'est pas écrit sur le calendrier.
|
||||
|
||||
Avec ``now=2026-01-14`` et la configuration par défaut (7 jours dans
|
||||
le passé, 30 dans le futur), la fenêtre couvre les journées complètes
|
||||
du 2026-01-07 au 2026-02-14 (fin exclusive) : un cours le 2026-02-15
|
||||
doit être filtré et ne jamais atteindre le planificateur ni le
|
||||
calendrier.
|
||||
"""
|
||||
# Données Pronote avec un cours hors de la fenêtre
|
||||
pronote_data = PronoteData(
|
||||
lessons=[
|
||||
Lesson(
|
||||
id="L-001",
|
||||
start=datetime(2026, 2, 15, 8, 0),
|
||||
end=datetime(2026, 2, 15, 9, 0),
|
||||
subject="Mathématiques",
|
||||
teachers=("Prof Dupont",),
|
||||
rooms=("Salle 101",),
|
||||
status=LessonStatus.NORMAL,
|
||||
group=None,
|
||||
content=None,
|
||||
)
|
||||
],
|
||||
homeworks=[],
|
||||
school_events=[],
|
||||
messages=[],
|
||||
target_date=datetime(2026, 2, 15).date(),
|
||||
generated_at=datetime(2026, 2, 15, 0, 0),
|
||||
)
|
||||
|
||||
# Synchronisation
|
||||
result = synchronize(
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 1, 14, 12, 0),
|
||||
)
|
||||
|
||||
# Vérifications : le cours hors fenêtre n'est jamais écrit
|
||||
assert result.status == CalDAVSyncStatus.SKIPPED
|
||||
assert result.added == 0
|
||||
assert result.updated == 0
|
||||
assert result.removed == 0
|
||||
assert len(fake_caldav_server.get_events()) == 0
|
||||
|
||||
def test_sync_idempotent_at_window_edge(
|
||||
self,
|
||||
fake_caldav_server: FakeCalDAVServer,
|
||||
full_settings: Settings,
|
||||
) -> None:
|
||||
"""Teste l'idempotence pour un cours tardif en fin de fenêtre.
|
||||
|
||||
Un cours à 23:00 le dernier jour complet de la fenêtre (2026-02-13 ;
|
||||
la fenêtre se termine de façon exclusive le 2026-02-14) doit être
|
||||
ajouté une seule fois : une seconde exécution identique doit le
|
||||
détecter comme déjà présent et ne rien réécrire.
|
||||
"""
|
||||
# Données Pronote avec un cours tardif le dernier jour de la fenêtre
|
||||
pronote_data = PronoteData(
|
||||
lessons=[
|
||||
Lesson(
|
||||
id="L-001",
|
||||
start=datetime(2026, 2, 13, 23, 0),
|
||||
end=datetime(2026, 2, 13, 23, 30),
|
||||
subject="Mathématiques",
|
||||
teachers=("Prof Dupont",),
|
||||
rooms=("Salle 101",),
|
||||
status=LessonStatus.NORMAL,
|
||||
group=None,
|
||||
content=None,
|
||||
)
|
||||
],
|
||||
homeworks=[],
|
||||
school_events=[],
|
||||
messages=[],
|
||||
target_date=datetime(2026, 2, 13).date(),
|
||||
generated_at=datetime(2026, 2, 13, 0, 0),
|
||||
)
|
||||
|
||||
# Première synchronisation : le cours est ajouté
|
||||
result1 = synchronize(
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 1, 14, 12, 0),
|
||||
)
|
||||
assert result1.status == CalDAVSyncStatus.SUCCESS
|
||||
assert result1.added == 1
|
||||
assert len(fake_caldav_server.get_events()) == 1
|
||||
|
||||
# Seconde synchronisation identique : rien n'est réécrit
|
||||
result2 = synchronize(
|
||||
pronote_data=pronote_data,
|
||||
settings=full_settings,
|
||||
client_factory=fake_caldav_server.client_factory,
|
||||
now=datetime(2026, 1, 14, 12, 0),
|
||||
)
|
||||
assert result2.status == CalDAVSyncStatus.SKIPPED
|
||||
assert result2.added == 0
|
||||
assert result2.updated == 0
|
||||
assert result2.removed == 0
|
||||
assert len(fake_caldav_server.get_events()) == 1
|
||||
|
||||
@@ -339,12 +339,69 @@ def test_real_update_calls_gateway(
|
||||
:return: None
|
||||
"""
|
||||
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
|
||||
result = executor.execute(plan_with_lesson_update)
|
||||
result = executor.execute(
|
||||
plan_with_lesson_update,
|
||||
remote_raw_by_canonical={"L-1234": "L-1234"},
|
||||
)
|
||||
|
||||
mock_gateway.upsert_event.assert_called_once()
|
||||
assert result.updated == 1
|
||||
|
||||
|
||||
def test_update_uses_raw_uid_from_mapping(
|
||||
mock_gateway: MagicMock, plan_with_lesson_update: CalDAVSyncPlan
|
||||
) -> None:
|
||||
"""Vérifie que la mise à jour cible l'UID brut distant du mapping.
|
||||
|
||||
Pour une mise à jour, l'exécuteur doit appeler ``upsert_event`` avec
|
||||
l'UID brut fourni par ``remote_raw_by_canonical`` (celui stocké sur le
|
||||
serveur) et non l'UID canonique, afin d'éviter la création d'un doublon.
|
||||
|
||||
:param mock_gateway: Passerelle mockée.
|
||||
:param plan_with_lesson_update: Plan avec un cours à mettre à jour.
|
||||
:return: None
|
||||
"""
|
||||
raw_uid = "L-1234-20260905T080000Z-Index-Education"
|
||||
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
|
||||
result = executor.execute(
|
||||
plan_with_lesson_update,
|
||||
remote_raw_by_canonical={"L-1234": raw_uid},
|
||||
)
|
||||
|
||||
call_args = mock_gateway.upsert_event.call_args
|
||||
assert call_args[0][0] is not None
|
||||
# Le contenu VEVENT porte l'UID canonique du modèle.
|
||||
assert "UID:L-1234" in call_args[0][0]
|
||||
# La cible (2e argument) est l'UID brut distant, pas l'UID canonique.
|
||||
assert call_args[0][1] == raw_uid
|
||||
assert result.updated == 1
|
||||
assert len(result.errors) == 0
|
||||
|
||||
|
||||
def test_update_without_mapping_raises_error(
|
||||
mock_gateway: MagicMock, plan_with_lesson_update: CalDAVSyncPlan
|
||||
) -> None:
|
||||
"""Vérifie qu'une mise à jour sans mapping distant consigne une erreur.
|
||||
|
||||
Si ``remote_raw_by_canonical`` est absent (ou sans clé pour l'UID
|
||||
canonique), l'exécuteur ne doit pas retomber silencieusement sur l'UID
|
||||
canonique (créant un doublon) : l'erreur est consignée dans
|
||||
``result.errors`` et le lot continue sans appeler la passerelle.
|
||||
|
||||
:param mock_gateway: Passerelle mockée.
|
||||
:param plan_with_lesson_update: Plan avec un cours à mettre à jour.
|
||||
:return: None
|
||||
"""
|
||||
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
|
||||
result = executor.execute(plan_with_lesson_update)
|
||||
|
||||
mock_gateway.upsert_event.assert_not_called()
|
||||
assert len(result.errors) == 1
|
||||
assert "UID canonique sans correspondant distant" in result.errors[0]
|
||||
assert result.updated == 0
|
||||
assert result.status == CalDAVSyncStatus.FAILED
|
||||
|
||||
|
||||
def test_real_delete_calls_gateway(
|
||||
mock_gateway: MagicMock, plan_with_lesson_remove: CalDAVSyncPlan
|
||||
) -> None:
|
||||
@@ -380,7 +437,10 @@ def test_real_execution_all_operations(
|
||||
)
|
||||
|
||||
executor = CalDAVSyncExecutor(mock_gateway, dry_run=False)
|
||||
result = executor.execute(plan)
|
||||
result = executor.execute(
|
||||
plan,
|
||||
remote_raw_by_canonical={"L-0002": "L-0002"},
|
||||
)
|
||||
|
||||
# upsert_event appelé pour les ajouts et mises à jour
|
||||
# 1 lesson add + 1 lesson update + 1 homework add + 1 school event add = 4
|
||||
|
||||
@@ -7,6 +7,7 @@ avec une attention particulière à la sécurité (masquage des secrets).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock
|
||||
@@ -22,7 +23,7 @@ from pronote_sync.sync.caldav import CalDAVGateway
|
||||
from pronote_sync.sync.serialization import MANAGED_PROPERTY, MANAGED_VALUE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
from _pytest.logging import LogCaptureFixture
|
||||
|
||||
|
||||
# --- Fixtures ---
|
||||
@@ -312,7 +313,7 @@ def test_list_managed_events_returns_only_managed(
|
||||
mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[
|
||||
0
|
||||
]
|
||||
mock_calendar.date_search.return_value = [mock_managed_event, mock_unmanaged_event]
|
||||
mock_calendar.search.return_value = [mock_managed_event, mock_unmanaged_event]
|
||||
|
||||
start = datetime(2026, 1, 1)
|
||||
end = datetime(2026, 12, 31)
|
||||
@@ -320,8 +321,9 @@ def test_list_managed_events_returns_only_managed(
|
||||
|
||||
# Seuls les événements gérés doivent être retournés
|
||||
assert len(result) == 1
|
||||
uid, vevent = result[0]
|
||||
assert uid == "test-uid-123"
|
||||
raw_uid, canonical_uid, vevent = result[0]
|
||||
assert raw_uid == "test-uid-123"
|
||||
assert canonical_uid == "test-uid-123" # Pas de suffixe Pronote : canonique == brut
|
||||
assert str(vevent.get("UID")) == "test-uid-123"
|
||||
|
||||
|
||||
@@ -359,7 +361,7 @@ def test_list_managed_events_caldav_error_raises(
|
||||
mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[
|
||||
0
|
||||
]
|
||||
mock_calendar.date_search.side_effect = Exception("CalDAV error")
|
||||
mock_calendar.search.side_effect = Exception("CalDAV error")
|
||||
|
||||
with pytest.raises(PronoteSyncError) as exc_info:
|
||||
gateway.list_managed_events(datetime(2026, 1, 1), datetime(2026, 12, 31))
|
||||
@@ -402,11 +404,13 @@ def test_upsert_event_creates_new_event_when_uid_missing(
|
||||
def test_upsert_event_updates_existing_event_by_uid(
|
||||
caldav_settings: CalDAVSettings,
|
||||
mock_client_factory: MagicMock,
|
||||
mock_managed_event: MagicMock,
|
||||
) -> None:
|
||||
"""Vérifie que upsert_event remplace le contenu d'un événement existant.
|
||||
"""Vérifie que upsert_event remplace le contenu d'un événement géré existant.
|
||||
|
||||
:param caldav_settings: Paramètres CalDAV valides.
|
||||
:param mock_client_factory: Usine de clients mockée.
|
||||
:param mock_managed_event: Événement géré mocké.
|
||||
:return: None
|
||||
"""
|
||||
gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory)
|
||||
@@ -415,16 +419,15 @@ def test_upsert_event_updates_existing_event_by_uid(
|
||||
mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[
|
||||
0
|
||||
]
|
||||
mock_event = MagicMock()
|
||||
mock_calendar.get_event_by_uid.return_value = mock_event
|
||||
mock_calendar.get_event_by_uid.return_value = mock_managed_event
|
||||
|
||||
vcalendar_text = "BEGIN:VCALENDAR\nBEGIN:VEVENT\nUID:test-123\nEND:VEVENT\nEND:VCALENDAR"
|
||||
|
||||
gateway.upsert_event(vcalendar_text, "test-123")
|
||||
|
||||
mock_calendar.get_event_by_uid.assert_called_once_with("test-123")
|
||||
assert mock_event.data == vcalendar_text
|
||||
mock_event.save.assert_called_once()
|
||||
assert mock_managed_event.data == vcalendar_text
|
||||
mock_managed_event.save.assert_called_once()
|
||||
mock_calendar.add_event.assert_not_called()
|
||||
|
||||
|
||||
@@ -474,30 +477,66 @@ def test_upsert_event_caldav_error_raises(
|
||||
# --- delete_event tests ---
|
||||
|
||||
|
||||
def test_delete_event_calls_calendar(
|
||||
def test_upsert_event_refuses_unmanaged_event(
|
||||
caldav_settings: CalDAVSettings,
|
||||
mock_client_factory: MagicMock,
|
||||
mock_unmanaged_event: MagicMock,
|
||||
) -> None:
|
||||
"""Vérifie que delete_event appelle event.delete() sur l'événement trouvé.
|
||||
"""Vérifie que upsert_event refuse de modifier un événement non géré.
|
||||
|
||||
Un événement distant existant sans le marqueur de gestion ne doit jamais
|
||||
être écrasé : la méthode lève PronoteSyncError avec un message « Conflit »
|
||||
et n'appelle pas ``add_event``.
|
||||
|
||||
:param caldav_settings: Paramètres CalDAV valides.
|
||||
:param mock_client_factory: Usine de clients mockée.
|
||||
:param mock_unmanaged_event: Événement non géré mocké.
|
||||
:return: None
|
||||
"""
|
||||
gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory)
|
||||
gateway.connect()
|
||||
|
||||
# Configurer le calendrier pour retourner un événement mocké
|
||||
mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[
|
||||
0
|
||||
]
|
||||
mock_event = MagicMock()
|
||||
mock_calendar.get_event_by_uid.return_value = mock_event
|
||||
mock_calendar.get_event_by_uid.return_value = mock_unmanaged_event
|
||||
|
||||
vcalendar_text = "BEGIN:VCALENDAR\nBEGIN:VEVENT\nUID:test-123\nEND:VEVENT\nEND:VCALENDAR"
|
||||
|
||||
with pytest.raises(PronoteSyncError) as exc_info:
|
||||
gateway.upsert_event(vcalendar_text, "test-123")
|
||||
|
||||
assert "Conflit" in str(exc_info.value)
|
||||
mock_calendar.get_event_by_uid.assert_called_once_with("test-123")
|
||||
mock_calendar.add_event.assert_not_called()
|
||||
mock_unmanaged_event.save.assert_not_called()
|
||||
|
||||
|
||||
def test_delete_event_calls_calendar(
|
||||
caldav_settings: CalDAVSettings,
|
||||
mock_client_factory: MagicMock,
|
||||
mock_managed_event: MagicMock,
|
||||
) -> None:
|
||||
"""Vérifie que delete_event appelle event.delete() sur un événement géré.
|
||||
|
||||
:param caldav_settings: Paramètres CalDAV valides.
|
||||
:param mock_client_factory: Usine de clients mockée.
|
||||
:param mock_managed_event: Événement géré mocké.
|
||||
:return: None
|
||||
"""
|
||||
gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory)
|
||||
gateway.connect()
|
||||
|
||||
# Configurer le calendrier pour retourner un événement géré mocké
|
||||
mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[
|
||||
0
|
||||
]
|
||||
mock_calendar.get_event_by_uid.return_value = mock_managed_event
|
||||
|
||||
gateway.delete_event("test-uid-123")
|
||||
|
||||
mock_calendar.get_event_by_uid.assert_called_once_with("test-uid-123")
|
||||
mock_event.delete.assert_called_once()
|
||||
mock_managed_event.delete.assert_called_once()
|
||||
|
||||
|
||||
def test_delete_event_not_connected_raises(
|
||||
@@ -543,6 +582,66 @@ def test_delete_event_caldav_error_raises(
|
||||
assert exc_info.value.__context__ is None
|
||||
|
||||
|
||||
def test_delete_event_refuses_unmanaged_event(
|
||||
caldav_settings: CalDAVSettings,
|
||||
mock_client_factory: MagicMock,
|
||||
mock_unmanaged_event: MagicMock,
|
||||
caplog: LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Vérifie que delete_event refuse de supprimer un événement non géré.
|
||||
|
||||
L'événement distant sans le marqueur de gestion ne doit jamais être
|
||||
supprimé : la méthode retourne sans erreur, journalise un avertissement
|
||||
et n'appelle pas ``event.delete()``.
|
||||
|
||||
:param caldav_settings: Paramètres CalDAV valides.
|
||||
:param mock_client_factory: Usine de clients mockée.
|
||||
:param mock_unmanaged_event: Événement non géré mocké.
|
||||
:param caplog: Capture des journaux pytest.
|
||||
:return: None
|
||||
"""
|
||||
gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory)
|
||||
gateway.connect()
|
||||
|
||||
mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[
|
||||
0
|
||||
]
|
||||
mock_calendar.get_event_by_uid.return_value = mock_unmanaged_event
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
gateway.delete_event("test-uid-123")
|
||||
|
||||
mock_calendar.get_event_by_uid.assert_called_once_with("test-uid-123")
|
||||
mock_unmanaged_event.delete.assert_not_called()
|
||||
assert any("Suppression refusée" in record.getMessage() for record in caplog.records)
|
||||
|
||||
|
||||
def test_delete_event_idempotent_when_not_found(
|
||||
caldav_settings: CalDAVSettings,
|
||||
mock_client_factory: MagicMock,
|
||||
) -> None:
|
||||
"""Vérifie que delete_event est idempotent quand l'UID est introuvable.
|
||||
|
||||
Une suppression d'un événement déjà absent est un succès silencieux :
|
||||
aucune exception n'est levée.
|
||||
|
||||
:param caldav_settings: Paramètres CalDAV valides.
|
||||
:param mock_client_factory: Usine de clients mockée.
|
||||
:return: None
|
||||
"""
|
||||
gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory)
|
||||
gateway.connect()
|
||||
|
||||
mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[
|
||||
0
|
||||
]
|
||||
mock_calendar.get_event_by_uid.side_effect = NotFoundError("Event not found")
|
||||
|
||||
gateway.delete_event("test-uid-123")
|
||||
|
||||
mock_calendar.get_event_by_uid.assert_called_once_with("test-uid-123")
|
||||
|
||||
|
||||
# --- Context manager tests ---
|
||||
|
||||
|
||||
|
||||
@@ -183,9 +183,9 @@ def test_lesson_add_when_not_in_remote() -> None:
|
||||
"""
|
||||
lesson = _make_lesson(lesson_id="L-1234")
|
||||
pronote_data = _make_pronote_data(lessons=[lesson])
|
||||
remote_managed: list[tuple[str, Event]] = []
|
||||
remote_managed: list[tuple[str, str, Event]] = []
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
assert len(plan.lessons_to_add) == 1
|
||||
assert plan.lessons_to_add[0].id == "L-1234"
|
||||
@@ -208,9 +208,9 @@ def test_lesson_update_when_signature_differs() -> None:
|
||||
dtstart=datetime(2026, 1, 15, 8, 0),
|
||||
dtend=datetime(2026, 1, 15, 9, 0),
|
||||
)
|
||||
remote_managed: list[tuple[str, Event]] = [("L-1234", remote_event)]
|
||||
remote_managed: list[tuple[str, str, Event]] = [("L-1234", "L-1234", remote_event)]
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
assert len(plan.lessons_to_add) == 0
|
||||
assert len(plan.lessons_to_update) == 1
|
||||
@@ -228,9 +228,9 @@ def test_lesson_idempotent_when_signature_same() -> None:
|
||||
|
||||
# Créer un VEVENT distant avec les mêmes propriétés
|
||||
remote_event = lesson_to_vevent(lesson)
|
||||
remote_managed: list[tuple[str, Event]] = [("L-1234", remote_event)]
|
||||
remote_managed: list[tuple[str, str, Event]] = [("L-1234", "L-1234", remote_event)]
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
assert len(plan.lessons_to_add) == 0
|
||||
assert len(plan.lessons_to_update) == 0
|
||||
@@ -250,9 +250,9 @@ def test_lesson_remove_when_not_in_local() -> None:
|
||||
dtstart=datetime(2026, 1, 15, 8, 0),
|
||||
dtend=datetime(2026, 1, 15, 9, 0),
|
||||
)
|
||||
remote_managed: list[tuple[str, Event]] = [("L-9999", remote_event)]
|
||||
remote_managed: list[tuple[str, str, Event]] = [("L-9999", "L-9999", remote_event)]
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
assert len(plan.lessons_to_add) == 0
|
||||
assert len(plan.lessons_to_update) == 0
|
||||
@@ -260,6 +260,62 @@ def test_lesson_remove_when_not_in_local() -> None:
|
||||
assert plan.lessons_to_remove[0] == "L-9999"
|
||||
|
||||
|
||||
def test_plan_with_suffixed_remote_uid_matches_canonical() -> None:
|
||||
"""Vérifie qu'un UID distant suffixé apparié par UID canonique ne produit rien.
|
||||
|
||||
L'événement distant porte un UID brut suffixé
|
||||
(``L-1234-20260905T080000Z-Index-Education``) dont la forme canonique
|
||||
(``L-1234``) correspond au cours local ; les signatures étant identiques,
|
||||
le plan doit être vide — sans ajout, mise à jour ou suppression artificiels.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
lesson = _make_lesson(lesson_id="L-1234", subject="Mathématiques")
|
||||
pronote_data = _make_pronote_data(lessons=[lesson])
|
||||
|
||||
# VEVENT distant construit à partir du même cours : contenu sémantique
|
||||
# identique (seul l'UID brut stocké diffère, capturé par le triplet).
|
||||
remote_event = lesson_to_vevent(lesson)
|
||||
remote_managed: list[tuple[str, str, Event]] = [
|
||||
("L-1234-20260905T080000Z-Index-Education", "L-1234", remote_event)
|
||||
]
|
||||
|
||||
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
assert len(plan.lessons_to_add) == 0
|
||||
assert len(plan.lessons_to_update) == 0
|
||||
assert len(plan.lessons_to_remove) == 0
|
||||
|
||||
|
||||
def test_plan_with_suffixed_remote_uid_and_no_local_adds_to_remove() -> None:
|
||||
"""Vérifie que la suppression d'un UID distant suffixé utilise l'UID brut.
|
||||
|
||||
Un événement distant orphelin (aucun cours local) dont l'UID brut est
|
||||
suffixé doit être supprimé en ciblant l'UID brut stocké sur le serveur,
|
||||
et non sa forme canonique.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
pronote_data = _make_pronote_data(lessons=[])
|
||||
|
||||
raw_uid = "L-9999-20260101T080000Z-Index-Education"
|
||||
remote_event = _make_vevent(
|
||||
uid=raw_uid,
|
||||
summary="Ancien cours",
|
||||
dtstart=datetime(2026, 1, 15, 8, 0),
|
||||
dtend=datetime(2026, 1, 15, 9, 0),
|
||||
)
|
||||
remote_managed: list[tuple[str, str, Event]] = [(raw_uid, "L-9999", remote_event)]
|
||||
|
||||
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
assert len(plan.lessons_to_add) == 0
|
||||
assert len(plan.lessons_to_update) == 0
|
||||
assert len(plan.lessons_to_remove) == 1
|
||||
# La liste de suppression contient l'UID brut, pas la forme canonique.
|
||||
assert plan.lessons_to_remove[0] == raw_uid
|
||||
|
||||
|
||||
def test_lesson_cancelled_preserved() -> None:
|
||||
"""Vérifie qu'un cours annulé est traité normalement (ajout/mise à jour).
|
||||
|
||||
@@ -267,9 +323,9 @@ def test_lesson_cancelled_preserved() -> None:
|
||||
"""
|
||||
lesson = _make_lesson(lesson_id="L-1234", status=LessonStatus.CANCELLED)
|
||||
pronote_data = _make_pronote_data(lessons=[lesson])
|
||||
remote_managed: list[tuple[str, Event]] = []
|
||||
remote_managed: list[tuple[str, str, Event]] = []
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
# Un cours annulé doit aller dans lessons_to_add comme n'importe quel autre cours
|
||||
assert len(plan.lessons_to_add) == 1
|
||||
@@ -287,9 +343,9 @@ def test_homework_add_when_not_in_remote() -> None:
|
||||
"""
|
||||
homework = _make_homework(homework_id="HW-5678")
|
||||
pronote_data = _make_pronote_data(homeworks=[homework])
|
||||
remote_managed: list[tuple[str, Event]] = []
|
||||
remote_managed: list[tuple[str, str, Event]] = []
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
assert len(plan.homeworks_to_add) == 1
|
||||
assert plan.homeworks_to_add[0].id == "HW-5678"
|
||||
@@ -312,9 +368,11 @@ def test_homework_update_when_signature_differs() -> None:
|
||||
dtstart=datetime(2026, 1, 20, 8, 0),
|
||||
dtend=datetime(2026, 1, 20, 18, 0),
|
||||
)
|
||||
remote_managed: list[tuple[str, Event]] = [("homework-HW-5678", remote_event)]
|
||||
remote_managed: list[tuple[str, str, Event]] = [
|
||||
("homework-HW-5678", "homework-HW-5678", remote_event)
|
||||
]
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
assert len(plan.homeworks_to_add) == 0
|
||||
assert len(plan.homeworks_to_update) == 1
|
||||
@@ -335,9 +393,11 @@ def test_homework_remove_when_not_in_local() -> None:
|
||||
dtstart=datetime(2026, 1, 20, 8, 0),
|
||||
dtend=datetime(2026, 1, 20, 18, 0),
|
||||
)
|
||||
remote_managed: list[tuple[str, Event]] = [("homework-HW-9999", remote_event)]
|
||||
remote_managed: list[tuple[str, str, Event]] = [
|
||||
("homework-HW-9999", "homework-HW-9999", remote_event)
|
||||
]
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
assert len(plan.homeworks_to_add) == 0
|
||||
assert len(plan.homeworks_to_update) == 0
|
||||
@@ -358,9 +418,9 @@ def test_school_event_add_when_not_in_remote() -> None:
|
||||
from_date=date(2026, 12, 20),
|
||||
)
|
||||
pronote_data = _make_pronote_data(school_events=[school_event])
|
||||
remote_managed: list[tuple[str, Event]] = []
|
||||
remote_managed: list[tuple[str, str, Event]] = []
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
assert len(plan.school_events_to_add) == 1
|
||||
assert plan.school_events_to_add[0].label == "Vacances de Noël"
|
||||
@@ -390,11 +450,15 @@ def test_school_event_update_when_signature_differs() -> None:
|
||||
dtstart=datetime(2026, 12, 20, 0, 0),
|
||||
dtend=datetime(2027, 1, 5, 0, 0),
|
||||
)
|
||||
remote_managed: list[tuple[str, Event]] = [
|
||||
("school-event-Vacances de Noël-2026-12-20", remote_event)
|
||||
remote_managed: list[tuple[str, str, Event]] = [
|
||||
(
|
||||
"school-event-Vacances de Noël-2026-12-20",
|
||||
"school-event-Vacances de Noël-2026-12-20",
|
||||
remote_event,
|
||||
)
|
||||
]
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
assert len(plan.school_events_to_add) == 0
|
||||
assert len(plan.school_events_to_update) == 1
|
||||
@@ -415,11 +479,15 @@ def test_school_event_remove_when_not_in_local() -> None:
|
||||
dtstart=datetime(2026, 1, 1, 0, 0),
|
||||
dtend=datetime(2026, 1, 2, 0, 0),
|
||||
)
|
||||
remote_managed: list[tuple[str, Event]] = [
|
||||
("school-event-Ancien événement-2026-01-01", remote_event)
|
||||
remote_managed: list[tuple[str, str, Event]] = [
|
||||
(
|
||||
"school-event-Ancien événement-2026-01-01",
|
||||
"school-event-Ancien événement-2026-01-01",
|
||||
remote_event,
|
||||
)
|
||||
]
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
assert len(plan.school_events_to_add) == 0
|
||||
assert len(plan.school_events_to_update) == 0
|
||||
@@ -443,9 +511,11 @@ def test_uid_routing_homework_to_remove() -> None:
|
||||
dtstart=datetime(2026, 1, 20, 8, 0),
|
||||
dtend=datetime(2026, 1, 20, 18, 0),
|
||||
)
|
||||
remote_managed: list[tuple[str, Event]] = [("homework-HW-9999", remote_event)]
|
||||
remote_managed: list[tuple[str, str, Event]] = [
|
||||
("homework-HW-9999", "homework-HW-9999", remote_event)
|
||||
]
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
# Ne doit PAS aller dans lessons_to_remove
|
||||
assert len(plan.lessons_to_remove) == 0
|
||||
@@ -466,9 +536,11 @@ def test_uid_routing_school_event_to_remove() -> None:
|
||||
dtstart=datetime(2026, 12, 20, 0, 0),
|
||||
dtend=datetime(2027, 1, 5, 0, 0),
|
||||
)
|
||||
remote_managed: list[tuple[str, Event]] = [("school-event-Vacances-2026-12-20", remote_event)]
|
||||
remote_managed: list[tuple[str, str, Event]] = [
|
||||
("school-event-Vacances-2026-12-20", "school-event-Vacances-2026-12-20", remote_event)
|
||||
]
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
# Ne doit PAS aller dans lessons_to_remove
|
||||
assert len(plan.lessons_to_remove) == 0
|
||||
@@ -485,9 +557,9 @@ def test_empty_inputs_empty_plan() -> None:
|
||||
:return: None
|
||||
"""
|
||||
pronote_data = _make_pronote_data(lessons=[], homeworks=[], school_events=[])
|
||||
remote_managed: list[tuple[str, Event]] = []
|
||||
remote_managed: list[tuple[str, str, Event]] = []
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
assert len(plan.lessons_to_add) == 0
|
||||
assert len(plan.lessons_to_update) == 0
|
||||
@@ -535,12 +607,12 @@ def test_mixed_scenario() -> None:
|
||||
dtend=datetime(2026, 1, 15, 9, 0),
|
||||
)
|
||||
|
||||
remote_managed: list[tuple[str, Event]] = [
|
||||
("L-0002", remote_lesson2),
|
||||
("L-0003", remote_lesson3),
|
||||
remote_managed: list[tuple[str, str, Event]] = [
|
||||
("L-0002", "L-0002", remote_lesson2),
|
||||
("L-0003", "L-0003", remote_lesson3),
|
||||
]
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
# Ajouts
|
||||
assert len(plan.lessons_to_add) == 1
|
||||
@@ -570,9 +642,9 @@ def test_unmanaged_events_not_in_remote_managed() -> None:
|
||||
|
||||
# remote_managed ne contient que des événements gérés (par hypothèse)
|
||||
# Donc pas besoin de tester le filtrage ici - c'est la responsabilité de list_managed_events
|
||||
remote_managed: list[tuple[str, Event]] = []
|
||||
remote_managed: list[tuple[str, str, Event]] = []
|
||||
|
||||
plan = compute_plan(pronote_data, remote_managed)
|
||||
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
|
||||
|
||||
# Le cours doit être dans lessons_to_add
|
||||
assert len(plan.lessons_to_add) == 1
|
||||
|
||||
@@ -257,7 +257,7 @@ class TestCalDAVGatewaySecurity:
|
||||
mock_principal = MagicMock()
|
||||
mock_calendar = MagicMock()
|
||||
mock_calendar.url = "https://caldav.example.com/cal/"
|
||||
mock_calendar.date_search.side_effect = Exception(
|
||||
mock_calendar.search.side_effect = Exception(
|
||||
f"Search failed at {SENTINEL_URL}?token={SENTINEL_PASSWORD}"
|
||||
)
|
||||
mock_principal.calendars.return_value = [mock_calendar]
|
||||
|
||||
Reference in New Issue
Block a user