Files
ObsiGate/backend/hub_reglages.py
T
Spark b88191ffcb
CI / lint (pull_request) Successful in 1m16s
CI / security (pull_request) Successful in 19s
CI / test (pull_request) Successful in 3m41s
CI / build (pull_request) Successful in 13s
CI / e2e (pull_request) Failing after 14m58s
feat: HUB-21 — réglages généraux du Hub consommés par ObsiGate (bannière + drapeau recherche_partout) #229
2026-11-06 17:02:04 +00:00

155 lines
5.5 KiB
Python

# backend/hub_reglages.py
# Hub HUB-21 (propagation) — Réglages généraux du Hub consommés par
# ObsiGate, sur le patron posé par l'hôte pilote TikTak (0.61.22).
#
# Le Hub expose ``GET {hub}/api/reglages/effectifs`` — endpoint
# **public** (aucun cookie relayé), ``Accept: application/json``,
# délai 2 s. Réponse 200, forme figée (liste fermée D6(a)) :
# ``{"recherche_partout": bool, "banniere": str,
# "domaine_defaut": "pro"|"famille"|"perso"|null,
# "ttl_secondes": 60}``.
#
# Cache mémoire 60 s, horodatage posé **avant** l'appel : un échec
# conserve l'ancien cache et ne déclenche pas de rafale de requêtes
# vers un Hub en panne.
#
# **Repli intégral** : Hub injoignable, en erreur ou réponse de forme
# inattendue → les **défauts actuels** (``recherche_partout: true``,
# ``banniere: ""``, ``domaine_defaut: null``), c'est-à-dire le
# comportement d'ObsiGate avant HUB-21. Ce module ne lève jamais.
#
# Consommation chez ObsiGate :
# - ``banniere`` : injectée côté serveur sous la barre commune
# (page principale et page /recherche), texte échappé ;
# - ``recherche_partout`` : posé en attribut ``data-recherche-partout``
# de la page /recherche — si faux, la partie fédérée est désactivée
# (la recherche locale ObsiGate continue) ;
# - ``domaine_defaut`` : **non consommé** — ObsiGate n'a pas de
# notion de domaine propre ; le sélecteur de domaine de la barre
# commune pilote le panneau Spark, pas les vues d'ObsiGate. La
# valeur est lue et validée, sans effet local.
#
# URL du Hub : `draco_config.app_url("hub")` — la même résolution que
# la barre commune (#223) : variable explicite d'abord, dérivation
# par l'hôte sœur ensuite, défaut officiel enfin. Jamais d'URL dev
# figée.
from __future__ import annotations
import json
import logging
import threading
import time
import urllib.request
logger = logging.getLogger("obsigate.hub_reglages")
#: Réglages par défaut = comportement actuel d'ObsiGate (repli).
DEFAUTS = {"recherche_partout": True, "banniere": "", "domaine_defaut": None}
#: Domaines reconnus du contrat D6(a).
DOMAINES = ("pro", "famille", "perso")
#: TTL du cache, aligné sur ``ttl_secondes`` annoncé par le Hub (60 s).
CACHE_TTL_SECONDS = 60
#: Délai maximal de la consultation du Hub (contrat : 2 s).
DELAI_SECONDS = 2
_lock = threading.Lock()
_cache: dict | None = None # derniers réglages validés
_cache_at: float | None = None # horodatage (monotone) du dernier appel
def _hub_url() -> str:
"""Base publique du Hub, résolue comme pour la barre commune."""
from backend.draco_config import app_url
return app_url("hub")
def _valider(donnees) -> dict | None:
"""Valide la forme figée du contrat ; ``None`` si inattendue."""
if not isinstance(donnees, dict):
return None
recherche = donnees.get("recherche_partout")
banniere = donnees.get("banniere")
domaine = donnees.get("domaine_defaut")
if not isinstance(recherche, bool):
return None
if not isinstance(banniere, str):
return None
if domaine is not None and domaine not in DOMAINES:
return None
return {
"recherche_partout": recherche,
"banniere": banniere,
"domaine_defaut": domaine,
}
def _consulter_hub(url: str) -> dict | None:
"""Appelle le Hub et retourne les réglages validés, ou ``None``.
Toute erreur (réseau, délai dépassé, statut ≠ 200, JSON
illisible, forme hors contrat) retourne ``None`` : repli.
Endpoint public : aucun cookie n'est relayé.
"""
requete = urllib.request.Request(
url, method="GET", headers={"Accept": "application/json"})
try:
with urllib.request.urlopen(requete, timeout=DELAI_SECONDS) as reponse:
if reponse.status != 200:
return None
donnees = json.loads(reponse.read().decode("utf-8"))
except Exception: # les réglages ne cassent jamais une page
return None
return _valider(donnees)
def reglages_effectifs() -> dict:
"""Réglages généraux effectifs pour ObsiGate.
Réponse du Hub validée et mise en cache 60 s ; à défaut (Hub
muet, en erreur ou forme inattendue), l'ancien cache s'il
existe, sinon les défauts actuels. Ne lève jamais.
"""
global _cache, _cache_at
with _lock:
if _cache is not None and _cache_at is not None \
and time.monotonic() - _cache_at < CACHE_TTL_SECONDS:
return dict(_cache)
# Horodatage posé AVANT l'appel (patron du pilote) : l'ancien
# cache reste en vigueur, un échec ne déclenche pas de rafale.
_cache_at = time.monotonic()
url = f"{_hub_url().rstrip('/')}/api/reglages/effectifs"
reglages = _consulter_hub(url)
if reglages is None:
logger.warning(
"Réglages généraux du Hub indisponibles (%s) — "
"comportement actuel conservé", url)
with _lock:
return dict(_cache) if _cache is not None else dict(DEFAUTS)
with _lock:
_cache = reglages
_cache_at = time.monotonic()
return dict(reglages)
def banniere() -> str:
"""Texte de la bannière d'annonce (``""`` = aucune bannière)."""
return reglages_effectifs()["banniere"]
def recherche_partout() -> bool:
"""Drapeau de la recherche fédérée (défaut actuel : actif)."""
return bool(reglages_effectifs()["recherche_partout"])
def reinitialiser_cache() -> None:
"""Vide le cache (tests)."""
global _cache, _cache_at
with _lock:
_cache = None
_cache_at = None