feat(M3): modèles de données Pydantic (22 modèles, 10 modules)

Modèles créés :
- models/agenda.py : Status, LessonStatus, HomeworkBlock, Lesson (frozen),
  SchoolEventKind, SchoolEvent (frozen), TheoreticalLesson (frozen)
- models/homework.py : Homework (frozen)
- models/message.py : MessageType, Message (frozen)
- models/blog.py : BlogArticle (frozen), ExternalInfo (mutable)
- models/diff.py : AgendaChangeType, AgendaChange (frozen), AgendaDiff (frozen)
- models/pronote.py : PronoteData (mutable)
- models/sync.py : CalDAVSyncStatus, CalDAVSyncPlan, CalDAVSyncResult (mutable)
- models/synthesis.py : SynthesisInput, SynthesisResult (mutable)
- models/xmpp.py : XmppMessage (frozen, external_info: ExternalInfo | None)
- models/__init__.py : ré-export des 22 modèles via __all__

Conventions appliquées :
- Pydantic v2 : model_config = ConfigDict(frozen=True), pas de json_encoders
- StrEnum au lieu de (str, Enum) (ruff UP042)
- Alias _date/_time pour éviter collision champ date/type date
- Sphinx/reST docstrings sur toutes les classes
- TODO.md : 11 items M3 cochés

Validations (@verifier) :
- ruff check : PASS (0 error)
- ruff format --check : PASS (10 fichiers)
- mypy strict : PASS (10 fichiers)
- bandit : PASS (0 issue)
- import 22 modèles : OK
- sérialisation JSON (frozen + mutable) : OK
- frozen immuable, PronoteData/CalDAVSyncResult mutable : OK
- StrEnum isinstance str : OK
- pytest : 0 test (infrastructure OK)

Co-authored-by: OpenCode/orchestrator <opencode-orchestrator@agents.invalid>
This commit is contained in:
2026-09-05 23:21:39 +02:00
parent c3f76ab91b
commit 2e7dfe6e46
11 changed files with 405 additions and 11 deletions

22
TODO.md
View File

@@ -50,17 +50,17 @@ Implémenter la configuration Pydantic Settings, le masquage des secrets et la j
Définir tous les modèles de domaine, immuables pour les contrats, mutables pour les résultats de travail. Définir tous les modèles de domaine, immuables pour les contrats, mutables pour les résultats de travail.
- [ ] Créer `models/agenda.py` : `Status`, `LessonStatus`, `HomeworkBlock`, `Lesson` (frozen), `SchoolEventKind`, `SchoolEvent`, `TheoreticalLesson`. - [x] Créer `models/agenda.py` : `Status`, `LessonStatus`, `HomeworkBlock`, `Lesson` (frozen), `SchoolEventKind`, `SchoolEvent`, `TheoreticalLesson`.
- [ ] Créer `models/homework.py` : `Homework` (frozen, `id` = hachage stable). - [x] Créer `models/homework.py` : `Homework` (frozen, `id` = hachage stable).
- [ ] Créer `models/message.py` : `MessageType`, `Message` (frozen). - [x] Créer `models/message.py` : `MessageType`, `Message` (frozen).
- [ ] Créer `models/diff.py` : `AgendaChangeType`, `AgendaChange` (frozen), `AgendaDiff` (frozen). - [x] Créer `models/diff.py` : `AgendaChangeType`, `AgendaChange` (frozen), `AgendaDiff` (frozen).
- [ ] Créer `models/pronote.py` : `PronoteData` (mutable). - [x] Créer `models/pronote.py` : `PronoteData` (mutable).
- [ ] Créer `models/sync.py` : `CalDAVSyncStatus`, `CalDAVSyncPlan`, `CalDAVSyncResult` (mutable). - [x] Créer `models/sync.py` : `CalDAVSyncStatus`, `CalDAVSyncPlan`, `CalDAVSyncResult` (mutable).
- [ ] Créer `models/synthesis.py` : `SynthesisInput`, `SynthesisResult`. - [x] Créer `models/synthesis.py` : `SynthesisInput`, `SynthesisResult`.
- [ ] Créer `models/blog.py` : `BlogArticle` (frozen), `ExternalInfo` (§5 bis.5/6). - [x] Créer `models/blog.py` : `BlogArticle` (frozen), `ExternalInfo` (§5 bis.5/6).
- [ ] Créer `models/xmpp.py` : `XmppMessage` (frozen) intégrant `external_info`. - [x] Créer `models/xmpp.py` : `XmppMessage` (frozen) intégrant `external_info`.
- [ ] Créer `models/__init__.py` ré-exportant tous les modèles. - [x] Créer `models/__init__.py` ré-exportant tous les modèles.
- [ ] Valider la sérialisation JSON (datetime/date en ISO) pour chaque modèle. - [x] Valider la sérialisation JSON (datetime/date en ISO) pour chaque modèle.
### Critères d'acceptation ### Critères d'acceptation
- Chaque modèle s'instancie et se sérialise en JSON valide. - Chaque modèle s'instancie et se sérialise en JSON valide.

View File

@@ -0,0 +1,44 @@
"""Modèles de données Pydantic pour le pipeline pronote-sync."""
from pronote_sync.models.agenda import (
HomeworkBlock,
Lesson,
LessonStatus,
SchoolEvent,
SchoolEventKind,
Status,
TheoreticalLesson,
)
from pronote_sync.models.blog import BlogArticle, ExternalInfo
from pronote_sync.models.diff import AgendaChange, AgendaChangeType, AgendaDiff
from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message, MessageType
from pronote_sync.models.pronote import PronoteData
from pronote_sync.models.sync import CalDAVSyncPlan, CalDAVSyncResult, CalDAVSyncStatus
from pronote_sync.models.synthesis import SynthesisInput, SynthesisResult
from pronote_sync.models.xmpp import XmppMessage
__all__ = [
"AgendaChange",
"AgendaChangeType",
"AgendaDiff",
"BlogArticle",
"CalDAVSyncPlan",
"CalDAVSyncResult",
"CalDAVSyncStatus",
"ExternalInfo",
"Homework",
"HomeworkBlock",
"Lesson",
"LessonStatus",
"Message",
"MessageType",
"PronoteData",
"SchoolEvent",
"SchoolEventKind",
"Status",
"SynthesisInput",
"SynthesisResult",
"TheoreticalLesson",
"XmppMessage",
]

View File

@@ -0,0 +1,90 @@
"""Modèles de données pour l'agenda Pronote (cours, événements, agenda théorique)."""
from __future__ import annotations
from datetime import date as _date
from datetime import datetime
from datetime import time as _time
from enum import StrEnum
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class Status(StrEnum):
"""Statut générique pour les événements."""
NORMAL = "normal"
CANCELLED = "cancelled"
MOVED = "moved"
class LessonStatus(StrEnum):
"""Statut d'un cours."""
NORMAL = "normal"
CANCELLED = "cancelled"
MOVED = "moved"
class HomeworkBlock(BaseModel):
"""Représente un bloc de devoir extrait de la description d'un cours."""
kind: Literal["due", "assigned"] = Field(
..., description="Type de bloc (échéance ou attribution)"
)
date: _date = Field(..., description="Date associée au bloc")
text: str = Field(..., description="Texte du devoir (brut)")
html: str = Field(default="", description="Texte du devoir (HTML)")
class Lesson(BaseModel):
"""Représente un cours dans l'agenda Pronote."""
model_config = ConfigDict(frozen=True)
id: str = Field(..., description="UID stable du cours (normalisé)")
start: datetime = Field(..., description="Date/heure de début")
end: datetime = Field(..., description="Date/heure de fin")
subject: str = Field(..., description="Matière")
teachers: list[str] = Field(default_factory=list, description="Liste des professeurs")
rooms: list[str] = Field(default_factory=list, description="Liste des salles")
group: str | None = Field(None, description="Groupe (ex: Classe entière)")
status: LessonStatus = Field(default=LessonStatus.NORMAL, description="Statut du cours")
content: str | None = Field(None, description="Contenu pédagogique")
homework_blocks: list[HomeworkBlock] = Field(
default_factory=list,
description="Blocs de devoirs extraits de la description",
)
class SchoolEventKind(StrEnum):
"""Type d'événement scolaire."""
HOLIDAY = "holiday"
PUBLIC_HOLIDAY = "public_holiday"
class SchoolEvent(BaseModel):
"""Représente un événement scolaire (vacances, jours fériés)."""
model_config = ConfigDict(frozen=True)
kind: SchoolEventKind = Field(..., description="Type d'événement")
label: str = Field(..., description="Libellé (ex: Vacances de Noël)")
from_date: _date = Field(..., description="Date de début (inclusive)")
to_date: _date = Field(..., description="Date de fin (exclusive)")
class TheoreticalLesson(BaseModel):
"""Représente un cours dans l'agenda théorique."""
model_config = ConfigDict(frozen=True)
id: str = Field(..., description="Identifiant unique")
day_of_week: int = Field(..., description="Jour de la semaine (0=lundi, 6=dimanche)")
start_time: _time = Field(..., description="Heure de début")
end_time: _time = Field(..., description="Heure de fin")
subject: str = Field(..., description="Matière")
teachers: list[str] = Field(default_factory=list, description="Liste des professeurs")
rooms: list[str] = Field(default_factory=list, description="Liste des salles")

View File

@@ -0,0 +1,42 @@
"""Modèles de données pour le blog du collège (RSS) et les informations externes."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from pronote_sync.models.message import Message
class BlogArticle(BaseModel):
"""Représente un article du blog du collège."""
model_config = ConfigDict(frozen=True)
id: str = Field(..., description="GUID de l'article (clé de déduplication)")
title: str = Field(..., description="Titre de l'article")
url: str = Field(..., description="URL canonique de l'article")
published_at: datetime = Field(..., description="Date de publication (UTC)")
updated_at: datetime | None = Field(None, description="Date de dernière mise à jour")
category: str | None = Field(None, description="Catégorie de l'article")
author: str | None = Field(None, description="Auteur (si disponible)")
content_html: str = Field(..., description="Contenu HTML complet")
content_text: str = Field(..., description="Contenu en texte brut (pour XMPP)")
class ExternalInfo(BaseModel):
"""Agrège les informations externes (blog, messages Pronote) pour le message XMPP."""
blog_articles: list[BlogArticle] = Field(
default_factory=list,
description="Liste des nouveaux articles du blog",
)
pronote_messages: list[Message] = Field(
default_factory=list,
description="Liste des messages Pronote",
)
other_info: list[str] = Field(
default_factory=list,
description="Autres informations (extensible)",
)

View File

@@ -0,0 +1,40 @@
"""Modèles de données pour la comparaison d'agenda (diff)."""
from __future__ import annotations
from datetime import date
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field
from pronote_sync.models.agenda import Lesson, TheoreticalLesson
class AgendaChangeType(StrEnum):
"""Type de changement dans l'agenda."""
ADDED = "added"
REMOVED = "removed"
MODIFIED = "modified"
class AgendaChange(BaseModel):
"""Représente un changement entre l'agenda réel et l'agenda théorique."""
model_config = ConfigDict(frozen=True)
type: AgendaChangeType = Field(..., description="Type de changement")
lesson: Lesson | None = Field(None, description="Cours concerné (pour ADDED/MODIFIED)")
theoretical_lesson: TheoreticalLesson | None = Field(
None, description="Cours théorique concerné (pour REMOVED/MODIFIED)"
)
details: str = Field(default="", description="Détails du changement")
class AgendaDiff(BaseModel):
"""Représente les différences entre l'agenda réel et l'agenda théorique."""
model_config = ConfigDict(frozen=True)
target_date: date = Field(..., description="Date cible de la comparaison")
changes: list[AgendaChange] = Field(default_factory=list, description="Liste des changements")

View File

@@ -0,0 +1,21 @@
"""Modèle de données pour les devoirs Pronote."""
from __future__ import annotations
from datetime import date
from pydantic import BaseModel, ConfigDict, Field
class Homework(BaseModel):
"""Représente un devoir Pronote."""
model_config = ConfigDict(frozen=True)
id: str = Field(..., description="ID stable (hachage)")
subject: str = Field(..., description="Matière")
teachers: list[str] = Field(default_factory=list, description="Liste des professeurs")
assigned_on: date | None = Field(None, description="Date de distribution")
due_on: date = Field(..., description="Date d'échéance")
text: str = Field(..., description="Texte du devoir (brut)")
html: str = Field(default="", description="Texte du devoir (HTML)")

View File

@@ -0,0 +1,30 @@
"""Modèle de données pour les messages Pronote."""
from __future__ import annotations
from datetime import datetime
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field
class MessageType(StrEnum):
"""Type de message Pronote."""
DISCUSSION = "discussion"
INFORMATION = "information"
SURVEY = "survey"
class Message(BaseModel):
"""Représente un message ou une information Pronote."""
model_config = ConfigDict(frozen=True)
id: str = Field(..., description="Identifiant unique")
type: MessageType = Field(..., description="Type de message")
title: str = Field(..., description="Titre")
content: str = Field(..., description="Contenu")
author: str = Field(..., description="Auteur")
date: datetime = Field(..., description="Date de création")
read: bool = Field(False, description="Lu ou non")

View File

@@ -0,0 +1,26 @@
"""Modèle de données normalisées récupérées depuis Pronote."""
from __future__ import annotations
from datetime import date, datetime
from pydantic import BaseModel, Field
from pronote_sync.models.agenda import Lesson, SchoolEvent
from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message
class PronoteData(BaseModel):
"""Données normalisées récupérées depuis Pronote."""
lessons: list[Lesson] = Field(default_factory=list, description="Liste des cours")
homeworks: list[Homework] = Field(default_factory=list, description="Liste des devoirs")
school_events: list[SchoolEvent] = Field(
default_factory=list, description="Liste des événements scolaires"
)
messages: list[Message] = Field(
default_factory=list, description="Liste des messages/informations"
)
target_date: date = Field(..., description="Date cible (J+1 ou prochain jour scolaire)")
generated_at: datetime = Field(..., description="Date/heure de génération")

View File

@@ -0,0 +1,42 @@
"""Modèles de données pour la synchronisation CalDAV."""
from __future__ import annotations
from enum import StrEnum
from pydantic import BaseModel, Field
from pronote_sync.models.agenda import Lesson, SchoolEvent
from pronote_sync.models.homework import Homework
class CalDAVSyncStatus(StrEnum):
"""Statut de synchronisation CalDAV."""
SUCCESS = "success"
FAILED = "failed"
SKIPPED = "skipped"
class CalDAVSyncPlan(BaseModel):
"""Plan de synchronisation CalDAV."""
lessons_to_add: list[Lesson] = Field(default_factory=list)
lessons_to_update: list[Lesson] = Field(default_factory=list)
lessons_to_remove: list[str] = Field(default_factory=list, description="Liste d'UID")
homeworks_to_add: list[Homework] = Field(default_factory=list)
homeworks_to_update: list[Homework] = Field(default_factory=list)
homeworks_to_remove: list[str] = Field(default_factory=list, description="Liste d'UID")
school_events_to_add: list[SchoolEvent] = Field(default_factory=list)
school_events_to_update: list[SchoolEvent] = Field(default_factory=list)
school_events_to_remove: list[str] = Field(default_factory=list, description="Liste d'UID")
class CalDAVSyncResult(BaseModel):
"""Résultat d'une synchronisation CalDAV (mutable)."""
status: CalDAVSyncStatus = Field(..., description="Statut global")
added: int = Field(0, description="Nombre d'événements ajoutés")
updated: int = Field(0, description="Nombre d'événements mis à jour")
removed: int = Field(0, description="Nombre d'événements supprimés")
errors: list[str] = Field(default_factory=list, description="Liste des erreurs")

View File

@@ -0,0 +1,28 @@
"""Modèles de données pour la synthèse IA."""
from __future__ import annotations
from datetime import date
from pydantic import BaseModel, Field
from pronote_sync.models.agenda import SchoolEvent
from pronote_sync.models.diff import AgendaDiff
from pronote_sync.models.message import Message
class SynthesisInput(BaseModel):
"""Entrée pour la synthèse IA."""
agenda_diff: AgendaDiff | None = Field(None, description="Différences d'agenda")
messages: list[Message] = Field(default_factory=list, description="Messages importants")
school_events: list[SchoolEvent] = Field(
default_factory=list, description="Événements scolaires"
)
target_date: date = Field(..., description="Date cible")
class SynthesisResult(BaseModel):
"""Résultat de la synthèse IA."""
text: str | None = Field(None, description="Texte de la synthèse IA")

View File

@@ -0,0 +1,31 @@
"""Modèle de données pour le message XMPP final."""
from __future__ import annotations
from datetime import date
from pydantic import BaseModel, ConfigDict, Field
from pronote_sync.models.blog import ExternalInfo
from pronote_sync.models.diff import AgendaChange
from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message
class XmppMessage(BaseModel):
"""Représente le message final à envoyer par XMPP."""
model_config = ConfigDict(frozen=True)
target_date: date = Field(..., description="Date cible")
synthesis: str | None = Field(None, description="Synthèse IA (optionnelle, 3-5 phrases)")
homeworks: list[Homework] = Field(default_factory=list, description="Liste brute des devoirs")
changes: list[AgendaChange] = Field(
default_factory=list, description="Liste des changements d'agenda"
)
messages: list[Message] = Field(
default_factory=list, description="Liste des messages/informations"
)
external_info: ExternalInfo | None = Field(
None, description="Informations externes (blog, messages Pronote)"
)