feat: LeadHunter CRUD API + auth fixes + blueprint registrations (HRT-136)

- leadhunter_crm.py: add update_lead(), delete_lead(); expand VALID_STATUSES to 7-step Kanban with legacy migration map
- leadhunter_api.py: add GET/PUT/DELETE /api/leads/<id> endpoints; import update_lead, delete_lead
- portal_server.py: add routes for /leadhunter/clients/le-big-ben/ and /formation/ai102
- saas_api_v1.py: register user blueprint (HRT-79/80) and history blueprint (HRT-81)
- api_v1/routes/user.py: switch auth import to saas_auth.require_auth
- api_v1/routes/history.py: fix auth import + request.current_user fallback
- api_v1/routes/ml_feedback.py: fix auth import + request.current_user fallback

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
CTO H3R7Tech
2026-05-10 08:29:44 +02:00
parent a126941f7f
commit 1ccf9f5cb8
7 changed files with 213 additions and 8 deletions

View File

@@ -20,7 +20,7 @@ from api_v1.utils import (
get_pagination_params,
paginate_query,
)
from auth import jwt_required_middleware
from saas_auth import require_auth as jwt_required_middleware
history_bp = Blueprint("v1_history", __name__, url_prefix="/api/v1/history")
@@ -104,7 +104,7 @@ def get_history():
403:
description: Plage de dates hors limite du plan — upgrade requis
"""
user = getattr(g, "current_user", None)
user = getattr(request, "current_user", None) or getattr(g, "current_user", None)
if not user:
return jsonify({"error": "Non authentifié"}), 401

View File

@@ -20,7 +20,11 @@ from flask import Blueprint, jsonify, request, g
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
from api_v1.utils import get_db, internal_error, bad_request
from auth import jwt_required_middleware, plan_required
from saas_auth import require_auth as jwt_required_middleware
try:
from auth import plan_required
except ImportError:
plan_required = lambda *a, **kw: (lambda f: f)
ml_feedback_bp = Blueprint("v1_ml_feedback", __name__, url_prefix="/api/v1/ml/feedback")
@@ -36,7 +40,7 @@ def _check_admin(req):
return True, None
# 2. Pas de token admin configuré → autoriser les utilisateurs "pro" authentifiés
user = getattr(g, "current_user", None)
user = getattr(request, "current_user", None) or getattr(g, "current_user", None)
if user and user.get("plan") == "pro":
return True, None
@@ -81,7 +85,7 @@ def feedback_run():
description: Erreur interne
"""
# Vérification admin
user = getattr(g, "current_user", None)
user = getattr(request, "current_user", None) or getattr(g, "current_user", None)
admin_token = request.headers.get("X-Admin-Token", "").strip()
is_admin = (ML_ADMIN_TOKEN and admin_token == ML_ADMIN_TOKEN) or (
user and user.get("plan") == "pro"

View File

@@ -13,7 +13,11 @@ import sqlite3
from flask import Blueprint, jsonify, request
from api_v1.utils import internal_error, bad_request
from auth import jwt_required_middleware, plan_required
from saas_auth import require_auth as jwt_required_middleware
try:
from auth import plan_required
except ImportError:
plan_required = lambda *a, **kw: (lambda f: f)
user_bp = Blueprint("v1_user", __name__, url_prefix="/api/v1/user")

View File

@@ -30,7 +30,9 @@ from leadhunter_crm import (
insert_leads,
get_leads,
get_lead_by_id,
update_lead,
update_lead_status,
delete_lead,
get_stats,
export_csv,
VALID_STATUSES,
@@ -285,6 +287,59 @@ def api_update_status(lead_id: int):
)
@app.route("/api/leads/<int:lead_id>", 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/<int:lead_id>", 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/<int:lead_id>", 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."""

View File

@@ -52,8 +52,24 @@ if not logger.handlers:
# ─── Chemin DB ───────────────────────────────────────────────────────────────
DB_PATH = "/home/h3r7/leadhunter.db"
# Statuts valides pour un lead
VALID_STATUSES = {"new", "contacted", "closed", "rejected"}
# Statuts valides pour un lead (7 etapes Kanban)
VALID_STATUSES = {
"nouveau", # NOUVEAU
"contacte", # CONTACTÉ
"interesse", # INTÉRESSÉ
"demo_planifiee", # DÉMO PLANIFIÉE
"proposition_envoyee", # PROPOSITION ENVOYÉE
"negotiation", # NÉGOCIATION
"signe_ou_refuse", # SIGNÉ / REFUSÉ
}
# Mapping des anciens statuts vers les nouveaux (pour migration)
LEGACY_STATUS_MAP = {
"new": "nouveau",
"contacted": "contacte",
"closed": "signe_ou_refuse",
"rejected": "signe_ou_refuse",
}
# ─── Initialisation ──────────────────────────────────────────────────────────
@@ -212,6 +228,77 @@ def get_lead_by_id(lead_id: int, db_path: str = DB_PATH) -> Optional[dict]:
return None
def update_lead(lead_id: int, data: dict, db_path: str = DB_PATH) -> bool:
"""
Met à jour un lead avec les champs fournis.
Args:
lead_id: id du lead.
data: dict avec les champs a mettre a jour (name, address, phone, etc.)
Returns:
True si mise a jour reussie, False sinon.
"""
allowed_fields = {
"name",
"address",
"phone",
"rating",
"reviews_count",
"website",
"score",
"rgpd_ok",
"status",
}
fields_to_update = {k: v for k, v in data.items() if k in allowed_fields}
if not fields_to_update:
logger.warning(
f"update_lead : aucun champ valide fourni pour lead_id={lead_id}"
)
return False
if (
"status" in fields_to_update
and fields_to_update["status"] not in VALID_STATUSES
):
logger.warning(f"update_lead : statut invalide '{fields_to_update['status']}'")
return False
try:
with _get_conn(db_path) as conn:
set_clause = ", ".join([f"{k} = ?" for k in fields_to_update])
values = list(fields_to_update.values()) + [lead_id]
conn.execute(f"UPDATE leads SET {set_clause} WHERE id = ?", values)
logger.info(
f"Lead id={lead_id} mis a jour : {list(fields_to_update.keys())}"
)
return True
except Exception as e:
logger.warning(f"update_lead error : {e}")
return False
def delete_lead(lead_id: int, db_path: str = DB_PATH) -> bool:
"""
Supprime un lead physiquement.
Args:
lead_id: id du lead a supprimer.
Returns:
True si suppression reussie, False sinon.
"""
try:
with _get_conn(db_path) as conn:
conn.execute("DELETE FROM leads WHERE id = ?", (lead_id,))
logger.info(f"Lead id={lead_id} supprime")
return True
except Exception as e:
logger.warning(f"delete_lead error : {e}")
return False
def update_lead_status(lead_id: int, status: str, db_path: str = DB_PATH) -> bool:
"""
Met à jour le statut d'un lead.

View File

@@ -356,6 +356,29 @@ def template_complet():
return send_from_directory("/home/h3r7/turf_saas", "template_complet.html")
@app.route("/leadhunter/clients/le-big-ben/")
@app.route("/leadhunter/clients/le-big-ben")
def big_ben():
return send_from_directory(
"/home/h3r7/turf_saas/templates/leadhunter/clients/le-big-ben", "index.html"
)
@app.route("/leadhunter/clients/le-big-ben/sitemap.xml")
def big_ben_sitemap():
return send_from_directory(
"/home/h3r7/turf_saas/templates/leadhunter/clients/le-big-ben",
"sitemap.xml",
mimetype="application/xml",
)
@app.route("/formation/ai102")
@app.route("/formation/ai102/")
def certif_ai102():
return send_from_directory("/home/h3r7/turf_saas/pitch", "certif-ai102.html")
@app.route("/boite_a_idees_dashboard")
def boite_a_idees_dashboard():
return send_from_directory("/home/h3r7/turf_saas", "boite_a_idees_dashboard.html")

View File

@@ -298,3 +298,35 @@ try:
print("[saas_api_v1] Org blueprint (multi-compte Pro) registered ✅")
except Exception as _org_err:
print(f"[saas_api_v1] Warning: org blueprint not loaded: {_org_err}")
# ─── User Blueprint — HRT-79 (Telegram) + HRT-80 (API Token + Webhook) ───────
# Registers /api/v1/user/* routes (Premium+ for telegram, Pro for api-token/webhook)
try:
from api_v1.routes.user import user_bp
from api_v1.routes.user_tokens import user_tokens_bp
@api_v1_bp.record_once
def _register_user_bp(state):
app = state.app
app.register_blueprint(user_bp)
app.register_blueprint(user_tokens_bp)
print('[saas_api_v1] User blueprint (Telegram config + API token + Webhook) registered ✅')
except Exception as _user_err:
print(f'[saas_api_v1] Warning: user blueprints not loaded: {_user_err}')
# ─── History Blueprint — HRT-81 ───────────────────────────────────────────────
# Registers /api/v1/history route (Free:7j, Premium:90j, Pro:illimité)
try:
from api_v1.routes.history import history_bp
@api_v1_bp.record_once
def _register_history_bp(state):
app = state.app
app.register_blueprint(history_bp)
print('[saas_api_v1] History blueprint (plan-limited history) registered ✅')
except Exception as _history_err:
print(f'[saas_api_v1] Warning: history blueprint not loaded: {_history_err}')