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:
@@ -58,13 +58,17 @@ def remove_exclude(term: str) -> list[str]:
|
||||
|
||||
def load_prefs() -> dict:
|
||||
if not PREFS_PATH.exists():
|
||||
return {"liked": [], "disliked": []}
|
||||
return {"liked": [], "disliked": [], "allow_premium": False}
|
||||
data = json.loads(PREFS_PATH.read_text(encoding="utf-8"))
|
||||
return {"liked": data.get("liked", []), "disliked": data.get("disliked", [])}
|
||||
return {
|
||||
"liked": data.get("liked", []),
|
||||
"disliked": data.get("disliked", []),
|
||||
"allow_premium": bool(data.get("allow_premium", False)),
|
||||
}
|
||||
|
||||
|
||||
def save_prefs(liked: list[str], disliked: list[str]) -> dict:
|
||||
"""Écrit liked/disliked en préservant le commentaire éventuel du fichier."""
|
||||
"""Écrit liked/disliked en préservant le reste du fichier (commentaire, allow_premium)."""
|
||||
existing = {}
|
||||
if PREFS_PATH.exists():
|
||||
existing = json.loads(PREFS_PATH.read_text(encoding="utf-8"))
|
||||
@@ -74,6 +78,21 @@ def save_prefs(liked: list[str], disliked: list[str]) -> dict:
|
||||
return {"liked": liked, "disliked": disliked}
|
||||
|
||||
|
||||
def load_allow_premium() -> bool:
|
||||
"""Réglage : True si les recettes à supplément (premium) sont autorisées par défaut."""
|
||||
return load_prefs()["allow_premium"]
|
||||
|
||||
|
||||
def save_allow_premium(value: bool) -> bool:
|
||||
"""Persiste le réglage `allow_premium` en préservant le reste de prefs.json."""
|
||||
existing = {}
|
||||
if PREFS_PATH.exists():
|
||||
existing = json.loads(PREFS_PATH.read_text(encoding="utf-8"))
|
||||
existing["allow_premium"] = bool(value)
|
||||
PREFS_PATH.write_text(json.dumps(existing, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
return bool(value)
|
||||
|
||||
|
||||
# --- application aux recettes ----------------------------------------------
|
||||
def _exclusion_haystack(recipe: Recipe) -> str:
|
||||
"""Champs FAISANT FOI pour l'exclusion : ingrédients, allergènes, nom, accroche.
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user