# ----------------------------------------------------------------------------- # Fabri-Ka — Agrégateur de produits québécois # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # hubfav.py : favoris « Mon univers Ka » — magasin central du Groupe KA. # AUCUN stockage local : chaque ♥ est poussé/lu au hub (groupe-ka.com), # requêtes signées HMAC-SHA256 du secret SSO partagé # (sig = HMAC(KA_SSO_SECRET, "fabri-ka..") en hex, ts ±5 min). # · hub_toggle : POST synchrone add/remove (timeout 6 s) — l'appelant sait # si le hub a bien enregistré le ♥ (source de vérité unique). # · hub_list : GET signé, cache mémoire 30 s par membre, invalidé au toggle. # Routes : GET /api/favorites -> {ids, items} ; POST /api/favorites/toggle # {on, item}. 401 sans session KA ID. Config .env : KA_SSO_SECRET, KA_HUB_URL. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import hmac import os import threading import time import requests from fastapi import APIRouter, Body, HTTPException, Request from . import auth CLIENT_ID = "fabri-ka" KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") CACHE_TTL = 30 # secondes — le hub reste la source de vérité TIMEOUT = 6 # secondes (POST synchrone : on attend la réponse du hub) # champs d'item acceptés (longueur max), alignés sur l'API du hub _ITEM_FIELDS = {"item_id": 120, "title": 200, "subtitle": 200, "price_label": 60, "image_url": 500, "url": 500} _cache: dict[str, tuple[float, list[dict]]] = {} _lock = threading.Lock() def _sig(ka_id: str, ts: int) -> str | None: secret = os.environ.get("KA_SSO_SECRET") if not secret: return None return hmac.new(secret.encode(), f"{CLIENT_ID}.{ka_id}.{ts}".encode(), hashlib.sha256).hexdigest() def hub_toggle(ka_id: str, action: str, item: dict) -> bool: """Pousse un ♥ (« add » ou « remove ») au magasin central du Groupe KA. Synchrone : True seulement si le hub a répondu 200 (le ♥ est enregistré).""" ts = int(time.time()) sig = _sig(ka_id, ts) if not ka_id or not sig: return False try: r = requests.post(f"{KA_HUB_URL}/api/sso/favorites", timeout=TIMEOUT, json={"client_id": CLIENT_ID, "ka_id": ka_id, "ts": str(ts), "sig": sig, "action": action, "item": item}) ok = r.status_code == 200 except Exception: ok = False with _lock: # l'état a (peut-être) changé au hub : on relira _cache.pop(ka_id, None) return ok def hub_list(ka_id: str) -> list[dict]: """Favoris Fabri-Ka du membre, lus au hub (cache mémoire 30 s).""" if not ka_id: return [] now = time.time() with _lock: hit = _cache.get(ka_id) if hit and now - hit[0] < CACHE_TTL: return hit[1] ts = int(now) sig = _sig(ka_id, ts) if not sig: return [] try: r = requests.get(f"{KA_HUB_URL}/api/sso/favorites", timeout=TIMEOUT, params={"client_id": CLIENT_ID, "ka_id": ka_id, "ts": ts, "sig": sig}) if r.status_code != 200: return [] # erreur transitoire : pas de cache favs = r.json().get("favorites") or [] if not isinstance(favs, list): return [] favs = [f for f in favs if isinstance(f, dict)] except Exception: return [] with _lock: _cache[ka_id] = (now, favs) return favs def _clean_item(raw: dict) -> dict: """Ne garde que les champs connus, en chaînes tronquées (défense en entrée).""" out = {} for key, maxlen in _ITEM_FIELDS.items(): v = raw.get(key) if v is not None and str(v).strip(): out[key] = str(v).strip()[:maxlen] return out # -- routes -------------------------------------------------------------------- router = APIRouter(prefix="/api/favorites") def _require_user(request: Request) -> dict: user = auth.current_user(request) if not user or not user.get("ka_id"): raise HTTPException(401, "Connexion KA ID requise") return user @router.get("") def list_favorites(request: Request): """Favoris du membre connecté, lus au magasin central du Groupe KA.""" user = _require_user(request) items = hub_list(str(user["ka_id"])) return {"ids": [it.get("item_id") for it in items if it.get("item_id")], "items": items} @router.post("/toggle") def toggle_favorite(request: Request, payload: dict = Body(...)): """♥ on/off : {on: bool, item: {item_id, title, …}} — poussé au hub, qui est l'unique magasin des favoris (rien n'est stocké ici).""" user = _require_user(request) on = bool(payload.get("on")) item = _clean_item(payload.get("item") or {}) if not item.get("item_id"): raise HTTPException(422, "item.item_id requis") if not on: # remove : l'identifiant suffit au hub item = {"item_id": item["item_id"]} ok = hub_toggle(str(user["ka_id"]), "add" if on else "remove", item) # signal fort du moteur de préférences KA ID (features lues de la BD) from . import db as _db, kaid as _kaid con = _db.connect() try: row = con.execute( """SELECT p.*, s.name AS store_name, s.region AS store_region, s.origin_class FROM products p JOIN stores s ON s.id=p.store_id WHERE p.uid=?""", (item["item_id"],)).fetchone() finally: con.close() from .web import _kaid_features as _feats _kaid.track(user, "favorite" if on else "unfavorite", entity_type="product", entity_id=item["item_id"], features=_feats(dict(row)) if row else None) return {"ok": ok, "on": on, "item_id": item["item_id"]}