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:
20
README.md
20
README.md
@@ -35,9 +35,11 @@ intervention ni re-sync. Le navigateur headless ne sert plus que de filet de sec
|
||||
|
||||
```
|
||||
Hermes ──HTTP──▶ server.py (FastMCP, :9200/mcp)
|
||||
navigateur ──▶ server.py (UI admin, :9200/)
|
||||
├─ hellofresh/auth.py session storage_state + refresh HTTP /gw/refresh
|
||||
├─ hellofresh/api.py httpx : menu, détails, deliveries, PUT cart
|
||||
├─ hellofresh/filter.py exclusion (coco !) + scoring préférences
|
||||
├─ hellofresh/webui.py page d'admin + API JSON (édition à chaud)
|
||||
└─ config/ excludes.json · prefs.json · endpoints.json
|
||||
```
|
||||
|
||||
@@ -129,10 +131,24 @@ Enregistrer AntiCoco dans la config MCP de Hermes (côté homelab), URL
|
||||
| `hf_next_delivery()` | **prochaine box réellement sélectionnée** (≈4 recettes) + date/cutoff, images servables — prêt Telegram |
|
||||
| `hf_favorites()` | recettes **favorites** du compte (images servables) |
|
||||
| `hf_get_menu(week)` | toutes les recettes, avec `contains_excluded` et `is_favorite` |
|
||||
| `hf_propose(week, count=0)` | shortlist **sans coco**, classée par préférences |
|
||||
| `hf_confirm_selection(week, recipe_ids)` | **écrit** la sélection (refuse la coco) |
|
||||
| `hf_propose(week, count=0)` | shortlist **sans coco ni premium**, classée par préférences |
|
||||
| `hf_confirm_selection(week, recipe_ids, allow_premium=False)` | **écrit** la sélection (refuse coco **et** recettes payantes hors abonnement) |
|
||||
| `hf_get_excludes()` / `hf_add_exclude(term)` / `hf_remove_exclude(term)` | gérer la liste d'exclusion |
|
||||
|
||||
Les recettes premium (supplément, `chargeSetting` côté HelloFresh) portent `is_premium` /
|
||||
`surcharge_eur` dans chaque réponse. `hf_confirm_selection` les refuse par défaut ;
|
||||
`allow_premium=True` accepte sciemment le surcoût.
|
||||
|
||||
## Interface web d'admin
|
||||
|
||||
Sur le **même port que MCP** : `http://127.0.0.1:9200/` (MCP reste sur `/mcp`).
|
||||
Édition à chaud (prise en compte au prochain appel, sans redémarrage) de :
|
||||
- la **liste d'exclusion** (coco & co) ;
|
||||
- les **préférences** de scoring `liked` / `disliked`.
|
||||
|
||||
Le port est bindé sur `127.0.0.1` du homelab : y accéder via tunnel SSH
|
||||
(`ssh -L 9200:127.0.0.1:9200 homelab`) puis ouvrir `http://localhost:9200/`.
|
||||
|
||||
## Configuration
|
||||
|
||||
- `config/excludes.json` — ingrédients bannis (matching insensible casse/accents). Coco déjà listée.
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
275
hellofresh/webui.py
Normal 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>
|
||||
"""
|
||||
30
server.py
30
server.py
@@ -20,11 +20,15 @@ from mcp.server.fastmcp import FastMCP
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from hellofresh import api, auth, filter as hf_filter # noqa: E402
|
||||
from hellofresh import api, auth, filter as hf_filter, webui # noqa: E402
|
||||
|
||||
PORT = int(os.environ.get("ANTICOCO_PORT", "9200"))
|
||||
mcp = FastMCP("AntiCoco", host="0.0.0.0", port=PORT)
|
||||
|
||||
# Interface web d'admin (édition à chaud des excludes/préférences) sur le même port.
|
||||
# MCP reste servi sur /mcp ; l'UI est sur / (cf. hellofresh/webui.py).
|
||||
webui.register(mcp)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def hf_auth_status() -> dict:
|
||||
@@ -159,21 +163,26 @@ async def hf_propose(week: str = "", count: int = 0) -> dict:
|
||||
recipes = client.get_menu(w)
|
||||
safe = hf_filter.propose(recipes, count=count or None)
|
||||
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]
|
||||
return {
|
||||
"week": w,
|
||||
"proposed": [r.summary() for r in safe],
|
||||
"excluded_for_coco_etc": excluded,
|
||||
"note": "Aucune écriture effectuée. Confirme avec hf_confirm_selection(week, recipe_ids).",
|
||||
"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).",
|
||||
}
|
||||
|
||||
return await anyio.to_thread.run_sync(_impl)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def hf_confirm_selection(week: str, recipe_ids: list[str], dry_run: bool = False) -> dict:
|
||||
async def hf_confirm_selection(week: str, recipe_ids: list[str], dry_run: bool = False,
|
||||
allow_premium: bool = False) -> dict:
|
||||
"""ÉCRIT la sélection de recettes dans la box de la semaine (après confirmation).
|
||||
|
||||
Garde-fou : refuse toute recette contenant un ingrédient exclu (coco !).
|
||||
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`.
|
||||
`dry_run=True` : construit et renvoie la requête sans l'envoyer (vérification).
|
||||
"""
|
||||
def _impl() -> dict:
|
||||
@@ -188,6 +197,19 @@ async def hf_confirm_selection(week: str, recipe_ids: list[str], dry_run: bool =
|
||||
"error": "Sélection refusée : recette(s) avec ingrédient exclu (coco ?).",
|
||||
"offending_ids": bad,
|
||||
}
|
||||
premium = [rid for rid in recipe_ids
|
||||
if rid in by_id and by_id[rid].is_premium]
|
||||
if premium and not allow_premium:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "Sélection refusée : recette(s) payante(s) hors abonnement (supplément). "
|
||||
"Repasse allow_premium=True pour accepter le surcoût.",
|
||||
"premium_recipes": [
|
||||
{"id": rid, "name": by_id[rid].name,
|
||||
"surcharge_eur": round(by_id[rid].surcharge_cents / 100, 2)}
|
||||
for rid in premium
|
||||
],
|
||||
}
|
||||
unknown = [rid for rid in recipe_ids if rid not in by_id]
|
||||
if unknown:
|
||||
return {"ok": False, "error": "Recette(s) inconnue(s) pour cette semaine.",
|
||||
|
||||
Reference in New Issue
Block a user