Files
Antoine Van Elstraete 292aef4f5b fix(M3): immuabilité et validation des modèles — correction des écarts FIXME_M3
Immuabilité :
- Collections des modèles frozen : list → tuple (Lesson, TheoreticalLesson,
  SchoolEvent, AgendaDiff, Homework, XmppMessage, ExternalInfo)
- Gel de HomeworkBlock et ExternalInfo (sous-modèles imbriqués dans contrats)
- Valeurs par défaut immuables : Field(default=(), ...)

Validation de domaine :
- TheoreticalLesson.day_of_week : Field(ge=0, le=6)
- Lesson/TheoreticalLesson : start < end (validateur de modèle)
- SchoolEvent : from_date <= to_date
- AgendaChange : cohérence type/payload (ADDED/MODIFIED → lesson,
  REMOVED → theoretical_lesson)
- CalDAVSyncResult : compteurs Field(ge=0) sur added/updated/removed

Tests : 66 tests (construction/sérialisation, immuabilité, invariants)
couvrant le gel, la mutabilité légitime, et les cas invalides par invariant.

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-06 12:36:27 +02:00

131 lines
4.6 KiB
Python

"""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, model_validator
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."""
model_config = ConfigDict(frozen=True)
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: tuple[str, ...] = Field(default=(), description="Liste des professeurs")
rooms: tuple[str, ...] = Field(default=(), 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: tuple[HomeworkBlock, ...] = Field(
default=(),
description="Blocs de devoirs extraits de la description",
)
@model_validator(mode="after")
def _validate_time_order(self) -> Lesson:
"""Valide que l'heure de fin est postérieure à l'heure de début.
:return: L'instance validée.
:rtype: Lesson
:raises ValueError: Si ``end`` n'est pas postérieur à ``start``.
"""
if self.end <= self.start:
raise ValueError("end doit être postérieur à start")
return self
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)")
@model_validator(mode="after")
def _validate_date_order(self) -> SchoolEvent:
"""Valide que la date de fin est postérieure ou égale à la date de début.
:return: L'instance validée.
:rtype: SchoolEvent
:raises ValueError: Si ``to_date`` est antérieur à ``from_date``.
"""
if self.to_date < self.from_date:
raise ValueError("to_date doit être postérieur ou égal à from_date")
return self
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(
..., ge=0, le=6, 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: tuple[str, ...] = Field(default=(), description="Liste des professeurs")
rooms: tuple[str, ...] = Field(default=(), description="Liste des salles")
@model_validator(mode="after")
def _validate_time_order(self) -> TheoreticalLesson:
"""Valide que l'heure de fin est postérieure à l'heure de début.
:return: L'instance validée.
:rtype: TheoreticalLesson
:raises ValueError: Si ``end_time`` n'est pas postérieur à ``start_time``.
"""
if self.end_time <= self.start_time:
raise ValueError("end_time doit être postérieur à start_time")
return self