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