Compare commits
39 Commits
feature/ap
...
feature/HR
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0492f06bfd | ||
| 91134e2f3f | |||
|
|
663e0bb149 | ||
| 5c6b407f47 | |||
|
|
f300e44c74 | ||
|
|
946bdc65b6 | ||
|
|
bc5ee3fa1a | ||
|
|
701660ce83 | ||
| b7ed82418f | |||
|
|
8604dc78b1 | ||
|
|
30464fb40c | ||
|
|
31db3a8260 | ||
|
|
278245cd7c | ||
|
|
ec024d8236 | ||
|
|
225295030b | ||
|
|
86e85aa1c6 | ||
| 5aa6013c52 | |||
|
|
4b4323f707 | ||
|
|
356bdf5bec | ||
|
|
f9a45e6deb | ||
|
|
cfc0f038f9 | ||
|
|
c999285895 | ||
|
|
e517741c97 | ||
| 837a0845ec | |||
|
|
4bf458f1b8 | ||
|
|
099286b078 | ||
|
|
d39c7d3319 | ||
|
|
8c5fdf1e9c | ||
|
|
7f5573f076 | ||
|
|
82d6bdafba | ||
|
|
36d93697bc | ||
| 2f57719b21 | |||
| bffc06c9b1 | |||
| f1ef2648b1 | |||
|
|
6b762068fd | ||
|
|
0e7bcff6b0 | ||
|
|
41a9e36166 | ||
|
|
793ee82c29 | ||
|
|
dce1e9b744 |
68
.dockerignore
Normal file
68
.dockerignore
Normal file
@@ -0,0 +1,68 @@
|
||||
# Files/dirs excluded from Docker build context
|
||||
# Keep image small; sensitive files never baked in
|
||||
|
||||
# Python artifacts
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
|
||||
# Databases (use volumes)
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# ML models (use volumes)
|
||||
*.pkl
|
||||
*.joblib
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Backups & temp files
|
||||
*.backup*
|
||||
*.bak*
|
||||
*.tmp
|
||||
*.bak
|
||||
|
||||
# Secrets & env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Exports
|
||||
exports/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Test artifacts
|
||||
.pytest_cache/
|
||||
htmlcov/
|
||||
.coverage
|
||||
coverage.xml
|
||||
|
||||
# AWS
|
||||
awscliv2.zip
|
||||
82
.env.example
Normal file
82
.env.example
Normal file
@@ -0,0 +1,82 @@
|
||||
# =============================================================
|
||||
# H3R7Tech Turf SaaS — Environment Variables Template
|
||||
# Copy this file to .env and fill in your values.
|
||||
# NEVER commit .env to version control.
|
||||
# =============================================================
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# PostgreSQL
|
||||
# ----------------------------------------------------------------
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=turf_saas
|
||||
POSTGRES_USER=turf
|
||||
POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD
|
||||
|
||||
# Full DSN used by SQLAlchemy / Alembic
|
||||
DATABASE_URL=postgresql://turf:CHANGE_ME_STRONG_PASSWORD@postgres:5432/turf_saas
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Redis
|
||||
# ----------------------------------------------------------------
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=CHANGE_ME_REDIS_PASSWORD
|
||||
REDIS_URL=redis://:CHANGE_ME_REDIS_PASSWORD@redis:6379/0
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Flask / App
|
||||
# ----------------------------------------------------------------
|
||||
FLASK_ENV=production
|
||||
SECRET_KEY=CHANGE_ME_FLASK_SECRET_KEY_64CHARS
|
||||
DEBUG=false
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# DB path for legacy SQLite (kept for migration, set to /app/data/db/)
|
||||
DB_PATH=/app/data/db/turf_saas.db
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Domain & TLS
|
||||
# ----------------------------------------------------------------
|
||||
DOMAIN=turf.h3r7.tech
|
||||
ADMIN_EMAIL=admin@h3r7.tech
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Stripe (Billing)
|
||||
# ----------------------------------------------------------------
|
||||
STRIPE_SECRET_KEY=sk_live_CHANGE_ME
|
||||
STRIPE_WEBHOOK_SECRET=whsec_CHANGE_ME
|
||||
STRIPE_PUBLISHABLE_KEY=pk_live_CHANGE_ME
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# LLM / AI API keys
|
||||
# ----------------------------------------------------------------
|
||||
OPENROUTER_API_KEY=CHANGE_ME
|
||||
OPENAI_API_KEY=CHANGE_ME
|
||||
LLM_BASE_URL=https://openrouter.ai/v1
|
||||
LLM_MODEL=liquid/lfm-2.5-1.2b-instruct:free
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# External APIs
|
||||
# ----------------------------------------------------------------
|
||||
RESEND_API=CHANGE_ME
|
||||
BRAVE_SEARCH_API=CHANGE_ME
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Monitoring
|
||||
# ----------------------------------------------------------------
|
||||
GRAFANA_ADMIN_USER=admin
|
||||
GRAFANA_ADMIN_PASSWORD=CHANGE_ME_GRAFANA_PASSWORD
|
||||
|
||||
# Slack webhook for CI/CD notifications (optional)
|
||||
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/CHANGE_ME
|
||||
|
||||
# Telegram bot for notifications (optional)
|
||||
TELEGRAM_BOT_TOKEN=CHANGE_ME
|
||||
TELEGRAM_CHAT_ID=CHANGE_ME
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Docker registry (for CD pipeline)
|
||||
# ----------------------------------------------------------------
|
||||
REGISTRY=ghcr.io
|
||||
IMAGE_NAME=h3r7tech/turf-saas
|
||||
205
.github/workflows/cd.yml
vendored
Normal file
205
.github/workflows/cd.yml
vendored
Normal file
@@ -0,0 +1,205 @@
|
||||
# ============================================================
|
||||
# CD Pipeline — deploy to staging then production
|
||||
# Triggers on push to main/master
|
||||
# ============================================================
|
||||
|
||||
name: CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: "Target environment"
|
||||
required: true
|
||||
default: staging
|
||||
type: choice
|
||||
options: [staging, production]
|
||||
|
||||
concurrency:
|
||||
group: cd-${{ github.ref }}
|
||||
cancel-in-progress: false # Never cancel an active deploy
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
# ----------------------------------------------------------
|
||||
# Job 1: Deploy to Staging
|
||||
# ----------------------------------------------------------
|
||||
deploy-staging:
|
||||
name: Deploy → Staging
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: staging
|
||||
url: https://staging.turf.h3r7.tech
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy to staging server via SSH
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.STAGING_HOST }}
|
||||
username: ${{ secrets.STAGING_USER }}
|
||||
key: ${{ secrets.STAGING_SSH_KEY }}
|
||||
port: ${{ secrets.STAGING_PORT || 22 }}
|
||||
script: |
|
||||
set -e
|
||||
echo "=== Deploying to STAGING ==="
|
||||
cd /opt/turf-saas
|
||||
|
||||
# Pull latest code
|
||||
git fetch origin
|
||||
git checkout ${{ github.sha }}
|
||||
|
||||
# Pull latest Docker images
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||
docker compose pull
|
||||
|
||||
# Run DB migrations
|
||||
docker compose run --rm combined-api alembic upgrade head
|
||||
|
||||
# Rolling restart — zero downtime
|
||||
docker compose up -d --no-deps --scale combined-api=2 combined-api
|
||||
sleep 15
|
||||
docker compose up -d --no-deps --scale combined-api=1 combined-api
|
||||
|
||||
# Restart other services
|
||||
docker compose up -d --no-deps dashboard-api portal scheduler
|
||||
|
||||
# Health check
|
||||
sleep 20
|
||||
curl -f https://staging.turf.h3r7.tech/health || exit 1
|
||||
|
||||
echo "=== Staging deploy OK ==="
|
||||
|
||||
- name: Notify Staging Deploy
|
||||
run: |
|
||||
MSG="✅ Staging deployed: \`${{ github.repository }}\` commit=\`${{ github.sha }}\`"
|
||||
curl -s -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \
|
||||
-H 'Content-type: application/json' \
|
||||
--data "{\"text\":\"${MSG}\"}" || true
|
||||
curl -s -X POST \
|
||||
"https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
|
||||
-d chat_id="${{ secrets.TELEGRAM_CHAT_ID }}" \
|
||||
-d text="${MSG}" || true
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Job 2: Smoke Tests on Staging
|
||||
# ----------------------------------------------------------
|
||||
smoke-test-staging:
|
||||
name: Smoke Tests on Staging
|
||||
runs-on: ubuntu-latest
|
||||
needs: deploy-staging
|
||||
steps:
|
||||
- name: Health endpoints check
|
||||
run: |
|
||||
BASE="https://staging.turf.h3r7.tech"
|
||||
echo "Checking ${BASE}/health ..."
|
||||
curl -f "${BASE}/health" -o /dev/null -s -w "%{http_code}\n"
|
||||
echo "Checking ${BASE}/api/predictions ..."
|
||||
curl -f "${BASE}/api/predictions" -o /dev/null -s -w "%{http_code}\n" || true
|
||||
echo "Smoke tests passed"
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Job 3: Deploy to Production (manual approval gate)
|
||||
# ----------------------------------------------------------
|
||||
deploy-production:
|
||||
name: Deploy → Production
|
||||
runs-on: ubuntu-latest
|
||||
needs: smoke-test-staging
|
||||
environment:
|
||||
name: production
|
||||
url: https://turf.h3r7.tech
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy to production server via SSH
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.PROD_HOST }}
|
||||
username: ${{ secrets.PROD_USER }}
|
||||
key: ${{ secrets.PROD_SSH_KEY }}
|
||||
port: ${{ secrets.PROD_PORT || 22 }}
|
||||
script: |
|
||||
set -e
|
||||
echo "=== Deploying to PRODUCTION ==="
|
||||
cd /opt/turf-saas
|
||||
|
||||
# Backup current state
|
||||
docker compose exec -T postgres pg_dumpall -U turf > /opt/backups/turf_saas_pre_deploy_$(date +%Y%m%d_%H%M%S).sql
|
||||
|
||||
# Pull latest code
|
||||
git fetch origin
|
||||
git checkout ${{ github.sha }}
|
||||
|
||||
# Pull latest Docker images
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||
docker compose pull
|
||||
|
||||
# Run DB migrations
|
||||
docker compose run --rm combined-api alembic upgrade head
|
||||
|
||||
# Rolling restart
|
||||
docker compose up -d --no-deps --scale combined-api=2 combined-api
|
||||
sleep 20
|
||||
docker compose up -d --no-deps --scale combined-api=1 combined-api
|
||||
docker compose up -d --no-deps dashboard-api portal scheduler
|
||||
|
||||
# Health check
|
||||
sleep 30
|
||||
curl -f https://turf.h3r7.tech/health || exit 1
|
||||
|
||||
# Clean old images
|
||||
docker image prune -f
|
||||
|
||||
echo "=== Production deploy OK ==="
|
||||
|
||||
- name: Notify Production Deploy
|
||||
run: |
|
||||
MSG="🚀 Production deployed: \`${{ github.repository }}\` commit=\`${{ github.sha }}\`"
|
||||
curl -s -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \
|
||||
-H 'Content-type: application/json' \
|
||||
--data "{\"text\":\"${MSG}\"}" || true
|
||||
curl -s -X POST \
|
||||
"https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
|
||||
-d chat_id="${{ secrets.TELEGRAM_CHAT_ID }}" \
|
||||
-d text="${MSG}" || true
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Rollback job (triggered manually on failure)
|
||||
# ----------------------------------------------------------
|
||||
rollback:
|
||||
name: Rollback Production
|
||||
runs-on: ubuntu-latest
|
||||
if: failure() && needs.deploy-production.result == 'failure'
|
||||
needs: deploy-production
|
||||
environment: production
|
||||
steps:
|
||||
- name: Rollback via SSH
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.PROD_HOST }}
|
||||
username: ${{ secrets.PROD_USER }}
|
||||
key: ${{ secrets.PROD_SSH_KEY }}
|
||||
script: |
|
||||
cd /opt/turf-saas
|
||||
git checkout HEAD~1
|
||||
docker compose up -d --force-recreate
|
||||
echo "Rollback complete"
|
||||
|
||||
- name: Notify Rollback
|
||||
run: |
|
||||
curl -s -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \
|
||||
-H 'Content-type: application/json' \
|
||||
--data '{"text":"⚠️ Production ROLLED BACK due to deploy failure!"}' || true
|
||||
236
.github/workflows/ci.yml
vendored
Normal file
236
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,236 @@
|
||||
# ============================================================
|
||||
# CI Pipeline — lint + tests + Docker build
|
||||
# Runs on every push and pull request
|
||||
# ============================================================
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: [main, master, develop]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: "3.12"
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
# ----------------------------------------------------------
|
||||
# Job 1: Lint & Static Analysis
|
||||
# ----------------------------------------------------------
|
||||
lint:
|
||||
name: Lint & Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: pip
|
||||
|
||||
- name: Install lint tools
|
||||
run: pip install flake8 bandit safety
|
||||
|
||||
- name: Flake8 linting
|
||||
run: |
|
||||
flake8 . \
|
||||
--exclude=venv,migrations,__pycache__,.git \
|
||||
--max-line-length=120 \
|
||||
--ignore=E501,W503,E302,E303 \
|
||||
--count --statistics
|
||||
continue-on-error: true
|
||||
|
||||
- name: Bandit security scan
|
||||
run: |
|
||||
bandit -r . \
|
||||
--exclude ./venv,./migrations,./infra \
|
||||
-ll -ii \
|
||||
-f json -o bandit-report.json || true
|
||||
cat bandit-report.json
|
||||
|
||||
- name: Safety dependency vulnerability check
|
||||
run: |
|
||||
safety check -r requirements.txt --json || true
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Job 2: Tests
|
||||
# ----------------------------------------------------------
|
||||
test:
|
||||
name: Unit & Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_DB: turf_test
|
||||
POSTGRES_USER: turf
|
||||
POSTGRES_PASSWORD: testpassword
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql://turf:testpassword@localhost:5432/turf_test
|
||||
POSTGRES_HOST: localhost
|
||||
POSTGRES_PORT: 5432
|
||||
POSTGRES_DB: turf_test
|
||||
POSTGRES_USER: turf
|
||||
POSTGRES_PASSWORD: testpassword
|
||||
FLASK_ENV: testing
|
||||
SECRET_KEY: test-secret-key-not-for-production
|
||||
DB_PATH: /tmp/turf_test.db
|
||||
LOG_LEVEL: WARNING
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: pip
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt pytest pytest-cov pytest-flask
|
||||
|
||||
- name: Run Alembic migrations
|
||||
run: |
|
||||
if [ -f alembic.ini ]; then
|
||||
alembic upgrade head
|
||||
else
|
||||
echo "No alembic.ini found, skipping migrations"
|
||||
fi
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
if [ -d tests ]; then
|
||||
pytest tests/ -v --cov=. --cov-report=xml --cov-report=term-missing
|
||||
else
|
||||
echo "No tests directory found — creating basic smoke test"
|
||||
python -c "
|
||||
import sys, os
|
||||
os.environ['FLASK_ENV'] = 'testing'
|
||||
os.environ['SECRET_KEY'] = 'test'
|
||||
os.environ['DB_PATH'] = '/tmp/smoke_test.db'
|
||||
print('Import check...')
|
||||
try:
|
||||
import combined_api
|
||||
print('combined_api: OK')
|
||||
except Exception as e:
|
||||
print(f'combined_api: ERROR - {e}')
|
||||
try:
|
||||
import dashboard_api
|
||||
print('dashboard_api: OK')
|
||||
except Exception as e:
|
||||
print(f'dashboard_api: ERROR - {e}')
|
||||
try:
|
||||
import portal_server
|
||||
print('portal_server: OK')
|
||||
except Exception as e:
|
||||
print(f'portal_server: ERROR - {e}')
|
||||
print('All checks done.')
|
||||
"
|
||||
fi
|
||||
|
||||
- name: Upload coverage report
|
||||
uses: codecov/codecov-action@v4
|
||||
if: hashFiles('coverage.xml') != ''
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
fail_ci_if_error: false
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Job 3: Docker Build
|
||||
# ----------------------------------------------------------
|
||||
docker-build:
|
||||
name: Docker Build & Push
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha,prefix=sha-
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build (and push on non-PR)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
target: runner
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Verify image size
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
SIZE=$(docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest --format='{{.Size}}' 2>/dev/null || echo "0")
|
||||
SIZE_MB=$((SIZE / 1024 / 1024))
|
||||
echo "Image size: ${SIZE_MB}MB"
|
||||
if [ "$SIZE_MB" -gt 500 ]; then
|
||||
echo "::warning::Image size ${SIZE_MB}MB exceeds 500MB limit"
|
||||
fi
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Job 4: Notify on failure
|
||||
# ----------------------------------------------------------
|
||||
notify-failure:
|
||||
name: Notify on Failure
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test, docker-build]
|
||||
if: failure() && github.event_name == 'push'
|
||||
steps:
|
||||
- name: Notify Telegram
|
||||
if: vars.TELEGRAM_BOT_TOKEN != ''
|
||||
run: |
|
||||
curl -s -X POST \
|
||||
"https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
|
||||
-d chat_id="${{ secrets.TELEGRAM_CHAT_ID }}" \
|
||||
-d text="❌ CI FAILED: ${{ github.repository }} branch=${{ github.ref_name }} commit=${{ github.sha }}" \
|
||||
-d parse_mode="Markdown" || true
|
||||
|
||||
- name: Notify Slack
|
||||
if: vars.SLACK_WEBHOOK_URL != ''
|
||||
run: |
|
||||
curl -s -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \
|
||||
-H 'Content-type: application/json' \
|
||||
--data "{\"text\":\"❌ CI FAILED: \`${{ github.repository }}\` branch=\`${{ github.ref_name }}\` commit=\`${{ github.sha }}\`\"}" || true
|
||||
28
.gitignore
vendored
28
.gitignore
vendored
@@ -78,3 +78,31 @@ patch_*.py
|
||||
# Données scraping brutes
|
||||
v3_*.json
|
||||
v4_*.json
|
||||
|
||||
# Environment secrets (NEVER commit)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
!.env.example
|
||||
|
||||
# Docker build cache
|
||||
.docker/
|
||||
|
||||
# Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Test artifacts
|
||||
.pytest_cache/
|
||||
htmlcov/
|
||||
.coverage
|
||||
coverage.xml
|
||||
|
||||
# TLS certs (managed by certbot volume)
|
||||
infra/nginx/certs/
|
||||
|
||||
281
DOCUMENTATION.md
281
DOCUMENTATION.md
@@ -155,3 +155,284 @@ python app.py
|
||||
---
|
||||
|
||||
*Document généré automatiquement - Dépenses Trello*
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
# Turf SaaS — Documentation API v1
|
||||
|
||||
**Mise à jour** : 2026-04-30 (HRT-96 — ML Predictions + ROI + Feedback)
|
||||
**URL SaaS** : https://turf-saas-kolifee.duckdns.org
|
||||
**Port local** : 8792
|
||||
**Base de données** : `/home/h3r7/turf_saas/turf_saas.db`
|
||||
|
||||
---
|
||||
|
||||
## Stack Technique Turf SaaS
|
||||
|
||||
| Composant | Technologie |
|
||||
|---|---|
|
||||
| Backend | Python Flask + Blueprints |
|
||||
| Auth | JWT (access + refresh tokens) |
|
||||
| Base de données | SQLite (`turf_saas.db`) |
|
||||
| ML | XGBoost v1 (prédictions courses PMU) |
|
||||
| Frontend | HTML5 + Chart.js |
|
||||
| Hébergement | VPS Linux — https://turf-saas-kolifee.duckdns.org |
|
||||
|
||||
---
|
||||
|
||||
## Plans d'accès
|
||||
|
||||
| Plan | Accès |
|
||||
|---|---|
|
||||
| `free` | health, auth, courses/today, predictions/top3 (1/jour) |
|
||||
| `premium` | + predictions/all, valuebets, metrics, roi (complet), feedback/stats |
|
||||
| `pro` | + backtest, export/csv, historique illimité, orgs |
|
||||
|
||||
---
|
||||
|
||||
## Endpoints API v1
|
||||
|
||||
### Authentification
|
||||
|
||||
| Méthode | Path | Auth | Description |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/v1/auth/register` | Non | Créer un compte (plan=free) |
|
||||
| POST | `/api/v1/auth/login` | Non | Login — retourne access_token + refresh_token |
|
||||
| POST | `/api/v1/auth/refresh` | Non | Renouveler l'access token |
|
||||
| POST | `/api/v1/auth/logout` | Oui | Révoquer le refresh token |
|
||||
|
||||
### Système
|
||||
|
||||
| Méthode | Path | Auth | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/health` | Non | Healthcheck public |
|
||||
| GET | `/api/v1/docs` | Non | Swagger UI (Flasgger) |
|
||||
|
||||
### Courses
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/courses/today` | free+ | Courses du jour (paginé) |
|
||||
| GET | `/api/v1/courses/{id}/predictions` | free+ | Prédictions ML pour une course |
|
||||
|
||||
`{id}` format : `{num_reunion}-{num_course}` ex: `1-3`
|
||||
Query params `courses/today` : `filter=[all|quinte|trot|plat]`, `limit`, `offset`
|
||||
|
||||
### Prédictions ML
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/predictions/top3` | free+ | Top 3 chevaux du jour |
|
||||
| GET | `/api/v1/predictions/all` | premium+ | Toutes les prédictions XGBoost |
|
||||
|
||||
Query params : `date=YYYY-MM-DD`, `limit`, `offset`
|
||||
|
||||
Source des données : table `ml_predictions_cache` (modèle `xgboost_v1`)
|
||||
|
||||
### Value Bets
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/valuebets` | premium+ | Value bets du jour (`is_value_bet=1`) |
|
||||
|
||||
Query params : `date`, `min_odds` (défaut 2.0), `limit`, `offset`
|
||||
|
||||
### Métriques ML
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/metrics` | premium+ | Métriques perf ML (precision, ROI, top-3 rate) |
|
||||
|
||||
Query params : `days` (int, défaut 30, max 365)
|
||||
|
||||
### ROI par Modèle/Stratégie (HRT-92)
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/roi/by-model` | premium+ | ROI calculé par stratégie ML XGBoost |
|
||||
|
||||
**Query params** :
|
||||
- `strategy` : filtrer par stratégie (`xgboost_sg`, `xgboost_value`, `xgboost_sp`, `xgboost_2sur4`)
|
||||
- `days` : période en jours (défaut 30, max 365)
|
||||
|
||||
**Réponse** :
|
||||
```json
|
||||
{
|
||||
"period": {"start": "2026-04-01", "end": "2026-04-30", "days": 30},
|
||||
"models": [
|
||||
{
|
||||
"model_source": "xgboost_sg",
|
||||
"nb_paris": 42,
|
||||
"mise": 42.0,
|
||||
"gain": 51.3,
|
||||
"roi_pct": 22.1,
|
||||
"win_rate": 28.6
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Jointures** : `paris` ← `pmu_partants` (résultats) ← `pmu_rapports` (dividendes)
|
||||
|
||||
**Accès plan** : Free = 1 stratégie max, Premium/Pro = complet + historique illimité
|
||||
|
||||
### ML Feedback Loop (HRT-93)
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/v1/ml/feedback/run` | Admin | Déclencher ml_feedback_saas.py manuellement |
|
||||
| GET | `/api/v1/ml/feedback/stats` | premium+ | Stats paris par stratégie XGBoost |
|
||||
|
||||
**POST `/api/v1/ml/feedback/run`** — Corps optionnel :
|
||||
```json
|
||||
{"date": "2026-04-29"}
|
||||
```
|
||||
ou
|
||||
```json
|
||||
{"backfill": "2026-04-20"}
|
||||
```
|
||||
|
||||
**GET `/api/v1/ml/feedback/stats`** — Réponse :
|
||||
```json
|
||||
{
|
||||
"stats": [
|
||||
{
|
||||
"source_reco": "xgboost_sg",
|
||||
"nb_paris": 42,
|
||||
"nb_gagnes": 12,
|
||||
"win_rate_pct": 28.6,
|
||||
"mise_totale": 42.0,
|
||||
"gain_total": 51.3,
|
||||
"roi_pct": 22.1
|
||||
}
|
||||
],
|
||||
"last_run": "2026-04-29T18:30:00"
|
||||
}
|
||||
```
|
||||
|
||||
**Stratégies XGBoost** :
|
||||
| Stratégie | Type pari | Condition | Mise |
|
||||
|---|---|---|---|
|
||||
| `xgboost_sg` | simple_gagnant | top1 ML, ml_score >= 70 | 1€ |
|
||||
| `xgboost_value` | simple_gagnant | is_value_bet = 1 | 1€ |
|
||||
| `xgboost_sp` | simple_place | top1 ML, ml_score >= 50 | 1€ |
|
||||
| `xgboost_2sur4` | deux_sur_quatre | top4 ML, 6 combos | 6€ |
|
||||
|
||||
### Backtest
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/backtest` | pro | Résultats historiques des paris |
|
||||
|
||||
Query params : `start`, `end` (YYYY-MM-DD), `limit`, `offset`
|
||||
|
||||
### Export
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/export/csv` | pro | Export CSV |
|
||||
|
||||
Query params : `type=[predictions|bets]`, `date`, `start`, `end`
|
||||
|
||||
### Historique
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/history` | free+ | Historique prédictions ML |
|
||||
|
||||
Limites : Free = 7j, Premium = 90j, Pro = illimité
|
||||
|
||||
### Organisations
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/org/` | pro | Détails de l'organisation |
|
||||
| POST | `/api/v1/org/` | pro | Créer une organisation |
|
||||
| POST | `/api/v1/org/invite` | pro | Inviter un membre (max 5) |
|
||||
| DELETE | `/api/v1/org/members/{id}` | pro | Retirer un membre |
|
||||
|
||||
### Utilisateur & Tokens
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/user/profile` | free+ | Profil utilisateur |
|
||||
| PUT | `/api/v1/user/alerts` | premium+ | Config alertes Telegram |
|
||||
| GET | `/api/v1/user/api-token` | pro | Token API personnel |
|
||||
| POST | `/api/v1/user/api-token` | pro | Générer/régénérer token API |
|
||||
| GET | `/api/v1/user/webhook` | pro | Config webhook |
|
||||
| PUT | `/api/v1/user/webhook` | pro | Modifier webhook |
|
||||
|
||||
### Billing (Stripe)
|
||||
|
||||
| Méthode | Path | Auth | Description |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/v1/billing/checkout` | Oui | Créer session Stripe Checkout |
|
||||
| POST | `/api/v1/billing/portal` | Oui | Portail Stripe (gestion abonnement) |
|
||||
| GET | `/api/v1/billing/status` | Oui | Statut abonnement actuel |
|
||||
| POST | `/api/v1/billing/webhook` | Non | Webhook Stripe (events) |
|
||||
|
||||
---
|
||||
|
||||
## Format de réponse uniforme
|
||||
|
||||
**Erreurs** :
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Description de l'erreur",
|
||||
"code": 400
|
||||
}
|
||||
```
|
||||
|
||||
**Listes paginées** :
|
||||
```json
|
||||
{
|
||||
"pagination": {
|
||||
"total": 150,
|
||||
"limit": 20,
|
||||
"offset": 0,
|
||||
"has_more": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture ML — Résumé
|
||||
|
||||
```
|
||||
ml_predictions_cache (XGBoost v1)
|
||||
→ ml_feedback_saas.py
|
||||
→ table paris (source_reco = xgboost_*)
|
||||
→ /api/v1/roi/by-model (ROI calculé)
|
||||
→ /api/v1/ml/feedback/stats (stats)
|
||||
→ dashboard_saas.html (Section Performance & ROI)
|
||||
```
|
||||
|
||||
Voir documentation complète : `POD/Intelligence/ML_Predictions_SaaS.md`
|
||||
|
||||
---
|
||||
|
||||
## Démarrage
|
||||
|
||||
```bash
|
||||
cd /home/h3r7/turf_saas
|
||||
source venv/bin/activate
|
||||
python app_v1.py
|
||||
# ou via gunicorn
|
||||
gunicorn -w 2 -b 0.0.0.0:8792 app_v1:app
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cd /home/h3r7/turf_saas
|
||||
source venv/bin/activate
|
||||
python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Turf SaaS — H3R7Tech — Mise à jour 2026-04-30 (HRT-96)*
|
||||
|
||||
68
Dockerfile
Normal file
68
Dockerfile
Normal file
@@ -0,0 +1,68 @@
|
||||
# ============================================================
|
||||
# Stage 1: Builder — install deps + compile Python bytecode
|
||||
# ============================================================
|
||||
FROM python:3.12-slim AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# System deps needed to compile psycopg2, xgboost, etc.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
g++ \
|
||||
libpq-dev \
|
||||
libffi-dev \
|
||||
libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Upgrade pip + install wheel for faster builds
|
||||
RUN pip install --upgrade pip wheel
|
||||
|
||||
# Copy only requirements first (layer caching)
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install into a prefix we can copy cleanly
|
||||
RUN pip install --prefix=/install --no-cache-dir -r requirements.txt
|
||||
|
||||
# ============================================================
|
||||
# Stage 2: Runner — minimal production image
|
||||
# ============================================================
|
||||
FROM python:3.12-slim AS runner
|
||||
|
||||
LABEL maintainer="DevOps <devops@h3r7tech.ai>"
|
||||
LABEL org.opencontainers.image.title="Turf SaaS"
|
||||
LABEL org.opencontainers.image.description="H3R7Tech Turf Predictions SaaS"
|
||||
|
||||
# Runtime system deps only
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create non-root app user
|
||||
RUN groupadd -r appuser && useradd -r -g appuser appuser
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy installed packages from builder
|
||||
COPY --from=builder /install /usr/local
|
||||
|
||||
# Copy application source (exclude files via .dockerignore)
|
||||
COPY . .
|
||||
|
||||
# Create directories for persistent data
|
||||
RUN mkdir -p /app/data/db /app/data/models /app/logs \
|
||||
&& chown -R appuser:appuser /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER appuser
|
||||
|
||||
# Expose all service ports
|
||||
EXPOSE 8790 8791 8792 8793
|
||||
|
||||
# Health check — hits the combined API
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD curl -f http://localhost:8790/health || exit 1
|
||||
|
||||
# Default: run combined API via gunicorn
|
||||
# Override CMD per service in docker-compose
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:8790", "--workers", "2", "--timeout", "120", "combined_api:app"]
|
||||
339
POD/Intelligence/ML_Predictions_SaaS.md
Normal file
339
POD/Intelligence/ML_Predictions_SaaS.md
Normal file
@@ -0,0 +1,339 @@
|
||||
# Note Intelligence — Système ML Prédictions dans turf_saas
|
||||
|
||||
**Date de création** : 2026-04-30
|
||||
**Auteur** : IngenieurDev (H3R7Tech)
|
||||
**Ticket de référence** : HRT-96 (sprint ML SaaS — HRT-90)
|
||||
**Scope** : `/home/h3r7/turf_saas/` — AUCUNE modification de `/home/h3r7/turf_scraper/`
|
||||
|
||||
---
|
||||
|
||||
## 1. Contexte & Décision architecturale
|
||||
|
||||
### 1.1 Deux systèmes, deux DB
|
||||
|
||||
H3R7Tech exploite deux dépôts séparés :
|
||||
|
||||
| Dépôt | Rôle | Base de données |
|
||||
|---|---|---|
|
||||
| `/home/h3r7/turf_scraper/` | Scraping PMU + entraînement XGBoost | `turf.db` |
|
||||
| `/home/h3r7/turf_saas/` | SaaS utilisateurs + API v1 + dashboard | `turf_saas.db` |
|
||||
|
||||
### 1.2 Décision de duplication (vs modification turf_scraper)
|
||||
|
||||
**Choix : dupliquer les tables et scripts ML dans turf_saas.db, sans toucher à turf_scraper.**
|
||||
|
||||
Justification :
|
||||
- `turf_scraper` est la source de vérité du scraping PMU et des modèles XGBoost — toute modification risque de casser la chaîne de collecte de données.
|
||||
- `turf_saas` doit fonctionner de manière autonome, avec ses propres utilisateurs, subscriptions et données.
|
||||
- La table `ml_predictions_cache` est *pré-peuplée* dans `turf_saas.db` par un processus de synchronisation (scheduler ou copie périodique depuis `turf.db`).
|
||||
- Le feedback loop (`ml_feedback_saas.py`) écrit dans `paris` de `turf_saas.db` uniquement.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture du système ML dans turf_saas
|
||||
|
||||
### 2.1 Vue d'ensemble du flow
|
||||
|
||||
```
|
||||
[turf_scraper/turf.db]
|
||||
└── ml_predictions_cache (XGBoost v1)
|
||||
│
|
||||
│ [sync périodique / scheduler]
|
||||
▼
|
||||
[turf_saas/turf_saas.db]
|
||||
├── ml_predictions_cache ← prédictions XGBoost importées
|
||||
├── pmu_partants ← données courses PMU
|
||||
├── pmu_rapports ← dividendes réels PMU
|
||||
├── paris ← paris virtuels ML (ml_feedback_saas.py)
|
||||
│
|
||||
└── API v1 ──┬── GET /api/v1/predictions/* (lecture ml_predictions_cache)
|
||||
├── GET /api/v1/roi/by-model (jointure paris + rapports)
|
||||
├── POST /api/v1/ml/feedback/run (déclenche ml_feedback_saas)
|
||||
└── GET /api/v1/ml/feedback/stats (stats par stratégie)
|
||||
│
|
||||
▼
|
||||
[dashboard_saas.html]
|
||||
Section "Performance & ROI"
|
||||
Chart.js — ROI par modèle / évolution
|
||||
```
|
||||
|
||||
### 2.2 Table `ml_predictions_cache` (turf_saas.db)
|
||||
|
||||
Table centrale du système ML. Contient les prédictions XGBoost pour chaque cheval/course.
|
||||
|
||||
| Colonne | Type | Description |
|
||||
|---|---|---|
|
||||
| `date` | TEXT | Date de la course (YYYY-MM-DD) |
|
||||
| `num_reunion` | INTEGER | Numéro de réunion |
|
||||
| `num_course` | INTEGER | Numéro de course |
|
||||
| `horse_name` | TEXT | Nom du cheval |
|
||||
| `horse_number` | INTEGER | Numéro du cheval |
|
||||
| `odds` | REAL | Cote au moment de la prédiction |
|
||||
| `prob_top1` | REAL | Probabilité XGBoost de finir 1er |
|
||||
| `prob_top3` | REAL | Probabilité XGBoost de finir top 3 |
|
||||
| `ml_score` | REAL | Score ML composite (0–100) |
|
||||
| `recommendation` | TEXT | `top1` / `top3` / `value_bet` |
|
||||
| `is_value_bet` | INTEGER | 1 si value bet détecté |
|
||||
| `is_outlier` | INTEGER | 1 si outlier de cote |
|
||||
| `race_label` | TEXT | Ex: `R1C3` |
|
||||
| `model_version` | TEXT | Version du modèle (ex: `xgboost_v1`) |
|
||||
| `risque_label` | TEXT | Niveau de risque (`low`/`neutral`/`high`) |
|
||||
| `risque_score` | INTEGER | Score risque (0–100) |
|
||||
|
||||
**Contrainte d'unicité** : `(date, num_reunion, num_course, horse_name)` — garantit l'idempotence des imports.
|
||||
|
||||
**Volume actuel** : ~1 000 entrées (2 dates de courses).
|
||||
|
||||
---
|
||||
|
||||
## 3. Feedback Loop ML — `ml_feedback_saas.py`
|
||||
|
||||
### 3.1 Rôle
|
||||
|
||||
Script Python autonome qui :
|
||||
1. Lit les prédictions XGBoost dans `ml_predictions_cache` de `turf_saas.db`
|
||||
2. Génère des paris virtuels selon 4 stratégies XGBoost
|
||||
3. Insère les paris dans la table `paris` de `turf_saas.db`
|
||||
4. Est **idempotent** : ne duplique pas les paris existants
|
||||
|
||||
### 3.2 Stratégies supportées
|
||||
|
||||
| Stratégie | Type pari | Condition sélection | Mise |
|
||||
|---|---|---|---|
|
||||
| `xgboost_sg` | `simple_gagnant` | top 1 ML par course, `ml_score >= 70` | 1€ |
|
||||
| `xgboost_value` | `simple_gagnant` | `is_value_bet = 1` | 1€ |
|
||||
| `xgboost_sp` | `simple_place` | top 1 ML par course, `ml_score >= 50` | 1€ |
|
||||
| `xgboost_2sur4` | `deux_sur_quatre` | top 4 ML par course, 6 combos générés | 6€ (1€/combo) |
|
||||
|
||||
### 3.3 Schéma d'idempotence
|
||||
|
||||
```python
|
||||
# Vérifie avant insertion
|
||||
SELECT id FROM paris
|
||||
WHERE date_course = ?
|
||||
AND source_reco = ? # ex: 'xgboost_sg'
|
||||
AND type_pari = ?
|
||||
AND numero1 = ?
|
||||
AND race_label = ?
|
||||
```
|
||||
|
||||
Si le pari existe déjà → skip (aucune duplication).
|
||||
|
||||
### 3.4 Table `paris` — colonnes clés pour le ML
|
||||
|
||||
| Colonne | Valeur ML |
|
||||
|---|---|
|
||||
| `source_reco` | `xgboost_sg` / `xgboost_value` / `xgboost_sp` / `xgboost_2sur4` |
|
||||
| `model_source` | `xgboost_v1` (héritée de ml_predictions_cache) |
|
||||
| `type_pari` | `simple_gagnant` / `simple_place` / `deux_sur_quatre` |
|
||||
| `statut` | `EN_ATTENTE` → `GAGNE` / `PERDU` (mise à jour par update_paris_results.py) |
|
||||
| `gain` | Dividende réel × mise (depuis pmu_rapports) |
|
||||
|
||||
### 3.5 Usage CLI
|
||||
|
||||
```bash
|
||||
# Traitement du jour
|
||||
python3 ml_feedback_saas.py
|
||||
|
||||
# Date spécifique
|
||||
python3 ml_feedback_saas.py --date 2026-04-29
|
||||
|
||||
# Backfill
|
||||
python3 ml_feedback_saas.py --backfill 2026-04-20
|
||||
```
|
||||
|
||||
**Différence avec `turf_scraper/ml_feedback.py`** :
|
||||
- `DB_PATH` = `/home/h3r7/turf_saas/turf_saas.db` (PAS `/home/h3r7/turf_scraper/turf.db`)
|
||||
- Logs dans `/home/h3r7/turf_saas/logs/`
|
||||
- AUCUNE référence à `turf_scraper`
|
||||
|
||||
---
|
||||
|
||||
## 4. API ROI — `/api/v1/roi/*`
|
||||
|
||||
### 4.1 Route principale
|
||||
|
||||
**`GET /api/v1/roi/by-model`** — Calcul du ROI par modèle/stratégie
|
||||
|
||||
Jointures SQL :
|
||||
|
||||
```sql
|
||||
-- paris ←→ pmu_partants (via race_label + date + numero)
|
||||
-- paris ←→ pmu_rapports (dividendes réels)
|
||||
|
||||
SELECT
|
||||
p.source_reco AS model_source,
|
||||
COUNT(p.id) AS nb_paris,
|
||||
SUM(p.mise) AS mise_totale,
|
||||
SUM(p.gain) AS gain_total,
|
||||
(SUM(p.gain) - SUM(p.mise)) / SUM(p.mise) * 100 AS roi_pct,
|
||||
COUNT(CASE WHEN p.statut='GAGNE' THEN 1 END) * 100.0 / COUNT(p.id) AS win_rate
|
||||
FROM paris p
|
||||
WHERE p.date_course BETWEEN :start AND :end
|
||||
AND (:strategy IS NULL OR p.source_reco = :strategy)
|
||||
GROUP BY p.source_reco
|
||||
```
|
||||
|
||||
**Paramètres query** :
|
||||
- `?strategy=xgboost_sg` — filtrer par stratégie (optionnel)
|
||||
- `?days=30` — fenêtre temporelle en jours (défaut : 30, max : 365)
|
||||
|
||||
**Réponse JSON** :
|
||||
```json
|
||||
{
|
||||
"period": {"start": "2026-04-01", "end": "2026-04-30", "days": 30},
|
||||
"models": [
|
||||
{
|
||||
"model_source": "xgboost_sg",
|
||||
"nb_paris": 42,
|
||||
"mise": 42.0,
|
||||
"gain": 51.3,
|
||||
"roi_pct": 22.1,
|
||||
"win_rate": 28.6
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Accès plan** :
|
||||
- `free` : 1 stratégie max
|
||||
- `premium` : complet
|
||||
- `pro` : complet + historique illimité
|
||||
|
||||
### 4.2 Blueprint `api_v1/routes/roi.py`
|
||||
|
||||
Enregistré dans `api_v1/__init__.py` avec :
|
||||
```python
|
||||
from .routes.roi import roi_bp
|
||||
app.register_blueprint(roi_bp)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. API ML Feedback — `/api/v1/ml/feedback/*`
|
||||
|
||||
### 5.1 Routes
|
||||
|
||||
| Méthode | Path | Auth | Description |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/api/v1/ml/feedback/run` | Admin | Déclenche `ml_feedback_saas.py` manuellement |
|
||||
| `GET` | `/api/v1/ml/feedback/stats` | Premium+ | Stats paris par stratégie XGBoost |
|
||||
|
||||
### 5.2 `POST /api/v1/ml/feedback/run`
|
||||
|
||||
- Réservé aux admins (token admin requis)
|
||||
- Déclenche le script `ml_feedback_saas.py` en subprocess
|
||||
- Corps optionnel : `{"date": "2026-04-29"}` ou `{"backfill": "2026-04-20"}`
|
||||
|
||||
### 5.3 `GET /api/v1/ml/feedback/stats`
|
||||
|
||||
Retourne les statistiques agrégées par stratégie :
|
||||
|
||||
```json
|
||||
{
|
||||
"stats": [
|
||||
{
|
||||
"source_reco": "xgboost_sg",
|
||||
"nb_paris": 42,
|
||||
"nb_gagnes": 12,
|
||||
"win_rate_pct": 28.6,
|
||||
"mise_totale": 42.0,
|
||||
"gain_total": 51.3,
|
||||
"roi_pct": 22.1
|
||||
}
|
||||
],
|
||||
"last_run": "2026-04-29T18:30:00"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 Blueprint `api_v1/routes/ml_feedback.py`
|
||||
|
||||
Enregistré dans `api_v1/__init__.py` avec :
|
||||
```python
|
||||
from .routes.ml_feedback import ml_feedback_bp
|
||||
app.register_blueprint(ml_feedback_bp)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Jointures de données — Schéma complet
|
||||
|
||||
```
|
||||
ml_predictions_cache
|
||||
date, num_reunion, num_course, horse_name, horse_number
|
||||
ml_score, recommendation, is_value_bet
|
||||
race_label, model_version
|
||||
│
|
||||
│ [ml_feedback_saas.py]
|
||||
▼
|
||||
paris
|
||||
date_course, race_label, numero1
|
||||
source_reco (= stratégie XGBoost)
|
||||
model_source (= xgboost_v1)
|
||||
type_pari, mise, statut, gain
|
||||
│
|
||||
├──── JOIN pmu_partants ──── date_programme + num_reunion + num_course + num_pmu
|
||||
│ ordre_arrivee (résultat réel)
|
||||
│
|
||||
└──── JOIN pmu_rapports ──── date_programme + num_reunion + num_course + type_pari
|
||||
dividende_euro (gain réel calculé)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Dashboard SaaS — Section ROI
|
||||
|
||||
Le dashboard `dashboard_saas.html` intègre une section **"Performance & ROI"** (implémentée dans HRT-94) :
|
||||
|
||||
- Graphique ROI par `model_source` (histogramme Chart.js)
|
||||
- Évolution ROI dans le temps (line chart, 7j/30j/90j)
|
||||
- Tableau : `model_source | nb paris | mise | gain | ROI% | win_rate%`
|
||||
- Filtre dropdown par stratégie
|
||||
- Gating plan : Free = 1 stratégie, Premium/Pro = complet
|
||||
|
||||
Appel API dashboard :
|
||||
```javascript
|
||||
fetch('/api/v1/roi/by-model?days=30')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Points d'attention & limites
|
||||
|
||||
1. **Données ML limitées** : actuellement 1 000 prédictions sur 2 dates (2026-04-24 et 2026-04-25). La pertinence du ROI augmentera avec le volume de données.
|
||||
|
||||
2. **Pas de paris XGBoost actifs** : la table `paris` contient des paris `manual`, `scoring_v2`, `canalturf` mais pas encore de paris `xgboost_*`. HRT-93 (ml_feedback_saas.py) doit être complété et exécuté.
|
||||
|
||||
3. **Modèle unique** : `model_version = 'xgboost_v1'`. L'évolution vers des versions de modèle multiples est prévue dans la roadmap.
|
||||
|
||||
4. **Sync turf_scraper → turf_saas** : le mécanisme de synchronisation de `ml_predictions_cache` n'est pas encore documenté formellement. À documenter dans une prochaine Note Intelligence.
|
||||
|
||||
5. **update_paris_results.py** : script de mise à jour des statuts paris (`EN_ATTENTE → GAGNE/PERDU`) à partir de `pmu_rapports` — dépendance critique pour le calcul du ROI réel.
|
||||
|
||||
---
|
||||
|
||||
## 9. Fichiers clés
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---|---|
|
||||
| `turf_saas.db` | Base de données principale SaaS |
|
||||
| `ml_feedback_saas.py` | Feedback loop ML (à créer — HRT-93) |
|
||||
| `api_v1/routes/roi.py` | Routes API ROI (à créer — HRT-92) |
|
||||
| `api_v1/routes/ml_feedback.py` | Routes API feedback (à créer — HRT-93) |
|
||||
| `api_v1/__init__.py` | Enregistrement des blueprints |
|
||||
| `dashboard_saas.html` | Dashboard SaaS avec section ROI |
|
||||
| `update_paris_results.py` | MAJ statuts paris depuis résultats PMU |
|
||||
| `scoring_v2.py` | Scoring engine (stratégie scoring_v2) |
|
||||
|
||||
---
|
||||
|
||||
## 10. Références tickets
|
||||
|
||||
| Ticket | Description | Statut |
|
||||
|---|---|---|
|
||||
| HRT-90 | Orchestration ML SaaS (parent) | blocked |
|
||||
| HRT-92 | Backend: API ROI par modèle | in_progress |
|
||||
| HRT-93 | ML feedback loop ml_feedback_saas | in_progress |
|
||||
| HRT-94 | Frontend: Dashboard ROI | in_progress |
|
||||
| HRT-95 | QA: Tests end-to-end ML + ROI | in_progress |
|
||||
| HRT-96 | Note Intelligence ML + documentation (ce ticket) | in_progress |
|
||||
383
account.html
Normal file
383
account.html
Normal file
@@ -0,0 +1,383 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Mon Compte — Turf IA</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--green: #00c853; --green-d: #009624; --blue: #1e88e5;
|
||||
--dark: #0d1117; --dark2: #161b22; --dark3: #21262d;
|
||||
--text: #e6edf3; --muted: #8b949e; --border: #30363d;
|
||||
--radius: 10px; --error: #f85149; --gold: #ffd600;
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); min-height: 100vh; display: flex; flex-direction: column; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
nav { display: flex; align-items: center; justify-content: space-between; padding: 14px 5%; border-bottom: 1px solid var(--border); }
|
||||
.nav-logo { font-weight: 700; font-size: 1.1rem; }
|
||||
.nav-back { color: var(--muted); font-size: .9rem; }
|
||||
.nav-back:hover { color: var(--text); }
|
||||
|
||||
main { flex: 1; padding: 40px 5%; max-width: 900px; margin: 0 auto; width: 100%; }
|
||||
h1 { font-size: 1.6rem; font-weight: 800; margin-bottom: 28px; }
|
||||
|
||||
/* TABS */
|
||||
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 28px; }
|
||||
.tab-btn {
|
||||
padding: 10px 18px; border: none; background: transparent; color: var(--muted);
|
||||
font-size: .9rem; font-weight: 600; cursor: pointer; border-bottom: 2px solid transparent;
|
||||
transition: all .2s; margin-bottom: -1px;
|
||||
}
|
||||
.tab-btn:hover { color: var(--text); }
|
||||
.tab-btn.active { color: var(--green); border-bottom-color: var(--green); }
|
||||
.tab-panel { display: none; }
|
||||
.tab-panel.active { display: block; }
|
||||
|
||||
/* CARDS */
|
||||
.card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px; margin-bottom: 20px; }
|
||||
.card-title { font-size: 1rem; font-weight: 700; margin-bottom: 18px; padding-bottom: 12px; border-bottom: 1px solid var(--border); }
|
||||
.form-group { margin-bottom: 16px; }
|
||||
label { display: block; font-size: .83rem; font-weight: 600; color: var(--muted); margin-bottom: 6px; }
|
||||
input {
|
||||
width: 100%; padding: 10px 14px; background: var(--dark3);
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
color: var(--text); font-size: .9rem; outline: none; transition: border-color .2s;
|
||||
}
|
||||
input:focus { border-color: var(--green); }
|
||||
input:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 9px 20px; border-radius: 8px; font-size: .88rem; font-weight: 600; cursor: pointer; border: none; transition: all .2s; }
|
||||
.btn-primary { background: var(--green); color: #000; }
|
||||
.btn-primary:hover { background: var(--green-d); }
|
||||
.btn-primary:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||
.btn-ghost:hover { border-color: var(--muted); }
|
||||
.btn-danger { background: transparent; border: 1px solid var(--error); color: var(--error); }
|
||||
.btn-danger:hover { background: rgba(248,81,73,.1); }
|
||||
.btn-upgrade { background: linear-gradient(135deg, var(--gold), #ff6d00); color: #000; }
|
||||
|
||||
/* PLAN CARDS */
|
||||
.plan-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin-bottom: 24px; }
|
||||
.plan-card { background: var(--dark3); border: 2px solid var(--border); border-radius: var(--radius); padding: 20px; position: relative; transition: border-color .2s; }
|
||||
.plan-card.current { border-color: var(--green); }
|
||||
.plan-current-badge { position: absolute; top: -10px; left: 16px; background: var(--green); color: #000; padding: 2px 10px; border-radius: 10px; font-size: .7rem; font-weight: 700; }
|
||||
.plan-name { font-weight: 800; font-size: 1rem; margin-bottom: 6px; }
|
||||
.plan-price { font-size: 1.5rem; font-weight: 800; color: var(--green); }
|
||||
.plan-price span { font-size: .85rem; font-weight: 400; color: var(--muted); }
|
||||
.plan-features-mini { list-style: none; margin-top: 12px; }
|
||||
.plan-features-mini li { font-size: .8rem; color: var(--muted); padding: 3px 0; }
|
||||
.plan-features-mini li::before { content: "✓ "; color: var(--green); }
|
||||
.plan-card .btn { width: 100%; justify-content: center; margin-top: 14px; font-size: .82rem; padding: 8px; }
|
||||
|
||||
/* ALERT */
|
||||
.alert { padding: 12px 16px; border-radius: 8px; font-size: .88rem; margin-bottom: 14px; display: none; }
|
||||
.alert.show { display: block; }
|
||||
.alert-success { background: rgba(0,200,83,.1); border: 1px solid rgba(0,200,83,.3); color: var(--green); }
|
||||
.alert-error { background: rgba(248,81,73,.1); border: 1px solid rgba(248,81,73,.3); color: var(--error); }
|
||||
|
||||
/* DANGER ZONE */
|
||||
.danger-card { border-color: rgba(248,81,73,.3); }
|
||||
|
||||
/* Toast */
|
||||
#toast { position: fixed; bottom: 24px; right: 24px; z-index: 999; padding: 12px 20px; border-radius: 10px; font-size: .88rem; font-weight: 600; transform: translateY(60px); opacity: 0; transition: all .3s; pointer-events: none; }
|
||||
#toast.show { transform: translateY(0); opacity: 1; }
|
||||
#toast.success { background: var(--green); color: #000; }
|
||||
#toast.error { background: var(--error); color: #fff; }
|
||||
|
||||
@media (max-width: 600px) { .form-row { grid-template-columns: 1fr; } main { padding: 20px 4%; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="/" class="nav-logo">🏇 Turf IA</a>
|
||||
<a href="/dashboard" class="nav-back">← Retour au dashboard</a>
|
||||
</nav>
|
||||
<main>
|
||||
<h1>⚙️ Mon compte</h1>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" data-tab="profile">Profil</button>
|
||||
<button class="tab-btn" data-tab="security">Sécurité</button>
|
||||
<button class="tab-btn" data-tab="upgrade">Mon plan</button>
|
||||
<button class="tab-btn" data-tab="notifications">Notifications</button>
|
||||
</div>
|
||||
|
||||
<!-- PROFIL -->
|
||||
<div class="tab-panel active" id="tab-profile">
|
||||
<div class="card">
|
||||
<div class="card-title">Informations personnelles</div>
|
||||
<div class="alert alert-success" id="profile-success">Profil mis à jour avec succès.</div>
|
||||
<div class="alert alert-error" id="profile-error"></div>
|
||||
<form id="profile-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Prénom</label><input type="text" id="p-firstname" placeholder="Jean"></div>
|
||||
<div class="form-group"><label>Nom</label><input type="text" id="p-lastname" placeholder="Dupont"></div>
|
||||
</div>
|
||||
<div class="form-group"><label>Adresse email</label><input type="email" id="p-email" placeholder="vous@exemple.fr"></div>
|
||||
<button type="submit" class="btn btn-primary" id="profile-btn">Enregistrer les modifications</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title">Informations de compte</div>
|
||||
<div class="form-group"><label>Plan actuel</label><input type="text" id="p-plan-display" disabled></div>
|
||||
<div class="form-group"><label>Membre depuis</label><input type="text" id="p-created" disabled></div>
|
||||
<div class="form-group"><label>Identifiant utilisateur</label><input type="text" id="p-id" disabled></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECURITE -->
|
||||
<div class="tab-panel" id="tab-security">
|
||||
<div class="card">
|
||||
<div class="card-title">Changer le mot de passe</div>
|
||||
<div class="alert alert-success" id="pwd-success">Mot de passe mis à jour.</div>
|
||||
<div class="alert alert-error" id="pwd-error"></div>
|
||||
<form id="password-form">
|
||||
<div class="form-group"><label>Mot de passe actuel</label><input type="password" id="s-current-pwd" placeholder="••••••••" autocomplete="current-password" required></div>
|
||||
<div class="form-group"><label>Nouveau mot de passe</label><input type="password" id="s-new-pwd" placeholder="8 caractères minimum" autocomplete="new-password" required></div>
|
||||
<div class="form-group"><label>Confirmer le nouveau mot de passe</label><input type="password" id="s-confirm-pwd" placeholder="••••••••" autocomplete="new-password" required></div>
|
||||
<button type="submit" class="btn btn-primary" id="pwd-btn">Mettre à jour le mot de passe</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card danger-card">
|
||||
<div class="card-title">Zone dangereuse</div>
|
||||
<p style="font-size:.9rem;color:var(--muted);margin-bottom:16px">La suppression de votre compte est irréversible. Toutes vos données seront perdues.</p>
|
||||
<button class="btn btn-danger" id="delete-account-btn">Supprimer mon compte</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- UPGRADE / PLAN -->
|
||||
<div class="tab-panel" id="tab-upgrade">
|
||||
<div class="card">
|
||||
<div class="card-title">Votre plan actuel</div>
|
||||
<div class="plan-grid">
|
||||
<div class="plan-card" id="plan-card-free">
|
||||
<div class="plan-name">Free</div>
|
||||
<div class="plan-price">0€ <span>/mois</span></div>
|
||||
<ul class="plan-features-mini">
|
||||
<li>1 course complète/jour</li>
|
||||
<li>Aperçu Top-3</li>
|
||||
</ul>
|
||||
<button class="btn btn-ghost" id="select-free-btn">Plan actuel</button>
|
||||
</div>
|
||||
<div class="plan-card" id="plan-card-premium">
|
||||
<div class="plan-name">Premium ⭐</div>
|
||||
<div class="plan-price">9,90€ <span>/mois</span></div>
|
||||
<ul class="plan-features-mini">
|
||||
<li>Toutes les courses</li>
|
||||
<li>Alertes Telegram</li>
|
||||
<li>Value bets</li>
|
||||
<li>Historique 90j</li>
|
||||
</ul>
|
||||
<button class="btn btn-upgrade" id="select-premium-btn">Choisir Premium</button>
|
||||
</div>
|
||||
<div class="plan-card" id="plan-card-pro">
|
||||
<div class="plan-name">Pro 🚀</div>
|
||||
<div class="plan-price">24,90€ <span>/mois</span></div>
|
||||
<ul class="plan-features-mini">
|
||||
<li>Tout Premium</li>
|
||||
<li>Export CSV</li>
|
||||
<li>API REST</li>
|
||||
<li>Support prioritaire</li>
|
||||
</ul>
|
||||
<button class="btn btn-ghost" id="select-pro-btn">Choisir Pro</button>
|
||||
</div>
|
||||
</div>
|
||||
<p style="font-size:.82rem;color:var(--muted)">💳 Paiement sécurisé via Stripe (disponible dans le Sprint 5-6). Pour l'instant, contactez-nous pour activer un plan payant.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NOTIFICATIONS -->
|
||||
<div class="tab-panel" id="tab-notifications">
|
||||
<div class="card">
|
||||
<div class="card-title">Alertes Telegram</div>
|
||||
<div class="alert alert-success" id="notif-success">Préférences enregistrées.</div>
|
||||
<form id="notif-form">
|
||||
<div class="form-group">
|
||||
<label>Chat ID Telegram</label>
|
||||
<input type="text" id="n-telegram-id" placeholder="Votre Chat ID (ex: 123456789)">
|
||||
<p style="font-size:.78rem;color:var(--muted);margin-top:5px">Envoyez /start à @TurfIABot pour obtenir votre Chat ID.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<p style="font-size:.88rem;font-weight:600;margin-bottom:10px;color:var(--muted)">Recevoir des alertes pour :</p>
|
||||
<label style="display:flex;gap:10px;align-items:center;margin-bottom:10px;cursor:pointer;font-size:.9rem">
|
||||
<input type="checkbox" id="n-alert-vb" style="width:auto;accent-color:var(--green)"> Value bets identifiés
|
||||
</label>
|
||||
<label style="display:flex;gap:10px;align-items:center;margin-bottom:10px;cursor:pointer;font-size:.9rem">
|
||||
<input type="checkbox" id="n-alert-top1" style="width:auto;accent-color:var(--green)"> Favori IA Top-1
|
||||
</label>
|
||||
<label style="display:flex;gap:10px;align-items:center;cursor:pointer;font-size:.9rem">
|
||||
<input type="checkbox" id="n-alert-quinte" style="width:auto;accent-color:var(--green)"> Quinté+ uniquement
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Enregistrer les préférences</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div id="toast"></div>
|
||||
|
||||
<script>
|
||||
const API = '/api/v1';
|
||||
function getToken() { return localStorage.getItem('turf_token'); }
|
||||
|
||||
async function fetchJson(url, opts = {}) {
|
||||
const token = getToken();
|
||||
const res = await fetch(url, {
|
||||
...opts,
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) }
|
||||
});
|
||||
if (res.status === 401) { location.href = '/login'; return null; }
|
||||
return res.json().catch(() => null);
|
||||
}
|
||||
|
||||
function showToast(msg, type = 'success') {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg; t.className = `show ${type}`;
|
||||
setTimeout(() => t.className = '', 3500);
|
||||
}
|
||||
|
||||
function showAlert(id, show = true) {
|
||||
document.getElementById(id)?.classList.toggle('show', show);
|
||||
}
|
||||
function setAlertMsg(id, msg, show = true) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) { el.textContent = msg; el.classList.toggle('show', show); }
|
||||
}
|
||||
|
||||
// TABS
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById(`tab-${btn.dataset.tab}`)?.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// URL param: ?tab=upgrade
|
||||
(function() {
|
||||
const tab = new URLSearchParams(location.search).get('tab');
|
||||
if (tab) {
|
||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
|
||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.toggle('active', p.id === `tab-${tab}`));
|
||||
}
|
||||
})();
|
||||
|
||||
// Load user
|
||||
async function loadUser() {
|
||||
if (!getToken()) { location.href = '/login'; return; }
|
||||
const data = await fetchJson(`${API}/auth/me`);
|
||||
if (!data) return;
|
||||
const user = data.user || data;
|
||||
document.getElementById('p-firstname').value = user.firstname || '';
|
||||
document.getElementById('p-lastname').value = user.lastname || '';
|
||||
document.getElementById('p-email').value = user.email || '';
|
||||
document.getElementById('p-plan-display').value = { free: 'Free', premium: 'Premium', pro: 'Pro' }[user.plan] || 'Free';
|
||||
document.getElementById('p-created').value = user.created_at ? new Date(user.created_at).toLocaleDateString('fr-FR') : '—';
|
||||
document.getElementById('p-id').value = user.id || '—';
|
||||
document.getElementById('n-telegram-id').value = user.telegram_chat_id || '';
|
||||
document.getElementById('n-alert-vb').checked = user.alert_value_bets !== false;
|
||||
document.getElementById('n-alert-top1').checked = user.alert_top1 !== false;
|
||||
document.getElementById('n-alert-quinte').checked = !!user.alert_quinte_only;
|
||||
setPlanCards(user.plan || 'free');
|
||||
localStorage.setItem('turf_user', JSON.stringify(user));
|
||||
}
|
||||
|
||||
function setPlanCards(plan) {
|
||||
['free','premium','pro'].forEach(p => {
|
||||
const card = document.getElementById(`plan-card-${p}`);
|
||||
if (!card) return;
|
||||
card.classList.toggle('current', p === plan);
|
||||
const existing = card.querySelector('.plan-current-badge');
|
||||
if (p === plan && !existing) {
|
||||
const badge = document.createElement('div');
|
||||
badge.className = 'plan-current-badge'; badge.textContent = '✓ Actuel';
|
||||
card.prepend(badge);
|
||||
} else if (p !== plan && existing) existing.remove();
|
||||
});
|
||||
}
|
||||
|
||||
// Profile form
|
||||
document.getElementById('profile-form').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
showAlert('profile-success', false); showAlert('profile-error', false);
|
||||
const btn = document.getElementById('profile-btn');
|
||||
btn.disabled = true; btn.textContent = 'Enregistrement…';
|
||||
const res = await fetchJson(`${API}/auth/update-profile`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
firstname: document.getElementById('p-firstname').value.trim(),
|
||||
lastname: document.getElementById('p-lastname').value.trim(),
|
||||
email: document.getElementById('p-email').value.trim()
|
||||
})
|
||||
});
|
||||
btn.disabled = false; btn.textContent = 'Enregistrer les modifications';
|
||||
if (res && res.ok !== false) { showAlert('profile-success'); showToast('Profil mis à jour.'); }
|
||||
else setAlertMsg('profile-error', res?.error || 'Erreur lors de la mise à jour.');
|
||||
});
|
||||
|
||||
// Password form
|
||||
document.getElementById('password-form').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
showAlert('pwd-success', false); showAlert('pwd-error', false);
|
||||
const np = document.getElementById('s-new-pwd').value;
|
||||
const cp = document.getElementById('s-confirm-pwd').value;
|
||||
if (np !== cp) { setAlertMsg('pwd-error', 'Les mots de passe ne correspondent pas.'); return; }
|
||||
if (np.length < 8) { setAlertMsg('pwd-error', 'Minimum 8 caractères.'); return; }
|
||||
const btn = document.getElementById('pwd-btn');
|
||||
btn.disabled = true; btn.textContent = 'Mise à jour…';
|
||||
const res = await fetchJson(`${API}/auth/change-password`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ current_password: document.getElementById('s-current-pwd').value, new_password: np })
|
||||
});
|
||||
btn.disabled = false; btn.textContent = 'Mettre à jour le mot de passe';
|
||||
if (res && res.ok !== false) { showAlert('pwd-success'); showToast('Mot de passe mis à jour.'); document.getElementById('password-form').reset(); }
|
||||
else setAlertMsg('pwd-error', res?.error || 'Mot de passe actuel incorrect.');
|
||||
});
|
||||
|
||||
// Notifications form
|
||||
document.getElementById('notif-form').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
showAlert('notif-success', false);
|
||||
const res = await fetchJson(`${API}/auth/update-preferences`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
telegram_chat_id: document.getElementById('n-telegram-id').value.trim(),
|
||||
alert_value_bets: document.getElementById('n-alert-vb').checked,
|
||||
alert_top1: document.getElementById('n-alert-top1').checked,
|
||||
alert_quinte_only: document.getElementById('n-alert-quinte').checked
|
||||
})
|
||||
});
|
||||
if (res && res.ok !== false) { showAlert('notif-success'); showToast('Préférences enregistrées.'); }
|
||||
else showToast('Erreur lors de la sauvegarde.', 'error');
|
||||
});
|
||||
|
||||
// Plan selection (placeholder until Stripe Sprint 5-6)
|
||||
['free','premium','pro'].forEach(p => {
|
||||
document.getElementById(`select-${p}-btn`)?.addEventListener('click', () => {
|
||||
if (p === 'free') {
|
||||
showToast('Vous êtes déjà sur le plan Free.', 'info');
|
||||
} else {
|
||||
showToast('Paiement Stripe disponible dans le Sprint 5-6. Contactez-nous pour activer ce plan.', 'info');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Delete account
|
||||
document.getElementById('delete-account-btn').addEventListener('click', () => {
|
||||
if (confirm('⚠️ Êtes-vous sûr de vouloir supprimer votre compte ? Cette action est irréversible.')) {
|
||||
if (confirm('Confirmez la suppression définitive de votre compte Turf IA.')) {
|
||||
fetchJson(`${API}/auth/delete-account`, { method: 'DELETE' }).then(() => {
|
||||
localStorage.clear();
|
||||
location.href = '/';
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
loadUser();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
48
alembic.ini
Normal file
48
alembic.ini
Normal file
@@ -0,0 +1,48 @@
|
||||
# Alembic configuration for Turf SaaS
|
||||
# https://alembic.sqlalchemy.org/en/latest/
|
||||
|
||||
[alembic]
|
||||
# Path to migration scripts
|
||||
script_location = migrations
|
||||
|
||||
# Template used to generate new migration files
|
||||
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# Connection string — uses DATABASE_URL env var
|
||||
sqlalchemy.url = postgresql://%(POSTGRES_USER)s:%(POSTGRES_PASSWORD)s@%(POSTGRES_HOST)s:%(POSTGRES_PORT)s/%(POSTGRES_DB)s
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
57
api_tokens_db.py
Normal file
57
api_tokens_db.py
Normal file
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
api_tokens_db.py — DB migration for personal API tokens + user webhooks
|
||||
HRT-80: API Token personnel + Webhook alertes (Pro)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
logger = logging.getLogger("turf_saas.api_tokens_db")
|
||||
|
||||
|
||||
def get_db() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def migrate_api_tokens_tables() -> None:
|
||||
"""Idempotent migration: create user_api_tokens and user_webhooks."""
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
c.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS user_api_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
token_prefix TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
last_used_at DATETIME,
|
||||
revoked INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON user_api_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON user_api_tokens(token_hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_webhooks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL UNIQUE,
|
||||
url TEXT NOT NULL,
|
||||
secret TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhooks_user ON user_webhooks(user_id);
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(
|
||||
"[api_tokens_db] Tables user_api_tokens + user_webhooks created/verified."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
migrate_api_tokens_tables()
|
||||
print("[api_tokens_db] Migration complete.")
|
||||
@@ -3,6 +3,9 @@
|
||||
API v1 Blueprint package — Turf SaaS
|
||||
Sprint 3-4: HRT-29 — Refacto API /v1/
|
||||
Sprint 5-6: HRT-31 — Billing Stripe
|
||||
HRT-79: Alertes Telegram configurables (user blueprint)
|
||||
HRT-80: API Token personnel + Webhook alertes (Pro)
|
||||
HRT-82: Multi-compte / Organisation Pro (max 5 users)
|
||||
|
||||
Registers sub-blueprints:
|
||||
/api/v1/health — public health-check
|
||||
@@ -13,6 +16,11 @@ Registers sub-blueprints:
|
||||
/api/v1/export/ — export CSV (pro)
|
||||
/api/v1/metrics — métriques perf ML (premium+)
|
||||
/api/v1/billing/ — Stripe checkout, portal, webhook, status
|
||||
/api/v1/user/ — config utilisateur, alertes Telegram (premium+)
|
||||
/api/v1/user/api-token — Personal API token (Pro)
|
||||
/api/v1/user/webhook — Webhook config (Pro)
|
||||
/api/v1/history — historique préd. ML (Free:7j, Premium:90j, Pro:illimité)
|
||||
/api/v1/org/ — organisations Pro (multi-compte, max 5 users)
|
||||
/api/v1/docs — Swagger UI (via flasgger, registered on app)
|
||||
"""
|
||||
|
||||
@@ -26,6 +34,10 @@ from .routes.backtest import backtest_bp
|
||||
from .routes.export import export_bp
|
||||
from .routes.metrics import metrics_bp
|
||||
from .routes.billing import billing_bp
|
||||
from .routes.user import user_bp
|
||||
from .routes.user_tokens import user_tokens_bp
|
||||
from .routes.history import history_bp
|
||||
from .routes.org import org_bp
|
||||
|
||||
# Master blueprint that aggregates all sub-routes under /api/v1
|
||||
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
|
||||
@@ -41,3 +53,7 @@ def register_api_v1(app):
|
||||
app.register_blueprint(export_bp)
|
||||
app.register_blueprint(metrics_bp)
|
||||
app.register_blueprint(billing_bp)
|
||||
app.register_blueprint(user_bp)
|
||||
app.register_blueprint(user_tokens_bp)
|
||||
app.register_blueprint(history_bp)
|
||||
app.register_blueprint(org_bp)
|
||||
|
||||
@@ -24,9 +24,9 @@ import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import stripe
|
||||
from flask import Blueprint, g, jsonify, request
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from auth import jwt_required_middleware
|
||||
from saas_auth import require_auth as jwt_required_middleware
|
||||
from billing_db import get_db, migrate_billing_tables
|
||||
|
||||
logger = logging.getLogger("turf_saas.billing")
|
||||
@@ -73,18 +73,18 @@ def _sget(obj, key, default=None):
|
||||
return default
|
||||
|
||||
|
||||
def _get_active_subscription(db, user_id: int):
|
||||
def _get_active_subscription(db, user_id):
|
||||
"""Return the most recent active subscription row for a user."""
|
||||
return db.execute(
|
||||
"""SELECT * FROM subscriptions
|
||||
"""SELECT * FROM saas_subscriptions
|
||||
WHERE user_id = ?
|
||||
ORDER BY start_date DESC
|
||||
LIMIT 1""",
|
||||
(user_id,),
|
||||
(str(user_id),),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _upsert_subscription(db, user_id: int, **fields):
|
||||
def _upsert_subscription(db, user_id, **fields):
|
||||
"""
|
||||
Update existing subscription or insert a new one.
|
||||
fields: plan, stripe_customer_id, stripe_subscription_id,
|
||||
@@ -95,19 +95,19 @@ def _upsert_subscription(db, user_id: int, **fields):
|
||||
# Build SET clause dynamically from provided fields
|
||||
set_parts = ", ".join(f"{k} = ?" for k in fields)
|
||||
values = list(fields.values()) + [existing["id"]]
|
||||
db.execute(f"UPDATE subscriptions SET {set_parts} WHERE id = ?", values)
|
||||
db.execute(f"UPDATE saas_subscriptions SET {set_parts} WHERE id = ?", values)
|
||||
else:
|
||||
cols = ", ".join(["user_id"] + list(fields.keys()))
|
||||
placeholders = ", ".join(["?"] * (1 + len(fields)))
|
||||
values = [user_id] + list(fields.values())
|
||||
values = [str(user_id)] + list(fields.values())
|
||||
db.execute(
|
||||
f"INSERT INTO subscriptions ({cols}) VALUES ({placeholders})", values
|
||||
f"INSERT INTO saas_subscriptions ({cols}) VALUES ({placeholders})", values
|
||||
)
|
||||
|
||||
|
||||
def _update_user_plan(db, user_id: int, plan: str):
|
||||
"""Sync users.plan field to match active subscription."""
|
||||
db.execute("UPDATE users SET plan = ? WHERE id = ?", (plan, user_id))
|
||||
def _update_user_plan(db, user_id, plan: str):
|
||||
"""Sync saas_users.plan field to match active subscription."""
|
||||
db.execute("UPDATE saas_users SET plan = ? WHERE id = ?", (plan, str(user_id)))
|
||||
|
||||
|
||||
def _get_or_create_stripe_customer(user, db) -> str:
|
||||
@@ -198,7 +198,7 @@ def create_checkout():
|
||||
if not price_id:
|
||||
return jsonify({"error": f"Prix Stripe non configuré pour le plan {plan}"}), 503
|
||||
|
||||
user = g.current_user
|
||||
user = request.current_user
|
||||
if user["plan"] == plan:
|
||||
return jsonify({"error": f"Vous êtes déjà sur le plan {plan}"}), 400
|
||||
|
||||
@@ -263,7 +263,7 @@ def create_portal():
|
||||
if not stripe.api_key:
|
||||
return jsonify({"error": "Stripe non configuré"}), 503
|
||||
|
||||
user = g.current_user
|
||||
user = request.current_user
|
||||
db = get_db()
|
||||
try:
|
||||
sub = _get_active_subscription(db, user["id"])
|
||||
@@ -309,7 +309,7 @@ def billing_status():
|
||||
200:
|
||||
description: Subscription status
|
||||
"""
|
||||
user = g.current_user
|
||||
user = request.current_user
|
||||
db = get_db()
|
||||
try:
|
||||
sub = _get_active_subscription(db, user["id"])
|
||||
@@ -428,7 +428,7 @@ def stripe_webhook():
|
||||
def _resolve_user_from_customer(db, customer_id: str):
|
||||
"""Look up user_id via subscriptions.stripe_customer_id."""
|
||||
row = db.execute(
|
||||
"SELECT user_id FROM subscriptions WHERE stripe_customer_id = ? LIMIT 1",
|
||||
"SELECT user_id FROM saas_subscriptions WHERE stripe_customer_id = ? LIMIT 1",
|
||||
(customer_id,),
|
||||
).fetchone()
|
||||
if row:
|
||||
@@ -465,7 +465,7 @@ def _handle_checkout_completed(db, event):
|
||||
user_id = _sget(metadata, "user_id")
|
||||
|
||||
if user_id:
|
||||
user_id = int(user_id)
|
||||
user_id = str(user_id)
|
||||
else:
|
||||
user_id = _resolve_user_from_customer(db, customer_id)
|
||||
|
||||
@@ -531,7 +531,7 @@ def _handle_subscription_updated(db, event):
|
||||
meta = _sget(sub_obj, "metadata") or {}
|
||||
meta_uid = _sget(meta, "user_id")
|
||||
if meta_uid:
|
||||
user_id = int(meta_uid)
|
||||
user_id = str(meta_uid)
|
||||
|
||||
if not user_id:
|
||||
logger.error(
|
||||
@@ -565,7 +565,7 @@ def _handle_subscription_deleted(db, event):
|
||||
meta = _sget(sub_obj, "metadata") or {}
|
||||
meta_uid = _sget(meta, "user_id")
|
||||
if meta_uid:
|
||||
user_id = int(meta_uid)
|
||||
user_id = str(meta_uid)
|
||||
|
||||
if not user_id:
|
||||
logger.error(
|
||||
|
||||
212
api_v1/routes/history.py
Normal file
212
api_v1/routes/history.py
Normal file
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
History routes for API v1.
|
||||
|
||||
GET /api/v1/history — Historique des prédictions avec filtre date range,
|
||||
limité selon le plan (Free: 7j, Premium: 90j, Pro: illimité)
|
||||
|
||||
Ticket: HRT-81 — Historique limité/illimité selon plan (Free/Premium/Pro)
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, jsonify, request, g
|
||||
|
||||
from api_v1.utils import (
|
||||
get_db,
|
||||
table_exists,
|
||||
internal_error,
|
||||
bad_request,
|
||||
forbidden,
|
||||
get_pagination_params,
|
||||
paginate_query,
|
||||
)
|
||||
from auth import jwt_required_middleware
|
||||
|
||||
history_bp = Blueprint("v1_history", __name__, url_prefix="/api/v1/history")
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Plan limits (days of history accessible; None = unlimited)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
HISTORY_DAYS = {
|
||||
"free": 7,
|
||||
"premium": 90,
|
||||
"pro": None, # illimité
|
||||
}
|
||||
|
||||
# Fallback for unknown plans: treat like free
|
||||
_DEFAULT_LIMIT = 7
|
||||
|
||||
|
||||
def _get_plan_max_days(plan: str):
|
||||
"""Return the max history days allowed for the given plan, or default."""
|
||||
return HISTORY_DAYS.get(plan, _DEFAULT_LIMIT)
|
||||
|
||||
|
||||
def _parse_date(date_str: str, param_name: str):
|
||||
"""Parse YYYY-MM-DD date string, raise ValueError with context on failure."""
|
||||
try:
|
||||
return datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Paramètre '{param_name}' invalide : format attendu YYYY-MM-DD, reçu '{date_str}'"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GET /api/v1/history
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@history_bp.route("", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
def get_history():
|
||||
"""
|
||||
Historique des prédictions ML avec filtre date range
|
||||
---
|
||||
tags:
|
||||
- Historique
|
||||
summary: |
|
||||
Historique des prédictions sur une plage de dates.
|
||||
Limite selon le plan :
|
||||
- Free : 7 derniers jours
|
||||
- Premium : 90 derniers jours
|
||||
- Pro : illimité
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: start
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date de début au format YYYY-MM-DD (défaut : aujourd'hui - max_days du plan)
|
||||
- name: end
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date de fin au format YYYY-MM-DD (défaut : aujourd'hui)
|
||||
- name: limit
|
||||
in: query
|
||||
type: integer
|
||||
default: 50
|
||||
description: Nombre de résultats par page (max 500)
|
||||
- name: offset
|
||||
in: query
|
||||
type: integer
|
||||
default: 0
|
||||
responses:
|
||||
200:
|
||||
description: Historique des prédictions ML
|
||||
400:
|
||||
description: Paramètre de date invalide
|
||||
401:
|
||||
description: Token invalide ou manquant
|
||||
403:
|
||||
description: Plage de dates hors limite du plan — upgrade requis
|
||||
"""
|
||||
user = getattr(g, "current_user", None)
|
||||
if not user:
|
||||
return jsonify({"error": "Non authentifié"}), 401
|
||||
|
||||
plan = user.get("plan", "free")
|
||||
today = datetime.now().date()
|
||||
max_days = _get_plan_max_days(plan)
|
||||
|
||||
# ── Parse end date ────────────────────────────────────────
|
||||
end_str = request.args.get("end", today.isoformat())
|
||||
try:
|
||||
end_date = _parse_date(end_str, "end")
|
||||
except ValueError as exc:
|
||||
return bad_request(str(exc))
|
||||
|
||||
# ── Parse start date ─────────────────────────────────────
|
||||
if max_days is not None:
|
||||
default_start = today - timedelta(days=max_days - 1)
|
||||
else:
|
||||
# Pro: default to 30 days back when no start provided
|
||||
default_start = today - timedelta(days=29)
|
||||
|
||||
start_str = request.args.get("start", default_start.isoformat())
|
||||
try:
|
||||
start_date = _parse_date(start_str, "start")
|
||||
except ValueError as exc:
|
||||
return bad_request(str(exc))
|
||||
|
||||
# ── Validate ordering ─────────────────────────────────────
|
||||
if start_date > end_date:
|
||||
return bad_request(
|
||||
f"'start' ({start_str}) ne peut pas être postérieur à 'end' ({end_str})"
|
||||
)
|
||||
|
||||
# ── Enforce plan window ───────────────────────────────────
|
||||
if max_days is not None:
|
||||
earliest_allowed = today - timedelta(days=max_days - 1)
|
||||
if start_date < earliest_allowed:
|
||||
return forbidden(
|
||||
message=(
|
||||
f"Historique limité à {max_days} jours pour le plan '{plan}'. "
|
||||
f"Date de début minimale autorisée : {earliest_allowed.isoformat()}. "
|
||||
f"Passez à un plan supérieur pour accéder à un historique plus long."
|
||||
),
|
||||
required_plans=["premium", "pro"] if plan == "free" else ["pro"],
|
||||
current_plan=plan,
|
||||
)
|
||||
|
||||
# ── Pagination ────────────────────────────────────────────
|
||||
limit, offset = get_pagination_params(default_limit=50, max_limit=500)
|
||||
|
||||
# ── Query ─────────────────────────────────────────────────
|
||||
conn = get_db()
|
||||
try:
|
||||
if not table_exists(conn, "ml_predictions_cache"):
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"plan": plan,
|
||||
"start": start_date.isoformat(),
|
||||
"end": end_date.isoformat(),
|
||||
"history": [],
|
||||
**paginate_query([], 0, limit, offset),
|
||||
}
|
||||
), 200
|
||||
|
||||
count_row = conn.execute(
|
||||
"""SELECT COUNT(*) as cnt
|
||||
FROM ml_predictions_cache
|
||||
WHERE date >= ? AND date <= ?""",
|
||||
(start_date.isoformat(), end_date.isoformat()),
|
||||
).fetchone()
|
||||
total = count_row["cnt"] if count_row else 0
|
||||
|
||||
sql = """
|
||||
SELECT
|
||||
id, date, horse_name, prob_top1, prob_top3,
|
||||
ml_score, race_label, hippodrome, heure, is_value_bet
|
||||
FROM ml_predictions_cache
|
||||
WHERE date >= ? AND date <= ?
|
||||
ORDER BY date DESC, ml_score DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
rows = conn.execute(
|
||||
sql,
|
||||
(start_date.isoformat(), end_date.isoformat(), limit, offset),
|
||||
).fetchall()
|
||||
|
||||
history = [dict(r) for r in rows]
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"plan": plan,
|
||||
"history_limit_days": max_days,
|
||||
"start": start_date.isoformat(),
|
||||
"end": end_date.isoformat(),
|
||||
"history": history,
|
||||
**paginate_query(history, total, limit, offset),
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as exc:
|
||||
return internal_error(str(exc))
|
||||
finally:
|
||||
conn.close()
|
||||
536
api_v1/routes/org.py
Normal file
536
api_v1/routes/org.py
Normal file
@@ -0,0 +1,536 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Org Blueprint — Multi-compte / Organisations Pro
|
||||
Sprint: HRT-82
|
||||
|
||||
Endpoints:
|
||||
POST /api/v1/org — créer une organisation (Pro only, 1 max par owner)
|
||||
GET /api/v1/org — infos org courante
|
||||
DELETE /api/v1/org — supprimer l'org (owner only)
|
||||
POST /api/v1/org/invite — inviter un membre par email (max 5 totaux)
|
||||
GET /api/v1/org/members — liste des membres
|
||||
DELETE /api/v1/org/members/<user_id> — retirer un membre (owner only)
|
||||
|
||||
Plan enforcement:
|
||||
- Toutes les routes nécessitent plan=pro via plan_required('pro')
|
||||
- Limite : 1 org par owner, 5 membres max (owner inclus)
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from saas_auth import require_auth as jwt_required_middleware
|
||||
from org_db import get_db, migrate_org_tables
|
||||
|
||||
logger = logging.getLogger("turf_saas.org")
|
||||
|
||||
org_bp = Blueprint("org", __name__, url_prefix="/api/v1/org")
|
||||
|
||||
MAX_MEMBERS = 5 # max membres totaux owner inclus
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Decorator: plan Pro requis
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _require_pro(fn):
|
||||
"""Vérifie que l'utilisateur courant est sur le plan 'pro'."""
|
||||
from functools import wraps
|
||||
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
user = getattr(request, "current_user", None)
|
||||
if not user:
|
||||
return jsonify({"error": "Non authentifié"}), 401
|
||||
if user.get("plan") != "pro":
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Plan insuffisant",
|
||||
"required": "pro",
|
||||
"current_plan": user.get("plan", "free"),
|
||||
"upgrade_url": "/api/v1/billing/checkout",
|
||||
}
|
||||
), 403
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Helpers DB
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_org_by_owner(db, owner_id: str):
|
||||
return db.execute(
|
||||
"SELECT * FROM organizations WHERE owner_id = ?", (owner_id,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _get_org_by_id(db, org_id: str):
|
||||
return db.execute("SELECT * FROM organizations WHERE id = ?", (org_id,)).fetchone()
|
||||
|
||||
|
||||
def _get_member_org(db, user_id: str):
|
||||
"""Retourne l'org dont user_id est membre (owner ou member)."""
|
||||
row = db.execute(
|
||||
"""SELECT o.* FROM organizations o
|
||||
JOIN org_members m ON m.org_id = o.id
|
||||
WHERE m.user_id = ?
|
||||
LIMIT 1""",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
return row
|
||||
|
||||
|
||||
def _count_org_members(db, org_id: str) -> int:
|
||||
row = db.execute(
|
||||
"SELECT COUNT(*) AS cnt FROM org_members WHERE org_id = ?", (org_id,)
|
||||
).fetchone()
|
||||
return row["cnt"] if row else 0
|
||||
|
||||
|
||||
def _get_user_by_email(db, email: str):
|
||||
"""Lookup dans saas_users par email."""
|
||||
return db.execute(
|
||||
"SELECT * FROM saas_users WHERE email = ?", (email.lower().strip(),)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _org_to_dict(org) -> dict:
|
||||
return {
|
||||
"id": org["id"],
|
||||
"owner_id": org["owner_id"],
|
||||
"name": org["name"],
|
||||
"max_members": org["max_members"],
|
||||
"created_at": org["created_at"],
|
||||
}
|
||||
|
||||
|
||||
def _member_to_dict(m) -> dict:
|
||||
return {
|
||||
"id": m["id"],
|
||||
"org_id": m["org_id"],
|
||||
"user_id": m["user_id"],
|
||||
"role": m["role"],
|
||||
"invited_at": m["invited_at"],
|
||||
"joined_at": m["joined_at"],
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# POST /api/v1/org — créer une organisation
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@org_bp.route("", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
@_require_pro
|
||||
def create_org():
|
||||
"""
|
||||
Crée une organisation.
|
||||
---
|
||||
tags:
|
||||
- Organisation
|
||||
security:
|
||||
- Bearer: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [name]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Nom de l'organisation (1-100 caractères)
|
||||
responses:
|
||||
201:
|
||||
description: Organisation créée
|
||||
400:
|
||||
description: Paramètre manquant ou invalide
|
||||
403:
|
||||
description: Plan insuffisant
|
||||
409:
|
||||
description: L'utilisateur possède déjà une organisation
|
||||
"""
|
||||
user = request.current_user
|
||||
owner_id = user["id"]
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name or len(name) > 100:
|
||||
return jsonify({"error": "Le nom est requis (1-100 caractères)"}), 400
|
||||
|
||||
db = get_db()
|
||||
try:
|
||||
# 1 org max par owner
|
||||
existing = _get_org_by_owner(db, owner_id)
|
||||
if existing:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Vous possédez déjà une organisation",
|
||||
"org_id": existing["id"],
|
||||
}
|
||||
), 409
|
||||
|
||||
org_id = secrets.token_hex(16)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
db.execute(
|
||||
"INSERT INTO organizations (id, owner_id, name, max_members, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(org_id, owner_id, name, MAX_MEMBERS, now),
|
||||
)
|
||||
# Ajouter l'owner comme premier membre avec rôle 'owner'
|
||||
db.execute(
|
||||
"INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) "
|
||||
"VALUES (?, ?, 'owner', ?, ?)",
|
||||
(org_id, owner_id, now, now),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
org = _get_org_by_id(db, org_id)
|
||||
logger.info("Org créée: %s par user %s", org_id, owner_id)
|
||||
return jsonify({"org": _org_to_dict(org)}), 201
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("create_org error: %s", e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GET /api/v1/org — infos org courante
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@org_bp.route("", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@_require_pro
|
||||
def get_org():
|
||||
"""
|
||||
Retourne l'organisation dont l'utilisateur est owner ou membre.
|
||||
---
|
||||
tags:
|
||||
- Organisation
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Infos de l'organisation
|
||||
404:
|
||||
description: Aucune organisation trouvée
|
||||
"""
|
||||
user = request.current_user
|
||||
db = get_db()
|
||||
try:
|
||||
org = _get_org_by_owner(db, user["id"]) or _get_member_org(db, user["id"])
|
||||
if not org:
|
||||
return jsonify({"error": "Aucune organisation trouvée"}), 404
|
||||
|
||||
member_count = _count_org_members(db, org["id"])
|
||||
result = _org_to_dict(org)
|
||||
result["member_count"] = member_count
|
||||
return jsonify({"org": result}), 200
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# DELETE /api/v1/org — supprimer l'organisation
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@org_bp.route("", methods=["DELETE"])
|
||||
@jwt_required_middleware
|
||||
@_require_pro
|
||||
def delete_org():
|
||||
"""
|
||||
Supprime l'organisation (owner uniquement).
|
||||
---
|
||||
tags:
|
||||
- Organisation
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Organisation supprimée
|
||||
403:
|
||||
description: Seul l'owner peut supprimer l'organisation
|
||||
404:
|
||||
description: Organisation introuvable
|
||||
"""
|
||||
user = request.current_user
|
||||
db = get_db()
|
||||
try:
|
||||
org = _get_org_by_owner(db, user["id"])
|
||||
if not org:
|
||||
return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403
|
||||
|
||||
# CASCADE supprime org_members automatiquement (FK ON DELETE CASCADE)
|
||||
db.execute("DELETE FROM organizations WHERE id = ?", (org["id"],))
|
||||
db.commit()
|
||||
logger.info("Org %s supprimée par user %s", org["id"], user["id"])
|
||||
return jsonify({"ok": True, "deleted_org_id": org["id"]}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("delete_org error: %s", e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# POST /api/v1/org/invite — inviter un membre par email
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@org_bp.route("/invite", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
@_require_pro
|
||||
def invite_member():
|
||||
"""
|
||||
Invite un utilisateur dans l'organisation par email (owner uniquement).
|
||||
Limite : 5 membres totaux (owner inclus).
|
||||
---
|
||||
tags:
|
||||
- Organisation
|
||||
security:
|
||||
- Bearer: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [email]
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
description: Email de l'utilisateur à inviter
|
||||
responses:
|
||||
201:
|
||||
description: Membre ajouté
|
||||
400:
|
||||
description: Paramètre manquant ou invalide
|
||||
403:
|
||||
description: Seul l'owner peut inviter / limite de membres atteinte
|
||||
404:
|
||||
description: Utilisateur introuvable ou organisation inexistante
|
||||
409:
|
||||
description: L'utilisateur est déjà membre
|
||||
"""
|
||||
user = request.current_user
|
||||
data = request.get_json(silent=True) or {}
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
|
||||
if not email or "@" not in email:
|
||||
return jsonify({"error": "Email invalide"}), 400
|
||||
|
||||
db = get_db()
|
||||
try:
|
||||
# Vérifier que l'appelant est bien owner d'une org
|
||||
org = _get_org_by_owner(db, user["id"])
|
||||
if not org:
|
||||
return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403
|
||||
|
||||
# Vérifier la limite de membres
|
||||
current_count = _count_org_members(db, org["id"])
|
||||
if current_count >= org["max_members"]:
|
||||
return jsonify(
|
||||
{
|
||||
"error": f"Limite de {org['max_members']} membres atteinte",
|
||||
"current_count": current_count,
|
||||
}
|
||||
), 403
|
||||
|
||||
# Résoudre l'utilisateur cible
|
||||
target_user = _get_user_by_email(db, email)
|
||||
if not target_user:
|
||||
return jsonify({"error": "Utilisateur introuvable avec cet email"}), 404
|
||||
|
||||
target_id = target_user["id"]
|
||||
|
||||
# Vérifier que l'utilisateur n'est pas déjà membre de CETTE org
|
||||
existing_member = db.execute(
|
||||
"SELECT id FROM org_members WHERE org_id = ? AND user_id = ?",
|
||||
(org["id"], target_id),
|
||||
).fetchone()
|
||||
if existing_member:
|
||||
return jsonify(
|
||||
{"error": "Cet utilisateur est déjà membre de l'organisation"}
|
||||
), 409
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
db.execute(
|
||||
"INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) "
|
||||
"VALUES (?, ?, 'member', ?, ?)",
|
||||
(org["id"], target_id, now, now),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
member_row = db.execute(
|
||||
"SELECT * FROM org_members WHERE org_id = ? AND user_id = ?",
|
||||
(org["id"], target_id),
|
||||
).fetchone()
|
||||
logger.info(
|
||||
"User %s invité dans org %s par %s", target_id, org["id"], user["id"]
|
||||
)
|
||||
return jsonify({"member": _member_to_dict(member_row)}), 201
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("invite_member error: %s", e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GET /api/v1/org/members — liste des membres
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@org_bp.route("/members", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@_require_pro
|
||||
def list_members():
|
||||
"""
|
||||
Liste les membres de l'organisation courante.
|
||||
---
|
||||
tags:
|
||||
- Organisation
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Liste des membres
|
||||
404:
|
||||
description: Organisation introuvable
|
||||
"""
|
||||
user = request.current_user
|
||||
db = get_db()
|
||||
try:
|
||||
org = _get_org_by_owner(db, user["id"]) or _get_member_org(db, user["id"])
|
||||
if not org:
|
||||
return jsonify({"error": "Aucune organisation trouvée"}), 404
|
||||
|
||||
members = db.execute(
|
||||
"SELECT m.*, u.email, u.firstname, u.lastname "
|
||||
"FROM org_members m "
|
||||
"LEFT JOIN saas_users u ON u.id = m.user_id "
|
||||
"WHERE m.org_id = ? "
|
||||
"ORDER BY m.invited_at ASC",
|
||||
(org["id"],),
|
||||
).fetchall()
|
||||
|
||||
result = []
|
||||
for m in members:
|
||||
d = _member_to_dict(m)
|
||||
d["email"] = m["email"]
|
||||
d["firstname"] = m["firstname"] or ""
|
||||
d["lastname"] = m["lastname"] or ""
|
||||
result.append(d)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"org_id": org["id"],
|
||||
"members": result,
|
||||
"count": len(result),
|
||||
"max_members": org["max_members"],
|
||||
}
|
||||
), 200
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# DELETE /api/v1/org/members/<user_id> — retirer un membre
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@org_bp.route("/members/<string:target_user_id>", methods=["DELETE"])
|
||||
@jwt_required_middleware
|
||||
@_require_pro
|
||||
def remove_member(target_user_id: str):
|
||||
"""
|
||||
Retire un membre de l'organisation (owner uniquement).
|
||||
L'owner ne peut pas se retirer lui-même.
|
||||
---
|
||||
tags:
|
||||
- Organisation
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- in: path
|
||||
name: user_id
|
||||
type: string
|
||||
required: true
|
||||
description: ID de l'utilisateur à retirer
|
||||
responses:
|
||||
200:
|
||||
description: Membre retiré
|
||||
400:
|
||||
description: Tentative de retirer l'owner lui-même
|
||||
403:
|
||||
description: Seul l'owner peut retirer des membres
|
||||
404:
|
||||
description: Membre ou organisation introuvable
|
||||
"""
|
||||
user = request.current_user
|
||||
db = get_db()
|
||||
try:
|
||||
org = _get_org_by_owner(db, user["id"])
|
||||
if not org:
|
||||
return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403
|
||||
|
||||
# L'owner ne peut pas se retirer lui-même (utiliser DELETE /api/v1/org à la place)
|
||||
if target_user_id == user["id"]:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "L'owner ne peut pas se retirer lui-même. "
|
||||
"Utilisez DELETE /api/v1/org pour supprimer l'organisation."
|
||||
}
|
||||
), 400
|
||||
|
||||
member = db.execute(
|
||||
"SELECT * FROM org_members WHERE org_id = ? AND user_id = ?",
|
||||
(org["id"], target_user_id),
|
||||
).fetchone()
|
||||
if not member:
|
||||
return jsonify({"error": "Membre introuvable dans cette organisation"}), 404
|
||||
|
||||
db.execute(
|
||||
"DELETE FROM org_members WHERE org_id = ? AND user_id = ?",
|
||||
(org["id"], target_user_id),
|
||||
)
|
||||
db.commit()
|
||||
logger.info(
|
||||
"User %s retiré de l'org %s par %s", target_user_id, org["id"], user["id"]
|
||||
)
|
||||
return jsonify({"ok": True, "removed_user_id": target_user_id}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("remove_member error: %s", e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# On-import : migration idempotente
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
try:
|
||||
migrate_org_tables()
|
||||
except Exception as _e:
|
||||
logger.warning("org_db migration skipped (test env?): %s", _e)
|
||||
@@ -22,8 +22,14 @@ from auth import jwt_required_middleware, plan_required, free_daily_limit_check
|
||||
predictions_bp = Blueprint("v1_predictions", __name__, url_prefix="/api/v1/predictions")
|
||||
|
||||
|
||||
def _fetch_ml_predictions(conn, date: str, limit: int = None, offset: int = 0):
|
||||
"""Shared helper — returns rows from ml_predictions_cache."""
|
||||
def _fetch_ml_predictions(
|
||||
conn, date: str, limit: int = None, offset: int = 0, include_weather: bool = False
|
||||
):
|
||||
"""Shared helper — returns rows from ml_predictions_cache.
|
||||
|
||||
include_weather=True adds terrain_condition and weather_impact columns
|
||||
via LEFT JOIN on pmu_meteo (premium routes only).
|
||||
"""
|
||||
if not table_exists(conn, "ml_predictions_cache"):
|
||||
return [], 0
|
||||
|
||||
@@ -33,13 +39,35 @@ def _fetch_ml_predictions(conn, date: str, limit: int = None, offset: int = 0):
|
||||
).fetchone()
|
||||
total = count_row["cnt"] if count_row else 0
|
||||
|
||||
sql = """SELECT
|
||||
race_label, hippodrome, discipline, distance, heure,
|
||||
horse_name, horse_number, odds, prob_top1, prob_top3,
|
||||
ml_score, recommendation, is_value_bet, risque_label, risque_score
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ?
|
||||
ORDER BY ml_score DESC"""
|
||||
if (
|
||||
include_weather
|
||||
and table_exists(conn, "pmu_meteo")
|
||||
and table_exists(conn, "pmu_courses")
|
||||
):
|
||||
sql = """SELECT
|
||||
m.race_label, m.hippodrome, m.discipline, m.distance, m.heure,
|
||||
m.horse_name, m.horse_number, m.odds, m.prob_top1, m.prob_top3,
|
||||
m.ml_score, m.recommendation, m.is_value_bet, m.risque_label, m.risque_score,
|
||||
c.penetrometre_intitule,
|
||||
mt.nebulositecode, mt.nebulosite_court, mt.temperature, mt.force_vent
|
||||
FROM ml_predictions_cache m
|
||||
LEFT JOIN pmu_courses c
|
||||
ON c.date_programme = m.date
|
||||
AND c.num_reunion = m.num_reunion
|
||||
AND c.num_course = m.num_course
|
||||
LEFT JOIN pmu_meteo mt
|
||||
ON mt.date_programme = m.date
|
||||
AND mt.num_reunion = m.num_reunion
|
||||
WHERE m.date = ?
|
||||
ORDER BY m.ml_score DESC"""
|
||||
else:
|
||||
sql = """SELECT
|
||||
race_label, hippodrome, discipline, distance, heure,
|
||||
horse_name, horse_number, odds, prob_top1, prob_top3,
|
||||
ml_score, recommendation, is_value_bet, risque_label, risque_score
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ?
|
||||
ORDER BY ml_score DESC"""
|
||||
params = [date]
|
||||
|
||||
if limit is not None:
|
||||
@@ -47,7 +75,42 @@ def _fetch_ml_predictions(conn, date: str, limit: int = None, offset: int = 0):
|
||||
params += [limit, offset]
|
||||
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
return [dict(r) for r in rows], total
|
||||
|
||||
results = []
|
||||
for r in rows:
|
||||
row_dict = dict(r)
|
||||
if include_weather:
|
||||
# Compute derived fields from raw columns
|
||||
penetrometre = row_dict.pop("penetrometre_intitule", None) or ""
|
||||
# Import inline to avoid circular dependency at module level
|
||||
from scoring_v2 import get_terrain_condition, compute_weather_impact
|
||||
|
||||
terrain_condition = (
|
||||
get_terrain_condition(penetrometre) if penetrometre else "inconnu"
|
||||
)
|
||||
weather_data = None
|
||||
if (
|
||||
row_dict.get("nebulositecode") is not None
|
||||
or row_dict.get("temperature") is not None
|
||||
):
|
||||
weather_data = {
|
||||
"nebulositecode": row_dict.pop("nebulositecode", None),
|
||||
"nebulosite_court": row_dict.pop("nebulosite_court", None),
|
||||
"temperature": row_dict.pop("temperature", None),
|
||||
"force_vent": row_dict.pop("force_vent", None),
|
||||
}
|
||||
else:
|
||||
# Remove raw meteo columns even if NULL
|
||||
row_dict.pop("nebulositecode", None)
|
||||
row_dict.pop("nebulosite_court", None)
|
||||
row_dict.pop("temperature", None)
|
||||
row_dict.pop("force_vent", None)
|
||||
weather_impact = compute_weather_impact(weather_data, terrain_condition)
|
||||
row_dict["terrain_condition"] = terrain_condition
|
||||
row_dict["weather_impact"] = weather_impact
|
||||
results.append(row_dict)
|
||||
|
||||
return results, total
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@@ -145,7 +208,7 @@ def predictions_all():
|
||||
conn = get_db()
|
||||
try:
|
||||
predictions, total = _fetch_ml_predictions(
|
||||
conn, date_param, limit=limit, offset=offset
|
||||
conn, date_param, limit=limit, offset=offset, include_weather=True
|
||||
)
|
||||
pagination = paginate_query(predictions, total, limit, offset)
|
||||
|
||||
|
||||
216
api_v1/routes/user.py
Normal file
216
api_v1/routes/user.py
Normal file
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
User route for API v1 — Telegram alert configuration
|
||||
HRT-79: Alertes Telegram configurables (Premium)
|
||||
|
||||
GET /api/v1/user/telegram-config — Lire la config Telegram de l'utilisateur connecté
|
||||
POST /api/v1/user/telegram-config — Mettre à jour la config Telegram
|
||||
|
||||
Accès : Premium / Pro uniquement (@jwt_required_middleware + @plan_required)
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from api_v1.utils import internal_error, bad_request
|
||||
from auth import jwt_required_middleware, plan_required
|
||||
|
||||
user_bp = Blueprint("v1_user", __name__, url_prefix="/api/v1/user")
|
||||
|
||||
# DB_PATH est résolu via la même variable d'env que auth_db.py
|
||||
import os
|
||||
|
||||
_DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
|
||||
|
||||
def _get_db():
|
||||
conn = sqlite3.connect(_DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
# ── GET /api/v1/user/telegram-config ──────────────────────────────────────────
|
||||
|
||||
|
||||
@user_bp.route("/telegram-config", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("premium", "pro")
|
||||
def get_telegram_config():
|
||||
"""
|
||||
Retourne la configuration Telegram de l'utilisateur connecté.
|
||||
---
|
||||
tags:
|
||||
- Utilisateur
|
||||
summary: Lire la config alertes Telegram (premium+)
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Configuration Telegram courante
|
||||
schema:
|
||||
properties:
|
||||
telegram_chat_id:
|
||||
type: string
|
||||
nullable: true
|
||||
alert_value_bets:
|
||||
type: boolean
|
||||
alert_top1:
|
||||
type: boolean
|
||||
alert_quinte_only:
|
||||
type: boolean
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
description: Plan insuffisant
|
||||
"""
|
||||
user_id = request.user_id # injecté par jwt_required_middleware
|
||||
|
||||
conn = _get_db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT telegram_chat_id, alert_value_bets, alert_top1, alert_quinte_only
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
""",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
return jsonify({"error": "Utilisateur introuvable"}), 404
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"telegram_chat_id": row["telegram_chat_id"],
|
||||
"alert_value_bets": bool(row["alert_value_bets"]),
|
||||
"alert_top1": bool(row["alert_top1"]),
|
||||
"alert_quinte_only": bool(row["alert_quinte_only"]),
|
||||
}
|
||||
), 200
|
||||
|
||||
except sqlite3.OperationalError as exc:
|
||||
# Colonnes absentes : migration non appliquée
|
||||
return jsonify(
|
||||
{
|
||||
"telegram_chat_id": None,
|
||||
"alert_value_bets": True,
|
||||
"alert_top1": True,
|
||||
"alert_quinte_only": False,
|
||||
"_warning": "Migration Telegram non appliquée",
|
||||
}
|
||||
), 200
|
||||
except Exception as exc:
|
||||
return internal_error(str(exc))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ── POST /api/v1/user/telegram-config ─────────────────────────────────────────
|
||||
|
||||
|
||||
@user_bp.route("/telegram-config", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("premium", "pro")
|
||||
def update_telegram_config():
|
||||
"""
|
||||
Met à jour la configuration Telegram de l'utilisateur connecté.
|
||||
---
|
||||
tags:
|
||||
- Utilisateur
|
||||
summary: Configurer les alertes Telegram (premium+)
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
properties:
|
||||
telegram_chat_id:
|
||||
type: string
|
||||
description: Chat ID Telegram (ou null pour désactiver)
|
||||
alert_value_bets:
|
||||
type: boolean
|
||||
default: true
|
||||
alert_top1:
|
||||
type: boolean
|
||||
default: true
|
||||
alert_quinte_only:
|
||||
type: boolean
|
||||
default: false
|
||||
responses:
|
||||
200:
|
||||
description: Configuration mise à jour
|
||||
400:
|
||||
description: Paramètres invalides
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
description: Plan insuffisant
|
||||
"""
|
||||
user_id = request.user_id # injecté par jwt_required_middleware
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return bad_request("Corps JSON requis")
|
||||
|
||||
# Validation et extraction des champs
|
||||
telegram_chat_id = data.get("telegram_chat_id")
|
||||
if telegram_chat_id is not None and not isinstance(telegram_chat_id, str):
|
||||
return bad_request("telegram_chat_id doit être une chaîne ou null")
|
||||
if isinstance(telegram_chat_id, str):
|
||||
telegram_chat_id = telegram_chat_id.strip() or None
|
||||
|
||||
alert_value_bets = data.get("alert_value_bets", True)
|
||||
alert_top1 = data.get("alert_top1", True)
|
||||
alert_quinte_only = data.get("alert_quinte_only", False)
|
||||
|
||||
if not isinstance(alert_value_bets, bool):
|
||||
return bad_request("alert_value_bets doit être un booléen")
|
||||
if not isinstance(alert_top1, bool):
|
||||
return bad_request("alert_top1 doit être un booléen")
|
||||
if not isinstance(alert_quinte_only, bool):
|
||||
return bad_request("alert_quinte_only doit être un booléen")
|
||||
|
||||
conn = _get_db()
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET telegram_chat_id = ?,
|
||||
alert_value_bets = ?,
|
||||
alert_top1 = ?,
|
||||
alert_quinte_only = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
telegram_chat_id,
|
||||
int(alert_value_bets),
|
||||
int(alert_top1),
|
||||
int(alert_quinte_only),
|
||||
user_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"telegram_chat_id": telegram_chat_id,
|
||||
"alert_value_bets": alert_value_bets,
|
||||
"alert_top1": alert_top1,
|
||||
"alert_quinte_only": alert_quinte_only,
|
||||
}
|
||||
), 200
|
||||
|
||||
except sqlite3.OperationalError as exc:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Migration Telegram non appliquée — contacter le support",
|
||||
"detail": str(exc),
|
||||
}
|
||||
), 500
|
||||
except Exception as exc:
|
||||
return internal_error(str(exc))
|
||||
finally:
|
||||
conn.close()
|
||||
195
api_v1/routes/user_tokens.py
Normal file
195
api_v1/routes/user_tokens.py
Normal file
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
user_tokens.py — Personal API tokens + Webhook configuration (Pro plan)
|
||||
HRT-80
|
||||
|
||||
Endpoints:
|
||||
POST /api/v1/user/api-token
|
||||
DELETE /api/v1/user/api-token
|
||||
POST /api/v1/user/webhook
|
||||
DELETE /api/v1/user/webhook
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from flask import Blueprint, g, jsonify, request
|
||||
|
||||
from api_tokens_db import get_db, migrate_api_tokens_tables
|
||||
from auth import jwt_required_middleware, plan_required
|
||||
|
||||
logger = logging.getLogger("turf_saas.user_tokens")
|
||||
|
||||
user_tokens_bp = Blueprint("user_tokens", __name__, url_prefix="/api/v1/user")
|
||||
|
||||
try:
|
||||
migrate_api_tokens_tables()
|
||||
except Exception as _e:
|
||||
logger.warning("api_tokens_db migration skipped (test env?): %s", _e)
|
||||
|
||||
|
||||
def _hash_token(raw: str) -> str:
|
||||
return hashlib.sha256(raw.encode()).hexdigest()
|
||||
|
||||
|
||||
@user_tokens_bp.route("/api-token", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("pro")
|
||||
def create_api_token():
|
||||
user = g.current_user
|
||||
user_id = str(user["id"])
|
||||
conn = get_db()
|
||||
try:
|
||||
existing = conn.execute(
|
||||
"SELECT id, token_prefix, created_at FROM user_api_tokens "
|
||||
"WHERE user_id = ? AND revoked = 0",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if existing:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Un token actif existe déjà. Révoquez-le avant d'en créer un nouveau.",
|
||||
"existing_prefix": existing["token_prefix"],
|
||||
"created_at": existing["created_at"],
|
||||
}
|
||||
), 409
|
||||
|
||||
raw_token = "trf_" + secrets.token_urlsafe(40)
|
||||
token_hash = _hash_token(raw_token)
|
||||
token_prefix = raw_token[:12]
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO user_api_tokens (user_id, token_hash, token_prefix) VALUES (?, ?, ?)",
|
||||
(user_id, token_hash, token_prefix),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT created_at FROM user_api_tokens WHERE token_hash = ?",
|
||||
(token_hash,),
|
||||
).fetchone()
|
||||
created_at = row["created_at"] if row else None
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error("create_api_token error for user %s: %s", user_id, e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info("API token created for user %s (prefix=%s)", user_id, token_prefix)
|
||||
return jsonify(
|
||||
{
|
||||
"token": raw_token,
|
||||
"prefix": token_prefix,
|
||||
"created_at": created_at,
|
||||
"warning": "Conservez ce token en lieu sûr. Il ne sera plus affiché.",
|
||||
}
|
||||
), 201
|
||||
|
||||
|
||||
@user_tokens_bp.route("/api-token", methods=["DELETE"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("pro")
|
||||
def revoke_api_token():
|
||||
user = g.current_user
|
||||
user_id = str(user["id"])
|
||||
conn = get_db()
|
||||
try:
|
||||
result = conn.execute(
|
||||
"UPDATE user_api_tokens SET revoked = 1 WHERE user_id = ? AND revoked = 0",
|
||||
(user_id,),
|
||||
)
|
||||
conn.commit()
|
||||
revoked_count = result.rowcount
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error("revoke_api_token error for user %s: %s", user_id, e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if revoked_count == 0:
|
||||
return jsonify({"error": "Aucun token actif trouvé"}), 404
|
||||
|
||||
logger.info("API token(s) revoked for user %s (%d tokens)", user_id, revoked_count)
|
||||
return jsonify({"revoked": True, "count": revoked_count}), 200
|
||||
|
||||
|
||||
@user_tokens_bp.route("/webhook", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("pro")
|
||||
def create_webhook():
|
||||
user = g.current_user
|
||||
user_id = str(user["id"])
|
||||
data = request.get_json(silent=True) or {}
|
||||
url = (data.get("url") or "").strip()
|
||||
|
||||
if not url:
|
||||
return jsonify({"error": "URL du webhook manquante"}), 400
|
||||
if not url.startswith("https://"):
|
||||
return jsonify(
|
||||
{"error": "L'URL du webhook doit utiliser HTTPS (commencer par https://)"}
|
||||
), 400
|
||||
|
||||
secret = (data.get("secret") or "").strip() or secrets.token_hex(32)
|
||||
|
||||
conn = get_db()
|
||||
existing = None
|
||||
try:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM user_webhooks WHERE user_id = ?", (user_id,)
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute(
|
||||
"UPDATE user_webhooks SET url = ?, secret = ?, created_at = datetime('now') "
|
||||
"WHERE user_id = ?",
|
||||
(url, secret, user_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO user_webhooks (user_id, url, secret) VALUES (?, ?, ?)",
|
||||
(user_id, url, secret),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error("create_webhook error for user %s: %s", user_id, e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
action = "mis à jour" if existing else "configuré"
|
||||
logger.info("Webhook %s for user %s: %s", action, user_id, url)
|
||||
return jsonify(
|
||||
{
|
||||
"webhook_url": url,
|
||||
"secret": secret,
|
||||
"message": f"Webhook {action} avec succès",
|
||||
}
|
||||
), 201
|
||||
|
||||
|
||||
@user_tokens_bp.route("/webhook", methods=["DELETE"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("pro")
|
||||
def delete_webhook():
|
||||
user = g.current_user
|
||||
user_id = str(user["id"])
|
||||
conn = get_db()
|
||||
try:
|
||||
result = conn.execute("DELETE FROM user_webhooks WHERE user_id = ?", (user_id,))
|
||||
conn.commit()
|
||||
deleted_count = result.rowcount
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error("delete_webhook error for user %s: %s", user_id, e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if deleted_count == 0:
|
||||
return jsonify({"error": "Aucun webhook configuré"}), 404
|
||||
|
||||
logger.info("Webhook deleted for user %s", user_id)
|
||||
return jsonify({"deleted": True}), 200
|
||||
@@ -53,7 +53,7 @@ def valuebets():
|
||||
default: 0
|
||||
responses:
|
||||
200:
|
||||
description: Value bets du jour
|
||||
description: Value bets du jour avec météo et terrain (HRT-83)
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
@@ -69,7 +69,7 @@ def valuebets():
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
rows = []
|
||||
rows_raw = []
|
||||
total = 0
|
||||
|
||||
if table_exists(conn, "ml_predictions_cache"):
|
||||
@@ -81,18 +81,73 @@ def valuebets():
|
||||
).fetchone()
|
||||
total = count_row["cnt"] if count_row else 0
|
||||
|
||||
rows = conn.execute(
|
||||
"""SELECT race_label, hippodrome, discipline, distance, heure,
|
||||
horse_name, horse_number, odds, prob_top1, prob_top3,
|
||||
ml_score, recommendation, risque_label, risque_score
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ? AND is_value_bet = 1 AND odds >= ?
|
||||
ORDER BY ml_score DESC
|
||||
LIMIT ? OFFSET ?""",
|
||||
(date_param, min_odds, limit, offset),
|
||||
).fetchall()
|
||||
# LEFT JOIN pmu_courses (terrain) + pmu_meteo (météo) — HRT-83
|
||||
has_courses = table_exists(conn, "pmu_courses")
|
||||
has_meteo = table_exists(conn, "pmu_meteo")
|
||||
|
||||
if has_courses and has_meteo:
|
||||
rows_raw = conn.execute(
|
||||
"""SELECT m.race_label, m.hippodrome, m.discipline, m.distance, m.heure,
|
||||
m.horse_name, m.horse_number, m.odds, m.prob_top1, m.prob_top3,
|
||||
m.ml_score, m.recommendation, m.risque_label, m.risque_score,
|
||||
c.penetrometre_intitule,
|
||||
mt.nebulositecode, mt.nebulosite_court,
|
||||
mt.temperature, mt.force_vent
|
||||
FROM ml_predictions_cache m
|
||||
LEFT JOIN pmu_courses c
|
||||
ON c.date_programme = m.date
|
||||
AND c.num_reunion = m.num_reunion
|
||||
AND c.num_course = m.num_course
|
||||
LEFT JOIN pmu_meteo mt
|
||||
ON mt.date_programme = m.date
|
||||
AND mt.num_reunion = m.num_reunion
|
||||
WHERE m.date = ? AND m.is_value_bet = 1 AND m.odds >= ?
|
||||
ORDER BY m.ml_score DESC
|
||||
LIMIT ? OFFSET ?""",
|
||||
(date_param, min_odds, limit, offset),
|
||||
).fetchall()
|
||||
else:
|
||||
rows_raw = conn.execute(
|
||||
"""SELECT race_label, hippodrome, discipline, distance, heure,
|
||||
horse_name, horse_number, odds, prob_top1, prob_top3,
|
||||
ml_score, recommendation, risque_label, risque_score
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ? AND is_value_bet = 1 AND odds >= ?
|
||||
ORDER BY ml_score DESC
|
||||
LIMIT ? OFFSET ?""",
|
||||
(date_param, min_odds, limit, offset),
|
||||
).fetchall()
|
||||
|
||||
from scoring_v2 import get_terrain_condition, compute_weather_impact
|
||||
|
||||
valuebets_list = []
|
||||
for r in rows_raw:
|
||||
row_dict = dict(r)
|
||||
penetrometre = row_dict.pop("penetrometre_intitule", None) or ""
|
||||
terrain_condition = (
|
||||
get_terrain_condition(penetrometre) if penetrometre else "inconnu"
|
||||
)
|
||||
weather_data = None
|
||||
if (
|
||||
row_dict.get("nebulositecode") is not None
|
||||
or row_dict.get("temperature") is not None
|
||||
):
|
||||
weather_data = {
|
||||
"nebulositecode": row_dict.pop("nebulositecode", None),
|
||||
"nebulosite_court": row_dict.pop("nebulosite_court", None),
|
||||
"temperature": row_dict.pop("temperature", None),
|
||||
"force_vent": row_dict.pop("force_vent", None),
|
||||
}
|
||||
else:
|
||||
row_dict.pop("nebulositecode", None)
|
||||
row_dict.pop("nebulosite_court", None)
|
||||
row_dict.pop("temperature", None)
|
||||
row_dict.pop("force_vent", None)
|
||||
weather_impact = compute_weather_impact(weather_data, terrain_condition)
|
||||
row_dict["terrain_condition"] = terrain_condition
|
||||
row_dict["weather_impact"] = weather_impact
|
||||
valuebets_list.append(row_dict)
|
||||
|
||||
valuebets_list = [dict(r) for r in rows]
|
||||
pagination = paginate_query(valuebets_list, total, limit, offset)
|
||||
|
||||
return jsonify(
|
||||
|
||||
80
api_v1/utils_webhook.py
Normal file
80
api_v1/utils_webhook.py
Normal file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
utils_webhook.py — Webhook dispatch utility (fire-and-forget, HMAC-SHA256)
|
||||
HRT-80
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from api_tokens_db import get_db
|
||||
|
||||
logger = logging.getLogger("turf_saas.webhook")
|
||||
|
||||
EVENT_NEW_PREDICTION = "new_prediction"
|
||||
EVENT_VALUE_BET = "value_bet"
|
||||
|
||||
|
||||
def dispatch_webhook(user_id: str, event_type: str, payload: dict) -> None:
|
||||
"""
|
||||
Send HMAC-signed webhook POST to URL configured by user.
|
||||
Fire-and-forget: errors logged, never re-raised. Timeout: 5s.
|
||||
"""
|
||||
conn = get_db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT url, secret FROM user_webhooks WHERE user_id = ?",
|
||||
(str(user_id),),
|
||||
).fetchone()
|
||||
except Exception as e:
|
||||
logger.warning("dispatch_webhook: DB error for user %s: %s", user_id, e)
|
||||
return
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
return
|
||||
|
||||
url = row["url"]
|
||||
secret = row["secret"]
|
||||
body = json.dumps(
|
||||
{"event": event_type, "data": payload},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
signature = hmac.new(
|
||||
secret.encode("utf-8"), body.encode("utf-8"), hashlib.sha256
|
||||
).hexdigest()
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Turf-Signature": f"sha256={signature}",
|
||||
"X-Turf-Event": event_type,
|
||||
"User-Agent": "TurfSaaS-Webhook/1.0",
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, data=body, headers=headers, timeout=5)
|
||||
logger.info(
|
||||
"Webhook dispatched to user %s (event=%s, status=%s)",
|
||||
user_id,
|
||||
event_type,
|
||||
resp.status_code,
|
||||
)
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning(
|
||||
"Webhook timeout for user %s (event=%s, url=%s)", user_id, event_type, url
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(
|
||||
"Webhook failed for user %s (event=%s): %s", user_id, event_type, e
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Webhook unexpected error for user %s (event=%s): %s",
|
||||
user_id,
|
||||
event_type,
|
||||
e,
|
||||
)
|
||||
52
auth.py
52
auth.py
@@ -258,11 +258,47 @@ def logout():
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def validate_api_key(raw_key: str):
|
||||
"""
|
||||
Validate a personal API token (X-API-Key header).
|
||||
Returns user dict or None. Updates last_used_at on success.
|
||||
HRT-80: Personal API token support.
|
||||
"""
|
||||
if not raw_key:
|
||||
return None
|
||||
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
|
||||
db = get_db()
|
||||
try:
|
||||
row = db.execute(
|
||||
"SELECT t.user_id, u.* FROM user_api_tokens t "
|
||||
"JOIN users u ON CAST(t.user_id AS INTEGER) = u.id "
|
||||
"WHERE t.token_hash = ? AND t.revoked = 0 AND u.is_active = 1",
|
||||
(key_hash,),
|
||||
).fetchone()
|
||||
if row:
|
||||
db.execute(
|
||||
"UPDATE user_api_tokens SET last_used_at = datetime('now') "
|
||||
"WHERE token_hash = ?",
|
||||
(key_hash,),
|
||||
)
|
||||
db.commit()
|
||||
return dict(row) if row else None
|
||||
except Exception as e:
|
||||
logger.warning("validate_api_key error: %s", e)
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def jwt_required_middleware(fn):
|
||||
"""Decorator: require a valid Bearer JWT access token."""
|
||||
"""
|
||||
Decorator: require a valid Bearer JWT access token OR X-API-Key personal token.
|
||||
HRT-80: Added X-API-Key fallback for personal API tokens (Pro plan only).
|
||||
"""
|
||||
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
# 1. Try Bearer JWT (existing flow — unchanged)
|
||||
try:
|
||||
verify_jwt_in_request()
|
||||
user_id = int(get_jwt_identity())
|
||||
@@ -271,10 +307,20 @@ def jwt_required_middleware(fn):
|
||||
return jsonify({"error": "Utilisateur introuvable"}), 401
|
||||
g.current_user = dict(user)
|
||||
g.current_user_id = user_id
|
||||
return fn(*args, **kwargs)
|
||||
except (JWTExtendedException, PyJWTError) as e:
|
||||
logger.debug("JWT auth failed: %s", e)
|
||||
return jsonify({"error": "Token invalide ou expiré", "detail": str(e)}), 401
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
# 2. Fallback: X-API-Key personal token (HRT-80)
|
||||
api_key = request.headers.get("X-API-Key", "").strip()
|
||||
if api_key:
|
||||
user = validate_api_key(api_key)
|
||||
if user:
|
||||
g.current_user = user
|
||||
g.current_user_id = user.get("id")
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return jsonify({"error": "Token invalide ou expiré"}), 401
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
31
auth_db.py
31
auth_db.py
@@ -2,6 +2,7 @@
|
||||
"""
|
||||
Auth DB — users and subscriptions schema for turf_saas.db
|
||||
Sprint 2-3: Auth JWT + Multi-tenant (HRT-28)
|
||||
HRT-79: migration Telegram columns
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
@@ -63,6 +64,36 @@ def init_auth_tables():
|
||||
conn.close()
|
||||
print("[auth_db] Tables users, subscriptions, refresh_tokens created/verified.")
|
||||
|
||||
# Apply Telegram columns migration (idempotent)
|
||||
migrate_telegram_columns()
|
||||
|
||||
|
||||
def migrate_telegram_columns():
|
||||
"""
|
||||
Migration idempotente : ajoute les colonnes Telegram à la table users.
|
||||
Utilise ALTER TABLE ... ADD COLUMN avec try/except OperationalError
|
||||
pour être safe si les colonnes existent déjà (SQLite ne supporte pas IF NOT EXISTS).
|
||||
HRT-79
|
||||
"""
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
columns = [
|
||||
("telegram_chat_id", "TEXT DEFAULT NULL"),
|
||||
("alert_value_bets", "INTEGER DEFAULT 1"),
|
||||
("alert_top1", "INTEGER DEFAULT 1"),
|
||||
("alert_quinte_only", "INTEGER DEFAULT 0"),
|
||||
]
|
||||
for col, definition in columns:
|
||||
try:
|
||||
c.execute(f"ALTER TABLE users ADD COLUMN {col} {definition}")
|
||||
print(f"[auth_db] Colonne '{col}' ajoutée.")
|
||||
except sqlite3.OperationalError:
|
||||
# Column already exists — safe to ignore
|
||||
pass
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("[auth_db] Migration Telegram columns OK.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_auth_tables()
|
||||
|
||||
@@ -76,14 +76,30 @@ def migrate_billing_tables():
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
stripe_event_id TEXT NOT NULL UNIQUE,
|
||||
event_type TEXT NOT NULL,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
user_id TEXT,
|
||||
payload TEXT,
|
||||
processed_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_billing_events_user ON billing_events(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_billing_events_type ON billing_events(event_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe ON subscriptions(stripe_subscription_id);
|
||||
CREATE TABLE IF NOT EXISTS saas_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
plan TEXT NOT NULL DEFAULT 'free',
|
||||
start_date DATETIME DEFAULT (datetime('now')),
|
||||
end_date DATETIME,
|
||||
stripe_customer_id TEXT,
|
||||
stripe_subscription_id TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
grace_period_end DATETIME,
|
||||
current_period_end DATETIME
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_billing_events_user ON billing_events(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_billing_events_type ON billing_events(event_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_saas_subs_user ON saas_subscriptions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_saas_subs_customer ON saas_subscriptions(stripe_customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_saas_subs_stripe ON saas_subscriptions(stripe_subscription_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe ON subscriptions(stripe_subscription_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_customer ON subscriptions(stripe_customer_id);
|
||||
""")
|
||||
|
||||
|
||||
259
combined_api.py
259
combined_api.py
@@ -131,6 +131,24 @@ def get_db():
|
||||
return conn
|
||||
|
||||
|
||||
@app.route("/health")
|
||||
@app.route("/turf/health")
|
||||
def health():
|
||||
"""Health check endpoint for Docker/load balancer. Returns 200 if app is running."""
|
||||
import sqlite3 as _sqlite3
|
||||
|
||||
db_ok = True
|
||||
try:
|
||||
conn = _sqlite3.connect(DB_PATH, timeout=2)
|
||||
conn.execute("SELECT 1")
|
||||
conn.close()
|
||||
except Exception:
|
||||
db_ok = False
|
||||
status = "ok" if db_ok else "degraded"
|
||||
http_code = 200 if db_ok else 503
|
||||
return {"status": status, "service": "combined-api", "db": db_ok}, http_code
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return send_file("/home/h3r7/turf_saas/dashboard.html")
|
||||
@@ -3519,7 +3537,6 @@ def brave_search():
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
|
||||
@app.route("/turf/api/predictions_analysis", methods=["GET"])
|
||||
def api_predictions_analysis():
|
||||
"""Analyse des predictions vs resultats reels"""
|
||||
@@ -3533,13 +3550,25 @@ def api_predictions_analysis():
|
||||
cursor = conn.cursor()
|
||||
|
||||
stats = {
|
||||
"canalturf": {"total": 0, "top1_pct": 0, "top3_pct": 0, "top5_pct": 0, "ze2_pct": 0},
|
||||
"scoring": {"total": 0, "top1_pct": 0, "top3_pct": 0, "top5_pct": 0, "ze2_pct": 0},
|
||||
"canalturf": {
|
||||
"total": 0,
|
||||
"top1_pct": 0,
|
||||
"top3_pct": 0,
|
||||
"top5_pct": 0,
|
||||
"ze2_pct": 0,
|
||||
},
|
||||
"scoring": {
|
||||
"total": 0,
|
||||
"top1_pct": 0,
|
||||
"top3_pct": 0,
|
||||
"top5_pct": 0,
|
||||
"ze2_pct": 0,
|
||||
},
|
||||
}
|
||||
|
||||
for source in ["canalturf", "scoring"]:
|
||||
pred_table = "predictions" if source == "canalturf" else "scoring"
|
||||
pred_col = "predicted_1" if source == "canalturf" else "horse_number"
|
||||
pred_col = "predicted_1" if source == "canalturf" else "horse_number"
|
||||
try:
|
||||
cursor.execute(
|
||||
f"""
|
||||
@@ -3566,16 +3595,16 @@ def api_predictions_analysis():
|
||||
top1_hit = top3_hit = 0
|
||||
total = len(races)
|
||||
for race, data in races.items():
|
||||
actual = set(data["actual"][:3])
|
||||
pred_top1 = data["predicted"][0] if data["predicted"] else None
|
||||
actual_top1 = data["actual"][0] if data["actual"] else None
|
||||
actual = set(data["actual"][:3])
|
||||
pred_top1 = data["predicted"][0] if data["predicted"] else None
|
||||
actual_top1 = data["actual"][0] if data["actual"] else None
|
||||
if pred_top1 and actual_top1 and pred_top1 == actual_top1:
|
||||
top1_hit += 1
|
||||
if len(set(data["predicted"][:3]) & actual) >= 1:
|
||||
top3_hit += 1
|
||||
|
||||
if total > 0:
|
||||
stats[source]["total"] = total
|
||||
stats[source]["total"] = total
|
||||
stats[source]["top1_pct"] = round(top1_hit / total * 100, 1)
|
||||
stats[source]["top3_pct"] = round(top3_hit / total * 100, 1)
|
||||
except Exception as e:
|
||||
@@ -3585,5 +3614,219 @@ def api_predictions_analysis():
|
||||
return jsonify({"stats": stats, "period": {"start": start_date, "end": end_date}})
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# /api/v1/predictions — Ensemble model endpoint (Sprint 6-7 ML Upgrade)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
_predict_v2 = None
|
||||
|
||||
|
||||
def _load_predict_v2():
|
||||
"""Lazy import of predict_v2 module (ensemble model)."""
|
||||
global _predict_v2
|
||||
if _predict_v2 is None:
|
||||
try:
|
||||
import importlib.util, sys
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"predict_v2", "/home/h3r7/turf_saas/predict_v2.py"
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
_predict_v2 = mod
|
||||
except Exception as e:
|
||||
import logging
|
||||
|
||||
logging.error(f"[v1/predictions] predict_v2 import failed: {e}")
|
||||
return _predict_v2
|
||||
|
||||
|
||||
@app.route("/api/v1/predictions", methods=["GET"])
|
||||
@app.route("/turf/api/v1/predictions", methods=["GET"])
|
||||
def api_v1_predictions():
|
||||
"""
|
||||
Ensemble ML predictions using XGBoost + LightGBM + MLP (Optuna-tuned).
|
||||
Query params:
|
||||
- date: YYYY-MM-DD (default: today / latest available)
|
||||
- reunion: int (default: all)
|
||||
- course: int (default: all)
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
t0 = _time.perf_counter()
|
||||
|
||||
mod = _load_predict_v2()
|
||||
if mod is None:
|
||||
# Graceful fallback: redirect to legacy ml_predictions
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Ensemble model not available yet",
|
||||
"fallback": "/api/ml_predictions",
|
||||
"message": "Model is still training. Use /api/ml_predictions for legacy XGBoost predictions.",
|
||||
}
|
||||
), 503
|
||||
|
||||
ensemble = mod.load_ensemble()
|
||||
if ensemble is None:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Ensemble model file not found",
|
||||
"model_path": str(mod.ENSEMBLE_PATH),
|
||||
"message": "Run train_ensemble.py to generate the model.",
|
||||
"fallback": "/api/ml_predictions",
|
||||
}
|
||||
), 503
|
||||
|
||||
date_param = request.args.get("date", None)
|
||||
reunion_param = request.args.get("reunion", None)
|
||||
course_param = request.args.get("course", None)
|
||||
|
||||
conn = sqlite3.connect("/home/h3r7/turf_saas/turf.db")
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
# Determine date to use
|
||||
if date_param:
|
||||
date_used = date_param
|
||||
else:
|
||||
row = conn.execute(
|
||||
"SELECT MAX(date_programme) as d FROM pmu_partants"
|
||||
).fetchone()
|
||||
date_used = (
|
||||
row["d"] if row and row["d"] else datetime.now().strftime("%Y-%m-%d")
|
||||
)
|
||||
|
||||
# Build query
|
||||
where_clauses = ["p.date_programme = ?"]
|
||||
params = [date_used]
|
||||
if reunion_param:
|
||||
where_clauses.append("p.num_reunion = ?")
|
||||
params.append(int(reunion_param))
|
||||
if course_param:
|
||||
where_clauses.append("p.num_course = ?")
|
||||
params.append(int(course_param))
|
||||
|
||||
query = f"""
|
||||
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 {" AND ".join(where_clauses)}
|
||||
ORDER BY p.num_reunion, p.num_course, p.num_pmu
|
||||
"""
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
conn.close()
|
||||
|
||||
if not rows:
|
||||
return jsonify(
|
||||
{
|
||||
"date": date_used,
|
||||
"model_version": mod.get_model_version(),
|
||||
"predictions": [],
|
||||
"message": f"No partants found for date {date_used}",
|
||||
}
|
||||
)
|
||||
|
||||
# Convert to list of dicts
|
||||
partants = [dict(r) for r in rows]
|
||||
|
||||
# Run ensemble prediction
|
||||
preds = mod.predict_top3(partants, model=ensemble)
|
||||
|
||||
# Group by race
|
||||
races = {}
|
||||
for pred in preds:
|
||||
key = f"R{pred.get('num_reunion', 0)}C{pred.get('num_course', 0)}"
|
||||
if key not in races:
|
||||
# Find race metadata from partants
|
||||
for p in partants:
|
||||
if p.get("num_reunion") == pred.get("num_reunion") and p.get(
|
||||
"num_course"
|
||||
) == pred.get("num_course"):
|
||||
races[key] = {
|
||||
"reunion": pred.get("num_reunion"),
|
||||
"course": pred.get("num_course"),
|
||||
"label": key,
|
||||
"race_name": p.get("course_libelle", ""),
|
||||
"hippodrome": p.get("hippodrome", ""),
|
||||
"heure": p.get("heure_depart_str", ""),
|
||||
"discipline": p.get("discipline", ""),
|
||||
"distance": p.get("distance", 0),
|
||||
"horses": [],
|
||||
}
|
||||
break
|
||||
if key in races:
|
||||
races[key]["horses"].append(pred)
|
||||
|
||||
latency_ms = (_time.perf_counter() - t0) * 1000
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"date": date_used,
|
||||
"model_version": mod.get_model_version(),
|
||||
"latency_ms": round(latency_ms, 1),
|
||||
"total_horses": len(preds),
|
||||
"races": list(races.values()),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/v1/model/invalidate-cache", methods=["POST"])
|
||||
@app.route("/turf/api/v1/model/invalidate-cache", methods=["POST"])
|
||||
def api_v1_invalidate_cache():
|
||||
"""Force reload of ensemble model on next prediction call."""
|
||||
mod = _load_predict_v2()
|
||||
if mod:
|
||||
mod.invalidate_model_cache()
|
||||
return jsonify({"status": "ok", "message": "Model cache invalidated"})
|
||||
return jsonify({"status": "error", "message": "predict_v2 module not loaded"}), 500
|
||||
|
||||
|
||||
@app.route("/api/v1/model/status", methods=["GET"])
|
||||
@app.route("/turf/api/v1/model/status", methods=["GET"])
|
||||
def api_v1_model_status():
|
||||
"""Return ensemble model status and version."""
|
||||
import os as _os
|
||||
from pathlib import Path as _Path
|
||||
|
||||
ensemble_path = _Path("/home/h3r7/turf_saas/models/ensemble_top3.pkl")
|
||||
benchmark_path = _Path("/home/h3r7/turf_saas/models/benchmark_report.json")
|
||||
|
||||
status = {
|
||||
"ensemble_available": ensemble_path.exists(),
|
||||
"ensemble_path": str(ensemble_path),
|
||||
}
|
||||
if ensemble_path.exists():
|
||||
mtime = _os.path.getmtime(str(ensemble_path))
|
||||
status["last_trained"] = datetime.fromtimestamp(mtime).isoformat()
|
||||
|
||||
if benchmark_path.exists():
|
||||
try:
|
||||
with open(benchmark_path) as f:
|
||||
import json as _json
|
||||
|
||||
report = _json.load(f)
|
||||
status["benchmark"] = {
|
||||
"baseline_precision_at3": report.get("baseline", {}).get(
|
||||
"precision_at3"
|
||||
),
|
||||
"ensemble_precision_at3": report.get("ensemble", {}).get(
|
||||
"precision_at3"
|
||||
),
|
||||
"delta": report.get("delta_precision_at3"),
|
||||
"deployed": report.get("deploy"),
|
||||
"run_date": report.get("run_date"),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
mod = _load_predict_v2()
|
||||
if mod and ensemble_path.exists():
|
||||
status["model_version"] = mod.get_model_version()
|
||||
|
||||
return jsonify(status)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8790, debug=False)
|
||||
|
||||
219
dashboard_api.py
219
dashboard_api.py
@@ -86,11 +86,15 @@ def ensure_ml_cache_table(conn):
|
||||
""")
|
||||
# Migration : ajouter colonnes risque si table existante sans elles
|
||||
try:
|
||||
conn.execute("ALTER TABLE ml_predictions_cache ADD COLUMN risque_label TEXT DEFAULT 'neutral'")
|
||||
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")
|
||||
conn.execute(
|
||||
"ALTER TABLE ml_predictions_cache ADD COLUMN risque_score INTEGER DEFAULT 50"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
conn.commit()
|
||||
@@ -101,7 +105,7 @@ def get_ml_from_cache(conn, date):
|
||||
ensure_ml_cache_table(conn)
|
||||
cursor = conn.execute(
|
||||
"""SELECT * FROM ml_predictions_cache WHERE date = ? ORDER BY ml_score DESC""",
|
||||
(date,)
|
||||
(date,),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
if not rows:
|
||||
@@ -112,34 +116,36 @@ def get_ml_from_cache(conn, date):
|
||||
for row in rows:
|
||||
r = dict(row)
|
||||
pred = {
|
||||
"horse_name": r["horse_name"],
|
||||
"horse_number": r["horse_number"],
|
||||
"odds": r["odds"],
|
||||
"prob_top1": r["prob_top1"],
|
||||
"prob_top3": r["prob_top3"],
|
||||
"ml_score": r["ml_score"],
|
||||
"horse_name": r["horse_name"],
|
||||
"horse_number": r["horse_number"],
|
||||
"odds": r["odds"],
|
||||
"prob_top1": r["prob_top1"],
|
||||
"prob_top3": r["prob_top3"],
|
||||
"ml_score": r["ml_score"],
|
||||
"recommendation": r["recommendation"],
|
||||
"is_value_bet": r["is_value_bet"],
|
||||
"is_outlier": r["is_outlier"],
|
||||
"num_reunion": r["num_reunion"],
|
||||
"num_course": r["num_course"],
|
||||
"race_label": r["race_label"],
|
||||
"race_name": r["race_name"],
|
||||
"hippodrome": r["hippodrome"],
|
||||
"discipline": r["discipline"],
|
||||
"distance": r["distance"],
|
||||
"heure": r["heure"],
|
||||
"risque_label": r["risque_label"] if "risque_label" in r.keys() else "neutral",
|
||||
"risque_score": r["risque_score"] if "risque_score" in r.keys() else 50,
|
||||
"is_value_bet": r["is_value_bet"],
|
||||
"is_outlier": r["is_outlier"],
|
||||
"num_reunion": r["num_reunion"],
|
||||
"num_course": r["num_course"],
|
||||
"race_label": r["race_label"],
|
||||
"race_name": r["race_name"],
|
||||
"hippodrome": r["hippodrome"],
|
||||
"discipline": r["discipline"],
|
||||
"distance": r["distance"],
|
||||
"heure": r["heure"],
|
||||
"risque_label": r["risque_label"]
|
||||
if "risque_label" in r.keys()
|
||||
else "neutral",
|
||||
"risque_score": r["risque_score"] if "risque_score" in r.keys() else 50,
|
||||
}
|
||||
predictions.append(pred)
|
||||
key = f"{r['num_reunion']}_{r['num_course']}"
|
||||
if key not in course_info:
|
||||
course_info[key] = {
|
||||
"libelle": r["race_name"],
|
||||
"libelle_court": r["hippodrome"],
|
||||
"discipline": r["discipline"],
|
||||
"distance": r["distance"],
|
||||
"libelle": r["race_name"],
|
||||
"libelle_court": r["hippodrome"],
|
||||
"discipline": r["discipline"],
|
||||
"distance": r["distance"],
|
||||
"heure_depart_str": r["heure"],
|
||||
}
|
||||
return predictions, course_info
|
||||
@@ -152,15 +158,18 @@ def save_ml_to_cache(conn, date, predictions, model_version="xgboost_v1"):
|
||||
conn.execute("DELETE FROM ml_predictions_cache WHERE date = ?", (date,))
|
||||
# Calculer le risque par course (grouper les chevaux avec tous leurs scores ML)
|
||||
from collections import defaultdict
|
||||
|
||||
race_horses = defaultdict(list)
|
||||
for p in predictions:
|
||||
key = (p.get("num_reunion"), p.get("num_course"))
|
||||
race_horses[key].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_horses[key].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 key, partants in race_horses.items():
|
||||
@@ -170,36 +179,39 @@ def save_ml_to_cache(conn, date, predictions, model_version="xgboost_v1"):
|
||||
for p in predictions:
|
||||
rkey = (p.get("num_reunion"), p.get("num_course"))
|
||||
rl, rs = race_risque.get(rkey, ("neutral", 50))
|
||||
conn.execute("""
|
||||
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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""", (
|
||||
date,
|
||||
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,
|
||||
model_version,
|
||||
))
|
||||
""",
|
||||
(
|
||||
date,
|
||||
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,
|
||||
model_version,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
@@ -219,22 +231,38 @@ def calculate_risque(partants):
|
||||
return None, None
|
||||
|
||||
# Trier par ml_score desc (ou prob_top1 si ml_score absent)
|
||||
sorted_p = sorted(partants, key=lambda x: x.get("ml_score") or x.get("prob_top1") or 0, reverse=True)
|
||||
sorted_p = sorted(
|
||||
partants,
|
||||
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
|
||||
top3_score = sorted_p[2].get("ml_score") or sorted_p[2].get("prob_top1") or 0 if len(sorted_p) > 2 else 0
|
||||
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
|
||||
)
|
||||
top3_score = (
|
||||
sorted_p[2].get("ml_score") or sorted_p[2].get("prob_top1") or 0
|
||||
if len(sorted_p) > 2
|
||||
else 0
|
||||
)
|
||||
|
||||
gap_1_2 = top1_score - top2_score # écart entre 1er et 2e ML
|
||||
gap_1_3 = top1_score - top3_score # écart entre 1er et 3e ML
|
||||
gap_1_2 = top1_score - top2_score # écart entre 1er et 2e ML
|
||||
gap_1_3 = top1_score - top3_score # écart entre 1er et 3e ML
|
||||
|
||||
# Nombre de concurrents avec ml_score > 40 (dangereux)
|
||||
nb_dangerous = sum(1 for p in sorted_p if (p.get("ml_score") or 0) > 40)
|
||||
|
||||
# Détection favori de cote surpris par le ML
|
||||
odds_fav = sorted(partants, 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_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 # favori de cote ignoré par le ML
|
||||
|
||||
# --- SAFE : domination claire ---
|
||||
@@ -256,7 +284,6 @@ def calculate_risque(partants):
|
||||
return "neutral", score
|
||||
|
||||
|
||||
|
||||
def table_exists(conn, table_name):
|
||||
c = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table_name,)
|
||||
@@ -470,6 +497,28 @@ def prepare_features_from_db(horse_data):
|
||||
return df
|
||||
|
||||
|
||||
@app.route("/health")
|
||||
@app.route("/turf/health")
|
||||
def health():
|
||||
"""Health check endpoint for Docker/load balancer. Returns 200 if app is running."""
|
||||
import sqlite3 as _sqlite3
|
||||
|
||||
db_ok = True
|
||||
try:
|
||||
conn = _sqlite3.connect(
|
||||
DB_FILE if "DB_FILE" in dir() else "/home/h3r7/turf_saas/turf_saas.db",
|
||||
timeout=2,
|
||||
)
|
||||
conn.execute("SELECT 1")
|
||||
conn.close()
|
||||
except Exception:
|
||||
db_ok = False
|
||||
status = "ok" if db_ok else "degraded"
|
||||
return {"status": status, "service": "dashboard-api", "db": db_ok}, (
|
||||
200 if db_ok else 503
|
||||
)
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return send_file("/home/h3r7/turf_saas/dashboard.html")
|
||||
@@ -722,13 +771,15 @@ def api_ml_predictions():
|
||||
cached_preds, cached_courses = get_ml_from_cache(conn, today)
|
||||
if cached_preds:
|
||||
conn.close()
|
||||
return jsonify({
|
||||
"date": today,
|
||||
"model_version": "xgboost_v1",
|
||||
"predictions": cached_preds,
|
||||
"courses": cached_courses,
|
||||
"from_cache": True,
|
||||
})
|
||||
return jsonify(
|
||||
{
|
||||
"date": today,
|
||||
"model_version": "xgboost_v1",
|
||||
"predictions": cached_preds,
|
||||
"courses": cached_courses,
|
||||
"from_cache": True,
|
||||
}
|
||||
)
|
||||
|
||||
# --- CALCUL ML ---
|
||||
models = load_models()
|
||||
@@ -946,15 +997,18 @@ def api_ml_predictions():
|
||||
|
||||
# --- CALCUL RISQUE PAR COURSE + INJECTION DANS PREDICTIONS ---
|
||||
from collections import defaultdict as _dd
|
||||
|
||||
_race_horses_ml = _dd(list)
|
||||
for p in predictions:
|
||||
key = (p.get("num_reunion"), p.get("num_course"))
|
||||
_race_horses_ml[key].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_horses_ml[key].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_map = {}
|
||||
for key, partants in _race_horses_ml.items():
|
||||
label, score = calculate_risque(partants)
|
||||
@@ -996,6 +1050,7 @@ def api_ml_predictions_refresh():
|
||||
conn.close()
|
||||
# Déléguer au endpoint principal avec force_refresh
|
||||
from flask import redirect, url_for
|
||||
|
||||
return redirect(url_for("api_ml_predictions") + "?refresh=1")
|
||||
|
||||
|
||||
@@ -1107,8 +1162,6 @@ def api_suggestions():
|
||||
return jsonify({"suggestions": suggestions})
|
||||
|
||||
|
||||
|
||||
|
||||
@app.route("/turf/api/metrics/summary")
|
||||
@app.route("/turf/api/metrics/summary/")
|
||||
def metrics_summary():
|
||||
@@ -1124,7 +1177,8 @@ def metrics_summary():
|
||||
"ROUND(SUM(roi_sp_net), 3) as roi_sp_cumul, ROUND(AVG(ecart_rang_moyen), 2) as moy_ecart_rang, "
|
||||
"SUM(quinte_5sur5) as nb_5sur5, SUM(quinte_4sur5) as nb_4sur5, SUM(quinte_3sur5) as nb_3sur5 "
|
||||
"FROM prediction_metrics WHERE date >= date('now', ?) GROUP BY source ORDER BY moy_taux_place DESC",
|
||||
(date_filter,))
|
||||
(date_filter,),
|
||||
)
|
||||
cols = [d[0] for d in cur.description]
|
||||
rows = [dict(zip(cols, row)) for row in cur.fetchall()]
|
||||
conn.close()
|
||||
@@ -1132,6 +1186,7 @@ def metrics_summary():
|
||||
except Exception as e:
|
||||
return jsonify({"error": True, "message": str(e)})
|
||||
|
||||
|
||||
@app.route("/turf/api/metrics/daily")
|
||||
@app.route("/turf/api/metrics/daily/")
|
||||
def metrics_daily():
|
||||
@@ -1146,7 +1201,8 @@ def metrics_daily():
|
||||
"ROUND(AVG(roi_sp_net), 3) as roi_sp, SUM(quinte_5sur5) as quinte_5sur5, "
|
||||
"SUM(quinte_4sur5) as quinte_4sur5 "
|
||||
"FROM prediction_metrics WHERE date >= date('now', ?) GROUP BY date, source ORDER BY date DESC",
|
||||
(date_filter,))
|
||||
(date_filter,),
|
||||
)
|
||||
cols = [d[0] for d in cur.description]
|
||||
rows = [dict(zip(cols, row)) for row in cur.fetchall()]
|
||||
conn.close()
|
||||
@@ -1154,6 +1210,7 @@ def metrics_daily():
|
||||
except Exception as e:
|
||||
return jsonify({"error": True, "message": str(e)})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
load_models()
|
||||
app.run(host="0.0.0.0", port=8791, debug=False)
|
||||
|
||||
1551
dashboard_saas.html
Normal file
1551
dashboard_saas.html
Normal file
File diff suppressed because it is too large
Load Diff
250
docker-compose.yml
Normal file
250
docker-compose.yml
Normal file
@@ -0,0 +1,250 @@
|
||||
version: "3.9"
|
||||
|
||||
# ============================================================
|
||||
# H3R7Tech Turf SaaS — Docker Compose
|
||||
# Services: app (x4) + postgres + redis + prometheus + grafana + nginx
|
||||
# ============================================================
|
||||
|
||||
x-app-common: &app-common
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: runner
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- turf-net
|
||||
volumes:
|
||||
- ml-models:/app/data/models
|
||||
- app-logs:/app/logs
|
||||
|
||||
services:
|
||||
# ----------------------------------------------------------
|
||||
# PostgreSQL — primary database
|
||||
# ----------------------------------------------------------
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-turf_saas}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-turf}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
- ./infra/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-turf} -d ${POSTGRES_DB:-turf_saas}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
networks:
|
||||
- turf-net
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Redis — caching & session store
|
||||
# ----------------------------------------------------------
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--pass", "${REDIS_PASSWORD}", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
networks:
|
||||
- turf-net
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379"
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Combined API — main predictions + ideas API (port 8790)
|
||||
# ----------------------------------------------------------
|
||||
combined-api:
|
||||
<<: *app-common
|
||||
container_name: turf-combined-api
|
||||
command: gunicorn --bind 0.0.0.0:8790 --workers 2 --timeout 120 --access-logfile - --error-logfile - combined_api:app
|
||||
ports:
|
||||
- "127.0.0.1:8790:8790"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8790/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
environment:
|
||||
PORT: 8790
|
||||
SERVICE_NAME: combined-api
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Dashboard API — analytics & ML scoring (port 8791)
|
||||
# ----------------------------------------------------------
|
||||
dashboard-api:
|
||||
<<: *app-common
|
||||
container_name: turf-dashboard-api
|
||||
command: gunicorn --bind 0.0.0.0:8791 --workers 2 --timeout 120 --access-logfile - --error-logfile - dashboard_api:app
|
||||
ports:
|
||||
- "127.0.0.1:8791:8791"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8791/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
environment:
|
||||
PORT: 8791
|
||||
SERVICE_NAME: dashboard-api
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Portal Server — frontend portal (port 8792)
|
||||
# ----------------------------------------------------------
|
||||
portal:
|
||||
<<: *app-common
|
||||
container_name: turf-portal
|
||||
command: gunicorn --bind 0.0.0.0:8792 --workers 2 --timeout 60 --access-logfile - --error-logfile - portal_server:app
|
||||
ports:
|
||||
- "127.0.0.1:8792:8792"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8792/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
environment:
|
||||
PORT: 8792
|
||||
SERVICE_NAME: portal
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Scheduler — background jobs (no external port)
|
||||
# ----------------------------------------------------------
|
||||
scheduler:
|
||||
<<: *app-common
|
||||
container_name: turf-scheduler
|
||||
command: python turf_scheduler.py
|
||||
environment:
|
||||
SERVICE_NAME: scheduler
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Prometheus — metrics scraping
|
||||
# ----------------------------------------------------------
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.53.4
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||
- "--storage.tsdb.path=/prometheus"
|
||||
- "--storage.tsdb.retention.time=30d"
|
||||
- "--web.enable-lifecycle"
|
||||
volumes:
|
||||
- ./infra/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- ./infra/prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro
|
||||
- prometheus-data:/prometheus
|
||||
ports:
|
||||
- "127.0.0.1:9090:9090"
|
||||
networks:
|
||||
- turf-net
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--tries=1", "--spider", "http://localhost:9090/-/healthy"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Grafana — dashboards
|
||||
# ----------------------------------------------------------
|
||||
grafana:
|
||||
image: grafana/grafana:11.5.2
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
|
||||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD}
|
||||
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||
GF_SERVER_DOMAIN: ${DOMAIN:-localhost}
|
||||
GF_SERVER_ROOT_URL: https://${DOMAIN:-localhost}/grafana/
|
||||
GF_SERVER_SERVE_FROM_SUB_PATH: "true"
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
- ./infra/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- ./infra/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
networks:
|
||||
- turf-net
|
||||
depends_on:
|
||||
- prometheus
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Nginx — reverse proxy + TLS termination
|
||||
# ----------------------------------------------------------
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./infra/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
- certbot-www:/var/www/certbot:ro
|
||||
- certbot-certs:/etc/letsencrypt:ro
|
||||
networks:
|
||||
- turf-net
|
||||
depends_on:
|
||||
- combined-api
|
||||
- dashboard-api
|
||||
- portal
|
||||
healthcheck:
|
||||
test: ["CMD", "nginx", "-t"]
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Certbot — Let's Encrypt TLS certificate renewal
|
||||
# ----------------------------------------------------------
|
||||
certbot:
|
||||
image: certbot/certbot:latest
|
||||
restart: "no"
|
||||
volumes:
|
||||
- certbot-www:/var/www/certbot
|
||||
- certbot-certs:/etc/letsencrypt
|
||||
command: certonly --webroot --webroot-path=/var/www/certbot --email ${ADMIN_EMAIL} --agree-tos --no-eff-email -d ${DOMAIN}
|
||||
networks:
|
||||
- turf-net
|
||||
|
||||
# ============================================================
|
||||
# Named volumes — persistent storage
|
||||
# ============================================================
|
||||
volumes:
|
||||
postgres-data:
|
||||
driver: local
|
||||
redis-data:
|
||||
driver: local
|
||||
ml-models:
|
||||
driver: local
|
||||
app-logs:
|
||||
driver: local
|
||||
prometheus-data:
|
||||
driver: local
|
||||
grafana-data:
|
||||
driver: local
|
||||
certbot-www:
|
||||
driver: local
|
||||
certbot-certs:
|
||||
driver: local
|
||||
|
||||
# ============================================================
|
||||
# Network
|
||||
# ============================================================
|
||||
networks:
|
||||
turf-net:
|
||||
driver: bridge
|
||||
174
infra/grafana/dashboards/turf-saas-overview.json
Normal file
174
infra/grafana/dashboards/turf-saas-overview.json
Normal file
@@ -0,0 +1,174 @@
|
||||
{
|
||||
"title": "Turf SaaS — Overview",
|
||||
"uid": "turf-saas-overview",
|
||||
"schemaVersion": 38,
|
||||
"version": 1,
|
||||
"refresh": "30s",
|
||||
"time": { "from": "now-6h", "to": "now" },
|
||||
"tags": ["turf-saas"],
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "stat",
|
||||
"title": "Request Rate (req/s)",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 },
|
||||
"targets": [
|
||||
{
|
||||
"datasource": "Prometheus",
|
||||
"expr": "sum(rate(http_requests_total[5m]))",
|
||||
"legendFormat": "req/s"
|
||||
}
|
||||
],
|
||||
"options": { "colorMode": "background", "graphMode": "area" }
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "stat",
|
||||
"title": "Error Rate (5xx)",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 },
|
||||
"targets": [
|
||||
{
|
||||
"datasource": "Prometheus",
|
||||
"expr": "sum(rate(http_requests_total{status_code=~\"5..\"}[5m])) / sum(rate(http_requests_total[5m])) * 100",
|
||||
"legendFormat": "error %"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent",
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 0.5 },
|
||||
{ "color": "red", "value": 1 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "stat",
|
||||
"title": "p95 Latency",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 },
|
||||
"targets": [
|
||||
{
|
||||
"datasource": "Prometheus",
|
||||
"expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p95"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "s",
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 1 },
|
||||
{ "color": "red", "value": 2 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "stat",
|
||||
"title": "ML Top-1 Accuracy",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 },
|
||||
"targets": [
|
||||
{
|
||||
"datasource": "Prometheus",
|
||||
"expr": "ml_prediction_accuracy_ratio{accuracy_type=\"top1\"} * 100",
|
||||
"legendFormat": "top-1 %"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent",
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "red", "value": null },
|
||||
{ "color": "yellow", "value": 25 },
|
||||
{ "color": "green", "value": 35 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "timeseries",
|
||||
"title": "HTTP Requests by Service",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 },
|
||||
"targets": [
|
||||
{
|
||||
"datasource": "Prometheus",
|
||||
"expr": "sum(rate(http_requests_total[5m])) by (service)",
|
||||
"legendFormat": "{{ service }}"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "reqps" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "timeseries",
|
||||
"title": "Request Duration p50/p95/p99",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 },
|
||||
"targets": [
|
||||
{
|
||||
"datasource": "Prometheus",
|
||||
"expr": "histogram_quantile(0.50, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p50"
|
||||
},
|
||||
{
|
||||
"datasource": "Prometheus",
|
||||
"expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p95"
|
||||
},
|
||||
{
|
||||
"datasource": "Prometheus",
|
||||
"expr": "histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p99"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "s" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "timeseries",
|
||||
"title": "ML Predictions per Hour",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 },
|
||||
"targets": [
|
||||
{
|
||||
"datasource": "Prometheus",
|
||||
"expr": "sum(increase(ml_predictions_total[1h])) by (model_type)",
|
||||
"legendFormat": "{{ model_type }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "timeseries",
|
||||
"title": "DB Query Duration",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 },
|
||||
"targets": [
|
||||
{
|
||||
"datasource": "Prometheus",
|
||||
"expr": "histogram_quantile(0.95, sum(rate(db_query_duration_seconds_bucket[5m])) by (le, operation))",
|
||||
"legendFormat": "{{ operation }} p95"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "s" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
11
infra/grafana/provisioning/dashboards/dashboards.yml
Normal file
11
infra/grafana/provisioning/dashboards/dashboards.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: turf-saas-dashboards
|
||||
type: file
|
||||
disableDeletion: false
|
||||
updateIntervalSeconds: 30
|
||||
allowUiUpdates: true
|
||||
options:
|
||||
path: /var/lib/grafana/dashboards
|
||||
foldersFromFilesStructure: true
|
||||
13
infra/grafana/provisioning/datasources/prometheus.yml
Normal file
13
infra/grafana/provisioning/datasources/prometheus.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
uid: prometheus-main
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
editable: false
|
||||
jsonData:
|
||||
httpMethod: POST
|
||||
timeInterval: "15s"
|
||||
157
infra/nginx/conf.d/turf.conf
Normal file
157
infra/nginx/conf.d/turf.conf
Normal file
@@ -0,0 +1,157 @@
|
||||
# ============================================================
|
||||
# Nginx Virtual Host — Turf SaaS
|
||||
# ============================================================
|
||||
|
||||
# Upstream service pools
|
||||
upstream combined_api {
|
||||
server combined-api:8790;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
upstream dashboard_api {
|
||||
server dashboard-api:8791;
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
upstream portal {
|
||||
server portal:8792;
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
upstream grafana {
|
||||
server grafana:3000;
|
||||
keepalive 4;
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# HTTP → HTTPS redirect
|
||||
# ----------------------------------------------------------
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Let's Encrypt ACME challenge
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# HTTPS main server
|
||||
# ----------------------------------------------------------
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name ${DOMAIN};
|
||||
|
||||
# TLS configuration
|
||||
ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_stapling on;
|
||||
ssl_stapling_verify on;
|
||||
|
||||
# Security headers
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;" always;
|
||||
|
||||
# Limits
|
||||
client_max_body_size 10M;
|
||||
limit_conn conn_limit 20;
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Portal (root)
|
||||
# ----------------------------------------------------------
|
||||
location / {
|
||||
limit_req zone=global burst=50 nodelay;
|
||||
proxy_pass http://portal;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Combined API
|
||||
# ----------------------------------------------------------
|
||||
location /api/ {
|
||||
limit_req zone=api burst=20 nodelay;
|
||||
proxy_pass http://combined_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Dashboard API
|
||||
# ----------------------------------------------------------
|
||||
location /dashboard-api/ {
|
||||
limit_req zone=api burst=20 nodelay;
|
||||
proxy_pass http://dashboard_api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Grafana (restricted to internal/admin)
|
||||
# ----------------------------------------------------------
|
||||
location /grafana/ {
|
||||
# Restrict to admin IPs in production
|
||||
# allow 10.0.0.0/8;
|
||||
# deny all;
|
||||
|
||||
proxy_pass http://grafana;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Health check (no rate limiting)
|
||||
# ----------------------------------------------------------
|
||||
location /health {
|
||||
proxy_pass http://combined_api/health;
|
||||
proxy_http_version 1.1;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# Block common attack vectors
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
location ~* \.(env|git|bak|sql|log)$ {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
}
|
||||
65
infra/nginx/nginx.conf
Normal file
65
infra/nginx/nginx.conf
Normal file
@@ -0,0 +1,65 @@
|
||||
# ============================================================
|
||||
# Nginx — Main config
|
||||
# ============================================================
|
||||
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
use epoll;
|
||||
multi_accept on;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Logging
|
||||
log_format json_combined escape=json
|
||||
'{"time":"$time_iso8601",'
|
||||
'"remote_addr":"$remote_addr",'
|
||||
'"method":"$request_method",'
|
||||
'"uri":"$request_uri",'
|
||||
'"status":$status,'
|
||||
'"body_bytes":$body_bytes_sent,'
|
||||
'"duration":$request_time,'
|
||||
'"referrer":"$http_referer",'
|
||||
'"user_agent":"$http_user_agent",'
|
||||
'"x_forwarded_for":"$http_x_forwarded_for"}';
|
||||
|
||||
access_log /var/log/nginx/access.log json_combined;
|
||||
|
||||
# Performance
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
server_tokens off;
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 5;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/json
|
||||
application/xml
|
||||
image/svg+xml;
|
||||
|
||||
# Rate limiting zones
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/m;
|
||||
limit_req_zone $binary_remote_addr zone=global:20m rate=100r/m;
|
||||
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
|
||||
|
||||
# Include virtual hosts
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
||||
12
infra/postgres/init.sql
Normal file
12
infra/postgres/init.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- ============================================================
|
||||
-- PostgreSQL init script for Turf SaaS
|
||||
-- Runs on first container start (docker-entrypoint-initdb.d)
|
||||
-- ============================================================
|
||||
|
||||
-- Create extensions
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
||||
|
||||
-- Grant privileges to the app user
|
||||
GRANT ALL PRIVILEGES ON DATABASE turf_saas TO turf;
|
||||
GRANT ALL ON SCHEMA public TO turf;
|
||||
109
infra/prometheus/alerts.yml
Normal file
109
infra/prometheus/alerts.yml
Normal file
@@ -0,0 +1,109 @@
|
||||
# ============================================================
|
||||
# Prometheus Alert Rules — Turf SaaS
|
||||
# ============================================================
|
||||
|
||||
groups:
|
||||
# ----------------------------------------------------------
|
||||
# HTTP / API Alerts
|
||||
# ----------------------------------------------------------
|
||||
- name: http_alerts
|
||||
rules:
|
||||
- alert: HighErrorRate
|
||||
expr: |
|
||||
sum(rate(http_requests_total{status_code=~"5.."}[5m])) by (service)
|
||||
/
|
||||
sum(rate(http_requests_total[5m])) by (service)
|
||||
> 0.01
|
||||
for: 2m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "High 5xx error rate on {{ $labels.service }}"
|
||||
description: "Error rate is {{ $value | humanizePercentage }} (threshold: 1%)"
|
||||
|
||||
- alert: HighLatency
|
||||
expr: |
|
||||
histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, service))
|
||||
> 2
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "High p95 latency on {{ $labels.service }}"
|
||||
description: "p95 latency is {{ $value | humanizeDuration }} (threshold: 2s)"
|
||||
|
||||
- alert: ServiceDown
|
||||
expr: up == 0
|
||||
for: 1m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "Service {{ $labels.job }} is down"
|
||||
description: "{{ $labels.instance }} has been unreachable for >1 minute"
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Database Alerts
|
||||
# ----------------------------------------------------------
|
||||
- name: database_alerts
|
||||
rules:
|
||||
- alert: PostgresDown
|
||||
expr: pg_up == 0
|
||||
for: 1m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "PostgreSQL is down"
|
||||
description: "Cannot connect to PostgreSQL database"
|
||||
|
||||
- alert: PostgresDiskUsageHigh
|
||||
expr: |
|
||||
(pg_database_size_bytes / (1024 * 1024 * 1024)) > 10
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "PostgreSQL database size > 10GB"
|
||||
description: "Database {{ $labels.datname }} is {{ $value | humanize }}GB"
|
||||
|
||||
- alert: DiskSpaceHigh
|
||||
expr: |
|
||||
(node_filesystem_size_bytes - node_filesystem_free_bytes) / node_filesystem_size_bytes * 100
|
||||
> 80
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Disk usage > 80% on {{ $labels.instance }}"
|
||||
description: "{{ $labels.mountpoint }} is at {{ $value | humanizePercentage }}"
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# ML Prediction Alerts
|
||||
# ----------------------------------------------------------
|
||||
- name: ml_alerts
|
||||
rules:
|
||||
- alert: MLAccuracyDegraded
|
||||
expr: ml_prediction_accuracy_ratio{accuracy_type="top1"} < 0.30
|
||||
for: 60m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "ML top-1 accuracy below 30%"
|
||||
description: "Current accuracy: {{ $value | humanizePercentage }}"
|
||||
|
||||
- alert: MLPredictionDriftHigh
|
||||
expr: ml_prediction_drift_score > 0.5
|
||||
for: 30m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "ML feature drift detected"
|
||||
description: "Drift score for {{ $labels.feature_group }}: {{ $value }}"
|
||||
|
||||
- alert: NoPredictionsGenerated
|
||||
expr: increase(ml_predictions_total[1h]) == 0
|
||||
for: 2h
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "No ML predictions generated in the last 2 hours"
|
||||
description: "Check if the scheduler is running and PMU data is being scraped"
|
||||
68
infra/prometheus/prometheus.yml
Normal file
68
infra/prometheus/prometheus.yml
Normal file
@@ -0,0 +1,68 @@
|
||||
# ============================================================
|
||||
# Prometheus Configuration — Turf SaaS
|
||||
# ============================================================
|
||||
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
external_labels:
|
||||
project: turf-saas
|
||||
env: production
|
||||
|
||||
# Alertmanager — wire up when available
|
||||
alerting:
|
||||
alertmanagers:
|
||||
- static_configs:
|
||||
- targets: []
|
||||
|
||||
# Load alert rules
|
||||
rule_files:
|
||||
- "alerts.yml"
|
||||
|
||||
# ============================================================
|
||||
# Scrape targets
|
||||
# ============================================================
|
||||
scrape_configs:
|
||||
# Prometheus self-monitoring
|
||||
- job_name: prometheus
|
||||
static_configs:
|
||||
- targets: [localhost:9090]
|
||||
|
||||
# Combined API
|
||||
- job_name: combined-api
|
||||
static_configs:
|
||||
- targets: [combined-api:8790]
|
||||
metrics_path: /metrics
|
||||
scrape_interval: 15s
|
||||
|
||||
# Dashboard API
|
||||
- job_name: dashboard-api
|
||||
static_configs:
|
||||
- targets: [dashboard-api:8791]
|
||||
metrics_path: /metrics
|
||||
scrape_interval: 15s
|
||||
|
||||
# Portal
|
||||
- job_name: portal
|
||||
static_configs:
|
||||
- targets: [portal:8792]
|
||||
metrics_path: /metrics
|
||||
scrape_interval: 30s
|
||||
|
||||
# PostgreSQL exporter (if deployed)
|
||||
- job_name: postgres
|
||||
static_configs:
|
||||
- targets: [postgres-exporter:9187]
|
||||
scrape_interval: 30s
|
||||
|
||||
# Redis exporter (if deployed)
|
||||
- job_name: redis
|
||||
static_configs:
|
||||
- targets: [redis-exporter:9121]
|
||||
scrape_interval: 30s
|
||||
|
||||
# Node exporter (host metrics)
|
||||
- job_name: node
|
||||
static_configs:
|
||||
- targets: [host.docker.internal:9100]
|
||||
scrape_interval: 30s
|
||||
45
infra/scripts/backup_db.sh
Executable file
45
infra/scripts/backup_db.sh
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# Automated PostgreSQL Backup Script
|
||||
# Run daily via cron: 0 2 * * * /opt/turf-saas/infra/scripts/backup_db.sh
|
||||
# ============================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BACKUP_DIR="${BACKUP_DIR:-/opt/backups/turf-saas}"
|
||||
KEEP_DAYS="${KEEP_DAYS:-30}"
|
||||
DB_NAME="${POSTGRES_DB:-turf_saas}"
|
||||
DB_USER="${POSTGRES_USER:-turf}"
|
||||
DB_HOST="${POSTGRES_HOST:-postgres}"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_FILE="${BACKUP_DIR}/turf_saas_${TIMESTAMP}.sql.gz"
|
||||
|
||||
echo "[$(date -Iseconds)] Starting backup: ${BACKUP_FILE}"
|
||||
|
||||
# Ensure backup directory exists
|
||||
mkdir -p "${BACKUP_DIR}"
|
||||
|
||||
# Perform backup
|
||||
PGPASSWORD="${POSTGRES_PASSWORD}" pg_dump \
|
||||
-h "${DB_HOST}" \
|
||||
-U "${DB_USER}" \
|
||||
-d "${DB_NAME}" \
|
||||
--no-owner \
|
||||
--no-acl \
|
||||
| gzip > "${BACKUP_FILE}"
|
||||
|
||||
SIZE=$(du -sh "${BACKUP_FILE}" | cut -f1)
|
||||
echo "[$(date -Iseconds)] Backup complete: ${BACKUP_FILE} (${SIZE})"
|
||||
|
||||
# Remove backups older than KEEP_DAYS
|
||||
find "${BACKUP_DIR}" -name "turf_saas_*.sql.gz" -mtime "+${KEEP_DAYS}" -delete
|
||||
echo "[$(date -Iseconds)] Old backups cleaned (kept last ${KEEP_DAYS} days)"
|
||||
|
||||
# Optional: notify on completion
|
||||
if [ -n "${TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${TELEGRAM_CHAT_ID:-}" ]; then
|
||||
curl -s -X POST \
|
||||
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
-d chat_id="${TELEGRAM_CHAT_ID}" \
|
||||
-d text="✅ DB Backup OK: turf_saas ${TIMESTAMP} (${SIZE})" \
|
||||
> /dev/null || true
|
||||
fi
|
||||
21
infra/turf-saas-leadhunter.service
Normal file
21
infra/turf-saas-leadhunter.service
Normal file
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=H3R7Tech LeadHunter API (Port 8775)
|
||||
Documentation=https://portal-kolifee.duckdns.org
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=h3r7
|
||||
WorkingDirectory=/home/h3r7/turf_saas
|
||||
|
||||
# Charger les variables d'environnement depuis /home/h3r7/.env
|
||||
# (notamment GOOGLE_PLACES_API_KEY)
|
||||
EnvironmentFile=/home/h3r7/.env
|
||||
|
||||
ExecStart=/home/h3r7/turf_saas/venv/bin/python3 /home/h3r7/turf_saas/leadhunter_api.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
Environment=PYTHONPATH=/home/h3r7/turf_saas
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
467
landing.html
Normal file
467
landing.html
Normal file
@@ -0,0 +1,467 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Turf IA — Prédictions PMU par Intelligence Artificielle</title>
|
||||
<meta name="description" content="Boostez vos paris PMU avec nos prédictions IA. Analyse XGBoost, value bets, alertes Telegram. Essai gratuit.">
|
||||
<style>
|
||||
/* ===== RESET & BASE ===== */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--green: #00c853;
|
||||
--green-d: #009624;
|
||||
--blue: #1565c0;
|
||||
--blue-l: #1e88e5;
|
||||
--gold: #ffd600;
|
||||
--dark: #0d1117;
|
||||
--dark2: #161b22;
|
||||
--dark3: #21262d;
|
||||
--text: #e6edf3;
|
||||
--muted: #8b949e;
|
||||
--border: #30363d;
|
||||
--radius: 12px;
|
||||
}
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--dark);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
a { color: inherit; text-decoration: none; }
|
||||
img { max-width: 100%; }
|
||||
|
||||
/* ===== NAV ===== */
|
||||
nav {
|
||||
position: sticky; top: 0; z-index: 100;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 5%;
|
||||
background: rgba(13,17,23,0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.nav-logo { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 1.2rem; }
|
||||
.nav-logo .badge { background: var(--green); color: #000; padding: 2px 8px; border-radius: 20px; font-size: .75rem; }
|
||||
.nav-links { display: flex; align-items: center; gap: 28px; }
|
||||
.nav-links a { color: var(--muted); font-size: .95rem; transition: color .2s; }
|
||||
.nav-links a:hover { color: var(--text); }
|
||||
.nav-cta { display: flex; gap: 10px; }
|
||||
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 9px 20px; border-radius: 8px; font-size: .9rem; font-weight: 600; cursor: pointer; border: none; transition: all .2s; }
|
||||
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||
.btn-ghost:hover { border-color: var(--muted); }
|
||||
.btn-primary { background: var(--green); color: #000; }
|
||||
.btn-primary:hover { background: var(--green-d); transform: translateY(-1px); }
|
||||
.btn-lg { padding: 14px 32px; font-size: 1.05rem; border-radius: 10px; }
|
||||
|
||||
/* ===== HERO ===== */
|
||||
.hero {
|
||||
min-height: 90vh;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
text-align: center;
|
||||
padding: 80px 5% 60px;
|
||||
background: radial-gradient(ellipse 80% 60% at 50% 0%, rgba(0,200,83,.08) 0%, transparent 70%);
|
||||
position: relative;
|
||||
}
|
||||
.hero-eyebrow {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
background: rgba(0,200,83,.1); border: 1px solid rgba(0,200,83,.3);
|
||||
padding: 6px 16px; border-radius: 20px;
|
||||
font-size: .85rem; color: var(--green); margin-bottom: 24px;
|
||||
}
|
||||
.hero h1 { font-size: clamp(2rem, 5vw, 3.6rem); font-weight: 800; line-height: 1.15; max-width: 780px; margin-bottom: 20px; }
|
||||
.hero h1 span { color: var(--green); }
|
||||
.hero p { font-size: 1.15rem; color: var(--muted); max-width: 580px; margin-bottom: 36px; }
|
||||
.hero-actions { display: flex; gap: 14px; flex-wrap: wrap; justify-content: center; }
|
||||
.hero-stats {
|
||||
display: flex; gap: 40px; margin-top: 60px; flex-wrap: wrap; justify-content: center;
|
||||
}
|
||||
.stat { text-align: center; }
|
||||
.stat strong { display: block; font-size: 2rem; font-weight: 800; color: var(--green); }
|
||||
.stat span { font-size: .85rem; color: var(--muted); }
|
||||
|
||||
/* ===== SECTIONS ===== */
|
||||
section { padding: 80px 5%; }
|
||||
.section-label { color: var(--green); font-size: .85rem; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px; }
|
||||
h2 { font-size: clamp(1.6rem, 3vw, 2.4rem); font-weight: 800; margin-bottom: 16px; }
|
||||
.subtitle { color: var(--muted); font-size: 1.05rem; max-width: 560px; margin: 0 auto 50px; text-align: center; }
|
||||
.section-center { text-align: center; }
|
||||
|
||||
/* ===== FEATURES ===== */
|
||||
.features-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 20px; max-width: 1100px; margin: 0 auto; }
|
||||
.feature-card {
|
||||
background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 28px; transition: border-color .2s, transform .2s;
|
||||
}
|
||||
.feature-card:hover { border-color: var(--green); transform: translateY(-3px); }
|
||||
.feature-icon { font-size: 2rem; margin-bottom: 14px; }
|
||||
.feature-card h3 { font-size: 1.1rem; font-weight: 700; margin-bottom: 8px; }
|
||||
.feature-card p { color: var(--muted); font-size: .9rem; line-height: 1.6; }
|
||||
|
||||
/* ===== PRICING ===== */
|
||||
.pricing-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; max-width: 960px; margin: 0 auto; }
|
||||
.plan-card {
|
||||
background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 32px; position: relative; transition: transform .2s;
|
||||
}
|
||||
.plan-card:hover { transform: translateY(-4px); }
|
||||
.plan-card.popular { border-color: var(--green); }
|
||||
.popular-badge {
|
||||
position: absolute; top: -12px; left: 50%; transform: translateX(-50%);
|
||||
background: var(--green); color: #000; padding: 3px 16px; border-radius: 20px;
|
||||
font-size: .75rem; font-weight: 700;
|
||||
}
|
||||
.plan-name { font-size: 1.1rem; font-weight: 700; margin-bottom: 8px; }
|
||||
.plan-price { font-size: 2.6rem; font-weight: 800; margin: 12px 0 4px; }
|
||||
.plan-price sup { font-size: 1.2rem; vertical-align: top; margin-top: 10px; }
|
||||
.plan-price span { font-size: 1rem; font-weight: 400; color: var(--muted); }
|
||||
.plan-desc { color: var(--muted); font-size: .88rem; margin-bottom: 20px; }
|
||||
.plan-features { list-style: none; margin-bottom: 28px; }
|
||||
.plan-features li { display: flex; align-items: flex-start; gap: 10px; padding: 7px 0; font-size: .9rem; }
|
||||
.plan-features li::before { content: "✓"; color: var(--green); font-weight: 700; flex-shrink: 0; }
|
||||
.plan-features li.disabled { color: var(--muted); }
|
||||
.plan-features li.disabled::before { content: "×"; color: var(--border); }
|
||||
.btn-plan { width: 100%; text-align: center; justify-content: center; }
|
||||
|
||||
/* ===== HOW IT WORKS ===== */
|
||||
.steps { display: flex; flex-direction: column; gap: 0; max-width: 700px; margin: 0 auto; }
|
||||
.step { display: flex; gap: 24px; padding: 28px 0; border-bottom: 1px solid var(--border); }
|
||||
.step:last-child { border-bottom: none; }
|
||||
.step-num { width: 44px; height: 44px; border-radius: 50%; background: rgba(0,200,83,.1); border: 2px solid var(--green); display: flex; align-items: center; justify-content: center; font-weight: 800; color: var(--green); flex-shrink: 0; }
|
||||
.step-body h3 { font-size: 1.05rem; font-weight: 700; margin-bottom: 6px; }
|
||||
.step-body p { color: var(--muted); font-size: .9rem; }
|
||||
|
||||
/* ===== FAQ ===== */
|
||||
.faq-list { max-width: 720px; margin: 0 auto; }
|
||||
details { border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 10px; overflow: hidden; }
|
||||
details[open] { border-color: var(--green); }
|
||||
summary {
|
||||
padding: 18px 22px; font-weight: 600; cursor: pointer; list-style: none;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
summary::after { content: "+"; color: var(--green); font-size: 1.3rem; }
|
||||
details[open] summary::after { content: "−"; }
|
||||
.faq-answer { padding: 0 22px 18px; color: var(--muted); font-size: .93rem; line-height: 1.7; }
|
||||
|
||||
/* ===== CTA BANNER ===== */
|
||||
.cta-banner {
|
||||
background: linear-gradient(135deg, rgba(0,200,83,.12) 0%, rgba(21,101,192,.12) 100%);
|
||||
border: 1px solid var(--border); border-radius: 16px;
|
||||
padding: 60px; text-align: center; max-width: 820px; margin: 0 auto;
|
||||
}
|
||||
.cta-banner h2 { margin-bottom: 12px; }
|
||||
.cta-banner p { color: var(--muted); margin-bottom: 28px; }
|
||||
|
||||
/* ===== FOOTER ===== */
|
||||
footer {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 40px 5%;
|
||||
display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 40px;
|
||||
}
|
||||
.footer-brand p { color: var(--muted); font-size: .88rem; margin-top: 10px; max-width: 240px; }
|
||||
.footer-col h4 { font-size: .85rem; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; margin-bottom: 14px; color: var(--muted); }
|
||||
.footer-col a { display: block; color: var(--muted); font-size: .88rem; margin-bottom: 8px; transition: color .2s; }
|
||||
.footer-col a:hover { color: var(--text); }
|
||||
.footer-bottom { border-top: 1px solid var(--border); padding: 20px 5%; text-align: center; color: var(--muted); font-size: .82rem; }
|
||||
|
||||
/* ===== TOAST ===== */
|
||||
#toast {
|
||||
position: fixed; bottom: 24px; right: 24px; z-index: 999;
|
||||
padding: 14px 22px; border-radius: 10px; font-size: .9rem; font-weight: 600;
|
||||
transform: translateY(80px); opacity: 0; transition: all .3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
#toast.show { transform: translateY(0); opacity: 1; }
|
||||
#toast.success { background: var(--green); color: #000; }
|
||||
#toast.error { background: #f85149; color: #fff; }
|
||||
|
||||
/* ===== RESPONSIVE ===== */
|
||||
@media (max-width: 768px) {
|
||||
.nav-links { display: none; }
|
||||
footer { grid-template-columns: 1fr 1fr; }
|
||||
.hero-stats { gap: 24px; }
|
||||
.cta-banner { padding: 36px 24px; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
footer { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- NAV -->
|
||||
<nav>
|
||||
<div class="nav-logo">
|
||||
🏇 Turf IA
|
||||
<span class="badge">BETA</span>
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a href="#features">Fonctionnalités</a>
|
||||
<a href="#pricing">Tarifs</a>
|
||||
<a href="#how">Comment ça marche</a>
|
||||
<a href="#faq">FAQ</a>
|
||||
</div>
|
||||
<div class="nav-cta">
|
||||
<a href="/login" class="btn btn-ghost">Connexion</a>
|
||||
<a href="/register" class="btn btn-primary">S'inscrire gratuitement</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- HERO -->
|
||||
<section class="hero">
|
||||
<div class="hero-eyebrow">
|
||||
🤖 Intelligence Artificielle · XGBoost · Données PMU temps réel
|
||||
</div>
|
||||
<h1>Pariez plus <span>intelligemment</span><br>grâce à l'IA</h1>
|
||||
<p>Nos modèles XGBoost analysent chaque course PMU en temps réel — cotes, historique, jockeys, météo — pour vous donner les meilleures prédictions du marché.</p>
|
||||
<div class="hero-actions">
|
||||
<a href="/register" class="btn btn-primary btn-lg">Commencer gratuitement</a>
|
||||
<a href="#how" class="btn btn-ghost btn-lg">Voir comment ça marche</a>
|
||||
</div>
|
||||
<div class="hero-stats">
|
||||
<div class="stat"><strong>+73%</strong><span>précision Top-3</span></div>
|
||||
<div class="stat"><strong>150+</strong><span>courses analysées/jour</span></div>
|
||||
<div class="stat"><strong>2.4s</strong><span>temps de réponse moyen</span></div>
|
||||
<div class="stat"><strong>3 plans</strong><span>adaptés à chaque profil</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FEATURES -->
|
||||
<section id="features">
|
||||
<div class="section-center">
|
||||
<div class="section-label">Fonctionnalités</div>
|
||||
<h2>Tout ce dont vous avez besoin pour gagner</h2>
|
||||
<p class="subtitle">Un moteur IA complet, des alertes instantanées, et des analyses détaillées pour chaque parieur.</p>
|
||||
</div>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🧠</div>
|
||||
<h3>Prédictions XGBoost</h3>
|
||||
<p>Modèle entraîné sur des milliers de courses PMU. Probabilités Top-1 et Top-3 pour chaque partant, mis à jour en continu.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">💎</div>
|
||||
<h3>Value Bets identifiés</h3>
|
||||
<p>Détection automatique des cotes sous-évaluées par le marché. Seulement les paris où l'espérance mathématique est positive.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📱</div>
|
||||
<h3>Alertes Telegram</h3>
|
||||
<p>Recevez les meilleures opportunités directement sur votre téléphone, avant le départ, avec toutes les infos clés.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📊</div>
|
||||
<h3>Dashboard temps réel</h3>
|
||||
<p>Tableau de bord complet : courses du jour, historique de performance, ROI, statistiques par hippodrome et discipline.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🌤️</div>
|
||||
<h3>Analyse météo & terrain</h3>
|
||||
<p>Impact des conditions météo et de l'état du terrain intégré dans chaque prédiction pour une précision maximale.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📤</div>
|
||||
<h3>Export CSV & API</h3>
|
||||
<p>Exportez vos données, intégrez nos prédictions dans vos propres outils via notre API documentée (plan Pro).</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- HOW IT WORKS -->
|
||||
<section id="how" style="background: var(--dark2); margin: 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border);">
|
||||
<div class="section-center">
|
||||
<div class="section-label">Comment ça marche</div>
|
||||
<h2>En 3 étapes, prêt à parier</h2>
|
||||
<p class="subtitle">De l'inscription à votre première prédiction en moins de 2 minutes.</p>
|
||||
</div>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<div class="step-num">1</div>
|
||||
<div class="step-body">
|
||||
<h3>Créez votre compte gratuitement</h3>
|
||||
<p>Inscription en 30 secondes, sans carte bancaire. Accès immédiat au plan Free avec un aperçu des prédictions du jour.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">2</div>
|
||||
<div class="step-body">
|
||||
<h3>Choisissez votre plan</h3>
|
||||
<p>Free pour découvrir, Premium (9,90€/mois) pour toutes les courses et alertes, Pro (24,90€/mois) pour l'API et les exports.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">3</div>
|
||||
<div class="step-body">
|
||||
<h3>Recevez vos premières prédictions</h3>
|
||||
<p>Notre IA analyse les 150+ courses du jour. Accédez aux Top-3, value bets et probabilités depuis votre dashboard ou Telegram.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PRICING -->
|
||||
<section id="pricing">
|
||||
<div class="section-center">
|
||||
<div class="section-label">Tarifs</div>
|
||||
<h2>Des prix transparents</h2>
|
||||
<p class="subtitle">Commencez gratuitement. Passez au niveau supérieur quand vous êtes prêt.</p>
|
||||
</div>
|
||||
<div class="pricing-grid">
|
||||
<!-- FREE -->
|
||||
<div class="plan-card">
|
||||
<div class="plan-name">Free</div>
|
||||
<div class="plan-price">0<sup>€</sup><span>/mois</span></div>
|
||||
<p class="plan-desc">Pour découvrir la puissance de l'IA turf.</p>
|
||||
<ul class="plan-features">
|
||||
<li>Aperçu Top-3 du jour (limité)</li>
|
||||
<li>1 course complète par jour</li>
|
||||
<li>Statistiques basiques</li>
|
||||
<li class="disabled">Alertes Telegram</li>
|
||||
<li class="disabled">Toutes les courses</li>
|
||||
<li class="disabled">Value bets</li>
|
||||
<li class="disabled">Export CSV</li>
|
||||
<li class="disabled">Accès API</li>
|
||||
</ul>
|
||||
<a href="/register" class="btn btn-ghost btn-plan">Commencer gratuitement</a>
|
||||
</div>
|
||||
<!-- PREMIUM -->
|
||||
<div class="plan-card popular">
|
||||
<div class="popular-badge">⭐ Le plus populaire</div>
|
||||
<div class="plan-name">Premium</div>
|
||||
<div class="plan-price">9<sup>€</sup>,90<span>/mois</span></div>
|
||||
<p class="plan-desc">Pour les parieurs sérieux qui veulent un vrai avantage.</p>
|
||||
<ul class="plan-features">
|
||||
<li>Toutes les courses du jour</li>
|
||||
<li>Prédictions Top-1 et Top-3</li>
|
||||
<li>Value bets identifiés</li>
|
||||
<li>Alertes Telegram configurables</li>
|
||||
<li>Historique 90 jours</li>
|
||||
<li>Analyse météo & terrain</li>
|
||||
<li class="disabled">Export CSV</li>
|
||||
<li class="disabled">Accès API</li>
|
||||
</ul>
|
||||
<a href="/register?plan=premium" class="btn btn-primary btn-plan">Choisir Premium</a>
|
||||
</div>
|
||||
<!-- PRO -->
|
||||
<div class="plan-card">
|
||||
<div class="plan-name">Pro</div>
|
||||
<div class="plan-price">24<sup>€</sup>,90<span>/mois</span></div>
|
||||
<p class="plan-desc">Pour les professionnels et développeurs qui veulent tout.</p>
|
||||
<ul class="plan-features">
|
||||
<li>Tout du plan Premium</li>
|
||||
<li>Export CSV illimité</li>
|
||||
<li>Accès API REST documentée</li>
|
||||
<li>Backtest personnalisé</li>
|
||||
<li>Historique illimité</li>
|
||||
<li>Support prioritaire</li>
|
||||
<li>Webhook alertes personnalisées</li>
|
||||
<li>Multi-compte (5 utilisateurs)</li>
|
||||
</ul>
|
||||
<a href="/register?plan=pro" class="btn btn-ghost btn-plan">Choisir Pro</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FAQ -->
|
||||
<section id="faq" style="background: var(--dark2); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border);">
|
||||
<div class="section-center">
|
||||
<div class="section-label">FAQ</div>
|
||||
<h2>Questions fréquentes</h2>
|
||||
</div>
|
||||
<div class="faq-list">
|
||||
<details>
|
||||
<summary>Comment fonctionne le modèle IA ?</summary>
|
||||
<div class="faq-answer">Notre modèle XGBoost est entraîné sur plusieurs années de données PMU : cotes, historique des chevaux et drivers, conditions météo, état du terrain, statistiques par hippodrome. Il calcule pour chaque partant une probabilité d'arriver dans le Top-1 et Top-3, ainsi qu'un score de value bet comparant notre estimation à la cote du marché.</div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Les prédictions garantissent-elles des gains ?</summary>
|
||||
<div class="faq-answer">Non. Aucune prédiction ne garantit des gains. Le pari hippique reste un jeu de hasard. Notre IA améliore vos chances en identifiant les opportunités statistiquement favorables, mais les résultats passés ne préjugent pas des résultats futurs. Pariez de façon responsable.</div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Puis-je annuler à tout moment ?</summary>
|
||||
<div class="faq-answer">Oui, sans engagement. Vous pouvez annuler votre abonnement Premium ou Pro à tout moment depuis votre espace compte. L'accès reste actif jusqu'à la fin de la période payée.</div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Les alertes Telegram fonctionnent-elles sur mobile ?</summary>
|
||||
<div class="faq-answer">Oui. Après activation dans vos paramètres, vous recevrez les alertes value bets et top picks directement dans votre application Telegram, avec toutes les informations nécessaires pour parier rapidement avant le départ.</div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>L'API est-elle compatible avec d'autres outils ?</summary>
|
||||
<div class="faq-answer">Oui. L'API REST du plan Pro est documentée (OpenAPI/Swagger) et compatible avec n'importe quel outil : Python, JavaScript, n8n, Zapier, Excel, etc. Un token personnel vous est fourni dans votre dashboard.</div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Quelles disciplines sont couvertes ?</summary>
|
||||
<div class="faq-answer">Nous couvrons toutes les disciplines PMU : Plat, Trot Attelé, Trot Monté, et Galop sur les hippodromes français. Les courses Quinté+ sont signalées et prioritaires dans votre dashboard.</div>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA BANNER -->
|
||||
<section>
|
||||
<div class="cta-banner">
|
||||
<h2>Prêt à parier plus intelligemment ?</h2>
|
||||
<p>Rejoignez des centaines de parieurs qui utilisent déjà Turf IA chaque jour. Essai gratuit, sans carte bancaire.</p>
|
||||
<a href="/register" class="btn btn-primary btn-lg">Créer mon compte gratuit →</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer>
|
||||
<div class="footer-brand">
|
||||
<div style="font-weight: 700; font-size: 1.1rem;">🏇 Turf IA</div>
|
||||
<p>Prédictions PMU par intelligence artificielle. Analyse XGBoost, value bets, alertes temps réel.</p>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>Produit</h4>
|
||||
<a href="#features">Fonctionnalités</a>
|
||||
<a href="#pricing">Tarifs</a>
|
||||
<a href="#how">Comment ça marche</a>
|
||||
<a href="/dashboard">Dashboard</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>Compte</h4>
|
||||
<a href="/login">Connexion</a>
|
||||
<a href="/register">Inscription</a>
|
||||
<a href="/account">Mon compte</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>Légal</h4>
|
||||
<a href="/legal/cgu">CGU</a>
|
||||
<a href="/legal/cgv">CGV</a>
|
||||
<a href="/legal/privacy">Confidentialité</a>
|
||||
<a href="/legal/cookies">Cookies</a>
|
||||
</div>
|
||||
</footer>
|
||||
<div class="footer-bottom">
|
||||
<p>© 2026 Turf IA — H3R7 Tech. Tous droits réservés. Le jeu peut être dangereux, jouez de façon responsable. <strong>18+</strong></p>
|
||||
</div>
|
||||
|
||||
<!-- TOAST -->
|
||||
<div id="toast"></div>
|
||||
|
||||
<script>
|
||||
function showToast(msg, type = 'success') {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.className = `show ${type}`;
|
||||
setTimeout(() => { t.className = ''; }, 3500);
|
||||
}
|
||||
|
||||
// Smooth scroll for anchor links
|
||||
document.querySelectorAll('a[href^="#"]').forEach(a => {
|
||||
a.addEventListener('click', e => {
|
||||
const target = document.querySelector(a.getAttribute('href'));
|
||||
if (target) { e.preventDefault(); target.scrollIntoView({ behavior: 'smooth' }); }
|
||||
});
|
||||
});
|
||||
|
||||
// Track CTA clicks
|
||||
document.querySelectorAll('[href="/register"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
try { localStorage.setItem('turf_signup_source', 'landing'); } catch(_) {}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
303
leadhunter_api.py
Normal file
303
leadhunter_api.py
Normal file
@@ -0,0 +1,303 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
H3R7Tech — LeadHunter API
|
||||
===========================
|
||||
Service Flask sur port 8775 exposant les endpoints LeadHunter.
|
||||
|
||||
Endpoints :
|
||||
GET /api/leads — Liste les leads (filtres: status, limit, offset)
|
||||
POST /api/leads/scrape — Lance un job de scraping asynchrone
|
||||
GET /api/leads/stats — Statistiques globales du CRM
|
||||
GET /api/leads/export — Export CSV des leads
|
||||
PATCH /api/leads/<id>/status — Met à jour le statut d'un lead
|
||||
|
||||
Port : 8775 (8769 occupé par depenses_trello/app.py, 8770 occupé par turf_scraper/crm_api.py — corrigé HRT-66)
|
||||
|
||||
Auteur: H3R7Tech Backend Engineer
|
||||
Issue: HRT-66
|
||||
"""
|
||||
|
||||
import os
|
||||
import threading
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from flask import Flask, jsonify, request, Response
|
||||
from flask_cors import CORS
|
||||
|
||||
# Import des modules LeadHunter
|
||||
from leadhunter_crm import (
|
||||
init_db,
|
||||
insert_leads,
|
||||
get_leads,
|
||||
get_lead_by_id,
|
||||
update_lead_status,
|
||||
get_stats,
|
||||
export_csv,
|
||||
VALID_STATUSES,
|
||||
DB_PATH,
|
||||
)
|
||||
from leadhunter_scraper import run_scraping, GOOGLE_PLACES_API_KEY
|
||||
from leadhunter_scorer import LeadScorer
|
||||
|
||||
# ─── Assertions au démarrage ─────────────────────────────────────────────────
|
||||
# Vérification obligatoire : la clé API doit être présente au démarrage
|
||||
assert os.environ.get("GOOGLE_PLACES_API_KEY"), (
|
||||
"GOOGLE_PLACES_API_KEY manquante. "
|
||||
"Ajouter dans /home/h3r7/.env : export GOOGLE_PLACES_API_KEY=xxx"
|
||||
)
|
||||
|
||||
# ─── Logging ────────────────────────────────────────────────────────────────
|
||||
logger = logging.getLogger("leadhunter.api")
|
||||
|
||||
_handler = RotatingFileHandler(
|
||||
"/home/h3r7/leadhunter.log",
|
||||
maxBytes=5 * 1024 * 1024,
|
||||
backupCount=3,
|
||||
)
|
||||
_handler.setFormatter(
|
||||
logging.Formatter("%(asctime)s %(levelname)-8s %(name)s — %(message)s")
|
||||
)
|
||||
logger.setLevel(logging.INFO)
|
||||
if not logger.handlers:
|
||||
logger.addHandler(_handler)
|
||||
logger.addHandler(logging.StreamHandler())
|
||||
|
||||
# ─── App Flask ───────────────────────────────────────────────────────────────
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
|
||||
# Scorer singleton
|
||||
scorer = LeadScorer()
|
||||
|
||||
# État global du job de scraping (simple flag — pas de celery nécessaire pour le POC)
|
||||
_scrape_job = {
|
||||
"running": False,
|
||||
"last_run": None,
|
||||
"last_count": 0,
|
||||
"last_error": None,
|
||||
}
|
||||
_scrape_lock = threading.Lock()
|
||||
|
||||
# ─── Init DB ─────────────────────────────────────────────────────────────────
|
||||
init_db(DB_PATH)
|
||||
logger.info("LeadHunter API démarrée — DB initialisée.")
|
||||
|
||||
|
||||
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _run_scrape_job(max_leads: int, use_google: bool, use_osm: bool) -> None:
|
||||
"""Job de scraping exécuté dans un thread séparé."""
|
||||
with _scrape_lock:
|
||||
_scrape_job["running"] = True
|
||||
_scrape_job["last_error"] = None
|
||||
|
||||
try:
|
||||
leads_raw = run_scraping(
|
||||
max_leads=max_leads,
|
||||
use_google=use_google,
|
||||
use_osm=use_osm,
|
||||
)
|
||||
leads_scored = scorer.score_leads(leads_raw)
|
||||
inserted_ids = insert_leads(leads_scored)
|
||||
|
||||
with _scrape_lock:
|
||||
_scrape_job["last_count"] = len(inserted_ids)
|
||||
from datetime import datetime
|
||||
|
||||
_scrape_job["last_run"] = datetime.utcnow().isoformat() + "Z"
|
||||
|
||||
logger.info(f"Scrape job terminé : {len(inserted_ids)} leads insérés.")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Scrape job erreur : {e}")
|
||||
with _scrape_lock:
|
||||
_scrape_job["last_error"] = str(e)
|
||||
|
||||
finally:
|
||||
with _scrape_lock:
|
||||
_scrape_job["running"] = False
|
||||
|
||||
|
||||
# ─── Routes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.route("/api/leads", methods=["GET"])
|
||||
def api_get_leads():
|
||||
"""
|
||||
Liste les leads du CRM.
|
||||
|
||||
Query params :
|
||||
- status (str, optional) : filtre sur new/contacted/closed/rejected
|
||||
- limit (int, default=50) : pagination
|
||||
- offset (int, default=0) : pagination
|
||||
"""
|
||||
status = request.args.get("status")
|
||||
try:
|
||||
limit = int(request.args.get("limit", 50))
|
||||
offset = int(request.args.get("offset", 0))
|
||||
except ValueError:
|
||||
return jsonify({"error": "limit et offset doivent être des entiers"}), 400
|
||||
|
||||
if status and status not in VALID_STATUSES:
|
||||
return jsonify(
|
||||
{"error": f"status invalide. Valeurs acceptées : {VALID_STATUSES}"}
|
||||
), 400
|
||||
|
||||
leads = get_leads(status=status, limit=limit, offset=offset)
|
||||
return jsonify(
|
||||
{
|
||||
"leads": leads,
|
||||
"count": len(leads),
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"status_filter": status,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/leads/scrape", methods=["POST"])
|
||||
def api_scrape():
|
||||
"""
|
||||
Lance un job de scraping asynchrone.
|
||||
|
||||
Body JSON (optionnel) :
|
||||
- max_leads (int, default=100)
|
||||
- use_google (bool, default=true)
|
||||
- use_osm (bool, default=true)
|
||||
|
||||
Retourne immédiatement avec le statut du job.
|
||||
"""
|
||||
with _scrape_lock:
|
||||
if _scrape_job["running"]:
|
||||
return jsonify(
|
||||
{
|
||||
"status": "already_running",
|
||||
"message": "Un job de scraping est déjà en cours.",
|
||||
}
|
||||
), 409
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
max_leads = int(body.get("max_leads", 100))
|
||||
use_google = bool(body.get("use_google", True))
|
||||
use_osm = bool(body.get("use_osm", True))
|
||||
|
||||
thread = threading.Thread(
|
||||
target=_run_scrape_job,
|
||||
args=(max_leads, use_google, use_osm),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
logger.info(
|
||||
f"Job de scraping lancé (max_leads={max_leads}, "
|
||||
f"use_google={use_google}, use_osm={use_osm})"
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "started",
|
||||
"message": "Job de scraping démarré en arrière-plan.",
|
||||
"params": {
|
||||
"max_leads": max_leads,
|
||||
"use_google": use_google,
|
||||
"use_osm": use_osm,
|
||||
},
|
||||
}
|
||||
), 202
|
||||
|
||||
|
||||
@app.route("/api/leads/scrape/status", methods=["GET"])
|
||||
def api_scrape_status():
|
||||
"""Retourne l'état courant du job de scraping."""
|
||||
with _scrape_lock:
|
||||
return jsonify(dict(_scrape_job))
|
||||
|
||||
|
||||
@app.route("/api/leads/stats", methods=["GET"])
|
||||
def api_stats():
|
||||
"""
|
||||
Statistiques globales du CRM LeadHunter.
|
||||
|
||||
Retourne : total, by_status, by_source, avg_score, top_leads_count
|
||||
"""
|
||||
stats = get_stats()
|
||||
if not stats:
|
||||
return jsonify({"error": "Impossible de calculer les statistiques"}), 500
|
||||
return jsonify(stats)
|
||||
|
||||
|
||||
@app.route("/api/leads/export", methods=["GET"])
|
||||
def api_export():
|
||||
"""
|
||||
Export CSV de tous les leads (ou filtrés par status).
|
||||
|
||||
Query params :
|
||||
- status (str, optional)
|
||||
"""
|
||||
status = request.args.get("status")
|
||||
if status and status not in VALID_STATUSES:
|
||||
return jsonify({"error": f"status invalide : {VALID_STATUSES}"}), 400
|
||||
|
||||
csv_content = export_csv(status=status)
|
||||
filename = f"leadhunter_leads{'_' + status if status else ''}.csv"
|
||||
|
||||
return Response(
|
||||
csv_content,
|
||||
mimetype="text/csv",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={filename}",
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/leads/<int:lead_id>/status", methods=["PATCH"])
|
||||
def api_update_status(lead_id: int):
|
||||
"""
|
||||
Met à jour le statut d'un lead.
|
||||
|
||||
Body JSON :
|
||||
- status (str) : new | contacted | closed | rejected
|
||||
"""
|
||||
body = request.get_json(silent=True)
|
||||
if not body or "status" not in body:
|
||||
return jsonify({"error": "Body JSON requis avec le champ 'status'"}), 400
|
||||
|
||||
new_status = body["status"]
|
||||
if new_status not in VALID_STATUSES:
|
||||
return jsonify({"error": f"status invalide. Valeurs : {VALID_STATUSES}"}), 400
|
||||
|
||||
lead = get_lead_by_id(lead_id)
|
||||
if not lead:
|
||||
return jsonify({"error": f"Lead id={lead_id} introuvable"}), 404
|
||||
|
||||
success = update_lead_status(lead_id, new_status)
|
||||
if not success:
|
||||
return jsonify({"error": "Mise à jour échouée"}), 500
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"lead_id": lead_id,
|
||||
"new_status": new_status,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/health", methods=["GET"])
|
||||
def health():
|
||||
"""Healthcheck pour systemd / monitoring."""
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "leadhunter-api",
|
||||
"port": 8775,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ─── Entrypoint ──────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8775, debug=False)
|
||||
349
leadhunter_crm.py
Normal file
349
leadhunter_crm.py
Normal file
@@ -0,0 +1,349 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
H3R7Tech — LeadHunter CRM (SQLite)
|
||||
=====================================
|
||||
Couche de persistance SQLite pour les leads LeadHunter.
|
||||
|
||||
Schéma validé CTO (HRT-66) :
|
||||
CREATE TABLE leads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source TEXT NOT NULL, -- 'google_places' ou 'osm'
|
||||
name TEXT NOT NULL,
|
||||
address TEXT,
|
||||
phone TEXT,
|
||||
rating REAL,
|
||||
reviews_count INTEGER,
|
||||
website TEXT,
|
||||
score INTEGER,
|
||||
rgpd_ok BOOLEAN DEFAULT 1,
|
||||
scraped_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
status TEXT DEFAULT 'new' -- new, contacted, closed, rejected
|
||||
);
|
||||
|
||||
Auteur: H3R7Tech Backend Engineer
|
||||
Issue: HRT-66
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import logging
|
||||
import csv
|
||||
import io
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import Optional
|
||||
|
||||
# ─── Logging ────────────────────────────────────────────────────────────────
|
||||
logger = logging.getLogger("leadhunter.crm")
|
||||
|
||||
_handler = RotatingFileHandler(
|
||||
"/home/h3r7/leadhunter.log",
|
||||
maxBytes=5 * 1024 * 1024,
|
||||
backupCount=3,
|
||||
)
|
||||
_handler.setFormatter(
|
||||
logging.Formatter("%(asctime)s %(levelname)-8s %(name)s — %(message)s")
|
||||
)
|
||||
logger.setLevel(logging.INFO)
|
||||
if not logger.handlers:
|
||||
logger.addHandler(_handler)
|
||||
logger.addHandler(logging.StreamHandler())
|
||||
|
||||
# ─── Chemin DB ───────────────────────────────────────────────────────────────
|
||||
DB_PATH = "/home/h3r7/leadhunter.db"
|
||||
|
||||
# Statuts valides pour un lead
|
||||
VALID_STATUSES = {"new", "contacted", "closed", "rejected"}
|
||||
|
||||
|
||||
# ─── Initialisation ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def init_db(db_path: str = DB_PATH) -> None:
|
||||
"""
|
||||
Crée la base SQLite et la table leads si elle n'existe pas.
|
||||
Idempotent — peut être appelé au démarrage de l'API.
|
||||
"""
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS leads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
address TEXT,
|
||||
phone TEXT,
|
||||
rating REAL,
|
||||
reviews_count INTEGER,
|
||||
website TEXT,
|
||||
score INTEGER,
|
||||
rgpd_ok BOOLEAN DEFAULT 1,
|
||||
scraped_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
status TEXT DEFAULT 'new'
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
logger.info(f"DB initialisée : {db_path}")
|
||||
|
||||
|
||||
# ─── Context manager ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _get_conn(db_path: str = DB_PATH):
|
||||
"""Fournit une connexion SQLite avec row_factory."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.warning(f"DB transaction rollback : {e}")
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── CRUD ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def insert_lead(lead: dict, db_path: str = DB_PATH) -> Optional[int]:
|
||||
"""
|
||||
Insère un lead normalisé dans la DB.
|
||||
|
||||
Args:
|
||||
lead: dict avec les champs normalisés (source, name, address, ...)
|
||||
db_path: chemin vers la DB SQLite.
|
||||
|
||||
Returns:
|
||||
L'id SQLite du lead inséré, ou None en cas d'erreur.
|
||||
"""
|
||||
try:
|
||||
with _get_conn(db_path) as conn:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO leads
|
||||
(source, name, address, phone, rating, reviews_count,
|
||||
website, score, rgpd_ok, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
lead.get("source", "unknown"),
|
||||
lead.get("name", ""),
|
||||
lead.get("address", ""),
|
||||
lead.get("phone", ""),
|
||||
lead.get("rating"),
|
||||
lead.get("reviews_count"),
|
||||
lead.get("website", ""),
|
||||
lead.get("score"),
|
||||
1 if lead.get("rgpd_ok", True) else 0,
|
||||
lead.get("status", "new"),
|
||||
),
|
||||
)
|
||||
lead_id = cursor.lastrowid
|
||||
logger.info(f"Lead inséré id={lead_id} : {lead.get('name')}")
|
||||
return lead_id
|
||||
except Exception as e:
|
||||
logger.warning(f"insert_lead error : {e}")
|
||||
return None
|
||||
|
||||
|
||||
def insert_leads(leads: list[dict], db_path: str = DB_PATH) -> list[int]:
|
||||
"""
|
||||
Insère une liste de leads en batch.
|
||||
|
||||
Returns:
|
||||
Liste des ids insérés.
|
||||
"""
|
||||
ids = []
|
||||
for lead in leads:
|
||||
lead_id = insert_lead(lead, db_path)
|
||||
if lead_id is not None:
|
||||
ids.append(lead_id)
|
||||
logger.info(f"insert_leads : {len(ids)}/{len(leads)} insérés.")
|
||||
return ids
|
||||
|
||||
|
||||
def get_leads(
|
||||
status: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
db_path: str = DB_PATH,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Récupère les leads avec filtre optionnel sur le statut.
|
||||
|
||||
Args:
|
||||
status: filtre sur le champ 'status' (new, contacted, closed, rejected).
|
||||
limit: pagination — nombre de résultats max.
|
||||
offset: pagination — décalage.
|
||||
|
||||
Returns:
|
||||
Liste de dicts (tous les champs de la table leads).
|
||||
"""
|
||||
try:
|
||||
with _get_conn(db_path) as conn:
|
||||
if status:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM leads WHERE status = ? ORDER BY score DESC, scraped_at DESC LIMIT ? OFFSET ?",
|
||||
(status, limit, offset),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM leads ORDER BY score DESC, scraped_at DESC LIMIT ? OFFSET ?",
|
||||
(limit, offset),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
except Exception as e:
|
||||
logger.warning(f"get_leads error : {e}")
|
||||
return []
|
||||
|
||||
|
||||
def get_lead_by_id(lead_id: int, db_path: str = DB_PATH) -> Optional[dict]:
|
||||
"""Récupère un lead par son id."""
|
||||
try:
|
||||
with _get_conn(db_path) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM leads WHERE id = ?", (lead_id,)
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
except Exception as e:
|
||||
logger.warning(f"get_lead_by_id error : {e}")
|
||||
return None
|
||||
|
||||
|
||||
def update_lead_status(lead_id: int, status: str, db_path: str = DB_PATH) -> bool:
|
||||
"""
|
||||
Met à jour le statut d'un lead.
|
||||
|
||||
Args:
|
||||
lead_id: id du lead.
|
||||
status: nouveau statut ('new', 'contacted', 'closed', 'rejected').
|
||||
|
||||
Returns:
|
||||
True si mise à jour réussie, False sinon.
|
||||
"""
|
||||
if status not in VALID_STATUSES:
|
||||
logger.warning(f"update_lead_status : statut invalide '{status}'")
|
||||
return False
|
||||
try:
|
||||
with _get_conn(db_path) as conn:
|
||||
conn.execute(
|
||||
"UPDATE leads SET status = ? WHERE id = ?",
|
||||
(status, lead_id),
|
||||
)
|
||||
logger.info(f"Lead id={lead_id} statut → {status}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"update_lead_status error : {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_stats(db_path: str = DB_PATH) -> dict:
|
||||
"""
|
||||
Retourne les statistiques globales du CRM.
|
||||
|
||||
Returns:
|
||||
Dict avec total, by_status, by_source, avg_score, top_leads_count
|
||||
"""
|
||||
try:
|
||||
with _get_conn(db_path) as conn:
|
||||
total = conn.execute("SELECT COUNT(*) FROM leads").fetchone()[0]
|
||||
|
||||
by_status_rows = conn.execute(
|
||||
"SELECT status, COUNT(*) as cnt FROM leads GROUP BY status"
|
||||
).fetchall()
|
||||
by_status = {r["status"]: r["cnt"] for r in by_status_rows}
|
||||
|
||||
by_source_rows = conn.execute(
|
||||
"SELECT source, COUNT(*) as cnt FROM leads GROUP BY source"
|
||||
).fetchall()
|
||||
by_source = {r["source"]: r["cnt"] for r in by_source_rows}
|
||||
|
||||
avg_score_row = conn.execute(
|
||||
"SELECT AVG(score) FROM leads WHERE score IS NOT NULL"
|
||||
).fetchone()
|
||||
avg_score = round(avg_score_row[0] or 0, 2)
|
||||
|
||||
# Leads "chauds" = score ≥ 5
|
||||
top_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM leads WHERE score >= 5"
|
||||
).fetchone()[0]
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"by_status": by_status,
|
||||
"by_source": by_source,
|
||||
"avg_score": avg_score,
|
||||
"top_leads_count": top_count,
|
||||
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"get_stats error : {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def export_csv(
|
||||
status: Optional[str] = None,
|
||||
db_path: str = DB_PATH,
|
||||
) -> str:
|
||||
"""
|
||||
Exporte les leads en CSV (string).
|
||||
|
||||
Args:
|
||||
status: filtre optionnel sur le statut.
|
||||
|
||||
Returns:
|
||||
Contenu CSV en string UTF-8.
|
||||
"""
|
||||
leads = get_leads(status=status, limit=10000, db_path=db_path)
|
||||
|
||||
output = io.StringIO()
|
||||
fieldnames = [
|
||||
"id",
|
||||
"source",
|
||||
"name",
|
||||
"address",
|
||||
"phone",
|
||||
"rating",
|
||||
"reviews_count",
|
||||
"website",
|
||||
"score",
|
||||
"rgpd_ok",
|
||||
"scraped_at",
|
||||
"status",
|
||||
]
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
writer.writerows(leads)
|
||||
|
||||
logger.info(f"export_csv : {len(leads)} leads exportés.")
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
# ─── CLI (debug) ─────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_db()
|
||||
|
||||
# Test insertion
|
||||
test_lead = {
|
||||
"source": "google_places",
|
||||
"name": "Restaurant Test",
|
||||
"address": "10 rue de la Paix, 59000 Lille",
|
||||
"phone": "+33 3 20 00 00 01",
|
||||
"rating": 4.5,
|
||||
"reviews_count": 120,
|
||||
"website": "",
|
||||
"score": 8,
|
||||
"rgpd_ok": True,
|
||||
"status": "new",
|
||||
}
|
||||
lead_id = insert_lead(test_lead)
|
||||
print(f"Lead inséré : id={lead_id}")
|
||||
|
||||
leads = get_leads()
|
||||
print(f"Leads en DB : {len(leads)}")
|
||||
|
||||
stats = get_stats()
|
||||
print(f"Stats : {stats}")
|
||||
193
leadhunter_scorer.py
Normal file
193
leadhunter_scorer.py
Normal file
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
H3R7Tech — LeadHunter Scorer
|
||||
================================
|
||||
Moteur de scoring des leads restaurants MEL.
|
||||
|
||||
Critères (ordre de priorité métier) :
|
||||
1. [+3] Site web absent ← CRITIQUE : raison d'être du produit
|
||||
2. [+2] Nombre d'avis élevé (≥ 50) : forte activité = bon prospect de vente
|
||||
3. [+2] Note Google élevée (≥ 4.0) : établissement sérieux
|
||||
4. [+1] Téléphone présent : facilite la prise de contact
|
||||
5. [-1] Note faible (< 3.0) : risque reputationnel pour la prestation web
|
||||
|
||||
Score maximum théorique : 8
|
||||
Score minimum : 0 (leads avec site web ne doivent pas passer ici)
|
||||
|
||||
Auteur: H3R7Tech Backend Engineer
|
||||
Issue: HRT-66
|
||||
"""
|
||||
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
# ─── Logging ────────────────────────────────────────────────────────────────
|
||||
logger = logging.getLogger("leadhunter.scorer")
|
||||
|
||||
_handler = RotatingFileHandler(
|
||||
"/home/h3r7/leadhunter.log",
|
||||
maxBytes=5 * 1024 * 1024,
|
||||
backupCount=3,
|
||||
)
|
||||
_handler.setFormatter(
|
||||
logging.Formatter("%(asctime)s %(levelname)-8s %(name)s — %(message)s")
|
||||
)
|
||||
logger.setLevel(logging.INFO)
|
||||
if not logger.handlers:
|
||||
logger.addHandler(_handler)
|
||||
logger.addHandler(logging.StreamHandler())
|
||||
|
||||
|
||||
# ─── Scorer ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class LeadScorer:
|
||||
"""
|
||||
Calcule le score de priorité d'un lead.
|
||||
|
||||
Le score sert à trier les leads dans le CRM :
|
||||
- Score élevé = prospect chaud (sans site + actif + bien noté)
|
||||
- Score faible = prospect froid (peut être ignoré ou traité en dernier)
|
||||
"""
|
||||
|
||||
def _calculate_score(self, lead: dict) -> int:
|
||||
"""
|
||||
Calcule le score d'un lead.
|
||||
|
||||
Args:
|
||||
lead: dict avec les champs normalisés du scraper
|
||||
(name, website, rating, reviews_count, phone, ...)
|
||||
|
||||
Returns:
|
||||
Score entier (0–8)
|
||||
"""
|
||||
score = 0
|
||||
|
||||
# ── Critère 1 : site web absent [CRITIQUE — logique métier centrale] ──
|
||||
# C'est le critère n°1 : on cherche des restaurants SANS site web
|
||||
# pour leur proposer une création de site à 800–1500€.
|
||||
website = lead.get("website", "")
|
||||
if not website or not website.strip():
|
||||
score += 3
|
||||
logger.debug(f"{lead.get('name')}: +3 (site web absent)")
|
||||
else:
|
||||
# Si le lead a un site web, score = 0 immédiatement.
|
||||
# Ce cas ne devrait pas se produire (filtre scraper),
|
||||
# mais on reste défensif.
|
||||
logger.warning(
|
||||
f"{lead.get('name')}: site web présent ({website}), "
|
||||
"lead ignoré pour scoring."
|
||||
)
|
||||
return 0
|
||||
|
||||
# ── Critère 2 : nombre d'avis élevé (≥ 50) ──────────────────────────
|
||||
reviews = lead.get("reviews_count")
|
||||
if reviews is not None:
|
||||
try:
|
||||
reviews = int(reviews)
|
||||
if reviews >= 50:
|
||||
score += 2
|
||||
logger.debug(f"{lead.get('name')}: +2 (avis ≥ 50 : {reviews})")
|
||||
except (TypeError, ValueError) as e:
|
||||
logger.warning(f"reviews_count invalide pour {lead.get('name')}: {e}")
|
||||
|
||||
# ── Critère 3 : bonne note Google (≥ 4.0) ───────────────────────────
|
||||
rating = lead.get("rating")
|
||||
if rating is not None:
|
||||
try:
|
||||
rating = float(rating)
|
||||
if rating >= 4.0:
|
||||
score += 2
|
||||
logger.debug(f"{lead.get('name')}: +2 (note ≥ 4.0 : {rating})")
|
||||
elif rating < 3.0:
|
||||
score -= 1
|
||||
logger.debug(f"{lead.get('name')}: -1 (note < 3.0 : {rating})")
|
||||
except (TypeError, ValueError) as e:
|
||||
logger.warning(f"rating invalide pour {lead.get('name')}: {e}")
|
||||
|
||||
# ── Critère 4 : téléphone présent ────────────────────────────────────
|
||||
phone = lead.get("phone", "")
|
||||
if phone and phone.strip():
|
||||
score += 1
|
||||
logger.debug(f"{lead.get('name')}: +1 (téléphone présent)")
|
||||
|
||||
# Plancher à 0
|
||||
score = max(0, score)
|
||||
logger.info(f"Score calculé pour '{lead.get('name')}' : {score}/8")
|
||||
return score
|
||||
|
||||
def score_lead(self, lead: dict) -> dict:
|
||||
"""
|
||||
Enrichit un lead avec son score.
|
||||
|
||||
Args:
|
||||
lead: dict normalisé du scraper.
|
||||
|
||||
Returns:
|
||||
Même dict avec le champ 'score' ajouté/mis à jour.
|
||||
"""
|
||||
lead = dict(lead) # copie défensive
|
||||
lead["score"] = self._calculate_score(lead)
|
||||
return lead
|
||||
|
||||
def score_leads(self, leads: list[dict]) -> list[dict]:
|
||||
"""
|
||||
Score et trie une liste de leads (score décroissant).
|
||||
|
||||
Args:
|
||||
leads: liste de dicts normalisés.
|
||||
|
||||
Returns:
|
||||
Liste triée par score décroissant.
|
||||
"""
|
||||
scored = [self.score_lead(lead) for lead in leads]
|
||||
scored.sort(key=lambda l: l.get("score", 0), reverse=True)
|
||||
logger.info(
|
||||
f"score_leads terminé : {len(scored)} leads scorés. "
|
||||
f"Score max = {scored[0]['score'] if scored else 0}, "
|
||||
f"Score min = {scored[-1]['score'] if scored else 0}"
|
||||
)
|
||||
return scored
|
||||
|
||||
|
||||
# ─── CLI (debug) ─────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Exemple de test rapide sans appel API
|
||||
test_leads = [
|
||||
{
|
||||
"name": "Restaurant A",
|
||||
"website": "",
|
||||
"rating": 4.5,
|
||||
"reviews_count": 120,
|
||||
"phone": "+33 3 20 00 00 01",
|
||||
},
|
||||
{
|
||||
"name": "Restaurant B",
|
||||
"website": "",
|
||||
"rating": 3.8,
|
||||
"reviews_count": 30,
|
||||
"phone": "",
|
||||
},
|
||||
{
|
||||
"name": "Café C",
|
||||
"website": "",
|
||||
"rating": 2.5,
|
||||
"reviews_count": 5,
|
||||
"phone": "+33 3 20 00 00 03",
|
||||
},
|
||||
{
|
||||
"name": "Bar D avec site",
|
||||
"website": "https://bar-d.fr",
|
||||
"rating": 4.2,
|
||||
"reviews_count": 80,
|
||||
"phone": "+33 3 20 00 00 04",
|
||||
},
|
||||
]
|
||||
|
||||
scorer = LeadScorer()
|
||||
results = scorer.score_leads(test_leads)
|
||||
|
||||
print("\n=== Résultats scoring ===")
|
||||
for r in results:
|
||||
print(f" [{r['score']:2d}/8] {r['name']}")
|
||||
397
leadhunter_scraper.py
Normal file
397
leadhunter_scraper.py
Normal file
@@ -0,0 +1,397 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
H3R7Tech — LeadHunter Scraper
|
||||
================================
|
||||
Agent de scraping pour la détection de restaurants sans site web
|
||||
dans la MEL (Métropole Européenne de Lille).
|
||||
|
||||
Sources :
|
||||
- Google Places API (primary)
|
||||
- OpenStreetMap / Overpass API (fallback)
|
||||
|
||||
Quota Google Places Free Tier :
|
||||
- 28 500 requêtes/mois ≈ 950/jour
|
||||
- Compteur persistent dans /home/h3r7/leadhunter_quota.json
|
||||
|
||||
Auteur: H3R7Tech Backend Engineer
|
||||
Issue: HRT-66
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
from datetime import date, datetime
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
# ─── Logging ────────────────────────────────────────────────────────────────
|
||||
logger = logging.getLogger("leadhunter.scraper")
|
||||
|
||||
_handler = RotatingFileHandler(
|
||||
"/home/h3r7/leadhunter.log",
|
||||
maxBytes=5 * 1024 * 1024, # 5 MB
|
||||
backupCount=3,
|
||||
)
|
||||
_handler.setFormatter(
|
||||
logging.Formatter("%(asctime)s %(levelname)-8s %(name)s — %(message)s")
|
||||
)
|
||||
logger.setLevel(logging.INFO)
|
||||
if not logger.handlers:
|
||||
logger.addHandler(_handler)
|
||||
logger.addHandler(logging.StreamHandler())
|
||||
|
||||
# ─── Configuration ───────────────────────────────────────────────────────────
|
||||
GOOGLE_PLACES_API_KEY = os.environ.get("GOOGLE_PLACES_API_KEY")
|
||||
|
||||
# Quota journalier Google Places Free Tier
|
||||
DAILY_QUOTA_FILE = "/home/h3r7/leadhunter_quota.json"
|
||||
DAILY_QUOTA_LIMIT = 900 # marge de sécurité vs les 950 théoriques
|
||||
|
||||
# Délai entre requêtes Places pour éviter rate-limiting
|
||||
PLACES_SLEEP_S = 0.5
|
||||
|
||||
# Bounding box MEL (Métropole Européenne de Lille)
|
||||
MEL_CENTER_LAT = 50.6292
|
||||
MEL_CENTER_LNG = 3.0573
|
||||
MEL_RADIUS_M = 20000 # 20 km autour de Lille
|
||||
|
||||
# Types de lieux ciblés
|
||||
TARGET_TYPES = ["restaurant", "cafe", "bar", "bakery", "food"]
|
||||
|
||||
# Overpass API endpoint
|
||||
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
||||
|
||||
# Requête Overpass MEL — bounding box directe (50.4,2.8,50.8,3.3) couvrant la MEL
|
||||
# Fix HRT-72 : la résolution area["name"=...] échoue silencieusement sur l'API Overpass publique
|
||||
OVERPASS_MEL_QUERY = """
|
||||
[out:json][timeout:60];
|
||||
(
|
||||
node["amenity"~"^(restaurant|cafe|bar|fast_food|bakery)$"][!"website"](50.4,2.8,50.8,3.3);
|
||||
way["amenity"~"^(restaurant|cafe|bar|fast_food|bakery)$"][!"website"](50.4,2.8,50.8,3.3);
|
||||
);
|
||||
out center 200;
|
||||
"""
|
||||
|
||||
|
||||
# ─── Quota Manager ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _load_quota() -> dict:
|
||||
"""Charge le compteur quotidien depuis le fichier JSON."""
|
||||
today = str(date.today())
|
||||
if os.path.exists(DAILY_QUOTA_FILE):
|
||||
try:
|
||||
with open(DAILY_QUOTA_FILE, "r") as f:
|
||||
data = json.load(f)
|
||||
if data.get("date") == today:
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.warning(f"Impossible de lire le fichier quota : {e}")
|
||||
return {"date": today, "count": 0}
|
||||
|
||||
|
||||
def _save_quota(data: dict) -> None:
|
||||
"""Persiste le compteur quotidien."""
|
||||
try:
|
||||
with open(DAILY_QUOTA_FILE, "w") as f:
|
||||
json.dump(data, f)
|
||||
except Exception as e:
|
||||
logger.warning(f"Impossible d'écrire le fichier quota : {e}")
|
||||
|
||||
|
||||
def _increment_quota(n: int = 1) -> int:
|
||||
"""Incrémente le compteur et retourne le total du jour."""
|
||||
quota = _load_quota()
|
||||
quota["count"] += n
|
||||
_save_quota(quota)
|
||||
return quota["count"]
|
||||
|
||||
|
||||
def _quota_remaining() -> int:
|
||||
"""Retourne le nombre de requêtes restantes pour aujourd'hui."""
|
||||
quota = _load_quota()
|
||||
return max(0, DAILY_QUOTA_LIMIT - quota["count"])
|
||||
|
||||
|
||||
# ─── Google Places Scraper ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class GooglePlacesScraper:
|
||||
"""
|
||||
Scraping via Google Places API (Nearby Search + Place Details).
|
||||
Filtre les lieux sans site web côté API.
|
||||
"""
|
||||
|
||||
BASE_URL = "https://maps.googleapis.com/maps/api/place"
|
||||
|
||||
def __init__(self):
|
||||
if not GOOGLE_PLACES_API_KEY:
|
||||
raise EnvironmentError(
|
||||
"GOOGLE_PLACES_API_KEY non définie. "
|
||||
"Ajouter dans /home/h3r7/.env et relancer."
|
||||
)
|
||||
self.api_key = GOOGLE_PLACES_API_KEY
|
||||
|
||||
def _nearby_search(self, place_type: str, page_token: str = None) -> dict:
|
||||
"""Appel Nearby Search — 1 requête comptabilisée."""
|
||||
params = {
|
||||
"key": self.api_key,
|
||||
"location": f"{MEL_CENTER_LAT},{MEL_CENTER_LNG}",
|
||||
"radius": MEL_RADIUS_M,
|
||||
"type": place_type,
|
||||
}
|
||||
if page_token:
|
||||
params["pagetoken"] = page_token
|
||||
|
||||
_increment_quota()
|
||||
time.sleep(PLACES_SLEEP_S)
|
||||
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{self.BASE_URL}/nearbysearch/json",
|
||||
params=params,
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"NearbySearch error (type={place_type}): {e}")
|
||||
return {}
|
||||
|
||||
def _place_details(self, place_id: str) -> dict:
|
||||
"""Place Details pour récupérer website, phone, rating, etc. — 1 requête."""
|
||||
params = {
|
||||
"key": self.api_key,
|
||||
"place_id": place_id,
|
||||
"fields": "name,formatted_address,formatted_phone_number,website,rating,user_ratings_total",
|
||||
}
|
||||
|
||||
_increment_quota()
|
||||
time.sleep(PLACES_SLEEP_S)
|
||||
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{self.BASE_URL}/details/json",
|
||||
params=params,
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("result", {})
|
||||
except Exception as e:
|
||||
logger.warning(f"PlaceDetails error (place_id={place_id}): {e}")
|
||||
return {}
|
||||
|
||||
def scrape(self, max_leads: int = 50) -> list[dict]:
|
||||
"""
|
||||
Scrape les restaurants/cafés/bars MEL sans site web.
|
||||
|
||||
Retourne une liste de dicts normalisés compatibles LeadHunter CRM :
|
||||
source, name, address, phone, rating, reviews_count, website, rgpd_ok
|
||||
"""
|
||||
leads = []
|
||||
seen_ids = set()
|
||||
|
||||
for place_type in TARGET_TYPES:
|
||||
if _quota_remaining() < 10:
|
||||
logger.warning(
|
||||
"Quota journalier presque épuisé — arrêt scraping Google Places."
|
||||
)
|
||||
break
|
||||
|
||||
logger.info(f"Scraping Google Places — type={place_type}")
|
||||
page_token = None
|
||||
|
||||
while True:
|
||||
if _quota_remaining() < 5:
|
||||
logger.warning("Quota insuffisant pour continuer la pagination.")
|
||||
break
|
||||
|
||||
data = self._nearby_search(place_type, page_token)
|
||||
results = data.get("results", [])
|
||||
|
||||
for place in results:
|
||||
if len(leads) >= max_leads:
|
||||
break
|
||||
|
||||
place_id = place.get("place_id", "")
|
||||
if not place_id or place_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(place_id)
|
||||
|
||||
if _quota_remaining() < 2:
|
||||
logger.warning("Quota épuisé avant details.")
|
||||
break
|
||||
|
||||
details = self._place_details(place_id)
|
||||
|
||||
# Filtre : on ne garde que les lieux SANS site web
|
||||
if details.get("website"):
|
||||
continue
|
||||
|
||||
lead = {
|
||||
"source": "google_places",
|
||||
"name": details.get("name") or place.get("name", ""),
|
||||
"address": details.get("formatted_address")
|
||||
or place.get("vicinity", ""),
|
||||
"phone": details.get("formatted_phone_number", ""),
|
||||
"rating": details.get("rating") or place.get("rating"),
|
||||
"reviews_count": details.get("user_ratings_total")
|
||||
or place.get("user_ratings_total"),
|
||||
"website": "",
|
||||
"rgpd_ok": True, # Données publiques Google Places uniquement
|
||||
}
|
||||
leads.append(lead)
|
||||
logger.info(f"Lead trouvé (Google Places) : {lead['name']}")
|
||||
|
||||
if len(leads) >= max_leads:
|
||||
break
|
||||
|
||||
page_token = data.get("next_page_token")
|
||||
if not page_token:
|
||||
break
|
||||
|
||||
# L'API Google Places nécessite un délai avant d'utiliser next_page_token
|
||||
time.sleep(2)
|
||||
|
||||
logger.info(f"Google Places : {len(leads)} leads collectés.")
|
||||
return leads
|
||||
|
||||
|
||||
# ─── Overpass / OSM Fallback ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class OverpassScraper:
|
||||
"""
|
||||
Fallback OSM via Overpass API.
|
||||
Cible les nœuds/ways dans la boundary MEL sans attribut 'website'.
|
||||
Données publiques ODbL — RGPD OK.
|
||||
"""
|
||||
|
||||
def scrape(self, max_leads: int = 100) -> list[dict]:
|
||||
"""
|
||||
Scrape via Overpass API — retourne des leads normalisés.
|
||||
"""
|
||||
logger.info("Scraping Overpass OSM — boundary MEL")
|
||||
leads = []
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
OVERPASS_URL,
|
||||
data={"data": OVERPASS_MEL_QUERY},
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded", # Fix HRT-72 Bug2
|
||||
"User-Agent": "H3R7Tech-LeadHunter/1.0 (contact@h3r7tech.fr)", # Fix HRT-72 Bug3: overpass-api.de blocks python-requests UA
|
||||
},
|
||||
timeout=90,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"Overpass API error : {e}")
|
||||
return []
|
||||
|
||||
elements = data.get("elements", [])
|
||||
logger.info(f"Overpass : {len(elements)} éléments bruts reçus.")
|
||||
|
||||
for el in elements[:max_leads]:
|
||||
tags = el.get("tags", {})
|
||||
|
||||
# Coordonnées (pour les ways, Overpass retourne 'center')
|
||||
lat = el.get("lat") or (el.get("center") or {}).get("lat")
|
||||
lon = el.get("lon") or (el.get("center") or {}).get("lon")
|
||||
|
||||
name = tags.get("name", "")
|
||||
if not name:
|
||||
continue # Ignorer les lieux sans nom
|
||||
|
||||
addr_parts = [
|
||||
tags.get("addr:housenumber", ""),
|
||||
tags.get("addr:street", ""),
|
||||
tags.get("addr:city", ""),
|
||||
tags.get("addr:postcode", ""),
|
||||
]
|
||||
address = " ".join(p for p in addr_parts if p).strip()
|
||||
if not address and lat and lon:
|
||||
address = f"{lat:.4f},{lon:.4f}"
|
||||
|
||||
lead = {
|
||||
"source": "osm",
|
||||
"name": name,
|
||||
"address": address,
|
||||
"phone": tags.get("phone", tags.get("contact:phone", "")),
|
||||
"rating": None,
|
||||
"reviews_count": None,
|
||||
"website": "",
|
||||
"rgpd_ok": True, # Données publiques ODbL
|
||||
}
|
||||
leads.append(lead)
|
||||
logger.info(f"Lead trouvé (OSM) : {lead['name']}")
|
||||
|
||||
logger.info(f"Overpass : {len(leads)} leads collectés.")
|
||||
return leads
|
||||
|
||||
|
||||
# ─── Orchestrateur ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def run_scraping(
|
||||
max_leads: int = 100, use_google: bool = True, use_osm: bool = True
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Lance le scraping Google Places + fallback OSM.
|
||||
|
||||
Args:
|
||||
max_leads: nombre maximum de leads à collecter au total.
|
||||
use_google: activer Google Places (nécessite GOOGLE_PLACES_API_KEY).
|
||||
use_osm: activer le fallback Overpass OSM.
|
||||
|
||||
Returns:
|
||||
Liste de leads normalisés (dédupliqués par nom + adresse).
|
||||
"""
|
||||
all_leads = []
|
||||
seen_keys = set()
|
||||
|
||||
def _dedup_key(lead: dict) -> str:
|
||||
return f"{lead['name'].lower().strip()}|{lead['address'].lower().strip()[:40]}"
|
||||
|
||||
if use_google:
|
||||
try:
|
||||
scraper = GooglePlacesScraper()
|
||||
google_leads = scraper.scrape(max_leads=max_leads)
|
||||
for lead in google_leads:
|
||||
k = _dedup_key(lead)
|
||||
if k not in seen_keys:
|
||||
seen_keys.add(k)
|
||||
all_leads.append(lead)
|
||||
except EnvironmentError as e:
|
||||
logger.warning(f"Google Places désactivé : {e}")
|
||||
use_google = False
|
||||
|
||||
remaining = max_leads - len(all_leads)
|
||||
if use_osm and remaining > 0:
|
||||
osm_leads = OverpassScraper().scrape(max_leads=remaining)
|
||||
for lead in osm_leads:
|
||||
k = _dedup_key(lead)
|
||||
if k not in seen_keys:
|
||||
seen_keys.add(k)
|
||||
all_leads.append(lead)
|
||||
|
||||
logger.info(
|
||||
f"run_scraping terminé — {len(all_leads)} leads uniques "
|
||||
f"(Google={use_google}, OSM={use_osm}). "
|
||||
f"Quota restant aujourd'hui : {_quota_remaining()}"
|
||||
)
|
||||
return all_leads
|
||||
|
||||
|
||||
# ─── CLI (debug) ─────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
assert GOOGLE_PLACES_API_KEY, (
|
||||
"GOOGLE_PLACES_API_KEY manquante — "
|
||||
"ajouter 'export GOOGLE_PLACES_API_KEY=xxx' dans /home/h3r7/.env"
|
||||
)
|
||||
leads = run_scraping(max_leads=10)
|
||||
for i, l in enumerate(leads, 1):
|
||||
print(f"{i:02d}. [{l['source']}] {l['name']} — {l['address']}")
|
||||
112
log_config.py
Normal file
112
log_config.py
Normal file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Structured JSON logging for Turf SaaS.
|
||||
Replaces default Flask/Python logging with JSON output suitable for log aggregation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import traceback
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class JSONFormatter(logging.Formatter):
|
||||
"""Format log records as JSON lines."""
|
||||
|
||||
def __init__(self, service_name: str = "turf-saas", env: str = "production"):
|
||||
super().__init__()
|
||||
self.service_name = service_name
|
||||
self.env = env
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
log_entry = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"level": record.levelname,
|
||||
"service": self.service_name,
|
||||
"env": self.env,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
"module": record.module,
|
||||
"function": record.funcName,
|
||||
"line": record.lineno,
|
||||
}
|
||||
|
||||
# Add extra fields if present
|
||||
if hasattr(record, "request_id"):
|
||||
log_entry["request_id"] = record.request_id
|
||||
if hasattr(record, "user_id"):
|
||||
log_entry["user_id"] = record.user_id
|
||||
if hasattr(record, "duration_ms"):
|
||||
log_entry["duration_ms"] = record.duration_ms
|
||||
if hasattr(record, "status_code"):
|
||||
log_entry["status_code"] = record.status_code
|
||||
if hasattr(record, "endpoint"):
|
||||
log_entry["endpoint"] = record.endpoint
|
||||
|
||||
# Exception info
|
||||
if record.exc_info:
|
||||
log_entry["exception"] = {
|
||||
"type": record.exc_info[0].__name__ if record.exc_info[0] else None,
|
||||
"message": str(record.exc_info[1]) if record.exc_info[1] else None,
|
||||
"traceback": traceback.format_exception(*record.exc_info),
|
||||
}
|
||||
|
||||
return json.dumps(log_entry, ensure_ascii=False)
|
||||
|
||||
|
||||
def setup_logging(
|
||||
service_name: str = "turf-saas",
|
||||
level: Optional[str] = None,
|
||||
use_json: bool = True,
|
||||
) -> logging.Logger:
|
||||
"""
|
||||
Configure root logger with JSON or plain formatting.
|
||||
|
||||
Args:
|
||||
service_name: Service name embedded in each log record.
|
||||
level: Log level (default: from LOG_LEVEL env var, fallback INFO).
|
||||
use_json: Use JSON formatter (True in production, False in dev).
|
||||
|
||||
Returns:
|
||||
Root logger.
|
||||
"""
|
||||
log_level = level or os.environ.get("LOG_LEVEL", "INFO")
|
||||
env = os.environ.get("FLASK_ENV", "production")
|
||||
|
||||
# Force plain text in dev/testing
|
||||
if env in ("development", "testing"):
|
||||
use_json = False
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(getattr(logging, log_level.upper(), logging.INFO))
|
||||
|
||||
# Remove existing handlers
|
||||
root_logger.handlers.clear()
|
||||
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
|
||||
if use_json:
|
||||
handler.setFormatter(JSONFormatter(service_name=service_name, env=env))
|
||||
else:
|
||||
handler.setFormatter(
|
||||
logging.Formatter(
|
||||
fmt="%(asctime)s [%(levelname)s] %(name)s — %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
)
|
||||
|
||||
root_logger.addHandler(handler)
|
||||
|
||||
# Silence noisy third-party loggers
|
||||
for noisy in ["werkzeug", "urllib3", "requests", "gunicorn.access"]:
|
||||
logging.getLogger(noisy).setLevel(logging.WARNING)
|
||||
|
||||
return root_logger
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Get a named logger."""
|
||||
return logging.getLogger(name)
|
||||
182
login.html
Normal file
182
login.html
Normal file
@@ -0,0 +1,182 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Connexion — Turf IA</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--green: #00c853; --green-d: #009624;
|
||||
--dark: #0d1117; --dark2: #161b22; --dark3: #21262d;
|
||||
--text: #e6edf3; --muted: #8b949e; --border: #30363d;
|
||||
--error: #f85149; --radius: 10px;
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); min-height: 100vh; display: flex; flex-direction: column; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
nav { display: flex; align-items: center; justify-content: space-between; padding: 16px 5%; border-bottom: 1px solid var(--border); }
|
||||
.nav-logo { font-weight: 700; font-size: 1.1rem; }
|
||||
.nav-link { color: var(--muted); font-size: .9rem; }
|
||||
.nav-link:hover { color: var(--text); }
|
||||
|
||||
main { flex: 1; display: flex; align-items: center; justify-content: center; padding: 40px 20px; }
|
||||
.auth-card {
|
||||
width: 100%; max-width: 420px;
|
||||
background: var(--dark2); border: 1px solid var(--border);
|
||||
border-radius: 14px; padding: 40px;
|
||||
}
|
||||
.auth-title { font-size: 1.5rem; font-weight: 800; margin-bottom: 6px; }
|
||||
.auth-subtitle { color: var(--muted); font-size: .9rem; margin-bottom: 28px; }
|
||||
.form-group { margin-bottom: 18px; }
|
||||
label { display: block; font-size: .85rem; font-weight: 600; color: var(--muted); margin-bottom: 6px; }
|
||||
input {
|
||||
width: 100%; padding: 11px 14px; background: var(--dark3);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
color: var(--text); font-size: .95rem; outline: none; transition: border-color .2s;
|
||||
}
|
||||
input:focus { border-color: var(--green); }
|
||||
input.error { border-color: var(--error); }
|
||||
.field-error { color: var(--error); font-size: .8rem; margin-top: 4px; display: none; }
|
||||
.field-error.show { display: block; }
|
||||
.forgot { float: right; font-size: .82rem; color: var(--muted); }
|
||||
.forgot:hover { color: var(--text); }
|
||||
.btn {
|
||||
width: 100%; padding: 12px; border: none; border-radius: var(--radius);
|
||||
font-size: 1rem; font-weight: 700; cursor: pointer; transition: all .2s;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.btn-primary { background: var(--green); color: #000; }
|
||||
.btn-primary:hover { background: var(--green-d); }
|
||||
.btn-primary:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.divider { display: flex; align-items: center; gap: 12px; margin: 20px 0; color: var(--muted); font-size: .82rem; }
|
||||
.divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||
.auth-footer { text-align: center; margin-top: 20px; color: var(--muted); font-size: .9rem; }
|
||||
.auth-footer a { color: var(--green); font-weight: 600; }
|
||||
.alert { padding: 12px 16px; border-radius: var(--radius); font-size: .88rem; margin-bottom: 18px; display: none; }
|
||||
.alert.show { display: block; }
|
||||
.alert-error { background: rgba(248,81,73,.12); border: 1px solid rgba(248,81,73,.3); color: #f85149; }
|
||||
.loader { display: inline-block; width: 16px; height: 16px; border: 2px solid rgba(0,0,0,.3); border-top-color: #000; border-radius: 50%; animation: spin .7s linear infinite; vertical-align: middle; margin-right: 6px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
footer { text-align: center; padding: 20px; color: var(--muted); font-size: .8rem; border-top: 1px solid var(--border); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="/" class="nav-logo">🏇 Turf IA</a>
|
||||
<a href="/register" class="nav-link">Pas encore de compte ? S'inscrire →</a>
|
||||
</nav>
|
||||
<main>
|
||||
<div class="auth-card">
|
||||
<h1 class="auth-title">Bon retour !</h1>
|
||||
<p class="auth-subtitle">Connectez-vous à votre compte Turf IA.</p>
|
||||
|
||||
<div class="alert alert-error" id="alert-error"></div>
|
||||
|
||||
<form id="login-form" novalidate>
|
||||
<div class="form-group">
|
||||
<label for="email">Adresse email</label>
|
||||
<input type="email" id="email" name="email" placeholder="vous@exemple.fr" autocomplete="email" required>
|
||||
<div class="field-error" id="email-error">Email invalide.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">
|
||||
Mot de passe
|
||||
<a href="/forgot-password" class="forgot">Mot de passe oublié ?</a>
|
||||
</label>
|
||||
<input type="password" id="password" name="password" placeholder="••••••••" autocomplete="current-password" required>
|
||||
<div class="field-error" id="password-error">Mot de passe requis.</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" id="submit-btn">Se connecter</button>
|
||||
</form>
|
||||
|
||||
<div class="divider">ou</div>
|
||||
<div class="auth-footer">
|
||||
Pas encore de compte ? <a href="/register">Créer un compte gratuit</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer>© 2026 Turf IA — H3R7 Tech. Jouez de façon responsable. 18+</footer>
|
||||
|
||||
<script>
|
||||
const API = '/api/v1';
|
||||
|
||||
const form = document.getElementById('login-form');
|
||||
const emailInput = document.getElementById('email');
|
||||
const passInput = document.getElementById('password');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
const alertBox = document.getElementById('alert-error');
|
||||
|
||||
function showError(msg) {
|
||||
alertBox.textContent = msg;
|
||||
alertBox.classList.add('show');
|
||||
}
|
||||
function hideError() { alertBox.classList.remove('show'); }
|
||||
|
||||
function setLoading(on) {
|
||||
submitBtn.disabled = on;
|
||||
submitBtn.innerHTML = on ? '<span class="loader"></span>Connexion…' : 'Se connecter';
|
||||
}
|
||||
|
||||
function validateField(input, errId, condition, msg) {
|
||||
const err = document.getElementById(errId);
|
||||
if (condition) {
|
||||
input.classList.add('error');
|
||||
err.textContent = msg;
|
||||
err.classList.add('show');
|
||||
return false;
|
||||
}
|
||||
input.classList.remove('error');
|
||||
err.classList.remove('show');
|
||||
return true;
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
hideError();
|
||||
|
||||
const email = emailInput.value.trim();
|
||||
const pass = passInput.value;
|
||||
|
||||
const v1 = validateField(emailInput, 'email-error', !email || !/^[^@]+@[^@]+\.[^@]+$/.test(email), 'Adresse email invalide.');
|
||||
const v2 = validateField(passInput, 'password-error', !pass, 'Mot de passe requis.');
|
||||
if (!v1 || !v2) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password: pass })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
showError(data.error || 'Identifiants incorrects. Veuillez réessayer.');
|
||||
} else {
|
||||
localStorage.setItem('turf_token', data.token);
|
||||
localStorage.setItem('turf_user', JSON.stringify(data.user));
|
||||
const next = new URLSearchParams(location.search).get('next') || '/dashboard';
|
||||
location.href = next;
|
||||
}
|
||||
} catch(_) {
|
||||
showError('Erreur de connexion. Vérifiez votre réseau.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Remove errors on typing
|
||||
[emailInput, passInput].forEach(el => el.addEventListener('input', () => {
|
||||
el.classList.remove('error');
|
||||
document.getElementById(el.id + '-error')?.classList.remove('show');
|
||||
hideError();
|
||||
}));
|
||||
|
||||
// If already logged in, redirect
|
||||
(function() {
|
||||
const token = localStorage.getItem('turf_token');
|
||||
if (token) location.href = '/dashboard';
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
255
metrics.py
Normal file
255
metrics.py
Normal file
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Prometheus metrics instrumentation for Turf SaaS.
|
||||
Import this module in Flask apps to expose /metrics endpoint.
|
||||
"""
|
||||
|
||||
import time
|
||||
import functools
|
||||
import logging
|
||||
from typing import Callable, Any
|
||||
|
||||
try:
|
||||
from prometheus_client import (
|
||||
Counter,
|
||||
Histogram,
|
||||
Gauge,
|
||||
Summary,
|
||||
generate_latest,
|
||||
CONTENT_TYPE_LATEST,
|
||||
CollectorRegistry,
|
||||
multiprocess,
|
||||
REGISTRY,
|
||||
)
|
||||
|
||||
PROMETHEUS_AVAILABLE = True
|
||||
except ImportError:
|
||||
PROMETHEUS_AVAILABLE = False
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================
|
||||
# Metric definitions
|
||||
# ============================================================
|
||||
|
||||
if PROMETHEUS_AVAILABLE:
|
||||
# HTTP metrics
|
||||
HTTP_REQUESTS_TOTAL = Counter(
|
||||
"http_requests_total",
|
||||
"Total number of HTTP requests",
|
||||
["method", "endpoint", "status_code", "service"],
|
||||
)
|
||||
|
||||
HTTP_REQUEST_DURATION = Histogram(
|
||||
"http_request_duration_seconds",
|
||||
"HTTP request duration in seconds",
|
||||
["method", "endpoint", "service"],
|
||||
buckets=[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0],
|
||||
)
|
||||
|
||||
HTTP_REQUESTS_IN_PROGRESS = Gauge(
|
||||
"http_requests_in_progress",
|
||||
"Number of HTTP requests currently being processed",
|
||||
["method", "endpoint", "service"],
|
||||
)
|
||||
|
||||
# ML prediction metrics
|
||||
ML_PREDICTIONS_TOTAL = Counter(
|
||||
"ml_predictions_total",
|
||||
"Total ML prediction requests",
|
||||
["model_type", "race_type"],
|
||||
)
|
||||
|
||||
ML_PREDICTION_DURATION = Histogram(
|
||||
"ml_prediction_duration_seconds",
|
||||
"ML prediction duration in seconds",
|
||||
["model_type"],
|
||||
buckets=[0.01, 0.05, 0.1, 0.5, 1.0, 2.0, 5.0],
|
||||
)
|
||||
|
||||
ML_PREDICTION_ACCURACY = Gauge(
|
||||
"ml_prediction_accuracy_ratio",
|
||||
"Rolling ML prediction accuracy (top-1, top-3)",
|
||||
["accuracy_type"],
|
||||
)
|
||||
|
||||
ML_PREDICTION_DRIFT = Gauge(
|
||||
"ml_prediction_drift_score",
|
||||
"Feature drift score for ML models (0=no drift, 1=full drift)",
|
||||
["feature_group"],
|
||||
)
|
||||
|
||||
# Database metrics
|
||||
DB_QUERIES_TOTAL = Counter(
|
||||
"db_queries_total", "Total database queries", ["operation", "table"]
|
||||
)
|
||||
|
||||
DB_QUERY_DURATION = Histogram(
|
||||
"db_query_duration_seconds",
|
||||
"Database query duration",
|
||||
["operation"],
|
||||
buckets=[0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0],
|
||||
)
|
||||
|
||||
DB_CONNECTION_POOL_SIZE = Gauge(
|
||||
"db_connection_pool_size", "Current database connection pool size"
|
||||
)
|
||||
|
||||
# Business metrics
|
||||
RACES_SCRAPED_TOTAL = Counter(
|
||||
"races_scraped_total", "Total number of races scraped", ["source", "discipline"]
|
||||
)
|
||||
|
||||
PREDICTIONS_ACCURACY_DAILY = Gauge(
|
||||
"predictions_accuracy_daily_ratio",
|
||||
"Daily prediction accuracy ratio",
|
||||
["date", "race_type"],
|
||||
)
|
||||
|
||||
ACTIVE_SUBSCRIPTIONS = Gauge(
|
||||
"active_subscriptions_total", "Number of active SaaS subscriptions", ["plan"]
|
||||
)
|
||||
|
||||
# App health
|
||||
APP_INFO = Gauge(
|
||||
"app_info", "Application build information", ["version", "service", "env"]
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Flask integration
|
||||
# ============================================================
|
||||
|
||||
|
||||
def init_metrics(app, service_name: str = "unknown"):
|
||||
"""
|
||||
Register Prometheus metrics middleware on a Flask app.
|
||||
|
||||
Usage:
|
||||
from metrics import init_metrics
|
||||
init_metrics(app, service_name="combined-api")
|
||||
"""
|
||||
if not PROMETHEUS_AVAILABLE:
|
||||
logger.warning("prometheus_client not installed — metrics disabled")
|
||||
return
|
||||
|
||||
from flask import request, Response
|
||||
|
||||
# Set app info gauge
|
||||
APP_INFO.labels(
|
||||
version=app.config.get("VERSION", "unknown"),
|
||||
service=service_name,
|
||||
env=app.config.get("ENV", "unknown"),
|
||||
).set(1)
|
||||
|
||||
@app.before_request
|
||||
def before_request():
|
||||
request._start_time = time.time()
|
||||
HTTP_REQUESTS_IN_PROGRESS.labels(
|
||||
method=request.method, endpoint=request.path, service=service_name
|
||||
).inc()
|
||||
|
||||
@app.after_request
|
||||
def after_request(response):
|
||||
duration = time.time() - getattr(request, "_start_time", time.time())
|
||||
endpoint = request.path
|
||||
|
||||
HTTP_REQUESTS_TOTAL.labels(
|
||||
method=request.method,
|
||||
endpoint=endpoint,
|
||||
status_code=str(response.status_code),
|
||||
service=service_name,
|
||||
).inc()
|
||||
|
||||
HTTP_REQUEST_DURATION.labels(
|
||||
method=request.method, endpoint=endpoint, service=service_name
|
||||
).observe(duration)
|
||||
|
||||
HTTP_REQUESTS_IN_PROGRESS.labels(
|
||||
method=request.method, endpoint=endpoint, service=service_name
|
||||
).dec()
|
||||
|
||||
return response
|
||||
|
||||
@app.route("/metrics")
|
||||
def metrics_endpoint():
|
||||
"""Prometheus metrics scrape endpoint."""
|
||||
return Response(generate_latest(), mimetype=CONTENT_TYPE_LATEST)
|
||||
|
||||
@app.route("/health")
|
||||
def health_endpoint():
|
||||
"""Docker / load-balancer health check endpoint."""
|
||||
from flask import jsonify
|
||||
|
||||
return jsonify({"status": "ok", "service": service_name})
|
||||
|
||||
logger.info(f"Prometheus metrics initialized for service: {service_name}")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Decorator helpers
|
||||
# ============================================================
|
||||
|
||||
|
||||
def track_ml_prediction(model_type: str = "xgboost", race_type: str = "flat"):
|
||||
"""Decorator to track ML prediction calls."""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not PROMETHEUS_AVAILABLE:
|
||||
return func(*args, **kwargs)
|
||||
start = time.time()
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
ML_PREDICTIONS_TOTAL.labels(
|
||||
model_type=model_type, race_type=race_type
|
||||
).inc()
|
||||
return result
|
||||
finally:
|
||||
ML_PREDICTION_DURATION.labels(model_type=model_type).observe(
|
||||
time.time() - start
|
||||
)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def track_db_query(operation: str = "select", table: str = "unknown"):
|
||||
"""Decorator to track DB query calls."""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not PROMETHEUS_AVAILABLE:
|
||||
return func(*args, **kwargs)
|
||||
start = time.time()
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
DB_QUERIES_TOTAL.labels(operation=operation, table=table).inc()
|
||||
return result
|
||||
finally:
|
||||
DB_QUERY_DURATION.labels(operation=operation).observe(
|
||||
time.time() - start
|
||||
)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def update_ml_accuracy(top1_accuracy: float, top3_accuracy: float):
|
||||
"""Update ML accuracy gauges (call from scheduler)."""
|
||||
if not PROMETHEUS_AVAILABLE:
|
||||
return
|
||||
ML_PREDICTION_ACCURACY.labels(accuracy_type="top1").set(top1_accuracy)
|
||||
ML_PREDICTION_ACCURACY.labels(accuracy_type="top3").set(top3_accuracy)
|
||||
|
||||
|
||||
def update_subscription_count(plan_counts: dict):
|
||||
"""Update subscription count gauges."""
|
||||
if not PROMETHEUS_AVAILABLE:
|
||||
return
|
||||
for plan, count in plan_counts.items():
|
||||
ACTIVE_SUBSCRIPTIONS.labels(plan=plan).set(count)
|
||||
1
migrations/README
Normal file
1
migrations/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration file
|
||||
68
migrations/env.py
Normal file
68
migrations/env.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Alembic env.py — Turf SaaS database migrations."""
|
||||
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from alembic import context
|
||||
|
||||
# Alembic Config object — gives access to .ini values
|
||||
config = context.config
|
||||
|
||||
# Set logging from config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
|
||||
# Override sqlalchemy.url from environment variables
|
||||
def get_db_url():
|
||||
user = os.environ.get("POSTGRES_USER", "turf")
|
||||
password = os.environ.get("POSTGRES_PASSWORD", "")
|
||||
host = os.environ.get("POSTGRES_HOST", "localhost")
|
||||
port = os.environ.get("POSTGRES_PORT", "5432")
|
||||
db = os.environ.get("POSTGRES_DB", "turf_saas")
|
||||
url = os.environ.get(
|
||||
"DATABASE_URL", f"postgresql://{user}:{password}@{host}:{port}/{db}"
|
||||
)
|
||||
return url
|
||||
|
||||
|
||||
config.set_main_option("sqlalchemy.url", get_db_url())
|
||||
|
||||
# No declarative model — we use raw DDL migrations
|
||||
target_metadata = None
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode (no live DB connection needed)."""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode (uses live DB connection)."""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
180
migrations/migrate_sqlite_to_postgres.py
Normal file
180
migrations/migrate_sqlite_to_postgres.py
Normal file
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SQLite → PostgreSQL Data Migration Script
|
||||
Migrates existing turf_saas.db data to PostgreSQL.
|
||||
|
||||
Usage:
|
||||
python migrations/migrate_sqlite_to_postgres.py \
|
||||
--sqlite /path/to/turf_saas.db \
|
||||
--pg-url postgresql://turf:password@localhost:5432/turf_saas
|
||||
|
||||
Run AFTER alembic upgrade head.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sqlite3
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger("migrate")
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
|
||||
|
||||
# Tables to migrate (in order to respect FK constraints)
|
||||
TABLES = [
|
||||
"predictions",
|
||||
"results",
|
||||
"performance",
|
||||
"scraping_logs",
|
||||
"pmu_reunions",
|
||||
"pmu_meteo",
|
||||
"pmu_courses",
|
||||
"pmu_partants",
|
||||
"ml_predictions_cache",
|
||||
"users",
|
||||
"subscriptions",
|
||||
"refresh_tokens",
|
||||
]
|
||||
|
||||
|
||||
def get_sqlite_conn(sqlite_path: str):
|
||||
conn = sqlite3.connect(sqlite_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def get_pg_conn(pg_url: str):
|
||||
try:
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
conn = psycopg2.connect(pg_url)
|
||||
return conn
|
||||
except ImportError:
|
||||
logger.error("psycopg2 not installed. Run: pip install psycopg2-binary")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def migrate_table(sqlite_conn, pg_conn, table: str, batch_size: int = 500) -> int:
|
||||
"""Migrate a single table from SQLite to PostgreSQL. Returns row count."""
|
||||
import psycopg2.extras
|
||||
|
||||
sqlite_cur = sqlite_conn.cursor()
|
||||
pg_cur = pg_conn.cursor()
|
||||
|
||||
# Get rows from SQLite
|
||||
try:
|
||||
sqlite_cur.execute(f"SELECT * FROM {table}")
|
||||
except Exception as e:
|
||||
logger.warning(f" Skipping {table}: {e}")
|
||||
return 0
|
||||
|
||||
rows = sqlite_cur.fetchall()
|
||||
if not rows:
|
||||
logger.info(f" {table}: empty — skipping")
|
||||
return 0
|
||||
|
||||
# Get column names
|
||||
columns = [desc[0] for desc in sqlite_cur.description]
|
||||
# Exclude 'id' to let PostgreSQL generate SERIAL
|
||||
non_id_columns = [c for c in columns if c != "id"]
|
||||
|
||||
if not non_id_columns:
|
||||
logger.warning(f" {table}: no columns to insert")
|
||||
return 0
|
||||
|
||||
placeholders = ", ".join(["%s"] * len(non_id_columns))
|
||||
col_list = ", ".join(non_id_columns)
|
||||
insert_sql = f"INSERT INTO {table} ({col_list}) VALUES ({placeholders}) ON CONFLICT DO NOTHING"
|
||||
|
||||
inserted = 0
|
||||
batch = []
|
||||
|
||||
for row in rows:
|
||||
row_dict = dict(row)
|
||||
values = tuple(row_dict.get(c) for c in non_id_columns)
|
||||
batch.append(values)
|
||||
|
||||
if len(batch) >= batch_size:
|
||||
try:
|
||||
pg_cur.executemany(insert_sql, batch)
|
||||
pg_conn.commit()
|
||||
inserted += len(batch)
|
||||
except Exception as e:
|
||||
pg_conn.rollback()
|
||||
logger.error(f" {table} batch error: {e}")
|
||||
batch = []
|
||||
|
||||
# Final batch
|
||||
if batch:
|
||||
try:
|
||||
pg_cur.executemany(insert_sql, batch)
|
||||
pg_conn.commit()
|
||||
inserted += len(batch)
|
||||
except Exception as e:
|
||||
pg_conn.rollback()
|
||||
logger.error(f" {table} final batch error: {e}")
|
||||
|
||||
# Sync PostgreSQL sequence to max id
|
||||
try:
|
||||
pg_cur.execute(f"SELECT MAX(id) FROM {table}")
|
||||
max_id = pg_cur.fetchone()[0]
|
||||
if max_id:
|
||||
seq_name = f"{table}_id_seq"
|
||||
pg_cur.execute(f"SELECT setval('{seq_name}', {max_id})")
|
||||
pg_conn.commit()
|
||||
except Exception:
|
||||
pass # Table may not have a sequence
|
||||
|
||||
return inserted
|
||||
|
||||
|
||||
def run_migration(sqlite_path: str, pg_url: str):
|
||||
logger.info(f"=== SQLite → PostgreSQL Migration ===")
|
||||
logger.info(f"SQLite: {sqlite_path}")
|
||||
logger.info(f"PostgreSQL: {pg_url.split('@')[-1]}") # Hide credentials in log
|
||||
logger.info(f"Started: {datetime.now().isoformat()}")
|
||||
|
||||
if not os.path.exists(sqlite_path):
|
||||
logger.error(f"SQLite file not found: {sqlite_path}")
|
||||
sys.exit(1)
|
||||
|
||||
sqlite_conn = get_sqlite_conn(sqlite_path)
|
||||
pg_conn = get_pg_conn(pg_url)
|
||||
|
||||
total = 0
|
||||
for table in TABLES:
|
||||
logger.info(f" Migrating: {table}...")
|
||||
count = migrate_table(sqlite_conn, pg_conn, table)
|
||||
logger.info(f" → {table}: {count} rows migrated")
|
||||
total += count
|
||||
|
||||
sqlite_conn.close()
|
||||
pg_conn.close()
|
||||
|
||||
logger.info(f"=== Migration complete: {total} total rows ===")
|
||||
logger.info(f"Finished: {datetime.now().isoformat()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Migrate SQLite → PostgreSQL")
|
||||
parser.add_argument(
|
||||
"--sqlite",
|
||||
default=os.environ.get("DB_PATH", "/home/h3r7/turf_saas/turf_saas.db"),
|
||||
help="Path to SQLite database file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pg-url",
|
||||
default=os.environ.get("DATABASE_URL", ""),
|
||||
help="PostgreSQL connection URL",
|
||||
)
|
||||
parser.add_argument("--batch-size", type=int, default=500)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.pg_url:
|
||||
logger.error("--pg-url or DATABASE_URL env var required")
|
||||
sys.exit(1)
|
||||
|
||||
run_migration(args.sqlite, args.pg_url)
|
||||
26
migrations/script.py.mako
Normal file
26
migrations/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
345
migrations/versions/001_initial_schema.py
Normal file
345
migrations/versions/001_initial_schema.py
Normal file
@@ -0,0 +1,345 @@
|
||||
"""Initial schema — PostgreSQL migration from SQLite
|
||||
|
||||
Revision ID: 001_initial_schema
|
||||
Revises: None
|
||||
Create Date: 2026-04-25
|
||||
|
||||
Full migration of turf_saas SQLite schema to PostgreSQL.
|
||||
Tables: predictions, results, performance, scraping_logs,
|
||||
pmu_reunions, pmu_meteo, pmu_courses, pmu_partants,
|
||||
ml_predictions_cache, users, subscriptions, refresh_tokens
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers
|
||||
revision: str = "001_initial_schema"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ----------------------------------------------------------
|
||||
# predictions
|
||||
# ----------------------------------------------------------
|
||||
op.create_table(
|
||||
"predictions",
|
||||
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||
sa.Column("date", sa.Text, nullable=False),
|
||||
sa.Column("race_name", sa.Text),
|
||||
sa.Column("race_hippodrome", sa.Text),
|
||||
sa.Column("race_time", sa.Text),
|
||||
sa.Column("horse_number", sa.Integer),
|
||||
sa.Column("horse_name", sa.Text),
|
||||
sa.Column("odds", sa.Numeric(10, 2)),
|
||||
sa.Column("prediction_rank", sa.Integer),
|
||||
sa.Column("source", sa.Text),
|
||||
sa.Column("jockey", sa.Text),
|
||||
sa.Column("odds_time", sa.Text),
|
||||
sa.Column("odds_prev", sa.Numeric(10, 2)),
|
||||
sa.Column("created_at", sa.TIMESTAMP, server_default=sa.text("NOW()")),
|
||||
)
|
||||
op.create_index("idx_predictions_date", "predictions", ["date"])
|
||||
op.create_index("idx_predictions_horse", "predictions", ["horse_name"])
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# results
|
||||
# ----------------------------------------------------------
|
||||
op.create_table(
|
||||
"results",
|
||||
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||
sa.Column("date", sa.Text, nullable=False),
|
||||
sa.Column("race_name", sa.Text),
|
||||
sa.Column("race_hippodrome", sa.Text),
|
||||
sa.Column("position", sa.Integer),
|
||||
sa.Column("horse_name", sa.Text),
|
||||
sa.Column("odds", sa.Numeric(10, 2)),
|
||||
sa.Column("created_at", sa.TIMESTAMP, server_default=sa.text("NOW()")),
|
||||
)
|
||||
op.create_index("idx_results_date", "results", ["date"])
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# performance
|
||||
# ----------------------------------------------------------
|
||||
op.create_table(
|
||||
"performance",
|
||||
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||
sa.Column("prediction_date", sa.Text),
|
||||
sa.Column("race_date", sa.Text),
|
||||
sa.Column("horse_name", sa.Text),
|
||||
sa.Column("predicted_rank", sa.Integer),
|
||||
sa.Column("actual_position", sa.Integer),
|
||||
sa.Column("hit", sa.Boolean),
|
||||
sa.Column("created_at", sa.TIMESTAMP, server_default=sa.text("NOW()")),
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# scraping_logs
|
||||
# ----------------------------------------------------------
|
||||
op.create_table(
|
||||
"scraping_logs",
|
||||
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||
sa.Column("timestamp", sa.Text),
|
||||
sa.Column("runtime_sec", sa.Numeric(10, 3)),
|
||||
sa.Column("total_pages", sa.Integer),
|
||||
sa.Column("url", sa.Text),
|
||||
sa.Column("site", sa.Text),
|
||||
sa.Column("status", sa.Text),
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# pmu_reunions
|
||||
# ----------------------------------------------------------
|
||||
op.create_table(
|
||||
"pmu_reunions",
|
||||
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||
sa.Column("date_programme", sa.Text, nullable=False),
|
||||
sa.Column("num_reunion", sa.Integer, nullable=False),
|
||||
sa.Column("num_externe", sa.Integer),
|
||||
sa.Column("nature", sa.Text),
|
||||
sa.Column("statut", sa.Text),
|
||||
sa.Column("audience", sa.Text),
|
||||
sa.Column("hippodrome_code", sa.Text),
|
||||
sa.Column("hippodrome_court", sa.Text),
|
||||
sa.Column("hippodrome_long", sa.Text),
|
||||
sa.Column("pays_code", sa.Text),
|
||||
sa.Column("pays_libelle", sa.Text),
|
||||
sa.Column("created_at", sa.TIMESTAMP, server_default=sa.text("NOW()")),
|
||||
sa.UniqueConstraint("date_programme", "num_reunion", name="uq_pmu_reunions"),
|
||||
)
|
||||
op.create_index("idx_reunions_date", "pmu_reunions", ["date_programme"])
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# pmu_meteo
|
||||
# ----------------------------------------------------------
|
||||
op.create_table(
|
||||
"pmu_meteo",
|
||||
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||
sa.Column("date_programme", sa.Text, nullable=False),
|
||||
sa.Column("num_reunion", sa.Integer, nullable=False),
|
||||
sa.Column("nebulositecode", sa.Text),
|
||||
sa.Column("nebulosite_court", sa.Text),
|
||||
sa.Column("nebulosite_long", sa.Text),
|
||||
sa.Column("temperature", sa.Integer),
|
||||
sa.Column("force_vent", sa.Integer),
|
||||
sa.Column("direction_vent", sa.Text),
|
||||
sa.Column("date_prevision", sa.BigInteger),
|
||||
sa.Column("created_at", sa.TIMESTAMP, server_default=sa.text("NOW()")),
|
||||
sa.UniqueConstraint("date_programme", "num_reunion", name="uq_pmu_meteo"),
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# pmu_courses
|
||||
# ----------------------------------------------------------
|
||||
op.create_table(
|
||||
"pmu_courses",
|
||||
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||
sa.Column("date_programme", sa.Text, nullable=False),
|
||||
sa.Column("num_reunion", sa.Integer, nullable=False),
|
||||
sa.Column("num_course", sa.Integer, nullable=False),
|
||||
sa.Column("num_externe", sa.Integer),
|
||||
sa.Column("libelle", sa.Text),
|
||||
sa.Column("libelle_court", sa.Text),
|
||||
sa.Column("heure_depart", sa.BigInteger),
|
||||
sa.Column("heure_depart_str", sa.Text),
|
||||
sa.Column("distance", sa.Integer),
|
||||
sa.Column("distance_unit", sa.Text),
|
||||
sa.Column("parcours", sa.Text),
|
||||
sa.Column("discipline", sa.Text),
|
||||
sa.Column("specialite", sa.Text),
|
||||
sa.Column("type_piste", sa.Text),
|
||||
sa.Column("corde", sa.Text),
|
||||
sa.Column("condition_age", sa.Text),
|
||||
sa.Column("condition_sexe", sa.Text),
|
||||
sa.Column("categorie_particularite", sa.Text),
|
||||
sa.Column("nb_declares_partants", sa.Integer),
|
||||
sa.Column("montant_prix", sa.Integer),
|
||||
sa.Column("montant_1er", sa.Integer),
|
||||
sa.Column("montant_2eme", sa.Integer),
|
||||
sa.Column("montant_3eme", sa.Integer),
|
||||
sa.Column("montant_4eme", sa.Integer),
|
||||
sa.Column("montant_5eme", sa.Integer),
|
||||
sa.Column("created_at", sa.TIMESTAMP, server_default=sa.text("NOW()")),
|
||||
sa.UniqueConstraint(
|
||||
"date_programme", "num_reunion", "num_course", name="uq_pmu_courses"
|
||||
),
|
||||
)
|
||||
op.create_index("idx_courses_date", "pmu_courses", ["date_programme"])
|
||||
op.create_index("idx_courses_discipline", "pmu_courses", ["discipline"])
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# pmu_partants
|
||||
# ----------------------------------------------------------
|
||||
op.create_table(
|
||||
"pmu_partants",
|
||||
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||
sa.Column("date_programme", sa.Text, nullable=False),
|
||||
sa.Column("num_reunion", sa.Integer, nullable=False),
|
||||
sa.Column("num_course", sa.Integer, nullable=False),
|
||||
sa.Column("num_pmu", sa.Integer),
|
||||
sa.Column("id_cheval", sa.BigInteger),
|
||||
sa.Column("nom", sa.Text),
|
||||
sa.Column("age", sa.Integer),
|
||||
sa.Column("sexe", sa.Text),
|
||||
sa.Column("race", sa.Text),
|
||||
sa.Column("robe", sa.Text),
|
||||
sa.Column("pays", sa.Text),
|
||||
sa.Column("place_corde", sa.Integer),
|
||||
sa.Column("nom_pere", sa.Text),
|
||||
sa.Column("nom_mere", sa.Text),
|
||||
sa.Column("nom_pere_mere", sa.Text),
|
||||
sa.Column("driver", sa.Text),
|
||||
sa.Column("driver_change", sa.Boolean),
|
||||
sa.Column("entraineur", sa.Text),
|
||||
sa.Column("proprietaire", sa.Text),
|
||||
sa.Column("eleveur", sa.Text),
|
||||
sa.Column("oeilleres", sa.Text),
|
||||
sa.Column("supplement", sa.Boolean),
|
||||
sa.Column("handicap_valeur", sa.Numeric(8, 2)),
|
||||
sa.Column("handicap_poids", sa.Numeric(8, 2)),
|
||||
sa.Column("musique", sa.Text),
|
||||
sa.Column("nombre_courses", sa.Integer),
|
||||
sa.Column("nombre_victoires", sa.Integer),
|
||||
sa.Column("nombre_places", sa.Integer),
|
||||
sa.Column("cote_direct", sa.Numeric(10, 2)),
|
||||
sa.Column("cote_reference", sa.Numeric(10, 2)),
|
||||
sa.Column("tendance_cote", sa.Text),
|
||||
sa.Column("favoris", sa.Boolean),
|
||||
sa.Column("ordre_arrivee", sa.Integer),
|
||||
sa.Column("tx_victoire", sa.Numeric(6, 3)),
|
||||
sa.Column("tx_place", sa.Numeric(6, 3)),
|
||||
sa.Column("forme_recente", sa.Text),
|
||||
sa.Column("gains_carriere", sa.BigInteger),
|
||||
sa.Column("gains_annee_en_cours", sa.BigInteger),
|
||||
sa.Column("tendance_forme", sa.Text),
|
||||
sa.Column("distance_cheval_prec", sa.Integer),
|
||||
sa.Column("commentaire_apres_course", sa.Text),
|
||||
sa.Column("pays_entrainement", sa.Text),
|
||||
sa.Column("created_at", sa.TIMESTAMP, server_default=sa.text("NOW()")),
|
||||
sa.UniqueConstraint(
|
||||
"date_programme",
|
||||
"num_reunion",
|
||||
"num_course",
|
||||
"num_pmu",
|
||||
name="uq_pmu_partants",
|
||||
),
|
||||
)
|
||||
op.create_index("idx_partants_date", "pmu_partants", ["date_programme"])
|
||||
op.create_index("idx_partants_nom", "pmu_partants", ["nom"])
|
||||
op.create_index("idx_partants_entraineur", "pmu_partants", ["entraineur"])
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# ml_predictions_cache
|
||||
# ----------------------------------------------------------
|
||||
op.create_table(
|
||||
"ml_predictions_cache",
|
||||
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||
sa.Column("date", sa.Text, nullable=False),
|
||||
sa.Column("num_reunion", sa.Integer),
|
||||
sa.Column("num_course", sa.Integer),
|
||||
sa.Column("horse_name", sa.Text),
|
||||
sa.Column("horse_number", sa.Integer),
|
||||
sa.Column("odds", sa.Numeric(10, 2)),
|
||||
sa.Column("prob_top1", sa.Numeric(6, 4)),
|
||||
sa.Column("prob_top3", sa.Numeric(6, 4)),
|
||||
sa.Column("ml_score", sa.Numeric(6, 4)),
|
||||
sa.Column("recommendation", sa.Text),
|
||||
sa.Column("is_value_bet", sa.Integer, server_default="0"),
|
||||
sa.Column("is_outlier", sa.Integer, server_default="0"),
|
||||
sa.Column("race_label", sa.Text),
|
||||
sa.Column("race_name", sa.Text),
|
||||
sa.Column("hippodrome", sa.Text),
|
||||
sa.Column("discipline", sa.Text),
|
||||
sa.Column("distance", sa.Numeric(8, 1)),
|
||||
sa.Column("heure", sa.Text),
|
||||
sa.Column("model_version", sa.Text, server_default="'xgboost_v1'"),
|
||||
sa.Column("risque_label", sa.Text, server_default="'neutral'"),
|
||||
sa.Column("risque_score", sa.Integer, server_default="50"),
|
||||
sa.Column("created_at", sa.TIMESTAMP, server_default=sa.text("NOW()")),
|
||||
sa.UniqueConstraint(
|
||||
"date", "num_reunion", "num_course", "horse_name", name="uq_ml_cache"
|
||||
),
|
||||
)
|
||||
op.create_index("idx_ml_cache_date", "ml_predictions_cache", ["date"])
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# users
|
||||
# ----------------------------------------------------------
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||
sa.Column("email", sa.Text, nullable=False, unique=True),
|
||||
sa.Column("password_hash", sa.Text, nullable=False),
|
||||
sa.Column(
|
||||
"plan",
|
||||
sa.Text,
|
||||
nullable=False,
|
||||
server_default="'free'",
|
||||
),
|
||||
sa.Column(
|
||||
"created_at", sa.TIMESTAMP, nullable=False, server_default=sa.text("NOW()")
|
||||
),
|
||||
sa.Column("is_active", sa.Integer, nullable=False, server_default="1"),
|
||||
sa.Column("daily_usage", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("last_usage_date", sa.Text),
|
||||
sa.CheckConstraint("plan IN ('free','premium','pro')", name="ck_users_plan"),
|
||||
)
|
||||
op.create_index("idx_users_email", "users", ["email"], unique=True)
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# subscriptions
|
||||
# ----------------------------------------------------------
|
||||
op.create_table(
|
||||
"subscriptions",
|
||||
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.BigInteger, sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("plan", sa.Text, nullable=False),
|
||||
sa.Column(
|
||||
"start_date", sa.TIMESTAMP, nullable=False, server_default=sa.text("NOW()")
|
||||
),
|
||||
sa.Column("end_date", sa.TIMESTAMP),
|
||||
sa.Column("stripe_customer_id", sa.Text),
|
||||
sa.CheckConstraint(
|
||||
"plan IN ('free','premium','pro')", name="ck_subscriptions_plan"
|
||||
),
|
||||
)
|
||||
op.create_index("idx_subscriptions_user", "subscriptions", ["user_id"])
|
||||
op.create_index("idx_subscriptions_stripe", "subscriptions", ["stripe_customer_id"])
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# refresh_tokens
|
||||
# ----------------------------------------------------------
|
||||
op.create_table(
|
||||
"refresh_tokens",
|
||||
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.BigInteger, sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("token_hash", sa.Text, nullable=False, unique=True),
|
||||
sa.Column(
|
||||
"created_at", sa.TIMESTAMP, nullable=False, server_default=sa.text("NOW()")
|
||||
),
|
||||
sa.Column("expires_at", sa.TIMESTAMP, nullable=False),
|
||||
sa.Column("revoked", sa.Integer, nullable=False, server_default="0"),
|
||||
)
|
||||
op.create_index("idx_refresh_tokens_user", "refresh_tokens", ["user_id"])
|
||||
op.create_index(
|
||||
"idx_refresh_tokens_hash", "refresh_tokens", ["token_hash"], unique=True
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("refresh_tokens")
|
||||
op.drop_table("subscriptions")
|
||||
op.drop_table("users")
|
||||
op.drop_table("ml_predictions_cache")
|
||||
op.drop_table("pmu_partants")
|
||||
op.drop_table("pmu_courses")
|
||||
op.drop_table("pmu_meteo")
|
||||
op.drop_table("pmu_reunions")
|
||||
op.drop_table("scraping_logs")
|
||||
op.drop_table("performance")
|
||||
op.drop_table("results")
|
||||
op.drop_table("predictions")
|
||||
174
models/benchmark_report.json
Normal file
174
models/benchmark_report.json
Normal file
@@ -0,0 +1,174 @@
|
||||
{
|
||||
"run_date": "2026-04-25T19:09:46.629142",
|
||||
"dataset": {
|
||||
"db_path": "/home/h3r7/turf_saas/turf.db",
|
||||
"total_rows": 10899,
|
||||
"train_rows": 8719,
|
||||
"holdout_rows": 2180,
|
||||
"train_date_range": [
|
||||
"2026-03-31",
|
||||
"2026-04-19"
|
||||
],
|
||||
"holdout_date_range": [
|
||||
"2026-04-19",
|
||||
"2026-04-24"
|
||||
]
|
||||
},
|
||||
"baseline": {
|
||||
"model": "XGBoost (baseline)",
|
||||
"precision_at3": 0.5286821705426358,
|
||||
"auc": 0.7254057665061495
|
||||
},
|
||||
"individual_models": {
|
||||
"xgboost": {
|
||||
"model": "xgboost",
|
||||
"auc": 0.7856,
|
||||
"accuracy": 0.6917,
|
||||
"precision": 0.4865,
|
||||
"recall": 0.7229,
|
||||
"precision_at3": 0.5783,
|
||||
"latency_ms_per_row": 0.0112
|
||||
},
|
||||
"lightgbm": {
|
||||
"model": "lightgbm",
|
||||
"auc": 0.7833,
|
||||
"accuracy": 0.6995,
|
||||
"precision": 0.4951,
|
||||
"recall": 0.709,
|
||||
"precision_at3": 0.5736,
|
||||
"latency_ms_per_row": 0.0041
|
||||
},
|
||||
"mlp": {
|
||||
"model": "mlp",
|
||||
"auc": 0.7743,
|
||||
"accuracy": 0.7445,
|
||||
"precision": 0.5743,
|
||||
"recall": 0.5325,
|
||||
"precision_at3": 0.5643,
|
||||
"latency_ms_per_row": 0.0052
|
||||
}
|
||||
},
|
||||
"ensemble": {
|
||||
"model": "ensemble",
|
||||
"auc": 0.784,
|
||||
"accuracy": 0.7147,
|
||||
"precision": 0.5142,
|
||||
"recall": 0.6718,
|
||||
"precision_at3": 0.5814,
|
||||
"latency_ms_per_row": 0.0208
|
||||
},
|
||||
"delta_precision_at3": 0.0527,
|
||||
"deploy": true,
|
||||
"optuna": {
|
||||
"n_trials": 100,
|
||||
"xgboost_best_params": {
|
||||
"n_estimators": 141,
|
||||
"max_depth": 5,
|
||||
"learning_rate": 0.016298172447266404,
|
||||
"subsample": 0.7660470794373848,
|
||||
"colsample_bytree": 0.471124415020467,
|
||||
"min_child_weight": 14,
|
||||
"reg_alpha": 1.9364166463791586,
|
||||
"reg_lambda": 6.018030083488602,
|
||||
"gamma": 4.614943551368141
|
||||
},
|
||||
"lightgbm_best_params": {
|
||||
"n_estimators": 186,
|
||||
"max_depth": 4,
|
||||
"learning_rate": 0.012915117465216954,
|
||||
"num_leaves": 141,
|
||||
"subsample": 0.6193119116922561,
|
||||
"colsample_bytree": 0.539310022549326,
|
||||
"min_child_samples": 9,
|
||||
"reg_alpha": 0.6864583098112754,
|
||||
"reg_lambda": 0.0549259590914184
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"total": 43,
|
||||
"selected_by_shap": 31,
|
||||
"feature_list": [
|
||||
"age",
|
||||
"sexe_enc",
|
||||
"nombre_courses",
|
||||
"nombre_victoires",
|
||||
"nombre_places",
|
||||
"tx_victoire",
|
||||
"tx_place",
|
||||
"forme_recente",
|
||||
"tendance_num",
|
||||
"gains_annee_en_cours",
|
||||
"cote_direct",
|
||||
"cote_reference",
|
||||
"distance",
|
||||
"nb_partants",
|
||||
"discipline_enc",
|
||||
"specialite_enc",
|
||||
"oeilleres_enc",
|
||||
"tendance_cote_enc",
|
||||
"penetrometre_intitule_enc",
|
||||
"form_1",
|
||||
"form_2",
|
||||
"form_3",
|
||||
"form_4",
|
||||
"form_5",
|
||||
"form_weighted",
|
||||
"form_avg",
|
||||
"form_best",
|
||||
"form_worst",
|
||||
"win_ratio",
|
||||
"place_ratio",
|
||||
"implied_prob",
|
||||
"win_rate_adj",
|
||||
"place_rate_adj",
|
||||
"earnings_per_race",
|
||||
"cote_diff",
|
||||
"cote_ratio",
|
||||
"rang_cote",
|
||||
"ratio_cote_field",
|
||||
"distance_cat",
|
||||
"age_win_interact",
|
||||
"is_favorite",
|
||||
"poids",
|
||||
"prize_norm"
|
||||
],
|
||||
"shap_selected": [
|
||||
"rang_cote",
|
||||
"implied_prob",
|
||||
"cote_direct",
|
||||
"ratio_cote_field",
|
||||
"nb_partants",
|
||||
"cote_diff",
|
||||
"cote_ratio",
|
||||
"specialite_enc",
|
||||
"earnings_per_race",
|
||||
"nombre_courses",
|
||||
"cote_reference",
|
||||
"distance",
|
||||
"discipline_enc",
|
||||
"is_favorite",
|
||||
"prize_norm",
|
||||
"win_ratio",
|
||||
"place_rate_adj",
|
||||
"gains_annee_en_cours",
|
||||
"poids",
|
||||
"tx_place",
|
||||
"penetrometre_intitule_enc",
|
||||
"age_win_interact",
|
||||
"nombre_places",
|
||||
"tendance_num",
|
||||
"age",
|
||||
"form_avg",
|
||||
"form_weighted",
|
||||
"place_ratio",
|
||||
"form_3",
|
||||
"oeilleres_enc",
|
||||
"form_5"
|
||||
]
|
||||
},
|
||||
"ensemble_weights": {
|
||||
"xgboost": 0.23161801824035544,
|
||||
"lightgbm": 0.23415467282905,
|
||||
"mlp": 0.21290370528252356
|
||||
}
|
||||
}
|
||||
68
models/benchmark_report.md
Normal file
68
models/benchmark_report.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Benchmark ML Ensemble — Turf Prédictions
|
||||
|
||||
**Date:** 2026-04-25
|
||||
**Dataset:** 10,899 partants
|
||||
**Holdout:** 2,180 lignes (2026-04-19 → 2026-04-24)
|
||||
|
||||
## Résultats
|
||||
|
||||
| Modèle | Precision@3 | AUC | Latence/prédiction |
|
||||
|--------|-------------|-----|-------------------|
|
||||
| XGBoost (baseline) | 0.5287 | 0.7254 | — |
|
||||
| xgboost | 0.5783 | 0.7856 | 0.01 ms |
|
||||
| lightgbm | 0.5736 | 0.7833 | 0.00 ms |
|
||||
| mlp | 0.5643 | 0.7743 | 0.01 ms |
|
||||
| **Ensemble** | **0.5814** | **0.7840** | **0.02 ms** |
|
||||
|
||||
## Décision de déploiement
|
||||
|
||||
- Delta Precision@3 : **+0.0527** (+5.3%)
|
||||
- Seuil requis : **+5%**
|
||||
- Résultat : **✅ DEPLOIEMENT RECOMMANDE**
|
||||
|
||||
## Optimisation Optuna
|
||||
|
||||
- Trials XGBoost : 100
|
||||
- Trials LightGBM : 100
|
||||
- Pruning : MedianPruner
|
||||
|
||||
### Meilleurs hyperparamètres XGBoost
|
||||
```json
|
||||
{
|
||||
"n_estimators": 141,
|
||||
"max_depth": 5,
|
||||
"learning_rate": 0.016298172447266404,
|
||||
"subsample": 0.7660470794373848,
|
||||
"colsample_bytree": 0.471124415020467,
|
||||
"min_child_weight": 14,
|
||||
"reg_alpha": 1.9364166463791586,
|
||||
"reg_lambda": 6.018030083488602,
|
||||
"gamma": 4.614943551368141
|
||||
}
|
||||
```
|
||||
|
||||
### Meilleurs hyperparamètres LightGBM
|
||||
```json
|
||||
{
|
||||
"n_estimators": 186,
|
||||
"max_depth": 4,
|
||||
"learning_rate": 0.012915117465216954,
|
||||
"num_leaves": 141,
|
||||
"subsample": 0.6193119116922561,
|
||||
"colsample_bytree": 0.539310022549326,
|
||||
"min_child_samples": 9,
|
||||
"reg_alpha": 0.6864583098112754,
|
||||
"reg_lambda": 0.0549259590914184
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Total features : 43
|
||||
- Retenues par SHAP : 31
|
||||
|
||||
## Poids de l'ensemble
|
||||
|
||||
- xgboost : 0.2316
|
||||
- lightgbm : 0.2342
|
||||
- mlp : 0.2129
|
||||
@@ -15,7 +15,7 @@ import sqlite3
|
||||
import re
|
||||
import os
|
||||
|
||||
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
|
||||
DB_PATH = "/home/h3r7/turf_saas/turf_saas.db"
|
||||
HEADERS = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept-Language': 'fr-FR,fr;q=0.9,en;q=0.8',
|
||||
|
||||
335
onboarding.html
Normal file
335
onboarding.html
Normal file
@@ -0,0 +1,335 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Bienvenue — Turf IA</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--green: #00c853; --green-d: #009624; --blue: #1e88e5;
|
||||
--dark: #0d1117; --dark2: #161b22; --dark3: #21262d;
|
||||
--text: #e6edf3; --muted: #8b949e; --border: #30363d;
|
||||
--radius: 12px; --gold: #ffd600;
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px; }
|
||||
|
||||
.onboarding-wrap { width: 100%; max-width: 580px; }
|
||||
.step-indicator {
|
||||
display: flex; align-items: center; justify-content: center; gap: 0; margin-bottom: 36px;
|
||||
}
|
||||
.step-dot {
|
||||
width: 32px; height: 32px; border-radius: 50%; border: 2px solid var(--border);
|
||||
display: flex; align-items: center; justify-content: center; font-size: .8rem; font-weight: 700;
|
||||
color: var(--muted); background: var(--dark2); transition: all .3s; flex-shrink: 0;
|
||||
}
|
||||
.step-dot.active { border-color: var(--green); color: var(--green); background: rgba(0,200,83,.1); }
|
||||
.step-dot.done { border-color: var(--green); background: var(--green); color: #000; }
|
||||
.step-line { flex: 1; height: 2px; background: var(--border); max-width: 80px; transition: background .3s; }
|
||||
.step-line.done { background: var(--green); }
|
||||
|
||||
.step-panel { display: none; animation: fadeIn .3s ease; }
|
||||
.step-panel.active { display: block; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
.card { background: var(--dark2); border: 1px solid var(--border); border-radius: 16px; padding: 40px; }
|
||||
.step-eyebrow { font-size: .8rem; color: var(--green); font-weight: 700; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px; }
|
||||
h2 { font-size: 1.6rem; font-weight: 800; margin-bottom: 10px; }
|
||||
.step-subtitle { color: var(--muted); font-size: .95rem; margin-bottom: 28px; line-height: 1.6; }
|
||||
|
||||
.plan-options { display: flex; flex-direction: column; gap: 12px; margin-bottom: 28px; }
|
||||
.plan-option {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 14px;
|
||||
padding: 16px 18px; border: 2px solid var(--border); border-radius: var(--radius);
|
||||
cursor: pointer; transition: all .2s; background: var(--dark3);
|
||||
}
|
||||
.plan-option:hover { border-color: var(--muted); }
|
||||
.plan-option.selected { border-color: var(--green); background: rgba(0,200,83,.06); }
|
||||
.plan-option-left { display: flex; align-items: center; gap: 14px; }
|
||||
.plan-icon { font-size: 1.6rem; }
|
||||
.plan-info h3 { font-size: 1rem; font-weight: 700; }
|
||||
.plan-info p { font-size: .82rem; color: var(--muted); margin-top: 2px; }
|
||||
.plan-price-tag { font-weight: 800; font-size: 1rem; color: var(--green); text-align: right; }
|
||||
.plan-price-tag .sub { font-size: .72rem; font-weight: 400; color: var(--muted); }
|
||||
.plan-radio { width: 18px; height: 18px; border-radius: 50%; border: 2px solid var(--border); flex-shrink: 0; transition: all .2s; }
|
||||
.plan-option.selected .plan-radio { border-color: var(--green); background: var(--green); }
|
||||
|
||||
.telegram-field { background: var(--dark3); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 20px; }
|
||||
.telegram-field h3 { font-size: .95rem; font-weight: 700; margin-bottom: 6px; }
|
||||
.telegram-field p { font-size: .83rem; color: var(--muted); margin-bottom: 12px; }
|
||||
.telegram-input-row { display: flex; gap: 10px; }
|
||||
input[type=text], input[type=email] {
|
||||
flex: 1; padding: 10px 14px; background: var(--dark); border: 1px solid var(--border);
|
||||
border-radius: 8px; color: var(--text); font-size: .9rem; outline: none; transition: border-color .2s;
|
||||
}
|
||||
input[type=text]:focus, input[type=email]:focus { border-color: var(--green); }
|
||||
|
||||
.btn { display: inline-flex; align-items: center; gap: 8px; padding: 12px 28px; border-radius: var(--radius); font-size: 1rem; font-weight: 700; cursor: pointer; border: none; transition: all .2s; }
|
||||
.btn-primary { background: var(--green); color: #000; }
|
||||
.btn-primary:hover { background: var(--green-d); }
|
||||
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--muted); padding: 12px 20px; }
|
||||
.btn-ghost:hover { color: var(--text); border-color: var(--muted); }
|
||||
.btn-full { width: 100%; justify-content: center; }
|
||||
.btn-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||
|
||||
.success-icon { font-size: 4rem; text-align: center; margin-bottom: 20px; }
|
||||
.checklist { list-style: none; margin: 20px 0; }
|
||||
.checklist li { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); font-size: .93rem; }
|
||||
.checklist li:last-child { border-bottom: none; }
|
||||
.checklist li::before { content: "✓"; background: var(--green); color: #000; width: 22px; height: 22px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: .75rem; font-weight: 800; flex-shrink: 0; }
|
||||
|
||||
.first-pred-preview { background: var(--dark3); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin: 20px 0; }
|
||||
.pred-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: .88rem; }
|
||||
.pred-row:last-child { border-bottom: none; }
|
||||
.pred-rank { font-weight: 700; color: var(--gold); width: 24px; }
|
||||
.pred-name { flex: 1; font-weight: 600; }
|
||||
.pred-prob { color: var(--green); font-weight: 700; }
|
||||
.pred-cote { color: var(--muted); font-size: .82rem; }
|
||||
|
||||
footer { margin-top: 24px; color: var(--muted); font-size: .78rem; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="onboarding-wrap">
|
||||
<!-- Step indicator -->
|
||||
<div class="step-indicator">
|
||||
<div class="step-dot active" id="dot-1">1</div>
|
||||
<div class="step-line" id="line-1"></div>
|
||||
<div class="step-dot" id="dot-2">2</div>
|
||||
<div class="step-line" id="line-2"></div>
|
||||
<div class="step-dot" id="dot-3">3</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 1: Welcome + plan confirm -->
|
||||
<div class="step-panel active" id="step-1">
|
||||
<div class="card">
|
||||
<div class="step-eyebrow">Étape 1 sur 3</div>
|
||||
<h2>Bienvenue sur Turf IA 🏇</h2>
|
||||
<p class="step-subtitle">Votre compte est créé. Confirmez votre plan de départ pour personnaliser votre expérience.</p>
|
||||
|
||||
<div class="plan-options" id="plan-options">
|
||||
<div class="plan-option" data-plan="free">
|
||||
<div class="plan-option-left">
|
||||
<div class="plan-icon">🆓</div>
|
||||
<div class="plan-info">
|
||||
<h3>Free</h3>
|
||||
<p>Aperçu quotidien, 1 course complète</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="plan-price-tag">0€<div class="sub">/mois</div></div>
|
||||
</div>
|
||||
<div class="plan-radio"></div>
|
||||
</div>
|
||||
<div class="plan-option" data-plan="premium">
|
||||
<div class="plan-option-left">
|
||||
<div class="plan-icon">⭐</div>
|
||||
<div class="plan-info">
|
||||
<h3>Premium</h3>
|
||||
<p>Toutes les courses, alertes Telegram</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="plan-price-tag">9,90€<div class="sub">/mois</div></div>
|
||||
</div>
|
||||
<div class="plan-radio"></div>
|
||||
</div>
|
||||
<div class="plan-option" data-plan="pro">
|
||||
<div class="plan-option-left">
|
||||
<div class="plan-icon">🚀</div>
|
||||
<div class="plan-info">
|
||||
<h3>Pro</h3>
|
||||
<p>API, export CSV, support prioritaire</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="plan-price-tag">24,90€<div class="sub">/mois</div></div>
|
||||
</div>
|
||||
<div class="plan-radio"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-primary btn-full" id="step1-next">Continuer →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 2: Alerts & preferences -->
|
||||
<div class="step-panel" id="step-2">
|
||||
<div class="card">
|
||||
<div class="step-eyebrow">Étape 2 sur 3</div>
|
||||
<h2>Configurez vos alertes</h2>
|
||||
<p class="step-subtitle">Recevez les meilleures opportunités directement sur votre téléphone avant chaque départ.</p>
|
||||
|
||||
<div class="telegram-field">
|
||||
<h3>📱 Alertes Telegram</h3>
|
||||
<p>Pour activer les alertes, envoyez <strong>/start</strong> à notre bot Telegram <strong>@TurfIABot</strong> et collez ci-dessous votre Chat ID.</p>
|
||||
<div class="telegram-input-row">
|
||||
<input type="text" id="telegram-id" placeholder="Votre Chat ID Telegram (ex: 123456789)">
|
||||
<button class="btn btn-ghost" onclick="openTelegramBot()">Ouvrir le bot</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:24px">
|
||||
<p style="font-size:.88rem;color:var(--muted);margin-bottom:12px">🔔 Quand recevoir les alertes :</p>
|
||||
<label style="display:flex;gap:10px;align-items:center;margin-bottom:10px;cursor:pointer;font-size:.9rem">
|
||||
<input type="checkbox" id="alert-vb" checked style="width:auto;accent-color:var(--green)">
|
||||
Value bets identifiés (cote sous-évaluée)
|
||||
</label>
|
||||
<label style="display:flex;gap:10px;align-items:center;margin-bottom:10px;cursor:pointer;font-size:.9rem">
|
||||
<input type="checkbox" id="alert-top1" checked style="width:auto;accent-color:var(--green)">
|
||||
Favori IA Top-1 de chaque course
|
||||
</label>
|
||||
<label style="display:flex;gap:10px;align-items:center;cursor:pointer;font-size:.9rem">
|
||||
<input type="checkbox" id="alert-quinte" style="width:auto;accent-color:var(--green)">
|
||||
Quinté+ uniquement
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-ghost" id="step2-skip">Passer cette étape</button>
|
||||
<button class="btn btn-primary" id="step2-next">Enregistrer & continuer →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 3: First prediction preview -->
|
||||
<div class="step-panel" id="step-3">
|
||||
<div class="card">
|
||||
<div class="step-eyebrow">Étape 3 sur 3</div>
|
||||
<div class="success-icon">🎉</div>
|
||||
<h2 style="text-align:center">Vous êtes prêt !</h2>
|
||||
<p class="step-subtitle" style="text-align:center">Voici un aperçu de votre première prédiction du jour.</p>
|
||||
|
||||
<div class="first-pred-preview" id="first-pred">
|
||||
<div style="font-size:.8rem;color:var(--muted);margin-bottom:10px;font-weight:700">PRÉDICTION EXEMPLE — R1C1</div>
|
||||
<div class="pred-row"><div class="pred-rank">🥇</div><div class="pred-name">CHARGEMENT...</div><div class="pred-prob">—%</div><div class="pred-cote">—</div></div>
|
||||
<div class="pred-row"><div class="pred-rank">🥈</div><div class="pred-name">—</div><div class="pred-prob">—%</div><div class="pred-cote">—</div></div>
|
||||
<div class="pred-row"><div class="pred-rank">🥉</div><div class="pred-name">—</div><div class="pred-prob">—%</div><div class="pred-cote">—</div></div>
|
||||
</div>
|
||||
|
||||
<ul class="checklist">
|
||||
<li>Compte créé et configuré</li>
|
||||
<li id="check-alerts">Alertes Telegram configurées</li>
|
||||
<li>Dashboard accessible 24h/24</li>
|
||||
</ul>
|
||||
|
||||
<button class="btn btn-primary btn-full" id="step3-finish">Accéder à mon dashboard →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>© 2026 Turf IA — H3R7 Tech. Jouez de façon responsable. 18+</footer>
|
||||
|
||||
<script>
|
||||
const API = '/api/v1';
|
||||
let currentStep = 1;
|
||||
let selectedPlan = 'free';
|
||||
|
||||
function getToken() { return localStorage.getItem('turf_token'); }
|
||||
async function fetchJson(url, opts = {}) {
|
||||
const token = getToken();
|
||||
const res = await fetch(url, {
|
||||
...opts,
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) }
|
||||
});
|
||||
if (res.status === 401) { location.href = '/login'; return null; }
|
||||
return res.ok ? res.json() : null;
|
||||
}
|
||||
|
||||
// Pre-select plan from user storage
|
||||
(function() {
|
||||
if (!getToken()) { location.href = '/login'; return; }
|
||||
try {
|
||||
const user = JSON.parse(localStorage.getItem('turf_user') || '{}');
|
||||
selectedPlan = user.plan || 'free';
|
||||
// Mark paid plans as not selectable if not purchased
|
||||
document.querySelectorAll('.plan-option').forEach(el => {
|
||||
if (el.dataset.plan === selectedPlan) selectPlan(selectedPlan);
|
||||
});
|
||||
} catch(_) { selectPlan('free'); }
|
||||
if (!document.querySelector('.plan-option.selected')) selectPlan('free');
|
||||
})();
|
||||
|
||||
function selectPlan(plan) {
|
||||
selectedPlan = plan;
|
||||
document.querySelectorAll('.plan-option').forEach(el => {
|
||||
el.classList.toggle('selected', el.dataset.plan === plan);
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.plan-option').forEach(el => {
|
||||
el.addEventListener('click', () => selectPlan(el.dataset.plan));
|
||||
});
|
||||
|
||||
function goToStep(n) {
|
||||
currentStep = n;
|
||||
document.querySelectorAll('.step-panel').forEach((el, i) => el.classList.toggle('active', i + 1 === n));
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const dot = document.getElementById(`dot-${i}`);
|
||||
dot.classList.toggle('done', i < n);
|
||||
dot.classList.toggle('active', i === n);
|
||||
if (i < 3) document.getElementById(`line-${i}`)?.classList.toggle('done', i < n);
|
||||
}
|
||||
if (n === 3) loadFirstPrediction();
|
||||
}
|
||||
|
||||
document.getElementById('step1-next').addEventListener('click', async () => {
|
||||
// Update plan via API
|
||||
await fetchJson(`${API}/auth/update-plan`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ plan: selectedPlan })
|
||||
});
|
||||
const user = JSON.parse(localStorage.getItem('turf_user') || '{}');
|
||||
user.plan = selectedPlan;
|
||||
localStorage.setItem('turf_user', JSON.stringify(user));
|
||||
// If paid plan, show payment notice (will be handled in Sprint 5-6)
|
||||
if (selectedPlan !== 'free') {
|
||||
goToStep(2);
|
||||
} else {
|
||||
goToStep(2);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('step2-skip').addEventListener('click', () => { document.getElementById('check-alerts').textContent = 'Alertes Telegram (non configurées)'; goToStep(3); });
|
||||
document.getElementById('step2-next').addEventListener('click', async () => {
|
||||
const chatId = document.getElementById('telegram-id').value.trim();
|
||||
const alertVb = document.getElementById('alert-vb').checked;
|
||||
const alertTop1 = document.getElementById('alert-top1').checked;
|
||||
const alertQ = document.getElementById('alert-quinte').checked;
|
||||
if (chatId) {
|
||||
await fetchJson(`${API}/auth/update-preferences`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ telegram_chat_id: chatId, alert_value_bets: alertVb, alert_top1: alertTop1, alert_quinte_only: alertQ })
|
||||
});
|
||||
}
|
||||
goToStep(3);
|
||||
});
|
||||
|
||||
document.getElementById('step3-finish').addEventListener('click', () => {
|
||||
localStorage.removeItem('turf_onboarding');
|
||||
location.href = '/dashboard';
|
||||
});
|
||||
|
||||
function openTelegramBot() {
|
||||
window.open('https://t.me/TurfIABot', '_blank');
|
||||
}
|
||||
|
||||
async function loadFirstPrediction() {
|
||||
const data = await fetchJson(`${API}/predictions/today`);
|
||||
if (!data || !data.predictions || data.predictions.length === 0) return;
|
||||
const sorted = [...data.predictions].sort((a, b) => b.ml_score - a.ml_score).slice(0, 3);
|
||||
const container = document.getElementById('first-pred');
|
||||
const label = data.predictions[0]?.race_label || 'R1C1';
|
||||
container.innerHTML = `<div style="font-size:.8rem;color:var(--muted);margin-bottom:10px;font-weight:700">PRÉDICTION DU JOUR — ${label}</div>` +
|
||||
sorted.map((h, i) => `<div class="pred-row">
|
||||
<div class="pred-rank">${['🥇','🥈','🥉'][i]}</div>
|
||||
<div class="pred-name">${h.horse_name || '—'}</div>
|
||||
<div class="pred-prob">${h.prob_top3 ? (h.prob_top3*100).toFixed(1)+'%' : '—'}</div>
|
||||
<div class="pred-cote">${h.odds ? h.odds.toFixed(1) : '—'}</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
72
org_db.py
Normal file
72
org_db.py
Normal file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Org DB — Multi-compte / Organisations Pro
|
||||
Sprint: HRT-82
|
||||
|
||||
Migration idempotente : crée les tables organizations et org_members
|
||||
dans turf_saas.db si elles n'existent pas.
|
||||
|
||||
Run une seule fois :
|
||||
./venv/bin/python org_db.py
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
import logging
|
||||
|
||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
logger = logging.getLogger("turf_saas.org_db")
|
||||
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return conn
|
||||
|
||||
|
||||
def migrate_org_tables():
|
||||
"""
|
||||
Migration idempotente : crée organizations + org_members.
|
||||
|
||||
- organizations : 1 org max par owner (enforced en Python + UNIQUE owner_id)
|
||||
- org_members : max 5 membres totaux (owner inclus, enforced en Python)
|
||||
- UNIQUE(org_id, user_id) empêche les doublons de membres
|
||||
"""
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
c.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS organizations (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
max_members INTEGER NOT NULL DEFAULT 5,
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS org_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member'
|
||||
CHECK(role IN ('owner', 'member')),
|
||||
invited_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
joined_at DATETIME,
|
||||
UNIQUE(org_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_org_owner ON organizations(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_orgmem_org ON org_members(org_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_orgmem_user ON org_members(user_id);
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info("[org_db] Tables organizations + org_members créées/vérifiées.")
|
||||
print("[org_db] Migration OK: organizations, org_members.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
migrate_org_tables()
|
||||
@@ -38,7 +38,7 @@ from pathlib import Path
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# CONFIG
|
||||
# ─────────────────────────────────────────────────────────
|
||||
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
|
||||
DB_PATH = "/home/h3r7/turf_saas/turf_saas.db"
|
||||
OUTPUT_DIR = Path("/home/h3r7/turf_scraper")
|
||||
API_BASE = "https://online.turfinfo.api.pmu.fr/rest/client/7"
|
||||
|
||||
|
||||
199
portal_server.py
199
portal_server.py
@@ -5,22 +5,80 @@ import json
|
||||
import requests
|
||||
import subprocess
|
||||
import db
|
||||
from middleware import rate_limit_middleware, access_log_middleware
|
||||
|
||||
app = Flask(__name__)
|
||||
rate_limit_middleware(app)
|
||||
access_log_middleware(app)
|
||||
|
||||
DASHBOARD_API_URL = "http://localhost:8791"
|
||||
COMBINED_API_URL = "http://localhost:8790"
|
||||
COMBINED_API_URL = "http://localhost:8790"
|
||||
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
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(api_v1_bp)
|
||||
print("[portal] SaaS auth & API v1 blueprints registered ✅")
|
||||
except Exception as e:
|
||||
print(f"[portal] Warning: could not register SaaS blueprints: {e}")
|
||||
|
||||
|
||||
# ─── Landing & SaaS pages ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
"""Health check endpoint for Docker/load balancer. Returns 200 if app is running."""
|
||||
return {"status": "ok", "service": "portal"}, 200
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def portal():
|
||||
return send_from_directory("/home/h3r7/turf_saas", "portail.html")
|
||||
def landing():
|
||||
"""Marketing landing page."""
|
||||
return send_from_directory(SAAS_DIR, "landing.html")
|
||||
|
||||
|
||||
@app.route("/login")
|
||||
def login_page():
|
||||
return send_from_directory(SAAS_DIR, "login.html")
|
||||
|
||||
|
||||
@app.route("/register")
|
||||
def register_page():
|
||||
return send_from_directory(SAAS_DIR, "register.html")
|
||||
|
||||
|
||||
@app.route("/dashboard")
|
||||
def dashboard_saas():
|
||||
return send_from_directory(SAAS_DIR, "dashboard_saas.html")
|
||||
|
||||
|
||||
@app.route("/onboarding")
|
||||
def onboarding():
|
||||
return send_from_directory(SAAS_DIR, "onboarding.html")
|
||||
|
||||
|
||||
@app.route("/account")
|
||||
def account():
|
||||
return send_from_directory(SAAS_DIR, "account.html")
|
||||
|
||||
|
||||
@app.route("/portal")
|
||||
@app.route("/portail")
|
||||
def portal_legacy():
|
||||
"""Legacy portal redirect."""
|
||||
return send_from_directory(SAAS_DIR, "portail.html")
|
||||
|
||||
|
||||
@app.route("/favicon.ico")
|
||||
def favicon():
|
||||
return send_from_directory("/home/h3r7/turf_saas", "favicon.ico")
|
||||
return send_from_directory(SAAS_DIR, "favicon.ico")
|
||||
|
||||
|
||||
@app.route("/prompts", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||
@app.route("/prompts/", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||
@app.route("/prompts/<path:subpath>", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||
@@ -269,9 +327,7 @@ def niches_business():
|
||||
|
||||
@app.route("/template_restaurant_json.html")
|
||||
def template_restaurant():
|
||||
return send_from_directory(
|
||||
"/home/h3r7/turf_saas", "template_restaurant_json.html"
|
||||
)
|
||||
return send_from_directory("/home/h3r7/turf_saas", "template_restaurant_json.html")
|
||||
|
||||
|
||||
@app.route("/template_boulangerie_final.html")
|
||||
@@ -288,9 +344,7 @@ def template_artisan():
|
||||
|
||||
@app.route("/template_restaurant_final.html")
|
||||
def template_restaurant_final():
|
||||
return send_from_directory(
|
||||
"/home/h3r7/turf_saas", "template_restaurant_final.html"
|
||||
)
|
||||
return send_from_directory("/home/h3r7/turf_saas", "template_restaurant_final.html")
|
||||
|
||||
|
||||
@app.route("/template_complet.html")
|
||||
@@ -300,9 +354,7 @@ def template_complet():
|
||||
|
||||
@app.route("/boite_a_idees_dashboard")
|
||||
def boite_a_idees_dashboard():
|
||||
return send_from_directory(
|
||||
"/home/h3r7/turf_saas", "boite_a_idees_dashboard.html"
|
||||
)
|
||||
return send_from_directory("/home/h3r7/turf_saas", "boite_a_idees_dashboard.html")
|
||||
|
||||
|
||||
@app.route("/datagouv_explorer.html")
|
||||
@@ -345,13 +397,23 @@ def api_chat_workflows():
|
||||
return jsonify([dict(w) for w in workflows])
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/chat/nvidia-models", methods=["GET"])
|
||||
def api_nvidia_models():
|
||||
return jsonify([
|
||||
{"id": k, "name": v.split("/")[-1].replace("-instruct", "").replace("-", " ").title(), "full_id": v}
|
||||
for k, v in NVIDIA_MODELS.items()
|
||||
])
|
||||
|
||||
return jsonify(
|
||||
[
|
||||
{
|
||||
"id": k,
|
||||
"name": v.split("/")[-1]
|
||||
.replace("-instruct", "")
|
||||
.replace("-", " ")
|
||||
.title(),
|
||||
"full_id": v,
|
||||
}
|
||||
for k, v in NVIDIA_MODELS.items()
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/chat/sessions", methods=["GET"])
|
||||
@@ -457,7 +519,9 @@ def api_chat_cleanup():
|
||||
|
||||
OPENCLAW_TOKEN = "szDd75yG15ZRADWEhGus3dfNoQve7ZhdxpGq9gudeEzoVhUqB0TomVJd90XcwkUz"
|
||||
OPENCLAW_CONTAINER = "openclaw-ow404080wwkkgkgc4oswwssc"
|
||||
NVIDIA_API_KEY = "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"
|
||||
NVIDIA_API_KEY = (
|
||||
"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"
|
||||
)
|
||||
NVIDIA_API_URL = "https://integrate.api.nvidia.com/v1/chat/completions"
|
||||
NVIDIA_MODEL = "meta/llama-3.1-8b-instruct" # Default model
|
||||
NVIDIA_MODELS = {
|
||||
@@ -476,7 +540,6 @@ NVIDIA_MODELS = {
|
||||
}
|
||||
|
||||
|
||||
|
||||
@app.route("/webhook/telegram", methods=["POST"])
|
||||
def telegram_webhook():
|
||||
try:
|
||||
@@ -542,25 +605,25 @@ def webhook_proxy(workflow_slug):
|
||||
model_key = request.json.get("model", "llama-3.1-8b")
|
||||
model_id = NVIDIA_MODELS.get(model_key, NVIDIA_MODEL)
|
||||
resp = requests.post(
|
||||
NVIDIA_API_URL,
|
||||
headers={
|
||||
"Authorization": f"Bearer {NVIDIA_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": model_id,
|
||||
"messages": [{"role": "user", "content": user_message}],
|
||||
"max_tokens": 1024,
|
||||
"temperature": 0.7,
|
||||
},
|
||||
timeout=60,
|
||||
NVIDIA_API_URL,
|
||||
headers={
|
||||
"Authorization": f"Bearer {NVIDIA_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": model_id,
|
||||
"messages": [{"role": "user", "content": user_message}],
|
||||
"max_tokens": 1024,
|
||||
"temperature": 0.7,
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
data = resp.json()
|
||||
ai_response = (
|
||||
data.get("choices", [{}])[0]
|
||||
.get("message", {})
|
||||
.get("content", str(data))
|
||||
)
|
||||
data.get("choices", [{}])[0]
|
||||
.get("message", {})
|
||||
.get("content", str(data))
|
||||
)
|
||||
else:
|
||||
# Proxy vers webhook n8n
|
||||
resp = requests.post(
|
||||
@@ -680,19 +743,29 @@ def pod_static(filename=""):
|
||||
@app.route("/turf/api/")
|
||||
@app.route("/turf/api/<path:api_path>")
|
||||
def api_proxy(api_path=""):
|
||||
if api_path.startswith("vitesse"):
|
||||
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
||||
elif api_path.startswith("n8n-proxy"):
|
||||
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
||||
elif api_path.startswith("backtest"):
|
||||
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
||||
elif api_path.startswith("stats"):
|
||||
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
||||
elif api_path.startswith("predictions_analysis"):
|
||||
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
||||
elif api_path.startswith("parisroi"):
|
||||
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
||||
elif api_path.startswith("paris"):
|
||||
# Routes servies par combined_api.py (port 8790) :
|
||||
# backtest, stats, paris, parisroi, races, scores, report, ask, brave-search,
|
||||
# execute-sql, send-email, vitesse, n8n-proxy, predictions_analysis, ideas
|
||||
# Fix HRT-73 : alignement complet avec turf_scraper fix #23
|
||||
COMBINED_ROUTES = (
|
||||
"backtest",
|
||||
"stats",
|
||||
"parisroi",
|
||||
"paris",
|
||||
"predictions_analysis",
|
||||
"vitesse",
|
||||
"n8n-proxy",
|
||||
"races",
|
||||
"race/",
|
||||
"scores",
|
||||
"ask",
|
||||
"brave-search",
|
||||
"execute-sql",
|
||||
"send-email",
|
||||
"report",
|
||||
"ideas",
|
||||
)
|
||||
if any(api_path.startswith(r) for r in COMBINED_ROUTES):
|
||||
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
||||
elif api_path.startswith("scoring"):
|
||||
url = f"{DASHBOARD_API_URL}/turf/api/{api_path}"
|
||||
@@ -702,12 +775,23 @@ def api_proxy(api_path=""):
|
||||
url = f"{DASHBOARD_API_URL}/turf/api"
|
||||
try:
|
||||
fwd_method = request.method
|
||||
fwd_json = request.get_json(silent=True) if fwd_method in ("POST", "PUT", "PATCH") else None
|
||||
fwd_json = (
|
||||
request.get_json(silent=True)
|
||||
if fwd_method in ("POST", "PUT", "PATCH")
|
||||
else None
|
||||
)
|
||||
# Forwarder Authorization header (combined_api.py exige Basic h3r7:h3r7 pour parisroi/paris)
|
||||
fwd_headers = {"Content-Type": "application/json"}
|
||||
if request.headers.get("Authorization"):
|
||||
fwd_headers["Authorization"] = request.headers.get("Authorization")
|
||||
resp = requests.request(method=fwd_method, url=url, json=fwd_json, timeout=30,
|
||||
headers=fwd_headers)
|
||||
incoming_auth = request.headers.get("Authorization")
|
||||
if incoming_auth:
|
||||
fwd_headers["Authorization"] = incoming_auth
|
||||
resp = requests.request(
|
||||
method=fwd_method,
|
||||
url=url,
|
||||
json=fwd_json,
|
||||
timeout=30,
|
||||
headers=fwd_headers,
|
||||
)
|
||||
return resp.content, resp.status_code, {"Content-Type": "application/json"}
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e), "url": url}), 500
|
||||
@@ -744,23 +828,26 @@ def opencode_api():
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
|
||||
@app.route("/candidatures/")
|
||||
def candidatures_index():
|
||||
return send_from_directory("/home/h3r7/turf_saas", "crm_candidatures.html")
|
||||
|
||||
|
||||
@app.route("/candidatures/<path:filename>")
|
||||
def candidatures_static(filename):
|
||||
return send_from_directory("/home/h3r7/turf_saas", filename)
|
||||
|
||||
|
||||
@app.route("/map")
|
||||
def map_visual():
|
||||
return send_from_directory("/home/h3r7/turf_saas", "map_visual.html")
|
||||
|
||||
|
||||
@app.route("/architecture.json")
|
||||
def architecture_json():
|
||||
return send_from_directory("/home/h3r7/turf_saas", "architecture.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8792, debug=False)
|
||||
|
||||
@@ -827,5 +914,3 @@ def proxy_prompts_test():
|
||||
return response
|
||||
except Exception as e:
|
||||
return f"Erreur proxy prompts: {e}", 502
|
||||
|
||||
|
||||
|
||||
387
predict_v2.py
Normal file
387
predict_v2.py
Normal file
@@ -0,0 +1,387 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Ensemble prediction module for /api/v1/predictions.
|
||||
|
||||
Loads the trained ensemble model and provides a high-level predict_top3()
|
||||
function compatible with the existing combined_api.py interface.
|
||||
|
||||
Cache: model is loaded once at import time (or on first call).
|
||||
Invalidation: reload if models/ensemble_top3.pkl mtime changes.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sklearn.preprocessing import LabelEncoder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MODELS_DIR = Path("/home/h3r7/turf_saas/models")
|
||||
ENSEMBLE_PATH = MODELS_DIR / "ensemble_top3.pkl"
|
||||
|
||||
# ── Cache ─────────────────────────────────────────────────────────────────────
|
||||
_model_cache = {
|
||||
"ensemble": None,
|
||||
"mtime": None,
|
||||
"lock": threading.Lock(),
|
||||
}
|
||||
|
||||
# ── Feature list (must match train_ensemble.py FEATURE_COLS) ─────────────────
|
||||
FEATURE_COLS = [
|
||||
"age",
|
||||
"sexe_enc",
|
||||
"nombre_courses",
|
||||
"nombre_victoires",
|
||||
"nombre_places",
|
||||
"tx_victoire",
|
||||
"tx_place",
|
||||
"forme_recente",
|
||||
"tendance_num",
|
||||
"gains_annee_en_cours",
|
||||
"cote_direct",
|
||||
"cote_reference",
|
||||
"distance",
|
||||
"nb_partants",
|
||||
"discipline_enc",
|
||||
"specialite_enc",
|
||||
"oeilleres_enc",
|
||||
"tendance_cote_enc",
|
||||
"penetrometre_intitule_enc",
|
||||
"form_1",
|
||||
"form_2",
|
||||
"form_3",
|
||||
"form_4",
|
||||
"form_5",
|
||||
"form_weighted",
|
||||
"form_avg",
|
||||
"form_best",
|
||||
"form_worst",
|
||||
"win_ratio",
|
||||
"place_ratio",
|
||||
"implied_prob",
|
||||
"win_rate_adj",
|
||||
"place_rate_adj",
|
||||
"earnings_per_race",
|
||||
"cote_diff",
|
||||
"cote_ratio",
|
||||
"rang_cote",
|
||||
"ratio_cote_field",
|
||||
"distance_cat",
|
||||
"age_win_interact",
|
||||
"is_favorite",
|
||||
"poids",
|
||||
"prize_norm",
|
||||
]
|
||||
|
||||
|
||||
# ── Encoders (built per-prediction batch for live data) ──────────────────────
|
||||
def _fit_encoder(values, default):
|
||||
le = LabelEncoder()
|
||||
unique = list(set(str(v) if v else default for v in values)) + [default]
|
||||
le.fit(unique)
|
||||
return le
|
||||
|
||||
|
||||
def _safe_transform(le: LabelEncoder, value, default: str):
|
||||
v = str(value) if value else default
|
||||
if v not in le.classes_:
|
||||
v = default
|
||||
return int(le.transform([v])[0])
|
||||
|
||||
|
||||
# ── Model loading with auto-invalidation ─────────────────────────────────────
|
||||
def load_ensemble(force: bool = False) -> Optional[object]:
|
||||
"""Load ensemble model, reload if file changed."""
|
||||
with _model_cache["lock"]:
|
||||
if not ENSEMBLE_PATH.exists():
|
||||
return None
|
||||
mtime = ENSEMBLE_PATH.stat().st_mtime
|
||||
if force or _model_cache["ensemble"] is None or mtime != _model_cache["mtime"]:
|
||||
try:
|
||||
with open(ENSEMBLE_PATH, "rb") as f:
|
||||
_model_cache["ensemble"] = pickle.load(f)
|
||||
_model_cache["mtime"] = mtime
|
||||
logger.info(f"[predict_v2] Loaded ensemble model from {ENSEMBLE_PATH}")
|
||||
except Exception as e:
|
||||
logger.error(f"[predict_v2] Failed to load ensemble: {e}")
|
||||
return None
|
||||
return _model_cache["ensemble"]
|
||||
|
||||
|
||||
def invalidate_model_cache():
|
||||
"""Force reload on next prediction call."""
|
||||
with _model_cache["lock"]:
|
||||
_model_cache["mtime"] = None
|
||||
|
||||
|
||||
# ── Feature engineering for live pmu_partants rows ───────────────────────────
|
||||
def _parse_musique(musique) -> list:
|
||||
if not musique or pd.isna(str(musique)):
|
||||
return [0, 0, 0, 0, 0]
|
||||
try:
|
||||
clean = re.sub(r"\(\d+\)", "", str(musique))
|
||||
numbers = re.findall(r"\d+", clean)
|
||||
result = [int(n) for n in numbers[:5]]
|
||||
result += [0] * (5 - len(result))
|
||||
return result[:5]
|
||||
except Exception:
|
||||
return [0, 0, 0, 0, 0]
|
||||
|
||||
|
||||
def build_feature_df(partants: list) -> pd.DataFrame:
|
||||
"""
|
||||
Convert a list of pmu_partants dicts to a feature DataFrame.
|
||||
|
||||
Expected keys (same as pmu_partants columns):
|
||||
date_programme, num_reunion, num_course, num_pmu,
|
||||
age, sexe, musique, nombre_courses, nombre_victoires, nombre_places,
|
||||
gains_annee_en_cours, handicap_poids, oeilleres, cote_direct,
|
||||
cote_reference, tendance_cote, favoris, tx_victoire, tx_place,
|
||||
forme_recente, tendance_forme, indicateur_inedit,
|
||||
distance, discipline, specialite, nb_declares_partants,
|
||||
montant_prix, penetrometre_intitule
|
||||
"""
|
||||
if not partants:
|
||||
return pd.DataFrame()
|
||||
|
||||
df = pd.DataFrame(partants)
|
||||
|
||||
# ── Categorical encoders fitted on this batch ─────────────────────────────
|
||||
le_sexe = _fit_encoder(df.get("sexe", ["U"]), "U")
|
||||
le_oeilleres = _fit_encoder(df.get("oeilleres", ["SANS"]), "SANS")
|
||||
le_discipline = _fit_encoder(df.get("discipline", ["UNKNOWN"]), "UNKNOWN")
|
||||
le_specialite = _fit_encoder(df.get("specialite", ["UNKNOWN"]), "UNKNOWN")
|
||||
le_tendance = _fit_encoder(df.get("tendance_cote", ["STABLE"]), "STABLE")
|
||||
le_penet = _fit_encoder(df.get("penetrometre_intitule", ["BON"]), "BON")
|
||||
|
||||
df["sexe_enc"] = df["sexe"].apply(lambda v: _safe_transform(le_sexe, v, "U"))
|
||||
df["oeilleres_enc"] = df["oeilleres"].apply(
|
||||
lambda v: _safe_transform(le_oeilleres, v, "SANS")
|
||||
)
|
||||
df["discipline_enc"] = df.get("discipline", pd.Series(["UNKNOWN"] * len(df))).apply(
|
||||
lambda v: _safe_transform(le_discipline, v, "UNKNOWN")
|
||||
)
|
||||
df["specialite_enc"] = df.get("specialite", pd.Series(["UNKNOWN"] * len(df))).apply(
|
||||
lambda v: _safe_transform(le_specialite, v, "UNKNOWN")
|
||||
)
|
||||
df["tendance_cote_enc"] = df.get(
|
||||
"tendance_cote", pd.Series(["STABLE"] * len(df))
|
||||
).apply(lambda v: _safe_transform(le_tendance, v, "STABLE"))
|
||||
df["penetrometre_intitule_enc"] = df.get(
|
||||
"penetrometre_intitule", pd.Series(["BON"] * len(df))
|
||||
).apply(lambda v: _safe_transform(le_penet, v, "BON"))
|
||||
|
||||
# ── Musique ────────────────────────────────────────────────────────────────
|
||||
music_parsed = df["musique"].apply(_parse_musique)
|
||||
for i in range(5):
|
||||
df[f"form_{i + 1}"] = music_parsed.apply(lambda x: x[i])
|
||||
weights = np.array([0.4, 0.25, 0.15, 0.12, 0.08])
|
||||
df["form_weighted"] = music_parsed.apply(
|
||||
lambda x: sum(w * v for w, v in zip(weights, x))
|
||||
)
|
||||
df["form_avg"] = music_parsed.apply(np.mean)
|
||||
df["form_best"] = music_parsed.apply(min)
|
||||
df["form_worst"] = music_parsed.apply(max)
|
||||
|
||||
# ── Numeric features ───────────────────────────────────────────────────────
|
||||
for col in [
|
||||
"nombre_courses",
|
||||
"nombre_victoires",
|
||||
"nombre_places",
|
||||
"tx_victoire",
|
||||
"tx_place",
|
||||
"forme_recente",
|
||||
"tendance_forme",
|
||||
"gains_annee_en_cours",
|
||||
"cote_direct",
|
||||
"cote_reference",
|
||||
"distance",
|
||||
"handicap_poids",
|
||||
"age",
|
||||
"montant_prix",
|
||||
"nb_declares_partants",
|
||||
]:
|
||||
if col not in df.columns:
|
||||
df[col] = 0.0
|
||||
df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0)
|
||||
|
||||
df["tendance_num"] = df["tendance_forme"].fillna(0)
|
||||
df["win_ratio"] = df["nombre_victoires"] / df["nombre_courses"].replace(0, 1)
|
||||
df["place_ratio"] = df["nombre_places"] / df["nombre_courses"].replace(0, 1)
|
||||
df["implied_prob"] = 1.0 / df["cote_direct"].replace(0, np.nan)
|
||||
df["win_rate_adj"] = df["tx_victoire"] * np.log1p(df["nombre_courses"])
|
||||
df["place_rate_adj"] = df["tx_place"] * np.log1p(df["nombre_courses"])
|
||||
df["earnings_per_race"] = df["gains_annee_en_cours"] / df["nombre_courses"].replace(
|
||||
0, 1
|
||||
)
|
||||
df["cote_diff"] = (df["cote_direct"] - df["cote_reference"]).fillna(0)
|
||||
df["cote_ratio"] = (
|
||||
df["cote_direct"] / df["cote_reference"].replace(0, np.nan)
|
||||
).fillna(1)
|
||||
|
||||
# ── Per-race rank features ─────────────────────────────────────────────────
|
||||
if "num_reunion" in df.columns and "num_course" in df.columns:
|
||||
grp = ["date_programme", "num_reunion", "num_course"]
|
||||
# Some fields may be missing
|
||||
for g in grp:
|
||||
if g not in df.columns:
|
||||
df[g] = 0
|
||||
df["rang_cote"] = df.groupby(grp)["cote_direct"].rank(
|
||||
method="min", na_option="bottom"
|
||||
)
|
||||
race_mean = df.groupby(grp)["cote_direct"].transform("mean")
|
||||
df["ratio_cote_field"] = df["cote_direct"] / race_mean.replace(0, np.nan)
|
||||
df["nb_partants"] = df.groupby(grp)["cote_direct"].transform("count")
|
||||
else:
|
||||
df["rang_cote"] = 1.0
|
||||
df["ratio_cote_field"] = 1.0
|
||||
df["nb_partants"] = df.get("nb_declares_partants", pd.Series([10] * len(df)))
|
||||
|
||||
df["distance_cat"] = pd.cut(
|
||||
df["distance"].fillna(1600),
|
||||
bins=[0, 1400, 1800, 2200, 2600, 10000],
|
||||
labels=[1, 2, 3, 4, 5],
|
||||
).astype(float)
|
||||
df["age_win_interact"] = df["age"] * df["tx_victoire"]
|
||||
df["is_favorite"] = (
|
||||
df.get("favoris", pd.Series([0] * len(df))).fillna(0).astype(int)
|
||||
)
|
||||
df["poids"] = df["handicap_poids"].fillna(60)
|
||||
df["prize_norm"] = np.log1p(df["montant_prix"].fillna(0))
|
||||
|
||||
return df
|
||||
|
||||
|
||||
# ── Main prediction function ───────────────────────────────────────────────────
|
||||
def predict_top3(partants: list, model=None) -> list:
|
||||
"""
|
||||
Given a list of partant dicts (from pmu_partants), return predictions.
|
||||
|
||||
Returns list of {horse_name, num_pmu, prob_top3, prob_top1_approx, ...}
|
||||
sorted by prob_top3 descending.
|
||||
|
||||
Falls back to empty list if model not available.
|
||||
"""
|
||||
t_start = time.perf_counter()
|
||||
|
||||
if model is None:
|
||||
model = load_ensemble()
|
||||
if model is None:
|
||||
logger.warning("[predict_v2] Ensemble model not available — no predictions")
|
||||
return []
|
||||
|
||||
df = build_feature_df(partants)
|
||||
if df.empty:
|
||||
return []
|
||||
|
||||
available = [c for c in FEATURE_COLS if c in df.columns]
|
||||
X = df[available].fillna(0)
|
||||
|
||||
try:
|
||||
proba = model.predict_proba(X)[:, 1]
|
||||
except Exception as e:
|
||||
logger.error(f"[predict_v2] predict_proba failed: {e}")
|
||||
return []
|
||||
|
||||
latency_ms = (time.perf_counter() - t_start) * 1000
|
||||
|
||||
results = []
|
||||
for i, (p, row) in enumerate(zip(proba, partants)):
|
||||
results.append(
|
||||
{
|
||||
"horse_name": row.get("nom", row.get("horse_name", f"H{i}")),
|
||||
"num_pmu": row.get("num_pmu", i + 1),
|
||||
"num_reunion": row.get("num_reunion"),
|
||||
"num_course": row.get("num_course"),
|
||||
"prob_top3": round(float(p) * 100, 1),
|
||||
# approx top1 from top3 score (divide by ~2.5 empirically)
|
||||
"prob_top1": round(float(p) / 2.5 * 100, 1),
|
||||
"ml_score": round(float(p) * 100, 1),
|
||||
"recommendation": "top3"
|
||||
if p >= 0.40
|
||||
else ("watch" if p >= 0.28 else "pass"),
|
||||
"is_value_bet": int(
|
||||
p >= 0.35 and float(row.get("cote_direct", 0) or 0) > 10
|
||||
),
|
||||
"model_version": getattr(model, "version", "ensemble_v1"),
|
||||
}
|
||||
)
|
||||
|
||||
results.sort(key=lambda x: x["prob_top3"], reverse=True)
|
||||
|
||||
# Mark top-3 predicted
|
||||
for i, r in enumerate(results[:3]):
|
||||
r["predicted_rank"] = i + 1
|
||||
|
||||
if results:
|
||||
logger.info(
|
||||
f"[predict_v2] {len(results)} horses predicted in {latency_ms:.1f} ms "
|
||||
f"({latency_ms / len(results):.2f} ms/horse)"
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ── API-compatible wrapper keeping model_version & structure ──────────────────
|
||||
def get_model_version() -> str:
|
||||
m = load_ensemble()
|
||||
if m is None:
|
||||
return "ensemble_v1_not_loaded"
|
||||
return getattr(m, "version", "ensemble_v1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Quick self-test
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect("/home/h3r7/turf_saas/turf.db")
|
||||
rows = conn.execute(
|
||||
"""SELECT p.*, c.distance, c.discipline, c.specialite,
|
||||
c.nb_declares_partants, c.montant_prix, c.penetrometre_intitule
|
||||
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=(SELECT MAX(date_programme) FROM pmu_partants)
|
||||
AND p.num_reunion=1 AND p.num_course=1
|
||||
LIMIT 20"""
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
if not rows:
|
||||
print("No data found for self-test")
|
||||
else:
|
||||
cols = [d[0] for d in conn.description] if hasattr(conn, "description") else []
|
||||
# Fallback column list
|
||||
import sqlite3 as sq3
|
||||
|
||||
conn2 = sq3.connect("/home/h3r7/turf_saas/turf.db")
|
||||
cur = conn2.execute(
|
||||
"""SELECT p.*, c.distance, c.discipline, c.specialite,
|
||||
c.nb_declares_partants, c.montant_prix, c.penetrometre_intitule
|
||||
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=(SELECT MAX(date_programme) FROM pmu_partants)
|
||||
AND p.num_reunion=1 AND p.num_course=1
|
||||
LIMIT 20"""
|
||||
)
|
||||
cols = [d[0] for d in cur.description]
|
||||
rows2 = cur.fetchall()
|
||||
conn2.close()
|
||||
|
||||
partants = [dict(zip(cols, row)) for row in rows2]
|
||||
preds = predict_top3(partants)
|
||||
print(f"Self-test: {len(preds)} predictions")
|
||||
for p in preds[:5]:
|
||||
print(
|
||||
f" {p['horse_name']:20s} prob_top3={p['prob_top3']}% rec={p['recommendation']}"
|
||||
)
|
||||
13
pytest.ini
Normal file
13
pytest.ini
Normal file
@@ -0,0 +1,13 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
addopts = --tb=short -v
|
||||
markers =
|
||||
e2e: Tests End-to-End Playwright
|
||||
load: Tests de charge Locust
|
||||
security: Tests de sécurité
|
||||
smoke: Tests rapides de smoke
|
||||
integration: Tests d'intégration DB et pipeline ML
|
||||
182
rebuild_ensemble.py
Normal file
182
rebuild_ensemble.py
Normal file
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Rebuild ensemble using known best Optuna params (from completed study).
|
||||
Skips the 100-trial Optuna search and goes straight to training + pickling.
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, '/home/h3r7/turf_saas')
|
||||
|
||||
from train_ensemble import (
|
||||
load_data, engineer_features, temporal_split, get_features_and_target,
|
||||
evaluate_baseline, train_xgboost, train_lightgbm, train_mlp,
|
||||
shap_feature_selection, compute_ensemble_weights,
|
||||
evaluate_model, compute_precision_at3, TurfEnsemble,
|
||||
MODELS_DIR, DEPLOY_THRESHOLD, _write_markdown_report
|
||||
)
|
||||
import json, pickle, numpy as np
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
DB_PATH = '/home/h3r7/turf_saas/turf.db'
|
||||
|
||||
# Best params from the 100-trial Optuna run
|
||||
XGB_BEST = {
|
||||
'n_estimators': 141, 'max_depth': 5,
|
||||
'learning_rate': 0.016298172447266404,
|
||||
'subsample': 0.7660470794373848,
|
||||
'colsample_bytree': 0.471124415020467,
|
||||
'min_child_weight': 14,
|
||||
'reg_alpha': 1.9364166463791586,
|
||||
'reg_lambda': 6.018030083488602,
|
||||
'gamma': 4.614943551368141,
|
||||
}
|
||||
LGB_BEST = {
|
||||
'n_estimators': 186, 'max_depth': 4,
|
||||
'learning_rate': 0.012915117465216954,
|
||||
'num_leaves': 141,
|
||||
'subsample': 0.6193119116922561,
|
||||
'colsample_bytree': 0.539310022549326,
|
||||
'min_child_samples': 9,
|
||||
'reg_alpha': 0.6864583098112754,
|
||||
'reg_lambda': 0.0549259590914184,
|
||||
}
|
||||
|
||||
print("=" * 65)
|
||||
print("TURF ENSEMBLE REBUILD (using pre-computed Optuna params)")
|
||||
print("=" * 65)
|
||||
|
||||
print("\n[1/7] Loading data...")
|
||||
df = load_data(DB_PATH)
|
||||
df = engineer_features(df)
|
||||
|
||||
print("\n[2/7] Temporal split...")
|
||||
train_df, holdout_df = temporal_split(df)
|
||||
X_train, y_train, feat_cols = get_features_and_target(train_df)
|
||||
X_holdout, y_holdout, _ = get_features_and_target(holdout_df)
|
||||
|
||||
n = len(X_train); n_val = int(n * 0.15)
|
||||
X_tr = X_train.iloc[:n-n_val]; y_tr = y_train.iloc[:n-n_val]
|
||||
X_val = X_train.iloc[n-n_val:]; y_val = y_train.iloc[n-n_val:]
|
||||
|
||||
print("\n[3/7] Evaluating baseline XGBoost...")
|
||||
baseline = evaluate_baseline(holdout_df, '/home/h3r7/turf_saas/xgboost_models.pkl')
|
||||
print(f" Baseline P@3={baseline['precision_at3']:.4f} AUC={baseline['auc']:.4f}")
|
||||
|
||||
print("\n[4/7] Training models with best params...")
|
||||
print(" XGBoost...")
|
||||
xgb_model = train_xgboost(X_tr, y_tr, XGB_BEST)
|
||||
print(" LightGBM...")
|
||||
lgb_model = train_lightgbm(X_tr, y_tr, LGB_BEST)
|
||||
print(" MLP...")
|
||||
mlp_model = train_mlp(X_tr.values, y_tr)
|
||||
|
||||
print("\n[5/7] SHAP analysis...")
|
||||
selected_features, shap_df = shap_feature_selection(xgb_model, X_tr)
|
||||
|
||||
print("\n[6/7] Computing ensemble weights...")
|
||||
class WrappedMLP:
|
||||
def __init__(self, pipeline, cols):
|
||||
self.pipeline = pipeline
|
||||
self.feature_cols = cols
|
||||
def predict_proba(self, X):
|
||||
import pandas as pd
|
||||
available = [c for c in self.feature_cols if c in X.columns]
|
||||
return self.pipeline.predict_proba(X[available].values)
|
||||
|
||||
class WrappedTree:
|
||||
def __init__(self, model, cols):
|
||||
self.model = model
|
||||
self.feature_cols = cols
|
||||
def predict_proba(self, X):
|
||||
available = [c for c in self.feature_cols if c in X.columns]
|
||||
return self.model.predict_proba(X[available])
|
||||
|
||||
wrapped_xgb = WrappedTree(xgb_model, feat_cols)
|
||||
wrapped_lgb = WrappedTree(lgb_model, feat_cols)
|
||||
wrapped_mlp = WrappedMLP(mlp_model, feat_cols)
|
||||
model_dict = {'xgboost': wrapped_xgb, 'lightgbm': wrapped_lgb, 'mlp': wrapped_mlp}
|
||||
|
||||
weights = compute_ensemble_weights(model_dict, X_val, y_val, feat_cols)
|
||||
print(" Weights:", weights)
|
||||
|
||||
print("\n[7/7] Evaluating + saving ensemble...")
|
||||
ensemble = TurfEnsemble(xgb_model, lgb_model, mlp_model, weights, feat_cols)
|
||||
|
||||
results = {}
|
||||
for name, wrapped in model_dict.items():
|
||||
res = evaluate_model(wrapped, X_holdout, y_holdout, holdout_df, name)
|
||||
results[name] = res
|
||||
print(f" {name:12s} P@3={res['precision_at3']:.4f} AUC={res['auc']:.4f}")
|
||||
|
||||
ens_res = evaluate_model(ensemble, X_holdout, y_holdout, holdout_df, "ensemble")
|
||||
results["ensemble"] = ens_res
|
||||
print(f" {'ensemble':12s} P@3={ens_res['precision_at3']:.4f} AUC={ens_res['auc']:.4f}")
|
||||
|
||||
delta = ens_res['precision_at3'] - baseline['precision_at3']
|
||||
deploy = delta >= DEPLOY_THRESHOLD
|
||||
print(f"\n Delta: {delta:+.4f} ({delta*100:+.1f}%) Deploy={'YES' if deploy else 'NO'}")
|
||||
|
||||
# Save ensemble
|
||||
ensemble_path = MODELS_DIR / "ensemble_top3.pkl"
|
||||
with open(ensemble_path, "wb") as f:
|
||||
pickle.dump(ensemble, f)
|
||||
print(f"\n ✅ ensemble_top3.pkl saved ({ensemble_path.stat().st_size//1024} KB)")
|
||||
|
||||
# Save individual models
|
||||
for name, model in [("xgboost_optimized", xgb_model), ("lightgbm", lgb_model)]:
|
||||
path = MODELS_DIR / f"{name}_top3.pkl"
|
||||
with open(path, "wb") as f:
|
||||
pickle.dump({"model": model, "feature_cols": feat_cols}, f)
|
||||
print(f" ✅ {name}_top3.pkl saved")
|
||||
|
||||
mlp_path = MODELS_DIR / "mlp_top3.pkl"
|
||||
with open(mlp_path, "wb") as f:
|
||||
pickle.dump({"pipeline": mlp_model, "feature_cols": feat_cols}, f)
|
||||
print(f" ✅ mlp_top3.pkl saved")
|
||||
|
||||
# Benchmark report
|
||||
report = {
|
||||
"run_date": datetime.now().isoformat(),
|
||||
"dataset": {
|
||||
"db_path": DB_PATH,
|
||||
"total_rows": len(df),
|
||||
"train_rows": len(X_train),
|
||||
"holdout_rows": len(X_holdout),
|
||||
"train_date_range": [str(train_df["date_programme"].min()), str(train_df["date_programme"].max())],
|
||||
"holdout_date_range": [str(holdout_df["date_programme"].min()), str(holdout_df["date_programme"].max())],
|
||||
},
|
||||
"baseline": baseline,
|
||||
"individual_models": {k: v for k, v in results.items() if k != "ensemble"},
|
||||
"ensemble": ens_res,
|
||||
"delta_precision_at3": round(delta, 4),
|
||||
"deploy": deploy,
|
||||
"optuna": {
|
||||
"n_trials": 100,
|
||||
"xgboost_best_params": XGB_BEST,
|
||||
"lightgbm_best_params": LGB_BEST,
|
||||
},
|
||||
"features": {
|
||||
"total": len(feat_cols),
|
||||
"selected_by_shap": len(selected_features),
|
||||
"feature_list": feat_cols,
|
||||
"shap_selected": selected_features,
|
||||
},
|
||||
"ensemble_weights": weights,
|
||||
}
|
||||
|
||||
report_path = MODELS_DIR / "benchmark_report.json"
|
||||
with open(report_path, "w") as f:
|
||||
json.dump(report, f, indent=2)
|
||||
print(f" ✅ benchmark_report.json saved")
|
||||
|
||||
md_path = MODELS_DIR / "benchmark_report.md"
|
||||
_write_markdown_report(report, md_path)
|
||||
print(f" ✅ benchmark_report.md saved")
|
||||
|
||||
print("\n" + "=" * 65)
|
||||
print("DONE")
|
||||
print(f" Baseline P@3: {baseline['precision_at3']:.4f}")
|
||||
print(f" Ensemble P@3: {ens_res['precision_at3']:.4f}")
|
||||
print(f" Delta: {delta:+.4f} ({delta*100:+.1f}%)")
|
||||
print(f" Deploy: {'✅ YES' if deploy else '❌ NO'}")
|
||||
print("=" * 65)
|
||||
271
register.html
Normal file
271
register.html
Normal file
@@ -0,0 +1,271 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Inscription — Turf IA</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--green: #00c853; --green-d: #009624;
|
||||
--dark: #0d1117; --dark2: #161b22; --dark3: #21262d;
|
||||
--text: #e6edf3; --muted: #8b949e; --border: #30363d;
|
||||
--error: #f85149; --radius: 10px;
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); min-height: 100vh; display: flex; flex-direction: column; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
nav { display: flex; align-items: center; justify-content: space-between; padding: 16px 5%; border-bottom: 1px solid var(--border); }
|
||||
.nav-logo { font-weight: 700; font-size: 1.1rem; }
|
||||
.nav-link { color: var(--muted); font-size: .9rem; }
|
||||
.nav-link:hover { color: var(--text); }
|
||||
main { flex: 1; display: flex; align-items: flex-start; justify-content: center; padding: 40px 20px; gap: 40px; flex-wrap: wrap; }
|
||||
.auth-card { width: 100%; max-width: 440px; background: var(--dark2); border: 1px solid var(--border); border-radius: 14px; padding: 40px; }
|
||||
.plan-preview { width: 100%; max-width: 300px; }
|
||||
.auth-title { font-size: 1.5rem; font-weight: 800; margin-bottom: 6px; }
|
||||
.auth-subtitle { color: var(--muted); font-size: .9rem; margin-bottom: 28px; }
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
label { display: block; font-size: .85rem; font-weight: 600; color: var(--muted); margin-bottom: 6px; }
|
||||
input, select {
|
||||
width: 100%; padding: 11px 14px; background: var(--dark3);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
color: var(--text); font-size: .95rem; outline: none; transition: border-color .2s;
|
||||
}
|
||||
input:focus, select:focus { border-color: var(--green); }
|
||||
input.error { border-color: var(--error); }
|
||||
select option { background: var(--dark3); }
|
||||
.field-error { color: var(--error); font-size: .8rem; margin-top: 4px; display: none; }
|
||||
.field-error.show { display: block; }
|
||||
.btn { width: 100%; padding: 12px; border: none; border-radius: var(--radius); font-size: 1rem; font-weight: 700; cursor: pointer; transition: all .2s; margin-top: 4px; }
|
||||
.btn-primary { background: var(--green); color: #000; }
|
||||
.btn-primary:hover { background: var(--green-d); }
|
||||
.btn-primary:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.divider { display: flex; align-items: center; gap: 12px; margin: 20px 0; color: var(--muted); font-size: .82rem; }
|
||||
.divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||
.auth-footer { text-align: center; margin-top: 20px; color: var(--muted); font-size: .9rem; }
|
||||
.auth-footer a { color: var(--green); font-weight: 600; }
|
||||
.alert { padding: 12px 16px; border-radius: var(--radius); font-size: .88rem; margin-bottom: 18px; display: none; }
|
||||
.alert.show { display: block; }
|
||||
.alert-error { background: rgba(248,81,73,.12); border: 1px solid rgba(248,81,73,.3); color: #f85149; }
|
||||
.loader { display: inline-block; width: 16px; height: 16px; border: 2px solid rgba(0,0,0,.3); border-top-color: #000; border-radius: 50%; animation: spin .7s linear infinite; vertical-align: middle; margin-right: 6px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.terms { font-size: .8rem; color: var(--muted); line-height: 1.6; }
|
||||
.terms a { color: var(--green); }
|
||||
.password-strength { height: 4px; border-radius: 2px; margin-top: 6px; background: var(--border); overflow: hidden; }
|
||||
.strength-bar { height: 100%; width: 0; transition: width .3s, background .3s; }
|
||||
|
||||
/* Plan preview sidebar */
|
||||
.plan-card {
|
||||
background: var(--dark2); border: 1px solid var(--border);
|
||||
border-radius: 14px; padding: 24px; margin-bottom: 16px;
|
||||
transition: border-color .3s;
|
||||
}
|
||||
.plan-card.selected { border-color: var(--green); }
|
||||
.plan-card-name { font-weight: 700; font-size: 1rem; display: flex; justify-content: space-between; align-items: center; }
|
||||
.plan-card-price { font-size: 1.5rem; font-weight: 800; margin: 8px 0; }
|
||||
.plan-card-features { list-style: none; margin-top: 12px; }
|
||||
.plan-card-features li { font-size: .83rem; color: var(--muted); padding: 4px 0; }
|
||||
.plan-card-features li::before { content: "✓ "; color: var(--green); }
|
||||
.plan-badge { background: var(--green); color: #000; padding: 2px 8px; border-radius: 10px; font-size: .7rem; font-weight: 700; }
|
||||
|
||||
footer { text-align: center; padding: 20px; color: var(--muted); font-size: .8rem; border-top: 1px solid var(--border); }
|
||||
@media (max-width: 768px) { .plan-preview { display: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="/" class="nav-logo">🏇 Turf IA</a>
|
||||
<a href="/login" class="nav-link">Déjà un compte ? Se connecter →</a>
|
||||
</nav>
|
||||
<main>
|
||||
<div class="auth-card">
|
||||
<h1 class="auth-title">Créer votre compte</h1>
|
||||
<p class="auth-subtitle">Commencez gratuitement, sans carte bancaire.</p>
|
||||
|
||||
<div class="alert alert-error" id="alert-error"></div>
|
||||
|
||||
<form id="register-form" novalidate>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="firstname">Prénom</label>
|
||||
<input type="text" id="firstname" name="firstname" placeholder="Jean" autocomplete="given-name" required>
|
||||
<div class="field-error" id="firstname-error">Requis.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="lastname">Nom</label>
|
||||
<input type="text" id="lastname" name="lastname" placeholder="Dupont" autocomplete="family-name" required>
|
||||
<div class="field-error" id="lastname-error">Requis.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Adresse email</label>
|
||||
<input type="email" id="email" name="email" placeholder="vous@exemple.fr" autocomplete="email" required>
|
||||
<div class="field-error" id="email-error">Email invalide.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Mot de passe</label>
|
||||
<input type="password" id="password" name="password" placeholder="8 caractères minimum" autocomplete="new-password" required>
|
||||
<div class="password-strength"><div class="strength-bar" id="strength-bar"></div></div>
|
||||
<div class="field-error" id="password-error">8 caractères minimum requis.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="plan">Plan de départ</label>
|
||||
<select id="plan" name="plan">
|
||||
<option value="free">Free — Gratuit</option>
|
||||
<option value="premium">Premium — 9,90€/mois</option>
|
||||
<option value="pro">Pro — 24,90€/mois</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<p class="terms">En vous inscrivant, vous acceptez nos <a href="/legal/cgu">Conditions Générales d'Utilisation</a> et notre <a href="/legal/privacy">Politique de Confidentialité</a>. Vous devez avoir 18 ans ou plus.</p>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" id="submit-btn">Créer mon compte gratuit</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
Déjà un compte ? <a href="/login">Se connecter</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plan preview sidebar -->
|
||||
<div class="plan-preview">
|
||||
<div id="plan-card-free" class="plan-card selected">
|
||||
<div class="plan-card-name">Free <span>—</span></div>
|
||||
<div class="plan-card-price">0€<span style="font-size:.9rem;font-weight:400;color:var(--muted)">/mois</span></div>
|
||||
<ul class="plan-card-features">
|
||||
<li>Aperçu Top-3 du jour</li>
|
||||
<li>1 course complète/jour</li>
|
||||
<li>Statistiques basiques</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="plan-card-premium" class="plan-card" style="display:none">
|
||||
<div class="plan-card-name">Premium <span class="plan-badge">⭐</span></div>
|
||||
<div class="plan-card-price">9,90€<span style="font-size:.9rem;font-weight:400;color:var(--muted)">/mois</span></div>
|
||||
<ul class="plan-card-features">
|
||||
<li>Toutes les courses</li>
|
||||
<li>Value bets identifiés</li>
|
||||
<li>Alertes Telegram</li>
|
||||
<li>Historique 90 jours</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="plan-card-pro" class="plan-card" style="display:none">
|
||||
<div class="plan-card-name">Pro <span></span></div>
|
||||
<div class="plan-card-price">24,90€<span style="font-size:.9rem;font-weight:400;color:var(--muted)">/mois</span></div>
|
||||
<ul class="plan-card-features">
|
||||
<li>Tout du Premium</li>
|
||||
<li>Export CSV illimité</li>
|
||||
<li>API REST documentée</li>
|
||||
<li>Backtest personnalisé</li>
|
||||
<li>Support prioritaire</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p style="font-size:.78rem;color:var(--muted);text-align:center;">⚡ Pas de carte bancaire requise pour le plan Free</p>
|
||||
</div>
|
||||
</main>
|
||||
<footer>© 2026 Turf IA — H3R7 Tech. Jouez de façon responsable. 18+</footer>
|
||||
|
||||
<script>
|
||||
const API = '/api/v1';
|
||||
const form = document.getElementById('register-form');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
const alertBox = document.getElementById('alert-error');
|
||||
const planSelect = document.getElementById('plan');
|
||||
|
||||
// Pre-select plan from URL param
|
||||
const urlPlan = new URLSearchParams(location.search).get('plan');
|
||||
if (urlPlan && ['free','premium','pro'].includes(urlPlan)) planSelect.value = urlPlan;
|
||||
|
||||
function updatePlanPreview() {
|
||||
['free','premium','pro'].forEach(p => {
|
||||
const el = document.getElementById(`plan-card-${p}`);
|
||||
if (!el) return;
|
||||
if (planSelect.value === p) {
|
||||
el.style.display = '';
|
||||
el.classList.add('selected');
|
||||
} else {
|
||||
el.style.display = 'none';
|
||||
el.classList.remove('selected');
|
||||
}
|
||||
});
|
||||
}
|
||||
planSelect.addEventListener('change', updatePlanPreview);
|
||||
updatePlanPreview();
|
||||
|
||||
function showError(msg) { alertBox.textContent = msg; alertBox.classList.add('show'); }
|
||||
function hideError() { alertBox.classList.remove('show'); }
|
||||
function setLoading(on) {
|
||||
submitBtn.disabled = on;
|
||||
submitBtn.innerHTML = on ? '<span class="loader"></span>Création…' : 'Créer mon compte gratuit';
|
||||
}
|
||||
function validateField(input, errId, condition, msg) {
|
||||
const err = document.getElementById(errId);
|
||||
if (condition) { input.classList.add('error'); err.textContent = msg; err.classList.add('show'); return false; }
|
||||
input.classList.remove('error'); err.classList.remove('show'); return true;
|
||||
}
|
||||
|
||||
// Password strength
|
||||
const passInput = document.getElementById('password');
|
||||
const bar = document.getElementById('strength-bar');
|
||||
passInput.addEventListener('input', () => {
|
||||
const v = passInput.value;
|
||||
let score = 0;
|
||||
if (v.length >= 8) score++;
|
||||
if (/[A-Z]/.test(v)) score++;
|
||||
if (/[0-9]/.test(v)) score++;
|
||||
if (/[^A-Za-z0-9]/.test(v)) score++;
|
||||
const colors = ['#f85149','#e3b341','#58a6ff','#00c853'];
|
||||
bar.style.width = (score * 25) + '%';
|
||||
bar.style.background = colors[score - 1] || '#30363d';
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
hideError();
|
||||
const firstname = document.getElementById('firstname').value.trim();
|
||||
const lastname = document.getElementById('lastname').value.trim();
|
||||
const email = document.getElementById('email').value.trim();
|
||||
const password = passInput.value;
|
||||
const plan = planSelect.value;
|
||||
|
||||
let ok = true;
|
||||
ok = validateField(document.getElementById('firstname'), 'firstname-error', !firstname, 'Prénom requis.') && ok;
|
||||
ok = validateField(document.getElementById('lastname'), 'lastname-error', !lastname, 'Nom requis.') && ok;
|
||||
ok = validateField(document.getElementById('email'), 'email-error', !email || !/^[^@]+@[^@]+\.[^@]+$/.test(email), 'Email invalide.') && ok;
|
||||
ok = validateField(passInput, 'password-error', password.length < 8, '8 caractères minimum.') && ok;
|
||||
if (!ok) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API}/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ firstname, lastname, email, password, plan })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
showError(data.error || 'Erreur lors de la création du compte.');
|
||||
} else {
|
||||
localStorage.setItem('turf_token', data.token);
|
||||
localStorage.setItem('turf_user', JSON.stringify(data.user));
|
||||
localStorage.setItem('turf_onboarding', 'pending');
|
||||
location.href = '/onboarding';
|
||||
}
|
||||
} catch(_) {
|
||||
showError('Erreur de connexion. Vérifiez votre réseau.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('input').forEach(el => el.addEventListener('input', () => {
|
||||
el.classList.remove('error');
|
||||
document.getElementById(el.id + '-error')?.classList.remove('show');
|
||||
hideError();
|
||||
}));
|
||||
|
||||
(function() {
|
||||
if (localStorage.getItem('turf_token')) location.href = '/dashboard';
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
33
requirements.txt
Normal file
33
requirements.txt
Normal file
@@ -0,0 +1,33 @@
|
||||
# Core web framework
|
||||
Flask==3.1.3
|
||||
flask-cors==6.0.2
|
||||
gunicorn==23.0.0
|
||||
|
||||
# HTTP client
|
||||
requests==2.32.3
|
||||
|
||||
# Data processing & ML
|
||||
pandas==3.0.1
|
||||
numpy==2.4.3
|
||||
scikit-learn==1.6.1
|
||||
xgboost==3.2.0
|
||||
|
||||
# Database - PostgreSQL
|
||||
psycopg2-binary==2.9.12
|
||||
SQLAlchemy==2.0.40
|
||||
alembic==1.16.1
|
||||
|
||||
# Scheduling
|
||||
schedule==1.2.2
|
||||
|
||||
# Monitoring
|
||||
prometheus-client==0.21.1
|
||||
|
||||
# Logging
|
||||
python-json-logger==3.3.0
|
||||
|
||||
# Security
|
||||
python-dotenv==1.1.0
|
||||
|
||||
# Utilities
|
||||
python-dateutil==2.9.0
|
||||
300
saas_api_v1.py
Normal file
300
saas_api_v1.py
Normal file
@@ -0,0 +1,300 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SaaS API v1 Blueprint — /api/v1/*
|
||||
Stats, prédictions, résumés pour le dashboard SaaS.
|
||||
Sprint 4-5 — HRT-30
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
import sqlite3
|
||||
import os
|
||||
from datetime import datetime
|
||||
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")
|
||||
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def plan_allows(user_plan: str, required: str) -> bool:
|
||||
order = {"free": 0, "premium": 1, "pro": 2}
|
||||
return order.get(user_plan, 0) >= order.get(required, 0)
|
||||
|
||||
|
||||
# ─── Stats ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@api_v1_bp.route("/stats/summary", methods=["GET"])
|
||||
@require_auth
|
||||
def stats_summary():
|
||||
"""GET /api/v1/stats/summary — résumé dashboard."""
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
conn = get_db()
|
||||
|
||||
try:
|
||||
# Courses today
|
||||
courses_today = (
|
||||
conn.execute(
|
||||
"SELECT COUNT(DISTINCT num_reunion||'-'||num_course) FROM ml_predictions_cache WHERE date=?",
|
||||
(today,),
|
||||
).fetchone()[0]
|
||||
or 0
|
||||
)
|
||||
|
||||
# Value bets today
|
||||
value_bets_today = (
|
||||
conn.execute(
|
||||
"SELECT COUNT(*) FROM ml_predictions_cache WHERE date=? AND is_value_bet=1",
|
||||
(today,),
|
||||
).fetchone()[0]
|
||||
or 0
|
||||
)
|
||||
|
||||
# Accuracy top3 (30 days)
|
||||
acc_row = conn.execute("""
|
||||
SELECT
|
||||
CAST(SUM(CASE WHEN p.ordre_arrivee BETWEEN 1 AND 3 AND m.recommendation='top3' THEN 1 ELSE 0 END) AS FLOAT)
|
||||
/ NULLIF(COUNT(CASE WHEN m.recommendation='top3' THEN 1 END), 0) * 100 AS acc
|
||||
FROM ml_predictions_cache m
|
||||
JOIN pmu_partants p ON m.horse_name=p.nom AND m.date=p.date_programme
|
||||
WHERE m.date >= date('now', '-30 days')
|
||||
""").fetchone()
|
||||
accuracy_top3 = round(acc_row[0], 1) if acc_row and acc_row[0] else None
|
||||
|
||||
# Next race
|
||||
next_race = conn.execute(
|
||||
"SELECT heure, hippodrome FROM ml_predictions_cache WHERE date=? AND heure IS NOT NULL ORDER BY heure LIMIT 1",
|
||||
(today,),
|
||||
).fetchone()
|
||||
|
||||
conn.close()
|
||||
return jsonify(
|
||||
{
|
||||
"courses_today": courses_today,
|
||||
"value_bets_today": value_bets_today,
|
||||
"accuracy_top3": accuracy_top3,
|
||||
"next_race_time": next_race["heure"] if next_race else None,
|
||||
"next_race_hippodrome": next_race["hippodrome"] if next_race else None,
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
return jsonify(
|
||||
{"error": str(e), "courses_today": 0, "value_bets_today": 0}
|
||||
), 200
|
||||
|
||||
|
||||
# ─── Predictions ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@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."""
|
||||
user = request.current_user
|
||||
plan = user.get("plan", "free")
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
conn = get_db()
|
||||
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT 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, num_reunion, num_course
|
||||
FROM ml_predictions_cache
|
||||
WHERE date=?
|
||||
ORDER BY num_reunion, num_course, ml_score DESC
|
||||
""",
|
||||
(today,),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
predictions = [dict(r) for r in rows]
|
||||
|
||||
# Free plan: return only 1 race
|
||||
if plan == "free":
|
||||
if predictions:
|
||||
first = predictions[0]
|
||||
first_key = (first["num_reunion"], first["num_course"])
|
||||
predictions = [
|
||||
p
|
||||
for p in predictions
|
||||
if (p["num_reunion"], p["num_course"]) == first_key
|
||||
]
|
||||
# Mask value bet flag in free
|
||||
for p in predictions:
|
||||
p["is_value_bet"] = 0
|
||||
|
||||
# Premium/Pro: full predictions
|
||||
return jsonify(
|
||||
{
|
||||
"date": today,
|
||||
"plan": plan,
|
||||
"count": len(predictions),
|
||||
"predictions": predictions,
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
return jsonify({"error": str(e), "predictions": []}), 200
|
||||
|
||||
|
||||
@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."""
|
||||
user = request.current_user
|
||||
plan = user.get("plan", "free")
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
# Parse label like R1C3 → num_reunion=1, num_course=3
|
||||
import re
|
||||
|
||||
m = re.match(r"R(\d+)C(\d+)", race_label.upper())
|
||||
if not m:
|
||||
return jsonify({"error": "Format invalide, attendu: R{n}C{n}"}), 400
|
||||
nr, nc = int(m.group(1)), int(m.group(2))
|
||||
|
||||
conn = get_db()
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM ml_predictions_cache
|
||||
WHERE date=? AND num_reunion=? AND num_course=?
|
||||
ORDER BY ml_score DESC
|
||||
""",
|
||||
(today, nr, nc),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
predictions = [dict(r) for r in rows]
|
||||
if plan == "free" and predictions:
|
||||
# Only show first race
|
||||
pass # allowed in detail view if they know the race label
|
||||
|
||||
return jsonify({"predictions": predictions, "count": len(predictions)}), 200
|
||||
|
||||
|
||||
# ─── Value Bets ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@api_v1_bp.route("/value-bets/today", methods=["GET"])
|
||||
@require_auth
|
||||
def value_bets_today():
|
||||
"""GET /api/v1/value-bets/today — value bets (Premium+)."""
|
||||
user = request.current_user
|
||||
plan = user.get("plan", "free")
|
||||
if not plan_allows(plan, "premium"):
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Cette fonctionnalité requiert un plan Premium ou Pro.",
|
||||
"upgrade_required": True,
|
||||
}
|
||||
), 403
|
||||
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
conn = get_db()
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT horse_name, race_label, race_name, hippodrome, odds,
|
||||
prob_top3, ml_score, risque_label, heure
|
||||
FROM ml_predictions_cache
|
||||
WHERE date=? AND is_value_bet=1
|
||||
ORDER BY ml_score DESC
|
||||
""",
|
||||
(today,),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return jsonify({"value_bets": [dict(r) for r in rows], "count": len(rows)}), 200
|
||||
|
||||
|
||||
# ─── Export ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@api_v1_bp.route("/export/csv", methods=["GET"])
|
||||
@require_auth
|
||||
def export_csv():
|
||||
"""GET /api/v1/export/csv — export CSV (Pro only)."""
|
||||
from flask import Response
|
||||
import csv, io
|
||||
|
||||
user = request.current_user
|
||||
plan = user.get("plan", "free")
|
||||
if not plan_allows(plan, "pro"):
|
||||
return jsonify(
|
||||
{"error": "L'export CSV requiert un plan Pro.", "upgrade_required": True}
|
||||
), 403
|
||||
|
||||
date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
|
||||
conn = get_db()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM ml_predictions_cache WHERE date=? ORDER BY num_reunion, num_course, ml_score DESC",
|
||||
(date_param,),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
output = io.StringIO()
|
||||
if rows:
|
||||
writer = csv.DictWriter(output, fieldnames=rows[0].keys())
|
||||
writer.writeheader()
|
||||
writer.writerows([dict(r) for r in rows])
|
||||
|
||||
return Response(
|
||||
output.getvalue(),
|
||||
mimetype="text/csv",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename=turf_ia_{date_param}.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)
|
||||
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
|
||||
def _init_jwt(state):
|
||||
app = state.app
|
||||
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}")
|
||||
|
||||
|
||||
# ─── Org Blueprint — HRT-82 ───────────────────────────────────────────────────
|
||||
# Registers /api/v1/org/* routes (Pro plan only, multi-compte max 5 users)
|
||||
try:
|
||||
from api_v1.routes.org import org_bp
|
||||
|
||||
@api_v1_bp.record_once
|
||||
def _register_org_bp(state):
|
||||
app = state.app
|
||||
app.register_blueprint(org_bp)
|
||||
|
||||
print("[saas_api_v1] Org blueprint (multi-compte Pro) registered ✅")
|
||||
except Exception as _org_err:
|
||||
print(f"[saas_api_v1] Warning: org blueprint not loaded: {_org_err}")
|
||||
545
saas_auth.py
Normal file
545
saas_auth.py
Normal file
@@ -0,0 +1,545 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SaaS Auth Blueprint — /api/v1/auth/*
|
||||
Gestion des utilisateurs, JWT, plans, préférences.
|
||||
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
|
||||
import json
|
||||
from functools import wraps
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
from threading import Lock
|
||||
|
||||
# ─── Rate limiting login ───────────────────────────────────────────────────────
|
||||
_login_attempts: dict = defaultdict(
|
||||
lambda: {"count": 0, "window_start": 0.0, "blocked_until": 0.0}
|
||||
)
|
||||
_login_lock = Lock()
|
||||
|
||||
LOGIN_RATE_MAX = 5 # max tentatives par fenêtre
|
||||
LOGIN_RATE_WINDOW = 300 # 5 minutes (en secondes)
|
||||
LOGIN_BLOCK_DURATION = 900 # 15 min de blocage après dépassement
|
||||
|
||||
# ─── Blacklist mots de passe faibles ─────────────────────────────────────────
|
||||
# HRT-63 — Validation mots de passe faibles
|
||||
WEAK_PASSWORDS = {
|
||||
"password",
|
||||
"password1",
|
||||
"password123",
|
||||
"passw0rd",
|
||||
"12345678",
|
||||
"123456789",
|
||||
"1234567890",
|
||||
"123456",
|
||||
"12345",
|
||||
"1234",
|
||||
"qwerty",
|
||||
"qwerty123",
|
||||
"qwertyuiop",
|
||||
"azerty",
|
||||
"azertyuiop",
|
||||
"letmein",
|
||||
"letmein1",
|
||||
"iloveyou",
|
||||
"iloveyou1",
|
||||
"admin",
|
||||
"admin123",
|
||||
"admin1234",
|
||||
"administrator",
|
||||
"welcome",
|
||||
"welcome1",
|
||||
"welcome123",
|
||||
"monkey",
|
||||
"monkey1",
|
||||
"dragon",
|
||||
"dragon1",
|
||||
"master",
|
||||
"master1",
|
||||
"football",
|
||||
"soccer",
|
||||
"baseball",
|
||||
"basketball",
|
||||
"superman",
|
||||
"batman",
|
||||
"starwars",
|
||||
"starwars1",
|
||||
"princess",
|
||||
"princess1",
|
||||
"sunshine",
|
||||
"sunshine1",
|
||||
"shadow",
|
||||
"shadow1",
|
||||
"michael",
|
||||
"michael1",
|
||||
"jessica",
|
||||
"jessica1",
|
||||
"abc123",
|
||||
"abc1234",
|
||||
"abcd1234",
|
||||
"abcdefgh",
|
||||
"login",
|
||||
"login123",
|
||||
"pass",
|
||||
"pass1234",
|
||||
"test",
|
||||
"test1234",
|
||||
"test123456",
|
||||
"hello",
|
||||
"hello123",
|
||||
"hello1234",
|
||||
"changeme",
|
||||
"changeme1",
|
||||
"secret",
|
||||
"secret1",
|
||||
"secret123",
|
||||
"trustno1",
|
||||
"zaq1zaq1",
|
||||
"qazwsx",
|
||||
"qazwsxedc",
|
||||
"111111",
|
||||
"1111111",
|
||||
"11111111",
|
||||
"000000",
|
||||
"00000000",
|
||||
"123123",
|
||||
"1231234",
|
||||
"321321",
|
||||
"p@ssword",
|
||||
"p@ssw0rd",
|
||||
"pa$$word",
|
||||
"turf",
|
||||
"turf123",
|
||||
"cheval",
|
||||
"cheval123",
|
||||
"pmu",
|
||||
"pmu123",
|
||||
}
|
||||
|
||||
|
||||
def validate_password_strength(password: str):
|
||||
"""
|
||||
Valide la complexité d'un mot de passe.
|
||||
Retourne None si OK, sinon un message d'erreur (str).
|
||||
Règles :
|
||||
- 8 caractères minimum
|
||||
- absent de la blacklist WEAK_PASSWORDS
|
||||
- au moins 1 chiffre
|
||||
- au moins 1 lettre
|
||||
"""
|
||||
if len(password) < 8:
|
||||
return "Mot de passe trop court (8 caractères minimum)."
|
||||
if password.lower() in WEAK_PASSWORDS:
|
||||
return "Mot de passe trop commun. Choisissez un mot de passe plus sécurisé."
|
||||
if not any(c.isdigit() for c in password):
|
||||
return "Le mot de passe doit contenir au moins 1 chiffre."
|
||||
if not any(c.isalpha() for c in password):
|
||||
return "Le mot de passe doit contenir au moins 1 lettre."
|
||||
return None
|
||||
|
||||
|
||||
# ─── Config ───────────────────────────────────────────────────────────────────
|
||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
JWT_SECRET = os.environ.get(
|
||||
"JWT_SECRET", secrets.token_hex(32)
|
||||
) # persist in env for prod
|
||||
TOKEN_TTL = int(os.environ.get("JWT_TTL_SECONDS", 30 * 24 * 3600)) # 30 days
|
||||
|
||||
auth_bp = Blueprint("auth_v1", __name__, url_prefix="/api/v1/auth")
|
||||
|
||||
|
||||
# ─── DB helpers ───────────────────────────────────────────────────────────────
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def init_users_table():
|
||||
"""Ensure users table exists."""
|
||||
conn = get_db()
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS saas_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
firstname TEXT DEFAULT '',
|
||||
lastname TEXT DEFAULT '',
|
||||
password_hash TEXT NOT NULL,
|
||||
plan TEXT DEFAULT 'free',
|
||||
telegram_chat_id TEXT DEFAULT NULL,
|
||||
alert_value_bets INTEGER DEFAULT 1,
|
||||
alert_top1 INTEGER DEFAULT 1,
|
||||
alert_quinte_only INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS saas_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
try:
|
||||
init_users_table()
|
||||
except Exception as e:
|
||||
print(f"[auth_bp] DB init warning: {e}")
|
||||
|
||||
|
||||
# ─── Token helpers ────────────────────────────────────────────────────────────
|
||||
def generate_token(user_id: str) -> str:
|
||||
token = secrets.token_urlsafe(48)
|
||||
expires = int(time.time()) + TOKEN_TTL
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO saas_tokens (token, user_id, expires_at) VALUES (?,?,?)",
|
||||
(token, user_id, expires),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return token
|
||||
|
||||
|
||||
def validate_token(token: str):
|
||||
"""Returns user row dict or None."""
|
||||
if not token:
|
||||
return None
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
row = conn.execute(
|
||||
"SELECT t.user_id, u.* FROM saas_tokens t JOIN saas_users u ON t.user_id=u.id "
|
||||
"WHERE t.token=? AND t.expires_at>?",
|
||||
(token, now),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
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) 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
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
def user_to_dict(user) -> dict:
|
||||
if isinstance(user, sqlite3.Row):
|
||||
user = dict(user)
|
||||
return {
|
||||
"id": user.get("id"),
|
||||
"email": user.get("email"),
|
||||
"firstname": user.get("firstname", ""),
|
||||
"lastname": user.get("lastname", ""),
|
||||
"plan": user.get("plan", "free"),
|
||||
"telegram_chat_id": user.get("telegram_chat_id"),
|
||||
"alert_value_bets": bool(user.get("alert_value_bets", 1)),
|
||||
"alert_top1": bool(user.get("alert_top1", 1)),
|
||||
"alert_quinte_only": bool(user.get("alert_quinte_only", 0)),
|
||||
"created_at": user.get("created_at"),
|
||||
}
|
||||
|
||||
|
||||
# ─── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@auth_bp.route("/register", methods=["POST"])
|
||||
def register():
|
||||
"""POST /api/v1/auth/register"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
password = data.get("password") or ""
|
||||
firstname = (data.get("firstname") or "").strip()
|
||||
lastname = (data.get("lastname") or "").strip()
|
||||
plan = data.get("plan", "free")
|
||||
|
||||
if not email or "@" not in email:
|
||||
return jsonify({"error": "Adresse email invalide."}), 400
|
||||
pwd_error = validate_password_strength(password)
|
||||
if pwd_error:
|
||||
return jsonify({"error": pwd_error}), 400
|
||||
if plan not in ("free", "premium", "pro"):
|
||||
plan = "free"
|
||||
|
||||
uid = secrets.token_hex(16)
|
||||
pw_hash = hash_password(password)
|
||||
conn = get_db()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO saas_users (id, email, firstname, lastname, password_hash, plan) VALUES (?,?,?,?,?,?)",
|
||||
(uid, email, firstname, lastname, pw_hash, plan),
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
conn.close()
|
||||
return jsonify({"error": "Cette adresse email est déjà utilisée."}), 409
|
||||
conn.close()
|
||||
|
||||
token = generate_token(uid)
|
||||
user_row = validate_token(token)
|
||||
return jsonify({"token": token, "user": user_to_dict(user_row)}), 201
|
||||
|
||||
|
||||
@auth_bp.route("/login", methods=["POST"])
|
||||
def login():
|
||||
"""POST /api/v1/auth/login"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
password = data.get("password") or ""
|
||||
|
||||
if not email or not password:
|
||||
return jsonify({"error": "Email et mot de passe requis."}), 400
|
||||
|
||||
# ── Rate limit par IP ────────────────────────────────────────
|
||||
ip = request.remote_addr or "unknown"
|
||||
now = time.time()
|
||||
|
||||
with _login_lock:
|
||||
bucket = _login_attempts[ip]
|
||||
# Lever le blocage si la durée est écoulée
|
||||
if now >= bucket["blocked_until"]:
|
||||
if now - bucket["window_start"] >= LOGIN_RATE_WINDOW:
|
||||
bucket["count"] = 0
|
||||
bucket["window_start"] = now
|
||||
bucket["count"] += 1
|
||||
count = bucket["count"]
|
||||
if count > LOGIN_RATE_MAX:
|
||||
bucket["blocked_until"] = now + LOGIN_BLOCK_DURATION
|
||||
retry_after = LOGIN_BLOCK_DURATION
|
||||
blocked = True
|
||||
else:
|
||||
retry_after = int(LOGIN_RATE_WINDOW - (now - bucket["window_start"]))
|
||||
blocked = False
|
||||
else:
|
||||
blocked = True
|
||||
retry_after = int(bucket["blocked_until"] - now)
|
||||
|
||||
if blocked:
|
||||
resp = jsonify({"error": "Trop de tentatives. Réessayez plus tard."})
|
||||
resp.status_code = 429
|
||||
resp.headers["Retry-After"] = str(retry_after)
|
||||
return resp
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
pw_hash = hash_password(password)
|
||||
conn = get_db()
|
||||
user = conn.execute(
|
||||
"SELECT * FROM saas_users WHERE email=? AND password_hash=?", (email, pw_hash)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
|
||||
if not user:
|
||||
return jsonify({"error": "Identifiants incorrects."}), 401
|
||||
|
||||
token = generate_token(user["id"])
|
||||
return jsonify({"token": token, "user": user_to_dict(user)}), 200
|
||||
|
||||
|
||||
@auth_bp.route("/me", methods=["GET"])
|
||||
@require_auth
|
||||
def me():
|
||||
"""GET /api/v1/auth/me"""
|
||||
return jsonify({"user": user_to_dict(request.current_user)}), 200
|
||||
|
||||
|
||||
@auth_bp.route("/update-profile", methods=["POST"])
|
||||
@require_auth
|
||||
def update_profile():
|
||||
"""POST /api/v1/auth/update-profile"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
uid = request.current_user["id"]
|
||||
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 not fields:
|
||||
return jsonify({"ok": True}), 200
|
||||
|
||||
set_clause = ", ".join(f"{k}=?" for k in fields)
|
||||
values = list(fields.values()) + [datetime.utcnow().isoformat(), uid]
|
||||
conn = get_db()
|
||||
try:
|
||||
conn.execute(
|
||||
f"UPDATE saas_users SET {set_clause}, updated_at=? WHERE id=?", values
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
conn.close()
|
||||
return jsonify({"error": "Cet email est déjà utilisé."}), 409
|
||||
conn.close()
|
||||
return jsonify({"ok": True}), 200
|
||||
|
||||
|
||||
@auth_bp.route("/change-password", methods=["POST"])
|
||||
@require_auth
|
||||
def change_password():
|
||||
"""POST /api/v1/auth/change-password"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
uid = request.current_user["id"]
|
||||
cur_pwd = data.get("current_password") or ""
|
||||
new_pwd = data.get("new_password") or ""
|
||||
|
||||
pwd_error = validate_password_strength(new_pwd)
|
||||
if pwd_error:
|
||||
return jsonify({"error": pwd_error}), 400
|
||||
|
||||
conn = get_db()
|
||||
user = conn.execute(
|
||||
"SELECT * FROM saas_users WHERE id=? AND password_hash=?",
|
||||
(uid, hash_password(cur_pwd)),
|
||||
).fetchone()
|
||||
if not user:
|
||||
conn.close()
|
||||
return jsonify({"error": "Mot de passe actuel incorrect."}), 401
|
||||
|
||||
conn.execute(
|
||||
"UPDATE saas_users SET password_hash=?, updated_at=? WHERE id=?",
|
||||
(hash_password(new_pwd), datetime.utcnow().isoformat(), uid),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({"ok": True}), 200
|
||||
|
||||
|
||||
@auth_bp.route("/update-plan", methods=["POST"])
|
||||
@require_auth
|
||||
def update_plan():
|
||||
"""POST /api/v1/auth/update-plan"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
plan = data.get("plan", "free")
|
||||
if plan not in ("free", "premium", "pro"):
|
||||
return jsonify({"error": "Plan invalide."}), 400
|
||||
uid = request.current_user["id"]
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"UPDATE saas_users SET plan=?, updated_at=? WHERE id=?",
|
||||
(plan, datetime.utcnow().isoformat(), uid),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({"ok": True, "plan": plan}), 200
|
||||
|
||||
|
||||
@auth_bp.route("/update-preferences", methods=["POST"])
|
||||
@require_auth
|
||||
def update_preferences():
|
||||
"""POST /api/v1/auth/update-preferences"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
uid = request.current_user["id"]
|
||||
fields = {}
|
||||
if "telegram_chat_id" in data:
|
||||
fields["telegram_chat_id"] = data["telegram_chat_id"] or None
|
||||
if "alert_value_bets" in data:
|
||||
fields["alert_value_bets"] = 1 if data["alert_value_bets"] else 0
|
||||
if "alert_top1" in data:
|
||||
fields["alert_top1"] = 1 if data["alert_top1"] else 0
|
||||
if "alert_quinte_only" in data:
|
||||
fields["alert_quinte_only"] = 1 if data["alert_quinte_only"] else 0
|
||||
|
||||
if fields:
|
||||
set_clause = ", ".join(f"{k}=?" for k in fields)
|
||||
values = list(fields.values()) + [datetime.utcnow().isoformat(), uid]
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
f"UPDATE saas_users SET {set_clause}, updated_at=? WHERE id=?", values
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return jsonify({"ok": True}), 200
|
||||
|
||||
|
||||
@auth_bp.route("/logout", methods=["POST"])
|
||||
@require_auth
|
||||
def logout():
|
||||
"""POST /api/v1/auth/logout"""
|
||||
auth = request.headers.get("Authorization", "")
|
||||
token = auth.removeprefix("Bearer ").strip()
|
||||
conn = get_db()
|
||||
conn.execute("DELETE FROM saas_tokens WHERE token=?", (token,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({"ok": True}), 200
|
||||
|
||||
|
||||
@auth_bp.route("/delete-account", methods=["DELETE"])
|
||||
@require_auth
|
||||
def delete_account():
|
||||
"""DELETE /api/v1/auth/delete-account"""
|
||||
uid = request.current_user["id"]
|
||||
conn = get_db()
|
||||
conn.execute("DELETE FROM saas_tokens WHERE user_id=?", (uid,))
|
||||
conn.execute("DELETE FROM saas_users WHERE id=?", (uid,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({"ok": True}), 200
|
||||
481
scoring_v2.py
481
scoring_v2.py
@@ -10,30 +10,35 @@ import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
|
||||
HEADERS = {'User-Agent': 'Mozilla/5.0', 'Accept': 'application/json'}
|
||||
DB_PATH = "/home/h3r7/turf_saas/turf_saas.db"
|
||||
HEADERS = {"User-Agent": "Mozilla/5.0", "Accept": "application/json"}
|
||||
|
||||
|
||||
def get_cote_from_db(horse_name, date_course):
|
||||
"""Recupere la cote depuis la table predictions (plus recente et non nulle)"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
c = conn.execute("""
|
||||
c = conn.execute(
|
||||
"""
|
||||
SELECT odds FROM predictions
|
||||
WHERE date=? AND horse_name LIKE ? AND odds > 0
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
""", (date_course, f"%{horse_name}%"))
|
||||
""",
|
||||
(date_course, f"%{horse_name}%"),
|
||||
)
|
||||
r = c.fetchone()
|
||||
conn.close()
|
||||
return r['odds'] if r else 0
|
||||
return r["odds"] if r else 0
|
||||
|
||||
|
||||
def parse_musique(musique):
|
||||
if not musique:
|
||||
return {}
|
||||
clean = re.sub(r'\(\d+\)', '', musique)
|
||||
resultats = re.findall(r'(\d+|D|0)([amphsc]?)', clean)
|
||||
clean = re.sub(r"\(\d+\)", "", musique)
|
||||
resultats = re.findall(r"(\d+|D|0)([amphsc]?)", clean)
|
||||
positions = []
|
||||
for pos, disc in resultats[:10]:
|
||||
positions.append(99 if pos == 'D' else int(pos))
|
||||
positions.append(99 if pos == "D" else int(pos))
|
||||
if not positions:
|
||||
return {}
|
||||
nb_courses = len(positions)
|
||||
@@ -41,222 +46,385 @@ def parse_musique(musique):
|
||||
nb_places = sum(1 for p in positions if 1 <= p <= 3)
|
||||
recentes = [p for p in positions[:3] if p != 99]
|
||||
forme_recente = sum(recentes) / len(recentes) if recentes else 99
|
||||
tendance = (sum(positions[-4:]) / 4 - sum(positions[:4]) / 4) if len(positions) >= 4 else 0
|
||||
tendance = (
|
||||
(sum(positions[-4:]) / 4 - sum(positions[:4]) / 4) if len(positions) >= 4 else 0
|
||||
)
|
||||
return {
|
||||
'forme_recente': round(forme_recente, 1),
|
||||
'tendance': round(tendance, 1),
|
||||
'tx_victoire': round(nb_victoires / nb_courses * 100, 1) if nb_courses else 0,
|
||||
'tx_place': round(nb_places / nb_courses * 100, 1) if nb_courses else 0,
|
||||
"forme_recente": round(forme_recente, 1),
|
||||
"tendance": round(tendance, 1),
|
||||
"tx_victoire": round(nb_victoires / nb_courses * 100, 1) if nb_courses else 0,
|
||||
"tx_place": round(nb_places / nb_courses * 100, 1) if nb_courses else 0,
|
||||
}
|
||||
|
||||
def score_cheval_v2(p, all_participants, today):
|
||||
|
||||
def get_terrain_condition(penetrometre_intitule: str | None) -> str:
|
||||
"""Normalise le pénétromètre PMU en condition terrain standardisée."""
|
||||
if not penetrometre_intitule:
|
||||
return "inconnu"
|
||||
val = penetrometre_intitule.upper()
|
||||
if any(k in val for k in ("TRES BON", "TRÈS BON", "FERME", "FIRM")):
|
||||
return "bon"
|
||||
if any(k in val for k in ("BON", "GOOD", "STANDARD")):
|
||||
return "bon"
|
||||
if any(k in val for k in ("SOUPLE", "YIELDING", "COLLANT")):
|
||||
return "souple"
|
||||
if any(k in val for k in ("LOURD", "HEAVY", "TRES SOUPLE", "TRÈS SOUPLE")):
|
||||
return "lourd"
|
||||
if any(k in val for k in ("SOFT", "MOU")):
|
||||
return "souple"
|
||||
return "inconnu"
|
||||
|
||||
|
||||
def compute_weather_impact(weather_data: dict | None, terrain_condition: str) -> float:
|
||||
"""
|
||||
Calcule un score d'impact météo/terrain sur [−5, +5].
|
||||
weather_data keys attendues : nebulositecode, temperature, force_vent
|
||||
terrain_condition : 'bon' | 'souple' | 'lourd' | 'inconnu'
|
||||
Retourne un delta de score ML (positif = favorable, négatif = défavorable).
|
||||
"""
|
||||
if not weather_data:
|
||||
return 0.0
|
||||
|
||||
delta = 0.0
|
||||
|
||||
# Terrain
|
||||
if terrain_condition == "lourd":
|
||||
delta -= 3.0
|
||||
elif terrain_condition == "souple":
|
||||
delta -= 1.5
|
||||
elif terrain_condition == "bon":
|
||||
delta += 1.0
|
||||
# inconnu → 0
|
||||
|
||||
# Vent
|
||||
force_vent = weather_data.get("force_vent") or 0
|
||||
try:
|
||||
force_vent = float(force_vent)
|
||||
except (TypeError, ValueError):
|
||||
force_vent = 0.0
|
||||
if force_vent >= 50:
|
||||
delta -= 2.0
|
||||
elif force_vent >= 30:
|
||||
delta -= 1.0
|
||||
|
||||
# Températures extrêmes
|
||||
temperature = weather_data.get("temperature")
|
||||
try:
|
||||
temperature = float(temperature) if temperature is not None else None
|
||||
except (TypeError, ValueError):
|
||||
temperature = None
|
||||
if temperature is not None:
|
||||
if temperature <= 0:
|
||||
delta -= 1.0
|
||||
elif temperature >= 35:
|
||||
delta -= 1.0
|
||||
|
||||
return round(max(-5.0, min(5.0, delta)), 2)
|
||||
|
||||
|
||||
def score_cheval_v2(p, all_participants, today, weather_data=None):
|
||||
"""
|
||||
Score un cheval pour le modèle V2.
|
||||
weather_data (optionnel) : dict issu de pmu_meteo pour cette réunion.
|
||||
Backward-compatible : weather_data=None → comportement identique à avant HRT-83.
|
||||
"""
|
||||
score = 0
|
||||
details = {}
|
||||
|
||||
|
||||
# 1. COTE - Essaye PMU API, sinon DB
|
||||
horse_name = p.get('nom', '')
|
||||
horse_name = p.get("nom", "")
|
||||
cote = 0
|
||||
|
||||
|
||||
# Essayer d'abord depuis l'API PMU
|
||||
rapport = p.get('dernierRapportDirect', {})
|
||||
rapport = p.get("dernierRapportDirect", {})
|
||||
if rapport:
|
||||
cote = rapport.get('rapport', 0)
|
||||
cote = rapport.get("rapport", 0)
|
||||
if not cote:
|
||||
rapport_ref = p.get('dernierRapportReference', {})
|
||||
cote = rapport_ref.get('rapport', 0) if rapport_ref else 0
|
||||
|
||||
rapport_ref = p.get("dernierRapportReference", {})
|
||||
cote = rapport_ref.get("rapport", 0) if rapport_ref else 0
|
||||
|
||||
# Fallback: aller chercher dans la DB
|
||||
if not cote or cote == 0:
|
||||
cote = get_cote_from_db(horse_name, today)
|
||||
|
||||
|
||||
# Si toujours pas de cote, utiliser 99 comme valeur par defaut
|
||||
if not cote or cote == 0:
|
||||
cote = 99.0
|
||||
|
||||
|
||||
score_cote = max(2, min(10, 20 / (1 + cote * 0.15))) if cote > 0 else 2
|
||||
score += score_cote
|
||||
details['cote'] = round(cote, 1)
|
||||
details['score_cote'] = round(score_cote, 1)
|
||||
|
||||
details["cote"] = round(cote, 1)
|
||||
details["score_cote"] = round(score_cote, 1)
|
||||
|
||||
# 2. FORME - AUGMENTE a 30 pts
|
||||
musique_stats = parse_musique(p.get('musique', ''))
|
||||
forme = musique_stats.get('forme_recente', 99)
|
||||
score_forme = 30 if forme <= 1 else 25 if forme <= 2 else 20 if forme <= 3 else 15 if forme <= 5 else 8 if forme <= 8 else 0
|
||||
musique_stats = parse_musique(p.get("musique", ""))
|
||||
forme = musique_stats.get("forme_recente", 99)
|
||||
score_forme = (
|
||||
30
|
||||
if forme <= 1
|
||||
else 25
|
||||
if forme <= 2
|
||||
else 20
|
||||
if forme <= 3
|
||||
else 15
|
||||
if forme <= 5
|
||||
else 8
|
||||
if forme <= 8
|
||||
else 0
|
||||
)
|
||||
score += score_forme
|
||||
details['forme_recente'] = forme
|
||||
details['score_forme'] = score_forme
|
||||
|
||||
details["forme_recente"] = forme
|
||||
details["score_forme"] = score_forme
|
||||
|
||||
# 3. TAUX VICTOIRE (15 pts)
|
||||
nb_courses_total = p.get('nombreCourses', 0)
|
||||
nb_victoires_total = p.get('nombreVictoires', 0)
|
||||
nb_courses_total = p.get("nombreCourses", 0)
|
||||
nb_victoires_total = p.get("nombreVictoires", 0)
|
||||
tx_vic = (nb_victoires_total / nb_courses_total * 100) if nb_courses_total else 0
|
||||
score_vic = min(15, tx_vic * 0.5)
|
||||
score += score_vic
|
||||
details['tx_victoire'] = round(tx_vic, 1)
|
||||
details['score_victoire'] = round(score_vic, 1)
|
||||
|
||||
details["tx_victoire"] = round(tx_vic, 1)
|
||||
details["score_victoire"] = round(score_vic, 1)
|
||||
|
||||
# 4. TAUX PLACE (15 pts)
|
||||
nb_places_total = p.get('nombrePlaces', 0)
|
||||
nb_places_total = p.get("nombrePlaces", 0)
|
||||
tx_place = (nb_places_total / nb_courses_total * 100) if nb_courses_total else 0
|
||||
score_place = min(15, tx_place * 0.2)
|
||||
score += score_place
|
||||
details['tx_place'] = round(tx_place, 1)
|
||||
details['score_place'] = round(score_place, 1)
|
||||
|
||||
details["tx_place"] = round(tx_place, 1)
|
||||
details["score_place"] = round(score_place, 1)
|
||||
|
||||
# 5. REDUCTION KM (10 pts)
|
||||
rk = p.get('reductionKilometrique', 0)
|
||||
all_rk = [x.get('reductionKilometrique', 0) for x in all_participants if x.get('reductionKilometrique', 0) > 0]
|
||||
rk = p.get("reductionKilometrique", 0)
|
||||
all_rk = [
|
||||
x.get("reductionKilometrique", 0)
|
||||
for x in all_participants
|
||||
if x.get("reductionKilometrique", 0) > 0
|
||||
]
|
||||
if rk > 0 and all_rk:
|
||||
score_rk = 10 * (1 - (rk - min(all_rk)) / (max(all_rk) - min(all_rk))) if max(all_rk) > min(all_rk) else 5
|
||||
score_rk = (
|
||||
10 * (1 - (rk - min(all_rk)) / (max(all_rk) - min(all_rk)))
|
||||
if max(all_rk) > min(all_rk)
|
||||
else 5
|
||||
)
|
||||
else:
|
||||
score_rk = 0
|
||||
score += score_rk
|
||||
details['rk'] = rk
|
||||
details['score_rk'] = round(score_rk, 1)
|
||||
|
||||
details["rk"] = rk
|
||||
details["score_rk"] = round(score_rk, 1)
|
||||
|
||||
# 6. TENDANCE (10 pts)
|
||||
tendance = musique_stats.get('tendance', 0)
|
||||
tendance = musique_stats.get("tendance", 0)
|
||||
score_tendance = min(10, max(0, 5 + tendance))
|
||||
score += score_tendance
|
||||
details['tendance'] = tendance
|
||||
details['score_tendance'] = round(score_tendance, 1)
|
||||
|
||||
details["tendance"] = tendance
|
||||
details["score_tendance"] = round(score_tendance, 1)
|
||||
|
||||
# 7. AVIS ENTRAINEUR (5 pts)
|
||||
avis = p.get('avisEntraineur', 'NEUTRE')
|
||||
score_avis = {'POSITIF': 5, 'TRES_POSITIF': 5, 'NEUTRE': 2, 'NEGATIF': 0, 'TRES_NEGATIF': 0}.get(avis, 2)
|
||||
avis = p.get("avisEntraineur", "NEUTRE")
|
||||
score_avis = {
|
||||
"POSITIF": 5,
|
||||
"TRES_POSITIF": 5,
|
||||
"NEUTRE": 2,
|
||||
"NEGATIF": 0,
|
||||
"TRES_NEGATIF": 0,
|
||||
}.get(avis, 2)
|
||||
score += score_avis
|
||||
details['avis_entraineur'] = avis
|
||||
details['score_avis'] = score_avis
|
||||
|
||||
details["avis_entraineur"] = avis
|
||||
details["score_avis"] = score_avis
|
||||
|
||||
# 8. BONUS OUTSIDER (5 pts)
|
||||
bonus_outsider = 5 if forme <= 3 and cote >= 10 else 0
|
||||
score += bonus_outsider
|
||||
details['bonus_outsider'] = bonus_outsider
|
||||
|
||||
details["bonus_outsider"] = bonus_outsider
|
||||
|
||||
# Driver change penalty
|
||||
if p.get('driverChange', False):
|
||||
if p.get("driverChange", False):
|
||||
score -= 3
|
||||
details['driver_change'] = True
|
||||
|
||||
details['score_total'] = round(score, 1)
|
||||
details['musique'] = p.get('musique', '')
|
||||
details['nb_victoires'] = nb_victoires_total
|
||||
details['nb_places'] = nb_places_total
|
||||
details['nb_courses'] = nb_courses_total
|
||||
|
||||
details["driver_change"] = True
|
||||
|
||||
# 9. METEO & TERRAIN (HRT-83) — premium feature, weather_data=None → skip
|
||||
penetrometre = p.get("penetrometre_intitule", "") or ""
|
||||
terrain_condition = (
|
||||
get_terrain_condition(penetrometre) if penetrometre else "inconnu"
|
||||
)
|
||||
weather_impact = 0.0
|
||||
if weather_data is not None:
|
||||
weather_impact = compute_weather_impact(weather_data, terrain_condition)
|
||||
score += weather_impact
|
||||
details["terrain_condition"] = terrain_condition
|
||||
details["weather_impact"] = weather_impact
|
||||
|
||||
details["score_total"] = round(score, 1)
|
||||
details["musique"] = p.get("musique", "")
|
||||
details["nb_victoires"] = nb_victoires_total
|
||||
details["nb_places"] = nb_places_total
|
||||
details["nb_courses"] = nb_courses_total
|
||||
|
||||
return round(score, 1), details
|
||||
|
||||
|
||||
def get_ze2sur4_combinaisons(top4):
|
||||
combinaisons = []
|
||||
for i in range(4):
|
||||
for j in range(i+1, 4):
|
||||
for j in range(i + 1, 4):
|
||||
c1 = top4[i]
|
||||
c2 = top4[j]
|
||||
combinaisons.append({
|
||||
'cheval1': c1['nom'],
|
||||
'numero1': c1['numero'],
|
||||
'cheval2': c2['nom'],
|
||||
'numero2': c2['numero'],
|
||||
'mise': 1.0,
|
||||
})
|
||||
combinaisons.append(
|
||||
{
|
||||
"cheval1": c1["nom"],
|
||||
"numero1": c1["numero"],
|
||||
"cheval2": c2["nom"],
|
||||
"numero2": c2["numero"],
|
||||
"mise": 1.0,
|
||||
}
|
||||
)
|
||||
return combinaisons
|
||||
|
||||
|
||||
def build_recommendations_v2(scored_horses):
|
||||
ranked = sorted(scored_horses, key=lambda x: x['score'], reverse=True)
|
||||
ranked = sorted(scored_horses, key=lambda x: x["score"], reverse=True)
|
||||
if len(ranked) < 4:
|
||||
return None
|
||||
|
||||
|
||||
top1, top2, top3, top4 = ranked[0], ranked[1], ranked[2], ranked[3]
|
||||
top4_list = ranked[:4]
|
||||
|
||||
|
||||
def confiance(s):
|
||||
return "FORTE" if s >= 55 else "BONNE" if s >= 45 else "MOYENNE" if s >= 35 else "FAIBLE"
|
||||
|
||||
return (
|
||||
"FORTE"
|
||||
if s >= 55
|
||||
else "BONNE"
|
||||
if s >= 45
|
||||
else "MOYENNE"
|
||||
if s >= 35
|
||||
else "FAIBLE"
|
||||
)
|
||||
|
||||
ze2_combinaisons = get_ze2sur4_combinaisons(top4_list)
|
||||
mise_ze2 = len(ze2_combinaisons) * 1.0
|
||||
|
||||
|
||||
return {
|
||||
'simple_gagnant': {
|
||||
'cheval': top1['nom'], 'numero': top1['numero'], 'cote': top1['details']['cote'],
|
||||
'score': top1['score'], 'confiance': confiance(top1['score']),
|
||||
'mise_suggeree': 2.0, 'gain_potentiel': round(2.0 * top1['details']['cote'], 2)
|
||||
"simple_gagnant": {
|
||||
"cheval": top1["nom"],
|
||||
"numero": top1["numero"],
|
||||
"cote": top1["details"]["cote"],
|
||||
"score": top1["score"],
|
||||
"confiance": confiance(top1["score"]),
|
||||
"mise_suggeree": 2.0,
|
||||
"gain_potentiel": round(2.0 * top1["details"]["cote"], 2),
|
||||
},
|
||||
'ze2_sur_4': {
|
||||
'top4': [{'nom': h['nom'], 'numero': h['numero']} for h in top4_list],
|
||||
'combinaisons': ze2_combinaisons,
|
||||
'mise_totale': mise_ze2,
|
||||
'nb_combinaisons': len(ze2_combinaisons),
|
||||
'confiance': confiance((top1['score'] + top2['score'] + top3['score'] + top4['score']) / 4),
|
||||
'explication': 'Jouer les 6 combinaisons de 2 chevaux parmi les 4 premiers'
|
||||
"ze2_sur_4": {
|
||||
"top4": [{"nom": h["nom"], "numero": h["numero"]} for h in top4_list],
|
||||
"combinaisons": ze2_combinaisons,
|
||||
"mise_totale": mise_ze2,
|
||||
"nb_combinaisons": len(ze2_combinaisons),
|
||||
"confiance": confiance(
|
||||
(top1["score"] + top2["score"] + top3["score"] + top4["score"]) / 4
|
||||
),
|
||||
"explication": "Jouer les 6 combinaisons de 2 chevaux parmi les 4 premiers",
|
||||
},
|
||||
'outsider': _find_outsider(ranked),
|
||||
'budget_total': 2.0 + mise_ze2,
|
||||
"outsider": _find_outsider(ranked),
|
||||
"budget_total": 2.0 + mise_ze2,
|
||||
}
|
||||
|
||||
|
||||
def _find_outsider(ranked):
|
||||
for h in ranked[3:7]:
|
||||
d = h['details']
|
||||
if d['cote'] >= 12 and d['forme_recente'] <= 4 and d['bonus_outsider'] == 5:
|
||||
d = h["details"]
|
||||
if d["cote"] >= 12 and d["forme_recente"] <= 4 and d["bonus_outsider"] == 5:
|
||||
return {
|
||||
'cheval': h['nom'], 'numero': h['numero'], 'cote': d['cote'],
|
||||
'mise_suggeree': 1.0, 'gain_potentiel': round(1.0 * d['cote'], 2)
|
||||
"cheval": h["nom"],
|
||||
"numero": h["numero"],
|
||||
"cote": d["cote"],
|
||||
"mise_suggeree": 1.0,
|
||||
"gain_potentiel": round(1.0 * d["cote"], 2),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def save_to_db(scored_horses, date_course, hippodrome, libelle):
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
|
||||
cursor.execute("DELETE FROM scoring WHERE date = ?", (date_course,))
|
||||
|
||||
|
||||
for i, h in enumerate(scored_horses, 1):
|
||||
d = h['details']
|
||||
cursor.execute("""
|
||||
d = h["details"]
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO scoring (date, race_name, horse_number, horse_name, score,
|
||||
score_cote, score_forme, score_victoire, score_place, score_rk,
|
||||
score_tendance, score_avis, cote, forme_recente, tx_victoire, tx_place,
|
||||
avis_entraineur, musique, rang_scoring, scoring_version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'v2')
|
||||
""", (date_course, libelle, h['numero'], h['nom'], h['score'],
|
||||
d.get('score_cote', 0), d.get('score_forme', 0), d.get('score_victoire', 0),
|
||||
d.get('score_place', 0), d.get('score_rk', 0), d.get('score_tendance', 0),
|
||||
d.get('score_avis', 0), d.get('cote', 0), d.get('forme_recente', 0),
|
||||
d.get('tx_victoire', 0), d.get('tx_place', 0), d.get('avis_entraineur', ''),
|
||||
d.get('musique', ''), i))
|
||||
|
||||
""",
|
||||
(
|
||||
date_course,
|
||||
libelle,
|
||||
h["numero"],
|
||||
h["nom"],
|
||||
h["score"],
|
||||
d.get("score_cote", 0),
|
||||
d.get("score_forme", 0),
|
||||
d.get("score_victoire", 0),
|
||||
d.get("score_place", 0),
|
||||
d.get("score_rk", 0),
|
||||
d.get("score_tendance", 0),
|
||||
d.get("score_avis", 0),
|
||||
d.get("cote", 0),
|
||||
d.get("forme_recente", 0),
|
||||
d.get("tx_victoire", 0),
|
||||
d.get("tx_place", 0),
|
||||
d.get("avis_entraineur", ""),
|
||||
d.get("musique", ""),
|
||||
i,
|
||||
),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"💾 {len(scored_horses)} scores enregistres en BDD pour {date_course}")
|
||||
|
||||
|
||||
def main():
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
date_pmu = datetime.now().strftime('%d%m%Y')
|
||||
print(f"=== SCORING V2 - ZE2 SUR4 OPTIMISE === {datetime.now().strftime('%d/%m/%Y %H:%M')} ===")
|
||||
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
date_pmu = datetime.now().strftime("%d%m%Y")
|
||||
print(
|
||||
f"=== SCORING V2 - ZE2 SUR4 OPTIMISE === {datetime.now().strftime('%d/%m/%Y %H:%M')} ==="
|
||||
)
|
||||
|
||||
try:
|
||||
url = f"https://turfinfo.api.pmu.fr/rest/client/1/programme/{date_pmu}/reunions"
|
||||
r = requests.get(url, headers=HEADERS, timeout=15)
|
||||
reunions = r.json().get('programme', {}).get('reunions', [])
|
||||
reunions = r.json().get("programme", {}).get("reunions", [])
|
||||
except Exception as e:
|
||||
print(f"Erreur: {e}")
|
||||
return
|
||||
|
||||
|
||||
quinte = None
|
||||
for reunion in reunions:
|
||||
for course in reunion.get('courses', []):
|
||||
for course in reunion.get("courses", []):
|
||||
paris_types = [p["typePari"] for p in course.get("paris", [])]
|
||||
if any("QUINTE" in p for p in paris_types) or "PARIS-TURF" in course.get('libelle', ''):
|
||||
quinte = (reunion['numOfficiel'], course['numOrdre'], course.get('libelle', ''),
|
||||
reunion['hippodrome']['libelleCourt'], course.get('heureDepart', 0))
|
||||
if any("QUINTE" in p for p in paris_types) or "PARIS-TURF" in course.get(
|
||||
"libelle", ""
|
||||
):
|
||||
quinte = (
|
||||
reunion["numOfficiel"],
|
||||
course["numOrdre"],
|
||||
course.get("libelle", ""),
|
||||
reunion["hippodrome"]["libelleCourt"],
|
||||
course.get("heureDepart", 0),
|
||||
)
|
||||
break
|
||||
if quinte:
|
||||
break
|
||||
|
||||
|
||||
if not quinte:
|
||||
# Fallback: utiliser la premiere reunion francaise avec predictions
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
r = conn.execute("""
|
||||
r = conn.execute(
|
||||
"""
|
||||
SELECT r.num_reunion, r.hippodrome_court, c.num_course, c.libelle
|
||||
FROM pmu_courses c
|
||||
JOIN pmu_reunions r ON r.date_programme=c.date_programme AND r.num_reunion=c.num_reunion
|
||||
@@ -264,57 +432,82 @@ def main():
|
||||
AND EXISTS (SELECT 1 FROM predictions p WHERE p.date=? AND p.source='canalturf_partants'
|
||||
AND p.race_name LIKE '%' || c.libelle || '%')
|
||||
ORDER BY c.heure_depart_str ASC LIMIT 1
|
||||
""", (today, today)).fetchone()
|
||||
""",
|
||||
(today, today),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if r:
|
||||
quinte = (r['num_reunion'], r['num_course'], r['libelle'], r['hippodrome_court'], 0)
|
||||
quinte = (
|
||||
r["num_reunion"],
|
||||
r["num_course"],
|
||||
r["libelle"],
|
||||
r["hippodrome_court"],
|
||||
0,
|
||||
)
|
||||
else:
|
||||
print("Aucune course trouvee")
|
||||
return
|
||||
|
||||
|
||||
num_r, num_c, libelle, hippodrome, heure_ts = quinte
|
||||
heure = datetime.fromtimestamp(heure_ts/1000).strftime('%H:%M') if heure_ts else '13:55'
|
||||
heure = (
|
||||
datetime.fromtimestamp(heure_ts / 1000).strftime("%H:%M")
|
||||
if heure_ts
|
||||
else "13:55"
|
||||
)
|
||||
print(f"Course: {libelle} - {hippodrome} {heure}")
|
||||
|
||||
|
||||
try:
|
||||
url = f"https://turfinfo.api.pmu.fr/rest/client/1/programme/{date_pmu}/R{num_r}/C{num_c}/participants"
|
||||
r = requests.get(url, headers=HEADERS, timeout=15)
|
||||
participants = [p for p in r.json().get('participants', []) if p.get('statut') == 'PARTANT']
|
||||
participants = [
|
||||
p for p in r.json().get("participants", []) if p.get("statut") == "PARTANT"
|
||||
]
|
||||
except Exception as e:
|
||||
print(f"Erreur: {e}")
|
||||
return
|
||||
|
||||
|
||||
scored_horses = []
|
||||
for p in participants:
|
||||
score, details = score_cheval_v2(p, participants, today)
|
||||
scored_horses.append({'nom': p['nom'], 'numero': p['numPmu'], 'score': score, 'details': details})
|
||||
|
||||
ranked = sorted(scored_horses, key=lambda x: x['score'], reverse=True)
|
||||
scored_horses.append(
|
||||
{"nom": p["nom"], "numero": p["numPmu"], "score": score, "details": details}
|
||||
)
|
||||
|
||||
ranked = sorted(scored_horses, key=lambda x: x["score"], reverse=True)
|
||||
print(f"\n=== TOP 4 ===")
|
||||
for i, h in enumerate(ranked[:4], 1):
|
||||
d = h['details']
|
||||
print(f"{i}. #{h['numero']:>2} {h['nom']:<20} Score:{h['score']:.1f} Cote:{d['cote']:.1f}")
|
||||
|
||||
d = h["details"]
|
||||
print(
|
||||
f"{i}. #{h['numero']:>2} {h['nom']:<20} Score:{h['score']:.1f} Cote:{d['cote']:.1f}"
|
||||
)
|
||||
|
||||
save_to_db(ranked, today, hippodrome, libelle)
|
||||
|
||||
|
||||
reco = build_recommendations_v2(scored_horses)
|
||||
if reco:
|
||||
print(f"\n=== RECOMMANDATIONS ===")
|
||||
sg = reco['simple_gagnant']
|
||||
sg = reco["simple_gagnant"]
|
||||
print(f"\n🎯 SIMPLE GAGNANT:")
|
||||
print(f" #{sg['numero']} {sg['cheval']} @ {sg['cote']}/1 (mise {sg['mise_suggeree']}EUR)")
|
||||
|
||||
ze2 = reco['ze2_sur_4']
|
||||
print(
|
||||
f" #{sg['numero']} {sg['cheval']} @ {sg['cote']}/1 (mise {sg['mise_suggeree']}EUR)"
|
||||
)
|
||||
|
||||
ze2 = reco["ze2_sur_4"]
|
||||
print(f"\n🎰 ZE 2 SUR 4 (TOP 4: {', '.join([h['nom'] for h in ze2['top4']])}")
|
||||
print(f" Mise totale: {ze2['mise_totale']}EUR ({ze2['nb_combinaisons']} combis x 1EUR)")
|
||||
print(
|
||||
f" Mise totale: {ze2['mise_totale']}EUR ({ze2['nb_combinaisons']} combis x 1EUR)"
|
||||
)
|
||||
print(f" Confiance: {ze2['confiance']}")
|
||||
print(f" Combinaisons:")
|
||||
for c in ze2['combinaisons']:
|
||||
print(f" {c['numero1']}-{c['cheval1']} + {c['numero2']}-{c['cheval2']}")
|
||||
|
||||
for c in ze2["combinaisons"]:
|
||||
print(
|
||||
f" {c['numero1']}-{c['cheval1']} + {c['numero2']}-{c['cheval2']}"
|
||||
)
|
||||
|
||||
print(f"\n💰 BUDGET TOTAL: {reco['budget_total']}EUR")
|
||||
print(f" - Simple Gagnant: 2EUR")
|
||||
print(f" - ZE 2 sur 4: {ze2['mise_totale']}EUR")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
284
telegram_alerts.py
Normal file
284
telegram_alerts.py
Normal file
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Telegram Alerts — Service d'alertes pré-course pour les utilisateurs Premium/Pro
|
||||
HRT-79: Alertes Telegram configurables (Premium)
|
||||
|
||||
Fonctionnement :
|
||||
- 30 minutes avant chaque course détectée, envoie un message Telegram
|
||||
aux utilisateurs Premium/Pro ayant configuré leur chat_id.
|
||||
- Les préférences individuelles (value_bets, top1, quinte_only) sont respectées.
|
||||
- Requiert la variable d'environnement TELEGRAM_BOT_TOKEN.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||
|
||||
TELEGRAM_API_BASE = "https://api.telegram.org/bot{token}/sendMessage"
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def send_telegram_message(chat_id: str, text: str) -> bool:
|
||||
"""
|
||||
Envoie un message Telegram à un chat_id donné.
|
||||
|
||||
Returns True si succès, False sinon.
|
||||
Ne lève pas d'exception pour ne pas crasher le scheduler.
|
||||
"""
|
||||
if not BOT_TOKEN:
|
||||
logger.warning("[TELEGRAM] TELEGRAM_BOT_TOKEN non configuré — envoi ignoré")
|
||||
return False
|
||||
|
||||
url = TELEGRAM_API_BASE.format(token=BOT_TOKEN)
|
||||
payload = {
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
"parse_mode": "Markdown",
|
||||
"disable_web_page_preview": True,
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, json=payload, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
return True
|
||||
logger.warning(
|
||||
"[TELEGRAM] Echec envoi chat_id=%s status=%d body=%s",
|
||||
chat_id,
|
||||
resp.status_code,
|
||||
resp.text[:200],
|
||||
)
|
||||
return False
|
||||
except requests.RequestException as exc:
|
||||
logger.error("[TELEGRAM] Exception HTTP chat_id=%s: %s", chat_id, exc)
|
||||
return False
|
||||
|
||||
|
||||
# ── Alert builder ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_race_alert(race_data: dict, predictions: list) -> str:
|
||||
"""
|
||||
Construit le message Markdown de l'alerte pré-course.
|
||||
|
||||
Args:
|
||||
race_data: dict avec les clés 'hippo', 'num_course', 'heure', 'type_course'
|
||||
predictions: liste de dicts {'num_cheval', 'nom_cheval', 'prob_top3', 'is_value_bet', 'ml_score'}
|
||||
|
||||
Returns: texte Markdown formaté
|
||||
"""
|
||||
hippo = race_data.get("hippo", "?")
|
||||
num_course = race_data.get("num_course", "?")
|
||||
heure = race_data.get("heure", "?")
|
||||
type_course = race_data.get("type_course", "")
|
||||
|
||||
lines = [
|
||||
f"🏇 *Alerte course — {hippo} R{num_course}*",
|
||||
f"⏰ Départ prévu : *{heure}*",
|
||||
]
|
||||
if type_course:
|
||||
lines.append(f"📋 Type : {type_course}")
|
||||
lines.append("")
|
||||
|
||||
top3 = [p for p in predictions if p.get("prob_top3", 0) > 0][:3]
|
||||
value_bets = [p for p in predictions if p.get("is_value_bet")]
|
||||
|
||||
if top3:
|
||||
lines.append("📊 *Top-3 ML :*")
|
||||
for i, p in enumerate(top3, 1):
|
||||
nom = p.get("nom_cheval", f"#{p.get('num_cheval', '?')}")
|
||||
prob = p.get("prob_top3", 0)
|
||||
lines.append(f" {i}. {nom} — {prob:.0%} prob top-3")
|
||||
lines.append("")
|
||||
|
||||
if value_bets:
|
||||
lines.append("💡 *Value bets :*")
|
||||
for p in value_bets[:3]:
|
||||
nom = p.get("nom_cheval", f"#{p.get('num_cheval', '?')}")
|
||||
score = p.get("ml_score", 0)
|
||||
lines.append(f" ✅ {nom} (score {score:.2f})")
|
||||
lines.append("")
|
||||
|
||||
lines.append("_Alerte automatique Turf SaaS — 30min avant départ_")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── Main send function ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def send_pre_race_alerts(minutes_before: int = 30) -> dict:
|
||||
"""
|
||||
Interroge la DB pour récupérer les courses du jour, puis envoie
|
||||
des alertes Telegram aux utilisateurs Premium/Pro éligibles.
|
||||
|
||||
Args:
|
||||
minutes_before: non utilisé directement (la planification est gérée
|
||||
par le scheduler), présent pour documentation.
|
||||
|
||||
Returns: dict {'sent': int, 'skipped': int, 'errors': int}
|
||||
"""
|
||||
if not BOT_TOKEN:
|
||||
logger.warning(
|
||||
"[TELEGRAM] TELEGRAM_BOT_TOKEN absent — send_pre_race_alerts ignoré"
|
||||
)
|
||||
return {"sent": 0, "skipped": 0, "errors": 0}
|
||||
|
||||
stats = {"sent": 0, "skipped": 0, "errors": 0}
|
||||
|
||||
try:
|
||||
conn = _get_db()
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
# Récupère les courses du jour
|
||||
try:
|
||||
courses_rows = conn.execute(
|
||||
"""
|
||||
SELECT DISTINCT
|
||||
hippo, num_course, heure_depart, type_course
|
||||
FROM pmu_courses
|
||||
WHERE date_programme = ?
|
||||
AND heure_depart IS NOT NULL
|
||||
ORDER BY heure_depart ASC
|
||||
LIMIT 20
|
||||
""",
|
||||
(today,),
|
||||
).fetchall()
|
||||
except sqlite3.OperationalError as exc:
|
||||
logger.warning("[TELEGRAM] Table pmu_courses introuvable: %s", exc)
|
||||
conn.close()
|
||||
return stats
|
||||
|
||||
if not courses_rows:
|
||||
logger.info("[TELEGRAM] Aucune course aujourd'hui — pas d'alerte")
|
||||
conn.close()
|
||||
return stats
|
||||
|
||||
# Récupère les utilisateurs Premium/Pro avec chat_id configuré
|
||||
try:
|
||||
users = conn.execute(
|
||||
"""
|
||||
SELECT id, telegram_chat_id,
|
||||
alert_value_bets, alert_top1, alert_quinte_only
|
||||
FROM users
|
||||
WHERE plan IN ('premium', 'pro')
|
||||
AND is_active = 1
|
||||
AND telegram_chat_id IS NOT NULL
|
||||
AND telegram_chat_id != ''
|
||||
""",
|
||||
).fetchall()
|
||||
except sqlite3.OperationalError as exc:
|
||||
logger.warning(
|
||||
"[TELEGRAM] Colonnes Telegram absentes (migration non appliquée?): %s",
|
||||
exc,
|
||||
)
|
||||
conn.close()
|
||||
return stats
|
||||
|
||||
if not users:
|
||||
logger.info("[TELEGRAM] Aucun utilisateur avec chat_id configuré")
|
||||
conn.close()
|
||||
return stats
|
||||
|
||||
for course_row in courses_rows:
|
||||
hippo = course_row["hippo"] or "?"
|
||||
num_course = course_row["num_course"] or "?"
|
||||
heure_ts = course_row["heure_depart"]
|
||||
type_course = course_row["type_course"] or ""
|
||||
|
||||
try:
|
||||
dt = datetime.fromtimestamp(heure_ts / 1000)
|
||||
heure_str = dt.strftime("%H:%M")
|
||||
except Exception:
|
||||
heure_str = str(heure_ts)
|
||||
|
||||
race_data = {
|
||||
"hippo": hippo,
|
||||
"num_course": num_course,
|
||||
"heure": heure_str,
|
||||
"type_course": type_course,
|
||||
}
|
||||
|
||||
# Récupère les prédictions ML pour cette course
|
||||
predictions = []
|
||||
try:
|
||||
pred_rows = conn.execute(
|
||||
"""
|
||||
SELECT num_cheval, nom_cheval, prob_top3, is_value_bet, ml_score
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ?
|
||||
AND hippo = ?
|
||||
AND num_course = ?
|
||||
ORDER BY prob_top3 DESC
|
||||
LIMIT 10
|
||||
""",
|
||||
(today, hippo, num_course),
|
||||
).fetchall()
|
||||
predictions = [dict(r) for r in pred_rows]
|
||||
except sqlite3.OperationalError:
|
||||
pass # table absente, on envoie quand même avec données minimales
|
||||
|
||||
is_quinte = (
|
||||
"quinté" in type_course.lower() or "quinte" in type_course.lower()
|
||||
)
|
||||
|
||||
for user in users:
|
||||
chat_id = user["telegram_chat_id"]
|
||||
alert_quinte_only = bool(user["alert_quinte_only"])
|
||||
alert_top1 = bool(user["alert_top1"])
|
||||
alert_value_bets = bool(user["alert_value_bets"])
|
||||
|
||||
# Filtre quinte_only
|
||||
if alert_quinte_only and not is_quinte:
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
|
||||
# Construit le message selon préférences
|
||||
filtered_preds = []
|
||||
if predictions:
|
||||
for p in predictions:
|
||||
include = False
|
||||
if alert_top1 and p.get("prob_top3", 0) > 0:
|
||||
include = True
|
||||
if alert_value_bets and p.get("is_value_bet"):
|
||||
include = True
|
||||
if include:
|
||||
filtered_preds.append(p)
|
||||
|
||||
text = build_race_alert(race_data, filtered_preds)
|
||||
ok = send_telegram_message(chat_id, text)
|
||||
if ok:
|
||||
stats["sent"] += 1
|
||||
else:
|
||||
stats["errors"] += 1
|
||||
|
||||
conn.close()
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("[TELEGRAM] Erreur inattendue dans send_pre_race_alerts: %s", exc)
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
stats["errors"] += 1
|
||||
|
||||
logger.info(
|
||||
"[TELEGRAM] Alertes pré-course: %d envoyées, %d ignorées, %d erreurs",
|
||||
stats["sent"],
|
||||
stats["skipped"],
|
||||
stats["errors"],
|
||||
)
|
||||
return stats
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
448
tests/beta_monitor.py
Normal file
448
tests/beta_monitor.py
Normal file
@@ -0,0 +1,448 @@
|
||||
"""
|
||||
Beta Monitoring — SaaS Turf Prédictions IA
|
||||
Sprint 8 — QA, Beta Fermee, Go/No-Go
|
||||
Ticket: HRT-34
|
||||
|
||||
Ce module :
|
||||
- Collecte les feedbacks beta via l'API in-app
|
||||
- Envoie des alertes Telegram en cas d'erreur détectée pendant la beta
|
||||
- Génère le rapport beta final (bugs, UX, NPS)
|
||||
|
||||
Usage :
|
||||
# Démarrer le monitoring beta
|
||||
python tests/beta_monitor.py --watch --interval 60
|
||||
|
||||
# Générer le rapport beta final
|
||||
python tests/beta_monitor.py --report
|
||||
|
||||
# Test d'envoi Telegram
|
||||
python tests/beta_monitor.py --test-telegram
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import sqlite3
|
||||
import requests
|
||||
import argparse
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
# ============================================================
|
||||
# Configuration
|
||||
# ============================================================
|
||||
|
||||
BASE_URL = os.environ.get("APP_URL", "http://localhost:8792")
|
||||
TELEGRAM_TOKEN = os.environ.get(
|
||||
"TELEGRAM_TOKEN", "8649773134:AAFqzZVtSHfPPFDadcte1B-1h23nZ8DmdYE"
|
||||
)
|
||||
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "") # À configurer
|
||||
|
||||
BETA_DB_PATH = os.environ.get("BETA_DB_PATH", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
REPORTS_DIR = Path("tests/reports")
|
||||
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Seuils d'alerte
|
||||
ERROR_RATE_THRESHOLD = 0.01 # 1% d'erreurs → alerte
|
||||
LATENCY_P95_THRESHOLD_MS = 500 # p95 > 500ms → alerte
|
||||
BETA_MIN_USERS = 10 # Minimum d'utilisateurs beta requis
|
||||
NPS_TARGET = 7.0 # NPS cible (sur 10)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Alertes Telegram
|
||||
# ============================================================
|
||||
|
||||
|
||||
def send_telegram(message: str, parse_mode: str = "Markdown") -> bool:
|
||||
"""Envoie un message Telegram d'alerte."""
|
||||
if not TELEGRAM_TOKEN or not TELEGRAM_CHAT_ID:
|
||||
print(f"⚠️ Telegram non configuré. Message: {message[:100]}")
|
||||
return False
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage",
|
||||
json={
|
||||
"chat_id": TELEGRAM_CHAT_ID,
|
||||
"text": message,
|
||||
"parse_mode": parse_mode,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
print(f"✅ Alerte Telegram envoyée")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ Telegram erreur: {resp.status_code} — {resp.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Telegram exception: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def alert_error(endpoint: str, status_code: int, message: str):
|
||||
"""Alerte Telegram sur erreur critique."""
|
||||
text = (
|
||||
f"🚨 *ALERTE BETA — SaaS Turf IA*\n\n"
|
||||
f"Erreur détectée sur `{endpoint}`\n"
|
||||
f"Status: `{status_code}`\n"
|
||||
f"Message: {message[:200]}\n"
|
||||
f"Heure: {datetime.now().strftime('%H:%M:%S')}\n\n"
|
||||
f"_Ticket: HRT-34_"
|
||||
)
|
||||
send_telegram(text)
|
||||
|
||||
|
||||
def alert_performance(p95_ms: float, error_rate: float):
|
||||
"""Alerte Telegram sur dégradation de performance."""
|
||||
text = (
|
||||
f"⚠️ *ALERTE PERFORMANCE — SaaS Turf IA*\n\n"
|
||||
f"p95 latence: `{p95_ms:.0f}ms` (seuil: {LATENCY_P95_THRESHOLD_MS}ms)\n"
|
||||
f"Error rate: `{error_rate * 100:.2f}%` (seuil: {ERROR_RATE_THRESHOLD * 100:.1f}%)\n"
|
||||
f"Heure: {datetime.now().strftime('%H:%M:%S')}\n\n"
|
||||
f"_Ticket: HRT-34_"
|
||||
)
|
||||
send_telegram(text)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Collecte de métriques
|
||||
# ============================================================
|
||||
|
||||
|
||||
class BetaMonitor:
|
||||
"""Moniteur actif pendant la beta fermée."""
|
||||
|
||||
ENDPOINTS_TO_CHECK = [
|
||||
"/api",
|
||||
"/api/races",
|
||||
"/api/scoring",
|
||||
"/dashboard",
|
||||
"/",
|
||||
]
|
||||
|
||||
def __init__(self, base_url: str = BASE_URL):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.errors: list[dict] = []
|
||||
self.latencies: list[float] = []
|
||||
self.check_count = 0
|
||||
|
||||
def check_endpoint(self, path: str) -> dict:
|
||||
"""Vérifie un endpoint et retourne le résultat."""
|
||||
start = time.time()
|
||||
try:
|
||||
resp = requests.get(f"{self.base_url}{path}", timeout=10)
|
||||
latency_ms = (time.time() - start) * 1000
|
||||
return {
|
||||
"path": path,
|
||||
"status": resp.status_code,
|
||||
"latency_ms": latency_ms,
|
||||
"ok": resp.status_code < 500,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
return {
|
||||
"path": path,
|
||||
"status": 0,
|
||||
"latency_ms": 0,
|
||||
"ok": False,
|
||||
"error": str(e),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"path": path,
|
||||
"status": 0,
|
||||
"latency_ms": 0,
|
||||
"ok": False,
|
||||
"error": str(e),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
def run_checks(self) -> dict:
|
||||
"""Exécute tous les checks et retourne un résumé."""
|
||||
results = [self.check_endpoint(p) for p in self.ENDPOINTS_TO_CHECK]
|
||||
self.check_count += 1
|
||||
|
||||
failures = [r for r in results if not r["ok"]]
|
||||
latencies = [r["latency_ms"] for r in results if r["latency_ms"] > 0]
|
||||
|
||||
p95 = (
|
||||
sorted(latencies)[int(len(latencies) * 0.95)]
|
||||
if len(latencies) >= 2
|
||||
else (latencies[0] if latencies else 0)
|
||||
)
|
||||
error_rate = len(failures) / len(results) if results else 0
|
||||
|
||||
# Stocker pour rapport
|
||||
self.latencies.extend(latencies)
|
||||
self.errors.extend(failures)
|
||||
|
||||
return {
|
||||
"check_number": self.check_count,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"total_checks": len(results),
|
||||
"failures": len(failures),
|
||||
"error_rate": error_rate,
|
||||
"p95_ms": p95,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
def watch(self, interval_seconds: int = 60):
|
||||
"""Surveillance continue avec alertes Telegram."""
|
||||
print(f"🔍 Beta monitoring démarré — {self.base_url}")
|
||||
print(f" Intervalle: {interval_seconds}s")
|
||||
print(f" Endpoints: {len(self.ENDPOINTS_TO_CHECK)}")
|
||||
print(f" Ctrl+C pour arrêter\n")
|
||||
|
||||
consecutive_errors = 0
|
||||
|
||||
try:
|
||||
while True:
|
||||
summary = self.run_checks()
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
status_icon = "✅" if summary["error_rate"] == 0 else "❌"
|
||||
print(
|
||||
f"[{timestamp}] {status_icon} "
|
||||
f"Check #{summary['check_number']} — "
|
||||
f"p95={summary['p95_ms']:.0f}ms, "
|
||||
f"errors={summary['failures']}/{summary['total_checks']}"
|
||||
)
|
||||
|
||||
# Alertes
|
||||
if summary["error_rate"] > ERROR_RATE_THRESHOLD:
|
||||
consecutive_errors += 1
|
||||
if consecutive_errors >= 2: # 2 checks consécutifs en erreur
|
||||
for failure in summary["results"]:
|
||||
if not failure["ok"]:
|
||||
alert_error(
|
||||
failure["path"],
|
||||
failure.get("status", 0),
|
||||
failure.get("error", "Non-2xx response"),
|
||||
)
|
||||
else:
|
||||
consecutive_errors = 0
|
||||
|
||||
if summary["p95_ms"] > LATENCY_P95_THRESHOLD_MS:
|
||||
print(f"⚠️ Latence p95 élevée: {summary['p95_ms']:.0f}ms")
|
||||
if summary["p95_ms"] > LATENCY_P95_THRESHOLD_MS * 2:
|
||||
alert_performance(summary["p95_ms"], summary["error_rate"])
|
||||
|
||||
# Sauvegarder les résultats
|
||||
log_file = REPORTS_DIR / "beta_monitor_log.jsonl"
|
||||
with open(log_file, "a") as f:
|
||||
f.write(json.dumps(summary) + "\n")
|
||||
|
||||
time.sleep(interval_seconds)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n⏹️ Monitoring arrêté après {self.check_count} checks")
|
||||
self.generate_report()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Rapport beta final
|
||||
# ============================================================
|
||||
|
||||
|
||||
class BetaReport:
|
||||
"""Générateur de rapport beta fermée."""
|
||||
|
||||
def __init__(self, base_url: str = BASE_URL):
|
||||
self.base_url = base_url
|
||||
self.timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
def collect_feedback_from_db(self) -> list[dict]:
|
||||
"""Collecte les feedbacks depuis la BDD (table beta_feedback si elle existe)."""
|
||||
try:
|
||||
conn = sqlite3.connect(BETA_DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='beta_feedback'"
|
||||
)
|
||||
if not c.fetchone():
|
||||
conn.close()
|
||||
return []
|
||||
c.execute("SELECT * FROM beta_feedback ORDER BY created_at DESC")
|
||||
rows = c.fetchall()
|
||||
conn.close()
|
||||
return [dict(zip([col[0] for col in c.description], row)) for row in rows]
|
||||
except Exception as e:
|
||||
print(f"⚠️ Impossible de lire beta_feedback: {e}")
|
||||
return []
|
||||
|
||||
def collect_monitor_logs(self) -> list[dict]:
|
||||
"""Lit les logs du monitoring beta."""
|
||||
log_file = REPORTS_DIR / "beta_monitor_log.jsonl"
|
||||
if not log_file.exists():
|
||||
return []
|
||||
entries = []
|
||||
with open(log_file) as f:
|
||||
for line in f:
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except Exception:
|
||||
pass
|
||||
return entries
|
||||
|
||||
def generate(self) -> str:
|
||||
"""Génère le rapport complet et le sauvegarde."""
|
||||
feedbacks = self.collect_feedback_from_db()
|
||||
monitor_logs = self.collect_monitor_logs()
|
||||
|
||||
# Calculer NPS depuis les feedbacks
|
||||
nps_scores = [
|
||||
f.get("nps_score") for f in feedbacks if f.get("nps_score") is not None
|
||||
]
|
||||
avg_nps = sum(nps_scores) / len(nps_scores) if nps_scores else None
|
||||
|
||||
# Statistiques monitoring
|
||||
if monitor_logs:
|
||||
all_latencies = []
|
||||
total_errors = 0
|
||||
total_checks = 0
|
||||
for entry in monitor_logs:
|
||||
all_latencies.extend(
|
||||
[
|
||||
r["latency_ms"]
|
||||
for r in entry.get("results", [])
|
||||
if r.get("latency_ms", 0) > 0
|
||||
]
|
||||
)
|
||||
total_errors += entry.get("failures", 0)
|
||||
total_checks += entry.get("total_checks", 0)
|
||||
avg_latency = (
|
||||
sum(all_latencies) / len(all_latencies) if all_latencies else 0
|
||||
)
|
||||
overall_error_rate = total_errors / total_checks if total_checks > 0 else 0
|
||||
else:
|
||||
avg_latency = 0
|
||||
overall_error_rate = 0
|
||||
total_checks = 0
|
||||
|
||||
# Construire le rapport
|
||||
report = []
|
||||
report.append("=" * 60)
|
||||
report.append("RAPPORT BETA FERMÉE — SaaS Turf Prédictions IA")
|
||||
report.append(f"Généré le : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
report.append(f"Ticket : HRT-34")
|
||||
report.append("=" * 60)
|
||||
report.append("")
|
||||
report.append("## 1. PARTICIPANTS BETA")
|
||||
report.append(f" Feedbacks reçus : {len(feedbacks)}")
|
||||
report.append(
|
||||
f" NPS moyen : {avg_nps:.1f}/10"
|
||||
if avg_nps
|
||||
else " NPS moyen : (en attente feedbacks)"
|
||||
)
|
||||
report.append(f" Cible NPS : ≥ {NPS_TARGET}/10")
|
||||
nps_ok = avg_nps is not None and avg_nps >= NPS_TARGET
|
||||
report.append(
|
||||
f" Statut NPS : {'✅ OBJECTIF ATTEINT' if nps_ok else '⏳ En attente' if avg_nps is None else '❌ OBJECTIF NON ATTEINT'}"
|
||||
)
|
||||
report.append("")
|
||||
report.append("## 2. BUGS SIGNALÉS")
|
||||
bugs = [f for f in feedbacks if f.get("type") == "bug"]
|
||||
critical_bugs = [b for b in bugs if b.get("severity") in ("critical", "high")]
|
||||
report.append(f" Total bugs : {len(bugs)}")
|
||||
report.append(f" Critiques/High : {len(critical_bugs)}")
|
||||
report.append(
|
||||
f" Statut : {'✅ 0 bug critique' if len(critical_bugs) == 0 else f'❌ {len(critical_bugs)} bug(s) critique(s)'}"
|
||||
)
|
||||
report.append("")
|
||||
report.append("## 3. PERFORMANCE RÉELLE (monitoring)")
|
||||
report.append(f" Checks effectués: {total_checks}")
|
||||
report.append(f" Latence moyenne : {avg_latency:.1f}ms")
|
||||
report.append(f" Error rate : {overall_error_rate * 100:.2f}%")
|
||||
report.append(f" Seuil latence : {LATENCY_P95_THRESHOLD_MS}ms")
|
||||
perf_ok = (
|
||||
avg_latency < LATENCY_P95_THRESHOLD_MS
|
||||
and overall_error_rate < ERROR_RATE_THRESHOLD
|
||||
)
|
||||
report.append(
|
||||
f" Statut : {'✅ OBJECTIF ATTEINT' if perf_ok else '⏳ Données insuffisantes' if total_checks == 0 else '❌ OBJECTIF NON ATTEINT'}"
|
||||
)
|
||||
report.append("")
|
||||
report.append("## 4. FEEDBACKS UX")
|
||||
ux_feedbacks = [f for f in feedbacks if f.get("type") == "ux"]
|
||||
report.append(f" Retours UX : {len(ux_feedbacks)}")
|
||||
if ux_feedbacks:
|
||||
for fb in ux_feedbacks[:5]: # Top 5
|
||||
report.append(f" - {fb.get('comment', '')[:100]}")
|
||||
report.append("")
|
||||
report.append("## 5. VERDICT BETA FERMÉE")
|
||||
users_ok = len(feedbacks) >= 5 # Au moins 5 feedbacks = 5 users satisfaits
|
||||
verdict = all([users_ok, nps_ok, len(critical_bugs) == 0])
|
||||
report.append(
|
||||
f" Participants suffisants (≥5) : {'✅' if users_ok else '❌'}"
|
||||
)
|
||||
report.append(f" NPS ≥ 7/10 : {'✅' if nps_ok else '❌'}")
|
||||
report.append(
|
||||
f" 0 bug critique : {'✅' if len(critical_bugs) == 0 else '❌'}"
|
||||
)
|
||||
report.append("")
|
||||
report.append(
|
||||
f" VERDICT GLOBAL : {'✅ GO — Beta réussie' if verdict else '❌ NO-GO — Conditions non remplies'}"
|
||||
)
|
||||
report.append("=" * 60)
|
||||
|
||||
report_text = "\n".join(report)
|
||||
|
||||
# Sauvegarder
|
||||
report_file = REPORTS_DIR / f"beta_report_{self.timestamp}.txt"
|
||||
with open(report_file, "w") as f:
|
||||
f.write(report_text)
|
||||
|
||||
print(report_text)
|
||||
print(f"\nRapport sauvegardé : {report_file}")
|
||||
|
||||
return report_text
|
||||
|
||||
|
||||
# ============================================================
|
||||
# CLI
|
||||
# ============================================================
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Beta Monitor — SaaS Turf IA")
|
||||
parser.add_argument("--watch", action="store_true", help="Surveillance continue")
|
||||
parser.add_argument(
|
||||
"--interval", type=int, default=60, help="Intervalle en secondes (défaut: 60)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--report", action="store_true", help="Générer le rapport beta final"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--test-telegram", action="store_true", help="Tester l'envoi Telegram"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--url", default=BASE_URL, help=f"URL de l'app (défaut: {BASE_URL})"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.test_telegram:
|
||||
print("Test d'envoi Telegram...")
|
||||
ok = send_telegram(
|
||||
"✅ *Test alerte Beta* — SaaS Turf IA\n_Ceci est un test du système d'alertes QA_\nTicket: HRT-34"
|
||||
)
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
if args.report:
|
||||
reporter = BetaReport(args.url)
|
||||
reporter.generate()
|
||||
sys.exit(0)
|
||||
|
||||
if args.watch:
|
||||
monitor = BetaMonitor(args.url)
|
||||
monitor.watch(interval_seconds=args.interval)
|
||||
sys.exit(0)
|
||||
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
124
tests/conftest.py
Normal file
124
tests/conftest.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
conftest.py — Configuration pytest globale
|
||||
SaaS Turf Prédictions IA — Sprint 8 QA
|
||||
Ticket: HRT-34
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# ============================================================
|
||||
# Répertoires de sortie
|
||||
# ============================================================
|
||||
|
||||
REPORTS_DIR = Path("tests/reports")
|
||||
SCREENSHOTS_DIR = Path("tests/screenshots")
|
||||
|
||||
for d in [REPORTS_DIR, SCREENSHOTS_DIR]:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Variables d'environnement
|
||||
# ============================================================
|
||||
|
||||
BASE_URL = os.environ.get("APP_URL", "http://localhost:8792")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Fixtures globales
|
||||
# ============================================================
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def base_url():
|
||||
return BASE_URL
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
"""Event loop partagé pour les tests async de la session."""
|
||||
policy = asyncio.get_event_loop_policy()
|
||||
loop = policy.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def reports_dir():
|
||||
return REPORTS_DIR
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def screenshots_dir():
|
||||
return SCREENSHOTS_DIR
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Hook : screenshot automatique sur échec
|
||||
# ============================================================
|
||||
|
||||
|
||||
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
|
||||
def pytest_runtest_makereport(item, call):
|
||||
"""Capture screenshot automatiquement sur tout test E2E en échec."""
|
||||
outcome = yield
|
||||
report = outcome.get_result()
|
||||
|
||||
if report.when == "call" and report.failed:
|
||||
# Récupérer la page Playwright si disponible dans les fixtures
|
||||
page = None
|
||||
for fixture_name in ("page", "context_page"):
|
||||
if fixture_name in item.funcargs:
|
||||
val = item.funcargs[fixture_name]
|
||||
if isinstance(val, tuple):
|
||||
page = val[0] # (page, browser_name)
|
||||
else:
|
||||
page = val
|
||||
break
|
||||
|
||||
if page is not None:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
test_name = item.name.replace("/", "_").replace(":", "_")
|
||||
screenshot_path = SCREENSHOTS_DIR / f"FAIL_{test_name}_{timestamp}.png"
|
||||
try:
|
||||
# Playwright page.screenshot est synchrone dans les fixtures sync
|
||||
# Pour les fixtures async, on force la capture
|
||||
import asyncio as _asyncio
|
||||
|
||||
if _asyncio.iscoroutinefunction(page.screenshot):
|
||||
loop = _asyncio.get_event_loop()
|
||||
loop.run_until_complete(page.screenshot(path=str(screenshot_path)))
|
||||
else:
|
||||
page.screenshot(path=str(screenshot_path))
|
||||
report.sections.append(
|
||||
("Screenshot", f"Sauvegardé : {screenshot_path}")
|
||||
)
|
||||
except Exception as e:
|
||||
report.sections.append(
|
||||
("Screenshot Error", f"Impossible de capturer : {e}")
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Marqueurs personnalisés
|
||||
# ============================================================
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line("markers", "e2e: Tests End-to-End Playwright")
|
||||
config.addinivalue_line("markers", "load: Tests de charge Locust")
|
||||
config.addinivalue_line("markers", "security: Tests de sécurité")
|
||||
config.addinivalue_line(
|
||||
"markers", "smoke: Tests rapides de smoke (sans infra complète)"
|
||||
)
|
||||
config.addinivalue_line("markers", "beta: Tests spécifiques beta fermée")
|
||||
config.addinivalue_line(
|
||||
"markers", "requires_billing: Nécessite HRT-31 (Billing Stripe)"
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers", "requires_infra: Nécessite HRT-33 (infra staging)"
|
||||
)
|
||||
@@ -141,7 +141,7 @@ class TestJWTAuthentication:
|
||||
"invalid_signature_here"
|
||||
)
|
||||
resp = requests.get(
|
||||
f"{BASE_URL}/api/races",
|
||||
f"{BASE_URL}/api/v1/predictions/today",
|
||||
headers={"Authorization": f"Bearer {expired_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
@@ -153,7 +153,7 @@ class TestJWTAuthentication:
|
||||
"""Un token JWT malformé doit être rejeté."""
|
||||
for bad_token in ["not.a.jwt", "Bearer", "null", "undefined", ""]:
|
||||
resp = requests.get(
|
||||
f"{BASE_URL}/api/races",
|
||||
f"{BASE_URL}/api/v1/predictions/today",
|
||||
headers={"Authorization": f"Bearer {bad_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
@@ -163,7 +163,7 @@ class TestJWTAuthentication:
|
||||
|
||||
def test_jwt_sans_token(self):
|
||||
"""Sans token, les routes protégées doivent retourner 401."""
|
||||
resp = requests.get(f"{BASE_URL}/api/export/csv", timeout=5)
|
||||
resp = requests.get(f"{BASE_URL}/api/v1/export/csv", timeout=5)
|
||||
assert resp.status_code in (401, 403), (
|
||||
f"Route protégée accessible sans token: status={resp.status_code}"
|
||||
)
|
||||
@@ -303,6 +303,138 @@ class TestPlanAuthorisation:
|
||||
)
|
||||
|
||||
|
||||
# === Tests validation mots de passe faibles (HRT-63) ===
|
||||
|
||||
|
||||
class TestWeakPasswordRejection:
|
||||
"""Tests rejet mots de passe faibles : blacklist + complexité (HRT-63)."""
|
||||
|
||||
REGISTER_URL = (
|
||||
os.environ.get("APP_URL", "http://localhost:8792") + "/api/v1/auth/register"
|
||||
)
|
||||
|
||||
WEAK_PASSWORDS = [
|
||||
"password",
|
||||
"12345678",
|
||||
"qwerty123",
|
||||
"letmein1",
|
||||
"admin123",
|
||||
"welcome1",
|
||||
"iloveyou",
|
||||
"abc1234",
|
||||
"sunshine",
|
||||
"111111111",
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("weak_pwd", WEAK_PASSWORDS)
|
||||
def test_weak_password_rejected(self, weak_pwd):
|
||||
"""Les mots de passe faibles/blacklistés doivent retourner 400."""
|
||||
import time as _time
|
||||
|
||||
unique_email = f"test_weak_{int(_time.time() * 1000)}_{weak_pwd[:4]}@h3r7.tech"
|
||||
resp = requests.post(
|
||||
self.REGISTER_URL,
|
||||
json={"email": unique_email, "password": weak_pwd, "plan": "free"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == 400, (
|
||||
f"Mot de passe faible accepté: pwd={weak_pwd!r}, status={resp.status_code}"
|
||||
)
|
||||
body = resp.json()
|
||||
assert "error" in body, f"Pas de champ 'error' dans la réponse: {body}"
|
||||
|
||||
def test_strong_password_accepted(self):
|
||||
"""Un mot de passe fort doit permettre l'inscription (retourne 201)."""
|
||||
import time as _time
|
||||
|
||||
unique_email = f"test_strong_{int(_time.time() * 1000)}@h3r7.tech"
|
||||
resp = requests.post(
|
||||
self.REGISTER_URL,
|
||||
json={"email": unique_email, "password": "Tr0ub4d@ur!", "plan": "free"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == 201, (
|
||||
f"Mot de passe fort rejeté: status={resp.status_code}, body={resp.text}"
|
||||
)
|
||||
data = resp.json()
|
||||
assert "token" in data, f"Pas de token dans la réponse: {data}"
|
||||
|
||||
def test_no_digit_rejected(self):
|
||||
"""Un mot de passe sans chiffre doit être rejeté."""
|
||||
import time as _time
|
||||
|
||||
unique_email = f"test_nodigit_{int(_time.time() * 1000)}@h3r7.tech"
|
||||
resp = requests.post(
|
||||
self.REGISTER_URL,
|
||||
json={"email": unique_email, "password": "NoDigitPassword", "plan": "free"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == 400, (
|
||||
f"Mot de passe sans chiffre accepté: status={resp.status_code}"
|
||||
)
|
||||
|
||||
def test_no_letter_rejected(self):
|
||||
"""Un mot de passe sans lettre doit être rejeté."""
|
||||
import time as _time
|
||||
|
||||
unique_email = f"test_noletter_{int(_time.time() * 1000)}@h3r7.tech"
|
||||
resp = requests.post(
|
||||
self.REGISTER_URL,
|
||||
json={"email": unique_email, "password": "12345678901", "plan": "free"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == 400, (
|
||||
f"Mot de passe sans lettre accepté: status={resp.status_code}"
|
||||
)
|
||||
# === Tests rate limiting login ===
|
||||
|
||||
|
||||
class TestLoginRateLimit:
|
||||
"""Tests rate limiting sur /api/v1/auth/login."""
|
||||
|
||||
TARGET_URL = (
|
||||
os.environ.get("APP_URL", "http://localhost:8792") + "/api/v1/auth/login"
|
||||
)
|
||||
|
||||
def test_login_brute_force_blocked_after_5_attempts(self):
|
||||
"""Après 5 tentatives, le 6ème appel doit retourner 429."""
|
||||
# Utiliser un email unique pour isoler le test
|
||||
email = f"ratelimit_test_{int(time.time())}@h3r7.tech"
|
||||
for i in range(5):
|
||||
resp = requests.post(
|
||||
self.TARGET_URL,
|
||||
json={"email": email, "password": "wrong_password"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code in (400, 401), (
|
||||
f"Tentative {i + 1}: status inattendu {resp.status_code}"
|
||||
)
|
||||
# La 6ème tentative doit être bloquée
|
||||
resp = requests.post(
|
||||
self.TARGET_URL,
|
||||
json={"email": email, "password": "wrong_password"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == 429, (
|
||||
f"Rate limit non appliqué après 5 tentatives: got {resp.status_code}"
|
||||
)
|
||||
assert "Retry-After" in resp.headers, "Header Retry-After manquant sur 429"
|
||||
|
||||
def test_login_429_has_retry_after_header(self):
|
||||
"""La réponse 429 doit inclure Retry-After."""
|
||||
email = f"ratelimit_test2_{int(time.time())}@h3r7.tech"
|
||||
for _ in range(6):
|
||||
requests.post(
|
||||
self.TARGET_URL, json={"email": email, "password": "x"}, timeout=5
|
||||
)
|
||||
resp = requests.post(
|
||||
self.TARGET_URL, json={"email": email, "password": "x"}, timeout=5
|
||||
)
|
||||
if resp.status_code == 429:
|
||||
assert "Retry-After" in resp.headers
|
||||
assert int(resp.headers["Retry-After"]) > 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import subprocess
|
||||
|
||||
|
||||
407
tests/test_history.py
Normal file
407
tests/test_history.py
Normal file
@@ -0,0 +1,407 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for GET /api/v1/history — HRT-81
|
||||
Historique limité/illimité selon plan (Free/Premium/Pro)
|
||||
|
||||
Run with:
|
||||
cd /home/h3r7/turf_saas
|
||||
source venv/bin/activate
|
||||
python -m pytest tests/test_history.py -v
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# Use an isolated temp DB for these tests
|
||||
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
_tmp_db.close()
|
||||
os.environ["TURF_SAAS_DB"] = _tmp_db.name
|
||||
os.environ["JWT_SECRET_KEY"] = "test-history-secret-key"
|
||||
|
||||
from app_v1 import create_app
|
||||
from auth_db import init_auth_tables
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
TODAY = datetime.now().date()
|
||||
|
||||
|
||||
def days_ago(n: int) -> str:
|
||||
return (TODAY - timedelta(days=n)).isoformat()
|
||||
|
||||
|
||||
def auth_header(token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Fixtures
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
application = create_app()
|
||||
application.config["TESTING"] = True
|
||||
application.config["JWT_SECRET_KEY"] = "test-history-secret-key"
|
||||
return application
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def seeded_db():
|
||||
"""
|
||||
Seed the test DB:
|
||||
- Create ml_predictions_cache with rows spanning 120 days back
|
||||
- Create users for free/premium/pro plans
|
||||
"""
|
||||
db_path = os.environ["TURF_SAAS_DB"]
|
||||
conn = sqlite3.connect(db_path)
|
||||
|
||||
# Create ml_predictions_cache table if absent
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS ml_predictions_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
horse_name TEXT,
|
||||
prob_top1 REAL,
|
||||
prob_top3 REAL,
|
||||
ml_score REAL,
|
||||
race_label TEXT,
|
||||
hippodrome TEXT,
|
||||
heure TEXT,
|
||||
is_value_bet INTEGER DEFAULT 0
|
||||
)
|
||||
""")
|
||||
|
||||
# Seed rows at: 1, 6, 7, 8, 30, 89, 90, 91, 100 days ago
|
||||
offsets = [1, 6, 7, 8, 30, 89, 90, 91, 100]
|
||||
for offset in offsets:
|
||||
d = days_ago(offset)
|
||||
conn.execute(
|
||||
"""INSERT INTO ml_predictions_cache
|
||||
(date, horse_name, prob_top1, prob_top3, ml_score, race_label, hippodrome, heure, is_value_bet)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(d, f"Cheval_{offset}j", 0.5, 0.8, 0.75, f"R1C1", "PARIS", "14:00", 0),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return db_path
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def auth_tokens(client, seeded_db):
|
||||
"""Register/login users for each plan and return their JWT tokens."""
|
||||
plans = {
|
||||
"free": "hist_free@test.com",
|
||||
"premium": "hist_premium@test.com",
|
||||
"pro": "hist_pro@test.com",
|
||||
}
|
||||
password = "password123"
|
||||
|
||||
for plan, email in plans.items():
|
||||
r = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": password},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert r.status_code in (201, 409), f"register failed for {plan}: {r.data}"
|
||||
|
||||
# Set plan via direct DB
|
||||
db_path = os.environ["TURF_SAAS_DB"]
|
||||
conn = sqlite3.connect(db_path)
|
||||
for plan, email in plans.items():
|
||||
conn.execute("UPDATE users SET plan = ? WHERE email = ?", (plan, email))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
tokens = {}
|
||||
for plan, email in plans.items():
|
||||
r = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": email, "password": password},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert r.status_code == 200, f"login failed for {plan}: {r.data}"
|
||||
tokens[plan] = r.get_json()["access_token"]
|
||||
|
||||
return tokens
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Auth guard
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHistoryAuth:
|
||||
def test_requires_auth(self, client):
|
||||
"""Unauthenticated request must return 401."""
|
||||
r = client.get("/api/v1/history")
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_invalid_token_returns_401(self, client):
|
||||
r = client.get(
|
||||
"/api/v1/history",
|
||||
headers={"Authorization": "Bearer this.is.not.valid"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Free plan — 7-day window
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHistoryFreePlan:
|
||||
def test_free_can_access_last_7_days(self, client, auth_tokens, seeded_db):
|
||||
"""Free user: start = today-6 (within 7-day window) must return 200."""
|
||||
start = days_ago(6)
|
||||
r = client.get(
|
||||
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
|
||||
headers=auth_header(auth_tokens["free"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["plan"] == "free"
|
||||
assert data["history_limit_days"] == 7
|
||||
|
||||
def test_free_blocked_beyond_7_days(self, client, auth_tokens, seeded_db):
|
||||
"""Free user: start = today-8 must return 403 (beyond 7-day window)."""
|
||||
start = days_ago(8)
|
||||
r = client.get(
|
||||
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
|
||||
headers=auth_header(auth_tokens["free"]),
|
||||
)
|
||||
assert r.status_code == 403
|
||||
data = r.get_json()
|
||||
assert data["code"] == 403
|
||||
assert (
|
||||
"upgrade" in data.get("message", "").lower()
|
||||
or "plan" in data.get("message", "").lower()
|
||||
)
|
||||
|
||||
def test_free_default_request_returns_200(self, client, auth_tokens, seeded_db):
|
||||
"""Free user: no dates specified — should use defaults and return 200."""
|
||||
r = client.get(
|
||||
"/api/v1/history",
|
||||
headers=auth_header(auth_tokens["free"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
assert data["status"] == "ok"
|
||||
assert "history" in data
|
||||
assert "pagination" in data
|
||||
|
||||
def test_free_upgrade_hint_in_403(self, client, auth_tokens, seeded_db):
|
||||
"""403 response must contain required_plans and upgrade_url."""
|
||||
start = days_ago(30)
|
||||
r = client.get(
|
||||
f"/api/v1/history?start={start}",
|
||||
headers=auth_header(auth_tokens["free"]),
|
||||
)
|
||||
assert r.status_code == 403
|
||||
data = r.get_json()
|
||||
assert "required_plans" in data
|
||||
assert "upgrade_url" in data
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Premium plan — 90-day window
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHistoryPremiumPlan:
|
||||
def test_premium_can_access_within_90_days(self, client, auth_tokens, seeded_db):
|
||||
"""Premium user: start = today-89 must return 200."""
|
||||
start = days_ago(89)
|
||||
r = client.get(
|
||||
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
|
||||
headers=auth_header(auth_tokens["premium"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["plan"] == "premium"
|
||||
assert data["history_limit_days"] == 90
|
||||
|
||||
def test_premium_blocked_beyond_90_days(self, client, auth_tokens, seeded_db):
|
||||
"""Premium user: start = today-91 must return 403."""
|
||||
start = days_ago(91)
|
||||
r = client.get(
|
||||
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
|
||||
headers=auth_header(auth_tokens["premium"]),
|
||||
)
|
||||
assert r.status_code == 403
|
||||
data = r.get_json()
|
||||
assert data["code"] == 403
|
||||
assert "required_plans" in data
|
||||
# Premium upgrade hint should suggest pro
|
||||
assert "pro" in data.get("required_plans", [])
|
||||
|
||||
def test_premium_can_access_last_7_days(self, client, auth_tokens, seeded_db):
|
||||
"""Premium user can always access the free window too."""
|
||||
start = days_ago(6)
|
||||
r = client.get(
|
||||
f"/api/v1/history?start={start}",
|
||||
headers=auth_header(auth_tokens["premium"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Pro plan — unlimited
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHistoryProPlan:
|
||||
def test_pro_can_access_old_data(self, client, auth_tokens, seeded_db):
|
||||
"""Pro user: start = today-100 must return 200 (unlimited)."""
|
||||
start = days_ago(100)
|
||||
r = client.get(
|
||||
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
|
||||
headers=auth_header(auth_tokens["pro"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["plan"] == "pro"
|
||||
assert data["history_limit_days"] is None # unlimited
|
||||
|
||||
def test_pro_default_request_returns_200(self, client, auth_tokens, seeded_db):
|
||||
r = client.get(
|
||||
"/api/v1/history",
|
||||
headers=auth_header(auth_tokens["pro"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_pro_can_see_all_seeded_rows(self, client, auth_tokens, seeded_db):
|
||||
"""Pro fetching entire seeded range (100 days) should get all inserted rows."""
|
||||
start = days_ago(100)
|
||||
end = TODAY.isoformat()
|
||||
r = client.get(
|
||||
f"/api/v1/history?start={start}&end={end}&limit=500",
|
||||
headers=auth_header(auth_tokens["pro"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
# All 9 seeded rows should be present
|
||||
assert data["pagination"]["total"] == 9
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Input validation
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHistoryValidation:
|
||||
def test_invalid_start_format(self, client, auth_tokens):
|
||||
r = client.get(
|
||||
"/api/v1/history?start=31-12-2025",
|
||||
headers=auth_header(auth_tokens["pro"]),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
data = r.get_json()
|
||||
assert data["code"] == 400
|
||||
assert "start" in data["message"].lower()
|
||||
|
||||
def test_invalid_end_format(self, client, auth_tokens):
|
||||
r = client.get(
|
||||
"/api/v1/history?end=2025/12/31",
|
||||
headers=auth_header(auth_tokens["pro"]),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
data = r.get_json()
|
||||
assert "end" in data["message"].lower()
|
||||
|
||||
def test_start_after_end_returns_400(self, client, auth_tokens):
|
||||
r = client.get(
|
||||
f"/api/v1/history?start={TODAY.isoformat()}&end={days_ago(5)}",
|
||||
headers=auth_header(auth_tokens["pro"]),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_pagination_limit_respected(self, client, auth_tokens, seeded_db):
|
||||
start = days_ago(100)
|
||||
r = client.get(
|
||||
f"/api/v1/history?start={start}&limit=3&offset=0",
|
||||
headers=auth_header(auth_tokens["pro"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
assert len(data["history"]) <= 3
|
||||
assert data["pagination"]["limit"] == 3
|
||||
|
||||
def test_pagination_has_more(self, client, auth_tokens, seeded_db):
|
||||
"""has_more should be True when more rows exist beyond current page."""
|
||||
start = days_ago(100)
|
||||
r = client.get(
|
||||
f"/api/v1/history?start={start}&limit=3&offset=0",
|
||||
headers=auth_header(auth_tokens["pro"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
# 9 total rows seeded, limit=3 → has_more=True
|
||||
assert data["pagination"]["has_more"] is True
|
||||
|
||||
def test_response_shape(self, client, auth_tokens, seeded_db):
|
||||
"""Verify the full response envelope shape."""
|
||||
r = client.get(
|
||||
"/api/v1/history",
|
||||
headers=auth_header(auth_tokens["pro"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
assert "status" in data
|
||||
assert "plan" in data
|
||||
assert "history_limit_days" in data
|
||||
assert "start" in data
|
||||
assert "end" in data
|
||||
assert "history" in data
|
||||
assert "pagination" in data
|
||||
pagination = data["pagination"]
|
||||
assert "total" in pagination
|
||||
assert "limit" in pagination
|
||||
assert "offset" in pagination
|
||||
assert "has_more" in pagination
|
||||
|
||||
def test_history_row_fields(self, client, auth_tokens, seeded_db):
|
||||
"""Each history row must contain the expected ML fields."""
|
||||
start = days_ago(10)
|
||||
r = client.get(
|
||||
f"/api/v1/history?start={start}&limit=5",
|
||||
headers=auth_header(auth_tokens["pro"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
if data["history"]:
|
||||
row = data["history"][0]
|
||||
expected_fields = {
|
||||
"id",
|
||||
"date",
|
||||
"horse_name",
|
||||
"prob_top1",
|
||||
"prob_top3",
|
||||
"ml_score",
|
||||
"race_label",
|
||||
"hippodrome",
|
||||
"heure",
|
||||
"is_value_bet",
|
||||
}
|
||||
assert expected_fields.issubset(set(row.keys()))
|
||||
300
tests/test_ml_cache_integrity.py
Normal file
300
tests/test_ml_cache_integrity.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""
|
||||
test_ml_cache_integrity.py — Test d'intégration : zéro NULL dans ml_predictions_cache
|
||||
SaaS Turf Prédictions IA
|
||||
Ticket: HRT-43 (suite au fix HRT-41 — métadonnées manquantes dans le cache ML)
|
||||
|
||||
Ces tests vérifient que la table ml_predictions_cache ne contient aucune ligne
|
||||
avec des métadonnées NULL (hippodrome, race_label, heure) pour la date courante,
|
||||
après le job ML de 19h30.
|
||||
|
||||
Usage:
|
||||
pytest tests/test_ml_cache_integrity.py -v -m integration
|
||||
pytest tests/test_ml_cache_integrity.py -v -m integration --date 2026-04-26
|
||||
|
||||
Variables d'environnement:
|
||||
TURF_DB_PATH : chemin vers turf.db (défaut: /home/h3r7/turf_scraper/turf.db)
|
||||
TEST_DATE : date cible au format YYYY-MM-DD (défaut: date du jour)
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
import pytest
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Configuration
|
||||
# ============================================================
|
||||
|
||||
DEFAULT_DB_PATH = "/home/h3r7/turf_scraper/turf.db"
|
||||
DB_PATH = os.environ.get("TURF_DB_PATH", DEFAULT_DB_PATH)
|
||||
|
||||
|
||||
def _get_test_date() -> str:
|
||||
"""Retourne la date cible pour les tests (env TEST_DATE ou date du jour)."""
|
||||
env_date = os.environ.get("TEST_DATE", "")
|
||||
if env_date:
|
||||
try:
|
||||
datetime.strptime(env_date, "%Y-%m-%d")
|
||||
return env_date
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"TEST_DATE invalide : '{env_date}'. Format attendu : YYYY-MM-DD"
|
||||
)
|
||||
return date.today().isoformat()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Fixture : connexion DB en lecture seule
|
||||
# ============================================================
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def db_connection():
|
||||
"""
|
||||
Connexion SQLite en mode lecture seule (uri=True + ?mode=ro).
|
||||
Garantit qu'aucune modification accidentelle de la DB de prod n'est possible.
|
||||
"""
|
||||
db_path = Path(DB_PATH)
|
||||
if not db_path.exists():
|
||||
pytest.skip(
|
||||
f"Base de données introuvable : {DB_PATH}. "
|
||||
"Définir TURF_DB_PATH ou vérifier le chemin."
|
||||
)
|
||||
|
||||
uri = f"file:{db_path.as_posix()}?mode=ro"
|
||||
conn = sqlite3.connect(uri, uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
yield conn
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def target_date():
|
||||
"""Date cible pour les tests (date du jour ou TEST_DATE)."""
|
||||
return _get_test_date()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Tests d'intégration
|
||||
# ============================================================
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestMlCacheNullIntegrity:
|
||||
"""
|
||||
Vérifie qu'après le job ML de 19h30, la table ml_predictions_cache
|
||||
ne contient aucune métadonnée NULL pour la date courante.
|
||||
|
||||
Régression testée : HRT-41 (Fix #17 — métadonnées manquantes dans le cache ML)
|
||||
"""
|
||||
|
||||
def test_table_exists(self, db_connection):
|
||||
"""Vérifie que la table ml_predictions_cache existe dans la DB."""
|
||||
cursor = db_connection.execute(
|
||||
"SELECT name FROM sqlite_master "
|
||||
"WHERE type='table' AND name='ml_predictions_cache'"
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
assert row is not None, (
|
||||
"La table ml_predictions_cache est introuvable dans la base de données. "
|
||||
"Vérifier que le job ML a bien créé la table."
|
||||
)
|
||||
|
||||
def test_rows_exist_for_today(self, db_connection, target_date):
|
||||
"""
|
||||
Vérifie que des prédictions existent pour la date cible.
|
||||
|
||||
Ce test passe en skip si aucune ligne n'existe (ex: avant le job 19h30).
|
||||
Il échoue uniquement si le job a manifestement tourné mais a laissé 0 lignes.
|
||||
"""
|
||||
cursor = db_connection.execute(
|
||||
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
||||
(target_date,),
|
||||
)
|
||||
count = cursor.fetchone()["cnt"]
|
||||
|
||||
if count == 0:
|
||||
pytest.skip(
|
||||
f"Aucune prédiction en cache pour le {target_date}. "
|
||||
"Ce test doit être exécuté après le job ML de 19h30."
|
||||
)
|
||||
|
||||
def test_zero_null_hippodrome_today(self, db_connection, target_date):
|
||||
"""
|
||||
CRITÈRE D'ACCEPTATION PRINCIPAL (HRT-43) :
|
||||
Vérifie que COUNT(*) WHERE date = today AND hippodrome IS NULL = 0.
|
||||
|
||||
Régression directe du bug HRT-41 : le champ hippodrome était NULL
|
||||
pour toutes les prédictions du cache ML.
|
||||
"""
|
||||
# Vérifier si des données existent avant de tester les NULLs
|
||||
cursor_total = db_connection.execute(
|
||||
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
||||
(target_date,),
|
||||
)
|
||||
total = cursor_total.fetchone()["cnt"]
|
||||
if total == 0:
|
||||
pytest.skip(
|
||||
f"Aucune prédiction en cache pour le {target_date}. "
|
||||
"Lancer ce test après le job ML de 19h30."
|
||||
)
|
||||
|
||||
cursor = db_connection.execute(
|
||||
"SELECT COUNT(*) as cnt FROM ml_predictions_cache "
|
||||
"WHERE date = ? AND hippodrome IS NULL",
|
||||
(target_date,),
|
||||
)
|
||||
null_count = cursor.fetchone()["cnt"]
|
||||
|
||||
assert null_count == 0, (
|
||||
f"RÉGRESSION HRT-41 DÉTECTÉE : {null_count} ligne(s) avec hippodrome IS NULL "
|
||||
f"dans ml_predictions_cache pour le {target_date}. "
|
||||
"Le patch de métadonnées n'a pas été appliqué correctement."
|
||||
)
|
||||
|
||||
def test_zero_null_race_label_today(self, db_connection, target_date):
|
||||
"""
|
||||
Vérifie que COUNT(*) WHERE date = today AND race_label IS NULL = 0.
|
||||
|
||||
Complément du test hippodrome : vérifie que le libellé de course
|
||||
est bien renseigné pour toutes les prédictions.
|
||||
"""
|
||||
cursor_total = db_connection.execute(
|
||||
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
||||
(target_date,),
|
||||
)
|
||||
total = cursor_total.fetchone()["cnt"]
|
||||
if total == 0:
|
||||
pytest.skip(
|
||||
f"Aucune prédiction en cache pour le {target_date}. "
|
||||
"Lancer ce test après le job ML de 19h30."
|
||||
)
|
||||
|
||||
cursor = db_connection.execute(
|
||||
"SELECT COUNT(*) as cnt FROM ml_predictions_cache "
|
||||
"WHERE date = ? AND race_label IS NULL",
|
||||
(target_date,),
|
||||
)
|
||||
null_count = cursor.fetchone()["cnt"]
|
||||
|
||||
assert null_count == 0, (
|
||||
f"ANOMALIE : {null_count} ligne(s) avec race_label IS NULL "
|
||||
f"dans ml_predictions_cache pour le {target_date}. "
|
||||
"Vérifier le pipeline de patch de métadonnées."
|
||||
)
|
||||
|
||||
def test_zero_null_heure_today(self, db_connection, target_date):
|
||||
"""
|
||||
Vérifie que COUNT(*) WHERE date = today AND heure IS NULL = 0.
|
||||
|
||||
Vérifie que l'heure de course est bien renseignée pour toutes les prédictions.
|
||||
"""
|
||||
cursor_total = db_connection.execute(
|
||||
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
||||
(target_date,),
|
||||
)
|
||||
total = cursor_total.fetchone()["cnt"]
|
||||
if total == 0:
|
||||
pytest.skip(
|
||||
f"Aucune prédiction en cache pour le {target_date}. "
|
||||
"Lancer ce test après le job ML de 19h30."
|
||||
)
|
||||
|
||||
cursor = db_connection.execute(
|
||||
"SELECT COUNT(*) as cnt FROM ml_predictions_cache "
|
||||
"WHERE date = ? AND heure IS NULL",
|
||||
(target_date,),
|
||||
)
|
||||
null_count = cursor.fetchone()["cnt"]
|
||||
|
||||
assert null_count == 0, (
|
||||
f"ANOMALIE : {null_count} ligne(s) avec heure IS NULL "
|
||||
f"dans ml_predictions_cache pour le {target_date}. "
|
||||
"Vérifier le pipeline de patch de métadonnées."
|
||||
)
|
||||
|
||||
def test_full_metadata_coverage_today(self, db_connection, target_date):
|
||||
"""
|
||||
Test de couverture globale : aucune des trois colonnes critiques
|
||||
(hippodrome, race_label, heure) n'est NULL pour une même ligne.
|
||||
|
||||
Retourne les 5 premières lignes problématiques pour faciliter le débogage.
|
||||
"""
|
||||
cursor_total = db_connection.execute(
|
||||
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
||||
(target_date,),
|
||||
)
|
||||
total = cursor_total.fetchone()["cnt"]
|
||||
if total == 0:
|
||||
pytest.skip(
|
||||
f"Aucune prédiction en cache pour le {target_date}. "
|
||||
"Lancer ce test après le job ML de 19h30."
|
||||
)
|
||||
|
||||
cursor = db_connection.execute(
|
||||
"SELECT id, num_reunion, num_course, horse_name, hippodrome, race_label, heure "
|
||||
"FROM ml_predictions_cache "
|
||||
"WHERE date = ? "
|
||||
"AND (hippodrome IS NULL OR race_label IS NULL OR heure IS NULL) "
|
||||
"LIMIT 5",
|
||||
(target_date,),
|
||||
)
|
||||
bad_rows = cursor.fetchall()
|
||||
|
||||
assert len(bad_rows) == 0, (
|
||||
f"ANOMALIE : {len(bad_rows)} ligne(s) avec au moins une métadonnée NULL "
|
||||
f"(hippodrome, race_label ou heure) pour le {target_date}.\n"
|
||||
"Exemples de lignes affectées :\n"
|
||||
+ "\n".join(
|
||||
f" - id={r['id']} R{r['num_reunion']}C{r['num_course']} "
|
||||
f"{r['horse_name']} | hippodrome={r['hippodrome']!r} "
|
||||
f"race_label={r['race_label']!r} heure={r['heure']!r}"
|
||||
for r in bad_rows
|
||||
)
|
||||
)
|
||||
|
||||
def test_metadata_completeness_summary(self, db_connection, target_date):
|
||||
"""
|
||||
Résumé diagnostique : affiche les statistiques de complétude des métadonnées
|
||||
pour la date cible. Toujours en mode informatif (pas de assertion stricte),
|
||||
utile pour le monitoring et les logs CI.
|
||||
"""
|
||||
cursor = db_connection.execute(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN hippodrome IS NULL THEN 1 ELSE 0 END) as null_hippodrome,
|
||||
SUM(CASE WHEN race_label IS NULL THEN 1 ELSE 0 END) as null_race_label,
|
||||
SUM(CASE WHEN heure IS NULL THEN 1 ELSE 0 END) as null_heure,
|
||||
COUNT(DISTINCT hippodrome) as distinct_hippodromes,
|
||||
COUNT(DISTINCT race_label) as distinct_race_labels
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ?
|
||||
""",
|
||||
(target_date,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
total = row["total"]
|
||||
|
||||
if total == 0:
|
||||
pytest.skip(
|
||||
f"Aucune prédiction en cache pour le {target_date}. "
|
||||
"Lancer ce test après le job ML de 19h30."
|
||||
)
|
||||
|
||||
# Afficher les statistiques (visibles avec pytest -v -s)
|
||||
print(f"\n=== Statistiques ml_predictions_cache pour le {target_date} ===")
|
||||
print(f" Total lignes : {total}")
|
||||
print(f" NULL hippodrome : {row['null_hippodrome']}")
|
||||
print(f" NULL race_label : {row['null_race_label']}")
|
||||
print(f" NULL heure : {row['null_heure']}")
|
||||
print(f" Hippodromes distincts: {row['distinct_hippodromes']}")
|
||||
print(f" Race labels distincts: {row['distinct_race_labels']}")
|
||||
|
||||
# L'assertion ici reste stricte pour hippodrome (bug HRT-41 critique)
|
||||
assert row["null_hippodrome"] == 0, (
|
||||
f"RÉGRESSION HRT-41 : {row['null_hippodrome']}/{total} lignes "
|
||||
f"avec hippodrome IS NULL pour le {target_date}."
|
||||
)
|
||||
333
tests/test_ml_ensemble.py
Normal file
333
tests/test_ml_ensemble.py
Normal file
@@ -0,0 +1,333 @@
|
||||
"""
|
||||
Tests ML Ensemble — HRT-32 Sprint 6-7
|
||||
Tests de régression, benchmark et latence pour le nouveau modèle ensemble.
|
||||
|
||||
Usage:
|
||||
pytest tests/test_ml_ensemble.py -v
|
||||
pytest tests/test_ml_ensemble.py -v -m regression
|
||||
pytest tests/test_ml_ensemble.py -v -m latency
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pickle
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
BASE_URL = os.environ.get("APP_URL", "http://localhost:8790")
|
||||
DB_PATH = os.environ.get("DB_PATH", "/home/h3r7/turf_saas/turf.db")
|
||||
MODELS_DIR = Path("/home/h3r7/turf_saas/models")
|
||||
ENSEMBLE_PATH = MODELS_DIR / "ensemble_top3.pkl"
|
||||
BENCHMARK_PATH = MODELS_DIR / "benchmark_report.json"
|
||||
|
||||
|
||||
# ─── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def ensemble_model():
|
||||
"""Load ensemble model (skip tests if not yet trained)."""
|
||||
if not ENSEMBLE_PATH.exists():
|
||||
pytest.skip(
|
||||
f"Ensemble model not found at {ENSEMBLE_PATH}. Run train_ensemble.py first."
|
||||
)
|
||||
with open(ENSEMBLE_PATH, "rb") as f:
|
||||
return pickle.load(f)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def benchmark_report():
|
||||
"""Load benchmark report (skip if not generated)."""
|
||||
if not BENCHMARK_PATH.exists():
|
||||
pytest.skip(f"Benchmark report not found at {BENCHMARK_PATH}.")
|
||||
with open(BENCHMARK_PATH) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def holdout_data():
|
||||
"""Load holdout slice (last 20% temporal) for regression tests."""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
df = pd.read_sql_query(
|
||||
"""
|
||||
SELECT p.*, c.distance, c.discipline, c.specialite,
|
||||
c.nb_declares_partants, c.montant_prix, c.penetrometre_intitule
|
||||
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.ordre_arrivee > 0
|
||||
ORDER BY p.date_programme, p.num_reunion, p.num_course, p.num_pmu
|
||||
""",
|
||||
conn,
|
||||
)
|
||||
conn.close()
|
||||
n = len(df)
|
||||
cutoff = int(n * 0.80)
|
||||
return df.iloc[cutoff:].copy()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def predict_v2():
|
||||
"""Import predict_v2 module."""
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"predict_v2", "/home/h3r7/turf_saas/predict_v2.py"
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
# ─── Model Existence Tests ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestModelFiles:
|
||||
"""Verify all expected model files exist."""
|
||||
|
||||
def test_ensemble_model_exists(self):
|
||||
assert ENSEMBLE_PATH.exists(), f"Ensemble model missing: {ENSEMBLE_PATH}"
|
||||
|
||||
def test_benchmark_report_exists(self):
|
||||
assert BENCHMARK_PATH.exists(), f"Benchmark report missing: {BENCHMARK_PATH}"
|
||||
|
||||
def test_models_dir_contains_expected_files(self):
|
||||
expected = ["ensemble_top3.pkl", "benchmark_report.json", "benchmark_report.md"]
|
||||
for fname in expected:
|
||||
assert (MODELS_DIR / fname).exists(), f"Missing: {MODELS_DIR / fname}"
|
||||
|
||||
|
||||
# ─── Benchmark Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBenchmark:
|
||||
"""Validate benchmark metrics from the training report."""
|
||||
|
||||
@pytest.mark.regression
|
||||
def test_ensemble_beats_baseline_or_meets_threshold(self, benchmark_report):
|
||||
"""Ensemble Precision@3 must be >= baseline XGBoost."""
|
||||
baseline = benchmark_report["baseline"]["precision_at3"]
|
||||
ensemble = benchmark_report["ensemble"]["precision_at3"]
|
||||
assert ensemble >= baseline, (
|
||||
f"Ensemble Precision@3 {ensemble:.4f} < baseline {baseline:.4f}"
|
||||
)
|
||||
|
||||
@pytest.mark.regression
|
||||
def test_ensemble_auc_above_random(self, benchmark_report):
|
||||
"""Ensemble AUC must be > 0.60 (significantly above random 0.50)."""
|
||||
auc = benchmark_report["ensemble"]["auc"]
|
||||
assert auc > 0.60, f"Ensemble AUC {auc:.4f} <= 0.60"
|
||||
|
||||
@pytest.mark.regression
|
||||
def test_optuna_ran_minimum_trials(self, benchmark_report):
|
||||
"""Optuna must have run at least 100 trials per model."""
|
||||
n_trials = benchmark_report["optuna"]["n_trials"]
|
||||
assert n_trials >= 100, f"Only {n_trials} Optuna trials (minimum 100 required)"
|
||||
|
||||
@pytest.mark.regression
|
||||
def test_no_precision_regression(self, benchmark_report):
|
||||
"""Ensemble Precision@3 must not be below naive random baseline (~30%)."""
|
||||
ensemble_p3 = benchmark_report["ensemble"]["precision_at3"]
|
||||
assert ensemble_p3 >= 0.30, (
|
||||
f"Precision@3 {ensemble_p3:.4f} is below random baseline (~0.30)"
|
||||
)
|
||||
|
||||
def test_benchmark_has_all_required_models(self, benchmark_report):
|
||||
"""Benchmark must include results for all 3 models."""
|
||||
required = {"xgboost", "lightgbm", "mlp"}
|
||||
found = set(benchmark_report.get("individual_models", {}).keys())
|
||||
missing = required - found
|
||||
assert not missing, f"Missing model benchmarks: {missing}"
|
||||
|
||||
|
||||
# ─── Regression Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPrecisionRegression:
|
||||
"""Holdout regression: ensure precision doesn't degrade."""
|
||||
|
||||
@pytest.mark.regression
|
||||
def test_precision_at3_on_holdout(self, ensemble_model, holdout_data):
|
||||
"""Precision@3 on holdout must be above naive baseline."""
|
||||
from predict_v2 import build_feature_df, FEATURE_COLS
|
||||
|
||||
df = holdout_data.copy()
|
||||
df["top3"] = (df["ordre_arrivee"] <= 3).astype(int)
|
||||
|
||||
partants = df.to_dict("records")
|
||||
feature_df = build_feature_df(partants)
|
||||
available = [c for c in FEATURE_COLS if c in feature_df.columns]
|
||||
X = feature_df[available].fillna(0)
|
||||
|
||||
proba = ensemble_model.predict_proba(X)[:, 1]
|
||||
|
||||
# Per-race Precision@3
|
||||
tmp = df[["date_programme", "num_reunion", "num_course"]].copy()
|
||||
tmp["proba"] = proba
|
||||
tmp["actual"] = df["top3"].values
|
||||
|
||||
precisions = []
|
||||
for _, group in tmp.groupby(["date_programme", "num_reunion", "num_course"]):
|
||||
if len(group) >= 3:
|
||||
top3_pred = group.nlargest(3, "proba")
|
||||
precisions.append(top3_pred["actual"].sum() / 3.0)
|
||||
|
||||
p_at3 = float(np.mean(precisions)) if precisions else 0.0
|
||||
print(f"\n Holdout Precision@3: {p_at3:.4f} over {len(precisions)} races")
|
||||
|
||||
# Must beat random baseline (30%)
|
||||
assert p_at3 >= 0.30, f"Holdout Precision@3 {p_at3:.4f} < 0.30"
|
||||
|
||||
@pytest.mark.regression
|
||||
def test_no_all_zero_predictions(self, ensemble_model, holdout_data):
|
||||
"""Ensemble must not predict 0 probability for all horses."""
|
||||
from predict_v2 import build_feature_df, FEATURE_COLS
|
||||
|
||||
partants = holdout_data.head(50).to_dict("records")
|
||||
feature_df = build_feature_df(partants)
|
||||
available = [c for c in FEATURE_COLS if c in feature_df.columns]
|
||||
X = feature_df[available].fillna(0)
|
||||
|
||||
proba = ensemble_model.predict_proba(X)[:, 1]
|
||||
assert proba.max() > 0.01, "All predictions are near 0 — model appears broken"
|
||||
assert proba.std() > 0.01, (
|
||||
"All predictions have identical probability — no discrimination"
|
||||
)
|
||||
|
||||
|
||||
# ─── Latency Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPredictionLatency:
|
||||
"""Prediction latency must be < 200ms per race."""
|
||||
|
||||
@pytest.mark.latency
|
||||
def test_single_race_latency(self, ensemble_model, holdout_data):
|
||||
"""Prediction for a single race (<=20 horses) must be < 200ms."""
|
||||
from predict_v2 import build_feature_df, FEATURE_COLS
|
||||
|
||||
# Take one race
|
||||
first_race = (
|
||||
holdout_data.groupby(["date_programme", "num_reunion", "num_course"])
|
||||
.first()
|
||||
.reset_index()
|
||||
.iloc[0]
|
||||
)
|
||||
mask = (
|
||||
(holdout_data["date_programme"] == first_race["date_programme"])
|
||||
& (holdout_data["num_reunion"] == first_race["num_reunion"])
|
||||
& (holdout_data["num_course"] == first_race["num_course"])
|
||||
)
|
||||
race_df = holdout_data[mask]
|
||||
partants = race_df.to_dict("records")
|
||||
|
||||
# Warm-up
|
||||
feature_df = build_feature_df(partants)
|
||||
available = [c for c in FEATURE_COLS if c in feature_df.columns]
|
||||
X = feature_df[available].fillna(0)
|
||||
ensemble_model.predict_proba(X)
|
||||
|
||||
# Timed run
|
||||
t0 = time.perf_counter()
|
||||
for _ in range(10):
|
||||
ensemble_model.predict_proba(X)
|
||||
elapsed_ms = (time.perf_counter() - t0) / 10 * 1000
|
||||
|
||||
print(f"\n Single-race latency: {elapsed_ms:.2f} ms ({len(partants)} horses)")
|
||||
assert elapsed_ms < 200, (
|
||||
f"Prediction latency {elapsed_ms:.1f} ms exceeds 200 ms limit"
|
||||
)
|
||||
|
||||
@pytest.mark.latency
|
||||
def test_full_day_latency(self, ensemble_model, holdout_data):
|
||||
"""Prediction for a full day (all races) must complete < 5 seconds."""
|
||||
from predict_v2 import build_feature_df, FEATURE_COLS
|
||||
|
||||
# Take one day
|
||||
day = holdout_data["date_programme"].iloc[0]
|
||||
day_df = holdout_data[holdout_data["date_programme"] == day]
|
||||
partants = day_df.to_dict("records")
|
||||
|
||||
feature_df = build_feature_df(partants)
|
||||
available = [c for c in FEATURE_COLS if c in feature_df.columns]
|
||||
X = feature_df[available].fillna(0)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
proba = ensemble_model.predict_proba(X)
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
|
||||
print(
|
||||
f"\n Full day latency: {elapsed_ms:.2f} ms ({len(partants)} horses, {day})"
|
||||
)
|
||||
assert elapsed_ms < 5000, (
|
||||
f"Full-day prediction {elapsed_ms:.0f} ms exceeds 5s limit"
|
||||
)
|
||||
|
||||
|
||||
# ─── API Endpoint Tests ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestV1PredictionsAPI:
|
||||
"""Tests for the new /api/v1/predictions endpoint."""
|
||||
|
||||
def _api_available(self):
|
||||
try:
|
||||
requests.get(f"{BASE_URL}/api/v1/model/status", timeout=3)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@pytest.mark.api
|
||||
def test_model_status_endpoint(self):
|
||||
"""GET /api/v1/model/status returns valid JSON."""
|
||||
if not self._api_available():
|
||||
pytest.skip("API server not running")
|
||||
resp = requests.get(f"{BASE_URL}/api/v1/model/status", timeout=10)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "ensemble_available" in data
|
||||
|
||||
@pytest.mark.api
|
||||
def test_v1_predictions_no_500(self):
|
||||
"""GET /api/v1/predictions must not return 5xx."""
|
||||
if not self._api_available():
|
||||
pytest.skip("API server not running")
|
||||
resp = requests.get(f"{BASE_URL}/api/v1/predictions", timeout=30)
|
||||
assert resp.status_code < 500, (
|
||||
f"Server error: {resp.status_code}\n{resp.text[:200]}"
|
||||
)
|
||||
|
||||
@pytest.mark.api
|
||||
def test_v1_predictions_returns_json(self):
|
||||
"""GET /api/v1/predictions returns valid JSON with expected keys."""
|
||||
if not self._api_available():
|
||||
pytest.skip("API server not running")
|
||||
resp = requests.get(f"{BASE_URL}/api/v1/predictions", timeout=30)
|
||||
if resp.status_code == 503:
|
||||
pytest.skip("Ensemble model not yet deployed")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "model_version" in data, "Missing model_version in response"
|
||||
assert "races" in data or "predictions" in data, (
|
||||
"Missing races/predictions in response"
|
||||
)
|
||||
|
||||
@pytest.mark.api
|
||||
def test_v1_predictions_latency(self):
|
||||
"""GET /api/v1/predictions must respond in < 3 seconds."""
|
||||
if not self._api_available():
|
||||
pytest.skip("API server not running")
|
||||
resp = requests.get(f"{BASE_URL}/api/v1/predictions", timeout=30)
|
||||
if resp.status_code == 503:
|
||||
pytest.skip("Ensemble model not yet deployed")
|
||||
# Check API-reported latency
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
latency = data.get("latency_ms", 0)
|
||||
assert latency < 3000, f"API latency {latency:.0f} ms > 3000 ms"
|
||||
533
tests/test_org.py
Normal file
533
tests/test_org.py
Normal file
@@ -0,0 +1,533 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests — Multi-compte / Organisations Pro
|
||||
Sprint: HRT-82
|
||||
|
||||
Couvre :
|
||||
- Migration DB (tables organizations + org_members)
|
||||
- POST /api/v1/org
|
||||
- GET /api/v1/org
|
||||
- DELETE /api/v1/org
|
||||
- POST /api/v1/org/invite
|
||||
- GET /api/v1/org/members
|
||||
- DELETE /api/v1/org/members/<user_id>
|
||||
- Plan enforcement (plan != pro → 403)
|
||||
- Contraintes métier (1 org/owner, max 5 membres, doublons, etc.)
|
||||
|
||||
Run:
|
||||
./venv/bin/pytest tests/test_org.py -v --tb=short
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import secrets
|
||||
|
||||
import pytest
|
||||
|
||||
# ─── Isolated temp DB ────────────────────────────────────────────────────────
|
||||
|
||||
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
_tmp_db.close()
|
||||
os.environ["TURF_SAAS_DB"] = _tmp_db.name
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
# ─── App import (après configuration env) ────────────────────────────────────
|
||||
|
||||
import sqlite3
|
||||
from org_db import get_db, migrate_org_tables
|
||||
from saas_auth import get_db as auth_get_db, init_users_table, generate_token
|
||||
|
||||
|
||||
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _create_user(email: str, plan: str = "free") -> dict:
|
||||
"""Crée un utilisateur directement en DB et retourne son token + id."""
|
||||
init_users_table()
|
||||
uid = secrets.token_hex(16)
|
||||
pw_hash = "hashed"
|
||||
conn = auth_get_db()
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO saas_users (id, email, firstname, lastname, password_hash, plan) "
|
||||
"VALUES (?,?,?,?,?,?)",
|
||||
(uid, email, "Test", "User", pw_hash, plan),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
token = generate_token(uid)
|
||||
return {"id": uid, "email": email, "token": token, "plan": plan}
|
||||
|
||||
|
||||
def _auth_header(token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# ─── Flask app fixture ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
"""Crée l'app Flask avec les blueprints org enregistrés."""
|
||||
from flask import Flask
|
||||
from flask_cors import CORS
|
||||
from saas_auth import auth_bp
|
||||
from api_v1.routes.org import org_bp
|
||||
|
||||
application = Flask(__name__)
|
||||
CORS(application)
|
||||
application.config["TESTING"] = True
|
||||
|
||||
# S'assurer que la migration a tourné
|
||||
migrate_org_tables()
|
||||
|
||||
application.register_blueprint(auth_bp)
|
||||
application.register_blueprint(org_bp)
|
||||
|
||||
yield application
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
# ─── Users fixtures ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pro_owner(app):
|
||||
"""Un utilisateur Pro qui va créer une org."""
|
||||
with app.app_context():
|
||||
return _create_user("owner_pro@test.com", plan="pro")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pro_user2(app):
|
||||
"""Un 2e utilisateur Pro à inviter."""
|
||||
with app.app_context():
|
||||
return _create_user("member2_pro@test.com", plan="pro")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pro_user3(app):
|
||||
with app.app_context():
|
||||
return _create_user("member3_pro@test.com", plan="pro")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pro_user4(app):
|
||||
with app.app_context():
|
||||
return _create_user("member4_pro@test.com", plan="pro")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pro_user5(app):
|
||||
with app.app_context():
|
||||
return _create_user("member5_pro@test.com", plan="pro")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pro_user6(app):
|
||||
"""6e utilisateur pour tester la limite MAX_MEMBERS."""
|
||||
with app.app_context():
|
||||
return _create_user("member6_pro@test.com", plan="pro")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def free_user(app):
|
||||
with app.app_context():
|
||||
return _create_user("free_user@test.com", plan="free")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def other_pro_owner(app):
|
||||
"""Un 2e owner Pro (pour tester conflits inter-orgs)."""
|
||||
with app.app_context():
|
||||
return _create_user("other_owner@test.com", plan="pro")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Tests DB migration
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestOrgDbMigration:
|
||||
def test_tables_exist(self):
|
||||
"""Les tables organizations et org_members doivent exister."""
|
||||
conn = get_db()
|
||||
tables = {
|
||||
row[0]
|
||||
for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
}
|
||||
conn.close()
|
||||
assert "organizations" in tables, "Table organizations manquante"
|
||||
assert "org_members" in tables, "Table org_members manquante"
|
||||
|
||||
def test_migration_idempotent(self):
|
||||
"""Appeler migrate_org_tables() deux fois ne doit pas lever d'erreur."""
|
||||
migrate_org_tables() # 2e appel — doit être silencieux
|
||||
self.test_tables_exist()
|
||||
|
||||
def test_org_members_unique_constraint(self):
|
||||
"""UNIQUE(org_id, user_id) doit être présent."""
|
||||
conn = get_db()
|
||||
indexes = [row[1] for row in conn.execute("PRAGMA index_list(org_members)")]
|
||||
conn.close()
|
||||
# Il doit y avoir un index d'unicité
|
||||
assert (
|
||||
any(
|
||||
"unique" in idx.lower() or "org_members" in idx.lower()
|
||||
for idx in indexes
|
||||
)
|
||||
or True
|
||||
)
|
||||
# On vérifie via insertion en double
|
||||
conn = get_db()
|
||||
oid = "test_org_unique"
|
||||
uid = "test_uid_unique"
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO organizations (id, owner_id, name) VALUES (?,?,?)",
|
||||
(oid, uid, "TestOrg"),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) "
|
||||
"VALUES (?,?,'member',datetime('now'),datetime('now'))",
|
||||
(oid, uid),
|
||||
)
|
||||
conn.commit()
|
||||
# 2e insertion doit lever IntegrityError
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute(
|
||||
"INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) "
|
||||
"VALUES (?,?,'member',datetime('now'),datetime('now'))",
|
||||
(oid, uid),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.execute("DELETE FROM org_members WHERE org_id=?", (oid,))
|
||||
conn.execute("DELETE FROM organizations WHERE id=?", (oid,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Tests plan enforcement
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestPlanEnforcement:
|
||||
def test_create_org_free_plan_403(self, client, free_user):
|
||||
"""Un utilisateur free ne peut pas créer une org."""
|
||||
resp = client.post(
|
||||
"/api/v1/org",
|
||||
json={"name": "FreePlanOrg"},
|
||||
headers=_auth_header(free_user["token"]),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
data = resp.get_json()
|
||||
assert data["required"] == "pro"
|
||||
|
||||
def test_get_org_free_plan_403(self, client, free_user):
|
||||
resp = client.get("/api/v1/org", headers=_auth_header(free_user["token"]))
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_invite_free_plan_403(self, client, free_user):
|
||||
resp = client.post(
|
||||
"/api/v1/org/invite",
|
||||
json={"email": "someone@test.com"},
|
||||
headers=_auth_header(free_user["token"]),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_members_free_plan_403(self, client, free_user):
|
||||
resp = client.get(
|
||||
"/api/v1/org/members", headers=_auth_header(free_user["token"])
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_no_token_401(self, client):
|
||||
resp = client.get("/api/v1/org")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Tests création d'organisation
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestCreateOrg:
|
||||
def test_create_org_success(self, client, pro_owner):
|
||||
"""Un Pro peut créer une organisation."""
|
||||
resp = client.post(
|
||||
"/api/v1/org",
|
||||
json={"name": "H3R7 Racing Club"},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.get_json()
|
||||
assert "org" in data
|
||||
assert data["org"]["name"] == "H3R7 Racing Club"
|
||||
assert data["org"]["owner_id"] == pro_owner["id"]
|
||||
assert data["org"]["max_members"] == 5
|
||||
|
||||
def test_create_org_duplicate_409(self, client, pro_owner):
|
||||
"""Un Pro ne peut pas créer 2 organisations."""
|
||||
resp = client.post(
|
||||
"/api/v1/org",
|
||||
json={"name": "Second Org"},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
data = resp.get_json()
|
||||
assert "org_id" in data
|
||||
|
||||
def test_create_org_missing_name_400(self, client, pro_owner):
|
||||
"""Le nom est obligatoire."""
|
||||
resp = client.post(
|
||||
"/api/v1/org",
|
||||
json={},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_create_org_empty_name_400(self, client, pro_owner):
|
||||
resp = client.post(
|
||||
"/api/v1/org",
|
||||
json={"name": " "},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_create_org_name_too_long_400(self, client, pro_owner):
|
||||
resp = client.post(
|
||||
"/api/v1/org",
|
||||
json={"name": "x" * 101},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Tests lecture d'organisation
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestGetOrg:
|
||||
def test_get_org_as_owner(self, client, pro_owner):
|
||||
resp = client.get("/api/v1/org", headers=_auth_header(pro_owner["token"]))
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["org"]["owner_id"] == pro_owner["id"]
|
||||
assert data["org"]["member_count"] >= 1 # au moins l'owner
|
||||
|
||||
def test_get_org_not_found_404(self, client, other_pro_owner):
|
||||
"""Un Pro sans org reçoit 404 avant d'en créer une."""
|
||||
# other_pro_owner n'a pas encore d'org dans ce test
|
||||
resp = client.get("/api/v1/org", headers=_auth_header(other_pro_owner["token"]))
|
||||
# Peut être 404 ou 200 selon l'ordre d'exécution; on accepte les deux ici
|
||||
assert resp.status_code in (200, 404)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Tests invitation de membres
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestInviteMember:
|
||||
def test_invite_member_success(self, client, pro_owner, pro_user2):
|
||||
resp = client.post(
|
||||
"/api/v1/org/invite",
|
||||
json={"email": pro_user2["email"]},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.get_json()
|
||||
assert data["member"]["user_id"] == pro_user2["id"]
|
||||
assert data["member"]["role"] == "member"
|
||||
|
||||
def test_invite_member_duplicate_409(self, client, pro_owner, pro_user2):
|
||||
"""Inviter 2x le même utilisateur → 409."""
|
||||
resp = client.post(
|
||||
"/api/v1/org/invite",
|
||||
json={"email": pro_user2["email"]},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
def test_invite_unknown_email_404(self, client, pro_owner):
|
||||
resp = client.post(
|
||||
"/api/v1/org/invite",
|
||||
json={"email": "nobody@nowhere.com"},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_invite_invalid_email_400(self, client, pro_owner):
|
||||
resp = client.post(
|
||||
"/api/v1/org/invite",
|
||||
json={"email": "not-an-email"},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_invite_non_owner_403(self, client, pro_user2):
|
||||
"""Un simple membre ne peut pas inviter."""
|
||||
resp = client.post(
|
||||
"/api/v1/org/invite",
|
||||
json={"email": "anyone@test.com"},
|
||||
headers=_auth_header(pro_user2["token"]),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_invite_fill_to_max(
|
||||
self, client, pro_owner, pro_user3, pro_user4, pro_user5
|
||||
):
|
||||
"""Remplir jusqu'à 5 membres (owner + 4 invités)."""
|
||||
for u in (pro_user3, pro_user4, pro_user5):
|
||||
resp = client.post(
|
||||
"/api/v1/org/invite",
|
||||
json={"email": u["email"]},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 201, (
|
||||
f"Invitation de {u['email']} échouée: {resp.get_json()}"
|
||||
)
|
||||
|
||||
def test_invite_exceeds_max_403(self, client, pro_owner, pro_user6):
|
||||
"""Le 6e membre doit être refusé (max 5)."""
|
||||
resp = client.post(
|
||||
"/api/v1/org/invite",
|
||||
json={"email": pro_user6["email"]},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
data = resp.get_json()
|
||||
assert "Limite" in data["error"] or "limite" in data["error"].lower()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Tests liste des membres
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestListMembers:
|
||||
def test_list_members_as_owner(self, client, pro_owner):
|
||||
resp = client.get(
|
||||
"/api/v1/org/members", headers=_auth_header(pro_owner["token"])
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert "members" in data
|
||||
assert data["count"] == 5 # owner + 4 invités (pro_user2..5)
|
||||
assert data["max_members"] == 5
|
||||
|
||||
def test_list_members_as_member(self, client, pro_user2):
|
||||
"""Un membre peut aussi consulter la liste."""
|
||||
resp = client.get(
|
||||
"/api/v1/org/members", headers=_auth_header(pro_user2["token"])
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["count"] >= 1
|
||||
|
||||
def test_list_members_includes_email(self, client, pro_owner, pro_user2):
|
||||
resp = client.get(
|
||||
"/api/v1/org/members", headers=_auth_header(pro_owner["token"])
|
||||
)
|
||||
data = resp.get_json()
|
||||
emails = [m["email"] for m in data["members"]]
|
||||
assert pro_user2["email"] in emails
|
||||
|
||||
def test_list_members_no_org_404(self, client, pro_user6):
|
||||
"""Un Pro sans org reçoit 404."""
|
||||
resp = client.get(
|
||||
"/api/v1/org/members", headers=_auth_header(pro_user6["token"])
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Tests suppression de membre
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestRemoveMember:
|
||||
def test_remove_member_success(self, client, pro_owner, pro_user5):
|
||||
resp = client.delete(
|
||||
f"/api/v1/org/members/{pro_user5['id']}",
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["removed_user_id"] == pro_user5["id"]
|
||||
|
||||
def test_remove_self_as_owner_400(self, client, pro_owner):
|
||||
"""L'owner ne peut pas se retirer lui-même."""
|
||||
resp = client.delete(
|
||||
f"/api/v1/org/members/{pro_owner['id']}",
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_remove_nonexistent_member_404(self, client, pro_owner):
|
||||
resp = client.delete(
|
||||
"/api/v1/org/members/nonexistent-id-xyz",
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_remove_member_non_owner_403(self, client, pro_user2, pro_user3):
|
||||
"""Un simple membre ne peut pas retirer un autre membre."""
|
||||
resp = client.delete(
|
||||
f"/api/v1/org/members/{pro_user3['id']}",
|
||||
headers=_auth_header(pro_user2["token"]),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_can_invite_again_after_removal(self, client, pro_owner, pro_user5):
|
||||
"""Après retrait, on peut ré-inviter (slot libéré)."""
|
||||
resp = client.post(
|
||||
"/api/v1/org/invite",
|
||||
json={"email": pro_user5["email"]},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Tests suppression d'organisation
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestDeleteOrg:
|
||||
def test_delete_org_non_owner_403(self, client, pro_user2):
|
||||
"""Un simple membre ne peut pas supprimer l'org."""
|
||||
resp = client.delete("/api/v1/org", headers=_auth_header(pro_user2["token"]))
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_delete_org_success(self, client, pro_owner):
|
||||
"""L'owner peut supprimer l'organisation."""
|
||||
resp = client.delete("/api/v1/org", headers=_auth_header(pro_owner["token"]))
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
|
||||
def test_get_org_after_delete_404(self, client, pro_owner):
|
||||
"""Après suppression, GET /org renvoie 404."""
|
||||
resp = client.get("/api/v1/org", headers=_auth_header(pro_owner["token"]))
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_org_no_org_403(self, client, pro_owner):
|
||||
"""Supprimer une org qui n'existe plus → 403."""
|
||||
resp = client.delete("/api/v1/org", headers=_auth_header(pro_owner["token"]))
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_members_cascade_deleted(self, client, pro_user2):
|
||||
"""Après suppression de l'org, les membres ne trouvent plus d'org."""
|
||||
resp = client.get(
|
||||
"/api/v1/org/members", headers=_auth_header(pro_user2["token"])
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
205
tests/test_smoke.py
Normal file
205
tests/test_smoke.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
Tests de smoke — SaaS Turf Prédictions IA
|
||||
Sprint 8 — QA, Beta Fermee, Go/No-Go
|
||||
Ticket: HRT-34
|
||||
|
||||
Vérifications rapides sur l'état de l'application :
|
||||
- Routes de base accessibles
|
||||
- API répond en JSON valide
|
||||
- Base de données accessible
|
||||
- Pas d'erreurs 5xx sur les routes principales
|
||||
|
||||
Ces tests peuvent tourner SANS infra complète (pas besoin de HRT-31/33).
|
||||
Exécuter sur l'app actuelle en staging ou localhost.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
import os
|
||||
import json
|
||||
|
||||
BASE_URL = os.environ.get("APP_URL", "http://localhost:8792")
|
||||
|
||||
# Routes qui doivent retourner 200 (publiques)
|
||||
PUBLIC_ROUTES_200 = [
|
||||
"/",
|
||||
"/dashboard",
|
||||
]
|
||||
|
||||
# Routes API qui doivent retourner 200 ou 401 (jamais 500)
|
||||
API_ROUTES_NO_500 = [
|
||||
"/api",
|
||||
"/api/races",
|
||||
"/api/scoring",
|
||||
"/api/weather",
|
||||
"/api/odds_history",
|
||||
]
|
||||
|
||||
|
||||
class TestSmoke:
|
||||
"""Tests de smoke : l'app répond correctement aux requêtes de base."""
|
||||
|
||||
@pytest.mark.smoke
|
||||
@pytest.mark.parametrize("route", PUBLIC_ROUTES_200)
|
||||
def test_route_publique_accessible(self, route):
|
||||
"""Les routes publiques doivent retourner 200."""
|
||||
try:
|
||||
resp = requests.get(f"{BASE_URL}{route}", timeout=10)
|
||||
assert resp.status_code in (200, 304), (
|
||||
f"Route publique inaccessible: {route} → {resp.status_code}"
|
||||
)
|
||||
assert len(resp.content) > 0, f"Réponse vide sur {route}"
|
||||
except requests.exceptions.ConnectionError:
|
||||
pytest.skip(
|
||||
f"App non accessible sur {BASE_URL} — vérifier que le serveur est démarré"
|
||||
)
|
||||
|
||||
@pytest.mark.smoke
|
||||
@pytest.mark.parametrize("route", API_ROUTES_NO_500)
|
||||
def test_api_pas_derreur_serveur(self, route):
|
||||
"""Les routes API ne doivent jamais retourner 5xx."""
|
||||
try:
|
||||
resp = requests.get(f"{BASE_URL}{route}", timeout=10)
|
||||
assert resp.status_code < 500, (
|
||||
f"Erreur serveur sur {route}: {resp.status_code}\n{resp.text[:200]}"
|
||||
)
|
||||
except requests.exceptions.ConnectionError:
|
||||
pytest.skip(f"App non accessible sur {BASE_URL}")
|
||||
|
||||
@pytest.mark.smoke
|
||||
def test_api_today_retourne_json(self):
|
||||
"""L'endpoint principal /api doit retourner du JSON valide."""
|
||||
try:
|
||||
resp = requests.get(f"{BASE_URL}/api", timeout=10)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
assert data is not None, "Réponse JSON nulle"
|
||||
assert isinstance(data, (list, dict)), (
|
||||
f"Type de réponse inattendu: {type(data)}"
|
||||
)
|
||||
except requests.exceptions.ConnectionError:
|
||||
pytest.skip(f"App non accessible sur {BASE_URL}")
|
||||
except json.JSONDecodeError as e:
|
||||
pytest.fail(f"/api ne retourne pas du JSON valide: {e}")
|
||||
|
||||
@pytest.mark.smoke
|
||||
def test_contenu_html_portail_valide(self):
|
||||
"""Le portail doit contenir un titre et du contenu significatif."""
|
||||
try:
|
||||
resp = requests.get(f"{BASE_URL}/", timeout=10)
|
||||
if resp.status_code == 200:
|
||||
content = resp.text
|
||||
assert "<html" in content.lower() or "<!doctype" in content.lower(), (
|
||||
"La page d'accueil ne retourne pas du HTML"
|
||||
)
|
||||
assert len(content) > 500, (
|
||||
f"Page d'accueil trop courte ({len(content)} chars)"
|
||||
)
|
||||
except requests.exceptions.ConnectionError:
|
||||
pytest.skip(f"App non accessible sur {BASE_URL}")
|
||||
|
||||
@pytest.mark.smoke
|
||||
def test_headers_securite_presents(self):
|
||||
"""Les headers de sécurité de base doivent être présents."""
|
||||
try:
|
||||
resp = requests.get(f"{BASE_URL}/", timeout=10)
|
||||
if resp.status_code != 200:
|
||||
return
|
||||
|
||||
# En production (derrière Nginx), ces headers doivent être présents
|
||||
# En dev direct Flask, ils peuvent être absents — on note seulement
|
||||
security_headers = {
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": None, # SAMEORIGIN ou DENY
|
||||
"X-XSS-Protection": None,
|
||||
}
|
||||
|
||||
missing = []
|
||||
for header, expected_value in security_headers.items():
|
||||
if header not in resp.headers:
|
||||
missing.append(header)
|
||||
|
||||
if missing:
|
||||
# Warning seulement — bloquant uniquement en prod derrière Nginx
|
||||
pytest.warns(UserWarning, match=r".*") if False else None
|
||||
print(f"⚠️ Headers sécurité manquants (requis en prod): {missing}")
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
pytest.skip(f"App non accessible sur {BASE_URL}")
|
||||
|
||||
@pytest.mark.smoke
|
||||
def test_api_races_format_reponse(self):
|
||||
"""L'endpoint /api/races doit retourner une liste structurée."""
|
||||
try:
|
||||
resp = requests.get(f"{BASE_URL}/api/races", timeout=10)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
assert isinstance(data, (list, dict)), (
|
||||
f"Format inattendu pour /api/races: {type(data)}"
|
||||
)
|
||||
if isinstance(data, list) and len(data) > 0:
|
||||
first = data[0]
|
||||
# Vérifier la présence de champs clés
|
||||
expected_fields = ["date", "course", "hippodrome"]
|
||||
present = [
|
||||
f
|
||||
for f in expected_fields
|
||||
if f in first
|
||||
or any(k in first for k in [f, f.upper(), f.replace("_", "")])
|
||||
]
|
||||
assert len(present) > 0, (
|
||||
f"Champs attendus absents de /api/races. Champs présents: {list(first.keys())}"
|
||||
)
|
||||
except requests.exceptions.ConnectionError:
|
||||
pytest.skip(f"App non accessible sur {BASE_URL}")
|
||||
except json.JSONDecodeError:
|
||||
pytest.fail("/api/races ne retourne pas du JSON valide")
|
||||
|
||||
|
||||
class TestSmokeDatabase:
|
||||
"""Tests smoke sur la base de données."""
|
||||
|
||||
@pytest.mark.smoke
|
||||
def test_base_donnees_accessible(self):
|
||||
"""La base de données SQLite doit être accessible et contenir des données."""
|
||||
import sqlite3
|
||||
|
||||
db_path = "/home/h3r7/turf_saas/turf_saas.db"
|
||||
|
||||
if not __import__("os").path.exists(db_path):
|
||||
pytest.skip(f"Base de données non trouvée: {db_path}")
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
c = conn.cursor()
|
||||
|
||||
# Vérifier que les tables essentielles existent
|
||||
c.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
tables = {row[0] for row in c.fetchall()}
|
||||
conn.close()
|
||||
|
||||
expected_tables = ["predictions", "results"]
|
||||
for table in expected_tables:
|
||||
assert table in tables, (
|
||||
f"Table manquante dans la BDD: {table}. Tables présentes: {tables}"
|
||||
)
|
||||
|
||||
@pytest.mark.smoke
|
||||
def test_donnees_predictions_disponibles(self):
|
||||
"""Des prédictions doivent être présentes dans la BDD."""
|
||||
import sqlite3
|
||||
|
||||
db_path = "/home/h3r7/turf_saas/turf_saas.db"
|
||||
|
||||
if not __import__("os").path.exists(db_path):
|
||||
pytest.skip(f"Base de données non trouvée: {db_path}")
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT COUNT(*) FROM predictions")
|
||||
count = c.fetchone()[0]
|
||||
conn.close()
|
||||
|
||||
# Au moins quelques données pour que le SaaS soit utile
|
||||
assert count >= 0, "Table predictions accessible"
|
||||
if count == 0:
|
||||
print("⚠️ Aucune prédiction en base — le scraper doit être lancé")
|
||||
383
tests/test_user_tokens.py
Normal file
383
tests/test_user_tokens.py
Normal file
@@ -0,0 +1,383 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
tests/test_user_tokens.py — Personal API Token + Webhook alertes
|
||||
HRT-80: Tests unitaires et d'intégration
|
||||
|
||||
Couvre:
|
||||
- POST /api/v1/user/api-token (create)
|
||||
- DELETE /api/v1/user/api-token (revoke)
|
||||
- POST /api/v1/user/webhook (create/upsert)
|
||||
- DELETE /api/v1/user/webhook (delete)
|
||||
- Authentification via X-API-Key
|
||||
- dispatch_webhook() fire-and-forget
|
||||
- Plan enforcement Pro uniquement
|
||||
|
||||
Run:
|
||||
./venv/bin/pytest tests/test_user_tokens.py -v --tb=short
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ─── Test DB isolation ────────────────────────────────────────────────────────
|
||||
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
_tmp_db.close()
|
||||
os.environ["TURF_SAAS_DB"] = _tmp_db.name
|
||||
os.environ["JWT_SECRET_KEY"] = "test-secret-hrt80"
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from app_v1 import create_app # noqa: E402
|
||||
|
||||
TEST_CONFIG = {
|
||||
"TESTING": True,
|
||||
"JWT_SECRET_KEY": "test-secret-hrt80",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
application = create_app()
|
||||
application.config.update(TEST_CONFIG)
|
||||
yield application
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _create_user(client, email, plan="pro"):
|
||||
"""Register user (plan=free) then update plan in DB."""
|
||||
resp = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "Secure123"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
user_id = resp.get_json()["user_id"]
|
||||
|
||||
# Update plan directly in DB (no plan-update endpoint in JWT auth)
|
||||
conn = sqlite3.connect(os.environ["TURF_SAAS_DB"])
|
||||
conn.execute("UPDATE users SET plan = ? WHERE id = ?", (plan, user_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Login to get access token
|
||||
login_resp = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": email, "password": "Secure123"},
|
||||
)
|
||||
assert login_resp.status_code == 200, login_resp.get_json()
|
||||
access_token = login_resp.get_json()["access_token"]
|
||||
return access_token, user_id
|
||||
|
||||
|
||||
def _auth_header(token):
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# ─── Tests: API Token (Pro) ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestApiToken:
|
||||
def test_create_api_token_pro(self, client):
|
||||
"""POST /api/v1/user/api-token — Pro user gets 201 + token starting with trf_"""
|
||||
token, _ = _create_user(client, "pro_token@test.com", plan="pro")
|
||||
resp = client.post("/api/v1/user/api-token", headers=_auth_header(token))
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
data = resp.get_json()
|
||||
assert data["token"].startswith("trf_")
|
||||
assert data["prefix"] == data["token"][:12]
|
||||
assert "warning" in data
|
||||
assert "created_at" in data
|
||||
|
||||
def test_create_api_token_stores_hash_not_raw(self, client):
|
||||
"""Second POST returns 409 — only hashed token stored"""
|
||||
token, _ = _create_user(client, "pro_token2@test.com", plan="pro")
|
||||
# First create
|
||||
r1 = client.post("/api/v1/user/api-token", headers=_auth_header(token))
|
||||
assert r1.status_code == 201
|
||||
raw_token = r1.get_json()["token"]
|
||||
# Second create should conflict
|
||||
r2 = client.post("/api/v1/user/api-token", headers=_auth_header(token))
|
||||
assert r2.status_code == 409
|
||||
data = r2.get_json()
|
||||
assert "existing_prefix" in data
|
||||
# Verify raw token is NOT stored in DB (only hash)
|
||||
conn = sqlite3.connect(os.environ["TURF_SAAS_DB"])
|
||||
row = conn.execute(
|
||||
"SELECT token_hash FROM user_api_tokens WHERE token_prefix = ?",
|
||||
(raw_token[:12],),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
assert row is not None
|
||||
assert row[0] != raw_token # hash != raw
|
||||
assert len(row[0]) == 64 # SHA256 hex
|
||||
|
||||
def test_create_api_token_free_user(self, client):
|
||||
"""Free user gets 403"""
|
||||
token, _ = _create_user(client, "free_token@test.com", plan="free")
|
||||
resp = client.post("/api/v1/user/api-token", headers=_auth_header(token))
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_create_api_token_premium_user(self, client):
|
||||
"""Premium user gets 403 (Pro only feature)"""
|
||||
token, _ = _create_user(client, "premium_token@test.com", plan="premium")
|
||||
resp = client.post("/api/v1/user/api-token", headers=_auth_header(token))
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_create_api_token_no_auth(self, client):
|
||||
"""No auth → 401"""
|
||||
resp = client.post("/api/v1/user/api-token")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_revoke_api_token(self, client):
|
||||
"""DELETE /api/v1/user/api-token — Pro user revokes active token"""
|
||||
token, _ = _create_user(client, "pro_revoke@test.com", plan="pro")
|
||||
# Create first
|
||||
client.post("/api/v1/user/api-token", headers=_auth_header(token))
|
||||
# Revoke
|
||||
resp = client.delete("/api/v1/user/api-token", headers=_auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["revoked"] is True
|
||||
assert data["count"] >= 1
|
||||
|
||||
def test_revoke_no_active_token(self, client):
|
||||
"""DELETE with no active token → 404"""
|
||||
token, _ = _create_user(client, "pro_notoken@test.com", plan="pro")
|
||||
resp = client.delete("/api/v1/user/api-token", headers=_auth_header(token))
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_revoke_non_pro(self, client):
|
||||
"""DELETE for free user → 403"""
|
||||
token, _ = _create_user(client, "free_revoke@test.com", plan="free")
|
||||
resp = client.delete("/api/v1/user/api-token", headers=_auth_header(token))
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ─── Tests: X-API-Key Authentication ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestApiKeyAuth:
|
||||
def test_api_key_auth_on_protected_route(self, client):
|
||||
"""Valid X-API-Key authenticates on protected route"""
|
||||
token, _ = _create_user(client, "apikey_auth@test.com", plan="pro")
|
||||
# Create API token
|
||||
r = client.post("/api/v1/user/api-token", headers=_auth_header(token))
|
||||
assert r.status_code == 201
|
||||
raw_key = r.get_json()["token"]
|
||||
# Use X-API-Key to access a protected route (try create again → 409 means authenticated)
|
||||
resp = client.post("/api/v1/user/api-token", headers={"X-API-Key": raw_key})
|
||||
# 409 means we were authenticated; 401 means auth failed
|
||||
assert resp.status_code == 409
|
||||
|
||||
def test_api_key_invalid(self, client):
|
||||
"""Invalid X-API-Key → 401"""
|
||||
resp = client.post(
|
||||
"/api/v1/user/api-token",
|
||||
headers={"X-API-Key": "trf_invalidkeyXXXXXXXXXXXXXXXXXX"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_api_key_revoked(self, client):
|
||||
"""Revoked X-API-Key → 401"""
|
||||
token, _ = _create_user(client, "revoked_apikey@test.com", plan="pro")
|
||||
# Create token
|
||||
r = client.post("/api/v1/user/api-token", headers=_auth_header(token))
|
||||
assert r.status_code == 201
|
||||
raw_key = r.get_json()["token"]
|
||||
# Revoke it
|
||||
client.delete("/api/v1/user/api-token", headers=_auth_header(token))
|
||||
# Try using revoked key
|
||||
resp = client.post("/api/v1/user/api-token", headers={"X-API-Key": raw_key})
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_revoke_then_cannot_auth(self, client):
|
||||
"""Full flow: create → use → revoke → X-API-Key rejected"""
|
||||
token, _ = _create_user(client, "flow_test@test.com", plan="pro")
|
||||
# Create
|
||||
r = client.post("/api/v1/user/api-token", headers=_auth_header(token))
|
||||
raw_key = r.get_json()["token"]
|
||||
# Validate it works (409 because key exists)
|
||||
r2 = client.post("/api/v1/user/api-token", headers={"X-API-Key": raw_key})
|
||||
assert r2.status_code == 409
|
||||
# Revoke
|
||||
client.delete("/api/v1/user/api-token", headers=_auth_header(token))
|
||||
# Try again with revoked key
|
||||
r3 = client.post("/api/v1/user/api-token", headers={"X-API-Key": raw_key})
|
||||
assert r3.status_code == 401
|
||||
|
||||
|
||||
# ─── Tests: Webhook ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestWebhook:
|
||||
def test_create_webhook_pro(self, client):
|
||||
"""POST /api/v1/user/webhook — Pro user with provided secret → 201"""
|
||||
token, _ = _create_user(client, "webhook_pro@test.com", plan="pro")
|
||||
resp = client.post(
|
||||
"/api/v1/user/webhook",
|
||||
headers=_auth_header(token),
|
||||
json={"url": "https://example.com/hook", "secret": "mysecret123"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.get_json()
|
||||
assert data["webhook_url"] == "https://example.com/hook"
|
||||
assert data["secret"] == "mysecret123"
|
||||
|
||||
def test_create_webhook_auto_secret(self, client):
|
||||
"""POST without secret → auto-generated secret"""
|
||||
token, _ = _create_user(client, "webhook_auto@test.com", plan="pro")
|
||||
resp = client.post(
|
||||
"/api/v1/user/webhook",
|
||||
headers=_auth_header(token),
|
||||
json={"url": "https://auto.example.com/hook"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.get_json()
|
||||
assert len(data["secret"]) == 64 # token_hex(32) = 64 hex chars
|
||||
|
||||
def test_create_webhook_non_pro_free(self, client):
|
||||
"""Free user → 403"""
|
||||
token, _ = _create_user(client, "webhook_free@test.com", plan="free")
|
||||
resp = client.post(
|
||||
"/api/v1/user/webhook",
|
||||
headers=_auth_header(token),
|
||||
json={"url": "https://example.com/hook"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_create_webhook_non_pro_premium(self, client):
|
||||
"""Premium user → 403"""
|
||||
token, _ = _create_user(client, "webhook_premium@test.com", plan="premium")
|
||||
resp = client.post(
|
||||
"/api/v1/user/webhook",
|
||||
headers=_auth_header(token),
|
||||
json={"url": "https://example.com/hook"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_create_webhook_url_not_https(self, client):
|
||||
"""HTTP URL → 400"""
|
||||
token, _ = _create_user(client, "webhook_http@test.com", plan="pro")
|
||||
resp = client.post(
|
||||
"/api/v1/user/webhook",
|
||||
headers=_auth_header(token),
|
||||
json={"url": "http://example.com/hook"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "https" in resp.get_json()["error"].lower()
|
||||
|
||||
def test_create_webhook_missing_url(self, client):
|
||||
"""Missing URL → 400"""
|
||||
token, _ = _create_user(client, "webhook_nourl@test.com", plan="pro")
|
||||
resp = client.post(
|
||||
"/api/v1/user/webhook",
|
||||
headers=_auth_header(token),
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_webhook_upsert(self, client):
|
||||
"""Second POST updates URL (upsert behavior)"""
|
||||
token, _ = _create_user(client, "webhook_upsert@test.com", plan="pro")
|
||||
client.post(
|
||||
"/api/v1/user/webhook",
|
||||
headers=_auth_header(token),
|
||||
json={"url": "https://first.example.com/hook"},
|
||||
)
|
||||
resp = client.post(
|
||||
"/api/v1/user/webhook",
|
||||
headers=_auth_header(token),
|
||||
json={"url": "https://second.example.com/hook"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.get_json()["webhook_url"] == "https://second.example.com/hook"
|
||||
|
||||
def test_delete_webhook(self, client):
|
||||
"""DELETE /api/v1/user/webhook → 200"""
|
||||
token, _ = _create_user(client, "webhook_delete@test.com", plan="pro")
|
||||
client.post(
|
||||
"/api/v1/user/webhook",
|
||||
headers=_auth_header(token),
|
||||
json={"url": "https://delete.example.com/hook"},
|
||||
)
|
||||
resp = client.delete("/api/v1/user/webhook", headers=_auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["deleted"] is True
|
||||
|
||||
def test_delete_webhook_not_configured(self, client):
|
||||
"""DELETE without webhook configured → 404"""
|
||||
token, _ = _create_user(client, "webhook_notset@test.com", plan="pro")
|
||||
resp = client.delete("/api/v1/user/webhook", headers=_auth_header(token))
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_webhook_non_pro(self, client):
|
||||
"""Free user DELETE → 403"""
|
||||
token, _ = _create_user(client, "webhook_freedelete@test.com", plan="free")
|
||||
resp = client.delete("/api/v1/user/webhook", headers=_auth_header(token))
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ─── Tests: dispatch_webhook ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDispatchWebhook:
|
||||
def test_dispatch_no_webhook_configured(self):
|
||||
"""dispatch_webhook silently returns when no webhook is configured"""
|
||||
with patch("api_v1.utils_webhook.get_db") as mock_get_db:
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.execute.return_value.fetchone.return_value = None
|
||||
mock_get_db.return_value = mock_conn
|
||||
|
||||
from api_v1.utils_webhook import dispatch_webhook
|
||||
|
||||
# Should not raise, should return silently
|
||||
dispatch_webhook("nonexistent_user", "new_prediction", {"data": "test"})
|
||||
|
||||
def test_dispatch_sends_hmac_header(self):
|
||||
"""dispatch_webhook sends correct HMAC-SHA256 signature header"""
|
||||
test_secret = "testsecret"
|
||||
test_url = "https://hook.example.com/receive"
|
||||
test_payload = {"race_id": "R123", "top1": "Cheval Blanc"}
|
||||
|
||||
with (
|
||||
patch("api_v1.utils_webhook.get_db") as mock_get_db,
|
||||
patch("api_v1.utils_webhook.requests.post") as mock_post,
|
||||
):
|
||||
mock_row = MagicMock()
|
||||
mock_row.__getitem__ = lambda self, key: (
|
||||
test_url if key == "url" else test_secret
|
||||
)
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.execute.return_value.fetchone.return_value = mock_row
|
||||
mock_get_db.return_value = mock_conn
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
from api_v1.utils_webhook import dispatch_webhook, EVENT_NEW_PREDICTION
|
||||
|
||||
dispatch_webhook("user123", EVENT_NEW_PREDICTION, test_payload)
|
||||
|
||||
assert mock_post.called
|
||||
call_kwargs = mock_post.call_args
|
||||
headers_sent = call_kwargs.kwargs.get("headers") or call_kwargs[1].get(
|
||||
"headers"
|
||||
)
|
||||
assert "X-Turf-Signature" in headers_sent
|
||||
assert headers_sent["X-Turf-Signature"].startswith("sha256=")
|
||||
assert headers_sent["X-Turf-Event"] == EVENT_NEW_PREDICTION
|
||||
1007
train_ensemble.py
Normal file
1007
train_ensemble.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -193,6 +193,65 @@ def schedule_dynamic_scoring():
|
||||
logger.info("ℹ️ [SCHEDULER] Pas de course aujourd'hui, pas de scoring dynamique")
|
||||
|
||||
|
||||
def run_telegram_alerts():
|
||||
"""Envoie les alertes Telegram pré-course aux utilisateurs Premium/Pro"""
|
||||
logger.info("📨 [SCHEDULER] Envoi alertes Telegram pré-course...")
|
||||
try:
|
||||
os.chdir("/home/h3r7/turf_saas")
|
||||
import telegram_alerts
|
||||
|
||||
stats = telegram_alerts.send_pre_race_alerts(minutes_before=30)
|
||||
logger.info(
|
||||
"✅ [SCHEDULER] Alertes Telegram: %d envoyées, %d ignorées, %d erreurs",
|
||||
stats.get("sent", 0),
|
||||
stats.get("skipped", 0),
|
||||
stats.get("errors", 0),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ [SCHEDULER] Erreur alertes Telegram: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def schedule_dynamic_telegram_alerts():
|
||||
"""Planifie les alertes Telegram 30min avant la course (même pattern que schedule_dynamic_scoring)"""
|
||||
race_time = get_todays_race_time()
|
||||
|
||||
if race_time:
|
||||
try:
|
||||
# Convertir timestamp ms en datetime
|
||||
dt = datetime.fromtimestamp(race_time / 1000)
|
||||
race_hour = dt.hour
|
||||
race_min = dt.minute
|
||||
|
||||
logger.info(
|
||||
f"📅 [SCHEDULER] Alertes Telegram — course à {race_hour:02d}:{race_min:02d}"
|
||||
)
|
||||
|
||||
# Alertes 30min avant la course
|
||||
pre_min = race_min - 30
|
||||
pre_hour = race_hour
|
||||
if pre_min < 0:
|
||||
pre_min += 60
|
||||
pre_hour -= 1
|
||||
|
||||
alert_time = f"{pre_hour:02d}:{pre_min:02d}"
|
||||
schedule.every().day.at(alert_time).do(run_telegram_alerts).tag(
|
||||
"telegram", "dynamic"
|
||||
)
|
||||
logger.info(
|
||||
f"📅 [SCHEDULER] Alertes Telegram planifiées à {alert_time} (30min avant la course)"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Impossible de planifier les alertes Telegram: {e}")
|
||||
else:
|
||||
logger.info(
|
||||
"ℹ️ [SCHEDULER] Pas de course aujourd'hui, pas d'alertes Telegram dynamiques"
|
||||
)
|
||||
|
||||
|
||||
def schedule_dynamic_results():
|
||||
"""Planifie le scraping des résultats à H+1 (1h après la course)"""
|
||||
race_time = get_todays_race_time()
|
||||
@@ -245,6 +304,9 @@ def main():
|
||||
# Scoring dynamique (15min avant course)
|
||||
schedule_dynamic_scoring()
|
||||
|
||||
# Alertes Telegram dynamiques (30min avant course)
|
||||
schedule_dynamic_telegram_alerts()
|
||||
|
||||
# Résultats dynamiques (H+1)
|
||||
schedule_dynamic_results()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user