Compare commits

...

7 Commits

Author SHA1 Message Date
DevOps Engineer
0492f06bfd docs(HRT-96): Note Intelligence ML + documentation API v1 finale
- Création POD/Intelligence/ML_Predictions_SaaS.md : architecture ML complète,
  flow ml_predictions_cache → ml_feedback_saas → paris → ROI dashboard,
  schéma données/jointures, décision duplication vs modification turf_scraper,
  documentation des 4 stratégies XGBoost, idempotence, usage CLI
- Mise à jour DOCUMENTATION.md : ajout section Turf SaaS API v1 complète
  avec tous les endpoints documentés dont /api/v1/roi/* et /api/v1/ml/feedback/*
  (HRT-92 ROI backend + HRT-93 ML feedback loop)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-30 21:28:52 +02:00
91134e2f3f Merge pull request '[HRT-83] feat: Météo & terrain intégrés dans prédictions ML (Premium)' (#10) from feature/HRT-83-meteo-terrain-ml-predictions into master
Some checks failed
CD / Deploy → Staging (push) Has been cancelled
CD / Smoke Tests on Staging (push) Has been cancelled
CD / Deploy → Production (push) Has been cancelled
CD / Rollback Production (push) Has been cancelled
2026-04-30 08:40:16 +02:00
DevOps Engineer
663e0bb149 Merge PR #12 — [HRT-82] Multi-compte / Organisation Pro (max 5 users)
Some checks failed
CD / Deploy → Staging (push) Has been cancelled
CD / Smoke Tests on Staging (push) Has been cancelled
CD / Deploy → Production (push) Has been cancelled
CD / Rollback Production (push) Has been cancelled
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-30 08:39:59 +02:00
5c6b407f47 Merge pull request '[HRT-80] API Token personnel + Webhook alertes (Pro)' (#13) from feature/HRT-80-api-tokens-webhooks into master
Some checks failed
CD / Deploy → Staging (push) Has been cancelled
CD / Smoke Tests on Staging (push) Has been cancelled
CD / Deploy → Production (push) Has been cancelled
CD / Rollback Production (push) Has been cancelled
2026-04-29 17:31:53 +02:00
DevOps Engineer
f300e44c74 feat(HRT-80): API Token personnel + Webhook alertes (Pro)
- Nouveaux fichiers: api_tokens_db.py, api_v1/routes/user_tokens.py, api_v1/utils_webhook.py
- Migration DB idempotente: tables user_api_tokens + user_webhooks
- Endpoints POST/DELETE /api/v1/user/api-token (Pro only)
- Endpoints POST/DELETE /api/v1/user/webhook (Pro only, HTTPS requis)
- HMAC-SHA256 fire-and-forget dispatch webhook
- auth.py: validate_api_key() + X-API-Key fallback dans jwt_required_middleware
- saas_auth.py: import logging au niveau module, validate_api_key(), X-API-Key fallback
- api_v1/__init__.py: enregistrement user_tokens_bp
- 24 tests pytest — tous passent

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-29 17:25:30 +02:00
DevOps Engineer
bc5ee3fa1a Merge feature/HRT-81-history-blueprint — Historique limité/illimité selon plan (Free/Premium/Pro)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-29 17:05:01 +02:00
DevOps Engineer
ec024d8236 feat(HRT-83): intégrer météo & terrain dans prédictions ML (Premium)
- scoring_v2.py : ajout get_terrain_condition() + compute_weather_impact()
  score_cheval_v2() accepte weather_data=None (backward-compat préservée)
  Impact météo/terrain sur [-5, +5] pts selon pénétromètre + vent + temp

- api_v1/routes/predictions.py : _fetch_ml_predictions() avec include_weather=True
  LEFT JOIN pmu_courses (pénétromètre) + pmu_meteo sur date+num_reunion
  /predictions/all → terrain_condition + weather_impact dans chaque row
  /predictions/top3 → inchangé (free tier, pas de champs météo)

- api_v1/routes/valuebets.py : même LEFT JOIN météo/terrain
  /valuebets → terrain_condition + weather_impact dans chaque value bet

Tests : 42/42 passent (pytest tests/test_api_v1.py)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-29 15:35:15 +02:00
12 changed files with 1909 additions and 171 deletions

View File

@@ -155,3 +155,284 @@ python app.py
--- ---
*Document généré automatiquement - Dépenses Trello* *Document généré automatiquement - Dépenses Trello*
---
---
# Turf SaaS — Documentation API v1
**Mise à jour** : 2026-04-30 (HRT-96 — ML Predictions + ROI + Feedback)
**URL SaaS** : https://turf-saas-kolifee.duckdns.org
**Port local** : 8792
**Base de données** : `/home/h3r7/turf_saas/turf_saas.db`
---
## Stack Technique Turf SaaS
| Composant | Technologie |
|---|---|
| Backend | Python Flask + Blueprints |
| Auth | JWT (access + refresh tokens) |
| Base de données | SQLite (`turf_saas.db`) |
| ML | XGBoost v1 (prédictions courses PMU) |
| Frontend | HTML5 + Chart.js |
| Hébergement | VPS Linux — https://turf-saas-kolifee.duckdns.org |
---
## Plans d'accès
| Plan | Accès |
|---|---|
| `free` | health, auth, courses/today, predictions/top3 (1/jour) |
| `premium` | + predictions/all, valuebets, metrics, roi (complet), feedback/stats |
| `pro` | + backtest, export/csv, historique illimité, orgs |
---
## Endpoints API v1
### Authentification
| Méthode | Path | Auth | Description |
|---|---|---|---|
| POST | `/api/v1/auth/register` | Non | Créer un compte (plan=free) |
| POST | `/api/v1/auth/login` | Non | Login — retourne access_token + refresh_token |
| POST | `/api/v1/auth/refresh` | Non | Renouveler l'access token |
| POST | `/api/v1/auth/logout` | Oui | Révoquer le refresh token |
### Système
| Méthode | Path | Auth | Description |
|---|---|---|---|
| GET | `/api/v1/health` | Non | Healthcheck public |
| GET | `/api/v1/docs` | Non | Swagger UI (Flasgger) |
### Courses
| Méthode | Path | Plan | Description |
|---|---|---|---|
| GET | `/api/v1/courses/today` | free+ | Courses du jour (paginé) |
| GET | `/api/v1/courses/{id}/predictions` | free+ | Prédictions ML pour une course |
`{id}` format : `{num_reunion}-{num_course}` ex: `1-3`
Query params `courses/today` : `filter=[all|quinte|trot|plat]`, `limit`, `offset`
### Prédictions ML
| Méthode | Path | Plan | Description |
|---|---|---|---|
| GET | `/api/v1/predictions/top3` | free+ | Top 3 chevaux du jour |
| GET | `/api/v1/predictions/all` | premium+ | Toutes les prédictions XGBoost |
Query params : `date=YYYY-MM-DD`, `limit`, `offset`
Source des données : table `ml_predictions_cache` (modèle `xgboost_v1`)
### Value Bets
| Méthode | Path | Plan | Description |
|---|---|---|---|
| GET | `/api/v1/valuebets` | premium+ | Value bets du jour (`is_value_bet=1`) |
Query params : `date`, `min_odds` (défaut 2.0), `limit`, `offset`
### Métriques ML
| Méthode | Path | Plan | Description |
|---|---|---|---|
| GET | `/api/v1/metrics` | premium+ | Métriques perf ML (precision, ROI, top-3 rate) |
Query params : `days` (int, défaut 30, max 365)
### ROI par Modèle/Stratégie (HRT-92)
| Méthode | Path | Plan | Description |
|---|---|---|---|
| GET | `/api/v1/roi/by-model` | premium+ | ROI calculé par stratégie ML XGBoost |
**Query params** :
- `strategy` : filtrer par stratégie (`xgboost_sg`, `xgboost_value`, `xgboost_sp`, `xgboost_2sur4`)
- `days` : période en jours (défaut 30, max 365)
**Réponse** :
```json
{
"period": {"start": "2026-04-01", "end": "2026-04-30", "days": 30},
"models": [
{
"model_source": "xgboost_sg",
"nb_paris": 42,
"mise": 42.0,
"gain": 51.3,
"roi_pct": 22.1,
"win_rate": 28.6
}
]
}
```
**Jointures** : `paris``pmu_partants` (résultats) ← `pmu_rapports` (dividendes)
**Accès plan** : Free = 1 stratégie max, Premium/Pro = complet + historique illimité
### ML Feedback Loop (HRT-93)
| Méthode | Path | Plan | Description |
|---|---|---|---|
| POST | `/api/v1/ml/feedback/run` | Admin | Déclencher ml_feedback_saas.py manuellement |
| GET | `/api/v1/ml/feedback/stats` | premium+ | Stats paris par stratégie XGBoost |
**POST `/api/v1/ml/feedback/run`** — Corps optionnel :
```json
{"date": "2026-04-29"}
```
ou
```json
{"backfill": "2026-04-20"}
```
**GET `/api/v1/ml/feedback/stats`** — Réponse :
```json
{
"stats": [
{
"source_reco": "xgboost_sg",
"nb_paris": 42,
"nb_gagnes": 12,
"win_rate_pct": 28.6,
"mise_totale": 42.0,
"gain_total": 51.3,
"roi_pct": 22.1
}
],
"last_run": "2026-04-29T18:30:00"
}
```
**Stratégies XGBoost** :
| Stratégie | Type pari | Condition | Mise |
|---|---|---|---|
| `xgboost_sg` | simple_gagnant | top1 ML, ml_score >= 70 | 1€ |
| `xgboost_value` | simple_gagnant | is_value_bet = 1 | 1€ |
| `xgboost_sp` | simple_place | top1 ML, ml_score >= 50 | 1€ |
| `xgboost_2sur4` | deux_sur_quatre | top4 ML, 6 combos | 6€ |
### Backtest
| Méthode | Path | Plan | Description |
|---|---|---|---|
| GET | `/api/v1/backtest` | pro | Résultats historiques des paris |
Query params : `start`, `end` (YYYY-MM-DD), `limit`, `offset`
### Export
| Méthode | Path | Plan | Description |
|---|---|---|---|
| GET | `/api/v1/export/csv` | pro | Export CSV |
Query params : `type=[predictions|bets]`, `date`, `start`, `end`
### Historique
| Méthode | Path | Plan | Description |
|---|---|---|---|
| GET | `/api/v1/history` | free+ | Historique prédictions ML |
Limites : Free = 7j, Premium = 90j, Pro = illimité
### Organisations
| Méthode | Path | Plan | Description |
|---|---|---|---|
| GET | `/api/v1/org/` | pro | Détails de l'organisation |
| POST | `/api/v1/org/` | pro | Créer une organisation |
| POST | `/api/v1/org/invite` | pro | Inviter un membre (max 5) |
| DELETE | `/api/v1/org/members/{id}` | pro | Retirer un membre |
### Utilisateur & Tokens
| Méthode | Path | Plan | Description |
|---|---|---|---|
| GET | `/api/v1/user/profile` | free+ | Profil utilisateur |
| PUT | `/api/v1/user/alerts` | premium+ | Config alertes Telegram |
| GET | `/api/v1/user/api-token` | pro | Token API personnel |
| POST | `/api/v1/user/api-token` | pro | Générer/régénérer token API |
| GET | `/api/v1/user/webhook` | pro | Config webhook |
| PUT | `/api/v1/user/webhook` | pro | Modifier webhook |
### Billing (Stripe)
| Méthode | Path | Auth | Description |
|---|---|---|---|
| POST | `/api/v1/billing/checkout` | Oui | Créer session Stripe Checkout |
| POST | `/api/v1/billing/portal` | Oui | Portail Stripe (gestion abonnement) |
| GET | `/api/v1/billing/status` | Oui | Statut abonnement actuel |
| POST | `/api/v1/billing/webhook` | Non | Webhook Stripe (events) |
---
## Format de réponse uniforme
**Erreurs** :
```json
{
"status": "error",
"message": "Description de l'erreur",
"code": 400
}
```
**Listes paginées** :
```json
{
"pagination": {
"total": 150,
"limit": 20,
"offset": 0,
"has_more": true
}
}
```
---
## Architecture ML — Résumé
```
ml_predictions_cache (XGBoost v1)
→ ml_feedback_saas.py
→ table paris (source_reco = xgboost_*)
→ /api/v1/roi/by-model (ROI calculé)
→ /api/v1/ml/feedback/stats (stats)
→ dashboard_saas.html (Section Performance & ROI)
```
Voir documentation complète : `POD/Intelligence/ML_Predictions_SaaS.md`
---
## Démarrage
```bash
cd /home/h3r7/turf_saas
source venv/bin/activate
python app_v1.py
# ou via gunicorn
gunicorn -w 2 -b 0.0.0.0:8792 app_v1:app
```
## Tests
```bash
cd /home/h3r7/turf_saas
source venv/bin/activate
python -m pytest tests/ -v
```
---
*Turf SaaS — H3R7Tech — Mise à jour 2026-04-30 (HRT-96)*

View File

@@ -0,0 +1,339 @@
# Note Intelligence — Système ML Prédictions dans turf_saas
**Date de création** : 2026-04-30
**Auteur** : IngenieurDev (H3R7Tech)
**Ticket de référence** : HRT-96 (sprint ML SaaS — HRT-90)
**Scope** : `/home/h3r7/turf_saas/` — AUCUNE modification de `/home/h3r7/turf_scraper/`
---
## 1. Contexte & Décision architecturale
### 1.1 Deux systèmes, deux DB
H3R7Tech exploite deux dépôts séparés :
| Dépôt | Rôle | Base de données |
|---|---|---|
| `/home/h3r7/turf_scraper/` | Scraping PMU + entraînement XGBoost | `turf.db` |
| `/home/h3r7/turf_saas/` | SaaS utilisateurs + API v1 + dashboard | `turf_saas.db` |
### 1.2 Décision de duplication (vs modification turf_scraper)
**Choix : dupliquer les tables et scripts ML dans turf_saas.db, sans toucher à turf_scraper.**
Justification :
- `turf_scraper` est la source de vérité du scraping PMU et des modèles XGBoost — toute modification risque de casser la chaîne de collecte de données.
- `turf_saas` doit fonctionner de manière autonome, avec ses propres utilisateurs, subscriptions et données.
- La table `ml_predictions_cache` est *pré-peuplée* dans `turf_saas.db` par un processus de synchronisation (scheduler ou copie périodique depuis `turf.db`).
- Le feedback loop (`ml_feedback_saas.py`) écrit dans `paris` de `turf_saas.db` uniquement.
---
## 2. Architecture du système ML dans turf_saas
### 2.1 Vue d'ensemble du flow
```
[turf_scraper/turf.db]
└── ml_predictions_cache (XGBoost v1)
│ [sync périodique / scheduler]
[turf_saas/turf_saas.db]
├── ml_predictions_cache ← prédictions XGBoost importées
├── pmu_partants ← données courses PMU
├── pmu_rapports ← dividendes réels PMU
├── paris ← paris virtuels ML (ml_feedback_saas.py)
└── API v1 ──┬── GET /api/v1/predictions/* (lecture ml_predictions_cache)
├── GET /api/v1/roi/by-model (jointure paris + rapports)
├── POST /api/v1/ml/feedback/run (déclenche ml_feedback_saas)
└── GET /api/v1/ml/feedback/stats (stats par stratégie)
[dashboard_saas.html]
Section "Performance & ROI"
Chart.js — ROI par modèle / évolution
```
### 2.2 Table `ml_predictions_cache` (turf_saas.db)
Table centrale du système ML. Contient les prédictions XGBoost pour chaque cheval/course.
| Colonne | Type | Description |
|---|---|---|
| `date` | TEXT | Date de la course (YYYY-MM-DD) |
| `num_reunion` | INTEGER | Numéro de réunion |
| `num_course` | INTEGER | Numéro de course |
| `horse_name` | TEXT | Nom du cheval |
| `horse_number` | INTEGER | Numéro du cheval |
| `odds` | REAL | Cote au moment de la prédiction |
| `prob_top1` | REAL | Probabilité XGBoost de finir 1er |
| `prob_top3` | REAL | Probabilité XGBoost de finir top 3 |
| `ml_score` | REAL | Score ML composite (0100) |
| `recommendation` | TEXT | `top1` / `top3` / `value_bet` |
| `is_value_bet` | INTEGER | 1 si value bet détecté |
| `is_outlier` | INTEGER | 1 si outlier de cote |
| `race_label` | TEXT | Ex: `R1C3` |
| `model_version` | TEXT | Version du modèle (ex: `xgboost_v1`) |
| `risque_label` | TEXT | Niveau de risque (`low`/`neutral`/`high`) |
| `risque_score` | INTEGER | Score risque (0100) |
**Contrainte d'unicité** : `(date, num_reunion, num_course, horse_name)` — garantit l'idempotence des imports.
**Volume actuel** : ~1 000 entrées (2 dates de courses).
---
## 3. Feedback Loop ML — `ml_feedback_saas.py`
### 3.1 Rôle
Script Python autonome qui :
1. Lit les prédictions XGBoost dans `ml_predictions_cache` de `turf_saas.db`
2. Génère des paris virtuels selon 4 stratégies XGBoost
3. Insère les paris dans la table `paris` de `turf_saas.db`
4. Est **idempotent** : ne duplique pas les paris existants
### 3.2 Stratégies supportées
| Stratégie | Type pari | Condition sélection | Mise |
|---|---|---|---|
| `xgboost_sg` | `simple_gagnant` | top 1 ML par course, `ml_score >= 70` | 1€ |
| `xgboost_value` | `simple_gagnant` | `is_value_bet = 1` | 1€ |
| `xgboost_sp` | `simple_place` | top 1 ML par course, `ml_score >= 50` | 1€ |
| `xgboost_2sur4` | `deux_sur_quatre` | top 4 ML par course, 6 combos générés | 6€ (1€/combo) |
### 3.3 Schéma d'idempotence
```python
# Vérifie avant insertion
SELECT id FROM paris
WHERE date_course = ?
AND source_reco = ? # ex: 'xgboost_sg'
AND type_pari = ?
AND numero1 = ?
AND race_label = ?
```
Si le pari existe déjà → skip (aucune duplication).
### 3.4 Table `paris` — colonnes clés pour le ML
| Colonne | Valeur ML |
|---|---|
| `source_reco` | `xgboost_sg` / `xgboost_value` / `xgboost_sp` / `xgboost_2sur4` |
| `model_source` | `xgboost_v1` (héritée de ml_predictions_cache) |
| `type_pari` | `simple_gagnant` / `simple_place` / `deux_sur_quatre` |
| `statut` | `EN_ATTENTE``GAGNE` / `PERDU` (mise à jour par update_paris_results.py) |
| `gain` | Dividende réel × mise (depuis pmu_rapports) |
### 3.5 Usage CLI
```bash
# Traitement du jour
python3 ml_feedback_saas.py
# Date spécifique
python3 ml_feedback_saas.py --date 2026-04-29
# Backfill
python3 ml_feedback_saas.py --backfill 2026-04-20
```
**Différence avec `turf_scraper/ml_feedback.py`** :
- `DB_PATH` = `/home/h3r7/turf_saas/turf_saas.db` (PAS `/home/h3r7/turf_scraper/turf.db`)
- Logs dans `/home/h3r7/turf_saas/logs/`
- AUCUNE référence à `turf_scraper`
---
## 4. API ROI — `/api/v1/roi/*`
### 4.1 Route principale
**`GET /api/v1/roi/by-model`** — Calcul du ROI par modèle/stratégie
Jointures SQL :
```sql
-- paris ←→ pmu_partants (via race_label + date + numero)
-- paris ←→ pmu_rapports (dividendes réels)
SELECT
p.source_reco AS model_source,
COUNT(p.id) AS nb_paris,
SUM(p.mise) AS mise_totale,
SUM(p.gain) AS gain_total,
(SUM(p.gain) - SUM(p.mise)) / SUM(p.mise) * 100 AS roi_pct,
COUNT(CASE WHEN p.statut='GAGNE' THEN 1 END) * 100.0 / COUNT(p.id) AS win_rate
FROM paris p
WHERE p.date_course BETWEEN :start AND :end
AND (:strategy IS NULL OR p.source_reco = :strategy)
GROUP BY p.source_reco
```
**Paramètres query** :
- `?strategy=xgboost_sg` — filtrer par stratégie (optionnel)
- `?days=30` — fenêtre temporelle en jours (défaut : 30, max : 365)
**Réponse JSON** :
```json
{
"period": {"start": "2026-04-01", "end": "2026-04-30", "days": 30},
"models": [
{
"model_source": "xgboost_sg",
"nb_paris": 42,
"mise": 42.0,
"gain": 51.3,
"roi_pct": 22.1,
"win_rate": 28.6
}
]
}
```
**Accès plan** :
- `free` : 1 stratégie max
- `premium` : complet
- `pro` : complet + historique illimité
### 4.2 Blueprint `api_v1/routes/roi.py`
Enregistré dans `api_v1/__init__.py` avec :
```python
from .routes.roi import roi_bp
app.register_blueprint(roi_bp)
```
---
## 5. API ML Feedback — `/api/v1/ml/feedback/*`
### 5.1 Routes
| Méthode | Path | Auth | Description |
|---|---|---|---|
| `POST` | `/api/v1/ml/feedback/run` | Admin | Déclenche `ml_feedback_saas.py` manuellement |
| `GET` | `/api/v1/ml/feedback/stats` | Premium+ | Stats paris par stratégie XGBoost |
### 5.2 `POST /api/v1/ml/feedback/run`
- Réservé aux admins (token admin requis)
- Déclenche le script `ml_feedback_saas.py` en subprocess
- Corps optionnel : `{"date": "2026-04-29"}` ou `{"backfill": "2026-04-20"}`
### 5.3 `GET /api/v1/ml/feedback/stats`
Retourne les statistiques agrégées par stratégie :
```json
{
"stats": [
{
"source_reco": "xgboost_sg",
"nb_paris": 42,
"nb_gagnes": 12,
"win_rate_pct": 28.6,
"mise_totale": 42.0,
"gain_total": 51.3,
"roi_pct": 22.1
}
],
"last_run": "2026-04-29T18:30:00"
}
```
### 5.4 Blueprint `api_v1/routes/ml_feedback.py`
Enregistré dans `api_v1/__init__.py` avec :
```python
from .routes.ml_feedback import ml_feedback_bp
app.register_blueprint(ml_feedback_bp)
```
---
## 6. Jointures de données — Schéma complet
```
ml_predictions_cache
date, num_reunion, num_course, horse_name, horse_number
ml_score, recommendation, is_value_bet
race_label, model_version
│ [ml_feedback_saas.py]
paris
date_course, race_label, numero1
source_reco (= stratégie XGBoost)
model_source (= xgboost_v1)
type_pari, mise, statut, gain
├──── JOIN pmu_partants ──── date_programme + num_reunion + num_course + num_pmu
│ ordre_arrivee (résultat réel)
└──── JOIN pmu_rapports ──── date_programme + num_reunion + num_course + type_pari
dividende_euro (gain réel calculé)
```
---
## 7. Dashboard SaaS — Section ROI
Le dashboard `dashboard_saas.html` intègre une section **"Performance & ROI"** (implémentée dans HRT-94) :
- Graphique ROI par `model_source` (histogramme Chart.js)
- Évolution ROI dans le temps (line chart, 7j/30j/90j)
- Tableau : `model_source | nb paris | mise | gain | ROI% | win_rate%`
- Filtre dropdown par stratégie
- Gating plan : Free = 1 stratégie, Premium/Pro = complet
Appel API dashboard :
```javascript
fetch('/api/v1/roi/by-model?days=30')
```
---
## 8. Points d'attention & limites
1. **Données ML limitées** : actuellement 1 000 prédictions sur 2 dates (2026-04-24 et 2026-04-25). La pertinence du ROI augmentera avec le volume de données.
2. **Pas de paris XGBoost actifs** : la table `paris` contient des paris `manual`, `scoring_v2`, `canalturf` mais pas encore de paris `xgboost_*`. HRT-93 (ml_feedback_saas.py) doit être complété et exécuté.
3. **Modèle unique** : `model_version = 'xgboost_v1'`. L'évolution vers des versions de modèle multiples est prévue dans la roadmap.
4. **Sync turf_scraper → turf_saas** : le mécanisme de synchronisation de `ml_predictions_cache` n'est pas encore documenté formellement. À documenter dans une prochaine Note Intelligence.
5. **update_paris_results.py** : script de mise à jour des statuts paris (`EN_ATTENTE → GAGNE/PERDU`) à partir de `pmu_rapports` — dépendance critique pour le calcul du ROI réel.
---
## 9. Fichiers clés
| Fichier | Rôle |
|---|---|
| `turf_saas.db` | Base de données principale SaaS |
| `ml_feedback_saas.py` | Feedback loop ML (à créer — HRT-93) |
| `api_v1/routes/roi.py` | Routes API ROI (à créer — HRT-92) |
| `api_v1/routes/ml_feedback.py` | Routes API feedback (à créer — HRT-93) |
| `api_v1/__init__.py` | Enregistrement des blueprints |
| `dashboard_saas.html` | Dashboard SaaS avec section ROI |
| `update_paris_results.py` | MAJ statuts paris depuis résultats PMU |
| `scoring_v2.py` | Scoring engine (stratégie scoring_v2) |
---
## 10. Références tickets
| Ticket | Description | Statut |
|---|---|---|
| HRT-90 | Orchestration ML SaaS (parent) | blocked |
| HRT-92 | Backend: API ROI par modèle | in_progress |
| HRT-93 | ML feedback loop ml_feedback_saas | in_progress |
| HRT-94 | Frontend: Dashboard ROI | in_progress |
| HRT-95 | QA: Tests end-to-end ML + ROI | in_progress |
| HRT-96 | Note Intelligence ML + documentation (ce ticket) | in_progress |

57
api_tokens_db.py Normal file
View File

@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""
api_tokens_db.py — DB migration for personal API tokens + user webhooks
HRT-80: API Token personnel + Webhook alertes (Pro)
"""
import logging
import os
import sqlite3
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
logger = logging.getLogger("turf_saas.api_tokens_db")
def get_db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def migrate_api_tokens_tables() -> None:
"""Idempotent migration: create user_api_tokens and user_webhooks."""
conn = get_db()
c = conn.cursor()
c.executescript("""
CREATE TABLE IF NOT EXISTS user_api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
token_prefix TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
last_used_at DATETIME,
revoked INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON user_api_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON user_api_tokens(token_hash);
CREATE TABLE IF NOT EXISTS user_webhooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL UNIQUE,
url TEXT NOT NULL,
secret TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_webhooks_user ON user_webhooks(user_id);
""")
conn.commit()
conn.close()
logger.info(
"[api_tokens_db] Tables user_api_tokens + user_webhooks created/verified."
)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
migrate_api_tokens_tables()
print("[api_tokens_db] Migration complete.")

View File

@@ -4,6 +4,7 @@ API v1 Blueprint package — Turf SaaS
Sprint 3-4: HRT-29 — Refacto API /v1/ Sprint 3-4: HRT-29 — Refacto API /v1/
Sprint 5-6: HRT-31 — Billing Stripe Sprint 5-6: HRT-31 — Billing Stripe
HRT-79: Alertes Telegram configurables (user blueprint) HRT-79: Alertes Telegram configurables (user blueprint)
HRT-80: API Token personnel + Webhook alertes (Pro)
HRT-82: Multi-compte / Organisation Pro (max 5 users) HRT-82: Multi-compte / Organisation Pro (max 5 users)
Registers sub-blueprints: Registers sub-blueprints:
@@ -16,6 +17,8 @@ Registers sub-blueprints:
/api/v1/metrics — métriques perf ML (premium+) /api/v1/metrics — métriques perf ML (premium+)
/api/v1/billing/ — Stripe checkout, portal, webhook, status /api/v1/billing/ — Stripe checkout, portal, webhook, status
/api/v1/user/ — config utilisateur, alertes Telegram (premium+) /api/v1/user/ — config utilisateur, alertes Telegram (premium+)
/api/v1/user/api-token — Personal API token (Pro)
/api/v1/user/webhook — Webhook config (Pro)
/api/v1/history — historique préd. ML (Free:7j, Premium:90j, Pro:illimité) /api/v1/history — historique préd. ML (Free:7j, Premium:90j, Pro:illimité)
/api/v1/org/ — organisations Pro (multi-compte, max 5 users) /api/v1/org/ — organisations Pro (multi-compte, max 5 users)
/api/v1/docs — Swagger UI (via flasgger, registered on app) /api/v1/docs — Swagger UI (via flasgger, registered on app)
@@ -32,6 +35,7 @@ from .routes.export import export_bp
from .routes.metrics import metrics_bp from .routes.metrics import metrics_bp
from .routes.billing import billing_bp from .routes.billing import billing_bp
from .routes.user import user_bp from .routes.user import user_bp
from .routes.user_tokens import user_tokens_bp
from .routes.history import history_bp from .routes.history import history_bp
from .routes.org import org_bp from .routes.org import org_bp
@@ -50,5 +54,6 @@ def register_api_v1(app):
app.register_blueprint(metrics_bp) app.register_blueprint(metrics_bp)
app.register_blueprint(billing_bp) app.register_blueprint(billing_bp)
app.register_blueprint(user_bp) app.register_blueprint(user_bp)
app.register_blueprint(user_tokens_bp)
app.register_blueprint(history_bp) app.register_blueprint(history_bp)
app.register_blueprint(org_bp) app.register_blueprint(org_bp)

View File

@@ -22,8 +22,14 @@ from auth import jwt_required_middleware, plan_required, free_daily_limit_check
predictions_bp = Blueprint("v1_predictions", __name__, url_prefix="/api/v1/predictions") predictions_bp = Blueprint("v1_predictions", __name__, url_prefix="/api/v1/predictions")
def _fetch_ml_predictions(conn, date: str, limit: int = None, offset: int = 0): def _fetch_ml_predictions(
"""Shared helper — returns rows from ml_predictions_cache.""" conn, date: str, limit: int = None, offset: int = 0, include_weather: bool = False
):
"""Shared helper — returns rows from ml_predictions_cache.
include_weather=True adds terrain_condition and weather_impact columns
via LEFT JOIN on pmu_meteo (premium routes only).
"""
if not table_exists(conn, "ml_predictions_cache"): if not table_exists(conn, "ml_predictions_cache"):
return [], 0 return [], 0
@@ -33,13 +39,35 @@ def _fetch_ml_predictions(conn, date: str, limit: int = None, offset: int = 0):
).fetchone() ).fetchone()
total = count_row["cnt"] if count_row else 0 total = count_row["cnt"] if count_row else 0
sql = """SELECT if (
race_label, hippodrome, discipline, distance, heure, include_weather
horse_name, horse_number, odds, prob_top1, prob_top3, and table_exists(conn, "pmu_meteo")
ml_score, recommendation, is_value_bet, risque_label, risque_score and table_exists(conn, "pmu_courses")
FROM ml_predictions_cache ):
WHERE date = ? sql = """SELECT
ORDER BY ml_score DESC""" m.race_label, m.hippodrome, m.discipline, m.distance, m.heure,
m.horse_name, m.horse_number, m.odds, m.prob_top1, m.prob_top3,
m.ml_score, m.recommendation, m.is_value_bet, m.risque_label, m.risque_score,
c.penetrometre_intitule,
mt.nebulositecode, mt.nebulosite_court, mt.temperature, mt.force_vent
FROM ml_predictions_cache m
LEFT JOIN pmu_courses c
ON c.date_programme = m.date
AND c.num_reunion = m.num_reunion
AND c.num_course = m.num_course
LEFT JOIN pmu_meteo mt
ON mt.date_programme = m.date
AND mt.num_reunion = m.num_reunion
WHERE m.date = ?
ORDER BY m.ml_score DESC"""
else:
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] params = [date]
if limit is not None: if limit is not None:
@@ -47,7 +75,42 @@ def _fetch_ml_predictions(conn, date: str, limit: int = None, offset: int = 0):
params += [limit, offset] params += [limit, offset]
rows = conn.execute(sql, params).fetchall() rows = conn.execute(sql, params).fetchall()
return [dict(r) for r in rows], total
results = []
for r in rows:
row_dict = dict(r)
if include_weather:
# Compute derived fields from raw columns
penetrometre = row_dict.pop("penetrometre_intitule", None) or ""
# Import inline to avoid circular dependency at module level
from scoring_v2 import get_terrain_condition, compute_weather_impact
terrain_condition = (
get_terrain_condition(penetrometre) if penetrometre else "inconnu"
)
weather_data = None
if (
row_dict.get("nebulositecode") is not None
or row_dict.get("temperature") is not None
):
weather_data = {
"nebulositecode": row_dict.pop("nebulositecode", None),
"nebulosite_court": row_dict.pop("nebulosite_court", None),
"temperature": row_dict.pop("temperature", None),
"force_vent": row_dict.pop("force_vent", None),
}
else:
# Remove raw meteo columns even if NULL
row_dict.pop("nebulositecode", None)
row_dict.pop("nebulosite_court", None)
row_dict.pop("temperature", None)
row_dict.pop("force_vent", None)
weather_impact = compute_weather_impact(weather_data, terrain_condition)
row_dict["terrain_condition"] = terrain_condition
row_dict["weather_impact"] = weather_impact
results.append(row_dict)
return results, total
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
@@ -145,7 +208,7 @@ def predictions_all():
conn = get_db() conn = get_db()
try: try:
predictions, total = _fetch_ml_predictions( predictions, total = _fetch_ml_predictions(
conn, date_param, limit=limit, offset=offset conn, date_param, limit=limit, offset=offset, include_weather=True
) )
pagination = paginate_query(predictions, total, limit, offset) pagination = paginate_query(predictions, total, limit, offset)

View File

@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""
user_tokens.py — Personal API tokens + Webhook configuration (Pro plan)
HRT-80
Endpoints:
POST /api/v1/user/api-token
DELETE /api/v1/user/api-token
POST /api/v1/user/webhook
DELETE /api/v1/user/webhook
"""
import hashlib
import logging
import secrets
from flask import Blueprint, g, jsonify, request
from api_tokens_db import get_db, migrate_api_tokens_tables
from auth import jwt_required_middleware, plan_required
logger = logging.getLogger("turf_saas.user_tokens")
user_tokens_bp = Blueprint("user_tokens", __name__, url_prefix="/api/v1/user")
try:
migrate_api_tokens_tables()
except Exception as _e:
logger.warning("api_tokens_db migration skipped (test env?): %s", _e)
def _hash_token(raw: str) -> str:
return hashlib.sha256(raw.encode()).hexdigest()
@user_tokens_bp.route("/api-token", methods=["POST"])
@jwt_required_middleware
@plan_required("pro")
def create_api_token():
user = g.current_user
user_id = str(user["id"])
conn = get_db()
try:
existing = conn.execute(
"SELECT id, token_prefix, created_at FROM user_api_tokens "
"WHERE user_id = ? AND revoked = 0",
(user_id,),
).fetchone()
if existing:
return jsonify(
{
"error": "Un token actif existe déjà. Révoquez-le avant d'en créer un nouveau.",
"existing_prefix": existing["token_prefix"],
"created_at": existing["created_at"],
}
), 409
raw_token = "trf_" + secrets.token_urlsafe(40)
token_hash = _hash_token(raw_token)
token_prefix = raw_token[:12]
conn.execute(
"INSERT INTO user_api_tokens (user_id, token_hash, token_prefix) VALUES (?, ?, ?)",
(user_id, token_hash, token_prefix),
)
conn.commit()
row = conn.execute(
"SELECT created_at FROM user_api_tokens WHERE token_hash = ?",
(token_hash,),
).fetchone()
created_at = row["created_at"] if row else None
except Exception as e:
conn.rollback()
logger.error("create_api_token error for user %s: %s", user_id, e)
return jsonify({"error": "Erreur interne"}), 500
finally:
conn.close()
logger.info("API token created for user %s (prefix=%s)", user_id, token_prefix)
return jsonify(
{
"token": raw_token,
"prefix": token_prefix,
"created_at": created_at,
"warning": "Conservez ce token en lieu sûr. Il ne sera plus affiché.",
}
), 201
@user_tokens_bp.route("/api-token", methods=["DELETE"])
@jwt_required_middleware
@plan_required("pro")
def revoke_api_token():
user = g.current_user
user_id = str(user["id"])
conn = get_db()
try:
result = conn.execute(
"UPDATE user_api_tokens SET revoked = 1 WHERE user_id = ? AND revoked = 0",
(user_id,),
)
conn.commit()
revoked_count = result.rowcount
except Exception as e:
conn.rollback()
logger.error("revoke_api_token error for user %s: %s", user_id, e)
return jsonify({"error": "Erreur interne"}), 500
finally:
conn.close()
if revoked_count == 0:
return jsonify({"error": "Aucun token actif trouvé"}), 404
logger.info("API token(s) revoked for user %s (%d tokens)", user_id, revoked_count)
return jsonify({"revoked": True, "count": revoked_count}), 200
@user_tokens_bp.route("/webhook", methods=["POST"])
@jwt_required_middleware
@plan_required("pro")
def create_webhook():
user = g.current_user
user_id = str(user["id"])
data = request.get_json(silent=True) or {}
url = (data.get("url") or "").strip()
if not url:
return jsonify({"error": "URL du webhook manquante"}), 400
if not url.startswith("https://"):
return jsonify(
{"error": "L'URL du webhook doit utiliser HTTPS (commencer par https://)"}
), 400
secret = (data.get("secret") or "").strip() or secrets.token_hex(32)
conn = get_db()
existing = None
try:
existing = conn.execute(
"SELECT id FROM user_webhooks WHERE user_id = ?", (user_id,)
).fetchone()
if existing:
conn.execute(
"UPDATE user_webhooks SET url = ?, secret = ?, created_at = datetime('now') "
"WHERE user_id = ?",
(url, secret, user_id),
)
else:
conn.execute(
"INSERT INTO user_webhooks (user_id, url, secret) VALUES (?, ?, ?)",
(user_id, url, secret),
)
conn.commit()
except Exception as e:
conn.rollback()
logger.error("create_webhook error for user %s: %s", user_id, e)
return jsonify({"error": "Erreur interne"}), 500
finally:
conn.close()
action = "mis à jour" if existing else "configuré"
logger.info("Webhook %s for user %s: %s", action, user_id, url)
return jsonify(
{
"webhook_url": url,
"secret": secret,
"message": f"Webhook {action} avec succès",
}
), 201
@user_tokens_bp.route("/webhook", methods=["DELETE"])
@jwt_required_middleware
@plan_required("pro")
def delete_webhook():
user = g.current_user
user_id = str(user["id"])
conn = get_db()
try:
result = conn.execute("DELETE FROM user_webhooks WHERE user_id = ?", (user_id,))
conn.commit()
deleted_count = result.rowcount
except Exception as e:
conn.rollback()
logger.error("delete_webhook error for user %s: %s", user_id, e)
return jsonify({"error": "Erreur interne"}), 500
finally:
conn.close()
if deleted_count == 0:
return jsonify({"error": "Aucun webhook configuré"}), 404
logger.info("Webhook deleted for user %s", user_id)
return jsonify({"deleted": True}), 200

View File

@@ -53,7 +53,7 @@ def valuebets():
default: 0 default: 0
responses: responses:
200: 200:
description: Value bets du jour description: Value bets du jour avec météo et terrain (HRT-83)
401: 401:
description: Token invalide description: Token invalide
403: 403:
@@ -69,7 +69,7 @@ def valuebets():
conn = get_db() conn = get_db()
try: try:
rows = [] rows_raw = []
total = 0 total = 0
if table_exists(conn, "ml_predictions_cache"): if table_exists(conn, "ml_predictions_cache"):
@@ -81,18 +81,73 @@ def valuebets():
).fetchone() ).fetchone()
total = count_row["cnt"] if count_row else 0 total = count_row["cnt"] if count_row else 0
rows = conn.execute( # LEFT JOIN pmu_courses (terrain) + pmu_meteo (météo) — HRT-83
"""SELECT race_label, hippodrome, discipline, distance, heure, has_courses = table_exists(conn, "pmu_courses")
horse_name, horse_number, odds, prob_top1, prob_top3, has_meteo = table_exists(conn, "pmu_meteo")
ml_score, recommendation, risque_label, risque_score
FROM ml_predictions_cache if has_courses and has_meteo:
WHERE date = ? AND is_value_bet = 1 AND odds >= ? rows_raw = conn.execute(
ORDER BY ml_score DESC """SELECT m.race_label, m.hippodrome, m.discipline, m.distance, m.heure,
LIMIT ? OFFSET ?""", m.horse_name, m.horse_number, m.odds, m.prob_top1, m.prob_top3,
(date_param, min_odds, limit, offset), m.ml_score, m.recommendation, m.risque_label, m.risque_score,
).fetchall() c.penetrometre_intitule,
mt.nebulositecode, mt.nebulosite_court,
mt.temperature, mt.force_vent
FROM ml_predictions_cache m
LEFT JOIN pmu_courses c
ON c.date_programme = m.date
AND c.num_reunion = m.num_reunion
AND c.num_course = m.num_course
LEFT JOIN pmu_meteo mt
ON mt.date_programme = m.date
AND mt.num_reunion = m.num_reunion
WHERE m.date = ? AND m.is_value_bet = 1 AND m.odds >= ?
ORDER BY m.ml_score DESC
LIMIT ? OFFSET ?""",
(date_param, min_odds, limit, offset),
).fetchall()
else:
rows_raw = 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()
from scoring_v2 import get_terrain_condition, compute_weather_impact
valuebets_list = []
for r in rows_raw:
row_dict = dict(r)
penetrometre = row_dict.pop("penetrometre_intitule", None) or ""
terrain_condition = (
get_terrain_condition(penetrometre) if penetrometre else "inconnu"
)
weather_data = None
if (
row_dict.get("nebulositecode") is not None
or row_dict.get("temperature") is not None
):
weather_data = {
"nebulositecode": row_dict.pop("nebulositecode", None),
"nebulosite_court": row_dict.pop("nebulosite_court", None),
"temperature": row_dict.pop("temperature", None),
"force_vent": row_dict.pop("force_vent", None),
}
else:
row_dict.pop("nebulositecode", None)
row_dict.pop("nebulosite_court", None)
row_dict.pop("temperature", None)
row_dict.pop("force_vent", None)
weather_impact = compute_weather_impact(weather_data, terrain_condition)
row_dict["terrain_condition"] = terrain_condition
row_dict["weather_impact"] = weather_impact
valuebets_list.append(row_dict)
valuebets_list = [dict(r) for r in rows]
pagination = paginate_query(valuebets_list, total, limit, offset) pagination = paginate_query(valuebets_list, total, limit, offset)
return jsonify( return jsonify(

80
api_v1/utils_webhook.py Normal file
View File

@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""
utils_webhook.py — Webhook dispatch utility (fire-and-forget, HMAC-SHA256)
HRT-80
"""
import hashlib
import hmac
import json
import logging
import requests
from api_tokens_db import get_db
logger = logging.getLogger("turf_saas.webhook")
EVENT_NEW_PREDICTION = "new_prediction"
EVENT_VALUE_BET = "value_bet"
def dispatch_webhook(user_id: str, event_type: str, payload: dict) -> None:
"""
Send HMAC-signed webhook POST to URL configured by user.
Fire-and-forget: errors logged, never re-raised. Timeout: 5s.
"""
conn = get_db()
try:
row = conn.execute(
"SELECT url, secret FROM user_webhooks WHERE user_id = ?",
(str(user_id),),
).fetchone()
except Exception as e:
logger.warning("dispatch_webhook: DB error for user %s: %s", user_id, e)
return
finally:
conn.close()
if not row:
return
url = row["url"]
secret = row["secret"]
body = json.dumps(
{"event": event_type, "data": payload},
ensure_ascii=False,
separators=(",", ":"),
)
signature = hmac.new(
secret.encode("utf-8"), body.encode("utf-8"), hashlib.sha256
).hexdigest()
headers = {
"Content-Type": "application/json",
"X-Turf-Signature": f"sha256={signature}",
"X-Turf-Event": event_type,
"User-Agent": "TurfSaaS-Webhook/1.0",
}
try:
resp = requests.post(url, data=body, headers=headers, timeout=5)
logger.info(
"Webhook dispatched to user %s (event=%s, status=%s)",
user_id,
event_type,
resp.status_code,
)
except requests.exceptions.Timeout:
logger.warning(
"Webhook timeout for user %s (event=%s, url=%s)", user_id, event_type, url
)
except requests.exceptions.RequestException as e:
logger.warning(
"Webhook failed for user %s (event=%s): %s", user_id, event_type, e
)
except Exception as e:
logger.warning(
"Webhook unexpected error for user %s (event=%s): %s",
user_id,
event_type,
e,
)

52
auth.py
View File

@@ -258,11 +258,47 @@ def logout():
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
def validate_api_key(raw_key: str):
"""
Validate a personal API token (X-API-Key header).
Returns user dict or None. Updates last_used_at on success.
HRT-80: Personal API token support.
"""
if not raw_key:
return None
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
db = get_db()
try:
row = db.execute(
"SELECT t.user_id, u.* FROM user_api_tokens t "
"JOIN users u ON CAST(t.user_id AS INTEGER) = u.id "
"WHERE t.token_hash = ? AND t.revoked = 0 AND u.is_active = 1",
(key_hash,),
).fetchone()
if row:
db.execute(
"UPDATE user_api_tokens SET last_used_at = datetime('now') "
"WHERE token_hash = ?",
(key_hash,),
)
db.commit()
return dict(row) if row else None
except Exception as e:
logger.warning("validate_api_key error: %s", e)
return None
finally:
db.close()
def jwt_required_middleware(fn): def jwt_required_middleware(fn):
"""Decorator: require a valid Bearer JWT access token.""" """
Decorator: require a valid Bearer JWT access token OR X-API-Key personal token.
HRT-80: Added X-API-Key fallback for personal API tokens (Pro plan only).
"""
@wraps(fn) @wraps(fn)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
# 1. Try Bearer JWT (existing flow — unchanged)
try: try:
verify_jwt_in_request() verify_jwt_in_request()
user_id = int(get_jwt_identity()) user_id = int(get_jwt_identity())
@@ -271,10 +307,20 @@ def jwt_required_middleware(fn):
return jsonify({"error": "Utilisateur introuvable"}), 401 return jsonify({"error": "Utilisateur introuvable"}), 401
g.current_user = dict(user) g.current_user = dict(user)
g.current_user_id = user_id g.current_user_id = user_id
return fn(*args, **kwargs)
except (JWTExtendedException, PyJWTError) as e: except (JWTExtendedException, PyJWTError) as e:
logger.debug("JWT auth failed: %s", e) logger.debug("JWT auth failed: %s", e)
return jsonify({"error": "Token invalide ou expiré", "detail": str(e)}), 401
return fn(*args, **kwargs) # 2. Fallback: X-API-Key personal token (HRT-80)
api_key = request.headers.get("X-API-Key", "").strip()
if api_key:
user = validate_api_key(api_key)
if user:
g.current_user = user
g.current_user_id = user.get("id")
return fn(*args, **kwargs)
return jsonify({"error": "Token invalide ou expiré"}), 401
return wrapper return wrapper

View File

@@ -8,6 +8,7 @@ Sprint 4-5 — HRT-30
from flask import Blueprint, request, jsonify, current_app from flask import Blueprint, request, jsonify, current_app
import sqlite3 import sqlite3
import hashlib import hashlib
import logging
import secrets import secrets
import os import os
import time import time
@@ -229,14 +230,54 @@ def hash_password(password: str) -> str:
return hashlib.sha256(password.encode("utf-8")).hexdigest() return hashlib.sha256(password.encode("utf-8")).hexdigest()
def validate_api_key(raw_key: str):
"""
Validate a personal API token (X-API-Key header).
Returns user dict or None. Updates last_used_at on success.
HRT-80
"""
if not raw_key:
return None
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
conn = get_db()
try:
row = conn.execute(
"SELECT t.user_id, u.* FROM user_api_tokens t "
"JOIN saas_users u ON t.user_id = u.id "
"WHERE t.token_hash = ? AND t.revoked = 0",
(key_hash,),
).fetchone()
if row:
conn.execute(
"UPDATE user_api_tokens SET last_used_at = datetime('now') "
"WHERE token_hash = ?",
(key_hash,),
)
conn.commit()
return dict(row) if row else None
except Exception as e:
logging.getLogger("turf_saas.auth").warning("validate_api_key error: %s", e)
return None
finally:
conn.close()
def require_auth(f): def require_auth(f):
@wraps(f) @wraps(f)
def decorated(*args, **kwargs): def decorated(*args, **kwargs):
# 1. Try Bearer session token (existing flow — unchanged)
auth = request.headers.get("Authorization", "") auth = request.headers.get("Authorization", "")
token = ( token = (
auth.removeprefix("Bearer ").strip() if auth.startswith("Bearer ") else None auth.removeprefix("Bearer ").strip() if auth.startswith("Bearer ") else None
) )
user = validate_token(token) user = validate_token(token) if token else None
# 2. Fallback: X-API-Key personal token (HRT-80)
if not user:
api_key = request.headers.get("X-API-Key", "").strip()
if api_key:
user = validate_api_key(api_key)
if not user: if not user:
return jsonify({"error": "Non authentifié"}), 401 return jsonify({"error": "Non authentifié"}), 401
request.current_user = user request.current_user = user

View File

@@ -11,29 +11,34 @@ import re
from datetime import datetime from datetime import datetime
DB_PATH = "/home/h3r7/turf_saas/turf_saas.db" DB_PATH = "/home/h3r7/turf_saas/turf_saas.db"
HEADERS = {'User-Agent': 'Mozilla/5.0', 'Accept': 'application/json'} HEADERS = {"User-Agent": "Mozilla/5.0", "Accept": "application/json"}
def get_cote_from_db(horse_name, date_course): def get_cote_from_db(horse_name, date_course):
"""Recupere la cote depuis la table predictions (plus recente et non nulle)""" """Recupere la cote depuis la table predictions (plus recente et non nulle)"""
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
c = conn.execute(""" c = conn.execute(
"""
SELECT odds FROM predictions SELECT odds FROM predictions
WHERE date=? AND horse_name LIKE ? AND odds > 0 WHERE date=? AND horse_name LIKE ? AND odds > 0
ORDER BY created_at DESC LIMIT 1 ORDER BY created_at DESC LIMIT 1
""", (date_course, f"%{horse_name}%")) """,
(date_course, f"%{horse_name}%"),
)
r = c.fetchone() r = c.fetchone()
conn.close() conn.close()
return r['odds'] if r else 0 return r["odds"] if r else 0
def parse_musique(musique): def parse_musique(musique):
if not musique: if not musique:
return {} return {}
clean = re.sub(r'\(\d+\)', '', musique) clean = re.sub(r"\(\d+\)", "", musique)
resultats = re.findall(r'(\d+|D|0)([amphsc]?)', clean) resultats = re.findall(r"(\d+|D|0)([amphsc]?)", clean)
positions = [] positions = []
for pos, disc in resultats[:10]: for pos, disc in resultats[:10]:
positions.append(99 if pos == 'D' else int(pos)) positions.append(99 if pos == "D" else int(pos))
if not positions: if not positions:
return {} return {}
nb_courses = len(positions) nb_courses = len(positions)
@@ -41,222 +46,385 @@ def parse_musique(musique):
nb_places = sum(1 for p in positions if 1 <= p <= 3) nb_places = sum(1 for p in positions if 1 <= p <= 3)
recentes = [p for p in positions[:3] if p != 99] recentes = [p for p in positions[:3] if p != 99]
forme_recente = sum(recentes) / len(recentes) if recentes else 99 forme_recente = sum(recentes) / len(recentes) if recentes else 99
tendance = (sum(positions[-4:]) / 4 - sum(positions[:4]) / 4) if len(positions) >= 4 else 0 tendance = (
(sum(positions[-4:]) / 4 - sum(positions[:4]) / 4) if len(positions) >= 4 else 0
)
return { return {
'forme_recente': round(forme_recente, 1), "forme_recente": round(forme_recente, 1),
'tendance': round(tendance, 1), "tendance": round(tendance, 1),
'tx_victoire': round(nb_victoires / nb_courses * 100, 1) if nb_courses else 0, "tx_victoire": round(nb_victoires / nb_courses * 100, 1) if nb_courses else 0,
'tx_place': round(nb_places / nb_courses * 100, 1) if nb_courses else 0, "tx_place": round(nb_places / nb_courses * 100, 1) if nb_courses else 0,
} }
def score_cheval_v2(p, all_participants, today):
def get_terrain_condition(penetrometre_intitule: str | None) -> str:
"""Normalise le pénétromètre PMU en condition terrain standardisée."""
if not penetrometre_intitule:
return "inconnu"
val = penetrometre_intitule.upper()
if any(k in val for k in ("TRES BON", "TRÈS BON", "FERME", "FIRM")):
return "bon"
if any(k in val for k in ("BON", "GOOD", "STANDARD")):
return "bon"
if any(k in val for k in ("SOUPLE", "YIELDING", "COLLANT")):
return "souple"
if any(k in val for k in ("LOURD", "HEAVY", "TRES SOUPLE", "TRÈS SOUPLE")):
return "lourd"
if any(k in val for k in ("SOFT", "MOU")):
return "souple"
return "inconnu"
def compute_weather_impact(weather_data: dict | None, terrain_condition: str) -> float:
"""
Calcule un score d'impact météo/terrain sur [5, +5].
weather_data keys attendues : nebulositecode, temperature, force_vent
terrain_condition : 'bon' | 'souple' | 'lourd' | 'inconnu'
Retourne un delta de score ML (positif = favorable, négatif = défavorable).
"""
if not weather_data:
return 0.0
delta = 0.0
# Terrain
if terrain_condition == "lourd":
delta -= 3.0
elif terrain_condition == "souple":
delta -= 1.5
elif terrain_condition == "bon":
delta += 1.0
# inconnu → 0
# Vent
force_vent = weather_data.get("force_vent") or 0
try:
force_vent = float(force_vent)
except (TypeError, ValueError):
force_vent = 0.0
if force_vent >= 50:
delta -= 2.0
elif force_vent >= 30:
delta -= 1.0
# Températures extrêmes
temperature = weather_data.get("temperature")
try:
temperature = float(temperature) if temperature is not None else None
except (TypeError, ValueError):
temperature = None
if temperature is not None:
if temperature <= 0:
delta -= 1.0
elif temperature >= 35:
delta -= 1.0
return round(max(-5.0, min(5.0, delta)), 2)
def score_cheval_v2(p, all_participants, today, weather_data=None):
"""
Score un cheval pour le modèle V2.
weather_data (optionnel) : dict issu de pmu_meteo pour cette réunion.
Backward-compatible : weather_data=None → comportement identique à avant HRT-83.
"""
score = 0 score = 0
details = {} details = {}
# 1. COTE - Essaye PMU API, sinon DB # 1. COTE - Essaye PMU API, sinon DB
horse_name = p.get('nom', '') horse_name = p.get("nom", "")
cote = 0 cote = 0
# Essayer d'abord depuis l'API PMU # Essayer d'abord depuis l'API PMU
rapport = p.get('dernierRapportDirect', {}) rapport = p.get("dernierRapportDirect", {})
if rapport: if rapport:
cote = rapport.get('rapport', 0) cote = rapport.get("rapport", 0)
if not cote: if not cote:
rapport_ref = p.get('dernierRapportReference', {}) rapport_ref = p.get("dernierRapportReference", {})
cote = rapport_ref.get('rapport', 0) if rapport_ref else 0 cote = rapport_ref.get("rapport", 0) if rapport_ref else 0
# Fallback: aller chercher dans la DB # Fallback: aller chercher dans la DB
if not cote or cote == 0: if not cote or cote == 0:
cote = get_cote_from_db(horse_name, today) cote = get_cote_from_db(horse_name, today)
# Si toujours pas de cote, utiliser 99 comme valeur par defaut # Si toujours pas de cote, utiliser 99 comme valeur par defaut
if not cote or cote == 0: if not cote or cote == 0:
cote = 99.0 cote = 99.0
score_cote = max(2, min(10, 20 / (1 + cote * 0.15))) if cote > 0 else 2 score_cote = max(2, min(10, 20 / (1 + cote * 0.15))) if cote > 0 else 2
score += score_cote score += score_cote
details['cote'] = round(cote, 1) details["cote"] = round(cote, 1)
details['score_cote'] = round(score_cote, 1) details["score_cote"] = round(score_cote, 1)
# 2. FORME - AUGMENTE a 30 pts # 2. FORME - AUGMENTE a 30 pts
musique_stats = parse_musique(p.get('musique', '')) musique_stats = parse_musique(p.get("musique", ""))
forme = musique_stats.get('forme_recente', 99) forme = musique_stats.get("forme_recente", 99)
score_forme = 30 if forme <= 1 else 25 if forme <= 2 else 20 if forme <= 3 else 15 if forme <= 5 else 8 if forme <= 8 else 0 score_forme = (
30
if forme <= 1
else 25
if forme <= 2
else 20
if forme <= 3
else 15
if forme <= 5
else 8
if forme <= 8
else 0
)
score += score_forme score += score_forme
details['forme_recente'] = forme details["forme_recente"] = forme
details['score_forme'] = score_forme details["score_forme"] = score_forme
# 3. TAUX VICTOIRE (15 pts) # 3. TAUX VICTOIRE (15 pts)
nb_courses_total = p.get('nombreCourses', 0) nb_courses_total = p.get("nombreCourses", 0)
nb_victoires_total = p.get('nombreVictoires', 0) nb_victoires_total = p.get("nombreVictoires", 0)
tx_vic = (nb_victoires_total / nb_courses_total * 100) if nb_courses_total else 0 tx_vic = (nb_victoires_total / nb_courses_total * 100) if nb_courses_total else 0
score_vic = min(15, tx_vic * 0.5) score_vic = min(15, tx_vic * 0.5)
score += score_vic score += score_vic
details['tx_victoire'] = round(tx_vic, 1) details["tx_victoire"] = round(tx_vic, 1)
details['score_victoire'] = round(score_vic, 1) details["score_victoire"] = round(score_vic, 1)
# 4. TAUX PLACE (15 pts) # 4. TAUX PLACE (15 pts)
nb_places_total = p.get('nombrePlaces', 0) nb_places_total = p.get("nombrePlaces", 0)
tx_place = (nb_places_total / nb_courses_total * 100) if nb_courses_total else 0 tx_place = (nb_places_total / nb_courses_total * 100) if nb_courses_total else 0
score_place = min(15, tx_place * 0.2) score_place = min(15, tx_place * 0.2)
score += score_place score += score_place
details['tx_place'] = round(tx_place, 1) details["tx_place"] = round(tx_place, 1)
details['score_place'] = round(score_place, 1) details["score_place"] = round(score_place, 1)
# 5. REDUCTION KM (10 pts) # 5. REDUCTION KM (10 pts)
rk = p.get('reductionKilometrique', 0) rk = p.get("reductionKilometrique", 0)
all_rk = [x.get('reductionKilometrique', 0) for x in all_participants if x.get('reductionKilometrique', 0) > 0] all_rk = [
x.get("reductionKilometrique", 0)
for x in all_participants
if x.get("reductionKilometrique", 0) > 0
]
if rk > 0 and all_rk: if rk > 0 and all_rk:
score_rk = 10 * (1 - (rk - min(all_rk)) / (max(all_rk) - min(all_rk))) if max(all_rk) > min(all_rk) else 5 score_rk = (
10 * (1 - (rk - min(all_rk)) / (max(all_rk) - min(all_rk)))
if max(all_rk) > min(all_rk)
else 5
)
else: else:
score_rk = 0 score_rk = 0
score += score_rk score += score_rk
details['rk'] = rk details["rk"] = rk
details['score_rk'] = round(score_rk, 1) details["score_rk"] = round(score_rk, 1)
# 6. TENDANCE (10 pts) # 6. TENDANCE (10 pts)
tendance = musique_stats.get('tendance', 0) tendance = musique_stats.get("tendance", 0)
score_tendance = min(10, max(0, 5 + tendance)) score_tendance = min(10, max(0, 5 + tendance))
score += score_tendance score += score_tendance
details['tendance'] = tendance details["tendance"] = tendance
details['score_tendance'] = round(score_tendance, 1) details["score_tendance"] = round(score_tendance, 1)
# 7. AVIS ENTRAINEUR (5 pts) # 7. AVIS ENTRAINEUR (5 pts)
avis = p.get('avisEntraineur', 'NEUTRE') avis = p.get("avisEntraineur", "NEUTRE")
score_avis = {'POSITIF': 5, 'TRES_POSITIF': 5, 'NEUTRE': 2, 'NEGATIF': 0, 'TRES_NEGATIF': 0}.get(avis, 2) score_avis = {
"POSITIF": 5,
"TRES_POSITIF": 5,
"NEUTRE": 2,
"NEGATIF": 0,
"TRES_NEGATIF": 0,
}.get(avis, 2)
score += score_avis score += score_avis
details['avis_entraineur'] = avis details["avis_entraineur"] = avis
details['score_avis'] = score_avis details["score_avis"] = score_avis
# 8. BONUS OUTSIDER (5 pts) # 8. BONUS OUTSIDER (5 pts)
bonus_outsider = 5 if forme <= 3 and cote >= 10 else 0 bonus_outsider = 5 if forme <= 3 and cote >= 10 else 0
score += bonus_outsider score += bonus_outsider
details['bonus_outsider'] = bonus_outsider details["bonus_outsider"] = bonus_outsider
# Driver change penalty # Driver change penalty
if p.get('driverChange', False): if p.get("driverChange", False):
score -= 3 score -= 3
details['driver_change'] = True details["driver_change"] = True
details['score_total'] = round(score, 1) # 9. METEO & TERRAIN (HRT-83) — premium feature, weather_data=None → skip
details['musique'] = p.get('musique', '') penetrometre = p.get("penetrometre_intitule", "") or ""
details['nb_victoires'] = nb_victoires_total terrain_condition = (
details['nb_places'] = nb_places_total get_terrain_condition(penetrometre) if penetrometre else "inconnu"
details['nb_courses'] = nb_courses_total )
weather_impact = 0.0
if weather_data is not None:
weather_impact = compute_weather_impact(weather_data, terrain_condition)
score += weather_impact
details["terrain_condition"] = terrain_condition
details["weather_impact"] = weather_impact
details["score_total"] = round(score, 1)
details["musique"] = p.get("musique", "")
details["nb_victoires"] = nb_victoires_total
details["nb_places"] = nb_places_total
details["nb_courses"] = nb_courses_total
return round(score, 1), details return round(score, 1), details
def get_ze2sur4_combinaisons(top4): def get_ze2sur4_combinaisons(top4):
combinaisons = [] combinaisons = []
for i in range(4): for i in range(4):
for j in range(i+1, 4): for j in range(i + 1, 4):
c1 = top4[i] c1 = top4[i]
c2 = top4[j] c2 = top4[j]
combinaisons.append({ combinaisons.append(
'cheval1': c1['nom'], {
'numero1': c1['numero'], "cheval1": c1["nom"],
'cheval2': c2['nom'], "numero1": c1["numero"],
'numero2': c2['numero'], "cheval2": c2["nom"],
'mise': 1.0, "numero2": c2["numero"],
}) "mise": 1.0,
}
)
return combinaisons return combinaisons
def build_recommendations_v2(scored_horses): def build_recommendations_v2(scored_horses):
ranked = sorted(scored_horses, key=lambda x: x['score'], reverse=True) ranked = sorted(scored_horses, key=lambda x: x["score"], reverse=True)
if len(ranked) < 4: if len(ranked) < 4:
return None return None
top1, top2, top3, top4 = ranked[0], ranked[1], ranked[2], ranked[3] top1, top2, top3, top4 = ranked[0], ranked[1], ranked[2], ranked[3]
top4_list = ranked[:4] top4_list = ranked[:4]
def confiance(s): def confiance(s):
return "FORTE" if s >= 55 else "BONNE" if s >= 45 else "MOYENNE" if s >= 35 else "FAIBLE" return (
"FORTE"
if s >= 55
else "BONNE"
if s >= 45
else "MOYENNE"
if s >= 35
else "FAIBLE"
)
ze2_combinaisons = get_ze2sur4_combinaisons(top4_list) ze2_combinaisons = get_ze2sur4_combinaisons(top4_list)
mise_ze2 = len(ze2_combinaisons) * 1.0 mise_ze2 = len(ze2_combinaisons) * 1.0
return { return {
'simple_gagnant': { "simple_gagnant": {
'cheval': top1['nom'], 'numero': top1['numero'], 'cote': top1['details']['cote'], "cheval": top1["nom"],
'score': top1['score'], 'confiance': confiance(top1['score']), "numero": top1["numero"],
'mise_suggeree': 2.0, 'gain_potentiel': round(2.0 * top1['details']['cote'], 2) "cote": top1["details"]["cote"],
"score": top1["score"],
"confiance": confiance(top1["score"]),
"mise_suggeree": 2.0,
"gain_potentiel": round(2.0 * top1["details"]["cote"], 2),
}, },
'ze2_sur_4': { "ze2_sur_4": {
'top4': [{'nom': h['nom'], 'numero': h['numero']} for h in top4_list], "top4": [{"nom": h["nom"], "numero": h["numero"]} for h in top4_list],
'combinaisons': ze2_combinaisons, "combinaisons": ze2_combinaisons,
'mise_totale': mise_ze2, "mise_totale": mise_ze2,
'nb_combinaisons': len(ze2_combinaisons), "nb_combinaisons": len(ze2_combinaisons),
'confiance': confiance((top1['score'] + top2['score'] + top3['score'] + top4['score']) / 4), "confiance": confiance(
'explication': 'Jouer les 6 combinaisons de 2 chevaux parmi les 4 premiers' (top1["score"] + top2["score"] + top3["score"] + top4["score"]) / 4
),
"explication": "Jouer les 6 combinaisons de 2 chevaux parmi les 4 premiers",
}, },
'outsider': _find_outsider(ranked), "outsider": _find_outsider(ranked),
'budget_total': 2.0 + mise_ze2, "budget_total": 2.0 + mise_ze2,
} }
def _find_outsider(ranked): def _find_outsider(ranked):
for h in ranked[3:7]: for h in ranked[3:7]:
d = h['details'] d = h["details"]
if d['cote'] >= 12 and d['forme_recente'] <= 4 and d['bonus_outsider'] == 5: if d["cote"] >= 12 and d["forme_recente"] <= 4 and d["bonus_outsider"] == 5:
return { return {
'cheval': h['nom'], 'numero': h['numero'], 'cote': d['cote'], "cheval": h["nom"],
'mise_suggeree': 1.0, 'gain_potentiel': round(1.0 * d['cote'], 2) "numero": h["numero"],
"cote": d["cote"],
"mise_suggeree": 1.0,
"gain_potentiel": round(1.0 * d["cote"], 2),
} }
return None return None
def save_to_db(scored_horses, date_course, hippodrome, libelle): def save_to_db(scored_horses, date_course, hippodrome, libelle):
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("DELETE FROM scoring WHERE date = ?", (date_course,)) cursor.execute("DELETE FROM scoring WHERE date = ?", (date_course,))
for i, h in enumerate(scored_horses, 1): for i, h in enumerate(scored_horses, 1):
d = h['details'] d = h["details"]
cursor.execute(""" cursor.execute(
"""
INSERT INTO scoring (date, race_name, horse_number, horse_name, score, INSERT INTO scoring (date, race_name, horse_number, horse_name, score,
score_cote, score_forme, score_victoire, score_place, score_rk, score_cote, score_forme, score_victoire, score_place, score_rk,
score_tendance, score_avis, cote, forme_recente, tx_victoire, tx_place, score_tendance, score_avis, cote, forme_recente, tx_victoire, tx_place,
avis_entraineur, musique, rang_scoring, scoring_version) avis_entraineur, musique, rang_scoring, scoring_version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'v2') VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'v2')
""", (date_course, libelle, h['numero'], h['nom'], h['score'], """,
d.get('score_cote', 0), d.get('score_forme', 0), d.get('score_victoire', 0), (
d.get('score_place', 0), d.get('score_rk', 0), d.get('score_tendance', 0), date_course,
d.get('score_avis', 0), d.get('cote', 0), d.get('forme_recente', 0), libelle,
d.get('tx_victoire', 0), d.get('tx_place', 0), d.get('avis_entraineur', ''), h["numero"],
d.get('musique', ''), i)) h["nom"],
h["score"],
d.get("score_cote", 0),
d.get("score_forme", 0),
d.get("score_victoire", 0),
d.get("score_place", 0),
d.get("score_rk", 0),
d.get("score_tendance", 0),
d.get("score_avis", 0),
d.get("cote", 0),
d.get("forme_recente", 0),
d.get("tx_victoire", 0),
d.get("tx_place", 0),
d.get("avis_entraineur", ""),
d.get("musique", ""),
i,
),
)
conn.commit() conn.commit()
conn.close() conn.close()
print(f"💾 {len(scored_horses)} scores enregistres en BDD pour {date_course}") print(f"💾 {len(scored_horses)} scores enregistres en BDD pour {date_course}")
def main(): def main():
today = datetime.now().strftime('%Y-%m-%d') today = datetime.now().strftime("%Y-%m-%d")
date_pmu = datetime.now().strftime('%d%m%Y') date_pmu = datetime.now().strftime("%d%m%Y")
print(f"=== SCORING V2 - ZE2 SUR4 OPTIMISE === {datetime.now().strftime('%d/%m/%Y %H:%M')} ===") print(
f"=== SCORING V2 - ZE2 SUR4 OPTIMISE === {datetime.now().strftime('%d/%m/%Y %H:%M')} ==="
)
try: try:
url = f"https://turfinfo.api.pmu.fr/rest/client/1/programme/{date_pmu}/reunions" url = f"https://turfinfo.api.pmu.fr/rest/client/1/programme/{date_pmu}/reunions"
r = requests.get(url, headers=HEADERS, timeout=15) r = requests.get(url, headers=HEADERS, timeout=15)
reunions = r.json().get('programme', {}).get('reunions', []) reunions = r.json().get("programme", {}).get("reunions", [])
except Exception as e: except Exception as e:
print(f"Erreur: {e}") print(f"Erreur: {e}")
return return
quinte = None quinte = None
for reunion in reunions: for reunion in reunions:
for course in reunion.get('courses', []): for course in reunion.get("courses", []):
paris_types = [p["typePari"] for p in course.get("paris", [])] paris_types = [p["typePari"] for p in course.get("paris", [])]
if any("QUINTE" in p for p in paris_types) or "PARIS-TURF" in course.get('libelle', ''): if any("QUINTE" in p for p in paris_types) or "PARIS-TURF" in course.get(
quinte = (reunion['numOfficiel'], course['numOrdre'], course.get('libelle', ''), "libelle", ""
reunion['hippodrome']['libelleCourt'], course.get('heureDepart', 0)) ):
quinte = (
reunion["numOfficiel"],
course["numOrdre"],
course.get("libelle", ""),
reunion["hippodrome"]["libelleCourt"],
course.get("heureDepart", 0),
)
break break
if quinte: if quinte:
break break
if not quinte: if not quinte:
# Fallback: utiliser la premiere reunion francaise avec predictions # Fallback: utiliser la premiere reunion francaise avec predictions
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
r = conn.execute(""" r = conn.execute(
"""
SELECT r.num_reunion, r.hippodrome_court, c.num_course, c.libelle SELECT r.num_reunion, r.hippodrome_court, c.num_course, c.libelle
FROM pmu_courses c FROM pmu_courses c
JOIN pmu_reunions r ON r.date_programme=c.date_programme AND r.num_reunion=c.num_reunion JOIN pmu_reunions r ON r.date_programme=c.date_programme AND r.num_reunion=c.num_reunion
@@ -264,57 +432,82 @@ def main():
AND EXISTS (SELECT 1 FROM predictions p WHERE p.date=? AND p.source='canalturf_partants' AND EXISTS (SELECT 1 FROM predictions p WHERE p.date=? AND p.source='canalturf_partants'
AND p.race_name LIKE '%' || c.libelle || '%') AND p.race_name LIKE '%' || c.libelle || '%')
ORDER BY c.heure_depart_str ASC LIMIT 1 ORDER BY c.heure_depart_str ASC LIMIT 1
""", (today, today)).fetchone() """,
(today, today),
).fetchone()
conn.close() conn.close()
if r: if r:
quinte = (r['num_reunion'], r['num_course'], r['libelle'], r['hippodrome_court'], 0) quinte = (
r["num_reunion"],
r["num_course"],
r["libelle"],
r["hippodrome_court"],
0,
)
else: else:
print("Aucune course trouvee") print("Aucune course trouvee")
return return
num_r, num_c, libelle, hippodrome, heure_ts = quinte num_r, num_c, libelle, hippodrome, heure_ts = quinte
heure = datetime.fromtimestamp(heure_ts/1000).strftime('%H:%M') if heure_ts else '13:55' heure = (
datetime.fromtimestamp(heure_ts / 1000).strftime("%H:%M")
if heure_ts
else "13:55"
)
print(f"Course: {libelle} - {hippodrome} {heure}") print(f"Course: {libelle} - {hippodrome} {heure}")
try: try:
url = f"https://turfinfo.api.pmu.fr/rest/client/1/programme/{date_pmu}/R{num_r}/C{num_c}/participants" url = f"https://turfinfo.api.pmu.fr/rest/client/1/programme/{date_pmu}/R{num_r}/C{num_c}/participants"
r = requests.get(url, headers=HEADERS, timeout=15) r = requests.get(url, headers=HEADERS, timeout=15)
participants = [p for p in r.json().get('participants', []) if p.get('statut') == 'PARTANT'] participants = [
p for p in r.json().get("participants", []) if p.get("statut") == "PARTANT"
]
except Exception as e: except Exception as e:
print(f"Erreur: {e}") print(f"Erreur: {e}")
return return
scored_horses = [] scored_horses = []
for p in participants: for p in participants:
score, details = score_cheval_v2(p, participants, today) score, details = score_cheval_v2(p, participants, today)
scored_horses.append({'nom': p['nom'], 'numero': p['numPmu'], 'score': score, 'details': details}) scored_horses.append(
{"nom": p["nom"], "numero": p["numPmu"], "score": score, "details": details}
ranked = sorted(scored_horses, key=lambda x: x['score'], reverse=True) )
ranked = sorted(scored_horses, key=lambda x: x["score"], reverse=True)
print(f"\n=== TOP 4 ===") print(f"\n=== TOP 4 ===")
for i, h in enumerate(ranked[:4], 1): for i, h in enumerate(ranked[:4], 1):
d = h['details'] d = h["details"]
print(f"{i}. #{h['numero']:>2} {h['nom']:<20} Score:{h['score']:.1f} Cote:{d['cote']:.1f}") print(
f"{i}. #{h['numero']:>2} {h['nom']:<20} Score:{h['score']:.1f} Cote:{d['cote']:.1f}"
)
save_to_db(ranked, today, hippodrome, libelle) save_to_db(ranked, today, hippodrome, libelle)
reco = build_recommendations_v2(scored_horses) reco = build_recommendations_v2(scored_horses)
if reco: if reco:
print(f"\n=== RECOMMANDATIONS ===") print(f"\n=== RECOMMANDATIONS ===")
sg = reco['simple_gagnant'] sg = reco["simple_gagnant"]
print(f"\n🎯 SIMPLE GAGNANT:") print(f"\n🎯 SIMPLE GAGNANT:")
print(f" #{sg['numero']} {sg['cheval']} @ {sg['cote']}/1 (mise {sg['mise_suggeree']}EUR)") print(
f" #{sg['numero']} {sg['cheval']} @ {sg['cote']}/1 (mise {sg['mise_suggeree']}EUR)"
ze2 = reco['ze2_sur_4'] )
ze2 = reco["ze2_sur_4"]
print(f"\n🎰 ZE 2 SUR 4 (TOP 4: {', '.join([h['nom'] for h in ze2['top4']])}") print(f"\n🎰 ZE 2 SUR 4 (TOP 4: {', '.join([h['nom'] for h in ze2['top4']])}")
print(f" Mise totale: {ze2['mise_totale']}EUR ({ze2['nb_combinaisons']} combis x 1EUR)") print(
f" Mise totale: {ze2['mise_totale']}EUR ({ze2['nb_combinaisons']} combis x 1EUR)"
)
print(f" Confiance: {ze2['confiance']}") print(f" Confiance: {ze2['confiance']}")
print(f" Combinaisons:") print(f" Combinaisons:")
for c in ze2['combinaisons']: for c in ze2["combinaisons"]:
print(f" {c['numero1']}-{c['cheval1']} + {c['numero2']}-{c['cheval2']}") print(
f" {c['numero1']}-{c['cheval1']} + {c['numero2']}-{c['cheval2']}"
)
print(f"\n💰 BUDGET TOTAL: {reco['budget_total']}EUR") print(f"\n💰 BUDGET TOTAL: {reco['budget_total']}EUR")
print(f" - Simple Gagnant: 2EUR") print(f" - Simple Gagnant: 2EUR")
print(f" - ZE 2 sur 4: {ze2['mise_totale']}EUR") print(f" - ZE 2 sur 4: {ze2['mise_totale']}EUR")
if __name__ == "__main__": if __name__ == "__main__":
main() main()

383
tests/test_user_tokens.py Normal file
View File

@@ -0,0 +1,383 @@
#!/usr/bin/env python3
"""
tests/test_user_tokens.py — Personal API Token + Webhook alertes
HRT-80: Tests unitaires et d'intégration
Couvre:
- POST /api/v1/user/api-token (create)
- DELETE /api/v1/user/api-token (revoke)
- POST /api/v1/user/webhook (create/upsert)
- DELETE /api/v1/user/webhook (delete)
- Authentification via X-API-Key
- dispatch_webhook() fire-and-forget
- Plan enforcement Pro uniquement
Run:
./venv/bin/pytest tests/test_user_tokens.py -v --tb=short
"""
import hashlib
import json
import os
import sqlite3
import sys
import tempfile
from unittest.mock import MagicMock, patch
import pytest
# ─── Test DB isolation ────────────────────────────────────────────────────────
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
_tmp_db.close()
os.environ["TURF_SAAS_DB"] = _tmp_db.name
os.environ["JWT_SECRET_KEY"] = "test-secret-hrt80"
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from app_v1 import create_app # noqa: E402
TEST_CONFIG = {
"TESTING": True,
"JWT_SECRET_KEY": "test-secret-hrt80",
}
@pytest.fixture(scope="module")
def app():
application = create_app()
application.config.update(TEST_CONFIG)
yield application
@pytest.fixture(scope="module")
def client(app):
return app.test_client()
# ─── Helpers ─────────────────────────────────────────────────────────────────
def _create_user(client, email, plan="pro"):
"""Register user (plan=free) then update plan in DB."""
resp = client.post(
"/api/v1/auth/register",
json={"email": email, "password": "Secure123"},
)
assert resp.status_code == 201, resp.get_json()
user_id = resp.get_json()["user_id"]
# Update plan directly in DB (no plan-update endpoint in JWT auth)
conn = sqlite3.connect(os.environ["TURF_SAAS_DB"])
conn.execute("UPDATE users SET plan = ? WHERE id = ?", (plan, user_id))
conn.commit()
conn.close()
# Login to get access token
login_resp = client.post(
"/api/v1/auth/login",
json={"email": email, "password": "Secure123"},
)
assert login_resp.status_code == 200, login_resp.get_json()
access_token = login_resp.get_json()["access_token"]
return access_token, user_id
def _auth_header(token):
return {"Authorization": f"Bearer {token}"}
# ─── Tests: API Token (Pro) ───────────────────────────────────────────────────
class TestApiToken:
def test_create_api_token_pro(self, client):
"""POST /api/v1/user/api-token — Pro user gets 201 + token starting with trf_"""
token, _ = _create_user(client, "pro_token@test.com", plan="pro")
resp = client.post("/api/v1/user/api-token", headers=_auth_header(token))
assert resp.status_code == 201, resp.get_json()
data = resp.get_json()
assert data["token"].startswith("trf_")
assert data["prefix"] == data["token"][:12]
assert "warning" in data
assert "created_at" in data
def test_create_api_token_stores_hash_not_raw(self, client):
"""Second POST returns 409 — only hashed token stored"""
token, _ = _create_user(client, "pro_token2@test.com", plan="pro")
# First create
r1 = client.post("/api/v1/user/api-token", headers=_auth_header(token))
assert r1.status_code == 201
raw_token = r1.get_json()["token"]
# Second create should conflict
r2 = client.post("/api/v1/user/api-token", headers=_auth_header(token))
assert r2.status_code == 409
data = r2.get_json()
assert "existing_prefix" in data
# Verify raw token is NOT stored in DB (only hash)
conn = sqlite3.connect(os.environ["TURF_SAAS_DB"])
row = conn.execute(
"SELECT token_hash FROM user_api_tokens WHERE token_prefix = ?",
(raw_token[:12],),
).fetchone()
conn.close()
assert row is not None
assert row[0] != raw_token # hash != raw
assert len(row[0]) == 64 # SHA256 hex
def test_create_api_token_free_user(self, client):
"""Free user gets 403"""
token, _ = _create_user(client, "free_token@test.com", plan="free")
resp = client.post("/api/v1/user/api-token", headers=_auth_header(token))
assert resp.status_code == 403
def test_create_api_token_premium_user(self, client):
"""Premium user gets 403 (Pro only feature)"""
token, _ = _create_user(client, "premium_token@test.com", plan="premium")
resp = client.post("/api/v1/user/api-token", headers=_auth_header(token))
assert resp.status_code == 403
def test_create_api_token_no_auth(self, client):
"""No auth → 401"""
resp = client.post("/api/v1/user/api-token")
assert resp.status_code == 401
def test_revoke_api_token(self, client):
"""DELETE /api/v1/user/api-token — Pro user revokes active token"""
token, _ = _create_user(client, "pro_revoke@test.com", plan="pro")
# Create first
client.post("/api/v1/user/api-token", headers=_auth_header(token))
# Revoke
resp = client.delete("/api/v1/user/api-token", headers=_auth_header(token))
assert resp.status_code == 200
data = resp.get_json()
assert data["revoked"] is True
assert data["count"] >= 1
def test_revoke_no_active_token(self, client):
"""DELETE with no active token → 404"""
token, _ = _create_user(client, "pro_notoken@test.com", plan="pro")
resp = client.delete("/api/v1/user/api-token", headers=_auth_header(token))
assert resp.status_code == 404
def test_revoke_non_pro(self, client):
"""DELETE for free user → 403"""
token, _ = _create_user(client, "free_revoke@test.com", plan="free")
resp = client.delete("/api/v1/user/api-token", headers=_auth_header(token))
assert resp.status_code == 403
# ─── Tests: X-API-Key Authentication ─────────────────────────────────────────
class TestApiKeyAuth:
def test_api_key_auth_on_protected_route(self, client):
"""Valid X-API-Key authenticates on protected route"""
token, _ = _create_user(client, "apikey_auth@test.com", plan="pro")
# Create API token
r = client.post("/api/v1/user/api-token", headers=_auth_header(token))
assert r.status_code == 201
raw_key = r.get_json()["token"]
# Use X-API-Key to access a protected route (try create again → 409 means authenticated)
resp = client.post("/api/v1/user/api-token", headers={"X-API-Key": raw_key})
# 409 means we were authenticated; 401 means auth failed
assert resp.status_code == 409
def test_api_key_invalid(self, client):
"""Invalid X-API-Key → 401"""
resp = client.post(
"/api/v1/user/api-token",
headers={"X-API-Key": "trf_invalidkeyXXXXXXXXXXXXXXXXXX"},
)
assert resp.status_code == 401
def test_api_key_revoked(self, client):
"""Revoked X-API-Key → 401"""
token, _ = _create_user(client, "revoked_apikey@test.com", plan="pro")
# Create token
r = client.post("/api/v1/user/api-token", headers=_auth_header(token))
assert r.status_code == 201
raw_key = r.get_json()["token"]
# Revoke it
client.delete("/api/v1/user/api-token", headers=_auth_header(token))
# Try using revoked key
resp = client.post("/api/v1/user/api-token", headers={"X-API-Key": raw_key})
assert resp.status_code == 401
def test_revoke_then_cannot_auth(self, client):
"""Full flow: create → use → revoke → X-API-Key rejected"""
token, _ = _create_user(client, "flow_test@test.com", plan="pro")
# Create
r = client.post("/api/v1/user/api-token", headers=_auth_header(token))
raw_key = r.get_json()["token"]
# Validate it works (409 because key exists)
r2 = client.post("/api/v1/user/api-token", headers={"X-API-Key": raw_key})
assert r2.status_code == 409
# Revoke
client.delete("/api/v1/user/api-token", headers=_auth_header(token))
# Try again with revoked key
r3 = client.post("/api/v1/user/api-token", headers={"X-API-Key": raw_key})
assert r3.status_code == 401
# ─── Tests: Webhook ───────────────────────────────────────────────────────────
class TestWebhook:
def test_create_webhook_pro(self, client):
"""POST /api/v1/user/webhook — Pro user with provided secret → 201"""
token, _ = _create_user(client, "webhook_pro@test.com", plan="pro")
resp = client.post(
"/api/v1/user/webhook",
headers=_auth_header(token),
json={"url": "https://example.com/hook", "secret": "mysecret123"},
)
assert resp.status_code == 201
data = resp.get_json()
assert data["webhook_url"] == "https://example.com/hook"
assert data["secret"] == "mysecret123"
def test_create_webhook_auto_secret(self, client):
"""POST without secret → auto-generated secret"""
token, _ = _create_user(client, "webhook_auto@test.com", plan="pro")
resp = client.post(
"/api/v1/user/webhook",
headers=_auth_header(token),
json={"url": "https://auto.example.com/hook"},
)
assert resp.status_code == 201
data = resp.get_json()
assert len(data["secret"]) == 64 # token_hex(32) = 64 hex chars
def test_create_webhook_non_pro_free(self, client):
"""Free user → 403"""
token, _ = _create_user(client, "webhook_free@test.com", plan="free")
resp = client.post(
"/api/v1/user/webhook",
headers=_auth_header(token),
json={"url": "https://example.com/hook"},
)
assert resp.status_code == 403
def test_create_webhook_non_pro_premium(self, client):
"""Premium user → 403"""
token, _ = _create_user(client, "webhook_premium@test.com", plan="premium")
resp = client.post(
"/api/v1/user/webhook",
headers=_auth_header(token),
json={"url": "https://example.com/hook"},
)
assert resp.status_code == 403
def test_create_webhook_url_not_https(self, client):
"""HTTP URL → 400"""
token, _ = _create_user(client, "webhook_http@test.com", plan="pro")
resp = client.post(
"/api/v1/user/webhook",
headers=_auth_header(token),
json={"url": "http://example.com/hook"},
)
assert resp.status_code == 400
assert "https" in resp.get_json()["error"].lower()
def test_create_webhook_missing_url(self, client):
"""Missing URL → 400"""
token, _ = _create_user(client, "webhook_nourl@test.com", plan="pro")
resp = client.post(
"/api/v1/user/webhook",
headers=_auth_header(token),
json={},
)
assert resp.status_code == 400
def test_webhook_upsert(self, client):
"""Second POST updates URL (upsert behavior)"""
token, _ = _create_user(client, "webhook_upsert@test.com", plan="pro")
client.post(
"/api/v1/user/webhook",
headers=_auth_header(token),
json={"url": "https://first.example.com/hook"},
)
resp = client.post(
"/api/v1/user/webhook",
headers=_auth_header(token),
json={"url": "https://second.example.com/hook"},
)
assert resp.status_code == 201
assert resp.get_json()["webhook_url"] == "https://second.example.com/hook"
def test_delete_webhook(self, client):
"""DELETE /api/v1/user/webhook → 200"""
token, _ = _create_user(client, "webhook_delete@test.com", plan="pro")
client.post(
"/api/v1/user/webhook",
headers=_auth_header(token),
json={"url": "https://delete.example.com/hook"},
)
resp = client.delete("/api/v1/user/webhook", headers=_auth_header(token))
assert resp.status_code == 200
assert resp.get_json()["deleted"] is True
def test_delete_webhook_not_configured(self, client):
"""DELETE without webhook configured → 404"""
token, _ = _create_user(client, "webhook_notset@test.com", plan="pro")
resp = client.delete("/api/v1/user/webhook", headers=_auth_header(token))
assert resp.status_code == 404
def test_delete_webhook_non_pro(self, client):
"""Free user DELETE → 403"""
token, _ = _create_user(client, "webhook_freedelete@test.com", plan="free")
resp = client.delete("/api/v1/user/webhook", headers=_auth_header(token))
assert resp.status_code == 403
# ─── Tests: dispatch_webhook ──────────────────────────────────────────────────
class TestDispatchWebhook:
def test_dispatch_no_webhook_configured(self):
"""dispatch_webhook silently returns when no webhook is configured"""
with patch("api_v1.utils_webhook.get_db") as mock_get_db:
mock_conn = MagicMock()
mock_conn.execute.return_value.fetchone.return_value = None
mock_get_db.return_value = mock_conn
from api_v1.utils_webhook import dispatch_webhook
# Should not raise, should return silently
dispatch_webhook("nonexistent_user", "new_prediction", {"data": "test"})
def test_dispatch_sends_hmac_header(self):
"""dispatch_webhook sends correct HMAC-SHA256 signature header"""
test_secret = "testsecret"
test_url = "https://hook.example.com/receive"
test_payload = {"race_id": "R123", "top1": "Cheval Blanc"}
with (
patch("api_v1.utils_webhook.get_db") as mock_get_db,
patch("api_v1.utils_webhook.requests.post") as mock_post,
):
mock_row = MagicMock()
mock_row.__getitem__ = lambda self, key: (
test_url if key == "url" else test_secret
)
mock_conn = MagicMock()
mock_conn.execute.return_value.fetchone.return_value = mock_row
mock_get_db.return_value = mock_conn
mock_response = MagicMock()
mock_response.status_code = 200
mock_post.return_value = mock_response
from api_v1.utils_webhook import dispatch_webhook, EVENT_NEW_PREDICTION
dispatch_webhook("user123", EVENT_NEW_PREDICTION, test_payload)
assert mock_post.called
call_kwargs = mock_post.call_args
headers_sent = call_kwargs.kwargs.get("headers") or call_kwargs[1].get(
"headers"
)
assert "X-Turf-Signature" in headers_sent
assert headers_sent["X-Turf-Signature"].startswith("sha256=")
assert headers_sent["X-Turf-Event"] == EVENT_NEW_PREDICTION