docs(M6): aligner guide, TODO et configuration pour l'agenda théorique JSON

Met à jour la documentation et la configuration pour le jalon M6 selon
les décisions d'architecture :

- GUIDE_DEV_PYTHON.md §8 : remplace iCal/CSV par JSON avec parité de
  semaine (paire/impaire) et calendrier de vacances scolaires séparé
- TODO.md M6 : nouveaux items (WeekParityService, SchoolHolidayCalendar,
  configuration, fixtures JSON)
- .env.example : THEORETICAL_AGENDA_PATH passe en .json, ajout de
  SCHOOL_HOLIDAYS_PATH, THEORETICAL_WEEK_ANCHOR_DATE et
  THEORETICAL_WEEK_ANCHOR_TYPE
- AppSettings : 3 nouveaux champs (school_holidays_path,
  theoretical_week_anchor_date, theoretical_week_anchor_type)

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/tech-writer <tech-writer@agents.invalid>
This commit is contained in:
2026-09-06 22:30:50 +02:00
parent 7fdca2ca55
commit 4ec827a945
5 changed files with 165 additions and 192 deletions

View File

@@ -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

View File

@@ -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": 5014,
"is_secret": false "is_secret": false
} }
] ]
}, },
"generated_at": "2026-09-06T18:57:58Z" "generated_at": "2026-09-06T20:30:44Z"
} }

View File

@@ -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
@@ -3320,6 +3326,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,188 +3377,131 @@ 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.
@@ -3567,22 +3520,22 @@ theoretical_lessons_sorted = sorted(
**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: PronoteLesson,
theoretical_events: list[TheoreticalEvent], theoretical_lessons: 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 le cours théorique correspondant, avec départage déterministe."""
candidates = [ candidates = [
t for t in theoretical_events t for t in theoretical_lessons
if abs((t.start - real_lesson.start).total_seconds()) <= tolerance_minutes * 60 if t.day_of_week == real_lesson.start.weekday()
and abs((t.start_time - real_lesson.start.time()).total_seconds()) <= tolerance_minutes * 60
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 # Tri déterministe par ID stable, puis par créneau
candidates.sort(key=lambda t: (t.uid or "", t.start)) candidates.sort(key=lambda t: (t.id, t.start_time))
return candidates[0] return candidates[0]
``` ```
@@ -3789,11 +3742,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.
--- ---

17
TODO.md
View File

@@ -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). - [ ] 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). - [ ] Créer `sources/theoretical/file.py` : parser JSON → liste de `TheoreticalLesson` avec filtrage par parité de semaine (paire/impaire/toutes).
- [ ] 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.
- [ ] Créer `sources/theoretical/holidays.py` : service `SchoolHolidayCalendar` lisant un fichier JSON de vacances scolaires (zone A) et exposant `is_holiday(date)`.
- [ ] Implémenter le provider JSON : filtrage par parité + vacances, génération d'identifiants déterministes incluant le type de semaine.
- [ ] Ajouter la configuration : `SCHOOL_HOLIDAYS_PATH`, `THEORETICAL_WEEK_ANCHOR_DATE`, `THEORETICAL_WEEK_ANCHOR_TYPE` dans `AppSettings`.
- [ ] Normaliser les matières et créneaux pour le matching déterministe. - [ ] Normaliser les matières et créneaux pour le matching déterministe.
- [ ] Supporter les deux formats (iCal et CSV) derrière la même interface. - [ ] 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.
--- ---
@@ -236,7 +243,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.

View File

@@ -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