Kanbante 0.46.3 — HUB-21 : consommation des réglages généraux du Hub (bannière + drapeau recherche_partout)
This commit is contained in:
@@ -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 `<main id="recherche">`. 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)
|
## 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).
|
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).
|
||||||
|
|||||||
@@ -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"])
|
||||||
+11
@@ -2401,6 +2401,17 @@ def render(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
context.setdefault("agent_url", _urls.agent_url)
|
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(
|
context.setdefault(
|
||||||
"sso_authorize_url",
|
"sso_authorize_url",
|
||||||
f"{_urls.sso_url}/sso/authorize?return_to="
|
f"{_urls.sso_url}/sso/authorize?return_to="
|
||||||
|
|||||||
@@ -11,6 +11,37 @@
|
|||||||
} catch (e) { /* mode privé : le thème ne survit qu'à la page */ }
|
} catch (e) { /* mode privé : le thème ne survit qu'à la page */ }
|
||||||
</script>
|
</script>
|
||||||
{% include "_draco_bar.html" %}
|
{% 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 %}
|
||||||
|
<div class="banniere-hub" id="banniere-hub" role="status" hidden>
|
||||||
|
<span class="banniere-hub-texte">{{ banniere_hub }}</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('kanbante_banniere_masquee') === t) { b.remove(); return; }
|
||||||
|
b.hidden = false;
|
||||||
|
document.getElementById('banniere-hub-fermer').addEventListener('click', function () {
|
||||||
|
try { sessionStorage.setItem('kanbante_banniere_masquee', t); } catch (e) { /* stockage indisponible */ }
|
||||||
|
b.remove();
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
var b2 = document.getElementById('banniere-hub');
|
||||||
|
if (b2) b2.hidden = false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
{# ── Cadre applicatif partagé (issue #16, refonte 0.16.0) ─────────
|
{# ── Cadre applicatif partagé (issue #16, refonte 0.16.0) ─────────
|
||||||
Barre latérale unique pour TOUTES les pages applicatives :
|
Barre latérale unique pour TOUTES les pages applicatives :
|
||||||
desktop = barre fixe repliable en rail d'icônes (état mémorisé),
|
desktop = barre fixe repliable en rail d'icônes (état mémorisé),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
{% include "_header.html" %}
|
{% include "_header.html" %}
|
||||||
|
|
||||||
<main class="rp-page" id="recherche" data-host="kanbante"
|
<main class="rp-page" id="recherche" data-host="kanbante"
|
||||||
|
data-recherche-partout="{{ 'true' if recherche_partout_hub else 'false' }}"
|
||||||
data-base-obsigate="{{ draco_urls.obsigate }}"
|
data-base-obsigate="{{ draco_urls.obsigate }}"
|
||||||
data-base-flowdeck="{{ draco_urls.flowdeck }}"
|
data-base-flowdeck="{{ draco_urls.flowdeck }}"
|
||||||
data-base-tiktak="{{ draco_urls.tiktak }}"
|
data-base-tiktak="{{ draco_urls.tiktak }}"
|
||||||
|
|||||||
@@ -2011,3 +2011,12 @@ html[data-theme="light"] .notes-panel { background: rgba(255, 255, 255, 0.85); }
|
|||||||
html[data-theme="light"] .age-badge { color: #b45309; }
|
html[data-theme="light"] .age-badge { color: #b45309; }
|
||||||
html[data-theme="light"] .age-badge.stale { color: #dc2626; }
|
html[data-theme="light"] .age-badge.stale { color: #dc2626; }
|
||||||
html[data-theme="light"] .column-count.over { color: #dc2626; }
|
html[data-theme="light"] .column-count.over { color: #dc2626; }
|
||||||
|
|
||||||
|
/* Bannière d'annonce des réglages généraux du Hub (HUB-21) :
|
||||||
|
bandeau rendu sous la barre commune (partiel _header.html),
|
||||||
|
fermable (masquage mémorisé pour la session du navigateur). */
|
||||||
|
.banniere-hub { display: flex; align-items: center; gap: 12px; padding: 8px 16px; background: var(--accent, #0d9488); 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); }
|
||||||
|
|||||||
+74
-2
@@ -246,6 +246,21 @@
|
|||||||
return [tab];
|
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 à
|
/* Troncature d'affichage : liste complète dans l'onglet propre à
|
||||||
* l'application, PER_APP_LIMIT partout ailleurs (vue « Tout »). */
|
* l'application, PER_APP_LIMIT partout ailleurs (vue « Tout »). */
|
||||||
function pageItems(items, full) {
|
function pageItems(items, full) {
|
||||||
@@ -514,6 +529,7 @@
|
|||||||
visibleKeys: visibleKeys,
|
visibleKeys: visibleKeys,
|
||||||
pageItems: pageItems,
|
pageItems: pageItems,
|
||||||
buildSearchUrl: buildSearchUrl,
|
buildSearchUrl: buildSearchUrl,
|
||||||
|
federationAutorisee: federationAutorisee,
|
||||||
tabCounts: tabCounts,
|
tabCounts: tabCounts,
|
||||||
searchPath: searchPath,
|
searchPath: searchPath,
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
@@ -609,6 +625,14 @@
|
|||||||
".</p>";
|
".</p>";
|
||||||
return;
|
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") {
|
if (state.status === "idle") {
|
||||||
count.textContent = "";
|
count.textContent = "";
|
||||||
body.innerHTML =
|
body.innerHTML =
|
||||||
@@ -651,6 +675,8 @@
|
|||||||
var input = document.getElementById("rp-q");
|
var input = document.getElementById("rp-q");
|
||||||
if (!input) return;
|
if (!input) return;
|
||||||
var hostApp = root.getAttribute("data-host") || "";
|
var hostApp = root.getAttribute("data-host") || "";
|
||||||
|
var federation = federationAutorisee(
|
||||||
|
root.getAttribute("data-recherche-partout"));
|
||||||
var explicit = {};
|
var explicit = {};
|
||||||
APPS.forEach(function (app) {
|
APPS.forEach(function (app) {
|
||||||
var v = root.getAttribute("data-base-" + app.key);
|
var v = root.getAttribute("data-base-" + app.key);
|
||||||
@@ -772,7 +798,23 @@
|
|||||||
applyTab(tab, opts);
|
applyTab(tab, opts);
|
||||||
/* La console n'est interrogée qu'une fois affichée. */
|
/* La console n'est interrogée qu'une fois affichée. */
|
||||||
if (parseTab(tab) === "console" && currentQ && consoleFetchedQ !== currentQ) {
|
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) {
|
APPS.forEach(function (app) {
|
||||||
var sec = sections[app.key];
|
var sec = sections[app.key];
|
||||||
if (!sec) return;
|
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] = {
|
states[app.key] = {
|
||||||
status: "loading",
|
status: "loading",
|
||||||
items: [],
|
items: [],
|
||||||
@@ -915,7 +973,21 @@
|
|||||||
});
|
});
|
||||||
/* Console : seulement si son onglet est affiché ; sinon son
|
/* Console : seulement si son onglet est affiché ; sinon son
|
||||||
* compteur reste vide (pas de faux 0) jusqu'à son activation. */
|
* 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);
|
fetchConsole(q, myRun);
|
||||||
} else {
|
} else {
|
||||||
states.console = {
|
states.console = {
|
||||||
|
|||||||
@@ -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.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");
|
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");
|
console.log("recherche.test.js : " + n + " assertions OK");
|
||||||
|
|||||||
@@ -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 <b>gras</b> & 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"
|
||||||
Reference in New Issue
Block a user