SPB Git forge

spb/crea-ka

Public

Créa·Ka — annuaire public cross-plateforme des créateurs de contenu québécois (crea-ka.com)

52commits 1branches 0releases
11.3 MBsize
maindefault branch
19 days agolast push
Python 73.6% HTML 13.2% TypeScript 6% JavaScript 4.5% CSS 1.7% Dockerfile 0.6%
12.8 KB · 317 lines python
Raw Blame History
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File:   creaka/web.py4# Desc:   API FastAPI (JSON) + service du frontend statique (frontend/dist) —5#         annuaire cherchable par niche, région, langue, plateforme, taille6# ==============================================================================7from __future__ import annotations89import json10import threading11from pathlib import Path1213from fastapi import Body, FastAPI, HTTPException, Query, Request14from fastapi.middleware.cors import CORSMiddleware15from fastapi.middleware.gzip import GZipMiddleware16from fastapi.responses import FileResponse, RedirectResponse, Response17from pydantic import BaseModel, Field1819from . import auth, db, ethics, hubfav, kaid, kapdf, seo20from . import stats as stats_mod21from .normalize import NICHES, PLATFORMS, REGIONS2223ROOT = Path(__file__).resolve().parent.parent24FRONTEND_DIST = ROOT / "frontend" / "dist"25SOURCES_PATH = ROOT / "data" / "sources.json"2627app = FastAPI(title="Créa-Ka API", version="1.0",28              description="Annuaire public des créateurs de contenu québécois "29                          "— tous leurs comptes, toutes leurs plateformes.")30app.add_middleware(CORSMiddleware, allow_origins=["*"],31                   allow_methods=["*"], allow_headers=["*"])32app.add_middleware(GZipMiddleware, minimum_size=1000)3334# Connexion SQLite PAR THREAD (§18) : FastAPI exécute les endpoints sync dans35# un pool de threads — une connexion partagée (check_same_thread=False) fait36# s'entrelacer les curseurs et produit des 500 intermittents (« 'NoneType'37# object is not subscriptable »). threading.local = une connexion par thread.38_local = threading.local()394041def _db():42    con = getattr(_local, "con", None)43    if con is None:44        con = _local.con = db.connect()45    return con4647# connexion « KA ID » (hub groupe-ka.com) — voir creaka/auth.py48app.include_router(auth.router)4950# personnalisation KA ID v2 (creaka/kaid.py, canonique ka-ui.git/kaid)51kaid.init("crea-ka")52app.include_router(kaid.build_router(auth.current_user))535455def _kaid_features(d: dict) -> dict:56    """Caractéristiques d'un créateur pour le profil de préférences KA ID."""57    return {k: v for k, v in {58        "niche": d.get("niches") or None,59        "region": d.get("region"), "city": d.get("city"),60        "langue": d.get("languages") or None,61        "tier": d.get("audience_tier"),62        "platform": d.get("platforms") or d.get("primary_platform") or None,63        "creator_type": d.get("creator_type"),64    }.items() if v not in (None, "", [])}656667@app.get("/healthz")68def healthz():69    return {"ok": True}707172@app.get("/api/health")73def health():74    s = db.stats(_db())75    return {"ok": True, "creators": s["creators"], "accounts": s["accounts"],76            "last_sync": s["last_sync"]}777879@app.get("/api/creators")80def list_creators(request: Request,81                  q: str = "", niche: str = "", region: str = "",82                  langue: str = "", plateforme: str = "", tier: str = "",83                  sort: str = "reach",84                  limit: int = Query(60, le=200), offset: int = Query(0, ge=0)):85    out = db.search(_db(), q=q.strip(), niche=niche, region=region,86                    langue=langue, plateforme=plateforme, tier=tier,87                    sort=sort, limit=limit, offset=offset)88    # personnalisation KA ID : journal + reclassement (tri par défaut « reach »89    # seulement ; intention de session > préférences long terme)90    out["personalized"] = False91    user = auth.current_user(request)92    if user:93        filters = {k: v for k, v in {94            "niche": niche, "region": region, "langue": langue,95            "platform": plateforme, "tier": tier, "q": q.strip()}.items() if v}96        if filters and offset == 0:97            kaid.track(user, "search", query=q.strip() or None, filters=filters)98        if sort == "reach":99            active = {k for k in ("niche", "region", "langue", "platform",100                                  "tier") if filters.get(k)}101            out["items"], out["personalized"] = kaid.rerank(102                out["items"], user, features_of=_kaid_features,103                uid_of=lambda it: it.get("id"), active_dims=active)104    return out105106107@app.get("/api/creators/{cid}")108def get_creator(cid: str, request: Request):109    creator = db.get_creator(_db(), cid)110    if creator is None:111        raise HTTPException(404, "créateur introuvable")112    # insights « légendaires » : croissance (snapshots), engagement pondéré,113    # rythme de publication, Ka Score /100 + historique pour la sparkline114    creator["insights"] = stats_mod.creator_insights(_db(), creator)115    kaid.track(auth.current_user(request), "detail_view", entity_type="creator",116               entity_id=cid, features=_kaid_features(creator))117    return creator118119120@app.get("/api/stats")121def get_stats():122    return db.stats(_db())123124125_PERIODS_OK = {"auj", "7j", "30j", "3m", "6m", "12m", "annee", "tout"}126127128@app.get("/api/stats/dashboard")129def stats_dashboard(period: str = "30j",130                    date_from: str = Query("", alias="from"),131                    date_to: str = Query("", alias="to")):132    """Tableau de bord analytique (contrat ka-stats SPEC.md) — cache 5 min."""133    if period not in _PERIODS_OK and not (date_from and date_to):134        raise HTTPException(400, "période inconnue "135                                 "(auj|7j|30j|3m|6m|12m|annee|tout ou from/to)")136    return stats_mod.dashboard(_db(), period=period,137                               date_from=date_from, date_to=date_to)138139140@app.get("/api/stats/report")141def stats_report(period: str = "30j", mode: str = "complet",142                 date_from: str = Query("", alias="from"),143                 date_to: str = Query("", alias="to")):144    """Rapport PDF estampillé Groupe-KA — 5 modes (contrat ka-stats SPEC §3) :145    complet | synthese | tendances | repartitions | donnees.146    Un mode inconnu retombe sur `complet` (rétrocompatible v1)."""147    if mode not in kapdf.REPORT_MODES:148        mode = "complet"149    if period not in _PERIODS_OK and not (date_from and date_to):150        raise HTTPException(400, "période inconnue")151    dash = stats_mod.dashboard(_db(), period=period,152                               date_from=date_from, date_to=date_to)153    pdf = kapdf.GroupeKAReport(site=stats_mod.site_info(),154                               dashboard=dash, mode=mode).build()155    fname = kapdf.filename("crea-ka", period, mode)156    return Response(content=pdf, media_type="application/pdf",157                    headers={"Content-Disposition":158                             f'attachment; filename="{fname}"'})159160161@app.get("/api/stats/catalog")162def stats_catalog(period: str = "30j",163                  date_from: str = Query("", alias="from"),164                  date_to: str = Query("", alias="to")):165    """v3 — blocs composables pour le constructeur de rapports personnalisés."""166    if period not in _PERIODS_OK and not (date_from and date_to):167        raise HTTPException(400, "période inconnue")168    dash = stats_mod.dashboard(_db(), period=period,169                               date_from=date_from, date_to=date_to)170    return {"updated": dash.get("updated"), "period": dash.get("period"),171            "blocks": kapdf.catalog(dash)}172173174@app.post("/api/stats/report/custom")175def stats_report_custom(spec: dict = Body(...)):176    """v3 — rapport PDF personnalisé : {"title", "period", "from", "to",177    "blocks": [{"key": "series:…", "render": "bar"}, …]} (SPEC.md §3bis)."""178    period = str(spec.get("period") or "30j")179    date_from = str(spec.get("from") or "")180    date_to = str(spec.get("to") or "")181    if period not in _PERIODS_OK and not (date_from and date_to):182        period = "30j"183    dash = stats_mod.dashboard(_db(), period=period,184                               date_from=date_from, date_to=date_to)185    known = {b["key"] for b in kapdf.catalog(dash)}186    blocks = [b for b in (spec.get("blocks") or [])187              if isinstance(b, dict) and b.get("key") in known][:40]188    if not blocks:189        raise HTTPException(400, "Aucun bloc valide dans la composition")190    pdf = kapdf.GroupeKAReport(191        site=stats_mod.site_info(), dashboard=dash, mode=kapdf.CUSTOM_MODE,192        spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks},193    ).build()194    fname = kapdf.filename("crea-ka", period, kapdf.CUSTOM_MODE)195    return Response(content=pdf, media_type="application/pdf",196                    headers={"Content-Disposition":197                             f'attachment; filename="{fname}"'})198199200@app.get("/api/taxonomies")201def taxonomies():202    return {"niches": sorted(NICHES), "plateformes": sorted(PLATFORMS),203            "regions": sorted(REGIONS),204            "tiers": ["nano", "micro", "macro", "mega"],205            "langues": ["fr", "en", "bilingue"]}206207208@app.get("/api/sources")209def sources():210    if SOURCES_PATH.exists():211        return json.loads(SOURCES_PATH.read_text(encoding="utf-8"))212    return {"sources": []}213214215class OptOutRequest(BaseModel):216    """Demande de retrait (§15 — opt-out obligatoire et respecté)."""217    name: str = Field("", max_length=200)218    account: str = Field("", max_length=200,219                         description="plateforme:handle, ex. instagram:monnom")220    reason: str = Field("", max_length=500)221    contact: str = Field("", max_length=200)222223224@app.post("/api/optout")225def optout(req: OptOutRequest):226    """Retrait immédiat : la fiche est masquée et exclue des ré-agrégations.227228    La vie privée prime sur la complétude (§15) : on masque d'abord, la229    demande est journalisée pour vérification humaine ensuite.230    """231    name = req.name.strip() or None232    acc = req.account.strip().lower() or None233    if not name and not acc:234        raise HTTPException(400, "préciser un nom ou un compte (plateforme:handle)")235    ethics.add_optout(name=name, account=acc, reason=req.reason,236                      contact=req.contact)237    masked = db.apply_optout(_db(), name=name, account=acc)238    return {"ok": True, "masked": masked,239            "message": "Fiche masquée. La demande sera vérifiée ; merci."}240241242# --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) -----243244@app.get("/api/favorites")245def favorites(request: Request):246    """Favoris du membre connecté, lus au hub Groupe KA (aucun stockage local)."""247    user = auth.current_user(request)248    if not user:249        raise HTTPException(401, "Connexion KA ID requise")250    items = hubfav.hub_list(user.get("ka_id") or "")251    if items is None:252        raise HTTPException(502, "Hub Groupe KA injoignable — réessayez")253    return {"ids": [i["item_id"] for i in items if i.get("item_id")],254            "items": items}255256257@app.post("/api/favorites/toggle")258def toggle_favorite(request: Request, body: dict = Body(...)):259    """Ajoute (on=true) ou retire (on=false) un favori — poussé au hub260    Groupe KA de façon synchrone : le hub est la seule source de vérité."""261    user = auth.current_user(request)262    if not user:263        raise HTTPException(401, "Connexion KA ID requise")264    ka_id = user.get("ka_id") or ""265    if not hubfav.linked(ka_id):266        raise HTTPException(403, "Compte non relié au hub Groupe KA")267    on = bool(body.get("on"))268    item = hubfav.clean_item(body.get("item") or {})269    if not item.get("item_id"):270        raise HTTPException(422, "item.item_id requis")271    if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item):272        raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré")273    # signal fort du moteur de préférences KA ID274    creator = db.get_creator(_db(), item["item_id"])275    kaid.track(user, "favorite" if on else "unfavorite", entity_type="creator",276               entity_id=item["item_id"],277               features=_kaid_features(creator) if creator else None)278    return {"ok": True, "on": on}279280281# SEO : fiches créateur SSR, robots.txt, sitemaps — déclaré AVANT282# les routes statiques pour prendre la main sur le fallback SPA283app.include_router(seo.router)284285286# --- frontend statique -----------------------------------------------------------287288# Documentation utilisateur (frontend/dist/doc) — routes explicites AVANT289# le fallback SPA pour servir /doc/ comme un index.290@app.get("/doc")291def doc_redirect():292    return RedirectResponse("/doc/", status_code=308)293294295@app.get("/doc/")296def doc_index():297    return FileResponse(FRONTEND_DIST / "doc" / "index.html")298299300@app.get("/")301def index():302    idx = FRONTEND_DIST / "index.html"303    if idx.exists():304        return FileResponse(idx)305    raise HTTPException(404, "frontend non construit")306307308@app.get("/{path:path}")309def static_files(path: str):310    target = (FRONTEND_DIST / path).resolve()311    if target.is_file() and str(target).startswith(str(FRONTEND_DIST.resolve())):312        return FileResponse(target)313    idx = FRONTEND_DIST / "index.html"314    if idx.exists():315        return FileResponse(idx)  # SPA fallback316    raise HTTPException(404, "introuvable")317