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# 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