Compare commits

..

15 Commits

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

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

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-29 17:25:30 +02:00
DevOps Engineer
946bdc65b6 feat(HRT-82): Multi-compte / Organisation Pro (max 5 users)
- Add org_db.py: SQLite schema with organizations + org_members tables
  PRAGMA foreign_keys=ON, ON DELETE CASCADE, UNIQUE constraints
- Add api_v1/routes/org.py: CRUD org endpoints + invite/accept flow
  POST/GET/DELETE /api/v1/org, POST /api/v1/org/invite,
  GET/DELETE /api/v1/org/members — Pro plan only, max 5 members
- Add tests/test_org.py: 36 unit tests (35/36 pass; 1 test-env issue)
- Update api_v1/__init__.py: register org_bp
- Update saas_api_v1.py: register org_bp on portal_server app via record_once
- Service restarted, /api/v1/org/* endpoints live (401 on unauthenticated)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-29 17:09:13 +02:00
DevOps Engineer
bc5ee3fa1a Merge feature/HRT-81-history-blueprint — Historique limité/illimité selon plan (Free/Premium/Pro)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-29 17:05:01 +02:00
DevOps Engineer
701660ce83 fix(HRT-81): enregistrer history_bp dans api_v1/__init__.py
- Ajouter import de history_bp depuis .routes.history
- Ajouter app.register_blueprint(history_bp) dans register_api_v1()
- Corriger le docstring du module pour lister /api/v1/history
- Tests: 19/19 passed (GET /api/v1/history — auth, free/premium/pro, validation, pagination)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-29 16:56:35 +02:00
b7ed82418f Merge pull request '[HRT-79] Alertes Telegram configurables (Premium)' (#11) from feature/HRT-79-telegram-alerts into master
Some checks failed
CD / Deploy → Staging (push) Has been cancelled
CD / Smoke Tests on Staging (push) Has been cancelled
CD / Deploy → Production (push) Has been cancelled
CD / Rollback Production (push) Has been cancelled
2026-04-29 16:48:46 +02:00
DevOps Engineer
8604dc78b1 feat(HRT-79): alertes Telegram configurables Premium/Pro
- telegram_alerts.py: service envoi alertes via Bot API (send_pre_race_alerts,
  build_race_alert, send_telegram_message) — gestion gracieuse TELEGRAM_BOT_TOKEN absent
- auth_db.py: migrate_telegram_columns() idempotente (ALTER TABLE + try/except OperationalError)
  colonnes: telegram_chat_id, alert_value_bets, alert_top1, alert_quinte_only
- api_v1/routes/user.py: blueprint user_bp GET/POST /api/v1/user/telegram-config
  protégé @jwt_required_middleware + @plan_required('premium','pro')
- api_v1/__init__.py: import + register user_bp
- turf_scheduler.py: run_telegram_alerts() + schedule_dynamic_telegram_alerts()
  planifiées 30min avant course (même pattern que schedule_dynamic_scoring)
  avec try/except Exception + fallback logger

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-29 16:42:15 +02:00
DevOps Engineer
30464fb40c Merge branch 'feature/HRT-84-dashboard-premium-pro' into master
Some checks failed
CD / Deploy → Staging (push) Has been cancelled
CD / Smoke Tests on Staging (push) Has been cancelled
CD / Deploy → Production (push) Has been cancelled
CD / Rollback Production (push) Has been cancelled
[HRT-84] Dashboard SaaS — UI Premium & Pro avec gating plan strict
- Sections Value Bets, Historique, Export CSV raccordées aux vrais endpoints
- Sections Telegram, API Token, Webhook avec mocks (TODO HRT-79, HRT-80)
- Gating plan strict: Free/Premium/Pro non contournable côté client
- Fix: maxDays Pro = 365j (corrige inversion 30j vs 90j)
- Multi-compte Pro: gating UI uniquement (endpoint non défini)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-29 15:49:56 +02:00
DevOps Engineer
31db3a8260 fix(HRT-84): maxDays historique Pro — 365j au lieu de 30j (inversion corrigée)
Pro = 365j (historique le plus long), Premium = 90j, Free = 7j
Corrigé suite au point d'attention CTO dans revue de code.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-29 15:49:25 +02:00
DevOps Engineer
278245cd7c feat(HRT-84): dashboard SaaS — UI Premium & Pro avec gating plan strict
- Ajout sections: Value Bets, Alertes Telegram, API Token, Webhook, Historique, Multi-compte
- Gating plan strict: Free < Premium < Pro (jamais de données réelles derrière plan inférieur)
- Value Bets: raccordé sur endpoint réel /api/v1/valuebets (premium+)
- Historique: raccordé sur endpoint réel /api/v1/history (HRT-81)
- Telegram / API Token / Webhook: mocks structurés avec contrats d'interface
  (TODO: replace mock — HRT-79 pour Telegram, HRT-80 pour API Token/Webhook)
- Multi-compte: gating UI Pro uniquement, endpoint non défini
- Navigation par section avec chargement lazy
- Design cohérent dark theme avec badges, lock icons et CTA upgrade par plan

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-29 15:43:02 +02:00
DevOps Engineer
ec024d8236 feat(HRT-83): intégrer météo & terrain dans prédictions ML (Premium)
- scoring_v2.py : ajout get_terrain_condition() + compute_weather_impact()
  score_cheval_v2() accepte weather_data=None (backward-compat préservée)
  Impact météo/terrain sur [-5, +5] pts selon pénétromètre + vent + temp

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

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

Tests : 42/42 passent (pytest tests/test_api_v1.py)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-29 15:35:15 +02:00
DevOps Engineer
225295030b fix(HRT-73): refactor api_proxy — COMBINED_ROUTES tuple + align with turf_scraper fix #23
Some checks failed
CD / Deploy → Staging (push) Has been cancelled
CD / Smoke Tests on Staging (push) Has been cancelled
CD / Deploy → Production (push) Has been cancelled
CD / Rollback Production (push) Has been cancelled
- Replace if/elif chain with COMBINED_ROUTES tuple for maintainability
- Add missing routes to combined_api: races, race/, scores, ask, brave-search,
  execute-sql, send-email, report, ideas
- Functionally equivalent to turf_scraper commit 048b969

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-27 22:38:32 +02:00
24 changed files with 5517 additions and 271 deletions

View File

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

View File

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

57
api_tokens_db.py Normal file
View File

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

View File

@@ -3,6 +3,9 @@
API v1 Blueprint package — Turf SaaS
Sprint 3-4: HRT-29 — Refacto API /v1/
Sprint 5-6: HRT-31 — Billing Stripe
HRT-79: Alertes Telegram configurables (user blueprint)
HRT-80: API Token personnel + Webhook alertes (Pro)
HRT-82: Multi-compte / Organisation Pro (max 5 users)
Registers sub-blueprints:
/api/v1/health — public health-check
@@ -13,6 +16,11 @@ Registers sub-blueprints:
/api/v1/export/ — export CSV (pro)
/api/v1/metrics — métriques perf ML (premium+)
/api/v1/billing/ — Stripe checkout, portal, webhook, status
/api/v1/user/ — config utilisateur, alertes Telegram (premium+)
/api/v1/user/api-token — Personal API token (Pro)
/api/v1/user/webhook — Webhook config (Pro)
/api/v1/history — historique préd. ML (Free:7j, Premium:90j, Pro:illimité)
/api/v1/org/ — organisations Pro (multi-compte, max 5 users)
/api/v1/docs — Swagger UI (via flasgger, registered on app)
"""
@@ -26,6 +34,10 @@ from .routes.backtest import backtest_bp
from .routes.export import export_bp
from .routes.metrics import metrics_bp
from .routes.billing import billing_bp
from .routes.user import user_bp
from .routes.user_tokens import user_tokens_bp
from .routes.history import history_bp
from .routes.org import org_bp
# Master blueprint that aggregates all sub-routes under /api/v1
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
@@ -41,3 +53,7 @@ def register_api_v1(app):
app.register_blueprint(export_bp)
app.register_blueprint(metrics_bp)
app.register_blueprint(billing_bp)
app.register_blueprint(user_bp)
app.register_blueprint(user_tokens_bp)
app.register_blueprint(history_bp)
app.register_blueprint(org_bp)

212
api_v1/routes/history.py Normal file
View File

@@ -0,0 +1,212 @@
#!/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,
)
from auth import 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(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()

536
api_v1/routes/org.py Normal file
View 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)

View File

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

216
api_v1/routes/user.py Normal file
View File

@@ -0,0 +1,216 @@
#!/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
from auth import jwt_required_middleware, plan_required
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()

View File

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

View File

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

80
api_v1/utils_webhook.py Normal file
View File

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

52
auth.py
View File

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

View File

@@ -2,6 +2,7 @@
"""
Auth DB — users and subscriptions schema for turf_saas.db
Sprint 2-3: Auth JWT + Multi-tenant (HRT-28)
HRT-79: migration Telegram columns
"""
import sqlite3
@@ -63,6 +64,36 @@ def init_auth_tables():
conn.close()
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__":
init_auth_tables()

File diff suppressed because it is too large Load Diff

72
org_db.py Normal file
View 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()

View File

@@ -743,19 +743,29 @@ def pod_static(filename=""):
@app.route("/turf/api/")
@app.route("/turf/api/<path:api_path>")
def api_proxy(api_path=""):
if api_path.startswith("vitesse"):
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
elif api_path.startswith("n8n-proxy"):
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
elif api_path.startswith("backtest"):
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
elif api_path.startswith("stats"):
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
elif api_path.startswith("predictions_analysis"):
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
elif api_path.startswith("parisroi"):
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
elif api_path.startswith("paris"):
# Routes servies par combined_api.py (port 8790) :
# backtest, stats, paris, parisroi, races, scores, report, ask, brave-search,
# execute-sql, send-email, vitesse, n8n-proxy, predictions_analysis, ideas
# Fix HRT-73 : alignement complet avec turf_scraper fix #23
COMBINED_ROUTES = (
"backtest",
"stats",
"parisroi",
"paris",
"predictions_analysis",
"vitesse",
"n8n-proxy",
"races",
"race/",
"scores",
"ask",
"brave-search",
"execute-sql",
"send-email",
"report",
"ideas",
)
if any(api_path.startswith(r) for r in COMBINED_ROUTES):
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
elif api_path.startswith("scoring"):
url = f"{DASHBOARD_API_URL}/turf/api/{api_path}"
@@ -770,11 +780,17 @@ def api_proxy(api_path=""):
if fwd_method in ("POST", "PUT", "PATCH")
else None
)
# Forwarder Authorization header (combined_api.py exige Basic h3r7:h3r7 pour parisroi/paris)
fwd_headers = {"Content-Type": "application/json"}
if request.headers.get("Authorization"):
fwd_headers["Authorization"] = request.headers.get("Authorization")
incoming_auth = request.headers.get("Authorization")
if incoming_auth:
fwd_headers["Authorization"] = incoming_auth
resp = requests.request(
method=fwd_method, url=url, json=fwd_json, timeout=30, headers=fwd_headers
method=fwd_method,
url=url,
json=fwd_json,
timeout=30,
headers=fwd_headers,
)
return resp.content, resp.status_code, {"Content-Type": "application/json"}
except Exception as e:

View File

@@ -268,15 +268,33 @@ try:
@api_v1_bp.record_once
def _init_jwt(state):
app = state.app
if not app.config.get('JWT_SECRET_KEY'):
if not app.config.get("JWT_SECRET_KEY"):
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:
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'
# (parent api_v1_bp has '/api/v1', so result is /api/v1/billing/*)
api_v1_bp.register_blueprint(billing_bp, url_prefix='/billing')
print('[saas_api_v1] Billing blueprint (Stripe) + JWT registered ✅')
api_v1_bp.register_blueprint(billing_bp, url_prefix="/billing")
print("[saas_api_v1] Billing blueprint (Stripe) + JWT registered ✅")
except Exception as _billing_err:
print(f'[saas_api_v1] Warning: billing blueprint not loaded: {_billing_err}')
print(f"[saas_api_v1] Warning: billing blueprint not loaded: {_billing_err}")
# ─── Org Blueprint — HRT-82 ───────────────────────────────────────────────────
# Registers /api/v1/org/* routes (Pro plan only, multi-compte max 5 users)
try:
from api_v1.routes.org import org_bp
@api_v1_bp.record_once
def _register_org_bp(state):
app = state.app
app.register_blueprint(org_bp)
print("[saas_api_v1] Org blueprint (multi-compte Pro) registered ✅")
except Exception as _org_err:
print(f"[saas_api_v1] Warning: org blueprint not loaded: {_org_err}")

View File

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

View File

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

284
telegram_alerts.py Normal file
View 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

407
tests/test_history.py Normal file
View File

@@ -0,0 +1,407 @@
#!/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():
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
"""
db_path = os.environ["TURF_SAAS_DB"]
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
db_path = os.environ["TURF_SAAS_DB"]
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
View 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

383
tests/test_user_tokens.py Normal file
View File

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

View File

@@ -193,6 +193,65 @@ def schedule_dynamic_scoring():
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():
"""Planifie le scraping des résultats à H+1 (1h après la course)"""
race_time = get_todays_race_time()
@@ -245,6 +304,9 @@ def main():
# Scoring dynamique (15min avant course)
schedule_dynamic_scoring()
# Alertes Telegram dynamiques (30min avant course)
schedule_dynamic_telegram_alerts()
# Résultats dynamiques (H+1)
schedule_dynamic_results()