#!/usr/bin/env python3 """ H3R7Tech — LeadHunter API =========================== Service Flask sur port 8775 exposant les endpoints LeadHunter. Endpoints : GET /api/leads — Liste les leads (filtres: status, limit, offset) POST /api/leads/scrape — Lance un job de scraping asynchrone GET /api/leads/stats — Statistiques globales du CRM GET /api/leads/export — Export CSV des leads PATCH /api/leads//status — Met à jour le statut d'un lead Port : 8775 (8769 occupé par depenses_trello/app.py, 8770 occupé par turf_scraper/crm_api.py — corrigé HRT-66) Auteur: H3R7Tech Backend Engineer Issue: HRT-66 """ import os import threading import logging from logging.handlers import RotatingFileHandler from flask import Flask, jsonify, request, Response from flask_cors import CORS # Import des modules LeadHunter from leadhunter_crm import ( init_db, insert_leads, get_leads, get_lead_by_id, update_lead, update_lead_status, delete_lead, get_stats, export_csv, VALID_STATUSES, DB_PATH, ) from leadhunter_scraper import run_scraping, GOOGLE_PLACES_API_KEY from leadhunter_scorer import LeadScorer # ─── Assertions au démarrage ───────────────────────────────────────────────── # Vérification obligatoire : la clé API doit être présente au démarrage assert os.environ.get("GOOGLE_PLACES_API_KEY"), ( "GOOGLE_PLACES_API_KEY manquante. " "Ajouter dans /home/h3r7/.env : export GOOGLE_PLACES_API_KEY=xxx" ) # ─── Logging ──────────────────────────────────────────────────────────────── logger = logging.getLogger("leadhunter.api") _handler = RotatingFileHandler( "/home/h3r7/leadhunter.log", maxBytes=5 * 1024 * 1024, backupCount=3, ) _handler.setFormatter( logging.Formatter("%(asctime)s %(levelname)-8s %(name)s — %(message)s") ) logger.setLevel(logging.INFO) if not logger.handlers: logger.addHandler(_handler) logger.addHandler(logging.StreamHandler()) # ─── App Flask ─────────────────────────────────────────────────────────────── app = Flask(__name__) CORS(app) # Scorer singleton scorer = LeadScorer() # État global du job de scraping (simple flag — pas de celery nécessaire pour le POC) _scrape_job = { "running": False, "last_run": None, "last_count": 0, "last_error": None, } _scrape_lock = threading.Lock() # ─── Init DB ───────────────────────────────────────────────────────────────── init_db(DB_PATH) logger.info("LeadHunter API démarrée — DB initialisée.") # ─── Helpers ───────────────────────────────────────────────────────────────── def _run_scrape_job(max_leads: int, use_google: bool, use_osm: bool) -> None: """Job de scraping exécuté dans un thread séparé.""" with _scrape_lock: _scrape_job["running"] = True _scrape_job["last_error"] = None try: leads_raw = run_scraping( max_leads=max_leads, use_google=use_google, use_osm=use_osm, ) leads_scored = scorer.score_leads(leads_raw) inserted_ids = insert_leads(leads_scored) with _scrape_lock: _scrape_job["last_count"] = len(inserted_ids) from datetime import datetime _scrape_job["last_run"] = datetime.utcnow().isoformat() + "Z" logger.info(f"Scrape job terminé : {len(inserted_ids)} leads insérés.") except Exception as e: logger.warning(f"Scrape job erreur : {e}") with _scrape_lock: _scrape_job["last_error"] = str(e) finally: with _scrape_lock: _scrape_job["running"] = False # ─── Routes ────────────────────────────────────────────────────────────────── @app.route("/api/leads", methods=["GET"]) def api_get_leads(): """ Liste les leads du CRM. Query params : - status (str, optional) : filtre sur new/contacted/closed/rejected - limit (int, default=50) : pagination - offset (int, default=0) : pagination """ status = request.args.get("status") try: limit = int(request.args.get("limit", 50)) offset = int(request.args.get("offset", 0)) except ValueError: return jsonify({"error": "limit et offset doivent être des entiers"}), 400 if status and status not in VALID_STATUSES: return jsonify( {"error": f"status invalide. Valeurs acceptées : {VALID_STATUSES}"} ), 400 leads = get_leads(status=status, limit=limit, offset=offset) return jsonify( { "leads": leads, "count": len(leads), "limit": limit, "offset": offset, "status_filter": status, } ) @app.route("/api/leads/scrape", methods=["POST"]) def api_scrape(): """ Lance un job de scraping asynchrone. Body JSON (optionnel) : - max_leads (int, default=100) - use_google (bool, default=true) - use_osm (bool, default=true) Retourne immédiatement avec le statut du job. """ with _scrape_lock: if _scrape_job["running"]: return jsonify( { "status": "already_running", "message": "Un job de scraping est déjà en cours.", } ), 409 body = request.get_json(silent=True) or {} max_leads = int(body.get("max_leads", 100)) use_google = bool(body.get("use_google", True)) use_osm = bool(body.get("use_osm", True)) thread = threading.Thread( target=_run_scrape_job, args=(max_leads, use_google, use_osm), daemon=True, ) thread.start() logger.info( f"Job de scraping lancé (max_leads={max_leads}, " f"use_google={use_google}, use_osm={use_osm})" ) return jsonify( { "status": "started", "message": "Job de scraping démarré en arrière-plan.", "params": { "max_leads": max_leads, "use_google": use_google, "use_osm": use_osm, }, } ), 202 @app.route("/api/leads/scrape/status", methods=["GET"]) def api_scrape_status(): """Retourne l'état courant du job de scraping.""" with _scrape_lock: return jsonify(dict(_scrape_job)) @app.route("/api/leads/stats", methods=["GET"]) def api_stats(): """ Statistiques globales du CRM LeadHunter. Retourne : total, by_status, by_source, avg_score, top_leads_count """ stats = get_stats() if not stats: return jsonify({"error": "Impossible de calculer les statistiques"}), 500 return jsonify(stats) @app.route("/api/leads/export", methods=["GET"]) def api_export(): """ Export CSV de tous les leads (ou filtrés par status). Query params : - status (str, optional) """ status = request.args.get("status") if status and status not in VALID_STATUSES: return jsonify({"error": f"status invalide : {VALID_STATUSES}"}), 400 csv_content = export_csv(status=status) filename = f"leadhunter_leads{'_' + status if status else ''}.csv" return Response( csv_content, mimetype="text/csv", headers={ "Content-Disposition": f"attachment; filename={filename}", "Content-Type": "text/csv; charset=utf-8", }, ) @app.route("/api/leads//status", methods=["PATCH"]) def api_update_status(lead_id: int): """ Met à jour le statut d'un lead. Body JSON : - status (str) : new | contacted | closed | rejected """ body = request.get_json(silent=True) if not body or "status" not in body: return jsonify({"error": "Body JSON requis avec le champ 'status'"}), 400 new_status = body["status"] if new_status not in VALID_STATUSES: return jsonify({"error": f"status invalide. Valeurs : {VALID_STATUSES}"}), 400 lead = get_lead_by_id(lead_id) if not lead: return jsonify({"error": f"Lead id={lead_id} introuvable"}), 404 success = update_lead_status(lead_id, new_status) if not success: return jsonify({"error": "Mise à jour échouée"}), 500 return jsonify( { "success": True, "lead_id": lead_id, "new_status": new_status, } ) @app.route("/api/leads/", methods=["GET"]) def api_get_lead(lead_id: int): """ Retourne le detail d'un lead par son ID. Returns: JSON avec les informations completes du lead, ou 404. """ lead = get_lead_by_id(lead_id) if not lead: return jsonify({"error": f"Lead id={lead_id} introuvable"}), 404 return jsonify(lead) @app.route("/api/leads/", methods=["PUT"]) def api_put_lead(lead_id: int): """ Met a jour completement un lead. Body JSON : dict avec les champs a mettre a jour. """ body = request.get_json(silent=True) if not body: return jsonify({"error": "Body JSON requis"}), 400 lead = get_lead_by_id(lead_id) if not lead: return jsonify({"error": f"Lead id={lead_id} introuvable"}), 404 success = update_lead(lead_id, body) if not success: return jsonify({"error": "Mise a jour echouee"}), 500 updated_lead = get_lead_by_id(lead_id) return jsonify({"success": True, "lead": updated_lead}) @app.route("/api/leads/", methods=["DELETE"]) def api_delete_lead(lead_id: int): """ Supprime un lead physiquement. """ lead = get_lead_by_id(lead_id) if not lead: return jsonify({"error": f"Lead id={lead_id} introuvable"}), 404 success = delete_lead(lead_id) if not success: return jsonify({"error": "Suppression echouee"}), 500 return jsonify({"success": True, "lead_id": lead_id, "deleted": True}) @app.route("/health", methods=["GET"]) def health(): """Healthcheck pour systemd / monitoring.""" return jsonify( { "status": "ok", "service": "leadhunter-api", "port": 8775, } ) # ─── Entrypoint ────────────────────────────────────────────────────────────── if __name__ == "__main__": app.run(host="0.0.0.0", port=8775, debug=False)