From 5d43144b7790b6cb483934012adc376101d1efad Mon Sep 17 00:00:00 2001 From: Spark Date: Fri, 6 Nov 2026 17:05:19 +0000 Subject: [PATCH] =?UTF-8?q?Kanbante=200.46.3=20=E2=80=94=20HUB-21=20:=20co?= =?UTF-8?q?nsommation=20des=20r=C3=A9glages=20g=C3=A9n=C3=A9raux=20du=20Hu?= =?UTF-8?q?b=20(banni=C3=A8re=20+=20drapeau=20recherche=5Fpartout)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++ VERSION | 2 +- app/hub_reglages.py | 153 +++++++++++++++++++++++++++++ app/main.py | 11 +++ app/templates/_header.html | 31 ++++++ app/templates/recherche.html | 1 + static/css/style.css | 9 ++ static/js/recherche.js | 76 +++++++++++++- tests/recherche.test.js | 13 +++ tests/test_hub_reglages.py | 185 +++++++++++++++++++++++++++++++++++ 10 files changed, 488 insertions(+), 3 deletions(-) create mode 100644 app/hub_reglages.py create mode 100644 tests/test_hub_reglages.py diff --git a/CHANGELOG.md b/CHANGELOG.md index acc9c89..f56e88c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## 0.46.3 — 2026-11-06 — Réglages généraux du Hub consommés par Kanbante (HUB-21, propagation du pilote TikTak 0.61.22) + +Version **corrective** au sens du patron du dépôt (branchement sur un contrat du Hub existant, aucun comportement métier nouveau au-delà de la bannière et du drapeau). + +- **Client des réglages généraux** (`app/hub_reglages.py`, nouveau) : consultation côté serveur de `GET {hub}/api/reglages/effectifs` — endpoint public, aucun cookie, `Accept: application/json`, délai 2,0 s. URL du Hub résolue comme la barre commune (`resoudre_urls_draco` : `DRACO_HUB_URL` explicite > hôte public de la requête > dérivation par l'hôte sœur de l'agent > défaut officiel — `xxx-dev` → `hub-dev`), jamais d'URL dev figée. Cache mémoire 60 s, horodatage posé **avant** l'appel : un échec conserve l'ancien cache, pas de rafale. Forme validée strictement (liste fermée D6(a)) ; toute erreur (réseau, statut, JSON, forme) = **repli intégral** sur les défauts actuels (`recherche_partout: true`, `banniere: ""`, `domaine_defaut: null`), jamais d'exception. +- **Bannière d'annonce** : `render()` pose `banniere_hub` dans le contexte commun ; le partiel `app/templates/_header.html` (toutes les pages applicatives) rend le bandeau sous la barre commune — texte échappé par l'autoescape Jinja (jamais de HTML injecté), bouton « fermer » qui mémorise le texte fermé en `sessionStorage` (clé `kanbante_banniere_masquee`) : une nouvelle annonce réapparaît. Bannière vide = aucun fragment rendu, zéro changement visuel. Styles `.banniere-hub*` dans `static/css/style.css` (fond `var(--accent)`). +- **Drapeau `recherche_partout`** : `render()` pose aussi `recherche_partout_hub` ; le gabarit `recherche.html` le rend en `data-recherche-partout` sur le `
`. Le JS vendangé `static/js/recherche.js` (même modification dans les 4 hôtes applicatifs) n'interroge plus les sœurs ni la console quand la valeur est `false` — sections « Recherche fédérée désactivée… », la recherche locale Kanbante continue ; attribut absent ou illisible = fédération active (repli). +- **`domaine_defaut` non consommé** : Kanbante n'a pas de notion de domaine (Pro/Famille/Perso) propre — le sélecteur de la barre commune pilote le panneau Spark, pas les vues Kanbante. La valeur est lue et validée par le client, sans effet local ; seul l'hôte console (agent) l'applique. +- Tests : `tests/test_hub_reglages.py` (nouveau, 10 tests — validation de la forme, contrat exact de l'appel, URL dérivée par l'hôte dev, cache 60 s et expiration, repli Hub muet/erreur 500/forme inattendue, ancien cache sans rafale ; intégration : bannière présente et échappée dans /recherche, absente si vide ou Hub muet, attribut du drapeau selon la valeur) ; `tests/recherche.test.js` étendu (fonction pure `federationAutorisee`). + ## 0.46.2 — 2026-11-06 — Rôles du Hub propagés à Kanbante (HUB-19, suite du pilote ShaarIt 0.7.0) Version **corrective** au sens du patron du dépôt (pas de fonction nouvelle côté métier : un garde d'accès branché sur le registre du Hub, contrat D3(a) du plan Hub v2). diff --git a/VERSION b/VERSION index 43c125a..c063aea 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.46.2 +0.46.3 diff --git a/app/hub_reglages.py b/app/hub_reglages.py new file mode 100644 index 0000000..82ad784 --- /dev/null +++ b/app/hub_reglages.py @@ -0,0 +1,153 @@ +"""Réglages généraux — consommation des réglages du Hub (HUB-21). + +Propagation du 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, sur le patron du pilote : l'horodatage est +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. L'URL +du Hub est résolue comme celle de la barre commune +(``app.config.resoudre_urls_draco`` : variable explicite > +hôte public de la requête > dérivation par l'hôte sœur de +l'agent > défaut officiel) — jamais d'URL dev figée. + +**Repli intégral** : Hub injoignable, en erreur ou réponse de +forme inattendue → les **défauts actuels** (``recherche_partout: +True``, ``banniere: ""``, ``domaine_defaut: None``), c'est-à-dire +le comportement de Kanbante avant HUB-21. Ce module ne lève +jamais. + +Consommation chez Kanbante : + +- ``banniere`` : bandeau sous la barre commune (partiel + ``_header.html``, présent sur toutes les pages applicatives), + texte échappé par l'autoescape Jinja ; +- ``recherche_partout`` : attribut ``data-recherche-partout`` de + la page /recherche — si faux, la partie fédérée est désactivée + (la recherche locale Kanbante continue) ; +- ``domaine_defaut`` : **non consommé** — Kanbante n'a pas de + notion de domaine (Pro/Famille/Perso) propre ; le sélecteur de + domaine de la barre commune pilote le panneau Spark, pas les + vues de Kanbante. La valeur est lue et validée, sans effet + local. +""" + +from __future__ import annotations + +import logging +import threading +import time + +import httpx + +from app.config import resoudre_urls_draco + +logger = logging.getLogger(__name__) + +#: Réglages par défaut = comportement actuel de Kanbante (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. +CACHE_TTL = 60.0 # secondes + +_verrou = threading.Lock() +_cache: dict | None = None # derniers réglages validés +_cache_fetched_at: float = 0.0 # horodatage (monotone) du dernier appel + + +def _reset_cache() -> None: + """Vide le cache (tests, ou redémarrage de processus).""" + global _cache, _cache_fetched_at + with _verrou: + _cache = None + _cache_fetched_at = 0.0 + + +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: + """Un appel au Hub ; réglages validés ou ``None`` (repli).""" + url = f"{hub_url.rstrip('/')}/api/reglages/effectifs" + try: + reponse = httpx.get( + url, headers={"Accept": "application/json"}, timeout=2.0) + except Exception as exc: # Hub injoignable : repli, jamais un blocage + logger.debug( + "Réglages généraux du Hub injoignables (%s) : %s", url, exc) + return None + if getattr(reponse, "status_code", None) != 200: + logger.debug( + "Réglages généraux du Hub en erreur (%s) : statut %s", + url, getattr(reponse, "status_code", "?")) + return None + try: + donnees = reponse.json() + except Exception: + return None + return _valider(donnees) + + +def reglages_effectifs(hote: str | None = None) -> dict: + """Réglages généraux effectifs pour Kanbante. + + 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. ``hote`` est l'hôte public + de la requête, pour résoudre l'URL du Hub comme la barre + commune. Ne lève jamais. + """ + global _cache, _cache_fetched_at + now = time.monotonic() + with _verrou: + if _cache is not None and now - _cache_fetched_at < CACHE_TTL: + return dict(_cache) + # Horodatage posé AVANT l'appel (patron du pilote) : + # l'ancien cache reste en vigueur pendant l'appel. + _cache_fetched_at = now + hub_url = resoudre_urls_draco(hote).hub_url + reglages = _consulter(hub_url) + if reglages is None: + logger.debug( + "Réglages généraux du Hub indisponibles (%s) — " + "comportement actuel conservé", hub_url) + with _verrou: + return dict(_cache) if _cache is not None else dict(DEFAUTS) + with _verrou: + _cache = reglages + _cache_fetched_at = time.monotonic() + return dict(reglages) + + +def banniere(hote: str | None = None) -> str: + """Texte de la bannière d'annonce (``""`` = aucune bannière).""" + return reglages_effectifs(hote)["banniere"] + + +def recherche_partout(hote: str | None = None) -> bool: + """Drapeau de la recherche fédérée (défaut actuel : actif).""" + return bool(reglages_effectifs(hote)["recherche_partout"]) diff --git a/app/main.py b/app/main.py index b795f10..044643c 100644 --- a/app/main.py +++ b/app/main.py @@ -2401,6 +2401,17 @@ def render( }, ) context.setdefault("agent_url", _urls.agent_url) + # Réglages généraux du Hub (HUB-21) : bannière d'annonce du + # partiel _header.html et drapeau de la recherche fédérée de la + # page /recherche. Client à cache 60 s et repli intégral + # (app.hub_reglages) : Hub muet = bannière vide et fédération + # active, c'est-à-dire le comportement actuel. + from app import hub_reglages as _hub_reglages + + _hote = request.headers.get("host") + context.setdefault("banniere_hub", _hub_reglages.banniere(_hote)) + context.setdefault( + "recherche_partout_hub", _hub_reglages.recherche_partout(_hote)) context.setdefault( "sso_authorize_url", f"{_urls.sso_url}/sso/authorize?return_to=" diff --git a/app/templates/_header.html b/app/templates/_header.html index 436c817..7814b2b 100644 --- a/app/templates/_header.html +++ b/app/templates/_header.html @@ -11,6 +11,37 @@ } catch (e) { /* mode privé : le thème ne survit qu'à la page */ } {% include "_draco_bar.html" %} +{# ── Bannière d'annonce des réglages généraux du Hub (HUB-21) ──── + Texte posé par render() (contexte `banniere_hub`, client serveur + à cache 60 s et repli intégral) et échappé par l'autoescape + Jinja — jamais de HTML injecté. Vide = aucun fragment rendu. + Le script masque le bandeau si le même texte a déjà été fermé + dans la session du navigateur (sessionStorage) : une nouvelle + annonce réapparaît. #} +{% if banniere_hub %} + + +{% endif %} {# ── Cadre applicatif partagé (issue #16, refonte 0.16.0) ───────── Barre latérale unique pour TOUTES les pages applicatives : desktop = barre fixe repliable en rail d'icônes (état mémorisé), diff --git a/app/templates/recherche.html b/app/templates/recherche.html index 51e0a61..c18a021 100644 --- a/app/templates/recherche.html +++ b/app/templates/recherche.html @@ -12,6 +12,7 @@ {% include "_header.html" %}
"; return; } + if (state.status === "desactivee") { + count.textContent = ""; + body.innerHTML = + '

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.

"; + 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 = { diff --git a/tests/recherche.test.js b/tests/recherche.test.js index e2720a7..ca921c9 100644 --- a/tests/recherche.test.js +++ b/tests/recherche.test.js @@ -197,4 +197,17 @@ 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(" FALSE ") === false, "fédération : casse/espaces ignorés"); +ok(rp.federationAutorisee("true") === true, "fédération : true active"); +ok(rp.federationAutorisee("1") === true, "fédération : 1 active"); +ok(rp.federationAutorisee(null) === true, "fédération : attribut absent = actif"); +ok(rp.federationAutorisee(undefined) === true, "fédération : undefined = actif"); +ok(rp.federationAutorisee("") === true, "fédération : vide = actif"); +ok(rp.federationAutorisee("nimporte") === true, "fédération : illisible = actif"); + console.log("recherche.test.js : " + n + " assertions OK"); diff --git a/tests/test_hub_reglages.py b/tests/test_hub_reglages.py new file mode 100644 index 0000000..57e27a5 --- /dev/null +++ b/tests/test_hub_reglages.py @@ -0,0 +1,185 @@ +"""HUB-21 — réglages généraux du Hub consommés par Kanbante. + +Propagation du pilote TikTak 0.61.22 : client serveur +``app.hub_reglages`` (cache 60 s, horodatage posé avant l'appel, +repli intégral sur les défauts actuels), bannière d'annonce dans +le partiel ``_header.html`` (texte échappé par l'autoescape +Jinja) et drapeau ``recherche_partout`` rendu en attribut +``data-recherche-partout`` de la page /recherche (la logique JS +associée est couverte par tests/recherche.test.js). +``domaine_defaut`` n'est pas appliqué : Kanbante n'a pas de +notion de domaine propre (voir le docstring du module). +""" + +import httpx +import pytest + +from app import hub_reglages + + +class _Reponse: + def __init__(self, status_code=200, donnees=None): + self.status_code = status_code + self._donnees = donnees + + def json(self): + if isinstance(self._donnees, Exception): + raise self._donnees + return self._donnees + + +@pytest.fixture(autouse=True) +def _cache_propre(): + hub_reglages._reset_cache() + yield + hub_reglages._reset_cache() + + +@pytest.fixture() +def faux_hub(monkeypatch): + """Faux Hub des réglages : état pilotable + journal d'appels.""" + etat = {"reglages": None, "appels": [], "mode": "ok"} + + def faux_get(url, headers=None, timeout=None): + etat["appels"].append( + {"url": url, "headers": dict(headers or {}), "timeout": timeout}) + if etat["mode"] == "panne": + raise httpx.ConnectError("Hub injoignable (simulation)") + if etat["mode"] == "erreur500": + return _Reponse(500, {"detail": "erreur"}) + return _Reponse(200, etat["reglages"]) + + monkeypatch.setattr(httpx, "get", faux_get) + return etat + + +# ── Client : validation, contrat d'appel, résolution d'URL ────────── + +def test_validation_forme_du_contrat(): + assert hub_reglages._valider({ + "recherche_partout": False, "banniere": "Maintenance dimanche", + "domaine_defaut": "famille", "ttl_secondes": 60}) == { + "recherche_partout": False, "banniere": "Maintenance dimanche", + "domaine_defaut": "famille"} + for charge in ( + None, "texte", ["x"], {}, + {"recherche_partout": "oui", "banniere": "", "domaine_defaut": None}, + {"recherche_partout": True, "banniere": 42, "domaine_defaut": None}, + {"recherche_partout": True, "banniere": "", "domaine_defaut": "travail"}, + ): + assert hub_reglages._valider(charge) is None, charge + + +def test_appel_contrat_exact_et_cache(faux_hub): + faux_hub["reglages"] = { + "recherche_partout": False, "banniere": "Annonce A", + "domaine_defaut": None, "ttl_secondes": 60} + assert hub_reglages.banniere() == "Annonce A" + assert hub_reglages.recherche_partout() is False + assert len(faux_hub["appels"]) == 1 # cache 60 s + appel = faux_hub["appels"][0] + assert appel["url"] == "https://hub.dracodev.net/api/reglages/effectifs" + assert appel["headers"]["Accept"] == "application/json" + assert "Cookie" not in appel["headers"] # endpoint public + assert appel["timeout"] == 2.0 + + +def test_url_hub_par_hote_dev(faux_hub): + faux_hub["reglages"] = { + "recherche_partout": True, "banniere": "", "domaine_defaut": None} + hub_reglages.reglages_effectifs(hote="kanbante-dev.dracodev.net") + assert faux_hub["appels"][0]["url"] == ( + "https://hub-dev.dracodev.net/api/reglages/effectifs") + + +def test_ttl_expire_reconsulte(faux_hub): + faux_hub["reglages"] = { + "recherche_partout": True, "banniere": "Annonce A", + "domaine_defaut": None} + assert hub_reglages.banniere() == "Annonce A" + hub_reglages._cache_fetched_at -= hub_reglages.CACHE_TTL + 1 + faux_hub["reglages"] = { + "recherche_partout": True, "banniere": "Annonce B", + "domaine_defaut": None} + assert hub_reglages.banniere() == "Annonce B" + assert len(faux_hub["appels"]) == 2 + + +def test_hub_muet_repli_defauts(faux_hub): + faux_hub["mode"] = "panne" + assert hub_reglages.reglages_effectifs() == hub_reglages.DEFAUTS + hub_reglages._reset_cache() + faux_hub["mode"] = "erreur500" + assert hub_reglages.reglages_effectifs() == hub_reglages.DEFAUTS + hub_reglages._reset_cache() + faux_hub["mode"] = "ok" + faux_hub["reglages"] = {"forme": "inattendue"} + assert hub_reglages.reglages_effectifs() == hub_reglages.DEFAUTS + assert hub_reglages.banniere() == "" + assert hub_reglages.recherche_partout() is True + + +def test_echec_ancien_cache_sans_rafale(faux_hub): + faux_hub["reglages"] = { + "recherche_partout": True, "banniere": "Annonce A", + "domaine_defaut": None} + assert hub_reglages.banniere() == "Annonce A" + hub_reglages._cache_fetched_at -= hub_reglages.CACHE_TTL + 1 + faux_hub["mode"] = "panne" # Hub tombe + assert hub_reglages.banniere() == "Annonce A" # ancien cache + appels = len(faux_hub["appels"]) + assert hub_reglages.banniere() == "Annonce A" + assert len(faux_hub["appels"]) == appels # pas de rafale + + +# ── Intégration : bannière et drapeau dans les pages ──────────────── + +def test_banniere_presente_et_echappee(client, faux_hub): + faux_hub["reglages"] = { + "recherche_partout": True, + "banniere": "Maintenance dimanche gras & cie", + "domaine_defaut": None, "ttl_secondes": 60} + r = client.get("/recherche") + assert r.status_code == 200 + assert 'id="banniere-hub"' in r.text + assert "Maintenance dimanche <b>gras</b> & cie" in r.text + assert "kanbante_banniere_masquee" in r.text + + +def test_banniere_absente_si_vide_ou_hub_muet(client, faux_hub): + faux_hub["reglages"] = { + "recherche_partout": True, "banniere": "", "domaine_defaut": "pro", + "ttl_secondes": 60} + r = client.get("/recherche") + assert r.status_code == 200 + assert "banniere-hub" not in r.text + hub_reglages._reset_cache() + faux_hub["mode"] = "panne" + r = client.get("/recherche") + assert r.status_code == 200 + assert "banniere-hub" not in r.text + + +def test_recherche_drapeau_rendu(client, faux_hub): + faux_hub["reglages"] = { + "recherche_partout": False, "banniere": "", "domaine_defaut": None, + "ttl_secondes": 60} + r = client.get("/recherche") + assert r.status_code == 200 + assert 'data-recherche-partout="false"' in r.text + hub_reglages._reset_cache() + faux_hub["reglages"] = { + "recherche_partout": True, "banniere": "", "domaine_defaut": None, + "ttl_secondes": 60} + r = client.get("/recherche") + assert 'data-recherche-partout="true"' in r.text + hub_reglages._reset_cache() + faux_hub["mode"] = "panne" # Hub muet → défaut actif + r = client.get("/recherche") + assert 'data-recherche-partout="true"' in r.text + + +def test_version_0_46_3(): + from pathlib import Path + version = (Path(__file__).parent.parent / "VERSION").read_text().strip() + assert version == "0.46.3" -- 2.54.0