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.
- [ ] Créer `models/agenda.py` : `Status`, `LessonStatus`, `HomeworkBlock`, `Lesson` (frozen), `SchoolEventKind`, `SchoolEvent`, `TheoreticalLesson`.
- [ ] Créer `models/homework.py` : `Homework` (frozen, `id` = hachage stable).
- [ ] Créer `models/message.py` : `MessageType`, `Message` (frozen).
- [ ] Créer `models/diff.py` : `AgendaChangeType`, `AgendaChange` (frozen), `AgendaDiff` (frozen).
- [ ] Créer `models/pronote.py` : `PronoteData` (mutable).
- [ ] Créer `models/sync.py` : `CalDAVSyncStatus`, `CalDAVSyncPlan`, `CalDAVSyncResult` (mutable).
- [ ] Créer `models/synthesis.py` : `SynthesisInput`, `SynthesisResult`.
- [ ] Créer `models/blog.py` : `BlogArticle` (frozen), `ExternalInfo` (§5 bis.5/6).
- [ ] Créer `models/xmpp.py` : `XmppMessage` (frozen) intégrant `external_info`.
- [ ] 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] Créer `models/agenda.py` : `Status`, `LessonStatus`, `HomeworkBlock`, `Lesson` (frozen), `SchoolEventKind`, `SchoolEvent`, `TheoreticalLesson`.
- [x] Créer `models/homework.py` : `Homework` (frozen, `id` = hachage stable).
- [x] Créer `models/message.py` : `MessageType`, `Message` (frozen).
- [x] Créer `models/diff.py` : `AgendaChangeType`, `AgendaChange` (frozen), `AgendaDiff` (frozen).
- [x] Créer `models/pronote.py` : `PronoteData` (mutable).
- [x] Créer `models/sync.py` : `CalDAVSyncStatus`, `CalDAVSyncPlan`, `CalDAVSyncResult` (mutable).
- [x] Créer `models/synthesis.py` : `SynthesisInput`, `SynthesisResult`.
- [x] Créer `models/blog.py` : `BlogArticle` (frozen), `ExternalInfo` (§5 bis.5/6).
- [x] Créer `models/xmpp.py` : `XmppMessage` (frozen) intégrant `external_info`.
- [x] Créer `models/__init__.py` ré-exportant tous les modèles.
- [x] Valider la sérialisation JSON (datetime/date en ISO) pour chaque modèle.
### Critères d'acceptation
- 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)"
)