Files
turf_saas/api_v1/routes/courses.py
DevOps Engineer b8ef1ed35d 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>
2026-04-25 18:00:54 +02:00

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()