Compare commits

..

8 Commits

Author SHA1 Message Date
ff740bc28b fix: PRONOTE_ENT optionnel pour les connexions pronotepy directes
PRONOTE_ENT était incorrectement traité comme obligatoire, bloquant
le pipeline quand l'ENT n'est pas configuré. Permet désormais la
connexion directe à Pronote sans ENT (ent=None), conformément à la
spécification (GUIDE_DEV_PYTHON.md §3.1.2).

Corrige deux bugs :
- Mode pronotepy explicite sans PRONOTE_ENT : erreur « ent sont requis »
- Mode auto sans iCal ni PRONOTE_ENT : erreur « ni la source iCal ni
  pronotepy n'est configurée » alors que les credentials sont présents

Changements :
- client.py : _connect() n'exige plus ent, résolution conditionnelle
- fallback.py : _is_pronotepy_configured() sans vérifier ent
- tests : 10 tests de régression (RED→GREEN)

Co-authored-by: opencode/coder litellm/coder@agents.invalid
Co-authored-by: opencode/test-engineer litellm/test-engineer@agents.invalid
2026-09-08 20:53:10 +02:00
c851f67172 docs: fichier de vacances scolaires Bordeaux (zone A, 2026-2027)
Crée data/school_holidays.json avec les dates officielles de l'académie de Bordeaux (zone A) pour 2026-2027. Corrige l'erreur au démarrage quand SCHOOL_HOLIDAYS_PATH pointe vers un fichier inexistant. Documentation wiki : page GuideAgendaVacancesScolaires créée et liée dans le sidebar.

Co-authored-by: Antoine Van Elstraete <antoine@van-elstraete.net>
Co-committed-by: Antoine Van Elstraete <antoine@van-elstraete.net>
2026-09-08 20:30:47 +02:00
4ac5be4c8d fix: corrige l'attribution de pronote-digest vers Yoan Bernabeu
L'attribution pointait à tort vers Antoine Coulon et le dépôt
antoine-coulon/pronote-digest. Le projet pronote-digest a été créé par
Yoan Bernabeu (https://yoanbernabeu.github.io/pronote-digest/, dépôt
https://github.com/yoanbernabeu/pronote-digest).

Co-authored-by: opencode/orchestrator <opencode-orchestrator@agents.invalid>
2026-09-08 18:57:55 +02:00
fdd3310462 fix: corrige l'attribution de pronote-digest vers Yoan Bernabeu
L'attribution pointait à tort vers Antoine Coulon et le dépôt
antoine-coulon/pronote-digest. Le projet pronote-digest a été créé par
Yoan Bernabeu (https://yoanbernabeu.github.io/pronote-digest/, dépôt
https://github.com/yoanbernabeu/pronote-digest).

Co-authored-by: opencode/orchestrator <opencode-orchestrator@agents.invalid>
2026-09-08 18:56:44 +02:00
0be02a660a docs: add M15 (Documentation) to CHANGELOG 0.1.0 entry 2026-09-08 18:33:40 +02:00
d85733116a chore: ignore HANDOFF.md and clean up temporary work files
Add HANDOFF.md to .gitignore for session handoff notes.
Delete 12 temporary files (FIXME_M1-M10, FEAT_M9, TEST_REPORT).
2026-09-08 17:55:55 +02:00
2deeb83c76 feat(M15): README, README.LLM.md, LICENSE, CHANGELOG, and final cleanup
Complete the M15 documentation and final review milestone:

README.md (French, brief):
- Project description, quick start, usage, deployment link, acknowledgments
- Acknowledgments: pronotepy, icalendar, caldav, slixmpp, pydantic, openai,
  feedparser, beautifulsoup4, and inspiration from pronote-digest (Antoine Coulon)
- Author: Antoine Van Elstraete

README.LLM.md (English, AI agent guide):
- Prerequisites, installation on LXC/VPS (Debian/CentOS)
- Non-secret configuration preparation (all env vars listed)
- Secret variables clearly identified as operator-only
- Pre-deployment checks (check_secrets.py, pip check, dry-run)
- systemd/timer/logrotate installation steps
- Notes for AI agent (do not commit secrets, do not modify existing files)

LICENSE:
- Standard MIT License, copyright Antoine Van Elstraete (2026)

CHANGELOG.md:
- Initial changelog following Keep a Changelog format
- Covers M1 through M14 with milestone summaries
- Gitea Actions noted as planned/optional, not delivered

.gitignore:
- Added FEAT_* pattern for temporary work files

TODO.md:
- M15 items 1-4 checked (README, architecture docs, CHANGELOG+LICENSE, final review)
- M15 item 5 (Gitea Actions) left unchecked (optional, not done)

Co-authored-by: opencode/tech-writer <tech-writer@agents.invalid>
2026-09-08 17:52:45 +02:00
b518508632 chore: replace GitHub Actions CI/CD with Gitea Actions (LXC/VPS)
Replace GitHub Actions references with Gitea Actions for deployment
on LXC/VPS (Debian/CentOS):

TODO.md:
- M15 optional item: GitHub Actions CI/CD -> Gitea Actions (LXC/VPS)
- Acceptance criterion: "La CI exécute..." -> "Gitea Actions exécute..."

GUIDE_DEV_PYTHON.md:
- §Prochaines étapes: "Configurer CI/CD" with GitHub Actions -> Gitea
  Actions for LXC/VPS (Debian/CentOS)

Other github.com references (library URLs, pre-commit repos, project
URLs) remain unchanged — they are legitimate technical references.
2026-09-08 17:33:17 +02:00
12 changed files with 691 additions and 25 deletions

2
.gitignore vendored
View File

@@ -52,7 +52,9 @@ Thumbs.db
# --- Local scratch / WIP files --- # --- Local scratch / WIP files ---
FIXME_* FIXME_*
FEAT_*
TEST_* TEST_*
HANDOFF.md
.worktrees/ .worktrees/
# --- Logs --- # --- Logs ---

28
CHANGELOG.md Normal file
View File

@@ -0,0 +1,28 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.0] - 2026-09-08
Initial release covering milestones M1 through M15.
### Added
- **M1 (Scaffolding)**: Python project structure with `pyproject.toml`, and tooling configuration for `ruff`, `mypy`, `bandit`, and `pre-commit`.
- **M2 (Configuration & secrets)**: Pydantic Settings for configuration management, `SecretStr` for sensitive fields, and redaction utilities (`redact_url`, `redact_secrets`, `redact_exception`) with `RedactingFormatter` for logging.
- **M3 (Data models)**: 16 Pydantic models and 6 enums across 10 modules, including frozen contracts and mutable work results.
- **M4 (Pronote sources)**: iCal fetch and parse, `pronotepy.ParentClient` integration, automatic fallback logic for `auto`, `ical`, and `pronotepy` modes, and error redaction for sensitive data.
- **M5 (Blog RSS)**: `feedparser`-based RSS client with GUID deduplication, HTTP cache support (ETag/If-Modified-Since), and `BlogRSSState` persistence.
- **M6 (Theoretical agenda)**: JSON provider with week parity (even/odd), school holidays calendar, and deterministic IDs for events.
- **M7 (CalDAV sync)**: Differential synchronization by UID, `X-PRONOTE-SYNC-MANAGED` marker for managed events, idempotent operations, preserved cancelled events, and dry-run support.
- **M8 (Agenda diff)**: `AgendaComparator` with deterministic matching, and generation of `AgendaDiff`/`AgendaChange` objects for tracking differences.
- **M9 (AI synthesis)**: `SynthesisProvider` protocol, OpenAI provider, optional `litellm` provider, and `openai-compatible` provider with degraded mode (returns `None` on failure).
- **M10 (XMPP channel)**: `XmppChannel` using `slixmpp`, formatted messages (synthesis, homeworks, changes, messages, blog), and error handling that returns `False` on failure.
- **M11 (Pipeline orchestration)**: `PipelineRunner` as composition root, 7 pipeline steps, degraded error handling, dry-run mode, and iCal reuse within a single run.
- **M12 (CLI entry point)**: `pronote-sync` command with `--dry-run` and `--log-level` options, redacted error display, and safe traceback in DEBUG mode.
- **M13 (Tests & coverage)**: 636 tests with 95.67% coverage, test fixtures (`pronote-4e.ics`, `pronote-6e.ics`), shared `conftest.py`, and secret non-leak tests.
- **M14 (Deployment)**: systemd service and timer (daily at 18:00), logrotate configuration (daily, rotate 7, compress), `check_secrets.py` pre-deployment scanner, and exploitation guide.
- **M15 (Documentation)**: README, README.LLM.md (AI agent setup guide), MIT LICENSE, CHANGELOG, and Gitea Actions CI/CD reference for LXC/VPS (Debian/CentOS).
- **Other**: MIT License. Gitea Actions CI/CD reference for LXC/VPS (Debian/CentOS) is planned and optional, not delivered in this release.

View File

@@ -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**.
@@ -6090,7 +6090,7 @@ Ce guide fournit une **base architecturale et technique solide** pour développe
1. **Créer le dépôt** : Initialiser un nouveau dépôt Python avec la structure proposée. 1. **Créer le dépôt** : Initialiser un nouveau dépôt Python avec la structure proposée.
2. **Implémenter le cœur** : Commencer par les modules `models/`, `sources/pronote/ical.py` et `utils/`. 2. **Implémenter le cœur** : Commencer par les modules `models/`, `sources/pronote/ical.py` et `utils/`.
3. **Ajouter les tests** : Écrire des tests unitaires pour chaque module dès le début. 3. **Ajouter les tests** : Écrire des tests unitaires pour chaque module dès le début.
4. **Configurer CI/CD** : Mettre en place GitHub Actions pour exécuter les tests et vérifier la sécurité. 4. **Configurer Gitea Actions** : Mettre en place Gitea Actions pour exécuter les tests et vérifier la sécurité, en vue d'un déploiement sur LXC/VPS (Debian/CentOS).
5. **Tester en conditions réelles** : Utiliser des flux iCal Pronote anonymisés pour valider le parsing. 5. **Tester en conditions réelles** : Utiliser des flux iCal Pronote anonymisés pour valider le parsing.
> **⚠️ Rappel** : Ce guide est **volontairement détaillé** pour préserver les connaissances acquises sur les spécificités de Pronote. Certaines sections (ex: parsing iCal) contiennent des **observations précises** issues de l'analyse du code TypeScript existant. **Ne pas sous-estimer l'importance de ces détails** : ils sont critiques pour un fonctionnement fiable du projet. > **⚠️ Rappel** : Ce guide est **volontairement détaillé** pour préserver les connaissances acquises sur les spécificités de Pronote. Certaines sections (ex: parsing iCal) contiennent des **observations précises** issues de l'analyse du code TypeScript existant. **Ne pas sous-estimer l'importance de ces détails** : ils sont critiques pour un fonctionnement fiable du projet.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Antoine Van Elstraete
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

147
README.LLM.md Normal file
View File

@@ -0,0 +1,147 @@
# pronote-sync — AI Agent Setup Guide
This document guides an AI agent through installing and pre-configuring the `pronote-sync` project on a fresh Linux host (Debian/CentOS). It covers environment setup, dependency installation, and configuration file preparation. It does **NOT** cover secrets provisioning — those must be provided by the operator.
---
## Prerequisites
- Python ≥ 3.13.5 (check with `python3 --version`)
- Git
- A non-root service user (e.g., `pronote-sync`)
- Target paths:
- `/opt/pronote-sync` (code)
- `/var/lib/pronote-sync` (state)
- `/var/log/pronote-sync` (logs)
- `/etc/pronote-sync` (config)
---
## Installation Steps
```bash
# Create service user
sudo useradd --system --no-create-home --shell /usr/sbin/nologin pronote-sync
# Clone the repository
sudo git clone <repo-url> /opt/pronote-sync
sudo chown -R pronote-sync:pronote-sync /opt/pronote-sync
# Create virtual environment
cd /opt/pronote-sync
sudo -u pronote-sync python3.13 -m venv .venv
sudo -u pronote-sync .venv/bin/pip install -e ".[dev]"
# Create directories
sudo install -d -m 0700 -o pronote-sync -g pronote-sync /etc/pronote-sync
sudo install -d -m 0750 -o pronote-sync -g pronote-sync /var/lib/pronote-sync
sudo install -d -m 0750 -o pronote-sync -g pronote-sync /var/log/pronote-sync
```
---
## Configuration Preparation (Without Secrets)
```bash
# Copy the example config
sudo -u pronote-sync cp /opt/pronote-sync/.env.example /etc/pronote-sync/pronote-sync.env
# The operator must fill in secrets (PRONOTE_PASSWORD, CALDAV_PASSWORD, XMPP_PASSWORD, AI_API_KEY, etc.)
# Do NOT populate secrets automatically — leave them for the operator.
```
### Non-Secret Environment Variables (Pre-Configurable)
The following variables can be safely pre-configured in `/etc/pronote-sync/pronote-sync.env`:
- **Pronote:**
- `PRONOTE_ACCOUNT_TYPE` (default: `parent`)
- `PRONOTE_ENT` (ENT slug, e.g., `lyceeconnecte`)
- `PRONOTE_AGENDA_SOURCE`, `PRONOTE_HOMEWORK_SOURCE`, `PRONOTE_MESSAGES_SOURCE` (`auto`, `ical`, or `pronotepy`)
- **CalDAV:**
- `CALDAV_CALENDAR_PATH` (e.g., `/pronote-sync/`)
- `CALDAV_ALLOW_INSECURE_HTTP` (default: `false`)
- **Sync Window:**
- `SYNC_PAST_DAYS`, `SYNC_FUTURE_DAYS`
- **Theoretical Agenda:**
- `THEORETICAL_AGENDA_PATH`, `SCHOOL_HOLIDAYS_PATH`
- `THEORETICAL_WEEK_ANCHOR_DATE`, `THEORETICAL_WEEK_ANCHOR_TYPE`
- **XMPP:**
- `XMPP_ENABLED`, `XMPP_HOST`, `XMPP_PORT`, `XMPP_USE_TLS`, `XMPP_TIMEOUT`, `XMPP_RESOURCE`
- **AI:**
- `AI_ENABLED`, `AI_PROVIDER`, `AI_BASE_URL`, `AI_MODEL`, `AI_ALLOW_INSECURE_HTTP`
- **Blog:**
- `BLOG_ENABLED`, `BLOG_RSS_URL`
- **General:**
- `DRY_RUN`, `LOG_LEVEL`
### Secret Variables (Operator Must Provide)
**Do NOT set these variables automatically.** The operator must manually provide the following secrets:
- **Pronote:**
- `PRONOTE_ICAL_URL`, `PRONOTE_URL`, `PRONOTE_USERNAME`, `PRONOTE_PASSWORD`
- **CalDAV:**
- `CALDAV_URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD`
- **XMPP:**
- `XMPP_JID`, `XMPP_PASSWORD`, `XMPP_TO`
- **AI:**
- `AI_API_KEY`
---
## Pre-Deployment Checks
```bash
# Verify no secrets in the codebase
/opt/pronote-sync/.venv/bin/python /opt/pronote-sync/scripts/check_secrets.py
# Verify dependencies
/opt/pronote-sync/.venv/bin/python -m pip check
# Dry-run test (will fail without secrets, but verifies the environment)
/opt/pronote-sync/.venv/bin/pronote-sync --dry-run
```
---
## systemd Installation
```bash
# Install systemd units
sudo install -m 0644 /opt/pronote-sync/deploy/systemd/pronote-sync.service /etc/systemd/system/
sudo install -m 0644 /opt/pronote-sync/deploy/systemd/pronote-sync.timer /etc/systemd/system/
# Install logrotate config
sudo install -m 0644 /opt/pronote-sync/deploy/logrotate/pronote_sync /etc/logrotate.d/pronote_sync
# Reload and enable
sudo systemctl daemon-reload
sudo systemctl enable --now pronote-sync.timer
```
---
## Notes for the AI Agent
- **Do NOT commit or write secrets** to any file in the repository.
- **Do NOT modify** `.gitignore`, `pyproject.toml`, or existing source files.
- If `python3.13` is not available, install it first:
- Debian: `sudo apt install python3.13`
- CentOS: Compile from source or use `dnf` if available.
- The `check_secrets.py` script exits with:
- `0` (clean)
- `1` (secrets found)
- `2` (error)
- All paths in the systemd unit assume `/opt/pronote-sync` — adjust if installed elsewhere.
- The operator **must** provide real values for all **SECRET** variables before enabling the timer.

69
README.md Normal file
View File

@@ -0,0 +1,69 @@
# pronote-sync
Synchronisation Pronote → CalDAV + XMPP.
---
Synchronise l'agenda et les devoirs de **Pronote** vers un calendrier **CalDAV** et envoie un résumé quotidien par **XMPP**. Supporte les sources iCal et `pronotepy` avec repli automatique. Synthèse IA optionnelle.
---
## 🚀 Démarrage rapide
```bash
# Cloner le dépôt
git clone <repo-url>
cd pronote-sync
# Créer l'environnement virtuel
python3.13 -m venv .venv
source .venv/bin/activate
# Installer
pip install -e ".[dev]"
# Configurer
cp .env.example .env
# Éditer .env avec vos paramètres (voir .env.example pour le détail)
# Tester
pronote-sync --dry-run --log-level DEBUG
```
---
## 📖 Utilisation
```bash
pronote-sync # Exécute la synchronisation
pronote-sync --dry-run # Simulation sans écriture
pronote-sync --log-level DEBUG # Verbosité des journaux
```
---
## 🛠️ Déploiement
Les artefacts pour **systemd/timer** et **logrotate** sont fournis dans `deploy/`. Voir [docs/exploitation.md](docs/exploitation.md) pour plus de détails.
---
## 🙏 Remerciements
Ce projet repose sur les bibliothèques open-source suivantes :
- [pronotepy](https://github.com/bain3/pronotepy) — client Pronote
- [icalendar](https://github.com/collective/icalendar) — parsing iCal
- [caldav](https://github.com/python-caldav/caldav) — client CalDAV
- [slixmpp](https://github.com/poezio/slixmpp) — client XMPP
- [pydantic](https://github.com/pydantic/pydantic) — validation et configuration
- [openai](https://github.com/openai/openai-python) — synthèse IA
- [feedparser](https://github.com/kurtmckee/feedparser) — parsing RSS
- [beautifulsoup4](https://www.crummy.com/software/BeautifulSoup/) — parsing HTML
Inspiré de [pronote-digest](https://github.com/yoanbernabeu/pronote-digest) par [Yoan Bernabeu](https://yoanbernabeu.github.io/pronote-digest/).
---
## Licence
MIT — voir [LICENSE](LICENSE).

12
TODO.md
View File

@@ -294,13 +294,13 @@ Mettre en production de façon supervisée (planification, rotation des logs, v
Rédiger la documentation utilisateur et finaliser le projet. Rédiger la documentation utilisateur et finaliser le projet.
- [ ] Créer `README.md` (installation, configuration `.env`, usage CLI, systemd/docker, limites, RGPD). - [x] Créer `README.md` (installation, configuration `.env`, usage CLI, systemd/docker, limites, RGPD).
- [ ] Documenter l'architecture (pipeline, modules) en résumé. - [x] Documenter l'architecture (pipeline, modules) en résumé.
- [ ] Ajouter `CHANGELOG` initial et la licence (MIT). - [x] Ajouter `CHANGELOG` initial et la licence (MIT).
- [ ] Revue finale : cohérence avec le guide, aucun secret documenté en clair. - [x] Revue finale : cohérence avec le guide, aucun secret documenté en clair.
- [ ] (Optionnel) Configurer GitHub Actions CI/CD (pytest + bandit + ruff + mypy) d'après §Prochaines étapes. - [ ] (Optionnel) Configurer Gitea Actions (pytest + bandit + ruff + mypy) pour le déploiement LXC/VPS (Debian/CentOS).
### Critères d'acceptation ### Critères d'acceptation
- `README.md` permet d'installer et de lancer le projet sans le guide. - `README.md` permet d'installer et de lancer le projet sans le guide.
- La CI exécute tests + lint + sécurité. - Gitea Actions exécute tests + lint + sécurité.
- Aucun secret dans la documentation. - Aucun secret dans la documentation.

31
data/school_holidays.json Normal file
View 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é"
}
]
}

View File

@@ -155,16 +155,17 @@ 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 ``pronote_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:
@@ -172,11 +173,9 @@ class PronoteClient:
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 pronote_url is None or username is None or password is None:
raise ValueError( raise ValueError("pronote_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"

View File

@@ -149,8 +149,8 @@ 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 ``pronote_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
@@ -158,7 +158,6 @@ class PronoteFetcher:
pronote.pronote_url is not None pronote.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]]:

View File

@@ -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(
pronote_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(
pronote_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(
pronote_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

View File

@@ -309,7 +309,9 @@ 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="pronote_url, username et password sont requis pour pronotepy"
):
client._connect() client._connect()
@@ -365,6 +367,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(
pronote_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 pronote_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 pronote_url
settings = PronoteSettings(
pronote_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 "pronote_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(
pronote_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 "pronote_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(
pronote_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 "pronote_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:
@@ -398,6 +526,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(
pronote_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: