SPB Git forge

spb/fabri-ka

Public

Agrégateur de produits québécois — www.fabri-ka.com

217commits 1branches 0releases
66.1 MBsize
maindefault branch
3 h agolast push
Python 38.4% HTML 30.3% TypeScript 17.2% CSS 11% JavaScript 3.2%
5.9 KB · 157 lines python
Raw Blame History
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