"""Fournisseur d'agenda théorique basé sur un fichier JSON. Ce module fournit :class:`JsonTheoreticalAgendaProvider`, une implémentation de :class:`~pronote_sync.sources.theoretical.provider.TheoreticalAgendaProvider` qui charge un fichier JSON d'emploi du temps théorique et expose les cours applicables par date ou plage de dates. Le filtrage tient compte du jour de la semaine, de la parité de semaine (``even``/``odd``) et du calendrier des vacances scolaires. """ from __future__ import annotations import logging from datetime import date, time, timedelta from pathlib import Path from typing import Literal from pronote_sync.errors import PronoteSyncError from pronote_sync.models.agenda import TheoreticalLesson from pronote_sync.sources.theoretical.holidays import SchoolHolidayCalendar from pronote_sync.sources.theoretical.model import TheoreticalAgendaFile, TheoreticalLessonEntry from pronote_sync.sources.theoretical.parity import WeekParityService from pronote_sync.utils.redaction import redact_exception, redact_secrets from pronote_sync.utils.text import normalize_subject as normalize_subject logger = logging.getLogger(__name__) def _generate_id(entry: TheoreticalLessonEntry) -> str: """Génère un identifiant déterministe pour une entrée de cours. L'identifiant intègre le type de semaine (``all``, ``even`` ou ``odd``), le jour de la semaine, le créneau horaire et la matière : deux leçons occupant le même créneau dans des semaines différentes (ou le même créneau un autre jour) obtiennent ainsi des identifiants distincts. :param entry: Entrée de cours du fichier JSON. :return: Identifiant déterministe unique. :rtype: str """ subject_slug = normalize_subject(entry.subject).replace(" ", "-") return f"theoretical:{entry.week}:{entry.day_of_week}:{entry.start_time}-{entry.end_time}:{subject_slug}" class JsonTheoreticalAgendaProvider: """Fournisseur d'agenda théorique basé sur un fichier JSON. Charge un fichier JSON d'emploi du temps théorique au format défini par :class:`~pronote_sync.sources.theoretical.model.TheoreticalAgendaFile` et expose les cours théoriques pour une date ou une plage de dates. Les cours peuvent être restreints à une parité de semaine (paire/impaire) via :class:`~pronote_sync.sources.theoretical.parity.WeekParityService` et exclus pendant les vacances scolaires via :class:`~pronote_sync.sources.theoretical.holidays.SchoolHolidayCalendar`. :param file_path: Chemin vers le fichier JSON de l'agenda théorique. :param parity_service: Service optionnel de calcul de la parité de semaine. :param holiday_calendar: Calendrier optionnel des vacances scolaires. :raises PronoteSyncError: Si le fichier ne peut être lu ou analysé, si des leçons à semaine paire/impaire sont présentes sans ancre de parité, ou si plusieurs leçons partagent le même identifiant (explicite ou généré). """ def __init__( self, file_path: str, parity_service: WeekParityService | None = None, holiday_calendar: SchoolHolidayCalendar | None = None, ) -> None: """Initialise le fournisseur en chargeant et analysant le fichier JSON. Le fichier est lu et analysé immédiatement. Toute erreur de lecture, de décodage JSON ou de validation est journalisée (chemin et exception expurgés) puis remontée sous forme de :class:`PronoteSyncError`. Si des leçons à semaine paire/impaire sont présentes alors qu'aucun service de parité n'est configuré, une :class:`PronoteSyncError` est également levée. :param file_path: Chemin vers le fichier JSON de l'agenda théorique. :param parity_service: Service optionnel de calcul de la parité de semaine. :param holiday_calendar: Calendrier optionnel des vacances scolaires. :raises PronoteSyncError: Si le fichier est introuvable, invalide, nécessite une ancre de parité non configurée ou contient plusieurs leçons partageant le même identifiant (explicite ou généré). """ self._file_path: str = file_path self._parity_service: WeekParityService | None = parity_service self._holiday_calendar: SchoolHolidayCalendar | None = holiday_calendar try: content = Path(file_path).read_text(encoding="utf-8") parsed = TheoreticalAgendaFile.model_validate_json(content) except Exception as exc: logger.error( "Fichier d'agenda théorique invalide %s : %s.", redact_secrets(str(file_path)), redact_exception(exc), ) raise PronoteSyncError( f"Le fichier d'agenda théorique est invalide : {redact_secrets(str(file_path))}" ) from None self._lessons: tuple[TheoreticalLessonEntry, ...] = parsed.lessons if self._parity_service is None and any( entry.week in ("even", "odd") for entry in self._lessons ): raise PronoteSyncError( "L'agenda théorique contient des leçons à semaine paire/impaire " "mais aucune ancre de parité n'est configurée " "(THEORETICAL_WEEK_ANCHOR_DATE et THEORETICAL_WEEK_ANCHOR_TYPE)" ) from None seen_ids: set[str] = set() for entry in self._lessons: effective_id = entry.id if entry.id is not None else _generate_id(entry) if effective_id in seen_ids: raise PronoteSyncError( f"Conflit d'identifiant dans l'agenda théorique : " f"l'identifiant '{redact_secrets(effective_id)}' est utilisé par plusieurs leçons. " f"Fournissez des identifiants explicites uniques." ) from None seen_ids.add(effective_id) def get_lessons(self, target_date: date) -> list[TheoreticalLesson]: """Retourne les cours théoriques applicables à la date donnée. Si un calendrier de vacances est configuré et que la date tombe pendant une période de vacances, la liste retournée est vide. La parité de la semaine est déterminée via le service de parité lorsqu'il est configuré ; sinon seuls les cours de type ``all`` sont conservés. Les entrées sont ensuite filtrées par jour de la semaine, converties en :class:`~pronote_sync.models.agenda.TheoreticalLesson` et triées par identifiant. :param target_date: Date cible. :return: Liste des cours théoriques triée par identifiant. :rtype: list[TheoreticalLesson] """ if self._holiday_calendar is not None and self._holiday_calendar.is_holiday(target_date): return [] week_parity: Literal["all", "even", "odd"] if self._parity_service is not None: week_parity = self._parity_service.parity_for(target_date) else: week_parity = "all" lessons: list[TheoreticalLesson] = [] for entry in self._lessons: if entry.week != "all" and entry.week != week_parity: continue if entry.day_of_week != target_date.weekday(): continue lesson_id = entry.id if entry.id is not None else _generate_id(entry) lessons.append( TheoreticalLesson( id=lesson_id, day_of_week=entry.day_of_week, start_time=time.fromisoformat(entry.start_time), end_time=time.fromisoformat(entry.end_time), subject=entry.subject, teachers=entry.teachers, rooms=entry.rooms, ) ) return sorted(lessons, key=lambda lesson: lesson.id) def get_lessons_for_range(self, start_date: date, end_date: date) -> list[TheoreticalLesson]: """Retourne les cours théoriques pour une plage de dates (inclusives). Chaque date de la plage, bornes incluses, est évaluée via :meth:`get_lessons`. Les cours sont dédupliqués par identifiant : pour un identifiant donné, la dernière occurrence (date la plus récente) écrase la précédente. Si ``start_date`` est postérieure à ``end_date``, la liste retournée est vide. :param start_date: Date de début (inclusive). :param end_date: Date de fin (inclusive). :return: Liste des cours théoriques triée par identifiant. :rtype: list[TheoreticalLesson] """ seen: dict[str, TheoreticalLesson] = {} current_date = start_date while current_date <= end_date: for lesson in self.get_lessons(current_date): seen[lesson.id] = lesson current_date += timedelta(days=1) return sorted(seen.values(), key=lambda lesson: lesson.id)