# ============================================================================== # Author: Simon-Pierre Boucher # File: creaka/web.py # Desc: API FastAPI (JSON) + service du frontend statique (frontend/dist) — # annuaire cherchable par niche, région, langue, plateforme, taille # ============================================================================== from __future__ import annotations import json import threading from pathlib import Path from fastapi import Body, FastAPI, HTTPException, Query, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import FileResponse, RedirectResponse, Response from pydantic import BaseModel, Field from . import auth, db, ethics, hubfav, kaid, kapdf, seo from . import stats as stats_mod from .normalize import NICHES, PLATFORMS, REGIONS ROOT = Path(__file__).resolve().parent.parent FRONTEND_DIST = ROOT / "frontend" / "dist" SOURCES_PATH = ROOT / "data" / "sources.json" app = FastAPI(title="Créa-Ka API", version="1.0", description="Annuaire public des créateurs de contenu québécois " "— tous leurs comptes, toutes leurs plateformes.") app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) app.add_middleware(GZipMiddleware, minimum_size=1000) # Connexion SQLite PAR THREAD (§18) : FastAPI exécute les endpoints sync dans # un pool de threads — une connexion partagée (check_same_thread=False) fait # s'entrelacer les curseurs et produit des 500 intermittents (« 'NoneType' # object is not subscriptable »). threading.local = une connexion par thread. _local = threading.local() def _db(): con = getattr(_local, "con", None) if con is None: con = _local.con = db.connect() return con # connexion « KA ID » (hub groupe-ka.com) — voir creaka/auth.py app.include_router(auth.router) # personnalisation KA ID v2 (creaka/kaid.py, canonique ka-ui.git/kaid) kaid.init("crea-ka") app.include_router(kaid.build_router(auth.current_user)) def _kaid_features(d: dict) -> dict: """Caractéristiques d'un créateur pour le profil de préférences KA ID.""" return {k: v for k, v in { "niche": d.get("niches") or None, "region": d.get("region"), "city": d.get("city"), "langue": d.get("languages") or None, "tier": d.get("audience_tier"), "platform": d.get("platforms") or d.get("primary_platform") or None, "creator_type": d.get("creator_type"), }.items() if v not in (None, "", [])} @app.get("/healthz") def healthz(): return {"ok": True} @app.get("/api/health") def health(): s = db.stats(_db()) return {"ok": True, "creators": s["creators"], "accounts": s["accounts"], "last_sync": s["last_sync"]} @app.get("/api/creators") def list_creators(request: Request, q: str = "", niche: str = "", region: str = "", langue: str = "", plateforme: str = "", tier: str = "", sort: str = "reach", limit: int = Query(60, le=200), offset: int = Query(0, ge=0)): out = db.search(_db(), q=q.strip(), niche=niche, region=region, langue=langue, plateforme=plateforme, tier=tier, sort=sort, limit=limit, offset=offset) # personnalisation KA ID : journal + reclassement (tri par défaut « reach » # seulement ; intention de session > préférences long terme) out["personalized"] = False user = auth.current_user(request) if user: filters = {k: v for k, v in { "niche": niche, "region": region, "langue": langue, "platform": plateforme, "tier": tier, "q": q.strip()}.items() if v} if filters and offset == 0: kaid.track(user, "search", query=q.strip() or None, filters=filters) if sort == "reach": active = {k for k in ("niche", "region", "langue", "platform", "tier") if filters.get(k)} out["items"], out["personalized"] = kaid.rerank( out["items"], user, features_of=_kaid_features, uid_of=lambda it: it.get("id"), active_dims=active) return out @app.get("/api/creators/{cid}") def get_creator(cid: str, request: Request): creator = db.get_creator(_db(), cid) if creator is None: raise HTTPException(404, "créateur introuvable") # insights « légendaires » : croissance (snapshots), engagement pondéré, # rythme de publication, Ka Score /100 + historique pour la sparkline creator["insights"] = stats_mod.creator_insights(_db(), creator) kaid.track(auth.current_user(request), "detail_view", entity_type="creator", entity_id=cid, features=_kaid_features(creator)) return creator @app.get("/api/stats") def get_stats(): return db.stats(_db()) _PERIODS_OK = {"auj", "7j", "30j", "3m", "6m", "12m", "annee", "tout"} @app.get("/api/stats/dashboard") def stats_dashboard(period: str = "30j", date_from: str = Query("", alias="from"), date_to: str = Query("", alias="to")): """Tableau de bord analytique (contrat ka-stats SPEC.md) — cache 5 min.""" if period not in _PERIODS_OK and not (date_from and date_to): raise HTTPException(400, "période inconnue " "(auj|7j|30j|3m|6m|12m|annee|tout ou from/to)") return stats_mod.dashboard(_db(), period=period, date_from=date_from, date_to=date_to) @app.get("/api/stats/report") def stats_report(period: str = "30j", mode: str = "complet", date_from: str = Query("", alias="from"), date_to: str = Query("", alias="to")): """Rapport PDF estampillé Groupe-KA — 5 modes (contrat ka-stats SPEC §3) : complet | synthese | tendances | repartitions | donnees. Un mode inconnu retombe sur `complet` (rétrocompatible v1).""" if mode not in kapdf.REPORT_MODES: mode = "complet" if period not in _PERIODS_OK and not (date_from and date_to): raise HTTPException(400, "période inconnue") dash = stats_mod.dashboard(_db(), period=period, date_from=date_from, date_to=date_to) pdf = kapdf.GroupeKAReport(site=stats_mod.site_info(), dashboard=dash, mode=mode).build() fname = kapdf.filename("crea-ka", period, mode) return Response(content=pdf, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{fname}"'}) @app.get("/api/stats/catalog") def stats_catalog(period: str = "30j", date_from: str = Query("", alias="from"), date_to: str = Query("", alias="to")): """v3 — blocs composables pour le constructeur de rapports personnalisés.""" if period not in _PERIODS_OK and not (date_from and date_to): raise HTTPException(400, "période inconnue") dash = stats_mod.dashboard(_db(), period=period, date_from=date_from, date_to=date_to) return {"updated": dash.get("updated"), "period": dash.get("period"), "blocks": kapdf.catalog(dash)} @app.post("/api/stats/report/custom") def stats_report_custom(spec: dict = Body(...)): """v3 — rapport PDF personnalisé : {"title", "period", "from", "to", "blocks": [{"key": "series:…", "render": "bar"}, …]} (SPEC.md §3bis).""" period = str(spec.get("period") or "30j") date_from = str(spec.get("from") or "") date_to = str(spec.get("to") or "") if period not in _PERIODS_OK and not (date_from and date_to): period = "30j" dash = stats_mod.dashboard(_db(), period=period, date_from=date_from, date_to=date_to) known = {b["key"] for b in kapdf.catalog(dash)} blocks = [b for b in (spec.get("blocks") or []) if isinstance(b, dict) and b.get("key") in known][:40] if not blocks: raise HTTPException(400, "Aucun bloc valide dans la composition") pdf = kapdf.GroupeKAReport( site=stats_mod.site_info(), dashboard=dash, mode=kapdf.CUSTOM_MODE, spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks}, ).build() fname = kapdf.filename("crea-ka", period, kapdf.CUSTOM_MODE) return Response(content=pdf, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{fname}"'}) @app.get("/api/taxonomies") def taxonomies(): return {"niches": sorted(NICHES), "plateformes": sorted(PLATFORMS), "regions": sorted(REGIONS), "tiers": ["nano", "micro", "macro", "mega"], "langues": ["fr", "en", "bilingue"]} @app.get("/api/sources") def sources(): if SOURCES_PATH.exists(): return json.loads(SOURCES_PATH.read_text(encoding="utf-8")) return {"sources": []} class OptOutRequest(BaseModel): """Demande de retrait (§15 — opt-out obligatoire et respecté).""" name: str = Field("", max_length=200) account: str = Field("", max_length=200, description="plateforme:handle, ex. instagram:monnom") reason: str = Field("", max_length=500) contact: str = Field("", max_length=200) @app.post("/api/optout") def optout(req: OptOutRequest): """Retrait immédiat : la fiche est masquée et exclue des ré-agrégations. La vie privée prime sur la complétude (§15) : on masque d'abord, la demande est journalisée pour vérification humaine ensuite. """ name = req.name.strip() or None acc = req.account.strip().lower() or None if not name and not acc: raise HTTPException(400, "préciser un nom ou un compte (plateforme:handle)") ethics.add_optout(name=name, account=acc, reason=req.reason, contact=req.contact) masked = db.apply_optout(_db(), name=name, account=acc) return {"ok": True, "masked": masked, "message": "Fiche masquée. La demande sera vérifiée ; merci."} # --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) ----- @app.get("/api/favorites") def favorites(request: Request): """Favoris du membre connecté, lus au hub Groupe KA (aucun stockage local).""" user = auth.current_user(request) if not user: raise HTTPException(401, "Connexion KA ID requise") items = hubfav.hub_list(user.get("ka_id") or "") if items is None: raise HTTPException(502, "Hub Groupe KA injoignable — réessayez") return {"ids": [i["item_id"] for i in items if i.get("item_id")], "items": items} @app.post("/api/favorites/toggle") def toggle_favorite(request: Request, body: dict = Body(...)): """Ajoute (on=true) ou retire (on=false) un favori — poussé au hub Groupe KA de façon synchrone : le hub est la seule source de vérité.""" user = auth.current_user(request) if not user: raise HTTPException(401, "Connexion KA ID requise") ka_id = user.get("ka_id") or "" if not hubfav.linked(ka_id): raise HTTPException(403, "Compte non relié au hub Groupe KA") on = bool(body.get("on")) item = hubfav.clean_item(body.get("item") or {}) if not item.get("item_id"): raise HTTPException(422, "item.item_id requis") if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item): raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré") # signal fort du moteur de préférences KA ID creator = db.get_creator(_db(), item["item_id"]) kaid.track(user, "favorite" if on else "unfavorite", entity_type="creator", entity_id=item["item_id"], features=_kaid_features(creator) if creator else None) return {"ok": True, "on": on} # SEO : fiches créateur SSR, robots.txt, sitemaps — déclaré AVANT # les routes statiques pour prendre la main sur le fallback SPA app.include_router(seo.router) # --- frontend statique ----------------------------------------------------------- # Documentation utilisateur (frontend/dist/doc) — routes explicites AVANT # le fallback SPA pour servir /doc/ comme un index. @app.get("/doc") def doc_redirect(): return RedirectResponse("/doc/", status_code=308) @app.get("/doc/") def doc_index(): return FileResponse(FRONTEND_DIST / "doc" / "index.html") @app.get("/") def index(): idx = FRONTEND_DIST / "index.html" if idx.exists(): return FileResponse(idx) raise HTTPException(404, "frontend non construit") @app.get("/{path:path}") def static_files(path: str): target = (FRONTEND_DIST / path).resolve() if target.is_file() and str(target).startswith(str(FRONTEND_DIST.resolve())): return FileResponse(target) idx = FRONTEND_DIST / "index.html" if idx.exists(): return FileResponse(idx) # SPA fallback raise HTTPException(404, "introuvable")