UI web d'admin + garde-fou recettes premium (supplément hors abonnement)

- Refus des recettes payantes (chargeSetting) à la sélection, override allow_premium
- Recipe.surcharge_cents/is_premium exposés dans summary(); propose() les exclut
- hellofresh/webui.py : page d'admin + API JSON montées sur FastMCP (/, /api/*)
  édition à chaud des excludes et préférences (liked/disliked)
This commit is contained in:
jerem
2026-06-18 18:07:12 +02:00
parent a14ce4664b
commit 61ee7f02a4
6 changed files with 358 additions and 10 deletions

View File

@@ -228,15 +228,23 @@ class HelloFreshClient:
if not courses:
return []
id_index: dict[str, int] = {}
charge_by_id: dict[str, dict] = {}
ids: list[str] = []
for c in courses:
rid = (c.get("recipe") or {}).get("id")
if rid and rid not in id_index:
id_index[rid] = c.get("index")
cs = c.get("chargeSetting")
if isinstance(cs, dict):
charge_by_id[rid] = cs
ids.append(rid)
recipes = self._fetch_details(ids)
for r in recipes:
r.course_index = id_index.get(r.id)
cs = charge_by_id.get(r.id)
if cs:
r.surcharge_cents = int(cs.get("amount") or 0)
r.surcharge_reason = str(cs.get("reason") or "")
return _dedupe_by_name(recipes)
def _fetch_details(self, ids: list[str]) -> list[Recipe]:

View File

@@ -38,7 +38,7 @@ def save_excludes(terms: list[str]) -> None:
if EXCLUDES_PATH.exists():
existing = json.loads(EXCLUDES_PATH.read_text(encoding="utf-8"))
existing["exclude"] = terms
EXCLUDES_PATH.write_text(json.dumps(existing, indent=2, ensure_ascii=False), encoding="utf-8")
EXCLUDES_PATH.write_text(json.dumps(existing, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def add_exclude(term: str) -> list[str]:
@@ -63,6 +63,17 @@ def load_prefs() -> dict:
return {"liked": data.get("liked", []), "disliked": data.get("disliked", [])}
def save_prefs(liked: list[str], disliked: list[str]) -> dict:
"""Écrit liked/disliked en préservant le commentaire éventuel du fichier."""
existing = {}
if PREFS_PATH.exists():
existing = json.loads(PREFS_PATH.read_text(encoding="utf-8"))
existing["liked"] = liked
existing["disliked"] = disliked
PREFS_PATH.write_text(json.dumps(existing, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
return {"liked": liked, "disliked": disliked}
# --- application aux recettes ----------------------------------------------
def _exclusion_haystack(recipe: Recipe) -> str:
"""Champs FAISANT FOI pour l'exclusion : ingrédients, allergènes, nom, accroche.
@@ -115,9 +126,14 @@ def annotate(recipes: list[Recipe]) -> list[Recipe]:
return recipes
def propose(recipes: list[Recipe], count: int | None = None) -> list[Recipe]:
"""Retire les recettes exclues (coco…) et classe le reste par score décroissant."""
def propose(recipes: list[Recipe], count: int | None = None,
allow_premium: bool = False) -> list[Recipe]:
"""Retire les recettes exclues (coco…) et payantes, classe le reste par score décroissant.
`allow_premium=True` conserve les recettes à supplément (hors abonnement) dans la liste.
"""
annotate(recipes)
safe = [r for r in recipes if not r.contains_excluded]
safe = [r for r in recipes
if not r.contains_excluded and (allow_premium or not r.is_premium)]
safe.sort(key=lambda r: r.score, reverse=True)
return safe[:count] if count else safe

View File

@@ -52,12 +52,20 @@ class Recipe:
image_url: str = ""
prep_time: str = ""
course_index: int | None = None # index du course dans le menu (sert à l'écriture)
# Supplément hors abonnement (rempli depuis le menu) : 0 = inclus, >0 = surcoût en centimes
surcharge_cents: int = 0
surcharge_reason: str = "" # ex. "premium" (motif renvoyé par chargeSetting)
# Champs calculés par le filtre (remplis plus tard)
contains_excluded: bool = False
matched_excludes: list[str] = field(default_factory=list)
score: float = 0.0
is_favorite: bool = False # rempli par api (best-effort) depuis le service favoris
@property
def is_premium(self) -> bool:
"""True si la recette coûte un supplément (non incluse dans l'abonnement)."""
return self.surcharge_cents > 0
@classmethod
def from_api(cls, raw: dict[str, Any]) -> "Recipe":
"""Construit une Recipe depuis un objet recette brut de l'API gateway.
@@ -123,6 +131,9 @@ class Recipe:
"image_url": fix_image_url(self.image_url),
"prep_time": self.prep_time,
"prep_minutes": _iso_duration_to_minutes(self.prep_time),
"is_premium": self.is_premium,
"surcharge_eur": round(self.surcharge_cents / 100, 2) if self.surcharge_cents else 0,
"surcharge_reason": self.surcharge_reason,
"allergens": self.allergens,
"contains_excluded": self.contains_excluded,
"matched_excludes": self.matched_excludes,

275
hellofresh/webui.py Normal file
View File

@@ -0,0 +1,275 @@
"""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}
PUT /api/excludes-> sauve la liste d'exclusion
PUT /api/prefs -> sauve liked/disliked
"""
from __future__ import annotations
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse, Response
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 _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/excludes", methods=["PUT"], include_in_schema=False)(_put_excludes)
mcp.custom_route("/api/prefs", methods=["PUT"], include_in_schema=False)(_put_prefs)
_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; } }
.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">
<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 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) || [];
render('excludes'); render('liked'); render('disliked');
} catch (e) {
toast('Chargement impossible : ' + e.message, true);
}
}
load();
</script>
</body>
</html>
"""