From 2e7dfe6e469ec0e2d1231cbb19e6e91357458a52 Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Sat, 5 Sep 2026 23:21:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(M3):=20mod=C3=A8les=20de=20donn=C3=A9es=20?= =?UTF-8?q?Pydantic=20(22=20mod=C3=A8les,=2010=20modules)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- TODO.md | 22 ++++---- pronote_sync/models/__init__.py | 44 ++++++++++++++++ pronote_sync/models/agenda.py | 90 ++++++++++++++++++++++++++++++++ pronote_sync/models/blog.py | 42 +++++++++++++++ pronote_sync/models/diff.py | 40 ++++++++++++++ pronote_sync/models/homework.py | 21 ++++++++ pronote_sync/models/message.py | 30 +++++++++++ pronote_sync/models/pronote.py | 26 +++++++++ pronote_sync/models/sync.py | 42 +++++++++++++++ pronote_sync/models/synthesis.py | 28 ++++++++++ pronote_sync/models/xmpp.py | 31 +++++++++++ 11 files changed, 405 insertions(+), 11 deletions(-) create mode 100644 pronote_sync/models/agenda.py create mode 100644 pronote_sync/models/blog.py create mode 100644 pronote_sync/models/diff.py create mode 100644 pronote_sync/models/homework.py create mode 100644 pronote_sync/models/message.py create mode 100644 pronote_sync/models/pronote.py create mode 100644 pronote_sync/models/sync.py create mode 100644 pronote_sync/models/synthesis.py create mode 100644 pronote_sync/models/xmpp.py diff --git a/TODO.md b/TODO.md index 28b8178..290088c 100644 --- a/TODO.md +++ b/TODO.md @@ -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. diff --git a/pronote_sync/models/__init__.py b/pronote_sync/models/__init__.py index e69de29..9b78779 100644 --- a/pronote_sync/models/__init__.py +++ b/pronote_sync/models/__init__.py @@ -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", +] diff --git a/pronote_sync/models/agenda.py b/pronote_sync/models/agenda.py new file mode 100644 index 0000000..7f2e08f --- /dev/null +++ b/pronote_sync/models/agenda.py @@ -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") diff --git a/pronote_sync/models/blog.py b/pronote_sync/models/blog.py new file mode 100644 index 0000000..b4d6db8 --- /dev/null +++ b/pronote_sync/models/blog.py @@ -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)", + ) diff --git a/pronote_sync/models/diff.py b/pronote_sync/models/diff.py new file mode 100644 index 0000000..e508eb8 --- /dev/null +++ b/pronote_sync/models/diff.py @@ -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") diff --git a/pronote_sync/models/homework.py b/pronote_sync/models/homework.py new file mode 100644 index 0000000..eeb96a0 --- /dev/null +++ b/pronote_sync/models/homework.py @@ -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)") diff --git a/pronote_sync/models/message.py b/pronote_sync/models/message.py new file mode 100644 index 0000000..26e79d8 --- /dev/null +++ b/pronote_sync/models/message.py @@ -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") diff --git a/pronote_sync/models/pronote.py b/pronote_sync/models/pronote.py new file mode 100644 index 0000000..a373f8d --- /dev/null +++ b/pronote_sync/models/pronote.py @@ -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") diff --git a/pronote_sync/models/sync.py b/pronote_sync/models/sync.py new file mode 100644 index 0000000..d74fc90 --- /dev/null +++ b/pronote_sync/models/sync.py @@ -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") diff --git a/pronote_sync/models/synthesis.py b/pronote_sync/models/synthesis.py new file mode 100644 index 0000000..ba4240d --- /dev/null +++ b/pronote_sync/models/synthesis.py @@ -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") diff --git a/pronote_sync/models/xmpp.py b/pronote_sync/models/xmpp.py new file mode 100644 index 0000000..86b5bb2 --- /dev/null +++ b/pronote_sync/models/xmpp.py @@ -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)" + )