UI web : statut de connexion HelloFresh + checkbox recettes premium

- Carte « Connexion HelloFresh » (pastille + bouton Rafraîchir) via un nouvel
  endpoint GET /api/auth-status (auth.auth_status, vérifié contre l'API, déporté
  dans un thread pour ne pas figer la boucle asyncio).
- Checkbox « Recettes premium » : réglage persistant allow_premium dans
  config/prefs.json (load/save_allow_premium dans filter.py), exposé par
  /api/config et piloté par PUT /api/allow-premium.
- Le réglage devient le défaut côté MCP : hf_propose inclut/écarte les premium
  selon la case (le signale dans allow_premium/note), hf_confirm_selection
  reprend ce défaut quand allow_premium n'est pas passé explicitement.
- .dockerignore ajouté.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 20:23:44 +02:00
parent ad2b00c425
commit 29ac984113
5 changed files with 163 additions and 16 deletions

View File

@@ -7,17 +7,21 @@ Permet d'éditer à la main les paramètres « métier » sans rebuild ni éditi
Les écritures passent par `hellofresh.filter` (mêmes fichiers que le serveur MCP),
donc le serveur prend en compte les changements au prochain appel — pas de redémarrage.
Routes enregistrées via `register(mcp)` depuis server.py :
GET / -> page HTML
GET /api/config -> {excludes, prefs}
PUT /api/excludes-> sauve la liste d'exclusion
PUT /api/prefs -> sauve liked/disliked
GET / -> page HTML
GET /api/config -> {excludes, prefs}
GET /api/auth-status -> état de connexion HelloFresh (vérifié contre l'API)
PUT /api/excludes -> sauve la liste d'exclusion
PUT /api/prefs -> sauve liked/disliked
PUT /api/allow-premium -> sauve le réglage « recettes premium autorisées »
"""
from __future__ import annotations
import anyio
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse, Response
from . import auth as hf_auth
from . import filter as hf_filter
@@ -64,6 +68,25 @@ async def _put_prefs(request: Request) -> Response:
return JSONResponse(saved)
async def _put_allow_premium(request: Request) -> Response:
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "JSON invalide"}, status_code=400)
value = hf_filter.save_allow_premium(bool(body.get("allow_premium")))
return JSONResponse({"allow_premium": value})
async def _auth_status(request: Request) -> Response:
"""État de connexion HelloFresh, vérifié par un vrai appel API (peut être lent).
Déporté dans un thread : `auth.auth_status` fait des I/O réseau bloquantes (httpx
sync) et ne doit pas figer la boucle asyncio du serveur.
"""
status = await anyio.to_thread.run_sync(hf_auth.auth_status)
return JSONResponse(status)
async def _index(request: Request) -> Response:
return HTMLResponse(_PAGE)
@@ -72,8 +95,10 @@ def register(mcp) -> None:
"""Enregistre les routes web sur le serveur FastMCP."""
mcp.custom_route("/", methods=["GET"], include_in_schema=False)(_index)
mcp.custom_route("/api/config", methods=["GET"], include_in_schema=False)(_config)
mcp.custom_route("/api/auth-status", methods=["GET"], include_in_schema=False)(_auth_status)
mcp.custom_route("/api/excludes", methods=["PUT"], include_in_schema=False)(_put_excludes)
mcp.custom_route("/api/prefs", methods=["PUT"], include_in_schema=False)(_put_prefs)
mcp.custom_route("/api/allow-premium", methods=["PUT"], include_in_schema=False)(_put_allow_premium)
_PAGE = """<!doctype html>
@@ -126,6 +151,27 @@ _PAGE = """<!doctype html>
}
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
@media (max-width: 560px) { .cols { grid-template-columns: 1fr; } }
.status-row { display: flex; align-items: center; gap: 14px; }
.status-row h2 { margin: 0; }
.status-row p { margin: 2px 0 0; }
.dot {
width: 12px; height: 12px; border-radius: 50%; flex: 0 0 auto;
background: var(--muted); box-shadow: 0 0 0 0 transparent;
}
.dot.ok { background: var(--accent); box-shadow: 0 0 8px 1px rgba(74,222,128,.5); }
.dot.bad { background: var(--danger); box-shadow: 0 0 8px 1px rgba(248,113,113,.5); }
.dot.wait { background: var(--muted); animation: pulse 1s ease-in-out infinite; }
@keyframes pulse { 0%,100% { opacity: .35; } 50% { opacity: 1; } }
button.ghost {
background: transparent; color: var(--fg); border: 1px solid var(--line);
border-radius: 9px; padding: 8px 14px; font-size: 13px; cursor: pointer;
}
button.ghost:hover { border-color: var(--accent); color: var(--accent); }
label.check {
display: flex; align-items: center; gap: 10px; cursor: pointer; user-select: none;
font-size: 14px;
}
label.check input { width: 17px; height: 17px; accent-color: var(--accent); cursor: pointer; }
.toast {
position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%) translateY(20px);
background: var(--card); border: 1px solid var(--line); color: var(--fg);
@@ -143,6 +189,26 @@ _PAGE = """<!doctype html>
<span>réglages — modifiés à chaud, pris en compte au prochain appel MCP</span>
</header>
<div class="card">
<div class="status-row">
<span class="dot wait" id="status-dot"></span>
<div style="flex:1">
<h2>Connexion HelloFresh</h2>
<p class="empty" id="status-text">Vérification…</p>
</div>
<button class="ghost" id="status-refresh" onclick="loadStatus()">Rafraîchir</button>
</div>
</div>
<div class="card">
<h2>Recettes premium</h2>
<p>Les recettes « premium » coûtent un supplément hors abonnement. Décoché, elles sont écartées des propositions et refusées à la confirmation (garde-fou anti-surcoût). Coché, elles sont autorisées par défaut.</p>
<label class="check">
<input type="checkbox" id="allow-premium" onchange="saveAllowPremium()">
<span>Autoriser les recettes premium (supplément)</span>
</label>
</div>
<div class="card">
<h2>Ingrédients à exclure</h2>
<p>Toute recette contenant l'un de ces termes (nom d'ingrédient ou allergène, sans accent ni casse) est refusée à la sélection.</p>
@@ -256,6 +322,46 @@ for (const id of ['excludes-input', 'liked-input', 'disliked-input']) {
});
}
async function loadStatus() {
const dot = document.getElementById('status-dot');
const txt = document.getElementById('status-text');
dot.className = 'dot wait';
txt.textContent = 'Vérification…';
try {
const r = await fetch('/api/auth-status');
const s = await r.json();
if (s.logged_in) {
dot.className = 'dot ok';
let detail = s.source ? ' · ' + s.source : '';
if (typeof s.token_age_s === 'number') detail += ' · token ' + Math.floor(s.token_age_s / 60) + ' min';
txt.textContent = 'Connecté' + detail;
} else {
dot.className = 'dot bad';
txt.textContent = 'Déconnecté — ' + (s.error || 'session expirée');
}
} catch (e) {
dot.className = 'dot bad';
txt.textContent = 'Statut indisponible : ' + e.message;
}
}
async function saveAllowPremium() {
const cb = document.getElementById('allow-premium');
try {
const r = await fetch('/api/allow-premium', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ allow_premium: cb.checked }),
});
if (!r.ok) throw new Error(await r.text());
const data = await r.json();
cb.checked = !!data.allow_premium;
toast(cb.checked ? 'Premium autorisé ✓' : 'Premium écarté ✓');
} catch (e) {
cb.checked = !cb.checked; // revert visuel si l'écriture a échoué
toast('Erreur : ' + e.message, true);
}
}
async function load() {
try {
const r = await fetch('/api/config');
@@ -263,12 +369,14 @@ async function load() {
state.excludes = cfg.excludes || [];
state.liked = (cfg.prefs && cfg.prefs.liked) || [];
state.disliked = (cfg.prefs && cfg.prefs.disliked) || [];
document.getElementById('allow-premium').checked = !!(cfg.prefs && cfg.prefs.allow_premium);
render('excludes'); render('liked'); render('disliked');
} catch (e) {
toast('Chargement impossible : ' + e.message, true);
}
}
load();
loadStatus();
</script>
</body>
</html>