From 29ac984113fea5a4c245672f836284106821745e Mon Sep 17 00:00:00 2001 From: Jerem Date: Thu, 18 Jun 2026 20:23:44 +0200 Subject: [PATCH] UI web : statut de connexion HelloFresh + checkbox recettes premium MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .dockerignore | 7 +++ config/prefs.json | 13 +++-- hellofresh/filter.py | 25 ++++++++-- hellofresh/webui.py | 116 +++++++++++++++++++++++++++++++++++++++++-- server.py | 18 +++++-- 5 files changed, 163 insertions(+), 16 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..12bdf96 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +__pycache__/ +*.pyc +.env +.session/ +.venv/ +.pytest_cache/ diff --git a/config/prefs.json b/config/prefs.json index 040fbc4..227bc99 100644 --- a/config/prefs.json +++ b/config/prefs.json @@ -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": [ "boeuf", "poulet", "pates", "fromage", - "champignon" + "champignon", + "burger", + "pizza", + "naan" ], "disliked": [ - "epinard", - "betterave" + "betterave", + "olive", + "cerfeuil" ] } diff --git a/hellofresh/filter.py b/hellofresh/filter.py index f3da5c0..37eb49f 100644 --- a/hellofresh/filter.py +++ b/hellofresh/filter.py @@ -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. diff --git a/hellofresh/webui.py b/hellofresh/webui.py index 5a5fcc2..ef9787c 100644 --- a/hellofresh/webui.py +++ b/hellofresh/webui.py @@ -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 = """ @@ -126,6 +151,27 @@ _PAGE = """ } .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 = """ réglages — modifiés à chaud, pris en compte au prochain appel MCP +
+
+ +
+

Connexion HelloFresh

+

Vérification…

+
+ +
+
+ +
+

Recettes premium

+

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.

+ +
+

Ingrédients à exclure

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.

@@ -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(); diff --git a/server.py b/server.py index 745b839..ec72410 100644 --- a/server.py +++ b/server.py @@ -159,18 +159,23 @@ async def hf_propose(week: str = "", count: int = 0) -> dict: """ def _impl() -> dict: w = week or api.current_week() + allow_premium = hf_filter.load_allow_premium() with api.HelloFreshClient() as client: 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] 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 { "week": w, + "allow_premium": allow_premium, "proposed": [r.summary() for r in safe], "excluded_for_coco_etc": excluded, "premium_extra_cost": premium, - "note": "Aucune écriture effectuée. Les recettes premium (supplément) sont exclues " - "de la proposition. Confirme avec hf_confirm_selection(week, recipe_ids).", + "note": "Aucune écriture effectuée. " + premium_note + + "Confirme avec hf_confirm_selection(week, recipe_ids).", } return await anyio.to_thread.run_sync(_impl) @@ -178,14 +183,17 @@ async def hf_propose(week: str = "", count: int = 0) -> dict: @mcp.tool() 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). 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`. + `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). """ def _impl() -> dict: + allow = hf_filter.load_allow_premium() if allow_premium is None else allow_premium with api.HelloFreshClient() as client: menu = client.get_menu(week) 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 if rid in by_id and by_id[rid].is_premium] - if premium and not allow_premium: + if premium and not allow: return { "ok": False, "error": "Sélection refusée : recette(s) payante(s) hors abonnement (supplément). "