- AgendaSource : enum StrEnum (AUTO/ICAL/PRONOTEPY)
- PronoteFetcherProtocol : Protocol consommé par le pipeline
- PronoteFetcher : unifie iCal et pronotepy avec repli automatique
- AUTO : iCal d'abord, repli pronotepy, PipelineCriticalError si
les deux échouent
- ICAL/PRONOTEPY : source explicite, erreur redactée puis
PipelineCriticalError
- Messages et informations : toujours via pronotepy
- Avertissement quand le repli pronotepy retourne un résultat vide
après échec iCal (limite du mode dégradé)
Co-authored-by: opencode/coder <coder@agents.invalid>
296 lines
12 KiB
Python
296 lines
12 KiB
Python
"""Logique de repli unifiant les sources iCal et pronotepy.
|
|
|
|
Ce module fournit l'enum :class:`AgendaSource`, le protocole
|
|
:class:`PronoteFetcherProtocol` consommé par le pipeline ainsi que la
|
|
classe :class:`PronoteFetcher` qui sélectionne la source selon la
|
|
configuration (``PRONOTE_AGENDA_SOURCE`` / ``PRONOTE_HOMEWORK_SOURCE``)
|
|
avec repli automatique iCal → pronotepy en mode ``auto``. Les messages
|
|
et informations proviennent toujours de pronotepy. Toutes les erreurs
|
|
sont journalisées avec des secrets masqués via
|
|
:func:`~pronote_sync.utils.redaction.redact_exception`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import date, timedelta
|
|
from enum import StrEnum
|
|
from typing import Protocol
|
|
|
|
from pronote_sync.config.settings import Settings
|
|
from pronote_sync.errors import PipelineCriticalError
|
|
from pronote_sync.models.agenda import Lesson, SchoolEvent
|
|
from pronote_sync.models.homework import Homework
|
|
from pronote_sync.models.message import Message
|
|
from pronote_sync.sources.pronote.client import PronoteClientProtocol
|
|
from pronote_sync.sources.pronote.ical import collect_homeworks, fetch_ical, parse_ical
|
|
from pronote_sync.utils.redaction import redact_exception
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class AgendaSource(StrEnum):
|
|
"""Source configurée pour la récupération de l'agenda et des devoirs."""
|
|
|
|
AUTO = "auto"
|
|
ICAL = "ical"
|
|
PRONOTEPY = "pronotepy"
|
|
|
|
|
|
class PronoteFetcherProtocol(Protocol):
|
|
"""Interface de récupération des données Pronote consommée par le pipeline."""
|
|
|
|
def fetch_agenda(self) -> tuple[list[Lesson], list[SchoolEvent]]:
|
|
"""Récupère les cours et les événements scolaires.
|
|
|
|
:return: Tuple ``(cours, événements scolaires)``.
|
|
:rtype: tuple[list[Lesson], list[SchoolEvent]]
|
|
:raises PipelineCriticalError: Si toutes les sources configurées échouent.
|
|
"""
|
|
...
|
|
|
|
def fetch_homework(self, target_date: date) -> list[Homework]:
|
|
"""Récupère les devoirs pour la date cible.
|
|
|
|
:param target_date: Date cible pour laquelle collecter les devoirs.
|
|
:return: Liste des devoirs.
|
|
:rtype: list[Homework]
|
|
:raises PipelineCriticalError: Si toutes les sources configurées échouent.
|
|
"""
|
|
...
|
|
|
|
def fetch_messages(self) -> list[Message]:
|
|
"""Récupère les messages des discussions Pronote.
|
|
|
|
:return: Liste des messages.
|
|
:rtype: list[Message]
|
|
"""
|
|
...
|
|
|
|
def fetch_informations(self) -> list[Message]:
|
|
"""Récupère les informations et sondages Pronote.
|
|
|
|
:return: Liste des informations et sondages.
|
|
:rtype: list[Message]
|
|
"""
|
|
...
|
|
|
|
|
|
class PronoteFetcher:
|
|
"""Récupère les données Pronote via iCal ou pronotepy avec repli.
|
|
|
|
Unifie les sources iCal et pronotepy selon la source configurée
|
|
(``agenda_source`` / ``homework_source``) : en mode ``AUTO``, le flux
|
|
iCal est essayé en premier et pronotepy sert de repli. Les messages et
|
|
informations proviennent toujours de pronotepy.
|
|
"""
|
|
|
|
def __init__(self, settings: Settings, pronote_client: PronoteClientProtocol) -> None:
|
|
"""Initialise le fetcher sans récupérer aucune donnée.
|
|
|
|
:param settings: Configuration racine du pipeline (Pronote, fenêtre de synchronisation).
|
|
:param pronote_client: Client pronotepy utilisé pour les sources pronotepy.
|
|
"""
|
|
self._settings: Settings = settings
|
|
self._pronote_client: PronoteClientProtocol = pronote_client
|
|
|
|
def _fetch_window(self) -> tuple[date, date]:
|
|
"""Calcule la fenêtre de synchronisation autour de la date du jour.
|
|
|
|
:return: Tuple ``(date de début, date de fin)`` de la fenêtre.
|
|
:rtype: tuple[date, date]
|
|
"""
|
|
today = date.today()
|
|
start = today - timedelta(days=self._settings.app.sync_past_days)
|
|
end = today + timedelta(days=self._settings.app.sync_future_days)
|
|
return start, end
|
|
|
|
def _fetch_agenda_ical(self) -> tuple[list[Lesson], list[SchoolEvent]]:
|
|
"""Récupère l'agenda depuis le flux iCal.
|
|
|
|
:return: Tuple ``(cours, événements scolaires)``.
|
|
:rtype: tuple[list[Lesson], list[SchoolEvent]]
|
|
:raises ValueError: Si ``ical_url`` n'est pas configuré ou si le flux est invalide.
|
|
:raises OSError: Si le fichier iCal local est illisible.
|
|
:raises requests.RequestException: Si la récupération HTTP échoue.
|
|
"""
|
|
ical_url = self._settings.pronote.ical_url
|
|
if ical_url is None:
|
|
raise ValueError("PRONOTE_ICAL_URL est requis pour la source iCal")
|
|
raw_ical = fetch_ical(ical_url.get_secret_value())
|
|
lessons, _, school_events = parse_ical(raw_ical)
|
|
return lessons, school_events
|
|
|
|
def _fetch_agenda_pronotepy(self) -> tuple[list[Lesson], list[SchoolEvent]]:
|
|
"""Récupère l'agenda depuis pronotepy.
|
|
|
|
Les événements scolaires (vacances, jours fériés) ne sont pas
|
|
fournis par pronotepy : la liste retournée est vide.
|
|
|
|
:return: Tuple ``(cours, événements scolaires)``.
|
|
:rtype: tuple[list[Lesson], list[SchoolEvent]]
|
|
"""
|
|
start, end = self._fetch_window()
|
|
lessons, _ = self._pronote_client.get_agenda_fallback(start, end)
|
|
return lessons, []
|
|
|
|
def fetch_agenda(self) -> tuple[list[Lesson], list[SchoolEvent]]:
|
|
"""Récupère les cours et les événements scolaires selon la source configurée.
|
|
|
|
En mode ``AUTO``, iCal est essayé en premier et pronotepy sert de
|
|
repli ; si les deux sources échouent, une erreur critique est levée.
|
|
|
|
:return: Tuple ``(cours, événements scolaires)``.
|
|
:rtype: tuple[list[Lesson], list[SchoolEvent]]
|
|
:raises PipelineCriticalError: Si toutes les sources configurées échouent.
|
|
"""
|
|
source = AgendaSource(self._settings.pronote.agenda_source)
|
|
if source is AgendaSource.ICAL:
|
|
try:
|
|
return self._fetch_agenda_ical()
|
|
except Exception as exc:
|
|
logger.error(
|
|
"Échec de la récupération iCal pour l'agenda : %s",
|
|
redact_exception(exc),
|
|
)
|
|
raise PipelineCriticalError(
|
|
"Impossible de récupérer l'agenda : la source iCal a échoué"
|
|
) from exc
|
|
if source is AgendaSource.PRONOTEPY:
|
|
try:
|
|
return self._fetch_agenda_pronotepy()
|
|
except Exception as exc:
|
|
logger.error(
|
|
"Échec de la récupération pronotepy pour l'agenda : %s",
|
|
redact_exception(exc),
|
|
)
|
|
raise PipelineCriticalError(
|
|
"Impossible de récupérer l'agenda : la source pronotepy a échoué"
|
|
) from exc
|
|
|
|
# Mode AUTO : essayer iCal d'abord, puis replier sur pronotepy.
|
|
try:
|
|
return self._fetch_agenda_ical()
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Échec de la récupération iCal pour l'agenda : %s",
|
|
redact_exception(exc),
|
|
)
|
|
logger.info("Repli sur pronotepy pour l'agenda.")
|
|
try:
|
|
lessons, school_events = self._fetch_agenda_pronotepy()
|
|
except Exception as exc:
|
|
logger.error(
|
|
"Échec de la récupération pronotepy pour l'agenda : %s",
|
|
redact_exception(exc),
|
|
)
|
|
raise PipelineCriticalError(
|
|
"Impossible de récupérer l'agenda : les sources iCal et pronotepy ont échoué"
|
|
) from exc
|
|
if not lessons:
|
|
logger.warning(
|
|
"Le repli pronotepy pour l'agenda a retourné un résultat vide après l'échec "
|
|
"d'iCal : impossible de distinguer une absence de cours d'un échec silencieux."
|
|
)
|
|
return lessons, school_events
|
|
|
|
def _fetch_homework_ical(self, target_date: date) -> list[Homework]:
|
|
"""Récupère les devoirs depuis le flux iCal pour la date cible.
|
|
|
|
:param target_date: Date cible pour laquelle collecter les devoirs.
|
|
:return: Liste des devoirs.
|
|
:rtype: list[Homework]
|
|
:raises ValueError: Si ``ical_url`` n'est pas configuré ou si le flux est invalide.
|
|
:raises OSError: Si le fichier iCal local est illisible.
|
|
:raises requests.RequestException: Si la récupération HTTP échoue.
|
|
"""
|
|
lessons, _ = self._fetch_agenda_ical()
|
|
return collect_homeworks(lessons, target_date)
|
|
|
|
def _fetch_homework_pronotepy(self) -> list[Homework]:
|
|
"""Récupère les devoirs depuis pronotepy.
|
|
|
|
:return: Liste des devoirs.
|
|
:rtype: list[Homework]
|
|
"""
|
|
start, end = self._fetch_window()
|
|
_, homeworks = self._pronote_client.get_agenda_fallback(start, end)
|
|
return homeworks
|
|
|
|
def fetch_homework(self, target_date: date) -> list[Homework]:
|
|
"""Récupère les devoirs selon la source configurée.
|
|
|
|
En mode ``AUTO``, iCal est essayé en premier et pronotepy sert de
|
|
repli ; si les deux sources échouent, une erreur critique est levée.
|
|
|
|
:param target_date: Date cible pour laquelle collecter les devoirs.
|
|
:return: Liste des devoirs.
|
|
:rtype: list[Homework]
|
|
:raises PipelineCriticalError: Si toutes les sources configurées échouent.
|
|
"""
|
|
source = AgendaSource(self._settings.pronote.homework_source)
|
|
if source is AgendaSource.ICAL:
|
|
try:
|
|
return self._fetch_homework_ical(target_date)
|
|
except Exception as exc:
|
|
logger.error(
|
|
"Échec de la récupération iCal pour les devoirs : %s",
|
|
redact_exception(exc),
|
|
)
|
|
raise PipelineCriticalError(
|
|
"Impossible de récupérer les devoirs : la source iCal a échoué"
|
|
) from exc
|
|
if source is AgendaSource.PRONOTEPY:
|
|
try:
|
|
return self._fetch_homework_pronotepy()
|
|
except Exception as exc:
|
|
logger.error(
|
|
"Échec de la récupération pronotepy pour les devoirs : %s",
|
|
redact_exception(exc),
|
|
)
|
|
raise PipelineCriticalError(
|
|
"Impossible de récupérer les devoirs : la source pronotepy a échoué"
|
|
) from exc
|
|
|
|
# Mode AUTO : essayer iCal d'abord, puis replier sur pronotepy.
|
|
try:
|
|
return self._fetch_homework_ical(target_date)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Échec de la récupération iCal pour les devoirs : %s",
|
|
redact_exception(exc),
|
|
)
|
|
logger.info("Repli sur pronotepy pour les devoirs.")
|
|
try:
|
|
homeworks = self._fetch_homework_pronotepy()
|
|
except Exception as exc:
|
|
logger.error(
|
|
"Échec de la récupération pronotepy pour les devoirs : %s",
|
|
redact_exception(exc),
|
|
)
|
|
raise PipelineCriticalError(
|
|
"Impossible de récupérer les devoirs : les sources iCal et pronotepy ont échoué"
|
|
) from exc
|
|
if not homeworks:
|
|
logger.warning(
|
|
"Le repli pronotepy pour les devoirs a retourné un résultat vide après l'échec "
|
|
"d'iCal : impossible de distinguer une absence de devoirs d'un échec silencieux."
|
|
)
|
|
return homeworks
|
|
|
|
def fetch_messages(self) -> list[Message]:
|
|
"""Récupère les messages des discussions Pronote (toujours via pronotepy).
|
|
|
|
:return: Liste des messages.
|
|
:rtype: list[Message]
|
|
"""
|
|
return self._pronote_client.get_messages()
|
|
|
|
def fetch_informations(self) -> list[Message]:
|
|
"""Récupère les informations et sondages Pronote (toujours via pronotepy).
|
|
|
|
:return: Liste des informations et sondages.
|
|
:rtype: list[Message]
|
|
"""
|
|
return self._pronote_client.get_informations()
|