"""Modèles Pydantic de parsing du fichier JSON de l'agenda théorique. Ce module définit les modèles de parsing utilisés pour lire le fichier JSON de l'agenda théorique : :class:`TheoreticalLessonEntry` pour une entrée de cours et :class:`TheoreticalAgendaFile` pour le fichier complet. Ces modèles sont distincts du modèle de domaine :class:`~pronote_sync.models.agenda.TheoreticalLesson` : ils restent proches du format JSON brut (heures au format ``HH:MM``) et servent uniquement à la désérialisation, la conversion vers le modèle de domaine étant réalisée ensuite par le fournisseur. """ from __future__ import annotations import re from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, model_validator _TIME_PATTERN = re.compile(r"^(?:[01]\d|2[0-3]):[0-5]\d$") class TheoreticalLessonEntry(BaseModel): """Représente une entrée de cours dans le fichier JSON de l'agenda théorique. Modèle figé (``frozen``) : les instances sont immuables après création. Le format des heures est validé (``HH:MM`` sur 24 heures, avec ``HH`` entre ``00`` et ``23`` et ``MM`` entre ``00`` et ``59``) ainsi que l'ordre des heures (fin postérieure au début). :param week: Type de semaine auquel s'applique le cours (``"all"``, ``"even"`` ou ``"odd"``). :param day_of_week: Jour de la semaine (0 = lundi, 6 = dimanche). :param start_time: Heure de début au format ``HH:MM`` sur 24 heures. :param end_time: Heure de fin au format ``HH:MM`` sur 24 heures. :param subject: Nom de la matière. :param teachers: Noms des professeurs. Tuple vide par défaut. :param rooms: Noms des salles. Tuple vide par défaut. :param id: Identifiant explicite optionnel. ``None`` par défaut ; en cas d'absence, le fournisseur en génère un. """ model_config = ConfigDict(frozen=True) week: Literal["all", "even", "odd"] = Field( ..., description="Type de semaine concerné (all, even ou odd)" ) day_of_week: int = Field( ..., ge=0, le=6, description="Jour de la semaine (0=lundi, 6=dimanche)" ) start_time: str = Field(..., description="Heure de début au format HH:MM") end_time: str = Field(..., description="Heure de fin au format HH:MM") subject: str = Field(..., description="Nom de la matière") teachers: tuple[str, ...] = Field(default=(), description="Noms des professeurs") rooms: tuple[str, ...] = Field(default=(), description="Noms des salles") id: str | None = Field( default=None, description="Identifiant explicite optionnel (None si absent)" ) @model_validator(mode="before") @classmethod def _validate_time_format(cls, data: Any) -> Any: """Valide le format ``HH:MM`` des heures de début et de fin. Les heures doivent être au format ``HH:MM`` sur 24 heures, avec ``HH`` entre ``00`` et ``23`` et ``MM`` entre ``00`` et ``59``. Cette validation précède :meth:`_validate_time_order`, dont la comparaison par ordre lexicographique n'est fiable que si le format est garanti. :param data: Données brutes transmises au modèle. :return: Les données brutes inchangées. :rtype: Any :raises ValueError: Si ``start_time`` ou ``end_time`` n'est pas au format ``HH:MM``. """ if not isinstance(data, dict): return data for field_name in ("start_time", "end_time"): if field_name not in data: continue value = data[field_name] if not isinstance(value, str) or _TIME_PATTERN.fullmatch(value) is None: raise ValueError( f"{field_name} doit être au format HH:MM (HH entre 00 et 23, MM entre 00 et 59)" ) return data @model_validator(mode="after") def _validate_time_order(self) -> TheoreticalLessonEntry: """Valide que l'heure de fin est postérieure à l'heure de début. La comparaison est effectuée sur les chaînes ``HH:MM`` de façon lexicographique ; elle n'est fiable que parce que :meth:`_validate_time_format` a déjà garanti le format à deux chiffres. :return: L'instance validée. :rtype: TheoreticalLessonEntry :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 class TheoreticalAgendaFile(BaseModel): """Représente le fichier JSON complet de l'agenda théorique. Modèle de parsing non figé : il sert uniquement à désérialiser le fichier JSON avant conversion vers les modèles de domaine. :param version: Version du schéma du fichier (vaut ``1``). :param lessons: Liste des entrées de cours du fichier. """ version: Literal[1] = Field(default=1, description="Version du schéma (1)") lessons: tuple[TheoreticalLessonEntry, ...] = Field( ..., description="Liste des entrées de cours" )