diff --git a/api_v1/routes/history.py b/api_v1/routes/history.py index f56fe33..7151f2e 100644 --- a/api_v1/routes/history.py +++ b/api_v1/routes/history.py @@ -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 diff --git a/api_v1/routes/ml_feedback.py b/api_v1/routes/ml_feedback.py index 7c4523f..aaa6972 100644 --- a/api_v1/routes/ml_feedback.py +++ b/api_v1/routes/ml_feedback.py @@ -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" diff --git a/api_v1/routes/user.py b/api_v1/routes/user.py index e5e20c7..3f3cc45 100644 --- a/api_v1/routes/user.py +++ b/api_v1/routes/user.py @@ -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") diff --git a/leadhunter_api.py b/leadhunter_api.py index f6720da..b023455 100644 --- a/leadhunter_api.py +++ b/leadhunter_api.py @@ -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/", 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.""" diff --git a/leadhunter_crm.py b/leadhunter_crm.py index 2094ebd..be7da86 100644 --- a/leadhunter_crm.py +++ b/leadhunter_crm.py @@ -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. diff --git a/portal_server.py b/portal_server.py index b1f5729..97f49ad 100755 --- a/portal_server.py +++ b/portal_server.py @@ -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") diff --git a/saas_api_v1.py b/saas_api_v1.py index 52e54b3..1944cf7 100644 --- a/saas_api_v1.py +++ b/saas_api_v1.py @@ -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}')