"""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) # 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 @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), "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