Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a6207f716 | |||
| 3b38253575 | |||
| bf4038814a | |||
| 82b9877aad | |||
| c851f67172 | |||
|
4ac5be4c8d
|
|||
|
fdd3310462
|
@@ -1,6 +1,6 @@
|
|||||||
# --- Pronote ---
|
# --- Pronote ---
|
||||||
PRONOTE_ICAL_URL=https://college.ent/pronote/ical/Edt_Jean.ics?icalsecurise=REPLACE_ME&version=2024
|
PRONOTE_ICAL_URL=https://college.ent/pronote/ical/Edt_Jean.ics?icalsecurise=REPLACE_ME&version=2024
|
||||||
PRONOTE_URL=https://college.ent/pronote/eleve.html
|
PRONOTE_URL=https://college.ent/pronote/parent.html
|
||||||
PRONOTE_ACCOUNT_TYPE=parent
|
PRONOTE_ACCOUNT_TYPE=parent
|
||||||
PRONOTE_USERNAME=parent.dupont
|
PRONOTE_USERNAME=parent.dupont
|
||||||
PRONOTE_PASSWORD=your_secure_password
|
PRONOTE_PASSWORD=your_secure_password
|
||||||
|
|||||||
44
AGENTS.md
44
AGENTS.md
@@ -326,3 +326,47 @@ Un changement est considéré comme **terminé** lorsque :
|
|||||||
- Le *handoff* distingue clairement :
|
- Le *handoff* distingue clairement :
|
||||||
- Ce qui a été vérifié localement (ex. : tests unitaires, linter).
|
- Ce qui a été vérifié localement (ex. : tests unitaires, linter).
|
||||||
- Ce qui nécessite encore une vérification manuelle (ex. : tests d'intégration avec un serveur CalDAV réel).
|
- Ce qui nécessite encore une vérification manuelle (ex. : tests d'intégration avec un serveur CalDAV réel).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Versionnage et releases
|
||||||
|
|
||||||
|
### Politique de versionnage
|
||||||
|
|
||||||
|
Le projet suit **Semantic Versioning** (semver.org v2.0.0). Phase actuelle : `0.x` (pré-`1.0.0`).
|
||||||
|
|
||||||
|
| Changement | Incrément |
|
||||||
|
|------------|----------|
|
||||||
|
| Défaut constaté au déploiement | Patch (`0.1.Z`) — correction rétrocompatible |
|
||||||
|
| Ajout ou cassure en phase `0.x` | Minor (`0.Y.0`) |
|
||||||
|
| Déploiement réel validé | `1.0.0` |
|
||||||
|
|
||||||
|
### Règle absolue de validation
|
||||||
|
|
||||||
|
**Aucune montée de version (tag + release) ne peut être effectuée
|
||||||
|
sans validation préalable en environnement réel.** Les tests automatisés et la revue de code
|
||||||
|
ne suffisent pas ; le correctif ou la fonctionnalité doit avoir été testé avec succès
|
||||||
|
sur le serveur de production (ou un environnement équivalent) avant de tagger.
|
||||||
|
|
||||||
|
### Procédure de release
|
||||||
|
|
||||||
|
1. **Valider en environnement réel** : le correctif ou la fonctionnalité est testé
|
||||||
|
sur le serveur de production.
|
||||||
|
2. **Mettre à jour `pyproject.toml`** : incrémenter le champ `version` à la nouvelle version.
|
||||||
|
3. **Mettre à jour `CHANGELOG.md`** : ajouter une entrée sous le format
|
||||||
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) avec la nouvelle version et la date.
|
||||||
|
4. **Committer** : un commit `chore: monter en version x.y.z` regroupe
|
||||||
|
les mises à jour de `pyproject.toml` et `CHANGELOG.md`.
|
||||||
|
5. **Tagger** : créer un tag annoté `vx.y.z` sur le commit de version.
|
||||||
|
6. **Pousser le tag** : `git push origin vx.y.z`.
|
||||||
|
7. **Créer la release** sur Gitea avec le changelog correspondant.
|
||||||
|
|
||||||
|
### Cohérence des versions
|
||||||
|
|
||||||
|
Les trois sources de version doivent toujours être synchronisées au moment d'un tag :
|
||||||
|
- Le tag Git (`vx.y.z`)
|
||||||
|
- `pyproject.toml` (`version = "x.y.z"`)
|
||||||
|
- `CHANGELOG.md` (`## [x.y.z] - YYYY-MM-DD`)
|
||||||
|
|
||||||
|
> **Rappel** : Ne jamais créer un tag sans avoir d'abord mis à jour
|
||||||
|
> `pyproject.toml` et `CHANGELOG.md`.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Guide de Développement : Synchronisation Pronote → CalDAV + XMPP (Python)
|
# Guide de Développement : Synchronisation Pronote → CalDAV + XMPP (Python)
|
||||||
|
|
||||||
> **Statut** : Guide de référence pour un futur projet Python inspiré de [`pronote-digest`](https://github.com/antoine-coulon/pronote-digest) (TypeScript).
|
> **Statut** : Guide de référence pour un futur projet Python inspiré de [`pronote-digest`](https://github.com/yoanbernabeu/pronote-digest) (TypeScript) par [Yoan Bernabeu](https://yoanbernabeu.github.io/pronote-digest/).
|
||||||
> **Public cible** : Développeurs Python (≥ 3.13.5) familiers avec les concepts de CLI, synchronisation de calendriers et messagerie instantanée.
|
> **Public cible** : Développeurs Python (≥ 3.13.5) familiers avec les concepts de CLI, synchronisation de calendriers et messagerie instantanée.
|
||||||
> **Objectif** : Fournir une base architecturale et technique pour un outil **synchronisant l'agenda Pronote vers CalDAV**, **comparant avec un agenda théorique**, **récupérant messages et informations**, et **envoyant une synthèse par XMPP**.
|
> **Objectif** : Fournir une base architecturale et technique pour un outil **synchronisant l'agenda Pronote vers CalDAV**, **comparant avec un agenda théorique**, **récupérant messages et informations**, et **envoyant une synthèse par XMPP**.
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ Ce projet repose sur les bibliothèques open-source suivantes :
|
|||||||
- [feedparser](https://github.com/kurtmckee/feedparser) — parsing RSS
|
- [feedparser](https://github.com/kurtmckee/feedparser) — parsing RSS
|
||||||
- [beautifulsoup4](https://www.crummy.com/software/BeautifulSoup/) — parsing HTML
|
- [beautifulsoup4](https://www.crummy.com/software/BeautifulSoup/) — parsing HTML
|
||||||
|
|
||||||
Inspiré de [pronote-digest](https://github.com/antoine-coulon/pronote-digest) par Antoine Coulon.
|
Inspiré de [pronote-digest](https://github.com/yoanbernabeu/pronote-digest) par [Yoan Bernabeu](https://yoanbernabeu.github.io/pronote-digest/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
31
data/school_holidays.json
Normal file
31
data/school_holidays.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"zone": "A",
|
||||||
|
"school_year": "2026-2027",
|
||||||
|
"periods": [
|
||||||
|
{
|
||||||
|
"start_date": "2026-10-17",
|
||||||
|
"end_date": "2026-11-02",
|
||||||
|
"label": "Toussaint"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start_date": "2026-12-19",
|
||||||
|
"end_date": "2027-01-04",
|
||||||
|
"label": "Noël"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start_date": "2027-02-13",
|
||||||
|
"end_date": "2027-03-01",
|
||||||
|
"label": "Hiver"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start_date": "2027-04-10",
|
||||||
|
"end_date": "2027-04-26",
|
||||||
|
"label": "Printemps"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start_date": "2027-07-03",
|
||||||
|
"end_date": "2027-09-01",
|
||||||
|
"label": "Été"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ class PronoteSettings(BaseSettings):
|
|||||||
username: str | None = None
|
username: str | None = None
|
||||||
password: SecretStr | None = None
|
password: SecretStr | None = None
|
||||||
ent: str | None = None
|
ent: str | None = None
|
||||||
pronote_url: str | None = None
|
url: str | None = None
|
||||||
account_type: Literal["student", "parent"] = "parent"
|
account_type: Literal["student", "parent"] = "parent"
|
||||||
agenda_source: Literal["auto", "ical", "pronotepy"] = "auto"
|
agenda_source: Literal["auto", "ical", "pronotepy"] = "auto"
|
||||||
homework_source: Literal["auto", "ical", "pronotepy"] = "auto"
|
homework_source: Literal["auto", "ical", "pronotepy"] = "auto"
|
||||||
|
|||||||
@@ -155,35 +155,34 @@ class PronoteClient:
|
|||||||
"""Crée et connecte le client ``pronotepy`` (connexion paresseuse).
|
"""Crée et connecte le client ``pronotepy`` (connexion paresseuse).
|
||||||
|
|
||||||
Le client est créé une seule fois puis réutilisé pour les appels
|
Le client est créé une seule fois puis réutilisé pour les appels
|
||||||
suivants. Le nom d'ENT est résolu via :func:`_resolve_ent` et le
|
suivants. Le nom d'ENT, s'il est configuré, est résolu via
|
||||||
type de compte (``student`` ou ``parent``) détermine la classe de
|
:func:`_resolve_ent` ; en l'absence d'ENT, ``ent=None`` est transmis
|
||||||
client utilisée. L'erreur de connexion est relancée sans
|
à ``pronotepy`` pour une connexion directe. Le type de compte
|
||||||
journalisation, la méthode publique appelante étant responsable
|
(``student`` ou ``parent``) détermine la classe de client utilisée.
|
||||||
de la journaliser.
|
L'erreur de connexion est relancée sans journalisation, la méthode
|
||||||
|
publique appelante étant responsable de la journaliser.
|
||||||
|
|
||||||
:return: Le client ``pronotepy`` connecté.
|
:return: Le client ``pronotepy`` connecté.
|
||||||
:rtype: pronotepy.Client
|
:rtype: pronotepy.Client
|
||||||
:raises ValueError: Si ``pronote_url``, ``username``, ``password``
|
:raises ValueError: Si ``url``, ``username`` ou ``password``
|
||||||
ou ``ent`` est manquant, ou si l'ENT est inconnu.
|
est manquant, ou si l'ENT fourni est inconnu.
|
||||||
:raises pronotepy.PronoteAPIError: Si la connexion à Pronote échoue.
|
:raises pronotepy.PronoteAPIError: Si la connexion à Pronote échoue.
|
||||||
"""
|
"""
|
||||||
if self._client is None:
|
if self._client is None:
|
||||||
pronote_url = self._settings.pronote_url
|
url = self._settings.url
|
||||||
username = self._settings.username
|
username = self._settings.username
|
||||||
password = self._settings.password
|
password = self._settings.password
|
||||||
ent = self._settings.ent
|
ent = self._settings.ent
|
||||||
if pronote_url is None or username is None or password is None or ent is None:
|
if url is None or username is None or password is None:
|
||||||
raise ValueError(
|
raise ValueError("url, username et password sont requis pour pronotepy")
|
||||||
"pronote_url, username, password et ent sont requis pour pronotepy"
|
resolver = _resolve_ent(ent) if ent is not None else None
|
||||||
)
|
|
||||||
resolver = _resolve_ent(ent)
|
|
||||||
client_class: type[pronotepy.Client] = (
|
client_class: type[pronotepy.Client] = (
|
||||||
pronotepy.ParentClient
|
pronotepy.ParentClient
|
||||||
if self._settings.account_type == "parent"
|
if self._settings.account_type == "parent"
|
||||||
else pronotepy.Client
|
else pronotepy.Client
|
||||||
)
|
)
|
||||||
self._client = client_class(
|
self._client = client_class(
|
||||||
pronote_url=pronote_url,
|
pronote_url=url,
|
||||||
username=username,
|
username=username,
|
||||||
password=password.get_secret_value(),
|
password=password.get_secret_value(),
|
||||||
ent=resolver,
|
ent=resolver,
|
||||||
|
|||||||
@@ -149,16 +149,15 @@ class PronoteFetcher:
|
|||||||
def _is_pronotepy_configured(self) -> bool:
|
def _is_pronotepy_configured(self) -> bool:
|
||||||
"""Vérifie que la source pronotepy est entièrement configurée.
|
"""Vérifie que la source pronotepy est entièrement configurée.
|
||||||
|
|
||||||
:return: ``True`` si ``pronote_url``, ``username``, ``password``
|
:return: ``True`` si ``url``, ``username`` et ``password``
|
||||||
et ``ent`` sont tous définis, ``False`` sinon.
|
sont tous définis, ``False`` sinon.
|
||||||
:rtype: bool
|
:rtype: bool
|
||||||
"""
|
"""
|
||||||
pronote = self._settings.pronote
|
pronote = self._settings.pronote
|
||||||
return (
|
return (
|
||||||
pronote.pronote_url is not None
|
pronote.url is not None
|
||||||
and pronote.username is not None
|
and pronote.username is not None
|
||||||
and pronote.password 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]]:
|
def _fetch_agenda_ical(self) -> tuple[list[Lesson], list[SchoolEvent]]:
|
||||||
|
|||||||
@@ -116,4 +116,19 @@ def test_no_singleton_import() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_url_from_pronote_url_env_var(monkeypatch: MonkeyPatch) -> None:
|
||||||
|
"""Vérifie que PRONOTE_URL mappe au champ url via le préfixe PRONOTE_.
|
||||||
|
|
||||||
|
Ce test couvre la régression où PRONOTE_URL n'était pas mappé vers le
|
||||||
|
champ du modèle à cause du double préfixe PRONOTE_.
|
||||||
|
|
||||||
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
test_url = "https://example.index-education.net/pronote/parent.html"
|
||||||
|
monkeypatch.setenv("PRONOTE_URL", test_url)
|
||||||
|
settings = load_settings()
|
||||||
|
assert settings.pronote.url == test_url
|
||||||
|
|
||||||
|
|
||||||
# Ensure trailing newline
|
# Ensure trailing newline
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ def fixture_mock_settings() -> Settings:
|
|||||||
"""
|
"""
|
||||||
return Settings(
|
return Settings(
|
||||||
pronote=PronoteSettings(
|
pronote=PronoteSettings(
|
||||||
pronote_url="https://pronote.example.com",
|
url="https://pronote.example.com",
|
||||||
ical_url=SecretStr("file:///fake/ical.ics"),
|
ical_url=SecretStr("file:///fake/ical.ics"),
|
||||||
agenda_source="auto",
|
agenda_source="auto",
|
||||||
homework_source="auto",
|
homework_source="auto",
|
||||||
@@ -251,7 +251,7 @@ def test_fetch_agenda_auto_both_fail(mock_fetcher: PronoteFetcher) -> None:
|
|||||||
:rtype: None
|
:rtype: None
|
||||||
"""
|
"""
|
||||||
# Disable pronotepy so fallback is None
|
# Disable pronotepy so fallback is None
|
||||||
mock_fetcher._settings.pronote.pronote_url = None
|
mock_fetcher._settings.pronote.url = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
|
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
|
||||||
@@ -280,7 +280,7 @@ def test_fetch_agenda_ical_mode_failure(mock_fetcher: PronoteFetcher) -> None:
|
|||||||
"""
|
"""
|
||||||
# Override settings to use ical mode explicitly and disable fallback
|
# Override settings to use ical mode explicitly and disable fallback
|
||||||
mock_fetcher._settings.pronote.agenda_source = "ical"
|
mock_fetcher._settings.pronote.agenda_source = "ical"
|
||||||
mock_fetcher._settings.pronote.pronote_url = None
|
mock_fetcher._settings.pronote.url = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
|
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
|
||||||
@@ -438,7 +438,7 @@ def test_fetch_homework_auto_both_fail(mock_fetcher: PronoteFetcher) -> None:
|
|||||||
target_date = date(2025, 9, 10)
|
target_date = date(2025, 9, 10)
|
||||||
|
|
||||||
# Disable pronotepy so fallback is None
|
# Disable pronotepy so fallback is None
|
||||||
mock_fetcher._settings.pronote.pronote_url = None
|
mock_fetcher._settings.pronote.url = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
|
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
|
||||||
@@ -531,7 +531,7 @@ def test_no_secrets_in_error_messages(
|
|||||||
:rtype: None
|
:rtype: None
|
||||||
"""
|
"""
|
||||||
# Disable pronotepy so fallback is None to trigger PipelineCriticalError
|
# Disable pronotepy so fallback is None to trigger PipelineCriticalError
|
||||||
mock_fetcher._settings.pronote.pronote_url = None
|
mock_fetcher._settings.pronote.url = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
|
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
|
||||||
@@ -629,7 +629,7 @@ def test_fetch_agenda_no_source_configured_raises(mock_fetcher: PronoteFetcher)
|
|||||||
"""
|
"""
|
||||||
# Disable both sources
|
# Disable both sources
|
||||||
mock_fetcher._settings.pronote.ical_url = None
|
mock_fetcher._settings.pronote.ical_url = None
|
||||||
mock_fetcher._settings.pronote.pronote_url = None
|
mock_fetcher._settings.pronote.url = None
|
||||||
|
|
||||||
with pytest.raises(PipelineCriticalError) as exc_info:
|
with pytest.raises(PipelineCriticalError) as exc_info:
|
||||||
mock_fetcher.fetch_agenda()
|
mock_fetcher.fetch_agenda()
|
||||||
@@ -1020,7 +1020,7 @@ def test_homework_sources_explicit_ical_mode_strict(mock_fetcher: PronoteFetcher
|
|||||||
assert fallback is None
|
assert fallback is None
|
||||||
|
|
||||||
# Without pronotepy configured
|
# Without pronotepy configured
|
||||||
mock_fetcher._settings.pronote.pronote_url = None
|
mock_fetcher._settings.pronote.url = None
|
||||||
primary, fallback = mock_fetcher._homework_sources()
|
primary, fallback = mock_fetcher._homework_sources()
|
||||||
assert primary == "ical"
|
assert primary == "ical"
|
||||||
assert fallback is None
|
assert fallback is None
|
||||||
@@ -1080,7 +1080,7 @@ def test_homework_sources_auto_no_source_configured_raises(mock_fetcher: Pronote
|
|||||||
"""
|
"""
|
||||||
mock_fetcher._settings.pronote.homework_source = "auto"
|
mock_fetcher._settings.pronote.homework_source = "auto"
|
||||||
mock_fetcher._settings.pronote.ical_url = None
|
mock_fetcher._settings.pronote.ical_url = None
|
||||||
mock_fetcher._settings.pronote.pronote_url = None
|
mock_fetcher._settings.pronote.url = None
|
||||||
|
|
||||||
with pytest.raises(PipelineCriticalError) as exc_info:
|
with pytest.raises(PipelineCriticalError) as exc_info:
|
||||||
mock_fetcher._homework_sources()
|
mock_fetcher._homework_sources()
|
||||||
@@ -1088,6 +1088,102 @@ def test_homework_sources_auto_no_source_configured_raises(mock_fetcher: Pronote
|
|||||||
assert "ni la source iCal ni pronotepy n'est configurée" in str(exc_info.value)
|
assert "ni la source iCal ni pronotepy n'est configurée" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_agenda_auto_ical_configured_fails_fallback_to_pronotepy_without_ent(
|
||||||
|
mock_fetcher: PronoteFetcher,
|
||||||
|
) -> None:
|
||||||
|
"""Test le mode auto : iCal configuré mais échoue, repli sur pronotepy sans ent.
|
||||||
|
|
||||||
|
On mock iCal pour échouer, pronotepy configuré sans ent. On vérifie que pronotepy est appelé
|
||||||
|
et que le résultat provient de pronotepy, pas une erreur.
|
||||||
|
|
||||||
|
:param mock_fetcher: Fetcher de test.
|
||||||
|
:return: None
|
||||||
|
:rtype: None
|
||||||
|
"""
|
||||||
|
start_dt = datetime(2025, 9, 1, 8, 0)
|
||||||
|
end_dt = datetime(2025, 9, 1, 9, 30)
|
||||||
|
lessons = [
|
||||||
|
Lesson(
|
||||||
|
id="l1",
|
||||||
|
start=start_dt,
|
||||||
|
end=end_dt,
|
||||||
|
subject="Maths",
|
||||||
|
teachers=("Dupont",),
|
||||||
|
rooms=("S1",),
|
||||||
|
group="2ndeA",
|
||||||
|
status=LessonStatus.NORMAL,
|
||||||
|
content=None,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Override settings to use auto mode with ical_url configured but ent=None
|
||||||
|
mock_fetcher._settings.pronote.agenda_source = "auto"
|
||||||
|
mock_fetcher._settings.pronote.ent = None # Explicitly None
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
|
||||||
|
patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical,
|
||||||
|
):
|
||||||
|
m_fetch_ical.side_effect = OSError("iCal unreachable")
|
||||||
|
m_parse_ical.side_effect = OSError("iCal parse error")
|
||||||
|
client = MagicMock()
|
||||||
|
client.get_lessons.return_value = lessons
|
||||||
|
mock_fetcher._pronote_client = client
|
||||||
|
|
||||||
|
result_lessons, result_events = mock_fetcher.fetch_agenda()
|
||||||
|
|
||||||
|
assert result_lessons == lessons
|
||||||
|
assert result_events == []
|
||||||
|
client.get_lessons.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_homework_auto_ical_configured_fails_fallback_to_pronotepy_without_ent(
|
||||||
|
mock_fetcher: PronoteFetcher,
|
||||||
|
) -> None:
|
||||||
|
"""Test le mode auto des devoirs : iCal configuré mais échoue, repli sur pronotepy sans ent.
|
||||||
|
|
||||||
|
On mock iCal pour échouer, pronotepy configuré sans ent. On vérifie que pronotepy est appelé
|
||||||
|
et que le résultat provient de pronotepy, pas une erreur.
|
||||||
|
|
||||||
|
:param mock_fetcher: Fetcher de test.
|
||||||
|
:return: None
|
||||||
|
:rtype: None
|
||||||
|
"""
|
||||||
|
target_date = date(2025, 9, 10)
|
||||||
|
homeworks = [
|
||||||
|
Homework(
|
||||||
|
id="hw1",
|
||||||
|
subject="Physique",
|
||||||
|
teachers=(),
|
||||||
|
assigned_on=None,
|
||||||
|
due_on=target_date,
|
||||||
|
text="TP à préparer",
|
||||||
|
html="TP à préparer",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Override settings to use auto mode with ical_url configured but ent=None
|
||||||
|
mock_fetcher._settings.pronote.homework_source = "auto"
|
||||||
|
mock_fetcher._settings.pronote.ent = None # Explicitly None
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
|
||||||
|
patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical,
|
||||||
|
patch("pronote_sync.sources.pronote.fallback.collect_homeworks") as m_collect,
|
||||||
|
):
|
||||||
|
m_fetch_ical.side_effect = OSError("iCal unreachable")
|
||||||
|
m_parse_ical.side_effect = OSError("iCal parse error")
|
||||||
|
client = MagicMock()
|
||||||
|
client.get_homeworks.return_value = homeworks
|
||||||
|
mock_fetcher._pronote_client = client
|
||||||
|
m_collect.return_value = homeworks
|
||||||
|
|
||||||
|
result = mock_fetcher.fetch_homework(target_date)
|
||||||
|
|
||||||
|
assert result == homeworks
|
||||||
|
client.get_homeworks.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_homework_fallback_both_fail_raises_pipeline_critical_error(
|
def test_fetch_homework_fallback_both_fail_raises_pipeline_critical_error(
|
||||||
mock_fetcher: PronoteFetcher,
|
mock_fetcher: PronoteFetcher,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1127,7 +1223,7 @@ def test_fetch_homework_fallback_both_fail_raises_pipeline_critical_error(
|
|||||||
def test_fetch_homework_auto_fallback_returns_empty_logs_warning(
|
def test_fetch_homework_auto_fallback_returns_empty_logs_warning(
|
||||||
mock_fetcher: PronoteFetcher, caplog: pytest.LogCaptureFixture
|
mock_fetcher: PronoteFetcher, caplog: pytest.LogCaptureFixture
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test que fetch_homework retourne [] et journalise un avertissement si le repli retourne vide.
|
"""Test que fetch_homework retourne [] et journalise un avertissement si le repli est vide.
|
||||||
|
|
||||||
On mock ICAL pour échouer, pronotepy configuré et retourne vide. On vérifie le retour et le log.
|
On mock ICAL pour échouer, pronotepy configuré et retourne vide. On vérifie le retour et le log.
|
||||||
Ce test utilise le mode AUTO pour tester le comportement de repli.
|
Ce test utilise le mode AUTO pour tester le comportement de repli.
|
||||||
@@ -1186,4 +1282,99 @@ def test_fetch_informations_logs_and_re_raises_secret(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_pronotepy_configured_without_ent_returns_true() -> None:
|
||||||
|
"""Test _is_pronotepy_configured() retourne True quand ent est None.
|
||||||
|
|
||||||
|
Les autres champs de la configuration pronotepy sont présents, donc la
|
||||||
|
fonction renvoie True.
|
||||||
|
|
||||||
|
Ce test valide que PRONOTE_ENT est optionnel pour pronotepy.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
from pronote_sync.config.settings import PronoteSettings, Settings
|
||||||
|
from pronote_sync.sources.pronote.fallback import PronoteFetcher
|
||||||
|
|
||||||
|
# Créer des settings avec pronotepy configuré mais sans ent
|
||||||
|
settings = Settings(
|
||||||
|
pronote=PronoteSettings(
|
||||||
|
url="https://pronote.example.com",
|
||||||
|
username="testuser",
|
||||||
|
password=SecretStr("testpass"),
|
||||||
|
ent=None, # Explicitement None
|
||||||
|
agenda_source="pronotepy",
|
||||||
|
homework_source="pronotepy",
|
||||||
|
),
|
||||||
|
app=Settings().app,
|
||||||
|
)
|
||||||
|
|
||||||
|
client: _MockPronoteClientProtocol = MagicMock()
|
||||||
|
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
|
||||||
|
|
||||||
|
# Should return True even without ent
|
||||||
|
assert fetcher._is_pronotepy_configured() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_agenda_sources_auto_without_ical_and_without_ent_returns_pronotepy() -> None:
|
||||||
|
"""Test _agenda_sources() en mode AUTO sans iCal URL et sans ent retourne pronotepy.
|
||||||
|
|
||||||
|
Ce test valide que le mode auto peut utiliser pronotepy même sans ent configuré.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
from pronote_sync.config.settings import PronoteSettings, Settings
|
||||||
|
from pronote_sync.sources.pronote.fallback import PronoteFetcher
|
||||||
|
|
||||||
|
settings = Settings(
|
||||||
|
pronote=PronoteSettings(
|
||||||
|
url="https://pronote.example.com",
|
||||||
|
username="testuser",
|
||||||
|
password=SecretStr("testpass"),
|
||||||
|
ent=None, # Explicitement None
|
||||||
|
ical_url=None, # Pas de iCal URL
|
||||||
|
agenda_source="auto",
|
||||||
|
),
|
||||||
|
app=Settings().app,
|
||||||
|
)
|
||||||
|
|
||||||
|
client: _MockPronoteClientProtocol = MagicMock()
|
||||||
|
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
|
||||||
|
|
||||||
|
primary, fallback = fetcher._agenda_sources()
|
||||||
|
|
||||||
|
assert primary == "pronotepy"
|
||||||
|
assert fallback is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_homework_sources_auto_without_ical_and_without_ent_returns_pronotepy() -> None:
|
||||||
|
"""Test _homework_sources() en mode AUTO sans iCal URL et sans ent retourne pronotepy.
|
||||||
|
|
||||||
|
Ce test valide que le mode auto peut utiliser pronotepy pour les devoirs même sans ent configuré.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
from pronote_sync.config.settings import PronoteSettings, Settings
|
||||||
|
from pronote_sync.sources.pronote.fallback import PronoteFetcher
|
||||||
|
|
||||||
|
settings = Settings(
|
||||||
|
pronote=PronoteSettings(
|
||||||
|
url="https://pronote.example.com",
|
||||||
|
username="testuser",
|
||||||
|
password=SecretStr("testpass"),
|
||||||
|
ent=None, # Explicitement None
|
||||||
|
ical_url=None, # Pas de iCal URL
|
||||||
|
homework_source="auto",
|
||||||
|
),
|
||||||
|
app=Settings().app,
|
||||||
|
)
|
||||||
|
|
||||||
|
client: _MockPronoteClientProtocol = MagicMock()
|
||||||
|
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
|
||||||
|
|
||||||
|
primary, fallback = fetcher._homework_sources()
|
||||||
|
|
||||||
|
assert primary == "pronotepy"
|
||||||
|
assert fallback is None
|
||||||
|
|
||||||
|
|
||||||
# Ensure trailing newline
|
# Ensure trailing newline
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ def pronote_settings() -> PronoteSettings:
|
|||||||
:rtype: PronoteSettings
|
:rtype: PronoteSettings
|
||||||
"""
|
"""
|
||||||
return PronoteSettings(
|
return PronoteSettings(
|
||||||
pronote_url="https://pronote.example.com",
|
url="https://pronote.example.com",
|
||||||
username="testuser",
|
username="testuser",
|
||||||
password=SecretStr("testpass"),
|
password=SecretStr("testpass"),
|
||||||
ent="bordeaux",
|
ent="bordeaux",
|
||||||
@@ -309,7 +309,7 @@ def test_missing_credentials_raises(empty_pronote_settings: PronoteSettings) ->
|
|||||||
"""
|
"""
|
||||||
client = PronoteClient(empty_pronote_settings)
|
client = PronoteClient(empty_pronote_settings)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="pronote_url, username, password et ent sont requis"):
|
with pytest.raises(ValueError, match="url, username et password sont requis pour pronotepy"):
|
||||||
client._connect()
|
client._connect()
|
||||||
|
|
||||||
|
|
||||||
@@ -365,6 +365,132 @@ def test_connect_parent_account_type(
|
|||||||
pronotepy.Client.assert_not_called() # type: ignore[attr-defined]
|
pronotepy.Client.assert_not_called() # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_without_ent_but_with_required_credentials(
|
||||||
|
mocker: pytest_mock.MockerFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que _connect() fonctionne sans ent mais avec les autres identifiants requis.
|
||||||
|
|
||||||
|
Ce test valide que PRONOTE_ENT est optionnel pour une connexion directe Pronote.
|
||||||
|
|
||||||
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from pronote_sync.config.settings import PronoteSettings
|
||||||
|
from pronote_sync.sources.pronote.client import PronoteClient
|
||||||
|
|
||||||
|
# Settings sans ent mais avec les autres champs requis
|
||||||
|
settings = PronoteSettings(
|
||||||
|
url="https://pronote.example.com",
|
||||||
|
username="testuser",
|
||||||
|
password=SecretStr("testpass"),
|
||||||
|
ent=None, # Explicitement None
|
||||||
|
account_type="parent",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_client = mocker.MagicMock()
|
||||||
|
mock_client_class = Mock(return_value=mock_client)
|
||||||
|
mocker.patch("pronotepy.ParentClient", new=mock_client_class)
|
||||||
|
mocker.patch("pronotepy.Client")
|
||||||
|
|
||||||
|
client = PronoteClient(settings)
|
||||||
|
connected_client = client._connect()
|
||||||
|
|
||||||
|
# Should not raise ValueError about missing ent
|
||||||
|
assert connected_client is mock_client
|
||||||
|
|
||||||
|
# Verify ParentClient was called with ent=None
|
||||||
|
mock_client_class.assert_called_once_with(
|
||||||
|
pronote_url="https://pronote.example.com",
|
||||||
|
username="testuser",
|
||||||
|
password="testpass", # pragma: allowlist secret
|
||||||
|
ent=None, # ent should be None, not resolved
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_missing_required_credentials_still_raises(
|
||||||
|
mocker: pytest_mock.MockerFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que _connect() lève ValueError si url, username ou password manquent.
|
||||||
|
|
||||||
|
Ce test valide que l'erreur ne mentionne plus ent comme requis.
|
||||||
|
|
||||||
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
from pronote_sync.config.settings import PronoteSettings
|
||||||
|
from pronote_sync.sources.pronote.client import PronoteClient
|
||||||
|
|
||||||
|
# Settings avec ent mais sans url
|
||||||
|
settings = PronoteSettings(
|
||||||
|
url=None,
|
||||||
|
username="testuser",
|
||||||
|
password=SecretStr("testpass"),
|
||||||
|
ent=None,
|
||||||
|
account_type="parent",
|
||||||
|
)
|
||||||
|
|
||||||
|
client = PronoteClient(settings)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
client._connect()
|
||||||
|
|
||||||
|
# Error should NOT mention ent as required
|
||||||
|
assert "url, username et password sont requis" in str(exc_info.value)
|
||||||
|
assert "ent" not in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_missing_username_raises(mocker: pytest_mock.MockerFixture) -> None:
|
||||||
|
"""Vérifie que _connect() lève ValueError si username manque.
|
||||||
|
|
||||||
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
from pronote_sync.config.settings import PronoteSettings
|
||||||
|
from pronote_sync.sources.pronote.client import PronoteClient
|
||||||
|
|
||||||
|
settings = PronoteSettings(
|
||||||
|
url="https://pronote.example.com",
|
||||||
|
username=None,
|
||||||
|
password=SecretStr("testpass"),
|
||||||
|
ent=None,
|
||||||
|
account_type="parent",
|
||||||
|
)
|
||||||
|
|
||||||
|
client = PronoteClient(settings)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
client._connect()
|
||||||
|
|
||||||
|
assert "url, username et password sont requis" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_missing_password_raises(mocker: pytest_mock.MockerFixture) -> None:
|
||||||
|
"""Vérifie que _connect() lève ValueError si password manque.
|
||||||
|
|
||||||
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
from pronote_sync.config.settings import PronoteSettings
|
||||||
|
from pronote_sync.sources.pronote.client import PronoteClient
|
||||||
|
|
||||||
|
settings = PronoteSettings(
|
||||||
|
url="https://pronote.example.com",
|
||||||
|
username="testuser",
|
||||||
|
password=None,
|
||||||
|
ent=None,
|
||||||
|
account_type="parent",
|
||||||
|
)
|
||||||
|
|
||||||
|
client = PronoteClient(settings)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
client._connect()
|
||||||
|
|
||||||
|
assert "url, username et password sont requis" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
def test_connect_student_account_type(
|
def test_connect_student_account_type(
|
||||||
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -379,7 +505,7 @@ def test_connect_student_account_type(
|
|||||||
from pronote_sync.sources.pronote.client import PronoteClient
|
from pronote_sync.sources.pronote.client import PronoteClient
|
||||||
|
|
||||||
pronote_settings_student = PronoteSettings(
|
pronote_settings_student = PronoteSettings(
|
||||||
pronote_url="https://pronote.example.com",
|
url="https://pronote.example.com",
|
||||||
username="testuser",
|
username="testuser",
|
||||||
password=SecretStr("testpass"),
|
password=SecretStr("testpass"),
|
||||||
ent="bordeaux",
|
ent="bordeaux",
|
||||||
@@ -398,6 +524,57 @@ def test_connect_student_account_type(
|
|||||||
pronotepy.ParentClient.assert_not_called() # type: ignore[attr-defined]
|
pronotepy.ParentClient.assert_not_called() # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_with_ent_resolution_still_works(
|
||||||
|
mocker: pytest_mock.MockerFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que _resolve_ent est appelé et fonctionne quand ent est fourni.
|
||||||
|
|
||||||
|
Ce test valide que lorsque ent est fourni, il est toujours résolu via _resolve_ent.
|
||||||
|
|
||||||
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from pronote_sync.config.settings import PronoteSettings
|
||||||
|
from pronote_sync.sources.pronote.client import PronoteClient
|
||||||
|
|
||||||
|
settings = PronoteSettings(
|
||||||
|
url="https://pronote.example.com",
|
||||||
|
username="testuser",
|
||||||
|
password=SecretStr("testpass"),
|
||||||
|
ent="bordeaux", # ent est fourni
|
||||||
|
account_type="parent",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_client = mocker.MagicMock()
|
||||||
|
mock_client_class = Mock(return_value=mock_client)
|
||||||
|
mocker.patch("pronotepy.ParentClient", new=mock_client_class)
|
||||||
|
|
||||||
|
# Mock _resolve_ent to return a mock resolver
|
||||||
|
mock_resolver = Mock()
|
||||||
|
mocker.patch(
|
||||||
|
"pronote_sync.sources.pronote.client._resolve_ent",
|
||||||
|
return_value=mock_resolver,
|
||||||
|
)
|
||||||
|
|
||||||
|
client = PronoteClient(settings)
|
||||||
|
_ = client._connect()
|
||||||
|
|
||||||
|
# _resolve_ent should have been called
|
||||||
|
from pronote_sync.sources.pronote.client import _resolve_ent as resolve_ent_func
|
||||||
|
|
||||||
|
resolve_ent_func.assert_called_once_with("bordeaux") # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
# ParentClient should have been called with the resolved ent
|
||||||
|
mock_client_class.assert_called_once_with(
|
||||||
|
pronote_url="https://pronote.example.com",
|
||||||
|
username="testuser",
|
||||||
|
password="testpass", # pragma: allowlist secret
|
||||||
|
ent=mock_resolver,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_get_messages_degraded_on_error(
|
def test_get_messages_degraded_on_error(
|
||||||
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user