# ============================================================================== # Author: Simon-Pierre Boucher # File: restoka/connectors/gloriafood.py # Desc: Connecteur templatisé GloriaFood/Oracle (Palier 1 — widget de # commande embarqué sur le site du resto -> PRIX RÉELS, contexte # `takeout`). Le widget s'identifie par data-glf-cuid/data-glf-ruid # dans le HTML du site ; le connecteur site-resto consigne ces ids # dans data/gloriafood-discovered.json AVEC l'uid du resto porteur. # Menu via l'API publique du widget : # GET pos.globalfoodsoft.com/pos/menu # (Authorization: , Glf-Api-Version: 2, Accept: JSON) # -> categories[].items[] {name, description, price, sizes[]}. # ENRICHISSEMENT PUR : n'émet aucune fiche — le menu est posé sur le # resto porteur (db.upsert_menu, price_context takeout) + traçabilité # details.gloriafood. CONNECTEUR DORMANT tant qu'aucune intégration # n'est découverte (SkipSource — pas un échec). L'endpoint refuse les # requêtes sans Authorization (403) : chaque intégration est tentée # défensivement, un refus n'interrompt pas le cycle. # # Base légale §15 : API du widget de commande du resto lui-même # (source de première partie), extraction minimale, lien conservé. # ============================================================================== from __future__ import annotations import datetime import json import sys import time from pathlib import Path from ..schema import Restaurant from .base import BaseConnector, SkipSource MENU_API = "https://pos.globalfoodsoft.com/pos/menu" SOURCES_PATH = Path(__file__).resolve().parents[2] / "data" / "sources.json" DISCOVERED = SOURCES_PATH.parent / "gloriafood-discovered.json" REFRESH_DAYS = 14 # menus takeout : rafraîchissement §14 def build_menu(payload: dict, captured_at: str) -> dict | None: """Menu standard Resto·Ka (§5.2) depuis le payload GloriaFood — parseur TOLÉRANT (structure observée : categories[].items[], variantes `sizes`).""" if isinstance(payload, list): # payload directement en liste cats = payload else: cats = (payload or {}).get("categories") sections_out: list[dict] = [] seen: set[str] = set() for cat in cats or []: if not isinstance(cat, dict): continue name = (cat.get("name") or "").strip() if not name or name in seen: continue items = [] for it in cat.get("items") or []: if not isinstance(it, dict): continue title = (it.get("name") or "").strip() if not title: continue price = it.get("price") entry = { "name": title, "description": (it.get("description") or "").strip(), "price": round(float(price), 2) if isinstance(price, (int, float)) and price > 0 else None, } sizes = [s for s in it.get("sizes") or [] if isinstance(s, dict) and (s.get("name") or "").strip()] if sizes: base = entry["price"] or 0.0 choices = [] for s in sizes: sp = s.get("price") delta = (round(float(sp) - base, 2) if isinstance(sp, (int, float)) else 0.0) choices.append({"name": s["name"].strip(), "price_delta": delta}) entry["options"] = [{"group": "Format", "required": True, "choices": choices}] items.append(entry) if items: seen.add(name) sections_out.append({"name": name, "items": items}) if not sections_out: return None return { "price_context": "takeout", "price_source": "gloriafood", "currency": "CAD", "captured_at": captured_at, "sections": sections_out, } class GloriaFoodConnector(BaseConnector): source_id = "gloriafood" request_delay = 1.0 timeout = 30 use_detail_cache = False # fraîcheur gérée via details.gloriafood enrichment_only = True # n'émet aucune fiche (ingest.run) def _integrations(self) -> list[dict]: registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8")) out: list[dict] = [] for src in registry.get("sources", []): if src.get("id") == self.source_id: out.extend(src.get("integrations") or []) if DISCOVERED.exists(): try: extra = json.loads(DISCOVERED.read_text(encoding="utf-8")) known = {i.get("key") for i in out} out.extend(i for i in extra.get("integrations", []) if i.get("key") not in known) except ValueError: pass return out def _now(self) -> str: return datetime.datetime.now(datetime.timezone.utc) \ .strftime("%Y-%m-%dT%H:%M:%SZ") def _fetch_menu(self, ruid: str) -> dict | None: resp = self.get(MENU_API, headers={ "Authorization": ruid, "Accept": "application/json", "Glf-Api-Version": "2", }) return build_menu(resp.json(), self._now()) def fetch(self) -> list[Restaurant]: integrations = [i for i in self._integrations() if i.get("key") and i.get("status") != "inactif"] if not integrations: raise SkipSource( "aucune intégration GloriaFood découverte " "(data/gloriafood-discovered.json vide) — connecteur dormant, " "en attente des découvertes du connecteur site-resto") from .. import db con = db.connect() now = time.time() menus = errors = 0 for integ in integrations: ruid, uid = str(integ["key"]), integ.get("uid") or "" if not uid: print(f"[resto-ka] gloriafood: {ruid} sans uid porteur — " "ignoré", file=sys.stderr) continue row = con.execute( "SELECT details FROM restaurants WHERE uid=? AND active=1", (uid,)).fetchone() if row is None: continue # resto porteur disparu try: details = json.loads(row["details"] or "{}") except ValueError: details = {} gf = details.get("gloriafood") or {} try: ts = datetime.datetime.strptime( gf.get("fetched_at", ""), "%Y-%m-%dT%H:%M:%SZ") \ .replace(tzinfo=datetime.timezone.utc).timestamp() if ts > now - REFRESH_DAYS * 86400: continue # déjà frais except (ValueError, TypeError): pass try: menu = self._fetch_menu(ruid) except Exception as exc: # 403/refus : défensif, on continue errors += 1 print(f"[resto-ka] gloriafood: {uid} ({ruid}) erreur: {exc}", file=sys.stderr) continue if not menu: continue # validation stricte du schéma menu avant stockage Restaurant(source=self.source_id, external_id=ruid, name=uid, menu=menu)._validate_menu() db.upsert_menu(con, uid, menu, time.time()) db.merge_details(con, uid, {"gloriafood": { "ruid": ruid, "cuid": integ.get("cuid"), "fetched_at": self._now()}}) con.commit() menus += 1 con.close() self.enriched_count = menus self.enrich_message = (f"{menus} menu(s) takeout GloriaFood posés, " f"{errors} refus/erreur(s) sur " f"{len(integrations)} intégration(s)") print(f"[resto-ka] gloriafood: {self.enrich_message}") return []