Author SHA1 Message Date
Codex 6575a5f60b fix(caldav): rendre les devoirs transparents 2026-09-12 15:43:12 +02:00
9 changed files with 119 additions and 200 deletions
-6
View File
@@ -66,12 +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.
Si le blog RSS est activé, ses GUID ne sont acquittés qu'après confirmation de
l'envoi XMPP. Un refus, une exception, l'absence de canal ou un `--dry-run`
laisse donc les articles récupérables à l'exécution suivante ; les en-têtes
HTTP associés à ces articles suivent la même règle pour éviter un `304` qui
masquerait une livraison non confirmée.
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
+2 -7
View File
@@ -230,13 +230,11 @@ class PipelineRunner:
data = normalize_step(fetched, generated_at=now) data = normalize_step(fetched, generated_at=now)
try: try:
blog_result = fetch_blog_step(self._blog_client, self._blog_state) blog_articles = fetch_blog_step(self._blog_client, self._blog_state)
blog_articles = list(blog_result.articles)
except PipelineCriticalError: except PipelineCriticalError:
raise raise
except Exception as exc: except Exception as exc:
self._warn("fetch_blog", self._redact(exc)) self._warn("fetch_blog", self._redact(exc))
blog_result = None
blog_articles = [] blog_articles = []
try: try:
@@ -292,11 +290,8 @@ class PipelineRunner:
) )
if self._channel is not None and not self._dry_run: if self._channel is not None and not self._dry_run:
try: try:
delivered = send_step(self._channel, message) if not send_step(self._channel, message):
if not delivered:
self._warn("send", "Le canal XMPP a refusé l'envoi") self._warn("send", "Le canal XMPP a refusé l'envoi")
elif blog_result is not None and self._blog_state is not None:
self._blog_state.acknowledge(blog_result)
except PipelineCriticalError: except PipelineCriticalError:
raise raise
except Exception as exc: except Exception as exc:
+9 -13
View File
@@ -2,28 +2,23 @@
from __future__ import annotations from __future__ import annotations
from pronote_sync.sources.blog.result import BlogRSSFetchResult from pronote_sync.models.blog import BlogArticle
from pronote_sync.sources.blog.rss import BlogRSSClient from pronote_sync.sources.blog.rss import BlogRSSClient
from pronote_sync.sources.blog.state import BlogRSSState from pronote_sync.sources.blog.state import BlogRSSState
from pronote_sync.utils.redaction import redact_exception from pronote_sync.utils.redaction import redact_exception
def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) -> BlogRSSFetchResult: def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) -> list[BlogArticle]:
"""Récupère les articles RSS nouveaux sans les acquitter. """Récupère les articles RSS nouveaux en conservant l'état du client.
L'état des GUID est acquitté séparément par le pipeline après confirmation
de la livraison XMPP. Les en-têtes de cache d'une réponse sans article
peuvent être conservés immédiatement, car aucune livraison n'est alors en
attente.
:param client: Client RSS configuré, ou ``None`` lorsque le blog est désactivé. :param client: Client RSS configuré, ou ``None`` lorsque le blog est désactivé.
:param state: État de déduplication et de cache HTTP associé au run. :param state: État de déduplication et de cache HTTP associé au run.
:return: Résultat de récupération, incluant les métadonnées de cache. :return: Nouveaux articles du blog.
:rtype: BlogRSSFetchResult :rtype: list[BlogArticle]
:raises RuntimeError: Si la récupération RSS injectée échoue. :raises RuntimeError: Si la récupération RSS injectée échoue.
""" """
if client is None or state is None: if client is None or state is None:
return BlogRSSFetchResult() return []
try: try:
etag, last_modified = state.get_cache_headers() etag, last_modified = state.get_cache_headers()
result = client.fetch_and_parse( result = client.fetch_and_parse(
@@ -31,8 +26,9 @@ def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) ->
) )
if result.error is not None: if result.error is not None:
raise RuntimeError(result.error) from None raise RuntimeError(result.error) from None
if not result.not_modified and not result.articles: if not result.not_modified:
state.add_guids(article.id for article in result.articles)
state.update_cache_headers(result.etag, result.last_modified) state.update_cache_headers(result.etag, result.last_modified)
return result return list(result.articles)
except Exception as exc: except Exception as exc:
raise RuntimeError(f"Récupération du blog échouée : {redact_exception(exc)}") from None raise RuntimeError(f"Récupération du blog échouée : {redact_exception(exc)}") from None
-17
View File
@@ -19,7 +19,6 @@ import logging
from collections.abc import Iterable from collections.abc import Iterable
from pathlib import Path from pathlib import Path
from pronote_sync.sources.blog.result import BlogRSSFetchResult
from pronote_sync.utils.redaction import redact_exception, redact_secrets from pronote_sync.utils.redaction import redact_exception, redact_secrets
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -157,22 +156,6 @@ class BlogRSSState:
self._known_guids.update(new_guids) self._known_guids.update(new_guids)
self._save() self._save()
def acknowledge(self, result: BlogRSSFetchResult) -> None:
"""Acquitte une récupération RSS après sa livraison confirmée.
Les GUID et les en-têtes de cache sont enregistrés ensemble afin qu'un
article dont la livraison a échoué reste récupérable à l'exécution
suivante. Une réponse ``304 Not Modified`` n'a rien à acquitter.
:param result: Résultat RSS livré avec succès.
"""
if result.not_modified:
return
self._known_guids.update(article.id for article in result.articles)
self._etag = result.etag
self._last_modified = result.last_modified
self._save()
def get_cache_headers(self) -> tuple[str | None, str | None]: def get_cache_headers(self) -> tuple[str | None, str | None]:
"""Renvoie les en-têtes de cache HTTP mémorisés. """Renvoie les en-têtes de cache HTTP mémorisés.
+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
+81 -1
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 isinstance(event_start, datetime):
if start is not None: if start is not None:
overlaps = overlaps and event_end > start overlaps = overlaps and event_end > start
if end is not None: if end is not None:
overlaps = overlaps and event_start < end 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,
-104
View File
@@ -1040,110 +1040,6 @@ def test_runner_blog_success_delivers_articles_into_xmpp_message_external_info(
assert xmpp_message.external_info.blog_articles[0].title == "Test Article" assert xmpp_message.external_info.blog_articles[0].title == "Test Article"
@pytest.mark.parametrize(
("channel_kind", "dry_run", "should_acknowledge"),
[
("success", False, True),
("false", False, False),
("exception", False, False),
("none", False, False),
("success", True, False),
],
)
def test_runner_acknowledges_blog_only_after_confirmed_xmpp_delivery(
pipeline_inputs: tuple[Lesson, Homework],
tmp_path: Any,
channel_kind: str,
dry_run: bool,
should_acknowledge: bool,
) -> None:
"""Les GUID RSS restent rejouables tant que XMPP n'a pas confirmé l'envoi.
:param pipeline_inputs: Données Pronote de test.
:param tmp_path: Répertoire temporaire pour l'état RSS.
:param channel_kind: Comportement du canal XMPP simulé.
:param dry_run: Active ou non le mode simulation.
:param should_acknowledge: Indique si l'état RSS doit être acquitté.
"""
lesson, homework = pipeline_inputs
calls: list[str] = []
state_file = tmp_path / "blog-state.json"
class SuccessfulBlogClient:
"""Client RSS renvoyant un article non encore livré."""
def fetch_and_parse(
self,
*,
known_guids: frozenset[str] | None = None,
etag: str | None = None,
last_modified: str | None = None,
) -> BlogRSSFetchResult:
"""Retourne un article et des en-têtes de cache déterministes.
:param known_guids: GUID déjà connus, ignorés dans ce faux client.
:param etag: ETag mémorisé, ignoré dans ce faux client.
:param last_modified: Date HTTP mémorisée, ignorée dans ce faux client.
:return: Résultat RSS avec un article à livrer.
:rtype: BlogRSSFetchResult
"""
del known_guids, etag, last_modified
return BlogRSSFetchResult(
articles=(
BlogArticle(
id="article-to-deliver",
title="Article à livrer",
url="https://example.com/article-to-deliver",
published_at=datetime(2026, 9, 8, 12, 0),
updated_at=None,
category=None,
author=None,
content_html="<p>Contenu</p>",
content_text="Contenu",
),
),
etag="etag-after-delivery",
last_modified="Tue, 08 Sep 2026 12:00:00 GMT",
)
channel: Any
if channel_kind == "success":
channel = StubChannel(calls)
elif channel_kind == "false":
channel = FailingChannel()
elif channel_kind == "exception":
channel = ExceptionalChannel()
else:
channel = None
runner = PipelineRunner(
settings=Settings(blog=Settings().blog.model_copy(update={"enabled": True})),
pronote_fetcher=StubFetcher(calls, lesson, homework),
caldav_synchronizer=lambda data, settings: successful_sync_result(),
agenda_comparator=cast("AgendaComparator | None", StubComparator(calls)),
blog_client=cast("BlogRSSClient | None", SuccessfulBlogClient()),
blog_state=BlogRSSState(state_file),
channel=channel,
dry_run=dry_run,
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data, errors = runner.run()
assert data is not None
if should_acknowledge:
acknowledged_state = BlogRSSState(state_file)
assert acknowledged_state.get_known_guids() == frozenset({"article-to-deliver"})
assert acknowledged_state.get_cache_headers() == (
"etag-after-delivery",
"Tue, 08 Sep 2026 12:00:00 GMT",
)
else:
assert not state_file.exists()
if channel_kind in {"false", "exception"}:
assert any(error.step == "send" for error in errors)
def test_runner_secret_redaction_in_pipeline_errors( def test_runner_secret_redaction_in_pipeline_errors(
pipeline_inputs: tuple[Lesson, Homework], pipeline_inputs: tuple[Lesson, Homework],
) -> None: ) -> None:
-35
View File
@@ -14,14 +14,11 @@ Tous les tests utilisent des fichiers temporaires via la fixture ``tmp_path``.
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
from pronote_sync.models.blog import BlogArticle
from pronote_sync.sources.blog.result import BlogRSSFetchResult
from pronote_sync.sources.blog.state import BlogRSSState from pronote_sync.sources.blog.state import BlogRSSState
@@ -119,38 +116,6 @@ def test_add_guids_empty_noop(tmp_path: Path) -> None:
assert state_file.read_text(encoding="utf-8") == original_content assert state_file.read_text(encoding="utf-8") == original_content
def test_acknowledge_persists_guids_and_cache_headers_together(tmp_path: Path) -> None:
"""Vérifie l'acquittement atomique après une livraison confirmée.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
state = BlogRSSState(state_file)
article = BlogArticle(
id="guid-1",
title="Article",
url="https://example.com/article",
published_at=datetime(2026, 9, 12, 8, 0, tzinfo=UTC),
updated_at=None,
category=None,
author=None,
content_html="<p>Contenu</p>",
content_text="Contenu",
)
state.acknowledge(
BlogRSSFetchResult(
articles=(article,),
etag="etag-1",
last_modified="Sat, 12 Sep 2026 08:00:00 GMT",
)
)
assert state.get_known_guids() == frozenset({"guid-1"})
assert state.get_cache_headers() == ("etag-1", "Sat, 12 Sep 2026 08:00:00 GMT")
def test_state_load_persisted_guids(tmp_path: Path) -> None: def test_state_load_persisted_guids(tmp_path: Path) -> None:
"""Vérifie que les GUID persistés sont rechargés dans une nouvelle instance. """Vérifie que les GUID persistés sont rechargés dans une nouvelle instance.
+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: