feat: Sprint 3-4 — Refacto API /v1/ (HRT-29)

- Blueprint Flask api_v1 avec prefix /api/v1/
- GET /api/v1/health — healthcheck public
- GET /api/v1/courses/today — courses du jour (paginé, filtré)
- GET /api/v1/courses/{id}/predictions — prédictions ML pour une course
- GET /api/v1/predictions/top3 — top 3 global (free tier)
- GET /api/v1/predictions/all — toutes prédictions (premium+)
- GET /api/v1/valuebets — value bets du jour (premium+)
- GET /api/v1/backtest — résultats backtest historiques (pro)
- GET /api/v1/export/csv — export CSV prédictions/paris (pro)
- GET /api/v1/metrics — métriques perf ML (premium+)
- Swagger/OpenAPI via flasgger à /api/v1/docs
- Erreurs uniformes {status, message, code}
- Pagination limit/offset sur toutes les listes
- 42 tests d'intégration passants

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DevOps Engineer
2026-04-25 18:00:54 +02:00
parent c8f1bfd478
commit b8ef1ed35d
14 changed files with 2691 additions and 0 deletions

View File

195
api_v1/routes/backtest.py Normal file
View File

@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""
Backtest route for API v1.
GET /api/v1/backtest — Résultats backtest historiques (pro)
"""
from datetime import datetime, timedelta
from flask import Blueprint, jsonify, request
from api_v1.utils import (
get_db,
table_exists,
internal_error,
bad_request,
get_pagination_params,
paginate_query,
)
from auth import jwt_required_middleware, plan_required
backtest_bp = Blueprint("v1_backtest", __name__, url_prefix="/api/v1")
@backtest_bp.route("/backtest", methods=["GET"])
@jwt_required_middleware
@plan_required("pro")
def backtest():
"""
Backtest historique
---
tags:
- Backtest
summary: Résultats backtest historiques des paris simulés — accès pro uniquement
security:
- Bearer: []
parameters:
- name: start
in: query
type: string
format: date
description: Date de début (YYYY-MM-DD), défaut = -30j
- name: end
in: query
type: string
format: date
description: Date de fin (YYYY-MM-DD), défaut = aujourd'hui
- name: limit
in: query
type: integer
default: 50
- name: offset
in: query
type: integer
default: 0
responses:
200:
description: Résultats backtest
401:
description: Token invalide
403:
description: Plan insuffisant (pro requis)
"""
start = request.args.get("start")
end = request.args.get("end")
# Validate date formats
for label, val in [("start", start), ("end", end)]:
if val:
try:
datetime.strptime(val, "%Y-%m-%d")
except ValueError:
return bad_request(
f"Paramètre '{label}' invalide, format attendu: YYYY-MM-DD"
)
if not start:
start = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")
if not end:
end = datetime.now().strftime("%Y-%m-%d")
limit, offset = get_pagination_params(default_limit=50, max_limit=200)
conn = get_db()
try:
if not table_exists(conn, "bet_results"):
return jsonify(
{
"status": "ok",
"period": {"start": start, "end": end},
"summary": {
"total_bets": 0,
"message": "Aucune donnée bet_results",
},
"by_type": {},
"details": [],
"pagination": {
"total": 0,
"limit": limit,
"offset": offset,
"has_more": False,
},
}
), 200
# Summary
summary_row = conn.execute(
"""SELECT
COUNT(*) AS total,
SUM(CASE WHEN resultat='GAGNE' THEN 1 ELSE 0 END) AS gagne,
SUM(mise) AS mise,
SUM(gain) AS gain
FROM bet_results
WHERE date BETWEEN ? AND ?""",
(start, end),
).fetchone()
total_bets = summary_row["total"] or 0
gagne = summary_row["gagne"] or 0
mise = float(summary_row["mise"] or 0)
gain = float(summary_row["gain"] or 0)
roi = round((gain - mise) / mise * 100, 1) if mise > 0 else 0.0
precision = round(gagne / total_bets * 100, 1) if total_bets > 0 else 0.0
# By type
by_type_rows = conn.execute(
"""SELECT
type_pari,
COUNT(*) AS total,
SUM(CASE WHEN resultat='GAGNE' THEN 1 ELSE 0 END) AS gagne,
SUM(mise) AS mise,
SUM(gain) AS gain
FROM bet_results
WHERE date BETWEEN ? AND ?
GROUP BY type_pari""",
(start, end),
).fetchall()
by_type = {}
for row in by_type_rows:
t = row["total"] or 0
g = row["gagne"] or 0
m = float(row["mise"] or 0)
gn = float(row["gain"] or 0)
by_type[row["type_pari"]] = {
"count": t,
"gagne": g,
"mise": round(m, 2),
"gain": round(gn, 2),
"roi": round((gn - m) / m * 100, 1) if m > 0 else 0.0,
"precision": round(g / t * 100, 1) if t > 0 else 0.0,
}
# Paginated details
count_row = conn.execute(
"SELECT COUNT(*) AS cnt FROM bet_results WHERE date BETWEEN ? AND ?",
(start, end),
).fetchone()
detail_total = count_row["cnt"] if count_row else 0
detail_rows = conn.execute(
"""SELECT date, race_name, type_pari, horse_name, horse_number,
COALESCE(cote, 0) AS cote, mise, resultat, gain
FROM bet_results
WHERE date BETWEEN ? AND ?
ORDER BY date DESC, id DESC
LIMIT ? OFFSET ?""",
(start, end, limit, offset),
).fetchall()
details = [dict(r) for r in detail_rows]
pagination = paginate_query(details, detail_total, limit, offset)
return jsonify(
{
"status": "ok",
"period": {"start": start, "end": end},
"summary": {
"total_bets": total_bets,
"gagne": gagne,
"perdu": total_bets - gagne,
"precision": precision,
"mise_totale": round(mise, 2),
"gain_total": round(gain, 2),
"roi": roi,
},
"by_type": by_type,
"details": details,
**pagination,
}
), 200
except Exception as e:
return internal_error(str(e))
finally:
conn.close()

664
api_v1/routes/billing.py Normal file
View File

@@ -0,0 +1,664 @@
#!/usr/bin/env python3
"""
Billing Blueprint — Stripe integration
Sprint 5-6: HRT-31
Endpoints:
POST /api/v1/billing/checkout — create Stripe Checkout session (auth required)
POST /api/v1/billing/portal — create Stripe Customer Portal session (auth required)
POST /api/v1/billing/webhook — Stripe webhook handler (public, signature-verified)
GET /api/v1/billing/status — current subscription status (auth required)
Environment variables required:
STRIPE_SECRET_KEY — Stripe secret key (sk_live_... or sk_test_...)
STRIPE_PUBLISHABLE_KEY — Stripe publishable key (pk_...)
STRIPE_WEBHOOK_SECRET — webhook signing secret (whsec_...)
STRIPE_PRICE_PREMIUM — Stripe Price ID for Premium plan (price_...)
STRIPE_PRICE_PRO — Stripe Price ID for Pro plan (price_...)
APP_BASE_URL — e.g. https://turf-ia.h3r7.tech (default http://localhost:8793)
"""
import json
import logging
import os
from datetime import datetime, timedelta, timezone
import stripe
from flask import Blueprint, g, jsonify, request
from auth import jwt_required_middleware
from billing_db import get_db, migrate_billing_tables
logger = logging.getLogger("turf_saas.billing")
billing_bp = Blueprint("billing", __name__, url_prefix="/api/v1/billing")
# ──────────────────────────────────────────────────────────────
# Stripe configuration
# ──────────────────────────────────────────────────────────────
stripe.api_key = os.environ.get("STRIPE_SECRET_KEY", "")
STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "")
STRIPE_PUBLISHABLE_KEY = os.environ.get("STRIPE_PUBLISHABLE_KEY", "")
APP_BASE_URL = os.environ.get("APP_BASE_URL", "http://localhost:8793")
# Plan → Stripe Price ID mapping
PLAN_PRICE_IDS = {
"premium": os.environ.get("STRIPE_PRICE_PREMIUM", ""),
"pro": os.environ.get("STRIPE_PRICE_PRO", ""),
}
# Plan display names
PLAN_NAMES = {
"free": "Free",
"premium": "Premium",
"pro": "Pro",
}
# ──────────────────────────────────────────────────────────────
# DB helpers
# ──────────────────────────────────────────────────────────────
def _sget(obj, key, default=None):
"""Safely get a value from a dict OR a Stripe StripeObject.
Stripe v7+ uses attribute-style access; plain dicts use [] / .get().
"""
try:
# StripeObject supports [] but not .get(); dict supports both
val = obj[key]
return val if val is not None else default
except (KeyError, TypeError):
return default
def _get_active_subscription(db, user_id: int):
"""Return the most recent active subscription row for a user."""
return db.execute(
"""SELECT * FROM subscriptions
WHERE user_id = ?
ORDER BY start_date DESC
LIMIT 1""",
(user_id,),
).fetchone()
def _upsert_subscription(db, user_id: int, **fields):
"""
Update existing subscription or insert a new one.
fields: plan, stripe_customer_id, stripe_subscription_id,
status, current_period_end, grace_period_end, end_date
"""
existing = _get_active_subscription(db, user_id)
if existing:
# Build SET clause dynamically from provided fields
set_parts = ", ".join(f"{k} = ?" for k in fields)
values = list(fields.values()) + [existing["id"]]
db.execute(f"UPDATE subscriptions SET {set_parts} WHERE id = ?", values)
else:
cols = ", ".join(["user_id"] + list(fields.keys()))
placeholders = ", ".join(["?"] * (1 + len(fields)))
values = [user_id] + list(fields.values())
db.execute(
f"INSERT INTO subscriptions ({cols}) VALUES ({placeholders})", values
)
def _update_user_plan(db, user_id: int, plan: str):
"""Sync users.plan field to match active subscription."""
db.execute("UPDATE users SET plan = ? WHERE id = ?", (plan, user_id))
def _get_or_create_stripe_customer(user, db) -> str:
"""Return existing stripe_customer_id or create a new Stripe Customer."""
sub = _get_active_subscription(db, user["id"])
if sub and sub["stripe_customer_id"]:
return sub["stripe_customer_id"]
# Create new customer in Stripe
customer = stripe.Customer.create(
email=user["email"],
metadata={"user_id": str(user["id"])},
)
return customer["id"]
def _record_billing_event(
db, stripe_event_id: str, event_type: str, user_id=None, payload=None
):
"""Insert a billing_events audit row (idempotent on stripe_event_id)."""
try:
db.execute(
"""INSERT OR IGNORE INTO billing_events
(stripe_event_id, event_type, user_id, payload)
VALUES (?, ?, ?, ?)""",
(
stripe_event_id,
event_type,
user_id,
json.dumps(payload) if payload else None,
),
)
except Exception as e:
logger.warning("Could not record billing event %s: %s", stripe_event_id, e)
# ──────────────────────────────────────────────────────────────
# POST /api/v1/billing/checkout
# ──────────────────────────────────────────────────────────────
@billing_bp.route("/checkout", methods=["POST"])
@jwt_required_middleware
def create_checkout():
"""
Create a Stripe Checkout session for upgrading to Premium or Pro.
---
tags:
- Billing
security:
- Bearer: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [plan]
properties:
plan:
type: string
enum: [premium, pro]
responses:
200:
description: Checkout session URL
schema:
type: object
properties:
checkout_url:
type: string
session_id:
type: string
400:
description: Invalid plan or Stripe not configured
503:
description: Stripe API error
"""
if not stripe.api_key:
return jsonify({"error": "Stripe non configuré"}), 503
body = request.get_json(silent=True) or {}
plan = body.get("plan", "").lower()
if plan not in ("premium", "pro"):
return jsonify({"error": "Plan invalide. Choisir 'premium' ou 'pro'"}), 400
price_id = PLAN_PRICE_IDS.get(plan)
if not price_id:
return jsonify({"error": f"Prix Stripe non configuré pour le plan {plan}"}), 503
user = g.current_user
if user["plan"] == plan:
return jsonify({"error": f"Vous êtes déjà sur le plan {plan}"}), 400
db = get_db()
try:
customer_id = _get_or_create_stripe_customer(user, db)
# Persist customer_id early to prevent duplicates
_upsert_subscription(
db, user["id"], stripe_customer_id=customer_id, plan=user["plan"]
)
db.commit()
session = stripe.checkout.Session.create(
customer=customer_id,
payment_method_types=["card"],
line_items=[{"price": price_id, "quantity": 1}],
mode="subscription",
success_url=f"{APP_BASE_URL}/billing/success?session_id={{CHECKOUT_SESSION_ID}}",
cancel_url=f"{APP_BASE_URL}/billing/cancel",
metadata={"user_id": str(user["id"]), "plan": plan},
subscription_data={"metadata": {"user_id": str(user["id"]), "plan": plan}},
)
except stripe.StripeError as e:
logger.error("Stripe checkout error for user %s: %s", user["id"], e)
return jsonify({"error": "Erreur Stripe", "detail": str(e)}), 503
finally:
db.close()
return jsonify(
{
"checkout_url": session.url,
"session_id": session.id,
"plan": plan,
"publishable_key": STRIPE_PUBLISHABLE_KEY,
}
), 200
# ──────────────────────────────────────────────────────────────
# POST /api/v1/billing/portal
# ──────────────────────────────────────────────────────────────
@billing_bp.route("/portal", methods=["POST"])
@jwt_required_middleware
def create_portal():
"""
Create a Stripe Customer Portal session for managing subscription.
---
tags:
- Billing
security:
- Bearer: []
responses:
200:
description: Portal session URL
400:
description: No Stripe customer found
503:
description: Stripe not configured or API error
"""
if not stripe.api_key:
return jsonify({"error": "Stripe non configuré"}), 503
user = g.current_user
db = get_db()
try:
sub = _get_active_subscription(db, user["id"])
customer_id = sub["stripe_customer_id"] if sub else None
if not customer_id:
return jsonify(
{
"error": "Aucun abonnement Stripe trouvé. "
"Souscrivez d'abord à un plan payant."
}
), 400
session = stripe.billing_portal.Session.create(
customer=customer_id,
return_url=f"{APP_BASE_URL}/account",
)
except stripe.StripeError as e:
logger.error("Stripe portal error for user %s: %s", user["id"], e)
return jsonify({"error": "Erreur Stripe", "detail": str(e)}), 503
finally:
db.close()
return jsonify({"portal_url": session.url}), 200
# ──────────────────────────────────────────────────────────────
# GET /api/v1/billing/status
# ──────────────────────────────────────────────────────────────
@billing_bp.route("/status", methods=["GET"])
@jwt_required_middleware
def billing_status():
"""
Return current subscription status for the authenticated user.
---
tags:
- Billing
security:
- Bearer: []
responses:
200:
description: Subscription status
"""
user = g.current_user
db = get_db()
try:
sub = _get_active_subscription(db, user["id"])
finally:
db.close()
if not sub:
return jsonify(
{
"plan": "free",
"status": "active",
"stripe_customer_id": None,
"stripe_subscription_id": None,
"current_period_end": None,
"grace_period_end": None,
}
), 200
return jsonify(
{
"plan": sub["plan"],
"status": sub["status"] or "active",
"stripe_customer_id": sub["stripe_customer_id"],
"stripe_subscription_id": sub["stripe_subscription_id"],
"start_date": sub["start_date"],
"end_date": sub["end_date"],
"current_period_end": sub["current_period_end"],
"grace_period_end": sub["grace_period_end"],
}
), 200
# ──────────────────────────────────────────────────────────────
# POST /api/v1/billing/webhook
# ──────────────────────────────────────────────────────────────
@billing_bp.route("/webhook", methods=["POST"])
def stripe_webhook():
"""
Stripe webhook handler — no auth, signature-verified.
Handled events:
checkout.session.completed → activate subscription
customer.subscription.updated → sync plan/status
customer.subscription.deleted → downgrade to free
invoice.payment_failed → set past_due + 3-day grace period
invoice.payment_succeeded → clear grace period
"""
payload = request.get_data()
sig_header = request.headers.get("Stripe-Signature", "")
# Verify webhook signature (required in production)
if STRIPE_WEBHOOK_SECRET:
try:
event = stripe.Webhook.construct_event(
payload, sig_header, STRIPE_WEBHOOK_SECRET
)
except stripe.SignatureVerificationError as e:
logger.warning("Stripe webhook signature invalid: %s", e)
return jsonify({"error": "Signature invalide"}), 400
except ValueError as e:
logger.warning("Stripe webhook payload invalid: %s", e)
return jsonify({"error": "Payload invalide"}), 400
else:
# Dev/test: accept without verification (log a warning)
logger.warning("STRIPE_WEBHOOK_SECRET not set — skipping signature check!")
try:
event = stripe.Event.construct_from(json.loads(payload), stripe.api_key)
except Exception as e:
return jsonify({"error": "Payload invalide", "detail": str(e)}), 400
event_type = event["type"]
event_id = event["id"]
logger.info("Stripe webhook received: %s (%s)", event_type, event_id)
db = get_db()
try:
if event_type == "checkout.session.completed":
_handle_checkout_completed(db, event)
elif event_type in (
"customer.subscription.updated",
"customer.subscription.created",
):
_handle_subscription_updated(db, event)
elif event_type == "customer.subscription.deleted":
_handle_subscription_deleted(db, event)
elif event_type == "invoice.payment_failed":
_handle_payment_failed(db, event)
elif event_type == "invoice.payment_succeeded":
_handle_payment_succeeded(db, event)
else:
logger.debug("Unhandled Stripe event type: %s", event_type)
db.commit()
except Exception as e:
db.rollback()
logger.error("Error processing Stripe webhook %s: %s", event_id, e)
return jsonify({"error": "Erreur interne"}), 500
finally:
db.close()
return jsonify({"status": "ok"}), 200
# ──────────────────────────────────────────────────────────────
# Webhook handlers
# ──────────────────────────────────────────────────────────────
def _resolve_user_from_customer(db, customer_id: str):
"""Look up user_id via subscriptions.stripe_customer_id."""
row = db.execute(
"SELECT user_id FROM subscriptions WHERE stripe_customer_id = ? LIMIT 1",
(customer_id,),
).fetchone()
if row:
return row["user_id"]
# Fallback: query Stripe for user_id metadata
try:
customer = stripe.Customer.retrieve(customer_id)
meta = _sget(customer, "metadata") or {}
uid = _sget(meta, "user_id")
if uid:
return int(uid)
except Exception:
pass
return None
def _resolve_plan_from_price(price_id: str) -> str:
"""Map Stripe price ID to internal plan name."""
for plan, pid in PLAN_PRICE_IDS.items():
if pid and pid == price_id:
return plan
# Unknown price — default to premium (safer than pro)
return "premium"
def _handle_checkout_completed(db, event):
"""checkout.session.completed → activate subscription for the user."""
session = event["data"]["object"]
customer_id = _sget(session, "customer")
subscription_id = _sget(session, "subscription")
metadata = _sget(session, "metadata") or {}
plan = _sget(metadata, "plan") or "premium"
user_id = _sget(metadata, "user_id")
if user_id:
user_id = int(user_id)
else:
user_id = _resolve_user_from_customer(db, customer_id)
if not user_id:
logger.error(
"checkout.session.completed: cannot resolve user for customer %s",
customer_id,
)
return
# Fetch subscription details from Stripe
current_period_end = None
if subscription_id:
try:
sub = stripe.Subscription.retrieve(subscription_id)
current_period_end = datetime.fromtimestamp(
sub["current_period_end"], tz=timezone.utc
).isoformat()
# Sync plan from price if metadata plan is missing
if sub["items"]["data"]:
price_id = sub["items"]["data"][0]["price"]["id"]
plan = _resolve_plan_from_price(price_id)
except Exception as e:
logger.warning("Could not fetch subscription %s: %s", subscription_id, e)
_upsert_subscription(
db,
user_id,
plan=plan,
stripe_customer_id=customer_id,
stripe_subscription_id=subscription_id,
status="active",
current_period_end=current_period_end,
grace_period_end=None,
)
_update_user_plan(db, user_id, plan)
_record_billing_event(db, event["id"], event["type"], user_id=user_id)
logger.info("checkout.session.completed: user %s upgraded to %s", user_id, plan)
def _handle_subscription_updated(db, event):
"""customer.subscription.updated → sync status and plan."""
sub_obj = event["data"]["object"]
customer_id = _sget(sub_obj, "customer")
subscription_id = _sget(sub_obj, "id")
stripe_status = _sget(sub_obj, "status") or "active"
current_period_end = None
cpe = _sget(sub_obj, "current_period_end")
if cpe:
current_period_end = datetime.fromtimestamp(cpe, tz=timezone.utc).isoformat()
# Resolve plan from price
plan = "premium"
items_data = _sget(_sget(sub_obj, "items") or {}, "data")
if items_data:
price_id = items_data[0]["price"]["id"]
plan = _resolve_plan_from_price(price_id)
user_id = _resolve_user_from_customer(db, customer_id)
if not user_id:
# Try metadata
meta = _sget(sub_obj, "metadata") or {}
meta_uid = _sget(meta, "user_id")
if meta_uid:
user_id = int(meta_uid)
if not user_id:
logger.error(
"subscription.updated: cannot resolve user for customer %s", customer_id
)
return
_upsert_subscription(
db,
user_id,
plan=plan,
stripe_customer_id=customer_id,
stripe_subscription_id=subscription_id,
status=stripe_status,
current_period_end=current_period_end,
)
_update_user_plan(db, user_id, plan)
_record_billing_event(db, event["id"], event["type"], user_id=user_id)
logger.info(
"subscription.updated: user %s plan=%s status=%s", user_id, plan, stripe_status
)
def _handle_subscription_deleted(db, event):
"""customer.subscription.deleted → downgrade to free."""
sub_obj = event["data"]["object"]
customer_id = _sget(sub_obj, "customer")
user_id = _resolve_user_from_customer(db, customer_id)
if not user_id:
meta = _sget(sub_obj, "metadata") or {}
meta_uid = _sget(meta, "user_id")
if meta_uid:
user_id = int(meta_uid)
if not user_id:
logger.error(
"subscription.deleted: cannot resolve user for customer %s", customer_id
)
return
_upsert_subscription(
db,
user_id,
plan="free",
stripe_subscription_id=None,
status="canceled",
end_date=datetime.now(timezone.utc).isoformat(),
current_period_end=None,
grace_period_end=None,
)
_update_user_plan(db, user_id, "free")
_record_billing_event(db, event["id"], event["type"], user_id=user_id)
logger.info("subscription.deleted: user %s downgraded to free", user_id)
def _handle_payment_failed(db, event):
"""invoice.payment_failed → mark past_due + 3-day grace period."""
invoice = event["data"]["object"]
customer_id = _sget(invoice, "customer")
subscription_id = _sget(invoice, "subscription")
user_id = _resolve_user_from_customer(db, customer_id)
if not user_id:
logger.error(
"invoice.payment_failed: cannot resolve user for customer %s", customer_id
)
return
grace_end = (datetime.now(timezone.utc) + timedelta(days=3)).isoformat()
_upsert_subscription(db, user_id, status="past_due", grace_period_end=grace_end)
_record_billing_event(
db,
event["id"],
event["type"],
user_id=user_id,
payload={"subscription_id": subscription_id},
)
# TODO: send notification email via /api/notifications
logger.warning(
"invoice.payment_failed: user %s past_due, grace period until %s",
user_id,
grace_end,
)
def _handle_payment_succeeded(db, event):
"""invoice.payment_succeeded → clear past_due / grace period."""
invoice = event["data"]["object"]
customer_id = _sget(invoice, "customer")
user_id = _resolve_user_from_customer(db, customer_id)
if not user_id:
return
# Refresh subscription period end
current_period_end = None
lines = _sget(invoice, "lines") or {}
lines_data = _sget(lines, "data") or []
if lines_data:
period = lines_data[0].get("period") or {}
period_end = (
period.get("end") if isinstance(period, dict) else _sget(period, "end")
)
if period_end:
current_period_end = datetime.fromtimestamp(
period_end, tz=timezone.utc
).isoformat()
_upsert_subscription(
db,
user_id,
status="active",
grace_period_end=None,
current_period_end=current_period_end,
)
_record_billing_event(db, event["id"], event["type"], user_id=user_id)
logger.info("invoice.payment_succeeded: user %s payment cleared", user_id)
# ──────────────────────────────────────────────────────────────
# On-import: ensure DB migration ran
# ──────────────────────────────────────────────────────────────
try:
migrate_billing_tables()
except Exception as _e:
logger.warning("billing_db migration skipped (test env?): %s", _e)

277
api_v1/routes/courses.py Normal file
View File

@@ -0,0 +1,277 @@
#!/usr/bin/env python3
"""
Courses routes for API v1.
GET /api/v1/courses/today — liste des courses du jour (public, paginated)
GET /api/v1/courses/{id}/predictions — prédictions ML pour une course (free tier, 1/day limit)
"""
import os
from datetime import datetime, timedelta
from flask import Blueprint, jsonify, request, g
from api_v1.utils import (
get_db,
table_exists,
error_response,
bad_request,
not_found,
internal_error,
get_pagination_params,
paginate_query,
)
from auth import jwt_required_middleware, free_daily_limit_check
courses_bp = Blueprint("v1_courses", __name__, url_prefix="/api/v1/courses")
# ──────────────────────────────────────────────────────────────
# GET /api/v1/courses/today
# ──────────────────────────────────────────────────────────────
@courses_bp.route("/today", methods=["GET"])
@jwt_required_middleware
def courses_today():
"""
Courses du jour
---
tags:
- Courses
summary: Liste toutes les courses du jour avec info course
security:
- Bearer: []
parameters:
- name: filter
in: query
type: string
enum: [all, quinte, trot, plat]
default: all
description: Filtre par type de course
- name: limit
in: query
type: integer
default: 20
- name: offset
in: query
type: integer
default: 0
responses:
200:
description: Liste des courses du jour
401:
description: Token manquant ou invalide
"""
race_filter = request.args.get("filter", "all").lower()
limit, offset = get_pagination_params(default_limit=50, max_limit=200)
today = datetime.now().strftime("%Y-%m-%d")
# Build SQL condition
if race_filter == "quinte":
cond = "AND (c.libelle LIKE '%Quinté%' OR c.libelle LIKE '%Quinte%')"
elif race_filter == "trot":
cond = "AND c.discipline LIKE '%Trot%'"
elif race_filter == "plat":
cond = "AND c.discipline LIKE '%Plat%'"
else:
cond = ""
conn = get_db()
try:
# Graceful handling if pmu_courses table doesn't exist yet
if not table_exists(conn, "pmu_courses"):
return jsonify(
{
"status": "ok",
"date": today,
"filter": race_filter,
"courses": [],
"pagination": {
"total": 0,
"limit": limit,
"offset": offset,
"has_more": False,
},
}
), 200
# Count total
count_row = conn.execute(
f"""SELECT COUNT(*) as cnt
FROM pmu_courses c
WHERE c.date_programme = ? {cond}""",
(today,),
).fetchone()
total = count_row["cnt"] if count_row else 0
rows = conn.execute(
f"""SELECT
c.date_programme,
c.num_reunion,
c.num_course,
c.libelle,
c.discipline,
c.distance,
c.hippodrome,
c.px_type,
COUNT(p.id_cheval) as nb_partants
FROM pmu_courses c
LEFT JOIN pmu_partants p
ON p.date_programme = c.date_programme
AND p.num_reunion = c.num_reunion
AND p.num_course = c.num_course
WHERE c.date_programme = ? {cond}
GROUP BY c.date_programme, c.num_reunion, c.num_course
ORDER BY c.num_reunion ASC, c.num_course ASC
LIMIT ? OFFSET ?""",
(today, limit, offset),
).fetchall()
courses = []
for r in rows:
course_id = f"{r['num_reunion']}-{r['num_course']}"
courses.append(
{
"id": course_id,
"date": r["date_programme"],
"num_reunion": r["num_reunion"],
"num_course": r["num_course"],
"libelle": r["libelle"],
"discipline": r["discipline"],
"distance": r["distance"],
"hippodrome": r["hippodrome"],
"type_pari": r["px_type"],
"nb_partants": r["nb_partants"],
}
)
pagination = paginate_query(courses, total, limit, offset)
return jsonify(
{
"status": "ok",
"date": today,
"filter": race_filter,
"courses": courses,
**pagination,
}
), 200
except Exception as e:
return internal_error(str(e))
finally:
conn.close()
# ──────────────────────────────────────────────────────────────
# GET /api/v1/courses/<course_id>/predictions
# course_id format: "{num_reunion}-{num_course}" e.g. "1-3"
# ──────────────────────────────────────────────────────────────
@courses_bp.route("/<course_id>/predictions", methods=["GET"])
@jwt_required_middleware
@free_daily_limit_check
def course_predictions(course_id):
"""
Prédictions pour une course
---
tags:
- Courses
summary: Prédictions ML pour une course identifiée par {num_reunion}-{num_course}
security:
- Bearer: []
parameters:
- name: course_id
in: path
type: string
required: true
description: Identifiant de la course (format num_reunion-num_course, ex "1-3")
- name: date
in: query
type: string
format: date
description: Date de la course (YYYY-MM-DD), défaut = aujourd'hui
responses:
200:
description: Prédictions ML pour la course
400:
description: Paramètres invalides
404:
description: Course introuvable
429:
description: Limite quotidienne free tier atteinte
"""
# Parse course_id
parts = course_id.split("-")
if len(parts) != 2:
return bad_request(
"course_id doit être au format {num_reunion}-{num_course}, ex: 1-3"
)
try:
num_reunion = int(parts[0])
num_course = int(parts[1])
except ValueError:
return bad_request("num_reunion et num_course doivent être des entiers")
date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
conn = get_db()
try:
# Fetch course info
course_row = conn.execute(
"""SELECT libelle, discipline, distance, hippodrome, px_type
FROM pmu_courses
WHERE date_programme = ? AND num_reunion = ? AND num_course = ?""",
(date_param, num_reunion, num_course),
).fetchone()
if not course_row:
return not_found(
f"Course R{num_reunion}C{num_course} introuvable pour le {date_param}"
)
# Fetch ML predictions from cache
preds = []
if table_exists(conn, "ml_predictions_cache"):
preds = conn.execute(
"""SELECT horse_name, horse_number, odds, prob_top1, prob_top3,
ml_score, recommendation, is_value_bet, risque_label, risque_score
FROM ml_predictions_cache
WHERE date = ? AND num_reunion = ? AND num_course = ?
ORDER BY ml_score DESC""",
(date_param, num_reunion, num_course),
).fetchall()
# Fetch partants
partants = conn.execute(
"""SELECT nom, num_pmu, cote_direct, cote_reference, tendance_cote, favoris,
tx_victoire, tx_place, forme_recente, driver, entraineur, musique
FROM pmu_partants
WHERE date_programme = ? AND num_reunion = ? AND num_course = ?
ORDER BY num_pmu ASC""",
(date_param, num_reunion, num_course),
).fetchall()
return jsonify(
{
"status": "ok",
"date": date_param,
"course": {
"id": course_id,
"libelle": course_row["libelle"],
"discipline": course_row["discipline"],
"distance": course_row["distance"],
"hippodrome": course_row["hippodrome"],
"type_pari": course_row["px_type"],
},
"predictions": [dict(p) for p in preds],
"partants": [dict(p) for p in partants],
}
), 200
except Exception as e:
return internal_error(str(e))
finally:
conn.close()

185
api_v1/routes/export.py Normal file
View File

@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""
Export route for API v1.
GET /api/v1/export/csv — Export CSV des prédictions ou paris (pro)
"""
import csv
import io
from datetime import datetime, timedelta
from flask import Blueprint, jsonify, request, Response
from api_v1.utils import (
get_db,
table_exists,
internal_error,
bad_request,
forbidden,
)
from auth import jwt_required_middleware, plan_required
export_bp = Blueprint("v1_export", __name__, url_prefix="/api/v1/export")
# Maximum rows exportable in one request
EXPORT_MAX_ROWS = 5000
@export_bp.route("/csv", methods=["GET"])
@jwt_required_middleware
@plan_required("pro")
def export_csv():
"""
Export CSV
---
tags:
- Export
summary: Export CSV des prédictions ML ou des paris historiques — accès pro uniquement
security:
- Bearer: []
parameters:
- name: type
in: query
type: string
enum: [predictions, bets]
default: predictions
description: Type de données à exporter
- name: start
in: query
type: string
format: date
description: Date de début (YYYY-MM-DD)
- name: end
in: query
type: string
format: date
description: Date de fin (YYYY-MM-DD)
- name: date
in: query
type: string
format: date
description: Date unique (YYYY-MM-DD), ignoré si start/end fournis
responses:
200:
description: Fichier CSV
content:
text/csv:
schema:
type: string
400:
description: Paramètre invalide
401:
description: Token invalide
403:
description: Plan insuffisant (pro requis)
"""
export_type = request.args.get("type", "predictions").lower()
if export_type not in ("predictions", "bets"):
return bad_request(
"Paramètre 'type' invalide. Valeurs acceptées: predictions, bets"
)
start = request.args.get("start")
end = request.args.get("end")
date = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
for label, val in [("start", start), ("end", end), ("date", date)]:
if val:
try:
datetime.strptime(val, "%Y-%m-%d")
except ValueError:
return bad_request(
f"Paramètre '{label}' invalide, format attendu: YYYY-MM-DD"
)
# Build date range
if start and end:
date_cond = "date BETWEEN ? AND ?"
date_params = [start, end]
elif start:
date_cond = "date >= ?"
date_params = [start]
else:
date_cond = "date = ?"
date_params = [date]
conn = get_db()
try:
output = io.StringIO()
if export_type == "predictions":
if not table_exists(conn, "ml_predictions_cache"):
return bad_request("Table ml_predictions_cache introuvable")
rows = conn.execute(
f"""SELECT date, race_label, hippodrome, discipline, distance, heure,
horse_name, horse_number, odds, prob_top1, prob_top3,
ml_score, recommendation, is_value_bet, risque_label
FROM ml_predictions_cache
WHERE {date_cond}
ORDER BY date DESC, ml_score DESC
LIMIT {EXPORT_MAX_ROWS}""",
date_params,
).fetchall()
fieldnames = [
"date",
"race_label",
"hippodrome",
"discipline",
"distance",
"heure",
"horse_name",
"horse_number",
"odds",
"prob_top1",
"prob_top3",
"ml_score",
"recommendation",
"is_value_bet",
"risque_label",
]
else: # bets
if not table_exists(conn, "bet_results"):
return bad_request("Table bet_results introuvable")
rows = conn.execute(
f"""SELECT date, race_name, type_pari, horse_name, horse_number,
COALESCE(cote, 0) AS cote, mise, resultat, gain
FROM bet_results
WHERE {date_cond}
ORDER BY date DESC
LIMIT {EXPORT_MAX_ROWS}""",
date_params,
).fetchall()
fieldnames = [
"date",
"race_name",
"type_pari",
"horse_name",
"horse_number",
"cote",
"mise",
"resultat",
"gain",
]
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
for row in rows:
writer.writerow(dict(row))
filename = f"turf_{export_type}_{date_params[0]}.csv"
return Response(
output.getvalue(),
status=200,
mimetype="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
except Exception as e:
return internal_error(str(e))
finally:
conn.close()

44
api_v1/routes/health.py Normal file
View File

@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""
GET /api/v1/health — public healthcheck endpoint.
No authentication required.
"""
from flask import Blueprint, jsonify
from datetime import datetime, timezone
health_bp = Blueprint("v1_health", __name__, url_prefix="/api/v1")
@health_bp.route("/health", methods=["GET"])
def health():
"""
Health check
---
tags:
- System
summary: Public healthcheck — returns API status and timestamp
responses:
200:
description: API is healthy
schema:
type: object
properties:
status:
type: string
example: ok
version:
type: string
example: "1.0"
timestamp:
type: string
format: date-time
"""
return jsonify(
{
"status": "ok",
"version": "1.0",
"api": "Turf SaaS API v1",
"timestamp": datetime.now(timezone.utc).isoformat(),
}
), 200

144
api_v1/routes/metrics.py Normal file
View File

@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""
Metrics route for API v1.
GET /api/v1/metrics — Métriques performances ML (premium+)
"""
from datetime import datetime, timedelta
from flask import Blueprint, jsonify, request
from api_v1.utils import (
get_db,
table_exists,
internal_error,
bad_request,
)
from auth import jwt_required_middleware, plan_required
metrics_bp = Blueprint("v1_metrics", __name__, url_prefix="/api/v1")
@metrics_bp.route("/metrics", methods=["GET"])
@jwt_required_middleware
@plan_required("premium", "pro")
def metrics():
"""
Métriques ML
---
tags:
- Métriques
summary: Métriques de performance du modèle ML (precision, ROI, top-3 rate) — premium+
security:
- Bearer: []
parameters:
- name: days
in: query
type: integer
default: 30
description: Nombre de jours à analyser (max 365)
responses:
200:
description: Métriques de performance ML
401:
description: Token invalide
403:
description: Plan insuffisant (premium ou pro requis)
"""
try:
days = int(request.args.get("days", 30))
except (ValueError, TypeError):
return bad_request("Paramètre 'days' doit être un entier")
days = max(1, min(days, 365))
end_date = datetime.now().strftime("%Y-%m-%d")
start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
conn = get_db()
try:
# ── Bet-level metrics from bet_results ──
bet_metrics = {
"available": False,
"period": {"start": start_date, "end": end_date, "days": days},
}
ml_metrics = {"available": False}
daily_stats = []
if table_exists(conn, "bet_results"):
row = conn.execute(
"""SELECT
COUNT(*) AS total,
SUM(CASE WHEN resultat='GAGNE' THEN 1 ELSE 0 END) AS gagne,
SUM(mise) AS mise,
SUM(gain) AS gain
FROM bet_results
WHERE date BETWEEN ? AND ?""",
(start_date, end_date),
).fetchone()
total = row["total"] or 0
gagne = row["gagne"] or 0
mise = float(row["mise"] or 0)
gain = float(row["gain"] or 0)
bet_metrics = {
"available": True,
"period": {"start": start_date, "end": end_date, "days": days},
"total_bets": total,
"precision_pct": round(gagne / total * 100, 2) if total > 0 else 0.0,
"roi_pct": round((gain - mise) / mise * 100, 2) if mise > 0 else 0.0,
"mise_totale": round(mise, 2),
"gain_total": round(gain, 2),
}
# ── ML predictions cache metrics ──
if table_exists(conn, "ml_predictions_cache"):
cache_row = conn.execute(
"""SELECT
COUNT(*) AS total,
SUM(is_value_bet) AS value_bets,
AVG(prob_top1) AS avg_prob_top1,
AVG(prob_top3) AS avg_prob_top3,
AVG(ml_score) AS avg_ml_score
FROM ml_predictions_cache
WHERE date BETWEEN ? AND ?""",
(start_date, end_date),
).fetchone()
if cache_row and cache_row["total"]:
ml_metrics = {
"available": True,
"total_predictions": cache_row["total"],
"value_bets": cache_row["value_bets"] or 0,
"avg_prob_top1": round(float(cache_row["avg_prob_top1"] or 0), 4),
"avg_prob_top3": round(float(cache_row["avg_prob_top3"] or 0), 4),
"avg_ml_score": round(float(cache_row["avg_ml_score"] or 0), 4),
}
# ── Daily breakdown ──
if table_exists(conn, "daily_stats"):
daily_rows = conn.execute(
"""SELECT date, total_bets, bets_gagne, precision_pct, roi_pct,
mise_totale, gain_total
FROM daily_stats
WHERE date BETWEEN ? AND ?
ORDER BY date DESC
LIMIT 60""",
(start_date, end_date),
).fetchall()
daily_stats = [dict(r) for r in daily_rows]
return jsonify(
{
"status": "ok",
"period": {"start": start_date, "end": end_date, "days": days},
"bet_metrics": bet_metrics,
"ml_metrics": ml_metrics,
"daily": daily_stats,
}
), 200
except Exception as e:
return internal_error(str(e))
finally:
conn.close()

View File

@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""
Predictions routes for API v1.
GET /api/v1/predictions/top3 — Top 3 global du jour (free tier, 1/day limit)
GET /api/v1/predictions/all — Toutes prédictions (premium+)
"""
from datetime import datetime, timedelta
from flask import Blueprint, jsonify, request
from api_v1.utils import (
get_db,
table_exists,
internal_error,
not_found,
get_pagination_params,
paginate_query,
)
from auth import jwt_required_middleware, plan_required, free_daily_limit_check
predictions_bp = Blueprint("v1_predictions", __name__, url_prefix="/api/v1/predictions")
def _fetch_ml_predictions(conn, date: str, limit: int = None, offset: int = 0):
"""Shared helper — returns rows from ml_predictions_cache."""
if not table_exists(conn, "ml_predictions_cache"):
return [], 0
count_row = conn.execute(
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
(date,),
).fetchone()
total = count_row["cnt"] if count_row else 0
sql = """SELECT
race_label, hippodrome, discipline, distance, heure,
horse_name, horse_number, odds, prob_top1, prob_top3,
ml_score, recommendation, is_value_bet, risque_label, risque_score
FROM ml_predictions_cache
WHERE date = ?
ORDER BY ml_score DESC"""
params = [date]
if limit is not None:
sql += " LIMIT ? OFFSET ?"
params += [limit, offset]
rows = conn.execute(sql, params).fetchall()
return [dict(r) for r in rows], total
# ──────────────────────────────────────────────────────────────
# GET /api/v1/predictions/top3
# ──────────────────────────────────────────────────────────────
@predictions_bp.route("/top3", methods=["GET"])
@jwt_required_middleware
@free_daily_limit_check
def predictions_top3():
"""
Top 3 prédictions du jour
---
tags:
- Prédictions
summary: Top 3 chevaux avec le meilleur score ML du jour (free tier inclus)
security:
- Bearer: []
parameters:
- name: date
in: query
type: string
format: date
description: Date au format YYYY-MM-DD (défaut aujourd'hui)
responses:
200:
description: Top 3 prédictions ML du jour
401:
description: Token invalide
429:
description: Limite quotidienne free tier atteinte
"""
date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
conn = get_db()
try:
predictions, _ = _fetch_ml_predictions(conn, date_param, limit=3, offset=0)
return jsonify(
{
"status": "ok",
"date": date_param,
"top3": predictions,
}
), 200
except Exception as e:
return internal_error(str(e))
finally:
conn.close()
# ──────────────────────────────────────────────────────────────
# GET /api/v1/predictions/all
# ──────────────────────────────────────────────────────────────
@predictions_bp.route("/all", methods=["GET"])
@jwt_required_middleware
@plan_required("premium", "pro")
def predictions_all():
"""
Toutes les prédictions du jour
---
tags:
- Prédictions
summary: Toutes les prédictions ML du jour — accès premium et pro uniquement
security:
- Bearer: []
parameters:
- name: date
in: query
type: string
format: date
description: Date au format YYYY-MM-DD (défaut aujourd'hui)
- name: limit
in: query
type: integer
default: 20
- name: offset
in: query
type: integer
default: 0
responses:
200:
description: Toutes les prédictions ML
401:
description: Token invalide
403:
description: Plan insuffisant (premium ou pro requis)
"""
date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
limit, offset = get_pagination_params(default_limit=50, max_limit=500)
conn = get_db()
try:
predictions, total = _fetch_ml_predictions(
conn, date_param, limit=limit, offset=offset
)
pagination = paginate_query(predictions, total, limit, offset)
return jsonify(
{
"status": "ok",
"date": date_param,
"predictions": predictions,
**pagination,
}
), 200
except Exception as e:
return internal_error(str(e))
finally:
conn.close()

111
api_v1/routes/valuebets.py Normal file
View File

@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""
Value bets route for API v1.
GET /api/v1/valuebets — Value bets du jour (premium+)
"""
from datetime import datetime
from flask import Blueprint, jsonify, request
from api_v1.utils import (
get_db,
table_exists,
internal_error,
get_pagination_params,
paginate_query,
)
from auth import jwt_required_middleware, plan_required
valuebets_bp = Blueprint("v1_valuebets", __name__, url_prefix="/api/v1")
@valuebets_bp.route("/valuebets", methods=["GET"])
@jwt_required_middleware
@plan_required("premium", "pro")
def valuebets():
"""
Value bets du jour
---
tags:
- Value Bets
summary: Value bets du jour — chevaux à cote surévaluée par le marché (premium+)
security:
- Bearer: []
parameters:
- name: date
in: query
type: string
format: date
description: Date YYYY-MM-DD (défaut aujourd'hui)
- name: min_odds
in: query
type: number
default: 2.0
description: Cote minimale pour filtrer les value bets
- name: limit
in: query
type: integer
default: 20
- name: offset
in: query
type: integer
default: 0
responses:
200:
description: Value bets du jour
401:
description: Token invalide
403:
description: Plan insuffisant (premium ou pro requis)
"""
date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
limit, offset = get_pagination_params(default_limit=20, max_limit=100)
try:
min_odds = float(request.args.get("min_odds", 2.0))
except (ValueError, TypeError):
min_odds = 2.0
conn = get_db()
try:
rows = []
total = 0
if table_exists(conn, "ml_predictions_cache"):
count_row = conn.execute(
"""SELECT COUNT(*) as cnt
FROM ml_predictions_cache
WHERE date = ? AND is_value_bet = 1 AND odds >= ?""",
(date_param, min_odds),
).fetchone()
total = count_row["cnt"] if count_row else 0
rows = conn.execute(
"""SELECT race_label, hippodrome, discipline, distance, heure,
horse_name, horse_number, odds, prob_top1, prob_top3,
ml_score, recommendation, risque_label, risque_score
FROM ml_predictions_cache
WHERE date = ? AND is_value_bet = 1 AND odds >= ?
ORDER BY ml_score DESC
LIMIT ? OFFSET ?""",
(date_param, min_odds, limit, offset),
).fetchall()
valuebets_list = [dict(r) for r in rows]
pagination = paginate_query(valuebets_list, total, limit, offset)
return jsonify(
{
"status": "ok",
"date": date_param,
"min_odds": min_odds,
"valuebets": valuebets_list,
**pagination,
}
), 200
except Exception as e:
return internal_error(str(e))
finally:
conn.close()