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

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
.git
__pycache__/
*.pyc
.env
.session/
.venv/
.pytest_cache/

View File

@@ -1,14 +1,19 @@
{ {
"_comment": "Préférences optionnelles pour classer les recettes proposées. 'liked'/'disliked' sont des mots-clés cherchés dans le nom, le titre et les ingrédients de la recette. Un match 'liked' augmente le score, un 'disliked' le baisse (sans exclure — pour exclure, utiliser excludes.json).", "_comment": "Préférences optionnelles pour classer les recettes proposées. 'liked'/'disliked' sont des mots-clés cherchés dans le nom, le titre et les ingrédients de la recette. Un match 'liked' augmente le score, un 'disliked' le baisse (sans exclure — pour exclure, utiliser excludes.json). 'allow_premium' : autorise par défaut les recettes à supplément hors abonnement (piloté par la checkbox de l'UI web).",
"allow_premium": false,
"liked": [ "liked": [
"boeuf", "boeuf",
"poulet", "poulet",
"pates", "pates",
"fromage", "fromage",
"champignon" "champignon",
"burger",
"pizza",
"naan"
], ],
"disliked": [ "disliked": [
"epinard", "betterave",
"betterave" "olive",
"cerfeuil"
] ]
} }

View File

@@ -58,13 +58,17 @@ def remove_exclude(term: str) -> list[str]:
def load_prefs() -> dict: def load_prefs() -> dict:
if not PREFS_PATH.exists(): if not PREFS_PATH.exists():
return {"liked": [], "disliked": []} return {"liked": [], "disliked": [], "allow_premium": False}
data = json.loads(PREFS_PATH.read_text(encoding="utf-8")) 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: 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 = {} existing = {}
if PREFS_PATH.exists(): if PREFS_PATH.exists():
existing = json.loads(PREFS_PATH.read_text(encoding="utf-8")) 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} 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 ---------------------------------------------- # --- application aux recettes ----------------------------------------------
def _exclusion_haystack(recipe: Recipe) -> str: def _exclusion_haystack(recipe: Recipe) -> str:
"""Champs FAISANT FOI pour l'exclusion : ingrédients, allergènes, nom, accroche. """Champs FAISANT FOI pour l'exclusion : ingrédients, allergènes, nom, accroche.

View File

@@ -9,15 +9,19 @@ donc le serveur prend en compte les changements au prochain appel — pas de red
Routes enregistrées via `register(mcp)` depuis server.py : Routes enregistrées via `register(mcp)` depuis server.py :
GET / -> page HTML GET / -> page HTML
GET /api/config -> {excludes, prefs} 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/excludes -> sauve la liste d'exclusion
PUT /api/prefs -> sauve liked/disliked PUT /api/prefs -> sauve liked/disliked
PUT /api/allow-premium -> sauve le réglage « recettes premium autorisées »
""" """
from __future__ import annotations from __future__ import annotations
import anyio
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse, Response from starlette.responses import HTMLResponse, JSONResponse, Response
from . import auth as hf_auth
from . import filter as hf_filter from . import filter as hf_filter
@@ -64,6 +68,25 @@ async def _put_prefs(request: Request) -> Response:
return JSONResponse(saved) 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: async def _index(request: Request) -> Response:
return HTMLResponse(_PAGE) return HTMLResponse(_PAGE)
@@ -72,8 +95,10 @@ def register(mcp) -> None:
"""Enregistre les routes web sur le serveur FastMCP.""" """Enregistre les routes web sur le serveur FastMCP."""
mcp.custom_route("/", methods=["GET"], include_in_schema=False)(_index) 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/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/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/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> _PAGE = """<!doctype html>
@@ -126,6 +151,27 @@ _PAGE = """<!doctype html>
} }
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; } .cols { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
@media (max-width: 560px) { .cols { grid-template-columns: 1fr; } } @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 { .toast {
position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%) translateY(20px); position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%) translateY(20px);
background: var(--card); border: 1px solid var(--line); color: var(--fg); 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> <span>réglages — modifiés à chaud, pris en compte au prochain appel MCP</span>
</header> </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"> <div class="card">
<h2>Ingrédients à exclure</h2> <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> <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() { async function load() {
try { try {
const r = await fetch('/api/config'); const r = await fetch('/api/config');
@@ -263,12 +369,14 @@ async function load() {
state.excludes = cfg.excludes || []; state.excludes = cfg.excludes || [];
state.liked = (cfg.prefs && cfg.prefs.liked) || []; state.liked = (cfg.prefs && cfg.prefs.liked) || [];
state.disliked = (cfg.prefs && cfg.prefs.disliked) || []; state.disliked = (cfg.prefs && cfg.prefs.disliked) || [];
document.getElementById('allow-premium').checked = !!(cfg.prefs && cfg.prefs.allow_premium);
render('excludes'); render('liked'); render('disliked'); render('excludes'); render('liked'); render('disliked');
} catch (e) { } catch (e) {
toast('Chargement impossible : ' + e.message, true); toast('Chargement impossible : ' + e.message, true);
} }
} }
load(); load();
loadStatus();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -159,18 +159,23 @@ async def hf_propose(week: str = "", count: int = 0) -> dict:
""" """
def _impl() -> dict: def _impl() -> dict:
w = week or api.current_week() w = week or api.current_week()
allow_premium = hf_filter.load_allow_premium()
with api.HelloFreshClient() as client: with api.HelloFreshClient() as client:
recipes = client.get_menu(w) recipes = client.get_menu(w)
safe = hf_filter.propose(recipes, count=count or None) safe = hf_filter.propose(recipes, count=count or None, allow_premium=allow_premium)
excluded = [r.summary() for r in recipes if r.contains_excluded] excluded = [r.summary() for r in recipes if r.contains_excluded]
premium = [r.summary() for r in recipes if r.is_premium and not r.contains_excluded] premium = [r.summary() for r in recipes if r.is_premium and not r.contains_excluded]
premium_note = ("Recettes premium (supplément) INCLUSES dans la proposition (réglage UI activé). "
if allow_premium else
"Les recettes premium (supplément) sont exclues de la proposition. ")
return { return {
"week": w, "week": w,
"allow_premium": allow_premium,
"proposed": [r.summary() for r in safe], "proposed": [r.summary() for r in safe],
"excluded_for_coco_etc": excluded, "excluded_for_coco_etc": excluded,
"premium_extra_cost": premium, "premium_extra_cost": premium,
"note": "Aucune écriture effectuée. Les recettes premium (supplément) sont exclues " "note": "Aucune écriture effectuée. " + premium_note
"de la proposition. Confirme avec hf_confirm_selection(week, recipe_ids).", + "Confirme avec hf_confirm_selection(week, recipe_ids).",
} }
return await anyio.to_thread.run_sync(_impl) return await anyio.to_thread.run_sync(_impl)
@@ -178,14 +183,17 @@ async def hf_propose(week: str = "", count: int = 0) -> dict:
@mcp.tool() @mcp.tool()
async def hf_confirm_selection(week: str, recipe_ids: list[str], dry_run: bool = False, async def hf_confirm_selection(week: str, recipe_ids: list[str], dry_run: bool = False,
allow_premium: bool = False) -> dict: allow_premium: bool | None = None) -> dict:
"""ÉCRIT la sélection de recettes dans la box de la semaine (après confirmation). """ÉCRIT la sélection de recettes dans la box de la semaine (après confirmation).
Garde-fous : refuse toute recette contenant un ingrédient exclu (coco !) ET toute Garde-fous : refuse toute recette contenant un ingrédient exclu (coco !) ET toute
recette payante hors abonnement (premium, supplément) sauf si `allow_premium=True`. recette payante hors abonnement (premium, supplément) sauf si `allow_premium=True`.
`allow_premium=None` (défaut) reprend le réglage de l'UI web (prefs.json) ; passer
True/False ici l'emporte ponctuellement sur ce réglage.
`dry_run=True` : construit et renvoie la requête sans l'envoyer (vérification). `dry_run=True` : construit et renvoie la requête sans l'envoyer (vérification).
""" """
def _impl() -> dict: def _impl() -> dict:
allow = hf_filter.load_allow_premium() if allow_premium is None else allow_premium
with api.HelloFreshClient() as client: with api.HelloFreshClient() as client:
menu = client.get_menu(week) menu = client.get_menu(week)
hf_filter.annotate(menu) hf_filter.annotate(menu)
@@ -199,7 +207,7 @@ async def hf_confirm_selection(week: str, recipe_ids: list[str], dry_run: bool =
} }
premium = [rid for rid in recipe_ids premium = [rid for rid in recipe_ids
if rid in by_id and by_id[rid].is_premium] if rid in by_id and by_id[rid].is_premium]
if premium and not allow_premium: if premium and not allow:
return { return {
"ok": False, "ok": False,
"error": "Sélection refusée : recette(s) payante(s) hors abonnement (supplément). " "error": "Sélection refusée : recette(s) payante(s) hors abonnement (supplément). "