- 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>
384 lines
14 KiB
Python
384 lines
14 KiB
Python
"""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>
|
||
"""
|