- 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)
193 lines
7.0 KiB
Python
193 lines
7.0 KiB
Python
"""Modèles de données HelloFresh, indépendants de la forme exacte de l'API interne.
|
|
|
|
Les dataclasses sont volontairement tolérantes : `Recipe.from_api` mappe au mieux les
|
|
champs des réponses gateway (qui varient selon les endpoints découverts) vers une forme
|
|
stable utilisée par le filtre et le serveur MCP.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass, field, asdict
|
|
from typing import Any
|
|
|
|
from .images import fix_image_url
|
|
|
|
_ISO_DURATION = re.compile(r"PT(?:(\d+)H)?(?:(\d+)M)?", re.IGNORECASE)
|
|
|
|
|
|
def _first(d: dict, *keys, default=None):
|
|
"""Renvoie la première clé présente et non vide parmi `keys`."""
|
|
for k in keys:
|
|
v = d.get(k)
|
|
if v not in (None, "", [], {}):
|
|
return v
|
|
return default
|
|
|
|
|
|
def _iso_duration_to_minutes(value: str) -> int | None:
|
|
"""Convertit une durée ISO 8601 ('PT1H30M', 'PT30M') en minutes entières.
|
|
|
|
Renvoie None si le format n'est pas reconnu (champ vide ou inattendu),
|
|
pour laisser le client distinguer « inconnu » de « 0 min ».
|
|
"""
|
|
if not value:
|
|
return None
|
|
m = _ISO_DURATION.fullmatch(value.strip())
|
|
if not m or not (m.group(1) or m.group(2)):
|
|
return None
|
|
hours = int(m.group(1) or 0)
|
|
minutes = int(m.group(2) or 0)
|
|
return hours * 60 + minutes
|
|
|
|
|
|
@dataclass
|
|
class Recipe:
|
|
id: str
|
|
name: str
|
|
headline: str = ""
|
|
ingredients: list[str] = field(default_factory=list)
|
|
allergens: list[str] = field(default_factory=list)
|
|
tags: list[str] = field(default_factory=list)
|
|
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.
|
|
|
|
Les noms de champs HelloFresh varient ; on tente plusieurs alias. À ajuster
|
|
une fois la forme réelle confirmée via discover_api.py.
|
|
"""
|
|
ingredients = []
|
|
for ing in _first(raw, "ingredients", default=[]) or []:
|
|
if isinstance(ing, dict):
|
|
name = _first(ing, "name", "label", "title")
|
|
if name:
|
|
ingredients.append(str(name))
|
|
elif isinstance(ing, str):
|
|
ingredients.append(ing)
|
|
|
|
allergens = []
|
|
for al in _first(raw, "allergens", default=[]) or []:
|
|
if isinstance(al, dict):
|
|
name = _first(al, "name", "label", "title")
|
|
if name:
|
|
allergens.append(str(name))
|
|
elif isinstance(al, str):
|
|
allergens.append(al)
|
|
|
|
tags = []
|
|
for tg in _first(raw, "tags", "labels", default=[]) or []:
|
|
if isinstance(tg, dict):
|
|
name = _first(tg, "name", "label", "text")
|
|
if name:
|
|
tags.append(str(name))
|
|
elif isinstance(tg, str):
|
|
tags.append(tg)
|
|
|
|
image = _first(raw, "imageLink", "image", "imageUrl", "cardLink", default="")
|
|
if isinstance(image, dict):
|
|
image = _first(image, "url", "link", default="")
|
|
|
|
return cls(
|
|
id=str(_first(raw, "id", "uuid", "recipeId", default="")),
|
|
name=str(_first(raw, "name", "title", default="(sans nom)")),
|
|
headline=str(_first(raw, "headline", "subtitle", "description", default="")),
|
|
ingredients=ingredients,
|
|
allergens=allergens,
|
|
tags=tags,
|
|
image_url=str(image or ""),
|
|
prep_time=str(_first(raw, "prepTime", "totalTime", "time", default="")),
|
|
)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
def summary(self) -> dict[str, Any]:
|
|
"""Version compacte pour les réponses MCP (moins de tokens).
|
|
|
|
Inclut de quoi composer une carte (image, temps, allergènes) côté client
|
|
Telegram sans appel supplémentaire — cf. besoin de mise en forme Hermes.
|
|
"""
|
|
return {
|
|
"id": self.id,
|
|
"name": self.name,
|
|
"headline": self.headline,
|
|
"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,
|
|
"score": self.score,
|
|
"tags": self.tags,
|
|
"is_favorite": self.is_favorite,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class Delivery:
|
|
"""Semaine de livraison de l'abonnement (issue de /deliveries)."""
|
|
week: str
|
|
delivery_date: str = ""
|
|
cutoff_date: str = ""
|
|
status: str = ""
|
|
editable: bool = False
|
|
|
|
@classmethod
|
|
def from_api(cls, raw: dict[str, Any]) -> "Delivery":
|
|
actionable = bool(_first(raw, "actionable", "isEditable", default=False))
|
|
status = str(_first(raw, "status", default=""))
|
|
return cls(
|
|
week=str(_first(raw, "id", "week", "handle", default="")),
|
|
delivery_date=str(_first(raw, "deliveryDate", "date", default="")),
|
|
cutoff_date=str(_first(raw, "cutoffDate", "cutoff", default="")),
|
|
status=status,
|
|
editable=actionable and status.upper() not in ("SKIPPED", "DELIVERED", "CUTOFF"),
|
|
)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
|
|
@dataclass
|
|
class Week:
|
|
id: str # handle de semaine, ex. "2026-W25"
|
|
delivery_date: str = ""
|
|
editable: bool = False
|
|
max_selectable: int = 0
|
|
recipes: list[Recipe] = field(default_factory=list)
|
|
|
|
@classmethod
|
|
def from_api(cls, raw: dict[str, Any], recipes: list[Recipe] | None = None) -> "Week":
|
|
return cls(
|
|
id=str(_first(raw, "id", "week", "handle", "yearWeek", default="")),
|
|
delivery_date=str(_first(raw, "deliveryDate", "date", default="")),
|
|
editable=bool(_first(raw, "editable", "isEditable", "menuEditable", default=False)),
|
|
max_selectable=int(_first(raw, "maxSelectable", "numberOfSelections", default=0) or 0),
|
|
recipes=recipes or [],
|
|
)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
d = asdict(self)
|
|
d["recipes"] = [r.summary() for r in self.recipes]
|
|
return d
|