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:
@@ -20,7 +20,7 @@ from api_v1.utils import (
|
|||||||
get_pagination_params,
|
get_pagination_params,
|
||||||
paginate_query,
|
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")
|
history_bp = Blueprint("v1_history", __name__, url_prefix="/api/v1/history")
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ def get_history():
|
|||||||
403:
|
403:
|
||||||
description: Plage de dates hors limite du plan — upgrade requis
|
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:
|
if not user:
|
||||||
return jsonify({"error": "Non authentifié"}), 401
|
return jsonify({"error": "Non authentifié"}), 401
|
||||||
|
|
||||||
|
|||||||
@@ -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__))))
|
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 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")
|
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
|
return True, None
|
||||||
|
|
||||||
# 2. Pas de token admin configuré → autoriser les utilisateurs "pro" authentifiés
|
# 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":
|
if user and user.get("plan") == "pro":
|
||||||
return True, None
|
return True, None
|
||||||
|
|
||||||
@@ -81,7 +85,7 @@ def feedback_run():
|
|||||||
description: Erreur interne
|
description: Erreur interne
|
||||||
"""
|
"""
|
||||||
# Vérification admin
|
# 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()
|
admin_token = request.headers.get("X-Admin-Token", "").strip()
|
||||||
is_admin = (ML_ADMIN_TOKEN and admin_token == ML_ADMIN_TOKEN) or (
|
is_admin = (ML_ADMIN_TOKEN and admin_token == ML_ADMIN_TOKEN) or (
|
||||||
user and user.get("plan") == "pro"
|
user and user.get("plan") == "pro"
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ import sqlite3
|
|||||||
from flask import Blueprint, jsonify, request
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
from api_v1.utils import internal_error, bad_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")
|
user_bp = Blueprint("v1_user", __name__, url_prefix="/api/v1/user")
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ from leadhunter_crm import (
|
|||||||
insert_leads,
|
insert_leads,
|
||||||
get_leads,
|
get_leads,
|
||||||
get_lead_by_id,
|
get_lead_by_id,
|
||||||
|
update_lead,
|
||||||
update_lead_status,
|
update_lead_status,
|
||||||
|
delete_lead,
|
||||||
get_stats,
|
get_stats,
|
||||||
export_csv,
|
export_csv,
|
||||||
VALID_STATUSES,
|
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"])
|
@app.route("/health", methods=["GET"])
|
||||||
def health():
|
def health():
|
||||||
"""Healthcheck pour systemd / monitoring."""
|
"""Healthcheck pour systemd / monitoring."""
|
||||||
|
|||||||
@@ -52,8 +52,24 @@ if not logger.handlers:
|
|||||||
# ─── Chemin DB ───────────────────────────────────────────────────────────────
|
# ─── Chemin DB ───────────────────────────────────────────────────────────────
|
||||||
DB_PATH = "/home/h3r7/leadhunter.db"
|
DB_PATH = "/home/h3r7/leadhunter.db"
|
||||||
|
|
||||||
# Statuts valides pour un lead
|
# Statuts valides pour un lead (7 etapes Kanban)
|
||||||
VALID_STATUSES = {"new", "contacted", "closed", "rejected"}
|
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 ──────────────────────────────────────────────────────────
|
# ─── Initialisation ──────────────────────────────────────────────────────────
|
||||||
@@ -212,6 +228,77 @@ def get_lead_by_id(lead_id: int, db_path: str = DB_PATH) -> Optional[dict]:
|
|||||||
return None
|
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:
|
def update_lead_status(lead_id: int, status: str, db_path: str = DB_PATH) -> bool:
|
||||||
"""
|
"""
|
||||||
Met à jour le statut d'un lead.
|
Met à jour le statut d'un lead.
|
||||||
|
|||||||
@@ -356,6 +356,29 @@ def template_complet():
|
|||||||
return send_from_directory("/home/h3r7/turf_saas", "template_complet.html")
|
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")
|
@app.route("/boite_a_idees_dashboard")
|
||||||
def boite_a_idees_dashboard():
|
def boite_a_idees_dashboard():
|
||||||
return send_from_directory("/home/h3r7/turf_saas", "boite_a_idees_dashboard.html")
|
return send_from_directory("/home/h3r7/turf_saas", "boite_a_idees_dashboard.html")
|
||||||
|
|||||||
@@ -298,3 +298,35 @@ try:
|
|||||||
print("[saas_api_v1] Org blueprint (multi-compte Pro) registered ✅")
|
print("[saas_api_v1] Org blueprint (multi-compte Pro) registered ✅")
|
||||||
except Exception as _org_err:
|
except Exception as _org_err:
|
||||||
print(f"[saas_api_v1] Warning: org blueprint not loaded: {_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}')
|
||||||
|
|||||||
Reference in New Issue
Block a user