Ajoute le mode d'authentification PRONOTE_AUTH_MODE=qr_token comme alternative au mode password pour les instances Pronote utilisant HubEduConnect/EduConnect où l'authentification par mot de passe échoue (CAPTCHA, MFA, flux SAML). Nouveaux éléments : - PronoteSettings : auth_mode, qr_code_file, qr_pin (SecretStr) - PronoteAuthState : persistance du token rotatif dans .pronote_auth_state.json (écriture atomique, permissions 0600, symlink-safe via O_EXCL|O_NOFOLLOW) - PronoteClient._connect_qr_token() : token_login avec creds persistés, qrcode_login pour l'enrôlement initial, export_credentials persisté après chaque login réussi - PronoteAuthRotationError : levée en cas d'échec de rotation du token, propagée sans wrapping à travers PronoteFetcher et fetch_step jusqu'à PipelineRunner.run() qui notifie via XMPP (si canal disponible et dry_run inactif) - _is_pronotepy_configured() mode-aware : qr_token ne requiert que PRONOTE_URL - _collect_auth_secrets() : redaction des secrets explicites (token, PIN, jeton QR) dans tous les logs du chemin d'authentification Documentation : - .env.example : PRONOTE_AUTH_MODE, PRONOTE_QR_CODE_FILE, PRONOTE_QR_PIN - AGENTS.md : contrat d'authentification QR code / token - Wiki GuidePronote : section enrôlement, exécutions suivantes, ré-enrôlement Tests (686 passés, couverture 94.87%) : - 5 tests config QR, 9 tests auth_state, 10 tests client QR, 3 tests propagation, 4 tests intégration rotation end-to-end, 4 tests fallback mode-aware - Tests de non-fuite : sentinelles distinctes pour token, PIN, jeton QR Co-authored-by: coder/litellm/coder <coder@agents.invalid>
131 lines
4.9 KiB
Python
131 lines
4.9 KiB
Python
"""Étape de récupération des données Pronote pour une exécution du pipeline."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import date
|
|
|
|
from pronote_sync.errors import PipelineCriticalError, PipelineWarning, PronoteAuthRotationError
|
|
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.fallback import PronoteFetcherProtocol
|
|
from pronote_sync.utils.redaction import redact_exception
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FetchedPronoteData:
|
|
"""Représente les données brutes récupérées pendant une exécution.
|
|
|
|
:ivar lessons: Cours récupérés depuis la source sélectionnée.
|
|
:ivar homeworks: Devoirs destinés à la date cible.
|
|
:ivar school_events: Événements scolaires récupérés avec l'agenda.
|
|
:ivar messages: Messages et informations Pronote disponibles.
|
|
:ivar target_date: Date cible du digest.
|
|
"""
|
|
|
|
lessons: list[Lesson]
|
|
homeworks: list[Homework]
|
|
school_events: list[SchoolEvent]
|
|
messages: list[Message]
|
|
target_date: date
|
|
|
|
|
|
def resolve_target_date(
|
|
today: date, lessons: list[Lesson], school_events: list[SchoolEvent]
|
|
) -> date:
|
|
"""Détermine la date cible du digest à partir de l'agenda disponible.
|
|
|
|
La règle privilégie J+1 lorsqu'il contient des cours. Si la journée en
|
|
cours contient des cours mais pas J+1, le prochain cours connu est choisi.
|
|
Sans cours correspondant, J+1 est conservé, y compris pendant les vacances.
|
|
|
|
:param today: Date de référence de l'exécution.
|
|
:param lessons: Cours récupérés pour la fenêtre de synchronisation.
|
|
:param school_events: Événements scolaires récupérés (réservés aux évolutions
|
|
du libellé de jour sans cours).
|
|
:return: Date cible du digest.
|
|
:rtype: date
|
|
"""
|
|
del school_events
|
|
tomorrow = date.fromordinal(today.toordinal() + 1)
|
|
lesson_dates = {lesson.start.date() for lesson in lessons}
|
|
if tomorrow in lesson_dates:
|
|
return tomorrow
|
|
if today in lesson_dates:
|
|
future_dates = sorted(day for day in lesson_dates if day > today)
|
|
if future_dates:
|
|
return future_dates[0]
|
|
return tomorrow
|
|
|
|
|
|
def _fetch_optional_messages(
|
|
fetcher: PronoteFetcherProtocol,
|
|
) -> tuple[list[Message], list[PipelineWarning]]:
|
|
"""Récupère les messages et informations sans bloquer le pipeline.
|
|
|
|
:param fetcher: Fetcher Pronote configuré.
|
|
:return: Messages disponibles et avertissements éventuels.
|
|
:rtype: tuple[list[Message], list[PipelineWarning]]
|
|
"""
|
|
messages: list[Message] = []
|
|
warnings: list[PipelineWarning] = []
|
|
for step, method in (
|
|
("fetch_messages", fetcher.fetch_messages),
|
|
("fetch_informations", fetcher.fetch_informations),
|
|
):
|
|
try:
|
|
messages.extend(method())
|
|
except Exception as exc:
|
|
warnings.append(
|
|
PipelineWarning(
|
|
f"Récupération non critique échouée : {redact_exception(exc)}",
|
|
step=step,
|
|
)
|
|
)
|
|
return messages, warnings
|
|
|
|
|
|
def fetch_step(
|
|
fetcher: PronoteFetcherProtocol, *, today: date | None = None
|
|
) -> tuple[FetchedPronoteData, list[PipelineWarning]]:
|
|
"""Récupère les données Pronote critiques et les compléments dégradables.
|
|
|
|
L'agenda et les devoirs sont critiques : leur échec empêche de produire un
|
|
digest fiable et est donc propagé comme :class:`PipelineCriticalError`.
|
|
Les messages et informations sont facultatifs ; leur échec produit un
|
|
avertissement et une liste partielle reste valide.
|
|
|
|
:param fetcher: Fetcher Pronote configuré.
|
|
:param today: Date de référence, injectée par les tests ; J courant par défaut.
|
|
:return: Données récupérées et avertissements non critiques.
|
|
:rtype: tuple[FetchedPronoteData, list[PipelineWarning]]
|
|
:raises PipelineCriticalError: Si l'agenda ou les devoirs ne sont pas disponibles.
|
|
:raises PronoteAuthRotationError: Si une rotation du token d'authentification
|
|
pronotepy est nécessaire : propagée telle quelle jusqu'au pipeline.
|
|
"""
|
|
try:
|
|
lessons, school_events = fetcher.fetch_agenda()
|
|
target_date = resolve_target_date(today or date.today(), lessons, school_events)
|
|
homeworks = fetcher.fetch_homework(target_date)
|
|
except PipelineCriticalError:
|
|
raise
|
|
except PronoteAuthRotationError:
|
|
raise
|
|
except Exception as exc:
|
|
raise PipelineCriticalError(
|
|
f"Récupération Pronote impossible : {redact_exception(exc)}", step="fetch"
|
|
) from None
|
|
|
|
messages, warnings = _fetch_optional_messages(fetcher)
|
|
return (
|
|
FetchedPronoteData(
|
|
lessons=lessons,
|
|
homeworks=homeworks,
|
|
school_events=school_events,
|
|
messages=messages,
|
|
target_date=target_date,
|
|
),
|
|
warnings,
|
|
)
|