# 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