feat: HUB-21 — réglages généraux du Hub consommés par ObsiGate (bannière + drapeau recherche_partout) #229
This commit is contained in:
+4
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.85.1**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.85.2**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,9 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.85.2] — 2026-11-06
|
||||
|
||||
- **HUB-21 — Consommation des réglages généraux du Hub par ObsiGate (propagation du pilote TikTak 0.61.22, liste fermée D6(a), #229)** : nouveau client `backend/hub_reglages.py` — consultation côté serveur de `GET {hub}/api/reglages/effectifs` (endpoint public, aucun cookie, `Accept: application/json`, délai 2 s), forme validée strictement, cache mémoire 60 s sur le patron du pilote (horodatage posé avant l'appel : un échec conserve l'ancien cache, pas de rafale) ; URL du Hub résolue comme la barre commune (`draco_config.app_url("hub")` — jamais d'URL dev figée). Toute erreur (réseau, statut, JSON, forme) = **repli intégral** sur les défauts actuels. **Bannière** : fragment injecté côté serveur sous la barre commune (page principale `_index_html` et page `/recherche`), texte échappé (jamais de HTML injecté), bouton fermer avec mémorisation du texte fermé en `sessionStorage` (clé `obsigate_banniere_masquee`) ; bannière vide = aucun fragment, zéro changement visuel ; styles `.banniere-hub*` dans `frontend/style.css` (variables CSS du thème). **Drapeau `recherche_partout`** : `serve_recherche_page` remplace le jeton `{{RECHERCHE_PARTOUT}}` rendu en `data-recherche-partout` du `<main id="recherche">` de `frontend/recherche.html` ; si faux, le JS vendangé `frontend/js/recherche.js` (même modification dans les 4 hôtes applicatifs) n'interroge plus les sœurs ni la console — la recherche locale ObsiGate continue ; vrai ou Hub muet = comportement actuel. **`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 par le client, sans effet local. Le libellé « Fermer l'annonce » est un attribut d'accessibilité du fragment injecté côté serveur (patron des autres hôtes), pas un texte d'interface du SPA : pas de clé i18n ajoutée. Fiche : `docs/features/hub-reglages-obsigate-229.md`. Tests : `tests/test_hub_reglages.py` (client : contrat exact, forme inattendue, erreurs, cache 60 s, ancien cache sans rafale ; intégration : bannière présente et échappée sur `/` et `/recherche`, absente si vide ou Hub muet, attribut du drapeau selon la valeur) ; `tests/frontend/recherche.test.mjs` étendu (`federationAutorisee`).
|
||||
## [2.85.1] — 2026-11-06
|
||||
|
||||
- **HUB-19 — Propagation des rôles du Hub à ObsiGate (suite du pilote ShaarIt, fusion Hub v2 §2.2, décision D3(a), #228)** : ObsiGate, à la fois émettrice et consommatrice du SSO, consomme à son tour le registre des rôles du Hub. Nouveau client `backend/hub_roles.py` : à l'ouverture de session draco (validation locale `draco_session.user_for_draco`), consultation de `GET {hub}/api/roles/obsigate` (en-tête Cookie complet relayé, `Accept: application/json`, délai 2 s), cache mémoire 60 s par `sub` sur le patron du pilote (horodatage posé avant l'appel : un échec conserve l'ancien cache, pas de rafale) ; URL du Hub résolue comme la barre commune (`draco_config.app_url("hub")` : `DRACO_HUB_URL` explicite, dérivation par l'hôte sœur, défaut officiel — jamais d'URL dev figée). **Effets, sessions établies par draco seulement** : niveau connu `aucun` → session draco refusée, `require_auth` répond 403 avec un message explicite en français (l'authentification native d'ObsiGate — l'émettrice, son accès propre est le secours — n'est jamais touchée) ; routes d'administration `/api/auth/admin/*` : nouvelle garde `require_admin_hub` — canal draco + rôle connu ⇒ niveau `administrateur` du registre exigé, sessions natives et rôle inconnu (Hub injoignable ou en erreur) ⇒ règles actuelles inchangées, aucun verrouillage nouveau, et le registre n'élève aucun privilège local. Validation locale du jeton, émission, révocations et renouvellement inchangés ; aucun rôle dans le jeton. Aucun impact frontend (pas d'i18n). Fiche : `docs/features/hub-roles-obsigate-228.md`. Tests : `tests/test_hub_roles.py` (client : parsing/cache/repli/résolution d'URL ; intégration : refus draco, garde admin). **Hypothèse de version** : branche depuis le main 2.84.1, cible 2.85.1 — rebasée sur la 2.85.0 (PR « API sessions HUB-22 » fusionnée d'abord) avant fusion.
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@
|
||||
|
||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.85.1).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.85.2).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.85.1 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.85.2 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.85.1).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.85.2).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.85.1 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.85.2 | Last updated: September 2026*
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# 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
|
||||
@@ -859,6 +859,49 @@ def _html_with_nonce(request: Request, name: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _fragment_banniere_hub() -> str:
|
||||
"""Bandeau d'annonce des réglages généraux du Hub (HUB-21).
|
||||
|
||||
Retourne le fragment HTML à insérer sous la barre commune, ou
|
||||
``""`` quand le Hub n'annonce rien (bannière vide ou Hub muet —
|
||||
repli intégral du client :mod:`backend.hub_reglages`). Le texte
|
||||
est **échappé** (jamais de HTML injecté). Le petit script inline
|
||||
(le nonce CSP est ajouté par l'appelant via ``inject_csp_nonce``)
|
||||
révèle le bandeau sauf si le même texte a déjà été fermé dans la
|
||||
session du navigateur (``sessionStorage``, clé
|
||||
``obsigate_banniere_masquee``) : une nouvelle annonce réapparaît.
|
||||
"""
|
||||
import html as _html
|
||||
|
||||
from backend import hub_reglages
|
||||
|
||||
texte = hub_reglages.banniere()
|
||||
if not texte:
|
||||
return ""
|
||||
return (
|
||||
'<div class="banniere-hub" id="banniere-hub" role="status" hidden>'
|
||||
f'<span class="banniere-hub-texte">{_html.escape(texte)}</span>'
|
||||
'<button type="button" class="banniere-hub-fermer"'
|
||||
' id="banniere-hub-fermer" aria-label="Fermer l\'annonce">✕</button>'
|
||||
"</div>"
|
||||
"<script>(function(){try{"
|
||||
'var b=document.getElementById("banniere-hub");if(!b)return;'
|
||||
'var t=b.querySelector(".banniere-hub-texte").textContent;'
|
||||
'if(sessionStorage.getItem("obsigate_banniere_masquee")===t)'
|
||||
"{b.remove();return;}"
|
||||
"b.hidden=false;"
|
||||
'document.getElementById("banniere-hub-fermer")'
|
||||
'.addEventListener("click",function(){'
|
||||
'try{sessionStorage.setItem("obsigate_banniere_masquee",t);}'
|
||||
"catch(e){}"
|
||||
"b.remove();});"
|
||||
"}catch(e){"
|
||||
'var b2=document.getElementById("banniere-hub");'
|
||||
"if(b2)b2.hidden=false;"
|
||||
"}})();</script>"
|
||||
)
|
||||
|
||||
|
||||
def _index_html(request: Request) -> str:
|
||||
"""``index.html`` + injection des URL publiques de l'écosystème (#223).
|
||||
|
||||
@@ -886,6 +929,15 @@ def _index_html(request: Request) -> str:
|
||||
html = html.replace("</head>", script + "</head>", 1)
|
||||
else: # repli défensif : tout en tête de document
|
||||
html = script + html
|
||||
# Bannière d'annonce du Hub (HUB-21) : sous la barre commune,
|
||||
# juste avant son rappel ; vide = aucun changement visuel.
|
||||
fragment = _fragment_banniere_hub()
|
||||
if fragment:
|
||||
marqueur = '<div class="draco-bar-rappel"'
|
||||
if marqueur in html:
|
||||
html = html.replace(marqueur, fragment + marqueur, 1)
|
||||
elif "</header>" in html:
|
||||
html = html.replace("</header>", "</header>" + fragment, 1)
|
||||
return inject_csp_nonce(html, request.state.csp_nonce)
|
||||
|
||||
if FRONTEND_DIR.exists():
|
||||
@@ -989,6 +1041,15 @@ if FRONTEND_DIR.exists():
|
||||
if not recherche_file.exists():
|
||||
raise HTTPException(status_code=404, detail="Recherche page not found")
|
||||
html = recherche_file.read_text(encoding="utf-8")
|
||||
# Drapeau « Rechercher partout » des réglages généraux du Hub
|
||||
# (HUB-21) : faux = la partie fédérée de la page est désactivée
|
||||
# par le JS vendangé, la recherche locale continue ; Hub muet
|
||||
# = défaut actuel (actif).
|
||||
from backend import hub_reglages
|
||||
|
||||
html = html.replace(
|
||||
"{{RECHERCHE_PARTOUT}}",
|
||||
"true" if hub_reglages.recherche_partout() else "false")
|
||||
urls = urls_publiques()
|
||||
for jeton, cle in (
|
||||
("{{URL_OBSIGATE}}", "obsigate"),
|
||||
@@ -1008,6 +1069,11 @@ if FRONTEND_DIR.exists():
|
||||
html = html.replace("</head>", script + "</head>", 1)
|
||||
else:
|
||||
html = script + html
|
||||
# Bannière d'annonce du Hub (HUB-21) : sous la barre commune ;
|
||||
# vide = aucun changement visuel.
|
||||
fragment = _fragment_banniere_hub()
|
||||
if fragment and "</header>" in html:
|
||||
html = html.replace("</header>", "</header>" + fragment, 1)
|
||||
html = inject_csp_nonce(html, request.state.csp_nonce)
|
||||
soeurs = " ".join(sorted({
|
||||
origin_of(urls.get(cle, "")) or ""
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.85.1"
|
||||
version = "2.85.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.85.1"
|
||||
version = "2.85.2"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.85.1",
|
||||
"version": "2.85.2",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
+23
-1
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.85.1 | **Dernière mise à jour :** 2026-11-06
|
||||
> **Version :** 2.85.2 | **Dernière mise à jour :** 2026-11-06
|
||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||
> vers les fonctionnalités livrées.
|
||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||
@@ -70,6 +70,28 @@
|
||||
- [x] Garde `require_admin_hub` sur les routes `/api/auth/admin/*`
|
||||
- [x] Tests `tests/test_hub_roles.py` (client + intégration du chemin draco)
|
||||
|
||||
### 229. Hub HUB-21 — consommation des réglages généraux du Hub par ObsiGate
|
||||
|
||||
- **Effort :** ~0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-11-06 | **Statut :** ✅ livré en 2.85.2 (voir CHANGELOG ; PR en attente de fusion ; redéploiement Docker requis)
|
||||
- **Fiche :** [features/hub-reglages-obsigate-229.md](./features/hub-reglages-obsigate-229.md)
|
||||
- **Description :** propagation de HUB-21 (pilote TikTak 0.61.22, liste
|
||||
fermée D6(a)) : consultation côté serveur de
|
||||
`GET {hub}/api/reglages/effectifs` (endpoint public, délai 2 s),
|
||||
cache mémoire 60 s (horodatage posé avant l'appel), repli intégral
|
||||
sur les défauts actuels si le Hub est muet, en erreur ou de forme
|
||||
inattendue. Bannière d'annonce injectée sous la barre commune (page
|
||||
principale et /recherche, texte échappé, fermable, masquage
|
||||
mémorisé en `sessionStorage`) ; drapeau `recherche_partout` rendu
|
||||
en `data-recherche-partout` de la page /recherche — faux = partie
|
||||
fédérée désactivée, recherche locale intacte. `domaine_defaut`
|
||||
**non consommé** : pas de notion de domaine propre à ObsiGate (le
|
||||
sélecteur de la barre pilote le panneau Spark).
|
||||
- **Sous-tâches :**
|
||||
- [x] Client `backend/hub_reglages.py` (forme D6(a), cache/repli, URL du Hub résolue comme la barre — `draco_config.app_url("hub")`)
|
||||
- [x] Bannière injectée (`backend/main.py::_fragment_banniere_hub`, `_index_html`, `serve_recherche_page`) + styles `frontend/style.css`
|
||||
- [x] Jeton `{{RECHERCHE_PARTOUT}}` dans `frontend/recherche.html` + branchement du JS vendangé `frontend/js/recherche.js`
|
||||
- [x] Tests `tests/test_hub_reglages.py` (client + intégration) et `tests/frontend/recherche.test.mjs` étendu
|
||||
|
||||
### 226. Hub HUB-13 — API d'administration des comptes (machine-à-machine)
|
||||
|
||||
- **Effort :** ~0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-11-05 | **Statut :** ✅ livré en 2.84.0 (PR en attente de fusion — voir CHANGELOG ; redéploiement Docker requis)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# #229 — HUB-21 : consommation des réglages généraux du Hub par ObsiGate
|
||||
|
||||
**Carte :** propagation de HUB-21 (fusion #135) — le pilote TikTak
|
||||
(0.61.22, en production) a posé le patron de client ; ObsiGate le
|
||||
consomme à son tour, comme les autres hôtes de la suite.
|
||||
**Version cible :** 2.85.2 (branche depuis le main 2.85.1).
|
||||
|
||||
## Contrat consommé (liste fermée D6(a))
|
||||
|
||||
- `GET {hub}/api/reglages/effectifs` — endpoint **public** (aucun
|
||||
cookie relayé), `Accept: application/json`, délai **2 s** ;
|
||||
- réponse 200, forme figée : `{"recherche_partout": bool,
|
||||
"banniere": str, "domaine_defaut": "pro"|"famille"|"perso"|null,
|
||||
"ttl_secondes": 60}`, `Cache-Control: public, max-age=60` ;
|
||||
- **URL du Hub** : `draco_config.app_url("hub")`, la résolution de 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.
|
||||
|
||||
## Client — `backend/hub_reglages.py`
|
||||
|
||||
Sur le patron du pilote TikTak (`app/hub_reglages.py`, httpx → urllib
|
||||
comme `backend/hub_roles.py`) :
|
||||
|
||||
- cache mémoire **60 s** ; horodatage posé **avant** l'appel : un
|
||||
échec conserve l'ancien cache et ne déclenche pas de rafale vers un
|
||||
Hub en panne ;
|
||||
- forme validée strictement ; toute erreur (réseau, délai, statut
|
||||
≠ 200, JSON illisible, forme hors contrat) → **repli intégral** sur
|
||||
les défauts actuels (`recherche_partout: true`, `banniere: ""`,
|
||||
`domaine_defaut: null`) — le comportement d'ObsiGate avant HUB-21.
|
||||
Le module ne lève jamais ; aucun appel au démarrage.
|
||||
|
||||
## Consommation
|
||||
|
||||
1. **`banniere`** — `backend/main.py::_fragment_banniere_hub()`
|
||||
construit le fragment (texte **échappé** via `html.escape`, jamais
|
||||
de HTML injecté), injecté sous la barre commune par `_index_html`
|
||||
(page principale, juste avant le rappel de la barre) et par
|
||||
`serve_recherche_page` (page /recherche, après l'en-tête de la
|
||||
barre). Le script inline (nonce CSP ajouté par `inject_csp_nonce`)
|
||||
révèle le bandeau sauf si le même texte a été fermé dans la
|
||||
session du navigateur (`sessionStorage`, clé
|
||||
`obsigate_banniere_masquee`) : une nouvelle annonce réapparaît.
|
||||
Bannière vide = aucun fragment rendu, zéro changement visuel.
|
||||
Styles `.banniere-hub*` dans `frontend/style.css`, sur les
|
||||
variables du thème (`var(--accent)`).
|
||||
2. **`recherche_partout`** — `serve_recherche_page` remplace le jeton
|
||||
`{{RECHERCHE_PARTOUT}}` de `frontend/recherche.html`, rendu en
|
||||
attribut `data-recherche-partout` du `<main id="recherche">`. Le
|
||||
JS vendangé `frontend/js/recherche.js` (modification identique
|
||||
dans les 4 hôtes applicatifs : fonction pure
|
||||
`federationAutorisee`) n'interroge plus les applications sœurs ni
|
||||
la console quand la valeur est `false` — leurs sections affichent
|
||||
« Recherche fédérée désactivée… » — tandis que la recherche locale
|
||||
ObsiGate (même origine) continue. Attribut absent ou illisible =
|
||||
fédération active (repli).
|
||||
3. **`domaine_defaut` — non consommé.** ObsiGate n'a pas de notion
|
||||
de domaine (Pro/Famille/Perso) propre à ses vues : le sélecteur de
|
||||
domaine de la barre commune pilote le panneau Spark, pas
|
||||
ObsiGate. La valeur est lue et validée par le client, sans effet
|
||||
local. Seul l'hôte console (agent) applique ce réglage.
|
||||
|
||||
## i18n
|
||||
|
||||
Aucun texte d'interface du SPA n'est ajouté : la bannière est un
|
||||
contenu d'annonce servi par le Hub (texte libre de l'administrateur),
|
||||
et le libellé « Fermer l'annonce » est l'attribut d'accessibilité du
|
||||
fragment injecté côté serveur, sur le même patron que les autres
|
||||
hôtes. Pas de clé `frontend/locales/*` ajoutée.
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/test_hub_reglages.py` (nouveau) : contrat exact de l'appel
|
||||
(URL, aucun cookie, délai 2 s), formes inattendues → repli, erreur
|
||||
HTTP / Hub injoignable / JSON illisible → repli, cache 60 s,
|
||||
ancien cache conservé sans rafale ; intégration TestClient :
|
||||
bannière présente et échappée sur `/` et `/recherche`, absente si
|
||||
vide ou Hub muet, `data-recherche-partout` selon la valeur du
|
||||
drapeau (faux / vrai / Hub muet).
|
||||
- `tests/frontend/recherche.test.mjs` : assertions ajoutées pour
|
||||
`federationAutorisee` (seules les valeurs explicitement négatives
|
||||
désactivent ; absent/illisible = actif).
|
||||
@@ -246,6 +246,21 @@
|
||||
return [tab];
|
||||
}
|
||||
|
||||
/* HUB-21 (propagation des réglages généraux du Hub) : le drapeau
|
||||
* « Rechercher partout », rendu par l'hôte dans l'attribut
|
||||
* data-recherche-partout de #recherche (« true »/« false », lu
|
||||
* côté serveur depuis GET {hub}/api/reglages/effectifs, cache
|
||||
* 60 s, patron du pilote TikTak 0.61.22). Seules les valeurs
|
||||
* explicitement négatives désactivent la fédération ; attribut
|
||||
* absent ou illisible = fédération active — le comportement
|
||||
* actuel, qui est aussi le repli quand le Hub est muet.
|
||||
* Fonction pure, exposée aux tests. */
|
||||
function federationAutorisee(valeur) {
|
||||
if (valeur == null) return true;
|
||||
var v = String(valeur).trim().toLowerCase();
|
||||
return v !== "false" && v !== "0" && v !== "non";
|
||||
}
|
||||
|
||||
/* Troncature d'affichage : liste complète dans l'onglet propre à
|
||||
* l'application, PER_APP_LIMIT partout ailleurs (vue « Tout »). */
|
||||
function pageItems(items, full) {
|
||||
@@ -514,6 +529,7 @@
|
||||
visibleKeys: visibleKeys,
|
||||
pageItems: pageItems,
|
||||
buildSearchUrl: buildSearchUrl,
|
||||
federationAutorisee: federationAutorisee,
|
||||
tabCounts: tabCounts,
|
||||
searchPath: searchPath,
|
||||
normalize: normalize,
|
||||
@@ -609,6 +625,14 @@
|
||||
".</p>";
|
||||
return;
|
||||
}
|
||||
if (state.status === "desactivee") {
|
||||
count.textContent = "";
|
||||
body.innerHTML =
|
||||
'<p class="rp-state">Recherche fédérée désactivée dans les ' +
|
||||
"réglages généraux de la suite (Hub) : cette application " +
|
||||
"n'est pas interrogée.</p>";
|
||||
return;
|
||||
}
|
||||
if (state.status === "idle") {
|
||||
count.textContent = "";
|
||||
body.innerHTML =
|
||||
@@ -651,6 +675,8 @@
|
||||
var input = document.getElementById("rp-q");
|
||||
if (!input) return;
|
||||
var hostApp = root.getAttribute("data-host") || "";
|
||||
var federation = federationAutorisee(
|
||||
root.getAttribute("data-recherche-partout"));
|
||||
var explicit = {};
|
||||
APPS.forEach(function (app) {
|
||||
var v = root.getAttribute("data-base-" + app.key);
|
||||
@@ -772,7 +798,23 @@
|
||||
applyTab(tab, opts);
|
||||
/* La console n'est interrogée qu'une fois affichée. */
|
||||
if (parseTab(tab) === "console" && currentQ && consoleFetchedQ !== currentQ) {
|
||||
fetchConsole(currentQ, runId);
|
||||
if (!federation) {
|
||||
var secConsOnglet = sections.console;
|
||||
if (secConsOnglet) {
|
||||
states.console = {
|
||||
status: "desactivee",
|
||||
items: [],
|
||||
message: "",
|
||||
q: currentQ,
|
||||
base: consoleBase,
|
||||
app: "console",
|
||||
};
|
||||
renderSection(secConsOnglet, states.console, true);
|
||||
renderTabs();
|
||||
}
|
||||
} else {
|
||||
fetchConsole(currentQ, runId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -865,6 +907,22 @@
|
||||
APPS.forEach(function (app) {
|
||||
var sec = sections[app.key];
|
||||
if (!sec) return;
|
||||
if (!federation && app.key !== hostApp) {
|
||||
/* HUB-21 : fédération désactivée par le Hub — la section
|
||||
* sœur n'est pas interrogée, la recherche locale
|
||||
* (app.key === hostApp, même origine) continue. */
|
||||
states[app.key] = {
|
||||
status: "desactivee",
|
||||
items: [],
|
||||
message: "",
|
||||
q: q,
|
||||
base: bases[app.key],
|
||||
app: app.key,
|
||||
};
|
||||
renderSection(sec, states[app.key], activeTab === app.key);
|
||||
renderTabs();
|
||||
return;
|
||||
}
|
||||
states[app.key] = {
|
||||
status: "loading",
|
||||
items: [],
|
||||
@@ -915,7 +973,21 @@
|
||||
});
|
||||
/* Console : seulement si son onglet est affiché ; sinon son
|
||||
* compteur reste vide (pas de faux 0) jusqu'à son activation. */
|
||||
if (activeTab === "console") {
|
||||
if (activeTab === "console" && !federation) {
|
||||
var secConsRecherche = sections.console;
|
||||
if (secConsRecherche) {
|
||||
states.console = {
|
||||
status: "desactivee",
|
||||
items: [],
|
||||
message: "",
|
||||
q: q,
|
||||
base: consoleBase,
|
||||
app: "console",
|
||||
};
|
||||
renderSection(secConsRecherche, states.console, true);
|
||||
renderTabs();
|
||||
}
|
||||
} else if (activeTab === "console") {
|
||||
fetchConsole(q, myRun);
|
||||
} else {
|
||||
states.console = {
|
||||
|
||||
@@ -129,6 +129,7 @@
|
||||
<div class="content-wrapper">
|
||||
<div class="content-area">
|
||||
<main class="rp-page" id="recherche" data-host="obsigate"
|
||||
data-recherche-partout="{{RECHERCHE_PARTOUT}}"
|
||||
data-base-obsigate="{{URL_OBSIGATE}}"
|
||||
data-base-flowdeck="{{URL_FLOWDECK}}"
|
||||
data-base-tiktak="{{URL_TIKTAK}}"
|
||||
|
||||
@@ -14378,3 +14378,12 @@ body.has-draco-bar .app-container { height: calc(100vh - 60px); }
|
||||
.coffres-context-menu { position: fixed; z-index: 1000; min-width: 220px; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); }
|
||||
.coffres-context-item { display: block; width: 100%; text-align: left; padding: 8px 12px; background: transparent; border: none; color: var(--text-primary, #e6edf3); cursor: pointer; border-radius: 6px; }
|
||||
.coffres-context-item:hover { background: var(--bg-hover, #1f2430); }
|
||||
|
||||
/* Bannière d'annonce des réglages généraux du Hub (HUB-21) :
|
||||
bandeau injecté côté serveur sous la barre commune, fermable
|
||||
(masquage mémorisé pour la session du navigateur). */
|
||||
.banniere-hub { display: flex; align-items: center; gap: 12px; padding: 8px 16px; background: var(--accent, #7c5cd6); color: #fff; font-size: 14px; }
|
||||
.banniere-hub[hidden] { display: none; }
|
||||
.banniere-hub-texte { flex: 1; overflow-wrap: anywhere; }
|
||||
.banniere-hub-fermer { flex: none; background: transparent; border: none; color: inherit; font-size: 15px; line-height: 1; padding: 4px 6px; cursor: pointer; border-radius: 6px; }
|
||||
.banniere-hub-fermer:hover { background: rgba(255, 255, 255, 0.18); }
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.85.1",
|
||||
"version": "2.85.2",
|
||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||
"main": "patch.js",
|
||||
"directories": {
|
||||
|
||||
@@ -207,4 +207,14 @@ ok(rc.recognized === true && rc.items.length === 0, "console : resultats vide re
|
||||
ok(rp.consoleLinkFor({ type: "discussion", id: "c1" }) === "/?section=conversation&disc=c1", "consoleLinkFor discussion");
|
||||
ok(rp.resolveConsoleBase({ host: "x.example", tabHrefs: { agent: "https://agent-perso.example/" } }) === "https://agent-perso.example/", "console : href onglet barre agent accepte");
|
||||
|
||||
/* federationAutorisee (HUB-21) : seules les valeurs explicitement
|
||||
négatives désactivent la fédération ; absent/illisible = actif
|
||||
(repli = comportement actuel, Hub muet compris). */
|
||||
ok(rp.federationAutorisee("false") === false, "fédération : false désactive");
|
||||
ok(rp.federationAutorisee("0") === false, "fédération : 0 désactive");
|
||||
ok(rp.federationAutorisee("true") === true, "fédération : true active");
|
||||
ok(rp.federationAutorisee(null) === true, "fédération : attribut absent = actif");
|
||||
ok(rp.federationAutorisee("") === true, "fédération : vide = actif");
|
||||
ok(rp.federationAutorisee("nimporte") === true, "fédération : illisible = actif");
|
||||
|
||||
console.log(`recherche.test.mjs : ${n} assertions OK`);
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
# tests/test_hub_reglages.py
|
||||
# Hub HUB-21 (propagation) — Réglages généraux du Hub consommés par
|
||||
# ObsiGate : client (parsing de la forme figée, cache 60 s, repli
|
||||
# intégral), bannière injectée côté serveur (page principale et page
|
||||
# /recherche, texte échappé) et drapeau ``recherche_partout`` rendu
|
||||
# en attribut de la page /recherche. Patron posé par l'hôte pilote
|
||||
# TikTak 0.61.22. ``domaine_defaut`` n'est pas appliqué : ObsiGate
|
||||
# n'a pas de notion de domaine propre (voir backend/hub_reglages.py).
|
||||
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
|
||||
import pytest
|
||||
|
||||
from backend import hub_reglages
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _hub_env(monkeypatch):
|
||||
"""Cache vierge + Hub de test explicite pour chaque test."""
|
||||
hub_reglages.reinitialiser_cache()
|
||||
monkeypatch.setenv("DRACO_HUB_URL", "https://hub-test.dracodev.net")
|
||||
yield
|
||||
hub_reglages.reinitialiser_cache()
|
||||
|
||||
|
||||
def _reponse(corps=None, statut=200):
|
||||
"""Fausse réponse urlopen portant un payload du contrat D6(a)."""
|
||||
if corps is None:
|
||||
corps = {"recherche_partout": True, "banniere": "",
|
||||
"domaine_defaut": None, "ttl_secondes": 60}
|
||||
charge = json.dumps(corps).encode("utf-8")
|
||||
|
||||
class _Resp:
|
||||
status = statut
|
||||
|
||||
def read(self):
|
||||
return charge
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
return _Resp()
|
||||
|
||||
|
||||
def _patch_consultation(monkeypatch, reglages, appels=None):
|
||||
"""Remplace la consultation HTTP par des réglages fixes."""
|
||||
def _faux(url):
|
||||
if appels is not None:
|
||||
appels.append(url)
|
||||
return reglages
|
||||
monkeypatch.setattr(hub_reglages, "_consulter_hub", _faux)
|
||||
|
||||
|
||||
# ── Client : parsing du contrat ─────────────────────────────────────
|
||||
|
||||
def test_consultation_parse_le_contrat(monkeypatch):
|
||||
vu = {}
|
||||
|
||||
def _faux_urlopen(requete, timeout=None):
|
||||
vu["url"] = requete.full_url
|
||||
vu["cookie"] = requete.get_header("Cookie")
|
||||
vu["accept"] = requete.get_header("Accept")
|
||||
vu["timeout"] = timeout
|
||||
return _reponse({"recherche_partout": False,
|
||||
"banniere": "Maintenance dimanche",
|
||||
"domaine_defaut": "famille", "ttl_secondes": 60})
|
||||
|
||||
monkeypatch.setattr(hub_reglages.urllib.request, "urlopen", _faux_urlopen)
|
||||
reglages = hub_reglages.reglages_effectifs()
|
||||
assert reglages == {"recherche_partout": False,
|
||||
"banniere": "Maintenance dimanche",
|
||||
"domaine_defaut": "famille"}
|
||||
assert vu["url"] == "https://hub-test.dracodev.net/api/reglages/effectifs"
|
||||
assert vu["cookie"] is None # endpoint public : aucun cookie
|
||||
assert vu["accept"] == "application/json"
|
||||
assert vu["timeout"] == 2 # délai du contrat
|
||||
|
||||
|
||||
@pytest.mark.parametrize("corps", [
|
||||
{"recherche_partout": "oui", "banniere": "", "domaine_defaut": None},
|
||||
{"recherche_partout": True, "banniere": 42, "domaine_defaut": None},
|
||||
{"recherche_partout": True, "banniere": "", "domaine_defaut": "travail"},
|
||||
{"banniere": "", "domaine_defaut": None},
|
||||
["pas", "un", "dict"],
|
||||
])
|
||||
def test_consultation_forme_inattendue_repli(monkeypatch, corps):
|
||||
monkeypatch.setattr(hub_reglages.urllib.request, "urlopen",
|
||||
lambda requete, timeout=None: _reponse(corps))
|
||||
assert hub_reglages.reglages_effectifs() == hub_reglages.DEFAUTS
|
||||
|
||||
|
||||
def test_consultation_erreur_http_repli(monkeypatch):
|
||||
monkeypatch.setattr(hub_reglages.urllib.request, "urlopen",
|
||||
lambda requete, timeout=None: _reponse(statut=500))
|
||||
assert hub_reglages.reglages_effectifs() == hub_reglages.DEFAUTS
|
||||
assert hub_reglages.banniere() == ""
|
||||
assert hub_reglages.recherche_partout() is True
|
||||
|
||||
|
||||
def test_consultation_hub_injoignable_repli(monkeypatch):
|
||||
def _leve(requete, timeout=None):
|
||||
raise urllib.error.URLError("injoignable")
|
||||
monkeypatch.setattr(hub_reglages.urllib.request, "urlopen", _leve)
|
||||
assert hub_reglages.reglages_effectifs() == hub_reglages.DEFAUTS
|
||||
|
||||
|
||||
def test_consultation_json_illisible_repli(monkeypatch):
|
||||
class _Resp:
|
||||
status = 200
|
||||
|
||||
def read(self):
|
||||
return b"pas du json"
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(hub_reglages.urllib.request, "urlopen",
|
||||
lambda requete, timeout=None: _Resp())
|
||||
assert hub_reglages.reglages_effectifs() == hub_reglages.DEFAUTS
|
||||
|
||||
|
||||
# ── Client : cache 60 s, ancien cache, pas de rafale ────────────────
|
||||
|
||||
def test_cache_60_s(monkeypatch):
|
||||
appels = []
|
||||
_patch_consultation(monkeypatch, {
|
||||
"recherche_partout": False, "banniere": "Annonce A",
|
||||
"domaine_defaut": None}, appels)
|
||||
assert hub_reglages.banniere() == "Annonce A"
|
||||
assert hub_reglages.recherche_partout() is False
|
||||
assert len(appels) == 1 # cache 60 s
|
||||
hub_reglages._cache_at = time.monotonic() - 61
|
||||
assert hub_reglages.banniere() == "Annonce A"
|
||||
assert len(appels) == 2 # expiration → rappel
|
||||
|
||||
|
||||
def test_echec_ancien_cache_sans_rafale(monkeypatch):
|
||||
appels = []
|
||||
_patch_consultation(monkeypatch, {
|
||||
"recherche_partout": True, "banniere": "Annonce A",
|
||||
"domaine_defaut": None}, appels)
|
||||
assert hub_reglages.banniere() == "Annonce A"
|
||||
hub_reglages._cache_at = time.monotonic() - 61
|
||||
_patch_consultation(monkeypatch, None, appels) # Hub tombe
|
||||
assert hub_reglages.banniere() == "Annonce A" # ancien cache
|
||||
assert len(appels) == 2 # pas de rafale
|
||||
assert hub_reglages.banniere() == "Annonce A"
|
||||
assert len(appels) == 2
|
||||
|
||||
|
||||
# ── Intégration : bannière et drapeau dans les pages servies ────────
|
||||
|
||||
@pytest.fixture()
|
||||
def hub_banniere(monkeypatch):
|
||||
etat = {"reglages": None}
|
||||
monkeypatch.setattr(hub_reglages, "_consulter_hub",
|
||||
lambda url: etat["reglages"])
|
||||
hub_reglages.reinitialiser_cache()
|
||||
return etat
|
||||
|
||||
|
||||
def test_banniere_presente_et_echappee_index(client, hub_banniere):
|
||||
hub_banniere["reglages"] = {
|
||||
"recherche_partout": True,
|
||||
"banniere": "Maintenance dimanche <b>gras</b> & cie",
|
||||
"domaine_defaut": None}
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert 'id="banniere-hub"' in r.text
|
||||
assert "Maintenance dimanche <b>gras</b> & cie" in r.text
|
||||
assert "obsigate_banniere_masquee" in r.text
|
||||
|
||||
|
||||
def test_banniere_absente_si_vide_ou_hub_muet(client, hub_banniere):
|
||||
hub_banniere["reglages"] = {
|
||||
"recherche_partout": True, "banniere": "", "domaine_defaut": "pro"}
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert "banniere-hub" not in r.text
|
||||
hub_banniere["reglages"] = None
|
||||
hub_reglages.reinitialiser_cache()
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert "banniere-hub" not in r.text
|
||||
|
||||
|
||||
def test_recherche_banniere_et_drapeau(client, hub_banniere):
|
||||
hub_banniere["reglages"] = {
|
||||
"recherche_partout": False, "banniere": "Annonce recherche",
|
||||
"domaine_defaut": None}
|
||||
r = client.get("/recherche")
|
||||
assert r.status_code == 200
|
||||
assert 'data-recherche-partout="false"' in r.text
|
||||
assert 'id="banniere-hub"' in r.text
|
||||
assert "Annonce recherche" in r.text
|
||||
hub_banniere["reglages"] = {
|
||||
"recherche_partout": True, "banniere": "", "domaine_defaut": None}
|
||||
hub_reglages.reinitialiser_cache()
|
||||
r = client.get("/recherche")
|
||||
assert 'data-recherche-partout="true"' in r.text
|
||||
assert "banniere-hub" not in r.text
|
||||
hub_banniere["reglages"] = None # Hub muet → défaut actif
|
||||
hub_reglages.reinitialiser_cache()
|
||||
r = client.get("/recherche")
|
||||
assert 'data-recherche-partout="true"' in r.text
|
||||
Reference in New Issue
Block a user