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%
10.1 KB · 249 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Fabri-Ka — Agrégateur de produits québécois3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# inscriptions.py : canal d'auto-inscription des artisans/fabricants5#                   (« Inscrire mon atelier »).6#7#   POST /api/inscriptions            dépôt public (honeypot + limite par IP)8#                                     -> statut `pending` (quarantaine de9#                                     validation, RIEN n'est publié)10#   GET  /api/inscriptions            liste (admin, X-Admin-Token)11#   POST /api/inscriptions/{id}/decision   approve|reject (admin)12#        approve : vérifie le site (pipeline verify), ajoute la fiche au13#        registre + DB (source `auto_inscription`, additive, sérialisée) —14#        connectée si un catalogue public répond, sinon fiche annuaire.15#16#   Notification simple : chaque dépôt est journalisé dans17#   logs/inscriptions.log (+ visible via le GET admin).18#   Anti-abus : honeypot (champ `entreprise_url` caché) + 5 dépôts/24 h/IP.19# -----------------------------------------------------------------------------20from __future__ import annotations2122import json23import os24import re25import time26from datetime import date27from pathlib import Path2829from fastapi import APIRouter, HTTPException, Request30from pydantic import BaseModel, Field3132from . import db33from .schema import CATEGORIES3435router = APIRouter(prefix="/api/inscriptions")3637ROOT = Path(__file__).resolve().parent.parent38LOG = ROOT / "logs" / "inscriptions.log"39ADMIN_TOKEN = os.environ.get("FABRIKA_ADMIN_TOKEN", "")4041MAX_PER_IP_24H = 542EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[a-zA-Z]{2,}$")434445class InscriptionIn(BaseModel):46    name: str = Field(min_length=2, max_length=120)47    metier: str = Field(min_length=2, max_length=60)      # clé de CATEGORIES48    description: str = Field(min_length=20, max_length=4000)49    address: str = Field("", max_length=300)50    website: str = Field("", max_length=200)51    email: str = Field("", max_length=200)52    phone: str = Field("", max_length=40)53    socials: list[str] = Field(default_factory=list)54    photos: list[str] = Field(default_factory=list)       # URLs d'images55    # honeypot : champ invisible au frontend — un humain le laisse vide56    entreprise_url: str = ""575859def _log(line: str) -> None:60    LOG.parent.mkdir(parents=True, exist_ok=True)61    with open(LOG, "a") as f:62        f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {line}\n")636465def _client_ip(request: Request) -> str:66    fwd = request.headers.get("x-forwarded-for", "")67    return (fwd.split(",")[0].strip() if fwd else None) or \68        (request.client.host if request.client else "?")697071@router.post("")72def submit(body: InscriptionIn, request: Request):73    # honeypot : on répond comme si tout allait bien, sans rien stocker74    if body.entreprise_url.strip():75        return {"status": "pending", "id": 0}76    if body.metier not in CATEGORIES:77        raise HTTPException(400, "Métier/spécialité inconnu de la taxonomie")78    if not body.email and not body.phone:79        raise HTTPException(400, "Un courriel ou un téléphone est requis "80                                 "(coordonnées vérifiables)")81    if body.email and not EMAIL_RE.match(body.email.strip()):82        raise HTTPException(400, "Courriel invalide")83    urls = [u for u in ([body.website] + body.socials + body.photos) if u]84    for u in urls:85        if not re.match(r"^https?://", u.strip()):86            raise HTTPException(400, f"URL invalide (http(s) requis) : {u[:60]}")87    ip = _client_ip(request)88    con = db.connect()89    try:90        n = con.execute("SELECT COUNT(*) FROM inscriptions WHERE ip=? AND ts>?",91                        (ip, time.time() - 86400)).fetchone()[0]92        if n >= MAX_PER_IP_24H:93            raise HTTPException(429, "Trop de dépôts depuis cette adresse — "94                                     "réessayez demain")95        cur = con.execute(96            """INSERT INTO inscriptions (ts, ip, name, metier, description,97                   address, website, email, phone, socials, photos, status)98               VALUES (?,?,?,?,?,?,?,?,?,?,?,'pending')""",99            (time.time(), ip, body.name.strip(), body.metier,100             body.description.strip(), body.address.strip(),101             body.website.strip(), body.email.strip(), body.phone.strip(),102             json.dumps(body.socials[:6], ensure_ascii=False),103             json.dumps(body.photos[:6], ensure_ascii=False)))104        con.commit()105        sid = cur.lastrowid106    finally:107        con.close()108    _log(f"NOUVEAU #{sid} «{body.name.strip()}» metier={body.metier} "109         f"site={body.website or '-'} ip={ip}")110    return {"status": "pending", "id": sid,111            "message": "Merci ! Votre atelier est en attente de validation "112                       "manuelle avant publication."}113114115def _require_admin(request: Request) -> None:116    tok = request.headers.get("x-admin-token", "")117    if not ADMIN_TOKEN or tok != ADMIN_TOKEN:118        raise HTTPException(401, "Jeton admin requis (X-Admin-Token)")119120121@router.get("")122def list_inscriptions(request: Request, status: str = "pending"):123    _require_admin(request)124    con = db.connect()125    try:126        rows = [dict(r) for r in con.execute(127            "SELECT * FROM inscriptions WHERE status=? ORDER BY ts DESC LIMIT 200",128            (status,))]129        for r in rows:130            r["socials"] = json.loads(r.get("socials") or "[]")131            r["photos"] = json.loads(r.get("photos") or "[]")132        return {"total": len(rows), "items": rows}133    finally:134        con.close()135136137class DecisionIn(BaseModel):138    action: str                      # approve | reject139    note: str = ""140141142@router.post("/{ins_id}/decision")143def decide(ins_id: int, body: DecisionIn, request: Request):144    _require_admin(request)145    if body.action not in ("approve", "reject"):146        raise HTTPException(400, "action = approve | reject")147    con = db.connect()148    try:149        row = con.execute("SELECT * FROM inscriptions WHERE id=?",150                          (ins_id,)).fetchone()151        if not row:152            raise HTTPException(404)153        if row["status"] != "pending":154            raise HTTPException(409, f"Déjà traité ({row['status']})")155        ins = dict(row)156        con.execute("UPDATE inscriptions SET status=?, reviewed_at=?, review_note=? "157                    "WHERE id=?",158                    ("approved" if body.action == "approve" else "rejected",159                     time.time(), body.note[:300], ins_id))160        con.commit()161    finally:162        con.close()163    if body.action == "reject":164        _log(f"REJET #{ins_id} «{ins['name']}» ({body.note[:80]})")165        return {"status": "rejected", "id": ins_id}166    result = _publish(ins)167    _log(f"APPROUVÉ #{ins_id} «{ins['name']}» -> {result}")168    return {"status": "approved", "id": ins_id, "publication": result}169170171def _publish(ins: dict) -> dict:172    """Publication d'une inscription approuvée : fiche au registre + DB.173174    ⚠ écrit data/stores.json — sérialisé par convention (ne pas lancer en175    même temps qu'une passe qui réécrit le registre)."""176    import sys177    sys.path.insert(0, str(ROOT / "scripts"))178    from urllib.parse import urlparse179    from verify import verify_domain, POSTAL_RE180    from aggregate import norm_domain181182    website = (ins.get("website") or "").strip()183    dom = norm_domain(website) if website else None184    reg_path = ROOT / "data" / "stores.json"185    reg = json.load(open(reg_path))186    if dom and any(s["id"] == dom for s in reg["stores"]):187        return {"outcome": "deja_au_registre", "id": dom}188189    platform, endpoint, url, ver = "", "", website, {}190    if dom:191        ver = verify_domain(dom)192        if not ver.get("active"):193            dom = None               # site injoignable -> fiche annuaire sans domaine194    if dom:195        final_dom = ver.get("final_domain") or dom196        fu = urlparse(ver.get("final_url") or f"https://{final_dom}")197        url = f"{fu.scheme}://{fu.netloc}"198        platform = ver.get("platform") or ""199        endpoint = ver.get("catalog_endpoint") or ""200        if platform == "wix" and not endpoint:201            endpoint = "/_api/wix-ecommerce-storefront-web/api"202        store_id = final_dom203    else:204        # pas de site vérifiable : identifiant synthétique, fiche annuaire205        slug = re.sub(r"[^a-z0-9]+", "-", ins["name"].lower()).strip("-")[:40]206        store_id = f"inscription-{ins['id']}-{slug}"207    postal = POSTAL_RE.search(ins.get("address") or "")208    store = {209        "id": store_id,210        "name": ins["name"],211        "url": url or "",212        "platform": platform,213        "catalog_endpoint": endpoint,214        "city": "",215        "region": "",216        "postal_prefix": postal.group(0).replace(" ", "")[:3] if postal else None,217        "phone": ins.get("phone") or None,218        "email": ins.get("email") or "",219        "origin_class": "D",220        "origin_confidence": 0.7,221        "origin_evidence": "Atelier auto-inscrit via www.fabri-ka.com, validé "222                           f"manuellement le {date.today()} (inscription "223                           f"#{ins['id']})",224        "categories": [ins["metier"]],225        "socials": json.loads(ins.get("socials") or "[]")[:4],226        "discovery_sources": ["auto_inscription"],227        "discovery_source_urls": [],228        "language": "fr",229        "ecommerce": bool(endpoint or (ver.get("has_cart") if ver else False)),230        "verification_date": str(date.today()),231        "status": "verified",232        "enabled": bool(endpoint),233    }234    reg["stores"].append(store)235    reg["count"] = len(reg["stores"])236    reg["generated"] = str(date.today())237    json.dump(reg, open(reg_path, "w"), ensure_ascii=False, indent=1)238    con = db.connect()239    try:240        db.upsert_store(con, store)241        if ins.get("email"):242            con.execute("UPDATE stores SET email=? WHERE id=? AND "243                        "COALESCE(email,'')=''", (ins["email"], store_id))244        con.commit()245    finally:246        con.close()247    return {"outcome": "connectee" if endpoint else "fiche_annuaire",248            "id": store_id, "platform": platform, "endpoint": endpoint}249