|
|
|
|
@@ -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`.
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
|
|
|
4. **Génération de la synthèse** :
|
|
|
|
|
@@ -206,7 +206,7 @@ pronote_sync/
|
|
|
|
|
│ │ └── state.py # État local (déduplication, cache HTTP)
|
|
|
|
|
│ └── theoretical/ # Agenda théorique
|
|
|
|
|
│ ├── __init__.py
|
|
|
|
|
│ ├── file.py # Lecture fichier iCal/CSV
|
|
|
|
|
│ ├── file.py # Lecture fichier JSON (parité + vacances)
|
|
|
|
|
│ └── provider.py # Interface TheoreticalAgendaProvider
|
|
|
|
|
├── sync/ # Synchronisation CalDAV + Blog
|
|
|
|
|
│ ├── __init__.py
|
|
|
|
|
@@ -306,7 +306,10 @@ d'un besoin réel et testé.
|
|
|
|
|
> ⚠️ **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`.
|
|
|
|
|
> 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_PROVIDER` | Fournisseur IA (`openai` ou `litellm`). | `openai` | `str` |
|
|
|
|
|
| `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_FUTURE_DAYS=30
|
|
|
|
|
|
|
|
|
|
# --- Agenda théorique ---
|
|
|
|
|
THEORETICAL_AGENDA_PATH=./data/theoretical.ics
|
|
|
|
|
# --- Agenda théorique (JSON) ---
|
|
|
|
|
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_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 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.
|
|
|
|
|
- **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)).
|
|
|
|
|
|
|
|
|
|
### 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.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
|
|
|
|
|
from typing import List
|
|
|
|
|
from datetime import date, time
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from icalendar import Calendar, Event
|
|
|
|
|
from ..models.agenda import TheoreticalLesson
|
|
|
|
|
from .provider import TheoreticalAgendaProvider
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ICalTheoreticalAgendaProvider:
|
|
|
|
|
"""Fournisseur d'agenda théorique depuis un fichier iCal."""
|
|
|
|
|
|
|
|
|
|
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 iCal et parse les cours."""
|
|
|
|
|
if not self.file_path.exists():
|
|
|
|
|
raise FileNotFoundError(f"Fichier iCal introuvable: {self.file_path}")
|
|
|
|
|
|
|
|
|
|
with open(self.file_path, "rb") as f:
|
|
|
|
|
cal = Calendar.from_ical(f.read())
|
|
|
|
|
|
|
|
|
|
for component in cal.walk():
|
|
|
|
|
if not isinstance(component, Event):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Ignorer les événements tout le jour (vacances, etc.)
|
|
|
|
|
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,
|
|
|
|
|
```json
|
|
|
|
|
{
|
|
|
|
|
"version": 1,
|
|
|
|
|
"lessons": [
|
|
|
|
|
{
|
|
|
|
|
"week": "all",
|
|
|
|
|
"day_of_week": 0,
|
|
|
|
|
"start_time": "08:00",
|
|
|
|
|
"end_time": "09:00",
|
|
|
|
|
"subject": "Mathématiques",
|
|
|
|
|
"teachers": ["M. Dupont"],
|
|
|
|
|
"rooms": ["101"]
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"week": "even",
|
|
|
|
|
"day_of_week": 1,
|
|
|
|
|
"start_time": "10:00",
|
|
|
|
|
"end_time": "11:00",
|
|
|
|
|
"subject": "Anglais",
|
|
|
|
|
"teachers": [],
|
|
|
|
|
"rooms": []
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"week": "odd",
|
|
|
|
|
"day_of_week": 1,
|
|
|
|
|
"start_time": "10:00",
|
|
|
|
|
"end_time": "11:00",
|
|
|
|
|
"subject": "Espagnol",
|
|
|
|
|
"teachers": [],
|
|
|
|
|
"rooms": []
|
|
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
**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.
|
|
|
|
|
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** :
|
|
|
|
|
```python
|
|
|
|
|
def match_theoretical_event(
|
|
|
|
|
def match_theoretical_lesson(
|
|
|
|
|
real_lesson: PronoteLesson,
|
|
|
|
|
theoretical_events: list[TheoreticalEvent],
|
|
|
|
|
theoretical_lessons: list[TheoreticalLesson],
|
|
|
|
|
tolerance_minutes: int = 15,
|
|
|
|
|
) -> TheoreticalEvent | None:
|
|
|
|
|
"""Trouve l'événement théorique correspondant, avec départage déterministe."""
|
|
|
|
|
) -> TheoreticalLesson | None:
|
|
|
|
|
"""Trouve le cours théorique correspondant, avec départage déterministe."""
|
|
|
|
|
candidates = [
|
|
|
|
|
t for t in theoretical_events
|
|
|
|
|
if abs((t.start - real_lesson.start).total_seconds()) <= tolerance_minutes * 60
|
|
|
|
|
t for t in theoretical_lessons
|
|
|
|
|
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 t.start.date() == real_lesson.start.date()
|
|
|
|
|
]
|
|
|
|
|
if not candidates:
|
|
|
|
|
return None
|
|
|
|
|
# Tri déterministe par UID stable, puis par créneau
|
|
|
|
|
candidates.sort(key=lambda t: (t.uid or "", t.start))
|
|
|
|
|
# Tri déterministe par ID stable, puis par créneau
|
|
|
|
|
candidates.sort(key=lambda t: (t.id, t.start_time))
|
|
|
|
|
return candidates[0]
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
@@ -3789,11 +3742,14 @@ class AgendaComparator:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### 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.
|
|
|
|
|
- **Normalisation** : Les matières et heures sont normalisées pour éviter les faux négatifs.
|
|
|
|
|
- **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 (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.
|
|
|
|
|
- **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.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|