fix(M4): pronote_url + account_type et parsing iCal (STATUS:CANCELLED + blocs multi-dates)
Configuration (D1+D3) : - PronoteSettings : ajout pronote_url (str | None) et account_type (Literal student/parent, défaut parent) Parsing iCal (Fix 4+7) : - parse_ical : détection STATUS:CANCELLED en plus de CATEGORIES - parse_body : list[tuple[date, str]] au lieu de dict[date, str] pour préserver les blocs multiples à la même date - parse_homework_blocks : adapté aux listes Co-authored-by: opencode/coder <coder@agents.invalid>
This commit is contained in:
@@ -3,11 +3,14 @@
|
||||
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
|
||||
configuration (``PRONOTE_AGENDA_SOURCE`` / ``PRONOTE_HOMEWORK_SOURCE``).
|
||||
La logique applique un repli **unique** : une source primaire est
|
||||
essayée en premier et, en cas d'échec, une seule source de repli (jamais
|
||||
réciproque ni itératif) est essayée si elle est configurée. 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`.
|
||||
:func:`~pronote_sync.utils.redaction.redact_exception` ; les exceptions
|
||||
d'origine ne sont jamais chaînées (``from None``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -15,7 +18,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from enum import StrEnum
|
||||
from typing import Protocol
|
||||
from typing import Literal, Protocol
|
||||
|
||||
from pronote_sync.config.settings import Settings
|
||||
from pronote_sync.errors import PipelineCriticalError
|
||||
@@ -28,6 +31,8 @@ from pronote_sync.utils.redaction import redact_exception
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SourceName = Literal["ical", "pronotepy"]
|
||||
|
||||
|
||||
class AgendaSource(StrEnum):
|
||||
"""Source configurée pour la récupération de l'agenda et des devoirs."""
|
||||
@@ -77,12 +82,13 @@ class PronoteFetcherProtocol(Protocol):
|
||||
|
||||
|
||||
class PronoteFetcher:
|
||||
"""Récupère les données Pronote via iCal ou pronotepy avec repli.
|
||||
"""Récupère les données Pronote via iCal ou pronotepy avec repli unique.
|
||||
|
||||
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.
|
||||
(``agenda_source`` / ``homework_source``) : la source primaire est
|
||||
essayée en premier et, si elle échoue, une seule source de repli est
|
||||
essayée lorsqu'elle est configurée. Les messages et informations
|
||||
proviennent toujours de pronotepy.
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Settings, pronote_client: PronoteClientProtocol) -> None:
|
||||
@@ -105,6 +111,29 @@ class PronoteFetcher:
|
||||
end = today + timedelta(days=self._settings.app.sync_future_days)
|
||||
return start, end
|
||||
|
||||
def _is_ical_configured(self) -> bool:
|
||||
"""Vérifie que la source iCal est configurée.
|
||||
|
||||
:return: ``True`` si ``ical_url`` est défini, ``False`` sinon.
|
||||
:rtype: bool
|
||||
"""
|
||||
return self._settings.pronote.ical_url is not None
|
||||
|
||||
def _is_pronotepy_configured(self) -> bool:
|
||||
"""Vérifie que la source pronotepy est entièrement configurée.
|
||||
|
||||
:return: ``True`` si ``pronote_url``, ``username``, ``password``
|
||||
et ``ent`` sont tous définis, ``False`` sinon.
|
||||
:rtype: bool
|
||||
"""
|
||||
pronote = self._settings.pronote
|
||||
return (
|
||||
pronote.pronote_url is not None
|
||||
and pronote.username is not None
|
||||
and pronote.password is not None
|
||||
and pronote.ent is not None
|
||||
)
|
||||
|
||||
def _fetch_agenda_ical(self) -> tuple[list[Lesson], list[SchoolEvent]]:
|
||||
"""Récupère l'agenda depuis le flux iCal.
|
||||
|
||||
@@ -129,70 +158,101 @@ class PronoteFetcher:
|
||||
|
||||
:return: Tuple ``(cours, événements scolaires)``.
|
||||
:rtype: tuple[list[Lesson], list[SchoolEvent]]
|
||||
:raises pronotepy.PronoteAPIError: Si l'API Pronote échoue.
|
||||
:raises ValueError: Si la configuration ou l'ENT est invalide.
|
||||
:raises requests.RequestException: Si une requête réseau échoue.
|
||||
:raises ConnectionError: Si la connexion réseau échoue.
|
||||
:raises TimeoutError: Si la requête réseau expire.
|
||||
"""
|
||||
start, end = self._fetch_window()
|
||||
lessons, _ = self._pronote_client.get_agenda_fallback(start, end)
|
||||
lessons = self._pronote_client.get_lessons(start, end)
|
||||
return lessons, []
|
||||
|
||||
def _agenda_sources(self) -> tuple[_SourceName, _SourceName | None]:
|
||||
"""Sélectionne la source primaire et le repli unique pour l'agenda.
|
||||
|
||||
En mode ``AUTO``, iCal est primaire si ``ical_url`` est configuré
|
||||
(repli pronotepy si la configuration pronotepy est complète),
|
||||
sinon pronotepy sans repli. Les modes explicites ``ICAL`` et
|
||||
``PRONOTEPY`` désignent la source primaire et tentent l'autre
|
||||
source en repli unique si elle est configurée.
|
||||
|
||||
:return: Tuple ``(source primaire, source de repli ou ``None``)``.
|
||||
:rtype: tuple[_SourceName, _SourceName | None]
|
||||
:raises PipelineCriticalError: Si aucune source n'est configurée en mode ``AUTO``.
|
||||
"""
|
||||
source = AgendaSource(self._settings.pronote.agenda_source)
|
||||
if source is AgendaSource.ICAL:
|
||||
return "ical", "pronotepy" if self._is_pronotepy_configured() else None
|
||||
if source is AgendaSource.PRONOTEPY:
|
||||
return "pronotepy", "ical" if self._is_ical_configured() else None
|
||||
if self._is_ical_configured():
|
||||
return "ical", "pronotepy" if self._is_pronotepy_configured() else None
|
||||
if self._is_pronotepy_configured():
|
||||
return "pronotepy", None
|
||||
raise PipelineCriticalError(
|
||||
"Impossible de récupérer l'agenda : ni la source iCal ni pronotepy n'est configurée"
|
||||
) from None
|
||||
|
||||
def _fetch_agenda_source(self, name: _SourceName) -> tuple[list[Lesson], list[SchoolEvent]]:
|
||||
"""Récupère l'agenda depuis la source nommée.
|
||||
|
||||
:param name: Nom de la source (``"ical"`` ou ``"pronotepy"``).
|
||||
:return: Tuple ``(cours, événements scolaires)``.
|
||||
:rtype: tuple[list[Lesson], list[SchoolEvent]]
|
||||
"""
|
||||
if name == "ical":
|
||||
return self._fetch_agenda_ical()
|
||||
return self._fetch_agenda_pronotepy()
|
||||
|
||||
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.
|
||||
La source primaire est essayée en premier ; si elle échoue, la
|
||||
source de repli unique (l'autre source, si configurée) est
|
||||
essayée. Si la source primaire et le repli échouent — ou si
|
||||
aucune source n'est configurée en mode ``AUTO`` — 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.
|
||||
:raises PipelineCriticalError: Si toutes les sources tenté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.
|
||||
primary, fallback = self._agenda_sources()
|
||||
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()
|
||||
return self._fetch_agenda_source(primary)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Échec de la récupération pronotepy pour l'agenda : %s",
|
||||
"Échec de la récupération %s pour l'agenda : %s",
|
||||
primary,
|
||||
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
|
||||
if fallback is None:
|
||||
raise PipelineCriticalError(
|
||||
f"Impossible de récupérer l'agenda : la source {primary} a échoué"
|
||||
) from None
|
||||
logger.info("Repli sur %s pour l'agenda.", fallback)
|
||||
try:
|
||||
lessons, school_events = self._fetch_agenda_source(fallback)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Échec de la récupération %s pour l'agenda : %s",
|
||||
fallback,
|
||||
redact_exception(exc),
|
||||
)
|
||||
raise PipelineCriticalError(
|
||||
f"Impossible de récupérer l'agenda : les sources {primary}"
|
||||
f" et {fallback} ont échoué"
|
||||
) from None
|
||||
if not lessons:
|
||||
logger.warning(
|
||||
"Le repli %s pour l'agenda a retourné un résultat vide après l'échec "
|
||||
"de %s : impossible de distinguer une absence de cours d'un échec "
|
||||
"silencieux.",
|
||||
fallback,
|
||||
primary,
|
||||
)
|
||||
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.
|
||||
@@ -207,76 +267,112 @@ class PronoteFetcher:
|
||||
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.
|
||||
def _fetch_homework_pronotepy(self, target_date: date) -> list[Homework]:
|
||||
"""Récupère les devoirs depuis pronotepy pour la date cible.
|
||||
|
||||
: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.
|
||||
Les devoirs sont filtrés sur la date d'échéance : seuls ceux dont
|
||||
``due_on`` correspond à ``target_date`` sont conservés.
|
||||
|
||||
: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.
|
||||
:raises pronotepy.PronoteAPIError: Si l'API Pronote échoue.
|
||||
:raises ValueError: Si la configuration ou l'ENT est invalide.
|
||||
:raises requests.RequestException: Si une requête réseau échoue.
|
||||
:raises ConnectionError: Si la connexion réseau échoue.
|
||||
:raises TimeoutError: Si la requête réseau expire.
|
||||
"""
|
||||
start, end = self._fetch_window()
|
||||
homeworks = self._pronote_client.get_homeworks(start, end)
|
||||
return [hw for hw in homeworks if hw.due_on == target_date]
|
||||
|
||||
def _homework_sources(self) -> tuple[_SourceName, _SourceName | None]:
|
||||
"""Sélectionne la source primaire et le repli unique pour les devoirs.
|
||||
|
||||
En mode ``AUTO``, iCal est primaire si ``ical_url`` est configuré
|
||||
(repli pronotepy si la configuration pronotepy est complète),
|
||||
sinon pronotepy sans repli. Les modes explicites ``ICAL`` et
|
||||
``PRONOTEPY`` désignent la source primaire et tentent l'autre
|
||||
source en repli unique si elle est configurée.
|
||||
|
||||
:return: Tuple ``(source primaire, source de repli ou ``None``)``.
|
||||
:rtype: tuple[_SourceName, _SourceName | None]
|
||||
:raises PipelineCriticalError: Si aucune source n'est configurée en mode ``AUTO``.
|
||||
"""
|
||||
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
|
||||
return "ical", "pronotepy" if self._is_pronotepy_configured() else None
|
||||
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
|
||||
return "pronotepy", "ical" if self._is_ical_configured() else None
|
||||
if self._is_ical_configured():
|
||||
return "ical", "pronotepy" if self._is_pronotepy_configured() else None
|
||||
if self._is_pronotepy_configured():
|
||||
return "pronotepy", None
|
||||
raise PipelineCriticalError(
|
||||
"Impossible de récupérer les devoirs : ni la source iCal ni pronotepy n'est configurée"
|
||||
) from None
|
||||
|
||||
# Mode AUTO : essayer iCal d'abord, puis replier sur pronotepy.
|
||||
try:
|
||||
def _fetch_homework_source(self, name: _SourceName, target_date: date) -> list[Homework]:
|
||||
"""Récupère les devoirs depuis la source nommée.
|
||||
|
||||
:param name: Nom de la source (``"ical"`` ou ``"pronotepy"``).
|
||||
:param target_date: Date cible pour laquelle collecter les devoirs.
|
||||
:return: Liste des devoirs.
|
||||
:rtype: list[Homework]
|
||||
"""
|
||||
if name == "ical":
|
||||
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.")
|
||||
return self._fetch_homework_pronotepy(target_date)
|
||||
|
||||
def fetch_homework(self, target_date: date) -> list[Homework]:
|
||||
"""Récupère les devoirs selon la source configurée.
|
||||
|
||||
La source primaire est essayée en premier ; si elle échoue, la
|
||||
source de repli unique (l'autre source, si configurée) est
|
||||
essayée. Si la source primaire et le repli échouent — ou si
|
||||
aucune source n'est configurée en mode ``AUTO`` — 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 tentées échouent.
|
||||
"""
|
||||
primary, fallback = self._homework_sources()
|
||||
try:
|
||||
homeworks = self._fetch_homework_pronotepy()
|
||||
return self._fetch_homework_source(primary, target_date)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Échec de la récupération pronotepy pour les devoirs : %s",
|
||||
"Échec de la récupération %s pour les devoirs : %s",
|
||||
primary,
|
||||
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
|
||||
if fallback is None:
|
||||
raise PipelineCriticalError(
|
||||
f"Impossible de récupérer les devoirs : la source {primary} a échoué"
|
||||
) from None
|
||||
logger.info("Repli sur %s pour les devoirs.", fallback)
|
||||
try:
|
||||
homeworks = self._fetch_homework_source(fallback, target_date)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Échec de la récupération %s pour les devoirs : %s",
|
||||
fallback,
|
||||
redact_exception(exc),
|
||||
)
|
||||
raise PipelineCriticalError(
|
||||
f"Impossible de récupérer les devoirs : les sources {primary}"
|
||||
f" et {fallback} ont échoué"
|
||||
) from None
|
||||
if not homeworks:
|
||||
logger.warning(
|
||||
"Le repli %s pour les devoirs a retourné un résultat vide après "
|
||||
"l'échec de %s : impossible de distinguer une absence de devoirs "
|
||||
"d'un échec silencieux.",
|
||||
fallback,
|
||||
primary,
|
||||
)
|
||||
return homeworks
|
||||
|
||||
def fetch_messages(self) -> list[Message]:
|
||||
"""Récupère les messages des discussions Pronote (toujours via pronotepy).
|
||||
@@ -284,7 +380,14 @@ class PronoteFetcher:
|
||||
:return: Liste des messages.
|
||||
:rtype: list[Message]
|
||||
"""
|
||||
return self._pronote_client.get_messages()
|
||||
try:
|
||||
return self._pronote_client.get_messages()
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Échec de la récupération des messages : %s",
|
||||
redact_exception(exc),
|
||||
)
|
||||
raise
|
||||
|
||||
def fetch_informations(self) -> list[Message]:
|
||||
"""Récupère les informations et sondages Pronote (toujours via pronotepy).
|
||||
@@ -292,4 +395,11 @@ class PronoteFetcher:
|
||||
:return: Liste des informations et sondages.
|
||||
:rtype: list[Message]
|
||||
"""
|
||||
return self._pronote_client.get_informations()
|
||||
try:
|
||||
return self._pronote_client.get_informations()
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Échec de la récupération des informations : %s",
|
||||
redact_exception(exc),
|
||||
)
|
||||
raise
|
||||
|
||||
Reference in New Issue
Block a user