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:
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
17
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).
|
- [ ] 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user