Compare commits
8 Commits
fix/m5-blo
...
feature/m7
| Author | SHA1 | Date | |
|---|---|---|---|
|
ebbe39f1f0
|
|||
|
958bb3ec5d
|
|||
|
1d26d49e74
|
|||
|
29270427ef
|
|||
|
f9a1a5aa43
|
|||
|
4ec827a945
|
|||
|
7fdca2ca55
|
|||
|
344745d725
|
@@ -22,7 +22,10 @@ SYNC_PAST_DAYS=7
|
|||||||
SYNC_FUTURE_DAYS=30
|
SYNC_FUTURE_DAYS=30
|
||||||
|
|
||||||
# --- Agenda théorique ---
|
# --- Agenda théorique ---
|
||||||
THEORETICAL_AGENDA_PATH=./data/theoretical.ics
|
THEORETICAL_AGENDA_PATH=./data/theoretical.json
|
||||||
|
SCHOOL_HOLIDAYS_PATH=./data/school_holidays.json
|
||||||
|
THEORETICAL_WEEK_ANCHOR_DATE=2026-09-01
|
||||||
|
THEORETICAL_WEEK_ANCHOR_TYPE=even
|
||||||
|
|
||||||
# --- XMPP ---
|
# --- XMPP ---
|
||||||
XMPP_ENABLED=false
|
XMPP_ENABLED=false
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ repos:
|
|||||||
name: mypy
|
name: mypy
|
||||||
entry: mypy
|
entry: mypy
|
||||||
language: python
|
language: python
|
||||||
additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0", "pytest>=8.0.0", "types-requests>=2.31.0", "icalendar>=5.0.0", "pronotepy>=2.15.0", "responses>=0.25.0", "pytest-mock>=3.10.0", "feedparser>=6.0.0"]
|
additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0", "pytest>=8.0.0", "types-requests>=2.31.0", "icalendar>=5.0.0", "pronotepy>=2.15.0", "responses>=0.25.0", "pytest-mock>=3.10.0", "feedparser>=6.0.0", "caldav>=1.3.0"]
|
||||||
types: [python]
|
types: [python]
|
||||||
pass_filenames: true
|
pass_filenames: true
|
||||||
|
|
||||||
|
|||||||
@@ -140,10 +140,10 @@
|
|||||||
"filename": "GUIDE_DEV_PYTHON.md",
|
"filename": "GUIDE_DEV_PYTHON.md",
|
||||||
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
||||||
"is_verified": true,
|
"is_verified": true,
|
||||||
"line_number": 5058,
|
"line_number": 5034,
|
||||||
"is_secret": false
|
"is_secret": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"generated_at": "2026-09-06T18:57:58Z"
|
"generated_at": "2026-09-06T22:00:48Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Le projet doit implémenter les fonctionnalités suivantes, dans l'ordre logique
|
|||||||
- Conservation des événements annulés avec `STATUS:CANCELLED`.
|
- Conservation des événements annulés avec `STATUS:CANCELLED`.
|
||||||
|
|
||||||
3. **Comparaison avec l'agenda théorique** :
|
3. **Comparaison avec l'agenda théorique** :
|
||||||
- Détection des **changements** (ajouts, suppressions, modifications) entre l'agenda réel (Pronote) et un agenda théorique (fichier iCal/CSV local).
|
- Détection des **changements** (ajouts, suppressions, modifications) entre l'agenda réel (Pronote) et un agenda théorique (fichier JSON local, avec parité des semaines et vacances scolaires).
|
||||||
- Génération d'une liste structurée des différences.
|
- Génération d'une liste structurée des différences.
|
||||||
|
|
||||||
4. **Génération de la synthèse** :
|
4. **Génération de la synthèse** :
|
||||||
@@ -206,7 +206,7 @@ pronote_sync/
|
|||||||
│ │ └── state.py # État local (déduplication, cache HTTP)
|
│ │ └── state.py # État local (déduplication, cache HTTP)
|
||||||
│ └── theoretical/ # Agenda théorique
|
│ └── theoretical/ # Agenda théorique
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
│ ├── file.py # Lecture fichier iCal/CSV
|
│ ├── file.py # Lecture fichier JSON (parité + vacances)
|
||||||
│ └── provider.py # Interface TheoreticalAgendaProvider
|
│ └── provider.py # Interface TheoreticalAgendaProvider
|
||||||
├── sync/ # Synchronisation CalDAV + Blog
|
├── sync/ # Synchronisation CalDAV + Blog
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
@@ -306,7 +306,10 @@ d'un besoin réel et testé.
|
|||||||
> ⚠️ **Décision d'implémentation** :
|
> ⚠️ **Décision d'implémentation** :
|
||||||
> Ces variables sont désormais dans `AppSettings` (et non `CalDAVSettings`) car `CalDAVSettings` utilise `env_prefix="CALDAV_"`, ce qui nécessiterait `CALDAV_SYNC_PAST_DAYS`.
|
> Ces variables sont désormais dans `AppSettings` (et non `CalDAVSettings`) car `CalDAVSettings` utilise `env_prefix="CALDAV_"`, ce qui nécessiterait `CALDAV_SYNC_PAST_DAYS`.
|
||||||
> Leur placement dans `AppSettings` (sans préfixe) garantit un mappage correct avec `SYNC_PAST_DAYS` / `SYNC_FUTURE_DAYS`.
|
> Leur placement dans `AppSettings` (sans préfixe) garantit un mappage correct avec `SYNC_PAST_DAYS` / `SYNC_FUTURE_DAYS`.
|
||||||
| `THEORETICAL_AGENDA_PATH` | Chemin vers le fichier iCal/CSV de l'agenda théorique. | `None` | `str \| None`|
|
| `THEORETICAL_AGENDA_PATH` | Chemin vers le fichier JSON de l'agenda théorique. | `None` | `str \| None`|
|
||||||
|
| `SCHOOL_HOLIDAYS_PATH` | Chemin vers le fichier JSON des vacances scolaires. | `None` | `str \| None`|
|
||||||
|
| `THEORETICAL_WEEK_ANCHOR_DATE` | Date de référence pour la parité des semaines (paire/impaire). | `None` | `date \| None`|
|
||||||
|
| `THEORETICAL_WEEK_ANCHOR_TYPE` | Parité de la semaine de référence (`even` ou `odd`). | `None` | `Literal["even", "odd"] \| None`|
|
||||||
| `AI_ENABLED` | Activer la synthèse IA. | `False` | `bool` |
|
| `AI_ENABLED` | Activer la synthèse IA. | `False` | `bool` |
|
||||||
| `AI_PROVIDER` | Fournisseur IA (`openai` ou `litellm`). | `openai` | `str` |
|
| `AI_PROVIDER` | Fournisseur IA (`openai` ou `litellm`). | `openai` | `str` |
|
||||||
| `AI_BASE_URL` | URL de base pour l'API IA (ex: OpenAI compatible). | `None` | `str \| None`|
|
| `AI_BASE_URL` | URL de base pour l'API IA (ex: OpenAI compatible). | `None` | `str \| None`|
|
||||||
@@ -341,8 +344,11 @@ CALDAV_CALENDAR_PATH=/pronote-sync/
|
|||||||
SYNC_PAST_DAYS=7
|
SYNC_PAST_DAYS=7
|
||||||
SYNC_FUTURE_DAYS=30
|
SYNC_FUTURE_DAYS=30
|
||||||
|
|
||||||
# --- Agenda théorique ---
|
# --- Agenda théorique (JSON) ---
|
||||||
THEORETICAL_AGENDA_PATH=./data/theoretical.ics
|
THEORETICAL_AGENDA_PATH=./data/theoretical.json
|
||||||
|
SCHOOL_HOLIDAYS_PATH=./data/school_holidays.json
|
||||||
|
THEORETICAL_WEEK_ANCHOR_DATE=2026-09-01
|
||||||
|
THEORETICAL_WEEK_ANCHOR_TYPE=even
|
||||||
|
|
||||||
# --- XMPP ---
|
# --- XMPP ---
|
||||||
XMPP_JID=user@example.com
|
XMPP_JID=user@example.com
|
||||||
@@ -2706,14 +2712,95 @@ class SynthesisResult(BaseModel):
|
|||||||
### 7.1 Principes
|
### 7.1 Principes
|
||||||
- **Synchronisation différentielle** : Ne pas écraser le calendrier distant, mais **mettre à jour uniquement les événements modifiés** (décision [3](#3-synchronisation-caldav---différentielle-avec-uid-stables)).
|
- **Synchronisation différentielle** : Ne pas écraser le calendrier distant, mais **mettre à jour uniquement les événements modifiés** (décision [3](#3-synchronisation-caldav---différentielle-avec-uid-stables)).
|
||||||
- **UID stables** : Utiliser des UID normalisés pour éviter les doublons.
|
- **UID stables** : Utiliser des UID normalisés pour éviter les doublons.
|
||||||
|
- **Canonicalisation à la frontière des sources** : les UID sont normalisés une seule fois à la frontière des sources via `utils.uid.normalize_pronote_uid`, afin qu'un même cours provenant d'iCal ou de `pronotepy` produise le **même identifiant canonique** (aucun doublon ni suppression/ajout artificiel lors d'un changement de source).
|
||||||
- **Fenêtre de synchronisation** : Configurable via `SYNC_PAST_DAYS` et `SYNC_FUTURE_DAYS`.
|
- **Fenêtre de synchronisation** : Configurable via `SYNC_PAST_DAYS` et `SYNC_FUTURE_DAYS`.
|
||||||
- **Mode dry-run** : Obligatoire pour tester sans modifier le calendrier distant.
|
- **Mode dry-run** : Obligatoire pour tester sans modifier le calendrier distant.
|
||||||
- **Idempotence** : Deux exécutions identiques **doivent** produire le même état CalDAV.
|
- **Idempotence** : Deux exécutions identiques **doivent** produire le même état CalDAV.
|
||||||
|
- **Scan du calendrier distant** : La synchronisation repose sur le **scan du calendrier CalDAV distant** (les événements gérés sont relus à chaque exécution) — **aucun état local** n'est conservé (voir §7.3).
|
||||||
|
- **Plan explicite** : Le `CalDAVSyncPlan` (ajouts / mises à jour / suppressions) est **calculé explicitement avant l'exécution** de la synchronisation (voir §7.2).
|
||||||
|
- **Événements non gérés** : Les événements **non marqués** `X-PRONOTE-SYNC-MANAGED: v1` **ne sont jamais modifiés ni supprimés** : ils appartiennent à d'autres outils ou à l'utilisateur.
|
||||||
|
|
||||||
### 7.2 Client CalDAV (`sync/caldav.py`)
|
### 7.2 Client CalDAV (`sync/caldav.py`)
|
||||||
|
|
||||||
Utilisation de la bibliothèque [`caldav`](https://pypi.org/project/caldav/) (Python 3.8+, maintenue).
|
Utilisation de la bibliothèque [`caldav`](https://pypi.org/project/caldav/) (Python 3.8+, maintenue).
|
||||||
|
|
||||||
|
> **⚠️ Code illustratif** : le bloc de code ci-dessous est **illustratif** : il montre les
|
||||||
|
> règles métier de la synchronisation (marqueur, comparaison, cours annulés, dry-run).
|
||||||
|
> L'**implémentation réelle** doit s'adapter à la version de la bibliothèque `caldav`
|
||||||
|
> installée (`caldav>=1.3.0`) : l'**API réelle** documentée ci-dessous **prévaut** sur les
|
||||||
|
> anciens appels encore présents dans l'exemple (ex: `calendar(name=...)`,
|
||||||
|
> `calendar.add_event(...)`, `event.properties`, `vobject_instance`).
|
||||||
|
|
||||||
|
#### API réelle (`caldav>=1.3.0`)
|
||||||
|
|
||||||
|
- **Connexion** : `caldav.DAVClient(url, username, password)` — les paramètres proviennent
|
||||||
|
de `CalDAVSettings` (`CALDAV_URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD`).
|
||||||
|
- **Résolution du calendrier** : `DAVClient.principal()` puis `principal.calendars()` ;
|
||||||
|
sélectionner le calendrier dont l'URL correspond à **`CalDAVSettings.calendar_path`**
|
||||||
|
(ex: `/pronote-sync/`). La résolution ne se fait **pas** par nom de calendrier :
|
||||||
|
`calendar_name` est abandonné au profit de `calendar_path`.
|
||||||
|
- **Lecture des événements** : `calendar.objects()` liste les objets du calendrier
|
||||||
|
(`calendar.date_search(start=..., end=...)` reste utilisable pour une fenêtre selon la
|
||||||
|
version installée).
|
||||||
|
- **Contenu iCalendar** : chaque objet expose `event.icalendar_component` (un
|
||||||
|
`icalendar.Event`) donnant accès aux propriétés (`uid`, `summary`, `dtstart`, `dtend`,
|
||||||
|
`status`, `categories`, `X-PRONOTE-SYNC-MANAGED`).
|
||||||
|
- **Ajout** : `calendar.save_event(ical_text)` crée un événement (UID normalisé et
|
||||||
|
marqueur inclus).
|
||||||
|
- **Mise à jour** : modifier les propriétés de l'`icalendar_component` puis
|
||||||
|
`event.save()` (si la version installée le supporte), sinon supprimer puis recréer sur
|
||||||
|
le même UID via `save_event()`.
|
||||||
|
- **Suppression** : `event.delete()` — **uniquement** pour les événements marqués.
|
||||||
|
|
||||||
|
**Règles métier conservées** (indépendantes de la version de `caldav`) :
|
||||||
|
- **Marqueur** : chaque événement géré porte `X-PRONOTE-SYNC-MANAGED: v1`.
|
||||||
|
- **Comparaison `_events_equal`** : ne compare que les **champs gérés** (UID, DTSTART,
|
||||||
|
DTEND, SUMMARY, DESCRIPTION, STATUS, CATEGORIES, marqueur) et ignore les propriétés
|
||||||
|
**volatiles** (`DTSTAMP`, `CREATED`, `LAST-MODIFIED`) qui changent à chaque écriture
|
||||||
|
côté serveur.
|
||||||
|
- **Cours annulés** : conservés avec `STATUS:CANCELLED` (ne pas supprimer).
|
||||||
|
- **Mode dry-run** : logue le plan sans écrire sur le calendrier distant.
|
||||||
|
|
||||||
|
#### Approche en trois phases
|
||||||
|
|
||||||
|
1. **Collecte / scan** : lister les événements du calendrier distant (fenêtre
|
||||||
|
`SYNC_PAST_DAYS` / `SYNC_FUTURE_DAYS`) et ne retenir que les événements **gérés**
|
||||||
|
(`X-PRONOTE-SYNC-MANAGED: v1`), indexés par UID normalisé (`normalize_pronote_uid`).
|
||||||
|
2. **Calcul du plan** : comparer (via `_events_equal`) les événements distants gérés avec
|
||||||
|
les données Pronote (cours, devoirs, événements scolaires) et produire un
|
||||||
|
**`CalDAVSyncPlan` explicite** (`lessons_to_add`, `lessons_to_update`,
|
||||||
|
`lessons_to_remove`, etc.) — **aucune écriture** à ce stade.
|
||||||
|
3. **Exécution** : appliquer le plan (ajouts via `calendar.save_event()`, mises à jour si
|
||||||
|
les événements diffèrent, suppressions si l'UID est absent des données Pronote) ; en
|
||||||
|
mode `dry_run`, loguer le plan **sans rien écrire**.
|
||||||
|
|
||||||
|
Exemple minimal (API réelle) :
|
||||||
|
|
||||||
|
```python
|
||||||
|
import caldav
|
||||||
|
|
||||||
|
# CalDAVSettings : url, username, password (SecretStr), calendar_path = "/pronote-sync/"
|
||||||
|
settings = None # instance de CalDAVSettings (pydantic-settings)
|
||||||
|
|
||||||
|
client = caldav.DAVClient(
|
||||||
|
url=settings.url,
|
||||||
|
username=settings.username,
|
||||||
|
password=settings.password.get_secret_value(),
|
||||||
|
)
|
||||||
|
principal = client.principal()
|
||||||
|
calendar = next(
|
||||||
|
c for c in principal.calendars()
|
||||||
|
if str(c.url).rstrip("/").endswith(settings.calendar_path.rstrip("/"))
|
||||||
|
)
|
||||||
|
|
||||||
|
for obj in calendar.objects():
|
||||||
|
vevent = obj.icalendar_component # icalendar.Event
|
||||||
|
if vevent.get("X-PRONOTE-SYNC-MANAGED") == "v1":
|
||||||
|
print(vevent.get("uid"))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exemple illustratif** (règles métier complètes — API partiellement ancienne) :
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
@@ -2742,13 +2829,13 @@ class CalDAVClient:
|
|||||||
url: str,
|
url: str,
|
||||||
username: str,
|
username: str,
|
||||||
password: str,
|
password: str,
|
||||||
calendar_name: str = "Pronote",
|
calendar_path: str = "/pronote-sync/",
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
):
|
):
|
||||||
self.url = url
|
self.url = url
|
||||||
self.username = username
|
self.username = username
|
||||||
self.password = password
|
self.password = password
|
||||||
self.calendar_name = calendar_name
|
self.calendar_path = calendar_path
|
||||||
self.dry_run = dry_run
|
self.dry_run = dry_run
|
||||||
self._client: Optional[caldav.DAVClient] = None
|
self._client: Optional[caldav.DAVClient] = None
|
||||||
self._calendar: Optional[DAVCalendar] = None
|
self._calendar: Optional[DAVCalendar] = None
|
||||||
@@ -2761,40 +2848,39 @@ class CalDAVClient:
|
|||||||
password=self.password,
|
password=self.password,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Récupérer ou créer le calendrier
|
# Résoudre le calendrier via calendar_path (cf. CalDAVSettings) :
|
||||||
try:
|
# API réelle (caldav>=1.3.0) : principal.calendars() puis correspondance
|
||||||
self._calendar = self._client.calendar(name=self.calendar_name)
|
# sur l'URL du calendrier.
|
||||||
except caldav.lib.error.NotFoundError:
|
principal = self._client.principal()
|
||||||
# Créer le calendrier s'il n'existe pas
|
matches = [
|
||||||
if not self.dry_run:
|
c for c in principal.calendars()
|
||||||
self._calendar = self._client.make_calendar(
|
if str(c.url).rstrip("/").endswith(self.calendar_path.rstrip("/"))
|
||||||
name=self.calendar_name,
|
]
|
||||||
supported_calendar_components=["VEVENT"],
|
if matches:
|
||||||
)
|
self._calendar = matches[0]
|
||||||
else:
|
else:
|
||||||
|
# Pas de création automatique : la résolution se fait par chemin uniquement.
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Calendrier {self.calendar_name} introuvable et dry_run activé. "
|
f"Calendrier {self.calendar_path} introuvable"
|
||||||
"Aucune modification ne sera effectuée."
|
f"{' et dry_run activé. Aucune modification ne sera effectuée.' if self.dry_run else ' : vérifier CalDAVSettings.calendar_path.'}"
|
||||||
)
|
)
|
||||||
# Créer un calendrier fictif pour les tests
|
|
||||||
self._calendar = None
|
self._calendar = None
|
||||||
|
|
||||||
def _is_managed_event(self, event: DAVEvent) -> bool:
|
def _is_managed_event(self, event: DAVEvent) -> bool:
|
||||||
"""Vérifie si un événement est géré par l'outil."""
|
"""Vérifie si un événement est géré par l'outil."""
|
||||||
# Vérifier la présence du marqueur X-PRONOTE-SYNC-MANAGED
|
# API réelle : event.icalendar_component (icalendar.Event)
|
||||||
props = event.properties
|
vevent = event.icalendar_component
|
||||||
managed = props.get(self.MANAGED_PROPERTY, None)
|
managed = vevent.get(self.MANAGED_PROPERTY)
|
||||||
return managed and managed.value == self.MANAGED_VALUE
|
return managed is not None and str(managed) == self.MANAGED_VALUE
|
||||||
|
|
||||||
def _get_event_uid(self, event: DAVEvent) -> str:
|
def _get_event_uid(self, event: DAVEvent) -> str:
|
||||||
"""Récupère l'UID normalisé d'un événement."""
|
"""Récupère l'UID normalisé d'un événement."""
|
||||||
uid = event.vobject_instance.uid.value
|
uid = str(event.icalendar_component.get("uid"))
|
||||||
return normalize_pronote_uid(uid)
|
return normalize_pronote_uid(uid)
|
||||||
|
|
||||||
def _build_event(
|
def _build_event(
|
||||||
self,
|
self,
|
||||||
lesson: Lesson,
|
lesson: Lesson,
|
||||||
calendar_name: Optional[str] = None,
|
|
||||||
) -> DAVEvent:
|
) -> DAVEvent:
|
||||||
"""Construit un événement CalDAV à partir d'un cours Pronote."""
|
"""Construit un événement CalDAV à partir d'un cours Pronote."""
|
||||||
from icalendar import Event, vDatetime, vDate, vText, vUri
|
from icalendar import Event, vDatetime, vDate, vText, vUri
|
||||||
@@ -2836,10 +2922,6 @@ class CalDAVClient:
|
|||||||
categories.append("Déplacé")
|
categories.append("Déplacé")
|
||||||
event.add("categories", categories)
|
event.add("categories", categories)
|
||||||
|
|
||||||
# Nom du calendrier (si disponible)
|
|
||||||
if calendar_name:
|
|
||||||
event.add("x-wr-calname", calendar_name)
|
|
||||||
|
|
||||||
return DAVEvent(event)
|
return DAVEvent(event)
|
||||||
|
|
||||||
def _build_homework_event(self, homework: Homework) -> DAVEvent:
|
def _build_homework_event(self, homework: Homework) -> DAVEvent:
|
||||||
@@ -2895,7 +2977,12 @@ class CalDAVClient:
|
|||||||
"""
|
"""
|
||||||
Compare les champs gérés pour déterminer si une mise à jour est nécessaire.
|
Compare les champs gérés pour déterminer si une mise à jour est nécessaire.
|
||||||
Seuls les champs explicitement gérés par l'outil sont comparés :
|
Seuls les champs explicitement gérés par l'outil sont comparés :
|
||||||
UID, DTSTART, DTEND, SUMMARY, DESCRIPTION, STATUS, CATEGORIES, et X-PRONOTE-SYNC-MANAGED.
|
UID, DTSTART, DTEND, SUMMARY, DESCRIPTION, STATUS, CATEGORIES, et
|
||||||
|
X-PRONOTE-SYNC-MANAGED.
|
||||||
|
|
||||||
|
Les propriétés volatiles (DTSTAMP, CREATED, LAST-MODIFIED) sont
|
||||||
|
volontairement **exclues** de la comparaison : elles sont modifiées par le
|
||||||
|
serveur à chaque écriture et ne reflètent aucun changement Pronote.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
event1: Événement existant dans CalDAV.
|
event1: Événement existant dans CalDAV.
|
||||||
@@ -2905,19 +2992,17 @@ class CalDAVClient:
|
|||||||
True si les événements sont identiques pour les champs gérés, False sinon.
|
True si les événements sont identiques pour les champs gérés, False sinon.
|
||||||
"""
|
"""
|
||||||
# Comparaison des UID normalisés
|
# Comparaison des UID normalisés
|
||||||
uid1 = self._get_event_uid(event1)
|
if self._get_event_uid(event1) != self._get_event_uid(event2):
|
||||||
uid2 = self._get_event_uid(event2)
|
|
||||||
if uid1 != uid2:
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Comparaison des champs gérés
|
# Comparaison des champs gérés (API réelle : icalendar_component)
|
||||||
vobj1 = event1.vobject_instance
|
vobj1 = event1.icalendar_component
|
||||||
vobj2 = event2.vobject_instance
|
vobj2 = event2.icalendar_component
|
||||||
|
|
||||||
# DTSTART et DTEND
|
# DTSTART et DTEND
|
||||||
if vobj1.get("dtstart").value != vobj2.get("dtstart").value:
|
if vobj1.get("dtstart").dt != vobj2.get("dtstart").dt:
|
||||||
return False
|
return False
|
||||||
if vobj1.get("dtend").value != vobj2.get("dtend").value:
|
if vobj1.get("dtend").dt != vobj2.get("dtend").dt:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# SUMMARY
|
# SUMMARY
|
||||||
@@ -2933,8 +3018,8 @@ class CalDAVClient:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# CATEGORIES (comparaison des listes)
|
# CATEGORIES (comparaison des listes)
|
||||||
cats1 = [str(c) for c in vobj1.get("categories", []).cats] if hasattr(vobj1.get("categories", None), "cats") else []
|
cats1 = [str(c) for c in vobj1.get("categories", [])]
|
||||||
cats2 = [str(c) for c in vobj2.get("categories", []).cats] if hasattr(vobj2.get("categories", None), "cats") else []
|
cats2 = [str(c) for c in vobj2.get("categories", [])]
|
||||||
if sorted(cats1) != sorted(cats2):
|
if sorted(cats1) != sorted(cats2):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -3172,138 +3257,25 @@ class CalDAVClient:
|
|||||||
self._calendar = None
|
self._calendar = None
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7.3 État de synchronisation (`sync/state.py`)
|
### 7.3 État de synchronisation
|
||||||
|
|
||||||
Pour éviter de synchroniser à chaque exécution tous les événements depuis le début des temps, on peut stocker un **état local** de la synchronisation.
|
**M7 ne stocke aucun état local** : il n'existe ni fichier d'état ni module
|
||||||
|
`sync/state.py`.
|
||||||
|
|
||||||
**Protéger les fichiers d'état** :
|
- **Scan du calendrier distant** : à chaque exécution, la synchronisation **scanne le
|
||||||
- **Permissions** : Appliquer `chmod 600` sur les fichiers d'état (ex: `.pronote_sync_state.json`) pour limiter l'accès au propriétaire.
|
calendrier CalDAV distant** pour retrouver les événements gérés (marqueur
|
||||||
- **Exclusion Git** : Ajouter les fichiers d'état au `.gitignore` pour éviter de les commiter.
|
`X-PRONOTE-SYNC-MANAGED: v1`), indexés par UID normalisé.
|
||||||
- **Exclusion des sauvegardes** : Exclure les fichiers d'état des sauvegardes automatiques (ex: Time Machine, rsync).
|
- **Le calendrier distant est la source de vérité** : la comparaison entre événements
|
||||||
- **Emplacement** : Stocker les fichiers d'état dans un répertoire dédié (ex: `~/.config/pronote-sync/`) hors de l'arborescence Git.
|
distants gérés et données Pronote se fait directement sur le calendrier, sans fichier
|
||||||
|
intermédiaire. Cela garantit une **idempotence naturelle** (deux exécutions identiques
|
||||||
|
produisent le même état) et supprime les risques liés à un fichier local (corruption,
|
||||||
|
perte, fuite de données, permissions `chmod 600`, exclusion `.gitignore` ou des
|
||||||
|
sauvegardes).
|
||||||
|
|
||||||
#### 7.3.1 Options pour l'état local
|
> **Évolution future** : si les performances l'exigent (calendrier très chargé, scans
|
||||||
| **Option** | **Avantages** | **Inconvénients** | **Recommandation** |
|
> 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
|
||||||
| Fichier JSON | Simple, portable, pas de dépendance | Moins performant pour les gros volumes | ✅ Pour un usage simple |
|
> (§7.1, §7.2 et §7.4 restent valables).
|
||||||
| SQLite | Performant, requêtes complexes | Dépendance supplémentaire | ✅ Pour un usage avancé |
|
|
||||||
| Sync-token CalDAV| Natif, optimisé | Pas toujours supporté par les serveurs | ⚠️ Si disponible |
|
|
||||||
|
|
||||||
#### 7.3.2 Implémentation avec JSON (`sync/state.py`)
|
|
||||||
|
|
||||||
```python
|
|
||||||
import json
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, Optional, Any
|
|
||||||
from ..models.agenda import Lesson, Homework
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class SyncState:
|
|
||||||
"""
|
|
||||||
Gère l'état de synchronisation local (fichier JSON).
|
|
||||||
Stocke les UID et les timestamps des dernières synchronisations.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, state_file: str = ".pronote_sync_state.json"):
|
|
||||||
self.state_file = Path(state_file)
|
|
||||||
self._state: Dict[str, Any] = {
|
|
||||||
"last_sync": None,
|
|
||||||
"synced_uids": {
|
|
||||||
"lessons": set(),
|
|
||||||
"homeworks": set(),
|
|
||||||
"school_events": set(),
|
|
||||||
},
|
|
||||||
"sync_history": [],
|
|
||||||
}
|
|
||||||
self._load()
|
|
||||||
|
|
||||||
def _load(self) -> None:
|
|
||||||
"""Charge l'état depuis le fichier."""
|
|
||||||
if self.state_file.exists():
|
|
||||||
try:
|
|
||||||
with open(self.state_file, "r", encoding="utf-8") as f:
|
|
||||||
self._state = json.load(f)
|
|
||||||
# Convertir les sets en sets (JSON les stocke comme des listes)
|
|
||||||
self._state["synced_uids"] = {
|
|
||||||
k: set(v) for k, v in self._state.get("synced_uids", {}).items()
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Échec du chargement de l'état: {e}")
|
|
||||||
self._state = {
|
|
||||||
"last_sync": None,
|
|
||||||
"synced_uids": {
|
|
||||||
"lessons": set(),
|
|
||||||
"homeworks": set(),
|
|
||||||
"school_events": set(),
|
|
||||||
},
|
|
||||||
"sync_history": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
def _save(self) -> None:
|
|
||||||
"""Sauvegarde l'état dans le fichier."""
|
|
||||||
# Convertir les sets en listes pour JSON
|
|
||||||
state_to_save = {
|
|
||||||
**self._state,
|
|
||||||
"synced_uids": {
|
|
||||||
k: list(v) for k, v in self._state["synced_uids"].items()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
with open(self.state_file, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(state_to_save, f, indent=2, ensure_ascii=False)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Échec de la sauvegarde de l'état: {e}")
|
|
||||||
|
|
||||||
def mark_synced(
|
|
||||||
self,
|
|
||||||
lessons: list[Lesson],
|
|
||||||
homeworks: list[Homework],
|
|
||||||
school_events: list,
|
|
||||||
) -> None:
|
|
||||||
"""Marque les UID comme synchronisés."""
|
|
||||||
self._state["synced_uids"]["lessons"].update(lesson.id for lesson in lessons)
|
|
||||||
self._state["synced_uids"]["homeworks"].update(
|
|
||||||
f"homework-{hw.id}" for hw in homeworks
|
|
||||||
)
|
|
||||||
self._state["synced_uids"]["school_events"].update(
|
|
||||||
f"school-event-{se.label}-{se.from_date.isoformat()}" for se in school_events
|
|
||||||
)
|
|
||||||
self._state["last_sync"] = datetime.now().isoformat()
|
|
||||||
self._state["sync_history"].append({
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
|
||||||
"lessons": len(lessons),
|
|
||||||
"homeworks": len(homeworks),
|
|
||||||
"school_events": len(school_events),
|
|
||||||
})
|
|
||||||
self._save()
|
|
||||||
|
|
||||||
def is_synced(self, uid: str, kind: str = "lessons") -> bool:
|
|
||||||
"""Vérifie si un UID a déjà été synchronisé."""
|
|
||||||
return uid in self._state["synced_uids"].get(kind, set())
|
|
||||||
|
|
||||||
def get_last_sync(self) -> Optional[datetime]:
|
|
||||||
"""Récupère la date de la dernière synchronisation."""
|
|
||||||
if self._state["last_sync"]:
|
|
||||||
return datetime.fromisoformat(self._state["last_sync"])
|
|
||||||
return None
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
"""Efface l'état."""
|
|
||||||
self._state = {
|
|
||||||
"last_sync": None,
|
|
||||||
"synced_uids": {
|
|
||||||
"lessons": set(),
|
|
||||||
"homeworks": set(),
|
|
||||||
"school_events": set(),
|
|
||||||
},
|
|
||||||
"sync_history": [],
|
|
||||||
}
|
|
||||||
self._save()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.4 Points clés
|
### 7.4 Points clés
|
||||||
- **Différentielle** : La synchronisation compare les UID existants avec ceux à synchroniser.
|
- **Différentielle** : La synchronisation compare les UID existants avec ceux à synchroniser.
|
||||||
@@ -3311,6 +3283,9 @@ class SyncState:
|
|||||||
- **Dry-run** : Mode obligatoire pour tester sans effet de bord.
|
- **Dry-run** : Mode obligatoire pour tester sans effet de bord.
|
||||||
- **Marquage** : Les événements gérés sont marqués avec `X-PRONOTE-SYNC-MANAGED: v1` pour éviter les conflits.
|
- **Marquage** : Les événements gérés sont marqués avec `X-PRONOTE-SYNC-MANAGED: v1` pour éviter les conflits.
|
||||||
- **Cours annulés** : Conservés avec `STATUS:CANCELLED` (ne pas supprimer).
|
- **Cours annulés** : Conservés avec `STATUS:CANCELLED` (ne pas supprimer).
|
||||||
|
- **Plan explicite** : Le `CalDAVSyncPlan` est calculé avant l'exécution.
|
||||||
|
- **Pas d'état local** : La comparaison se fait avec le calendrier distant (scan).
|
||||||
|
- **Événements non gérés** : Les événements non marqués ne sont jamais modifiés ni supprimés.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -3320,6 +3295,10 @@ class SyncState:
|
|||||||
- **Agenda théorique** : Représente l'emploi du temps **attendu** (ex: emploi du temps officiel de l'établissement).
|
- **Agenda théorique** : Représente l'emploi du temps **attendu** (ex: emploi du temps officiel de l'établissement).
|
||||||
- **Agenda réel** : Représente l'emploi du temps **réel** (récupéré depuis Pronote).
|
- **Agenda réel** : Représente l'emploi du temps **réel** (récupéré depuis Pronote).
|
||||||
- **Objectif** : Détecter les **changements** (ajouts, suppressions, modifications) entre les deux.
|
- **Objectif** : Détecter les **changements** (ajouts, suppressions, modifications) entre les deux.
|
||||||
|
- **Format JSON** : L'agenda théorique est désormais décrit par un **fichier JSON** (et non plus iCal/CSV), avec gestion de la **parité des semaines** (paire/impaire) et des **vacances scolaires**.
|
||||||
|
- **Parité des semaines** : Chaque leçon peut s'appliquer à **toutes** les semaines (`all`), uniquement aux semaines **paires** (`even`) ou **impaires** (`odd`). La parité d'une date est calculée par rapport à une **date de référence** configurée.
|
||||||
|
- **Vacances scolaires** : Un **fichier JSON séparé** liste les périodes de vacances (ex: zone A) ; aucune leçon théorique n'est produite pendant ces périodes.
|
||||||
|
- **Encapsulation** : Le provider encapsule **en interne** le calcul de la parité et le filtrage des vacances ; l'appelant (ex: `AgendaComparator`) reçoit simplement les cours théoriques ou une liste vide.
|
||||||
- **Matching déterministe** : Utiliser des règles claires pour associer un cours réel à un cours théorique (décision [4](#4-agenda-théorique---interface-abstraite--implémentation-fichier)).
|
- **Matching déterministe** : Utiliser des règles claires pour associer un cours réel à un cours théorique (décision [4](#4-agenda-théorique---interface-abstraite--implémentation-fichier)).
|
||||||
|
|
||||||
### 8.2 Interface `TheoreticalAgendaProvider` (`sources/theoretical/provider.py`)
|
### 8.2 Interface `TheoreticalAgendaProvider` (`sources/theoretical/provider.py`)
|
||||||
@@ -3367,192 +3346,135 @@ class TheoreticalAgendaProvider(Protocol):
|
|||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
> **Note sur le périmètre du provider** : Le fournisseur gère **en interne** la **parité des semaines** (paire/impaire) et les **vacances scolaires**. L'appelant ne connaît ni la date de référence de parité, ni les périodes de vacances : en période de vacances (ou pour une semaine dont la parité ne correspond à aucune leçon), il reçoit simplement une **liste vide**. Le contrat `TheoreticalAgendaProvider` reste donc volontairement minimal et stable.
|
||||||
|
|
||||||
|
|
||||||
### 8.3 Implémentation par fichier (`sources/theoretical/file.py`)
|
### 8.3 Implémentation par fichier (`sources/theoretical/file.py`)
|
||||||
|
|
||||||
#### 8.3.1 Fichier iCal
|
#### 8.3.1 Format du fichier JSON de l'agenda théorique
|
||||||
|
|
||||||
Si l'agenda théorique est fourni sous forme de **fichier iCal** (ex: export depuis un autre outil), on peut le parser de la même manière que le flux Pronote.
|
L'agenda théorique est fourni sous forme de **fichier JSON** (ex: `./data/theoretical.json`) :
|
||||||
|
|
||||||
```python
|
```json
|
||||||
from typing import List
|
{
|
||||||
from datetime import date, time
|
"version": 1,
|
||||||
from pathlib import Path
|
"lessons": [
|
||||||
from icalendar import Calendar, Event
|
{
|
||||||
from ..models.agenda import TheoreticalLesson
|
"week": "all",
|
||||||
from .provider import TheoreticalAgendaProvider
|
"day_of_week": 0,
|
||||||
|
"start_time": "08:00",
|
||||||
|
"end_time": "09:00",
|
||||||
class ICalTheoreticalAgendaProvider:
|
"subject": "Mathématiques",
|
||||||
"""Fournisseur d'agenda théorique depuis un fichier iCal."""
|
"teachers": ["M. Dupont"],
|
||||||
|
"rooms": ["101"]
|
||||||
def __init__(self, file_path: str):
|
},
|
||||||
self.file_path = Path(file_path)
|
{
|
||||||
self._lessons: List[TheoreticalLesson] = []
|
"week": "even",
|
||||||
self._load()
|
"day_of_week": 1,
|
||||||
|
"start_time": "10:00",
|
||||||
def _load(self) -> None:
|
"end_time": "11:00",
|
||||||
"""Charge le fichier iCal et parse les cours."""
|
"subject": "Anglais",
|
||||||
if not self.file_path.exists():
|
"teachers": [],
|
||||||
raise FileNotFoundError(f"Fichier iCal introuvable: {self.file_path}")
|
"rooms": []
|
||||||
|
},
|
||||||
with open(self.file_path, "rb") as f:
|
{
|
||||||
cal = Calendar.from_ical(f.read())
|
"week": "odd",
|
||||||
|
"day_of_week": 1,
|
||||||
for component in cal.walk():
|
"start_time": "10:00",
|
||||||
if not isinstance(component, Event):
|
"end_time": "11:00",
|
||||||
continue
|
"subject": "Espagnol",
|
||||||
|
"teachers": [],
|
||||||
# Ignorer les événements tout le jour (vacances, etc.)
|
"rooms": []
|
||||||
if hasattr(component.get("dtstart"), "dt") and not hasattr(component.get("dtstart").dt, "hour"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
start = component.get("dtstart").dt
|
|
||||||
end = component.get("dtend").dt
|
|
||||||
|
|
||||||
# Générer un ID stable (basé sur le jour, l'heure et la matière)
|
|
||||||
summary = str(component.get("summary", ""))
|
|
||||||
uid = f"theoretical-{start.strftime('%Y%m%d')}-{start.hour}{start.minute}-{summary}"
|
|
||||||
|
|
||||||
lesson = TheoreticalLesson(
|
|
||||||
id=uid,
|
|
||||||
day_of_week=start.weekday(),
|
|
||||||
start_time=time(start.hour, start.minute),
|
|
||||||
end_time=time(end.hour, end.minute),
|
|
||||||
subject=summary,
|
|
||||||
teachers=[], # À extraire de la description si disponible
|
|
||||||
rooms=[],
|
|
||||||
)
|
|
||||||
self._lessons.append(lesson)
|
|
||||||
|
|
||||||
def get_lessons(self, date: date) -> List[TheoreticalLesson]:
|
|
||||||
"""Récupère les cours pour une date donnée."""
|
|
||||||
day_of_week = date.weekday()
|
|
||||||
return [
|
|
||||||
lesson for lesson in self._lessons
|
|
||||||
if lesson.day_of_week == day_of_week
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_lessons_for_range(
|
|
||||||
self,
|
|
||||||
start_date: date,
|
|
||||||
end_date: date,
|
|
||||||
) -> List[TheoreticalLesson]:
|
|
||||||
"""Récupère les cours pour une plage de dates."""
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
result = []
|
|
||||||
current_date = start_date
|
|
||||||
while current_date <= end_date:
|
|
||||||
result.extend(self.get_lessons(current_date))
|
|
||||||
current_date += timedelta(days=1)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
#### 8.3.2 Fichier CSV
|
|
||||||
|
|
||||||
Si l'agenda théorique est fourni sous forme de **fichier CSV**, on peut le parser ainsi :
|
|
||||||
|
|
||||||
```csv
|
|
||||||
jour,semaine,heure_debut,heure_fin,matiere,professeur,salle
|
|
||||||
lundi,1,08:00,09:00,Mathématiques,M. Dupont,204
|
|
||||||
lundi,1,09:00,10:00,Français,Mme Martin,205
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
```python
|
|
||||||
import csv
|
|
||||||
from typing import List
|
|
||||||
from datetime import date, time
|
|
||||||
from pathlib import Path
|
|
||||||
from ..models.agenda import TheoreticalLesson
|
|
||||||
from .provider import TheoreticalAgendaProvider
|
|
||||||
|
|
||||||
|
|
||||||
class CSVTheoreticalAgendaProvider:
|
|
||||||
"""Fournisseur d'agenda théorique depuis un fichier CSV."""
|
|
||||||
|
|
||||||
def __init__(self, file_path: str):
|
|
||||||
self.file_path = Path(file_path)
|
|
||||||
self._lessons: List[TheoreticalLesson] = []
|
|
||||||
self._load()
|
|
||||||
|
|
||||||
def _load(self) -> None:
|
|
||||||
"""Charge le fichier CSV et parse les cours."""
|
|
||||||
if not self.file_path.exists():
|
|
||||||
raise FileNotFoundError(f"Fichier CSV introuvable: {self.file_path}")
|
|
||||||
|
|
||||||
with open(self.file_path, "r", encoding="utf-8") as f:
|
|
||||||
reader = csv.DictReader(f)
|
|
||||||
for row in reader:
|
|
||||||
day_of_week = self._parse_day(row["jour"])
|
|
||||||
start_time = self._parse_time(row["heure_debut"])
|
|
||||||
end_time = self._parse_time(row["heure_fin"])
|
|
||||||
|
|
||||||
# Générer un ID stable
|
|
||||||
uid = f"theoretical-{day_of_week}-{start_time.isoformat()}-{row['matiere']}"
|
|
||||||
|
|
||||||
lesson = TheoreticalLesson(
|
|
||||||
id=uid,
|
|
||||||
day_of_week=day_of_week,
|
|
||||||
start_time=start_time,
|
|
||||||
end_time=end_time,
|
|
||||||
subject=row["matiere"],
|
|
||||||
teachers=[row["professeur"]] if row.get("professeur") else [],
|
|
||||||
rooms=[row["salle"]] if row.get("salle") else [],
|
|
||||||
)
|
|
||||||
self._lessons.append(lesson)
|
|
||||||
|
|
||||||
def _parse_day(self, day: str) -> int:
|
|
||||||
"""Convertit un nom de jour en index (0=lundi, 6=dimanche)."""
|
|
||||||
days = {
|
|
||||||
"lundi": 0,
|
|
||||||
"mardi": 1,
|
|
||||||
"mercredi": 2,
|
|
||||||
"jeudi": 3,
|
|
||||||
"vendredi": 4,
|
|
||||||
"samedi": 5,
|
|
||||||
"dimanche": 6,
|
|
||||||
}
|
}
|
||||||
return days.get(day.lower(), 0)
|
|
||||||
|
|
||||||
def _parse_time(self, time_str: str) -> time:
|
|
||||||
"""Parse une chaîne de temps (ex: 08:00)."""
|
|
||||||
hour, minute = map(int, time_str.split(":"))
|
|
||||||
return time(hour, minute)
|
|
||||||
|
|
||||||
def get_lessons(self, date: date) -> List[TheoreticalLesson]:
|
|
||||||
"""Récupère les cours pour une date donnée."""
|
|
||||||
day_of_week = date.weekday()
|
|
||||||
return [
|
|
||||||
lesson for lesson in self._lessons
|
|
||||||
if lesson.day_of_week == day_of_week
|
|
||||||
]
|
]
|
||||||
|
}
|
||||||
def get_lessons_for_range(
|
|
||||||
self,
|
|
||||||
start_date: date,
|
|
||||||
end_date: date,
|
|
||||||
) -> List[TheoreticalLesson]:
|
|
||||||
"""Récupère les cours pour une plage de dates."""
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
result = []
|
|
||||||
current_date = start_date
|
|
||||||
while current_date <= end_date:
|
|
||||||
result.extend(self.get_lessons(current_date))
|
|
||||||
current_date += timedelta(days=1)
|
|
||||||
return result
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- `week` : `"all"` (toutes les semaines), `"even"` (semaines paires) ou `"odd"` (semaines impaires).
|
||||||
|
- `day_of_week` : entier de **0 (lundi)** à **6 (dimanche)**.
|
||||||
|
- `start_time` / `end_time` : chaînes au format `"HH:MM"`.
|
||||||
|
- `teachers` / `rooms` : listes de chaînes, **optionnelles** (défaut : liste vide).
|
||||||
|
- `id` : **optionnel** ; s'il est absent, le provider génère un identifiant **déterministe incluant le type de semaine**, afin que deux leçons de parité différente sur le même créneau aient des identifiants distincts.
|
||||||
|
|
||||||
|
#### 8.3.2 Format du fichier JSON des vacances scolaires (fichier séparé)
|
||||||
|
|
||||||
|
Les vacances scolaires sont décrites dans un **fichier JSON séparé** (ex: `./data/school_holidays.json`) :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"zone": "A",
|
||||||
|
"school_year": "2026-2027",
|
||||||
|
"periods": [
|
||||||
|
{
|
||||||
|
"start_date": "2026-10-17",
|
||||||
|
"end_date": "2026-11-02",
|
||||||
|
"label": "Toussaint"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `start_date` et `end_date` sont des dates ISO (`YYYY-MM-DD`) **inclusives**.
|
||||||
|
- `is_holiday(date)` retourne `True` si la date tombe dans **l'une** des périodes (`start_date <= date <= end_date`).
|
||||||
|
|
||||||
|
#### 8.3.3 Service de parité de semaine
|
||||||
|
|
||||||
|
La parité des semaines est configurée via deux paramètres :
|
||||||
|
|
||||||
|
- `THEORETICAL_WEEK_ANCHOR_DATE` : date de référence (ex: `2026-09-01`).
|
||||||
|
- `THEORETICAL_WEEK_ANCHOR_TYPE` : `"even"` ou `"odd"` (parité de la semaine de référence).
|
||||||
|
|
||||||
|
**Algorithme** : on calcule le nombre de semaines entre le **lundi de la semaine cible** et le **lundi de la semaine de référence**. Si ce décalage est **pair**, la semaine cible a la **même parité** que l'ancre ; s'il est **impair**, la parité est **opposée**.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
|
def week_parity(
|
||||||
|
target: date,
|
||||||
|
anchor_date: date,
|
||||||
|
anchor_type: Literal["even", "odd"],
|
||||||
|
) -> Literal["even", "odd"]:
|
||||||
|
"""Détermine la parité (paire/impaire) de la semaine d'une date cible.
|
||||||
|
|
||||||
|
:param target: Date dont on veut connaître la parité de semaine.
|
||||||
|
:param anchor_date: Date de référence (semaine de parité ``anchor_type``).
|
||||||
|
:param anchor_type: Parité de la semaine de référence (``"even"`` ou ``"odd"``).
|
||||||
|
:return: ``"even"`` ou ``"odd"`` selon la parité calculée.
|
||||||
|
:rtype: Literal["even", "odd"]
|
||||||
|
"""
|
||||||
|
target_monday = target - timedelta(days=target.weekday())
|
||||||
|
anchor_monday = anchor_date - timedelta(days=anchor_date.weekday())
|
||||||
|
offset_weeks = (target_monday - anchor_monday).days // 7
|
||||||
|
if offset_weeks % 2 == 0:
|
||||||
|
return anchor_type
|
||||||
|
return "odd" if anchor_type == "even" else "even"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 8.3.4 Comportement du provider JSON
|
||||||
|
|
||||||
|
- `get_lessons(date)` : si la date tombe pendant les **vacances scolaires**, retourner `[]`. Sinon, déterminer la **parité de la semaine**, filtrer les leçons selon le champ `week` (`all` correspond à toutes les semaines, `even`/`odd` à leur parité respective), construire les objets `TheoreticalLesson` et retourner la liste **triée par `id`**.
|
||||||
|
- `get_lessons_for_range(start_date, end_date)` : itérer sur **chaque date** de la plage, ignorer les **vacances scolaires**, appliquer le **filtrage de parité** à chaque jour et retourner la liste cumulée (éventuellement **dédupliquée par `id`**).
|
||||||
|
|
||||||
|
#### 8.3.5 Configuration
|
||||||
|
|
||||||
|
- `THEORETICAL_AGENDA_PATH` : chemin vers le fichier JSON de l'agenda théorique (ex: `./data/theoretical.json`).
|
||||||
|
- `SCHOOL_HOLIDAYS_PATH` : chemin vers le fichier JSON des vacances scolaires.
|
||||||
|
- `THEORETICAL_WEEK_ANCHOR_DATE` : date de référence pour la parité (ex: `2026-09-01`).
|
||||||
|
- `THEORETICAL_WEEK_ANCHOR_TYPE` : `"even"` ou `"odd"`.
|
||||||
|
- Si `THEORETICAL_AGENDA_PATH` est `None`, le provider est **désactivé** (M8 retourne un diff vide, non bloquant).
|
||||||
|
- Si le fichier d'agenda contient des leçons `even`/`odd` mais **aucune ancre n'est configurée**, lever une **erreur de configuration** explicite.
|
||||||
|
|
||||||
|
|
||||||
### 8.4 Politique de départage pour les collisions
|
### 8.4 Politique de départage pour les collisions
|
||||||
|
|
||||||
**Règle déterministe** pour les collisions entre cours théoriques et réels :
|
**Règle déterministe** pour les collisions entre cours théoriques et réels :
|
||||||
1. **Tri par identifiant stable** : Les cours sont triés par UID ou clé de matching (ex: `theoretical-{day_of_week}-{start_time}-{subject}`).
|
1. **Tri par identifiant stable** : Les cours sont triés par ID ou clé de matching (ex: `theoretical-{day_of_week}-{start_time}-{subject}`).
|
||||||
2. **Comparaison exacte** : Les créneaux horaires et la matière normalisée doivent correspondre.
|
2. **Comparaison exacte** : Les créneaux horaires et la matière normalisée doivent correspondre.
|
||||||
3. **Choix de la première correspondance** : En cas de multiples correspondances admissibles, choisir la **première** après tri déterministe.
|
3. **Choix de la première correspondance** : En cas de multiples correspondances admissibles, choisir la **première** après tri déterministe.
|
||||||
|
|
||||||
**Exemple de tri** :
|
**Exemple de tri** :
|
||||||
```python
|
```python
|
||||||
# Tri des cours théoriques par ID stable (pour un matching déterministe)
|
# Tri des cours théoriques par ID stable (pour un matching déterministe)
|
||||||
theoretical_lessons_sorted = sorted(
|
theoretical_lessons_sorted = sorted(
|
||||||
@@ -3564,25 +3486,44 @@ theoretical_lessons_sorted = sorted(
|
|||||||
lesson.subject.lower(),
|
lesson.subject.lower(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
```
|
||||||
|
|
||||||
**Exemple de matching avec départage déterministe** :
|
**Exemple de matching avec départage déterministe** :
|
||||||
```python
|
```python
|
||||||
def match_theoretical_event(
|
def match_theoretical_lesson(
|
||||||
real_lesson: PronoteLesson,
|
real_lesson: Lesson,
|
||||||
theoretical_events: list[TheoreticalEvent],
|
theoretical_events: list[TheoreticalLesson],
|
||||||
tolerance_minutes: int = 15,
|
tolerance_minutes: int = 15,
|
||||||
) -> TheoreticalEvent | None:
|
) -> TheoreticalLesson | None:
|
||||||
"""Trouve l'événement théorique correspondant, avec départage déterministe."""
|
"""Trouve la leçon théorique correspondant à une leçon réelle.
|
||||||
|
|
||||||
|
:param real_lesson: Leçon réelle depuis Pronote.
|
||||||
|
:param theoretical_events: Liste des leçons théoriques candidates.
|
||||||
|
:param tolerance_minutes: Tolérance en minutes pour le créneau horaire.
|
||||||
|
:return: La leçon théorique correspondante, ou None.
|
||||||
|
:rtype: TheoreticalLesson | None
|
||||||
|
"""
|
||||||
|
real_start = real_lesson.start
|
||||||
|
real_day = real_start.weekday()
|
||||||
|
|
||||||
|
def to_minutes(t: time) -> int:
|
||||||
|
return t.hour * 60 + t.minute
|
||||||
|
|
||||||
|
# ``normalize_subject`` sera défini dans ``sync/diff.py`` (M8) ou dans le
|
||||||
|
# module théorique ; il normalise les matières pour un matching déterministe.
|
||||||
|
start_minutes = real_start.hour * 60 + real_start.minute
|
||||||
|
end_minutes = real_lesson.end.hour * 60 + real_lesson.end.minute
|
||||||
candidates = [
|
candidates = [
|
||||||
t for t in theoretical_events
|
t
|
||||||
if abs((t.start - real_lesson.start).total_seconds()) <= tolerance_minutes * 60
|
for t in theoretical_events
|
||||||
|
if t.day_of_week == real_day
|
||||||
|
and abs(to_minutes(t.start_time) - start_minutes) <= tolerance_minutes
|
||||||
|
and abs(to_minutes(t.end_time) - end_minutes) <= tolerance_minutes
|
||||||
and normalize_subject(t.subject) == normalize_subject(real_lesson.subject)
|
and normalize_subject(t.subject) == normalize_subject(real_lesson.subject)
|
||||||
and t.start.date() == real_lesson.start.date()
|
|
||||||
]
|
]
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return None
|
return None
|
||||||
# Tri déterministe par UID stable, puis par créneau
|
candidates.sort(key=lambda t: (t.id, t.start_time))
|
||||||
candidates.sort(key=lambda t: (t.uid or "", t.start))
|
|
||||||
return candidates[0]
|
return candidates[0]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -3789,11 +3730,14 @@ class AgendaComparator:
|
|||||||
|
|
||||||
|
|
||||||
### 8.7 Points clés
|
### 8.7 Points clés
|
||||||
|
- **Format JSON** : L'agenda théorique est décrit par un **fichier JSON** (leçons `all`/`even`/`odd`) ; les vacances scolaires sont décrites par un **fichier JSON séparé**.
|
||||||
|
- **Parité des semaines** : Déterminée par `THEORETICAL_WEEK_ANCHOR_DATE` et `THEORETICAL_WEEK_ANCHOR_TYPE` (décalage en semaines entre le lundi de référence et le lundi cible).
|
||||||
|
- **Vacances scolaires** : Les jours de vacances retournent une **liste vide** (aucune leçon théorique).
|
||||||
|
- **Identifiants déterministes** : Générés par le provider (type de semaine inclus) pour garantir des IDs distincts et stables.
|
||||||
- **Matching déterministe** : Basé sur le jour, le créneau horaire (avec tolérance) et la matière normalisée.
|
- **Matching déterministe** : Basé sur le jour, le créneau horaire (avec tolérance) et la matière normalisée.
|
||||||
- **Normalisation** : Les matières et heures sont normalisées pour éviter les faux négatifs.
|
- **Normalisation** : Les matières et heures sont normalisées pour éviter les faux négatifs.
|
||||||
- **Types de changements** : Ajout, suppression, modification.
|
- **Types de changements** : Ajout, suppression, modification.
|
||||||
- **Agenda théorique** : Peut être fourni via fichier iCal ou CSV (extensible à d'autres sources).
|
- **Politique de départage** : Tri par identifiant stable (ID), puis comparaison exacte des créneaux et matière normalisée. En cas de multiples correspondances, choix de la première après tri déterministe.
|
||||||
- **Politique de départage** : Tri par identifiant stable (UID), puis comparaison exacte des créneaux et matière normalisée. En cas de multiples correspondances, choix de la première après tri déterministe.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -4917,11 +4861,11 @@ Les autres étapes (`normalize_step`, `compare_step`, etc.) suivent le même pri
|
|||||||
tests/
|
tests/
|
||||||
├── __init__.py
|
├── __init__.py
|
||||||
├── conftest.py # Fixtures pytest partagées
|
├── conftest.py # Fixtures pytest partagées
|
||||||
├── fixtures/ # Fichiers de fixtures (iCal, CSV, XML, etc.)
|
├── fixtures/ # Fichiers de fixtures (iCal, JSON, XML, etc.)
|
||||||
│ ├── pronote-4e.ics # Flux iCal Pronote anonymisé (4ème)
|
│ ├── pronote-4e.ics # Flux iCal Pronote anonymisé (4ème)
|
||||||
│ ├── pronote-6e.ics # Flux iCal Pronote anonymisé (6ème)
|
│ ├── pronote-6e.ics # Flux iCal Pronote anonymisé (6ème)
|
||||||
│ ├── theoretical.ics # Agenda théorique iCal
|
│ ├── theoretical.json # Agenda théorique JSON
|
||||||
│ ├── theoretical.csv # Agenda théorique CSV
|
│ ├── school_holidays.json # Vacances scolaires JSON
|
||||||
│ └── blog_rss.xml # Flux RSS du blog anonymisé
|
│ └── blog_rss.xml # Flux RSS du blog anonymisé
|
||||||
├── unit/ # Tests unitaires
|
├── unit/ # Tests unitaires
|
||||||
│ ├── test_models.py # Tests des modèles Pydantic
|
│ ├── test_models.py # Tests des modèles Pydantic
|
||||||
@@ -4995,17 +4939,49 @@ END:VCALENDAR
|
|||||||
- **Données réalistes** : Structure identique aux flux réels Pronote.
|
- **Données réalistes** : Structure identique aux flux réels Pronote.
|
||||||
|
|
||||||
|
|
||||||
#### 12.3.2 Exemple de fichier CSV théorique (`tests/fixtures/theoretical.csv`)
|
#### 12.3.2 Exemple de fichier JSON théorique (`tests/fixtures/theoretical.json`)
|
||||||
|
|
||||||
```csv
|
```json
|
||||||
jour,semaine,heure_debut,heure_fin,matiere,professeur,salle
|
{
|
||||||
lundi,1,08:00,09:00,Mathématiques,M. Dupont,204
|
"version": 1,
|
||||||
lundi,1,09:00,10:00,Français,Mme Martin,205
|
"lessons": [
|
||||||
lundi,1,10:00,11:00,Histoire-Géographie,M. Bernard,206
|
{
|
||||||
mardi,1,08:00,09:00,Physique-Chimie,Mme Durand,301
|
"id": "theoretical-maths-monday-1",
|
||||||
...
|
"week": "all",
|
||||||
|
"day_of_week": 0,
|
||||||
|
"start_time": "08:00",
|
||||||
|
"end_time": "09:00",
|
||||||
|
"subject": "Mathématiques",
|
||||||
|
"teachers": ["Mme Martin"],
|
||||||
|
"rooms": ["101"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"week": "all",
|
||||||
|
"day_of_week": 0,
|
||||||
|
"start_time": "09:00",
|
||||||
|
"end_time": "10:00",
|
||||||
|
"subject": "Français",
|
||||||
|
"teachers": ["M. Dupont"],
|
||||||
|
"rooms": ["102"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"week": "even",
|
||||||
|
"day_of_week": 1,
|
||||||
|
"start_time": "10:00",
|
||||||
|
"end_time": "11:00",
|
||||||
|
"subject": "Anglais",
|
||||||
|
"teachers": ["Mme Bernard"],
|
||||||
|
"rooms": ["201"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Champs clés** :
|
||||||
|
- `week` : `all` (toutes les semaines), `even` (semaines paires) ou `odd` (semaines impaires).
|
||||||
|
- `day_of_week` : jour de la semaine (0 = lundi, 4 = vendredi).
|
||||||
|
- `start_time` / `end_time` : créneau horaire au format `HH:MM`.
|
||||||
|
|
||||||
|
|
||||||
### 12.4 Configuration pytest (`tests/conftest.py`)
|
### 12.4 Configuration pytest (`tests/conftest.py`)
|
||||||
|
|
||||||
@@ -5247,7 +5223,7 @@ def sample_settings():
|
|||||||
app=AppSettings(
|
app=AppSettings(
|
||||||
dry_run=True,
|
dry_run=True,
|
||||||
log_level="DEBUG",
|
log_level="DEBUG",
|
||||||
theoretical_agenda_path="./tests/fixtures/theoretical.ics",
|
theoretical_agenda_path="./tests/fixtures/theoretical.json",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -5298,7 +5274,7 @@ def test_pipeline_full(mock_requests_get, mock_caldav_client, mock_ai_provider,
|
|||||||
from pronote_sync.sources.pronote.fallback import PronoteFetcher
|
from pronote_sync.sources.pronote.fallback import PronoteFetcher
|
||||||
from pronote_sync.sync.caldav import CalDAVClient
|
from pronote_sync.sync.caldav import CalDAVClient
|
||||||
from pronote_sync.sync.diff import AgendaComparator
|
from pronote_sync.sync.diff import AgendaComparator
|
||||||
from pronote_sync.sources.theoretical.file import CSVTheoreticalAgendaProvider
|
from pronote_sync.sources.theoretical.file import JsonTheoreticalAgendaProvider
|
||||||
|
|
||||||
# Configurer le fetcher Pronote
|
# Configurer le fetcher Pronote
|
||||||
pronote_client = PronoteClient(sample_settings.pronote)
|
pronote_client = PronoteClient(sample_settings.pronote)
|
||||||
@@ -5313,7 +5289,7 @@ def test_pipeline_full(mock_requests_get, mock_caldav_client, mock_ai_provider,
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Configurer le comparateur d'agenda
|
# Configurer le comparateur d'agenda
|
||||||
theoretical_provider = CSVTheoreticalAgendaProvider(
|
theoretical_provider = JsonTheoreticalAgendaProvider(
|
||||||
file_path=sample_settings.app.theoretical_agenda_path
|
file_path=sample_settings.app.theoretical_agenda_path
|
||||||
)
|
)
|
||||||
comparator = AgendaComparator(theoretical_provider)
|
comparator = AgendaComparator(theoretical_provider)
|
||||||
|
|||||||
36
TODO.md
36
TODO.md
@@ -112,16 +112,23 @@ Récupérer le flux RSS du blog du collège, parser et dédupliquer les articles
|
|||||||
|
|
||||||
## M6. Source agenda théorique — Priorité : Moyenne
|
## M6. Source agenda théorique — Priorité : Moyenne
|
||||||
|
|
||||||
Lire l'agenda théorique (iCal ou CSV) via une interface de provider extensible.
|
Lire l'agenda théorique (JSON) via une interface de provider extensible, avec gestion de la parité des semaines (paire/impaire) et des vacances scolaires.
|
||||||
|
|
||||||
- [ ] Créer `sources/theoretical/provider.py` : protocole `TheoreticalAgendaProvider` (§8.2).
|
- [x] Créer `sources/theoretical/provider.py` : protocole `TheoreticalAgendaProvider` (§8.2).
|
||||||
- [ ] Créer `sources/theoretical/file.py` : lecture fichier iCal/CSV → liste de `TheoreticalLesson` (§8.3).
|
- [x] Créer `sources/theoretical/file.py` : parser JSON → liste de `TheoreticalLesson` avec filtrage par parité de semaine (paire/impaire/toutes).
|
||||||
- [ ] Normaliser les matières et créneaux pour le matching déterministe.
|
- [x] Créer `sources/theoretical/parity.py` : service `WeekParityService` déterminant la parité d'une date à partir d'une date de référence configurée.
|
||||||
- [ ] Supporter les deux formats (iCal et CSV) derrière la même interface.
|
- [x] Créer `sources/theoretical/holidays.py` : service `SchoolHolidayCalendar` lisant un fichier JSON de vacances scolaires (zone A) et exposant `is_holiday(date)`.
|
||||||
|
- [x] Implémenter le provider JSON : filtrage par parité + vacances, génération d'identifiants déterministes incluant le type de semaine.
|
||||||
|
- [x] Ajouter la configuration : `SCHOOL_HOLIDAYS_PATH`, `THEORETICAL_WEEK_ANCHOR_DATE`, `THEORETICAL_WEEK_ANCHOR_TYPE` dans `AppSettings`.
|
||||||
|
- [x] Normaliser les matières et créneaux pour le matching déterministe.
|
||||||
|
- [x] Créer les fixtures : `tests/fixtures/theoretical.json` et `tests/fixtures/school_holidays.json`.
|
||||||
|
|
||||||
### Critères d'acceptation
|
### Critères d'acceptation
|
||||||
- `file.py` lit `tests/fixtures/theoretical.ics` et `theoretical.csv` en `TheoreticalLesson`.
|
- `file.py` lit `tests/fixtures/theoretical.json` en `TheoreticalLesson` avec filtrage par parité.
|
||||||
|
- Le provider renvoie une liste vide pendant les vacances scolaires.
|
||||||
- Le provider renvoie une liste stable et déterministe (tri par identifiant).
|
- Le provider renvoie une liste stable et déterministe (tri par identifiant).
|
||||||
|
- Les identifiants sont distincts pour des leçons de parité différente sur le même créneau.
|
||||||
|
- Une configuration incomplète (ancre de parité manquante alors que des leçons `even`/`odd` existent) produit une erreur explicite.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -129,19 +136,20 @@ Lire l'agenda théorique (iCal ou CSV) via une interface de provider extensible.
|
|||||||
|
|
||||||
Synchroniser différentiellement les événements Pronote vers le calendrier CalDAV, de façon idempotente.
|
Synchroniser différentiellement les événements Pronote vers le calendrier CalDAV, de façon idempotente.
|
||||||
|
|
||||||
- [ ] Créer `sync/caldav.py` : `CalDAVClient` (connexion, liste/ajout/MAJ/suppression, marqueur `X-PRONOTE-SYNC-MANAGED: v1`).
|
- [ ] 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`).
|
||||||
- [ ] Créer `sync/state.py` : état local de sync (SQLite ou JSON) assurant l'idempotence (UID connus).
|
- [ ] Calculer le `CalDAVSyncPlan` (to_add / to_update / to_remove) par UID stable, explicitement avant l'exécution de la sync.
|
||||||
- [ ] Calculer le `CalDAVSyncPlan` (to_add / to_update / to_remove) par UID stable.
|
- [ ] Implémenter l'exécution du plan : ajout, mise à jour (si modifié), suppression (si absent). En mode `dry_run`, loguer le plan sans écrire.
|
||||||
- [ ] Avant de figer le plan de sync, 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 à la frontière des sources si nécessaire.
|
- [ ] 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.
|
- [ ] 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`).
|
- [ ] Garantir l'idempotence (2 exécutions identiques → même `CalDAVSyncResult`), sans état local persistant (scan du calendrier distant).
|
||||||
- [ ] Réutiliser `BlogRSSState` pour l'état blog si pertinent (sinon `sync/blog_state.py`).
|
- [ ] Ne jamais modifier ou supprimer les événements non marqués `X-PRONOTE-SYNC-MANAGED`.
|
||||||
|
|
||||||
### Critères d'acceptation
|
### Critères d'acceptation
|
||||||
- Le plan de sync est correctement calculé (PronoteData vs état local).
|
- 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 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.
|
- Un run dry-run n'écrit rien ; deux runs identiques donnent un résultat identique.
|
||||||
- Les événements annulés restent (`STATUS:CANCELLED`) et sont marqués `MANAGED`.
|
- Les événements annulés restent (`STATUS:CANCELLED`) et sont marqués `MANAGED`.
|
||||||
|
- Les événements non marqués ne sont jamais modifiés ni supprimés.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -236,7 +244,7 @@ Exposer le lancement du pipeline via une interface en ligne de commande.
|
|||||||
|
|
||||||
Couvrir l'ensemble du code par des tests sans réseau, avec fixtures anonymisées, jusqu'à ≥ 90 %.
|
Couvrir l'ensemble du code par des tests sans réseau, avec fixtures anonymisées, jusqu'à ≥ 90 %.
|
||||||
|
|
||||||
- [ ] Créer `tests/fixtures/` : `pronote-4e.ics`, `pronote-6e.ics`, `theoretical.ics`, `theoretical.csv`, `blog_rss.xml` (anonymisés, sans `icalsecurise`).
|
- [ ] Créer `tests/fixtures/` : `pronote-4e.ics`, `pronote-6e.ics`, `theoretical.json`, `school_holidays.json`, `blog_rss.xml` (anonymisés, sans `icalsecurise`).
|
||||||
- [ ] Créer `tests/conftest.py` : fixtures partagées (sample_lesson, sample_cancelled_lesson, sample_homework, sample_school_event, sample_message, sample_pronote_data…).
|
- [ ] Créer `tests/conftest.py` : fixtures partagées (sample_lesson, sample_cancelled_lesson, sample_homework, sample_school_event, sample_message, sample_pronote_data…).
|
||||||
- [ ] Écrire `tests/unit/` : `test_models`, `test_parsing` (iCal), `test_uid`, `test_redaction`, `test_diff`, `test_sync`.
|
- [ ] Écrire `tests/unit/` : `test_models`, `test_parsing` (iCal), `test_uid`, `test_redaction`, `test_diff`, `test_sync`.
|
||||||
- [ ] Couvrir les régressions M4 : signature réelle de `ParentClient`, ENT autorisé/inconnu, erreur vs résultat vide, `STATUS:CANCELLED` sans catégorie, plusieurs devoirs à la même date, filtrage `pronotepy` sur la date cible et stabilité d'identité entre sources.
|
- [ ] Couvrir les régressions M4 : signature réelle de `ParentClient`, ENT autorisé/inconnu, erreur vs résultat vide, `STATUS:CANCELLED` sans catégorie, plusieurs devoirs à la même date, filtrage `pronotepy` sur la date cible et stabilité d'identité entre sources.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ depuis les variables d'environnement (préfixées par groupe) et le fichier
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import Field, SecretStr, field_serializer
|
from pydantic import Field, SecretStr, field_serializer
|
||||||
@@ -114,7 +115,10 @@ class AppSettings(BaseSettings):
|
|||||||
"""Paramètres généraux de l'application, sans préfixe d'environnement.
|
"""Paramètres généraux de l'application, sans préfixe d'environnement.
|
||||||
|
|
||||||
Contient notamment la fenêtre de synchronisation en jours
|
Contient notamment la fenêtre de synchronisation en jours
|
||||||
(``SYNC_PAST_DAYS`` / ``SYNC_FUTURE_DAYS``).
|
(``SYNC_PAST_DAYS`` / ``SYNC_FUTURE_DAYS``) et la configuration de
|
||||||
|
l'agenda théorique (``THEORETICAL_AGENDA_PATH``,
|
||||||
|
``THEORETICAL_WEEK_ANCHOR_DATE``, ``THEORETICAL_WEEK_ANCHOR_TYPE`` ainsi
|
||||||
|
que ``SCHOOL_HOLIDAYS_PATH`` pour les vacances scolaires).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
@@ -122,6 +126,9 @@ class AppSettings(BaseSettings):
|
|||||||
dry_run: bool = False
|
dry_run: bool = False
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
theoretical_agenda_path: str | None = None
|
theoretical_agenda_path: str | None = None
|
||||||
|
school_holidays_path: str | None = None
|
||||||
|
theoretical_week_anchor_date: date | None = None
|
||||||
|
theoretical_week_anchor_type: Literal["even", "odd"] | None = None
|
||||||
sync_past_days: int = 7
|
sync_past_days: int = 7
|
||||||
sync_future_days: int = 30
|
sync_future_days: int = 30
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Usine de construction du fournisseur d'agenda théorique.
|
||||||
|
|
||||||
|
Ce module expose l'API publique du package ``theoretical`` : les classes
|
||||||
|
:class:`~pronote_sync.sources.theoretical.provider.TheoreticalAgendaProvider`,
|
||||||
|
:class:`~pronote_sync.sources.theoretical.file.JsonTheoreticalAgendaProvider`,
|
||||||
|
:class:`~pronote_sync.sources.theoretical.parity.WeekParityService` et
|
||||||
|
:class:`~pronote_sync.sources.theoretical.holidays.SchoolHolidayCalendar`, ainsi
|
||||||
|
que la fonction :func:`get_theoretical_provider` qui assemble la configuration
|
||||||
|
(chemin du fichier JSON, parité des semaines et vacances scolaires) pour
|
||||||
|
produire un fournisseur d'agenda théorique prêt à l'emploi.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pronote_sync.errors import PronoteSyncError
|
||||||
|
from pronote_sync.sources.theoretical.file import JsonTheoreticalAgendaProvider
|
||||||
|
from pronote_sync.sources.theoretical.holidays import SchoolHolidayCalendar
|
||||||
|
from pronote_sync.sources.theoretical.parity import WeekParityService
|
||||||
|
from pronote_sync.sources.theoretical.provider import TheoreticalAgendaProvider
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"TheoreticalAgendaProvider",
|
||||||
|
"JsonTheoreticalAgendaProvider",
|
||||||
|
"WeekParityService",
|
||||||
|
"SchoolHolidayCalendar",
|
||||||
|
"get_theoretical_provider",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_theoretical_provider(
|
||||||
|
agenda_path: str | None,
|
||||||
|
holidays_path: str | None,
|
||||||
|
anchor_date: date | None,
|
||||||
|
anchor_type: Literal["even", "odd"] | None,
|
||||||
|
) -> TheoreticalAgendaProvider | None:
|
||||||
|
"""Construit un fournisseur d'agenda théorique depuis la configuration.
|
||||||
|
|
||||||
|
:param agenda_path: Chemin du fichier JSON d'agenda théorique. Si None, retourne None.
|
||||||
|
:param holidays_path: Chemin du fichier JSON de vacances scolaires (optionnel).
|
||||||
|
:param anchor_date: Date de référence pour la parité des semaines.
|
||||||
|
:param anchor_type: Type de la semaine de référence ("even" ou "odd").
|
||||||
|
:return: Le fournisseur configuré, ou None si l'agenda théorique est désactivé.
|
||||||
|
:rtype: TheoreticalAgendaProvider | None
|
||||||
|
:raises PronoteSyncError: Si la configuration de parité est incomplète
|
||||||
|
(date sans type ou inversement) alors que l'agenda nécessite la parité.
|
||||||
|
"""
|
||||||
|
if agenda_path is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Build parity service if both anchor fields are provided
|
||||||
|
parity_service: WeekParityService | None = None
|
||||||
|
if anchor_date is not None and anchor_type is not None:
|
||||||
|
parity_service = WeekParityService(anchor_date, anchor_type)
|
||||||
|
elif anchor_date is not None or anchor_type is not None:
|
||||||
|
# Partial parity config — one field without the other
|
||||||
|
raise PronoteSyncError(
|
||||||
|
"Configuration de parité incomplète : THEORETICAL_WEEK_ANCHOR_DATE et "
|
||||||
|
"THEORETICAL_WEEK_ANCHOR_TYPE doivent être fournis ensemble."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build holiday calendar if path is provided
|
||||||
|
holiday_calendar: SchoolHolidayCalendar | None = None
|
||||||
|
if holidays_path is not None:
|
||||||
|
holiday_calendar = SchoolHolidayCalendar(holidays_path)
|
||||||
|
|
||||||
|
# Build provider — the provider's __init__ will validate that parity_service
|
||||||
|
# is provided if the JSON contains even/odd lessons
|
||||||
|
return JsonTheoreticalAgendaProvider(
|
||||||
|
file_path=agenda_path,
|
||||||
|
parity_service=parity_service,
|
||||||
|
holiday_calendar=holiday_calendar,
|
||||||
|
)
|
||||||
|
|||||||
204
pronote_sync/sources/theoretical/file.py
Normal file
204
pronote_sync/sources/theoretical/file.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"""Fournisseur d'agenda théorique basé sur un fichier JSON.
|
||||||
|
|
||||||
|
Ce module fournit :class:`JsonTheoreticalAgendaProvider`, une implémentation de
|
||||||
|
:class:`~pronote_sync.sources.theoretical.provider.TheoreticalAgendaProvider` qui charge
|
||||||
|
un fichier JSON d'emploi du temps théorique et expose les cours applicables par date ou
|
||||||
|
plage de dates. Le filtrage tient compte du jour de la semaine, de la parité de semaine
|
||||||
|
(``even``/``odd``) et du calendrier des vacances scolaires.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from datetime import date, time, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pronote_sync.errors import PronoteSyncError
|
||||||
|
from pronote_sync.models.agenda import TheoreticalLesson
|
||||||
|
from pronote_sync.sources.theoretical.holidays import SchoolHolidayCalendar
|
||||||
|
from pronote_sync.sources.theoretical.model import TheoreticalAgendaFile, TheoreticalLessonEntry
|
||||||
|
from pronote_sync.sources.theoretical.parity import WeekParityService
|
||||||
|
from pronote_sync.utils.redaction import redact_exception, redact_secrets
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_subject(subject: str) -> str:
|
||||||
|
"""Normalise une matière pour le matching déterministe.
|
||||||
|
|
||||||
|
Applique la normalisation Unicode NFKC, unifie les espaces (y compris
|
||||||
|
tabulations et espaces insécables), supprime la ponctuation et met la
|
||||||
|
chaîne en minuscules. Deux représentations visuellement identiques d'une
|
||||||
|
même matière produisent ainsi la même forme normalisée.
|
||||||
|
|
||||||
|
:param subject: La matière brute.
|
||||||
|
:return: La forme normalisée (NFKC, espaces unifiés, sans ponctuation, minuscule).
|
||||||
|
:rtype: str
|
||||||
|
"""
|
||||||
|
normalized = unicodedata.normalize("NFKC", subject)
|
||||||
|
normalized = re.sub(r"\s+", " ", normalized).strip()
|
||||||
|
normalized = re.sub(r"[^\w\s]", "", normalized)
|
||||||
|
normalized = re.sub(r"\s+", " ", normalized).strip()
|
||||||
|
return normalized.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_id(entry: TheoreticalLessonEntry) -> str:
|
||||||
|
"""Génère un identifiant déterministe pour une entrée de cours.
|
||||||
|
|
||||||
|
L'identifiant intègre le type de semaine (``all``, ``even`` ou ``odd``),
|
||||||
|
le jour de la semaine, le créneau horaire et la matière : deux leçons
|
||||||
|
occupant le même créneau dans des semaines différentes (ou le même
|
||||||
|
créneau un autre jour) obtiennent ainsi des identifiants distincts.
|
||||||
|
|
||||||
|
:param entry: Entrée de cours du fichier JSON.
|
||||||
|
:return: Identifiant déterministe unique.
|
||||||
|
:rtype: str
|
||||||
|
"""
|
||||||
|
subject_slug = normalize_subject(entry.subject).replace(" ", "-")
|
||||||
|
return f"theoretical:{entry.week}:{entry.day_of_week}:{entry.start_time}-{entry.end_time}:{subject_slug}"
|
||||||
|
|
||||||
|
|
||||||
|
class JsonTheoreticalAgendaProvider:
|
||||||
|
"""Fournisseur d'agenda théorique basé sur un fichier JSON.
|
||||||
|
|
||||||
|
Charge un fichier JSON d'emploi du temps théorique au format défini par
|
||||||
|
:class:`~pronote_sync.sources.theoretical.model.TheoreticalAgendaFile` et expose
|
||||||
|
les cours théoriques pour une date ou une plage de dates. Les cours peuvent être
|
||||||
|
restreints à une parité de semaine (paire/impaire) via
|
||||||
|
:class:`~pronote_sync.sources.theoretical.parity.WeekParityService` et exclus
|
||||||
|
pendant les vacances scolaires via
|
||||||
|
:class:`~pronote_sync.sources.theoretical.holidays.SchoolHolidayCalendar`.
|
||||||
|
|
||||||
|
:param file_path: Chemin vers le fichier JSON de l'agenda théorique.
|
||||||
|
:param parity_service: Service optionnel de calcul de la parité de semaine.
|
||||||
|
:param holiday_calendar: Calendrier optionnel des vacances scolaires.
|
||||||
|
:raises PronoteSyncError: Si le fichier ne peut être lu ou analysé, si
|
||||||
|
des leçons à semaine paire/impaire sont présentes sans ancre de parité,
|
||||||
|
ou si plusieurs leçons partagent le même identifiant (explicite ou
|
||||||
|
généré).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
file_path: str,
|
||||||
|
parity_service: WeekParityService | None = None,
|
||||||
|
holiday_calendar: SchoolHolidayCalendar | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Initialise le fournisseur en chargeant et analysant le fichier JSON.
|
||||||
|
|
||||||
|
Le fichier est lu et analysé immédiatement. Toute erreur de lecture,
|
||||||
|
de décodage JSON ou de validation est journalisée (chemin et exception
|
||||||
|
expurgés) puis remontée sous forme de :class:`PronoteSyncError`. Si des
|
||||||
|
leçons à semaine paire/impaire sont présentes alors qu'aucun service de
|
||||||
|
parité n'est configuré, une :class:`PronoteSyncError` est également levée.
|
||||||
|
|
||||||
|
:param file_path: Chemin vers le fichier JSON de l'agenda théorique.
|
||||||
|
:param parity_service: Service optionnel de calcul de la parité de semaine.
|
||||||
|
:param holiday_calendar: Calendrier optionnel des vacances scolaires.
|
||||||
|
:raises PronoteSyncError: Si le fichier est introuvable, invalide,
|
||||||
|
nécessite une ancre de parité non configurée ou contient plusieurs
|
||||||
|
leçons partageant le même identifiant (explicite ou généré).
|
||||||
|
"""
|
||||||
|
self._file_path: str = file_path
|
||||||
|
self._parity_service: WeekParityService | None = parity_service
|
||||||
|
self._holiday_calendar: SchoolHolidayCalendar | None = holiday_calendar
|
||||||
|
try:
|
||||||
|
content = Path(file_path).read_text(encoding="utf-8")
|
||||||
|
parsed = TheoreticalAgendaFile.model_validate_json(content)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Fichier d'agenda théorique invalide %s : %s.",
|
||||||
|
redact_secrets(str(file_path)),
|
||||||
|
redact_exception(exc),
|
||||||
|
)
|
||||||
|
raise PronoteSyncError(
|
||||||
|
f"Le fichier d'agenda théorique est invalide : {redact_secrets(str(file_path))}"
|
||||||
|
) from None
|
||||||
|
self._lessons: tuple[TheoreticalLessonEntry, ...] = parsed.lessons
|
||||||
|
if self._parity_service is None and any(
|
||||||
|
entry.week in ("even", "odd") for entry in self._lessons
|
||||||
|
):
|
||||||
|
raise PronoteSyncError(
|
||||||
|
"L'agenda théorique contient des leçons à semaine paire/impaire "
|
||||||
|
"mais aucune ancre de parité n'est configurée "
|
||||||
|
"(THEORETICAL_WEEK_ANCHOR_DATE et THEORETICAL_WEEK_ANCHOR_TYPE)"
|
||||||
|
) from None
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
for entry in self._lessons:
|
||||||
|
effective_id = entry.id if entry.id is not None else _generate_id(entry)
|
||||||
|
if effective_id in seen_ids:
|
||||||
|
raise PronoteSyncError(
|
||||||
|
f"Conflit d'identifiant dans l'agenda théorique : "
|
||||||
|
f"l'identifiant '{redact_secrets(effective_id)}' est utilisé par plusieurs leçons. "
|
||||||
|
f"Fournissez des identifiants explicites uniques."
|
||||||
|
) from None
|
||||||
|
seen_ids.add(effective_id)
|
||||||
|
|
||||||
|
def get_lessons(self, target_date: date) -> list[TheoreticalLesson]:
|
||||||
|
"""Retourne les cours théoriques applicables à la date donnée.
|
||||||
|
|
||||||
|
Si un calendrier de vacances est configuré et que la date tombe pendant
|
||||||
|
une période de vacances, la liste retournée est vide. La parité de la
|
||||||
|
semaine est déterminée via le service de parité lorsqu'il est configuré ;
|
||||||
|
sinon seuls les cours de type ``all`` sont conservés. Les entrées sont
|
||||||
|
ensuite filtrées par jour de la semaine, converties en
|
||||||
|
:class:`~pronote_sync.models.agenda.TheoreticalLesson` et triées par
|
||||||
|
identifiant.
|
||||||
|
|
||||||
|
:param target_date: Date cible.
|
||||||
|
:return: Liste des cours théoriques triée par identifiant.
|
||||||
|
:rtype: list[TheoreticalLesson]
|
||||||
|
"""
|
||||||
|
if self._holiday_calendar is not None and self._holiday_calendar.is_holiday(target_date):
|
||||||
|
return []
|
||||||
|
|
||||||
|
week_parity: Literal["all", "even", "odd"]
|
||||||
|
if self._parity_service is not None:
|
||||||
|
week_parity = self._parity_service.parity_for(target_date)
|
||||||
|
else:
|
||||||
|
week_parity = "all"
|
||||||
|
|
||||||
|
lessons: list[TheoreticalLesson] = []
|
||||||
|
for entry in self._lessons:
|
||||||
|
if entry.week != "all" and entry.week != week_parity:
|
||||||
|
continue
|
||||||
|
if entry.day_of_week != target_date.weekday():
|
||||||
|
continue
|
||||||
|
lesson_id = entry.id if entry.id is not None else _generate_id(entry)
|
||||||
|
lessons.append(
|
||||||
|
TheoreticalLesson(
|
||||||
|
id=lesson_id,
|
||||||
|
day_of_week=entry.day_of_week,
|
||||||
|
start_time=time.fromisoformat(entry.start_time),
|
||||||
|
end_time=time.fromisoformat(entry.end_time),
|
||||||
|
subject=entry.subject,
|
||||||
|
teachers=entry.teachers,
|
||||||
|
rooms=entry.rooms,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return sorted(lessons, key=lambda lesson: lesson.id)
|
||||||
|
|
||||||
|
def get_lessons_for_range(self, start_date: date, end_date: date) -> list[TheoreticalLesson]:
|
||||||
|
"""Retourne les cours théoriques pour une plage de dates (inclusives).
|
||||||
|
|
||||||
|
Chaque date de la plage, bornes incluses, est évaluée via
|
||||||
|
:meth:`get_lessons`. Les cours sont dédupliqués par identifiant : pour
|
||||||
|
un identifiant donné, la dernière occurrence (date la plus récente)
|
||||||
|
écrase la précédente. Si ``start_date`` est postérieure à ``end_date``,
|
||||||
|
la liste retournée est vide.
|
||||||
|
|
||||||
|
:param start_date: Date de début (inclusive).
|
||||||
|
:param end_date: Date de fin (inclusive).
|
||||||
|
:return: Liste des cours théoriques triée par identifiant.
|
||||||
|
:rtype: list[TheoreticalLesson]
|
||||||
|
"""
|
||||||
|
seen: dict[str, TheoreticalLesson] = {}
|
||||||
|
current_date = start_date
|
||||||
|
while current_date <= end_date:
|
||||||
|
for lesson in self.get_lessons(current_date):
|
||||||
|
seen[lesson.id] = lesson
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
return sorted(seen.values(), key=lambda lesson: lesson.id)
|
||||||
106
pronote_sync/sources/theoretical/holidays.py
Normal file
106
pronote_sync/sources/theoretical/holidays.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""Service de calendrier des vacances scolaires.
|
||||||
|
|
||||||
|
Ce module fournit les modèles de données :class:`HolidayPeriod` et
|
||||||
|
:class:`SchoolHolidayFile`, ainsi que le service :class:`SchoolHolidayCalendar`
|
||||||
|
qui charge un fichier JSON de périodes de vacances scolaires et permet de
|
||||||
|
déterminer si une date donnée tombe pendant ces vacances.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Self
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
from pronote_sync.errors import PronoteSyncError
|
||||||
|
from pronote_sync.utils.redaction import redact_exception, redact_secrets
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class HolidayPeriod(BaseModel):
|
||||||
|
"""Période de vacances scolaires, bornes incluses.
|
||||||
|
|
||||||
|
:ivar start_date: Date de début de la période (incluse).
|
||||||
|
:ivar end_date: Date de fin de la période (incluse).
|
||||||
|
:ivar label: Nom de la période (ex. « Toussaint »).
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
|
start_date: date
|
||||||
|
end_date: date
|
||||||
|
label: str
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_date_order(self) -> Self:
|
||||||
|
"""Vérifie que la date de fin n'est pas antérieure à la date de début.
|
||||||
|
|
||||||
|
:return: L'instance de période après validation.
|
||||||
|
:rtype: Self
|
||||||
|
:raises ValueError: Si ``end_date`` est strictement antérieure à ``start_date``.
|
||||||
|
"""
|
||||||
|
if self.end_date < self.start_date:
|
||||||
|
raise ValueError("end_date doit être supérieure ou égale à start_date.")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class SchoolHolidayFile(BaseModel):
|
||||||
|
"""Modèle de parsing d'un fichier JSON de vacances scolaires.
|
||||||
|
|
||||||
|
:ivar zone: Zone académique (ex. « A »).
|
||||||
|
:ivar school_year: Année scolaire (ex. « 2026-2027 »).
|
||||||
|
:ivar periods: Périodes de vacances scolaires du fichier.
|
||||||
|
"""
|
||||||
|
|
||||||
|
zone: str
|
||||||
|
school_year: str
|
||||||
|
periods: tuple[HolidayPeriod, ...] = Field(default=())
|
||||||
|
|
||||||
|
|
||||||
|
class SchoolHolidayCalendar:
|
||||||
|
"""Calendrier des vacances scolaires chargé depuis un fichier JSON."""
|
||||||
|
|
||||||
|
def __init__(self, file_path: Path | str) -> None:
|
||||||
|
"""Charge les périodes de vacances scolaires depuis un fichier JSON.
|
||||||
|
|
||||||
|
:param file_path: Chemin vers le fichier JSON.
|
||||||
|
:raises PronoteSyncError: Si le fichier ne peut être lu ou analysé.
|
||||||
|
"""
|
||||||
|
path = Path(file_path)
|
||||||
|
self._periods: tuple[HolidayPeriod, ...]
|
||||||
|
if not path.is_file():
|
||||||
|
raise PronoteSyncError(
|
||||||
|
f"Le fichier de vacances scolaires est introuvable : {redact_secrets(str(path))}"
|
||||||
|
) from None
|
||||||
|
try:
|
||||||
|
data: Any = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
file_model: SchoolHolidayFile = SchoolHolidayFile.model_validate(data)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Fichier de vacances scolaires invalide %s : %s.",
|
||||||
|
redact_secrets(str(path)),
|
||||||
|
redact_exception(exc),
|
||||||
|
)
|
||||||
|
raise PronoteSyncError(
|
||||||
|
f"Le fichier de vacances scolaires est invalide : {redact_secrets(str(path))}"
|
||||||
|
) from None
|
||||||
|
self._periods = file_model.periods
|
||||||
|
|
||||||
|
def is_holiday(self, target_date: date) -> bool:
|
||||||
|
"""Vérifie si la date donnée tombe pendant une période de vacances.
|
||||||
|
|
||||||
|
La date est considérée comme étant en vacances si elle appartient à
|
||||||
|
l'intervalle d'au moins une période, bornes incluses
|
||||||
|
(``start_date <= target_date <= end_date``).
|
||||||
|
|
||||||
|
:param target_date: Date à vérifier.
|
||||||
|
:return: ``True`` si la date tombe pendant les vacances scolaires,
|
||||||
|
``False`` sinon.
|
||||||
|
:rtype: bool
|
||||||
|
"""
|
||||||
|
return any(period.start_date <= target_date <= period.end_date for period in self._periods)
|
||||||
123
pronote_sync/sources/theoretical/model.py
Normal file
123
pronote_sync/sources/theoretical/model.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"""Modèles Pydantic de parsing du fichier JSON de l'agenda théorique.
|
||||||
|
|
||||||
|
Ce module définit les modèles de parsing utilisés pour lire le fichier
|
||||||
|
JSON de l'agenda théorique : :class:`TheoreticalLessonEntry` pour une
|
||||||
|
entrée de cours et :class:`TheoreticalAgendaFile` pour le fichier
|
||||||
|
complet.
|
||||||
|
|
||||||
|
Ces modèles sont distincts du modèle de domaine
|
||||||
|
:class:`~pronote_sync.models.agenda.TheoreticalLesson` : ils restent
|
||||||
|
proches du format JSON brut (heures au format ``HH:MM``) et servent
|
||||||
|
uniquement à la désérialisation, la conversion vers le modèle de domaine
|
||||||
|
étant réalisée ensuite par le fournisseur.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
_TIME_PATTERN = re.compile(r"^(?:[01]\d|2[0-3]):[0-5]\d$")
|
||||||
|
|
||||||
|
|
||||||
|
class TheoreticalLessonEntry(BaseModel):
|
||||||
|
"""Représente une entrée de cours dans le fichier JSON de l'agenda théorique.
|
||||||
|
|
||||||
|
Modèle figé (``frozen``) : les instances sont immuables après
|
||||||
|
création. Le format des heures est validé (``HH:MM`` sur 24 heures,
|
||||||
|
avec ``HH`` entre ``00`` et ``23`` et ``MM`` entre ``00`` et ``59``)
|
||||||
|
ainsi que l'ordre des heures (fin postérieure au début).
|
||||||
|
|
||||||
|
:param week: Type de semaine auquel s'applique le cours
|
||||||
|
(``"all"``, ``"even"`` ou ``"odd"``).
|
||||||
|
:param day_of_week: Jour de la semaine (0 = lundi, 6 = dimanche).
|
||||||
|
:param start_time: Heure de début au format ``HH:MM`` sur 24 heures.
|
||||||
|
:param end_time: Heure de fin au format ``HH:MM`` sur 24 heures.
|
||||||
|
:param subject: Nom de la matière.
|
||||||
|
:param teachers: Noms des professeurs. Tuple vide par défaut.
|
||||||
|
:param rooms: Noms des salles. Tuple vide par défaut.
|
||||||
|
:param id: Identifiant explicite optionnel. ``None`` par défaut ; en
|
||||||
|
cas d'absence, le fournisseur en génère un.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
|
week: Literal["all", "even", "odd"] = Field(
|
||||||
|
..., description="Type de semaine concerné (all, even ou odd)"
|
||||||
|
)
|
||||||
|
day_of_week: int = Field(
|
||||||
|
..., ge=0, le=6, description="Jour de la semaine (0=lundi, 6=dimanche)"
|
||||||
|
)
|
||||||
|
start_time: str = Field(..., description="Heure de début au format HH:MM")
|
||||||
|
end_time: str = Field(..., description="Heure de fin au format HH:MM")
|
||||||
|
subject: str = Field(..., description="Nom de la matière")
|
||||||
|
teachers: tuple[str, ...] = Field(default=(), description="Noms des professeurs")
|
||||||
|
rooms: tuple[str, ...] = Field(default=(), description="Noms des salles")
|
||||||
|
id: str | None = Field(
|
||||||
|
default=None, description="Identifiant explicite optionnel (None si absent)"
|
||||||
|
)
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _validate_time_format(cls, data: Any) -> Any:
|
||||||
|
"""Valide le format ``HH:MM`` des heures de début et de fin.
|
||||||
|
|
||||||
|
Les heures doivent être au format ``HH:MM`` sur 24 heures, avec
|
||||||
|
``HH`` entre ``00`` et ``23`` et ``MM`` entre ``00`` et ``59``.
|
||||||
|
Cette validation précède :meth:`_validate_time_order`, dont la
|
||||||
|
comparaison par ordre lexicographique n'est fiable que si le
|
||||||
|
format est garanti.
|
||||||
|
|
||||||
|
:param data: Données brutes transmises au modèle.
|
||||||
|
:return: Les données brutes inchangées.
|
||||||
|
:rtype: Any
|
||||||
|
:raises ValueError: Si ``start_time`` ou ``end_time`` n'est pas
|
||||||
|
au format ``HH:MM``.
|
||||||
|
"""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
for field_name in ("start_time", "end_time"):
|
||||||
|
if field_name not in data:
|
||||||
|
continue
|
||||||
|
value = data[field_name]
|
||||||
|
if not isinstance(value, str) or _TIME_PATTERN.fullmatch(value) is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"{field_name} doit être au format HH:MM (HH entre 00 et 23, MM entre 00 et 59)"
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_time_order(self) -> TheoreticalLessonEntry:
|
||||||
|
"""Valide que l'heure de fin est postérieure à l'heure de début.
|
||||||
|
|
||||||
|
La comparaison est effectuée sur les chaînes ``HH:MM`` de façon
|
||||||
|
lexicographique ; elle n'est fiable que parce que
|
||||||
|
:meth:`_validate_time_format` a déjà garanti le format à deux
|
||||||
|
chiffres.
|
||||||
|
|
||||||
|
:return: L'instance validée.
|
||||||
|
:rtype: TheoreticalLessonEntry
|
||||||
|
:raises ValueError: Si ``end_time`` n'est pas postérieur à
|
||||||
|
``start_time``.
|
||||||
|
"""
|
||||||
|
if self.end_time <= self.start_time:
|
||||||
|
raise ValueError("end_time doit être postérieur à start_time")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class TheoreticalAgendaFile(BaseModel):
|
||||||
|
"""Représente le fichier JSON complet de l'agenda théorique.
|
||||||
|
|
||||||
|
Modèle de parsing non figé : il sert uniquement à désérialiser le
|
||||||
|
fichier JSON avant conversion vers les modèles de domaine.
|
||||||
|
|
||||||
|
:param version: Version du schéma du fichier (vaut ``1``).
|
||||||
|
:param lessons: Liste des entrées de cours du fichier.
|
||||||
|
"""
|
||||||
|
|
||||||
|
version: Literal[1] = Field(default=1, description="Version du schéma (1)")
|
||||||
|
lessons: tuple[TheoreticalLessonEntry, ...] = Field(
|
||||||
|
..., description="Liste des entrées de cours"
|
||||||
|
)
|
||||||
55
pronote_sync/sources/theoretical/parity.py
Normal file
55
pronote_sync/sources/theoretical/parity.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""Service déterministe de calcul de la parité des semaines pour l'agenda théorique.
|
||||||
|
|
||||||
|
Ce module fournit :class:`WeekParityService`, un service sans état qui détermine
|
||||||
|
si la semaine contenant une date donnée est paire ou impaire, à partir d'une
|
||||||
|
date d'ancrage dont la parité est connue. L'algorithme repose sur le décalage
|
||||||
|
entre les lundis des deux semaines, et non sur les numéros de semaine ISO.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
|
class WeekParityService:
|
||||||
|
"""Service déterministe de calcul de la parité des semaines.
|
||||||
|
|
||||||
|
La parité d'une semaine est déduite d'une date d'ancrage fournie à la
|
||||||
|
construction : la semaine contenant cette date a une parité connue
|
||||||
|
(paire ou impaire). Le service est immuable après construction et ne
|
||||||
|
dépend d'aucun état global ni de l'horloge système.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, anchor_date: date, anchor_type: Literal["even", "odd"]) -> None:
|
||||||
|
"""Initialise le service avec la date d'ancrage et sa parité.
|
||||||
|
|
||||||
|
:param anchor_date: Date de référence dont la semaine a une parité connue.
|
||||||
|
:param anchor_type: Parité de la semaine d'ancrage (``"even"`` ou ``"odd"``).
|
||||||
|
"""
|
||||||
|
self._anchor_monday = anchor_date - timedelta(days=anchor_date.weekday())
|
||||||
|
self._anchor_type = anchor_type
|
||||||
|
|
||||||
|
def parity_for(self, target_date: date) -> Literal["even", "odd"]:
|
||||||
|
"""Détermine la parité de la semaine contenant la date cible.
|
||||||
|
|
||||||
|
Algorithme :
|
||||||
|
1. Calculer le lundi de la semaine de la date cible.
|
||||||
|
2. Utiliser le lundi de la semaine de la date d'ancrage (stocké à
|
||||||
|
l'initialisation).
|
||||||
|
3. Calculer le nombre de semaines entre les deux lundis :
|
||||||
|
``(target_monday - anchor_monday).days // 7``.
|
||||||
|
4. Si le décalage de semaines est pair, la cible a la même parité que
|
||||||
|
l'ancrage.
|
||||||
|
5. Si le décalage de semaines est impair, la cible a la parité opposée.
|
||||||
|
|
||||||
|
:param target_date: Date dont il faut déterminer la parité.
|
||||||
|
:return: ``"even"`` ou ``"odd"`` selon la parité de la semaine cible.
|
||||||
|
:rtype: Literal["even", "odd"]
|
||||||
|
"""
|
||||||
|
target_monday = target_date - timedelta(days=target_date.weekday())
|
||||||
|
week_offset = (target_monday - self._anchor_monday).days // 7
|
||||||
|
if week_offset % 2 == 0:
|
||||||
|
return self._anchor_type
|
||||||
|
# Inverse la parité.
|
||||||
|
return "odd" if self._anchor_type == "even" else "even"
|
||||||
44
pronote_sync/sources/theoretical/provider.py
Normal file
44
pronote_sync/sources/theoretical/provider.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Protocole pour les fournisseurs d'agenda théorique.
|
||||||
|
|
||||||
|
Ce module définit :class:`TheoreticalAgendaProvider`, le contrat que
|
||||||
|
tous les fournisseurs d'agenda théorique doivent respecter pour exposer
|
||||||
|
les cours théoriques (emploi du temps attendu) par date ou plage de
|
||||||
|
dates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from pronote_sync.models.agenda import TheoreticalLesson
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class TheoreticalAgendaProvider(Protocol):
|
||||||
|
"""Protocole pour un fournisseur d'agenda théorique.
|
||||||
|
|
||||||
|
Un fournisseur d'agenda théorique expose les cours théoriques
|
||||||
|
(emploi du temps attendu) pour une date ou une plage de dates.
|
||||||
|
L'implémentation encapsule la logique de filtrage par parité de
|
||||||
|
semaine et par vacances scolaires.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_lessons(self, target_date: date) -> list[TheoreticalLesson]:
|
||||||
|
"""Retourne les cours théoriques applicables à la date donnée.
|
||||||
|
|
||||||
|
:param target_date: Date cible.
|
||||||
|
:return: Liste des cours théoriques triée par identifiant.
|
||||||
|
:rtype: list[TheoreticalLesson]
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_lessons_for_range(self, start_date: date, end_date: date) -> list[TheoreticalLesson]:
|
||||||
|
"""Retourne les cours théoriques pour une plage de dates (inclusives).
|
||||||
|
|
||||||
|
:param start_date: Date de début (inclusive).
|
||||||
|
:param end_date: Date de fin (inclusive).
|
||||||
|
:return: Liste des cours théoriques triée par identifiant.
|
||||||
|
:rtype: list[TheoreticalLesson]
|
||||||
|
"""
|
||||||
|
...
|
||||||
26
tests/fixtures/school_holidays.json
vendored
Normal file
26
tests/fixtures/school_holidays.json
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"zone": "A",
|
||||||
|
"school_year": "2026-2027",
|
||||||
|
"periods": [
|
||||||
|
{
|
||||||
|
"start_date": "2026-10-17",
|
||||||
|
"end_date": "2026-11-02",
|
||||||
|
"label": "Toussaint"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start_date": "2026-12-19",
|
||||||
|
"end_date": "2027-01-04",
|
||||||
|
"label": "Noël"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start_date": "2027-02-06",
|
||||||
|
"end_date": "2027-02-22",
|
||||||
|
"label": "Hiver"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start_date": "2027-04-03",
|
||||||
|
"end_date": "2027-04-19",
|
||||||
|
"label": "Printemps"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
87
tests/fixtures/theoretical.json
vendored
Normal file
87
tests/fixtures/theoretical.json
vendored
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"lessons": [
|
||||||
|
{
|
||||||
|
"id": "theoretical-maths-monday-1",
|
||||||
|
"week": "all",
|
||||||
|
"day_of_week": 0,
|
||||||
|
"start_time": "08:00",
|
||||||
|
"end_time": "09:00",
|
||||||
|
"subject": "Mathématiques",
|
||||||
|
"teachers": ["Mme Martin"],
|
||||||
|
"rooms": ["101"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"week": "all",
|
||||||
|
"day_of_week": 0,
|
||||||
|
"start_time": "09:00",
|
||||||
|
"end_time": "10:00",
|
||||||
|
"subject": "Français",
|
||||||
|
"teachers": ["M. Dupont"],
|
||||||
|
"rooms": ["102"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"week": "all",
|
||||||
|
"day_of_week": 0,
|
||||||
|
"start_time": "11:00",
|
||||||
|
"end_time": "12:00",
|
||||||
|
"subject": "Histoire-Géographie",
|
||||||
|
"teachers": ["Mme Petit"],
|
||||||
|
"rooms": ["103"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"week": "even",
|
||||||
|
"day_of_week": 1,
|
||||||
|
"start_time": "10:00",
|
||||||
|
"end_time": "11:00",
|
||||||
|
"subject": "Anglais",
|
||||||
|
"teachers": ["Mme Bernard"],
|
||||||
|
"rooms": ["201"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"week": "even",
|
||||||
|
"day_of_week": 1,
|
||||||
|
"start_time": "10:00",
|
||||||
|
"end_time": "11:00",
|
||||||
|
"subject": "Technologie",
|
||||||
|
"teachers": [],
|
||||||
|
"rooms": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"week": "odd",
|
||||||
|
"day_of_week": 1,
|
||||||
|
"start_time": "10:00",
|
||||||
|
"end_time": "11:00",
|
||||||
|
"subject": "Espagnol",
|
||||||
|
"teachers": ["M. Garcia"],
|
||||||
|
"rooms": ["202"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"week": "odd",
|
||||||
|
"day_of_week": 1,
|
||||||
|
"start_time": "10:00",
|
||||||
|
"end_time": "11:00",
|
||||||
|
"subject": "Éducation musicale",
|
||||||
|
"teachers": [],
|
||||||
|
"rooms": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"week": "all",
|
||||||
|
"day_of_week": 2,
|
||||||
|
"start_time": "14:00",
|
||||||
|
"end_time": "15:00",
|
||||||
|
"subject": "Sciences",
|
||||||
|
"teachers": [],
|
||||||
|
"rooms": ["203"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"week": "all",
|
||||||
|
"day_of_week": 4,
|
||||||
|
"start_time": "09:00",
|
||||||
|
"end_time": "10:00",
|
||||||
|
"subject": "Arts plastiques",
|
||||||
|
"teachers": [],
|
||||||
|
"rooms": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
146
tests/unit/test_theoretical_factory.py
Normal file
146
tests/unit/test_theoretical_factory.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""Tests unitaires pour l'usine de construction du fournisseur d'agenda théorique.
|
||||||
|
|
||||||
|
Ce module contient les tests pour la fonction :func:`get_theoretical_provider`
|
||||||
|
du module :mod:`pronote_sync.sources.theoretical`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pronote_sync.errors import PronoteSyncError
|
||||||
|
from pronote_sync.sources.theoretical import (
|
||||||
|
TheoreticalAgendaProvider,
|
||||||
|
get_theoretical_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetTheoreticalProvider:
|
||||||
|
"""Tests pour la fonction get_theoretical_provider."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fixture_path(self) -> Path:
|
||||||
|
"""Retourne le chemin du fichier de fixture theoretical.json."""
|
||||||
|
return Path(__file__).parent.parent / "fixtures" / "theoretical.json"
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def holidays_path(self) -> Path:
|
||||||
|
"""Retourne le chemin du fichier de fixture school_holidays.json."""
|
||||||
|
return Path(__file__).parent.parent / "fixtures" / "school_holidays.json"
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def all_only_path(self, tmp_path: Path) -> Path:
|
||||||
|
"""Crée un fichier JSON avec uniquement des cours "all"."""
|
||||||
|
data = {
|
||||||
|
"version": 1,
|
||||||
|
"lessons": [
|
||||||
|
{
|
||||||
|
"week": "all",
|
||||||
|
"day_of_week": 0,
|
||||||
|
"start_time": "08:00",
|
||||||
|
"end_time": "09:00",
|
||||||
|
"subject": "Test",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
file_path = tmp_path / "all_only.json"
|
||||||
|
file_path.write_text(json.dumps(data), encoding="utf-8")
|
||||||
|
return file_path
|
||||||
|
|
||||||
|
def test_factory_returns_none_when_path_none(self) -> None:
|
||||||
|
"""Teste que l'usine retourne None quand agenda_path est None.
|
||||||
|
|
||||||
|
:assert: get_theoretical_provider(None, None, None, None) retourne None.
|
||||||
|
"""
|
||||||
|
result = get_theoretical_provider(None, None, None, None)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_factory_returns_provider_with_full_config(
|
||||||
|
self, fixture_path: Path, holidays_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Teste que l'usine retourne un fournisseur avec une configuration complète.
|
||||||
|
|
||||||
|
:assert: Un fournisseur est retourné avec tous les paramètres.
|
||||||
|
"""
|
||||||
|
result = get_theoretical_provider(
|
||||||
|
agenda_path=str(fixture_path),
|
||||||
|
holidays_path=str(holidays_path),
|
||||||
|
anchor_date=date(2026, 9, 1),
|
||||||
|
anchor_type="even",
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
assert isinstance(result, TheoreticalAgendaProvider)
|
||||||
|
|
||||||
|
def test_factory_partial_parity_config_error(self, fixture_path: Path) -> None:
|
||||||
|
"""Teste qu'une configuration de parité partielle lève une PronoteSyncError.
|
||||||
|
|
||||||
|
:assert: PronoteSyncError est levée quand anchor_date est fourni sans anchor_type.
|
||||||
|
"""
|
||||||
|
with pytest.raises(PronoteSyncError) as exc_info:
|
||||||
|
get_theoretical_provider(
|
||||||
|
agenda_path=str(fixture_path),
|
||||||
|
holidays_path=None,
|
||||||
|
anchor_date=date(2026, 9, 1),
|
||||||
|
anchor_type=None,
|
||||||
|
)
|
||||||
|
assert "incomplète" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_factory_partial_parity_config_error_type_only(self, fixture_path: Path) -> None:
|
||||||
|
"""Teste qu'une configuration de parité partielle (type seulement) lève une PronoteSyncError.
|
||||||
|
|
||||||
|
:assert: PronoteSyncError est levée quand anchor_type est fourni sans anchor_date.
|
||||||
|
"""
|
||||||
|
with pytest.raises(PronoteSyncError) as exc_info:
|
||||||
|
get_theoretical_provider(
|
||||||
|
agenda_path=str(fixture_path),
|
||||||
|
holidays_path=None,
|
||||||
|
anchor_date=None,
|
||||||
|
anchor_type="even",
|
||||||
|
)
|
||||||
|
assert "incomplète" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_factory_no_holidays(self, fixture_path: Path) -> None:
|
||||||
|
"""Teste que l'usine retourne un fournisseur sans calendrier de vacances.
|
||||||
|
|
||||||
|
:assert: Un fournisseur est retourné sans calendrier de vacances.
|
||||||
|
"""
|
||||||
|
result = get_theoretical_provider(
|
||||||
|
agenda_path=str(fixture_path),
|
||||||
|
holidays_path=None,
|
||||||
|
anchor_date=date(2026, 9, 1),
|
||||||
|
anchor_type="even",
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
assert isinstance(result, TheoreticalAgendaProvider)
|
||||||
|
|
||||||
|
def test_factory_no_parity(self, all_only_path: Path) -> None:
|
||||||
|
"""Teste que l'usine retourne un fournisseur sans service de parité pour des cours "all".
|
||||||
|
|
||||||
|
:assert: Un fournisseur est retourné sans service de parité.
|
||||||
|
"""
|
||||||
|
result = get_theoretical_provider(
|
||||||
|
agenda_path=str(all_only_path),
|
||||||
|
holidays_path=None,
|
||||||
|
anchor_date=None,
|
||||||
|
anchor_type=None,
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
assert isinstance(result, TheoreticalAgendaProvider)
|
||||||
|
|
||||||
|
def test_factory_protocol_compliance(self, fixture_path: Path, holidays_path: Path) -> None:
|
||||||
|
"""Teste que le fournisseur retourné respecte le protocole TheoreticalAgendaProvider.
|
||||||
|
|
||||||
|
:assert: Le fournisseur satisfait isinstance(provider, TheoreticalAgendaProvider).
|
||||||
|
"""
|
||||||
|
result = get_theoretical_provider(
|
||||||
|
agenda_path=str(fixture_path),
|
||||||
|
holidays_path=str(holidays_path),
|
||||||
|
anchor_date=date(2026, 9, 1),
|
||||||
|
anchor_type="even",
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
assert isinstance(result, TheoreticalAgendaProvider)
|
||||||
188
tests/unit/test_theoretical_holidays.py
Normal file
188
tests/unit/test_theoretical_holidays.py
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
"""Tests unitaires pour le calendrier des vacances scolaires.
|
||||||
|
|
||||||
|
Ce module contient les tests pour les classes :class:`SchoolHolidayCalendar`,
|
||||||
|
:class:`HolidayPeriod` et :class:`SchoolHolidayFile` du module
|
||||||
|
:mod:`pronote_sync.sources.theoretical.holidays`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pronote_sync.errors import PronoteSyncError
|
||||||
|
from pronote_sync.sources.theoretical.holidays import SchoolHolidayCalendar
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchoolHolidayCalendar:
|
||||||
|
"""Tests pour la classe SchoolHolidayCalendar."""
|
||||||
|
|
||||||
|
def test_load_valid_file(self, tmp_path: Path) -> None:
|
||||||
|
"""Teste le chargement d'un fichier JSON valide.
|
||||||
|
|
||||||
|
:assert: is_holiday retourne True pour une date dans une période.
|
||||||
|
"""
|
||||||
|
holiday_data = {
|
||||||
|
"zone": "A",
|
||||||
|
"school_year": "2026-2027",
|
||||||
|
"periods": [
|
||||||
|
{
|
||||||
|
"start_date": "2026-10-17",
|
||||||
|
"end_date": "2026-11-02",
|
||||||
|
"label": "Toussaint",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
file_path = tmp_path / "holidays.json"
|
||||||
|
file_path.write_text(json.dumps(holiday_data), encoding="utf-8")
|
||||||
|
|
||||||
|
calendar = SchoolHolidayCalendar(file_path)
|
||||||
|
# Date dans la période de Toussaint
|
||||||
|
assert calendar.is_holiday(date(2026, 10, 20)) is True
|
||||||
|
|
||||||
|
def test_date_outside_periods(self, tmp_path: Path) -> None:
|
||||||
|
"""Teste qu'une date en dehors des périodes retourne False.
|
||||||
|
|
||||||
|
:assert: is_holiday retourne False pour une date hors période.
|
||||||
|
"""
|
||||||
|
holiday_data = {
|
||||||
|
"zone": "A",
|
||||||
|
"school_year": "2026-2027",
|
||||||
|
"periods": [
|
||||||
|
{
|
||||||
|
"start_date": "2026-10-17",
|
||||||
|
"end_date": "2026-11-02",
|
||||||
|
"label": "Toussaint",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
file_path = tmp_path / "holidays.json"
|
||||||
|
file_path.write_text(json.dumps(holiday_data), encoding="utf-8")
|
||||||
|
|
||||||
|
calendar = SchoolHolidayCalendar(file_path)
|
||||||
|
# Date en dehors de la période
|
||||||
|
assert calendar.is_holiday(date(2026, 9, 1)) is False
|
||||||
|
|
||||||
|
def test_start_date_inclusive(self, tmp_path: Path) -> None:
|
||||||
|
"""Teste que la date de début est incluse dans la période.
|
||||||
|
|
||||||
|
:assert: is_holiday retourne True pour une date égale à start_date.
|
||||||
|
"""
|
||||||
|
holiday_data = {
|
||||||
|
"zone": "A",
|
||||||
|
"school_year": "2026-2027",
|
||||||
|
"periods": [
|
||||||
|
{
|
||||||
|
"start_date": "2026-10-17",
|
||||||
|
"end_date": "2026-11-02",
|
||||||
|
"label": "Toussaint",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
file_path = tmp_path / "holidays.json"
|
||||||
|
file_path.write_text(json.dumps(holiday_data), encoding="utf-8")
|
||||||
|
|
||||||
|
calendar = SchoolHolidayCalendar(file_path)
|
||||||
|
assert calendar.is_holiday(date(2026, 10, 17)) is True
|
||||||
|
|
||||||
|
def test_end_date_inclusive(self, tmp_path: Path) -> None:
|
||||||
|
"""Teste que la date de fin est incluse dans la période.
|
||||||
|
|
||||||
|
:assert: is_holiday retourne True pour une date égale à end_date.
|
||||||
|
"""
|
||||||
|
holiday_data = {
|
||||||
|
"zone": "A",
|
||||||
|
"school_year": "2026-2027",
|
||||||
|
"periods": [
|
||||||
|
{
|
||||||
|
"start_date": "2026-10-17",
|
||||||
|
"end_date": "2026-11-02",
|
||||||
|
"label": "Toussaint",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
file_path = tmp_path / "holidays.json"
|
||||||
|
file_path.write_text(json.dumps(holiday_data), encoding="utf-8")
|
||||||
|
|
||||||
|
calendar = SchoolHolidayCalendar(file_path)
|
||||||
|
assert calendar.is_holiday(date(2026, 11, 2)) is True
|
||||||
|
|
||||||
|
def test_file_not_found(self, tmp_path: Path) -> None:
|
||||||
|
"""Teste qu'un fichier introuvable lève une PronoteSyncError.
|
||||||
|
|
||||||
|
:assert: PronoteSyncError est levée pour un fichier introuvable.
|
||||||
|
"""
|
||||||
|
file_path = tmp_path / "nonexistent.json"
|
||||||
|
with pytest.raises(PronoteSyncError) as exc_info:
|
||||||
|
SchoolHolidayCalendar(file_path)
|
||||||
|
assert "introuvable" in str(exc_info.value)
|
||||||
|
# Vérifier qu'aucun secret n'est fuité dans le message d'erreur
|
||||||
|
assert "nonexistent" not in str(exc_info.value) or "introuvable" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_invalid_json(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
"""Teste qu'un fichier JSON invalide lève une PronoteSyncError.
|
||||||
|
|
||||||
|
:assert: PronoteSyncError est levée pour un JSON invalide.
|
||||||
|
"""
|
||||||
|
file_path = tmp_path / "invalid.json"
|
||||||
|
file_path.write_text("{ invalid json }", encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(PronoteSyncError) as exc_info:
|
||||||
|
SchoolHolidayCalendar(file_path)
|
||||||
|
assert "invalide" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_invalid_period_dates(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
"""Teste qu'une période avec end_date < start_date lève une ValidationError.
|
||||||
|
|
||||||
|
:assert: PronoteSyncError est levée pour des dates de période invalides.
|
||||||
|
"""
|
||||||
|
holiday_data = {
|
||||||
|
"zone": "A",
|
||||||
|
"school_year": "2026-2027",
|
||||||
|
"periods": [
|
||||||
|
{
|
||||||
|
"start_date": "2026-11-02",
|
||||||
|
"end_date": "2026-10-17", # Inversé
|
||||||
|
"label": "Toussaint",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
file_path = tmp_path / "holidays.json"
|
||||||
|
file_path.write_text(json.dumps(holiday_data), encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(PronoteSyncError):
|
||||||
|
SchoolHolidayCalendar(file_path)
|
||||||
|
|
||||||
|
def test_empty_periods(self, tmp_path: Path) -> None:
|
||||||
|
"""Teste qu'un fichier avec des périodes vides retourne toujours False.
|
||||||
|
|
||||||
|
:assert: is_holiday retourne False pour toutes les dates.
|
||||||
|
"""
|
||||||
|
holiday_data = {
|
||||||
|
"zone": "A",
|
||||||
|
"school_year": "2026-2027",
|
||||||
|
"periods": [],
|
||||||
|
}
|
||||||
|
file_path = tmp_path / "holidays.json"
|
||||||
|
file_path.write_text(json.dumps(holiday_data), encoding="utf-8")
|
||||||
|
|
||||||
|
calendar = SchoolHolidayCalendar(file_path)
|
||||||
|
assert calendar.is_holiday(date(2026, 10, 20)) is False
|
||||||
|
assert calendar.is_holiday(date(2026, 1, 1)) is False
|
||||||
|
|
||||||
|
def test_load_from_fixture(self) -> None:
|
||||||
|
"""Teste le chargement du fichier de fixture et vérifie une date connue.
|
||||||
|
|
||||||
|
:assert: is_holiday retourne True pour une date de vacances connue.
|
||||||
|
"""
|
||||||
|
fixture_path = Path(__file__).parent.parent / "fixtures" / "school_holidays.json"
|
||||||
|
calendar = SchoolHolidayCalendar(fixture_path)
|
||||||
|
# Date dans les vacances de Toussaint (17 oct - 2 nov 2026)
|
||||||
|
assert calendar.is_holiday(date(2026, 10, 20)) is True
|
||||||
|
# Date dans les vacances de Noël (19 déc 2026 - 4 janv 2027)
|
||||||
|
assert calendar.is_holiday(date(2026, 12, 25)) is True
|
||||||
|
# Date en dehors des vacances
|
||||||
|
assert calendar.is_holiday(date(2026, 9, 1)) is False
|
||||||
201
tests/unit/test_theoretical_model.py
Normal file
201
tests/unit/test_theoretical_model.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"""Tests unitaires pour les modèles de parsing de l'agenda théorique.
|
||||||
|
|
||||||
|
Ce module contient les tests pour les classes :class:`TheoreticalLessonEntry`
|
||||||
|
et :class:`TheoreticalAgendaFile` du module
|
||||||
|
:mod:`pronote_sync.sources.theoretical.model`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from pronote_sync.sources.theoretical.model import (
|
||||||
|
TheoreticalAgendaFile,
|
||||||
|
TheoreticalLessonEntry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheoreticalLessonEntry:
|
||||||
|
"""Tests pour la classe TheoreticalLessonEntry."""
|
||||||
|
|
||||||
|
def test_valid_lesson_entry(self) -> None:
|
||||||
|
"""Teste la construction d'une entrée de cours valide.
|
||||||
|
|
||||||
|
:assert: Tous les champs sont correctement initialisés.
|
||||||
|
"""
|
||||||
|
entry = TheoreticalLessonEntry(
|
||||||
|
week="all",
|
||||||
|
day_of_week=0,
|
||||||
|
start_time="08:00",
|
||||||
|
end_time="09:00",
|
||||||
|
subject="Mathématiques",
|
||||||
|
teachers=("Mme Martin",),
|
||||||
|
rooms=("101",),
|
||||||
|
id="math-1",
|
||||||
|
)
|
||||||
|
assert entry.week == "all"
|
||||||
|
assert entry.day_of_week == 0
|
||||||
|
assert entry.start_time == "08:00"
|
||||||
|
assert entry.end_time == "09:00"
|
||||||
|
assert entry.subject == "Mathématiques"
|
||||||
|
assert entry.teachers == ("Mme Martin",)
|
||||||
|
assert entry.rooms == ("101",)
|
||||||
|
assert entry.id == "math-1"
|
||||||
|
|
||||||
|
def test_invalid_time_format(self) -> None:
|
||||||
|
"""Teste qu'une heure invalide (25:00) lève une ValidationError.
|
||||||
|
|
||||||
|
:assert: ValidationError est levée pour start_time="25:00".
|
||||||
|
"""
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
TheoreticalLessonEntry(
|
||||||
|
week="all",
|
||||||
|
day_of_week=0,
|
||||||
|
start_time="25:00",
|
||||||
|
end_time="09:00",
|
||||||
|
subject="Test",
|
||||||
|
)
|
||||||
|
assert "start_time" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_invalid_time_format_minutes(self) -> None:
|
||||||
|
"""Teste qu'une minute invalide (60) lève une ValidationError.
|
||||||
|
|
||||||
|
:assert: ValidationError est levée pour start_time="08:60".
|
||||||
|
"""
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
TheoreticalLessonEntry(
|
||||||
|
week="all",
|
||||||
|
day_of_week=0,
|
||||||
|
start_time="08:60",
|
||||||
|
end_time="09:00",
|
||||||
|
subject="Test",
|
||||||
|
)
|
||||||
|
assert "start_time" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_end_before_start(self) -> None:
|
||||||
|
"""Teste qu'une fin avant le début lève une ValidationError.
|
||||||
|
|
||||||
|
:assert: ValidationError est levée pour end_time < start_time.
|
||||||
|
"""
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
TheoreticalLessonEntry(
|
||||||
|
week="all",
|
||||||
|
day_of_week=0,
|
||||||
|
start_time="10:00",
|
||||||
|
end_time="09:00",
|
||||||
|
subject="Test",
|
||||||
|
)
|
||||||
|
assert "end_time" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_end_equal_start(self) -> None:
|
||||||
|
"""Teste qu'une fin égale au début lève une ValidationError.
|
||||||
|
|
||||||
|
:assert: ValidationError est levée pour end_time == start_time.
|
||||||
|
"""
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
TheoreticalLessonEntry(
|
||||||
|
week="all",
|
||||||
|
day_of_week=0,
|
||||||
|
start_time="09:00",
|
||||||
|
end_time="09:00",
|
||||||
|
subject="Test",
|
||||||
|
)
|
||||||
|
assert "end_time" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_invalid_week(self) -> None:
|
||||||
|
"""Teste qu'une semaine invalide lève une ValidationError.
|
||||||
|
|
||||||
|
:assert: ValidationError est levée pour week="weekly".
|
||||||
|
"""
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
TheoreticalLessonEntry(
|
||||||
|
week="weekly", # type: ignore[arg-type]
|
||||||
|
day_of_week=0,
|
||||||
|
start_time="08:00",
|
||||||
|
end_time="09:00",
|
||||||
|
subject="Test",
|
||||||
|
)
|
||||||
|
assert "week" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_invalid_day_of_week(self) -> None:
|
||||||
|
"""Teste qu'un jour de semaine invalide (7) lève une ValidationError.
|
||||||
|
|
||||||
|
:assert: ValidationError est levée pour day_of_week=7.
|
||||||
|
"""
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
TheoreticalLessonEntry(
|
||||||
|
week="all",
|
||||||
|
day_of_week=7,
|
||||||
|
start_time="08:00",
|
||||||
|
end_time="09:00",
|
||||||
|
subject="Test",
|
||||||
|
)
|
||||||
|
assert "day_of_week" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_optional_fields_defaults(self) -> None:
|
||||||
|
"""Teste que les champs optionnels ont des valeurs par défaut.
|
||||||
|
|
||||||
|
:assert: teachers et rooms valent () par défaut.
|
||||||
|
"""
|
||||||
|
entry = TheoreticalLessonEntry(
|
||||||
|
week="all",
|
||||||
|
day_of_week=0,
|
||||||
|
start_time="08:00",
|
||||||
|
end_time="09:00",
|
||||||
|
subject="Test",
|
||||||
|
)
|
||||||
|
assert entry.teachers == ()
|
||||||
|
assert entry.rooms == ()
|
||||||
|
|
||||||
|
def test_frozen_model(self) -> None:
|
||||||
|
"""Teste que le modèle est immuable (frozen).
|
||||||
|
|
||||||
|
:assert: La tentative de mutation lève une exception.
|
||||||
|
"""
|
||||||
|
entry = TheoreticalLessonEntry(
|
||||||
|
week="all",
|
||||||
|
day_of_week=0,
|
||||||
|
start_time="08:00",
|
||||||
|
end_time="09:00",
|
||||||
|
subject="Test",
|
||||||
|
)
|
||||||
|
with pytest.raises((ValidationError, AttributeError)):
|
||||||
|
entry.subject = "Nouveau"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheoreticalAgendaFile:
|
||||||
|
"""Tests pour la classe TheoreticalAgendaFile."""
|
||||||
|
|
||||||
|
def test_agenda_file_valid(self) -> None:
|
||||||
|
"""Teste la construction d'un fichier d'agenda valide.
|
||||||
|
|
||||||
|
:assert: version == 1 et lessons a le bon nombre d'entrées.
|
||||||
|
"""
|
||||||
|
lessons = [
|
||||||
|
TheoreticalLessonEntry(
|
||||||
|
week="all",
|
||||||
|
day_of_week=0,
|
||||||
|
start_time="08:00",
|
||||||
|
end_time="09:00",
|
||||||
|
subject="Mathématiques",
|
||||||
|
),
|
||||||
|
TheoreticalLessonEntry(
|
||||||
|
week="even",
|
||||||
|
day_of_week=1,
|
||||||
|
start_time="10:00",
|
||||||
|
end_time="11:00",
|
||||||
|
subject="Anglais",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
agenda = TheoreticalAgendaFile(version=1, lessons=tuple(lessons))
|
||||||
|
assert agenda.version == 1
|
||||||
|
assert len(agenda.lessons) == 2
|
||||||
|
|
||||||
|
def test_agenda_file_default_version(self) -> None:
|
||||||
|
"""Teste que la version par défaut est 1.
|
||||||
|
|
||||||
|
:assert: version vaut 1 par défaut.
|
||||||
|
"""
|
||||||
|
agenda = TheoreticalAgendaFile(lessons=())
|
||||||
|
assert agenda.version == 1
|
||||||
116
tests/unit/test_theoretical_parity.py
Normal file
116
tests/unit/test_theoretical_parity.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""Tests unitaires pour le service de parité des semaines.
|
||||||
|
|
||||||
|
Ce module contient les tests pour la classe :class:`WeekParityService`
|
||||||
|
du module :mod:`pronote_sync.sources.theoretical.parity`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from pronote_sync.sources.theoretical.parity import WeekParityService
|
||||||
|
|
||||||
|
|
||||||
|
class TestWeekParityService:
|
||||||
|
"""Tests pour la classe WeekParityService."""
|
||||||
|
|
||||||
|
def test_same_week_as_anchor_even(self) -> None:
|
||||||
|
"""Teste qu'une date dans la même semaine que l'ancrage pair retourne "even".
|
||||||
|
|
||||||
|
:assert: parity_for retourne "even" pour une date dans la même semaine.
|
||||||
|
"""
|
||||||
|
anchor_date = date(2026, 9, 1) # Mardi
|
||||||
|
service = WeekParityService(anchor_date, "even")
|
||||||
|
# Même semaine (lundi 31 août 2026)
|
||||||
|
target_date = date(2026, 9, 1)
|
||||||
|
assert service.parity_for(target_date) == "even"
|
||||||
|
|
||||||
|
def test_one_week_after_anchor_even(self) -> None:
|
||||||
|
"""Teste qu'une date une semaine après l'ancrage pair retourne "odd".
|
||||||
|
|
||||||
|
:assert: parity_for retourne "odd" pour une date une semaine après.
|
||||||
|
"""
|
||||||
|
anchor_date = date(2026, 9, 1) # Mardi
|
||||||
|
service = WeekParityService(anchor_date, "even")
|
||||||
|
# Une semaine après (mardi 8 septembre 2026)
|
||||||
|
target_date = date(2026, 9, 8)
|
||||||
|
assert service.parity_for(target_date) == "odd"
|
||||||
|
|
||||||
|
def test_two_weeks_after_anchor_even(self) -> None:
|
||||||
|
"""Teste qu'une date deux semaines après l'ancrage pair retourne "even".
|
||||||
|
|
||||||
|
:assert: parity_for retourne "even" pour une date deux semaines après.
|
||||||
|
"""
|
||||||
|
anchor_date = date(2026, 9, 1) # Mardi
|
||||||
|
service = WeekParityService(anchor_date, "even")
|
||||||
|
# Deux semaines après (mardi 15 septembre 2026)
|
||||||
|
target_date = date(2026, 9, 15)
|
||||||
|
assert service.parity_for(target_date) == "even"
|
||||||
|
|
||||||
|
def test_same_week_as_anchor_odd(self) -> None:
|
||||||
|
"""Teste qu'une date dans la même semaine que l'ancrage impair retourne "odd".
|
||||||
|
|
||||||
|
:assert: parity_for retourne "odd" pour une date dans la même semaine.
|
||||||
|
"""
|
||||||
|
anchor_date = date(2026, 9, 1) # Mardi
|
||||||
|
service = WeekParityService(anchor_date, "odd")
|
||||||
|
target_date = date(2026, 9, 1)
|
||||||
|
assert service.parity_for(target_date) == "odd"
|
||||||
|
|
||||||
|
def test_one_week_before_anchor(self) -> None:
|
||||||
|
"""Teste qu'une date une semaine avant l'ancrage retourne la parité opposée.
|
||||||
|
|
||||||
|
:assert: parity_for retourne la parité opposée pour une date une semaine avant.
|
||||||
|
"""
|
||||||
|
anchor_date = date(2026, 9, 8) # Mardi
|
||||||
|
service = WeekParityService(anchor_date, "even")
|
||||||
|
# Une semaine avant (mardi 1er septembre 2026)
|
||||||
|
target_date = date(2026, 9, 1)
|
||||||
|
assert service.parity_for(target_date) == "odd"
|
||||||
|
|
||||||
|
def test_many_weeks_later(self) -> None:
|
||||||
|
"""Teste qu'une date 10 semaines après l'ancrage pair retourne "even".
|
||||||
|
|
||||||
|
:assert: parity_for retourne "even" pour une date 10 semaines après.
|
||||||
|
"""
|
||||||
|
anchor_date = date(2026, 9, 1) # Mardi
|
||||||
|
service = WeekParityService(anchor_date, "even")
|
||||||
|
# 10 semaines après (70 jours)
|
||||||
|
target_date = date(2026, 11, 10)
|
||||||
|
assert service.parity_for(target_date) == "even"
|
||||||
|
|
||||||
|
def test_negative_offset(self) -> None:
|
||||||
|
"""Teste qu'une date loin avant l'ancrage calcule correctement la parité.
|
||||||
|
|
||||||
|
:assert: parity_for retourne la parité correcte pour une date loin dans le passé.
|
||||||
|
"""
|
||||||
|
anchor_date = date(2026, 9, 1) # Mardi
|
||||||
|
service = WeekParityService(anchor_date, "even")
|
||||||
|
# 10 semaines avant (70 jours)
|
||||||
|
target_date = date(2026, 6, 23)
|
||||||
|
assert service.parity_for(target_date) == "even"
|
||||||
|
|
||||||
|
def test_different_day_in_same_week(self) -> None:
|
||||||
|
"""Teste que lundi et vendredi de la même semaine ont la même parité.
|
||||||
|
|
||||||
|
:assert: parity_for retourne la même parité pour lundi et vendredi de la même semaine.
|
||||||
|
"""
|
||||||
|
anchor_date = date(2026, 9, 1) # Mardi
|
||||||
|
service = WeekParityService(anchor_date, "even")
|
||||||
|
# Lundi de la même semaine
|
||||||
|
monday = date(2026, 8, 31)
|
||||||
|
# Vendredi de la même semaine
|
||||||
|
friday = date(2026, 9, 4)
|
||||||
|
assert service.parity_for(monday) == "even"
|
||||||
|
assert service.parity_for(friday) == "even"
|
||||||
|
|
||||||
|
def test_crosses_year_boundary(self) -> None:
|
||||||
|
"""Teste que le calcul de parité fonctionne à cheval sur une année.
|
||||||
|
|
||||||
|
:assert: parity_for retourne la parité correcte à cheval sur une année.
|
||||||
|
"""
|
||||||
|
anchor_date = date(2026, 12, 29) # Mardi (semaine 53)
|
||||||
|
service = WeekParityService(anchor_date, "even")
|
||||||
|
# Date dans la semaine suivante (année 2027)
|
||||||
|
target_date = date(2027, 1, 5) # Mardi de la semaine suivante
|
||||||
|
assert service.parity_for(target_date) == "odd"
|
||||||
722
tests/unit/test_theoretical_provider.py
Normal file
722
tests/unit/test_theoretical_provider.py
Normal file
@@ -0,0 +1,722 @@
|
|||||||
|
"""Tests unitaires pour le fournisseur d'agenda théorique basé sur fichier JSON.
|
||||||
|
|
||||||
|
Ce module contient les tests pour la classe :class:`JsonTheoreticalAgendaProvider`
|
||||||
|
du module :mod:`pronote_sync.sources.theoretical.file`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pronote_sync.errors import PronoteSyncError
|
||||||
|
from pronote_sync.sources.theoretical.file import JsonTheoreticalAgendaProvider, normalize_subject
|
||||||
|
from pronote_sync.sources.theoretical.holidays import SchoolHolidayCalendar
|
||||||
|
from pronote_sync.sources.theoretical.parity import WeekParityService
|
||||||
|
|
||||||
|
|
||||||
|
class TestJsonTheoreticalAgendaProvider:
|
||||||
|
"""Tests pour la classe JsonTheoreticalAgendaProvider."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fixture_path(self) -> Path:
|
||||||
|
"""Retourne le chemin du fichier de fixture theoretical.json."""
|
||||||
|
return Path(__file__).parent.parent / "fixtures" / "theoretical.json"
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def holidays_path(self) -> Path:
|
||||||
|
"""Retourne le chemin du fichier de fixture school_holidays.json."""
|
||||||
|
return Path(__file__).parent.parent / "fixtures" / "school_holidays.json"
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def all_only_path(self, tmp_path: Path) -> Path:
|
||||||
|
"""Crée un fichier JSON avec uniquement des cours "all"."""
|
||||||
|
data = {
|
||||||
|
"version": 1,
|
||||||
|
"lessons": [
|
||||||
|
{
|
||||||
|
"id": "theoretical-maths-monday-1",
|
||||||
|
"week": "all",
|
||||||
|
"day_of_week": 0,
|
||||||
|
"start_time": "08:00",
|
||||||
|
"end_time": "09:00",
|
||||||
|
"subject": "Mathématiques",
|
||||||
|
"teachers": ["Mme Martin"],
|
||||||
|
"rooms": ["101"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"week": "all",
|
||||||
|
"day_of_week": 0,
|
||||||
|
"start_time": "09:00",
|
||||||
|
"end_time": "10:00",
|
||||||
|
"subject": "Français",
|
||||||
|
"teachers": ["M. Dupont"],
|
||||||
|
"rooms": ["102"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"week": "all",
|
||||||
|
"day_of_week": 0,
|
||||||
|
"start_time": "11:00",
|
||||||
|
"end_time": "12:00",
|
||||||
|
"subject": "Histoire-Géographie",
|
||||||
|
"teachers": ["Mme Petit"],
|
||||||
|
"rooms": ["103"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"week": "all",
|
||||||
|
"day_of_week": 2,
|
||||||
|
"start_time": "14:00",
|
||||||
|
"end_time": "15:00",
|
||||||
|
"subject": "Sciences",
|
||||||
|
"teachers": [],
|
||||||
|
"rooms": ["203"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"week": "all",
|
||||||
|
"day_of_week": 4,
|
||||||
|
"start_time": "09:00",
|
||||||
|
"end_time": "10:00",
|
||||||
|
"subject": "Arts plastiques",
|
||||||
|
"teachers": [],
|
||||||
|
"rooms": [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
file_path = tmp_path / "all_only.json"
|
||||||
|
file_path.write_text(json.dumps(data), encoding="utf-8")
|
||||||
|
return file_path
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def provider_no_parity_no_holidays(self, all_only_path: Path) -> JsonTheoreticalAgendaProvider:
|
||||||
|
"""Fournisseur sans service de parité ni calendrier de vacances."""
|
||||||
|
return JsonTheoreticalAgendaProvider(
|
||||||
|
file_path=str(all_only_path),
|
||||||
|
parity_service=None,
|
||||||
|
holiday_calendar=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def provider_with_parity(self, fixture_path: Path) -> JsonTheoreticalAgendaProvider:
|
||||||
|
"""Fournisseur avec service de parité (ancrage sur 2026-09-01, even)."""
|
||||||
|
parity_service = WeekParityService(date(2026, 9, 1), "even")
|
||||||
|
return JsonTheoreticalAgendaProvider(
|
||||||
|
file_path=str(fixture_path),
|
||||||
|
parity_service=parity_service,
|
||||||
|
holiday_calendar=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def provider_with_holidays(
|
||||||
|
self, fixture_path: Path, holidays_path: Path
|
||||||
|
) -> JsonTheoreticalAgendaProvider:
|
||||||
|
"""Fournisseur avec calendrier de vacances."""
|
||||||
|
holiday_calendar = SchoolHolidayCalendar(holidays_path)
|
||||||
|
return JsonTheoreticalAgendaProvider(
|
||||||
|
file_path=str(fixture_path),
|
||||||
|
parity_service=None,
|
||||||
|
holiday_calendar=holiday_calendar,
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def provider_full(
|
||||||
|
self, fixture_path: Path, holidays_path: Path
|
||||||
|
) -> JsonTheoreticalAgendaProvider:
|
||||||
|
"""Fournisseur avec service de parité et calendrier de vacances."""
|
||||||
|
parity_service = WeekParityService(date(2026, 9, 1), "even")
|
||||||
|
holiday_calendar = SchoolHolidayCalendar(holidays_path)
|
||||||
|
return JsonTheoreticalAgendaProvider(
|
||||||
|
file_path=str(fixture_path),
|
||||||
|
parity_service=parity_service,
|
||||||
|
holiday_calendar=holiday_calendar,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_lessons_all_weeks(
|
||||||
|
self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider
|
||||||
|
) -> None:
|
||||||
|
"""Teste la récupération des cours pour un lundi (jour 0) sans parité.
|
||||||
|
|
||||||
|
:assert: Les cours "all" pour le lundi sont retournés.
|
||||||
|
"""
|
||||||
|
# Lundi 2026-08-31 (weekday = 0)
|
||||||
|
target_date = date(2026, 8, 31)
|
||||||
|
lessons = provider_no_parity_no_holidays.get_lessons(target_date)
|
||||||
|
# Le fichier all_only a 3 cours "all" pour le lundi (day_of_week=0)
|
||||||
|
assert len(lessons) == 3
|
||||||
|
subjects = [lesson.subject for lesson in lessons]
|
||||||
|
assert "Mathématiques" in subjects
|
||||||
|
assert "Français" in subjects
|
||||||
|
assert "Histoire-Géographie" in subjects
|
||||||
|
|
||||||
|
def test_get_lessons_even_week(
|
||||||
|
self, provider_with_parity: JsonTheoreticalAgendaProvider
|
||||||
|
) -> None:
|
||||||
|
"""Teste la récupération des cours pour une semaine paire.
|
||||||
|
|
||||||
|
:assert: Les cours "even" pour le mardi sont retournés.
|
||||||
|
"""
|
||||||
|
# Mardi 2026-09-01 (weekday = 1), semaine paire (ancrage 2026-09-01 even)
|
||||||
|
# Note: 2026-09-01 est mardi (weekday=1)
|
||||||
|
target_date = date(2026, 9, 1)
|
||||||
|
lessons = provider_with_parity.get_lessons(target_date)
|
||||||
|
# Le fixture a 2 cours "even" pour le mardi (day_of_week=1)
|
||||||
|
subjects = [lesson.subject for lesson in lessons]
|
||||||
|
assert "Anglais" in subjects
|
||||||
|
assert "Technologie" in subjects
|
||||||
|
|
||||||
|
def test_get_lessons_odd_week(
|
||||||
|
self, provider_with_parity: JsonTheoreticalAgendaProvider
|
||||||
|
) -> None:
|
||||||
|
"""Teste la récupération des cours pour une semaine impaire.
|
||||||
|
|
||||||
|
:assert: Les cours "odd" pour le mardi sont retournés.
|
||||||
|
"""
|
||||||
|
# Mardi 2026-09-08 (weekday = 1), semaine impaire (1 semaine après l'ancrage 2026-09-01)
|
||||||
|
target_date = date(2026, 9, 8)
|
||||||
|
lessons = provider_with_parity.get_lessons(target_date)
|
||||||
|
# Le fixture a 2 cours "odd" pour le mardi (day_of_week=1)
|
||||||
|
subjects = [lesson.subject for lesson in lessons]
|
||||||
|
assert "Espagnol" in subjects
|
||||||
|
assert "Éducation musicale" in subjects
|
||||||
|
|
||||||
|
def test_get_lessons_holiday_returns_empty(
|
||||||
|
self, provider_full: JsonTheoreticalAgendaProvider
|
||||||
|
) -> None:
|
||||||
|
"""Teste qu'une date en vacances retourne une liste vide.
|
||||||
|
|
||||||
|
:assert: get_lessons retourne [] pour une date en vacances.
|
||||||
|
"""
|
||||||
|
# Date pendant les vacances de Toussaint (17 oct - 2 nov 2026)
|
||||||
|
target_date = date(2026, 10, 20)
|
||||||
|
lessons = provider_full.get_lessons(target_date)
|
||||||
|
assert lessons == []
|
||||||
|
|
||||||
|
def test_get_lessons_no_holiday_calendar(
|
||||||
|
self, provider_with_parity: JsonTheoreticalAgendaProvider
|
||||||
|
) -> None:
|
||||||
|
"""Teste que sans calendrier de vacances, les cours sont retournés même en vacances.
|
||||||
|
|
||||||
|
:assert: Les cours sont retournés pour une date en vacances.
|
||||||
|
"""
|
||||||
|
# Date pendant les vacances de Toussaint, mais sans calendrier de vacances
|
||||||
|
target_date = date(2026, 10, 20) # Mardi (weekday = 1)
|
||||||
|
lessons = provider_with_parity.get_lessons(target_date)
|
||||||
|
# Sans calendrier de vacances, les cours "even" pour le mardi sont retournés
|
||||||
|
assert len(lessons) == 2
|
||||||
|
|
||||||
|
def test_get_lessons_sorted_by_id(
|
||||||
|
self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider
|
||||||
|
) -> None:
|
||||||
|
"""Teste que les cours sont triés par identifiant.
|
||||||
|
|
||||||
|
:assert: Les cours sont triés par id.
|
||||||
|
"""
|
||||||
|
# Lundi 2026-08-31 (weekday = 0)
|
||||||
|
target_date = date(2026, 8, 31)
|
||||||
|
lessons = provider_no_parity_no_holidays.get_lessons(target_date)
|
||||||
|
ids = [lesson.id for lesson in lessons]
|
||||||
|
assert ids == sorted(ids)
|
||||||
|
|
||||||
|
def test_get_lessons_deterministic(
|
||||||
|
self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider
|
||||||
|
) -> None:
|
||||||
|
"""Teste que get_lessons retourne des résultats identiques pour la même date.
|
||||||
|
|
||||||
|
:assert: Deux appels avec la même date retournent des résultats identiques.
|
||||||
|
"""
|
||||||
|
# Lundi 2026-08-31 (weekday = 0)
|
||||||
|
target_date = date(2026, 8, 31)
|
||||||
|
lessons1 = provider_no_parity_no_holidays.get_lessons(target_date)
|
||||||
|
lessons2 = provider_no_parity_no_holidays.get_lessons(target_date)
|
||||||
|
assert lessons1 == lessons2
|
||||||
|
|
||||||
|
def test_even_odd_different_subjects_different_ids(
|
||||||
|
self, provider_with_parity: JsonTheoreticalAgendaProvider
|
||||||
|
) -> None:
|
||||||
|
"""Teste que les cours even/odd sur le même créneau avec des matières différentes ont des IDs différents.
|
||||||
|
|
||||||
|
:assert: Les IDs des cours even et odd sont différents.
|
||||||
|
"""
|
||||||
|
# Mardi 2026-09-01 (semaine paire, même semaine que l'ancrage)
|
||||||
|
even_lessons = provider_with_parity.get_lessons(date(2026, 9, 1))
|
||||||
|
# Mardi 2026-09-08 (semaine impaire, 1 semaine après l'ancrage)
|
||||||
|
odd_lessons = provider_with_parity.get_lessons(date(2026, 9, 8))
|
||||||
|
|
||||||
|
# Les deux semaines ont des cours sur le même créneau (10:00-11:00)
|
||||||
|
# mais avec des matières différentes (Anglais/Technologie vs Espagnol/Éducation musicale)
|
||||||
|
even_subjects = {lesson.subject for lesson in even_lessons}
|
||||||
|
odd_subjects = {lesson.subject for lesson in odd_lessons}
|
||||||
|
|
||||||
|
# Les matières doivent être différentes
|
||||||
|
assert "Anglais" in even_subjects
|
||||||
|
assert "Espagnol" in odd_subjects
|
||||||
|
# Les IDs ne doivent pas être identiques
|
||||||
|
even_ids = {lesson.id for lesson in even_lessons}
|
||||||
|
odd_ids = {lesson.id for lesson in odd_lessons}
|
||||||
|
assert even_ids.isdisjoint(odd_ids)
|
||||||
|
|
||||||
|
def test_even_odd_same_subject_different_ids(self, tmp_path: Path) -> None:
|
||||||
|
"""Vérifie que les leçons paire/impaire sur le même créneau avec la même matière ont des IDs distincts.
|
||||||
|
|
||||||
|
Utilise la même matière pour isoler la parité comme seul différenciateur.
|
||||||
|
|
||||||
|
:assert: Les IDs des cours even et odd sont différents, avec la même matière.
|
||||||
|
"""
|
||||||
|
json_content = json.dumps(
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"lessons": [
|
||||||
|
{
|
||||||
|
"week": "even",
|
||||||
|
"day_of_week": 1,
|
||||||
|
"start_time": "10:00",
|
||||||
|
"end_time": "11:00",
|
||||||
|
"subject": "Langue vivante",
|
||||||
|
"teachers": [],
|
||||||
|
"rooms": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"week": "odd",
|
||||||
|
"day_of_week": 1,
|
||||||
|
"start_time": "10:00",
|
||||||
|
"end_time": "11:00",
|
||||||
|
"subject": "Langue vivante",
|
||||||
|
"teachers": [],
|
||||||
|
"rooms": [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
file_path = tmp_path / "theoretical.json"
|
||||||
|
file_path.write_text(json_content, encoding="utf-8")
|
||||||
|
|
||||||
|
anchor_date = date(2026, 9, 1) # Tuesday
|
||||||
|
parity = WeekParityService(anchor_date, "even")
|
||||||
|
provider = JsonTheoreticalAgendaProvider(file_path=str(file_path), parity_service=parity)
|
||||||
|
|
||||||
|
# Tuesday in even week
|
||||||
|
even_tuesday = date(2026, 9, 1) # Same week as anchor (even)
|
||||||
|
odd_tuesday = date(2026, 9, 8) # One week later (odd)
|
||||||
|
|
||||||
|
even_lessons = provider.get_lessons(even_tuesday)
|
||||||
|
odd_lessons = provider.get_lessons(odd_tuesday)
|
||||||
|
|
||||||
|
assert len(even_lessons) == 1
|
||||||
|
assert len(odd_lessons) == 1
|
||||||
|
assert even_lessons[0].id != odd_lessons[0].id
|
||||||
|
# The only difference in the ID should be the week type
|
||||||
|
assert "even" in even_lessons[0].id
|
||||||
|
assert "odd" in odd_lessons[0].id
|
||||||
|
|
||||||
|
def test_explicit_id_preserved(
|
||||||
|
self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider
|
||||||
|
) -> None:
|
||||||
|
"""Teste qu'un ID explicite est préservé.
|
||||||
|
|
||||||
|
:assert: L'ID explicite "theoretical-maths-monday-1" est utilisé.
|
||||||
|
"""
|
||||||
|
# Lundi 2026-08-31 (weekday = 0)
|
||||||
|
target_date = date(2026, 8, 31)
|
||||||
|
lessons = provider_no_parity_no_holidays.get_lessons(target_date)
|
||||||
|
ids = [lesson.id for lesson in lessons]
|
||||||
|
assert "theoretical-maths-monday-1" in ids
|
||||||
|
|
||||||
|
def test_generated_id_includes_week(
|
||||||
|
self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider
|
||||||
|
) -> None:
|
||||||
|
"""Teste qu'un ID généré inclut le type de semaine.
|
||||||
|
|
||||||
|
:assert: L'ID généré contient le type de semaine.
|
||||||
|
"""
|
||||||
|
# Lundi 2026-08-31 (weekday = 0)
|
||||||
|
target_date = date(2026, 8, 31)
|
||||||
|
lessons = provider_no_parity_no_holidays.get_lessons(target_date)
|
||||||
|
# Trouver un cours sans ID explicite (Français ou Histoire-Géographie)
|
||||||
|
for lesson in lessons:
|
||||||
|
if lesson.subject in ("Français", "Histoire-Géographie"):
|
||||||
|
assert "all" in lesson.id
|
||||||
|
break
|
||||||
|
|
||||||
|
def test_no_parity_service_with_all_only_lessons(self, all_only_path: Path) -> None:
|
||||||
|
"""Teste qu'un fournisseur sans service de parité fonctionne avec des cours "all".
|
||||||
|
|
||||||
|
:assert: Les cours "all" sont retournés correctement.
|
||||||
|
"""
|
||||||
|
provider = JsonTheoreticalAgendaProvider(
|
||||||
|
file_path=str(all_only_path),
|
||||||
|
parity_service=None,
|
||||||
|
holiday_calendar=None,
|
||||||
|
)
|
||||||
|
# Lundi 2026-08-31 (weekday = 0)
|
||||||
|
target_date = date(2026, 8, 31)
|
||||||
|
lessons = provider.get_lessons(target_date)
|
||||||
|
assert len(lessons) == 3
|
||||||
|
|
||||||
|
def test_even_lessons_without_parity_service_error(
|
||||||
|
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""Teste qu'un fichier avec des cours even/odd sans service de parité lève une erreur.
|
||||||
|
|
||||||
|
:assert: PronoteSyncError est levée.
|
||||||
|
"""
|
||||||
|
# Créer un fichier JSON avec uniquement des cours even/odd
|
||||||
|
data = {
|
||||||
|
"version": 1,
|
||||||
|
"lessons": [
|
||||||
|
{
|
||||||
|
"week": "even",
|
||||||
|
"day_of_week": 0,
|
||||||
|
"start_time": "08:00",
|
||||||
|
"end_time": "09:00",
|
||||||
|
"subject": "Test",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
file_path = tmp_path / "even_only.json"
|
||||||
|
file_path.write_text(json.dumps(data), encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(PronoteSyncError) as exc_info:
|
||||||
|
JsonTheoreticalAgendaProvider(
|
||||||
|
file_path=str(file_path),
|
||||||
|
parity_service=None,
|
||||||
|
holiday_calendar=None,
|
||||||
|
)
|
||||||
|
assert "ancre de parité" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_invalid_json_file(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
"""Teste qu'un fichier JSON invalide lève une PronoteSyncError.
|
||||||
|
|
||||||
|
:assert: PronoteSyncError est levée pour un JSON invalide.
|
||||||
|
"""
|
||||||
|
file_path = tmp_path / "invalid.json"
|
||||||
|
file_path.write_text("{ invalid json }", encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(PronoteSyncError) as exc_info:
|
||||||
|
JsonTheoreticalAgendaProvider(
|
||||||
|
file_path=str(file_path),
|
||||||
|
parity_service=None,
|
||||||
|
holiday_calendar=None,
|
||||||
|
)
|
||||||
|
assert "invalide" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_get_lessons_for_range(
|
||||||
|
self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider
|
||||||
|
) -> None:
|
||||||
|
"""Teste la récupération des cours pour une plage de 5 jours.
|
||||||
|
|
||||||
|
:assert: Tous les cours uniques sont retournés, triés par ID.
|
||||||
|
"""
|
||||||
|
# Lundi 2026-08-31 à Vendredi 2026-09-04
|
||||||
|
start_date = date(2026, 8, 31) # Lundi
|
||||||
|
end_date = date(2026, 9, 4) # Vendredi
|
||||||
|
lessons = provider_no_parity_no_holidays.get_lessons_for_range(start_date, end_date)
|
||||||
|
|
||||||
|
# Le fichier all_only a des cours pour lundi (3), mercredi (1), vendredi (1)
|
||||||
|
# Total: 3 + 1 + 1 = 5 cours uniques
|
||||||
|
assert len(lessons) == 5
|
||||||
|
ids = [lesson.id for lesson in lessons]
|
||||||
|
assert ids == sorted(ids)
|
||||||
|
|
||||||
|
def test_get_lessons_for_range_with_holidays(
|
||||||
|
self, provider_full: JsonTheoreticalAgendaProvider
|
||||||
|
) -> None:
|
||||||
|
"""Teste la récupération des cours pour une plage incluant des jours de vacances.
|
||||||
|
|
||||||
|
:assert: Les jours de vacances ne contribuent pas de cours.
|
||||||
|
"""
|
||||||
|
# Plage incluant des vacances de Toussaint (17 oct - 2 nov 2026)
|
||||||
|
# Le 15 oct 2026 est un mercredi (day_of_week=2), le fixture a un cours "all" pour mercredi
|
||||||
|
# Le 16 oct 2026 est un jeudi (day_of_week=3), pas de cours dans le fixture
|
||||||
|
# Le 17 oct 2026 est un vendredi (day_of_week=4), le fixture a un cours "all" pour vendredi
|
||||||
|
# Mais le 17 oct est le début des vacances, donc pas de cours
|
||||||
|
start_date = date(2026, 10, 15) # Mercredi (avant vacances)
|
||||||
|
end_date = date(2026, 10, 16) # Jeudi (avant vacances)
|
||||||
|
lessons = provider_full.get_lessons_for_range(start_date, end_date)
|
||||||
|
|
||||||
|
# Le 15 oct (mercredi) devrait avoir un cours "all" pour Sciences
|
||||||
|
# Le 16 oct (jeudi) n'a pas de cours dans le fixture
|
||||||
|
# Donc on devrait avoir au moins 1 cours
|
||||||
|
assert len(lessons) >= 1
|
||||||
|
|
||||||
|
def test_get_lessons_for_range_empty(
|
||||||
|
self, provider_full: JsonTheoreticalAgendaProvider
|
||||||
|
) -> None:
|
||||||
|
"""Teste qu'une plage où tous les jours sont en vacances retourne une liste vide.
|
||||||
|
|
||||||
|
:assert: get_lessons_for_range retourne [] pour une plage entièrement en vacances.
|
||||||
|
"""
|
||||||
|
# Plage entièrement pendant les vacances de Toussaint
|
||||||
|
start_date = date(2026, 10, 17)
|
||||||
|
end_date = date(2026, 10, 24)
|
||||||
|
lessons = provider_full.get_lessons_for_range(start_date, end_date)
|
||||||
|
assert lessons == []
|
||||||
|
|
||||||
|
def test_get_lessons_sunday(
|
||||||
|
self, provider_no_parity_no_holidays: JsonTheoreticalAgendaProvider
|
||||||
|
) -> None:
|
||||||
|
"""Teste qu'un dimanche retourne une liste vide.
|
||||||
|
|
||||||
|
:assert: get_lessons retourne [] pour un dimanche.
|
||||||
|
"""
|
||||||
|
# Dimanche 2026-09-06 (weekday = 6)
|
||||||
|
target_date = date(2026, 9, 6)
|
||||||
|
lessons = provider_no_parity_no_holidays.get_lessons(target_date)
|
||||||
|
# Aucun cours n'est prévu pour le dimanche dans le fichier all_only
|
||||||
|
assert lessons == []
|
||||||
|
|
||||||
|
def test_get_lessons_for_range_dedup_across_weeks(self, tmp_path: Path) -> None:
|
||||||
|
"""Vérifie que get_lessons_for_range déduplique les leçons récurrentes.
|
||||||
|
|
||||||
|
:assert: Une leçon récurrente sur plusieurs semaines n'apparaît qu'une fois.
|
||||||
|
"""
|
||||||
|
# Créer un fichier JSON avec une seule leçon "all" le lundi 08:00-09:00 "Maths"
|
||||||
|
data = {
|
||||||
|
"version": 1,
|
||||||
|
"lessons": [
|
||||||
|
{
|
||||||
|
"week": "all",
|
||||||
|
"day_of_week": 0, # Lundi
|
||||||
|
"start_time": "08:00",
|
||||||
|
"end_time": "09:00",
|
||||||
|
"subject": "Maths",
|
||||||
|
"teachers": [],
|
||||||
|
"rooms": [],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
file_path = tmp_path / "dedup_test.json"
|
||||||
|
file_path.write_text(json.dumps(data), encoding="utf-8")
|
||||||
|
|
||||||
|
provider = JsonTheoreticalAgendaProvider(
|
||||||
|
file_path=str(file_path),
|
||||||
|
parity_service=None,
|
||||||
|
holiday_calendar=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Plage du 31 août 2026 (lundi) au 11 septembre 2026 (vendredi)
|
||||||
|
# Cela couvre 2 lundis (31 août et 7 septembre)
|
||||||
|
start_date = date(2026, 8, 31)
|
||||||
|
end_date = date(2026, 9, 11)
|
||||||
|
lessons = provider.get_lessons_for_range(start_date, end_date)
|
||||||
|
|
||||||
|
# La leçon récurrente devrait apparaître une seule fois
|
||||||
|
assert len(lessons) == 1
|
||||||
|
assert lessons[0].subject == "Maths"
|
||||||
|
|
||||||
|
def test_duplicate_explicit_ids_rejected(self, tmp_path: Path) -> None:
|
||||||
|
"""Vérifie que les IDs explicites dupliqués sont rejetés.
|
||||||
|
|
||||||
|
:assert: PronoteSyncError est levée pour des IDs explicites dupliqués.
|
||||||
|
"""
|
||||||
|
# Créer un fichier JSON avec 2 leçons ayant le même ID explicite
|
||||||
|
data = {
|
||||||
|
"version": 1,
|
||||||
|
"lessons": [
|
||||||
|
{
|
||||||
|
"id": "dup-id",
|
||||||
|
"week": "all",
|
||||||
|
"day_of_week": 0,
|
||||||
|
"start_time": "08:00",
|
||||||
|
"end_time": "09:00",
|
||||||
|
"subject": "Maths",
|
||||||
|
"teachers": [],
|
||||||
|
"rooms": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dup-id",
|
||||||
|
"week": "all",
|
||||||
|
"day_of_week": 1,
|
||||||
|
"start_time": "09:00",
|
||||||
|
"end_time": "10:00",
|
||||||
|
"subject": "Français",
|
||||||
|
"teachers": [],
|
||||||
|
"rooms": [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
file_path = tmp_path / "duplicate_ids.json"
|
||||||
|
file_path.write_text(json.dumps(data), encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(PronoteSyncError) as exc_info:
|
||||||
|
JsonTheoreticalAgendaProvider(
|
||||||
|
file_path=str(file_path),
|
||||||
|
parity_service=None,
|
||||||
|
holiday_calendar=None,
|
||||||
|
)
|
||||||
|
assert "Conflit d'identifiant" in str(exc_info.value) or "identifiant" in str(
|
||||||
|
exc_info.value
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_generated_id_collision_rejected(self, tmp_path: Path) -> None:
|
||||||
|
"""Vérifie que les collisions d'IDs générés sont détectées.
|
||||||
|
|
||||||
|
:assert: PronoteSyncError est levée pour des IDs générés identiques.
|
||||||
|
"""
|
||||||
|
# Créer un fichier JSON avec 2 leçons qui produisent le même ID généré
|
||||||
|
# Même week, day_of_week, start_time, end_time, subject mais enseignants différents
|
||||||
|
data = {
|
||||||
|
"version": 1,
|
||||||
|
"lessons": [
|
||||||
|
{
|
||||||
|
"week": "all",
|
||||||
|
"day_of_week": 0,
|
||||||
|
"start_time": "08:00",
|
||||||
|
"end_time": "09:00",
|
||||||
|
"subject": "Maths",
|
||||||
|
"teachers": ["Prof1"],
|
||||||
|
"rooms": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"week": "all",
|
||||||
|
"day_of_week": 0,
|
||||||
|
"start_time": "08:00",
|
||||||
|
"end_time": "09:00",
|
||||||
|
"subject": "Maths",
|
||||||
|
"teachers": ["Prof2"],
|
||||||
|
"rooms": [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
file_path = tmp_path / "generated_id_collision.json"
|
||||||
|
file_path.write_text(json.dumps(data), encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(PronoteSyncError) as exc_info:
|
||||||
|
JsonTheoreticalAgendaProvider(
|
||||||
|
file_path=str(file_path),
|
||||||
|
parity_service=None,
|
||||||
|
holiday_calendar=None,
|
||||||
|
)
|
||||||
|
assert "Conflit d'identifiant" in str(exc_info.value) or "identifiant" in str(
|
||||||
|
exc_info.value
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_lessons_for_range_mixed_holidays_and_normal(self, tmp_path: Path) -> None:
|
||||||
|
"""Vérifie qu'une plage mixte (jours normaux et vacances) retourne les leçons des jours non vacanciers.
|
||||||
|
|
||||||
|
:assert: Les leçons des jours non vacanciers sont retournées, les jours de vacances sont ignorés.
|
||||||
|
"""
|
||||||
|
# Créer un fichier JSON avec une leçon "all" le jeudi 14:00-15:00 "Sciences"
|
||||||
|
# Note: 2026-10-15 est un jeudi (weekday=3)
|
||||||
|
data = {
|
||||||
|
"version": 1,
|
||||||
|
"lessons": [
|
||||||
|
{
|
||||||
|
"week": "all",
|
||||||
|
"day_of_week": 3, # Jeudi
|
||||||
|
"start_time": "14:00",
|
||||||
|
"end_time": "15:00",
|
||||||
|
"subject": "Sciences",
|
||||||
|
"teachers": [],
|
||||||
|
"rooms": [],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
file_path = tmp_path / "mixed_holidays.json"
|
||||||
|
file_path.write_text(json.dumps(data), encoding="utf-8")
|
||||||
|
|
||||||
|
# Créer un fichier de vacances avec une période du 16 au 20 octobre 2026
|
||||||
|
holidays_data = {
|
||||||
|
"zone": "A",
|
||||||
|
"school_year": "2026-2027",
|
||||||
|
"periods": [
|
||||||
|
{"start_date": "2026-10-16", "end_date": "2026-10-20", "label": "Test Vacances"}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
holidays_path = tmp_path / "test_holidays.json"
|
||||||
|
holidays_path.write_text(json.dumps(holidays_data), encoding="utf-8")
|
||||||
|
|
||||||
|
holiday_calendar = SchoolHolidayCalendar(holidays_path)
|
||||||
|
provider = JsonTheoreticalAgendaProvider(
|
||||||
|
file_path=str(file_path),
|
||||||
|
parity_service=None,
|
||||||
|
holiday_calendar=holiday_calendar,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Plage du 15 octobre (jeudi, pas en vacances) au 17 octobre (samedi, en vacances)
|
||||||
|
start_date = date(2026, 10, 15) # Jeudi
|
||||||
|
end_date = date(2026, 10, 17) # Samedi
|
||||||
|
lessons = provider.get_lessons_for_range(start_date, end_date)
|
||||||
|
|
||||||
|
# Seule la leçon du 15 octobre (jeudi) devrait être retournée
|
||||||
|
assert len(lessons) == 1
|
||||||
|
assert lessons[0].subject == "Sciences"
|
||||||
|
|
||||||
|
def test_normalize_subject_variants_produce_same_id(self, tmp_path: Path) -> None:
|
||||||
|
"""Vérifie que des variantes de casse, d'espacement et d'Unicode produisent le même ID.
|
||||||
|
|
||||||
|
:assert: Les variantes de la même matière produisent le même ID normalisé.
|
||||||
|
"""
|
||||||
|
# Test the normalize_subject function directly
|
||||||
|
# Note: hyphens are removed entirely (not replaced with spaces) by normalize_subject
|
||||||
|
variants = [
|
||||||
|
"Mathématiques avancées",
|
||||||
|
"mathématiques avancées",
|
||||||
|
"Mathématiques avancées",
|
||||||
|
"MATHÉMATIQUES AVANCÉES",
|
||||||
|
]
|
||||||
|
normalized = [normalize_subject(variant) for variant in variants]
|
||||||
|
# All should normalize to the same value
|
||||||
|
assert all(n == normalized[0] for n in normalized)
|
||||||
|
# Should be lowercase, no extra spaces, no punctuation
|
||||||
|
assert normalized[0] == "mathématiques avancées"
|
||||||
|
|
||||||
|
def test_collision_error_no_secret_leak(
|
||||||
|
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'une sentinelle dans un ID dupliqué n'apparaît pas dans l'erreur.
|
||||||
|
|
||||||
|
:assert: PronoteSyncError est levée et la sentinelle n'apparaît pas dans l'erreur.
|
||||||
|
"""
|
||||||
|
# Use a secret pattern that the redaction system will actually catch
|
||||||
|
# The _ISOLATED_SECRET_PATTERN looks for things like secret=value or secret: value
|
||||||
|
sentinel_value = "SENTINELLE_M6_SECRET"
|
||||||
|
# Create an ID that contains a pattern like "secret=value" which will be redacted
|
||||||
|
secret_pattern = f"secret={sentinel_value}"
|
||||||
|
|
||||||
|
# Créer un fichier JSON avec 2 entrées ayant le même ID explicite contenant la sentinelle
|
||||||
|
data = {
|
||||||
|
"version": 1,
|
||||||
|
"lessons": [
|
||||||
|
{
|
||||||
|
"id": f"theoretical-{secret_pattern}-1",
|
||||||
|
"week": "all",
|
||||||
|
"day_of_week": 0,
|
||||||
|
"start_time": "08:00",
|
||||||
|
"end_time": "09:00",
|
||||||
|
"subject": "Maths",
|
||||||
|
"teachers": [],
|
||||||
|
"rooms": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": f"theoretical-{secret_pattern}-1",
|
||||||
|
"week": "all",
|
||||||
|
"day_of_week": 1,
|
||||||
|
"start_time": "09:00",
|
||||||
|
"end_time": "10:00",
|
||||||
|
"subject": "Français",
|
||||||
|
"teachers": [],
|
||||||
|
"rooms": [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
file_path = tmp_path / "collision_test.json"
|
||||||
|
file_path.write_text(json.dumps(data), encoding="utf-8")
|
||||||
|
|
||||||
|
with caplog.at_level("ERROR"):
|
||||||
|
with pytest.raises(PronoteSyncError) as exc_info:
|
||||||
|
JsonTheoreticalAgendaProvider(
|
||||||
|
file_path=str(file_path),
|
||||||
|
parity_service=None,
|
||||||
|
holiday_calendar=None,
|
||||||
|
)
|
||||||
|
# Check that the sentinel value does not appear in the error message
|
||||||
|
assert sentinel_value not in str(exc_info.value)
|
||||||
|
# Check that the sentinel value does not appear in the logs
|
||||||
|
assert sentinel_value not in caplog.text
|
||||||
|
# Check that the secret pattern was redacted (should contain REDACTED)
|
||||||
|
assert "REDACTED" in str(exc_info.value)
|
||||||
|
# Check that the sentinel does not appear in the cause
|
||||||
|
if exc_info.value.__cause__ is not None:
|
||||||
|
assert sentinel_value not in repr(exc_info.value.__cause__)
|
||||||
Reference in New Issue
Block a user