Bug #2: portal_server.py importait api_v1_bp depuis saas_api_v1 au lieu de api_v1/__init__.py. Tous les sous-blueprints api_v1/routes/* (health, courses, predictions, valuebets, backtest, export, metrics, ml_feedback) n'etaient jamais enregistres -> 404. Fix: utiliser register_api_v1(app) depuis api_v1/__init__.py. Bug #3: Conflit de nom de blueprint entre saas_api_v1 et api_v1 (tous deux nommes api_v1). Renomme le blueprint de saas_api_v1 en saas_api_v1_bp. Supprime les record_once handlers de saas_api_v1 qui dupliquaient l'enregistrement de sous-blueprints (billing, org, user, history) - desormais geres par register_api_v1(app). Co-Authored-By: Paperclip <noreply@paperclip.ing>
280 lines
9.4 KiB
Python
280 lines
9.4 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")
|
|
|
|
saas_api_v1_bp = Blueprint("saas_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 ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@saas_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 ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
@saas_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
|
|
|
|
|
|
@saas_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 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
@saas_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 ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@saas_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"
|
|
},
|
|
)
|
|
|
|
|
|
# ─── JWT init — HRT-49 ────────────────────────────────────────────────────────
|
|
# Initialize JWTManager on the Flask app (required for jwt_required_middleware)
|
|
# Called when saas_api_v1_bp is registered (portal_server.py)
|
|
try:
|
|
from flask_jwt_extended import JWTManager
|
|
|
|
@saas_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)
|
|
print("[saas_api_v1] JWT init registered ✅")
|
|
except Exception as _jwt_err:
|
|
print(f"[saas_api_v1] Warning: JWT init not loaded: {_jwt_err}")
|