Files
AntiCoco/hellofresh/webui.py
Jerem 29ac984113 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>
2026-06-18 20:23:44 +02:00

384 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Petite interface web d'administration, greffée sur le serveur FastMCP (même port).
Permet d'éditer à la main les paramètres « métier » sans rebuild ni édition JSON :
- la liste des ingrédients à exclure (coco & co) ;
- les préférences de scoring (liked / disliked).
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}
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
def _clean_list(value) -> list[str]:
"""Normalise une entrée en liste de chaînes non vides, dédupliquées (ordre conservé)."""
if not isinstance(value, list):
return []
out: list[str] = []
seen: set[str] = set()
for v in value:
s = str(v).strip()
key = hf_filter.normalize(s)
if s and key not in seen:
seen.add(key)
out.append(s)
return out
async def _config(request: Request) -> Response:
return JSONResponse({
"excludes": hf_filter.load_excludes(),
"prefs": hf_filter.load_prefs(),
})
async def _put_excludes(request: Request) -> Response:
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "JSON invalide"}, status_code=400)
terms = _clean_list(body.get("exclude"))
hf_filter.save_excludes(terms)
return JSONResponse({"exclude": terms})
async def _put_prefs(request: Request) -> Response:
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "JSON invalide"}, status_code=400)
liked = _clean_list(body.get("liked"))
disliked = _clean_list(body.get("disliked"))
saved = hf_filter.save_prefs(liked, disliked)
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)
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>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AntiCoco — réglages</title>
<style>
:root {
--bg: #0f1115; --card: #181b22; --line: #272b34; --fg: #e6e8ec;
--muted: #8b909c; --accent: #4ade80; --danger: #f87171; --chip: #232732;
}
* { box-sizing: border-box; }
body {
margin: 0; font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg); color: var(--fg); padding: 32px 16px;
}
.wrap { max-width: 760px; margin: 0 auto; }
header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 24px; }
h1 { font-size: 22px; margin: 0; letter-spacing: -.3px; }
header span { color: var(--muted); font-size: 13px; }
.card {
background: var(--card); border: 1px solid var(--line); border-radius: 14px;
padding: 20px 22px; margin-bottom: 18px;
}
.card h2 { font-size: 15px; margin: 0 0 4px; }
.card p { color: var(--muted); font-size: 13px; margin: 0 0 14px; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
.chip {
display: inline-flex; align-items: center; gap: 6px; background: var(--chip);
border: 1px solid var(--line); border-radius: 999px; padding: 5px 6px 5px 12px;
font-size: 13px;
}
.chip button {
border: 0; background: transparent; color: var(--muted); cursor: pointer;
font-size: 15px; line-height: 1; padding: 0 4px; border-radius: 50%;
}
.chip button:hover { color: var(--danger); }
.empty { color: var(--muted); font-size: 13px; font-style: italic; }
.addrow { display: flex; gap: 8px; }
input[type=text] {
flex: 1; background: var(--bg); border: 1px solid var(--line); color: var(--fg);
border-radius: 9px; padding: 9px 12px; font-size: 14px; outline: none;
}
input[type=text]:focus { border-color: var(--accent); }
.addrow button {
background: var(--accent); color: #06210f; border: 0; border-radius: 9px;
padding: 0 16px; font-weight: 600; cursor: pointer; font-size: 14px;
}
.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);
padding: 10px 18px; border-radius: 10px; font-size: 14px; opacity: 0;
transition: all .25s; pointer-events: none;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.toast.err { border-color: var(--danger); color: var(--danger); }
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>🥥🚫 AntiCoco</h1>
<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>
<div class="chips" id="excludes-chips"></div>
<div class="addrow">
<input type="text" id="excludes-input" placeholder="ex. coco, arachide…" autocomplete="off">
<button onclick="addItem('excludes')">Ajouter</button>
</div>
</div>
<div class="card">
<h2>Préférences de scoring</h2>
<p>Mots-clés cherchés dans le nom, le titre et les ingrédients. « Aimé » remonte la recette dans les propositions, « pas aimé » la fait descendre (sans l'exclure).</p>
<div class="cols">
<div>
<strong style="font-size:13px;color:var(--accent)">👍 Aimé</strong>
<div class="chips" id="liked-chips" style="margin-top:10px"></div>
<div class="addrow">
<input type="text" id="liked-input" placeholder="ex. poulet…" autocomplete="off">
<button onclick="addItem('liked')">+</button>
</div>
</div>
<div>
<strong style="font-size:13px;color:var(--danger)">👎 Pas aimé</strong>
<div class="chips" id="disliked-chips" style="margin-top:10px"></div>
<div class="addrow">
<input type="text" id="disliked-input" placeholder="ex. betterave…" autocomplete="off">
<button onclick="addItem('disliked')">+</button>
</div>
</div>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const state = { excludes: [], liked: [], disliked: [] };
function toast(msg, isErr) {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast show' + (isErr ? ' err' : '');
clearTimeout(t._tm);
t._tm = setTimeout(() => { t.className = 'toast'; }, 1800);
}
function render(key) {
const box = document.getElementById(key + '-chips');
const list = state[key];
box.innerHTML = '';
if (!list.length) {
box.innerHTML = '<span class="empty">aucun</span>';
return;
}
for (const term of list) {
const chip = document.createElement('span');
chip.className = 'chip';
chip.append(document.createTextNode(term));
const btn = document.createElement('button');
btn.textContent = '×';
btn.title = 'Retirer';
btn.onclick = () => removeItem(key, term);
chip.append(btn);
box.append(chip);
}
}
async function save(key) {
let url, payload;
if (key === 'excludes') {
url = '/api/excludes'; payload = { exclude: state.excludes };
} else {
url = '/api/prefs'; payload = { liked: state.liked, disliked: state.disliked };
}
try {
const r = await fetch(url, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!r.ok) throw new Error(await r.text());
const data = await r.json();
if (key === 'excludes') { state.excludes = data.exclude; render('excludes'); }
else { state.liked = data.liked; state.disliked = data.disliked; render('liked'); render('disliked'); }
toast('Enregistré ✓');
} catch (e) {
toast('Erreur : ' + e.message, true);
}
}
function addItem(key) {
const inputId = (key === 'excludes') ? 'excludes-input' : key + '-input';
const input = document.getElementById(inputId);
const val = input.value.trim();
if (!val) return;
if (!state[key].some(x => x.toLowerCase() === val.toLowerCase())) {
state[key].push(val);
}
input.value = '';
save(key === 'excludes' ? 'excludes' : 'prefs');
}
function removeItem(key, term) {
state[key] = state[key].filter(x => x !== term);
save(key === 'excludes' ? 'excludes' : 'prefs');
}
// Entrée = ajouter
for (const id of ['excludes-input', 'liked-input', 'disliked-input']) {
document.getElementById(id).addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); addItem(id.replace('-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');
const cfg = await r.json();
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>
"""