Files
turf_saas/saas_api_v1.py
CTO H3R7Tech 1ccf9f5cb8 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>
2026-05-10 08:29:44 +02:00

333 lines
12 KiB
Python

#!/usr/bin/env python3
"""
SaaS API v1 Blueprint — /api/v1/*
Stats, prédictions, résumés pour le dashboard SaaS.
Sprint 4-5 — HRT-30
"""
from flask import Blueprint, request, jsonify
import sqlite3
import os
from datetime import datetime
from saas_auth import require_auth
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def plan_allows(user_plan: str, required: str) -> bool:
order = {"free": 0, "premium": 1, "pro": 2}
return order.get(user_plan, 0) >= order.get(required, 0)
# ─── Stats ────────────────────────────────────────────────────────────────────
@api_v1_bp.route("/stats/summary", methods=["GET"])
@require_auth
def stats_summary():
"""GET /api/v1/stats/summary — résumé dashboard."""
today = datetime.now().strftime("%Y-%m-%d")
conn = get_db()
try:
# Courses today
courses_today = (
conn.execute(
"SELECT COUNT(DISTINCT num_reunion||'-'||num_course) FROM ml_predictions_cache WHERE date=?",
(today,),
).fetchone()[0]
or 0
)
# Value bets today
value_bets_today = (
conn.execute(
"SELECT COUNT(*) FROM ml_predictions_cache WHERE date=? AND is_value_bet=1",
(today,),
).fetchone()[0]
or 0
)
# Accuracy top3 (30 days)
acc_row = conn.execute("""
SELECT
CAST(SUM(CASE WHEN p.ordre_arrivee BETWEEN 1 AND 3 AND m.recommendation='top3' THEN 1 ELSE 0 END) AS FLOAT)
/ NULLIF(COUNT(CASE WHEN m.recommendation='top3' THEN 1 END), 0) * 100 AS acc
FROM ml_predictions_cache m
JOIN pmu_partants p ON m.horse_name=p.nom AND m.date=p.date_programme
WHERE m.date >= date('now', '-30 days')
""").fetchone()
accuracy_top3 = round(acc_row[0], 1) if acc_row and acc_row[0] else None
# Next race
next_race = conn.execute(
"SELECT heure, hippodrome FROM ml_predictions_cache WHERE date=? AND heure IS NOT NULL ORDER BY heure LIMIT 1",
(today,),
).fetchone()
conn.close()
return jsonify(
{
"courses_today": courses_today,
"value_bets_today": value_bets_today,
"accuracy_top3": accuracy_top3,
"next_race_time": next_race["heure"] if next_race else None,
"next_race_hippodrome": next_race["hippodrome"] if next_race else None,
}
), 200
except Exception as e:
conn.close()
return jsonify(
{"error": str(e), "courses_today": 0, "value_bets_today": 0}
), 200
# ─── Predictions ──────────────────────────────────────────────────────────────
@api_v1_bp.route("/predictions/today", methods=["GET"])
@require_auth
def predictions_today():
"""GET /api/v1/predictions/today — prédictions du jour selon le plan."""
user = request.current_user
plan = user.get("plan", "free")
today = datetime.now().strftime("%Y-%m-%d")
conn = get_db()
try:
rows = conn.execute(
"""
SELECT horse_name, horse_number, odds, prob_top1, prob_top3,
ml_score, recommendation, is_value_bet, is_outlier,
race_label, race_name, hippodrome, discipline, distance,
heure, risque_label, risque_score, num_reunion, num_course
FROM ml_predictions_cache
WHERE date=?
ORDER BY num_reunion, num_course, ml_score DESC
""",
(today,),
).fetchall()
conn.close()
predictions = [dict(r) for r in rows]
# Free plan: return only 1 race
if plan == "free":
if predictions:
first = predictions[0]
first_key = (first["num_reunion"], first["num_course"])
predictions = [
p
for p in predictions
if (p["num_reunion"], p["num_course"]) == first_key
]
# Mask value bet flag in free
for p in predictions:
p["is_value_bet"] = 0
# Premium/Pro: full predictions
return jsonify(
{
"date": today,
"plan": plan,
"count": len(predictions),
"predictions": predictions,
}
), 200
except Exception as e:
conn.close()
return jsonify({"error": str(e), "predictions": []}), 200
@api_v1_bp.route("/predictions/race/<race_label>", methods=["GET"])
@require_auth
def predictions_race(race_label):
"""GET /api/v1/predictions/race/<label> — prédictions d'une course."""
user = request.current_user
plan = user.get("plan", "free")
today = datetime.now().strftime("%Y-%m-%d")
# Parse label like R1C3 → num_reunion=1, num_course=3
import re
m = re.match(r"R(\d+)C(\d+)", race_label.upper())
if not m:
return jsonify({"error": "Format invalide, attendu: R{n}C{n}"}), 400
nr, nc = int(m.group(1)), int(m.group(2))
conn = get_db()
rows = conn.execute(
"""
SELECT * FROM ml_predictions_cache
WHERE date=? AND num_reunion=? AND num_course=?
ORDER BY ml_score DESC
""",
(today, nr, nc),
).fetchall()
conn.close()
predictions = [dict(r) for r in rows]
if plan == "free" and predictions:
# Only show first race
pass # allowed in detail view if they know the race label
return jsonify({"predictions": predictions, "count": len(predictions)}), 200
# ─── Value Bets ───────────────────────────────────────────────────────────────
@api_v1_bp.route("/value-bets/today", methods=["GET"])
@require_auth
def value_bets_today():
"""GET /api/v1/value-bets/today — value bets (Premium+)."""
user = request.current_user
plan = user.get("plan", "free")
if not plan_allows(plan, "premium"):
return jsonify(
{
"error": "Cette fonctionnalité requiert un plan Premium ou Pro.",
"upgrade_required": True,
}
), 403
today = datetime.now().strftime("%Y-%m-%d")
conn = get_db()
rows = conn.execute(
"""
SELECT horse_name, race_label, race_name, hippodrome, odds,
prob_top3, ml_score, risque_label, heure
FROM ml_predictions_cache
WHERE date=? AND is_value_bet=1
ORDER BY ml_score DESC
""",
(today,),
).fetchall()
conn.close()
return jsonify({"value_bets": [dict(r) for r in rows], "count": len(rows)}), 200
# ─── Export ───────────────────────────────────────────────────────────────────
@api_v1_bp.route("/export/csv", methods=["GET"])
@require_auth
def export_csv():
"""GET /api/v1/export/csv — export CSV (Pro only)."""
from flask import Response
import csv, io
user = request.current_user
plan = user.get("plan", "free")
if not plan_allows(plan, "pro"):
return jsonify(
{"error": "L'export CSV requiert un plan Pro.", "upgrade_required": True}
), 403
date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
conn = get_db()
rows = conn.execute(
"SELECT * FROM ml_predictions_cache WHERE date=? ORDER BY num_reunion, num_course, ml_score DESC",
(date_param,),
).fetchall()
conn.close()
output = io.StringIO()
if rows:
writer = csv.DictWriter(output, fieldnames=rows[0].keys())
writer.writeheader()
writer.writerows([dict(r) for r in rows])
return Response(
output.getvalue(),
mimetype="text/csv",
headers={
"Content-Disposition": f"attachment; filename=turf_ia_{date_param}.csv"
},
)
# ─── Billing Blueprint (Stripe) + JWT init — HRT-49 ─────────────────────────
# Registers /api/v1/billing/* routes via nested Blueprint (Flask 2.0+)
# Also initializes JWTManager on the Flask app (required for jwt_required_middleware)
try:
from flask_jwt_extended import JWTManager
from api_v1.routes.billing import billing_bp
# Initialize JWTManager on the Flask app when api_v1_bp is registered
@api_v1_bp.record_once
def _init_jwt(state):
app = state.app
if not app.config.get("JWT_SECRET_KEY"):
import os
app.config["JWT_SECRET_KEY"] = os.environ.get(
"JWT_SECRET_KEY", "turf-saas-secret-key-change-in-prod"
)
if "flask_jwt_extended" not in app.extensions:
JWTManager(app)
# Register billing blueprint with url_prefix='/billing'
# (parent api_v1_bp has '/api/v1', so result is /api/v1/billing/*)
api_v1_bp.register_blueprint(billing_bp, url_prefix="/billing")
print("[saas_api_v1] Billing blueprint (Stripe) + JWT registered ✅")
except Exception as _billing_err:
print(f"[saas_api_v1] Warning: billing blueprint not loaded: {_billing_err}")
# ─── Org Blueprint — HRT-82 ───────────────────────────────────────────────────
# Registers /api/v1/org/* routes (Pro plan only, multi-compte max 5 users)
try:
from api_v1.routes.org import org_bp
@api_v1_bp.record_once
def _register_org_bp(state):
app = state.app
app.register_blueprint(org_bp)
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}')