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:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.session/
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 :
|
||||
GET / -> page HTML
|
||||
GET /api/config -> {excludes, prefs}
|
||||
PUT /api/excludes-> sauve la liste d'exclusion
|
||||
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>
|
||||
|
||||
18
server.py
18
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). "
|
||||
|
||||
Reference in New Issue
Block a user