Compare commits

..

23 Commits

Author SHA1 Message Date
CTO H3R7Tech
837cddb406 feat: Client CRUD admin blueprint + auth + subscription management (HRT-199)
- New api_v1/routes/admin.py: admin client management blueprint
- admin_users table for admin role (no ALTER TABLE needed)
- require_admin decorator for endpoint protection
- GET/PUT/DELETE /api/v1/admin/clients/<id>
- POST /api/v1/admin/setup (first-time admin init)
- POST /api/v1/admin/clients/<id>/suspend|activate
- GET /api/v1/admin/stats (client counts by plan)
- Registered in api_v1/__init__: auto-wired into portal_server.py
- No new service, no merge tables, no ALTER TABLE
2026-05-24 10:12:10 +02:00
CTO H3R7Tech
8ab42343aa feat: Token Broker infrastructure (HRT-205)
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
- PostgreSQL dedie Docker (postgres:16-alpine, port 5434)
- 6 tables: api_tokens, refresh_tokens, token_audit_log, clients, providers, token_usage
- Init SQL + Flask init_db() mis a jour
- Systemd service token-broker (port 8783)
- Deploy script infra/scripts/deploy_token_broker.sh
- Docker compose broker (docker-compose.broker.yml)
- Health check OK: status=ok, database=connected

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-24 09:22:12 +02:00
CTO H3R7Tech
cd4cbcfb48 Fix #2+#3: Routes API 404 et conflit blueprint name
Bug #2: portal_server.py importait api_v1_bp depuis saas_api_v1 au lieu
de api_v1/__init__.py. Tous les sous-blueprints api_v1/routes/* (health,
courses, predictions, valuebets, backtest, export, metrics, ml_feedback)
n'etaient jamais enregistres -> 404.
Fix: utiliser register_api_v1(app) depuis api_v1/__init__.py.

Bug #3: Conflit de nom de blueprint entre saas_api_v1 et api_v1 (tous
deux nommes api_v1). Renomme le blueprint de saas_api_v1 en saas_api_v1_bp.
Supprime les record_once handlers de saas_api_v1 qui dupliquaient
l'enregistrement de sous-blueprints (billing, org, user, history) -
desormais geres par register_api_v1(app).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-23 22:57:06 +02:00
CTO H3R7Tech
c072f92794 Fix #1: Ajout job run_ml_cache dans scheduler pour alimenter ml_predictions_cache
- run_ml_cache() lit les partants, genere predictions via predict_v2,
  enrichit avec metadonnees course, calcule risque, ecrit dans cache
- Planifie 4x/jour: 09:30, 11:35, 13:30, 17:35
- Installe dependances: optuna, shap, lightgbm

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-23 22:54:29 +02:00
CTO H3R7Tech
fac498efec fix: test isolation + auth import compatibility + add optuna to requirements (HRT-136)
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
Test isolation fixes:
- auth_db.get_db(): read TURF_SAAS_DB dynamically (not frozen at import)
- api_v1/utils.get_db(): read TURF_SAAS_DB dynamically (not frozen at import)
- api_tokens_db.get_db(): read TURF_SAAS_DB dynamically (not frozen at import)
- tests/test_history.py: enforce _tmp_db.name + call init_auth_tables() in fixtures
- tests/test_user_tokens.py: enforce _tmp_db.name + call migrate_api_tokens_tables() in app fixture

Auth compatibility fixes:
- api_v1/routes/history.py: use auth.jwt_required_middleware (flask_jwt_extended)
  with saas_auth fallback for portal_server context
- api_v1/routes/ml_feedback.py: same auth import strategy
- api_v1/routes/user.py: same auth import strategy

Dependencies:
- requirements.txt: add optuna>=4.0.0 (used in ML ensemble tests and training)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-10 08:45:31 +02:00
CTO H3R7Tech
1ccf9f5cb8 feat: LeadHunter CRUD API + auth fixes + blueprint registrations (HRT-136)
- leadhunter_crm.py: add update_lead(), delete_lead(); expand VALID_STATUSES to 7-step Kanban with legacy migration map
- leadhunter_api.py: add GET/PUT/DELETE /api/leads/<id> endpoints; import update_lead, delete_lead
- portal_server.py: add routes for /leadhunter/clients/le-big-ben/ and /formation/ai102
- saas_api_v1.py: register user blueprint (HRT-79/80) and history blueprint (HRT-81)
- api_v1/routes/user.py: switch auth import to saas_auth.require_auth
- api_v1/routes/history.py: fix auth import + request.current_user fallback
- api_v1/routes/ml_feedback.py: fix auth import + request.current_user fallback

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-10 08:29:44 +02:00
DevOps Engineer
a126941f7f feat(saas): métriques ML + TEST_MODE + compte test pro
- portal_server.py: enregistre metrics_bp (/api/v1/metrics)
- api_v1/routes/metrics.py: switch vers saas_auth.require_auth (compat token opaque)
- dashboard_saas.html: onglet Métriques (KPIs + Chart.js ROI/précision/cumul + table daily)
- dashboard_saas.html: TEST_MODE=true -> plan level pro pour toutes les fonctionnalités
- turf_saas.db: compte admin@h3r7.ai / Test1234! plan=pro (test)
2026-05-02 22:49:59 +02:00
DevOps Engineer
3079c2c6c6 Merge branch 'feature/HRT-96-note-intelligence-ml'
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-05-01 11:43:31 +02:00
DevOps Engineer
52c0c95f22 feat(HRT-93): ml_feedback_saas.py — feedback loop ML pour turf_saas
- Crée ml_feedback_saas.py (adaptation de ml_feedback.py pour turf_saas.db)
  - DB_PATH = /home/h3r7/turf_saas/turf_saas.db
  - Stratégies : xgboost_sg, xgboost_value, xgboost_sp, xgboost_2sur4
  - Idempotent (ne duplique pas les paris existants)
  - Tested : 188 paris insérés en 1ère exécution, 0 en 2ème (idempotence OK)
- Crée api_v1/routes/ml_feedback.py
  - POST /api/v1/ml/feedback/run (admin only via X-Admin-Token ou plan pro)
  - GET /api/v1/ml/feedback/stats (premium+)
- Enregistre ml_feedback_bp dans api_v1/__init__.py

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-30 21:36:21 +02:00
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
39 changed files with 8444 additions and 280 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 |

59
api_tokens_db.py Normal file
View File

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

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,7 +16,14 @@ 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)
/api/v1/ml/feedback/run — trigger feedback loop ML (admin)
/api/v1/ml/feedback/stats — stats par stratégie (premium+)
"""
from flask import Blueprint
@@ -26,6 +36,12 @@ 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
from .routes.ml_feedback import ml_feedback_bp
from .routes.admin import admin_bp
# Master blueprint that aggregates all sub-routes under /api/v1
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
@@ -41,3 +57,9 @@ def register_api_v1(app):
app.register_blueprint(export_bp)
app.register_blueprint(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)
app.register_blueprint(ml_feedback_bp)
app.register_blueprint(admin_bp)

587
api_v1/routes/admin.py Normal file
View File

@@ -0,0 +1,587 @@
#!/usr/bin/env python3
"""
Admin Blueprint — Client CRUD + Subscription management
HRT-199 — Foundation (Client CRUD + Auth + Subscription)
Endpoints:
POST /api/v1/admin/setup — init first admin (no auth, 1 call only)
GET /api/v1/admin/clients — list all clients (paginated, filterable)
GET /api/v1/admin/clients/<id> — client detail + subscription
PUT /api/v1/admin/clients/<id> — update client (plan, name, email)
DELETE /api/v1/admin/clients/<id> — delete client + tokens + subscription
POST /api/v1/admin/clients/<id>/suspend — suspend client (set plan=suspended)
POST /api/v1/admin/clients/<id>/activate — reactivate client (restore plan)
GET /api/v1/admin/stats — client stats (total, by plan, new/30d)
"""
import sqlite3
import logging
import os
from datetime import datetime, timezone
from functools import wraps
from flask import Blueprint, jsonify, request
from saas_auth import require_auth
from api_v1.utils import get_db, paginate_query, get_pagination_params, not_found, bad_request, internal_error
logger = logging.getLogger("turf_saas.admin")
admin_bp = Blueprint("admin", __name__, url_prefix="/api/v1/admin")
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
def _get_saas_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def migrate_admin_tables():
"""Idempotent: create admin_users table."""
conn = _get_saas_db()
conn.executescript("""
CREATE TABLE IF NOT EXISTS admin_users (
user_id TEXT PRIMARY KEY REFERENCES saas_users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
created_by TEXT
);
""")
conn.commit()
conn.close()
try:
migrate_admin_tables()
except Exception as e:
logger.warning("admin DB init warning: %s", e)
def _is_admin(user_id: str, db=None) -> bool:
if not user_id:
return False
close = False
if db is None:
db = _get_saas_db()
close = True
try:
row = db.execute(
"SELECT 1 FROM admin_users WHERE user_id = ?", (user_id,)
).fetchone()
return row is not None
finally:
if close:
db.close()
def require_admin(f):
@wraps(f)
def decorated(*args, **kwargs):
user = getattr(request, "current_user", None)
if not user:
return jsonify({"error": "Non authentifié"}), 401
if not _is_admin(user["id"]):
return jsonify({"error": "Accès administrateur requis"}), 403
return f(*args, **kwargs)
return decorated
def _user_to_client(row) -> dict:
return {
"id": row["id"],
"email": row["email"],
"firstname": row.get("firstname", ""),
"lastname": row.get("lastname", ""),
"plan": row.get("plan", "free"),
"telegram_chat_id": row.get("telegram_chat_id"),
"alert_value_bets": bool(row.get("alert_value_bets", 1)),
"alert_top1": bool(row.get("alert_top1", 1)),
"alert_quinte_only": bool(row.get("alert_quinte_only", 0)),
"created_at": row.get("created_at"),
"updated_at": row.get("updated_at"),
}
def _fetch_subscription(db, user_id: str):
return db.execute(
"""SELECT * FROM saas_subscriptions
WHERE user_id = ? ORDER BY start_date DESC LIMIT 1""",
(user_id,),
).fetchone()
# ─── POST /api/v1/admin/setup ─────────────────────────────────
@admin_bp.route("/setup", methods=["POST"])
def admin_setup():
"""Init first admin (no auth). Only works once — when admin_users is empty."""
data = request.get_json(silent=True) or {}
email = (data.get("email") or "").strip().lower()
if not email or "@" not in email:
return jsonify({"error": "Email valide requis"}), 400
db = _get_saas_db()
try:
existing = db.execute("SELECT 1 FROM admin_users LIMIT 1").fetchone()
if existing:
return jsonify({"error": "Admin déjà configuré"}), 409
user = db.execute(
"SELECT id, email FROM saas_users WHERE email = ?", (email,)
).fetchone()
if not user:
return jsonify({"error": "Utilisateur introuvable avec cet email"}), 404
db.execute(
"INSERT INTO admin_users (user_id, created_by) VALUES (?, 'setup')",
(user["id"],),
)
db.commit()
logger.info("Admin setup: user %s (%s) promoted to admin", user["id"], email)
return jsonify({"ok": True, "user_id": user["id"], "email": email}), 201
except Exception as e:
db.rollback()
logger.error("admin_setup error: %s", e)
return jsonify({"error": "Erreur interne"}), 500
finally:
db.close()
# ─── GET /api/v1/admin/clients ─────────────────────────────────
@admin_bp.route("/clients", methods=["GET"])
@require_auth
@require_admin
def list_clients():
"""List all clients with pagination and filters.
---
tags:
- Admin
security:
- Bearer: []
parameters:
- in: query
name: page
type: integer
- in: query
name: per_page
type: integer
- in: query
name: search
type: string
description: Search by email or name
- in: query
name: plan
type: string
description: Filter by plan (free, premium, pro, suspended)
- in: query
name: sort_by
type: string
enum: [created_at, email, plan, updated_at]
- in: query
name: sort_order
type: string
enum: [asc, desc]
responses:
200:
description: Paginated client list
403:
description: Admin access required
"""
page = request.args.get("page", 1, type=int)
per_page = request.args.get("per_page", 20, type=int)
search = request.args.get("search", "").strip()
plan_filter = request.args.get("plan", "").strip()
sort_by = request.args.get("sort_by", "created_at").strip()
sort_order = request.args.get("sort_order", "desc").strip()
if sort_by not in ("created_at", "email", "plan", "updated_at"):
sort_by = "created_at"
if sort_order not in ("asc", "desc"):
sort_order = "desc"
if per_page < 1 or per_page > 100:
per_page = 20
if page < 1:
page = 1
offset = (page - 1) * per_page
db = _get_saas_db()
try:
conditions = []
params = []
if search:
conditions.append("(email LIKE ? OR firstname LIKE ? OR lastname LIKE ?)")
p = f"%{search}%"
params.extend([p, p, p])
if plan_filter:
conditions.append("plan = ?")
params.append(plan_filter)
where = (" WHERE " + " AND ".join(conditions)) if conditions else ""
total = db.execute(
f"SELECT COUNT(*) FROM saas_users{where}", params
).fetchone()[0]
rows = db.execute(
f"SELECT * FROM saas_users{where} ORDER BY {sort_by} {sort_order} LIMIT ? OFFSET ?",
params + [per_page, offset],
).fetchall()
result = []
for row in rows:
client = _user_to_client(row)
sub = _fetch_subscription(db, row["id"])
if sub:
client["subscription"] = {
"plan": sub["plan"],
"status": sub["status"],
"start_date": sub["start_date"],
"current_period_end": sub["current_period_end"],
"stripe_customer_id": sub["stripe_customer_id"],
}
else:
client["subscription"] = None
result.append(client)
return jsonify({
"clients": result,
"pagination": {
"page": page,
"per_page": per_page,
"total": total,
"total_pages": (total + per_page - 1) // per_page,
},
}), 200
except Exception as e:
logger.error("list_clients error: %s", e)
return jsonify({"error": "Erreur interne"}), 500
finally:
db.close()
# ─── GET /api/v1/admin/clients/<id> ────────────────────────────
@admin_bp.route("/clients/<string:client_id>", methods=["GET"])
@require_auth
@require_admin
def get_client(client_id: str):
"""Get client details with subscription info.
---
tags:
- Admin
security:
- Bearer: []
parameters:
- in: path
name: id
type: string
required: true
responses:
200:
description: Client details
404:
description: Client not found
"""
db = _get_saas_db()
try:
row = db.execute(
"SELECT * FROM saas_users WHERE id = ?", (client_id,)
).fetchone()
if not row:
return jsonify({"error": "Client introuvable"}), 404
client = _user_to_client(row)
sub = _fetch_subscription(db, client_id)
if sub:
client["subscription"] = {
"id": sub["id"],
"plan": sub["plan"],
"status": sub["status"],
"start_date": sub["start_date"],
"end_date": sub["end_date"],
"current_period_end": sub["current_period_end"],
"grace_period_end": sub["grace_period_end"],
"stripe_customer_id": sub["stripe_customer_id"],
"stripe_subscription_id": sub["stripe_subscription_id"],
}
else:
client["subscription"] = None
return jsonify({"client": client}), 200
except Exception as e:
logger.error("get_client error: %s", e)
return jsonify({"error": "Erreur interne"}), 500
finally:
db.close()
# ─── PUT /api/v1/admin/clients/<id> ────────────────────────────
@admin_bp.route("/clients/<string:client_id>", methods=["PUT"])
@require_auth
@require_admin
def update_client(client_id: str):
"""Update client fields (plan, firstname, lastname, email).
---
tags:
- Admin
security:
- Bearer: []
parameters:
- in: path
name: id
type: string
required: true
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
firstname: { type: string }
lastname: { type: string }
email: { type: string }
plan: { type: string, enum: [free, premium, pro, suspended] }
responses:
200:
description: Client updated
400:
description: Invalid parameters
404:
description: Client not found
"""
data = request.get_json(silent=True) or {}
if not data:
return jsonify({"error": "Corps JSON requis"}), 400
db = _get_saas_db()
try:
existing = db.execute(
"SELECT id FROM saas_users WHERE id = ?", (client_id,)
).fetchone()
if not existing:
return jsonify({"error": "Client introuvable"}), 404
fields = {}
if "firstname" in data:
fields["firstname"] = data["firstname"].strip()
if "lastname" in data:
fields["lastname"] = data["lastname"].strip()
if "email" in data:
email = data["email"].strip().lower()
if "@" not in email:
return jsonify({"error": "Email invalide"}), 400
fields["email"] = email
if "plan" in data:
plan = data["plan"].strip().lower()
if plan not in ("free", "premium", "pro", "suspended"):
return jsonify({"error": "Plan invalide. free|premium|pro|suspended"}), 400
fields["plan"] = plan
if not fields:
return jsonify({"ok": True}), 200
set_clause = ", ".join(f"{k}=?" for k in fields)
values = list(fields.values()) + [datetime.now(timezone.utc).isoformat(), client_id]
db.execute(
f"UPDATE saas_users SET {set_clause}, updated_at=? WHERE id=?", values
)
db.commit()
logger.info("Admin %s updated client %s: %s",
request.current_user["id"], client_id, fields)
return jsonify({"ok": True, "updated": list(fields.keys())}), 200
except sqlite3.IntegrityError:
return jsonify({"error": "Cet email est déjà utilisé"}), 409
except Exception as e:
db.rollback()
logger.error("update_client error: %s", e)
return jsonify({"error": "Erreur interne"}), 500
finally:
db.close()
# ─── DELETE /api/v1/admin/clients/<id> ─────────────────────────
@admin_bp.route("/clients/<string:client_id>", methods=["DELETE"])
@require_auth
@require_admin
def delete_client(client_id: str):
"""Delete client and all associated data (tokens, subscriptions).
---
tags:
- Admin
security:
- Bearer: []
parameters:
- in: path
name: id
type: string
required: true
responses:
200:
description: Client deleted
404:
description: Client not found
"""
admin_id = request.current_user["id"]
if client_id == admin_id:
return jsonify({"error": "Impossible de supprimer votre propre compte"}), 400
db = _get_saas_db()
try:
existing = db.execute(
"SELECT id FROM saas_users WHERE id = ?", (client_id,)
).fetchone()
if not existing:
return jsonify({"error": "Client introuvable"}), 404
db.execute("DELETE FROM saas_tokens WHERE user_id = ?", (client_id,))
db.execute("DELETE FROM saas_subscriptions WHERE user_id = ?", (client_id,))
db.execute("DELETE FROM admin_users WHERE user_id = ?", (client_id,))
db.execute("DELETE FROM saas_users WHERE id = ?", (client_id,))
db.commit()
logger.info("Admin %s deleted client %s", admin_id, client_id)
return jsonify({"ok": True, "deleted_id": client_id}), 200
except Exception as e:
db.rollback()
logger.error("delete_client error: %s", e)
return jsonify({"error": "Erreur interne"}), 500
finally:
db.close()
# ─── POST /api/v1/admin/clients/<id>/suspend ───────────────────
@admin_bp.route("/clients/<string:client_id>/suspend", methods=["POST"])
@require_auth
@require_admin
def suspend_client(client_id: str):
"""Suspend a client by setting plan to 'suspended'.
---
tags:
- Admin
security:
- Bearer: []
responses:
200:
description: Client suspended
404:
description: Client not found
"""
return _set_client_plan(client_id, "suspended")
# ─── POST /api/v1/admin/clients/<id>/activate ──────────────────
@admin_bp.route("/clients/<string:client_id>/activate", methods=["POST"])
@require_auth
@require_admin
def activate_client(client_id: str):
"""Reactivate a suspended client to 'free' plan.
---
tags:
- Admin
security:
- Bearer: []
responses:
200:
description: Client activated
404:
description: Client not found
"""
return _set_client_plan(client_id, "free")
def _set_client_plan(client_id: str, plan: str):
db = _get_saas_db()
try:
existing = db.execute(
"SELECT id, plan FROM saas_users WHERE id = ?", (client_id,)
).fetchone()
if not existing:
return jsonify({"error": "Client introuvable"}), 404
db.execute(
"UPDATE saas_users SET plan=?, updated_at=? WHERE id=?",
(plan, datetime.now(timezone.utc).isoformat(), client_id),
)
db.commit()
action = "suspendu" if plan == "suspended" else "réactivé"
logger.info("Client %s %s par admin %s", client_id, action,
request.current_user["id"])
return jsonify({"ok": True, "client_id": client_id, "plan": plan, "action": action}), 200
except Exception as e:
db.rollback()
logger.error("_set_client_plan error: %s", e)
return jsonify({"error": "Erreur interne"}), 500
finally:
db.close()
# ─── GET /api/v1/admin/stats ────────────────────────────────────
@admin_bp.route("/stats", methods=["GET"])
@require_auth
@require_admin
def admin_stats():
"""Client stats: totals by plan, new this month/30d.
---
tags:
- Admin
security:
- Bearer: []
responses:
200:
description: Admin stats
"""
db = _get_saas_db()
try:
total = db.execute("SELECT COUNT(*) FROM saas_users").fetchone()[0]
by_plan = {}
for row in db.execute(
"SELECT plan, COUNT(*) AS cnt FROM saas_users GROUP BY plan"
).fetchall():
by_plan[row["plan"]] = row["cnt"]
new_30d = db.execute(
"SELECT COUNT(*) FROM saas_users WHERE created_at >= datetime('now', '-30 days')"
).fetchone()[0]
new_7d = db.execute(
"SELECT COUNT(*) FROM saas_users WHERE created_at >= datetime('now', '-7 days')"
).fetchone()[0]
active_subs = db.execute(
"SELECT COUNT(DISTINCT user_id) FROM saas_subscriptions WHERE status = 'active'"
).fetchone()[0]
return jsonify({
"total_clients": total,
"clients_by_plan": by_plan,
"new_last_30d": new_30d,
"new_last_7d": new_7d,
"active_subscriptions": active_subs,
}), 200
except Exception as e:
logger.error("admin_stats error: %s", e)
return jsonify({"error": "Erreur interne"}), 500
finally:
db.close()

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

@@ -0,0 +1,213 @@
#!/usr/bin/env python3
"""
History routes for API v1.
GET /api/v1/history — Historique des prédictions avec filtre date range,
limité selon le plan (Free: 7j, Premium: 90j, Pro: illimité)
Ticket: HRT-81 — Historique limité/illimité selon plan (Free/Premium/Pro)
"""
from datetime import datetime, timedelta
from flask import Blueprint, jsonify, request, g
from api_v1.utils import (
get_db,
table_exists,
internal_error,
bad_request,
forbidden,
get_pagination_params,
paginate_query,
)
# Auth: try flask_jwt_extended (app_v1) first, fall back to saas_auth (portal_server)
from saas_auth import require_auth as jwt_required_middleware
history_bp = Blueprint("v1_history", __name__, url_prefix="/api/v1/history")
# ──────────────────────────────────────────────────────────────
# Plan limits (days of history accessible; None = unlimited)
# ──────────────────────────────────────────────────────────────
HISTORY_DAYS = {
"free": 7,
"premium": 90,
"pro": None, # illimité
}
# Fallback for unknown plans: treat like free
_DEFAULT_LIMIT = 7
def _get_plan_max_days(plan: str):
"""Return the max history days allowed for the given plan, or default."""
return HISTORY_DAYS.get(plan, _DEFAULT_LIMIT)
def _parse_date(date_str: str, param_name: str):
"""Parse YYYY-MM-DD date string, raise ValueError with context on failure."""
try:
return datetime.strptime(date_str, "%Y-%m-%d").date()
except ValueError:
raise ValueError(
f"Paramètre '{param_name}' invalide : format attendu YYYY-MM-DD, reçu '{date_str}'"
)
# ──────────────────────────────────────────────────────────────
# GET /api/v1/history
# ──────────────────────────────────────────────────────────────
@history_bp.route("", methods=["GET"])
@jwt_required_middleware
def get_history():
"""
Historique des prédictions ML avec filtre date range
---
tags:
- Historique
summary: |
Historique des prédictions sur une plage de dates.
Limite selon le plan :
- Free : 7 derniers jours
- Premium : 90 derniers jours
- Pro : illimité
security:
- Bearer: []
parameters:
- name: start
in: query
type: string
format: date
description: Date de début au format YYYY-MM-DD (défaut : aujourd'hui - max_days du plan)
- name: end
in: query
type: string
format: date
description: Date de fin au format YYYY-MM-DD (défaut : aujourd'hui)
- name: limit
in: query
type: integer
default: 50
description: Nombre de résultats par page (max 500)
- name: offset
in: query
type: integer
default: 0
responses:
200:
description: Historique des prédictions ML
400:
description: Paramètre de date invalide
401:
description: Token invalide ou manquant
403:
description: Plage de dates hors limite du plan — upgrade requis
"""
user = getattr(request, "current_user", None) or getattr(g, "current_user", None)
if not user:
return jsonify({"error": "Non authentifié"}), 401
plan = user.get("plan", "free")
today = datetime.now().date()
max_days = _get_plan_max_days(plan)
# ── Parse end date ────────────────────────────────────────
end_str = request.args.get("end", today.isoformat())
try:
end_date = _parse_date(end_str, "end")
except ValueError as exc:
return bad_request(str(exc))
# ── Parse start date ─────────────────────────────────────
if max_days is not None:
default_start = today - timedelta(days=max_days - 1)
else:
# Pro: default to 30 days back when no start provided
default_start = today - timedelta(days=29)
start_str = request.args.get("start", default_start.isoformat())
try:
start_date = _parse_date(start_str, "start")
except ValueError as exc:
return bad_request(str(exc))
# ── Validate ordering ─────────────────────────────────────
if start_date > end_date:
return bad_request(
f"'start' ({start_str}) ne peut pas être postérieur à 'end' ({end_str})"
)
# ── Enforce plan window ───────────────────────────────────
if max_days is not None:
earliest_allowed = today - timedelta(days=max_days - 1)
if start_date < earliest_allowed:
return forbidden(
message=(
f"Historique limité à {max_days} jours pour le plan '{plan}'. "
f"Date de début minimale autorisée : {earliest_allowed.isoformat()}. "
f"Passez à un plan supérieur pour accéder à un historique plus long."
),
required_plans=["premium", "pro"] if plan == "free" else ["pro"],
current_plan=plan,
)
# ── Pagination ────────────────────────────────────────────
limit, offset = get_pagination_params(default_limit=50, max_limit=500)
# ── Query ─────────────────────────────────────────────────
conn = get_db()
try:
if not table_exists(conn, "ml_predictions_cache"):
return jsonify(
{
"status": "ok",
"plan": plan,
"start": start_date.isoformat(),
"end": end_date.isoformat(),
"history": [],
**paginate_query([], 0, limit, offset),
}
), 200
count_row = conn.execute(
"""SELECT COUNT(*) as cnt
FROM ml_predictions_cache
WHERE date >= ? AND date <= ?""",
(start_date.isoformat(), end_date.isoformat()),
).fetchone()
total = count_row["cnt"] if count_row else 0
sql = """
SELECT
id, date, horse_name, prob_top1, prob_top3,
ml_score, race_label, hippodrome, heure, is_value_bet
FROM ml_predictions_cache
WHERE date >= ? AND date <= ?
ORDER BY date DESC, ml_score DESC
LIMIT ? OFFSET ?
"""
rows = conn.execute(
sql,
(start_date.isoformat(), end_date.isoformat(), limit, offset),
).fetchall()
history = [dict(r) for r in rows]
return jsonify(
{
"status": "ok",
"plan": plan,
"history_limit_days": max_days,
"start": start_date.isoformat(),
"end": end_date.isoformat(),
"history": history,
**paginate_query(history, total, limit, offset),
}
), 200
except Exception as exc:
return internal_error(str(exc))
finally:
conn.close()

View File

@@ -14,15 +14,21 @@ from api_v1.utils import (
internal_error,
bad_request,
)
from auth import jwt_required_middleware, plan_required
from saas_auth import require_auth as jwt_required_middleware
from flask import request as _req
metrics_bp = Blueprint("v1_metrics", __name__, url_prefix="/api/v1")
@metrics_bp.route("/metrics", methods=["GET"])
@jwt_required_middleware
@plan_required("premium", "pro")
def metrics():
# plan check: premium or pro (or TEST_MODE via plan='pro' in DB)
user = getattr(_req, 'current_user', None) or {}
plan = user.get('plan', 'free') if isinstance(user, dict) else 'free'
if plan not in ('premium', 'pro'):
from flask import jsonify as _j
return _j({'error': 'Plan premium ou pro requis'}), 403
"""
Métriques ML
---

View File

@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""
ml_feedback.py — API routes pour le feedback loop ML (turf_saas).
Routes:
POST /api/v1/ml/feedback/run — Déclenche le feedback loop (admin uniquement)
GET /api/v1/ml/feedback/stats — Stats performances par stratégie
Sécurité admin : token via variable d'environnement ML_ADMIN_TOKEN
ou plan "pro" en fallback pour les stats.
"""
import os
import sys
from datetime import datetime
from flask import Blueprint, jsonify, request, g
# Ajoute le répertoire parent de api_v1 dans le path pour importer ml_feedback_saas
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
from api_v1.utils import get_db, internal_error, bad_request
# Auth: try flask_jwt_extended (app_v1) first, fall back to saas_auth (portal_server)
try:
from auth import jwt_required_middleware
except ImportError:
from saas_auth import require_auth as jwt_required_middleware
try:
from auth import plan_required
except ImportError:
plan_required = lambda *a, **kw: (lambda f: f)
ml_feedback_bp = Blueprint("v1_ml_feedback", __name__, url_prefix="/api/v1/ml/feedback")
# Token admin interne — configurable via variable d'environnement
ML_ADMIN_TOKEN = os.environ.get("ML_ADMIN_TOKEN", "")
def _check_admin(req):
"""Vérifie le token admin via header X-Admin-Token ou Authorization Bearer (plan pro)."""
# 1. Token interne (scheduler/cron)
admin_token = req.headers.get("X-Admin-Token", "").strip()
if ML_ADMIN_TOKEN and admin_token == ML_ADMIN_TOKEN:
return True, None
# 2. Pas de token admin configuré → autoriser les utilisateurs "pro" authentifiés
user = getattr(request, "current_user", None) or getattr(g, "current_user", None)
if user and user.get("plan") == "pro":
return True, None
return False, jsonify({"error": "Accès admin requis", "code": 403}), 403
@ml_feedback_bp.route("/run", methods=["POST"])
@jwt_required_middleware
def feedback_run():
"""
Déclenche le feedback loop ML pour une date donnée.
---
tags:
- ML Feedback
summary: Déclenche le feedback loop XGBoost (admin only)
security:
- Bearer: []
- AdminToken: []
parameters:
- name: body
in: body
schema:
type: object
properties:
date:
type: string
description: Date YYYY-MM-DD (défaut aujourd'hui)
example: "2026-04-25"
mode:
type: string
description: "run (défaut) ou backfill"
enum: [run, backfill]
example: run
responses:
200:
description: Feedback loop exécuté avec succès
400:
description: Paramètre invalide
403:
description: Accès refusé
500:
description: Erreur interne
"""
# Vérification admin
user = getattr(request, "current_user", None) or getattr(g, "current_user", None)
admin_token = request.headers.get("X-Admin-Token", "").strip()
is_admin = (ML_ADMIN_TOKEN and admin_token == ML_ADMIN_TOKEN) or (
user and user.get("plan") == "pro"
)
if not is_admin:
return jsonify({"error": "Accès admin requis", "code": 403}), 403
body = request.get_json(silent=True) or {}
date_str = body.get("date") or datetime.now().strftime("%Y-%m-%d")
mode = body.get("mode", "run")
# Validation date
try:
datetime.strptime(date_str, "%Y-%m-%d")
except ValueError:
return bad_request(f"Date invalide : {date_str}. Format attendu : YYYY-MM-DD")
if mode not in ("run", "backfill"):
return bad_request("mode doit être 'run' ou 'backfill'")
try:
import ml_feedback_saas
if mode == "backfill":
inseres, maj = ml_feedback_saas.backfill(date_str)
total_inseres = inseres
else:
result = ml_feedback_saas.run(date_str)
total_inseres = sum(result["inseres"].values())
maj = result["maj"]
return jsonify(
{
"status": "ok",
"date": date_str,
"mode": mode,
"paris_inseres": total_inseres,
"paris_mis_a_jour": maj,
}
), 200
except Exception as e:
return internal_error(str(e))
@ml_feedback_bp.route("/stats", methods=["GET"])
@jwt_required_middleware
@plan_required("premium", "pro")
def feedback_stats():
"""
Stats performances ML par stratégie.
---
tags:
- ML Feedback
summary: Stats paris ML par stratégie (premium+)
security:
- Bearer: []
parameters:
- name: date_debut
in: query
type: string
description: Date de début YYYY-MM-DD
- name: date_fin
in: query
type: string
description: Date de fin YYYY-MM-DD
responses:
200:
description: Stats par stratégie
401:
description: Token invalide
403:
description: Plan insuffisant (premium ou pro requis)
"""
date_debut = request.args.get("date_debut")
date_fin = request.args.get("date_fin")
# Validation optionnelle des dates
for d_str, label in [(date_debut, "date_debut"), (date_fin, "date_fin")]:
if d_str:
try:
datetime.strptime(d_str, "%Y-%m-%d")
except ValueError:
return bad_request(f"{label} invalide : {d_str}. Format : YYYY-MM-DD")
conn = get_db()
try:
import ml_feedback_saas
stats = ml_feedback_saas.get_feedback_stats(conn, date_debut, date_fin)
return jsonify(
{
"status": "ok",
"strategies": stats,
"filters": {
"date_debut": date_debut,
"date_fin": date_fin,
},
"total_strategies": len(stats),
}
), 200
except Exception as e:
return internal_error(str(e))
finally:
conn.close()

536
api_v1/routes/org.py Normal file
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,6 +39,28 @@ def _fetch_ml_predictions(conn, date: str, limit: int = None, offset: int = 0):
).fetchone()
total = count_row["cnt"] if count_row else 0
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,
@@ -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)

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

@@ -0,0 +1,224 @@
#!/usr/bin/env python3
"""
User route for API v1 — Telegram alert configuration
HRT-79: Alertes Telegram configurables (Premium)
GET /api/v1/user/telegram-config — Lire la config Telegram de l'utilisateur connecté
POST /api/v1/user/telegram-config — Mettre à jour la config Telegram
Accès : Premium / Pro uniquement (@jwt_required_middleware + @plan_required)
"""
import sqlite3
from flask import Blueprint, jsonify, request
from api_v1.utils import internal_error, bad_request
# Auth: try flask_jwt_extended (app_v1) first, fall back to saas_auth (portal_server)
try:
from auth import jwt_required_middleware
except ImportError:
from saas_auth import require_auth as jwt_required_middleware
try:
from auth import plan_required
except ImportError:
plan_required = lambda *a, **kw: (lambda f: f)
user_bp = Blueprint("v1_user", __name__, url_prefix="/api/v1/user")
# DB_PATH est résolu via la même variable d'env que auth_db.py
import os
_DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
def _get_db():
conn = sqlite3.connect(_DB_PATH)
conn.row_factory = sqlite3.Row
return conn
# ── GET /api/v1/user/telegram-config ──────────────────────────────────────────
@user_bp.route("/telegram-config", methods=["GET"])
@jwt_required_middleware
@plan_required("premium", "pro")
def get_telegram_config():
"""
Retourne la configuration Telegram de l'utilisateur connecté.
---
tags:
- Utilisateur
summary: Lire la config alertes Telegram (premium+)
security:
- Bearer: []
responses:
200:
description: Configuration Telegram courante
schema:
properties:
telegram_chat_id:
type: string
nullable: true
alert_value_bets:
type: boolean
alert_top1:
type: boolean
alert_quinte_only:
type: boolean
401:
description: Token invalide
403:
description: Plan insuffisant
"""
user_id = request.user_id # injecté par jwt_required_middleware
conn = _get_db()
try:
row = conn.execute(
"""
SELECT telegram_chat_id, alert_value_bets, alert_top1, alert_quinte_only
FROM users
WHERE id = ?
""",
(user_id,),
).fetchone()
if not row:
return jsonify({"error": "Utilisateur introuvable"}), 404
return jsonify(
{
"telegram_chat_id": row["telegram_chat_id"],
"alert_value_bets": bool(row["alert_value_bets"]),
"alert_top1": bool(row["alert_top1"]),
"alert_quinte_only": bool(row["alert_quinte_only"]),
}
), 200
except sqlite3.OperationalError as exc:
# Colonnes absentes : migration non appliquée
return jsonify(
{
"telegram_chat_id": None,
"alert_value_bets": True,
"alert_top1": True,
"alert_quinte_only": False,
"_warning": "Migration Telegram non appliquée",
}
), 200
except Exception as exc:
return internal_error(str(exc))
finally:
conn.close()
# ── POST /api/v1/user/telegram-config ─────────────────────────────────────────
@user_bp.route("/telegram-config", methods=["POST"])
@jwt_required_middleware
@plan_required("premium", "pro")
def update_telegram_config():
"""
Met à jour la configuration Telegram de l'utilisateur connecté.
---
tags:
- Utilisateur
summary: Configurer les alertes Telegram (premium+)
security:
- Bearer: []
parameters:
- in: body
name: body
required: true
schema:
properties:
telegram_chat_id:
type: string
description: Chat ID Telegram (ou null pour désactiver)
alert_value_bets:
type: boolean
default: true
alert_top1:
type: boolean
default: true
alert_quinte_only:
type: boolean
default: false
responses:
200:
description: Configuration mise à jour
400:
description: Paramètres invalides
401:
description: Token invalide
403:
description: Plan insuffisant
"""
user_id = request.user_id # injecté par jwt_required_middleware
data = request.get_json(silent=True)
if not data:
return bad_request("Corps JSON requis")
# Validation et extraction des champs
telegram_chat_id = data.get("telegram_chat_id")
if telegram_chat_id is not None and not isinstance(telegram_chat_id, str):
return bad_request("telegram_chat_id doit être une chaîne ou null")
if isinstance(telegram_chat_id, str):
telegram_chat_id = telegram_chat_id.strip() or None
alert_value_bets = data.get("alert_value_bets", True)
alert_top1 = data.get("alert_top1", True)
alert_quinte_only = data.get("alert_quinte_only", False)
if not isinstance(alert_value_bets, bool):
return bad_request("alert_value_bets doit être un booléen")
if not isinstance(alert_top1, bool):
return bad_request("alert_top1 doit être un booléen")
if not isinstance(alert_quinte_only, bool):
return bad_request("alert_quinte_only doit être un booléen")
conn = _get_db()
try:
conn.execute(
"""
UPDATE users
SET telegram_chat_id = ?,
alert_value_bets = ?,
alert_top1 = ?,
alert_quinte_only = ?
WHERE id = ?
""",
(
telegram_chat_id,
int(alert_value_bets),
int(alert_top1),
int(alert_quinte_only),
user_id,
),
)
conn.commit()
return jsonify(
{
"status": "ok",
"telegram_chat_id": telegram_chat_id,
"alert_value_bets": alert_value_bets,
"alert_top1": alert_top1,
"alert_quinte_only": alert_quinte_only,
}
), 200
except sqlite3.OperationalError as exc:
return jsonify(
{
"error": "Migration Telegram non appliquée — contacter le support",
"detail": str(exc),
}
), 500
except Exception as exc:
return internal_error(str(exc))
finally:
conn.close()

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,7 +81,33 @@ def valuebets():
).fetchone()
total = count_row["cnt"] if count_row else 0
rows = conn.execute(
# 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
@@ -92,7 +118,36 @@ def valuebets():
(date_param, min_odds, limit, offset),
).fetchall()
valuebets_list = [dict(r) for r in rows]
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)
pagination = paginate_query(valuebets_list, total, limit, offset)
return jsonify(

View File

@@ -16,8 +16,9 @@ DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
def get_db():
"""Return a SQLite connection with Row factory."""
conn = sqlite3.connect(DB_PATH)
"""Return a SQLite connection with Row factory (reads TURF_SAAS_DB dynamically)."""
db_path = os.environ.get("TURF_SAAS_DB", DB_PATH)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
return conn

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

50
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,11 +307,21 @@ 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
# 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,16 +2,21 @@
"""
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
import os
# NOTE: DB_PATH kept for backward compat, but get_db() reads env at call time
# so test isolation works correctly when TURF_SAAS_DB is set per-module.
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
def get_db():
conn = sqlite3.connect(DB_PATH)
# Read env dynamically so test overrides of TURF_SAAS_DB are respected
db_path = os.environ.get("TURF_SAAS_DB", DB_PATH)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
return conn
@@ -63,6 +68,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

32
docker-compose.broker.yml Normal file
View File

@@ -0,0 +1,32 @@
# Token Broker Infrastructure
# PostgreSQL dedicated instance on port 5434
networks:
turf-net:
driver: bridge
services:
token-broker-db:
image: postgres:16-alpine
container_name: token-broker-db
restart: unless-stopped
environment:
POSTGRES_DB: token_broker
POSTGRES_USER: token_broker
POSTGRES_PASSWORD: ${TOKEN_BROKER_DB_PASSWORD:-CHANGE_ME_PASSWORD}
volumes:
- token-broker-pgdata:/var/lib/postgresql/data
- ./infra/postgres/token_broker_init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U token_broker -d token_broker"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- turf-net
ports:
- "127.0.0.1:5434:5432"
volumes:
token-broker-pgdata:
driver: local

View File

@@ -0,0 +1,94 @@
-- Token Broker PostgreSQL init script
-- 6 tables: api_tokens, refresh_tokens, token_audit_log, clients, providers, token_usage
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE IF NOT EXISTS api_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id INTEGER NOT NULL,
name TEXT NOT NULL DEFAULT 'default',
token_hash TEXT NOT NULL UNIQUE,
token_prefix TEXT NOT NULL,
scopes TEXT[] DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
metadata JSONB DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id INTEGER NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
token_prefix TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT FALSE,
revoked_at TIMESTAMPTZ,
replaced_by UUID
);
CREATE TABLE IF NOT EXISTS token_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id INTEGER,
action TEXT NOT NULL,
token_prefix TEXT,
ip_address TEXT,
user_agent TEXT,
details JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id TEXT NOT NULL UNIQUE,
client_secret TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT DEFAULT '',
redirect_uris TEXT[] DEFAULT '{}',
scopes TEXT[] DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
provider_type TEXT NOT NULL DEFAULT 'oauth2',
issuer_url TEXT,
client_id TEXT,
client_secret TEXT,
scopes TEXT[] DEFAULT '{}',
config JSONB DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS token_usage (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
token_id UUID,
action TEXT NOT NULL DEFAULT 'verify',
endpoint TEXT,
status TEXT NOT NULL DEFAULT 'success',
response_time_ms INTEGER,
ip_address TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON api_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_token_audit_log_user_id ON token_audit_log(user_id);
CREATE INDEX IF NOT EXISTS idx_token_audit_log_created_at ON token_audit_log(created_at);
CREATE INDEX IF NOT EXISTS idx_clients_client_id ON clients(client_id);
CREATE INDEX IF NOT EXISTS idx_providers_name ON providers(name);
CREATE INDEX IF NOT EXISTS idx_token_usage_user_id ON token_usage(user_id);
CREATE INDEX IF NOT EXISTS idx_token_usage_created_at ON token_usage(created_at);
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO token_broker;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO token_broker;

View File

@@ -0,0 +1,90 @@
#!/bin/bash
# ============================================================
# Deploy Token Broker — systemd service + Docker PG
# ============================================================
set -euo pipefail
APP_DIR="/home/h3r7/turf_saas"
SERVICE_NAME="token-broker"
PID_FILE="/tmp/token_broker.pid"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
echo "[$(date -Iseconds)] === Deploying Token Broker ==="
# Step 1: Backup current code
echo "[$(date -Iseconds)] Backing up current code..."
mkdir -p /home/h3r7/backups/token-broker
cp "${APP_DIR}/services/token-broker/token_broker_api.py" \
"/home/h3r7/backups/token-broker/token_broker_api_${TIMESTAMP}.py"
# Step 2: Ensure Docker PG is running
echo "[$(date -Iseconds)] Ensuring PostgreSQL container..."
if ! docker inspect token-broker-db >/dev/null 2>&1; then
echo "Creating PG container..."
docker run -d \
--name token-broker-db \
--restart unless-stopped \
-e POSTGRES_DB=token_broker \
-e POSTGRES_USER=token_broker \
-e POSTGRES_PASSWORD="${TOKEN_BROKER_DB_PASSWORD}" \
-v token-broker-pgdata:/var/lib/postgresql/data \
-v "${APP_DIR}/infra/postgres/token_broker_init.sql:/docker-entrypoint-initdb.d/init.sql:ro" \
-p 127.0.0.1:5434:5432 \
postgres:16-alpine
elif ! docker ps --filter name=token-broker-db --format '{{.Status}}' | grep -q Up; then
echo "Starting existing PG container..."
docker start token-broker-db
else
echo "PG container already running."
fi
# Wait for PG readiness
echo "[$(date -Iseconds)] Waiting for PG to be ready..."
for i in $(seq 1 20); do
if docker exec token-broker-db pg_isready -U token_broker -d token_broker >/dev/null 2>&1; then
echo "PG ready."
break
fi
sleep 2
done
# Step 3: Ensure psycopg2-binary is installed
echo "[$(date -Iseconds)] Checking Python deps..."
source "${APP_DIR}/venv/bin/activate"
pip install -q psycopg2-binary PyJWT flask-cors python-dotenv gunicorn 2>/dev/null || true
# Step 4: Stop current service
echo "[$(date -Iseconds)] Stopping current service..."
if systemctl is-active --quiet ${SERVICE_NAME} 2>/dev/null; then
systemctl stop ${SERVICE_NAME}
elif [ -f "$PID_FILE" ] && kill -0 $(cat "$PID_FILE") 2>/dev/null; then
kill $(cat "$PID_FILE") 2>/dev/null || true
fi
sleep 2
# Step 5: Copy systemd unit and start
echo "[$(date -Iseconds)] Starting via systemd..."
cp "${APP_DIR}/services/token-broker/token-broker.service" /etc/systemd/system/
systemctl daemon-reload
systemctl enable ${SERVICE_NAME}
systemctl start ${SERVICE_NAME}
# Wait for startup
sleep 3
# Step 6: Health check
echo "[$(date -Iseconds)] Running health check..."
HEALTH=$(curl -s http://127.0.0.1:8783/health 2>/dev/null || echo '{"status":"failed"}')
STATUS=$(echo "$HEALTH" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','unknown'))" 2>/dev/null || echo "unknown")
if [ "$STATUS" = "ok" ]; then
echo "[$(date -Iseconds)] ✅ Health check passed: ${HEALTH}"
echo "[$(date -Iseconds)] === Token Broker deploy SUCCESS ==="
else
echo "[$(date -Iseconds)] ❌ Health check failed: ${HEALTH}"
echo "[$(date -Iseconds)] === Token Broker deploy FAILED ==="
exit 1
fi
# Step 7: Clean old backups (keep last 30)
find /home/h3r7/backups/token-broker -name "*.py" -mtime +30 -delete

View File

@@ -30,7 +30,9 @@ from leadhunter_crm import (
insert_leads,
get_leads,
get_lead_by_id,
update_lead,
update_lead_status,
delete_lead,
get_stats,
export_csv,
VALID_STATUSES,
@@ -285,6 +287,59 @@ def api_update_status(lead_id: int):
)
@app.route("/api/leads/<int:lead_id>", methods=["GET"])
def api_get_lead(lead_id: int):
"""
Retourne le detail d'un lead par son ID.
Returns:
JSON avec les informations completes du lead, ou 404.
"""
lead = get_lead_by_id(lead_id)
if not lead:
return jsonify({"error": f"Lead id={lead_id} introuvable"}), 404
return jsonify(lead)
@app.route("/api/leads/<int:lead_id>", methods=["PUT"])
def api_put_lead(lead_id: int):
"""
Met a jour completement un lead.
Body JSON : dict avec les champs a mettre a jour.
"""
body = request.get_json(silent=True)
if not body:
return jsonify({"error": "Body JSON requis"}), 400
lead = get_lead_by_id(lead_id)
if not lead:
return jsonify({"error": f"Lead id={lead_id} introuvable"}), 404
success = update_lead(lead_id, body)
if not success:
return jsonify({"error": "Mise a jour echouee"}), 500
updated_lead = get_lead_by_id(lead_id)
return jsonify({"success": True, "lead": updated_lead})
@app.route("/api/leads/<int:lead_id>", methods=["DELETE"])
def api_delete_lead(lead_id: int):
"""
Supprime un lead physiquement.
"""
lead = get_lead_by_id(lead_id)
if not lead:
return jsonify({"error": f"Lead id={lead_id} introuvable"}), 404
success = delete_lead(lead_id)
if not success:
return jsonify({"error": "Suppression echouee"}), 500
return jsonify({"success": True, "lead_id": lead_id, "deleted": True})
@app.route("/health", methods=["GET"])
def health():
"""Healthcheck pour systemd / monitoring."""

View File

@@ -52,8 +52,24 @@ if not logger.handlers:
# ─── Chemin DB ───────────────────────────────────────────────────────────────
DB_PATH = "/home/h3r7/leadhunter.db"
# Statuts valides pour un lead
VALID_STATUSES = {"new", "contacted", "closed", "rejected"}
# Statuts valides pour un lead (7 etapes Kanban)
VALID_STATUSES = {
"nouveau", # NOUVEAU
"contacte", # CONTACTÉ
"interesse", # INTÉRESSÉ
"demo_planifiee", # DÉMO PLANIFIÉE
"proposition_envoyee", # PROPOSITION ENVOYÉE
"negotiation", # NÉGOCIATION
"signe_ou_refuse", # SIGNÉ / REFUSÉ
}
# Mapping des anciens statuts vers les nouveaux (pour migration)
LEGACY_STATUS_MAP = {
"new": "nouveau",
"contacted": "contacte",
"closed": "signe_ou_refuse",
"rejected": "signe_ou_refuse",
}
# ─── Initialisation ──────────────────────────────────────────────────────────
@@ -212,6 +228,77 @@ def get_lead_by_id(lead_id: int, db_path: str = DB_PATH) -> Optional[dict]:
return None
def update_lead(lead_id: int, data: dict, db_path: str = DB_PATH) -> bool:
"""
Met à jour un lead avec les champs fournis.
Args:
lead_id: id du lead.
data: dict avec les champs a mettre a jour (name, address, phone, etc.)
Returns:
True si mise a jour reussie, False sinon.
"""
allowed_fields = {
"name",
"address",
"phone",
"rating",
"reviews_count",
"website",
"score",
"rgpd_ok",
"status",
}
fields_to_update = {k: v for k, v in data.items() if k in allowed_fields}
if not fields_to_update:
logger.warning(
f"update_lead : aucun champ valide fourni pour lead_id={lead_id}"
)
return False
if (
"status" in fields_to_update
and fields_to_update["status"] not in VALID_STATUSES
):
logger.warning(f"update_lead : statut invalide '{fields_to_update['status']}'")
return False
try:
with _get_conn(db_path) as conn:
set_clause = ", ".join([f"{k} = ?" for k in fields_to_update])
values = list(fields_to_update.values()) + [lead_id]
conn.execute(f"UPDATE leads SET {set_clause} WHERE id = ?", values)
logger.info(
f"Lead id={lead_id} mis a jour : {list(fields_to_update.keys())}"
)
return True
except Exception as e:
logger.warning(f"update_lead error : {e}")
return False
def delete_lead(lead_id: int, db_path: str = DB_PATH) -> bool:
"""
Supprime un lead physiquement.
Args:
lead_id: id du lead a supprimer.
Returns:
True si suppression reussie, False sinon.
"""
try:
with _get_conn(db_path) as conn:
conn.execute("DELETE FROM leads WHERE id = ?", (lead_id,))
logger.info(f"Lead id={lead_id} supprime")
return True
except Exception as e:
logger.warning(f"delete_lead error : {e}")
return False
def update_lead_status(lead_id: int, status: str, db_path: str = DB_PATH) -> bool:
"""
Met à jour le statut d'un lead.

600
ml_feedback_saas.py Normal file
View File

@@ -0,0 +1,600 @@
#!/usr/bin/env python3
"""
ml_feedback_saas.py — Feedback loop ML pour turf_saas.
Enregistre les paris virtuels XGBoost depuis ml_predictions_cache
et met à jour les résultats/dividendes depuis pmu_partants + pmu_rapports.
DB cible : /home/h3r7/turf_saas/turf_saas.db
Stratégies :
A) xgboost_sg : simple_gagnant — top1 ML par course, ml_score >= 70, mise 1€
B) xgboost_value : simple_gagnant — is_value_bet = 1, mise 1€
C) xgboost_sp : simple_place — top1 ML par course, ml_score >= 50, mise 1€
D) xgboost_2sur4 : deux_sur_quatre — top4 ML par course, 6 combos x 1€ = mise 6€
Usage :
python3 ml_feedback_saas.py # Traite aujourd'hui
python3 ml_feedback_saas.py --backfill 2026-04-25
python3 ml_feedback_saas.py --date 2026-04-25
"""
import sqlite3
import sys
import logging
import os
from datetime import datetime, timedelta
DB_PATH = "/home/h3r7/turf_saas/turf_saas.db"
os.makedirs("/home/h3r7/turf_saas/logs", exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [ml_feedback_saas] %(levelname)s %(message)s",
handlers=[
logging.FileHandler("/home/h3r7/turf_saas/logs/ml_feedback_saas.log"),
logging.StreamHandler(),
],
)
log = logging.getLogger(__name__)
# ─────────────────────────────────────────────────────────
# UTILITAIRES
# ─────────────────────────────────────────────────────────
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def pari_existe(cursor, date, num_reunion, num_course, numero1, type_pari, source_reco):
"""Vérifie si un pari identique existe déjà (idempotence)."""
cursor.execute(
"""
SELECT id FROM paris
WHERE date_course = ? AND source_reco = ?
AND type_pari = ? AND numero1 = ?
AND race_label = ?
""",
(date, source_reco, type_pari, numero1, f"R{num_reunion}C{num_course}"),
)
return cursor.fetchone() is not None
def pari_2sur4_existe(cursor, date, num_reunion, num_course, source_reco):
"""Vérifie si un pari 2sur4 existe déjà pour cette course."""
cursor.execute(
"""
SELECT id FROM paris
WHERE date_course = ? AND source_reco = ?
AND race_label = ?
""",
(date, source_reco, f"R{num_reunion}C{num_course}"),
)
return cursor.fetchone() is not None
def get_top_ml_par_course(cursor, date, n=4, min_score=0):
"""Retourne les n meilleurs chevaux ML par course pour une date."""
cursor.execute(
"""
SELECT num_reunion, num_course, horse_name, horse_number,
ml_score, odds, recommendation, is_value_bet,
race_label, race_name, hippodrome, heure,
discipline, distance
FROM ml_predictions_cache
WHERE date = ?
AND ml_score >= ?
ORDER BY num_reunion, num_course, ml_score DESC
""",
(date, min_score),
)
rows = cursor.fetchall()
courses = {}
for r in rows:
key = (r["num_reunion"], r["num_course"])
if key not in courses:
courses[key] = []
if len(courses[key]) < n:
courses[key].append(dict(r))
return courses
# ─────────────────────────────────────────────────────────
# STRATÉGIE A — Simple Gagnant top1 ML (score >= 70)
# ─────────────────────────────────────────────────────────
def save_ml_paris_sg(conn, date):
"""Insère 1 pari simple_gagnant par course : top1 ML avec ml_score >= 70."""
cursor = conn.cursor()
courses = get_top_ml_par_course(cursor, date, n=1, min_score=70)
inseres = 0
for (num_reunion, num_course), chevaux in courses.items():
cheval = chevaux[0]
if pari_existe(
cursor,
date,
num_reunion,
num_course,
cheval["horse_number"],
"simple_gagnant",
"xgboost_sg",
):
continue
cursor.execute(
"""
INSERT INTO paris
(date_pari, date_course, race_name, race_label, hippodrome,
type_pari, chevaux, cheval1, numero1, cote, mise,
statut, gain, source_reco, model_source)
VALUES (?, ?, ?, ?, ?, 'simple_gagnant', ?, ?, ?, ?, 1.0,
'EN_ATTENTE', 0.0, 'xgboost_sg', 'xgboost_v1')
""",
(
date,
date,
cheval.get("race_name") or "",
f"R{num_reunion}C{num_course}",
cheval.get("hippodrome") or "",
cheval["horse_name"],
cheval["horse_name"],
cheval["horse_number"],
cheval["odds"],
),
)
inseres += 1
conn.commit()
log.info(f"[SG] {date}{inseres} paris simple_gagnant insérés (score>=70)")
return inseres
# ─────────────────────────────────────────────────────────
# STRATÉGIE B — Value Bet (is_value_bet = 1)
# ─────────────────────────────────────────────────────────
def save_ml_paris_value(conn, date):
"""Insère 1 pari simple_gagnant pour chaque cheval is_value_bet=1."""
cursor = conn.cursor()
cursor.execute(
"""
SELECT num_reunion, num_course, horse_name, horse_number,
ml_score, odds, race_label, race_name, hippodrome
FROM ml_predictions_cache
WHERE date = ? AND is_value_bet = 1
ORDER BY num_reunion, num_course, ml_score DESC
""",
(date,),
)
rows = [dict(r) for r in cursor.fetchall()]
inseres = 0
for r in rows:
if pari_existe(
cursor,
date,
r["num_reunion"],
r["num_course"],
r["horse_number"],
"simple_gagnant",
"xgboost_value",
):
continue
cursor.execute(
"""
INSERT INTO paris
(date_pari, date_course, race_name, race_label, hippodrome,
type_pari, chevaux, cheval1, numero1, cote, mise,
statut, gain, source_reco, model_source)
VALUES (?, ?, ?, ?, ?, 'simple_gagnant', ?, ?, ?, ?, 1.0,
'EN_ATTENTE', 0.0, 'xgboost_value', 'xgboost_v1')
""",
(
date,
date,
r.get("race_name") or "",
r.get("race_label") or f"R{r['num_reunion']}C{r['num_course']}",
r.get("hippodrome") or "",
r["horse_name"],
r["horse_name"],
r["horse_number"],
r["odds"],
),
)
inseres += 1
conn.commit()
log.info(f"[VALUE] {date}{inseres} paris value_bet insérés")
return inseres
# ─────────────────────────────────────────────────────────
# STRATÉGIE C — Simple Placé top1 ML (score >= 50)
# ─────────────────────────────────────────────────────────
def save_ml_paris_sp(conn, date):
"""Insère 1 pari simple_place par course : top1 ML avec ml_score >= 50."""
cursor = conn.cursor()
courses = get_top_ml_par_course(cursor, date, n=1, min_score=50)
inseres = 0
for (num_reunion, num_course), chevaux in courses.items():
cheval = chevaux[0]
if pari_existe(
cursor,
date,
num_reunion,
num_course,
cheval["horse_number"],
"simple_place",
"xgboost_sp",
):
continue
cursor.execute(
"""
INSERT INTO paris
(date_pari, date_course, race_name, race_label, hippodrome,
type_pari, chevaux, cheval1, numero1, cote, mise,
statut, gain, source_reco, model_source)
VALUES (?, ?, ?, ?, ?, 'simple_place', ?, ?, ?, ?, 1.0,
'EN_ATTENTE', 0.0, 'xgboost_sp', 'xgboost_v1')
""",
(
date,
date,
cheval.get("race_name") or "",
f"R{num_reunion}C{num_course}",
cheval.get("hippodrome") or "",
cheval["horse_name"],
cheval["horse_name"],
cheval["horse_number"],
cheval["odds"],
),
)
inseres += 1
conn.commit()
log.info(f"[SP] {date}{inseres} paris simple_place insérés (score>=50)")
return inseres
# ─────────────────────────────────────────────────────────
# STRATÉGIE D — 2sur4 top4 ML (6 combinaisons x 1€)
# ─────────────────────────────────────────────────────────
def save_ml_paris_2sur4(conn, date):
"""Insère 1 pari deux_sur_quatre par course : top4 ML, mise 6€."""
cursor = conn.cursor()
courses = get_top_ml_par_course(cursor, date, n=4, min_score=0)
inseres = 0
for (num_reunion, num_course), chevaux in courses.items():
if len(chevaux) < 4:
continue
if pari_2sur4_existe(cursor, date, num_reunion, num_course, "xgboost_2sur4"):
continue
top4 = chevaux[:4]
nums = [str(c["horse_number"]) for c in top4]
noms = [c["horse_name"] for c in top4]
chevaux_str = "/".join(noms)
cursor.execute(
"""
INSERT INTO paris
(date_pari, date_course, race_name, race_label, hippodrome,
type_pari, chevaux, cheval1, numero1, cote, mise,
statut, gain, source_reco, model_source, commentaire)
VALUES (?, ?, ?, ?, ?, 'deux_sur_quatre', ?, ?, ?, 0.0, 6.0,
'EN_ATTENTE', 0.0, 'xgboost_2sur4', 'xgboost_v1', ?)
""",
(
date,
date,
top4[0].get("race_name") or "",
f"R{num_reunion}C{num_course}",
top4[0].get("hippodrome") or "",
chevaux_str,
top4[0]["horse_name"],
top4[0]["horse_number"],
f"top4 ML: {'/'.join(nums)}",
),
)
inseres += 1
conn.commit()
log.info(f"[2S4] {date}{inseres} paris deux_sur_quatre insérés")
return inseres
# ─────────────────────────────────────────────────────────
# UPDATE RÉSULTATS + DIVIDENDES
# ─────────────────────────────────────────────────────────
def update_ml_paris_results(conn, date):
"""
Met à jour statut + gain (dividende PMU réel) pour tous les paris ML EN_ATTENTE.
Sources: pmu_partants (ordre_arrivee) + pmu_rapports (dividende_euro).
"""
cursor = conn.cursor()
cursor.execute(
"""
SELECT id, race_label, type_pari, numero1, chevaux, mise, source_reco, commentaire
FROM paris
WHERE date_course = ? AND statut = 'EN_ATTENTE'
AND source_reco LIKE 'xgboost%'
""",
(date,),
)
paris = [dict(r) for r in cursor.fetchall()]
if not paris:
log.info(f"[UPDATE] {date} → aucun pari ML EN_ATTENTE")
return 0
maj = 0
for pari in paris:
pari_id = pari["id"]
race_label = pari["race_label"] or ""
type_pari = pari["type_pari"]
numero1 = pari["numero1"]
mise = pari["mise"]
# Extraire num_reunion / num_course depuis le race_label "R{r}C{c}"
try:
parts = race_label.replace("R", "").split("C")
num_reunion = int(parts[0])
num_course = int(parts[1])
except Exception:
log.warning(f"[UPDATE] race_label invalide : {race_label}")
continue
if type_pari == "simple_gagnant":
cursor.execute(
"""
SELECT ordre_arrivee FROM pmu_partants
WHERE date_programme = ? AND num_reunion = ?
AND num_course = ? AND num_pmu = ?
""",
(date, num_reunion, num_course, numero1),
)
row = cursor.fetchone()
if not row or row["ordre_arrivee"] is None or row["ordre_arrivee"] == 0:
continue
gagne = row["ordre_arrivee"] == 1
gain = 0.0
if gagne:
cursor.execute(
"""
SELECT dividende_euro FROM pmu_rapports
WHERE date_programme = ? AND num_reunion = ?
AND num_course = ? AND type_pari = 'SIMPLE_GAGNANT'
AND CAST(combinaison AS INTEGER) = ?
AND libelle NOT LIKE '%NP%'
""",
(date, num_reunion, num_course, numero1),
)
div = cursor.fetchone()
gain = div["dividende_euro"] if div and div["dividende_euro"] else 0.0
cursor.execute(
"UPDATE paris SET statut=?, gain=? WHERE id=?",
("GAGNE" if gagne else "PERDU", gain, pari_id),
)
maj += 1
elif type_pari == "simple_place":
cursor.execute(
"""
SELECT ordre_arrivee FROM pmu_partants
WHERE date_programme = ? AND num_reunion = ?
AND num_course = ? AND num_pmu = ?
""",
(date, num_reunion, num_course, numero1),
)
row = cursor.fetchone()
if not row or not row["ordre_arrivee"]:
continue
gagne = 1 <= row["ordre_arrivee"] <= 3
gain = 0.0
if gagne:
cursor.execute(
"""
SELECT dividende_euro FROM pmu_rapports
WHERE date_programme = ? AND num_reunion = ?
AND num_course = ? AND type_pari = 'SIMPLE_PLACE'
AND CAST(combinaison AS INTEGER) = ?
AND libelle NOT LIKE '%NP%'
""",
(date, num_reunion, num_course, numero1),
)
div = cursor.fetchone()
gain = div["dividende_euro"] if div and div["dividende_euro"] else 0.0
cursor.execute(
"UPDATE paris SET statut=?, gain=? WHERE id=?",
("GAGNE" if gagne else "PERDU", gain, pari_id),
)
maj += 1
elif type_pari == "deux_sur_quatre":
# Récupère les 4 numéros depuis commentaire "top4 ML: n1/n2/n3/n4"
try:
nums_str = (
pari["commentaire"].split(": ")[1]
if pari.get("commentaire")
else ""
)
nums_top4 = [int(n) for n in nums_str.split("/") if n.strip().isdigit()]
except Exception:
nums_top4 = []
if len(nums_top4) < 4:
# Fallback : reconstituer top4 depuis ml_predictions_cache
cursor.execute(
"""
SELECT horse_number FROM ml_predictions_cache
WHERE date = ? AND num_reunion = ? AND num_course = ?
ORDER BY ml_score DESC LIMIT 4
""",
(date, num_reunion, num_course),
)
nums_top4 = [r["horse_number"] for r in cursor.fetchall()]
if len(nums_top4) < 2:
continue
cursor.execute(
"""
SELECT combinaison, dividende_euro FROM pmu_rapports
WHERE date_programme = ? AND num_reunion = ?
AND num_course = ? AND type_pari = 'DEUX_SUR_QUATRE'
AND libelle NOT LIKE '%NP%'
""",
(date, num_reunion, num_course),
)
rapports = [dict(r) for r in cursor.fetchall()]
gain_total = 0.0
for rap in rapports:
try:
n1, n2 = [int(x) for x in rap["combinaison"].split("-")]
except Exception:
continue
if n1 in nums_top4 and n2 in nums_top4:
gain_total += rap["dividende_euro"]
gagne = gain_total > 0
cursor.execute(
"UPDATE paris SET statut=?, gain=? WHERE id=?",
("GAGNE" if gagne else "PERDU", round(gain_total, 2), pari_id),
)
maj += 1
conn.commit()
log.info(f"[UPDATE] {date}{maj}/{len(paris)} paris ML mis à jour")
return maj
# ─────────────────────────────────────────────────────────
# STATS PAR STRATÉGIE
# ─────────────────────────────────────────────────────────
def get_feedback_stats(conn, date_debut=None, date_fin=None):
"""Stats performances ML par stratégie (source_reco)."""
cursor = conn.cursor()
cursor.execute(
"""
SELECT source_reco,
COUNT(*) as n_paris,
SUM(CASE WHEN statut='GAGNE' THEN 1 ELSE 0 END) as n_gagne,
SUM(CASE WHEN statut='PERDU' THEN 1 ELSE 0 END) as n_perdu,
SUM(CASE WHEN statut='EN_ATTENTE' THEN 1 ELSE 0 END) as n_attente,
ROUND(100.0 * SUM(CASE WHEN statut='GAGNE' THEN 1 ELSE 0 END)
/ NULLIF(SUM(CASE WHEN statut IN ('GAGNE','PERDU') THEN 1 ELSE 0 END), 0), 1) as win_rate_pct,
ROUND(SUM(gain), 2) as gain_total,
ROUND(SUM(mise), 2) as mise_totale,
ROUND(SUM(gain) - SUM(mise), 2) as roi_net
FROM paris
WHERE source_reco LIKE 'xgboost%'
AND (:debut IS NULL OR date_course >= :debut)
AND (:fin IS NULL OR date_course <= :fin)
GROUP BY source_reco
ORDER BY source_reco
""",
{"debut": date_debut, "fin": date_fin},
)
return [dict(r) for r in cursor.fetchall()]
# ─────────────────────────────────────────────────────────
# PIPELINE COMPLET
# ─────────────────────────────────────────────────────────
def run(date):
"""Enregistre les paris ML du jour + met à jour les résultats de J-1."""
conn = get_db()
log.info(f"=== ml_feedback_saas.run({date}) ===")
# 1. Enregistre les paris ML pour la date (depuis le cache du jour)
sg = save_ml_paris_sg(conn, date)
vb = save_ml_paris_value(conn, date)
sp = save_ml_paris_sp(conn, date)
s4 = save_ml_paris_2sur4(conn, date)
log.info(f"[SAVE] {date} → total insérés : SG={sg} VALUE={vb} SP={sp} 2S4={s4}")
# 2. Met à jour les résultats de J-1 (résultats PMU disponibles)
yesterday = (datetime.strptime(date, "%Y-%m-%d") - timedelta(days=1)).strftime(
"%Y-%m-%d"
)
maj = update_ml_paris_results(conn, yesterday)
log.info(f"[UPDATE] {yesterday}{maj} paris mis à jour")
conn.close()
return {"inseres": {"sg": sg, "value": vb, "sp": sp, "2sur4": s4}, "maj": maj}
def backfill(date):
"""Backfill : insère ET met à jour les résultats pour une date passée."""
conn = get_db()
log.info(f"=== ml_feedback_saas.backfill({date}) ===")
sg = save_ml_paris_sg(conn, date)
vb = save_ml_paris_value(conn, date)
sp = save_ml_paris_sp(conn, date)
s4 = save_ml_paris_2sur4(conn, date)
log.info(f"[SAVE] {date} → SG={sg} VALUE={vb} SP={sp} 2S4={s4}")
maj = update_ml_paris_results(conn, date)
log.info(f"[UPDATE] {date}{maj} paris mis à jour")
conn.close()
return sg + vb + sp + s4, maj
# ─────────────────────────────────────────────────────────
# MAIN
# ─────────────────────────────────────────────────────────
if __name__ == "__main__":
if "--backfill" in sys.argv:
idx = sys.argv.index("--backfill")
date = sys.argv[idx + 1] if idx + 1 < len(sys.argv) else None
if not date:
print("Usage: python3 ml_feedback_saas.py --backfill YYYY-MM-DD")
sys.exit(1)
inseres, maj = backfill(date)
print(f"Backfill {date} : {inseres} paris insérés, {maj} mis à jour")
elif "--date" in sys.argv:
idx = sys.argv.index("--date")
date = sys.argv[idx + 1] if idx + 1 < len(sys.argv) else None
if not date:
print("Usage: python3 ml_feedback_saas.py --date YYYY-MM-DD")
sys.exit(1)
result = run(date)
total = sum(result["inseres"].values())
print(f"Run {date} : {total} paris insérés, {result['maj']} mis à jour")
else:
result = run(datetime.now().strftime("%Y-%m-%d"))
total = sum(result["inseres"].values())
print(f"Run today : {total} paris insérés, {result['maj']} mis à jour")

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

@@ -18,10 +18,12 @@ SAAS_DIR = "/home/h3r7/turf_saas"
# ─── SaaS Auth & API v1 blueprints ────────────────────────────────────────────
try:
from saas_auth import auth_bp
from saas_api_v1 import api_v1_bp
from saas_api_v1 import saas_api_v1_bp
from api_v1 import register_api_v1
app.register_blueprint(auth_bp)
app.register_blueprint(api_v1_bp)
app.register_blueprint(saas_api_v1_bp)
register_api_v1(app)
print("[portal] SaaS auth & API v1 blueprints registered ✅")
except Exception as e:
print(f"[portal] Warning: could not register SaaS blueprints: {e}")
@@ -352,6 +354,29 @@ def template_complet():
return send_from_directory("/home/h3r7/turf_saas", "template_complet.html")
@app.route("/leadhunter/clients/le-big-ben/")
@app.route("/leadhunter/clients/le-big-ben")
def big_ben():
return send_from_directory(
"/home/h3r7/turf_saas/templates/leadhunter/clients/le-big-ben", "index.html"
)
@app.route("/leadhunter/clients/le-big-ben/sitemap.xml")
def big_ben_sitemap():
return send_from_directory(
"/home/h3r7/turf_saas/templates/leadhunter/clients/le-big-ben",
"sitemap.xml",
mimetype="application/xml",
)
@app.route("/formation/ai102")
@app.route("/formation/ai102/")
def certif_ai102():
return send_from_directory("/home/h3r7/turf_saas/pitch", "certif-ai102.html")
@app.route("/boite_a_idees_dashboard")
def boite_a_idees_dashboard():
return send_from_directory("/home/h3r7/turf_saas", "boite_a_idees_dashboard.html")

View File

@@ -31,3 +31,6 @@ python-dotenv==1.1.0
# Utilities
python-dateutil==2.9.0
# Hyperparameter optimization (ML ensemble tuning — HRT-136)
optuna>=4.0.0

View File

@@ -13,7 +13,7 @@ from saas_auth import require_auth
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
saas_api_v1_bp = Blueprint("saas_api_v1", __name__, url_prefix="/api/v1")
def get_db():
@@ -30,7 +30,7 @@ def plan_allows(user_plan: str, required: str) -> bool:
# ─── Stats ────────────────────────────────────────────────────────────────────
@api_v1_bp.route("/stats/summary", methods=["GET"])
@saas_api_v1_bp.route("/stats/summary", methods=["GET"])
@require_auth
def stats_summary():
"""GET /api/v1/stats/summary — résumé dashboard."""
@@ -94,7 +94,7 @@ def stats_summary():
# ─── Predictions ──────────────────────────────────────────────────────────────
@api_v1_bp.route("/predictions/today", methods=["GET"])
@saas_api_v1_bp.route("/predictions/today", methods=["GET"])
@require_auth
def predictions_today():
"""GET /api/v1/predictions/today — prédictions du jour selon le plan."""
@@ -149,7 +149,7 @@ def predictions_today():
return jsonify({"error": str(e), "predictions": []}), 200
@api_v1_bp.route("/predictions/race/<race_label>", methods=["GET"])
@saas_api_v1_bp.route("/predictions/race/<race_label>", methods=["GET"])
@require_auth
def predictions_race(race_label):
"""GET /api/v1/predictions/race/<label> — prédictions d'une course."""
@@ -187,7 +187,7 @@ def predictions_race(race_label):
# ─── Value Bets ───────────────────────────────────────────────────────────────
@api_v1_bp.route("/value-bets/today", methods=["GET"])
@saas_api_v1_bp.route("/value-bets/today", methods=["GET"])
@require_auth
def value_bets_today():
"""GET /api/v1/value-bets/today — value bets (Premium+)."""
@@ -220,7 +220,7 @@ def value_bets_today():
# ─── Export ───────────────────────────────────────────────────────────────────
@api_v1_bp.route("/export/csv", methods=["GET"])
@saas_api_v1_bp.route("/export/csv", methods=["GET"])
@require_auth
def export_csv():
"""GET /api/v1/export/csv — export CSV (Pro only)."""
@@ -257,26 +257,23 @@ def export_csv():
)
# ─── Billing Blueprint (Stripe) + JWT init — HRT-49 ─────────────────────────
# Registers /api/v1/billing/* routes via nested Blueprint (Flask 2.0+)
# Also initializes JWTManager on the Flask app (required for jwt_required_middleware)
# ─── JWT init — HRT-49 ────────────────────────────────────────────────────────
# Initialize JWTManager on the Flask app (required for jwt_required_middleware)
# Called when saas_api_v1_bp is registered (portal_server.py)
try:
from flask_jwt_extended import JWTManager
from api_v1.routes.billing import billing_bp
# Initialize JWTManager on the Flask app when api_v1_bp is registered
@api_v1_bp.record_once
@saas_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:
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 ✅')
except Exception as _billing_err:
print(f'[saas_api_v1] Warning: billing blueprint not loaded: {_billing_err}')
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)
print("[saas_api_v1] JWT init registered ✅")
except Exception as _jwt_err:
print(f"[saas_api_v1] Warning: JWT init not loaded: {_jwt_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()

View File

@@ -0,0 +1,10 @@
# Token Broker API — Configuration
TOKEN_BROKER_PORT=8783
TOKEN_BROKER_DB_HOST=127.0.0.1
TOKEN_BROKER_DB_PORT=5434
TOKEN_BROKER_DB_NAME=token_broker
TOKEN_BROKER_DB_USER=token_broker
TOKEN_BROKER_DB_PASSWORD=CHANGE_ME
TOKEN_BROKER_JWT_SECRET=CHANGE_ME_GENERATE_64_HEX
TOKEN_BROKER_ACCESS_EXPIRY=900
TOKEN_BROKER_REFRESH_EXPIRY=2592000

View File

@@ -0,0 +1,6 @@
Flask==3.1.3
flask-cors==5.0.1
gunicorn==23.0.0
psycopg2-binary==2.9.12
PyJWT==2.10.1
python-dotenv==1.1.0

View File

@@ -0,0 +1,21 @@
[Unit]
Description=Token Broker API (Port 8783)
Documentation=https://portal-kolifee.duckdns.org
After=network.target postgresql.service
[Service]
Type=simple
User=h3r7
WorkingDirectory=/home/h3r7/turf_saas/services/token-broker
EnvironmentFile=/home/h3r7/turf_saas/services/token-broker/.env
Environment=PYTHONPATH=/home/h3r7/turf_saas
Environment=FLASK_ENV=production
ExecStart=/home/h3r7/turf_saas/venv/bin/python3 /home/h3r7/turf_saas/services/token-broker/token_broker_api.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,679 @@
#!/usr/bin/env python3
"""
Token Broker API — JWT token management service
Port: 8783 | DB: PostgreSQL 5434
HRT-198 — Setup infra (PostgreSQL + Flask scaffold)
Endpoints:
GET /health — Healthcheck
POST /api/v1/tokens — Issue new token (create)
GET /api/v1/tokens/:id — Get token by ID
POST /api/v1/tokens/verify — Verify token
POST /api/v1/tokens/revoke/:id — Revoke token
GET /api/v1/tokens/user/:userId — List tokens for user
"""
import os
import sys
import uuid
import hashlib
import secrets
import logging
import logging.handlers
from datetime import datetime, timedelta, timezone
from functools import wraps
from flask import Flask, request, jsonify, g
from flask_cors import CORS
LOG_DIR = os.path.join(os.path.dirname(__file__), "logs")
os.makedirs(LOG_DIR, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] token-broker: %(name)s: %(message)s",
handlers=[
logging.StreamHandler(sys.stdout),
logging.handlers.RotatingFileHandler(
os.path.join(LOG_DIR, "token_broker.log"),
maxBytes=5 * 1024 * 1024,
backupCount=3,
),
],
)
logger = logging.getLogger("token_broker")
DB_HOST = os.environ.get("TOKEN_BROKER_DB_HOST", "127.0.0.1")
DB_PORT = int(os.environ.get("TOKEN_BROKER_DB_PORT", "5434"))
DB_NAME = os.environ.get("TOKEN_BROKER_DB_NAME", "token_broker")
DB_USER = os.environ.get("TOKEN_BROKER_DB_USER", "token_broker")
DB_PASSWORD = os.environ.get("TOKEN_BROKER_DB_PASSWORD", "")
JWT_SECRET = os.environ.get(
"TOKEN_BROKER_JWT_SECRET", "CHANGE_ME_" + secrets.token_hex(32)
)
ACCESS_TOKEN_EXPIRY = int(os.environ.get("TOKEN_BROKER_ACCESS_EXPIRY", "900"))
REFRESH_TOKEN_EXPIRY = int(os.environ.get("TOKEN_BROKER_REFRESH_EXPIRY", "2592000"))
def get_pg_conn():
try:
import psycopg2
import psycopg2.extras
conn = psycopg2.connect(
host=DB_HOST,
port=DB_PORT,
dbname=DB_NAME,
user=DB_USER,
password=DB_PASSWORD,
)
conn.autocommit = True
return conn
except Exception as e:
logger.error(f"PostgreSQL connection failed: {e}")
return None
def init_db():
conn = get_pg_conn()
if not conn:
logger.error("Cannot initialize DB — no connection")
return False
try:
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS api_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id INTEGER NOT NULL,
name TEXT NOT NULL DEFAULT 'default',
token_hash TEXT NOT NULL UNIQUE,
token_prefix TEXT NOT NULL,
scopes TEXT[] DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
metadata JSONB DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id INTEGER NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
token_prefix TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT FALSE,
revoked_at TIMESTAMPTZ,
replaced_by UUID
);
CREATE TABLE IF NOT EXISTS token_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id INTEGER,
action TEXT NOT NULL,
token_prefix TEXT,
ip_address TEXT,
user_agent TEXT,
details JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id TEXT NOT NULL UNIQUE,
client_secret TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT DEFAULT '',
redirect_uris TEXT[] DEFAULT '{}',
scopes TEXT[] DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
provider_type TEXT NOT NULL DEFAULT 'oauth2',
issuer_url TEXT,
client_id TEXT,
client_secret TEXT,
scopes TEXT[] DEFAULT '{}',
config JSONB DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS token_usage (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
token_id UUID,
action TEXT NOT NULL DEFAULT 'verify',
endpoint TEXT,
status TEXT NOT NULL DEFAULT 'success',
response_time_ms INTEGER,
ip_address TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON api_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_token_audit_log_user_id ON token_audit_log(user_id);
CREATE INDEX IF NOT EXISTS idx_token_audit_log_created_at ON token_audit_log(created_at);
CREATE INDEX IF NOT EXISTS idx_clients_client_id ON clients(client_id);
CREATE INDEX IF NOT EXISTS idx_providers_name ON providers(name);
CREATE INDEX IF NOT EXISTS idx_token_usage_user_id ON token_usage(user_id);
CREATE INDEX IF NOT EXISTS idx_token_usage_created_at ON token_usage(created_at);
""")
cur.close()
conn.close()
logger.info("Database tables initialized successfully")
return True
except Exception as e:
logger.error(f"Database initialization failed: {e}")
return False
def create_app():
app = Flask(__name__)
app.config["JWT_SECRET"] = JWT_SECRET
app.config["ACCESS_TOKEN_EXPIRY"] = ACCESS_TOKEN_EXPIRY
app.config["REFRESH_TOKEN_EXPIRY"] = REFRESH_TOKEN_EXPIRY
CORS(app)
register_routes(app)
register_error_handlers(app)
return app
def token_required(f):
@wraps(f)
def decorated(*args, **kwargs):
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return jsonify({"error": "missing_token", "message": "Bearer token required"}), 401
token = auth_header.split(" ", 1)[1]
payload = verify_jwt_token(token)
if not payload:
return jsonify({"error": "invalid_token", "message": "Token invalid or expired"}), 401
g.user_id = payload.get("user_id")
g.token_id = payload.get("token_id")
g.scopes = payload.get("scopes", [])
return f(*args, **kwargs)
return decorated
def generate_token_pair(user_id, scopes=None, metadata=None):
import jwt as pyjwt
now = datetime.now(timezone.utc)
access_payload = {
"user_id": user_id,
"token_id": str(uuid.uuid4()),
"scopes": scopes or [],
"type": "access",
"iat": now,
"exp": now + timedelta(seconds=ACCESS_TOKEN_EXPIRY),
}
access_token = pyjwt.encode(access_payload, JWT_SECRET, algorithm="HS256")
refresh_id = str(uuid.uuid4())
refresh_raw = secrets.token_urlsafe(48)
refresh_payload = {
"user_id": user_id,
"refresh_id": refresh_id,
"token_hash": hashlib.sha256(refresh_raw.encode()).hexdigest(),
"type": "refresh",
"iat": now,
"exp": now + timedelta(seconds=REFRESH_TOKEN_EXPIRY),
}
refresh_token = pyjwt.encode(refresh_payload, JWT_SECRET, algorithm="HS256")
store_refresh_token(user_id, refresh_id, refresh_payload["token_hash"])
log_audit(user_id, "token_issued", access_payload["token_id"][:8])
return {
"access_token": access_token,
"refresh_token": refresh_raw,
"expires_in": ACCESS_TOKEN_EXPIRY,
"token_type": "Bearer",
}
def verify_jwt_token(token):
import jwt as pyjwt
try:
payload = pyjwt.decode(token, JWT_SECRET, algorithms=["HS256"])
if payload.get("type") == "refresh":
token_hash = hashlib.sha256(token.encode()).hexdigest()
conn = get_pg_conn()
if conn:
cur = conn.cursor()
cur.execute(
"SELECT revoked FROM refresh_tokens WHERE token_hash = %s AND expires_at > NOW()",
(token_hash,),
)
row = cur.fetchone()
cur.close()
conn.close()
if not row or row[0]:
return None
return payload
except Exception:
return None
def store_refresh_token(user_id, refresh_id, token_hash):
conn = get_pg_conn()
if not conn:
return
try:
cur = conn.cursor()
cur.execute(
"""INSERT INTO refresh_tokens (id, user_id, token_hash, token_prefix, expires_at)
VALUES (%s, %s, %s, %s, NOW() + INTERVAL '30 days')""",
(refresh_id, user_id, token_hash, token_hash[:8]),
)
cur.close()
conn.close()
except Exception as e:
logger.error(f"Failed to store refresh token: {e}")
def log_audit(user_id, action, token_prefix, details=None):
conn = get_pg_conn()
if not conn:
return
try:
cur = conn.cursor()
cur.execute(
"""INSERT INTO token_audit_log (user_id, action, token_prefix, ip_address, user_agent, details)
VALUES (%s, %s, %s, %s, %s, %s)""",
(
user_id,
action,
token_prefix,
request.remote_addr if request else None,
request.user_agent.string if request and request.user_agent else None,
"{}" if details is None else details,
),
)
cur.close()
conn.close()
except Exception:
pass
def register_routes(app):
@app.route("/health", methods=["GET"])
def healthcheck():
conn = get_pg_conn()
db_ok = conn is not None
if conn:
conn.close()
return jsonify({
"status": "ok" if db_ok else "degraded",
"service": "token-broker",
"version": "1.0.0",
"database": "connected" if db_ok else "disconnected",
"timestamp": datetime.now(timezone.utc).isoformat(),
}), 200 if db_ok else 503
@app.route("/api/v1/tokens", methods=["POST"])
@token_required
def issue_token():
data = request.get_json(silent=True) or {}
user_id = g.user_id
scopes = data.get("scopes", [])
name = data.get("name", "default")
metadata = data.get("metadata", {})
conn = get_pg_conn()
if not conn:
return jsonify({"error": "db_error", "message": "Database unavailable"}), 503
try:
cur = conn.cursor()
import psycopg2.extras
raw_token = "tb_" + secrets.token_urlsafe(32)
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
token_prefix = raw_token[:12] + "..."
cur.execute(
"""INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes, metadata)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id, created_at, expires_at""",
(user_id, name, token_hash, token_prefix, scopes,
psycopg2.extras.Json(metadata)),
)
row = cur.fetchone()
cur.close()
conn.close()
log_audit(user_id, "api_token_created", token_prefix)
return jsonify({
"id": str(row[0]),
"token": raw_token,
"name": name,
"scopes": scopes,
"created_at": row[1].isoformat(),
"expires_at": row[2].isoformat() if row[2] else None,
}), 201
except Exception as e:
logger.error(f"Token creation failed: {e}")
return jsonify({"error": "creation_failed", "message": str(e)}), 500
@app.route("/api/v1/tokens/verify", methods=["POST"])
def verify_token():
data = request.get_json(silent=True) or {}
raw_token = data.get("token", "")
if not raw_token:
return jsonify({"valid": False, "error": "token_required"}), 400
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
conn = get_pg_conn()
if not conn:
return jsonify({"valid": False, "error": "db_error"}), 503
try:
cur = conn.cursor()
cur.execute(
"""SELECT id, user_id, name, scopes, is_active, created_at, expires_at, last_used_at
FROM api_tokens
WHERE token_hash = %s""",
(token_hash,),
)
row = cur.fetchone()
if not row:
cur.close()
conn.close()
return jsonify({"valid": False, "error": "token_not_found"}), 404
token_id, user_id, name, scopes, is_active, created_at, expires_at, last_used_at = row
if not is_active:
cur.close()
conn.close()
return jsonify({"valid": False, "error": "token_revoked"}), 403
if expires_at and expires_at < datetime.now(timezone.utc):
cur.close()
conn.close()
return jsonify({"valid": False, "error": "token_expired"}), 403
cur.execute(
"UPDATE api_tokens SET last_used_at = NOW() WHERE id = %s",
(token_id,),
)
cur.close()
conn.close()
return jsonify({
"valid": True,
"token_id": str(token_id),
"user_id": user_id,
"name": name,
"scopes": scopes,
})
except Exception as e:
logger.error(f"Token verification failed: {e}")
return jsonify({"valid": False, "error": "verification_failed"}), 500
@app.route("/api/v1/tokens/<token_id>", methods=["GET"])
@token_required
def get_token(token_id):
conn = get_pg_conn()
if not conn:
return jsonify({"error": "db_error"}), 503
try:
cur = conn.cursor()
cur.execute(
"""SELECT id, user_id, name, scopes, is_active, created_at, expires_at, last_used_at, metadata
FROM api_tokens WHERE id = %s AND user_id = %s""",
(token_id, g.user_id),
)
row = cur.fetchone()
cur.close()
conn.close()
if not row:
return jsonify({"error": "not_found"}), 404
return jsonify({
"id": str(row[0]),
"user_id": row[1],
"name": row[2],
"scopes": row[3],
"is_active": row[4],
"created_at": row[5].isoformat(),
"expires_at": row[6].isoformat() if row[6] else None,
"last_used_at": row[7].isoformat() if row[7] else None,
"metadata": row[8] if row[8] else {},
})
except Exception as e:
logger.error(f"Get token failed: {e}")
return jsonify({"error": "query_failed"}), 500
@app.route("/api/v1/tokens/revoke/<token_id>", methods=["POST"])
@token_required
def revoke_token(token_id):
conn = get_pg_conn()
if not conn:
return jsonify({"error": "db_error"}), 503
try:
cur = conn.cursor()
cur.execute(
"""UPDATE api_tokens SET is_active = FALSE WHERE id = %s AND user_id = %s
RETURNING id, name""",
(token_id, g.user_id),
)
row = cur.fetchone()
cur.close()
conn.close()
if not row:
return jsonify({"error": "not_found"}), 404
log_audit(g.user_id, "api_token_revoked", str(row[0])[:8])
return jsonify({"status": "revoked", "token_id": str(row[0])})
except Exception as e:
logger.error(f"Revoke token failed: {e}")
return jsonify({"error": "revoke_failed"}), 500
@app.route("/api/v1/tokens/user/<int:user_id>", methods=["GET"])
@token_required
def list_user_tokens(user_id):
if g.user_id != user_id and "admin" not in g.scopes:
return jsonify({"error": "forbidden"}), 403
conn = get_pg_conn()
if not conn:
return jsonify({"error": "db_error"}), 503
try:
cur = conn.cursor()
cur.execute(
"""SELECT id, user_id, name, scopes, is_active, created_at, expires_at, last_used_at
FROM api_tokens
WHERE user_id = %s
ORDER BY created_at DESC""",
(user_id,),
)
rows = cur.fetchall()
cur.close()
conn.close()
tokens = []
for row in rows:
tokens.append({
"id": str(row[0]),
"user_id": row[1],
"name": row[2],
"scopes": row[3],
"is_active": row[4],
"created_at": row[5].isoformat(),
"expires_at": row[6].isoformat() if row[6] else None,
"last_used_at": row[7].isoformat() if row[7] else None,
})
return jsonify({"tokens": tokens, "total": len(tokens)})
except Exception as e:
logger.error(f"List tokens failed: {e}")
return jsonify({"error": "query_failed"}), 500
@app.route("/api/v1/auth/token", methods=["POST"])
def exchange_token():
data = request.get_json(silent=True) or {}
grant_type = data.get("grant_type", "client_credentials")
raw_token = data.get("client_token", "") or data.get("token", "")
refresh_raw = data.get("refresh_token", "")
if grant_type == "refresh_token" and refresh_raw:
return refresh_access_token(refresh_raw)
if not raw_token:
return jsonify({"error": "invalid_request", "message": "client_token required"}), 400
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
conn = get_pg_conn()
if not conn:
return jsonify({"error": "db_error"}), 503
try:
cur = conn.cursor()
cur.execute(
"""SELECT id, user_id, scopes, is_active, expires_at
FROM api_tokens WHERE token_hash = %s""",
(token_hash,),
)
row = cur.fetchone()
cur.close()
conn.close()
if not row:
return jsonify({"error": "invalid_token"}), 401
if not row[3]:
return jsonify({"error": "token_revoked"}), 403
if row[4] and row[4] < datetime.now(timezone.utc):
return jsonify({"error": "token_expired"}), 403
token_pair = generate_token_pair(row[1], row[2])
return jsonify(token_pair), 200
except Exception as e:
logger.error(f"Token exchange failed: {e}")
return jsonify({"error": "exchange_failed"}), 500
@app.route("/api/v1/auth/refresh", methods=["POST"])
def refresh_token_endpoint():
data = request.get_json(silent=True) or {}
refresh_raw = data.get("refresh_token", "")
return refresh_access_token(refresh_raw)
@app.route("/api/v1/auth/revoke", methods=["POST"])
@token_required
def revoke_refresh_token():
data = request.get_json(silent=True) or {}
refresh_raw = data.get("refresh_token", "")
if not refresh_raw:
return jsonify({"error": "refresh_token_required"}), 400
token_hash = hashlib.sha256(refresh_raw.encode()).hexdigest()
conn = get_pg_conn()
if not conn:
return jsonify({"error": "db_error"}), 503
try:
cur = conn.cursor()
cur.execute(
"UPDATE refresh_tokens SET revoked = TRUE, revoked_at = NOW() WHERE token_hash = %s",
(token_hash,),
)
cur.close()
conn.close()
log_audit(g.user_id, "refresh_token_revoked", token_hash[:8])
return jsonify({"status": "revoked"})
except Exception as e:
logger.error(f"Revoke refresh token failed: {e}")
return jsonify({"error": "revoke_failed"}), 500
def refresh_access_token(refresh_raw):
if not refresh_raw:
return jsonify({"error": "refresh_token_required"}), 400
token_hash = hashlib.sha256(refresh_raw.encode()).hexdigest()
conn = get_pg_conn()
if not conn:
return jsonify({"error": "db_error"}), 503
try:
cur = conn.cursor()
cur.execute(
"""SELECT id, user_id, revoked, expires_at
FROM refresh_tokens WHERE token_hash = %s""",
(token_hash,),
)
row = cur.fetchone()
if not row:
cur.close()
conn.close()
return jsonify({"error": "invalid_token"}), 401
if row[2]:
cur.close()
conn.close()
return jsonify({"error": "token_revoked"}), 403
if row[3] < datetime.now(timezone.utc):
cur.close()
conn.close()
return jsonify({"error": "token_expired"}), 403
refresh_id = row[0]
user_id = row[1]
cur.execute(
"UPDATE refresh_tokens SET revoked = TRUE, revoked_at = NOW() WHERE id = %s",
(refresh_id,),
)
pairs = generate_token_pair(user_id)
cur.close()
conn.close()
return jsonify(pairs), 200
except Exception as e:
logger.error(f"Refresh token failed: {e}")
return jsonify({"error": "refresh_failed"}), 500
def register_error_handlers(app):
@app.errorhandler(404)
def not_found(e):
return jsonify({"error": "not_found", "message": "Route not found"}), 404
@app.errorhandler(405)
def method_not_allowed(e):
return jsonify({"error": "method_not_allowed", "message": "Method not allowed"}), 405
@app.errorhandler(500)
def internal_error(e):
logger.error(f"Internal error: {e}")
return jsonify({"error": "internal_error", "message": "Internal server error"}), 500
if __name__ == "__main__":
logger.info("=" * 60)
logger.info("Token Broker API starting...")
logger.info(f"DB: {DB_HOST}:{DB_PORT}/{DB_NAME}")
logger.info(f"Port: {os.environ.get('TOKEN_BROKER_PORT', '8783')}")
logger.info("=" * 60)
init_db()
port = int(os.environ.get("TOKEN_BROKER_PORT", "8783"))
debug = os.environ.get("FLASK_ENV", "production") == "development"
app = create_app()
app.run(host="0.0.0.0", port=port, debug=debug)

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

419
tests/test_history.py Normal file
View File

@@ -0,0 +1,419 @@
#!/usr/bin/env python3
"""
Tests for GET /api/v1/history — HRT-81
Historique limité/illimité selon plan (Free/Premium/Pro)
Run with:
cd /home/h3r7/turf_saas
source venv/bin/activate
python -m pytest tests/test_history.py -v
"""
import json
import os
import sys
import sqlite3
import tempfile
from datetime import datetime, timedelta
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Use an isolated temp DB for these tests
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
_tmp_db.close()
os.environ["TURF_SAAS_DB"] = _tmp_db.name
os.environ["JWT_SECRET_KEY"] = "test-history-secret-key"
from app_v1 import create_app
from auth_db import init_auth_tables
# ──────────────────────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────────────────────
TODAY = datetime.now().date()
def days_ago(n: int) -> str:
return (TODAY - timedelta(days=n)).isoformat()
def auth_header(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
# ──────────────────────────────────────────────────────────────
# Fixtures
# ──────────────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def app():
# Enforce this module s temp DB
os.environ["TURF_SAAS_DB"] = _tmp_db.name
os.environ["JWT_SECRET_KEY"] = "test-history-secret-key"
application = create_app()
application.config["TESTING"] = True
application.config["JWT_SECRET_KEY"] = "test-history-secret-key"
return application
@pytest.fixture(scope="module")
def client(app):
return app.test_client()
@pytest.fixture(scope="module")
def seeded_db():
"""
Seed the test DB:
- Create ml_predictions_cache with rows spanning 120 days back
- Create users for free/premium/pro plans
"""
# Reset TURF_SAAS_DB to this module-s temp DB at runtime
os.environ["TURF_SAAS_DB"] = _tmp_db.name
db_path = _tmp_db.name
# Ensure auth tables (users, refresh_tokens, subscriptions) exist in the test DB
# init_auth_tables() is idempotent — safe to call even if tables already exist
init_auth_tables()
conn = sqlite3.connect(db_path)
# Create ml_predictions_cache table if absent
conn.execute("""
CREATE TABLE IF NOT EXISTS ml_predictions_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
horse_name TEXT,
prob_top1 REAL,
prob_top3 REAL,
ml_score REAL,
race_label TEXT,
hippodrome TEXT,
heure TEXT,
is_value_bet INTEGER DEFAULT 0
)
""")
# Seed rows at: 1, 6, 7, 8, 30, 89, 90, 91, 100 days ago
offsets = [1, 6, 7, 8, 30, 89, 90, 91, 100]
for offset in offsets:
d = days_ago(offset)
conn.execute(
"""INSERT INTO ml_predictions_cache
(date, horse_name, prob_top1, prob_top3, ml_score, race_label, hippodrome, heure, is_value_bet)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(d, f"Cheval_{offset}j", 0.5, 0.8, 0.75, f"R1C1", "PARIS", "14:00", 0),
)
conn.commit()
conn.close()
return db_path
@pytest.fixture(scope="module")
def auth_tokens(client, seeded_db):
"""Register/login users for each plan and return their JWT tokens."""
plans = {
"free": "hist_free@test.com",
"premium": "hist_premium@test.com",
"pro": "hist_pro@test.com",
}
password = "password123"
for plan, email in plans.items():
r = client.post(
"/api/v1/auth/register",
json={"email": email, "password": password},
content_type="application/json",
)
assert r.status_code in (201, 409), f"register failed for {plan}: {r.data}"
# Set plan via direct DB
# Reset TURF_SAAS_DB to this module-s temp DB at runtime
os.environ["TURF_SAAS_DB"] = _tmp_db.name
db_path = _tmp_db.name
conn = sqlite3.connect(db_path)
for plan, email in plans.items():
conn.execute("UPDATE users SET plan = ? WHERE email = ?", (plan, email))
conn.commit()
conn.close()
tokens = {}
for plan, email in plans.items():
r = client.post(
"/api/v1/auth/login",
json={"email": email, "password": password},
content_type="application/json",
)
assert r.status_code == 200, f"login failed for {plan}: {r.data}"
tokens[plan] = r.get_json()["access_token"]
return tokens
# ──────────────────────────────────────────────────────────────
# Auth guard
# ──────────────────────────────────────────────────────────────
class TestHistoryAuth:
def test_requires_auth(self, client):
"""Unauthenticated request must return 401."""
r = client.get("/api/v1/history")
assert r.status_code == 401
def test_invalid_token_returns_401(self, client):
r = client.get(
"/api/v1/history",
headers={"Authorization": "Bearer this.is.not.valid"},
)
assert r.status_code == 401
# ──────────────────────────────────────────────────────────────
# Free plan — 7-day window
# ──────────────────────────────────────────────────────────────
class TestHistoryFreePlan:
def test_free_can_access_last_7_days(self, client, auth_tokens, seeded_db):
"""Free user: start = today-6 (within 7-day window) must return 200."""
start = days_ago(6)
r = client.get(
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
headers=auth_header(auth_tokens["free"]),
)
assert r.status_code == 200
data = r.get_json()
assert data["status"] == "ok"
assert data["plan"] == "free"
assert data["history_limit_days"] == 7
def test_free_blocked_beyond_7_days(self, client, auth_tokens, seeded_db):
"""Free user: start = today-8 must return 403 (beyond 7-day window)."""
start = days_ago(8)
r = client.get(
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
headers=auth_header(auth_tokens["free"]),
)
assert r.status_code == 403
data = r.get_json()
assert data["code"] == 403
assert (
"upgrade" in data.get("message", "").lower()
or "plan" in data.get("message", "").lower()
)
def test_free_default_request_returns_200(self, client, auth_tokens, seeded_db):
"""Free user: no dates specified — should use defaults and return 200."""
r = client.get(
"/api/v1/history",
headers=auth_header(auth_tokens["free"]),
)
assert r.status_code == 200
data = r.get_json()
assert data["status"] == "ok"
assert "history" in data
assert "pagination" in data
def test_free_upgrade_hint_in_403(self, client, auth_tokens, seeded_db):
"""403 response must contain required_plans and upgrade_url."""
start = days_ago(30)
r = client.get(
f"/api/v1/history?start={start}",
headers=auth_header(auth_tokens["free"]),
)
assert r.status_code == 403
data = r.get_json()
assert "required_plans" in data
assert "upgrade_url" in data
# ──────────────────────────────────────────────────────────────
# Premium plan — 90-day window
# ──────────────────────────────────────────────────────────────
class TestHistoryPremiumPlan:
def test_premium_can_access_within_90_days(self, client, auth_tokens, seeded_db):
"""Premium user: start = today-89 must return 200."""
start = days_ago(89)
r = client.get(
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
headers=auth_header(auth_tokens["premium"]),
)
assert r.status_code == 200
data = r.get_json()
assert data["status"] == "ok"
assert data["plan"] == "premium"
assert data["history_limit_days"] == 90
def test_premium_blocked_beyond_90_days(self, client, auth_tokens, seeded_db):
"""Premium user: start = today-91 must return 403."""
start = days_ago(91)
r = client.get(
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
headers=auth_header(auth_tokens["premium"]),
)
assert r.status_code == 403
data = r.get_json()
assert data["code"] == 403
assert "required_plans" in data
# Premium upgrade hint should suggest pro
assert "pro" in data.get("required_plans", [])
def test_premium_can_access_last_7_days(self, client, auth_tokens, seeded_db):
"""Premium user can always access the free window too."""
start = days_ago(6)
r = client.get(
f"/api/v1/history?start={start}",
headers=auth_header(auth_tokens["premium"]),
)
assert r.status_code == 200
# ──────────────────────────────────────────────────────────────
# Pro plan — unlimited
# ──────────────────────────────────────────────────────────────
class TestHistoryProPlan:
def test_pro_can_access_old_data(self, client, auth_tokens, seeded_db):
"""Pro user: start = today-100 must return 200 (unlimited)."""
start = days_ago(100)
r = client.get(
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
headers=auth_header(auth_tokens["pro"]),
)
assert r.status_code == 200
data = r.get_json()
assert data["status"] == "ok"
assert data["plan"] == "pro"
assert data["history_limit_days"] is None # unlimited
def test_pro_default_request_returns_200(self, client, auth_tokens, seeded_db):
r = client.get(
"/api/v1/history",
headers=auth_header(auth_tokens["pro"]),
)
assert r.status_code == 200
def test_pro_can_see_all_seeded_rows(self, client, auth_tokens, seeded_db):
"""Pro fetching entire seeded range (100 days) should get all inserted rows."""
start = days_ago(100)
end = TODAY.isoformat()
r = client.get(
f"/api/v1/history?start={start}&end={end}&limit=500",
headers=auth_header(auth_tokens["pro"]),
)
assert r.status_code == 200
data = r.get_json()
# All 9 seeded rows should be present
assert data["pagination"]["total"] == 9
# ──────────────────────────────────────────────────────────────
# Input validation
# ──────────────────────────────────────────────────────────────
class TestHistoryValidation:
def test_invalid_start_format(self, client, auth_tokens):
r = client.get(
"/api/v1/history?start=31-12-2025",
headers=auth_header(auth_tokens["pro"]),
)
assert r.status_code == 400
data = r.get_json()
assert data["code"] == 400
assert "start" in data["message"].lower()
def test_invalid_end_format(self, client, auth_tokens):
r = client.get(
"/api/v1/history?end=2025/12/31",
headers=auth_header(auth_tokens["pro"]),
)
assert r.status_code == 400
data = r.get_json()
assert "end" in data["message"].lower()
def test_start_after_end_returns_400(self, client, auth_tokens):
r = client.get(
f"/api/v1/history?start={TODAY.isoformat()}&end={days_ago(5)}",
headers=auth_header(auth_tokens["pro"]),
)
assert r.status_code == 400
def test_pagination_limit_respected(self, client, auth_tokens, seeded_db):
start = days_ago(100)
r = client.get(
f"/api/v1/history?start={start}&limit=3&offset=0",
headers=auth_header(auth_tokens["pro"]),
)
assert r.status_code == 200
data = r.get_json()
assert len(data["history"]) <= 3
assert data["pagination"]["limit"] == 3
def test_pagination_has_more(self, client, auth_tokens, seeded_db):
"""has_more should be True when more rows exist beyond current page."""
start = days_ago(100)
r = client.get(
f"/api/v1/history?start={start}&limit=3&offset=0",
headers=auth_header(auth_tokens["pro"]),
)
assert r.status_code == 200
data = r.get_json()
# 9 total rows seeded, limit=3 → has_more=True
assert data["pagination"]["has_more"] is True
def test_response_shape(self, client, auth_tokens, seeded_db):
"""Verify the full response envelope shape."""
r = client.get(
"/api/v1/history",
headers=auth_header(auth_tokens["pro"]),
)
assert r.status_code == 200
data = r.get_json()
assert "status" in data
assert "plan" in data
assert "history_limit_days" in data
assert "start" in data
assert "end" in data
assert "history" in data
assert "pagination" in data
pagination = data["pagination"]
assert "total" in pagination
assert "limit" in pagination
assert "offset" in pagination
assert "has_more" in pagination
def test_history_row_fields(self, client, auth_tokens, seeded_db):
"""Each history row must contain the expected ML fields."""
start = days_ago(10)
r = client.get(
f"/api/v1/history?start={start}&limit=5",
headers=auth_header(auth_tokens["pro"]),
)
assert r.status_code == 200
data = r.get_json()
if data["history"]:
row = data["history"][0]
expected_fields = {
"id",
"date",
"horse_name",
"prob_top1",
"prob_top3",
"ml_score",
"race_label",
"hippodrome",
"heure",
"is_value_bet",
}
assert expected_fields.issubset(set(row.keys()))

533
tests/test_org.py Normal file
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

388
tests/test_user_tokens.py Normal file
View File

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

View File

@@ -107,6 +107,34 @@ def run_analytics():
traceback.print_exc()
def run_sync_turf_db():
"""Synchronise turf.db vers turf_saas.db"""
logger.info("🔄 [SCHEDULER] Sync turf.db -> turf_saas.db...")
try:
import subprocess
result = subprocess.run(
[
"python3",
"/home/h3r7/turf_saas/sync_turf_db.py",
"--date",
datetime.now().strftime("%Y-%m-%d"),
],
capture_output=True,
text=True,
timeout=300,
)
if result.returncode == 0:
logger.info("✅ [SCHEDULER] Sync turf.db terminé")
else:
logger.error(f"❌ [SCHEDULER] Sync turf.db échoué: {result.stderr}")
except Exception as e:
logger.error(f"❌ [SCHEDULER] Erreur sync turf.db: {e}")
import traceback
traceback.print_exc()
def get_todays_race_time():
"""Récupère l'heure de la course principale du jour depuis la DB
Returns: timestamp en ms ou None
@@ -193,6 +221,65 @@ def schedule_dynamic_scoring():
logger.info(" [SCHEDULER] Pas de course aujourd'hui, pas de scoring dynamique")
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 +332,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()
@@ -253,6 +343,16 @@ def main():
schedule.every().day.at("20:00").do(run_results).tag("results", "daily_fallback")
schedule.every().day.at("19:00").do(run_scraper).tag("scraper", "late_evening")
# Sync turf.db -> turf_saas.db (2x/jour: post-scraping + post-cotes)
schedule.every().day.at("11:00").do(run_sync_turf_db).tag("sync", "post_scraping")
schedule.every().day.at("17:00").do(run_sync_turf_db).tag("sync", "post_cotes")
# ML Cache: populate ml_predictions_cache après chaque sync
schedule.every().day.at("11:35").do(run_ml_cache).tag("ml_cache", "post_sync_am")
schedule.every().day.at("17:35").do(run_ml_cache).tag("ml_cache", "post_sync_pm")
schedule.every().day.at("09:30").do(run_ml_cache).tag("ml_cache", "morning")
schedule.every().day.at("13:30").do(run_ml_cache).tag("ml_cache", "pre_race")
schedule.every().sunday.at("02:00").do(run_ml).tag("ml", "weekly")
schedule.every().wednesday.at("02:00").do(run_ml).tag("ml", "midweek")
@@ -273,6 +373,200 @@ def main():
time.sleep(30)
def run_ml_cache():
"""Populate ml_predictions_cache with ensemble (predict_v2) predictions"""
logger.info("🤖 [SCHEDULER] Mise à jour cache prédictions ML (ensemble)...")
try:
os.chdir("/home/h3r7/turf_saas")
import predict_v2
model = predict_v2.load_ensemble()
if model is None:
logger.warning("⚠️ [SCHEDULER] Ensemble model not available, skipping")
return
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
today = datetime.now().strftime("%Y-%m-%d")
rows = conn.execute("""
SELECT p.*, c.distance, c.discipline, c.specialite,
c.nb_declares_partants, c.montant_prix, c.penetrometre_intitule,
c.libelle as course_libelle, c.libelle_court as hippodrome,
c.heure_depart_str, c.parcours
FROM pmu_partants p
LEFT JOIN pmu_courses c ON p.date_programme = c.date_programme
AND p.num_reunion = c.num_reunion AND p.num_course = c.num_course
WHERE p.date_programme = ?
ORDER BY p.num_reunion, p.num_course, p.num_pmu
""", (today,)).fetchall()
if not rows:
logger.info(" [SCHEDULER] No partants today, skipping ML cache")
conn.close()
return
partants = [dict(r) for r in rows]
course_lookup = {}
for p in partants:
key = (p["num_reunion"], p["num_course"])
if key not in course_lookup:
course_lookup[key] = {
"libelle": p.get("course_libelle", ""),
"libelle_court": p.get("hippodrome", ""),
"discipline": p.get("discipline", ""),
"distance": p.get("distance", 0),
"heure_depart_str": p.get("heure_depart_str", ""),
}
odds_by_horse = {}
for p in partants:
odds_by_horse[(p["num_reunion"], p["num_course"], p["num_pmu"])] = p.get("cote_direct", 0)
preds = predict_v2.predict_top3(partants, model=model)
if not preds:
logger.warning("⚠️ [SCHEDULER] No predictions generated")
conn.close()
return
enriched = []
for p in preds:
key = (p.get("num_reunion"), p.get("num_course"))
ci = course_lookup.get(key, {})
odds_key = (p.get("num_reunion"), p.get("num_course"), p.get("num_pmu"))
enriched.append({
"num_reunion": p.get("num_reunion"),
"num_course": p.get("num_course"),
"horse_name": p.get("horse_name"),
"horse_number": p.get("num_pmu"),
"odds": odds_by_horse.get(odds_key, 0),
"prob_top1": p.get("prob_top1"),
"prob_top3": p.get("prob_top3"),
"ml_score": p.get("ml_score"),
"recommendation": p.get("recommendation"),
"is_value_bet": p.get("is_value_bet", 0),
"is_outlier": 0,
"race_label": f"R{p.get('num_reunion', 0)}C{p.get('num_course', 0)}",
"race_name": ci.get("libelle", ""),
"hippodrome": ci.get("libelle_court", ""),
"discipline": ci.get("discipline", ""),
"distance": ci.get("distance", 0),
"heure": ci.get("heure_depart_str", ""),
})
# Calculate risques per race (same logic as dashboard_api.calculate_risque)
from collections import defaultdict
race_horses = defaultdict(list)
for p in enriched:
rkey = (p.get("num_reunion"), p.get("num_course"))
race_horses[rkey].append({
"odds": p.get("odds", 999),
"ml_score": p.get("ml_score", 0),
"prob_top1": p.get("prob_top1", 0),
"prob_top3": p.get("prob_top3", 0),
})
race_risque = {}
for rkey, partants_list in race_horses.items():
label, score = _calc_risque(partants_list)
race_risque[rkey] = (label or "neutral", score or 50)
# Ensure table exists with all columns
conn.execute("""
CREATE TABLE IF NOT EXISTS ml_predictions_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL, num_reunion INTEGER, num_course INTEGER,
horse_name TEXT, horse_number INTEGER, odds REAL,
prob_top1 REAL, prob_top3 REAL, ml_score REAL,
recommendation TEXT, is_value_bet INTEGER DEFAULT 0,
is_outlier INTEGER DEFAULT 0, race_label TEXT, race_name TEXT,
hippodrome TEXT, discipline TEXT, distance REAL, heure TEXT,
model_version TEXT DEFAULT 'xgboost_v1',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
risque_label TEXT DEFAULT 'neutral', risque_score INTEGER DEFAULT 50,
UNIQUE(date, num_reunion, num_course, horse_name)
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_ml_cache_date ON ml_predictions_cache(date)")
try:
conn.execute("ALTER TABLE ml_predictions_cache ADD COLUMN risque_label TEXT DEFAULT 'neutral'")
except Exception:
pass
try:
conn.execute("ALTER TABLE ml_predictions_cache ADD COLUMN risque_score INTEGER DEFAULT 50")
except Exception:
pass
conn.execute("DELETE FROM ml_predictions_cache WHERE date = ?", (today,))
for p in enriched:
rkey = (p.get("num_reunion"), p.get("num_course"))
rl, rs = race_risque.get(rkey, ("neutral", 50))
conn.execute("""
INSERT INTO ml_predictions_cache
(date, num_reunion, num_course, horse_name, horse_number, odds,
prob_top1, prob_top3, ml_score, recommendation, is_value_bet, is_outlier,
race_label, race_name, hippodrome, discipline, distance, heure,
risque_label, risque_score, model_version)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", (
today, p.get("num_reunion"), p.get("num_course"),
p.get("horse_name"), p.get("horse_number"), p.get("odds"),
p.get("prob_top1"), p.get("prob_top3"), p.get("ml_score"),
p.get("recommendation"), p.get("is_value_bet", 0), p.get("is_outlier", 0),
p.get("race_label"), p.get("race_name"), p.get("hippodrome"),
p.get("discipline"), p.get("distance"), p.get("heure"),
rl, rs, "ensemble_v1",
))
conn.commit()
conn.close()
logger.info(f"✅ [SCHEDULER] ML cache mis à jour: {len(enriched)} prédictions pour {today}")
except Exception as e:
logger.error(f"❌ [SCHEDULER] Erreur ML cache: {e}")
import traceback
traceback.print_exc()
def _calc_risque(partants_list):
"""Same logic as dashboard_api.calculate_risque — kept local to avoid import side effects"""
if not partants_list:
return None, None
sorted_p = sorted(
partants_list,
key=lambda x: x.get("ml_score") or x.get("prob_top1") or 0,
reverse=True,
)
top1_score = sorted_p[0].get("ml_score") or sorted_p[0].get("prob_top1") or 0
top2_score = (
sorted_p[1].get("ml_score") or sorted_p[1].get("prob_top1") or 0
if len(sorted_p) > 1 else 0
)
gap_1_2 = top1_score - top2_score
nb_dangerous = sum(1 for p in sorted_p if (p.get("ml_score") or 0) > 40)
odds_fav = sorted(partants_list, key=lambda x: x.get("odds") or 999)
fav_odds = odds_fav[0].get("odds") or 999 if odds_fav else 999
fav_ml = (
odds_fav[0].get("ml_score") or odds_fav[0].get("prob_top1") or 0
if odds_fav else 0
)
fav_surprise = fav_odds < 5 and fav_ml < 25
if top1_score >= 65 and gap_1_2 >= 20:
score = min(100, int(50 + gap_1_2 * 1.5))
return "safe", score
if fav_surprise:
return "trap", max(10, int(35 - (25 - fav_ml)))
if nb_dangerous >= 4 and top1_score < 70:
return "trap", max(10, int(40 - nb_dangerous * 2))
if gap_1_2 < 8 and top2_score > 45:
return "trap", max(15, int(30 + gap_1_2))
score = min(64, max(35, int(35 + gap_1_2 * 1.2)))
return "neutral", score
def run_metrics_alerts():
"""Verifie les metriques du jour et envoie une alerte email si ROI > 1.0€"""
logger.info("📧 [SCHEDULER] Vérification alertes métriques...")