Compare commits

..
Author SHA1 Message Date
Codex 6575a5f60b fix(caldav): rendre les devoirs transparents 2026-09-12 15:43:12 +02:00
9 changed files with 111 additions and 89 deletions
-2
View File
@@ -23,8 +23,6 @@ PRONOTE_AUTH_MODE=password
# PRONOTE_AUTH_MODE=qr_token # PRONOTE_AUTH_MODE=qr_token
# PRONOTE_QR_CODE_FILE=/path/to/qr_code.json # PRONOTE_QR_CODE_FILE=/path/to/qr_code.json
# PRONOTE_QR_PIN=1234 # PRONOTE_QR_PIN=1234
# PIN de second facteur du compte Pronote, distinct du PIN de déchiffrement du QR code
# PRONOTE_ACCOUNT_PIN=
# --- CalDAV --- # --- CalDAV ---
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/ CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
-5
View File
@@ -66,11 +66,6 @@ processus. Le mode `PRONOTE_AUTH_MODE=qr_token` est incompatible avec cette gara
le refuse avant toute connexion afin de ne pas désynchroniser le token local du token distant. le refuse avant toute connexion afin de ne pas désynchroniser le token local du token distant.
Le dry-run ne remplace pas une vérification des paramètres réellement chargés. Le dry-run ne remplace pas une vérification des paramètres réellement chargés.
En mode `PRONOTE_AUTH_MODE=qr_token`, `PRONOTE_QR_PIN` déchiffre le QR code
exporté depuis le site web Pronote. Si le compte exige un second facteur,
configurez aussi `PRONOTE_ACCOUNT_PIN` avec le PIN du compte. Ce PIN est
transmis uniquement à `pronotepy` lors de l'enrôlement QR et des connexions par
token ; il n'est jamais écrit dans `.pronote_auth_state.json` ni dans les logs.
En mode `PRONOTE_AUTH_MODE=qr_token`, le fichier En mode `PRONOTE_AUTH_MODE=qr_token`, le fichier
`.pronote_auth_state.json` et son verrou frère sont créés dans le répertoire `.pronote_auth_state.json` et son verrou frère sont créés dans le répertoire
de travail du service (par exemple `/var/lib/pronote-sync`) avec le mode de travail du service (par exemple `/var/lib/pronote-sync`) avec le mode
-14
View File
@@ -47,7 +47,6 @@ class PronoteSettings(BaseSettings):
auth_mode: Literal["password", "qr_token"] = "password" auth_mode: Literal["password", "qr_token"] = "password"
qr_code_file: str | None = None qr_code_file: str | None = None
qr_pin: SecretStr | None = None qr_pin: SecretStr | None = None
account_pin: SecretStr | None = None
@field_serializer("ical_url") @field_serializer("ical_url")
def _serialize_ical_url(self, value: SecretStr | None) -> str | None: def _serialize_ical_url(self, value: SecretStr | None) -> str | None:
@@ -73,18 +72,6 @@ class PronoteSettings(BaseSettings):
return None return None
return "**********" return "**********"
@field_serializer("account_pin")
def _serialize_account_pin(self, value: SecretStr | None) -> str | None:
"""Masque le PIN du compte lors de la sérialisation.
:param value: Valeur du PIN de second facteur du compte.
:return: ``"**********"`` si la valeur est définie, ``None`` sinon.
:rtype: str | None
"""
if value is None:
return None
return "**********"
class CalDAVSettings(BaseSettings): class CalDAVSettings(BaseSettings):
"""Paramètres d'accès au serveur CalDAV de destination. """Paramètres d'accès au serveur CalDAV de destination.
@@ -341,7 +328,6 @@ class Settings(BaseSettings):
self.pronote.ical_url, self.pronote.ical_url,
self.pronote.password, self.pronote.password,
self.pronote.qr_pin, self.pronote.qr_pin,
self.pronote.account_pin,
self.caldav.url, self.caldav.url,
self.caldav.password, self.caldav.password,
self.xmpp.password, self.xmpp.password,
+1 -15
View File
@@ -119,9 +119,6 @@ def _collect_auth_secrets(client: PronoteClient) -> list[str]:
# PIN QR # PIN QR
if settings.qr_pin is not None: if settings.qr_pin is not None:
secrets.append(settings.qr_pin.get_secret_value()) secrets.append(settings.qr_pin.get_secret_value())
# PIN de second facteur du compte
if settings.account_pin is not None:
secrets.append(settings.account_pin.get_secret_value())
# Contenu du fichier QR (jeton, login, url) # Contenu du fichier QR (jeton, login, url)
if settings.qr_code_file is not None: if settings.qr_code_file is not None:
try: try:
@@ -340,12 +337,7 @@ class PronoteClient:
creds = self._auth_state.load() creds = self._auth_state.load()
if creds is not None: if creds is not None:
try: try:
account_pin = ( client = client_class.token_login(**creds)
self._settings.account_pin.get_secret_value()
if self._settings.account_pin is not None
else None
)
client = client_class.token_login(**creds, account_pin=account_pin)
if client.logged_in: if client.logged_in:
self._client = client self._client = client
self._persist_credentials() self._persist_credentials()
@@ -429,16 +421,10 @@ class PronoteClient:
app_uuid = f"pronote-sync-{uuid4().hex}" app_uuid = f"pronote-sync-{uuid4().hex}"
try: try:
account_pin = (
self._settings.account_pin.get_secret_value()
if self._settings.account_pin is not None
else None
)
client = client_class.qrcode_login( client = client_class.qrcode_login(
qr_code=qr_data, qr_code=qr_data,
pin=pin_value, pin=pin_value,
uuid=app_uuid, uuid=app_uuid,
account_pin=account_pin,
) )
except Exception as exc: except Exception as exc:
logger.error( logger.error(
+16 -7
View File
@@ -14,7 +14,7 @@ l'outil et de ne jamais toucher aux événements étrangers du calendrier.
from __future__ import annotations from __future__ import annotations
from datetime import datetime, time from datetime import datetime, timedelta
from typing import cast from typing import cast
from icalendar import Calendar, Component, Event, vDate, vDatetime from icalendar import Calendar, Component, Event, vDate, vDatetime
@@ -33,7 +33,15 @@ MANAGED_VALUE = "v1"
PRODID = "-//pronote-sync//NONSGML v1.0//EN" PRODID = "-//pronote-sync//NONSGML v1.0//EN"
#: Propriétés prises en compte dans la signature sémantique d'un composant. #: Propriétés prises en compte dans la signature sémantique d'un composant.
_SIGNATURE_KEYS: tuple[str, ...] = ("UID", "SUMMARY", "DTSTART", "DTEND", "STATUS", "DESCRIPTION") _SIGNATURE_KEYS: tuple[str, ...] = (
"UID",
"SUMMARY",
"DTSTART",
"DTEND",
"STATUS",
"TRANSP",
"DESCRIPTION",
)
def lesson_to_vevent(lesson: Lesson) -> Event: def lesson_to_vevent(lesson: Lesson) -> Event:
@@ -88,8 +96,9 @@ def lesson_to_vevent(lesson: Lesson) -> Event:
def homework_to_vevent(homework: Homework) -> Event: def homework_to_vevent(homework: Homework) -> Event:
"""Convertit un devoir Pronote en composant VEVENT iCalendar. """Convertit un devoir Pronote en composant VEVENT iCalendar.
Le devoir est représenté comme une tâche (``STATUS:NEEDS-ACTION``) sur la Le devoir est représenté comme un événement toute la journée à la date
journée d'échéance, entre 08:00 et 18:00. d'échéance. Il est transparent pour ne pas bloquer les disponibilités ;
aucun statut de tâche ``VTODO`` n'est ajouté à ce ``VEVENT``.
:param homework: Devoir Pronote à sérialiser. :param homework: Devoir Pronote à sérialiser.
:return: Composant :class:`icalendar.Event` marqué comme géré par l'outil. :return: Composant :class:`icalendar.Event` marqué comme géré par l'outil.
@@ -98,10 +107,10 @@ def homework_to_vevent(homework: Homework) -> Event:
event = Event() event = Event()
event.add("uid", f"homework-{homework.id}") event.add("uid", f"homework-{homework.id}")
event.add("summary", f"Devoir: {homework.subject}") event.add("summary", f"Devoir: {homework.subject}")
event.add("dtstart", vDatetime(datetime.combine(homework.due_on, time(8, 0)))) event.add("dtstart", vDate(homework.due_on))
event.add("dtend", vDatetime(datetime.combine(homework.due_on, time(18, 0)))) event.add("dtend", vDate(homework.due_on + timedelta(days=1)))
event.add("description", homework.text) event.add("description", homework.text)
event.add("status", "NEEDS-ACTION") event.add("transp", "TRANSPARENT")
event.add("categories", ["Pronote", "Devoir"]) event.add("categories", ["Pronote", "Devoir"])
event.add(MANAGED_PROPERTY, MANAGED_VALUE) event.add(MANAGED_PROPERTY, MANAGED_VALUE)
return event return event
+85 -5
View File
@@ -7,7 +7,7 @@ ajouts/mises à jour/suppressions, et la préservation des événements non gér
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import date, datetime
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import pytest import pytest
@@ -122,10 +122,16 @@ class FakeCalendar:
event_start = raw_start.dt event_start = raw_start.dt
event_end = raw_end.dt if raw_end is not None else event_start event_end = raw_end.dt if raw_end is not None else event_start
overlaps = True overlaps = True
if start is not None: if isinstance(event_start, datetime):
overlaps = overlaps and event_end > start if start is not None:
if end is not None: overlaps = overlaps and event_end > start
overlaps = overlaps and event_start < end if end is not None:
overlaps = overlaps and event_start < end
else:
if start is not None:
overlaps = overlaps and event_end > start.date()
if end is not None:
overlaps = overlaps and event_start < end.date()
if overlaps: if overlaps:
results.append(FakeCalendarEvent(ical_text, uid=uid, server=self._server)) results.append(FakeCalendarEvent(ical_text, uid=uid, server=self._server))
break break
@@ -686,6 +692,80 @@ class TestCalDAVSynchronize:
assert result.added == 1 assert result.added == 1
assert len(fake_caldav_server.get_events()) == 1 assert len(fake_caldav_server.get_events()) == 1
def test_legacy_homework_vevent_is_migrated_idempotently_and_removed(
self,
fake_caldav_server: FakeCalDAVServer,
full_settings: Settings,
) -> None:
"""Migre un ancien devoir puis vérifie l'idempotence et la suppression."""
fake_caldav_server._events["homework-hw-legacy"] = _create_vevent_text(
uid="homework-hw-legacy",
summary="Devoir: Histoire",
start=datetime(2026, 1, 20, 8, 0),
end=datetime(2026, 1, 20, 18, 0),
status="NEEDS-ACTION",
managed=True,
)
homework = Homework(
id="hw-legacy",
subject="Histoire",
assigned_on=None,
due_on=date(2026, 1, 20),
text="Lire le chapitre 5",
)
def make_data(homeworks: list[Homework]) -> PronoteData:
"""Construit les données de synchronisation du scénario."""
return PronoteData(
lessons=[],
homeworks=homeworks,
school_events=[],
messages=[],
target_date=date(2026, 1, 20),
generated_at=datetime(2026, 1, 14, 0, 0),
)
first = synchronize(
pronote_data=make_data([homework]),
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
assert first.status == CalDAVSyncStatus.SUCCESS
assert first.added == 0
assert first.updated == 1
assert first.removed == 0
migrated = Calendar.from_ical(fake_caldav_server.get_events()["homework-hw-legacy"])
event = migrated.walk("VEVENT")[0]
assert event.get("DTSTART").dt == date(2026, 1, 20)
assert event.get("DTEND").dt == date(2026, 1, 21)
assert event.get("STATUS") is None
assert str(event.get("TRANSP")) == "TRANSPARENT"
second = synchronize(
pronote_data=make_data([homework]),
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
assert second.status == CalDAVSyncStatus.SKIPPED
assert second.added == 0
assert second.updated == 0
assert second.removed == 0
removed = synchronize(
pronote_data=make_data([]),
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
assert removed.status == CalDAVSyncStatus.SUCCESS
assert removed.added == 0
assert removed.updated == 0
assert removed.removed == 1
assert fake_caldav_server.get_events() == {}
def test_school_event_sync( def test_school_event_sync(
self, self,
fake_caldav_server: FakeCalDAVServer, fake_caldav_server: FakeCalDAVServer,
-17
View File
@@ -207,21 +207,4 @@ def test_qr_pin_in_redaction_secrets(monkeypatch: MonkeyPatch) -> None:
assert "**********" in repr(settings.pronote.qr_pin) assert "**********" in repr(settings.pronote.qr_pin)
def test_account_pin_loaded_as_secretstr_and_redacted(monkeypatch: MonkeyPatch) -> None:
"""Vérifie que ``PRONOTE_ACCOUNT_PIN`` est secret et expurgé.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
monkeypatch.setenv("PRONOTE_ACCOUNT_PIN", "account-pin-42")
settings = load_settings()
assert isinstance(settings.pronote.account_pin, SecretStr)
assert settings.pronote.account_pin.get_secret_value() == "account-pin-42"
assert settings.pronote.account_pin in settings.redaction_secrets()
assert "account-pin-42" not in str(settings)
assert "account-pin-42" not in settings.model_dump_json()
assert "**********" in settings.model_dump_json()
# Ensure trailing newline # Ensure trailing newline
+2 -18
View File
@@ -674,10 +674,8 @@ def test_connect_password_mode_unchanged(
assert mock_client_class.call_count == 1 assert mock_client_class.call_count == 1
@pytest.mark.parametrize("account_pin", [None, "account-pin-42"])
def test_connect_qr_token_with_persisted_creds( def test_connect_qr_token_with_persisted_creds(
mocker: pytest_mock.MockerFixture, mocker: pytest_mock.MockerFixture,
account_pin: str | None,
) -> None: ) -> None:
"""Vérifie le login par token persisté en mode qr_token. """Vérifie le login par token persisté en mode qr_token.
@@ -685,7 +683,6 @@ def test_connect_qr_token_with_persisted_creds(
``token_login`` et le token rotate est resauvegardé. ``token_login`` et le token rotate est resauvegardé.
:param mocker: Fixture pytest-mock pour le mocking. :param mocker: Fixture pytest-mock pour le mocking.
:param account_pin: PIN de second facteur facultatif.
:return: None :return: None
""" """
creds = { creds = {
@@ -710,29 +707,23 @@ def test_connect_qr_token_with_persisted_creds(
ent=None, ent=None,
account_type="parent", account_type="parent",
auth_mode="qr_token", auth_mode="qr_token",
account_pin=SecretStr(account_pin) if account_pin is not None else None,
) )
client = PronoteClient(settings, auth_state=auth_state) client = PronoteClient(settings, auth_state=auth_state)
connected = client._connect() connected = client._connect()
assert connected is mock_client assert connected is mock_client
pronotepy.ParentClient.token_login.assert_called_once_with( # type: ignore[attr-defined] pronotepy.ParentClient.token_login.assert_called_once_with(**creds) # type: ignore[attr-defined]
**creds, account_pin=account_pin
)
auth_state.save.assert_called_once_with(rotated_creds) auth_state.save.assert_called_once_with(rotated_creds)
@pytest.mark.parametrize("account_pin", [None, "account-pin-42"])
def test_connect_qr_token_no_creds_with_qr_code( def test_connect_qr_token_no_creds_with_qr_code(
mocker: pytest_mock.MockerFixture, mocker: pytest_mock.MockerFixture,
tmp_path: Path, tmp_path: Path,
account_pin: str | None,
) -> None: ) -> None:
"""Vérifie l'enrôlement initial par QR code quand aucun token n'est persisté. """Vérifie l'enrôlement initial par QR code quand aucun token n'est persisté.
:param mocker: Fixture pytest-mock pour le mocking. :param mocker: Fixture pytest-mock pour le mocking.
:param tmp_path: Répertoire temporaire de test. :param tmp_path: Répertoire temporaire de test.
:param account_pin: PIN de second facteur facultatif.
:return: None :return: None
""" """
qr_file = tmp_path / "qr_code.json" qr_file = tmp_path / "qr_code.json"
@@ -769,7 +760,6 @@ def test_connect_qr_token_no_creds_with_qr_code(
auth_mode="qr_token", auth_mode="qr_token",
qr_code_file=str(qr_file), qr_code_file=str(qr_file),
qr_pin=SecretStr("123456"), qr_pin=SecretStr("123456"),
account_pin=SecretStr(account_pin) if account_pin is not None else None,
) )
client = PronoteClient(settings, auth_state=auth_state) client = PronoteClient(settings, auth_state=auth_state)
connected = client._connect() connected = client._connect()
@@ -779,7 +769,6 @@ def test_connect_qr_token_no_creds_with_qr_code(
qrcode_login.assert_called_once() # type: ignore[attr-defined] qrcode_login.assert_called_once() # type: ignore[attr-defined]
kwargs = qrcode_login.call_args.kwargs # type: ignore[attr-defined] kwargs = qrcode_login.call_args.kwargs # type: ignore[attr-defined]
assert kwargs["pin"] == "123456" assert kwargs["pin"] == "123456"
assert kwargs["account_pin"] == account_pin
assert kwargs["qr_code"] == { assert kwargs["qr_code"] == {
"login": "testuser", "login": "testuser",
"jeton": "qr-jeton", "jeton": "qr-jeton",
@@ -1178,7 +1167,6 @@ def test_no_raw_secrets_in_logs(
sentinel_token = "SENTINEL_RAW_TOKEN_ALPHA" sentinel_token = "SENTINEL_RAW_TOKEN_ALPHA"
sentinel_pin = "SENTINEL_RAW_PIN_BRAVO" sentinel_pin = "SENTINEL_RAW_PIN_BRAVO"
sentinel_jeton = "SENTINEL_RAW_JETON_CHARLIE" sentinel_jeton = "SENTINEL_RAW_JETON_CHARLIE"
sentinel_account_pin = "SENTINEL_RAW_ACCOUNT_PIN_DELTA"
qr_file = tmp_path / "qr_code.json" qr_file = tmp_path / "qr_code.json"
qr_file.write_text( qr_file.write_text(
@@ -1202,8 +1190,7 @@ def test_no_raw_secrets_in_logs(
mocker.patch( mocker.patch(
"pronotepy.ParentClient.token_login", "pronotepy.ParentClient.token_login",
side_effect=pronotepy.PronoteAPIError( side_effect=pronotepy.PronoteAPIError(
f"login refusé {sentinel_token} puis {sentinel_pin} puis {sentinel_jeton} " f"login refusé {sentinel_token} puis {sentinel_pin} puis {sentinel_jeton}"
f"puis {sentinel_account_pin}"
), ),
) )
@@ -1216,7 +1203,6 @@ def test_no_raw_secrets_in_logs(
auth_mode="qr_token", auth_mode="qr_token",
qr_code_file=str(qr_file), qr_code_file=str(qr_file),
qr_pin=SecretStr(sentinel_pin), qr_pin=SecretStr(sentinel_pin),
account_pin=SecretStr(sentinel_account_pin),
) )
client = PronoteClient(settings, auth_state=auth_state) client = PronoteClient(settings, auth_state=auth_state)
@@ -1228,12 +1214,10 @@ def test_no_raw_secrets_in_logs(
assert sentinel_token not in message assert sentinel_token not in message
assert sentinel_pin not in message assert sentinel_pin not in message
assert sentinel_jeton not in message assert sentinel_jeton not in message
assert sentinel_account_pin not in message
assert caplog.text assert caplog.text
assert sentinel_token not in caplog.text assert sentinel_token not in caplog.text
assert sentinel_pin not in caplog.text assert sentinel_pin not in caplog.text
assert sentinel_jeton not in caplog.text assert sentinel_jeton not in caplog.text
assert sentinel_account_pin not in caplog.text
# --- Persistence of credentials after data operations --- # --- Persistence of credentials after data operations ---
+7 -6
View File
@@ -239,15 +239,16 @@ def test_homework_to_vevent_uid_prefix() -> None:
assert str(event.get("UID")) == "homework-HW-5678" assert str(event.get("UID")) == "homework-HW-5678"
def test_homework_to_vevent_status() -> None: def test_homework_to_vevent_is_transparent_without_task_status() -> None:
"""Vérifie qu'un devoir a STATUS=NEEDS-ACTION. """Vérifie qu'un devoir VEVENT est transparent et sans statut VTODO.
:return: None :return: None
""" """
homework = _make_homework() homework = _make_homework()
event = homework_to_vevent(homework) event = homework_to_vevent(homework)
assert str(event.get("STATUS")) == "NEEDS-ACTION" assert event.get("STATUS") is None
assert str(event.get("TRANSP")) == "TRANSPARENT"
def test_homework_to_vevent_categories() -> None: def test_homework_to_vevent_categories() -> None:
@@ -265,15 +266,15 @@ def test_homework_to_vevent_categories() -> None:
def test_homework_to_vevent_dtstart_dtend() -> None: def test_homework_to_vevent_dtstart_dtend() -> None:
"""Vérifie que DTSTART et DTEND couvrent la journée d'échéance (08:00-18:00). """Vérifie que DTSTART et DTEND encadrent la journée d'échéance.
:return: None :return: None
""" """
homework = _make_homework(due_on=date(2026, 1, 20)) homework = _make_homework(due_on=date(2026, 1, 20))
event = homework_to_vevent(homework) event = homework_to_vevent(homework)
assert event.get("DTSTART").dt == datetime(2026, 1, 20, 8, 0) assert event.get("DTSTART").dt == date(2026, 1, 20)
assert event.get("DTEND").dt == datetime(2026, 1, 20, 18, 0) assert event.get("DTEND").dt == date(2026, 1, 21)
def test_homework_to_vevent_summary() -> None: def test_homework_to_vevent_summary() -> None: