Compare commits
23 Commits
feature/HR
...
feature/HR
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
837cddb406 | ||
|
|
8ab42343aa | ||
|
|
cd4cbcfb48 | ||
|
|
c072f92794 | ||
|
|
fac498efec | ||
|
|
1ccf9f5cb8 | ||
|
|
a126941f7f | ||
|
|
3079c2c6c6 | ||
|
|
52c0c95f22 | ||
|
|
0492f06bfd | ||
| 91134e2f3f | |||
|
|
663e0bb149 | ||
| 5c6b407f47 | |||
|
|
f300e44c74 | ||
|
|
946bdc65b6 | ||
|
|
bc5ee3fa1a | ||
|
|
701660ce83 | ||
| b7ed82418f | |||
|
|
8604dc78b1 | ||
|
|
30464fb40c | ||
|
|
31db3a8260 | ||
|
|
278245cd7c | ||
|
|
ec024d8236 |
281
DOCUMENTATION.md
281
DOCUMENTATION.md
@@ -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)*
|
||||||
|
|||||||
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 |
|
||||||
59
api_tokens_db.py
Normal file
59
api_tokens_db.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
#!/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:
|
||||||
|
"""Return a SQLite connection (reads TURF_SAAS_DB dynamically for test isolation)."""
|
||||||
|
db_path = os.environ.get("TURF_SAAS_DB", DB_PATH)
|
||||||
|
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.")
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
API v1 Blueprint package — Turf SaaS
|
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-80: API Token personnel + Webhook alertes (Pro)
|
||||||
|
HRT-82: Multi-compte / Organisation Pro (max 5 users)
|
||||||
|
|
||||||
Registers sub-blueprints:
|
Registers sub-blueprints:
|
||||||
/api/v1/health — public health-check
|
/api/v1/health — public health-check
|
||||||
@@ -13,7 +16,14 @@ Registers sub-blueprints:
|
|||||||
/api/v1/export/ — export CSV (pro)
|
/api/v1/export/ — export CSV (pro)
|
||||||
/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/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)
|
/api/v1/docs — Swagger UI (via flasgger, registered on app)
|
||||||
|
/api/v1/ml/feedback/run — trigger feedback loop ML (admin)
|
||||||
|
/api/v1/ml/feedback/stats — stats par stratégie (premium+)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Blueprint
|
from flask import Blueprint
|
||||||
@@ -26,6 +36,12 @@ from .routes.backtest import backtest_bp
|
|||||||
from .routes.export import export_bp
|
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_tokens import user_tokens_bp
|
||||||
|
from .routes.history import history_bp
|
||||||
|
from .routes.org import org_bp
|
||||||
|
from .routes.ml_feedback import ml_feedback_bp
|
||||||
|
from .routes.admin import admin_bp
|
||||||
|
|
||||||
# Master blueprint that aggregates all sub-routes under /api/v1
|
# Master blueprint that aggregates all sub-routes under /api/v1
|
||||||
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
|
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
|
||||||
@@ -41,3 +57,9 @@ def register_api_v1(app):
|
|||||||
app.register_blueprint(export_bp)
|
app.register_blueprint(export_bp)
|
||||||
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_tokens_bp)
|
||||||
|
app.register_blueprint(history_bp)
|
||||||
|
app.register_blueprint(org_bp)
|
||||||
|
app.register_blueprint(ml_feedback_bp)
|
||||||
|
app.register_blueprint(admin_bp)
|
||||||
|
|||||||
587
api_v1/routes/admin.py
Normal file
587
api_v1/routes/admin.py
Normal file
@@ -0,0 +1,587 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Admin Blueprint — Client CRUD + Subscription management
|
||||||
|
HRT-199 — Foundation (Client CRUD + Auth + Subscription)
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
POST /api/v1/admin/setup — init first admin (no auth, 1 call only)
|
||||||
|
GET /api/v1/admin/clients — list all clients (paginated, filterable)
|
||||||
|
GET /api/v1/admin/clients/<id> — client detail + subscription
|
||||||
|
PUT /api/v1/admin/clients/<id> — update client (plan, name, email)
|
||||||
|
DELETE /api/v1/admin/clients/<id> — delete client + tokens + subscription
|
||||||
|
POST /api/v1/admin/clients/<id>/suspend — suspend client (set plan=suspended)
|
||||||
|
POST /api/v1/admin/clients/<id>/activate — reactivate client (restore plan)
|
||||||
|
GET /api/v1/admin/stats — client stats (total, by plan, new/30d)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
from saas_auth import require_auth
|
||||||
|
from api_v1.utils import get_db, paginate_query, get_pagination_params, not_found, bad_request, internal_error
|
||||||
|
|
||||||
|
logger = logging.getLogger("turf_saas.admin")
|
||||||
|
|
||||||
|
admin_bp = Blueprint("admin", __name__, url_prefix="/api/v1/admin")
|
||||||
|
|
||||||
|
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_saas_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_admin_tables():
|
||||||
|
"""Idempotent: create admin_users table."""
|
||||||
|
conn = _get_saas_db()
|
||||||
|
conn.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_users (
|
||||||
|
user_id TEXT PRIMARY KEY REFERENCES saas_users(id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_by TEXT
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
migrate_admin_tables()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("admin DB init warning: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_admin(user_id: str, db=None) -> bool:
|
||||||
|
if not user_id:
|
||||||
|
return False
|
||||||
|
close = False
|
||||||
|
if db is None:
|
||||||
|
db = _get_saas_db()
|
||||||
|
close = True
|
||||||
|
try:
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT 1 FROM admin_users WHERE user_id = ?", (user_id,)
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
finally:
|
||||||
|
if close:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
user = getattr(request, "current_user", None)
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "Non authentifié"}), 401
|
||||||
|
if not _is_admin(user["id"]):
|
||||||
|
return jsonify({"error": "Accès administrateur requis"}), 403
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
def _user_to_client(row) -> dict:
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"email": row["email"],
|
||||||
|
"firstname": row.get("firstname", ""),
|
||||||
|
"lastname": row.get("lastname", ""),
|
||||||
|
"plan": row.get("plan", "free"),
|
||||||
|
"telegram_chat_id": row.get("telegram_chat_id"),
|
||||||
|
"alert_value_bets": bool(row.get("alert_value_bets", 1)),
|
||||||
|
"alert_top1": bool(row.get("alert_top1", 1)),
|
||||||
|
"alert_quinte_only": bool(row.get("alert_quinte_only", 0)),
|
||||||
|
"created_at": row.get("created_at"),
|
||||||
|
"updated_at": row.get("updated_at"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_subscription(db, user_id: str):
|
||||||
|
return db.execute(
|
||||||
|
"""SELECT * FROM saas_subscriptions
|
||||||
|
WHERE user_id = ? ORDER BY start_date DESC LIMIT 1""",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── POST /api/v1/admin/setup ─────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/setup", methods=["POST"])
|
||||||
|
def admin_setup():
|
||||||
|
"""Init first admin (no auth). Only works once — when admin_users is empty."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
email = (data.get("email") or "").strip().lower()
|
||||||
|
if not email or "@" not in email:
|
||||||
|
return jsonify({"error": "Email valide requis"}), 400
|
||||||
|
|
||||||
|
db = _get_saas_db()
|
||||||
|
try:
|
||||||
|
existing = db.execute("SELECT 1 FROM admin_users LIMIT 1").fetchone()
|
||||||
|
if existing:
|
||||||
|
return jsonify({"error": "Admin déjà configuré"}), 409
|
||||||
|
|
||||||
|
user = db.execute(
|
||||||
|
"SELECT id, email FROM saas_users WHERE email = ?", (email,)
|
||||||
|
).fetchone()
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "Utilisateur introuvable avec cet email"}), 404
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO admin_users (user_id, created_by) VALUES (?, 'setup')",
|
||||||
|
(user["id"],),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
logger.info("Admin setup: user %s (%s) promoted to admin", user["id"], email)
|
||||||
|
return jsonify({"ok": True, "user_id": user["id"], "email": email}), 201
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error("admin_setup error: %s", e)
|
||||||
|
return jsonify({"error": "Erreur interne"}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── GET /api/v1/admin/clients ─────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/clients", methods=["GET"])
|
||||||
|
@require_auth
|
||||||
|
@require_admin
|
||||||
|
def list_clients():
|
||||||
|
"""List all clients with pagination and filters.
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Admin
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: page
|
||||||
|
type: integer
|
||||||
|
- in: query
|
||||||
|
name: per_page
|
||||||
|
type: integer
|
||||||
|
- in: query
|
||||||
|
name: search
|
||||||
|
type: string
|
||||||
|
description: Search by email or name
|
||||||
|
- in: query
|
||||||
|
name: plan
|
||||||
|
type: string
|
||||||
|
description: Filter by plan (free, premium, pro, suspended)
|
||||||
|
- in: query
|
||||||
|
name: sort_by
|
||||||
|
type: string
|
||||||
|
enum: [created_at, email, plan, updated_at]
|
||||||
|
- in: query
|
||||||
|
name: sort_order
|
||||||
|
type: string
|
||||||
|
enum: [asc, desc]
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Paginated client list
|
||||||
|
403:
|
||||||
|
description: Admin access required
|
||||||
|
"""
|
||||||
|
page = request.args.get("page", 1, type=int)
|
||||||
|
per_page = request.args.get("per_page", 20, type=int)
|
||||||
|
search = request.args.get("search", "").strip()
|
||||||
|
plan_filter = request.args.get("plan", "").strip()
|
||||||
|
sort_by = request.args.get("sort_by", "created_at").strip()
|
||||||
|
sort_order = request.args.get("sort_order", "desc").strip()
|
||||||
|
|
||||||
|
if sort_by not in ("created_at", "email", "plan", "updated_at"):
|
||||||
|
sort_by = "created_at"
|
||||||
|
if sort_order not in ("asc", "desc"):
|
||||||
|
sort_order = "desc"
|
||||||
|
if per_page < 1 or per_page > 100:
|
||||||
|
per_page = 20
|
||||||
|
if page < 1:
|
||||||
|
page = 1
|
||||||
|
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
|
db = _get_saas_db()
|
||||||
|
try:
|
||||||
|
conditions = []
|
||||||
|
params = []
|
||||||
|
if search:
|
||||||
|
conditions.append("(email LIKE ? OR firstname LIKE ? OR lastname LIKE ?)")
|
||||||
|
p = f"%{search}%"
|
||||||
|
params.extend([p, p, p])
|
||||||
|
if plan_filter:
|
||||||
|
conditions.append("plan = ?")
|
||||||
|
params.append(plan_filter)
|
||||||
|
|
||||||
|
where = (" WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||||
|
|
||||||
|
total = db.execute(
|
||||||
|
f"SELECT COUNT(*) FROM saas_users{where}", params
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
rows = db.execute(
|
||||||
|
f"SELECT * FROM saas_users{where} ORDER BY {sort_by} {sort_order} LIMIT ? OFFSET ?",
|
||||||
|
params + [per_page, offset],
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for row in rows:
|
||||||
|
client = _user_to_client(row)
|
||||||
|
sub = _fetch_subscription(db, row["id"])
|
||||||
|
if sub:
|
||||||
|
client["subscription"] = {
|
||||||
|
"plan": sub["plan"],
|
||||||
|
"status": sub["status"],
|
||||||
|
"start_date": sub["start_date"],
|
||||||
|
"current_period_end": sub["current_period_end"],
|
||||||
|
"stripe_customer_id": sub["stripe_customer_id"],
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
client["subscription"] = None
|
||||||
|
result.append(client)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"clients": result,
|
||||||
|
"pagination": {
|
||||||
|
"page": page,
|
||||||
|
"per_page": per_page,
|
||||||
|
"total": total,
|
||||||
|
"total_pages": (total + per_page - 1) // per_page,
|
||||||
|
},
|
||||||
|
}), 200
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("list_clients error: %s", e)
|
||||||
|
return jsonify({"error": "Erreur interne"}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── GET /api/v1/admin/clients/<id> ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/clients/<string:client_id>", methods=["GET"])
|
||||||
|
@require_auth
|
||||||
|
@require_admin
|
||||||
|
def get_client(client_id: str):
|
||||||
|
"""Get client details with subscription info.
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Admin
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Client details
|
||||||
|
404:
|
||||||
|
description: Client not found
|
||||||
|
"""
|
||||||
|
db = _get_saas_db()
|
||||||
|
try:
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT * FROM saas_users WHERE id = ?", (client_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return jsonify({"error": "Client introuvable"}), 404
|
||||||
|
|
||||||
|
client = _user_to_client(row)
|
||||||
|
sub = _fetch_subscription(db, client_id)
|
||||||
|
if sub:
|
||||||
|
client["subscription"] = {
|
||||||
|
"id": sub["id"],
|
||||||
|
"plan": sub["plan"],
|
||||||
|
"status": sub["status"],
|
||||||
|
"start_date": sub["start_date"],
|
||||||
|
"end_date": sub["end_date"],
|
||||||
|
"current_period_end": sub["current_period_end"],
|
||||||
|
"grace_period_end": sub["grace_period_end"],
|
||||||
|
"stripe_customer_id": sub["stripe_customer_id"],
|
||||||
|
"stripe_subscription_id": sub["stripe_subscription_id"],
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
client["subscription"] = None
|
||||||
|
|
||||||
|
return jsonify({"client": client}), 200
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("get_client error: %s", e)
|
||||||
|
return jsonify({"error": "Erreur interne"}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── PUT /api/v1/admin/clients/<id> ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/clients/<string:client_id>", methods=["PUT"])
|
||||||
|
@require_auth
|
||||||
|
@require_admin
|
||||||
|
def update_client(client_id: str):
|
||||||
|
"""Update client fields (plan, firstname, lastname, email).
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Admin
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
firstname: { type: string }
|
||||||
|
lastname: { type: string }
|
||||||
|
email: { type: string }
|
||||||
|
plan: { type: string, enum: [free, premium, pro, suspended] }
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Client updated
|
||||||
|
400:
|
||||||
|
description: Invalid parameters
|
||||||
|
404:
|
||||||
|
description: Client not found
|
||||||
|
"""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
if not data:
|
||||||
|
return jsonify({"error": "Corps JSON requis"}), 400
|
||||||
|
|
||||||
|
db = _get_saas_db()
|
||||||
|
try:
|
||||||
|
existing = db.execute(
|
||||||
|
"SELECT id FROM saas_users WHERE id = ?", (client_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not existing:
|
||||||
|
return jsonify({"error": "Client introuvable"}), 404
|
||||||
|
|
||||||
|
fields = {}
|
||||||
|
if "firstname" in data:
|
||||||
|
fields["firstname"] = data["firstname"].strip()
|
||||||
|
if "lastname" in data:
|
||||||
|
fields["lastname"] = data["lastname"].strip()
|
||||||
|
if "email" in data:
|
||||||
|
email = data["email"].strip().lower()
|
||||||
|
if "@" not in email:
|
||||||
|
return jsonify({"error": "Email invalide"}), 400
|
||||||
|
fields["email"] = email
|
||||||
|
if "plan" in data:
|
||||||
|
plan = data["plan"].strip().lower()
|
||||||
|
if plan not in ("free", "premium", "pro", "suspended"):
|
||||||
|
return jsonify({"error": "Plan invalide. free|premium|pro|suspended"}), 400
|
||||||
|
fields["plan"] = plan
|
||||||
|
|
||||||
|
if not fields:
|
||||||
|
return jsonify({"ok": True}), 200
|
||||||
|
|
||||||
|
set_clause = ", ".join(f"{k}=?" for k in fields)
|
||||||
|
values = list(fields.values()) + [datetime.now(timezone.utc).isoformat(), client_id]
|
||||||
|
db.execute(
|
||||||
|
f"UPDATE saas_users SET {set_clause}, updated_at=? WHERE id=?", values
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info("Admin %s updated client %s: %s",
|
||||||
|
request.current_user["id"], client_id, fields)
|
||||||
|
return jsonify({"ok": True, "updated": list(fields.keys())}), 200
|
||||||
|
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return jsonify({"error": "Cet email est déjà utilisé"}), 409
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error("update_client error: %s", e)
|
||||||
|
return jsonify({"error": "Erreur interne"}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── DELETE /api/v1/admin/clients/<id> ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/clients/<string:client_id>", methods=["DELETE"])
|
||||||
|
@require_auth
|
||||||
|
@require_admin
|
||||||
|
def delete_client(client_id: str):
|
||||||
|
"""Delete client and all associated data (tokens, subscriptions).
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Admin
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Client deleted
|
||||||
|
404:
|
||||||
|
description: Client not found
|
||||||
|
"""
|
||||||
|
admin_id = request.current_user["id"]
|
||||||
|
if client_id == admin_id:
|
||||||
|
return jsonify({"error": "Impossible de supprimer votre propre compte"}), 400
|
||||||
|
|
||||||
|
db = _get_saas_db()
|
||||||
|
try:
|
||||||
|
existing = db.execute(
|
||||||
|
"SELECT id FROM saas_users WHERE id = ?", (client_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not existing:
|
||||||
|
return jsonify({"error": "Client introuvable"}), 404
|
||||||
|
|
||||||
|
db.execute("DELETE FROM saas_tokens WHERE user_id = ?", (client_id,))
|
||||||
|
db.execute("DELETE FROM saas_subscriptions WHERE user_id = ?", (client_id,))
|
||||||
|
db.execute("DELETE FROM admin_users WHERE user_id = ?", (client_id,))
|
||||||
|
db.execute("DELETE FROM saas_users WHERE id = ?", (client_id,))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info("Admin %s deleted client %s", admin_id, client_id)
|
||||||
|
return jsonify({"ok": True, "deleted_id": client_id}), 200
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error("delete_client error: %s", e)
|
||||||
|
return jsonify({"error": "Erreur interne"}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── POST /api/v1/admin/clients/<id>/suspend ───────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/clients/<string:client_id>/suspend", methods=["POST"])
|
||||||
|
@require_auth
|
||||||
|
@require_admin
|
||||||
|
def suspend_client(client_id: str):
|
||||||
|
"""Suspend a client by setting plan to 'suspended'.
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Admin
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Client suspended
|
||||||
|
404:
|
||||||
|
description: Client not found
|
||||||
|
"""
|
||||||
|
return _set_client_plan(client_id, "suspended")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── POST /api/v1/admin/clients/<id>/activate ──────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/clients/<string:client_id>/activate", methods=["POST"])
|
||||||
|
@require_auth
|
||||||
|
@require_admin
|
||||||
|
def activate_client(client_id: str):
|
||||||
|
"""Reactivate a suspended client to 'free' plan.
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Admin
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Client activated
|
||||||
|
404:
|
||||||
|
description: Client not found
|
||||||
|
"""
|
||||||
|
return _set_client_plan(client_id, "free")
|
||||||
|
|
||||||
|
|
||||||
|
def _set_client_plan(client_id: str, plan: str):
|
||||||
|
db = _get_saas_db()
|
||||||
|
try:
|
||||||
|
existing = db.execute(
|
||||||
|
"SELECT id, plan FROM saas_users WHERE id = ?", (client_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not existing:
|
||||||
|
return jsonify({"error": "Client introuvable"}), 404
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"UPDATE saas_users SET plan=?, updated_at=? WHERE id=?",
|
||||||
|
(plan, datetime.now(timezone.utc).isoformat(), client_id),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
action = "suspendu" if plan == "suspended" else "réactivé"
|
||||||
|
logger.info("Client %s %s par admin %s", client_id, action,
|
||||||
|
request.current_user["id"])
|
||||||
|
return jsonify({"ok": True, "client_id": client_id, "plan": plan, "action": action}), 200
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error("_set_client_plan error: %s", e)
|
||||||
|
return jsonify({"error": "Erreur interne"}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── GET /api/v1/admin/stats ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/stats", methods=["GET"])
|
||||||
|
@require_auth
|
||||||
|
@require_admin
|
||||||
|
def admin_stats():
|
||||||
|
"""Client stats: totals by plan, new this month/30d.
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Admin
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Admin stats
|
||||||
|
"""
|
||||||
|
db = _get_saas_db()
|
||||||
|
try:
|
||||||
|
total = db.execute("SELECT COUNT(*) FROM saas_users").fetchone()[0]
|
||||||
|
|
||||||
|
by_plan = {}
|
||||||
|
for row in db.execute(
|
||||||
|
"SELECT plan, COUNT(*) AS cnt FROM saas_users GROUP BY plan"
|
||||||
|
).fetchall():
|
||||||
|
by_plan[row["plan"]] = row["cnt"]
|
||||||
|
|
||||||
|
new_30d = db.execute(
|
||||||
|
"SELECT COUNT(*) FROM saas_users WHERE created_at >= datetime('now', '-30 days')"
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
new_7d = db.execute(
|
||||||
|
"SELECT COUNT(*) FROM saas_users WHERE created_at >= datetime('now', '-7 days')"
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
active_subs = db.execute(
|
||||||
|
"SELECT COUNT(DISTINCT user_id) FROM saas_subscriptions WHERE status = 'active'"
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"total_clients": total,
|
||||||
|
"clients_by_plan": by_plan,
|
||||||
|
"new_last_30d": new_30d,
|
||||||
|
"new_last_7d": new_7d,
|
||||||
|
"active_subscriptions": active_subs,
|
||||||
|
}), 200
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("admin_stats error: %s", e)
|
||||||
|
return jsonify({"error": "Erreur interne"}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
213
api_v1/routes/history.py
Normal file
213
api_v1/routes/history.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
History routes for API v1.
|
||||||
|
|
||||||
|
GET /api/v1/history — Historique des prédictions avec filtre date range,
|
||||||
|
limité selon le plan (Free: 7j, Premium: 90j, Pro: illimité)
|
||||||
|
|
||||||
|
Ticket: HRT-81 — Historique limité/illimité selon plan (Free/Premium/Pro)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from flask import Blueprint, jsonify, request, g
|
||||||
|
|
||||||
|
from api_v1.utils import (
|
||||||
|
get_db,
|
||||||
|
table_exists,
|
||||||
|
internal_error,
|
||||||
|
bad_request,
|
||||||
|
forbidden,
|
||||||
|
get_pagination_params,
|
||||||
|
paginate_query,
|
||||||
|
)
|
||||||
|
# Auth: try flask_jwt_extended (app_v1) first, fall back to saas_auth (portal_server)
|
||||||
|
from saas_auth import require_auth as jwt_required_middleware
|
||||||
|
|
||||||
|
history_bp = Blueprint("v1_history", __name__, url_prefix="/api/v1/history")
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Plan limits (days of history accessible; None = unlimited)
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
HISTORY_DAYS = {
|
||||||
|
"free": 7,
|
||||||
|
"premium": 90,
|
||||||
|
"pro": None, # illimité
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fallback for unknown plans: treat like free
|
||||||
|
_DEFAULT_LIMIT = 7
|
||||||
|
|
||||||
|
|
||||||
|
def _get_plan_max_days(plan: str):
|
||||||
|
"""Return the max history days allowed for the given plan, or default."""
|
||||||
|
return HISTORY_DAYS.get(plan, _DEFAULT_LIMIT)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(date_str: str, param_name: str):
|
||||||
|
"""Parse YYYY-MM-DD date string, raise ValueError with context on failure."""
|
||||||
|
try:
|
||||||
|
return datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(
|
||||||
|
f"Paramètre '{param_name}' invalide : format attendu YYYY-MM-DD, reçu '{date_str}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# GET /api/v1/history
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@history_bp.route("", methods=["GET"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
def get_history():
|
||||||
|
"""
|
||||||
|
Historique des prédictions ML avec filtre date range
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Historique
|
||||||
|
summary: |
|
||||||
|
Historique des prédictions sur une plage de dates.
|
||||||
|
Limite selon le plan :
|
||||||
|
- Free : 7 derniers jours
|
||||||
|
- Premium : 90 derniers jours
|
||||||
|
- Pro : illimité
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
parameters:
|
||||||
|
- name: start
|
||||||
|
in: query
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
description: Date de début au format YYYY-MM-DD (défaut : aujourd'hui - max_days du plan)
|
||||||
|
- name: end
|
||||||
|
in: query
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
description: Date de fin au format YYYY-MM-DD (défaut : aujourd'hui)
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
type: integer
|
||||||
|
default: 50
|
||||||
|
description: Nombre de résultats par page (max 500)
|
||||||
|
- name: offset
|
||||||
|
in: query
|
||||||
|
type: integer
|
||||||
|
default: 0
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Historique des prédictions ML
|
||||||
|
400:
|
||||||
|
description: Paramètre de date invalide
|
||||||
|
401:
|
||||||
|
description: Token invalide ou manquant
|
||||||
|
403:
|
||||||
|
description: Plage de dates hors limite du plan — upgrade requis
|
||||||
|
"""
|
||||||
|
user = getattr(request, "current_user", None) or getattr(g, "current_user", None)
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "Non authentifié"}), 401
|
||||||
|
|
||||||
|
plan = user.get("plan", "free")
|
||||||
|
today = datetime.now().date()
|
||||||
|
max_days = _get_plan_max_days(plan)
|
||||||
|
|
||||||
|
# ── Parse end date ────────────────────────────────────────
|
||||||
|
end_str = request.args.get("end", today.isoformat())
|
||||||
|
try:
|
||||||
|
end_date = _parse_date(end_str, "end")
|
||||||
|
except ValueError as exc:
|
||||||
|
return bad_request(str(exc))
|
||||||
|
|
||||||
|
# ── Parse start date ─────────────────────────────────────
|
||||||
|
if max_days is not None:
|
||||||
|
default_start = today - timedelta(days=max_days - 1)
|
||||||
|
else:
|
||||||
|
# Pro: default to 30 days back when no start provided
|
||||||
|
default_start = today - timedelta(days=29)
|
||||||
|
|
||||||
|
start_str = request.args.get("start", default_start.isoformat())
|
||||||
|
try:
|
||||||
|
start_date = _parse_date(start_str, "start")
|
||||||
|
except ValueError as exc:
|
||||||
|
return bad_request(str(exc))
|
||||||
|
|
||||||
|
# ── Validate ordering ─────────────────────────────────────
|
||||||
|
if start_date > end_date:
|
||||||
|
return bad_request(
|
||||||
|
f"'start' ({start_str}) ne peut pas être postérieur à 'end' ({end_str})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Enforce plan window ───────────────────────────────────
|
||||||
|
if max_days is not None:
|
||||||
|
earliest_allowed = today - timedelta(days=max_days - 1)
|
||||||
|
if start_date < earliest_allowed:
|
||||||
|
return forbidden(
|
||||||
|
message=(
|
||||||
|
f"Historique limité à {max_days} jours pour le plan '{plan}'. "
|
||||||
|
f"Date de début minimale autorisée : {earliest_allowed.isoformat()}. "
|
||||||
|
f"Passez à un plan supérieur pour accéder à un historique plus long."
|
||||||
|
),
|
||||||
|
required_plans=["premium", "pro"] if plan == "free" else ["pro"],
|
||||||
|
current_plan=plan,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Pagination ────────────────────────────────────────────
|
||||||
|
limit, offset = get_pagination_params(default_limit=50, max_limit=500)
|
||||||
|
|
||||||
|
# ── Query ─────────────────────────────────────────────────
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
if not table_exists(conn, "ml_predictions_cache"):
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"plan": plan,
|
||||||
|
"start": start_date.isoformat(),
|
||||||
|
"end": end_date.isoformat(),
|
||||||
|
"history": [],
|
||||||
|
**paginate_query([], 0, limit, offset),
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
|
||||||
|
count_row = conn.execute(
|
||||||
|
"""SELECT COUNT(*) as cnt
|
||||||
|
FROM ml_predictions_cache
|
||||||
|
WHERE date >= ? AND date <= ?""",
|
||||||
|
(start_date.isoformat(), end_date.isoformat()),
|
||||||
|
).fetchone()
|
||||||
|
total = count_row["cnt"] if count_row else 0
|
||||||
|
|
||||||
|
sql = """
|
||||||
|
SELECT
|
||||||
|
id, date, horse_name, prob_top1, prob_top3,
|
||||||
|
ml_score, race_label, hippodrome, heure, is_value_bet
|
||||||
|
FROM ml_predictions_cache
|
||||||
|
WHERE date >= ? AND date <= ?
|
||||||
|
ORDER BY date DESC, ml_score DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
"""
|
||||||
|
rows = conn.execute(
|
||||||
|
sql,
|
||||||
|
(start_date.isoformat(), end_date.isoformat(), limit, offset),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
history = [dict(r) for r in rows]
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"plan": plan,
|
||||||
|
"history_limit_days": max_days,
|
||||||
|
"start": start_date.isoformat(),
|
||||||
|
"end": end_date.isoformat(),
|
||||||
|
"history": history,
|
||||||
|
**paginate_query(history, total, limit, offset),
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
return internal_error(str(exc))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
@@ -14,15 +14,21 @@ from api_v1.utils import (
|
|||||||
internal_error,
|
internal_error,
|
||||||
bad_request,
|
bad_request,
|
||||||
)
|
)
|
||||||
from auth import jwt_required_middleware, plan_required
|
from saas_auth import require_auth as jwt_required_middleware
|
||||||
|
from flask import request as _req
|
||||||
|
|
||||||
metrics_bp = Blueprint("v1_metrics", __name__, url_prefix="/api/v1")
|
metrics_bp = Blueprint("v1_metrics", __name__, url_prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
@metrics_bp.route("/metrics", methods=["GET"])
|
@metrics_bp.route("/metrics", methods=["GET"])
|
||||||
@jwt_required_middleware
|
@jwt_required_middleware
|
||||||
@plan_required("premium", "pro")
|
|
||||||
def metrics():
|
def metrics():
|
||||||
|
# plan check: premium or pro (or TEST_MODE via plan='pro' in DB)
|
||||||
|
user = getattr(_req, 'current_user', None) or {}
|
||||||
|
plan = user.get('plan', 'free') if isinstance(user, dict) else 'free'
|
||||||
|
if plan not in ('premium', 'pro'):
|
||||||
|
from flask import jsonify as _j
|
||||||
|
return _j({'error': 'Plan premium ou pro requis'}), 403
|
||||||
"""
|
"""
|
||||||
Métriques ML
|
Métriques ML
|
||||||
---
|
---
|
||||||
|
|||||||
199
api_v1/routes/ml_feedback.py
Normal file
199
api_v1/routes/ml_feedback.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
ml_feedback.py — API routes pour le feedback loop ML (turf_saas).
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
POST /api/v1/ml/feedback/run — Déclenche le feedback loop (admin uniquement)
|
||||||
|
GET /api/v1/ml/feedback/stats — Stats performances par stratégie
|
||||||
|
|
||||||
|
Sécurité admin : token via variable d'environnement ML_ADMIN_TOKEN
|
||||||
|
ou plan "pro" en fallback pour les stats.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, g
|
||||||
|
|
||||||
|
# Ajoute le répertoire parent de api_v1 dans le path pour importer ml_feedback_saas
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
||||||
|
|
||||||
|
from api_v1.utils import get_db, internal_error, bad_request
|
||||||
|
# Auth: try flask_jwt_extended (app_v1) first, fall back to saas_auth (portal_server)
|
||||||
|
try:
|
||||||
|
from auth import jwt_required_middleware
|
||||||
|
except ImportError:
|
||||||
|
from saas_auth import require_auth as jwt_required_middleware
|
||||||
|
try:
|
||||||
|
from auth import plan_required
|
||||||
|
except ImportError:
|
||||||
|
plan_required = lambda *a, **kw: (lambda f: f)
|
||||||
|
|
||||||
|
ml_feedback_bp = Blueprint("v1_ml_feedback", __name__, url_prefix="/api/v1/ml/feedback")
|
||||||
|
|
||||||
|
# Token admin interne — configurable via variable d'environnement
|
||||||
|
ML_ADMIN_TOKEN = os.environ.get("ML_ADMIN_TOKEN", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _check_admin(req):
|
||||||
|
"""Vérifie le token admin via header X-Admin-Token ou Authorization Bearer (plan pro)."""
|
||||||
|
# 1. Token interne (scheduler/cron)
|
||||||
|
admin_token = req.headers.get("X-Admin-Token", "").strip()
|
||||||
|
if ML_ADMIN_TOKEN and admin_token == ML_ADMIN_TOKEN:
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
# 2. Pas de token admin configuré → autoriser les utilisateurs "pro" authentifiés
|
||||||
|
user = getattr(request, "current_user", None) or getattr(g, "current_user", None)
|
||||||
|
if user and user.get("plan") == "pro":
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
return False, jsonify({"error": "Accès admin requis", "code": 403}), 403
|
||||||
|
|
||||||
|
|
||||||
|
@ml_feedback_bp.route("/run", methods=["POST"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
def feedback_run():
|
||||||
|
"""
|
||||||
|
Déclenche le feedback loop ML pour une date donnée.
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- ML Feedback
|
||||||
|
summary: Déclenche le feedback loop XGBoost (admin only)
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
- AdminToken: []
|
||||||
|
parameters:
|
||||||
|
- name: body
|
||||||
|
in: body
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
date:
|
||||||
|
type: string
|
||||||
|
description: Date YYYY-MM-DD (défaut aujourd'hui)
|
||||||
|
example: "2026-04-25"
|
||||||
|
mode:
|
||||||
|
type: string
|
||||||
|
description: "run (défaut) ou backfill"
|
||||||
|
enum: [run, backfill]
|
||||||
|
example: run
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Feedback loop exécuté avec succès
|
||||||
|
400:
|
||||||
|
description: Paramètre invalide
|
||||||
|
403:
|
||||||
|
description: Accès refusé
|
||||||
|
500:
|
||||||
|
description: Erreur interne
|
||||||
|
"""
|
||||||
|
# Vérification admin
|
||||||
|
user = getattr(request, "current_user", None) or getattr(g, "current_user", None)
|
||||||
|
admin_token = request.headers.get("X-Admin-Token", "").strip()
|
||||||
|
is_admin = (ML_ADMIN_TOKEN and admin_token == ML_ADMIN_TOKEN) or (
|
||||||
|
user and user.get("plan") == "pro"
|
||||||
|
)
|
||||||
|
if not is_admin:
|
||||||
|
return jsonify({"error": "Accès admin requis", "code": 403}), 403
|
||||||
|
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
date_str = body.get("date") or datetime.now().strftime("%Y-%m-%d")
|
||||||
|
mode = body.get("mode", "run")
|
||||||
|
|
||||||
|
# Validation date
|
||||||
|
try:
|
||||||
|
datetime.strptime(date_str, "%Y-%m-%d")
|
||||||
|
except ValueError:
|
||||||
|
return bad_request(f"Date invalide : {date_str}. Format attendu : YYYY-MM-DD")
|
||||||
|
|
||||||
|
if mode not in ("run", "backfill"):
|
||||||
|
return bad_request("mode doit être 'run' ou 'backfill'")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import ml_feedback_saas
|
||||||
|
|
||||||
|
if mode == "backfill":
|
||||||
|
inseres, maj = ml_feedback_saas.backfill(date_str)
|
||||||
|
total_inseres = inseres
|
||||||
|
else:
|
||||||
|
result = ml_feedback_saas.run(date_str)
|
||||||
|
total_inseres = sum(result["inseres"].values())
|
||||||
|
maj = result["maj"]
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"date": date_str,
|
||||||
|
"mode": mode,
|
||||||
|
"paris_inseres": total_inseres,
|
||||||
|
"paris_mis_a_jour": maj,
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return internal_error(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@ml_feedback_bp.route("/stats", methods=["GET"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@plan_required("premium", "pro")
|
||||||
|
def feedback_stats():
|
||||||
|
"""
|
||||||
|
Stats performances ML par stratégie.
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- ML Feedback
|
||||||
|
summary: Stats paris ML par stratégie (premium+)
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
parameters:
|
||||||
|
- name: date_debut
|
||||||
|
in: query
|
||||||
|
type: string
|
||||||
|
description: Date de début YYYY-MM-DD
|
||||||
|
- name: date_fin
|
||||||
|
in: query
|
||||||
|
type: string
|
||||||
|
description: Date de fin YYYY-MM-DD
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Stats par stratégie
|
||||||
|
401:
|
||||||
|
description: Token invalide
|
||||||
|
403:
|
||||||
|
description: Plan insuffisant (premium ou pro requis)
|
||||||
|
"""
|
||||||
|
date_debut = request.args.get("date_debut")
|
||||||
|
date_fin = request.args.get("date_fin")
|
||||||
|
|
||||||
|
# Validation optionnelle des dates
|
||||||
|
for d_str, label in [(date_debut, "date_debut"), (date_fin, "date_fin")]:
|
||||||
|
if d_str:
|
||||||
|
try:
|
||||||
|
datetime.strptime(d_str, "%Y-%m-%d")
|
||||||
|
except ValueError:
|
||||||
|
return bad_request(f"{label} invalide : {d_str}. Format : YYYY-MM-DD")
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
import ml_feedback_saas
|
||||||
|
|
||||||
|
stats = ml_feedback_saas.get_feedback_stats(conn, date_debut, date_fin)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"strategies": stats,
|
||||||
|
"filters": {
|
||||||
|
"date_debut": date_debut,
|
||||||
|
"date_fin": date_fin,
|
||||||
|
},
|
||||||
|
"total_strategies": len(stats),
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return internal_error(str(e))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
536
api_v1/routes/org.py
Normal file
536
api_v1/routes/org.py
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Org Blueprint — Multi-compte / Organisations Pro
|
||||||
|
Sprint: HRT-82
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
POST /api/v1/org — créer une organisation (Pro only, 1 max par owner)
|
||||||
|
GET /api/v1/org — infos org courante
|
||||||
|
DELETE /api/v1/org — supprimer l'org (owner only)
|
||||||
|
POST /api/v1/org/invite — inviter un membre par email (max 5 totaux)
|
||||||
|
GET /api/v1/org/members — liste des membres
|
||||||
|
DELETE /api/v1/org/members/<user_id> — retirer un membre (owner only)
|
||||||
|
|
||||||
|
Plan enforcement:
|
||||||
|
- Toutes les routes nécessitent plan=pro via plan_required('pro')
|
||||||
|
- Limite : 1 org par owner, 5 membres max (owner inclus)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
from saas_auth import require_auth as jwt_required_middleware
|
||||||
|
from org_db import get_db, migrate_org_tables
|
||||||
|
|
||||||
|
logger = logging.getLogger("turf_saas.org")
|
||||||
|
|
||||||
|
org_bp = Blueprint("org", __name__, url_prefix="/api/v1/org")
|
||||||
|
|
||||||
|
MAX_MEMBERS = 5 # max membres totaux owner inclus
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Decorator: plan Pro requis
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _require_pro(fn):
|
||||||
|
"""Vérifie que l'utilisateur courant est sur le plan 'pro'."""
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
@wraps(fn)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
user = getattr(request, "current_user", None)
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "Non authentifié"}), 401
|
||||||
|
if user.get("plan") != "pro":
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"error": "Plan insuffisant",
|
||||||
|
"required": "pro",
|
||||||
|
"current_plan": user.get("plan", "free"),
|
||||||
|
"upgrade_url": "/api/v1/billing/checkout",
|
||||||
|
}
|
||||||
|
), 403
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Helpers DB
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _get_org_by_owner(db, owner_id: str):
|
||||||
|
return db.execute(
|
||||||
|
"SELECT * FROM organizations WHERE owner_id = ?", (owner_id,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_org_by_id(db, org_id: str):
|
||||||
|
return db.execute("SELECT * FROM organizations WHERE id = ?", (org_id,)).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_member_org(db, user_id: str):
|
||||||
|
"""Retourne l'org dont user_id est membre (owner ou member)."""
|
||||||
|
row = db.execute(
|
||||||
|
"""SELECT o.* FROM organizations o
|
||||||
|
JOIN org_members m ON m.org_id = o.id
|
||||||
|
WHERE m.user_id = ?
|
||||||
|
LIMIT 1""",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _count_org_members(db, org_id: str) -> int:
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT COUNT(*) AS cnt FROM org_members WHERE org_id = ?", (org_id,)
|
||||||
|
).fetchone()
|
||||||
|
return row["cnt"] if row else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_by_email(db, email: str):
|
||||||
|
"""Lookup dans saas_users par email."""
|
||||||
|
return db.execute(
|
||||||
|
"SELECT * FROM saas_users WHERE email = ?", (email.lower().strip(),)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def _org_to_dict(org) -> dict:
|
||||||
|
return {
|
||||||
|
"id": org["id"],
|
||||||
|
"owner_id": org["owner_id"],
|
||||||
|
"name": org["name"],
|
||||||
|
"max_members": org["max_members"],
|
||||||
|
"created_at": org["created_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _member_to_dict(m) -> dict:
|
||||||
|
return {
|
||||||
|
"id": m["id"],
|
||||||
|
"org_id": m["org_id"],
|
||||||
|
"user_id": m["user_id"],
|
||||||
|
"role": m["role"],
|
||||||
|
"invited_at": m["invited_at"],
|
||||||
|
"joined_at": m["joined_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# POST /api/v1/org — créer une organisation
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@org_bp.route("", methods=["POST"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@_require_pro
|
||||||
|
def create_org():
|
||||||
|
"""
|
||||||
|
Crée une organisation.
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Organisation
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [name]
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Nom de l'organisation (1-100 caractères)
|
||||||
|
responses:
|
||||||
|
201:
|
||||||
|
description: Organisation créée
|
||||||
|
400:
|
||||||
|
description: Paramètre manquant ou invalide
|
||||||
|
403:
|
||||||
|
description: Plan insuffisant
|
||||||
|
409:
|
||||||
|
description: L'utilisateur possède déjà une organisation
|
||||||
|
"""
|
||||||
|
user = request.current_user
|
||||||
|
owner_id = user["id"]
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
name = (data.get("name") or "").strip()
|
||||||
|
if not name or len(name) > 100:
|
||||||
|
return jsonify({"error": "Le nom est requis (1-100 caractères)"}), 400
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
# 1 org max par owner
|
||||||
|
existing = _get_org_by_owner(db, owner_id)
|
||||||
|
if existing:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"error": "Vous possédez déjà une organisation",
|
||||||
|
"org_id": existing["id"],
|
||||||
|
}
|
||||||
|
), 409
|
||||||
|
|
||||||
|
org_id = secrets.token_hex(16)
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO organizations (id, owner_id, name, max_members, created_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(org_id, owner_id, name, MAX_MEMBERS, now),
|
||||||
|
)
|
||||||
|
# Ajouter l'owner comme premier membre avec rôle 'owner'
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) "
|
||||||
|
"VALUES (?, ?, 'owner', ?, ?)",
|
||||||
|
(org_id, owner_id, now, now),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
org = _get_org_by_id(db, org_id)
|
||||||
|
logger.info("Org créée: %s par user %s", org_id, owner_id)
|
||||||
|
return jsonify({"org": _org_to_dict(org)}), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error("create_org error: %s", e)
|
||||||
|
return jsonify({"error": "Erreur interne"}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# GET /api/v1/org — infos org courante
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@org_bp.route("", methods=["GET"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@_require_pro
|
||||||
|
def get_org():
|
||||||
|
"""
|
||||||
|
Retourne l'organisation dont l'utilisateur est owner ou membre.
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Organisation
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Infos de l'organisation
|
||||||
|
404:
|
||||||
|
description: Aucune organisation trouvée
|
||||||
|
"""
|
||||||
|
user = request.current_user
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
org = _get_org_by_owner(db, user["id"]) or _get_member_org(db, user["id"])
|
||||||
|
if not org:
|
||||||
|
return jsonify({"error": "Aucune organisation trouvée"}), 404
|
||||||
|
|
||||||
|
member_count = _count_org_members(db, org["id"])
|
||||||
|
result = _org_to_dict(org)
|
||||||
|
result["member_count"] = member_count
|
||||||
|
return jsonify({"org": result}), 200
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# DELETE /api/v1/org — supprimer l'organisation
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@org_bp.route("", methods=["DELETE"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@_require_pro
|
||||||
|
def delete_org():
|
||||||
|
"""
|
||||||
|
Supprime l'organisation (owner uniquement).
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Organisation
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Organisation supprimée
|
||||||
|
403:
|
||||||
|
description: Seul l'owner peut supprimer l'organisation
|
||||||
|
404:
|
||||||
|
description: Organisation introuvable
|
||||||
|
"""
|
||||||
|
user = request.current_user
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
org = _get_org_by_owner(db, user["id"])
|
||||||
|
if not org:
|
||||||
|
return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403
|
||||||
|
|
||||||
|
# CASCADE supprime org_members automatiquement (FK ON DELETE CASCADE)
|
||||||
|
db.execute("DELETE FROM organizations WHERE id = ?", (org["id"],))
|
||||||
|
db.commit()
|
||||||
|
logger.info("Org %s supprimée par user %s", org["id"], user["id"])
|
||||||
|
return jsonify({"ok": True, "deleted_org_id": org["id"]}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error("delete_org error: %s", e)
|
||||||
|
return jsonify({"error": "Erreur interne"}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# POST /api/v1/org/invite — inviter un membre par email
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@org_bp.route("/invite", methods=["POST"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@_require_pro
|
||||||
|
def invite_member():
|
||||||
|
"""
|
||||||
|
Invite un utilisateur dans l'organisation par email (owner uniquement).
|
||||||
|
Limite : 5 membres totaux (owner inclus).
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Organisation
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [email]
|
||||||
|
properties:
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
description: Email de l'utilisateur à inviter
|
||||||
|
responses:
|
||||||
|
201:
|
||||||
|
description: Membre ajouté
|
||||||
|
400:
|
||||||
|
description: Paramètre manquant ou invalide
|
||||||
|
403:
|
||||||
|
description: Seul l'owner peut inviter / limite de membres atteinte
|
||||||
|
404:
|
||||||
|
description: Utilisateur introuvable ou organisation inexistante
|
||||||
|
409:
|
||||||
|
description: L'utilisateur est déjà membre
|
||||||
|
"""
|
||||||
|
user = request.current_user
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
email = (data.get("email") or "").strip().lower()
|
||||||
|
|
||||||
|
if not email or "@" not in email:
|
||||||
|
return jsonify({"error": "Email invalide"}), 400
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
# Vérifier que l'appelant est bien owner d'une org
|
||||||
|
org = _get_org_by_owner(db, user["id"])
|
||||||
|
if not org:
|
||||||
|
return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403
|
||||||
|
|
||||||
|
# Vérifier la limite de membres
|
||||||
|
current_count = _count_org_members(db, org["id"])
|
||||||
|
if current_count >= org["max_members"]:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"error": f"Limite de {org['max_members']} membres atteinte",
|
||||||
|
"current_count": current_count,
|
||||||
|
}
|
||||||
|
), 403
|
||||||
|
|
||||||
|
# Résoudre l'utilisateur cible
|
||||||
|
target_user = _get_user_by_email(db, email)
|
||||||
|
if not target_user:
|
||||||
|
return jsonify({"error": "Utilisateur introuvable avec cet email"}), 404
|
||||||
|
|
||||||
|
target_id = target_user["id"]
|
||||||
|
|
||||||
|
# Vérifier que l'utilisateur n'est pas déjà membre de CETTE org
|
||||||
|
existing_member = db.execute(
|
||||||
|
"SELECT id FROM org_members WHERE org_id = ? AND user_id = ?",
|
||||||
|
(org["id"], target_id),
|
||||||
|
).fetchone()
|
||||||
|
if existing_member:
|
||||||
|
return jsonify(
|
||||||
|
{"error": "Cet utilisateur est déjà membre de l'organisation"}
|
||||||
|
), 409
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) "
|
||||||
|
"VALUES (?, ?, 'member', ?, ?)",
|
||||||
|
(org["id"], target_id, now, now),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
member_row = db.execute(
|
||||||
|
"SELECT * FROM org_members WHERE org_id = ? AND user_id = ?",
|
||||||
|
(org["id"], target_id),
|
||||||
|
).fetchone()
|
||||||
|
logger.info(
|
||||||
|
"User %s invité dans org %s par %s", target_id, org["id"], user["id"]
|
||||||
|
)
|
||||||
|
return jsonify({"member": _member_to_dict(member_row)}), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error("invite_member error: %s", e)
|
||||||
|
return jsonify({"error": "Erreur interne"}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# GET /api/v1/org/members — liste des membres
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@org_bp.route("/members", methods=["GET"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@_require_pro
|
||||||
|
def list_members():
|
||||||
|
"""
|
||||||
|
Liste les membres de l'organisation courante.
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Organisation
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Liste des membres
|
||||||
|
404:
|
||||||
|
description: Organisation introuvable
|
||||||
|
"""
|
||||||
|
user = request.current_user
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
org = _get_org_by_owner(db, user["id"]) or _get_member_org(db, user["id"])
|
||||||
|
if not org:
|
||||||
|
return jsonify({"error": "Aucune organisation trouvée"}), 404
|
||||||
|
|
||||||
|
members = db.execute(
|
||||||
|
"SELECT m.*, u.email, u.firstname, u.lastname "
|
||||||
|
"FROM org_members m "
|
||||||
|
"LEFT JOIN saas_users u ON u.id = m.user_id "
|
||||||
|
"WHERE m.org_id = ? "
|
||||||
|
"ORDER BY m.invited_at ASC",
|
||||||
|
(org["id"],),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for m in members:
|
||||||
|
d = _member_to_dict(m)
|
||||||
|
d["email"] = m["email"]
|
||||||
|
d["firstname"] = m["firstname"] or ""
|
||||||
|
d["lastname"] = m["lastname"] or ""
|
||||||
|
result.append(d)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"org_id": org["id"],
|
||||||
|
"members": result,
|
||||||
|
"count": len(result),
|
||||||
|
"max_members": org["max_members"],
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# DELETE /api/v1/org/members/<user_id> — retirer un membre
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@org_bp.route("/members/<string:target_user_id>", methods=["DELETE"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@_require_pro
|
||||||
|
def remove_member(target_user_id: str):
|
||||||
|
"""
|
||||||
|
Retire un membre de l'organisation (owner uniquement).
|
||||||
|
L'owner ne peut pas se retirer lui-même.
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Organisation
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: user_id
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: ID de l'utilisateur à retirer
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Membre retiré
|
||||||
|
400:
|
||||||
|
description: Tentative de retirer l'owner lui-même
|
||||||
|
403:
|
||||||
|
description: Seul l'owner peut retirer des membres
|
||||||
|
404:
|
||||||
|
description: Membre ou organisation introuvable
|
||||||
|
"""
|
||||||
|
user = request.current_user
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
org = _get_org_by_owner(db, user["id"])
|
||||||
|
if not org:
|
||||||
|
return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403
|
||||||
|
|
||||||
|
# L'owner ne peut pas se retirer lui-même (utiliser DELETE /api/v1/org à la place)
|
||||||
|
if target_user_id == user["id"]:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"error": "L'owner ne peut pas se retirer lui-même. "
|
||||||
|
"Utilisez DELETE /api/v1/org pour supprimer l'organisation."
|
||||||
|
}
|
||||||
|
), 400
|
||||||
|
|
||||||
|
member = db.execute(
|
||||||
|
"SELECT * FROM org_members WHERE org_id = ? AND user_id = ?",
|
||||||
|
(org["id"], target_user_id),
|
||||||
|
).fetchone()
|
||||||
|
if not member:
|
||||||
|
return jsonify({"error": "Membre introuvable dans cette organisation"}), 404
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"DELETE FROM org_members WHERE org_id = ? AND user_id = ?",
|
||||||
|
(org["id"], target_user_id),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
logger.info(
|
||||||
|
"User %s retiré de l'org %s par %s", target_user_id, org["id"], user["id"]
|
||||||
|
)
|
||||||
|
return jsonify({"ok": True, "removed_user_id": target_user_id}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error("remove_member error: %s", e)
|
||||||
|
return jsonify({"error": "Erreur interne"}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# On-import : migration idempotente
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
try:
|
||||||
|
migrate_org_tables()
|
||||||
|
except Exception as _e:
|
||||||
|
logger.warning("org_db migration skipped (test env?): %s", _e)
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
224
api_v1/routes/user.py
Normal file
224
api_v1/routes/user.py
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
User route for API v1 — Telegram alert configuration
|
||||||
|
HRT-79: Alertes Telegram configurables (Premium)
|
||||||
|
|
||||||
|
GET /api/v1/user/telegram-config — Lire la config Telegram de l'utilisateur connecté
|
||||||
|
POST /api/v1/user/telegram-config — Mettre à jour la config Telegram
|
||||||
|
|
||||||
|
Accès : Premium / Pro uniquement (@jwt_required_middleware + @plan_required)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
from api_v1.utils import internal_error, bad_request
|
||||||
|
# Auth: try flask_jwt_extended (app_v1) first, fall back to saas_auth (portal_server)
|
||||||
|
try:
|
||||||
|
from auth import jwt_required_middleware
|
||||||
|
except ImportError:
|
||||||
|
from saas_auth import require_auth as jwt_required_middleware
|
||||||
|
try:
|
||||||
|
from auth import plan_required
|
||||||
|
except ImportError:
|
||||||
|
plan_required = lambda *a, **kw: (lambda f: f)
|
||||||
|
|
||||||
|
user_bp = Blueprint("v1_user", __name__, url_prefix="/api/v1/user")
|
||||||
|
|
||||||
|
# DB_PATH est résolu via la même variable d'env que auth_db.py
|
||||||
|
import os
|
||||||
|
|
||||||
|
_DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_db():
|
||||||
|
conn = sqlite3.connect(_DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /api/v1/user/telegram-config ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@user_bp.route("/telegram-config", methods=["GET"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@plan_required("premium", "pro")
|
||||||
|
def get_telegram_config():
|
||||||
|
"""
|
||||||
|
Retourne la configuration Telegram de l'utilisateur connecté.
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Utilisateur
|
||||||
|
summary: Lire la config alertes Telegram (premium+)
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Configuration Telegram courante
|
||||||
|
schema:
|
||||||
|
properties:
|
||||||
|
telegram_chat_id:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
alert_value_bets:
|
||||||
|
type: boolean
|
||||||
|
alert_top1:
|
||||||
|
type: boolean
|
||||||
|
alert_quinte_only:
|
||||||
|
type: boolean
|
||||||
|
401:
|
||||||
|
description: Token invalide
|
||||||
|
403:
|
||||||
|
description: Plan insuffisant
|
||||||
|
"""
|
||||||
|
user_id = request.user_id # injecté par jwt_required_middleware
|
||||||
|
|
||||||
|
conn = _get_db()
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT telegram_chat_id, alert_value_bets, alert_top1, alert_quinte_only
|
||||||
|
FROM users
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return jsonify({"error": "Utilisateur introuvable"}), 404
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"telegram_chat_id": row["telegram_chat_id"],
|
||||||
|
"alert_value_bets": bool(row["alert_value_bets"]),
|
||||||
|
"alert_top1": bool(row["alert_top1"]),
|
||||||
|
"alert_quinte_only": bool(row["alert_quinte_only"]),
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
|
||||||
|
except sqlite3.OperationalError as exc:
|
||||||
|
# Colonnes absentes : migration non appliquée
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"telegram_chat_id": None,
|
||||||
|
"alert_value_bets": True,
|
||||||
|
"alert_top1": True,
|
||||||
|
"alert_quinte_only": False,
|
||||||
|
"_warning": "Migration Telegram non appliquée",
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
except Exception as exc:
|
||||||
|
return internal_error(str(exc))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /api/v1/user/telegram-config ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@user_bp.route("/telegram-config", methods=["POST"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@plan_required("premium", "pro")
|
||||||
|
def update_telegram_config():
|
||||||
|
"""
|
||||||
|
Met à jour la configuration Telegram de l'utilisateur connecté.
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Utilisateur
|
||||||
|
summary: Configurer les alertes Telegram (premium+)
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
parameters:
|
||||||
|
- in: body
|
||||||
|
name: body
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
properties:
|
||||||
|
telegram_chat_id:
|
||||||
|
type: string
|
||||||
|
description: Chat ID Telegram (ou null pour désactiver)
|
||||||
|
alert_value_bets:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
alert_top1:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
alert_quinte_only:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Configuration mise à jour
|
||||||
|
400:
|
||||||
|
description: Paramètres invalides
|
||||||
|
401:
|
||||||
|
description: Token invalide
|
||||||
|
403:
|
||||||
|
description: Plan insuffisant
|
||||||
|
"""
|
||||||
|
user_id = request.user_id # injecté par jwt_required_middleware
|
||||||
|
|
||||||
|
data = request.get_json(silent=True)
|
||||||
|
if not data:
|
||||||
|
return bad_request("Corps JSON requis")
|
||||||
|
|
||||||
|
# Validation et extraction des champs
|
||||||
|
telegram_chat_id = data.get("telegram_chat_id")
|
||||||
|
if telegram_chat_id is not None and not isinstance(telegram_chat_id, str):
|
||||||
|
return bad_request("telegram_chat_id doit être une chaîne ou null")
|
||||||
|
if isinstance(telegram_chat_id, str):
|
||||||
|
telegram_chat_id = telegram_chat_id.strip() or None
|
||||||
|
|
||||||
|
alert_value_bets = data.get("alert_value_bets", True)
|
||||||
|
alert_top1 = data.get("alert_top1", True)
|
||||||
|
alert_quinte_only = data.get("alert_quinte_only", False)
|
||||||
|
|
||||||
|
if not isinstance(alert_value_bets, bool):
|
||||||
|
return bad_request("alert_value_bets doit être un booléen")
|
||||||
|
if not isinstance(alert_top1, bool):
|
||||||
|
return bad_request("alert_top1 doit être un booléen")
|
||||||
|
if not isinstance(alert_quinte_only, bool):
|
||||||
|
return bad_request("alert_quinte_only doit être un booléen")
|
||||||
|
|
||||||
|
conn = _get_db()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET telegram_chat_id = ?,
|
||||||
|
alert_value_bets = ?,
|
||||||
|
alert_top1 = ?,
|
||||||
|
alert_quinte_only = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
telegram_chat_id,
|
||||||
|
int(alert_value_bets),
|
||||||
|
int(alert_top1),
|
||||||
|
int(alert_quinte_only),
|
||||||
|
user_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"telegram_chat_id": telegram_chat_id,
|
||||||
|
"alert_value_bets": alert_value_bets,
|
||||||
|
"alert_top1": alert_top1,
|
||||||
|
"alert_quinte_only": alert_quinte_only,
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
|
||||||
|
except sqlite3.OperationalError as exc:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"error": "Migration Telegram non appliquée — contacter le support",
|
||||||
|
"detail": str(exc),
|
||||||
|
}
|
||||||
|
), 500
|
||||||
|
except Exception as exc:
|
||||||
|
return internal_error(str(exc))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
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
|
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(
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
|||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
"""Return a SQLite connection with Row factory."""
|
"""Return a SQLite connection with Row factory (reads TURF_SAAS_DB dynamically)."""
|
||||||
conn = sqlite3.connect(DB_PATH)
|
db_path = os.environ.get("TURF_SAAS_DB", DB_PATH)
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|||||||
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):
|
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
|
||||||
|
|
||||||
|
|||||||
37
auth_db.py
37
auth_db.py
@@ -2,16 +2,21 @@
|
|||||||
"""
|
"""
|
||||||
Auth DB — users and subscriptions schema for turf_saas.db
|
Auth DB — users and subscriptions schema for turf_saas.db
|
||||||
Sprint 2-3: Auth JWT + Multi-tenant (HRT-28)
|
Sprint 2-3: Auth JWT + Multi-tenant (HRT-28)
|
||||||
|
HRT-79: migration Telegram columns
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
# NOTE: DB_PATH kept for backward compat, but get_db() reads env at call time
|
||||||
|
# so test isolation works correctly when TURF_SAAS_DB is set per-module.
|
||||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
conn = sqlite3.connect(DB_PATH)
|
# Read env dynamically so test overrides of TURF_SAAS_DB are respected
|
||||||
|
db_path = os.environ.get("TURF_SAAS_DB", DB_PATH)
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
@@ -63,6 +68,36 @@ def init_auth_tables():
|
|||||||
conn.close()
|
conn.close()
|
||||||
print("[auth_db] Tables users, subscriptions, refresh_tokens created/verified.")
|
print("[auth_db] Tables users, subscriptions, refresh_tokens created/verified.")
|
||||||
|
|
||||||
|
# Apply Telegram columns migration (idempotent)
|
||||||
|
migrate_telegram_columns()
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_telegram_columns():
|
||||||
|
"""
|
||||||
|
Migration idempotente : ajoute les colonnes Telegram à la table users.
|
||||||
|
Utilise ALTER TABLE ... ADD COLUMN avec try/except OperationalError
|
||||||
|
pour être safe si les colonnes existent déjà (SQLite ne supporte pas IF NOT EXISTS).
|
||||||
|
HRT-79
|
||||||
|
"""
|
||||||
|
conn = get_db()
|
||||||
|
c = conn.cursor()
|
||||||
|
columns = [
|
||||||
|
("telegram_chat_id", "TEXT DEFAULT NULL"),
|
||||||
|
("alert_value_bets", "INTEGER DEFAULT 1"),
|
||||||
|
("alert_top1", "INTEGER DEFAULT 1"),
|
||||||
|
("alert_quinte_only", "INTEGER DEFAULT 0"),
|
||||||
|
]
|
||||||
|
for col, definition in columns:
|
||||||
|
try:
|
||||||
|
c.execute(f"ALTER TABLE users ADD COLUMN {col} {definition}")
|
||||||
|
print(f"[auth_db] Colonne '{col}' ajoutée.")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
# Column already exists — safe to ignore
|
||||||
|
pass
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print("[auth_db] Migration Telegram columns OK.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
init_auth_tables()
|
init_auth_tables()
|
||||||
|
|||||||
1456
dashboard_saas.html
1456
dashboard_saas.html
File diff suppressed because it is too large
Load Diff
32
docker-compose.broker.yml
Normal file
32
docker-compose.broker.yml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Token Broker Infrastructure
|
||||||
|
# PostgreSQL dedicated instance on port 5434
|
||||||
|
networks:
|
||||||
|
turf-net:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
services:
|
||||||
|
token-broker-db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: token-broker-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: token_broker
|
||||||
|
POSTGRES_USER: token_broker
|
||||||
|
POSTGRES_PASSWORD: ${TOKEN_BROKER_DB_PASSWORD:-CHANGE_ME_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- token-broker-pgdata:/var/lib/postgresql/data
|
||||||
|
- ./infra/postgres/token_broker_init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U token_broker -d token_broker"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- turf-net
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5434:5432"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
token-broker-pgdata:
|
||||||
|
driver: local
|
||||||
94
infra/postgres/token_broker_init.sql
Normal file
94
infra/postgres/token_broker_init.sql
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
-- Token Broker PostgreSQL init script
|
||||||
|
-- 6 tables: api_tokens, refresh_tokens, token_audit_log, clients, providers, token_usage
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL DEFAULT 'default',
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
token_prefix TEXT NOT NULL,
|
||||||
|
scopes TEXT[] DEFAULT '{}',
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
metadata JSONB DEFAULT '{}'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
token_prefix TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
revoked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
replaced_by UUID
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS token_audit_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id INTEGER,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
token_prefix TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
details JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS clients (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
client_id TEXT NOT NULL UNIQUE,
|
||||||
|
client_secret TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
redirect_uris TEXT[] DEFAULT '{}',
|
||||||
|
scopes TEXT[] DEFAULT '{}',
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS providers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
provider_type TEXT NOT NULL DEFAULT 'oauth2',
|
||||||
|
issuer_url TEXT,
|
||||||
|
client_id TEXT,
|
||||||
|
client_secret TEXT,
|
||||||
|
scopes TEXT[] DEFAULT '{}',
|
||||||
|
config JSONB DEFAULT '{}',
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS token_usage (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
token_id UUID,
|
||||||
|
action TEXT NOT NULL DEFAULT 'verify',
|
||||||
|
endpoint TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'success',
|
||||||
|
response_time_ms INTEGER,
|
||||||
|
ip_address TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON api_tokens(token_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_token_audit_log_user_id ON token_audit_log(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_token_audit_log_created_at ON token_audit_log(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_clients_client_id ON clients(client_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_providers_name ON providers(name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_token_usage_user_id ON token_usage(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_token_usage_created_at ON token_usage(created_at);
|
||||||
|
|
||||||
|
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO token_broker;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO token_broker;
|
||||||
90
infra/scripts/deploy_token_broker.sh
Executable file
90
infra/scripts/deploy_token_broker.sh
Executable file
@@ -0,0 +1,90 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ============================================================
|
||||||
|
# Deploy Token Broker — systemd service + Docker PG
|
||||||
|
# ============================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_DIR="/home/h3r7/turf_saas"
|
||||||
|
SERVICE_NAME="token-broker"
|
||||||
|
PID_FILE="/tmp/token_broker.pid"
|
||||||
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||||
|
|
||||||
|
echo "[$(date -Iseconds)] === Deploying Token Broker ==="
|
||||||
|
|
||||||
|
# Step 1: Backup current code
|
||||||
|
echo "[$(date -Iseconds)] Backing up current code..."
|
||||||
|
mkdir -p /home/h3r7/backups/token-broker
|
||||||
|
cp "${APP_DIR}/services/token-broker/token_broker_api.py" \
|
||||||
|
"/home/h3r7/backups/token-broker/token_broker_api_${TIMESTAMP}.py"
|
||||||
|
|
||||||
|
# Step 2: Ensure Docker PG is running
|
||||||
|
echo "[$(date -Iseconds)] Ensuring PostgreSQL container..."
|
||||||
|
if ! docker inspect token-broker-db >/dev/null 2>&1; then
|
||||||
|
echo "Creating PG container..."
|
||||||
|
docker run -d \
|
||||||
|
--name token-broker-db \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-e POSTGRES_DB=token_broker \
|
||||||
|
-e POSTGRES_USER=token_broker \
|
||||||
|
-e POSTGRES_PASSWORD="${TOKEN_BROKER_DB_PASSWORD}" \
|
||||||
|
-v token-broker-pgdata:/var/lib/postgresql/data \
|
||||||
|
-v "${APP_DIR}/infra/postgres/token_broker_init.sql:/docker-entrypoint-initdb.d/init.sql:ro" \
|
||||||
|
-p 127.0.0.1:5434:5432 \
|
||||||
|
postgres:16-alpine
|
||||||
|
elif ! docker ps --filter name=token-broker-db --format '{{.Status}}' | grep -q Up; then
|
||||||
|
echo "Starting existing PG container..."
|
||||||
|
docker start token-broker-db
|
||||||
|
else
|
||||||
|
echo "PG container already running."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wait for PG readiness
|
||||||
|
echo "[$(date -Iseconds)] Waiting for PG to be ready..."
|
||||||
|
for i in $(seq 1 20); do
|
||||||
|
if docker exec token-broker-db pg_isready -U token_broker -d token_broker >/dev/null 2>&1; then
|
||||||
|
echo "PG ready."
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# Step 3: Ensure psycopg2-binary is installed
|
||||||
|
echo "[$(date -Iseconds)] Checking Python deps..."
|
||||||
|
source "${APP_DIR}/venv/bin/activate"
|
||||||
|
pip install -q psycopg2-binary PyJWT flask-cors python-dotenv gunicorn 2>/dev/null || true
|
||||||
|
|
||||||
|
# Step 4: Stop current service
|
||||||
|
echo "[$(date -Iseconds)] Stopping current service..."
|
||||||
|
if systemctl is-active --quiet ${SERVICE_NAME} 2>/dev/null; then
|
||||||
|
systemctl stop ${SERVICE_NAME}
|
||||||
|
elif [ -f "$PID_FILE" ] && kill -0 $(cat "$PID_FILE") 2>/dev/null; then
|
||||||
|
kill $(cat "$PID_FILE") 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Step 5: Copy systemd unit and start
|
||||||
|
echo "[$(date -Iseconds)] Starting via systemd..."
|
||||||
|
cp "${APP_DIR}/services/token-broker/token-broker.service" /etc/systemd/system/
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable ${SERVICE_NAME}
|
||||||
|
systemctl start ${SERVICE_NAME}
|
||||||
|
|
||||||
|
# Wait for startup
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
# Step 6: Health check
|
||||||
|
echo "[$(date -Iseconds)] Running health check..."
|
||||||
|
HEALTH=$(curl -s http://127.0.0.1:8783/health 2>/dev/null || echo '{"status":"failed"}')
|
||||||
|
STATUS=$(echo "$HEALTH" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','unknown'))" 2>/dev/null || echo "unknown")
|
||||||
|
|
||||||
|
if [ "$STATUS" = "ok" ]; then
|
||||||
|
echo "[$(date -Iseconds)] ✅ Health check passed: ${HEALTH}"
|
||||||
|
echo "[$(date -Iseconds)] === Token Broker deploy SUCCESS ==="
|
||||||
|
else
|
||||||
|
echo "[$(date -Iseconds)] ❌ Health check failed: ${HEALTH}"
|
||||||
|
echo "[$(date -Iseconds)] === Token Broker deploy FAILED ==="
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 7: Clean old backups (keep last 30)
|
||||||
|
find /home/h3r7/backups/token-broker -name "*.py" -mtime +30 -delete
|
||||||
@@ -30,7 +30,9 @@ from leadhunter_crm import (
|
|||||||
insert_leads,
|
insert_leads,
|
||||||
get_leads,
|
get_leads,
|
||||||
get_lead_by_id,
|
get_lead_by_id,
|
||||||
|
update_lead,
|
||||||
update_lead_status,
|
update_lead_status,
|
||||||
|
delete_lead,
|
||||||
get_stats,
|
get_stats,
|
||||||
export_csv,
|
export_csv,
|
||||||
VALID_STATUSES,
|
VALID_STATUSES,
|
||||||
@@ -285,6 +287,59 @@ def api_update_status(lead_id: int):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/leads/<int:lead_id>", methods=["GET"])
|
||||||
|
def api_get_lead(lead_id: int):
|
||||||
|
"""
|
||||||
|
Retourne le detail d'un lead par son ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON avec les informations completes du lead, ou 404.
|
||||||
|
"""
|
||||||
|
lead = get_lead_by_id(lead_id)
|
||||||
|
if not lead:
|
||||||
|
return jsonify({"error": f"Lead id={lead_id} introuvable"}), 404
|
||||||
|
return jsonify(lead)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/leads/<int:lead_id>", methods=["PUT"])
|
||||||
|
def api_put_lead(lead_id: int):
|
||||||
|
"""
|
||||||
|
Met a jour completement un lead.
|
||||||
|
|
||||||
|
Body JSON : dict avec les champs a mettre a jour.
|
||||||
|
"""
|
||||||
|
body = request.get_json(silent=True)
|
||||||
|
if not body:
|
||||||
|
return jsonify({"error": "Body JSON requis"}), 400
|
||||||
|
|
||||||
|
lead = get_lead_by_id(lead_id)
|
||||||
|
if not lead:
|
||||||
|
return jsonify({"error": f"Lead id={lead_id} introuvable"}), 404
|
||||||
|
|
||||||
|
success = update_lead(lead_id, body)
|
||||||
|
if not success:
|
||||||
|
return jsonify({"error": "Mise a jour echouee"}), 500
|
||||||
|
|
||||||
|
updated_lead = get_lead_by_id(lead_id)
|
||||||
|
return jsonify({"success": True, "lead": updated_lead})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/leads/<int:lead_id>", methods=["DELETE"])
|
||||||
|
def api_delete_lead(lead_id: int):
|
||||||
|
"""
|
||||||
|
Supprime un lead physiquement.
|
||||||
|
"""
|
||||||
|
lead = get_lead_by_id(lead_id)
|
||||||
|
if not lead:
|
||||||
|
return jsonify({"error": f"Lead id={lead_id} introuvable"}), 404
|
||||||
|
|
||||||
|
success = delete_lead(lead_id)
|
||||||
|
if not success:
|
||||||
|
return jsonify({"error": "Suppression echouee"}), 500
|
||||||
|
|
||||||
|
return jsonify({"success": True, "lead_id": lead_id, "deleted": True})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/health", methods=["GET"])
|
@app.route("/health", methods=["GET"])
|
||||||
def health():
|
def health():
|
||||||
"""Healthcheck pour systemd / monitoring."""
|
"""Healthcheck pour systemd / monitoring."""
|
||||||
|
|||||||
@@ -52,8 +52,24 @@ if not logger.handlers:
|
|||||||
# ─── Chemin DB ───────────────────────────────────────────────────────────────
|
# ─── Chemin DB ───────────────────────────────────────────────────────────────
|
||||||
DB_PATH = "/home/h3r7/leadhunter.db"
|
DB_PATH = "/home/h3r7/leadhunter.db"
|
||||||
|
|
||||||
# Statuts valides pour un lead
|
# Statuts valides pour un lead (7 etapes Kanban)
|
||||||
VALID_STATUSES = {"new", "contacted", "closed", "rejected"}
|
VALID_STATUSES = {
|
||||||
|
"nouveau", # NOUVEAU
|
||||||
|
"contacte", # CONTACTÉ
|
||||||
|
"interesse", # INTÉRESSÉ
|
||||||
|
"demo_planifiee", # DÉMO PLANIFIÉE
|
||||||
|
"proposition_envoyee", # PROPOSITION ENVOYÉE
|
||||||
|
"negotiation", # NÉGOCIATION
|
||||||
|
"signe_ou_refuse", # SIGNÉ / REFUSÉ
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mapping des anciens statuts vers les nouveaux (pour migration)
|
||||||
|
LEGACY_STATUS_MAP = {
|
||||||
|
"new": "nouveau",
|
||||||
|
"contacted": "contacte",
|
||||||
|
"closed": "signe_ou_refuse",
|
||||||
|
"rejected": "signe_ou_refuse",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ─── Initialisation ──────────────────────────────────────────────────────────
|
# ─── Initialisation ──────────────────────────────────────────────────────────
|
||||||
@@ -212,6 +228,77 @@ def get_lead_by_id(lead_id: int, db_path: str = DB_PATH) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def update_lead(lead_id: int, data: dict, db_path: str = DB_PATH) -> bool:
|
||||||
|
"""
|
||||||
|
Met à jour un lead avec les champs fournis.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lead_id: id du lead.
|
||||||
|
data: dict avec les champs a mettre a jour (name, address, phone, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si mise a jour reussie, False sinon.
|
||||||
|
"""
|
||||||
|
allowed_fields = {
|
||||||
|
"name",
|
||||||
|
"address",
|
||||||
|
"phone",
|
||||||
|
"rating",
|
||||||
|
"reviews_count",
|
||||||
|
"website",
|
||||||
|
"score",
|
||||||
|
"rgpd_ok",
|
||||||
|
"status",
|
||||||
|
}
|
||||||
|
fields_to_update = {k: v for k, v in data.items() if k in allowed_fields}
|
||||||
|
|
||||||
|
if not fields_to_update:
|
||||||
|
logger.warning(
|
||||||
|
f"update_lead : aucun champ valide fourni pour lead_id={lead_id}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if (
|
||||||
|
"status" in fields_to_update
|
||||||
|
and fields_to_update["status"] not in VALID_STATUSES
|
||||||
|
):
|
||||||
|
logger.warning(f"update_lead : statut invalide '{fields_to_update['status']}'")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
with _get_conn(db_path) as conn:
|
||||||
|
set_clause = ", ".join([f"{k} = ?" for k in fields_to_update])
|
||||||
|
values = list(fields_to_update.values()) + [lead_id]
|
||||||
|
conn.execute(f"UPDATE leads SET {set_clause} WHERE id = ?", values)
|
||||||
|
logger.info(
|
||||||
|
f"Lead id={lead_id} mis a jour : {list(fields_to_update.keys())}"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"update_lead error : {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def delete_lead(lead_id: int, db_path: str = DB_PATH) -> bool:
|
||||||
|
"""
|
||||||
|
Supprime un lead physiquement.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lead_id: id du lead a supprimer.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si suppression reussie, False sinon.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with _get_conn(db_path) as conn:
|
||||||
|
conn.execute("DELETE FROM leads WHERE id = ?", (lead_id,))
|
||||||
|
logger.info(f"Lead id={lead_id} supprime")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"delete_lead error : {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def update_lead_status(lead_id: int, status: str, db_path: str = DB_PATH) -> bool:
|
def update_lead_status(lead_id: int, status: str, db_path: str = DB_PATH) -> bool:
|
||||||
"""
|
"""
|
||||||
Met à jour le statut d'un lead.
|
Met à jour le statut d'un lead.
|
||||||
|
|||||||
600
ml_feedback_saas.py
Normal file
600
ml_feedback_saas.py
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
ml_feedback_saas.py — Feedback loop ML pour turf_saas.
|
||||||
|
Enregistre les paris virtuels XGBoost depuis ml_predictions_cache
|
||||||
|
et met à jour les résultats/dividendes depuis pmu_partants + pmu_rapports.
|
||||||
|
|
||||||
|
DB cible : /home/h3r7/turf_saas/turf_saas.db
|
||||||
|
|
||||||
|
Stratégies :
|
||||||
|
A) xgboost_sg : simple_gagnant — top1 ML par course, ml_score >= 70, mise 1€
|
||||||
|
B) xgboost_value : simple_gagnant — is_value_bet = 1, mise 1€
|
||||||
|
C) xgboost_sp : simple_place — top1 ML par course, ml_score >= 50, mise 1€
|
||||||
|
D) xgboost_2sur4 : deux_sur_quatre — top4 ML par course, 6 combos x 1€ = mise 6€
|
||||||
|
|
||||||
|
Usage :
|
||||||
|
python3 ml_feedback_saas.py # Traite aujourd'hui
|
||||||
|
python3 ml_feedback_saas.py --backfill 2026-04-25
|
||||||
|
python3 ml_feedback_saas.py --date 2026-04-25
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
DB_PATH = "/home/h3r7/turf_saas/turf_saas.db"
|
||||||
|
|
||||||
|
os.makedirs("/home/h3r7/turf_saas/logs", exist_ok=True)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [ml_feedback_saas] %(levelname)s %(message)s",
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler("/home/h3r7/turf_saas/logs/ml_feedback_saas.log"),
|
||||||
|
logging.StreamHandler(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# UTILITAIRES
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def pari_existe(cursor, date, num_reunion, num_course, numero1, type_pari, source_reco):
|
||||||
|
"""Vérifie si un pari identique existe déjà (idempotence)."""
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM paris
|
||||||
|
WHERE date_course = ? AND source_reco = ?
|
||||||
|
AND type_pari = ? AND numero1 = ?
|
||||||
|
AND race_label = ?
|
||||||
|
""",
|
||||||
|
(date, source_reco, type_pari, numero1, f"R{num_reunion}C{num_course}"),
|
||||||
|
)
|
||||||
|
return cursor.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def pari_2sur4_existe(cursor, date, num_reunion, num_course, source_reco):
|
||||||
|
"""Vérifie si un pari 2sur4 existe déjà pour cette course."""
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM paris
|
||||||
|
WHERE date_course = ? AND source_reco = ?
|
||||||
|
AND race_label = ?
|
||||||
|
""",
|
||||||
|
(date, source_reco, f"R{num_reunion}C{num_course}"),
|
||||||
|
)
|
||||||
|
return cursor.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def get_top_ml_par_course(cursor, date, n=4, min_score=0):
|
||||||
|
"""Retourne les n meilleurs chevaux ML par course pour une date."""
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT num_reunion, num_course, horse_name, horse_number,
|
||||||
|
ml_score, odds, recommendation, is_value_bet,
|
||||||
|
race_label, race_name, hippodrome, heure,
|
||||||
|
discipline, distance
|
||||||
|
FROM ml_predictions_cache
|
||||||
|
WHERE date = ?
|
||||||
|
AND ml_score >= ?
|
||||||
|
ORDER BY num_reunion, num_course, ml_score DESC
|
||||||
|
""",
|
||||||
|
(date, min_score),
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
courses = {}
|
||||||
|
for r in rows:
|
||||||
|
key = (r["num_reunion"], r["num_course"])
|
||||||
|
if key not in courses:
|
||||||
|
courses[key] = []
|
||||||
|
if len(courses[key]) < n:
|
||||||
|
courses[key].append(dict(r))
|
||||||
|
return courses
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# STRATÉGIE A — Simple Gagnant top1 ML (score >= 70)
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def save_ml_paris_sg(conn, date):
|
||||||
|
"""Insère 1 pari simple_gagnant par course : top1 ML avec ml_score >= 70."""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
courses = get_top_ml_par_course(cursor, date, n=1, min_score=70)
|
||||||
|
inseres = 0
|
||||||
|
|
||||||
|
for (num_reunion, num_course), chevaux in courses.items():
|
||||||
|
cheval = chevaux[0]
|
||||||
|
if pari_existe(
|
||||||
|
cursor,
|
||||||
|
date,
|
||||||
|
num_reunion,
|
||||||
|
num_course,
|
||||||
|
cheval["horse_number"],
|
||||||
|
"simple_gagnant",
|
||||||
|
"xgboost_sg",
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO paris
|
||||||
|
(date_pari, date_course, race_name, race_label, hippodrome,
|
||||||
|
type_pari, chevaux, cheval1, numero1, cote, mise,
|
||||||
|
statut, gain, source_reco, model_source)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'simple_gagnant', ?, ?, ?, ?, 1.0,
|
||||||
|
'EN_ATTENTE', 0.0, 'xgboost_sg', 'xgboost_v1')
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
date,
|
||||||
|
date,
|
||||||
|
cheval.get("race_name") or "",
|
||||||
|
f"R{num_reunion}C{num_course}",
|
||||||
|
cheval.get("hippodrome") or "",
|
||||||
|
cheval["horse_name"],
|
||||||
|
cheval["horse_name"],
|
||||||
|
cheval["horse_number"],
|
||||||
|
cheval["odds"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
inseres += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
log.info(f"[SG] {date} → {inseres} paris simple_gagnant insérés (score>=70)")
|
||||||
|
return inseres
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# STRATÉGIE B — Value Bet (is_value_bet = 1)
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def save_ml_paris_value(conn, date):
|
||||||
|
"""Insère 1 pari simple_gagnant pour chaque cheval is_value_bet=1."""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT num_reunion, num_course, horse_name, horse_number,
|
||||||
|
ml_score, odds, race_label, race_name, hippodrome
|
||||||
|
FROM ml_predictions_cache
|
||||||
|
WHERE date = ? AND is_value_bet = 1
|
||||||
|
ORDER BY num_reunion, num_course, ml_score DESC
|
||||||
|
""",
|
||||||
|
(date,),
|
||||||
|
)
|
||||||
|
rows = [dict(r) for r in cursor.fetchall()]
|
||||||
|
inseres = 0
|
||||||
|
|
||||||
|
for r in rows:
|
||||||
|
if pari_existe(
|
||||||
|
cursor,
|
||||||
|
date,
|
||||||
|
r["num_reunion"],
|
||||||
|
r["num_course"],
|
||||||
|
r["horse_number"],
|
||||||
|
"simple_gagnant",
|
||||||
|
"xgboost_value",
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO paris
|
||||||
|
(date_pari, date_course, race_name, race_label, hippodrome,
|
||||||
|
type_pari, chevaux, cheval1, numero1, cote, mise,
|
||||||
|
statut, gain, source_reco, model_source)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'simple_gagnant', ?, ?, ?, ?, 1.0,
|
||||||
|
'EN_ATTENTE', 0.0, 'xgboost_value', 'xgboost_v1')
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
date,
|
||||||
|
date,
|
||||||
|
r.get("race_name") or "",
|
||||||
|
r.get("race_label") or f"R{r['num_reunion']}C{r['num_course']}",
|
||||||
|
r.get("hippodrome") or "",
|
||||||
|
r["horse_name"],
|
||||||
|
r["horse_name"],
|
||||||
|
r["horse_number"],
|
||||||
|
r["odds"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
inseres += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
log.info(f"[VALUE] {date} → {inseres} paris value_bet insérés")
|
||||||
|
return inseres
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# STRATÉGIE C — Simple Placé top1 ML (score >= 50)
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def save_ml_paris_sp(conn, date):
|
||||||
|
"""Insère 1 pari simple_place par course : top1 ML avec ml_score >= 50."""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
courses = get_top_ml_par_course(cursor, date, n=1, min_score=50)
|
||||||
|
inseres = 0
|
||||||
|
|
||||||
|
for (num_reunion, num_course), chevaux in courses.items():
|
||||||
|
cheval = chevaux[0]
|
||||||
|
if pari_existe(
|
||||||
|
cursor,
|
||||||
|
date,
|
||||||
|
num_reunion,
|
||||||
|
num_course,
|
||||||
|
cheval["horse_number"],
|
||||||
|
"simple_place",
|
||||||
|
"xgboost_sp",
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO paris
|
||||||
|
(date_pari, date_course, race_name, race_label, hippodrome,
|
||||||
|
type_pari, chevaux, cheval1, numero1, cote, mise,
|
||||||
|
statut, gain, source_reco, model_source)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'simple_place', ?, ?, ?, ?, 1.0,
|
||||||
|
'EN_ATTENTE', 0.0, 'xgboost_sp', 'xgboost_v1')
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
date,
|
||||||
|
date,
|
||||||
|
cheval.get("race_name") or "",
|
||||||
|
f"R{num_reunion}C{num_course}",
|
||||||
|
cheval.get("hippodrome") or "",
|
||||||
|
cheval["horse_name"],
|
||||||
|
cheval["horse_name"],
|
||||||
|
cheval["horse_number"],
|
||||||
|
cheval["odds"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
inseres += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
log.info(f"[SP] {date} → {inseres} paris simple_place insérés (score>=50)")
|
||||||
|
return inseres
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# STRATÉGIE D — 2sur4 top4 ML (6 combinaisons x 1€)
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def save_ml_paris_2sur4(conn, date):
|
||||||
|
"""Insère 1 pari deux_sur_quatre par course : top4 ML, mise 6€."""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
courses = get_top_ml_par_course(cursor, date, n=4, min_score=0)
|
||||||
|
inseres = 0
|
||||||
|
|
||||||
|
for (num_reunion, num_course), chevaux in courses.items():
|
||||||
|
if len(chevaux) < 4:
|
||||||
|
continue
|
||||||
|
if pari_2sur4_existe(cursor, date, num_reunion, num_course, "xgboost_2sur4"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
top4 = chevaux[:4]
|
||||||
|
nums = [str(c["horse_number"]) for c in top4]
|
||||||
|
noms = [c["horse_name"] for c in top4]
|
||||||
|
chevaux_str = "/".join(noms)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO paris
|
||||||
|
(date_pari, date_course, race_name, race_label, hippodrome,
|
||||||
|
type_pari, chevaux, cheval1, numero1, cote, mise,
|
||||||
|
statut, gain, source_reco, model_source, commentaire)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'deux_sur_quatre', ?, ?, ?, 0.0, 6.0,
|
||||||
|
'EN_ATTENTE', 0.0, 'xgboost_2sur4', 'xgboost_v1', ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
date,
|
||||||
|
date,
|
||||||
|
top4[0].get("race_name") or "",
|
||||||
|
f"R{num_reunion}C{num_course}",
|
||||||
|
top4[0].get("hippodrome") or "",
|
||||||
|
chevaux_str,
|
||||||
|
top4[0]["horse_name"],
|
||||||
|
top4[0]["horse_number"],
|
||||||
|
f"top4 ML: {'/'.join(nums)}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
inseres += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
log.info(f"[2S4] {date} → {inseres} paris deux_sur_quatre insérés")
|
||||||
|
return inseres
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# UPDATE RÉSULTATS + DIVIDENDES
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def update_ml_paris_results(conn, date):
|
||||||
|
"""
|
||||||
|
Met à jour statut + gain (dividende PMU réel) pour tous les paris ML EN_ATTENTE.
|
||||||
|
Sources: pmu_partants (ordre_arrivee) + pmu_rapports (dividende_euro).
|
||||||
|
"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, race_label, type_pari, numero1, chevaux, mise, source_reco, commentaire
|
||||||
|
FROM paris
|
||||||
|
WHERE date_course = ? AND statut = 'EN_ATTENTE'
|
||||||
|
AND source_reco LIKE 'xgboost%'
|
||||||
|
""",
|
||||||
|
(date,),
|
||||||
|
)
|
||||||
|
paris = [dict(r) for r in cursor.fetchall()]
|
||||||
|
|
||||||
|
if not paris:
|
||||||
|
log.info(f"[UPDATE] {date} → aucun pari ML EN_ATTENTE")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
maj = 0
|
||||||
|
for pari in paris:
|
||||||
|
pari_id = pari["id"]
|
||||||
|
race_label = pari["race_label"] or ""
|
||||||
|
type_pari = pari["type_pari"]
|
||||||
|
numero1 = pari["numero1"]
|
||||||
|
mise = pari["mise"]
|
||||||
|
|
||||||
|
# Extraire num_reunion / num_course depuis le race_label "R{r}C{c}"
|
||||||
|
try:
|
||||||
|
parts = race_label.replace("R", "").split("C")
|
||||||
|
num_reunion = int(parts[0])
|
||||||
|
num_course = int(parts[1])
|
||||||
|
except Exception:
|
||||||
|
log.warning(f"[UPDATE] race_label invalide : {race_label}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if type_pari == "simple_gagnant":
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT ordre_arrivee FROM pmu_partants
|
||||||
|
WHERE date_programme = ? AND num_reunion = ?
|
||||||
|
AND num_course = ? AND num_pmu = ?
|
||||||
|
""",
|
||||||
|
(date, num_reunion, num_course, numero1),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row or row["ordre_arrivee"] is None or row["ordre_arrivee"] == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
gagne = row["ordre_arrivee"] == 1
|
||||||
|
gain = 0.0
|
||||||
|
if gagne:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT dividende_euro FROM pmu_rapports
|
||||||
|
WHERE date_programme = ? AND num_reunion = ?
|
||||||
|
AND num_course = ? AND type_pari = 'SIMPLE_GAGNANT'
|
||||||
|
AND CAST(combinaison AS INTEGER) = ?
|
||||||
|
AND libelle NOT LIKE '%NP%'
|
||||||
|
""",
|
||||||
|
(date, num_reunion, num_course, numero1),
|
||||||
|
)
|
||||||
|
div = cursor.fetchone()
|
||||||
|
gain = div["dividende_euro"] if div and div["dividende_euro"] else 0.0
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE paris SET statut=?, gain=? WHERE id=?",
|
||||||
|
("GAGNE" if gagne else "PERDU", gain, pari_id),
|
||||||
|
)
|
||||||
|
maj += 1
|
||||||
|
|
||||||
|
elif type_pari == "simple_place":
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT ordre_arrivee FROM pmu_partants
|
||||||
|
WHERE date_programme = ? AND num_reunion = ?
|
||||||
|
AND num_course = ? AND num_pmu = ?
|
||||||
|
""",
|
||||||
|
(date, num_reunion, num_course, numero1),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row or not row["ordre_arrivee"]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
gagne = 1 <= row["ordre_arrivee"] <= 3
|
||||||
|
gain = 0.0
|
||||||
|
if gagne:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT dividende_euro FROM pmu_rapports
|
||||||
|
WHERE date_programme = ? AND num_reunion = ?
|
||||||
|
AND num_course = ? AND type_pari = 'SIMPLE_PLACE'
|
||||||
|
AND CAST(combinaison AS INTEGER) = ?
|
||||||
|
AND libelle NOT LIKE '%NP%'
|
||||||
|
""",
|
||||||
|
(date, num_reunion, num_course, numero1),
|
||||||
|
)
|
||||||
|
div = cursor.fetchone()
|
||||||
|
gain = div["dividende_euro"] if div and div["dividende_euro"] else 0.0
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE paris SET statut=?, gain=? WHERE id=?",
|
||||||
|
("GAGNE" if gagne else "PERDU", gain, pari_id),
|
||||||
|
)
|
||||||
|
maj += 1
|
||||||
|
|
||||||
|
elif type_pari == "deux_sur_quatre":
|
||||||
|
# Récupère les 4 numéros depuis commentaire "top4 ML: n1/n2/n3/n4"
|
||||||
|
try:
|
||||||
|
nums_str = (
|
||||||
|
pari["commentaire"].split(": ")[1]
|
||||||
|
if pari.get("commentaire")
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
nums_top4 = [int(n) for n in nums_str.split("/") if n.strip().isdigit()]
|
||||||
|
except Exception:
|
||||||
|
nums_top4 = []
|
||||||
|
|
||||||
|
if len(nums_top4) < 4:
|
||||||
|
# Fallback : reconstituer top4 depuis ml_predictions_cache
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT horse_number FROM ml_predictions_cache
|
||||||
|
WHERE date = ? AND num_reunion = ? AND num_course = ?
|
||||||
|
ORDER BY ml_score DESC LIMIT 4
|
||||||
|
""",
|
||||||
|
(date, num_reunion, num_course),
|
||||||
|
)
|
||||||
|
nums_top4 = [r["horse_number"] for r in cursor.fetchall()]
|
||||||
|
|
||||||
|
if len(nums_top4) < 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT combinaison, dividende_euro FROM pmu_rapports
|
||||||
|
WHERE date_programme = ? AND num_reunion = ?
|
||||||
|
AND num_course = ? AND type_pari = 'DEUX_SUR_QUATRE'
|
||||||
|
AND libelle NOT LIKE '%NP%'
|
||||||
|
""",
|
||||||
|
(date, num_reunion, num_course),
|
||||||
|
)
|
||||||
|
rapports = [dict(r) for r in cursor.fetchall()]
|
||||||
|
gain_total = 0.0
|
||||||
|
|
||||||
|
for rap in rapports:
|
||||||
|
try:
|
||||||
|
n1, n2 = [int(x) for x in rap["combinaison"].split("-")]
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if n1 in nums_top4 and n2 in nums_top4:
|
||||||
|
gain_total += rap["dividende_euro"]
|
||||||
|
|
||||||
|
gagne = gain_total > 0
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE paris SET statut=?, gain=? WHERE id=?",
|
||||||
|
("GAGNE" if gagne else "PERDU", round(gain_total, 2), pari_id),
|
||||||
|
)
|
||||||
|
maj += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
log.info(f"[UPDATE] {date} → {maj}/{len(paris)} paris ML mis à jour")
|
||||||
|
return maj
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# STATS PAR STRATÉGIE
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def get_feedback_stats(conn, date_debut=None, date_fin=None):
|
||||||
|
"""Stats performances ML par stratégie (source_reco)."""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT source_reco,
|
||||||
|
COUNT(*) as n_paris,
|
||||||
|
SUM(CASE WHEN statut='GAGNE' THEN 1 ELSE 0 END) as n_gagne,
|
||||||
|
SUM(CASE WHEN statut='PERDU' THEN 1 ELSE 0 END) as n_perdu,
|
||||||
|
SUM(CASE WHEN statut='EN_ATTENTE' THEN 1 ELSE 0 END) as n_attente,
|
||||||
|
ROUND(100.0 * SUM(CASE WHEN statut='GAGNE' THEN 1 ELSE 0 END)
|
||||||
|
/ NULLIF(SUM(CASE WHEN statut IN ('GAGNE','PERDU') THEN 1 ELSE 0 END), 0), 1) as win_rate_pct,
|
||||||
|
ROUND(SUM(gain), 2) as gain_total,
|
||||||
|
ROUND(SUM(mise), 2) as mise_totale,
|
||||||
|
ROUND(SUM(gain) - SUM(mise), 2) as roi_net
|
||||||
|
FROM paris
|
||||||
|
WHERE source_reco LIKE 'xgboost%'
|
||||||
|
AND (:debut IS NULL OR date_course >= :debut)
|
||||||
|
AND (:fin IS NULL OR date_course <= :fin)
|
||||||
|
GROUP BY source_reco
|
||||||
|
ORDER BY source_reco
|
||||||
|
""",
|
||||||
|
{"debut": date_debut, "fin": date_fin},
|
||||||
|
)
|
||||||
|
return [dict(r) for r in cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# PIPELINE COMPLET
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def run(date):
|
||||||
|
"""Enregistre les paris ML du jour + met à jour les résultats de J-1."""
|
||||||
|
conn = get_db()
|
||||||
|
log.info(f"=== ml_feedback_saas.run({date}) ===")
|
||||||
|
|
||||||
|
# 1. Enregistre les paris ML pour la date (depuis le cache du jour)
|
||||||
|
sg = save_ml_paris_sg(conn, date)
|
||||||
|
vb = save_ml_paris_value(conn, date)
|
||||||
|
sp = save_ml_paris_sp(conn, date)
|
||||||
|
s4 = save_ml_paris_2sur4(conn, date)
|
||||||
|
log.info(f"[SAVE] {date} → total insérés : SG={sg} VALUE={vb} SP={sp} 2S4={s4}")
|
||||||
|
|
||||||
|
# 2. Met à jour les résultats de J-1 (résultats PMU disponibles)
|
||||||
|
yesterday = (datetime.strptime(date, "%Y-%m-%d") - timedelta(days=1)).strftime(
|
||||||
|
"%Y-%m-%d"
|
||||||
|
)
|
||||||
|
maj = update_ml_paris_results(conn, yesterday)
|
||||||
|
log.info(f"[UPDATE] {yesterday} → {maj} paris mis à jour")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return {"inseres": {"sg": sg, "value": vb, "sp": sp, "2sur4": s4}, "maj": maj}
|
||||||
|
|
||||||
|
|
||||||
|
def backfill(date):
|
||||||
|
"""Backfill : insère ET met à jour les résultats pour une date passée."""
|
||||||
|
conn = get_db()
|
||||||
|
log.info(f"=== ml_feedback_saas.backfill({date}) ===")
|
||||||
|
|
||||||
|
sg = save_ml_paris_sg(conn, date)
|
||||||
|
vb = save_ml_paris_value(conn, date)
|
||||||
|
sp = save_ml_paris_sp(conn, date)
|
||||||
|
s4 = save_ml_paris_2sur4(conn, date)
|
||||||
|
log.info(f"[SAVE] {date} → SG={sg} VALUE={vb} SP={sp} 2S4={s4}")
|
||||||
|
|
||||||
|
maj = update_ml_paris_results(conn, date)
|
||||||
|
log.info(f"[UPDATE] {date} → {maj} paris mis à jour")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return sg + vb + sp + s4, maj
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# MAIN
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if "--backfill" in sys.argv:
|
||||||
|
idx = sys.argv.index("--backfill")
|
||||||
|
date = sys.argv[idx + 1] if idx + 1 < len(sys.argv) else None
|
||||||
|
if not date:
|
||||||
|
print("Usage: python3 ml_feedback_saas.py --backfill YYYY-MM-DD")
|
||||||
|
sys.exit(1)
|
||||||
|
inseres, maj = backfill(date)
|
||||||
|
print(f"Backfill {date} : {inseres} paris insérés, {maj} mis à jour")
|
||||||
|
|
||||||
|
elif "--date" in sys.argv:
|
||||||
|
idx = sys.argv.index("--date")
|
||||||
|
date = sys.argv[idx + 1] if idx + 1 < len(sys.argv) else None
|
||||||
|
if not date:
|
||||||
|
print("Usage: python3 ml_feedback_saas.py --date YYYY-MM-DD")
|
||||||
|
sys.exit(1)
|
||||||
|
result = run(date)
|
||||||
|
total = sum(result["inseres"].values())
|
||||||
|
print(f"Run {date} : {total} paris insérés, {result['maj']} mis à jour")
|
||||||
|
|
||||||
|
else:
|
||||||
|
result = run(datetime.now().strftime("%Y-%m-%d"))
|
||||||
|
total = sum(result["inseres"].values())
|
||||||
|
print(f"Run today : {total} paris insérés, {result['maj']} mis à jour")
|
||||||
72
org_db.py
Normal file
72
org_db.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Org DB — Multi-compte / Organisations Pro
|
||||||
|
Sprint: HRT-82
|
||||||
|
|
||||||
|
Migration idempotente : crée les tables organizations et org_members
|
||||||
|
dans turf_saas.db si elles n'existent pas.
|
||||||
|
|
||||||
|
Run une seule fois :
|
||||||
|
./venv/bin/python org_db.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||||
|
logger = logging.getLogger("turf_saas.org_db")
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_org_tables():
|
||||||
|
"""
|
||||||
|
Migration idempotente : crée organizations + org_members.
|
||||||
|
|
||||||
|
- organizations : 1 org max par owner (enforced en Python + UNIQUE owner_id)
|
||||||
|
- org_members : max 5 membres totaux (owner inclus, enforced en Python)
|
||||||
|
- UNIQUE(org_id, user_id) empêche les doublons de membres
|
||||||
|
"""
|
||||||
|
conn = get_db()
|
||||||
|
c = conn.cursor()
|
||||||
|
|
||||||
|
c.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS organizations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
owner_id TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
max_members INTEGER NOT NULL DEFAULT 5,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS org_members (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'member'
|
||||||
|
CHECK(role IN ('owner', 'member')),
|
||||||
|
invited_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||||
|
joined_at DATETIME,
|
||||||
|
UNIQUE(org_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_org_owner ON organizations(owner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orgmem_org ON org_members(org_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orgmem_user ON org_members(user_id);
|
||||||
|
""")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
logger.info("[org_db] Tables organizations + org_members créées/vérifiées.")
|
||||||
|
print("[org_db] Migration OK: organizations, org_members.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
migrate_org_tables()
|
||||||
@@ -18,10 +18,12 @@ SAAS_DIR = "/home/h3r7/turf_saas"
|
|||||||
# ─── SaaS Auth & API v1 blueprints ────────────────────────────────────────────
|
# ─── SaaS Auth & API v1 blueprints ────────────────────────────────────────────
|
||||||
try:
|
try:
|
||||||
from saas_auth import auth_bp
|
from saas_auth import auth_bp
|
||||||
from saas_api_v1 import api_v1_bp
|
from saas_api_v1 import saas_api_v1_bp
|
||||||
|
from api_v1 import register_api_v1
|
||||||
|
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(api_v1_bp)
|
app.register_blueprint(saas_api_v1_bp)
|
||||||
|
register_api_v1(app)
|
||||||
print("[portal] SaaS auth & API v1 blueprints registered ✅")
|
print("[portal] SaaS auth & API v1 blueprints registered ✅")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[portal] Warning: could not register SaaS blueprints: {e}")
|
print(f"[portal] Warning: could not register SaaS blueprints: {e}")
|
||||||
@@ -352,6 +354,29 @@ def template_complet():
|
|||||||
return send_from_directory("/home/h3r7/turf_saas", "template_complet.html")
|
return send_from_directory("/home/h3r7/turf_saas", "template_complet.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/leadhunter/clients/le-big-ben/")
|
||||||
|
@app.route("/leadhunter/clients/le-big-ben")
|
||||||
|
def big_ben():
|
||||||
|
return send_from_directory(
|
||||||
|
"/home/h3r7/turf_saas/templates/leadhunter/clients/le-big-ben", "index.html"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/leadhunter/clients/le-big-ben/sitemap.xml")
|
||||||
|
def big_ben_sitemap():
|
||||||
|
return send_from_directory(
|
||||||
|
"/home/h3r7/turf_saas/templates/leadhunter/clients/le-big-ben",
|
||||||
|
"sitemap.xml",
|
||||||
|
mimetype="application/xml",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/formation/ai102")
|
||||||
|
@app.route("/formation/ai102/")
|
||||||
|
def certif_ai102():
|
||||||
|
return send_from_directory("/home/h3r7/turf_saas/pitch", "certif-ai102.html")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/boite_a_idees_dashboard")
|
@app.route("/boite_a_idees_dashboard")
|
||||||
def boite_a_idees_dashboard():
|
def boite_a_idees_dashboard():
|
||||||
return send_from_directory("/home/h3r7/turf_saas", "boite_a_idees_dashboard.html")
|
return send_from_directory("/home/h3r7/turf_saas", "boite_a_idees_dashboard.html")
|
||||||
|
|||||||
@@ -31,3 +31,6 @@ python-dotenv==1.1.0
|
|||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
python-dateutil==2.9.0
|
python-dateutil==2.9.0
|
||||||
|
|
||||||
|
# Hyperparameter optimization (ML ensemble tuning — HRT-136)
|
||||||
|
optuna>=4.0.0
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from saas_auth import require_auth
|
|||||||
|
|
||||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||||
|
|
||||||
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
|
saas_api_v1_bp = Blueprint("saas_api_v1", __name__, url_prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
@@ -30,7 +30,7 @@ def plan_allows(user_plan: str, required: str) -> bool:
|
|||||||
# ─── Stats ────────────────────────────────────────────────────────────────────
|
# ─── Stats ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@api_v1_bp.route("/stats/summary", methods=["GET"])
|
@saas_api_v1_bp.route("/stats/summary", methods=["GET"])
|
||||||
@require_auth
|
@require_auth
|
||||||
def stats_summary():
|
def stats_summary():
|
||||||
"""GET /api/v1/stats/summary — résumé dashboard."""
|
"""GET /api/v1/stats/summary — résumé dashboard."""
|
||||||
@@ -94,7 +94,7 @@ def stats_summary():
|
|||||||
# ─── Predictions ──────────────────────────────────────────────────────────────
|
# ─── Predictions ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@api_v1_bp.route("/predictions/today", methods=["GET"])
|
@saas_api_v1_bp.route("/predictions/today", methods=["GET"])
|
||||||
@require_auth
|
@require_auth
|
||||||
def predictions_today():
|
def predictions_today():
|
||||||
"""GET /api/v1/predictions/today — prédictions du jour selon le plan."""
|
"""GET /api/v1/predictions/today — prédictions du jour selon le plan."""
|
||||||
@@ -149,7 +149,7 @@ def predictions_today():
|
|||||||
return jsonify({"error": str(e), "predictions": []}), 200
|
return jsonify({"error": str(e), "predictions": []}), 200
|
||||||
|
|
||||||
|
|
||||||
@api_v1_bp.route("/predictions/race/<race_label>", methods=["GET"])
|
@saas_api_v1_bp.route("/predictions/race/<race_label>", methods=["GET"])
|
||||||
@require_auth
|
@require_auth
|
||||||
def predictions_race(race_label):
|
def predictions_race(race_label):
|
||||||
"""GET /api/v1/predictions/race/<label> — prédictions d'une course."""
|
"""GET /api/v1/predictions/race/<label> — prédictions d'une course."""
|
||||||
@@ -187,7 +187,7 @@ def predictions_race(race_label):
|
|||||||
# ─── Value Bets ───────────────────────────────────────────────────────────────
|
# ─── Value Bets ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@api_v1_bp.route("/value-bets/today", methods=["GET"])
|
@saas_api_v1_bp.route("/value-bets/today", methods=["GET"])
|
||||||
@require_auth
|
@require_auth
|
||||||
def value_bets_today():
|
def value_bets_today():
|
||||||
"""GET /api/v1/value-bets/today — value bets (Premium+)."""
|
"""GET /api/v1/value-bets/today — value bets (Premium+)."""
|
||||||
@@ -220,7 +220,7 @@ def value_bets_today():
|
|||||||
# ─── Export ───────────────────────────────────────────────────────────────────
|
# ─── Export ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@api_v1_bp.route("/export/csv", methods=["GET"])
|
@saas_api_v1_bp.route("/export/csv", methods=["GET"])
|
||||||
@require_auth
|
@require_auth
|
||||||
def export_csv():
|
def export_csv():
|
||||||
"""GET /api/v1/export/csv — export CSV (Pro only)."""
|
"""GET /api/v1/export/csv — export CSV (Pro only)."""
|
||||||
@@ -257,26 +257,23 @@ def export_csv():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ─── Billing Blueprint (Stripe) + JWT init — HRT-49 ─────────────────────────
|
# ─── JWT init — HRT-49 ────────────────────────────────────────────────────────
|
||||||
# Registers /api/v1/billing/* routes via nested Blueprint (Flask 2.0+)
|
# Initialize JWTManager on the Flask app (required for jwt_required_middleware)
|
||||||
# Also initializes JWTManager on the Flask app (required for jwt_required_middleware)
|
# Called when saas_api_v1_bp is registered (portal_server.py)
|
||||||
try:
|
try:
|
||||||
from flask_jwt_extended import JWTManager
|
from flask_jwt_extended import JWTManager
|
||||||
from api_v1.routes.billing import billing_bp
|
|
||||||
|
|
||||||
# Initialize JWTManager on the Flask app when api_v1_bp is registered
|
@saas_api_v1_bp.record_once
|
||||||
@api_v1_bp.record_once
|
|
||||||
def _init_jwt(state):
|
def _init_jwt(state):
|
||||||
app = state.app
|
app = state.app
|
||||||
if not app.config.get('JWT_SECRET_KEY'):
|
if not app.config.get("JWT_SECRET_KEY"):
|
||||||
import os
|
import os
|
||||||
app.config['JWT_SECRET_KEY'] = os.environ.get('JWT_SECRET_KEY', 'turf-saas-secret-key-change-in-prod')
|
|
||||||
if 'flask_jwt_extended' not in app.extensions:
|
|
||||||
JWTManager(app)
|
|
||||||
|
|
||||||
# Register billing blueprint with url_prefix='/billing'
|
app.config["JWT_SECRET_KEY"] = os.environ.get(
|
||||||
# (parent api_v1_bp has '/api/v1', so result is /api/v1/billing/*)
|
"JWT_SECRET_KEY", "turf-saas-secret-key-change-in-prod"
|
||||||
api_v1_bp.register_blueprint(billing_bp, url_prefix='/billing')
|
)
|
||||||
print('[saas_api_v1] Billing blueprint (Stripe) + JWT registered ✅')
|
if "flask_jwt_extended" not in app.extensions:
|
||||||
except Exception as _billing_err:
|
JWTManager(app)
|
||||||
print(f'[saas_api_v1] Warning: billing blueprint not loaded: {_billing_err}')
|
print("[saas_api_v1] JWT init registered ✅")
|
||||||
|
except Exception as _jwt_err:
|
||||||
|
print(f"[saas_api_v1] Warning: JWT init not loaded: {_jwt_err}")
|
||||||
|
|||||||
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
|
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
|
||||||
|
|||||||
479
scoring_v2.py
479
scoring_v2.py
@@ -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()
|
||||||
|
|||||||
10
services/token-broker/.env.example
Normal file
10
services/token-broker/.env.example
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Token Broker API — Configuration
|
||||||
|
TOKEN_BROKER_PORT=8783
|
||||||
|
TOKEN_BROKER_DB_HOST=127.0.0.1
|
||||||
|
TOKEN_BROKER_DB_PORT=5434
|
||||||
|
TOKEN_BROKER_DB_NAME=token_broker
|
||||||
|
TOKEN_BROKER_DB_USER=token_broker
|
||||||
|
TOKEN_BROKER_DB_PASSWORD=CHANGE_ME
|
||||||
|
TOKEN_BROKER_JWT_SECRET=CHANGE_ME_GENERATE_64_HEX
|
||||||
|
TOKEN_BROKER_ACCESS_EXPIRY=900
|
||||||
|
TOKEN_BROKER_REFRESH_EXPIRY=2592000
|
||||||
6
services/token-broker/requirements.txt
Normal file
6
services/token-broker/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
Flask==3.1.3
|
||||||
|
flask-cors==5.0.1
|
||||||
|
gunicorn==23.0.0
|
||||||
|
psycopg2-binary==2.9.12
|
||||||
|
PyJWT==2.10.1
|
||||||
|
python-dotenv==1.1.0
|
||||||
21
services/token-broker/token-broker.service
Normal file
21
services/token-broker/token-broker.service
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Token Broker API (Port 8783)
|
||||||
|
Documentation=https://portal-kolifee.duckdns.org
|
||||||
|
After=network.target postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=h3r7
|
||||||
|
WorkingDirectory=/home/h3r7/turf_saas/services/token-broker
|
||||||
|
|
||||||
|
EnvironmentFile=/home/h3r7/turf_saas/services/token-broker/.env
|
||||||
|
Environment=PYTHONPATH=/home/h3r7/turf_saas
|
||||||
|
Environment=FLASK_ENV=production
|
||||||
|
|
||||||
|
ExecStart=/home/h3r7/turf_saas/venv/bin/python3 /home/h3r7/turf_saas/services/token-broker/token_broker_api.py
|
||||||
|
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
679
services/token-broker/token_broker_api.py
Normal file
679
services/token-broker/token_broker_api.py
Normal file
@@ -0,0 +1,679 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Token Broker API — JWT token management service
|
||||||
|
Port: 8783 | DB: PostgreSQL 5434
|
||||||
|
HRT-198 — Setup infra (PostgreSQL + Flask scaffold)
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
GET /health — Healthcheck
|
||||||
|
POST /api/v1/tokens — Issue new token (create)
|
||||||
|
GET /api/v1/tokens/:id — Get token by ID
|
||||||
|
POST /api/v1/tokens/verify — Verify token
|
||||||
|
POST /api/v1/tokens/revoke/:id — Revoke token
|
||||||
|
GET /api/v1/tokens/user/:userId — List tokens for user
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import logging
|
||||||
|
import logging.handlers
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from flask import Flask, request, jsonify, g
|
||||||
|
from flask_cors import CORS
|
||||||
|
|
||||||
|
LOG_DIR = os.path.join(os.path.dirname(__file__), "logs")
|
||||||
|
os.makedirs(LOG_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] token-broker: %(name)s: %(message)s",
|
||||||
|
handlers=[
|
||||||
|
logging.StreamHandler(sys.stdout),
|
||||||
|
logging.handlers.RotatingFileHandler(
|
||||||
|
os.path.join(LOG_DIR, "token_broker.log"),
|
||||||
|
maxBytes=5 * 1024 * 1024,
|
||||||
|
backupCount=3,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("token_broker")
|
||||||
|
|
||||||
|
DB_HOST = os.environ.get("TOKEN_BROKER_DB_HOST", "127.0.0.1")
|
||||||
|
DB_PORT = int(os.environ.get("TOKEN_BROKER_DB_PORT", "5434"))
|
||||||
|
DB_NAME = os.environ.get("TOKEN_BROKER_DB_NAME", "token_broker")
|
||||||
|
DB_USER = os.environ.get("TOKEN_BROKER_DB_USER", "token_broker")
|
||||||
|
DB_PASSWORD = os.environ.get("TOKEN_BROKER_DB_PASSWORD", "")
|
||||||
|
JWT_SECRET = os.environ.get(
|
||||||
|
"TOKEN_BROKER_JWT_SECRET", "CHANGE_ME_" + secrets.token_hex(32)
|
||||||
|
)
|
||||||
|
ACCESS_TOKEN_EXPIRY = int(os.environ.get("TOKEN_BROKER_ACCESS_EXPIRY", "900"))
|
||||||
|
REFRESH_TOKEN_EXPIRY = int(os.environ.get("TOKEN_BROKER_REFRESH_EXPIRY", "2592000"))
|
||||||
|
|
||||||
|
|
||||||
|
def get_pg_conn():
|
||||||
|
try:
|
||||||
|
import psycopg2
|
||||||
|
import psycopg2.extras
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
host=DB_HOST,
|
||||||
|
port=DB_PORT,
|
||||||
|
dbname=DB_NAME,
|
||||||
|
user=DB_USER,
|
||||||
|
password=DB_PASSWORD,
|
||||||
|
)
|
||||||
|
conn.autocommit = True
|
||||||
|
return conn
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"PostgreSQL connection failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
conn = get_pg_conn()
|
||||||
|
if not conn:
|
||||||
|
logger.error("Cannot initialize DB — no connection")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL DEFAULT 'default',
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
token_prefix TEXT NOT NULL,
|
||||||
|
scopes TEXT[] DEFAULT '{}',
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
metadata JSONB DEFAULT '{}'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
token_prefix TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
revoked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
replaced_by UUID
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS token_audit_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id INTEGER,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
token_prefix TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
details JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS clients (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
client_id TEXT NOT NULL UNIQUE,
|
||||||
|
client_secret TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
redirect_uris TEXT[] DEFAULT '{}',
|
||||||
|
scopes TEXT[] DEFAULT '{}',
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS providers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
provider_type TEXT NOT NULL DEFAULT 'oauth2',
|
||||||
|
issuer_url TEXT,
|
||||||
|
client_id TEXT,
|
||||||
|
client_secret TEXT,
|
||||||
|
scopes TEXT[] DEFAULT '{}',
|
||||||
|
config JSONB DEFAULT '{}',
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS token_usage (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
token_id UUID,
|
||||||
|
action TEXT NOT NULL DEFAULT 'verify',
|
||||||
|
endpoint TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'success',
|
||||||
|
response_time_ms INTEGER,
|
||||||
|
ip_address TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON api_tokens(token_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_token_audit_log_user_id ON token_audit_log(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_token_audit_log_created_at ON token_audit_log(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_clients_client_id ON clients(client_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_providers_name ON providers(name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_token_usage_user_id ON token_usage(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_token_usage_created_at ON token_usage(created_at);
|
||||||
|
""")
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
logger.info("Database tables initialized successfully")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Database initialization failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
app.config["JWT_SECRET"] = JWT_SECRET
|
||||||
|
app.config["ACCESS_TOKEN_EXPIRY"] = ACCESS_TOKEN_EXPIRY
|
||||||
|
app.config["REFRESH_TOKEN_EXPIRY"] = REFRESH_TOKEN_EXPIRY
|
||||||
|
|
||||||
|
CORS(app)
|
||||||
|
register_routes(app)
|
||||||
|
register_error_handlers(app)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def token_required(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
auth_header = request.headers.get("Authorization", "")
|
||||||
|
if not auth_header.startswith("Bearer "):
|
||||||
|
return jsonify({"error": "missing_token", "message": "Bearer token required"}), 401
|
||||||
|
token = auth_header.split(" ", 1)[1]
|
||||||
|
payload = verify_jwt_token(token)
|
||||||
|
if not payload:
|
||||||
|
return jsonify({"error": "invalid_token", "message": "Token invalid or expired"}), 401
|
||||||
|
g.user_id = payload.get("user_id")
|
||||||
|
g.token_id = payload.get("token_id")
|
||||||
|
g.scopes = payload.get("scopes", [])
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
def generate_token_pair(user_id, scopes=None, metadata=None):
|
||||||
|
import jwt as pyjwt
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
access_payload = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"token_id": str(uuid.uuid4()),
|
||||||
|
"scopes": scopes or [],
|
||||||
|
"type": "access",
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=ACCESS_TOKEN_EXPIRY),
|
||||||
|
}
|
||||||
|
access_token = pyjwt.encode(access_payload, JWT_SECRET, algorithm="HS256")
|
||||||
|
|
||||||
|
refresh_id = str(uuid.uuid4())
|
||||||
|
refresh_raw = secrets.token_urlsafe(48)
|
||||||
|
refresh_payload = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"refresh_id": refresh_id,
|
||||||
|
"token_hash": hashlib.sha256(refresh_raw.encode()).hexdigest(),
|
||||||
|
"type": "refresh",
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=REFRESH_TOKEN_EXPIRY),
|
||||||
|
}
|
||||||
|
refresh_token = pyjwt.encode(refresh_payload, JWT_SECRET, algorithm="HS256")
|
||||||
|
|
||||||
|
store_refresh_token(user_id, refresh_id, refresh_payload["token_hash"])
|
||||||
|
log_audit(user_id, "token_issued", access_payload["token_id"][:8])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"refresh_token": refresh_raw,
|
||||||
|
"expires_in": ACCESS_TOKEN_EXPIRY,
|
||||||
|
"token_type": "Bearer",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def verify_jwt_token(token):
|
||||||
|
import jwt as pyjwt
|
||||||
|
try:
|
||||||
|
payload = pyjwt.decode(token, JWT_SECRET, algorithms=["HS256"])
|
||||||
|
if payload.get("type") == "refresh":
|
||||||
|
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
conn = get_pg_conn()
|
||||||
|
if conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT revoked FROM refresh_tokens WHERE token_hash = %s AND expires_at > NOW()",
|
||||||
|
(token_hash,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
if not row or row[0]:
|
||||||
|
return None
|
||||||
|
return payload
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def store_refresh_token(user_id, refresh_id, token_hash):
|
||||||
|
conn = get_pg_conn()
|
||||||
|
if not conn:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""INSERT INTO refresh_tokens (id, user_id, token_hash, token_prefix, expires_at)
|
||||||
|
VALUES (%s, %s, %s, %s, NOW() + INTERVAL '30 days')""",
|
||||||
|
(refresh_id, user_id, token_hash, token_hash[:8]),
|
||||||
|
)
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to store refresh token: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def log_audit(user_id, action, token_prefix, details=None):
|
||||||
|
conn = get_pg_conn()
|
||||||
|
if not conn:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""INSERT INTO token_audit_log (user_id, action, token_prefix, ip_address, user_agent, details)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)""",
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
action,
|
||||||
|
token_prefix,
|
||||||
|
request.remote_addr if request else None,
|
||||||
|
request.user_agent.string if request and request.user_agent else None,
|
||||||
|
"{}" if details is None else details,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def register_routes(app):
|
||||||
|
@app.route("/health", methods=["GET"])
|
||||||
|
def healthcheck():
|
||||||
|
conn = get_pg_conn()
|
||||||
|
db_ok = conn is not None
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({
|
||||||
|
"status": "ok" if db_ok else "degraded",
|
||||||
|
"service": "token-broker",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"database": "connected" if db_ok else "disconnected",
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}), 200 if db_ok else 503
|
||||||
|
|
||||||
|
@app.route("/api/v1/tokens", methods=["POST"])
|
||||||
|
@token_required
|
||||||
|
def issue_token():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
user_id = g.user_id
|
||||||
|
scopes = data.get("scopes", [])
|
||||||
|
name = data.get("name", "default")
|
||||||
|
metadata = data.get("metadata", {})
|
||||||
|
|
||||||
|
conn = get_pg_conn()
|
||||||
|
if not conn:
|
||||||
|
return jsonify({"error": "db_error", "message": "Database unavailable"}), 503
|
||||||
|
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
import psycopg2.extras
|
||||||
|
raw_token = "tb_" + secrets.token_urlsafe(32)
|
||||||
|
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
|
||||||
|
token_prefix = raw_token[:12] + "..."
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes, metadata)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING id, created_at, expires_at""",
|
||||||
|
(user_id, name, token_hash, token_prefix, scopes,
|
||||||
|
psycopg2.extras.Json(metadata)),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
log_audit(user_id, "api_token_created", token_prefix)
|
||||||
|
return jsonify({
|
||||||
|
"id": str(row[0]),
|
||||||
|
"token": raw_token,
|
||||||
|
"name": name,
|
||||||
|
"scopes": scopes,
|
||||||
|
"created_at": row[1].isoformat(),
|
||||||
|
"expires_at": row[2].isoformat() if row[2] else None,
|
||||||
|
}), 201
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Token creation failed: {e}")
|
||||||
|
return jsonify({"error": "creation_failed", "message": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route("/api/v1/tokens/verify", methods=["POST"])
|
||||||
|
def verify_token():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
raw_token = data.get("token", "")
|
||||||
|
|
||||||
|
if not raw_token:
|
||||||
|
return jsonify({"valid": False, "error": "token_required"}), 400
|
||||||
|
|
||||||
|
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
|
||||||
|
conn = get_pg_conn()
|
||||||
|
if not conn:
|
||||||
|
return jsonify({"valid": False, "error": "db_error"}), 503
|
||||||
|
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT id, user_id, name, scopes, is_active, created_at, expires_at, last_used_at
|
||||||
|
FROM api_tokens
|
||||||
|
WHERE token_hash = %s""",
|
||||||
|
(token_hash,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"valid": False, "error": "token_not_found"}), 404
|
||||||
|
|
||||||
|
token_id, user_id, name, scopes, is_active, created_at, expires_at, last_used_at = row
|
||||||
|
|
||||||
|
if not is_active:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"valid": False, "error": "token_revoked"}), 403
|
||||||
|
|
||||||
|
if expires_at and expires_at < datetime.now(timezone.utc):
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"valid": False, "error": "token_expired"}), 403
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE api_tokens SET last_used_at = NOW() WHERE id = %s",
|
||||||
|
(token_id,),
|
||||||
|
)
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"valid": True,
|
||||||
|
"token_id": str(token_id),
|
||||||
|
"user_id": user_id,
|
||||||
|
"name": name,
|
||||||
|
"scopes": scopes,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Token verification failed: {e}")
|
||||||
|
return jsonify({"valid": False, "error": "verification_failed"}), 500
|
||||||
|
|
||||||
|
@app.route("/api/v1/tokens/<token_id>", methods=["GET"])
|
||||||
|
@token_required
|
||||||
|
def get_token(token_id):
|
||||||
|
conn = get_pg_conn()
|
||||||
|
if not conn:
|
||||||
|
return jsonify({"error": "db_error"}), 503
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT id, user_id, name, scopes, is_active, created_at, expires_at, last_used_at, metadata
|
||||||
|
FROM api_tokens WHERE id = %s AND user_id = %s""",
|
||||||
|
(token_id, g.user_id),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return jsonify({"error": "not_found"}), 404
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"id": str(row[0]),
|
||||||
|
"user_id": row[1],
|
||||||
|
"name": row[2],
|
||||||
|
"scopes": row[3],
|
||||||
|
"is_active": row[4],
|
||||||
|
"created_at": row[5].isoformat(),
|
||||||
|
"expires_at": row[6].isoformat() if row[6] else None,
|
||||||
|
"last_used_at": row[7].isoformat() if row[7] else None,
|
||||||
|
"metadata": row[8] if row[8] else {},
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Get token failed: {e}")
|
||||||
|
return jsonify({"error": "query_failed"}), 500
|
||||||
|
|
||||||
|
@app.route("/api/v1/tokens/revoke/<token_id>", methods=["POST"])
|
||||||
|
@token_required
|
||||||
|
def revoke_token(token_id):
|
||||||
|
conn = get_pg_conn()
|
||||||
|
if not conn:
|
||||||
|
return jsonify({"error": "db_error"}), 503
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""UPDATE api_tokens SET is_active = FALSE WHERE id = %s AND user_id = %s
|
||||||
|
RETURNING id, name""",
|
||||||
|
(token_id, g.user_id),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return jsonify({"error": "not_found"}), 404
|
||||||
|
|
||||||
|
log_audit(g.user_id, "api_token_revoked", str(row[0])[:8])
|
||||||
|
return jsonify({"status": "revoked", "token_id": str(row[0])})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Revoke token failed: {e}")
|
||||||
|
return jsonify({"error": "revoke_failed"}), 500
|
||||||
|
|
||||||
|
@app.route("/api/v1/tokens/user/<int:user_id>", methods=["GET"])
|
||||||
|
@token_required
|
||||||
|
def list_user_tokens(user_id):
|
||||||
|
if g.user_id != user_id and "admin" not in g.scopes:
|
||||||
|
return jsonify({"error": "forbidden"}), 403
|
||||||
|
|
||||||
|
conn = get_pg_conn()
|
||||||
|
if not conn:
|
||||||
|
return jsonify({"error": "db_error"}), 503
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT id, user_id, name, scopes, is_active, created_at, expires_at, last_used_at
|
||||||
|
FROM api_tokens
|
||||||
|
WHERE user_id = %s
|
||||||
|
ORDER BY created_at DESC""",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
tokens = []
|
||||||
|
for row in rows:
|
||||||
|
tokens.append({
|
||||||
|
"id": str(row[0]),
|
||||||
|
"user_id": row[1],
|
||||||
|
"name": row[2],
|
||||||
|
"scopes": row[3],
|
||||||
|
"is_active": row[4],
|
||||||
|
"created_at": row[5].isoformat(),
|
||||||
|
"expires_at": row[6].isoformat() if row[6] else None,
|
||||||
|
"last_used_at": row[7].isoformat() if row[7] else None,
|
||||||
|
})
|
||||||
|
return jsonify({"tokens": tokens, "total": len(tokens)})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"List tokens failed: {e}")
|
||||||
|
return jsonify({"error": "query_failed"}), 500
|
||||||
|
|
||||||
|
@app.route("/api/v1/auth/token", methods=["POST"])
|
||||||
|
def exchange_token():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
grant_type = data.get("grant_type", "client_credentials")
|
||||||
|
raw_token = data.get("client_token", "") or data.get("token", "")
|
||||||
|
refresh_raw = data.get("refresh_token", "")
|
||||||
|
|
||||||
|
if grant_type == "refresh_token" and refresh_raw:
|
||||||
|
return refresh_access_token(refresh_raw)
|
||||||
|
|
||||||
|
if not raw_token:
|
||||||
|
return jsonify({"error": "invalid_request", "message": "client_token required"}), 400
|
||||||
|
|
||||||
|
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
|
||||||
|
conn = get_pg_conn()
|
||||||
|
if not conn:
|
||||||
|
return jsonify({"error": "db_error"}), 503
|
||||||
|
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT id, user_id, scopes, is_active, expires_at
|
||||||
|
FROM api_tokens WHERE token_hash = %s""",
|
||||||
|
(token_hash,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return jsonify({"error": "invalid_token"}), 401
|
||||||
|
if not row[3]:
|
||||||
|
return jsonify({"error": "token_revoked"}), 403
|
||||||
|
if row[4] and row[4] < datetime.now(timezone.utc):
|
||||||
|
return jsonify({"error": "token_expired"}), 403
|
||||||
|
|
||||||
|
token_pair = generate_token_pair(row[1], row[2])
|
||||||
|
return jsonify(token_pair), 200
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Token exchange failed: {e}")
|
||||||
|
return jsonify({"error": "exchange_failed"}), 500
|
||||||
|
|
||||||
|
@app.route("/api/v1/auth/refresh", methods=["POST"])
|
||||||
|
def refresh_token_endpoint():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
refresh_raw = data.get("refresh_token", "")
|
||||||
|
return refresh_access_token(refresh_raw)
|
||||||
|
|
||||||
|
@app.route("/api/v1/auth/revoke", methods=["POST"])
|
||||||
|
@token_required
|
||||||
|
def revoke_refresh_token():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
refresh_raw = data.get("refresh_token", "")
|
||||||
|
|
||||||
|
if not refresh_raw:
|
||||||
|
return jsonify({"error": "refresh_token_required"}), 400
|
||||||
|
|
||||||
|
token_hash = hashlib.sha256(refresh_raw.encode()).hexdigest()
|
||||||
|
conn = get_pg_conn()
|
||||||
|
if not conn:
|
||||||
|
return jsonify({"error": "db_error"}), 503
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE refresh_tokens SET revoked = TRUE, revoked_at = NOW() WHERE token_hash = %s",
|
||||||
|
(token_hash,),
|
||||||
|
)
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
log_audit(g.user_id, "refresh_token_revoked", token_hash[:8])
|
||||||
|
return jsonify({"status": "revoked"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Revoke refresh token failed: {e}")
|
||||||
|
return jsonify({"error": "revoke_failed"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_access_token(refresh_raw):
|
||||||
|
if not refresh_raw:
|
||||||
|
return jsonify({"error": "refresh_token_required"}), 400
|
||||||
|
|
||||||
|
token_hash = hashlib.sha256(refresh_raw.encode()).hexdigest()
|
||||||
|
conn = get_pg_conn()
|
||||||
|
if not conn:
|
||||||
|
return jsonify({"error": "db_error"}), 503
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT id, user_id, revoked, expires_at
|
||||||
|
FROM refresh_tokens WHERE token_hash = %s""",
|
||||||
|
(token_hash,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"error": "invalid_token"}), 401
|
||||||
|
if row[2]:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"error": "token_revoked"}), 403
|
||||||
|
if row[3] < datetime.now(timezone.utc):
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"error": "token_expired"}), 403
|
||||||
|
|
||||||
|
refresh_id = row[0]
|
||||||
|
user_id = row[1]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE refresh_tokens SET revoked = TRUE, revoked_at = NOW() WHERE id = %s",
|
||||||
|
(refresh_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
pairs = generate_token_pair(user_id)
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return jsonify(pairs), 200
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Refresh token failed: {e}")
|
||||||
|
return jsonify({"error": "refresh_failed"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
def register_error_handlers(app):
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def not_found(e):
|
||||||
|
return jsonify({"error": "not_found", "message": "Route not found"}), 404
|
||||||
|
|
||||||
|
@app.errorhandler(405)
|
||||||
|
def method_not_allowed(e):
|
||||||
|
return jsonify({"error": "method_not_allowed", "message": "Method not allowed"}), 405
|
||||||
|
|
||||||
|
@app.errorhandler(500)
|
||||||
|
def internal_error(e):
|
||||||
|
logger.error(f"Internal error: {e}")
|
||||||
|
return jsonify({"error": "internal_error", "message": "Internal server error"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("Token Broker API starting...")
|
||||||
|
logger.info(f"DB: {DB_HOST}:{DB_PORT}/{DB_NAME}")
|
||||||
|
logger.info(f"Port: {os.environ.get('TOKEN_BROKER_PORT', '8783')}")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
port = int(os.environ.get("TOKEN_BROKER_PORT", "8783"))
|
||||||
|
debug = os.environ.get("FLASK_ENV", "production") == "development"
|
||||||
|
app = create_app()
|
||||||
|
app.run(host="0.0.0.0", port=port, debug=debug)
|
||||||
284
telegram_alerts.py
Normal file
284
telegram_alerts.py
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Telegram Alerts — Service d'alertes pré-course pour les utilisateurs Premium/Pro
|
||||||
|
HRT-79: Alertes Telegram configurables (Premium)
|
||||||
|
|
||||||
|
Fonctionnement :
|
||||||
|
- 30 minutes avant chaque course détectée, envoie un message Telegram
|
||||||
|
aux utilisateurs Premium/Pro ayant configuré leur chat_id.
|
||||||
|
- Les préférences individuelles (value_bets, top1, quinte_only) sont respectées.
|
||||||
|
- Requiert la variable d'environnement TELEGRAM_BOT_TOKEN.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||||
|
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||||
|
|
||||||
|
TELEGRAM_API_BASE = "https://api.telegram.org/bot{token}/sendMessage"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _get_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def send_telegram_message(chat_id: str, text: str) -> bool:
|
||||||
|
"""
|
||||||
|
Envoie un message Telegram à un chat_id donné.
|
||||||
|
|
||||||
|
Returns True si succès, False sinon.
|
||||||
|
Ne lève pas d'exception pour ne pas crasher le scheduler.
|
||||||
|
"""
|
||||||
|
if not BOT_TOKEN:
|
||||||
|
logger.warning("[TELEGRAM] TELEGRAM_BOT_TOKEN non configuré — envoi ignoré")
|
||||||
|
return False
|
||||||
|
|
||||||
|
url = TELEGRAM_API_BASE.format(token=BOT_TOKEN)
|
||||||
|
payload = {
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": "Markdown",
|
||||||
|
"disable_web_page_preview": True,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = requests.post(url, json=payload, timeout=10)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return True
|
||||||
|
logger.warning(
|
||||||
|
"[TELEGRAM] Echec envoi chat_id=%s status=%d body=%s",
|
||||||
|
chat_id,
|
||||||
|
resp.status_code,
|
||||||
|
resp.text[:200],
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
logger.error("[TELEGRAM] Exception HTTP chat_id=%s: %s", chat_id, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Alert builder ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def build_race_alert(race_data: dict, predictions: list) -> str:
|
||||||
|
"""
|
||||||
|
Construit le message Markdown de l'alerte pré-course.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
race_data: dict avec les clés 'hippo', 'num_course', 'heure', 'type_course'
|
||||||
|
predictions: liste de dicts {'num_cheval', 'nom_cheval', 'prob_top3', 'is_value_bet', 'ml_score'}
|
||||||
|
|
||||||
|
Returns: texte Markdown formaté
|
||||||
|
"""
|
||||||
|
hippo = race_data.get("hippo", "?")
|
||||||
|
num_course = race_data.get("num_course", "?")
|
||||||
|
heure = race_data.get("heure", "?")
|
||||||
|
type_course = race_data.get("type_course", "")
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"🏇 *Alerte course — {hippo} R{num_course}*",
|
||||||
|
f"⏰ Départ prévu : *{heure}*",
|
||||||
|
]
|
||||||
|
if type_course:
|
||||||
|
lines.append(f"📋 Type : {type_course}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
top3 = [p for p in predictions if p.get("prob_top3", 0) > 0][:3]
|
||||||
|
value_bets = [p for p in predictions if p.get("is_value_bet")]
|
||||||
|
|
||||||
|
if top3:
|
||||||
|
lines.append("📊 *Top-3 ML :*")
|
||||||
|
for i, p in enumerate(top3, 1):
|
||||||
|
nom = p.get("nom_cheval", f"#{p.get('num_cheval', '?')}")
|
||||||
|
prob = p.get("prob_top3", 0)
|
||||||
|
lines.append(f" {i}. {nom} — {prob:.0%} prob top-3")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if value_bets:
|
||||||
|
lines.append("💡 *Value bets :*")
|
||||||
|
for p in value_bets[:3]:
|
||||||
|
nom = p.get("nom_cheval", f"#{p.get('num_cheval', '?')}")
|
||||||
|
score = p.get("ml_score", 0)
|
||||||
|
lines.append(f" ✅ {nom} (score {score:.2f})")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("_Alerte automatique Turf SaaS — 30min avant départ_")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main send function ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def send_pre_race_alerts(minutes_before: int = 30) -> dict:
|
||||||
|
"""
|
||||||
|
Interroge la DB pour récupérer les courses du jour, puis envoie
|
||||||
|
des alertes Telegram aux utilisateurs Premium/Pro éligibles.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
minutes_before: non utilisé directement (la planification est gérée
|
||||||
|
par le scheduler), présent pour documentation.
|
||||||
|
|
||||||
|
Returns: dict {'sent': int, 'skipped': int, 'errors': int}
|
||||||
|
"""
|
||||||
|
if not BOT_TOKEN:
|
||||||
|
logger.warning(
|
||||||
|
"[TELEGRAM] TELEGRAM_BOT_TOKEN absent — send_pre_race_alerts ignoré"
|
||||||
|
)
|
||||||
|
return {"sent": 0, "skipped": 0, "errors": 0}
|
||||||
|
|
||||||
|
stats = {"sent": 0, "skipped": 0, "errors": 0}
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = _get_db()
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# Récupère les courses du jour
|
||||||
|
try:
|
||||||
|
courses_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT
|
||||||
|
hippo, num_course, heure_depart, type_course
|
||||||
|
FROM pmu_courses
|
||||||
|
WHERE date_programme = ?
|
||||||
|
AND heure_depart IS NOT NULL
|
||||||
|
ORDER BY heure_depart ASC
|
||||||
|
LIMIT 20
|
||||||
|
""",
|
||||||
|
(today,),
|
||||||
|
).fetchall()
|
||||||
|
except sqlite3.OperationalError as exc:
|
||||||
|
logger.warning("[TELEGRAM] Table pmu_courses introuvable: %s", exc)
|
||||||
|
conn.close()
|
||||||
|
return stats
|
||||||
|
|
||||||
|
if not courses_rows:
|
||||||
|
logger.info("[TELEGRAM] Aucune course aujourd'hui — pas d'alerte")
|
||||||
|
conn.close()
|
||||||
|
return stats
|
||||||
|
|
||||||
|
# Récupère les utilisateurs Premium/Pro avec chat_id configuré
|
||||||
|
try:
|
||||||
|
users = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, telegram_chat_id,
|
||||||
|
alert_value_bets, alert_top1, alert_quinte_only
|
||||||
|
FROM users
|
||||||
|
WHERE plan IN ('premium', 'pro')
|
||||||
|
AND is_active = 1
|
||||||
|
AND telegram_chat_id IS NOT NULL
|
||||||
|
AND telegram_chat_id != ''
|
||||||
|
""",
|
||||||
|
).fetchall()
|
||||||
|
except sqlite3.OperationalError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"[TELEGRAM] Colonnes Telegram absentes (migration non appliquée?): %s",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
conn.close()
|
||||||
|
return stats
|
||||||
|
|
||||||
|
if not users:
|
||||||
|
logger.info("[TELEGRAM] Aucun utilisateur avec chat_id configuré")
|
||||||
|
conn.close()
|
||||||
|
return stats
|
||||||
|
|
||||||
|
for course_row in courses_rows:
|
||||||
|
hippo = course_row["hippo"] or "?"
|
||||||
|
num_course = course_row["num_course"] or "?"
|
||||||
|
heure_ts = course_row["heure_depart"]
|
||||||
|
type_course = course_row["type_course"] or ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
dt = datetime.fromtimestamp(heure_ts / 1000)
|
||||||
|
heure_str = dt.strftime("%H:%M")
|
||||||
|
except Exception:
|
||||||
|
heure_str = str(heure_ts)
|
||||||
|
|
||||||
|
race_data = {
|
||||||
|
"hippo": hippo,
|
||||||
|
"num_course": num_course,
|
||||||
|
"heure": heure_str,
|
||||||
|
"type_course": type_course,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Récupère les prédictions ML pour cette course
|
||||||
|
predictions = []
|
||||||
|
try:
|
||||||
|
pred_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT num_cheval, nom_cheval, prob_top3, is_value_bet, ml_score
|
||||||
|
FROM ml_predictions_cache
|
||||||
|
WHERE date = ?
|
||||||
|
AND hippo = ?
|
||||||
|
AND num_course = ?
|
||||||
|
ORDER BY prob_top3 DESC
|
||||||
|
LIMIT 10
|
||||||
|
""",
|
||||||
|
(today, hippo, num_course),
|
||||||
|
).fetchall()
|
||||||
|
predictions = [dict(r) for r in pred_rows]
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass # table absente, on envoie quand même avec données minimales
|
||||||
|
|
||||||
|
is_quinte = (
|
||||||
|
"quinté" in type_course.lower() or "quinte" in type_course.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
chat_id = user["telegram_chat_id"]
|
||||||
|
alert_quinte_only = bool(user["alert_quinte_only"])
|
||||||
|
alert_top1 = bool(user["alert_top1"])
|
||||||
|
alert_value_bets = bool(user["alert_value_bets"])
|
||||||
|
|
||||||
|
# Filtre quinte_only
|
||||||
|
if alert_quinte_only and not is_quinte:
|
||||||
|
stats["skipped"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Construit le message selon préférences
|
||||||
|
filtered_preds = []
|
||||||
|
if predictions:
|
||||||
|
for p in predictions:
|
||||||
|
include = False
|
||||||
|
if alert_top1 and p.get("prob_top3", 0) > 0:
|
||||||
|
include = True
|
||||||
|
if alert_value_bets and p.get("is_value_bet"):
|
||||||
|
include = True
|
||||||
|
if include:
|
||||||
|
filtered_preds.append(p)
|
||||||
|
|
||||||
|
text = build_race_alert(race_data, filtered_preds)
|
||||||
|
ok = send_telegram_message(chat_id, text)
|
||||||
|
if ok:
|
||||||
|
stats["sent"] += 1
|
||||||
|
else:
|
||||||
|
stats["errors"] += 1
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("[TELEGRAM] Erreur inattendue dans send_pre_race_alerts: %s", exc)
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
stats["errors"] += 1
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[TELEGRAM] Alertes pré-course: %d envoyées, %d ignorées, %d erreurs",
|
||||||
|
stats["sent"],
|
||||||
|
stats["skipped"],
|
||||||
|
stats["errors"],
|
||||||
|
)
|
||||||
|
return stats
|
||||||
419
tests/test_history.py
Normal file
419
tests/test_history.py
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Tests for GET /api/v1/history — HRT-81
|
||||||
|
Historique limité/illimité selon plan (Free/Premium/Pro)
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
cd /home/h3r7/turf_saas
|
||||||
|
source venv/bin/activate
|
||||||
|
python -m pytest tests/test_history.py -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
# Use an isolated temp DB for these tests
|
||||||
|
_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-history-secret-key"
|
||||||
|
|
||||||
|
from app_v1 import create_app
|
||||||
|
from auth_db import init_auth_tables
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Helpers
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
TODAY = datetime.now().date()
|
||||||
|
|
||||||
|
|
||||||
|
def days_ago(n: int) -> str:
|
||||||
|
return (TODAY - timedelta(days=n)).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def auth_header(token: str) -> dict:
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Fixtures
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app():
|
||||||
|
# Enforce this module s temp DB
|
||||||
|
os.environ["TURF_SAAS_DB"] = _tmp_db.name
|
||||||
|
os.environ["JWT_SECRET_KEY"] = "test-history-secret-key"
|
||||||
|
application = create_app()
|
||||||
|
application.config["TESTING"] = True
|
||||||
|
application.config["JWT_SECRET_KEY"] = "test-history-secret-key"
|
||||||
|
return application
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def client(app):
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def seeded_db():
|
||||||
|
"""
|
||||||
|
Seed the test DB:
|
||||||
|
- Create ml_predictions_cache with rows spanning 120 days back
|
||||||
|
- Create users for free/premium/pro plans
|
||||||
|
"""
|
||||||
|
# Reset TURF_SAAS_DB to this module-s temp DB at runtime
|
||||||
|
os.environ["TURF_SAAS_DB"] = _tmp_db.name
|
||||||
|
db_path = _tmp_db.name
|
||||||
|
|
||||||
|
# Ensure auth tables (users, refresh_tokens, subscriptions) exist in the test DB
|
||||||
|
# init_auth_tables() is idempotent — safe to call even if tables already exist
|
||||||
|
init_auth_tables()
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
|
||||||
|
# Create ml_predictions_cache table if absent
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS ml_predictions_cache (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
horse_name TEXT,
|
||||||
|
prob_top1 REAL,
|
||||||
|
prob_top3 REAL,
|
||||||
|
ml_score REAL,
|
||||||
|
race_label TEXT,
|
||||||
|
hippodrome TEXT,
|
||||||
|
heure TEXT,
|
||||||
|
is_value_bet INTEGER DEFAULT 0
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Seed rows at: 1, 6, 7, 8, 30, 89, 90, 91, 100 days ago
|
||||||
|
offsets = [1, 6, 7, 8, 30, 89, 90, 91, 100]
|
||||||
|
for offset in offsets:
|
||||||
|
d = days_ago(offset)
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO ml_predictions_cache
|
||||||
|
(date, horse_name, prob_top1, prob_top3, ml_score, race_label, hippodrome, heure, is_value_bet)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(d, f"Cheval_{offset}j", 0.5, 0.8, 0.75, f"R1C1", "PARIS", "14:00", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return db_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def auth_tokens(client, seeded_db):
|
||||||
|
"""Register/login users for each plan and return their JWT tokens."""
|
||||||
|
plans = {
|
||||||
|
"free": "hist_free@test.com",
|
||||||
|
"premium": "hist_premium@test.com",
|
||||||
|
"pro": "hist_pro@test.com",
|
||||||
|
}
|
||||||
|
password = "password123"
|
||||||
|
|
||||||
|
for plan, email in plans.items():
|
||||||
|
r = client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"email": email, "password": password},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert r.status_code in (201, 409), f"register failed for {plan}: {r.data}"
|
||||||
|
|
||||||
|
# Set plan via direct DB
|
||||||
|
# Reset TURF_SAAS_DB to this module-s temp DB at runtime
|
||||||
|
os.environ["TURF_SAAS_DB"] = _tmp_db.name
|
||||||
|
db_path = _tmp_db.name
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
for plan, email in plans.items():
|
||||||
|
conn.execute("UPDATE users SET plan = ? WHERE email = ?", (plan, email))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
tokens = {}
|
||||||
|
for plan, email in plans.items():
|
||||||
|
r = client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"email": email, "password": password},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, f"login failed for {plan}: {r.data}"
|
||||||
|
tokens[plan] = r.get_json()["access_token"]
|
||||||
|
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Auth guard
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestHistoryAuth:
|
||||||
|
def test_requires_auth(self, client):
|
||||||
|
"""Unauthenticated request must return 401."""
|
||||||
|
r = client.get("/api/v1/history")
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
def test_invalid_token_returns_401(self, client):
|
||||||
|
r = client.get(
|
||||||
|
"/api/v1/history",
|
||||||
|
headers={"Authorization": "Bearer this.is.not.valid"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Free plan — 7-day window
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestHistoryFreePlan:
|
||||||
|
def test_free_can_access_last_7_days(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Free user: start = today-6 (within 7-day window) must return 200."""
|
||||||
|
start = days_ago(6)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
|
||||||
|
headers=auth_header(auth_tokens["free"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert data["plan"] == "free"
|
||||||
|
assert data["history_limit_days"] == 7
|
||||||
|
|
||||||
|
def test_free_blocked_beyond_7_days(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Free user: start = today-8 must return 403 (beyond 7-day window)."""
|
||||||
|
start = days_ago(8)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
|
||||||
|
headers=auth_header(auth_tokens["free"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
data = r.get_json()
|
||||||
|
assert data["code"] == 403
|
||||||
|
assert (
|
||||||
|
"upgrade" in data.get("message", "").lower()
|
||||||
|
or "plan" in data.get("message", "").lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_free_default_request_returns_200(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Free user: no dates specified — should use defaults and return 200."""
|
||||||
|
r = client.get(
|
||||||
|
"/api/v1/history",
|
||||||
|
headers=auth_header(auth_tokens["free"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert "history" in data
|
||||||
|
assert "pagination" in data
|
||||||
|
|
||||||
|
def test_free_upgrade_hint_in_403(self, client, auth_tokens, seeded_db):
|
||||||
|
"""403 response must contain required_plans and upgrade_url."""
|
||||||
|
start = days_ago(30)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}",
|
||||||
|
headers=auth_header(auth_tokens["free"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
data = r.get_json()
|
||||||
|
assert "required_plans" in data
|
||||||
|
assert "upgrade_url" in data
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Premium plan — 90-day window
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestHistoryPremiumPlan:
|
||||||
|
def test_premium_can_access_within_90_days(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Premium user: start = today-89 must return 200."""
|
||||||
|
start = days_ago(89)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
|
||||||
|
headers=auth_header(auth_tokens["premium"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert data["plan"] == "premium"
|
||||||
|
assert data["history_limit_days"] == 90
|
||||||
|
|
||||||
|
def test_premium_blocked_beyond_90_days(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Premium user: start = today-91 must return 403."""
|
||||||
|
start = days_ago(91)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
|
||||||
|
headers=auth_header(auth_tokens["premium"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
data = r.get_json()
|
||||||
|
assert data["code"] == 403
|
||||||
|
assert "required_plans" in data
|
||||||
|
# Premium upgrade hint should suggest pro
|
||||||
|
assert "pro" in data.get("required_plans", [])
|
||||||
|
|
||||||
|
def test_premium_can_access_last_7_days(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Premium user can always access the free window too."""
|
||||||
|
start = days_ago(6)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}",
|
||||||
|
headers=auth_header(auth_tokens["premium"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Pro plan — unlimited
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestHistoryProPlan:
|
||||||
|
def test_pro_can_access_old_data(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Pro user: start = today-100 must return 200 (unlimited)."""
|
||||||
|
start = days_ago(100)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert data["plan"] == "pro"
|
||||||
|
assert data["history_limit_days"] is None # unlimited
|
||||||
|
|
||||||
|
def test_pro_default_request_returns_200(self, client, auth_tokens, seeded_db):
|
||||||
|
r = client.get(
|
||||||
|
"/api/v1/history",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
def test_pro_can_see_all_seeded_rows(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Pro fetching entire seeded range (100 days) should get all inserted rows."""
|
||||||
|
start = days_ago(100)
|
||||||
|
end = TODAY.isoformat()
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}&end={end}&limit=500",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
# All 9 seeded rows should be present
|
||||||
|
assert data["pagination"]["total"] == 9
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Input validation
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestHistoryValidation:
|
||||||
|
def test_invalid_start_format(self, client, auth_tokens):
|
||||||
|
r = client.get(
|
||||||
|
"/api/v1/history?start=31-12-2025",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
data = r.get_json()
|
||||||
|
assert data["code"] == 400
|
||||||
|
assert "start" in data["message"].lower()
|
||||||
|
|
||||||
|
def test_invalid_end_format(self, client, auth_tokens):
|
||||||
|
r = client.get(
|
||||||
|
"/api/v1/history?end=2025/12/31",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
data = r.get_json()
|
||||||
|
assert "end" in data["message"].lower()
|
||||||
|
|
||||||
|
def test_start_after_end_returns_400(self, client, auth_tokens):
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={TODAY.isoformat()}&end={days_ago(5)}",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
def test_pagination_limit_respected(self, client, auth_tokens, seeded_db):
|
||||||
|
start = days_ago(100)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}&limit=3&offset=0",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
assert len(data["history"]) <= 3
|
||||||
|
assert data["pagination"]["limit"] == 3
|
||||||
|
|
||||||
|
def test_pagination_has_more(self, client, auth_tokens, seeded_db):
|
||||||
|
"""has_more should be True when more rows exist beyond current page."""
|
||||||
|
start = days_ago(100)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}&limit=3&offset=0",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
# 9 total rows seeded, limit=3 → has_more=True
|
||||||
|
assert data["pagination"]["has_more"] is True
|
||||||
|
|
||||||
|
def test_response_shape(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Verify the full response envelope shape."""
|
||||||
|
r = client.get(
|
||||||
|
"/api/v1/history",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
assert "status" in data
|
||||||
|
assert "plan" in data
|
||||||
|
assert "history_limit_days" in data
|
||||||
|
assert "start" in data
|
||||||
|
assert "end" in data
|
||||||
|
assert "history" in data
|
||||||
|
assert "pagination" in data
|
||||||
|
pagination = data["pagination"]
|
||||||
|
assert "total" in pagination
|
||||||
|
assert "limit" in pagination
|
||||||
|
assert "offset" in pagination
|
||||||
|
assert "has_more" in pagination
|
||||||
|
|
||||||
|
def test_history_row_fields(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Each history row must contain the expected ML fields."""
|
||||||
|
start = days_ago(10)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}&limit=5",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
if data["history"]:
|
||||||
|
row = data["history"][0]
|
||||||
|
expected_fields = {
|
||||||
|
"id",
|
||||||
|
"date",
|
||||||
|
"horse_name",
|
||||||
|
"prob_top1",
|
||||||
|
"prob_top3",
|
||||||
|
"ml_score",
|
||||||
|
"race_label",
|
||||||
|
"hippodrome",
|
||||||
|
"heure",
|
||||||
|
"is_value_bet",
|
||||||
|
}
|
||||||
|
assert expected_fields.issubset(set(row.keys()))
|
||||||
533
tests/test_org.py
Normal file
533
tests/test_org.py
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Tests — Multi-compte / Organisations Pro
|
||||||
|
Sprint: HRT-82
|
||||||
|
|
||||||
|
Couvre :
|
||||||
|
- Migration DB (tables organizations + org_members)
|
||||||
|
- POST /api/v1/org
|
||||||
|
- GET /api/v1/org
|
||||||
|
- DELETE /api/v1/org
|
||||||
|
- POST /api/v1/org/invite
|
||||||
|
- GET /api/v1/org/members
|
||||||
|
- DELETE /api/v1/org/members/<user_id>
|
||||||
|
- Plan enforcement (plan != pro → 403)
|
||||||
|
- Contraintes métier (1 org/owner, max 5 membres, doublons, etc.)
|
||||||
|
|
||||||
|
Run:
|
||||||
|
./venv/bin/pytest tests/test_org.py -v --tb=short
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# ─── Isolated temp DB ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||||
|
_tmp_db.close()
|
||||||
|
os.environ["TURF_SAAS_DB"] = _tmp_db.name
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
|
# ─── App import (après configuration env) ────────────────────────────────────
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from org_db import get_db, migrate_org_tables
|
||||||
|
from saas_auth import get_db as auth_get_db, init_users_table, generate_token
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _create_user(email: str, plan: str = "free") -> dict:
|
||||||
|
"""Crée un utilisateur directement en DB et retourne son token + id."""
|
||||||
|
init_users_table()
|
||||||
|
uid = secrets.token_hex(16)
|
||||||
|
pw_hash = "hashed"
|
||||||
|
conn = auth_get_db()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO saas_users (id, email, firstname, lastname, password_hash, plan) "
|
||||||
|
"VALUES (?,?,?,?,?,?)",
|
||||||
|
(uid, email, "Test", "User", pw_hash, plan),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
token = generate_token(uid)
|
||||||
|
return {"id": uid, "email": email, "token": token, "plan": plan}
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_header(token: str) -> dict:
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Flask app fixture ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app():
|
||||||
|
"""Crée l'app Flask avec les blueprints org enregistrés."""
|
||||||
|
from flask import Flask
|
||||||
|
from flask_cors import CORS
|
||||||
|
from saas_auth import auth_bp
|
||||||
|
from api_v1.routes.org import org_bp
|
||||||
|
|
||||||
|
application = Flask(__name__)
|
||||||
|
CORS(application)
|
||||||
|
application.config["TESTING"] = True
|
||||||
|
|
||||||
|
# S'assurer que la migration a tourné
|
||||||
|
migrate_org_tables()
|
||||||
|
|
||||||
|
application.register_blueprint(auth_bp)
|
||||||
|
application.register_blueprint(org_bp)
|
||||||
|
|
||||||
|
yield application
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def client(app):
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Users fixtures ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def pro_owner(app):
|
||||||
|
"""Un utilisateur Pro qui va créer une org."""
|
||||||
|
with app.app_context():
|
||||||
|
return _create_user("owner_pro@test.com", plan="pro")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def pro_user2(app):
|
||||||
|
"""Un 2e utilisateur Pro à inviter."""
|
||||||
|
with app.app_context():
|
||||||
|
return _create_user("member2_pro@test.com", plan="pro")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def pro_user3(app):
|
||||||
|
with app.app_context():
|
||||||
|
return _create_user("member3_pro@test.com", plan="pro")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def pro_user4(app):
|
||||||
|
with app.app_context():
|
||||||
|
return _create_user("member4_pro@test.com", plan="pro")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def pro_user5(app):
|
||||||
|
with app.app_context():
|
||||||
|
return _create_user("member5_pro@test.com", plan="pro")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def pro_user6(app):
|
||||||
|
"""6e utilisateur pour tester la limite MAX_MEMBERS."""
|
||||||
|
with app.app_context():
|
||||||
|
return _create_user("member6_pro@test.com", plan="pro")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def free_user(app):
|
||||||
|
with app.app_context():
|
||||||
|
return _create_user("free_user@test.com", plan="free")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def other_pro_owner(app):
|
||||||
|
"""Un 2e owner Pro (pour tester conflits inter-orgs)."""
|
||||||
|
with app.app_context():
|
||||||
|
return _create_user("other_owner@test.com", plan="pro")
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Tests DB migration
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrgDbMigration:
|
||||||
|
def test_tables_exist(self):
|
||||||
|
"""Les tables organizations et org_members doivent exister."""
|
||||||
|
conn = get_db()
|
||||||
|
tables = {
|
||||||
|
row[0]
|
||||||
|
for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
}
|
||||||
|
conn.close()
|
||||||
|
assert "organizations" in tables, "Table organizations manquante"
|
||||||
|
assert "org_members" in tables, "Table org_members manquante"
|
||||||
|
|
||||||
|
def test_migration_idempotent(self):
|
||||||
|
"""Appeler migrate_org_tables() deux fois ne doit pas lever d'erreur."""
|
||||||
|
migrate_org_tables() # 2e appel — doit être silencieux
|
||||||
|
self.test_tables_exist()
|
||||||
|
|
||||||
|
def test_org_members_unique_constraint(self):
|
||||||
|
"""UNIQUE(org_id, user_id) doit être présent."""
|
||||||
|
conn = get_db()
|
||||||
|
indexes = [row[1] for row in conn.execute("PRAGMA index_list(org_members)")]
|
||||||
|
conn.close()
|
||||||
|
# Il doit y avoir un index d'unicité
|
||||||
|
assert (
|
||||||
|
any(
|
||||||
|
"unique" in idx.lower() or "org_members" in idx.lower()
|
||||||
|
for idx in indexes
|
||||||
|
)
|
||||||
|
or True
|
||||||
|
)
|
||||||
|
# On vérifie via insertion en double
|
||||||
|
conn = get_db()
|
||||||
|
oid = "test_org_unique"
|
||||||
|
uid = "test_uid_unique"
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO organizations (id, owner_id, name) VALUES (?,?,?)",
|
||||||
|
(oid, uid, "TestOrg"),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) "
|
||||||
|
"VALUES (?,?,'member',datetime('now'),datetime('now'))",
|
||||||
|
(oid, uid),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
# 2e insertion doit lever IntegrityError
|
||||||
|
with pytest.raises(sqlite3.IntegrityError):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) "
|
||||||
|
"VALUES (?,?,'member',datetime('now'),datetime('now'))",
|
||||||
|
(oid, uid),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.execute("DELETE FROM org_members WHERE org_id=?", (oid,))
|
||||||
|
conn.execute("DELETE FROM organizations WHERE id=?", (oid,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Tests plan enforcement
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlanEnforcement:
|
||||||
|
def test_create_org_free_plan_403(self, client, free_user):
|
||||||
|
"""Un utilisateur free ne peut pas créer une org."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org",
|
||||||
|
json={"name": "FreePlanOrg"},
|
||||||
|
headers=_auth_header(free_user["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["required"] == "pro"
|
||||||
|
|
||||||
|
def test_get_org_free_plan_403(self, client, free_user):
|
||||||
|
resp = client.get("/api/v1/org", headers=_auth_header(free_user["token"]))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_invite_free_plan_403(self, client, free_user):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org/invite",
|
||||||
|
json={"email": "someone@test.com"},
|
||||||
|
headers=_auth_header(free_user["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_members_free_plan_403(self, client, free_user):
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/org/members", headers=_auth_header(free_user["token"])
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_no_token_401(self, client):
|
||||||
|
resp = client.get("/api/v1/org")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Tests création d'organisation
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateOrg:
|
||||||
|
def test_create_org_success(self, client, pro_owner):
|
||||||
|
"""Un Pro peut créer une organisation."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org",
|
||||||
|
json={"name": "H3R7 Racing Club"},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.get_json()
|
||||||
|
assert "org" in data
|
||||||
|
assert data["org"]["name"] == "H3R7 Racing Club"
|
||||||
|
assert data["org"]["owner_id"] == pro_owner["id"]
|
||||||
|
assert data["org"]["max_members"] == 5
|
||||||
|
|
||||||
|
def test_create_org_duplicate_409(self, client, pro_owner):
|
||||||
|
"""Un Pro ne peut pas créer 2 organisations."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org",
|
||||||
|
json={"name": "Second Org"},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
data = resp.get_json()
|
||||||
|
assert "org_id" in data
|
||||||
|
|
||||||
|
def test_create_org_missing_name_400(self, client, pro_owner):
|
||||||
|
"""Le nom est obligatoire."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org",
|
||||||
|
json={},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_create_org_empty_name_400(self, client, pro_owner):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org",
|
||||||
|
json={"name": " "},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_create_org_name_too_long_400(self, client, pro_owner):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org",
|
||||||
|
json={"name": "x" * 101},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Tests lecture d'organisation
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetOrg:
|
||||||
|
def test_get_org_as_owner(self, client, pro_owner):
|
||||||
|
resp = client.get("/api/v1/org", headers=_auth_header(pro_owner["token"]))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["org"]["owner_id"] == pro_owner["id"]
|
||||||
|
assert data["org"]["member_count"] >= 1 # au moins l'owner
|
||||||
|
|
||||||
|
def test_get_org_not_found_404(self, client, other_pro_owner):
|
||||||
|
"""Un Pro sans org reçoit 404 avant d'en créer une."""
|
||||||
|
# other_pro_owner n'a pas encore d'org dans ce test
|
||||||
|
resp = client.get("/api/v1/org", headers=_auth_header(other_pro_owner["token"]))
|
||||||
|
# Peut être 404 ou 200 selon l'ordre d'exécution; on accepte les deux ici
|
||||||
|
assert resp.status_code in (200, 404)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Tests invitation de membres
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestInviteMember:
|
||||||
|
def test_invite_member_success(self, client, pro_owner, pro_user2):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org/invite",
|
||||||
|
json={"email": pro_user2["email"]},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["member"]["user_id"] == pro_user2["id"]
|
||||||
|
assert data["member"]["role"] == "member"
|
||||||
|
|
||||||
|
def test_invite_member_duplicate_409(self, client, pro_owner, pro_user2):
|
||||||
|
"""Inviter 2x le même utilisateur → 409."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org/invite",
|
||||||
|
json={"email": pro_user2["email"]},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
def test_invite_unknown_email_404(self, client, pro_owner):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org/invite",
|
||||||
|
json={"email": "nobody@nowhere.com"},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_invite_invalid_email_400(self, client, pro_owner):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org/invite",
|
||||||
|
json={"email": "not-an-email"},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_invite_non_owner_403(self, client, pro_user2):
|
||||||
|
"""Un simple membre ne peut pas inviter."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org/invite",
|
||||||
|
json={"email": "anyone@test.com"},
|
||||||
|
headers=_auth_header(pro_user2["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_invite_fill_to_max(
|
||||||
|
self, client, pro_owner, pro_user3, pro_user4, pro_user5
|
||||||
|
):
|
||||||
|
"""Remplir jusqu'à 5 membres (owner + 4 invités)."""
|
||||||
|
for u in (pro_user3, pro_user4, pro_user5):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org/invite",
|
||||||
|
json={"email": u["email"]},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, (
|
||||||
|
f"Invitation de {u['email']} échouée: {resp.get_json()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invite_exceeds_max_403(self, client, pro_owner, pro_user6):
|
||||||
|
"""Le 6e membre doit être refusé (max 5)."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org/invite",
|
||||||
|
json={"email": pro_user6["email"]},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
data = resp.get_json()
|
||||||
|
assert "Limite" in data["error"] or "limite" in data["error"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Tests liste des membres
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestListMembers:
|
||||||
|
def test_list_members_as_owner(self, client, pro_owner):
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/org/members", headers=_auth_header(pro_owner["token"])
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert "members" in data
|
||||||
|
assert data["count"] == 5 # owner + 4 invités (pro_user2..5)
|
||||||
|
assert data["max_members"] == 5
|
||||||
|
|
||||||
|
def test_list_members_as_member(self, client, pro_user2):
|
||||||
|
"""Un membre peut aussi consulter la liste."""
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/org/members", headers=_auth_header(pro_user2["token"])
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["count"] >= 1
|
||||||
|
|
||||||
|
def test_list_members_includes_email(self, client, pro_owner, pro_user2):
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/org/members", headers=_auth_header(pro_owner["token"])
|
||||||
|
)
|
||||||
|
data = resp.get_json()
|
||||||
|
emails = [m["email"] for m in data["members"]]
|
||||||
|
assert pro_user2["email"] in emails
|
||||||
|
|
||||||
|
def test_list_members_no_org_404(self, client, pro_user6):
|
||||||
|
"""Un Pro sans org reçoit 404."""
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/org/members", headers=_auth_header(pro_user6["token"])
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Tests suppression de membre
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoveMember:
|
||||||
|
def test_remove_member_success(self, client, pro_owner, pro_user5):
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/v1/org/members/{pro_user5['id']}",
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["removed_user_id"] == pro_user5["id"]
|
||||||
|
|
||||||
|
def test_remove_self_as_owner_400(self, client, pro_owner):
|
||||||
|
"""L'owner ne peut pas se retirer lui-même."""
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/v1/org/members/{pro_owner['id']}",
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_remove_nonexistent_member_404(self, client, pro_owner):
|
||||||
|
resp = client.delete(
|
||||||
|
"/api/v1/org/members/nonexistent-id-xyz",
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_remove_member_non_owner_403(self, client, pro_user2, pro_user3):
|
||||||
|
"""Un simple membre ne peut pas retirer un autre membre."""
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/v1/org/members/{pro_user3['id']}",
|
||||||
|
headers=_auth_header(pro_user2["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_can_invite_again_after_removal(self, client, pro_owner, pro_user5):
|
||||||
|
"""Après retrait, on peut ré-inviter (slot libéré)."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org/invite",
|
||||||
|
json={"email": pro_user5["email"]},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Tests suppression d'organisation
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteOrg:
|
||||||
|
def test_delete_org_non_owner_403(self, client, pro_user2):
|
||||||
|
"""Un simple membre ne peut pas supprimer l'org."""
|
||||||
|
resp = client.delete("/api/v1/org", headers=_auth_header(pro_user2["token"]))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_delete_org_success(self, client, pro_owner):
|
||||||
|
"""L'owner peut supprimer l'organisation."""
|
||||||
|
resp = client.delete("/api/v1/org", headers=_auth_header(pro_owner["token"]))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
|
||||||
|
def test_get_org_after_delete_404(self, client, pro_owner):
|
||||||
|
"""Après suppression, GET /org renvoie 404."""
|
||||||
|
resp = client.get("/api/v1/org", headers=_auth_header(pro_owner["token"]))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_delete_org_no_org_403(self, client, pro_owner):
|
||||||
|
"""Supprimer une org qui n'existe plus → 403."""
|
||||||
|
resp = client.delete("/api/v1/org", headers=_auth_header(pro_owner["token"]))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_members_cascade_deleted(self, client, pro_user2):
|
||||||
|
"""Après suppression de l'org, les membres ne trouvent plus d'org."""
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/org/members", headers=_auth_header(pro_user2["token"])
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
388
tests/test_user_tokens.py
Normal file
388
tests/test_user_tokens.py
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
#!/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
|
||||||
|
from api_tokens_db import migrate_api_tokens_tables # noqa: E402
|
||||||
|
|
||||||
|
TEST_CONFIG = {
|
||||||
|
"TESTING": True,
|
||||||
|
"JWT_SECRET_KEY": "test-secret-hrt80",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app():
|
||||||
|
# Enforce this module s temp DB at fixture runtime
|
||||||
|
os.environ["TURF_SAAS_DB"] = _tmp_db.name
|
||||||
|
os.environ["JWT_SECRET_KEY"] = "test-secret-hrt80"
|
||||||
|
migrate_api_tokens_tables() # ensure tables exist in THIS module s temp DB
|
||||||
|
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
|
||||||
@@ -107,6 +107,34 @@ def run_analytics():
|
|||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
||||||
|
def run_sync_turf_db():
|
||||||
|
"""Synchronise turf.db vers turf_saas.db"""
|
||||||
|
logger.info("🔄 [SCHEDULER] Sync turf.db -> turf_saas.db...")
|
||||||
|
try:
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"python3",
|
||||||
|
"/home/h3r7/turf_saas/sync_turf_db.py",
|
||||||
|
"--date",
|
||||||
|
datetime.now().strftime("%Y-%m-%d"),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
logger.info("✅ [SCHEDULER] Sync turf.db terminé")
|
||||||
|
else:
|
||||||
|
logger.error(f"❌ [SCHEDULER] Sync turf.db échoué: {result.stderr}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ [SCHEDULER] Erreur sync turf.db: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
||||||
def get_todays_race_time():
|
def get_todays_race_time():
|
||||||
"""Récupère l'heure de la course principale du jour depuis la DB
|
"""Récupère l'heure de la course principale du jour depuis la DB
|
||||||
Returns: timestamp en ms ou None
|
Returns: timestamp en ms ou None
|
||||||
@@ -193,6 +221,65 @@ def schedule_dynamic_scoring():
|
|||||||
logger.info("ℹ️ [SCHEDULER] Pas de course aujourd'hui, pas de scoring dynamique")
|
logger.info("ℹ️ [SCHEDULER] Pas de course aujourd'hui, pas de scoring dynamique")
|
||||||
|
|
||||||
|
|
||||||
|
def run_telegram_alerts():
|
||||||
|
"""Envoie les alertes Telegram pré-course aux utilisateurs Premium/Pro"""
|
||||||
|
logger.info("📨 [SCHEDULER] Envoi alertes Telegram pré-course...")
|
||||||
|
try:
|
||||||
|
os.chdir("/home/h3r7/turf_saas")
|
||||||
|
import telegram_alerts
|
||||||
|
|
||||||
|
stats = telegram_alerts.send_pre_race_alerts(minutes_before=30)
|
||||||
|
logger.info(
|
||||||
|
"✅ [SCHEDULER] Alertes Telegram: %d envoyées, %d ignorées, %d erreurs",
|
||||||
|
stats.get("sent", 0),
|
||||||
|
stats.get("skipped", 0),
|
||||||
|
stats.get("errors", 0),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ [SCHEDULER] Erreur alertes Telegram: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_dynamic_telegram_alerts():
|
||||||
|
"""Planifie les alertes Telegram 30min avant la course (même pattern que schedule_dynamic_scoring)"""
|
||||||
|
race_time = get_todays_race_time()
|
||||||
|
|
||||||
|
if race_time:
|
||||||
|
try:
|
||||||
|
# Convertir timestamp ms en datetime
|
||||||
|
dt = datetime.fromtimestamp(race_time / 1000)
|
||||||
|
race_hour = dt.hour
|
||||||
|
race_min = dt.minute
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"📅 [SCHEDULER] Alertes Telegram — course à {race_hour:02d}:{race_min:02d}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Alertes 30min avant la course
|
||||||
|
pre_min = race_min - 30
|
||||||
|
pre_hour = race_hour
|
||||||
|
if pre_min < 0:
|
||||||
|
pre_min += 60
|
||||||
|
pre_hour -= 1
|
||||||
|
|
||||||
|
alert_time = f"{pre_hour:02d}:{pre_min:02d}"
|
||||||
|
schedule.every().day.at(alert_time).do(run_telegram_alerts).tag(
|
||||||
|
"telegram", "dynamic"
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"📅 [SCHEDULER] Alertes Telegram planifiées à {alert_time} (30min avant la course)"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"⚠️ Impossible de planifier les alertes Telegram: {e}")
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"ℹ️ [SCHEDULER] Pas de course aujourd'hui, pas d'alertes Telegram dynamiques"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def schedule_dynamic_results():
|
def schedule_dynamic_results():
|
||||||
"""Planifie le scraping des résultats à H+1 (1h après la course)"""
|
"""Planifie le scraping des résultats à H+1 (1h après la course)"""
|
||||||
race_time = get_todays_race_time()
|
race_time = get_todays_race_time()
|
||||||
@@ -245,6 +332,9 @@ def main():
|
|||||||
# Scoring dynamique (15min avant course)
|
# Scoring dynamique (15min avant course)
|
||||||
schedule_dynamic_scoring()
|
schedule_dynamic_scoring()
|
||||||
|
|
||||||
|
# Alertes Telegram dynamiques (30min avant course)
|
||||||
|
schedule_dynamic_telegram_alerts()
|
||||||
|
|
||||||
# Résultats dynamiques (H+1)
|
# Résultats dynamiques (H+1)
|
||||||
schedule_dynamic_results()
|
schedule_dynamic_results()
|
||||||
|
|
||||||
@@ -253,6 +343,16 @@ def main():
|
|||||||
schedule.every().day.at("20:00").do(run_results).tag("results", "daily_fallback")
|
schedule.every().day.at("20:00").do(run_results).tag("results", "daily_fallback")
|
||||||
schedule.every().day.at("19:00").do(run_scraper).tag("scraper", "late_evening")
|
schedule.every().day.at("19:00").do(run_scraper).tag("scraper", "late_evening")
|
||||||
|
|
||||||
|
# Sync turf.db -> turf_saas.db (2x/jour: post-scraping + post-cotes)
|
||||||
|
schedule.every().day.at("11:00").do(run_sync_turf_db).tag("sync", "post_scraping")
|
||||||
|
schedule.every().day.at("17:00").do(run_sync_turf_db).tag("sync", "post_cotes")
|
||||||
|
|
||||||
|
# ML Cache: populate ml_predictions_cache après chaque sync
|
||||||
|
schedule.every().day.at("11:35").do(run_ml_cache).tag("ml_cache", "post_sync_am")
|
||||||
|
schedule.every().day.at("17:35").do(run_ml_cache).tag("ml_cache", "post_sync_pm")
|
||||||
|
schedule.every().day.at("09:30").do(run_ml_cache).tag("ml_cache", "morning")
|
||||||
|
schedule.every().day.at("13:30").do(run_ml_cache).tag("ml_cache", "pre_race")
|
||||||
|
|
||||||
schedule.every().sunday.at("02:00").do(run_ml).tag("ml", "weekly")
|
schedule.every().sunday.at("02:00").do(run_ml).tag("ml", "weekly")
|
||||||
schedule.every().wednesday.at("02:00").do(run_ml).tag("ml", "midweek")
|
schedule.every().wednesday.at("02:00").do(run_ml).tag("ml", "midweek")
|
||||||
|
|
||||||
@@ -273,6 +373,200 @@ def main():
|
|||||||
time.sleep(30)
|
time.sleep(30)
|
||||||
|
|
||||||
|
|
||||||
|
def run_ml_cache():
|
||||||
|
"""Populate ml_predictions_cache with ensemble (predict_v2) predictions"""
|
||||||
|
logger.info("🤖 [SCHEDULER] Mise à jour cache prédictions ML (ensemble)...")
|
||||||
|
try:
|
||||||
|
os.chdir("/home/h3r7/turf_saas")
|
||||||
|
import predict_v2
|
||||||
|
|
||||||
|
model = predict_v2.load_ensemble()
|
||||||
|
if model is None:
|
||||||
|
logger.warning("⚠️ [SCHEDULER] Ensemble model not available, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT p.*, c.distance, c.discipline, c.specialite,
|
||||||
|
c.nb_declares_partants, c.montant_prix, c.penetrometre_intitule,
|
||||||
|
c.libelle as course_libelle, c.libelle_court as hippodrome,
|
||||||
|
c.heure_depart_str, c.parcours
|
||||||
|
FROM pmu_partants p
|
||||||
|
LEFT JOIN pmu_courses c ON p.date_programme = c.date_programme
|
||||||
|
AND p.num_reunion = c.num_reunion AND p.num_course = c.num_course
|
||||||
|
WHERE p.date_programme = ?
|
||||||
|
ORDER BY p.num_reunion, p.num_course, p.num_pmu
|
||||||
|
""", (today,)).fetchall()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
logger.info("ℹ️ [SCHEDULER] No partants today, skipping ML cache")
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
partants = [dict(r) for r in rows]
|
||||||
|
|
||||||
|
course_lookup = {}
|
||||||
|
for p in partants:
|
||||||
|
key = (p["num_reunion"], p["num_course"])
|
||||||
|
if key not in course_lookup:
|
||||||
|
course_lookup[key] = {
|
||||||
|
"libelle": p.get("course_libelle", ""),
|
||||||
|
"libelle_court": p.get("hippodrome", ""),
|
||||||
|
"discipline": p.get("discipline", ""),
|
||||||
|
"distance": p.get("distance", 0),
|
||||||
|
"heure_depart_str": p.get("heure_depart_str", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
odds_by_horse = {}
|
||||||
|
for p in partants:
|
||||||
|
odds_by_horse[(p["num_reunion"], p["num_course"], p["num_pmu"])] = p.get("cote_direct", 0)
|
||||||
|
|
||||||
|
preds = predict_v2.predict_top3(partants, model=model)
|
||||||
|
if not preds:
|
||||||
|
logger.warning("⚠️ [SCHEDULER] No predictions generated")
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
enriched = []
|
||||||
|
for p in preds:
|
||||||
|
key = (p.get("num_reunion"), p.get("num_course"))
|
||||||
|
ci = course_lookup.get(key, {})
|
||||||
|
odds_key = (p.get("num_reunion"), p.get("num_course"), p.get("num_pmu"))
|
||||||
|
enriched.append({
|
||||||
|
"num_reunion": p.get("num_reunion"),
|
||||||
|
"num_course": p.get("num_course"),
|
||||||
|
"horse_name": p.get("horse_name"),
|
||||||
|
"horse_number": p.get("num_pmu"),
|
||||||
|
"odds": odds_by_horse.get(odds_key, 0),
|
||||||
|
"prob_top1": p.get("prob_top1"),
|
||||||
|
"prob_top3": p.get("prob_top3"),
|
||||||
|
"ml_score": p.get("ml_score"),
|
||||||
|
"recommendation": p.get("recommendation"),
|
||||||
|
"is_value_bet": p.get("is_value_bet", 0),
|
||||||
|
"is_outlier": 0,
|
||||||
|
"race_label": f"R{p.get('num_reunion', 0)}C{p.get('num_course', 0)}",
|
||||||
|
"race_name": ci.get("libelle", ""),
|
||||||
|
"hippodrome": ci.get("libelle_court", ""),
|
||||||
|
"discipline": ci.get("discipline", ""),
|
||||||
|
"distance": ci.get("distance", 0),
|
||||||
|
"heure": ci.get("heure_depart_str", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Calculate risques per race (same logic as dashboard_api.calculate_risque)
|
||||||
|
from collections import defaultdict
|
||||||
|
race_horses = defaultdict(list)
|
||||||
|
for p in enriched:
|
||||||
|
rkey = (p.get("num_reunion"), p.get("num_course"))
|
||||||
|
race_horses[rkey].append({
|
||||||
|
"odds": p.get("odds", 999),
|
||||||
|
"ml_score": p.get("ml_score", 0),
|
||||||
|
"prob_top1": p.get("prob_top1", 0),
|
||||||
|
"prob_top3": p.get("prob_top3", 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
race_risque = {}
|
||||||
|
for rkey, partants_list in race_horses.items():
|
||||||
|
label, score = _calc_risque(partants_list)
|
||||||
|
race_risque[rkey] = (label or "neutral", score or 50)
|
||||||
|
|
||||||
|
# Ensure table exists with all columns
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS ml_predictions_cache (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
date TEXT NOT NULL, num_reunion INTEGER, num_course INTEGER,
|
||||||
|
horse_name TEXT, horse_number INTEGER, odds REAL,
|
||||||
|
prob_top1 REAL, prob_top3 REAL, ml_score REAL,
|
||||||
|
recommendation TEXT, is_value_bet INTEGER DEFAULT 0,
|
||||||
|
is_outlier INTEGER DEFAULT 0, race_label TEXT, race_name TEXT,
|
||||||
|
hippodrome TEXT, discipline TEXT, distance REAL, heure TEXT,
|
||||||
|
model_version TEXT DEFAULT 'xgboost_v1',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
risque_label TEXT DEFAULT 'neutral', risque_score INTEGER DEFAULT 50,
|
||||||
|
UNIQUE(date, num_reunion, num_course, horse_name)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_ml_cache_date ON ml_predictions_cache(date)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE ml_predictions_cache ADD COLUMN risque_label TEXT DEFAULT 'neutral'")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE ml_predictions_cache ADD COLUMN risque_score INTEGER DEFAULT 50")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
conn.execute("DELETE FROM ml_predictions_cache WHERE date = ?", (today,))
|
||||||
|
|
||||||
|
for p in enriched:
|
||||||
|
rkey = (p.get("num_reunion"), p.get("num_course"))
|
||||||
|
rl, rs = race_risque.get(rkey, ("neutral", 50))
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO ml_predictions_cache
|
||||||
|
(date, num_reunion, num_course, horse_name, horse_number, odds,
|
||||||
|
prob_top1, prob_top3, ml_score, recommendation, is_value_bet, is_outlier,
|
||||||
|
race_label, race_name, hippodrome, discipline, distance, heure,
|
||||||
|
risque_label, risque_score, model_version)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
""", (
|
||||||
|
today, p.get("num_reunion"), p.get("num_course"),
|
||||||
|
p.get("horse_name"), p.get("horse_number"), p.get("odds"),
|
||||||
|
p.get("prob_top1"), p.get("prob_top3"), p.get("ml_score"),
|
||||||
|
p.get("recommendation"), p.get("is_value_bet", 0), p.get("is_outlier", 0),
|
||||||
|
p.get("race_label"), p.get("race_name"), p.get("hippodrome"),
|
||||||
|
p.get("discipline"), p.get("distance"), p.get("heure"),
|
||||||
|
rl, rs, "ensemble_v1",
|
||||||
|
))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
logger.info(f"✅ [SCHEDULER] ML cache mis à jour: {len(enriched)} prédictions pour {today}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ [SCHEDULER] Erreur ML cache: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
||||||
|
def _calc_risque(partants_list):
|
||||||
|
"""Same logic as dashboard_api.calculate_risque — kept local to avoid import side effects"""
|
||||||
|
if not partants_list:
|
||||||
|
return None, None
|
||||||
|
sorted_p = sorted(
|
||||||
|
partants_list,
|
||||||
|
key=lambda x: x.get("ml_score") or x.get("prob_top1") or 0,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
top1_score = sorted_p[0].get("ml_score") or sorted_p[0].get("prob_top1") or 0
|
||||||
|
top2_score = (
|
||||||
|
sorted_p[1].get("ml_score") or sorted_p[1].get("prob_top1") or 0
|
||||||
|
if len(sorted_p) > 1 else 0
|
||||||
|
)
|
||||||
|
gap_1_2 = top1_score - top2_score
|
||||||
|
nb_dangerous = sum(1 for p in sorted_p if (p.get("ml_score") or 0) > 40)
|
||||||
|
odds_fav = sorted(partants_list, key=lambda x: x.get("odds") or 999)
|
||||||
|
fav_odds = odds_fav[0].get("odds") or 999 if odds_fav else 999
|
||||||
|
fav_ml = (
|
||||||
|
odds_fav[0].get("ml_score") or odds_fav[0].get("prob_top1") or 0
|
||||||
|
if odds_fav else 0
|
||||||
|
)
|
||||||
|
fav_surprise = fav_odds < 5 and fav_ml < 25
|
||||||
|
if top1_score >= 65 and gap_1_2 >= 20:
|
||||||
|
score = min(100, int(50 + gap_1_2 * 1.5))
|
||||||
|
return "safe", score
|
||||||
|
if fav_surprise:
|
||||||
|
return "trap", max(10, int(35 - (25 - fav_ml)))
|
||||||
|
if nb_dangerous >= 4 and top1_score < 70:
|
||||||
|
return "trap", max(10, int(40 - nb_dangerous * 2))
|
||||||
|
if gap_1_2 < 8 and top2_score > 45:
|
||||||
|
return "trap", max(15, int(30 + gap_1_2))
|
||||||
|
score = min(64, max(35, int(35 + gap_1_2 * 1.2)))
|
||||||
|
return "neutral", score
|
||||||
|
|
||||||
|
|
||||||
def run_metrics_alerts():
|
def run_metrics_alerts():
|
||||||
"""Verifie les metriques du jour et envoie une alerte email si ROI > 1.0€"""
|
"""Verifie les metriques du jour et envoie une alerte email si ROI > 1.0€"""
|
||||||
logger.info("📧 [SCHEDULER] Vérification alertes métriques...")
|
logger.info("📧 [SCHEDULER] Vérification alertes métriques...")
|
||||||
|
|||||||
Reference in New Issue
Block a user