Compare commits
7 Commits
feature/HR
...
feature/HR
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0492f06bfd | ||
| 91134e2f3f | |||
|
|
663e0bb149 | ||
| 5c6b407f47 | |||
|
|
f300e44c74 | ||
|
|
bc5ee3fa1a | ||
|
|
ec024d8236 |
281
DOCUMENTATION.md
281
DOCUMENTATION.md
@@ -155,3 +155,284 @@ python app.py
|
||||
---
|
||||
|
||||
*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)*
|
||||
|
||||
339
POD/Intelligence/ML_Predictions_SaaS.md
Normal file
339
POD/Intelligence/ML_Predictions_SaaS.md
Normal 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 (0–100) |
|
||||
| `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 (0–100) |
|
||||
|
||||
**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
57
api_tokens_db.py
Normal 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.")
|
||||
@@ -4,6 +4,7 @@ API v1 Blueprint package — Turf SaaS
|
||||
Sprint 3-4: HRT-29 — Refacto API /v1/
|
||||
Sprint 5-6: HRT-31 — Billing Stripe
|
||||
HRT-79: Alertes Telegram configurables (user blueprint)
|
||||
HRT-80: API Token personnel + Webhook alertes (Pro)
|
||||
HRT-82: Multi-compte / Organisation Pro (max 5 users)
|
||||
|
||||
Registers sub-blueprints:
|
||||
@@ -16,6 +17,8 @@ Registers sub-blueprints:
|
||||
/api/v1/metrics — métriques perf ML (premium+)
|
||||
/api/v1/billing/ — Stripe checkout, portal, webhook, status
|
||||
/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/org/ — organisations Pro (multi-compte, max 5 users)
|
||||
/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.billing import billing_bp
|
||||
from .routes.user import user_bp
|
||||
from .routes.user_tokens import user_tokens_bp
|
||||
from .routes.history import history_bp
|
||||
from .routes.org import org_bp
|
||||
|
||||
@@ -50,5 +54,6 @@ def register_api_v1(app):
|
||||
app.register_blueprint(metrics_bp)
|
||||
app.register_blueprint(billing_bp)
|
||||
app.register_blueprint(user_bp)
|
||||
app.register_blueprint(user_tokens_bp)
|
||||
app.register_blueprint(history_bp)
|
||||
app.register_blueprint(org_bp)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
def _fetch_ml_predictions(conn, date: str, limit: int = None, offset: int = 0):
|
||||
"""Shared helper — returns rows from ml_predictions_cache."""
|
||||
def _fetch_ml_predictions(
|
||||
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"):
|
||||
return [], 0
|
||||
|
||||
@@ -33,13 +39,35 @@ def _fetch_ml_predictions(conn, date: str, limit: int = None, offset: int = 0):
|
||||
).fetchone()
|
||||
total = count_row["cnt"] if count_row else 0
|
||||
|
||||
sql = """SELECT
|
||||
race_label, hippodrome, discipline, distance, heure,
|
||||
horse_name, horse_number, odds, prob_top1, prob_top3,
|
||||
ml_score, recommendation, is_value_bet, risque_label, risque_score
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ?
|
||||
ORDER BY ml_score DESC"""
|
||||
if (
|
||||
include_weather
|
||||
and table_exists(conn, "pmu_meteo")
|
||||
and table_exists(conn, "pmu_courses")
|
||||
):
|
||||
sql = """SELECT
|
||||
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]
|
||||
|
||||
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]
|
||||
|
||||
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()
|
||||
try:
|
||||
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)
|
||||
|
||||
|
||||
195
api_v1/routes/user_tokens.py
Normal file
195
api_v1/routes/user_tokens.py
Normal 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
|
||||
@@ -53,7 +53,7 @@ def valuebets():
|
||||
default: 0
|
||||
responses:
|
||||
200:
|
||||
description: Value bets du jour
|
||||
description: Value bets du jour avec météo et terrain (HRT-83)
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
@@ -69,7 +69,7 @@ def valuebets():
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
rows = []
|
||||
rows_raw = []
|
||||
total = 0
|
||||
|
||||
if table_exists(conn, "ml_predictions_cache"):
|
||||
@@ -81,18 +81,73 @@ def valuebets():
|
||||
).fetchone()
|
||||
total = count_row["cnt"] if count_row else 0
|
||||
|
||||
rows = conn.execute(
|
||||
"""SELECT race_label, hippodrome, discipline, distance, heure,
|
||||
horse_name, horse_number, odds, prob_top1, prob_top3,
|
||||
ml_score, recommendation, risque_label, risque_score
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ? AND is_value_bet = 1 AND odds >= ?
|
||||
ORDER BY ml_score DESC
|
||||
LIMIT ? OFFSET ?""",
|
||||
(date_param, min_odds, limit, offset),
|
||||
).fetchall()
|
||||
# LEFT JOIN pmu_courses (terrain) + pmu_meteo (météo) — HRT-83
|
||||
has_courses = table_exists(conn, "pmu_courses")
|
||||
has_meteo = table_exists(conn, "pmu_meteo")
|
||||
|
||||
if has_courses and has_meteo:
|
||||
rows_raw = conn.execute(
|
||||
"""SELECT 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.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 = ? 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)
|
||||
|
||||
return jsonify(
|
||||
|
||||
80
api_v1/utils_webhook.py
Normal file
80
api_v1/utils_webhook.py
Normal 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
52
auth.py
@@ -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):
|
||||
"""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)
|
||||
def wrapper(*args, **kwargs):
|
||||
# 1. Try Bearer JWT (existing flow — unchanged)
|
||||
try:
|
||||
verify_jwt_in_request()
|
||||
user_id = int(get_jwt_identity())
|
||||
@@ -271,10 +307,20 @@ def jwt_required_middleware(fn):
|
||||
return jsonify({"error": "Utilisateur introuvable"}), 401
|
||||
g.current_user = dict(user)
|
||||
g.current_user_id = user_id
|
||||
return fn(*args, **kwargs)
|
||||
except (JWTExtendedException, PyJWTError) as 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
|
||||
|
||||
|
||||
43
saas_auth.py
43
saas_auth.py
@@ -8,6 +8,7 @@ Sprint 4-5 — HRT-30
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
import sqlite3
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
import os
|
||||
import time
|
||||
@@ -229,14 +230,54 @@ def hash_password(password: str) -> str:
|
||||
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):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
# 1. Try Bearer session token (existing flow — unchanged)
|
||||
auth = request.headers.get("Authorization", "")
|
||||
token = (
|
||||
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:
|
||||
return jsonify({"error": "Non authentifié"}), 401
|
||||
request.current_user = user
|
||||
|
||||
479
scoring_v2.py
479
scoring_v2.py
@@ -11,29 +11,34 @@ import re
|
||||
from datetime import datetime
|
||||
|
||||
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):
|
||||
"""Recupere la cote depuis la table predictions (plus recente et non nulle)"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
c = conn.execute("""
|
||||
c = conn.execute(
|
||||
"""
|
||||
SELECT odds FROM predictions
|
||||
WHERE date=? AND horse_name LIKE ? AND odds > 0
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
""", (date_course, f"%{horse_name}%"))
|
||||
""",
|
||||
(date_course, f"%{horse_name}%"),
|
||||
)
|
||||
r = c.fetchone()
|
||||
conn.close()
|
||||
return r['odds'] if r else 0
|
||||
return r["odds"] if r else 0
|
||||
|
||||
|
||||
def parse_musique(musique):
|
||||
if not musique:
|
||||
return {}
|
||||
clean = re.sub(r'\(\d+\)', '', musique)
|
||||
resultats = re.findall(r'(\d+|D|0)([amphsc]?)', clean)
|
||||
clean = re.sub(r"\(\d+\)", "", musique)
|
||||
resultats = re.findall(r"(\d+|D|0)([amphsc]?)", clean)
|
||||
positions = []
|
||||
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:
|
||||
return {}
|
||||
nb_courses = len(positions)
|
||||
@@ -41,222 +46,385 @@ def parse_musique(musique):
|
||||
nb_places = sum(1 for p in positions if 1 <= p <= 3)
|
||||
recentes = [p for p in positions[:3] if p != 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 {
|
||||
'forme_recente': round(forme_recente, 1),
|
||||
'tendance': round(tendance, 1),
|
||||
'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,
|
||||
"forme_recente": round(forme_recente, 1),
|
||||
"tendance": round(tendance, 1),
|
||||
"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,
|
||||
}
|
||||
|
||||
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
|
||||
details = {}
|
||||
|
||||
|
||||
# 1. COTE - Essaye PMU API, sinon DB
|
||||
horse_name = p.get('nom', '')
|
||||
horse_name = p.get("nom", "")
|
||||
cote = 0
|
||||
|
||||
|
||||
# Essayer d'abord depuis l'API PMU
|
||||
rapport = p.get('dernierRapportDirect', {})
|
||||
rapport = p.get("dernierRapportDirect", {})
|
||||
if rapport:
|
||||
cote = rapport.get('rapport', 0)
|
||||
cote = rapport.get("rapport", 0)
|
||||
if not cote:
|
||||
rapport_ref = p.get('dernierRapportReference', {})
|
||||
cote = rapport_ref.get('rapport', 0) if rapport_ref else 0
|
||||
|
||||
rapport_ref = p.get("dernierRapportReference", {})
|
||||
cote = rapport_ref.get("rapport", 0) if rapport_ref else 0
|
||||
|
||||
# Fallback: aller chercher dans la DB
|
||||
if not cote or cote == 0:
|
||||
cote = get_cote_from_db(horse_name, today)
|
||||
|
||||
|
||||
# Si toujours pas de cote, utiliser 99 comme valeur par defaut
|
||||
if not cote or cote == 0:
|
||||
cote = 99.0
|
||||
|
||||
|
||||
score_cote = max(2, min(10, 20 / (1 + cote * 0.15))) if cote > 0 else 2
|
||||
score += score_cote
|
||||
details['cote'] = round(cote, 1)
|
||||
details['score_cote'] = round(score_cote, 1)
|
||||
|
||||
details["cote"] = round(cote, 1)
|
||||
details["score_cote"] = round(score_cote, 1)
|
||||
|
||||
# 2. FORME - AUGMENTE a 30 pts
|
||||
musique_stats = parse_musique(p.get('musique', ''))
|
||||
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
|
||||
musique_stats = parse_musique(p.get("musique", ""))
|
||||
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 += score_forme
|
||||
details['forme_recente'] = forme
|
||||
details['score_forme'] = score_forme
|
||||
|
||||
details["forme_recente"] = forme
|
||||
details["score_forme"] = score_forme
|
||||
|
||||
# 3. TAUX VICTOIRE (15 pts)
|
||||
nb_courses_total = p.get('nombreCourses', 0)
|
||||
nb_victoires_total = p.get('nombreVictoires', 0)
|
||||
nb_courses_total = p.get("nombreCourses", 0)
|
||||
nb_victoires_total = p.get("nombreVictoires", 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 += score_vic
|
||||
details['tx_victoire'] = round(tx_vic, 1)
|
||||
details['score_victoire'] = round(score_vic, 1)
|
||||
|
||||
details["tx_victoire"] = round(tx_vic, 1)
|
||||
details["score_victoire"] = round(score_vic, 1)
|
||||
|
||||
# 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
|
||||
score_place = min(15, tx_place * 0.2)
|
||||
score += score_place
|
||||
details['tx_place'] = round(tx_place, 1)
|
||||
details['score_place'] = round(score_place, 1)
|
||||
|
||||
details["tx_place"] = round(tx_place, 1)
|
||||
details["score_place"] = round(score_place, 1)
|
||||
|
||||
# 5. REDUCTION KM (10 pts)
|
||||
rk = p.get('reductionKilometrique', 0)
|
||||
all_rk = [x.get('reductionKilometrique', 0) for x in all_participants if x.get('reductionKilometrique', 0) > 0]
|
||||
rk = p.get("reductionKilometrique", 0)
|
||||
all_rk = [
|
||||
x.get("reductionKilometrique", 0)
|
||||
for x in all_participants
|
||||
if x.get("reductionKilometrique", 0) > 0
|
||||
]
|
||||
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:
|
||||
score_rk = 0
|
||||
score += score_rk
|
||||
details['rk'] = rk
|
||||
details['score_rk'] = round(score_rk, 1)
|
||||
|
||||
details["rk"] = rk
|
||||
details["score_rk"] = round(score_rk, 1)
|
||||
|
||||
# 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 += score_tendance
|
||||
details['tendance'] = tendance
|
||||
details['score_tendance'] = round(score_tendance, 1)
|
||||
|
||||
details["tendance"] = tendance
|
||||
details["score_tendance"] = round(score_tendance, 1)
|
||||
|
||||
# 7. AVIS ENTRAINEUR (5 pts)
|
||||
avis = p.get('avisEntraineur', 'NEUTRE')
|
||||
score_avis = {'POSITIF': 5, 'TRES_POSITIF': 5, 'NEUTRE': 2, 'NEGATIF': 0, 'TRES_NEGATIF': 0}.get(avis, 2)
|
||||
avis = p.get("avisEntraineur", "NEUTRE")
|
||||
score_avis = {
|
||||
"POSITIF": 5,
|
||||
"TRES_POSITIF": 5,
|
||||
"NEUTRE": 2,
|
||||
"NEGATIF": 0,
|
||||
"TRES_NEGATIF": 0,
|
||||
}.get(avis, 2)
|
||||
score += score_avis
|
||||
details['avis_entraineur'] = avis
|
||||
details['score_avis'] = score_avis
|
||||
|
||||
details["avis_entraineur"] = avis
|
||||
details["score_avis"] = score_avis
|
||||
|
||||
# 8. BONUS OUTSIDER (5 pts)
|
||||
bonus_outsider = 5 if forme <= 3 and cote >= 10 else 0
|
||||
score += bonus_outsider
|
||||
details['bonus_outsider'] = bonus_outsider
|
||||
|
||||
details["bonus_outsider"] = bonus_outsider
|
||||
|
||||
# Driver change penalty
|
||||
if p.get('driverChange', False):
|
||||
if p.get("driverChange", False):
|
||||
score -= 3
|
||||
details['driver_change'] = True
|
||||
|
||||
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
|
||||
|
||||
details["driver_change"] = True
|
||||
|
||||
# 9. METEO & TERRAIN (HRT-83) — premium feature, weather_data=None → skip
|
||||
penetrometre = p.get("penetrometre_intitule", "") or ""
|
||||
terrain_condition = (
|
||||
get_terrain_condition(penetrometre) if penetrometre else "inconnu"
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
def get_ze2sur4_combinaisons(top4):
|
||||
combinaisons = []
|
||||
for i in range(4):
|
||||
for j in range(i+1, 4):
|
||||
for j in range(i + 1, 4):
|
||||
c1 = top4[i]
|
||||
c2 = top4[j]
|
||||
combinaisons.append({
|
||||
'cheval1': c1['nom'],
|
||||
'numero1': c1['numero'],
|
||||
'cheval2': c2['nom'],
|
||||
'numero2': c2['numero'],
|
||||
'mise': 1.0,
|
||||
})
|
||||
combinaisons.append(
|
||||
{
|
||||
"cheval1": c1["nom"],
|
||||
"numero1": c1["numero"],
|
||||
"cheval2": c2["nom"],
|
||||
"numero2": c2["numero"],
|
||||
"mise": 1.0,
|
||||
}
|
||||
)
|
||||
return combinaisons
|
||||
|
||||
|
||||
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:
|
||||
return None
|
||||
|
||||
|
||||
top1, top2, top3, top4 = ranked[0], ranked[1], ranked[2], ranked[3]
|
||||
top4_list = ranked[:4]
|
||||
|
||||
|
||||
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)
|
||||
mise_ze2 = len(ze2_combinaisons) * 1.0
|
||||
|
||||
|
||||
return {
|
||||
'simple_gagnant': {
|
||||
'cheval': top1['nom'], 'numero': top1['numero'], 'cote': top1['details']['cote'],
|
||||
'score': top1['score'], 'confiance': confiance(top1['score']),
|
||||
'mise_suggeree': 2.0, 'gain_potentiel': round(2.0 * top1['details']['cote'], 2)
|
||||
"simple_gagnant": {
|
||||
"cheval": top1["nom"],
|
||||
"numero": top1["numero"],
|
||||
"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': {
|
||||
'top4': [{'nom': h['nom'], 'numero': h['numero']} for h in top4_list],
|
||||
'combinaisons': ze2_combinaisons,
|
||||
'mise_totale': mise_ze2,
|
||||
'nb_combinaisons': len(ze2_combinaisons),
|
||||
'confiance': confiance((top1['score'] + top2['score'] + top3['score'] + top4['score']) / 4),
|
||||
'explication': 'Jouer les 6 combinaisons de 2 chevaux parmi les 4 premiers'
|
||||
"ze2_sur_4": {
|
||||
"top4": [{"nom": h["nom"], "numero": h["numero"]} for h in top4_list],
|
||||
"combinaisons": ze2_combinaisons,
|
||||
"mise_totale": mise_ze2,
|
||||
"nb_combinaisons": len(ze2_combinaisons),
|
||||
"confiance": confiance(
|
||||
(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),
|
||||
'budget_total': 2.0 + mise_ze2,
|
||||
"outsider": _find_outsider(ranked),
|
||||
"budget_total": 2.0 + mise_ze2,
|
||||
}
|
||||
|
||||
|
||||
def _find_outsider(ranked):
|
||||
for h in ranked[3:7]:
|
||||
d = h['details']
|
||||
if d['cote'] >= 12 and d['forme_recente'] <= 4 and d['bonus_outsider'] == 5:
|
||||
d = h["details"]
|
||||
if d["cote"] >= 12 and d["forme_recente"] <= 4 and d["bonus_outsider"] == 5:
|
||||
return {
|
||||
'cheval': h['nom'], 'numero': h['numero'], 'cote': d['cote'],
|
||||
'mise_suggeree': 1.0, 'gain_potentiel': round(1.0 * d['cote'], 2)
|
||||
"cheval": h["nom"],
|
||||
"numero": h["numero"],
|
||||
"cote": d["cote"],
|
||||
"mise_suggeree": 1.0,
|
||||
"gain_potentiel": round(1.0 * d["cote"], 2),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def save_to_db(scored_horses, date_course, hippodrome, libelle):
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
|
||||
cursor.execute("DELETE FROM scoring WHERE date = ?", (date_course,))
|
||||
|
||||
|
||||
for i, h in enumerate(scored_horses, 1):
|
||||
d = h['details']
|
||||
cursor.execute("""
|
||||
d = h["details"]
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO scoring (date, race_name, horse_number, horse_name, score,
|
||||
score_cote, score_forme, score_victoire, score_place, score_rk,
|
||||
score_tendance, score_avis, cote, forme_recente, tx_victoire, tx_place,
|
||||
avis_entraineur, musique, rang_scoring, scoring_version)
|
||||
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),
|
||||
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))
|
||||
|
||||
""",
|
||||
(
|
||||
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),
|
||||
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.close()
|
||||
print(f"💾 {len(scored_horses)} scores enregistres en BDD pour {date_course}")
|
||||
|
||||
|
||||
def main():
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
date_pmu = datetime.now().strftime('%d%m%Y')
|
||||
print(f"=== SCORING V2 - ZE2 SUR4 OPTIMISE === {datetime.now().strftime('%d/%m/%Y %H:%M')} ===")
|
||||
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
date_pmu = datetime.now().strftime("%d%m%Y")
|
||||
print(
|
||||
f"=== SCORING V2 - ZE2 SUR4 OPTIMISE === {datetime.now().strftime('%d/%m/%Y %H:%M')} ==="
|
||||
)
|
||||
|
||||
try:
|
||||
url = f"https://turfinfo.api.pmu.fr/rest/client/1/programme/{date_pmu}/reunions"
|
||||
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:
|
||||
print(f"Erreur: {e}")
|
||||
return
|
||||
|
||||
|
||||
quinte = None
|
||||
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", [])]
|
||||
if any("QUINTE" in p for p in paris_types) or "PARIS-TURF" in course.get('libelle', ''):
|
||||
quinte = (reunion['numOfficiel'], course['numOrdre'], course.get('libelle', ''),
|
||||
reunion['hippodrome']['libelleCourt'], course.get('heureDepart', 0))
|
||||
if any("QUINTE" in p for p in paris_types) or "PARIS-TURF" in course.get(
|
||||
"libelle", ""
|
||||
):
|
||||
quinte = (
|
||||
reunion["numOfficiel"],
|
||||
course["numOrdre"],
|
||||
course.get("libelle", ""),
|
||||
reunion["hippodrome"]["libelleCourt"],
|
||||
course.get("heureDepart", 0),
|
||||
)
|
||||
break
|
||||
if quinte:
|
||||
break
|
||||
|
||||
|
||||
if not quinte:
|
||||
# Fallback: utiliser la premiere reunion francaise avec predictions
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
r = conn.execute("""
|
||||
r = conn.execute(
|
||||
"""
|
||||
SELECT r.num_reunion, r.hippodrome_court, c.num_course, c.libelle
|
||||
FROM pmu_courses c
|
||||
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 p.race_name LIKE '%' || c.libelle || '%')
|
||||
ORDER BY c.heure_depart_str ASC LIMIT 1
|
||||
""", (today, today)).fetchone()
|
||||
""",
|
||||
(today, today),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
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:
|
||||
print("Aucune course trouvee")
|
||||
return
|
||||
|
||||
|
||||
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}")
|
||||
|
||||
|
||||
try:
|
||||
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)
|
||||
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:
|
||||
print(f"Erreur: {e}")
|
||||
return
|
||||
|
||||
|
||||
scored_horses = []
|
||||
for p in participants:
|
||||
score, details = score_cheval_v2(p, participants, today)
|
||||
scored_horses.append({'nom': p['nom'], 'numero': p['numPmu'], 'score': score, 'details': details})
|
||||
|
||||
ranked = sorted(scored_horses, key=lambda x: x['score'], reverse=True)
|
||||
scored_horses.append(
|
||||
{"nom": p["nom"], "numero": p["numPmu"], "score": score, "details": details}
|
||||
)
|
||||
|
||||
ranked = sorted(scored_horses, key=lambda x: x["score"], reverse=True)
|
||||
print(f"\n=== TOP 4 ===")
|
||||
for i, h in enumerate(ranked[:4], 1):
|
||||
d = h['details']
|
||||
print(f"{i}. #{h['numero']:>2} {h['nom']:<20} Score:{h['score']:.1f} Cote:{d['cote']:.1f}")
|
||||
|
||||
d = h["details"]
|
||||
print(
|
||||
f"{i}. #{h['numero']:>2} {h['nom']:<20} Score:{h['score']:.1f} Cote:{d['cote']:.1f}"
|
||||
)
|
||||
|
||||
save_to_db(ranked, today, hippodrome, libelle)
|
||||
|
||||
|
||||
reco = build_recommendations_v2(scored_horses)
|
||||
if reco:
|
||||
print(f"\n=== RECOMMANDATIONS ===")
|
||||
sg = reco['simple_gagnant']
|
||||
sg = reco["simple_gagnant"]
|
||||
print(f"\n🎯 SIMPLE GAGNANT:")
|
||||
print(f" #{sg['numero']} {sg['cheval']} @ {sg['cote']}/1 (mise {sg['mise_suggeree']}EUR)")
|
||||
|
||||
ze2 = reco['ze2_sur_4']
|
||||
print(
|
||||
f" #{sg['numero']} {sg['cheval']} @ {sg['cote']}/1 (mise {sg['mise_suggeree']}EUR)"
|
||||
)
|
||||
|
||||
ze2 = reco["ze2_sur_4"]
|
||||
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" Combinaisons:")
|
||||
for c in ze2['combinaisons']:
|
||||
print(f" {c['numero1']}-{c['cheval1']} + {c['numero2']}-{c['cheval2']}")
|
||||
|
||||
for c in ze2["combinaisons"]:
|
||||
print(
|
||||
f" {c['numero1']}-{c['cheval1']} + {c['numero2']}-{c['cheval2']}"
|
||||
)
|
||||
|
||||
print(f"\n💰 BUDGET TOTAL: {reco['budget_total']}EUR")
|
||||
print(f" - Simple Gagnant: 2EUR")
|
||||
print(f" - ZE 2 sur 4: {ze2['mise_totale']}EUR")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
383
tests/test_user_tokens.py
Normal file
383
tests/test_user_tokens.py
Normal 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
|
||||
Reference in New Issue
Block a user