diff --git a/.env.example b/.env.example index f3c8488..3eef69d 100644 --- a/.env.example +++ b/.env.example @@ -94,7 +94,8 @@ AI_BASE_URL=https://api.openai.com/v1 # --- Blog --- BLOG_ENABLED=false -BLOG_RSS_URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2 +BLOG_ENDPOINT__URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2 +# Ancien nom temporairement supporté avec un avertissement de dépréciation : BLOG_RSS_URL # --- Divers --- DRY_RUN=false diff --git a/.secrets.baseline b/.secrets.baseline index 6ea4938..1612c70 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -140,7 +140,7 @@ "filename": "GUIDE_DEV_PYTHON.md", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "is_verified": false, - "line_number": 5186 + "line_number": 5183 } ], "tests/unit/test_caldav_gateway.py": [ @@ -185,5 +185,5 @@ } ] }, - "generated_at": "2026-09-12T22:12:56Z" + "generated_at": "2026-09-13T09:52:29Z" } diff --git a/GUIDE_DEV_PYTHON.md b/GUIDE_DEV_PYTHON.md index c216b09..9610fa3 100644 --- a/GUIDE_DEV_PYTHON.md +++ b/GUIDE_DEV_PYTHON.md @@ -1113,7 +1113,7 @@ La déduplication des articles du blog repose sur leur **GUID** (ou leur URL si Aucun fichier d'état local n'est utilisé : l'état est géré en mémoire par run. # Initialisation -rss_client = BlogRSSClient(rss_url=settings.blog.rss_url) +rss_client = BlogRSSClient(rss_url=settings.blog.endpoint.url.get_secret_value()) blog_state = ## (section obsolète supprimée)() # Récupération des nouveaux articles @@ -1218,7 +1218,7 @@ Ajouter les variables suivantes dans la configuration : | **Variable** | **Description** | **Valeur par défaut** | **Type** | |----------------------------|-------------------------------------------------------------------------------|-----------------------|-------------------| | `BLOG_ENABLED` | Activer la récupération du blog. | `False` | `bool` | -| `BLOG_RSS_URL` | URL du flux RSS du blog. | `https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2` | `str` | +| `BLOG_ENDPOINT__URL` | URL du flux RSS via l'endpoint commun (masquée en `SecretStr`). | `https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2` | `ExternalEndpoint` | #### 5 bis.9.2 Modèle Pydantic pour la configuration du blog @@ -1230,10 +1230,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class BlogSettings(BaseSettings): model_config = SettingsConfigDict(env_prefix="BLOG_", env_file=".env", extra="ignore") enabled: bool = Field(False, description="Activer la récupération du blog") - rss_url: str = Field( - "https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2", - description="URL du flux RSS du blog", - ) + endpoint: ExternalEndpoint ``` **Intégration dans `Settings`** : @@ -1253,7 +1250,7 @@ class Settings(BaseSettings): ```ini # --- Blog du collège --- BLOG_ENABLED=true -BLOG_RSS_URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2 +BLOG_ENDPOINT__URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2 ``` --- diff --git a/pronote_sync/config/settings.py b/pronote_sync/config/settings.py index 9791542..fc193b7 100644 --- a/pronote_sync/config/settings.py +++ b/pronote_sync/config/settings.py @@ -352,10 +352,60 @@ class BlogSettings(BaseSettings): ``BLOG_``. """ - model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="BLOG_") + model_config = SettingsConfigDict( + env_file=".env", + env_nested_delimiter="__", + extra="ignore", + env_prefix="BLOG_", + ) enabled: bool = False - rss_url: str = "https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2" + endpoint: ExternalEndpoint = Field( + default_factory=lambda: ExternalEndpoint( + url=SecretStr("https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2") + ) + ) + rss_url: str | None = Field( + default=None, + exclude=True, + deprecated="Utiliser endpoint.url à la place (BLOG_RSS_URL obsolète).", + ) + + @model_validator(mode="before") + @classmethod + def _migrate_legacy_rss_url(cls, data: object) -> object: + """Migre ``rss_url`` vers l'endpoint commun avec un avertissement. + + :param data: Données brutes du modèle. + :return: Données complétées avec ``endpoint`` si nécessaire. + :rtype: object + """ + if not isinstance(data, dict) or data.get("rss_url") is None: + return data + migrated_data = data.copy() + warnings.warn( + "BLOG_RSS_URL est obsolète : utiliser BLOG_ENDPOINT__URL.", + DeprecationWarning, + stacklevel=2, + ) + if migrated_data.get("endpoint") is None: + migrated_data["endpoint"] = {"url": migrated_data["rss_url"]} + return migrated_data + + @model_validator(mode="after") + def _validate_endpoint_policy(self) -> BlogSettings: + """Refuse les transports non sûrs pour le flux RSS de production. + + Le transport ``file`` reste autorisé pour les fixtures locales. + + :return: Instance validée inchangée. + :rtype: BlogSettings + :raises ValueError: Si le schéma n'est ni ``https`` ni ``file``. + """ + scheme = urlparse(self.endpoint.url.get_secret_value()).scheme + if scheme not in {"https", "file"}: + raise ValueError("URL RSS invalide : HTTPS ou file requis") from None + return self class AppSettings(BaseSettings): @@ -416,5 +466,6 @@ class Settings(BaseSettings): self.caldav.password, self.xmpp.password, self.ai.api_key, + self.blog.endpoint.url, ] return tuple(dict.fromkeys(secret for secret in secrets if secret is not None)) diff --git a/pronote_sync/pipeline/run.py b/pronote_sync/pipeline/run.py index 83c8c1f..e355f09 100644 --- a/pronote_sync/pipeline/run.py +++ b/pronote_sync/pipeline/run.py @@ -138,7 +138,11 @@ class PipelineRunner: comparator = ( AgendaComparator(theoretical_provider) if theoretical_provider is not None else None ) - blog_client = BlogRSSClient(settings.blog.rss_url) if settings.blog.enabled else None + blog_client = ( + BlogRSSClient(settings.blog.endpoint.url.get_secret_value()) + if settings.blog.enabled + else None + ) blog_state = ( BlogRSSState(persistence_enabled=persistence_enabled) if settings.blog.enabled else None )