Agrégateur de produits québécois — www.fabri-ka.com
Python 38.4%
HTML 30.3%
TypeScript 17.2%
CSS 11%
JavaScript 3.2%
1# -----------------------------------------------------------------------------2# Fabri-Ka — Agrégateur de produits québécois3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# hubfav.py : favoris « Mon univers Ka » — magasin central du Groupe KA.5# AUCUN stockage local : chaque ♥ est poussé/lu au hub (groupe-ka.com),6# requêtes signées HMAC-SHA256 du secret SSO partagé7# (sig = HMAC(KA_SSO_SECRET, "fabri-ka.<ka_id>.<ts>") en hex, ts ±5 min).8# · hub_toggle : POST synchrone add/remove (timeout 6 s) — l'appelant sait9# si le hub a bien enregistré le ♥ (source de vérité unique).10# · hub_list : GET signé, cache mémoire 30 s par membre, invalidé au toggle.11# Routes : GET /api/favorites -> {ids, items} ; POST /api/favorites/toggle12# {on, item}. 401 sans session KA ID. Config .env : KA_SSO_SECRET, KA_HUB_URL.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import hashlib17import hmac18import os19import threading20import time2122import requests23from fastapi import APIRouter, Body, HTTPException, Request2425from . import auth2627CLIENT_ID = "fabri-ka"28KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")29CACHE_TTL = 30 # secondes — le hub reste la source de vérité30TIMEOUT = 6 # secondes (POST synchrone : on attend la réponse du hub)3132# champs d'item acceptés (longueur max), alignés sur l'API du hub33_ITEM_FIELDS = {"item_id": 120, "title": 200, "subtitle": 200,34 "price_label": 60, "image_url": 500, "url": 500}3536_cache: dict[str, tuple[float, list[dict]]] = {}37_lock = threading.Lock()383940def _sig(ka_id: str, ts: int) -> str | None:41 secret = os.environ.get("KA_SSO_SECRET")42 if not secret:43 return None44 return hmac.new(secret.encode(),45 f"{CLIENT_ID}.{ka_id}.{ts}".encode(),46 hashlib.sha256).hexdigest()474849def hub_toggle(ka_id: str, action: str, item: dict) -> bool:50 """Pousse un ♥ (« add » ou « remove ») au magasin central du Groupe KA.51 Synchrone : True seulement si le hub a répondu 200 (le ♥ est enregistré)."""52 ts = int(time.time())53 sig = _sig(ka_id, ts)54 if not ka_id or not sig:55 return False56 try:57 r = requests.post(f"{KA_HUB_URL}/api/sso/favorites", timeout=TIMEOUT,58 json={"client_id": CLIENT_ID, "ka_id": ka_id,59 "ts": str(ts), "sig": sig,60 "action": action, "item": item})61 ok = r.status_code == 20062 except Exception:63 ok = False64 with _lock: # l'état a (peut-être) changé au hub : on relira65 _cache.pop(ka_id, None)66 return ok676869def hub_list(ka_id: str) -> list[dict]:70 """Favoris Fabri-Ka du membre, lus au hub (cache mémoire 30 s)."""71 if not ka_id:72 return []73 now = time.time()74 with _lock:75 hit = _cache.get(ka_id)76 if hit and now - hit[0] < CACHE_TTL:77 return hit[1]78 ts = int(now)79 sig = _sig(ka_id, ts)80 if not sig:81 return []82 try:83 r = requests.get(f"{KA_HUB_URL}/api/sso/favorites", timeout=TIMEOUT,84 params={"client_id": CLIENT_ID, "ka_id": ka_id,85 "ts": ts, "sig": sig})86 if r.status_code != 200:87 return [] # erreur transitoire : pas de cache88 favs = r.json().get("favorites") or []89 if not isinstance(favs, list):90 return []91 favs = [f for f in favs if isinstance(f, dict)]92 except Exception:93 return []94 with _lock:95 _cache[ka_id] = (now, favs)96 return favs979899def _clean_item(raw: dict) -> dict:100 """Ne garde que les champs connus, en chaînes tronquées (défense en entrée)."""101 out = {}102 for key, maxlen in _ITEM_FIELDS.items():103 v = raw.get(key)104 if v is not None and str(v).strip():105 out[key] = str(v).strip()[:maxlen]106 return out107108109# -- routes --------------------------------------------------------------------110router = APIRouter(prefix="/api/favorites")111112113def _require_user(request: Request) -> dict:114 user = auth.current_user(request)115 if not user or not user.get("ka_id"):116 raise HTTPException(401, "Connexion KA ID requise")117 return user118119120@router.get("")121def list_favorites(request: Request):122 """Favoris du membre connecté, lus au magasin central du Groupe KA."""123 user = _require_user(request)124 items = hub_list(str(user["ka_id"]))125 return {"ids": [it.get("item_id") for it in items if it.get("item_id")],126 "items": items}127128129@router.post("/toggle")130def toggle_favorite(request: Request, payload: dict = Body(...)):131 """♥ on/off : {on: bool, item: {item_id, title, …}} — poussé au hub,132 qui est l'unique magasin des favoris (rien n'est stocké ici)."""133 user = _require_user(request)134 on = bool(payload.get("on"))135 item = _clean_item(payload.get("item") or {})136 if not item.get("item_id"):137 raise HTTPException(422, "item.item_id requis")138 if not on: # remove : l'identifiant suffit au hub139 item = {"item_id": item["item_id"]}140 ok = hub_toggle(str(user["ka_id"]), "add" if on else "remove", item)141 # signal fort du moteur de préférences KA ID (features lues de la BD)142 from . import db as _db, kaid as _kaid143 con = _db.connect()144 try:145 row = con.execute(146 """SELECT p.*, s.name AS store_name, s.region AS store_region,147 s.origin_class FROM products p148 JOIN stores s ON s.id=p.store_id WHERE p.uid=?""",149 (item["item_id"],)).fetchone()150 finally:151 con.close()152 from .web import _kaid_features as _feats153 _kaid.track(user, "favorite" if on else "unfavorite",154 entity_type="product", entity_id=item["item_id"],155 features=_feats(dict(row)) if row else None)156 return {"ok": ok, "on": on, "item_id": item["item_id"]}157