- 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>
278 lines
9.1 KiB
Python
278 lines
9.1 KiB
Python
#!/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()
|