Flowdeck 7.85.3 — HUB-21 : consommation des réglages généraux du Hub (bannière + drapeau recherche_partout)
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (pull_request) Failing after 1s
FlowDeck CI / test (pull_request) Failing after 1s
FlowDeck CI / docker (pull_request) Skipped
FlowDeck CI / lint (push) Failing after 11s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (pull_request) Failing after 1s
FlowDeck CI / test (pull_request) Failing after 1s
FlowDeck CI / docker (pull_request) Skipped
FlowDeck CI / lint (push) Failing after 11s
This commit is contained in:
@@ -1,3 +1,11 @@
|
|||||||
|
## v7.85.3 (2026-11-06) — HUB-21 : consommation des réglages généraux du Hub
|
||||||
|
|
||||||
|
- **Client des réglages généraux** (`app/services/hub_reglages.py`, nouveau) : propagation du pilote TikTak 0.61.22. 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 le reste de la suite (`draco_urls.hub_url()` : hôte de la requête, puis dérivation par l'hôte de l'agent — `xxx-dev` → `hub-dev` — jamais d'URL dev figée ni de variable explicite qui figerait la dérivation). 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** : global Jinja `banniere_hub` (fonction, jamais un instantané) consommé par `app/templates/base.html` — bandeau sous la barre commune, texte rendu par l'autoescape Jinja (jamais de HTML injecté), bouton « fermer » qui mémorise le texte fermé en `sessionStorage` (clé `flowdeck_banniere_masquee`) : une nouvelle annonce réapparaît. Bannière vide = aucun fragment rendu, zéro changement visuel. Styles `.banniere-hub*` dans `static/css/app.css` (fond `var(--accent)`). Hors mode embarqué (`embed_mode`), jamais sur la page publique.
|
||||||
|
- **Drapeau `recherche_partout`** : la route `/recherche` pose `data-recherche-partout` sur la coquille ; 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` — leurs sections affichent « Recherche fédérée désactivée… », la recherche locale Flowdeck continue ; attribut absent ou illisible = fédération active (repli).
|
||||||
|
- **`domaine_defaut` non consommé** : Flowdeck 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 Flowdeck. 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, 13 tests — validation de la forme, contrat exact de l'appel, cache 60 s, ancien cache après échec, repli Hub muet, bannière présente et échappée dans la page /recherche, absente si vide ou Hub muet, attribut du drapeau rendu selon la valeur) ; `tests/recherche.test.js` étendu (fonction pure `federationAutorisee`, 92 assertions).
|
||||||
|
|
||||||
## v7.85.2 (2026-11-06) — HUB-19 : propagation des rôles du Hub (registre D3(a))
|
## v7.85.2 (2026-11-06) — HUB-19 : propagation des rôles du Hub (registre D3(a))
|
||||||
|
|
||||||
- **Client des rôles du Hub** (`app/services/hub_roles.py`, nouveau) : portage Python du pilote ShaarIt 0.7.0 (`hub_roles.rs`). À l'ouverture de session draco, consultation de `GET {hub}/api/roles/flowdeck` — en-tête Cookie complet de la requête relayé tel quel, `Accept: application/json`, délai 2 s. URL du Hub résolue comme la barre commune (`draco_urls.hub_url()` : explicite `HUB_URL` > hôte de la requête > dérivation par l'hôte de l'agent > défaut officiel), jamais d'URL dev figée. Cache mémoire par `sub`, TTL 60 s, horodatage posé **avant** l'appel : un échec conserve l'ancien cache, pas de rafale. Toute erreur (401/404/réseau/JSON illisible/rôle hors ensemble {`aucun`, `utilisateur`, `administrateur`}) = rôle **inconnu** → comportement actuel conservé, intégralement.
|
- **Client des rôles du Hub** (`app/services/hub_roles.py`, nouveau) : portage Python du pilote ShaarIt 0.7.0 (`hub_roles.rs`). À l'ouverture de session draco, consultation de `GET {hub}/api/roles/flowdeck` — en-tête Cookie complet de la requête relayé tel quel, `Accept: application/json`, délai 2 s. URL du Hub résolue comme la barre commune (`draco_urls.hub_url()` : explicite `HUB_URL` > hôte de la requête > dérivation par l'hôte de l'agent > défaut officiel), jamais d'URL dev figée. Cache mémoire par `sub`, TTL 60 s, horodatage posé **avant** l'appel : un échec conserve l'ancien cache, pas de rafale. Toute erreur (401/404/réseau/JSON illisible/rôle hors ensemble {`aucun`, `utilisateur`, `administrateur`}) = rôle **inconnu** → comportement actuel conservé, intégralement.
|
||||||
|
|||||||
@@ -56,8 +56,15 @@ def recherche_page(request: Request, q: str = Query(default="")):
|
|||||||
static/js/recherche.js (API de recherche de chaque app, en
|
static/js/recherche.js (API de recherche de chaque app, en
|
||||||
parallèle, états par application). La palette Ctrl+K native et
|
parallèle, états par application). La palette Ctrl+K native et
|
||||||
``GET /api/search`` restent inchangés par ailleurs.
|
``GET /api/search`` restent inchangés par ailleurs.
|
||||||
|
|
||||||
|
HUB-21 : le drapeau ``recherche_partout`` des réglages
|
||||||
|
généraux du Hub est rendu dans l'attribut
|
||||||
|
``data-recherche-partout`` de la coquille (cache serveur de
|
||||||
|
60 s de ``app.services.hub_reglages`` ; Hub muet → « true »,
|
||||||
|
comportement actuel) — quand il est faux, recherche.js
|
||||||
|
n'interroge que la section locale Flowdeck.
|
||||||
"""
|
"""
|
||||||
from app.services import draco_urls
|
from app.services import draco_urls, hub_reglages
|
||||||
from app.templating import ENV
|
from app.templating import ENV
|
||||||
|
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
@@ -69,6 +76,7 @@ def recherche_page(request: Request, q: str = Query(default="")):
|
|||||||
# l'onglet Console Spark (GET /api/v1/recherche, alignement #36).
|
# l'onglet Console Spark (GET /api/v1/recherche, alignement #36).
|
||||||
return HTMLResponse(template.render(
|
return HTMLResponse(template.render(
|
||||||
request=request, q=q.strip(), user=user,
|
request=request, q=q.strip(), user=user,
|
||||||
|
recherche_partout=hub_reglages.recherche_partout(),
|
||||||
bases={k: bases[k] for k in ("obsigate", "flowdeck", "tiktak",
|
bases={k: bases[k] for k in ("obsigate", "flowdeck", "tiktak",
|
||||||
"kanbante", "shaarit", "agent")},
|
"kanbante", "shaarit", "agent")},
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Réglages généraux de la suite — consommation du Hub (HUB-21).
|
||||||
|
|
||||||
|
Propagation du pilote TikTak (0.61.22, PR ticktick-clone #244) à
|
||||||
|
Flowdeck, sur le contrat figé du Hub 0.16.0 : ``GET
|
||||||
|
{hub}/api/reglages/effectifs`` → 200 JSON
|
||||||
|
``{"recherche_partout": bool, "banniere": string ("" = aucune),
|
||||||
|
"domaine_defaut": "pro"|"famille"|"perso"|null,
|
||||||
|
"ttl_secondes": 60}``, ``Cache-Control: public, max-age=60`` —
|
||||||
|
endpoint public, aucun cookie relayé.
|
||||||
|
|
||||||
|
Petit client serveur sur le patron du pilote et du client des
|
||||||
|
rôles (``app.services.hub_roles``) : cache mémoire 60 s,
|
||||||
|
horodatage posé **avant** l'appel — un échec ne déclenche pas de
|
||||||
|
rafale et l'ancien cache reste en vigueur. L'URL du Hub est
|
||||||
|
résolue comme la barre commune (``app.services.draco_urls.hub_url()``
|
||||||
|
: variable explicite ``HUB_URL`` / réglage, dérivation par l'hôte
|
||||||
|
de la requête, puis par l'hôte sœur de l'agent, puis défaut
|
||||||
|
officiel) — jamais d'URL dev figée. Consultation côté serveur,
|
||||||
|
délai court de 2 s, jamais au démarrage (premier appel paresseux,
|
||||||
|
au premier rendu de gabarit).
|
||||||
|
|
||||||
|
**Repli obligatoire** : Hub muet, en erreur ou réponse de forme
|
||||||
|
inattendue → valeurs par défaut actuelles (pas de bannière,
|
||||||
|
recherche partout active, aucun domaine par défaut) —
|
||||||
|
comportement d'aujourd'hui à l'identique. Ce module ne lève
|
||||||
|
jamais.
|
||||||
|
|
||||||
|
**Périmètre Flowdeck (cette PR)** :
|
||||||
|
- ``banniere`` : bandeau d'annonce dans le gabarit principal
|
||||||
|
(``app/templates/base.html``, sous la barre commune), exposé
|
||||||
|
aux gabarits par ``app.templating`` (fonction, pas un
|
||||||
|
instantané : chaque rendu lit le cache de 60 s) ; texte échappé
|
||||||
|
par l'autoescape Jinja, masquage de session navigateur.
|
||||||
|
- ``recherche_partout`` : la route ``GET /recherche`` rend
|
||||||
|
l'attribut ``data-recherche-partout`` de la coquille ; quand le
|
||||||
|
drapeau est faux, ``recherche.js`` (copie vendangée, même
|
||||||
|
modification dans chaque hôte) n'interroge plus les
|
||||||
|
applications sœurs ni la console — la recherche locale
|
||||||
|
Flowdeck (sa propre section, même origine) continue.
|
||||||
|
- ``domaine_defaut`` : **non consommé** — Flowdeck n'a pas de
|
||||||
|
notion de domaine (Pro/Famille/Perso) propre : ses vues ne sont
|
||||||
|
filtrées par aucun domaine ; le sélecteur de la barre commune
|
||||||
|
pilote le panneau Spark, pas Flowdeck. Le réglage est lu et
|
||||||
|
validé par le client, mais pas appliqué ici.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: TTL du cache, aligné sur ``ttl_secondes`` annoncé par le Hub.
|
||||||
|
CACHE_TTL = 60.0 # secondes
|
||||||
|
|
||||||
|
#: Délai de consultation du Hub : court par contrat (2 s).
|
||||||
|
DELAI = 2.0 # secondes
|
||||||
|
|
||||||
|
#: Valeurs par défaut actuelles (repli quand le Hub ne répond pas).
|
||||||
|
DEFAUTS: dict = {
|
||||||
|
"recherche_partout": True,
|
||||||
|
"banniere": "",
|
||||||
|
"domaine_defaut": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
_DOMAINES = ("pro", "famille", "perso")
|
||||||
|
|
||||||
|
_verrou = threading.Lock()
|
||||||
|
_cache: dict | None = None
|
||||||
|
_cache_at: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def reinitialiser_cache() -> None:
|
||||||
|
"""Vide le cache (tests uniquement)."""
|
||||||
|
global _cache, _cache_at
|
||||||
|
with _verrou:
|
||||||
|
_cache = None
|
||||||
|
_cache_at = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _valider(data) -> dict | None:
|
||||||
|
"""Valide la charge utile du Hub ; dict normalisé ou None."""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
recherche = data.get("recherche_partout")
|
||||||
|
banniere = data.get("banniere")
|
||||||
|
domaine = data.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() -> dict | None:
|
||||||
|
"""Un appel au Hub ; retourne les réglages validés ou None."""
|
||||||
|
from app.services import draco_urls
|
||||||
|
|
||||||
|
url = f"{draco_urls.hub_url().rstrip('/')}/api/reglages/effectifs"
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
r = httpx.get(url, headers={"Accept": "application/json"},
|
||||||
|
timeout=DELAI)
|
||||||
|
if r.status_code != 200:
|
||||||
|
return None
|
||||||
|
return _valider(r.json())
|
||||||
|
except Exception as exc: # Hub injoignable : repli, jamais d'exception
|
||||||
|
logger.debug("Réglages du Hub indisponibles (%s): %s", url, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def reglages_effectifs() -> dict:
|
||||||
|
"""Réglages généraux en vigueur : réponse du Hub (cache 60 s),
|
||||||
|
sinon ancien cache, sinon les valeurs par défaut actuelles."""
|
||||||
|
global _cache, _cache_at
|
||||||
|
with _verrou:
|
||||||
|
if _cache is not None and time.monotonic() - _cache_at < CACHE_TTL:
|
||||||
|
return dict(_cache)
|
||||||
|
# Horodatage posé avant l'appel (patron révocations) :
|
||||||
|
# l'ancien cache reste en vigueur si l'appel échoue.
|
||||||
|
_cache_at = time.monotonic()
|
||||||
|
ancien = dict(_cache) if _cache is not None else None
|
||||||
|
resultat = _consulter_hub()
|
||||||
|
if resultat is not None:
|
||||||
|
with _verrou:
|
||||||
|
_cache = resultat
|
||||||
|
_cache_at = time.monotonic()
|
||||||
|
return dict(resultat)
|
||||||
|
if ancien is not None:
|
||||||
|
return ancien
|
||||||
|
return dict(DEFAUTS)
|
||||||
|
|
||||||
|
|
||||||
|
def banniere() -> str:
|
||||||
|
"""Texte de la bannière d'annonce en vigueur ("" = aucune)."""
|
||||||
|
return str(reglages_effectifs()["banniere"] or "")
|
||||||
|
|
||||||
|
|
||||||
|
def recherche_partout() -> bool:
|
||||||
|
"""Drapeau « Rechercher partout » en vigueur (True = actif)."""
|
||||||
|
return bool(reglages_effectifs()["recherche_partout"])
|
||||||
@@ -150,6 +150,34 @@
|
|||||||
</head>
|
</head>
|
||||||
<body hx-headers='{"X-CSRF-Token":{{ csrf_token() | tojson }}}'{% if embed_mode %} class="embed-mode"{% else %} class="has-draco-bar"{% endif %}>
|
<body hx-headers='{"X-CSRF-Token":{{ csrf_token() | tojson }}}'{% if embed_mode %} class="embed-mode"{% else %} class="has-draco-bar"{% endif %}>
|
||||||
{% if not embed_mode %}{% include "_draco_bar.html" %}{% endif %}
|
{% if not embed_mode %}{% include "_draco_bar.html" %}{% endif %}
|
||||||
|
{% if not embed_mode %}{% set _banniere_hub = banniere_hub() %}{% 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="Masquer la bannière" title="Masquer pour cette session du navigateur">×</button>
|
||||||
|
</div>
|
||||||
|
<script nonce="{{ csp_nonce() }}">
|
||||||
|
/* Bannière d'annonce des réglages généraux du Hub (HUB-21) :
|
||||||
|
masquage par l'utilisateur pour la session du navigateur —
|
||||||
|
sessionStorage, valeur = texte de la bannière (une nouvelle
|
||||||
|
annonce réapparaît donc toujours). La consultation du Hub se
|
||||||
|
fait côté serveur (app.services.hub_reglages) ; le texte est
|
||||||
|
échappé par l'autoescape Jinja. Sans JS, le bandeau reste
|
||||||
|
masqué (attribut hidden). */
|
||||||
|
(function () {
|
||||||
|
var bandeau = document.getElementById("banniere-hub");
|
||||||
|
if (!bandeau) return;
|
||||||
|
var texte = {{ _banniere_hub | tojson }};
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem("flowdeck_banniere_masquee") === texte) return;
|
||||||
|
} catch (e) { /* stockage indisponible : on affiche */ }
|
||||||
|
bandeau.hidden = false;
|
||||||
|
document.getElementById("banniere-hub-fermer").addEventListener("click", function () {
|
||||||
|
bandeau.hidden = true;
|
||||||
|
try { sessionStorage.setItem("flowdeck_banniere_masquee", texte); } catch (err) { /* tant pis */ }
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}{% endif %}
|
||||||
<div class="app-layout" x-data="appState()" x-cloak>
|
<div class="app-layout" x-data="appState()" x-cloak>
|
||||||
|
|
||||||
<!-- ═══════════ MOBILE SIDEBAR OVERLAY ═══════════ -->
|
<!-- ═══════════ MOBILE SIDEBAR OVERLAY ═══════════ -->
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
son contexte `bases` (defaut 6). recherche.js garde ses replis
|
son contexte `bases` (defaut 6). recherche.js garde ses replis
|
||||||
(derivation -dev, href d'onglet de barre) si l'attribut est vide. #}
|
(derivation -dev, href d'onglet de barre) si l'attribut est vide. #}
|
||||||
<main class="rp-page" id="recherche" data-host="flowdeck"
|
<main class="rp-page" id="recherche" data-host="flowdeck"
|
||||||
|
data-recherche-partout="{{ 'true' if recherche_partout else 'false' }}"
|
||||||
data-base-obsigate="{{ bases.obsigate }}"
|
data-base-obsigate="{{ bases.obsigate }}"
|
||||||
data-base-flowdeck="{{ bases.flowdeck }}"
|
data-base-flowdeck="{{ bases.flowdeck }}"
|
||||||
data-base-tiktak="{{ bases.tiktak }}"
|
data-base-tiktak="{{ bases.tiktak }}"
|
||||||
|
|||||||
@@ -91,6 +91,12 @@ ENV.globals["app_base_url"] = lambda: _app_base_url()
|
|||||||
ENV.globals["draco_urls"] = lambda: _draco_urls()
|
ENV.globals["draco_urls"] = lambda: _draco_urls()
|
||||||
ENV.globals["draco_agent_url"] = lambda: _draco_urls()["agent"]
|
ENV.globals["draco_agent_url"] = lambda: _draco_urls()["agent"]
|
||||||
ENV.globals["gitea_url"] = lambda: _draco_urls()["gitea"]
|
ENV.globals["gitea_url"] = lambda: _draco_urls()["gitea"]
|
||||||
|
# Bannière d'annonce des réglages généraux du Hub (HUB-21) : la
|
||||||
|
# FONCTION est exposée (pas un instantané) — chaque rendu du
|
||||||
|
# gabarit principal lit le cache serveur de 60 s de
|
||||||
|
# app.services.hub_reglages. Hub muet → chaîne vide → aucune
|
||||||
|
# bannière, comme aujourd'hui.
|
||||||
|
ENV.globals["banniere_hub"] = lambda: _banniere_hub()
|
||||||
|
|
||||||
|
|
||||||
def _app_base_url() -> str:
|
def _app_base_url() -> str:
|
||||||
@@ -105,6 +111,12 @@ def _draco_urls() -> dict:
|
|||||||
return draco_urls.toutes()
|
return draco_urls.toutes()
|
||||||
|
|
||||||
|
|
||||||
|
def _banniere_hub() -> str:
|
||||||
|
from app.services import hub_reglages
|
||||||
|
|
||||||
|
return hub_reglages.banniere()
|
||||||
|
|
||||||
|
|
||||||
def _plugin_enabled(slug: str) -> bool:
|
def _plugin_enabled(slug: str) -> bool:
|
||||||
from app.services.plugins import is_enabled
|
from app.services.plugins import is_enabled
|
||||||
|
|
||||||
|
|||||||
@@ -5342,3 +5342,21 @@ body.has-draco-bar .app-layout { height: calc(100vh - 60px); }
|
|||||||
background: transparent; cursor: pointer; font: inherit; font-size: 13.5px;
|
background: transparent; cursor: pointer; font: inherit; font-size: 13.5px;
|
||||||
}
|
}
|
||||||
.draco-liens-erreur { color: #c0392b; font-size: 13px; margin: 8px 0 0; }
|
.draco-liens-erreur { color: #c0392b; font-size: 13px; margin: 8px 0 0; }
|
||||||
|
|
||||||
|
/* Bannière d'annonce des réglages généraux du Hub (HUB-21) :
|
||||||
|
bandeau rendu par base.html sous la barre commune, patron du
|
||||||
|
pilote TikTak — accent Flowdeck, texte centré. */
|
||||||
|
.banniere-hub {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: var(--accent, #2f6fed); color: #fff;
|
||||||
|
font-size: 14px; line-height: 1.35;
|
||||||
|
}
|
||||||
|
.banniere-hub[hidden] { display: none; }
|
||||||
|
.banniere-hub-texte { flex: 1 1 auto; text-align: center; overflow-wrap: anywhere; }
|
||||||
|
.banniere-hub-fermer {
|
||||||
|
flex: 0 0 auto; border: 0; background: transparent; color: inherit;
|
||||||
|
font-size: 18px; line-height: 1; cursor: pointer; padding: 2px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.banniere-hub-fermer:hover { background: rgba(255, 255, 255, .18); }
|
||||||
|
|||||||
+73
-1
@@ -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,9 +798,25 @@
|
|||||||
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) {
|
||||||
|
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);
|
fetchConsole(currentQ, runId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function fetchConsole(q, myRun) {
|
function fetchConsole(q, myRun) {
|
||||||
var sec = sections.console;
|
var sec = sections.console;
|
||||||
@@ -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,198 @@
|
|||||||
|
"""HUB-21 — réglages généraux du Hub consommés par Flowdeck.
|
||||||
|
|
||||||
|
Propagation du pilote TikTak 0.61.22 : client serveur
|
||||||
|
``app.services.hub_reglages`` (cache 60 s, horodatage posé avant
|
||||||
|
l'appel, repli intégral sur les défauts actuels), bannière
|
||||||
|
d'annonce dans le gabarit principal (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é : Flowdeck n'a pas de
|
||||||
|
notion de domaine propre (voir le docstring du module).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services import hub_reglages
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _cache_propre():
|
||||||
|
hub_reglages.reinitialiser_cache()
|
||||||
|
yield
|
||||||
|
hub_reglages.reinitialiser_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def _pose_reglages(monkeypatch, reglages, compteur=None):
|
||||||
|
"""Simule la consultation du Hub (appel réseau évité)."""
|
||||||
|
def faux_consulter():
|
||||||
|
if compteur is not None:
|
||||||
|
compteur.append(1)
|
||||||
|
return reglages
|
||||||
|
monkeypatch.setattr(hub_reglages, "_consulter_hub", faux_consulter)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Client : validation, cache, repli ───────────────────────────────
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class _Reponse:
|
||||||
|
def __init__(self, status_code, payload=None):
|
||||||
|
self.status_code = status_code
|
||||||
|
self._payload = payload
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
if isinstance(self._payload, Exception):
|
||||||
|
raise self._payload
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
|
||||||
|
def _pose_http(monkeypatch, reponse_ou_exception, appels=None):
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.services import draco_urls
|
||||||
|
monkeypatch.setattr(draco_urls, "hub_url",
|
||||||
|
lambda: "https://hub.example")
|
||||||
|
|
||||||
|
def faux_get(url, headers=None, timeout=None):
|
||||||
|
if appels is not None:
|
||||||
|
appels.append((url, headers, timeout))
|
||||||
|
if isinstance(reponse_ou_exception, Exception):
|
||||||
|
raise reponse_ou_exception
|
||||||
|
return reponse_ou_exception
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "get", faux_get)
|
||||||
|
|
||||||
|
|
||||||
|
def test_consultation_contrat_exact(monkeypatch):
|
||||||
|
appels = []
|
||||||
|
_pose_http(monkeypatch, _Reponse(200, {
|
||||||
|
"recherche_partout": True, "banniere": "Annonce",
|
||||||
|
"domaine_defaut": None, "ttl_secondes": 60}), appels)
|
||||||
|
assert hub_reglages._consulter_hub() == {
|
||||||
|
"recherche_partout": True, "banniere": "Annonce",
|
||||||
|
"domaine_defaut": None}
|
||||||
|
url, headers, timeout = appels[0]
|
||||||
|
assert url == "https://hub.example/api/reglages/effectifs"
|
||||||
|
assert headers["Accept"] == "application/json"
|
||||||
|
assert "Cookie" not in headers # endpoint public
|
||||||
|
assert timeout == 2.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("reponse", [
|
||||||
|
_Reponse(500, {}),
|
||||||
|
_Reponse(200, {"recherche_partout": "oui", "banniere": "",
|
||||||
|
"domaine_defaut": None}),
|
||||||
|
_Reponse(200, ValueError("json illisible")),
|
||||||
|
ConnectionError("hub injoignable"),
|
||||||
|
])
|
||||||
|
def test_consultation_erreurs_repli_none(monkeypatch, reponse):
|
||||||
|
_pose_http(monkeypatch, reponse)
|
||||||
|
assert hub_reglages._consulter_hub() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_hub_muet_repli_defauts(monkeypatch):
|
||||||
|
_pose_reglages(monkeypatch, None)
|
||||||
|
assert hub_reglages.reglages_effectifs() == hub_reglages.DEFAUTS
|
||||||
|
assert hub_reglages.banniere() == ""
|
||||||
|
assert hub_reglages.recherche_partout() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_60_s(monkeypatch):
|
||||||
|
appels = []
|
||||||
|
_pose_reglages(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_en_vigueur(monkeypatch):
|
||||||
|
appels = []
|
||||||
|
_pose_reglages(monkeypatch, {
|
||||||
|
"recherche_partout": True, "banniere": "Annonce A",
|
||||||
|
"domaine_defaut": None}, appels)
|
||||||
|
assert hub_reglages.banniere() == "Annonce A"
|
||||||
|
hub_reglages._cache_at = time.monotonic() - 61
|
||||||
|
_pose_reglages(monkeypatch, None, appels) # Hub tombe
|
||||||
|
assert hub_reglages.banniere() == "Annonce A" # ancien cache
|
||||||
|
assert len(appels) == 2 # pas de rafale
|
||||||
|
|
||||||
|
|
||||||
|
# ── Intégration : bannière et drapeau dans les pages ────────────────
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def hub_banniere(monkeypatch):
|
||||||
|
etat = {"reglages": None}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
hub_reglages, "_consulter_hub", lambda: etat["reglages"])
|
||||||
|
hub_reglages.reinitialiser_cache()
|
||||||
|
return etat
|
||||||
|
|
||||||
|
|
||||||
|
def test_banniere_presente_et_echappee(client, hub_banniere):
|
||||||
|
hub_banniere["reglages"] = {
|
||||||
|
"recherche_partout": True,
|
||||||
|
"banniere": "Maintenance dimanche <b>gras</b> & cie",
|
||||||
|
"domaine_defaut": None}
|
||||||
|
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 "sessionStorage" in r.text
|
||||||
|
assert "flowdeck_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("/recherche")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "banniere-hub" not in r.text
|
||||||
|
hub_banniere["reglages"] = None
|
||||||
|
hub_reglages.reinitialiser_cache()
|
||||||
|
r = client.get("/recherche")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "banniere-hub" not in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_recherche_drapeau_rendu_dans_la_coquille(client, hub_banniere):
|
||||||
|
hub_banniere["reglages"] = {
|
||||||
|
"recherche_partout": False, "banniere": "", "domaine_defaut": None}
|
||||||
|
r = client.get("/recherche")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert 'data-recherche-partout="false"' 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
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_7_85_3():
|
||||||
|
from pathlib import Path
|
||||||
|
version = (Path(__file__).parent.parent / "VERSION").read_text().strip()
|
||||||
|
assert version == "7.85.3"
|
||||||
Reference in New Issue
Block a user