Compare commits
53 Commits
feature/ml
...
feature/HR
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e25ec54d1 | ||
|
|
60e12cc4dd | ||
|
|
4b766cb908 | ||
|
|
837cddb406 | ||
|
|
8ab42343aa | ||
|
|
cd4cbcfb48 | ||
|
|
c072f92794 | ||
|
|
fac498efec | ||
|
|
1ccf9f5cb8 | ||
|
|
a126941f7f | ||
|
|
3079c2c6c6 | ||
|
|
52c0c95f22 | ||
|
|
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 | |||
|
|
ce0ee150ec | ||
|
|
41a9e36166 | ||
|
|
b8ef1ed35d | ||
|
|
793ee82c29 | ||
|
|
c8f1bfd478 | ||
|
|
5a23692ad1 | ||
|
|
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/
|
||||
|
||||
132
API_AUTH.md
Normal file
132
API_AUTH.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# API Auth JWT — Documentation
|
||||
## Sprint 2-3 (HRT-28)
|
||||
|
||||
Base URL: `http://localhost:8792`
|
||||
|
||||
---
|
||||
|
||||
## Endpoints d'authentification
|
||||
|
||||
### `POST /api/v1/auth/register`
|
||||
Inscription d'un nouvel utilisateur (plan free par défaut).
|
||||
|
||||
**Body JSON:**
|
||||
```json
|
||||
{ "email": "user@example.com", "password": "motdepasse123" }
|
||||
```
|
||||
**Réponse 201:**
|
||||
```json
|
||||
{ "message": "Compte créé avec succès", "user_id": 1 }
|
||||
```
|
||||
**Erreurs:** `400` (email invalide / mot de passe < 8 car.), `409` (email déjà utilisé)
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/v1/auth/login`
|
||||
Connexion — retourne access_token (15min) + refresh_token (30j).
|
||||
|
||||
**Body JSON:**
|
||||
```json
|
||||
{ "email": "user@example.com", "password": "motdepasse123" }
|
||||
```
|
||||
**Réponse 200:**
|
||||
```json
|
||||
{
|
||||
"access_token": "<JWT>",
|
||||
"refresh_token": "<refresh_JWT>",
|
||||
"token_type": "Bearer",
|
||||
"plan": "free"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/v1/auth/refresh`
|
||||
Rotation du refresh token — invalide l'ancien, émet un nouveau.
|
||||
|
||||
**Body JSON:**
|
||||
```json
|
||||
{ "refresh_token": "<refresh_JWT>" }
|
||||
```
|
||||
**Réponse 200:** identique à `/login`
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/v1/auth/logout`
|
||||
Révocation du refresh token.
|
||||
|
||||
**Body JSON:**
|
||||
```json
|
||||
{ "refresh_token": "<refresh_JWT>" }
|
||||
```
|
||||
**Réponse 200:**
|
||||
```json
|
||||
{ "message": "Déconnexion réussie" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Routes protégées
|
||||
|
||||
Toutes les routes protégées nécessitent le header:
|
||||
```
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
### `GET /api/v1/predictions`
|
||||
| Plan | Accès |
|
||||
|---------|---------------------------------------------|
|
||||
| free | Top 3 uniquement, 1 course/jour |
|
||||
| premium | Toutes les courses + alertes Telegram |
|
||||
| pro | API complète + lien export CSV |
|
||||
|
||||
### `GET /api/v1/predictions/export`
|
||||
Export CSV — **plan pro uniquement** (`403` pour free/premium).
|
||||
|
||||
### `GET /api/v1/subscription/upgrade`
|
||||
Infos sur les plans disponibles et plan courant de l'utilisateur.
|
||||
|
||||
### `GET /api/v1/health`
|
||||
Vérification d'état du service (pas d'auth requise).
|
||||
|
||||
---
|
||||
|
||||
## Sécurité
|
||||
|
||||
- **Passwords:** hashés avec bcrypt (saltRounds=12)
|
||||
- **JWT access:** expiration 15 minutes (HS256)
|
||||
- **JWT refresh:** expiration 30 jours, stocké hashé (SHA-256) en DB, rotation à chaque usage
|
||||
- **Rate limiting:** 100 requêtes/min par IP — header `X-RateLimit-Remaining`
|
||||
- **CORS:** configuré pour `https://turf-ia.h3r7.tech` + localhost dev
|
||||
- **Logs d'accès:** horodatés ISO 8601 dans `logs/saas_api.log`
|
||||
|
||||
---
|
||||
|
||||
## Lancement
|
||||
|
||||
```bash
|
||||
JWT_SECRET_KEY="votre_cle_secrete" \
|
||||
CORS_ORIGINS="https://turf-ia.h3r7.tech" \
|
||||
./venv/bin/python saas_api.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
./venv/bin/pytest tests/test_auth.py -v
|
||||
# Avec couverture:
|
||||
./venv/bin/pytest tests/test_auth.py --cov=auth --cov=auth_db --cov=middleware --cov=saas_api --cov-report=term-missing
|
||||
# Résultat: 27 tests OK, couverture globale 83%
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Structure des tables DB
|
||||
|
||||
```sql
|
||||
-- users: id, email, password_hash, plan(free/premium/pro), created_at, is_active, daily_usage, last_usage_date
|
||||
-- subscriptions: id, user_id, plan, start_date, end_date, stripe_customer_id
|
||||
-- refresh_tokens: id, user_id, token_hash, created_at, expires_at, revoked
|
||||
```
|
||||
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 |
|
||||
156
README_API_V1.md
Normal file
156
README_API_V1.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Turf SaaS — API v1 Reference
|
||||
|
||||
Sprint 3-4 · HRT-29 — Refacto API /v1/
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
http://<host>:8792
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
All endpoints (except `/api/v1/health` and `/api/v1/auth/*`) require a **Bearer JWT** token.
|
||||
|
||||
```
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
### Get a token
|
||||
|
||||
```bash
|
||||
# Register
|
||||
curl -X POST http://localhost:8792/api/v1/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "user@example.com", "password": "mypassword"}'
|
||||
|
||||
# Login → returns access_token + refresh_token
|
||||
curl -X POST http://localhost:8792/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "user@example.com", "password": "mypassword"}'
|
||||
```
|
||||
|
||||
## Plans & Access Control
|
||||
|
||||
| Plan | Inclus |
|
||||
|-----------|----------------------------------------------------|
|
||||
| `free` | health, auth, courses/today, predictions/top3 (1/j)|
|
||||
| `premium` | + predictions/all, valuebets, metrics |
|
||||
| `pro` | + backtest, export/csv |
|
||||
|
||||
## Endpoints
|
||||
|
||||
### System
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------------------|------|----------------------|
|
||||
| GET | `/api/v1/health` | Non | Healthcheck public |
|
||||
| GET | `/api/v1/docs` | Non | Swagger UI |
|
||||
|
||||
### Auth
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|---------------------------|--------------------------------|
|
||||
| POST | `/api/v1/auth/register` | Créer un compte (plan=free) |
|
||||
| POST | `/api/v1/auth/login` | Login → JWT tokens |
|
||||
| POST | `/api/v1/auth/refresh` | Renouveler l'access token |
|
||||
| POST | `/api/v1/auth/logout` | Révoquer le refresh token |
|
||||
|
||||
### Courses
|
||||
|
||||
| Method | 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 |
|
||||
|
||||
Query params `courses/today`: `filter=[all|quinte|trot|plat]`, `limit`, `offset`
|
||||
|
||||
`{id}` format: `{num_reunion}-{num_course}` ex: `1-3`
|
||||
|
||||
### Prédictions
|
||||
|
||||
| Method | Path | Plan | Description |
|
||||
|--------|---------------------------|-----------|------------------------------|
|
||||
| GET | `/api/v1/predictions/top3`| free+ | Top 3 chevaux du jour |
|
||||
| GET | `/api/v1/predictions/all` | premium+ | Toutes les prédictions ML |
|
||||
|
||||
Query params: `date=YYYY-MM-DD`, `limit`, `offset`
|
||||
|
||||
### Value Bets
|
||||
|
||||
| Method | Path | Plan | Description |
|
||||
|--------|---------------------|-----------|--------------------------|
|
||||
| GET | `/api/v1/valuebets` | premium+ | Value bets du jour |
|
||||
|
||||
Query params: `date`, `min_odds` (défaut 2.0), `limit`, `offset`
|
||||
|
||||
### Backtest
|
||||
|
||||
| Method | Path | Plan | Description |
|
||||
|--------|---------------------|------|----------------------------------|
|
||||
| GET | `/api/v1/backtest` | pro | Résultats historiques des paris |
|
||||
|
||||
Query params: `start`, `end` (YYYY-MM-DD), `limit`, `offset`
|
||||
|
||||
### Export
|
||||
|
||||
| Method | Path | Plan | Description |
|
||||
|--------|-------------------------|------|----------------------|
|
||||
| GET | `/api/v1/export/csv` | pro | Export CSV |
|
||||
|
||||
Query params: `type=[predictions|bets]`, `date`, `start`, `end`
|
||||
|
||||
### Métriques
|
||||
|
||||
| Method | Path | Plan | Description |
|
||||
|--------|---------------------|----------|-----------------------|
|
||||
| GET | `/api/v1/metrics` | premium+ | Métriques ML et paris |
|
||||
|
||||
Query params: `days` (int, défaut 30)
|
||||
|
||||
## Réponse uniforme
|
||||
|
||||
Toutes les erreurs retournent :
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Description de l'erreur",
|
||||
"code": 400
|
||||
}
|
||||
```
|
||||
|
||||
Les listes paginées incluent :
|
||||
|
||||
```json
|
||||
{
|
||||
"pagination": {
|
||||
"total": 150,
|
||||
"limit": 20,
|
||||
"offset": 0,
|
||||
"has_more": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Démarrage
|
||||
|
||||
```bash
|
||||
cd /home/h3r7/turf_saas
|
||||
source venv/bin/activate
|
||||
python app_v1.py
|
||||
# ou
|
||||
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/test_api_v1.py -v
|
||||
```
|
||||
|
||||
## Documentation Swagger
|
||||
|
||||
Accessible sur : `http://localhost:8792/api/v1/docs`
|
||||
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>
|
||||
4
ai_router/__init__.py
Normal file
4
ai_router/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .router import AIRouter
|
||||
from .api import ai_router_bp, register_ai_router
|
||||
|
||||
__all__ = ["AIRouter", "ai_router_bp", "register_ai_router"]
|
||||
172
ai_router/api.py
Normal file
172
ai_router/api.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""Flask Blueprint for AI Router — chat, health, models, admin."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from .router import AIRouter
|
||||
from .models import init_db, upsert_provider, upsert_model_mapping
|
||||
from .utils import require_auth, admin_required
|
||||
|
||||
logger = logging.getLogger("ai_router.api")
|
||||
|
||||
ai_router_bp = Blueprint("ai_router", __name__, url_prefix="/api/v1/ai")
|
||||
|
||||
|
||||
def register_ai_router(app):
|
||||
app.register_blueprint(ai_router_bp)
|
||||
|
||||
|
||||
_router = AIRouter()
|
||||
|
||||
|
||||
@ai_router_bp.route("/health", methods=["GET"])
|
||||
def health():
|
||||
health_data = _router.check_all_providers_health()
|
||||
all_ok = all(v["status"] == "ok" for v in health_data.values())
|
||||
return jsonify({
|
||||
"status": "ok" if all_ok else "degraded",
|
||||
"service": "ai-router",
|
||||
"version": "1.0.0",
|
||||
"providers": health_data,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}), 200 if all_ok else 503
|
||||
|
||||
|
||||
@ai_router_bp.route("/models", methods=["GET"])
|
||||
def list_models():
|
||||
models = _router.list_available_models()
|
||||
return jsonify({"models": models})
|
||||
|
||||
|
||||
@ai_router_bp.route("/chat", methods=["POST"])
|
||||
@require_auth
|
||||
def chat():
|
||||
data = request.get_json(silent=True) or {}
|
||||
messages = data.get("messages", [])
|
||||
model = data.get("model", "gpt-4o-mini")
|
||||
user_id = (request.current_user or {}).get("user_id")
|
||||
|
||||
if not messages:
|
||||
return jsonify({"error": "messages field is required"}), 400
|
||||
|
||||
kwargs = {k: data[k] for k in ("temperature", "max_tokens", "top_p", "stream") if k in data}
|
||||
|
||||
result = _router.chat(messages=messages, model_alias=model, user_id=user_id, **kwargs)
|
||||
|
||||
if result.get("status") == "error":
|
||||
code = 503 if "All providers failed" in result.get("error", "") else 400
|
||||
return jsonify(result), code
|
||||
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@ai_router_bp.route("/admin/providers", methods=["GET"])
|
||||
@admin_required
|
||||
def list_providers():
|
||||
from .models import get_db
|
||||
conn = get_db()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, provider_type, base_url, priority, is_active, created_at, updated_at "
|
||||
"FROM ai_providers ORDER BY priority"
|
||||
).fetchall()
|
||||
return jsonify({"providers": [dict(r) for r in rows]})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@ai_router_bp.route("/admin/providers", methods=["POST"])
|
||||
@admin_required
|
||||
def upsert_provider_endpoint():
|
||||
data = request.get_json(silent=True) or {}
|
||||
name = data.get("name", "")
|
||||
provider_type = data.get("provider_type", "")
|
||||
api_key = data.get("api_key", "")
|
||||
base_url = data.get("base_url", "")
|
||||
priority = data.get("priority", 99)
|
||||
|
||||
if not name or provider_type not in ("openai", "anthropic", "google", "mistral"):
|
||||
return jsonify({"error": "Valid name and provider_type required"}), 400
|
||||
|
||||
ok = upsert_provider(name, provider_type, api_key, base_url, priority=priority)
|
||||
if ok:
|
||||
return jsonify({"status": "ok", "message": f"Provider {name} saved"}), 200
|
||||
return jsonify({"error": "Failed to save provider"}), 500
|
||||
|
||||
|
||||
@ai_router_bp.route("/admin/model-mappings", methods=["POST"])
|
||||
@admin_required
|
||||
def upsert_model_mapping_endpoint():
|
||||
data = request.get_json(silent=True) or {}
|
||||
model_alias = data.get("model_alias", "")
|
||||
provider_id = data.get("provider_id")
|
||||
real_model_id = data.get("real_model_id", "")
|
||||
cost = data.get("cost_per_1k_tokens", 0)
|
||||
|
||||
if not model_alias or not provider_id or not real_model_id:
|
||||
return jsonify({"error": "model_alias, provider_id, real_model_id required"}), 400
|
||||
|
||||
ok = upsert_model_mapping(model_alias, provider_id, real_model_id, cost)
|
||||
if ok:
|
||||
return jsonify({"status": "ok", "message": f"Mapping for {model_alias} saved"}), 200
|
||||
return jsonify({"error": "Failed to save model mapping"}), 500
|
||||
|
||||
|
||||
@ai_router_bp.route("/admin/providers/<int:provider_id>", methods=["DELETE"])
|
||||
@admin_required
|
||||
def delete_provider(provider_id):
|
||||
from .models import get_db
|
||||
conn = get_db()
|
||||
try:
|
||||
conn.execute("DELETE FROM ai_model_mapping WHERE provider_id = ?", (provider_id,))
|
||||
conn.execute("DELETE FROM ai_providers WHERE id = ?", (provider_id,))
|
||||
conn.commit()
|
||||
return jsonify({"status": "deleted", "provider_id": provider_id})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@ai_router_bp.route("/usage", methods=["GET"])
|
||||
@admin_required
|
||||
def usage_stats():
|
||||
from .models import get_db
|
||||
conn = get_db()
|
||||
try:
|
||||
limit = request.args.get("limit", 50, type=int)
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM ai_router_log ORDER BY created_at DESC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
return jsonify({"usage": [dict(r) for r in rows]})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@ai_router_bp.route("/usage/summary", methods=["GET"])
|
||||
@admin_required
|
||||
def usage_summary():
|
||||
from .models import get_db
|
||||
conn = get_db()
|
||||
try:
|
||||
agg = conn.execute("""
|
||||
SELECT provider_used, status, COUNT(*) as count,
|
||||
SUM(duration_ms) as total_ms, SUM(tokens_in + tokens_out) as total_tokens
|
||||
FROM ai_router_log
|
||||
GROUP BY provider_used, status
|
||||
ORDER BY provider_used
|
||||
""").fetchall()
|
||||
totals = conn.execute("""
|
||||
SELECT COUNT(*) as total_requests,
|
||||
SUM(CASE WHEN status='success' THEN 1 ELSE 0 END) as success_count,
|
||||
SUM(tokens_in + tokens_out) as total_tokens
|
||||
FROM ai_router_log
|
||||
""").fetchone()
|
||||
return jsonify({
|
||||
"by_provider": [dict(r) for r in agg],
|
||||
"totals": dict(totals) if totals else {},
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
167
ai_router/models.py
Normal file
167
ai_router/models.py
Normal file
@@ -0,0 +1,167 @@
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger("ai_router.models")
|
||||
|
||||
DB_PATH = os.environ.get("AI_ROUTER_DB", "/home/h3r7/turf_saas/ai_router.db")
|
||||
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db():
|
||||
conn = get_db()
|
||||
try:
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS ai_providers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
provider_type TEXT NOT NULL CHECK(provider_type IN ('openai','anthropic','google','mistral')),
|
||||
api_key TEXT NOT NULL DEFAULT '',
|
||||
base_url TEXT DEFAULT '',
|
||||
config TEXT DEFAULT '{}',
|
||||
priority INTEGER NOT NULL DEFAULT 99,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_model_mapping (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
model_alias TEXT NOT NULL UNIQUE,
|
||||
provider_id INTEGER NOT NULL REFERENCES ai_providers(id),
|
||||
real_model_id TEXT NOT NULL,
|
||||
cost_per_1k_tokens REAL NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_router_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
request_id TEXT NOT NULL,
|
||||
user_id INTEGER,
|
||||
model_alias TEXT NOT NULL,
|
||||
provider_used TEXT NOT NULL,
|
||||
tokens_in INTEGER NOT NULL DEFAULT 0,
|
||||
tokens_out INTEGER NOT NULL DEFAULT 0,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL CHECK(status IN ('success','error')),
|
||||
error_message TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_router_log_request_id ON ai_router_log(request_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_router_log_created_at ON ai_router_log(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_model_mapping_alias ON ai_model_mapping(model_alias);
|
||||
""")
|
||||
conn.commit()
|
||||
logger.info("AI Router database tables initialized")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize AI Router DB: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_providers_from_db():
|
||||
conn = get_db()
|
||||
try:
|
||||
rows = conn.execute("""
|
||||
SELECT p.id, p.name, p.provider_type, p.api_key, p.base_url, p.config,
|
||||
p.priority, p.is_active, m.model_alias, m.real_model_id, m.cost_per_1k_tokens
|
||||
FROM ai_providers p
|
||||
LEFT JOIN ai_model_mapping m ON m.provider_id = p.id
|
||||
WHERE p.is_active = 1
|
||||
""").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not query providers: {e}")
|
||||
return []
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def log_router_attempt(request_id, user_id, model_alias, provider_used,
|
||||
tokens_in, tokens_out, duration_ms, status,
|
||||
error_message=""):
|
||||
conn = get_db()
|
||||
try:
|
||||
conn.execute(
|
||||
"""INSERT INTO ai_router_log
|
||||
(request_id, user_id, model_alias, provider_used,
|
||||
tokens_in, tokens_out, duration_ms, status, error_message)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(request_id, user_id, model_alias, provider_used,
|
||||
tokens_in, tokens_out, duration_ms, status, error_message),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to log router attempt: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def upsert_provider(name, provider_type, api_key="", base_url="",
|
||||
config=None, priority=99, is_active=1):
|
||||
conn = get_db()
|
||||
try:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM ai_providers WHERE name = ?", (name,)
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute(
|
||||
"""UPDATE ai_providers SET provider_type=?, api_key=?, base_url=?,
|
||||
config=?, priority=?, is_active=?, updated_at=datetime('now')
|
||||
WHERE name=?""",
|
||||
(provider_type, api_key, base_url,
|
||||
config or "{}", priority, is_active, name),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"""INSERT INTO ai_providers
|
||||
(name, provider_type, api_key, base_url, config, priority, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(name, provider_type, api_key, base_url,
|
||||
config or "{}", priority, is_active),
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to upsert provider: {e}")
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def upsert_model_mapping(model_alias, provider_id, real_model_id, cost_per_1k=0):
|
||||
conn = get_db()
|
||||
try:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM ai_model_mapping WHERE model_alias = ?", (model_alias,)
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute(
|
||||
"""UPDATE ai_model_mapping SET provider_id=?, real_model_id=?,
|
||||
cost_per_1k_tokens=? WHERE model_alias=?""",
|
||||
(provider_id, real_model_id, cost_per_1k, model_alias),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"""INSERT INTO ai_model_mapping
|
||||
(model_alias, provider_id, real_model_id, cost_per_1k_tokens)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(model_alias, provider_id, real_model_id, cost_per_1k),
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to upsert model mapping: {e}")
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
14
ai_router/providers/__init__.py
Normal file
14
ai_router/providers/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from .base import AIProvider
|
||||
from .openai_adapter import OpenAIAdapter
|
||||
from .anthropic_adapter import AnthropicAdapter
|
||||
from .google_adapter import GoogleAdapter
|
||||
from .mistral_adapter import MistralAdapter
|
||||
|
||||
PROVIDER_MAP = {
|
||||
"openai": OpenAIAdapter,
|
||||
"anthropic": AnthropicAdapter,
|
||||
"google": GoogleAdapter,
|
||||
"mistral": MistralAdapter,
|
||||
}
|
||||
|
||||
__all__ = ["AIProvider", "PROVIDER_MAP", "OpenAIAdapter", "AnthropicAdapter", "GoogleAdapter", "MistralAdapter"]
|
||||
57
ai_router/providers/anthropic_adapter.py
Normal file
57
ai_router/providers/anthropic_adapter.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from .base import AIProvider
|
||||
|
||||
logger = logging.getLogger("ai_router.anthropic")
|
||||
|
||||
|
||||
class AnthropicAdapter(AIProvider):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "anthropic"
|
||||
|
||||
def chat(self, messages: list, model: str, api_key: Optional[str] = None, **kwargs) -> dict:
|
||||
from anthropic import Anthropic
|
||||
|
||||
key = api_key or self.get_api_key()
|
||||
client = Anthropic(api_key=key)
|
||||
|
||||
system_msg = None
|
||||
chat_messages = messages
|
||||
if messages and messages[0].get("role") == "system":
|
||||
system_msg = messages[0]["content"]
|
||||
chat_messages = messages[1:]
|
||||
|
||||
resp = client.messages.create(
|
||||
model=model,
|
||||
system=system_msg,
|
||||
messages=[{"role": m["role"], "content": m["content"]} for m in chat_messages],
|
||||
**{k: v for k, v in kwargs.items() if k in ("temperature", "max_tokens", "top_p")},
|
||||
)
|
||||
return {
|
||||
"content": resp.content[0].text if resp.content else "",
|
||||
"model": resp.model,
|
||||
"provider": self.name,
|
||||
"usage": {
|
||||
"prompt_tokens": resp.usage.input_tokens if resp.usage else 0,
|
||||
"completion_tokens": resp.usage.output_tokens if resp.usage else 0,
|
||||
"total_tokens": (resp.usage.input_tokens + resp.usage.output_tokens) if resp.usage else 0,
|
||||
},
|
||||
}
|
||||
|
||||
def models(self) -> list:
|
||||
from anthropic import Anthropic
|
||||
|
||||
client = Anthropic(api_key=self.get_api_key())
|
||||
return [m.id for m in client.models.list()]
|
||||
|
||||
def check_health(self) -> dict:
|
||||
try:
|
||||
from anthropic import Anthropic
|
||||
client = Anthropic(api_key=self.get_api_key())
|
||||
client.models.list()
|
||||
return {"status": "ok", "details": "API reachable"}
|
||||
except Exception as e:
|
||||
logger.warning(f"Anthropic health check failed: {e}")
|
||||
return {"status": "error", "details": str(e)}
|
||||
44
ai_router/providers/base.py
Normal file
44
ai_router/providers/base.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class AIProvider(ABC):
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Provider identifier (openai, anthropic, google, mistral)."""
|
||||
|
||||
@abstractmethod
|
||||
def chat(self, messages: list, model: str, **kwargs) -> dict:
|
||||
"""Send a chat completion request. Returns dict with at least:
|
||||
{
|
||||
"content": str,
|
||||
"model": str,
|
||||
"provider": self.name,
|
||||
"usage": {"prompt_tokens": int, "completion_tokens": int, "total_tokens": int}
|
||||
}
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def models(self) -> list:
|
||||
"""Return list of available models from this provider."""
|
||||
|
||||
@abstractmethod
|
||||
def check_health(self) -> dict:
|
||||
"""Check provider connectivity. Returns {"status": "ok"|"error", "details": str}"""
|
||||
|
||||
def get_api_key(self, db_config: Optional[dict] = None) -> Optional[str]:
|
||||
"""Resolve API key: DB override > env var."""
|
||||
provider_env_map = {
|
||||
"openai": "OPENAI_API_KEY",
|
||||
"anthropic": "ANTHROPIC_API_KEY",
|
||||
"google": "GOOGLE_API_KEY",
|
||||
"mistral": "MISTRAL_API_KEY",
|
||||
}
|
||||
if db_config and db_config.get("api_key"):
|
||||
return db_config["api_key"]
|
||||
import os
|
||||
env_var = provider_env_map.get(self.name)
|
||||
if env_var:
|
||||
return os.environ.get(env_var)
|
||||
return None
|
||||
57
ai_router/providers/google_adapter.py
Normal file
57
ai_router/providers/google_adapter.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from .base import AIProvider
|
||||
|
||||
logger = logging.getLogger("ai_router.google")
|
||||
|
||||
|
||||
class GoogleAdapter(AIProvider):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "google"
|
||||
|
||||
def chat(self, messages: list, model: str, api_key: Optional[str] = None, **kwargs) -> dict:
|
||||
from google import genai
|
||||
|
||||
key = api_key or self.get_api_key()
|
||||
client = genai.Client(api_key=key)
|
||||
|
||||
system_instruction = None
|
||||
chat_messages = messages
|
||||
if messages and messages[0].get("role") == "system":
|
||||
system_instruction = messages[0]["content"]
|
||||
chat_messages = messages[1:]
|
||||
|
||||
contents = []
|
||||
for m in chat_messages:
|
||||
role = "user" if m["role"] in ("user", "system") else "model"
|
||||
contents.append({"role": role, "parts": [{"text": m["content"]}]})
|
||||
|
||||
resp = client.models.generate_content(
|
||||
model=model,
|
||||
contents=contents,
|
||||
config={"system_instruction": system_instruction} if system_instruction else None,
|
||||
)
|
||||
return {
|
||||
"content": resp.text or "",
|
||||
"model": model,
|
||||
"provider": self.name,
|
||||
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
||||
}
|
||||
|
||||
def models(self) -> list:
|
||||
from google import genai
|
||||
|
||||
client = genai.Client(api_key=self.get_api_key())
|
||||
return [m.name for m in client.models.list()]
|
||||
|
||||
def check_health(self) -> dict:
|
||||
try:
|
||||
from google import genai
|
||||
client = genai.Client(api_key=self.get_api_key())
|
||||
client.models.list()
|
||||
return {"status": "ok", "details": "API reachable"}
|
||||
except Exception as e:
|
||||
logger.warning(f"Google health check failed: {e}")
|
||||
return {"status": "error", "details": str(e)}
|
||||
70
ai_router/providers/mistral_adapter.py
Normal file
70
ai_router/providers/mistral_adapter.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
from .base import AIProvider
|
||||
|
||||
logger = logging.getLogger("ai_router.mistral")
|
||||
|
||||
MISTRAL_API_BASE = "https://api.mistral.ai/v1"
|
||||
|
||||
|
||||
class MistralAdapter(AIProvider):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "mistral"
|
||||
|
||||
def _headers(self, api_key: Optional[str] = None) -> dict:
|
||||
key = api_key or self.get_api_key()
|
||||
return {
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def chat(self, messages: list, model: str, api_key: Optional[str] = None, **kwargs) -> dict:
|
||||
key = api_key or self.get_api_key()
|
||||
headers = self._headers(key)
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
}
|
||||
for k in ("temperature", "max_tokens", "top_p", "stream"):
|
||||
if k in kwargs:
|
||||
payload[k] = kwargs[k]
|
||||
|
||||
resp = requests.post(
|
||||
f"{MISTRAL_API_BASE}/chat/completions",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
choice = data["choices"][0]
|
||||
return {
|
||||
"content": choice["message"]["content"] or "",
|
||||
"model": data.get("model", model),
|
||||
"provider": self.name,
|
||||
"usage": {
|
||||
"prompt_tokens": data.get("usage", {}).get("prompt_tokens", 0),
|
||||
"completion_tokens": data.get("usage", {}).get("completion_tokens", 0),
|
||||
"total_tokens": data.get("usage", {}).get("total_tokens", 0),
|
||||
},
|
||||
}
|
||||
|
||||
def models(self) -> list:
|
||||
resp = requests.get(f"{MISTRAL_API_BASE}/models", headers=self._headers(), timeout=30)
|
||||
resp.raise_for_status()
|
||||
return [m["id"] for m in resp.json().get("data", [])]
|
||||
|
||||
def check_health(self) -> dict:
|
||||
try:
|
||||
resp = requests.get(f"{MISTRAL_API_BASE}/models", headers=self._headers(), timeout=10)
|
||||
resp.raise_for_status()
|
||||
return {"status": "ok", "details": "API reachable"}
|
||||
except Exception as e:
|
||||
logger.warning(f"Mistral health check failed: {e}")
|
||||
return {"status": "error", "details": str(e)}
|
||||
50
ai_router/providers/openai_adapter.py
Normal file
50
ai_router/providers/openai_adapter.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from .base import AIProvider
|
||||
|
||||
logger = logging.getLogger("ai_router.openai")
|
||||
|
||||
|
||||
class OpenAIAdapter(AIProvider):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "openai"
|
||||
|
||||
def chat(self, messages: list, model: str, api_key: Optional[str] = None, **kwargs) -> dict:
|
||||
from openai import OpenAI
|
||||
|
||||
key = api_key or self.get_api_key()
|
||||
client = OpenAI(api_key=key)
|
||||
resp = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
**{k: v for k, v in kwargs.items() if k in ("temperature", "max_tokens", "top_p", "stream")},
|
||||
)
|
||||
choice = resp.choices[0]
|
||||
return {
|
||||
"content": choice.message.content or "",
|
||||
"model": resp.model,
|
||||
"provider": self.name,
|
||||
"usage": {
|
||||
"prompt_tokens": resp.usage.prompt_tokens if resp.usage else 0,
|
||||
"completion_tokens": resp.usage.completion_tokens if resp.usage else 0,
|
||||
"total_tokens": resp.usage.total_tokens if resp.usage else 0,
|
||||
},
|
||||
}
|
||||
|
||||
def models(self) -> list:
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(api_key=self.get_api_key())
|
||||
return [m.id for m in client.models.list()]
|
||||
|
||||
def check_health(self) -> dict:
|
||||
try:
|
||||
from openai import OpenAI
|
||||
client = OpenAI(api_key=self.get_api_key())
|
||||
client.models.list()
|
||||
return {"status": "ok", "details": "API reachable"}
|
||||
except Exception as e:
|
||||
logger.warning(f"OpenAI health check failed: {e}")
|
||||
return {"status": "error", "details": str(e)}
|
||||
174
ai_router/router.py
Normal file
174
ai_router/router.py
Normal file
@@ -0,0 +1,174 @@
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from .providers import PROVIDER_MAP, AIProvider
|
||||
from .models import get_providers_from_db, log_router_attempt
|
||||
|
||||
logger = logging.getLogger("ai_router.router")
|
||||
|
||||
DEFAULT_MODEL_MAP = {
|
||||
"gpt-4o": {"provider": "openai", "real_model": "gpt-4o"},
|
||||
"gpt-4o-mini": {"provider": "openai", "real_model": "gpt-4o-mini"},
|
||||
"claude-3-opus": {"provider": "anthropic", "real_model": "claude-3-opus-20240229"},
|
||||
"claude-3-sonnet": {"provider": "anthropic", "real_model": "claude-3-sonnet-20240229"},
|
||||
"claude-3-haiku": {"provider": "anthropic", "real_model": "claude-3-haiku-20240307"},
|
||||
"gemini-pro": {"provider": "google", "real_model": "gemini-1.5-pro"},
|
||||
"gemini-flash": {"provider": "google", "real_model": "gemini-1.5-flash"},
|
||||
"mistral-large": {"provider": "mistral", "real_model": "mistral-large-latest"},
|
||||
"mistral-small": {"provider": "mistral", "real_model": "mistral-small-latest"},
|
||||
}
|
||||
|
||||
|
||||
class AIRouter:
|
||||
def __init__(self):
|
||||
self._provider_instances = {}
|
||||
|
||||
def get_provider(self, name: str) -> Optional[AIProvider]:
|
||||
if name not in self._provider_instances:
|
||||
cls = PROVIDER_MAP.get(name)
|
||||
if not cls:
|
||||
return None
|
||||
self._provider_instances[name] = cls()
|
||||
return self._provider_instances[name]
|
||||
|
||||
def _resolve_model(self, model_alias: str) -> Optional[dict]:
|
||||
mapping = self._load_model_mappings()
|
||||
return mapping.get(model_alias)
|
||||
|
||||
def _load_model_mappings(self) -> dict:
|
||||
db_mappings = []
|
||||
try:
|
||||
db_mappings = get_providers_from_db()
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load model mappings from DB: {e}")
|
||||
|
||||
merged = dict(DEFAULT_MODEL_MAP)
|
||||
for entry in db_mappings:
|
||||
alias = entry.get("model_alias")
|
||||
if alias:
|
||||
merged[alias] = {
|
||||
"provider": entry["provider_type"],
|
||||
"real_model": entry.get("real_model_id", alias),
|
||||
"cost_per_1k": entry.get("cost_per_1k_tokens", 0),
|
||||
"db_config": entry,
|
||||
}
|
||||
return merged
|
||||
|
||||
def _get_prioritized_providers(self):
|
||||
providers = []
|
||||
try:
|
||||
db_providers = get_providers_from_db()
|
||||
seen_names = set()
|
||||
for p in sorted(db_providers, key=lambda x: x.get("priority", 99)):
|
||||
name = p["provider_type"]
|
||||
if name not in seen_names:
|
||||
seen_names.add(name)
|
||||
providers.append((name, p))
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load provider priority from DB: {e}")
|
||||
|
||||
if not providers:
|
||||
default_order = ["openai", "anthropic", "google", "mistral"]
|
||||
providers = [(n, None) for n in default_order]
|
||||
return providers
|
||||
|
||||
def chat(self, messages: list, model_alias: str, user_id: Optional[int] = None, **kwargs) -> dict:
|
||||
request_id = str(uuid.uuid4())
|
||||
start_time = time.time()
|
||||
|
||||
model_info = self._resolve_model(model_alias)
|
||||
if not model_info:
|
||||
return {"error": f"Unknown model: {model_alias}", "status": "error"}
|
||||
|
||||
provider_order = self._get_prioritized_providers()
|
||||
preferred_provider = model_info["provider"]
|
||||
real_model = model_info["real_model"]
|
||||
|
||||
ordered = []
|
||||
for name, db_config in provider_order:
|
||||
if name == preferred_provider:
|
||||
ordered.insert(0, (name, db_config))
|
||||
else:
|
||||
ordered.append((name, db_config))
|
||||
|
||||
if preferred_provider not in [p[0] for p in ordered]:
|
||||
ordered.insert(0, (preferred_provider, None))
|
||||
|
||||
last_error = None
|
||||
for attempt, (provider_name, db_config) in enumerate(ordered):
|
||||
provider = self.get_provider(provider_name)
|
||||
if not provider:
|
||||
continue
|
||||
|
||||
try:
|
||||
if attempt > 0:
|
||||
backoff = min(2 ** (attempt - 1), 30)
|
||||
logger.info(f"Failover to {provider_name} after {backoff}s backoff (attempt {attempt})")
|
||||
time.sleep(backoff)
|
||||
|
||||
api_key = provider.get_api_key(db_config)
|
||||
if not api_key:
|
||||
logger.warning(f"No API key configured for {provider_name}, skipping")
|
||||
continue
|
||||
|
||||
result = provider.chat(messages, model=real_model, api_key=api_key, **kwargs)
|
||||
elapsed = int((time.time() - start_time) * 1000)
|
||||
|
||||
log_router_attempt(
|
||||
request_id=request_id,
|
||||
user_id=user_id,
|
||||
model_alias=model_alias,
|
||||
provider_used=provider_name,
|
||||
tokens_in=result.get("usage", {}).get("prompt_tokens", 0),
|
||||
tokens_out=result.get("usage", {}).get("completion_tokens", 0),
|
||||
duration_ms=elapsed,
|
||||
status="success",
|
||||
)
|
||||
result["request_id"] = request_id
|
||||
result["status"] = "success"
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
elapsed = int((time.time() - start_time) * 1000)
|
||||
logger.warning(f"Provider {provider_name} failed: {e}")
|
||||
log_router_attempt(
|
||||
request_id=request_id,
|
||||
user_id=user_id,
|
||||
model_alias=model_alias,
|
||||
provider_used=provider_name,
|
||||
tokens_in=0,
|
||||
tokens_out=0,
|
||||
duration_ms=elapsed,
|
||||
status="error",
|
||||
error_message=last_error,
|
||||
)
|
||||
|
||||
elapsed = int((time.time() - start_time) * 1000)
|
||||
return {
|
||||
"error": f"All providers failed. Last error: {last_error}",
|
||||
"status": "error",
|
||||
"request_id": request_id,
|
||||
"duration_ms": elapsed,
|
||||
}
|
||||
|
||||
def check_all_providers_health(self) -> dict:
|
||||
results = {}
|
||||
for name in PROVIDER_MAP:
|
||||
provider = self.get_provider(name)
|
||||
results[name] = provider.check_health()
|
||||
return results
|
||||
|
||||
def list_available_models(self) -> list:
|
||||
model_map = self._load_model_mappings()
|
||||
return [
|
||||
{
|
||||
"alias": alias,
|
||||
"provider": info["provider"],
|
||||
"real_model": info["real_model"],
|
||||
"cost_per_1k_tokens": info.get("cost_per_1k", 0),
|
||||
}
|
||||
for alias, info in model_map.items()
|
||||
]
|
||||
93
ai_router/utils.py
Normal file
93
ai_router/utils.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from functools import wraps
|
||||
|
||||
from flask import request, jsonify
|
||||
|
||||
logger = logging.getLogger("ai_router")
|
||||
|
||||
TOKEN_BROKER_URL = os.environ.get(
|
||||
"TOKEN_BROKER_URL", "http://localhost:8783"
|
||||
)
|
||||
|
||||
|
||||
def verify_token_via_broker(token: str) -> dict:
|
||||
"""Verify an API token via the token-broker /verify endpoint."""
|
||||
import requests
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{TOKEN_BROKER_URL}/api/v1/tokens/verify",
|
||||
json={"token": token},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
if data.get("valid"):
|
||||
return data
|
||||
return {}
|
||||
except requests.RequestException as e:
|
||||
logger.warning(f"Token broker unreachable: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def require_auth(f):
|
||||
"""Decorator: validate Bearer or X-API-Key via token-broker."""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
api_key = request.headers.get("X-API-Key", "")
|
||||
|
||||
raw_token = ""
|
||||
if auth_header.startswith("Bearer "):
|
||||
raw_token = auth_header.split(" ", 1)[1]
|
||||
elif api_key:
|
||||
raw_token = api_key
|
||||
|
||||
if not raw_token:
|
||||
return jsonify({"error": "Authentication required"}), 401
|
||||
|
||||
payload = verify_token_via_broker(raw_token)
|
||||
if not payload or not payload.get("valid"):
|
||||
return jsonify({"error": "Invalid or expired token"}), 401
|
||||
|
||||
request.current_user = {
|
||||
"user_id": payload.get("user_id"),
|
||||
"token_id": payload.get("token_id"),
|
||||
"scopes": payload.get("scopes", []),
|
||||
}
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
"""Decorator: require admin scope on the authenticated token."""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
api_key = request.headers.get("X-API-Key", "")
|
||||
|
||||
raw_token = ""
|
||||
if auth_header.startswith("Bearer "):
|
||||
raw_token = auth_header.split(" ", 1)[1]
|
||||
elif api_key:
|
||||
raw_token = api_key
|
||||
|
||||
if not raw_token:
|
||||
return jsonify({"error": "Authentication required"}), 401
|
||||
|
||||
payload = verify_token_via_broker(raw_token)
|
||||
if not payload or not payload.get("valid"):
|
||||
return jsonify({"error": "Invalid or expired token"}), 401
|
||||
|
||||
scopes = payload.get("scopes", [])
|
||||
if "admin" not in scopes and "ai_router_admin" not in scopes:
|
||||
return jsonify({"error": "Admin access required"}), 403
|
||||
|
||||
request.current_user = {
|
||||
"user_id": payload.get("user_id"),
|
||||
"token_id": payload.get("token_id"),
|
||||
"scopes": scopes,
|
||||
}
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
77
ai_router_api.py
Normal file
77
ai_router_api.py
Normal file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AI Router API — Multi-provider LLM routing with failover
|
||||
Port: 8783 | DB: SQLite ai_router.db
|
||||
HRT-200 — AI Router (Multi-provider + failover)
|
||||
|
||||
Endpoints:
|
||||
GET /api/v1/ai/health — Check all providers health
|
||||
GET /api/v1/ai/models — List available models
|
||||
POST /api/v1/ai/chat — Chat completion with auto-failover
|
||||
GET /api/v1/ai/admin/providers — List configured providers
|
||||
POST /api/v1/ai/admin/providers — Upsert a provider
|
||||
POST /api/v1/ai/admin/model-mappings— Upsert a model mapping
|
||||
DELETE /api/v1/ai/admin/providers/:id — Remove a provider
|
||||
GET /api/v1/ai/usage — Usage logs
|
||||
GET /api/v1/ai/usage/summary — Aggregated usage stats
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import sys
|
||||
|
||||
from flask import Flask, jsonify
|
||||
from flask_cors import CORS
|
||||
|
||||
LOG_DIR = os.path.join(os.path.dirname(__file__), "ai_router", "logs")
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] ai-router: %(name)s: %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.handlers.RotatingFileHandler(
|
||||
os.path.join(LOG_DIR, "ai_router.log"),
|
||||
maxBytes=5 * 1024 * 1024,
|
||||
backupCount=3,
|
||||
),
|
||||
],
|
||||
)
|
||||
logger = logging.getLogger("ai_router")
|
||||
|
||||
PORT = int(os.environ.get("AI_ROUTER_PORT", "8783"))
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
|
||||
from ai_router.api import register_ai_router
|
||||
from ai_router.models import init_db
|
||||
|
||||
init_db()
|
||||
register_ai_router(app)
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(e):
|
||||
return jsonify({"error": "not_found", "message": "Route not found"}), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(e):
|
||||
logger.error(f"Internal error: {e}")
|
||||
return jsonify({"error": "internal_error", "message": "Internal server error"}), 500
|
||||
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("=" * 60)
|
||||
logger.info("AI Router API starting...")
|
||||
logger.info(f"Port: {PORT}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
app = create_app()
|
||||
debug = os.environ.get("FLASK_ENV", "production") == "development"
|
||||
app.run(host="0.0.0.0", port=PORT, debug=debug)
|
||||
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
|
||||
59
api_tokens_db.py
Normal file
59
api_tokens_db.py
Normal file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
api_tokens_db.py — DB migration for personal API tokens + user webhooks
|
||||
HRT-80: API Token personnel + Webhook alertes (Pro)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
logger = logging.getLogger("turf_saas.api_tokens_db")
|
||||
|
||||
|
||||
def get_db() -> sqlite3.Connection:
|
||||
"""Return a SQLite connection (reads TURF_SAAS_DB dynamically for test isolation)."""
|
||||
db_path = os.environ.get("TURF_SAAS_DB", DB_PATH)
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def migrate_api_tokens_tables() -> None:
|
||||
"""Idempotent migration: create user_api_tokens and user_webhooks."""
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
c.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS user_api_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
token_prefix TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
last_used_at DATETIME,
|
||||
revoked INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON user_api_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON user_api_tokens(token_hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_webhooks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL UNIQUE,
|
||||
url TEXT NOT NULL,
|
||||
secret TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhooks_user ON user_webhooks(user_id);
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(
|
||||
"[api_tokens_db] Tables user_api_tokens + user_webhooks created/verified."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
migrate_api_tokens_tables()
|
||||
print("[api_tokens_db] Migration complete.")
|
||||
65
api_v1/__init__.py
Normal file
65
api_v1/__init__.py
Normal file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
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
|
||||
/api/v1/courses/ — courses du jour
|
||||
/api/v1/predictions/— predictions ML
|
||||
/api/v1/valuebets — value bets (premium+)
|
||||
/api/v1/backtest — backtest historique (pro)
|
||||
/api/v1/export/ — export CSV (pro)
|
||||
/api/v1/metrics — métriques perf ML (premium+)
|
||||
/api/v1/billing/ — Stripe checkout, portal, webhook, status
|
||||
/api/v1/user/ — config utilisateur, alertes Telegram (premium+)
|
||||
/api/v1/user/api-token — Personal API token (Pro)
|
||||
/api/v1/user/webhook — Webhook config (Pro)
|
||||
/api/v1/history — historique préd. ML (Free:7j, Premium:90j, Pro:illimité)
|
||||
/api/v1/org/ — organisations Pro (multi-compte, max 5 users)
|
||||
/api/v1/docs — Swagger UI (via flasgger, registered on app)
|
||||
/api/v1/ml/feedback/run — trigger feedback loop ML (admin)
|
||||
/api/v1/ml/feedback/stats — stats par stratégie (premium+)
|
||||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
from .routes.health import health_bp
|
||||
from .routes.courses import courses_bp
|
||||
from .routes.predictions import predictions_bp
|
||||
from .routes.valuebets import valuebets_bp
|
||||
from .routes.backtest import backtest_bp
|
||||
from .routes.export import export_bp
|
||||
from .routes.metrics import metrics_bp
|
||||
from .routes.billing import billing_bp
|
||||
from .routes.user import user_bp
|
||||
from .routes.user_tokens import user_tokens_bp
|
||||
from .routes.history import history_bp
|
||||
from .routes.org import org_bp
|
||||
from .routes.ml_feedback import ml_feedback_bp
|
||||
from .routes.admin import admin_bp
|
||||
|
||||
# Master blueprint that aggregates all sub-routes under /api/v1
|
||||
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
|
||||
|
||||
|
||||
def register_api_v1(app):
|
||||
"""Register all API v1 blueprints onto the Flask app."""
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(courses_bp)
|
||||
app.register_blueprint(predictions_bp)
|
||||
app.register_blueprint(valuebets_bp)
|
||||
app.register_blueprint(backtest_bp)
|
||||
app.register_blueprint(export_bp)
|
||||
app.register_blueprint(metrics_bp)
|
||||
app.register_blueprint(billing_bp)
|
||||
app.register_blueprint(user_bp)
|
||||
app.register_blueprint(user_tokens_bp)
|
||||
app.register_blueprint(history_bp)
|
||||
app.register_blueprint(org_bp)
|
||||
app.register_blueprint(ml_feedback_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
0
api_v1/routes/__init__.py
Normal file
0
api_v1/routes/__init__.py
Normal file
587
api_v1/routes/admin.py
Normal file
587
api_v1/routes/admin.py
Normal file
@@ -0,0 +1,587 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Admin Blueprint — Client CRUD + Subscription management
|
||||
HRT-199 — Foundation (Client CRUD + Auth + Subscription)
|
||||
|
||||
Endpoints:
|
||||
POST /api/v1/admin/setup — init first admin (no auth, 1 call only)
|
||||
GET /api/v1/admin/clients — list all clients (paginated, filterable)
|
||||
GET /api/v1/admin/clients/<id> — client detail + subscription
|
||||
PUT /api/v1/admin/clients/<id> — update client (plan, name, email)
|
||||
DELETE /api/v1/admin/clients/<id> — delete client + tokens + subscription
|
||||
POST /api/v1/admin/clients/<id>/suspend — suspend client (set plan=suspended)
|
||||
POST /api/v1/admin/clients/<id>/activate — reactivate client (restore plan)
|
||||
GET /api/v1/admin/stats — client stats (total, by plan, new/30d)
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from functools import wraps
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from saas_auth import require_auth
|
||||
from api_v1.utils import get_db, paginate_query, get_pagination_params, not_found, bad_request, internal_error
|
||||
|
||||
logger = logging.getLogger("turf_saas.admin")
|
||||
|
||||
admin_bp = Blueprint("admin", __name__, url_prefix="/api/v1/admin")
|
||||
|
||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
|
||||
|
||||
def _get_saas_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return conn
|
||||
|
||||
|
||||
def migrate_admin_tables():
|
||||
"""Idempotent: create admin_users table."""
|
||||
conn = _get_saas_db()
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS admin_users (
|
||||
user_id TEXT PRIMARY KEY REFERENCES saas_users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_by TEXT
|
||||
);
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
try:
|
||||
migrate_admin_tables()
|
||||
except Exception as e:
|
||||
logger.warning("admin DB init warning: %s", e)
|
||||
|
||||
|
||||
def _is_admin(user_id: str, db=None) -> bool:
|
||||
if not user_id:
|
||||
return False
|
||||
close = False
|
||||
if db is None:
|
||||
db = _get_saas_db()
|
||||
close = True
|
||||
try:
|
||||
row = db.execute(
|
||||
"SELECT 1 FROM admin_users WHERE user_id = ?", (user_id,)
|
||||
).fetchone()
|
||||
return row is not None
|
||||
finally:
|
||||
if close:
|
||||
db.close()
|
||||
|
||||
|
||||
def require_admin(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
user = getattr(request, "current_user", None)
|
||||
if not user:
|
||||
return jsonify({"error": "Non authentifié"}), 401
|
||||
if not _is_admin(user["id"]):
|
||||
return jsonify({"error": "Accès administrateur requis"}), 403
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
def _user_to_client(row) -> dict:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"email": row["email"],
|
||||
"firstname": row.get("firstname", ""),
|
||||
"lastname": row.get("lastname", ""),
|
||||
"plan": row.get("plan", "free"),
|
||||
"telegram_chat_id": row.get("telegram_chat_id"),
|
||||
"alert_value_bets": bool(row.get("alert_value_bets", 1)),
|
||||
"alert_top1": bool(row.get("alert_top1", 1)),
|
||||
"alert_quinte_only": bool(row.get("alert_quinte_only", 0)),
|
||||
"created_at": row.get("created_at"),
|
||||
"updated_at": row.get("updated_at"),
|
||||
}
|
||||
|
||||
|
||||
def _fetch_subscription(db, user_id: str):
|
||||
return db.execute(
|
||||
"""SELECT * FROM saas_subscriptions
|
||||
WHERE user_id = ? ORDER BY start_date DESC LIMIT 1""",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
# ─── POST /api/v1/admin/setup ─────────────────────────────────
|
||||
|
||||
|
||||
@admin_bp.route("/setup", methods=["POST"])
|
||||
def admin_setup():
|
||||
"""Init first admin (no auth). Only works once — when admin_users is empty."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
if not email or "@" not in email:
|
||||
return jsonify({"error": "Email valide requis"}), 400
|
||||
|
||||
db = _get_saas_db()
|
||||
try:
|
||||
existing = db.execute("SELECT 1 FROM admin_users LIMIT 1").fetchone()
|
||||
if existing:
|
||||
return jsonify({"error": "Admin déjà configuré"}), 409
|
||||
|
||||
user = db.execute(
|
||||
"SELECT id, email FROM saas_users WHERE email = ?", (email,)
|
||||
).fetchone()
|
||||
if not user:
|
||||
return jsonify({"error": "Utilisateur introuvable avec cet email"}), 404
|
||||
|
||||
db.execute(
|
||||
"INSERT INTO admin_users (user_id, created_by) VALUES (?, 'setup')",
|
||||
(user["id"],),
|
||||
)
|
||||
db.commit()
|
||||
logger.info("Admin setup: user %s (%s) promoted to admin", user["id"], email)
|
||||
return jsonify({"ok": True, "user_id": user["id"], "email": email}), 201
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("admin_setup error: %s", e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ─── GET /api/v1/admin/clients ─────────────────────────────────
|
||||
|
||||
|
||||
@admin_bp.route("/clients", methods=["GET"])
|
||||
@require_auth
|
||||
@require_admin
|
||||
def list_clients():
|
||||
"""List all clients with pagination and filters.
|
||||
---
|
||||
tags:
|
||||
- Admin
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- in: query
|
||||
name: page
|
||||
type: integer
|
||||
- in: query
|
||||
name: per_page
|
||||
type: integer
|
||||
- in: query
|
||||
name: search
|
||||
type: string
|
||||
description: Search by email or name
|
||||
- in: query
|
||||
name: plan
|
||||
type: string
|
||||
description: Filter by plan (free, premium, pro, suspended)
|
||||
- in: query
|
||||
name: sort_by
|
||||
type: string
|
||||
enum: [created_at, email, plan, updated_at]
|
||||
- in: query
|
||||
name: sort_order
|
||||
type: string
|
||||
enum: [asc, desc]
|
||||
responses:
|
||||
200:
|
||||
description: Paginated client list
|
||||
403:
|
||||
description: Admin access required
|
||||
"""
|
||||
page = request.args.get("page", 1, type=int)
|
||||
per_page = request.args.get("per_page", 20, type=int)
|
||||
search = request.args.get("search", "").strip()
|
||||
plan_filter = request.args.get("plan", "").strip()
|
||||
sort_by = request.args.get("sort_by", "created_at").strip()
|
||||
sort_order = request.args.get("sort_order", "desc").strip()
|
||||
|
||||
if sort_by not in ("created_at", "email", "plan", "updated_at"):
|
||||
sort_by = "created_at"
|
||||
if sort_order not in ("asc", "desc"):
|
||||
sort_order = "desc"
|
||||
if per_page < 1 or per_page > 100:
|
||||
per_page = 20
|
||||
if page < 1:
|
||||
page = 1
|
||||
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
db = _get_saas_db()
|
||||
try:
|
||||
conditions = []
|
||||
params = []
|
||||
if search:
|
||||
conditions.append("(email LIKE ? OR firstname LIKE ? OR lastname LIKE ?)")
|
||||
p = f"%{search}%"
|
||||
params.extend([p, p, p])
|
||||
if plan_filter:
|
||||
conditions.append("plan = ?")
|
||||
params.append(plan_filter)
|
||||
|
||||
where = (" WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||
|
||||
total = db.execute(
|
||||
f"SELECT COUNT(*) FROM saas_users{where}", params
|
||||
).fetchone()[0]
|
||||
|
||||
rows = db.execute(
|
||||
f"SELECT * FROM saas_users{where} ORDER BY {sort_by} {sort_order} LIMIT ? OFFSET ?",
|
||||
params + [per_page, offset],
|
||||
).fetchall()
|
||||
|
||||
result = []
|
||||
for row in rows:
|
||||
client = _user_to_client(row)
|
||||
sub = _fetch_subscription(db, row["id"])
|
||||
if sub:
|
||||
client["subscription"] = {
|
||||
"plan": sub["plan"],
|
||||
"status": sub["status"],
|
||||
"start_date": sub["start_date"],
|
||||
"current_period_end": sub["current_period_end"],
|
||||
"stripe_customer_id": sub["stripe_customer_id"],
|
||||
}
|
||||
else:
|
||||
client["subscription"] = None
|
||||
result.append(client)
|
||||
|
||||
return jsonify({
|
||||
"clients": result,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total": total,
|
||||
"total_pages": (total + per_page - 1) // per_page,
|
||||
},
|
||||
}), 200
|
||||
except Exception as e:
|
||||
logger.error("list_clients error: %s", e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ─── GET /api/v1/admin/clients/<id> ────────────────────────────
|
||||
|
||||
|
||||
@admin_bp.route("/clients/<string:client_id>", methods=["GET"])
|
||||
@require_auth
|
||||
@require_admin
|
||||
def get_client(client_id: str):
|
||||
"""Get client details with subscription info.
|
||||
---
|
||||
tags:
|
||||
- Admin
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
type: string
|
||||
required: true
|
||||
responses:
|
||||
200:
|
||||
description: Client details
|
||||
404:
|
||||
description: Client not found
|
||||
"""
|
||||
db = _get_saas_db()
|
||||
try:
|
||||
row = db.execute(
|
||||
"SELECT * FROM saas_users WHERE id = ?", (client_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return jsonify({"error": "Client introuvable"}), 404
|
||||
|
||||
client = _user_to_client(row)
|
||||
sub = _fetch_subscription(db, client_id)
|
||||
if sub:
|
||||
client["subscription"] = {
|
||||
"id": sub["id"],
|
||||
"plan": sub["plan"],
|
||||
"status": sub["status"],
|
||||
"start_date": sub["start_date"],
|
||||
"end_date": sub["end_date"],
|
||||
"current_period_end": sub["current_period_end"],
|
||||
"grace_period_end": sub["grace_period_end"],
|
||||
"stripe_customer_id": sub["stripe_customer_id"],
|
||||
"stripe_subscription_id": sub["stripe_subscription_id"],
|
||||
}
|
||||
else:
|
||||
client["subscription"] = None
|
||||
|
||||
return jsonify({"client": client}), 200
|
||||
except Exception as e:
|
||||
logger.error("get_client error: %s", e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ─── PUT /api/v1/admin/clients/<id> ────────────────────────────
|
||||
|
||||
|
||||
@admin_bp.route("/clients/<string:client_id>", methods=["PUT"])
|
||||
@require_auth
|
||||
@require_admin
|
||||
def update_client(client_id: str):
|
||||
"""Update client fields (plan, firstname, lastname, email).
|
||||
---
|
||||
tags:
|
||||
- Admin
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
type: string
|
||||
required: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
firstname: { type: string }
|
||||
lastname: { type: string }
|
||||
email: { type: string }
|
||||
plan: { type: string, enum: [free, premium, pro, suspended] }
|
||||
responses:
|
||||
200:
|
||||
description: Client updated
|
||||
400:
|
||||
description: Invalid parameters
|
||||
404:
|
||||
description: Client not found
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
if not data:
|
||||
return jsonify({"error": "Corps JSON requis"}), 400
|
||||
|
||||
db = _get_saas_db()
|
||||
try:
|
||||
existing = db.execute(
|
||||
"SELECT id FROM saas_users WHERE id = ?", (client_id,)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
return jsonify({"error": "Client introuvable"}), 404
|
||||
|
||||
fields = {}
|
||||
if "firstname" in data:
|
||||
fields["firstname"] = data["firstname"].strip()
|
||||
if "lastname" in data:
|
||||
fields["lastname"] = data["lastname"].strip()
|
||||
if "email" in data:
|
||||
email = data["email"].strip().lower()
|
||||
if "@" not in email:
|
||||
return jsonify({"error": "Email invalide"}), 400
|
||||
fields["email"] = email
|
||||
if "plan" in data:
|
||||
plan = data["plan"].strip().lower()
|
||||
if plan not in ("free", "premium", "pro", "suspended"):
|
||||
return jsonify({"error": "Plan invalide. free|premium|pro|suspended"}), 400
|
||||
fields["plan"] = plan
|
||||
|
||||
if not fields:
|
||||
return jsonify({"ok": True}), 200
|
||||
|
||||
set_clause = ", ".join(f"{k}=?" for k in fields)
|
||||
values = list(fields.values()) + [datetime.now(timezone.utc).isoformat(), client_id]
|
||||
db.execute(
|
||||
f"UPDATE saas_users SET {set_clause}, updated_at=? WHERE id=?", values
|
||||
)
|
||||
db.commit()
|
||||
|
||||
logger.info("Admin %s updated client %s: %s",
|
||||
request.current_user["id"], client_id, fields)
|
||||
return jsonify({"ok": True, "updated": list(fields.keys())}), 200
|
||||
|
||||
except sqlite3.IntegrityError:
|
||||
return jsonify({"error": "Cet email est déjà utilisé"}), 409
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("update_client error: %s", e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ─── DELETE /api/v1/admin/clients/<id> ─────────────────────────
|
||||
|
||||
|
||||
@admin_bp.route("/clients/<string:client_id>", methods=["DELETE"])
|
||||
@require_auth
|
||||
@require_admin
|
||||
def delete_client(client_id: str):
|
||||
"""Delete client and all associated data (tokens, subscriptions).
|
||||
---
|
||||
tags:
|
||||
- Admin
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
type: string
|
||||
required: true
|
||||
responses:
|
||||
200:
|
||||
description: Client deleted
|
||||
404:
|
||||
description: Client not found
|
||||
"""
|
||||
admin_id = request.current_user["id"]
|
||||
if client_id == admin_id:
|
||||
return jsonify({"error": "Impossible de supprimer votre propre compte"}), 400
|
||||
|
||||
db = _get_saas_db()
|
||||
try:
|
||||
existing = db.execute(
|
||||
"SELECT id FROM saas_users WHERE id = ?", (client_id,)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
return jsonify({"error": "Client introuvable"}), 404
|
||||
|
||||
db.execute("DELETE FROM saas_tokens WHERE user_id = ?", (client_id,))
|
||||
db.execute("DELETE FROM saas_subscriptions WHERE user_id = ?", (client_id,))
|
||||
db.execute("DELETE FROM admin_users WHERE user_id = ?", (client_id,))
|
||||
db.execute("DELETE FROM saas_users WHERE id = ?", (client_id,))
|
||||
db.commit()
|
||||
|
||||
logger.info("Admin %s deleted client %s", admin_id, client_id)
|
||||
return jsonify({"ok": True, "deleted_id": client_id}), 200
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("delete_client error: %s", e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ─── POST /api/v1/admin/clients/<id>/suspend ───────────────────
|
||||
|
||||
|
||||
@admin_bp.route("/clients/<string:client_id>/suspend", methods=["POST"])
|
||||
@require_auth
|
||||
@require_admin
|
||||
def suspend_client(client_id: str):
|
||||
"""Suspend a client by setting plan to 'suspended'.
|
||||
---
|
||||
tags:
|
||||
- Admin
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Client suspended
|
||||
404:
|
||||
description: Client not found
|
||||
"""
|
||||
return _set_client_plan(client_id, "suspended")
|
||||
|
||||
|
||||
# ─── POST /api/v1/admin/clients/<id>/activate ──────────────────
|
||||
|
||||
|
||||
@admin_bp.route("/clients/<string:client_id>/activate", methods=["POST"])
|
||||
@require_auth
|
||||
@require_admin
|
||||
def activate_client(client_id: str):
|
||||
"""Reactivate a suspended client to 'free' plan.
|
||||
---
|
||||
tags:
|
||||
- Admin
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Client activated
|
||||
404:
|
||||
description: Client not found
|
||||
"""
|
||||
return _set_client_plan(client_id, "free")
|
||||
|
||||
|
||||
def _set_client_plan(client_id: str, plan: str):
|
||||
db = _get_saas_db()
|
||||
try:
|
||||
existing = db.execute(
|
||||
"SELECT id, plan FROM saas_users WHERE id = ?", (client_id,)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
return jsonify({"error": "Client introuvable"}), 404
|
||||
|
||||
db.execute(
|
||||
"UPDATE saas_users SET plan=?, updated_at=? WHERE id=?",
|
||||
(plan, datetime.now(timezone.utc).isoformat(), client_id),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
action = "suspendu" if plan == "suspended" else "réactivé"
|
||||
logger.info("Client %s %s par admin %s", client_id, action,
|
||||
request.current_user["id"])
|
||||
return jsonify({"ok": True, "client_id": client_id, "plan": plan, "action": action}), 200
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("_set_client_plan error: %s", e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ─── GET /api/v1/admin/stats ────────────────────────────────────
|
||||
|
||||
|
||||
@admin_bp.route("/stats", methods=["GET"])
|
||||
@require_auth
|
||||
@require_admin
|
||||
def admin_stats():
|
||||
"""Client stats: totals by plan, new this month/30d.
|
||||
---
|
||||
tags:
|
||||
- Admin
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Admin stats
|
||||
"""
|
||||
db = _get_saas_db()
|
||||
try:
|
||||
total = db.execute("SELECT COUNT(*) FROM saas_users").fetchone()[0]
|
||||
|
||||
by_plan = {}
|
||||
for row in db.execute(
|
||||
"SELECT plan, COUNT(*) AS cnt FROM saas_users GROUP BY plan"
|
||||
).fetchall():
|
||||
by_plan[row["plan"]] = row["cnt"]
|
||||
|
||||
new_30d = db.execute(
|
||||
"SELECT COUNT(*) FROM saas_users WHERE created_at >= datetime('now', '-30 days')"
|
||||
).fetchone()[0]
|
||||
|
||||
new_7d = db.execute(
|
||||
"SELECT COUNT(*) FROM saas_users WHERE created_at >= datetime('now', '-7 days')"
|
||||
).fetchone()[0]
|
||||
|
||||
active_subs = db.execute(
|
||||
"SELECT COUNT(DISTINCT user_id) FROM saas_subscriptions WHERE status = 'active'"
|
||||
).fetchone()[0]
|
||||
|
||||
return jsonify({
|
||||
"total_clients": total,
|
||||
"clients_by_plan": by_plan,
|
||||
"new_last_30d": new_30d,
|
||||
"new_last_7d": new_7d,
|
||||
"active_subscriptions": active_subs,
|
||||
}), 200
|
||||
except Exception as e:
|
||||
logger.error("admin_stats error: %s", e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
195
api_v1/routes/backtest.py
Normal file
195
api_v1/routes/backtest.py
Normal file
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Backtest route for API v1.
|
||||
|
||||
GET /api/v1/backtest — Résultats backtest historiques (pro)
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from api_v1.utils import (
|
||||
get_db,
|
||||
table_exists,
|
||||
internal_error,
|
||||
bad_request,
|
||||
get_pagination_params,
|
||||
paginate_query,
|
||||
)
|
||||
from auth import jwt_required_middleware, plan_required
|
||||
|
||||
backtest_bp = Blueprint("v1_backtest", __name__, url_prefix="/api/v1")
|
||||
|
||||
|
||||
@backtest_bp.route("/backtest", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("pro")
|
||||
def backtest():
|
||||
"""
|
||||
Backtest historique
|
||||
---
|
||||
tags:
|
||||
- Backtest
|
||||
summary: Résultats backtest historiques des paris simulés — accès pro uniquement
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: start
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date de début (YYYY-MM-DD), défaut = -30j
|
||||
- name: end
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date de fin (YYYY-MM-DD), défaut = aujourd'hui
|
||||
- name: limit
|
||||
in: query
|
||||
type: integer
|
||||
default: 50
|
||||
- name: offset
|
||||
in: query
|
||||
type: integer
|
||||
default: 0
|
||||
responses:
|
||||
200:
|
||||
description: Résultats backtest
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
description: Plan insuffisant (pro requis)
|
||||
"""
|
||||
start = request.args.get("start")
|
||||
end = request.args.get("end")
|
||||
|
||||
# Validate date formats
|
||||
for label, val in [("start", start), ("end", end)]:
|
||||
if val:
|
||||
try:
|
||||
datetime.strptime(val, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return bad_request(
|
||||
f"Paramètre '{label}' invalide, format attendu: YYYY-MM-DD"
|
||||
)
|
||||
|
||||
if not start:
|
||||
start = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")
|
||||
if not end:
|
||||
end = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
limit, offset = get_pagination_params(default_limit=50, max_limit=200)
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
if not table_exists(conn, "bet_results"):
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"period": {"start": start, "end": end},
|
||||
"summary": {
|
||||
"total_bets": 0,
|
||||
"message": "Aucune donnée bet_results",
|
||||
},
|
||||
"by_type": {},
|
||||
"details": [],
|
||||
"pagination": {
|
||||
"total": 0,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"has_more": False,
|
||||
},
|
||||
}
|
||||
), 200
|
||||
|
||||
# Summary
|
||||
summary_row = conn.execute(
|
||||
"""SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN resultat='GAGNE' THEN 1 ELSE 0 END) AS gagne,
|
||||
SUM(mise) AS mise,
|
||||
SUM(gain) AS gain
|
||||
FROM bet_results
|
||||
WHERE date BETWEEN ? AND ?""",
|
||||
(start, end),
|
||||
).fetchone()
|
||||
|
||||
total_bets = summary_row["total"] or 0
|
||||
gagne = summary_row["gagne"] or 0
|
||||
mise = float(summary_row["mise"] or 0)
|
||||
gain = float(summary_row["gain"] or 0)
|
||||
roi = round((gain - mise) / mise * 100, 1) if mise > 0 else 0.0
|
||||
precision = round(gagne / total_bets * 100, 1) if total_bets > 0 else 0.0
|
||||
|
||||
# By type
|
||||
by_type_rows = conn.execute(
|
||||
"""SELECT
|
||||
type_pari,
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN resultat='GAGNE' THEN 1 ELSE 0 END) AS gagne,
|
||||
SUM(mise) AS mise,
|
||||
SUM(gain) AS gain
|
||||
FROM bet_results
|
||||
WHERE date BETWEEN ? AND ?
|
||||
GROUP BY type_pari""",
|
||||
(start, end),
|
||||
).fetchall()
|
||||
|
||||
by_type = {}
|
||||
for row in by_type_rows:
|
||||
t = row["total"] or 0
|
||||
g = row["gagne"] or 0
|
||||
m = float(row["mise"] or 0)
|
||||
gn = float(row["gain"] or 0)
|
||||
by_type[row["type_pari"]] = {
|
||||
"count": t,
|
||||
"gagne": g,
|
||||
"mise": round(m, 2),
|
||||
"gain": round(gn, 2),
|
||||
"roi": round((gn - m) / m * 100, 1) if m > 0 else 0.0,
|
||||
"precision": round(g / t * 100, 1) if t > 0 else 0.0,
|
||||
}
|
||||
|
||||
# Paginated details
|
||||
count_row = conn.execute(
|
||||
"SELECT COUNT(*) AS cnt FROM bet_results WHERE date BETWEEN ? AND ?",
|
||||
(start, end),
|
||||
).fetchone()
|
||||
detail_total = count_row["cnt"] if count_row else 0
|
||||
|
||||
detail_rows = conn.execute(
|
||||
"""SELECT date, race_name, type_pari, horse_name, horse_number,
|
||||
COALESCE(cote, 0) AS cote, mise, resultat, gain
|
||||
FROM bet_results
|
||||
WHERE date BETWEEN ? AND ?
|
||||
ORDER BY date DESC, id DESC
|
||||
LIMIT ? OFFSET ?""",
|
||||
(start, end, limit, offset),
|
||||
).fetchall()
|
||||
|
||||
details = [dict(r) for r in detail_rows]
|
||||
pagination = paginate_query(details, detail_total, limit, offset)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"period": {"start": start, "end": end},
|
||||
"summary": {
|
||||
"total_bets": total_bets,
|
||||
"gagne": gagne,
|
||||
"perdu": total_bets - gagne,
|
||||
"precision": precision,
|
||||
"mise_totale": round(mise, 2),
|
||||
"gain_total": round(gain, 2),
|
||||
"roi": roi,
|
||||
},
|
||||
"by_type": by_type,
|
||||
"details": details,
|
||||
**pagination,
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
797
api_v1/routes/billing.py
Normal file
797
api_v1/routes/billing.py
Normal file
@@ -0,0 +1,797 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Billing Blueprint — Stripe integration
|
||||
Sprint 5-6: HRT-31
|
||||
|
||||
Endpoints:
|
||||
POST /api/v1/billing/checkout — create Stripe Checkout session (auth required)
|
||||
POST /api/v1/billing/portal — create Stripe Customer Portal session (auth required)
|
||||
POST /api/v1/billing/webhook — Stripe webhook handler (public, signature-verified)
|
||||
GET /api/v1/billing/status — current subscription status (auth required)
|
||||
|
||||
Environment variables required:
|
||||
STRIPE_SECRET_KEY — Stripe secret key (sk_live_... or sk_test_...)
|
||||
STRIPE_PUBLISHABLE_KEY — Stripe publishable key (pk_...)
|
||||
STRIPE_WEBHOOK_SECRET — webhook signing secret (whsec_...)
|
||||
STRIPE_PRICE_PREMIUM — Stripe Price ID for Premium plan (price_...)
|
||||
STRIPE_PRICE_PRO — Stripe Price ID for Pro plan (price_...)
|
||||
APP_BASE_URL — e.g. https://turf-ia.h3r7.tech (default http://localhost:8793)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import stripe
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
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")
|
||||
|
||||
billing_bp = Blueprint("billing", __name__, url_prefix="/api/v1/billing")
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Stripe configuration
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
stripe.api_key = os.environ.get("STRIPE_SECRET_KEY", "")
|
||||
STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "")
|
||||
STRIPE_PUBLISHABLE_KEY = os.environ.get("STRIPE_PUBLISHABLE_KEY", "")
|
||||
APP_BASE_URL = os.environ.get("APP_BASE_URL", "http://localhost:8793")
|
||||
|
||||
# Plan → Stripe Price ID mapping
|
||||
PLAN_PRICE_IDS = {
|
||||
"premium": os.environ.get("STRIPE_PRICE_PREMIUM", ""),
|
||||
"pro": os.environ.get("STRIPE_PRICE_PRO", ""),
|
||||
}
|
||||
|
||||
# Plan display names
|
||||
PLAN_NAMES = {
|
||||
"free": "Free",
|
||||
"premium": "Premium",
|
||||
"pro": "Pro",
|
||||
}
|
||||
|
||||
# Plan consumption limits (configurable, not hardcoded)
|
||||
# Override via env vars: BILLING_LIMIT_FREE_API_CALLS, BILLING_LIMIT_PREMIUM_API_CALLS, etc.
|
||||
PLAN_LIMITS = {
|
||||
"free": {
|
||||
"monthly_api_calls": int(os.environ.get("BILLING_LIMIT_FREE_API_CALLS", "300")),
|
||||
"monthly_tokens": int(os.environ.get("BILLING_LIMIT_FREE_TOKENS", "100000")),
|
||||
},
|
||||
"premium": {
|
||||
"monthly_api_calls": int(os.environ.get("BILLING_LIMIT_PREMIUM_API_CALLS", "3000")),
|
||||
"monthly_tokens": int(os.environ.get("BILLING_LIMIT_PREMIUM_TOKENS", "1000000")),
|
||||
},
|
||||
"pro": {
|
||||
"monthly_api_calls": int(os.environ.get("BILLING_LIMIT_PRO_API_CALLS", "30000")),
|
||||
"monthly_tokens": int(os.environ.get("BILLING_LIMIT_PRO_TOKENS", "10000000")),
|
||||
},
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# DB helpers
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _sget(obj, key, default=None):
|
||||
"""Safely get a value from a dict OR a Stripe StripeObject.
|
||||
|
||||
Stripe v7+ uses attribute-style access; plain dicts use [] / .get().
|
||||
"""
|
||||
try:
|
||||
# StripeObject supports [] but not .get(); dict supports both
|
||||
val = obj[key]
|
||||
return val if val is not None else default
|
||||
except (KeyError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def _get_active_subscription(db, user_id):
|
||||
"""Return the most recent active subscription row for a user."""
|
||||
return db.execute(
|
||||
"""SELECT * FROM saas_subscriptions
|
||||
WHERE user_id = ?
|
||||
ORDER BY start_date DESC
|
||||
LIMIT 1""",
|
||||
(str(user_id),),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _upsert_subscription(db, user_id, **fields):
|
||||
"""
|
||||
Update existing subscription or insert a new one.
|
||||
fields: plan, stripe_customer_id, stripe_subscription_id,
|
||||
status, current_period_end, grace_period_end, end_date
|
||||
"""
|
||||
existing = _get_active_subscription(db, user_id)
|
||||
if existing:
|
||||
# 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 saas_subscriptions SET {set_parts} WHERE id = ?", values)
|
||||
else:
|
||||
cols = ", ".join(["user_id"] + list(fields.keys()))
|
||||
placeholders = ", ".join(["?"] * (1 + len(fields)))
|
||||
values = [str(user_id)] + list(fields.values())
|
||||
db.execute(
|
||||
f"INSERT INTO saas_subscriptions ({cols}) VALUES ({placeholders})", values
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
"""Return existing stripe_customer_id or create a new Stripe Customer."""
|
||||
sub = _get_active_subscription(db, user["id"])
|
||||
if sub and sub["stripe_customer_id"]:
|
||||
return sub["stripe_customer_id"]
|
||||
|
||||
# Create new customer in Stripe
|
||||
customer = stripe.Customer.create(
|
||||
email=user["email"],
|
||||
metadata={"user_id": str(user["id"])},
|
||||
)
|
||||
return customer["id"]
|
||||
|
||||
|
||||
def _record_billing_event(
|
||||
db, stripe_event_id: str, event_type: str, user_id=None, payload=None
|
||||
):
|
||||
"""Insert a billing_events audit row (idempotent on stripe_event_id)."""
|
||||
try:
|
||||
db.execute(
|
||||
"""INSERT OR IGNORE INTO billing_events
|
||||
(stripe_event_id, event_type, user_id, payload)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(
|
||||
stripe_event_id,
|
||||
event_type,
|
||||
user_id,
|
||||
json.dumps(payload) if payload else None,
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Could not record billing event %s: %s", stripe_event_id, e)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# POST /api/v1/billing/checkout
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@billing_bp.route("/checkout", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
def create_checkout():
|
||||
"""
|
||||
Create a Stripe Checkout session for upgrading to Premium or Pro.
|
||||
---
|
||||
tags:
|
||||
- Billing
|
||||
security:
|
||||
- Bearer: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [plan]
|
||||
properties:
|
||||
plan:
|
||||
type: string
|
||||
enum: [premium, pro]
|
||||
responses:
|
||||
200:
|
||||
description: Checkout session URL
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
checkout_url:
|
||||
type: string
|
||||
session_id:
|
||||
type: string
|
||||
400:
|
||||
description: Invalid plan or Stripe not configured
|
||||
503:
|
||||
description: Stripe API error
|
||||
"""
|
||||
if not stripe.api_key:
|
||||
return jsonify({"error": "Stripe non configuré"}), 503
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
plan = body.get("plan", "").lower()
|
||||
|
||||
if plan not in ("premium", "pro"):
|
||||
return jsonify({"error": "Plan invalide. Choisir 'premium' ou 'pro'"}), 400
|
||||
|
||||
price_id = PLAN_PRICE_IDS.get(plan)
|
||||
if not price_id:
|
||||
return jsonify({"error": f"Prix Stripe non configuré pour le plan {plan}"}), 503
|
||||
|
||||
user = request.current_user
|
||||
if user["plan"] == plan:
|
||||
return jsonify({"error": f"Vous êtes déjà sur le plan {plan}"}), 400
|
||||
|
||||
db = get_db()
|
||||
try:
|
||||
customer_id = _get_or_create_stripe_customer(user, db)
|
||||
# Persist customer_id early to prevent duplicates
|
||||
_upsert_subscription(
|
||||
db, user["id"], stripe_customer_id=customer_id, plan=user["plan"]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
session = stripe.checkout.Session.create(
|
||||
customer=customer_id,
|
||||
payment_method_types=["card"],
|
||||
line_items=[{"price": price_id, "quantity": 1}],
|
||||
mode="subscription",
|
||||
success_url=f"{APP_BASE_URL}/billing/success?session_id={{CHECKOUT_SESSION_ID}}",
|
||||
cancel_url=f"{APP_BASE_URL}/billing/cancel",
|
||||
metadata={"user_id": str(user["id"]), "plan": plan},
|
||||
subscription_data={"metadata": {"user_id": str(user["id"]), "plan": plan}},
|
||||
)
|
||||
except stripe.StripeError as e:
|
||||
logger.error("Stripe checkout error for user %s: %s", user["id"], e)
|
||||
return jsonify({"error": "Erreur Stripe", "detail": str(e)}), 503
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"checkout_url": session.url,
|
||||
"session_id": session.id,
|
||||
"plan": plan,
|
||||
"publishable_key": STRIPE_PUBLISHABLE_KEY,
|
||||
}
|
||||
), 200
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# POST /api/v1/billing/portal
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@billing_bp.route("/portal", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
def create_portal():
|
||||
"""
|
||||
Create a Stripe Customer Portal session for managing subscription.
|
||||
---
|
||||
tags:
|
||||
- Billing
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Portal session URL
|
||||
400:
|
||||
description: No Stripe customer found
|
||||
503:
|
||||
description: Stripe not configured or API error
|
||||
"""
|
||||
if not stripe.api_key:
|
||||
return jsonify({"error": "Stripe non configuré"}), 503
|
||||
|
||||
user = request.current_user
|
||||
db = get_db()
|
||||
try:
|
||||
sub = _get_active_subscription(db, user["id"])
|
||||
customer_id = sub["stripe_customer_id"] if sub else None
|
||||
|
||||
if not customer_id:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Aucun abonnement Stripe trouvé. "
|
||||
"Souscrivez d'abord à un plan payant."
|
||||
}
|
||||
), 400
|
||||
|
||||
session = stripe.billing_portal.Session.create(
|
||||
customer=customer_id,
|
||||
return_url=f"{APP_BASE_URL}/account",
|
||||
)
|
||||
except stripe.StripeError as e:
|
||||
logger.error("Stripe portal error for user %s: %s", user["id"], e)
|
||||
return jsonify({"error": "Erreur Stripe", "detail": str(e)}), 503
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return jsonify({"portal_url": session.url}), 200
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GET /api/v1/billing/status
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@billing_bp.route("/status", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
def billing_status():
|
||||
"""
|
||||
Return current subscription status for the authenticated user.
|
||||
---
|
||||
tags:
|
||||
- Billing
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Subscription status
|
||||
"""
|
||||
user = request.current_user
|
||||
db = get_db()
|
||||
try:
|
||||
sub = _get_active_subscription(db, user["id"])
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if not sub:
|
||||
return jsonify(
|
||||
{
|
||||
"plan": "free",
|
||||
"status": "active",
|
||||
"stripe_customer_id": None,
|
||||
"stripe_subscription_id": None,
|
||||
"current_period_end": None,
|
||||
"grace_period_end": None,
|
||||
}
|
||||
), 200
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"plan": sub["plan"],
|
||||
"status": sub["status"] or "active",
|
||||
"stripe_customer_id": sub["stripe_customer_id"],
|
||||
"stripe_subscription_id": sub["stripe_subscription_id"],
|
||||
"start_date": sub["start_date"],
|
||||
"end_date": sub["end_date"],
|
||||
"current_period_end": sub["current_period_end"],
|
||||
"grace_period_end": sub["grace_period_end"],
|
||||
}
|
||||
), 200
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# POST /api/v1/billing/webhook
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@billing_bp.route("/webhook", methods=["POST"])
|
||||
def stripe_webhook():
|
||||
"""
|
||||
Stripe webhook handler — no auth, signature-verified.
|
||||
|
||||
Handled events:
|
||||
checkout.session.completed → activate subscription
|
||||
customer.subscription.updated → sync plan/status
|
||||
customer.subscription.deleted → downgrade to free
|
||||
invoice.payment_failed → set past_due + 3-day grace period
|
||||
invoice.payment_succeeded → clear grace period
|
||||
"""
|
||||
payload = request.get_data()
|
||||
sig_header = request.headers.get("Stripe-Signature", "")
|
||||
|
||||
# Verify webhook signature (required in production)
|
||||
if STRIPE_WEBHOOK_SECRET:
|
||||
try:
|
||||
event = stripe.Webhook.construct_event(
|
||||
payload, sig_header, STRIPE_WEBHOOK_SECRET
|
||||
)
|
||||
except stripe.SignatureVerificationError as e:
|
||||
logger.warning("Stripe webhook signature invalid: %s", e)
|
||||
return jsonify({"error": "Signature invalide"}), 400
|
||||
except ValueError as e:
|
||||
logger.warning("Stripe webhook payload invalid: %s", e)
|
||||
return jsonify({"error": "Payload invalide"}), 400
|
||||
else:
|
||||
# Dev/test: accept without verification (log a warning)
|
||||
logger.warning("STRIPE_WEBHOOK_SECRET not set — skipping signature check!")
|
||||
try:
|
||||
event = stripe.Event.construct_from(json.loads(payload), stripe.api_key)
|
||||
except Exception as e:
|
||||
return jsonify({"error": "Payload invalide", "detail": str(e)}), 400
|
||||
|
||||
event_type = event["type"]
|
||||
event_id = event["id"]
|
||||
logger.info("Stripe webhook received: %s (%s)", event_type, event_id)
|
||||
|
||||
db = get_db()
|
||||
try:
|
||||
if event_type == "checkout.session.completed":
|
||||
_handle_checkout_completed(db, event)
|
||||
|
||||
elif event_type in (
|
||||
"customer.subscription.updated",
|
||||
"customer.subscription.created",
|
||||
):
|
||||
_handle_subscription_updated(db, event)
|
||||
|
||||
elif event_type == "customer.subscription.deleted":
|
||||
_handle_subscription_deleted(db, event)
|
||||
|
||||
elif event_type == "invoice.payment_failed":
|
||||
_handle_payment_failed(db, event)
|
||||
|
||||
elif event_type == "invoice.payment_succeeded":
|
||||
_handle_payment_succeeded(db, event)
|
||||
|
||||
else:
|
||||
logger.debug("Unhandled Stripe event type: %s", event_type)
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("Error processing Stripe webhook %s: %s", event_id, e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return jsonify({"status": "ok"}), 200
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Webhook handlers
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
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 saas_subscriptions WHERE stripe_customer_id = ? LIMIT 1",
|
||||
(customer_id,),
|
||||
).fetchone()
|
||||
if row:
|
||||
return row["user_id"]
|
||||
|
||||
# Fallback: query Stripe for user_id metadata
|
||||
try:
|
||||
customer = stripe.Customer.retrieve(customer_id)
|
||||
meta = _sget(customer, "metadata") or {}
|
||||
uid = _sget(meta, "user_id")
|
||||
if uid:
|
||||
return int(uid)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_plan_from_price(price_id: str) -> str:
|
||||
"""Map Stripe price ID to internal plan name."""
|
||||
for plan, pid in PLAN_PRICE_IDS.items():
|
||||
if pid and pid == price_id:
|
||||
return plan
|
||||
# Unknown price — default to premium (safer than pro)
|
||||
return "premium"
|
||||
|
||||
|
||||
def _handle_checkout_completed(db, event):
|
||||
"""checkout.session.completed → activate subscription for the user."""
|
||||
session = event["data"]["object"]
|
||||
customer_id = _sget(session, "customer")
|
||||
subscription_id = _sget(session, "subscription")
|
||||
metadata = _sget(session, "metadata") or {}
|
||||
plan = _sget(metadata, "plan") or "premium"
|
||||
user_id = _sget(metadata, "user_id")
|
||||
|
||||
if user_id:
|
||||
user_id = str(user_id)
|
||||
else:
|
||||
user_id = _resolve_user_from_customer(db, customer_id)
|
||||
|
||||
if not user_id:
|
||||
logger.error(
|
||||
"checkout.session.completed: cannot resolve user for customer %s",
|
||||
customer_id,
|
||||
)
|
||||
return
|
||||
|
||||
# Fetch subscription details from Stripe
|
||||
current_period_end = None
|
||||
if subscription_id:
|
||||
try:
|
||||
sub = stripe.Subscription.retrieve(subscription_id)
|
||||
current_period_end = datetime.fromtimestamp(
|
||||
sub["current_period_end"], tz=timezone.utc
|
||||
).isoformat()
|
||||
# Sync plan from price if metadata plan is missing
|
||||
if sub["items"]["data"]:
|
||||
price_id = sub["items"]["data"][0]["price"]["id"]
|
||||
plan = _resolve_plan_from_price(price_id)
|
||||
except Exception as e:
|
||||
logger.warning("Could not fetch subscription %s: %s", subscription_id, e)
|
||||
|
||||
_upsert_subscription(
|
||||
db,
|
||||
user_id,
|
||||
plan=plan,
|
||||
stripe_customer_id=customer_id,
|
||||
stripe_subscription_id=subscription_id,
|
||||
status="active",
|
||||
current_period_end=current_period_end,
|
||||
grace_period_end=None,
|
||||
)
|
||||
_update_user_plan(db, user_id, plan)
|
||||
_record_billing_event(db, event["id"], event["type"], user_id=user_id)
|
||||
logger.info("checkout.session.completed: user %s upgraded to %s", user_id, plan)
|
||||
|
||||
|
||||
def _handle_subscription_updated(db, event):
|
||||
"""customer.subscription.updated → sync status and plan."""
|
||||
sub_obj = event["data"]["object"]
|
||||
customer_id = _sget(sub_obj, "customer")
|
||||
subscription_id = _sget(sub_obj, "id")
|
||||
stripe_status = _sget(sub_obj, "status") or "active"
|
||||
current_period_end = None
|
||||
|
||||
cpe = _sget(sub_obj, "current_period_end")
|
||||
if cpe:
|
||||
current_period_end = datetime.fromtimestamp(cpe, tz=timezone.utc).isoformat()
|
||||
|
||||
# Resolve plan from price
|
||||
plan = "premium"
|
||||
items_data = _sget(_sget(sub_obj, "items") or {}, "data")
|
||||
if items_data:
|
||||
price_id = items_data[0]["price"]["id"]
|
||||
plan = _resolve_plan_from_price(price_id)
|
||||
|
||||
user_id = _resolve_user_from_customer(db, customer_id)
|
||||
if not user_id:
|
||||
# Try metadata
|
||||
meta = _sget(sub_obj, "metadata") or {}
|
||||
meta_uid = _sget(meta, "user_id")
|
||||
if meta_uid:
|
||||
user_id = str(meta_uid)
|
||||
|
||||
if not user_id:
|
||||
logger.error(
|
||||
"subscription.updated: cannot resolve user for customer %s", customer_id
|
||||
)
|
||||
return
|
||||
|
||||
_upsert_subscription(
|
||||
db,
|
||||
user_id,
|
||||
plan=plan,
|
||||
stripe_customer_id=customer_id,
|
||||
stripe_subscription_id=subscription_id,
|
||||
status=stripe_status,
|
||||
current_period_end=current_period_end,
|
||||
)
|
||||
_update_user_plan(db, user_id, plan)
|
||||
_record_billing_event(db, event["id"], event["type"], user_id=user_id)
|
||||
logger.info(
|
||||
"subscription.updated: user %s plan=%s status=%s", user_id, plan, stripe_status
|
||||
)
|
||||
|
||||
|
||||
def _handle_subscription_deleted(db, event):
|
||||
"""customer.subscription.deleted → downgrade to free."""
|
||||
sub_obj = event["data"]["object"]
|
||||
customer_id = _sget(sub_obj, "customer")
|
||||
|
||||
user_id = _resolve_user_from_customer(db, customer_id)
|
||||
if not user_id:
|
||||
meta = _sget(sub_obj, "metadata") or {}
|
||||
meta_uid = _sget(meta, "user_id")
|
||||
if meta_uid:
|
||||
user_id = str(meta_uid)
|
||||
|
||||
if not user_id:
|
||||
logger.error(
|
||||
"subscription.deleted: cannot resolve user for customer %s", customer_id
|
||||
)
|
||||
return
|
||||
|
||||
_upsert_subscription(
|
||||
db,
|
||||
user_id,
|
||||
plan="free",
|
||||
stripe_subscription_id=None,
|
||||
status="canceled",
|
||||
end_date=datetime.now(timezone.utc).isoformat(),
|
||||
current_period_end=None,
|
||||
grace_period_end=None,
|
||||
)
|
||||
_update_user_plan(db, user_id, "free")
|
||||
_record_billing_event(db, event["id"], event["type"], user_id=user_id)
|
||||
logger.info("subscription.deleted: user %s downgraded to free", user_id)
|
||||
|
||||
|
||||
def _handle_payment_failed(db, event):
|
||||
"""invoice.payment_failed → mark past_due + 3-day grace period."""
|
||||
invoice = event["data"]["object"]
|
||||
customer_id = _sget(invoice, "customer")
|
||||
subscription_id = _sget(invoice, "subscription")
|
||||
|
||||
user_id = _resolve_user_from_customer(db, customer_id)
|
||||
if not user_id:
|
||||
logger.error(
|
||||
"invoice.payment_failed: cannot resolve user for customer %s", customer_id
|
||||
)
|
||||
return
|
||||
|
||||
grace_end = (datetime.now(timezone.utc) + timedelta(days=3)).isoformat()
|
||||
|
||||
_upsert_subscription(db, user_id, status="past_due", grace_period_end=grace_end)
|
||||
_record_billing_event(
|
||||
db,
|
||||
event["id"],
|
||||
event["type"],
|
||||
user_id=user_id,
|
||||
payload={"subscription_id": subscription_id},
|
||||
)
|
||||
|
||||
# TODO: send notification email via /api/notifications
|
||||
logger.warning(
|
||||
"invoice.payment_failed: user %s past_due, grace period until %s",
|
||||
user_id,
|
||||
grace_end,
|
||||
)
|
||||
|
||||
|
||||
def _handle_payment_succeeded(db, event):
|
||||
"""invoice.payment_succeeded → clear past_due / grace period."""
|
||||
invoice = event["data"]["object"]
|
||||
customer_id = _sget(invoice, "customer")
|
||||
|
||||
user_id = _resolve_user_from_customer(db, customer_id)
|
||||
if not user_id:
|
||||
return
|
||||
|
||||
# Refresh subscription period end
|
||||
current_period_end = None
|
||||
lines = _sget(invoice, "lines") or {}
|
||||
lines_data = _sget(lines, "data") or []
|
||||
if lines_data:
|
||||
period = lines_data[0].get("period") or {}
|
||||
period_end = (
|
||||
period.get("end") if isinstance(period, dict) else _sget(period, "end")
|
||||
)
|
||||
if period_end:
|
||||
current_period_end = datetime.fromtimestamp(
|
||||
period_end, tz=timezone.utc
|
||||
).isoformat()
|
||||
|
||||
_upsert_subscription(
|
||||
db,
|
||||
user_id,
|
||||
status="active",
|
||||
grace_period_end=None,
|
||||
current_period_end=current_period_end,
|
||||
)
|
||||
_record_billing_event(db, event["id"], event["type"], user_id=user_id)
|
||||
logger.info("invoice.payment_succeeded: user %s payment cleared", user_id)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GET /api/v1/billing/consumption
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _parse_month(month: str):
|
||||
"""Validate YYYY-MM format, return (year, month) tuple or None."""
|
||||
import re
|
||||
if not re.match(r"^\d{4}-\d{2}$", month):
|
||||
return None
|
||||
parts = month.split("-")
|
||||
y, m = int(parts[0]), int(parts[1])
|
||||
if m < 1 or m > 12:
|
||||
return None
|
||||
return y, m
|
||||
|
||||
|
||||
@billing_bp.route("/consumption", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
def consumption_status():
|
||||
"""
|
||||
Return current month consumption vs plan limits for the authenticated user.
|
||||
---
|
||||
tags:
|
||||
- Billing
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- in: query
|
||||
name: month
|
||||
type: string
|
||||
required: false
|
||||
description: "Month in YYYY-MM format (default: current month)"
|
||||
responses:
|
||||
200:
|
||||
description: Consumption status with usage, limits, and alerts
|
||||
400:
|
||||
description: Invalid parameters
|
||||
422:
|
||||
description: Malformed month format
|
||||
"""
|
||||
user = request.current_user
|
||||
month = request.args.get("month", datetime.now().strftime("%Y-%m"))
|
||||
parsed = _parse_month(month)
|
||||
if not parsed:
|
||||
return jsonify({"error": "Format mois invalide. Utiliser YYYY-MM"}), 422
|
||||
|
||||
year, mon = parsed
|
||||
plan = user.get("plan", "free")
|
||||
limits = PLAN_LIMITS.get(plan, PLAN_LIMITS["free"])
|
||||
|
||||
db = get_db()
|
||||
try:
|
||||
# Aggregate consumption for the given month
|
||||
month_start = f"{year:04d}-{mon:02d}-01"
|
||||
if mon == 12:
|
||||
month_end = f"{year + 1:04d}-01-01"
|
||||
else:
|
||||
month_end = f"{year:04d}-{mon + 1:02d}-01"
|
||||
|
||||
row = db.execute(
|
||||
"""SELECT
|
||||
COALESCE(SUM(api_calls), 0) AS total_api_calls
|
||||
FROM consumption_log
|
||||
WHERE user_id = ? AND date >= ? AND date < ?""",
|
||||
(str(user["id"]), month_start, month_end),
|
||||
).fetchone()
|
||||
|
||||
total_api_calls = row["total_api_calls"] if row else 0
|
||||
|
||||
# Calculate alert levels
|
||||
api_limit = limits["monthly_api_calls"]
|
||||
api_pct = round((total_api_calls / api_limit * 100), 1) if api_limit > 0 else 0
|
||||
|
||||
alerts = []
|
||||
if api_pct >= 100:
|
||||
alerts.append({
|
||||
"type": "hard",
|
||||
"metric": "api_calls",
|
||||
"message": "Limite mensuelle d'appels API atteinte.",
|
||||
"current": total_api_calls,
|
||||
"limit": api_limit,
|
||||
})
|
||||
elif api_pct >= 80:
|
||||
alerts.append({
|
||||
"type": "soft",
|
||||
"metric": "api_calls",
|
||||
"message": f"Appels API à {api_pct}% de la limite mensuelle.",
|
||||
"current": total_api_calls,
|
||||
"limit": api_limit,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Consumption query error for user %s: %s", user["id"], e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
resp = jsonify({
|
||||
"user_id": user["id"],
|
||||
"plan": plan,
|
||||
"month": month,
|
||||
"consumption": {
|
||||
"total_api_calls": total_api_calls,
|
||||
"limit_api_calls": api_limit,
|
||||
"usage_pct": api_pct,
|
||||
},
|
||||
"alerts": alerts,
|
||||
})
|
||||
if any(a["type"] == "hard" for a in alerts):
|
||||
resp.headers["X-Billing-Alert"] = "hard_limit_reached"
|
||||
elif any(a["type"] == "soft" for a in alerts):
|
||||
resp.headers["X-Billing-Alert"] = "soft_limit_warning"
|
||||
return resp, 200
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# On-import: ensure DB migration ran
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
try:
|
||||
migrate_billing_tables()
|
||||
except Exception as _e:
|
||||
logger.warning("billing_db migration skipped (test env?): %s", _e)
|
||||
277
api_v1/routes/courses.py
Normal file
277
api_v1/routes/courses.py
Normal file
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Courses routes for API v1.
|
||||
|
||||
GET /api/v1/courses/today — liste des courses du jour (public, paginated)
|
||||
GET /api/v1/courses/{id}/predictions — prédictions ML pour une course (free tier, 1/day limit)
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, jsonify, request, g
|
||||
|
||||
from api_v1.utils import (
|
||||
get_db,
|
||||
table_exists,
|
||||
error_response,
|
||||
bad_request,
|
||||
not_found,
|
||||
internal_error,
|
||||
get_pagination_params,
|
||||
paginate_query,
|
||||
)
|
||||
from auth import jwt_required_middleware, free_daily_limit_check
|
||||
|
||||
courses_bp = Blueprint("v1_courses", __name__, url_prefix="/api/v1/courses")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GET /api/v1/courses/today
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@courses_bp.route("/today", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
def courses_today():
|
||||
"""
|
||||
Courses du jour
|
||||
---
|
||||
tags:
|
||||
- Courses
|
||||
summary: Liste toutes les courses du jour avec info course
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: filter
|
||||
in: query
|
||||
type: string
|
||||
enum: [all, quinte, trot, plat]
|
||||
default: all
|
||||
description: Filtre par type de course
|
||||
- name: limit
|
||||
in: query
|
||||
type: integer
|
||||
default: 20
|
||||
- name: offset
|
||||
in: query
|
||||
type: integer
|
||||
default: 0
|
||||
responses:
|
||||
200:
|
||||
description: Liste des courses du jour
|
||||
401:
|
||||
description: Token manquant ou invalide
|
||||
"""
|
||||
race_filter = request.args.get("filter", "all").lower()
|
||||
limit, offset = get_pagination_params(default_limit=50, max_limit=200)
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
# Build SQL condition
|
||||
if race_filter == "quinte":
|
||||
cond = "AND (c.libelle LIKE '%Quinté%' OR c.libelle LIKE '%Quinte%')"
|
||||
elif race_filter == "trot":
|
||||
cond = "AND c.discipline LIKE '%Trot%'"
|
||||
elif race_filter == "plat":
|
||||
cond = "AND c.discipline LIKE '%Plat%'"
|
||||
else:
|
||||
cond = ""
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
# Graceful handling if pmu_courses table doesn't exist yet
|
||||
if not table_exists(conn, "pmu_courses"):
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"date": today,
|
||||
"filter": race_filter,
|
||||
"courses": [],
|
||||
"pagination": {
|
||||
"total": 0,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"has_more": False,
|
||||
},
|
||||
}
|
||||
), 200
|
||||
|
||||
# Count total
|
||||
count_row = conn.execute(
|
||||
f"""SELECT COUNT(*) as cnt
|
||||
FROM pmu_courses c
|
||||
WHERE c.date_programme = ? {cond}""",
|
||||
(today,),
|
||||
).fetchone()
|
||||
total = count_row["cnt"] if count_row else 0
|
||||
|
||||
rows = conn.execute(
|
||||
f"""SELECT
|
||||
c.date_programme,
|
||||
c.num_reunion,
|
||||
c.num_course,
|
||||
c.libelle,
|
||||
c.discipline,
|
||||
c.distance,
|
||||
c.hippodrome,
|
||||
c.px_type,
|
||||
COUNT(p.id_cheval) as nb_partants
|
||||
FROM pmu_courses c
|
||||
LEFT JOIN pmu_partants p
|
||||
ON p.date_programme = c.date_programme
|
||||
AND p.num_reunion = c.num_reunion
|
||||
AND p.num_course = c.num_course
|
||||
WHERE c.date_programme = ? {cond}
|
||||
GROUP BY c.date_programme, c.num_reunion, c.num_course
|
||||
ORDER BY c.num_reunion ASC, c.num_course ASC
|
||||
LIMIT ? OFFSET ?""",
|
||||
(today, limit, offset),
|
||||
).fetchall()
|
||||
|
||||
courses = []
|
||||
for r in rows:
|
||||
course_id = f"{r['num_reunion']}-{r['num_course']}"
|
||||
courses.append(
|
||||
{
|
||||
"id": course_id,
|
||||
"date": r["date_programme"],
|
||||
"num_reunion": r["num_reunion"],
|
||||
"num_course": r["num_course"],
|
||||
"libelle": r["libelle"],
|
||||
"discipline": r["discipline"],
|
||||
"distance": r["distance"],
|
||||
"hippodrome": r["hippodrome"],
|
||||
"type_pari": r["px_type"],
|
||||
"nb_partants": r["nb_partants"],
|
||||
}
|
||||
)
|
||||
|
||||
pagination = paginate_query(courses, total, limit, offset)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"date": today,
|
||||
"filter": race_filter,
|
||||
"courses": courses,
|
||||
**pagination,
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GET /api/v1/courses/<course_id>/predictions
|
||||
# course_id format: "{num_reunion}-{num_course}" e.g. "1-3"
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@courses_bp.route("/<course_id>/predictions", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@free_daily_limit_check
|
||||
def course_predictions(course_id):
|
||||
"""
|
||||
Prédictions pour une course
|
||||
---
|
||||
tags:
|
||||
- Courses
|
||||
summary: Prédictions ML pour une course identifiée par {num_reunion}-{num_course}
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: course_id
|
||||
in: path
|
||||
type: string
|
||||
required: true
|
||||
description: Identifiant de la course (format num_reunion-num_course, ex "1-3")
|
||||
- name: date
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date de la course (YYYY-MM-DD), défaut = aujourd'hui
|
||||
responses:
|
||||
200:
|
||||
description: Prédictions ML pour la course
|
||||
400:
|
||||
description: Paramètres invalides
|
||||
404:
|
||||
description: Course introuvable
|
||||
429:
|
||||
description: Limite quotidienne free tier atteinte
|
||||
"""
|
||||
# Parse course_id
|
||||
parts = course_id.split("-")
|
||||
if len(parts) != 2:
|
||||
return bad_request(
|
||||
"course_id doit être au format {num_reunion}-{num_course}, ex: 1-3"
|
||||
)
|
||||
|
||||
try:
|
||||
num_reunion = int(parts[0])
|
||||
num_course = int(parts[1])
|
||||
except ValueError:
|
||||
return bad_request("num_reunion et num_course doivent être des entiers")
|
||||
|
||||
date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
# Fetch course info
|
||||
course_row = conn.execute(
|
||||
"""SELECT libelle, discipline, distance, hippodrome, px_type
|
||||
FROM pmu_courses
|
||||
WHERE date_programme = ? AND num_reunion = ? AND num_course = ?""",
|
||||
(date_param, num_reunion, num_course),
|
||||
).fetchone()
|
||||
|
||||
if not course_row:
|
||||
return not_found(
|
||||
f"Course R{num_reunion}C{num_course} introuvable pour le {date_param}"
|
||||
)
|
||||
|
||||
# Fetch ML predictions from cache
|
||||
preds = []
|
||||
if table_exists(conn, "ml_predictions_cache"):
|
||||
preds = conn.execute(
|
||||
"""SELECT 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 = ? AND num_reunion = ? AND num_course = ?
|
||||
ORDER BY ml_score DESC""",
|
||||
(date_param, num_reunion, num_course),
|
||||
).fetchall()
|
||||
|
||||
# Fetch partants
|
||||
partants = conn.execute(
|
||||
"""SELECT nom, num_pmu, cote_direct, cote_reference, tendance_cote, favoris,
|
||||
tx_victoire, tx_place, forme_recente, driver, entraineur, musique
|
||||
FROM pmu_partants
|
||||
WHERE date_programme = ? AND num_reunion = ? AND num_course = ?
|
||||
ORDER BY num_pmu ASC""",
|
||||
(date_param, num_reunion, num_course),
|
||||
).fetchall()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"date": date_param,
|
||||
"course": {
|
||||
"id": course_id,
|
||||
"libelle": course_row["libelle"],
|
||||
"discipline": course_row["discipline"],
|
||||
"distance": course_row["distance"],
|
||||
"hippodrome": course_row["hippodrome"],
|
||||
"type_pari": course_row["px_type"],
|
||||
},
|
||||
"predictions": [dict(p) for p in preds],
|
||||
"partants": [dict(p) for p in partants],
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
185
api_v1/routes/export.py
Normal file
185
api_v1/routes/export.py
Normal file
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Export route for API v1.
|
||||
|
||||
GET /api/v1/export/csv — Export CSV des prédictions ou paris (pro)
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from api_v1.utils import (
|
||||
get_db,
|
||||
table_exists,
|
||||
internal_error,
|
||||
bad_request,
|
||||
forbidden,
|
||||
)
|
||||
from auth import jwt_required_middleware, plan_required
|
||||
|
||||
export_bp = Blueprint("v1_export", __name__, url_prefix="/api/v1/export")
|
||||
|
||||
# Maximum rows exportable in one request
|
||||
EXPORT_MAX_ROWS = 5000
|
||||
|
||||
|
||||
@export_bp.route("/csv", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("pro")
|
||||
def export_csv():
|
||||
"""
|
||||
Export CSV
|
||||
---
|
||||
tags:
|
||||
- Export
|
||||
summary: Export CSV des prédictions ML ou des paris historiques — accès pro uniquement
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: type
|
||||
in: query
|
||||
type: string
|
||||
enum: [predictions, bets]
|
||||
default: predictions
|
||||
description: Type de données à exporter
|
||||
- name: start
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date de début (YYYY-MM-DD)
|
||||
- name: end
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date de fin (YYYY-MM-DD)
|
||||
- name: date
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date unique (YYYY-MM-DD), ignoré si start/end fournis
|
||||
responses:
|
||||
200:
|
||||
description: Fichier CSV
|
||||
content:
|
||||
text/csv:
|
||||
schema:
|
||||
type: string
|
||||
400:
|
||||
description: Paramètre invalide
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
description: Plan insuffisant (pro requis)
|
||||
"""
|
||||
export_type = request.args.get("type", "predictions").lower()
|
||||
if export_type not in ("predictions", "bets"):
|
||||
return bad_request(
|
||||
"Paramètre 'type' invalide. Valeurs acceptées: predictions, bets"
|
||||
)
|
||||
|
||||
start = request.args.get("start")
|
||||
end = request.args.get("end")
|
||||
date = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
|
||||
|
||||
for label, val in [("start", start), ("end", end), ("date", date)]:
|
||||
if val:
|
||||
try:
|
||||
datetime.strptime(val, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return bad_request(
|
||||
f"Paramètre '{label}' invalide, format attendu: YYYY-MM-DD"
|
||||
)
|
||||
|
||||
# Build date range
|
||||
if start and end:
|
||||
date_cond = "date BETWEEN ? AND ?"
|
||||
date_params = [start, end]
|
||||
elif start:
|
||||
date_cond = "date >= ?"
|
||||
date_params = [start]
|
||||
else:
|
||||
date_cond = "date = ?"
|
||||
date_params = [date]
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
output = io.StringIO()
|
||||
|
||||
if export_type == "predictions":
|
||||
if not table_exists(conn, "ml_predictions_cache"):
|
||||
return bad_request("Table ml_predictions_cache introuvable")
|
||||
|
||||
rows = conn.execute(
|
||||
f"""SELECT date, race_label, hippodrome, discipline, distance, heure,
|
||||
horse_name, horse_number, odds, prob_top1, prob_top3,
|
||||
ml_score, recommendation, is_value_bet, risque_label
|
||||
FROM ml_predictions_cache
|
||||
WHERE {date_cond}
|
||||
ORDER BY date DESC, ml_score DESC
|
||||
LIMIT {EXPORT_MAX_ROWS}""",
|
||||
date_params,
|
||||
).fetchall()
|
||||
|
||||
fieldnames = [
|
||||
"date",
|
||||
"race_label",
|
||||
"hippodrome",
|
||||
"discipline",
|
||||
"distance",
|
||||
"heure",
|
||||
"horse_name",
|
||||
"horse_number",
|
||||
"odds",
|
||||
"prob_top1",
|
||||
"prob_top3",
|
||||
"ml_score",
|
||||
"recommendation",
|
||||
"is_value_bet",
|
||||
"risque_label",
|
||||
]
|
||||
|
||||
else: # bets
|
||||
if not table_exists(conn, "bet_results"):
|
||||
return bad_request("Table bet_results introuvable")
|
||||
|
||||
rows = conn.execute(
|
||||
f"""SELECT date, race_name, type_pari, horse_name, horse_number,
|
||||
COALESCE(cote, 0) AS cote, mise, resultat, gain
|
||||
FROM bet_results
|
||||
WHERE {date_cond}
|
||||
ORDER BY date DESC
|
||||
LIMIT {EXPORT_MAX_ROWS}""",
|
||||
date_params,
|
||||
).fetchall()
|
||||
|
||||
fieldnames = [
|
||||
"date",
|
||||
"race_name",
|
||||
"type_pari",
|
||||
"horse_name",
|
||||
"horse_number",
|
||||
"cote",
|
||||
"mise",
|
||||
"resultat",
|
||||
"gain",
|
||||
]
|
||||
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
for row in rows:
|
||||
writer.writerow(dict(row))
|
||||
|
||||
filename = f"turf_{export_type}_{date_params[0]}.csv"
|
||||
return Response(
|
||||
output.getvalue(),
|
||||
status=200,
|
||||
mimetype="text/csv",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
44
api_v1/routes/health.py
Normal file
44
api_v1/routes/health.py
Normal file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
GET /api/v1/health — public healthcheck endpoint.
|
||||
No authentication required.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify
|
||||
from datetime import datetime, timezone
|
||||
|
||||
health_bp = Blueprint("v1_health", __name__, url_prefix="/api/v1")
|
||||
|
||||
|
||||
@health_bp.route("/health", methods=["GET"])
|
||||
def health():
|
||||
"""
|
||||
Health check
|
||||
---
|
||||
tags:
|
||||
- System
|
||||
summary: Public healthcheck — returns API status and timestamp
|
||||
responses:
|
||||
200:
|
||||
description: API is healthy
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
example: ok
|
||||
version:
|
||||
type: string
|
||||
example: "1.0"
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
"""
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.0",
|
||||
"api": "Turf SaaS API v1",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
), 200
|
||||
213
api_v1/routes/history.py
Normal file
213
api_v1/routes/history.py
Normal file
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
History routes for API v1.
|
||||
|
||||
GET /api/v1/history — Historique des prédictions avec filtre date range,
|
||||
limité selon le plan (Free: 7j, Premium: 90j, Pro: illimité)
|
||||
|
||||
Ticket: HRT-81 — Historique limité/illimité selon plan (Free/Premium/Pro)
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, jsonify, request, g
|
||||
|
||||
from api_v1.utils import (
|
||||
get_db,
|
||||
table_exists,
|
||||
internal_error,
|
||||
bad_request,
|
||||
forbidden,
|
||||
get_pagination_params,
|
||||
paginate_query,
|
||||
)
|
||||
# Auth: try flask_jwt_extended (app_v1) first, fall back to saas_auth (portal_server)
|
||||
from saas_auth import require_auth as jwt_required_middleware
|
||||
|
||||
history_bp = Blueprint("v1_history", __name__, url_prefix="/api/v1/history")
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Plan limits (days of history accessible; None = unlimited)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
HISTORY_DAYS = {
|
||||
"free": 7,
|
||||
"premium": 90,
|
||||
"pro": None, # illimité
|
||||
}
|
||||
|
||||
# Fallback for unknown plans: treat like free
|
||||
_DEFAULT_LIMIT = 7
|
||||
|
||||
|
||||
def _get_plan_max_days(plan: str):
|
||||
"""Return the max history days allowed for the given plan, or default."""
|
||||
return HISTORY_DAYS.get(plan, _DEFAULT_LIMIT)
|
||||
|
||||
|
||||
def _parse_date(date_str: str, param_name: str):
|
||||
"""Parse YYYY-MM-DD date string, raise ValueError with context on failure."""
|
||||
try:
|
||||
return datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Paramètre '{param_name}' invalide : format attendu YYYY-MM-DD, reçu '{date_str}'"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GET /api/v1/history
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@history_bp.route("", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
def get_history():
|
||||
"""
|
||||
Historique des prédictions ML avec filtre date range
|
||||
---
|
||||
tags:
|
||||
- Historique
|
||||
summary: |
|
||||
Historique des prédictions sur une plage de dates.
|
||||
Limite selon le plan :
|
||||
- Free : 7 derniers jours
|
||||
- Premium : 90 derniers jours
|
||||
- Pro : illimité
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: start
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date de début au format YYYY-MM-DD (défaut : aujourd'hui - max_days du plan)
|
||||
- name: end
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date de fin au format YYYY-MM-DD (défaut : aujourd'hui)
|
||||
- name: limit
|
||||
in: query
|
||||
type: integer
|
||||
default: 50
|
||||
description: Nombre de résultats par page (max 500)
|
||||
- name: offset
|
||||
in: query
|
||||
type: integer
|
||||
default: 0
|
||||
responses:
|
||||
200:
|
||||
description: Historique des prédictions ML
|
||||
400:
|
||||
description: Paramètre de date invalide
|
||||
401:
|
||||
description: Token invalide ou manquant
|
||||
403:
|
||||
description: Plage de dates hors limite du plan — upgrade requis
|
||||
"""
|
||||
user = getattr(request, "current_user", None) or getattr(g, "current_user", None)
|
||||
if not user:
|
||||
return jsonify({"error": "Non authentifié"}), 401
|
||||
|
||||
plan = user.get("plan", "free")
|
||||
today = datetime.now().date()
|
||||
max_days = _get_plan_max_days(plan)
|
||||
|
||||
# ── Parse end date ────────────────────────────────────────
|
||||
end_str = request.args.get("end", today.isoformat())
|
||||
try:
|
||||
end_date = _parse_date(end_str, "end")
|
||||
except ValueError as exc:
|
||||
return bad_request(str(exc))
|
||||
|
||||
# ── Parse start date ─────────────────────────────────────
|
||||
if max_days is not None:
|
||||
default_start = today - timedelta(days=max_days - 1)
|
||||
else:
|
||||
# Pro: default to 30 days back when no start provided
|
||||
default_start = today - timedelta(days=29)
|
||||
|
||||
start_str = request.args.get("start", default_start.isoformat())
|
||||
try:
|
||||
start_date = _parse_date(start_str, "start")
|
||||
except ValueError as exc:
|
||||
return bad_request(str(exc))
|
||||
|
||||
# ── Validate ordering ─────────────────────────────────────
|
||||
if start_date > end_date:
|
||||
return bad_request(
|
||||
f"'start' ({start_str}) ne peut pas être postérieur à 'end' ({end_str})"
|
||||
)
|
||||
|
||||
# ── Enforce plan window ───────────────────────────────────
|
||||
if max_days is not None:
|
||||
earliest_allowed = today - timedelta(days=max_days - 1)
|
||||
if start_date < earliest_allowed:
|
||||
return forbidden(
|
||||
message=(
|
||||
f"Historique limité à {max_days} jours pour le plan '{plan}'. "
|
||||
f"Date de début minimale autorisée : {earliest_allowed.isoformat()}. "
|
||||
f"Passez à un plan supérieur pour accéder à un historique plus long."
|
||||
),
|
||||
required_plans=["premium", "pro"] if plan == "free" else ["pro"],
|
||||
current_plan=plan,
|
||||
)
|
||||
|
||||
# ── Pagination ────────────────────────────────────────────
|
||||
limit, offset = get_pagination_params(default_limit=50, max_limit=500)
|
||||
|
||||
# ── Query ─────────────────────────────────────────────────
|
||||
conn = get_db()
|
||||
try:
|
||||
if not table_exists(conn, "ml_predictions_cache"):
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"plan": plan,
|
||||
"start": start_date.isoformat(),
|
||||
"end": end_date.isoformat(),
|
||||
"history": [],
|
||||
**paginate_query([], 0, limit, offset),
|
||||
}
|
||||
), 200
|
||||
|
||||
count_row = conn.execute(
|
||||
"""SELECT COUNT(*) as cnt
|
||||
FROM ml_predictions_cache
|
||||
WHERE date >= ? AND date <= ?""",
|
||||
(start_date.isoformat(), end_date.isoformat()),
|
||||
).fetchone()
|
||||
total = count_row["cnt"] if count_row else 0
|
||||
|
||||
sql = """
|
||||
SELECT
|
||||
id, date, horse_name, prob_top1, prob_top3,
|
||||
ml_score, race_label, hippodrome, heure, is_value_bet
|
||||
FROM ml_predictions_cache
|
||||
WHERE date >= ? AND date <= ?
|
||||
ORDER BY date DESC, ml_score DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
rows = conn.execute(
|
||||
sql,
|
||||
(start_date.isoformat(), end_date.isoformat(), limit, offset),
|
||||
).fetchall()
|
||||
|
||||
history = [dict(r) for r in rows]
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"plan": plan,
|
||||
"history_limit_days": max_days,
|
||||
"start": start_date.isoformat(),
|
||||
"end": end_date.isoformat(),
|
||||
"history": history,
|
||||
**paginate_query(history, total, limit, offset),
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as exc:
|
||||
return internal_error(str(exc))
|
||||
finally:
|
||||
conn.close()
|
||||
150
api_v1/routes/metrics.py
Normal file
150
api_v1/routes/metrics.py
Normal file
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Metrics route for API v1.
|
||||
|
||||
GET /api/v1/metrics — Métriques performances ML (premium+)
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from api_v1.utils import (
|
||||
get_db,
|
||||
table_exists,
|
||||
internal_error,
|
||||
bad_request,
|
||||
)
|
||||
from saas_auth import require_auth as jwt_required_middleware
|
||||
from flask import request as _req
|
||||
|
||||
metrics_bp = Blueprint("v1_metrics", __name__, url_prefix="/api/v1")
|
||||
|
||||
|
||||
@metrics_bp.route("/metrics", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
def metrics():
|
||||
# plan check: premium or pro (or TEST_MODE via plan='pro' in DB)
|
||||
user = getattr(_req, 'current_user', None) or {}
|
||||
plan = user.get('plan', 'free') if isinstance(user, dict) else 'free'
|
||||
if plan not in ('premium', 'pro'):
|
||||
from flask import jsonify as _j
|
||||
return _j({'error': 'Plan premium ou pro requis'}), 403
|
||||
"""
|
||||
Métriques ML
|
||||
---
|
||||
tags:
|
||||
- Métriques
|
||||
summary: Métriques de performance du modèle ML (precision, ROI, top-3 rate) — premium+
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: days
|
||||
in: query
|
||||
type: integer
|
||||
default: 30
|
||||
description: Nombre de jours à analyser (max 365)
|
||||
responses:
|
||||
200:
|
||||
description: Métriques de performance ML
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
description: Plan insuffisant (premium ou pro requis)
|
||||
"""
|
||||
try:
|
||||
days = int(request.args.get("days", 30))
|
||||
except (ValueError, TypeError):
|
||||
return bad_request("Paramètre 'days' doit être un entier")
|
||||
|
||||
days = max(1, min(days, 365))
|
||||
end_date = datetime.now().strftime("%Y-%m-%d")
|
||||
start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
# ── Bet-level metrics from bet_results ──
|
||||
bet_metrics = {
|
||||
"available": False,
|
||||
"period": {"start": start_date, "end": end_date, "days": days},
|
||||
}
|
||||
ml_metrics = {"available": False}
|
||||
daily_stats = []
|
||||
|
||||
if table_exists(conn, "bet_results"):
|
||||
row = conn.execute(
|
||||
"""SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN resultat='GAGNE' THEN 1 ELSE 0 END) AS gagne,
|
||||
SUM(mise) AS mise,
|
||||
SUM(gain) AS gain
|
||||
FROM bet_results
|
||||
WHERE date BETWEEN ? AND ?""",
|
||||
(start_date, end_date),
|
||||
).fetchone()
|
||||
|
||||
total = row["total"] or 0
|
||||
gagne = row["gagne"] or 0
|
||||
mise = float(row["mise"] or 0)
|
||||
gain = float(row["gain"] or 0)
|
||||
|
||||
bet_metrics = {
|
||||
"available": True,
|
||||
"period": {"start": start_date, "end": end_date, "days": days},
|
||||
"total_bets": total,
|
||||
"precision_pct": round(gagne / total * 100, 2) if total > 0 else 0.0,
|
||||
"roi_pct": round((gain - mise) / mise * 100, 2) if mise > 0 else 0.0,
|
||||
"mise_totale": round(mise, 2),
|
||||
"gain_total": round(gain, 2),
|
||||
}
|
||||
|
||||
# ── ML predictions cache metrics ──
|
||||
if table_exists(conn, "ml_predictions_cache"):
|
||||
cache_row = conn.execute(
|
||||
"""SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(is_value_bet) AS value_bets,
|
||||
AVG(prob_top1) AS avg_prob_top1,
|
||||
AVG(prob_top3) AS avg_prob_top3,
|
||||
AVG(ml_score) AS avg_ml_score
|
||||
FROM ml_predictions_cache
|
||||
WHERE date BETWEEN ? AND ?""",
|
||||
(start_date, end_date),
|
||||
).fetchone()
|
||||
|
||||
if cache_row and cache_row["total"]:
|
||||
ml_metrics = {
|
||||
"available": True,
|
||||
"total_predictions": cache_row["total"],
|
||||
"value_bets": cache_row["value_bets"] or 0,
|
||||
"avg_prob_top1": round(float(cache_row["avg_prob_top1"] or 0), 4),
|
||||
"avg_prob_top3": round(float(cache_row["avg_prob_top3"] or 0), 4),
|
||||
"avg_ml_score": round(float(cache_row["avg_ml_score"] or 0), 4),
|
||||
}
|
||||
|
||||
# ── Daily breakdown ──
|
||||
if table_exists(conn, "daily_stats"):
|
||||
daily_rows = conn.execute(
|
||||
"""SELECT date, total_bets, bets_gagne, precision_pct, roi_pct,
|
||||
mise_totale, gain_total
|
||||
FROM daily_stats
|
||||
WHERE date BETWEEN ? AND ?
|
||||
ORDER BY date DESC
|
||||
LIMIT 60""",
|
||||
(start_date, end_date),
|
||||
).fetchall()
|
||||
daily_stats = [dict(r) for r in daily_rows]
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"period": {"start": start_date, "end": end_date, "days": days},
|
||||
"bet_metrics": bet_metrics,
|
||||
"ml_metrics": ml_metrics,
|
||||
"daily": daily_stats,
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
199
api_v1/routes/ml_feedback.py
Normal file
199
api_v1/routes/ml_feedback.py
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ml_feedback.py — API routes pour le feedback loop ML (turf_saas).
|
||||
|
||||
Routes:
|
||||
POST /api/v1/ml/feedback/run — Déclenche le feedback loop (admin uniquement)
|
||||
GET /api/v1/ml/feedback/stats — Stats performances par stratégie
|
||||
|
||||
Sécurité admin : token via variable d'environnement ML_ADMIN_TOKEN
|
||||
ou plan "pro" en fallback pour les stats.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, jsonify, request, g
|
||||
|
||||
# Ajoute le répertoire parent de api_v1 dans le path pour importer ml_feedback_saas
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
||||
|
||||
from api_v1.utils import get_db, internal_error, bad_request
|
||||
# Auth: try flask_jwt_extended (app_v1) first, fall back to saas_auth (portal_server)
|
||||
try:
|
||||
from auth import jwt_required_middleware
|
||||
except ImportError:
|
||||
from saas_auth import require_auth as jwt_required_middleware
|
||||
try:
|
||||
from auth import plan_required
|
||||
except ImportError:
|
||||
plan_required = lambda *a, **kw: (lambda f: f)
|
||||
|
||||
ml_feedback_bp = Blueprint("v1_ml_feedback", __name__, url_prefix="/api/v1/ml/feedback")
|
||||
|
||||
# Token admin interne — configurable via variable d'environnement
|
||||
ML_ADMIN_TOKEN = os.environ.get("ML_ADMIN_TOKEN", "")
|
||||
|
||||
|
||||
def _check_admin(req):
|
||||
"""Vérifie le token admin via header X-Admin-Token ou Authorization Bearer (plan pro)."""
|
||||
# 1. Token interne (scheduler/cron)
|
||||
admin_token = req.headers.get("X-Admin-Token", "").strip()
|
||||
if ML_ADMIN_TOKEN and admin_token == ML_ADMIN_TOKEN:
|
||||
return True, None
|
||||
|
||||
# 2. Pas de token admin configuré → autoriser les utilisateurs "pro" authentifiés
|
||||
user = getattr(request, "current_user", None) or getattr(g, "current_user", None)
|
||||
if user and user.get("plan") == "pro":
|
||||
return True, None
|
||||
|
||||
return False, jsonify({"error": "Accès admin requis", "code": 403}), 403
|
||||
|
||||
|
||||
@ml_feedback_bp.route("/run", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
def feedback_run():
|
||||
"""
|
||||
Déclenche le feedback loop ML pour une date donnée.
|
||||
---
|
||||
tags:
|
||||
- ML Feedback
|
||||
summary: Déclenche le feedback loop XGBoost (admin only)
|
||||
security:
|
||||
- Bearer: []
|
||||
- AdminToken: []
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
date:
|
||||
type: string
|
||||
description: Date YYYY-MM-DD (défaut aujourd'hui)
|
||||
example: "2026-04-25"
|
||||
mode:
|
||||
type: string
|
||||
description: "run (défaut) ou backfill"
|
||||
enum: [run, backfill]
|
||||
example: run
|
||||
responses:
|
||||
200:
|
||||
description: Feedback loop exécuté avec succès
|
||||
400:
|
||||
description: Paramètre invalide
|
||||
403:
|
||||
description: Accès refusé
|
||||
500:
|
||||
description: Erreur interne
|
||||
"""
|
||||
# Vérification admin
|
||||
user = getattr(request, "current_user", None) or getattr(g, "current_user", None)
|
||||
admin_token = request.headers.get("X-Admin-Token", "").strip()
|
||||
is_admin = (ML_ADMIN_TOKEN and admin_token == ML_ADMIN_TOKEN) or (
|
||||
user and user.get("plan") == "pro"
|
||||
)
|
||||
if not is_admin:
|
||||
return jsonify({"error": "Accès admin requis", "code": 403}), 403
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
date_str = body.get("date") or datetime.now().strftime("%Y-%m-%d")
|
||||
mode = body.get("mode", "run")
|
||||
|
||||
# Validation date
|
||||
try:
|
||||
datetime.strptime(date_str, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return bad_request(f"Date invalide : {date_str}. Format attendu : YYYY-MM-DD")
|
||||
|
||||
if mode not in ("run", "backfill"):
|
||||
return bad_request("mode doit être 'run' ou 'backfill'")
|
||||
|
||||
try:
|
||||
import ml_feedback_saas
|
||||
|
||||
if mode == "backfill":
|
||||
inseres, maj = ml_feedback_saas.backfill(date_str)
|
||||
total_inseres = inseres
|
||||
else:
|
||||
result = ml_feedback_saas.run(date_str)
|
||||
total_inseres = sum(result["inseres"].values())
|
||||
maj = result["maj"]
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"date": date_str,
|
||||
"mode": mode,
|
||||
"paris_inseres": total_inseres,
|
||||
"paris_mis_a_jour": maj,
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
|
||||
|
||||
@ml_feedback_bp.route("/stats", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("premium", "pro")
|
||||
def feedback_stats():
|
||||
"""
|
||||
Stats performances ML par stratégie.
|
||||
---
|
||||
tags:
|
||||
- ML Feedback
|
||||
summary: Stats paris ML par stratégie (premium+)
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: date_debut
|
||||
in: query
|
||||
type: string
|
||||
description: Date de début YYYY-MM-DD
|
||||
- name: date_fin
|
||||
in: query
|
||||
type: string
|
||||
description: Date de fin YYYY-MM-DD
|
||||
responses:
|
||||
200:
|
||||
description: Stats par stratégie
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
description: Plan insuffisant (premium ou pro requis)
|
||||
"""
|
||||
date_debut = request.args.get("date_debut")
|
||||
date_fin = request.args.get("date_fin")
|
||||
|
||||
# Validation optionnelle des dates
|
||||
for d_str, label in [(date_debut, "date_debut"), (date_fin, "date_fin")]:
|
||||
if d_str:
|
||||
try:
|
||||
datetime.strptime(d_str, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return bad_request(f"{label} invalide : {d_str}. Format : YYYY-MM-DD")
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
import ml_feedback_saas
|
||||
|
||||
stats = ml_feedback_saas.get_feedback_stats(conn, date_debut, date_fin)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"strategies": stats,
|
||||
"filters": {
|
||||
"date_debut": date_debut,
|
||||
"date_fin": date_fin,
|
||||
},
|
||||
"total_strategies": len(stats),
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
536
api_v1/routes/org.py
Normal file
536
api_v1/routes/org.py
Normal file
@@ -0,0 +1,536 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Org Blueprint — Multi-compte / Organisations Pro
|
||||
Sprint: HRT-82
|
||||
|
||||
Endpoints:
|
||||
POST /api/v1/org — créer une organisation (Pro only, 1 max par owner)
|
||||
GET /api/v1/org — infos org courante
|
||||
DELETE /api/v1/org — supprimer l'org (owner only)
|
||||
POST /api/v1/org/invite — inviter un membre par email (max 5 totaux)
|
||||
GET /api/v1/org/members — liste des membres
|
||||
DELETE /api/v1/org/members/<user_id> — retirer un membre (owner only)
|
||||
|
||||
Plan enforcement:
|
||||
- Toutes les routes nécessitent plan=pro via plan_required('pro')
|
||||
- Limite : 1 org par owner, 5 membres max (owner inclus)
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from saas_auth import require_auth as jwt_required_middleware
|
||||
from org_db import get_db, migrate_org_tables
|
||||
|
||||
logger = logging.getLogger("turf_saas.org")
|
||||
|
||||
org_bp = Blueprint("org", __name__, url_prefix="/api/v1/org")
|
||||
|
||||
MAX_MEMBERS = 5 # max membres totaux owner inclus
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Decorator: plan Pro requis
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _require_pro(fn):
|
||||
"""Vérifie que l'utilisateur courant est sur le plan 'pro'."""
|
||||
from functools import wraps
|
||||
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
user = getattr(request, "current_user", None)
|
||||
if not user:
|
||||
return jsonify({"error": "Non authentifié"}), 401
|
||||
if user.get("plan") != "pro":
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Plan insuffisant",
|
||||
"required": "pro",
|
||||
"current_plan": user.get("plan", "free"),
|
||||
"upgrade_url": "/api/v1/billing/checkout",
|
||||
}
|
||||
), 403
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Helpers DB
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_org_by_owner(db, owner_id: str):
|
||||
return db.execute(
|
||||
"SELECT * FROM organizations WHERE owner_id = ?", (owner_id,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _get_org_by_id(db, org_id: str):
|
||||
return db.execute("SELECT * FROM organizations WHERE id = ?", (org_id,)).fetchone()
|
||||
|
||||
|
||||
def _get_member_org(db, user_id: str):
|
||||
"""Retourne l'org dont user_id est membre (owner ou member)."""
|
||||
row = db.execute(
|
||||
"""SELECT o.* FROM organizations o
|
||||
JOIN org_members m ON m.org_id = o.id
|
||||
WHERE m.user_id = ?
|
||||
LIMIT 1""",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
return row
|
||||
|
||||
|
||||
def _count_org_members(db, org_id: str) -> int:
|
||||
row = db.execute(
|
||||
"SELECT COUNT(*) AS cnt FROM org_members WHERE org_id = ?", (org_id,)
|
||||
).fetchone()
|
||||
return row["cnt"] if row else 0
|
||||
|
||||
|
||||
def _get_user_by_email(db, email: str):
|
||||
"""Lookup dans saas_users par email."""
|
||||
return db.execute(
|
||||
"SELECT * FROM saas_users WHERE email = ?", (email.lower().strip(),)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _org_to_dict(org) -> dict:
|
||||
return {
|
||||
"id": org["id"],
|
||||
"owner_id": org["owner_id"],
|
||||
"name": org["name"],
|
||||
"max_members": org["max_members"],
|
||||
"created_at": org["created_at"],
|
||||
}
|
||||
|
||||
|
||||
def _member_to_dict(m) -> dict:
|
||||
return {
|
||||
"id": m["id"],
|
||||
"org_id": m["org_id"],
|
||||
"user_id": m["user_id"],
|
||||
"role": m["role"],
|
||||
"invited_at": m["invited_at"],
|
||||
"joined_at": m["joined_at"],
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# POST /api/v1/org — créer une organisation
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@org_bp.route("", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
@_require_pro
|
||||
def create_org():
|
||||
"""
|
||||
Crée une organisation.
|
||||
---
|
||||
tags:
|
||||
- Organisation
|
||||
security:
|
||||
- Bearer: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [name]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Nom de l'organisation (1-100 caractères)
|
||||
responses:
|
||||
201:
|
||||
description: Organisation créée
|
||||
400:
|
||||
description: Paramètre manquant ou invalide
|
||||
403:
|
||||
description: Plan insuffisant
|
||||
409:
|
||||
description: L'utilisateur possède déjà une organisation
|
||||
"""
|
||||
user = request.current_user
|
||||
owner_id = user["id"]
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name or len(name) > 100:
|
||||
return jsonify({"error": "Le nom est requis (1-100 caractères)"}), 400
|
||||
|
||||
db = get_db()
|
||||
try:
|
||||
# 1 org max par owner
|
||||
existing = _get_org_by_owner(db, owner_id)
|
||||
if existing:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Vous possédez déjà une organisation",
|
||||
"org_id": existing["id"],
|
||||
}
|
||||
), 409
|
||||
|
||||
org_id = secrets.token_hex(16)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
db.execute(
|
||||
"INSERT INTO organizations (id, owner_id, name, max_members, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(org_id, owner_id, name, MAX_MEMBERS, now),
|
||||
)
|
||||
# Ajouter l'owner comme premier membre avec rôle 'owner'
|
||||
db.execute(
|
||||
"INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) "
|
||||
"VALUES (?, ?, 'owner', ?, ?)",
|
||||
(org_id, owner_id, now, now),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
org = _get_org_by_id(db, org_id)
|
||||
logger.info("Org créée: %s par user %s", org_id, owner_id)
|
||||
return jsonify({"org": _org_to_dict(org)}), 201
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("create_org error: %s", e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GET /api/v1/org — infos org courante
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@org_bp.route("", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@_require_pro
|
||||
def get_org():
|
||||
"""
|
||||
Retourne l'organisation dont l'utilisateur est owner ou membre.
|
||||
---
|
||||
tags:
|
||||
- Organisation
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Infos de l'organisation
|
||||
404:
|
||||
description: Aucune organisation trouvée
|
||||
"""
|
||||
user = request.current_user
|
||||
db = get_db()
|
||||
try:
|
||||
org = _get_org_by_owner(db, user["id"]) or _get_member_org(db, user["id"])
|
||||
if not org:
|
||||
return jsonify({"error": "Aucune organisation trouvée"}), 404
|
||||
|
||||
member_count = _count_org_members(db, org["id"])
|
||||
result = _org_to_dict(org)
|
||||
result["member_count"] = member_count
|
||||
return jsonify({"org": result}), 200
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# DELETE /api/v1/org — supprimer l'organisation
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@org_bp.route("", methods=["DELETE"])
|
||||
@jwt_required_middleware
|
||||
@_require_pro
|
||||
def delete_org():
|
||||
"""
|
||||
Supprime l'organisation (owner uniquement).
|
||||
---
|
||||
tags:
|
||||
- Organisation
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Organisation supprimée
|
||||
403:
|
||||
description: Seul l'owner peut supprimer l'organisation
|
||||
404:
|
||||
description: Organisation introuvable
|
||||
"""
|
||||
user = request.current_user
|
||||
db = get_db()
|
||||
try:
|
||||
org = _get_org_by_owner(db, user["id"])
|
||||
if not org:
|
||||
return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403
|
||||
|
||||
# CASCADE supprime org_members automatiquement (FK ON DELETE CASCADE)
|
||||
db.execute("DELETE FROM organizations WHERE id = ?", (org["id"],))
|
||||
db.commit()
|
||||
logger.info("Org %s supprimée par user %s", org["id"], user["id"])
|
||||
return jsonify({"ok": True, "deleted_org_id": org["id"]}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("delete_org error: %s", e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# POST /api/v1/org/invite — inviter un membre par email
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@org_bp.route("/invite", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
@_require_pro
|
||||
def invite_member():
|
||||
"""
|
||||
Invite un utilisateur dans l'organisation par email (owner uniquement).
|
||||
Limite : 5 membres totaux (owner inclus).
|
||||
---
|
||||
tags:
|
||||
- Organisation
|
||||
security:
|
||||
- Bearer: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [email]
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
description: Email de l'utilisateur à inviter
|
||||
responses:
|
||||
201:
|
||||
description: Membre ajouté
|
||||
400:
|
||||
description: Paramètre manquant ou invalide
|
||||
403:
|
||||
description: Seul l'owner peut inviter / limite de membres atteinte
|
||||
404:
|
||||
description: Utilisateur introuvable ou organisation inexistante
|
||||
409:
|
||||
description: L'utilisateur est déjà membre
|
||||
"""
|
||||
user = request.current_user
|
||||
data = request.get_json(silent=True) or {}
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
|
||||
if not email or "@" not in email:
|
||||
return jsonify({"error": "Email invalide"}), 400
|
||||
|
||||
db = get_db()
|
||||
try:
|
||||
# Vérifier que l'appelant est bien owner d'une org
|
||||
org = _get_org_by_owner(db, user["id"])
|
||||
if not org:
|
||||
return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403
|
||||
|
||||
# Vérifier la limite de membres
|
||||
current_count = _count_org_members(db, org["id"])
|
||||
if current_count >= org["max_members"]:
|
||||
return jsonify(
|
||||
{
|
||||
"error": f"Limite de {org['max_members']} membres atteinte",
|
||||
"current_count": current_count,
|
||||
}
|
||||
), 403
|
||||
|
||||
# Résoudre l'utilisateur cible
|
||||
target_user = _get_user_by_email(db, email)
|
||||
if not target_user:
|
||||
return jsonify({"error": "Utilisateur introuvable avec cet email"}), 404
|
||||
|
||||
target_id = target_user["id"]
|
||||
|
||||
# Vérifier que l'utilisateur n'est pas déjà membre de CETTE org
|
||||
existing_member = db.execute(
|
||||
"SELECT id FROM org_members WHERE org_id = ? AND user_id = ?",
|
||||
(org["id"], target_id),
|
||||
).fetchone()
|
||||
if existing_member:
|
||||
return jsonify(
|
||||
{"error": "Cet utilisateur est déjà membre de l'organisation"}
|
||||
), 409
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
db.execute(
|
||||
"INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) "
|
||||
"VALUES (?, ?, 'member', ?, ?)",
|
||||
(org["id"], target_id, now, now),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
member_row = db.execute(
|
||||
"SELECT * FROM org_members WHERE org_id = ? AND user_id = ?",
|
||||
(org["id"], target_id),
|
||||
).fetchone()
|
||||
logger.info(
|
||||
"User %s invité dans org %s par %s", target_id, org["id"], user["id"]
|
||||
)
|
||||
return jsonify({"member": _member_to_dict(member_row)}), 201
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("invite_member error: %s", e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GET /api/v1/org/members — liste des membres
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@org_bp.route("/members", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@_require_pro
|
||||
def list_members():
|
||||
"""
|
||||
Liste les membres de l'organisation courante.
|
||||
---
|
||||
tags:
|
||||
- Organisation
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Liste des membres
|
||||
404:
|
||||
description: Organisation introuvable
|
||||
"""
|
||||
user = request.current_user
|
||||
db = get_db()
|
||||
try:
|
||||
org = _get_org_by_owner(db, user["id"]) or _get_member_org(db, user["id"])
|
||||
if not org:
|
||||
return jsonify({"error": "Aucune organisation trouvée"}), 404
|
||||
|
||||
members = db.execute(
|
||||
"SELECT m.*, u.email, u.firstname, u.lastname "
|
||||
"FROM org_members m "
|
||||
"LEFT JOIN saas_users u ON u.id = m.user_id "
|
||||
"WHERE m.org_id = ? "
|
||||
"ORDER BY m.invited_at ASC",
|
||||
(org["id"],),
|
||||
).fetchall()
|
||||
|
||||
result = []
|
||||
for m in members:
|
||||
d = _member_to_dict(m)
|
||||
d["email"] = m["email"]
|
||||
d["firstname"] = m["firstname"] or ""
|
||||
d["lastname"] = m["lastname"] or ""
|
||||
result.append(d)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"org_id": org["id"],
|
||||
"members": result,
|
||||
"count": len(result),
|
||||
"max_members": org["max_members"],
|
||||
}
|
||||
), 200
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# DELETE /api/v1/org/members/<user_id> — retirer un membre
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@org_bp.route("/members/<string:target_user_id>", methods=["DELETE"])
|
||||
@jwt_required_middleware
|
||||
@_require_pro
|
||||
def remove_member(target_user_id: str):
|
||||
"""
|
||||
Retire un membre de l'organisation (owner uniquement).
|
||||
L'owner ne peut pas se retirer lui-même.
|
||||
---
|
||||
tags:
|
||||
- Organisation
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- in: path
|
||||
name: user_id
|
||||
type: string
|
||||
required: true
|
||||
description: ID de l'utilisateur à retirer
|
||||
responses:
|
||||
200:
|
||||
description: Membre retiré
|
||||
400:
|
||||
description: Tentative de retirer l'owner lui-même
|
||||
403:
|
||||
description: Seul l'owner peut retirer des membres
|
||||
404:
|
||||
description: Membre ou organisation introuvable
|
||||
"""
|
||||
user = request.current_user
|
||||
db = get_db()
|
||||
try:
|
||||
org = _get_org_by_owner(db, user["id"])
|
||||
if not org:
|
||||
return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403
|
||||
|
||||
# L'owner ne peut pas se retirer lui-même (utiliser DELETE /api/v1/org à la place)
|
||||
if target_user_id == user["id"]:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "L'owner ne peut pas se retirer lui-même. "
|
||||
"Utilisez DELETE /api/v1/org pour supprimer l'organisation."
|
||||
}
|
||||
), 400
|
||||
|
||||
member = db.execute(
|
||||
"SELECT * FROM org_members WHERE org_id = ? AND user_id = ?",
|
||||
(org["id"], target_user_id),
|
||||
).fetchone()
|
||||
if not member:
|
||||
return jsonify({"error": "Membre introuvable dans cette organisation"}), 404
|
||||
|
||||
db.execute(
|
||||
"DELETE FROM org_members WHERE org_id = ? AND user_id = ?",
|
||||
(org["id"], target_user_id),
|
||||
)
|
||||
db.commit()
|
||||
logger.info(
|
||||
"User %s retiré de l'org %s par %s", target_user_id, org["id"], user["id"]
|
||||
)
|
||||
return jsonify({"ok": True, "removed_user_id": target_user_id}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("remove_member error: %s", e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# On-import : migration idempotente
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
try:
|
||||
migrate_org_tables()
|
||||
except Exception as _e:
|
||||
logger.warning("org_db migration skipped (test env?): %s", _e)
|
||||
226
api_v1/routes/predictions.py
Normal file
226
api_v1/routes/predictions.py
Normal file
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Predictions routes for API v1.
|
||||
|
||||
GET /api/v1/predictions/top3 — Top 3 global du jour (free tier, 1/day limit)
|
||||
GET /api/v1/predictions/all — Toutes prédictions (premium+)
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from api_v1.utils import (
|
||||
get_db,
|
||||
table_exists,
|
||||
internal_error,
|
||||
not_found,
|
||||
get_pagination_params,
|
||||
paginate_query,
|
||||
)
|
||||
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, 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
|
||||
|
||||
count_row = conn.execute(
|
||||
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
||||
(date,),
|
||||
).fetchone()
|
||||
total = count_row["cnt"] if count_row else 0
|
||||
|
||||
if (
|
||||
include_weather
|
||||
and table_exists(conn, "pmu_meteo")
|
||||
and table_exists(conn, "pmu_courses")
|
||||
):
|
||||
sql = """SELECT
|
||||
m.race_label, m.hippodrome, m.discipline, m.distance, m.heure,
|
||||
m.horse_name, m.horse_number, m.odds, m.prob_top1, m.prob_top3,
|
||||
m.ml_score, m.recommendation, m.is_value_bet, m.risque_label, m.risque_score,
|
||||
c.penetrometre_intitule,
|
||||
mt.nebulositecode, mt.nebulosite_court, mt.temperature, mt.force_vent
|
||||
FROM ml_predictions_cache m
|
||||
LEFT JOIN pmu_courses c
|
||||
ON c.date_programme = m.date
|
||||
AND c.num_reunion = m.num_reunion
|
||||
AND c.num_course = m.num_course
|
||||
LEFT JOIN pmu_meteo mt
|
||||
ON mt.date_programme = m.date
|
||||
AND mt.num_reunion = m.num_reunion
|
||||
WHERE m.date = ?
|
||||
ORDER BY m.ml_score DESC"""
|
||||
else:
|
||||
sql = """SELECT
|
||||
race_label, hippodrome, discipline, distance, heure,
|
||||
horse_name, horse_number, odds, prob_top1, prob_top3,
|
||||
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:
|
||||
sql += " LIMIT ? OFFSET ?"
|
||||
params += [limit, offset]
|
||||
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GET /api/v1/predictions/top3
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@predictions_bp.route("/top3", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@free_daily_limit_check
|
||||
def predictions_top3():
|
||||
"""
|
||||
Top 3 prédictions du jour
|
||||
---
|
||||
tags:
|
||||
- Prédictions
|
||||
summary: Top 3 chevaux avec le meilleur score ML du jour (free tier inclus)
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: date
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date au format YYYY-MM-DD (défaut aujourd'hui)
|
||||
responses:
|
||||
200:
|
||||
description: Top 3 prédictions ML du jour
|
||||
401:
|
||||
description: Token invalide
|
||||
429:
|
||||
description: Limite quotidienne free tier atteinte
|
||||
"""
|
||||
date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
predictions, _ = _fetch_ml_predictions(conn, date_param, limit=3, offset=0)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"date": date_param,
|
||||
"top3": predictions,
|
||||
}
|
||||
), 200
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GET /api/v1/predictions/all
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@predictions_bp.route("/all", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("premium", "pro")
|
||||
def predictions_all():
|
||||
"""
|
||||
Toutes les prédictions du jour
|
||||
---
|
||||
tags:
|
||||
- Prédictions
|
||||
summary: Toutes les prédictions ML du jour — accès premium et pro uniquement
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: date
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date au format YYYY-MM-DD (défaut aujourd'hui)
|
||||
- name: limit
|
||||
in: query
|
||||
type: integer
|
||||
default: 20
|
||||
- name: offset
|
||||
in: query
|
||||
type: integer
|
||||
default: 0
|
||||
responses:
|
||||
200:
|
||||
description: Toutes les prédictions ML
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
description: Plan insuffisant (premium ou pro requis)
|
||||
"""
|
||||
date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
|
||||
limit, offset = get_pagination_params(default_limit=50, max_limit=500)
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
predictions, total = _fetch_ml_predictions(
|
||||
conn, date_param, limit=limit, offset=offset, include_weather=True
|
||||
)
|
||||
pagination = paginate_query(predictions, total, limit, offset)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"date": date_param,
|
||||
"predictions": predictions,
|
||||
**pagination,
|
||||
}
|
||||
), 200
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
224
api_v1/routes/user.py
Normal file
224
api_v1/routes/user.py
Normal file
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
User route for API v1 — Telegram alert configuration
|
||||
HRT-79: Alertes Telegram configurables (Premium)
|
||||
|
||||
GET /api/v1/user/telegram-config — Lire la config Telegram de l'utilisateur connecté
|
||||
POST /api/v1/user/telegram-config — Mettre à jour la config Telegram
|
||||
|
||||
Accès : Premium / Pro uniquement (@jwt_required_middleware + @plan_required)
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from api_v1.utils import internal_error, bad_request
|
||||
# Auth: try flask_jwt_extended (app_v1) first, fall back to saas_auth (portal_server)
|
||||
try:
|
||||
from auth import jwt_required_middleware
|
||||
except ImportError:
|
||||
from saas_auth import require_auth as jwt_required_middleware
|
||||
try:
|
||||
from auth import plan_required
|
||||
except ImportError:
|
||||
plan_required = lambda *a, **kw: (lambda f: f)
|
||||
|
||||
user_bp = Blueprint("v1_user", __name__, url_prefix="/api/v1/user")
|
||||
|
||||
# DB_PATH est résolu via la même variable d'env que auth_db.py
|
||||
import os
|
||||
|
||||
_DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
|
||||
|
||||
def _get_db():
|
||||
conn = sqlite3.connect(_DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
# ── GET /api/v1/user/telegram-config ──────────────────────────────────────────
|
||||
|
||||
|
||||
@user_bp.route("/telegram-config", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("premium", "pro")
|
||||
def get_telegram_config():
|
||||
"""
|
||||
Retourne la configuration Telegram de l'utilisateur connecté.
|
||||
---
|
||||
tags:
|
||||
- Utilisateur
|
||||
summary: Lire la config alertes Telegram (premium+)
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Configuration Telegram courante
|
||||
schema:
|
||||
properties:
|
||||
telegram_chat_id:
|
||||
type: string
|
||||
nullable: true
|
||||
alert_value_bets:
|
||||
type: boolean
|
||||
alert_top1:
|
||||
type: boolean
|
||||
alert_quinte_only:
|
||||
type: boolean
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
description: Plan insuffisant
|
||||
"""
|
||||
user_id = request.user_id # injecté par jwt_required_middleware
|
||||
|
||||
conn = _get_db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT telegram_chat_id, alert_value_bets, alert_top1, alert_quinte_only
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
""",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
return jsonify({"error": "Utilisateur introuvable"}), 404
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"telegram_chat_id": row["telegram_chat_id"],
|
||||
"alert_value_bets": bool(row["alert_value_bets"]),
|
||||
"alert_top1": bool(row["alert_top1"]),
|
||||
"alert_quinte_only": bool(row["alert_quinte_only"]),
|
||||
}
|
||||
), 200
|
||||
|
||||
except sqlite3.OperationalError as exc:
|
||||
# Colonnes absentes : migration non appliquée
|
||||
return jsonify(
|
||||
{
|
||||
"telegram_chat_id": None,
|
||||
"alert_value_bets": True,
|
||||
"alert_top1": True,
|
||||
"alert_quinte_only": False,
|
||||
"_warning": "Migration Telegram non appliquée",
|
||||
}
|
||||
), 200
|
||||
except Exception as exc:
|
||||
return internal_error(str(exc))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ── POST /api/v1/user/telegram-config ─────────────────────────────────────────
|
||||
|
||||
|
||||
@user_bp.route("/telegram-config", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("premium", "pro")
|
||||
def update_telegram_config():
|
||||
"""
|
||||
Met à jour la configuration Telegram de l'utilisateur connecté.
|
||||
---
|
||||
tags:
|
||||
- Utilisateur
|
||||
summary: Configurer les alertes Telegram (premium+)
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
properties:
|
||||
telegram_chat_id:
|
||||
type: string
|
||||
description: Chat ID Telegram (ou null pour désactiver)
|
||||
alert_value_bets:
|
||||
type: boolean
|
||||
default: true
|
||||
alert_top1:
|
||||
type: boolean
|
||||
default: true
|
||||
alert_quinte_only:
|
||||
type: boolean
|
||||
default: false
|
||||
responses:
|
||||
200:
|
||||
description: Configuration mise à jour
|
||||
400:
|
||||
description: Paramètres invalides
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
description: Plan insuffisant
|
||||
"""
|
||||
user_id = request.user_id # injecté par jwt_required_middleware
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return bad_request("Corps JSON requis")
|
||||
|
||||
# Validation et extraction des champs
|
||||
telegram_chat_id = data.get("telegram_chat_id")
|
||||
if telegram_chat_id is not None and not isinstance(telegram_chat_id, str):
|
||||
return bad_request("telegram_chat_id doit être une chaîne ou null")
|
||||
if isinstance(telegram_chat_id, str):
|
||||
telegram_chat_id = telegram_chat_id.strip() or None
|
||||
|
||||
alert_value_bets = data.get("alert_value_bets", True)
|
||||
alert_top1 = data.get("alert_top1", True)
|
||||
alert_quinte_only = data.get("alert_quinte_only", False)
|
||||
|
||||
if not isinstance(alert_value_bets, bool):
|
||||
return bad_request("alert_value_bets doit être un booléen")
|
||||
if not isinstance(alert_top1, bool):
|
||||
return bad_request("alert_top1 doit être un booléen")
|
||||
if not isinstance(alert_quinte_only, bool):
|
||||
return bad_request("alert_quinte_only doit être un booléen")
|
||||
|
||||
conn = _get_db()
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET telegram_chat_id = ?,
|
||||
alert_value_bets = ?,
|
||||
alert_top1 = ?,
|
||||
alert_quinte_only = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
telegram_chat_id,
|
||||
int(alert_value_bets),
|
||||
int(alert_top1),
|
||||
int(alert_quinte_only),
|
||||
user_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"telegram_chat_id": telegram_chat_id,
|
||||
"alert_value_bets": alert_value_bets,
|
||||
"alert_top1": alert_top1,
|
||||
"alert_quinte_only": alert_quinte_only,
|
||||
}
|
||||
), 200
|
||||
|
||||
except sqlite3.OperationalError as exc:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Migration Telegram non appliquée — contacter le support",
|
||||
"detail": str(exc),
|
||||
}
|
||||
), 500
|
||||
except Exception as exc:
|
||||
return internal_error(str(exc))
|
||||
finally:
|
||||
conn.close()
|
||||
195
api_v1/routes/user_tokens.py
Normal file
195
api_v1/routes/user_tokens.py
Normal file
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
user_tokens.py — Personal API tokens + Webhook configuration (Pro plan)
|
||||
HRT-80
|
||||
|
||||
Endpoints:
|
||||
POST /api/v1/user/api-token
|
||||
DELETE /api/v1/user/api-token
|
||||
POST /api/v1/user/webhook
|
||||
DELETE /api/v1/user/webhook
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from flask import Blueprint, g, jsonify, request
|
||||
|
||||
from api_tokens_db import get_db, migrate_api_tokens_tables
|
||||
from auth import jwt_required_middleware, plan_required
|
||||
|
||||
logger = logging.getLogger("turf_saas.user_tokens")
|
||||
|
||||
user_tokens_bp = Blueprint("user_tokens", __name__, url_prefix="/api/v1/user")
|
||||
|
||||
try:
|
||||
migrate_api_tokens_tables()
|
||||
except Exception as _e:
|
||||
logger.warning("api_tokens_db migration skipped (test env?): %s", _e)
|
||||
|
||||
|
||||
def _hash_token(raw: str) -> str:
|
||||
return hashlib.sha256(raw.encode()).hexdigest()
|
||||
|
||||
|
||||
@user_tokens_bp.route("/api-token", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("pro")
|
||||
def create_api_token():
|
||||
user = g.current_user
|
||||
user_id = str(user["id"])
|
||||
conn = get_db()
|
||||
try:
|
||||
existing = conn.execute(
|
||||
"SELECT id, token_prefix, created_at FROM user_api_tokens "
|
||||
"WHERE user_id = ? AND revoked = 0",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if existing:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Un token actif existe déjà. Révoquez-le avant d'en créer un nouveau.",
|
||||
"existing_prefix": existing["token_prefix"],
|
||||
"created_at": existing["created_at"],
|
||||
}
|
||||
), 409
|
||||
|
||||
raw_token = "trf_" + secrets.token_urlsafe(40)
|
||||
token_hash = _hash_token(raw_token)
|
||||
token_prefix = raw_token[:12]
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO user_api_tokens (user_id, token_hash, token_prefix) VALUES (?, ?, ?)",
|
||||
(user_id, token_hash, token_prefix),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT created_at FROM user_api_tokens WHERE token_hash = ?",
|
||||
(token_hash,),
|
||||
).fetchone()
|
||||
created_at = row["created_at"] if row else None
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error("create_api_token error for user %s: %s", user_id, e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info("API token created for user %s (prefix=%s)", user_id, token_prefix)
|
||||
return jsonify(
|
||||
{
|
||||
"token": raw_token,
|
||||
"prefix": token_prefix,
|
||||
"created_at": created_at,
|
||||
"warning": "Conservez ce token en lieu sûr. Il ne sera plus affiché.",
|
||||
}
|
||||
), 201
|
||||
|
||||
|
||||
@user_tokens_bp.route("/api-token", methods=["DELETE"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("pro")
|
||||
def revoke_api_token():
|
||||
user = g.current_user
|
||||
user_id = str(user["id"])
|
||||
conn = get_db()
|
||||
try:
|
||||
result = conn.execute(
|
||||
"UPDATE user_api_tokens SET revoked = 1 WHERE user_id = ? AND revoked = 0",
|
||||
(user_id,),
|
||||
)
|
||||
conn.commit()
|
||||
revoked_count = result.rowcount
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error("revoke_api_token error for user %s: %s", user_id, e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if revoked_count == 0:
|
||||
return jsonify({"error": "Aucun token actif trouvé"}), 404
|
||||
|
||||
logger.info("API token(s) revoked for user %s (%d tokens)", user_id, revoked_count)
|
||||
return jsonify({"revoked": True, "count": revoked_count}), 200
|
||||
|
||||
|
||||
@user_tokens_bp.route("/webhook", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("pro")
|
||||
def create_webhook():
|
||||
user = g.current_user
|
||||
user_id = str(user["id"])
|
||||
data = request.get_json(silent=True) or {}
|
||||
url = (data.get("url") or "").strip()
|
||||
|
||||
if not url:
|
||||
return jsonify({"error": "URL du webhook manquante"}), 400
|
||||
if not url.startswith("https://"):
|
||||
return jsonify(
|
||||
{"error": "L'URL du webhook doit utiliser HTTPS (commencer par https://)"}
|
||||
), 400
|
||||
|
||||
secret = (data.get("secret") or "").strip() or secrets.token_hex(32)
|
||||
|
||||
conn = get_db()
|
||||
existing = None
|
||||
try:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM user_webhooks WHERE user_id = ?", (user_id,)
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute(
|
||||
"UPDATE user_webhooks SET url = ?, secret = ?, created_at = datetime('now') "
|
||||
"WHERE user_id = ?",
|
||||
(url, secret, user_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO user_webhooks (user_id, url, secret) VALUES (?, ?, ?)",
|
||||
(user_id, url, secret),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error("create_webhook error for user %s: %s", user_id, e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
action = "mis à jour" if existing else "configuré"
|
||||
logger.info("Webhook %s for user %s: %s", action, user_id, url)
|
||||
return jsonify(
|
||||
{
|
||||
"webhook_url": url,
|
||||
"secret": secret,
|
||||
"message": f"Webhook {action} avec succès",
|
||||
}
|
||||
), 201
|
||||
|
||||
|
||||
@user_tokens_bp.route("/webhook", methods=["DELETE"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("pro")
|
||||
def delete_webhook():
|
||||
user = g.current_user
|
||||
user_id = str(user["id"])
|
||||
conn = get_db()
|
||||
try:
|
||||
result = conn.execute("DELETE FROM user_webhooks WHERE user_id = ?", (user_id,))
|
||||
conn.commit()
|
||||
deleted_count = result.rowcount
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error("delete_webhook error for user %s: %s", user_id, e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if deleted_count == 0:
|
||||
return jsonify({"error": "Aucun webhook configuré"}), 404
|
||||
|
||||
logger.info("Webhook deleted for user %s", user_id)
|
||||
return jsonify({"deleted": True}), 200
|
||||
166
api_v1/routes/valuebets.py
Normal file
166
api_v1/routes/valuebets.py
Normal file
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Value bets route for API v1.
|
||||
|
||||
GET /api/v1/valuebets — Value bets du jour (premium+)
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from api_v1.utils import (
|
||||
get_db,
|
||||
table_exists,
|
||||
internal_error,
|
||||
get_pagination_params,
|
||||
paginate_query,
|
||||
)
|
||||
from auth import jwt_required_middleware, plan_required
|
||||
|
||||
valuebets_bp = Blueprint("v1_valuebets", __name__, url_prefix="/api/v1")
|
||||
|
||||
|
||||
@valuebets_bp.route("/valuebets", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("premium", "pro")
|
||||
def valuebets():
|
||||
"""
|
||||
Value bets du jour
|
||||
---
|
||||
tags:
|
||||
- Value Bets
|
||||
summary: Value bets du jour — chevaux à cote surévaluée par le marché (premium+)
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: date
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date YYYY-MM-DD (défaut aujourd'hui)
|
||||
- name: min_odds
|
||||
in: query
|
||||
type: number
|
||||
default: 2.0
|
||||
description: Cote minimale pour filtrer les value bets
|
||||
- name: limit
|
||||
in: query
|
||||
type: integer
|
||||
default: 20
|
||||
- name: offset
|
||||
in: query
|
||||
type: integer
|
||||
default: 0
|
||||
responses:
|
||||
200:
|
||||
description: Value bets du jour avec météo et terrain (HRT-83)
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
description: Plan insuffisant (premium ou pro requis)
|
||||
"""
|
||||
date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
|
||||
limit, offset = get_pagination_params(default_limit=20, max_limit=100)
|
||||
|
||||
try:
|
||||
min_odds = float(request.args.get("min_odds", 2.0))
|
||||
except (ValueError, TypeError):
|
||||
min_odds = 2.0
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
rows_raw = []
|
||||
total = 0
|
||||
|
||||
if table_exists(conn, "ml_predictions_cache"):
|
||||
count_row = conn.execute(
|
||||
"""SELECT COUNT(*) as cnt
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ? AND is_value_bet = 1 AND odds >= ?""",
|
||||
(date_param, min_odds),
|
||||
).fetchone()
|
||||
total = count_row["cnt"] if count_row else 0
|
||||
|
||||
# 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)
|
||||
|
||||
pagination = paginate_query(valuebets_list, total, limit, offset)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"date": date_param,
|
||||
"min_odds": min_odds,
|
||||
"valuebets": valuebets_list,
|
||||
**pagination,
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
99
api_v1/utils.py
Normal file
99
api_v1/utils.py
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared utilities for API v1 — error helpers, pagination, DB access.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
from flask import jsonify, request
|
||||
|
||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Database
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Return a SQLite connection with Row factory (reads TURF_SAAS_DB dynamically)."""
|
||||
db_path = os.environ.get("TURF_SAAS_DB", DB_PATH)
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def table_exists(conn, table_name: str) -> bool:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table_name,)
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Uniform error responses
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def error_response(message: str, code: int, status: str = "error"):
|
||||
"""Return a JSON error envelope consistent with the API contract.
|
||||
|
||||
Shape: {"status": "error", "message": "...", "code": 400}
|
||||
"""
|
||||
return jsonify({"status": status, "message": message, "code": code}), code
|
||||
|
||||
|
||||
def not_found(message: str = "Resource not found"):
|
||||
return error_response(message, 404)
|
||||
|
||||
|
||||
def bad_request(message: str = "Bad request"):
|
||||
return error_response(message, 400)
|
||||
|
||||
|
||||
def forbidden(message: str = "Forbidden", required_plans=None, current_plan=None):
|
||||
payload = {"status": "error", "message": message, "code": 403}
|
||||
if required_plans:
|
||||
payload["required_plans"] = required_plans
|
||||
if current_plan:
|
||||
payload["current_plan"] = current_plan
|
||||
payload["upgrade_url"] = "/api/v1/subscription/upgrade"
|
||||
return jsonify(payload), 403
|
||||
|
||||
|
||||
def internal_error(message: str = "Internal server error"):
|
||||
return error_response(message, 500)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Pagination helpers
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_pagination_params(default_limit: int = 20, max_limit: int = 100):
|
||||
"""Extract and validate limit/offset from query-string."""
|
||||
try:
|
||||
limit = int(request.args.get("limit", default_limit))
|
||||
except (ValueError, TypeError):
|
||||
limit = default_limit
|
||||
|
||||
try:
|
||||
offset = int(request.args.get("offset", 0))
|
||||
except (ValueError, TypeError):
|
||||
offset = 0
|
||||
|
||||
limit = max(1, min(limit, max_limit))
|
||||
offset = max(0, offset)
|
||||
return limit, offset
|
||||
|
||||
|
||||
def paginate_query(rows, total: int, limit: int, offset: int):
|
||||
"""Wrap a list of rows in a pagination envelope."""
|
||||
return {
|
||||
"pagination": {
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"has_more": (offset + limit) < total,
|
||||
}
|
||||
}
|
||||
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,
|
||||
)
|
||||
138
app_v1.py
Normal file
138
app_v1.py
Normal file
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
app_v1.py — Turf SaaS Flask application with versioned API /v1/
|
||||
|
||||
This module creates the Flask app, registers:
|
||||
- Auth JWT (from Sprint 2-3)
|
||||
- API v1 blueprints
|
||||
- Swagger/OpenAPI documentation at /api/v1/docs
|
||||
|
||||
Usage:
|
||||
python app_v1.py
|
||||
# or via gunicorn:
|
||||
gunicorn -w 2 -b 0.0.0.0:8792 app_v1:app
|
||||
|
||||
Sprint 3-4: HRT-29 — Refacto API /v1/
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from flask import Flask, jsonify
|
||||
from flask_cors import CORS
|
||||
from flask_jwt_extended import JWTManager
|
||||
from flasgger import Swagger
|
||||
|
||||
from auth_db import init_auth_tables
|
||||
from auth import auth_bp
|
||||
from api_v1 import register_api_v1
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("turf_saas.app_v1")
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
"""Application factory."""
|
||||
app = Flask(__name__)
|
||||
|
||||
# ── CORS ──
|
||||
CORS(app, origins=["*"], methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
||||
|
||||
# ── JWT config ──
|
||||
app.config["JWT_SECRET_KEY"] = os.environ.get(
|
||||
"JWT_SECRET_KEY", "change-me-in-production-use-strong-random-secret"
|
||||
)
|
||||
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(minutes=15)
|
||||
app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=30)
|
||||
JWTManager(app)
|
||||
|
||||
# ── Swagger / OpenAPI ──
|
||||
swagger_config = {
|
||||
"headers": [],
|
||||
"specs": [
|
||||
{
|
||||
"endpoint": "apispec_v1",
|
||||
"route": "/api/v1/apispec.json",
|
||||
"rule_filter": lambda rule: str(rule).startswith("/api/v1"),
|
||||
"model_filter": lambda tag: True,
|
||||
}
|
||||
],
|
||||
"static_url_path": "/flasgger_static",
|
||||
"swagger_ui": True,
|
||||
"specs_route": "/api/v1/docs",
|
||||
}
|
||||
|
||||
swagger_template = {
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"title": "Turf SaaS API",
|
||||
"description": (
|
||||
"API v1 — Prédictions turf IA, value bets, backtest & métriques.\n\n"
|
||||
"**Plans:** `free` | `premium` | `pro`\n\n"
|
||||
"**Auth:** Bearer JWT — obtenir un token via `POST /api/v1/auth/login`"
|
||||
),
|
||||
"version": "1.0.0",
|
||||
"contact": {"name": "H3R7 Tech"},
|
||||
},
|
||||
"basePath": "/",
|
||||
"schemes": ["http", "https"],
|
||||
"securityDefinitions": {
|
||||
"Bearer": {
|
||||
"type": "apiKey",
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"description": "Entrer: **Bearer <token>**",
|
||||
}
|
||||
},
|
||||
"consumes": ["application/json"],
|
||||
"produces": ["application/json"],
|
||||
}
|
||||
|
||||
Swagger(app, config=swagger_config, template=swagger_template)
|
||||
|
||||
# ── Auth DB init ──
|
||||
with app.app_context():
|
||||
try:
|
||||
init_auth_tables()
|
||||
except Exception as e:
|
||||
logger.warning("init_auth_tables warning: %s", e)
|
||||
|
||||
# ── Register auth blueprint ──
|
||||
app.register_blueprint(auth_bp)
|
||||
|
||||
# ── Register API v1 blueprints ──
|
||||
register_api_v1(app)
|
||||
|
||||
# ── Global error handlers ──
|
||||
@app.errorhandler(404)
|
||||
def not_found_handler(e):
|
||||
return jsonify(
|
||||
{"status": "error", "message": "Route introuvable", "code": 404}
|
||||
), 404
|
||||
|
||||
@app.errorhandler(405)
|
||||
def method_not_allowed_handler(e):
|
||||
return jsonify(
|
||||
{"status": "error", "message": "Méthode non autorisée", "code": 405}
|
||||
), 405
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error_handler(e):
|
||||
logger.exception("Unhandled 500 error")
|
||||
return jsonify(
|
||||
{"status": "error", "message": "Erreur serveur interne", "code": 500}
|
||||
), 500
|
||||
|
||||
logger.info("Turf SaaS API v1 ready — docs at /api/v1/docs")
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(os.environ.get("PORT", 8792))
|
||||
app.run(host="0.0.0.0", port=port, debug=False)
|
||||
408
auth.py
Normal file
408
auth.py
Normal file
@@ -0,0 +1,408 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Auth Blueprint — JWT authentication + multi-tenant plan enforcement
|
||||
Sprint 2-3: HRT-28
|
||||
|
||||
Endpoints:
|
||||
POST /api/v1/auth/register — email/password registration
|
||||
POST /api/v1/auth/login — returns access_token (15min) + refresh_token (30d)
|
||||
POST /api/v1/auth/refresh — rotate refresh token, issue new access_token
|
||||
POST /api/v1/auth/logout — revoke refresh token
|
||||
|
||||
Middleware exposed:
|
||||
jwt_required_middleware() — decorator: valid access JWT required
|
||||
plan_required(plans) — decorator: user plan must be in given list
|
||||
"""
|
||||
|
||||
import os
|
||||
import hashlib
|
||||
import secrets
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import wraps
|
||||
|
||||
import bcrypt
|
||||
from flask import Blueprint, request, jsonify, g, current_app
|
||||
from flask_jwt_extended import (
|
||||
JWTManager,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
get_jwt_identity,
|
||||
verify_jwt_in_request,
|
||||
)
|
||||
from flask_jwt_extended.exceptions import JWTExtendedException
|
||||
from jwt.exceptions import PyJWTError
|
||||
|
||||
from auth_db import get_db
|
||||
|
||||
logger = logging.getLogger("turf_saas.auth")
|
||||
|
||||
auth_bp = Blueprint("auth", __name__, url_prefix="/api/v1/auth")
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _hash_token(raw_token: str) -> str:
|
||||
"""SHA-256 hash of a token string for secure DB storage."""
|
||||
return hashlib.sha256(raw_token.encode()).hexdigest()
|
||||
|
||||
|
||||
def _get_user_by_email(email: str):
|
||||
db = get_db()
|
||||
user = db.execute(
|
||||
"SELECT * FROM users WHERE email = ? AND is_active = 1", (email.lower(),)
|
||||
).fetchone()
|
||||
db.close()
|
||||
return user
|
||||
|
||||
|
||||
def _get_user_by_id(user_id: int):
|
||||
db = get_db()
|
||||
user = db.execute(
|
||||
"SELECT * FROM users WHERE id = ? AND is_active = 1", (user_id,)
|
||||
).fetchone()
|
||||
db.close()
|
||||
return user
|
||||
|
||||
|
||||
def _store_refresh_token(user_id: int, raw_token: str, expires_at: datetime):
|
||||
token_hash = _hash_token(raw_token)
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?,?,?)",
|
||||
(user_id, token_hash, expires_at.isoformat()),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def _revoke_refresh_token(raw_token: str):
|
||||
token_hash = _hash_token(raw_token)
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"UPDATE refresh_tokens SET revoked = 1 WHERE token_hash = ?", (token_hash,)
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def _is_refresh_token_valid(raw_token: str, user_id: int) -> bool:
|
||||
token_hash = _hash_token(raw_token)
|
||||
db = get_db()
|
||||
row = db.execute(
|
||||
"""SELECT id FROM refresh_tokens
|
||||
WHERE token_hash = ? AND user_id = ? AND revoked = 0
|
||||
AND expires_at > datetime('now')""",
|
||||
(token_hash, user_id),
|
||||
).fetchone()
|
||||
db.close()
|
||||
return row is not None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Auth endpoints
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@auth_bp.route("/register", methods=["POST"])
|
||||
def register():
|
||||
"""POST /api/v1/auth/register — create a new user account (plan=free)."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
password = data.get("password") or ""
|
||||
|
||||
if not email or "@" not in email:
|
||||
return jsonify({"error": "Email invalide"}), 400
|
||||
if len(password) < 8:
|
||||
return jsonify({"error": "Mot de passe trop court (min 8 caractères)"}), 400
|
||||
|
||||
# Check uniqueness
|
||||
existing = _get_user_by_email(email)
|
||||
if existing:
|
||||
return jsonify({"error": "Email déjà enregistré"}), 409
|
||||
|
||||
password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
db = get_db()
|
||||
try:
|
||||
cursor = db.execute(
|
||||
"INSERT INTO users (email, password_hash, plan) VALUES (?,?,?)",
|
||||
(email, password_hash, "free"),
|
||||
)
|
||||
user_id = cursor.lastrowid
|
||||
# Create initial subscription record
|
||||
db.execute(
|
||||
"INSERT INTO subscriptions (user_id, plan) VALUES (?,?)",
|
||||
(user_id, "free"),
|
||||
)
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("register error: %s", e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
logger.info("New user registered: %s (id=%s)", email, user_id)
|
||||
return jsonify({"message": "Compte créé avec succès", "user_id": user_id}), 201
|
||||
|
||||
|
||||
@auth_bp.route("/login", methods=["POST"])
|
||||
def login():
|
||||
"""POST /api/v1/auth/login — returns JWT access_token + refresh_token."""
|
||||
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
|
||||
|
||||
user = _get_user_by_email(email)
|
||||
if not user:
|
||||
return jsonify({"error": "Identifiants invalides"}), 401
|
||||
|
||||
if not bcrypt.checkpw(password.encode(), user["password_hash"].encode()):
|
||||
logger.warning("Failed login attempt for %s", email)
|
||||
return jsonify({"error": "Identifiants invalides"}), 401
|
||||
|
||||
# Create tokens
|
||||
identity = str(user["id"])
|
||||
additional_claims = {"plan": user["plan"], "email": user["email"]}
|
||||
|
||||
access_token = create_access_token(
|
||||
identity=identity,
|
||||
additional_claims=additional_claims,
|
||||
)
|
||||
raw_refresh = create_refresh_token(identity=identity)
|
||||
|
||||
refresh_expires = datetime.now(timezone.utc) + timedelta(days=30)
|
||||
_store_refresh_token(user["id"], raw_refresh, refresh_expires)
|
||||
|
||||
logger.info("User %s logged in (plan=%s)", email, user["plan"])
|
||||
return jsonify(
|
||||
{
|
||||
"access_token": access_token,
|
||||
"refresh_token": raw_refresh,
|
||||
"token_type": "Bearer",
|
||||
"plan": user["plan"],
|
||||
}
|
||||
), 200
|
||||
|
||||
|
||||
@auth_bp.route("/refresh", methods=["POST"])
|
||||
def refresh():
|
||||
"""POST /api/v1/auth/refresh — rotate refresh token, issue new access_token."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
raw_refresh = (data.get("refresh_token") or "").strip()
|
||||
|
||||
if not raw_refresh:
|
||||
return jsonify({"error": "refresh_token manquant"}), 400
|
||||
|
||||
# Decode without verifying in DB first (to get user_id)
|
||||
try:
|
||||
decoded = decode_token(raw_refresh)
|
||||
except Exception:
|
||||
return jsonify({"error": "Refresh token invalide ou expiré"}), 401
|
||||
|
||||
user_id = int(decoded.get("sub", 0))
|
||||
|
||||
if not _is_refresh_token_valid(raw_refresh, user_id):
|
||||
return jsonify({"error": "Refresh token invalide, révoqué ou expiré"}), 401
|
||||
|
||||
user = _get_user_by_id(user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "Utilisateur introuvable"}), 401
|
||||
|
||||
# Revoke old refresh token (rotation)
|
||||
_revoke_refresh_token(raw_refresh)
|
||||
|
||||
# Issue new tokens
|
||||
identity = str(user["id"])
|
||||
additional_claims = {"plan": user["plan"], "email": user["email"]}
|
||||
new_access = create_access_token(
|
||||
identity=identity, additional_claims=additional_claims
|
||||
)
|
||||
new_refresh = create_refresh_token(identity=identity)
|
||||
|
||||
refresh_expires = datetime.now(timezone.utc) + timedelta(days=30)
|
||||
_store_refresh_token(user["id"], new_refresh, refresh_expires)
|
||||
|
||||
logger.info("Token refreshed for user_id=%s", user_id)
|
||||
return jsonify(
|
||||
{
|
||||
"access_token": new_access,
|
||||
"refresh_token": new_refresh,
|
||||
"token_type": "Bearer",
|
||||
"plan": user["plan"],
|
||||
}
|
||||
), 200
|
||||
|
||||
|
||||
@auth_bp.route("/logout", methods=["POST"])
|
||||
def logout():
|
||||
"""POST /api/v1/auth/logout — revoke refresh token."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
raw_refresh = (data.get("refresh_token") or "").strip()
|
||||
|
||||
if raw_refresh:
|
||||
_revoke_refresh_token(raw_refresh)
|
||||
|
||||
return jsonify({"message": "Déconnexion réussie"}), 200
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# JWT-protected middleware
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
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 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())
|
||||
user = _get_user_by_id(user_id)
|
||||
if not user:
|
||||
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)
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
def plan_required(*allowed_plans):
|
||||
"""
|
||||
Decorator factory: user's plan must be in allowed_plans.
|
||||
Must be applied AFTER @jwt_required_middleware.
|
||||
|
||||
Example:
|
||||
@app.route("/api/v1/predictions")
|
||||
@jwt_required_middleware
|
||||
@plan_required("premium", "pro")
|
||||
def premium_predictions():
|
||||
...
|
||||
"""
|
||||
|
||||
def decorator(fn):
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
user = getattr(g, "current_user", None)
|
||||
if not user:
|
||||
return jsonify({"error": "Non authentifié"}), 401
|
||||
if user["plan"] not in allowed_plans:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Plan insuffisant",
|
||||
"required": list(allowed_plans),
|
||||
"current_plan": user["plan"],
|
||||
"upgrade_url": "/api/v1/subscription/upgrade",
|
||||
}
|
||||
), 403
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def free_daily_limit_check(fn):
|
||||
"""
|
||||
Decorator: enforce free plan daily limit (1 course/jour).
|
||||
Must be applied AFTER @jwt_required_middleware.
|
||||
"""
|
||||
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
user = getattr(g, "current_user", None)
|
||||
if not user or user["plan"] != "free":
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
today = datetime.now(timezone.utc).date().isoformat()
|
||||
db = get_db()
|
||||
row = db.execute(
|
||||
"SELECT daily_usage, last_usage_date FROM users WHERE id = ?",
|
||||
(user["id"],),
|
||||
).fetchone()
|
||||
db.close()
|
||||
|
||||
if row and row["last_usage_date"] == today and row["daily_usage"] >= 1:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Limite quotidienne atteinte (plan free: 1 course/jour)",
|
||||
"upgrade_url": "/api/v1/subscription/upgrade",
|
||||
}
|
||||
), 429
|
||||
|
||||
# Increment usage
|
||||
db = get_db()
|
||||
if row and row["last_usage_date"] == today:
|
||||
db.execute(
|
||||
"UPDATE users SET daily_usage = daily_usage + 1 WHERE id = ?",
|
||||
(user["id"],),
|
||||
)
|
||||
else:
|
||||
db.execute(
|
||||
"UPDATE users SET daily_usage = 1, last_usage_date = ? WHERE id = ?",
|
||||
(today, user["id"]),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
103
auth_db.py
Normal file
103
auth_db.py
Normal file
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Auth DB — users and subscriptions schema for turf_saas.db
|
||||
Sprint 2-3: Auth JWT + Multi-tenant (HRT-28)
|
||||
HRT-79: migration Telegram columns
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
# NOTE: DB_PATH kept for backward compat, but get_db() reads env at call time
|
||||
# so test isolation works correctly when TURF_SAAS_DB is set per-module.
|
||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
|
||||
|
||||
def get_db():
|
||||
# Read env dynamically so test overrides of TURF_SAAS_DB are respected
|
||||
db_path = os.environ.get("TURF_SAAS_DB", DB_PATH)
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def init_auth_tables():
|
||||
"""Create users and subscriptions tables if they don't exist."""
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
c.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
plan TEXT NOT NULL DEFAULT 'free'
|
||||
CHECK(plan IN ('free','premium','pro')),
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
daily_usage INTEGER NOT NULL DEFAULT 0,
|
||||
last_usage_date TEXT DEFAULT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
plan TEXT NOT NULL CHECK(plan IN ('free','premium','pro')),
|
||||
start_date DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
end_date DATETIME,
|
||||
stripe_customer_id TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at DATETIME NOT NULL,
|
||||
revoked INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash);
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
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()
|
||||
238
billing_db.py
Normal file
238
billing_db.py
Normal file
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DB Migration — Billing Stripe
|
||||
Sprint 5-6: HRT-31
|
||||
|
||||
Adds stripe_subscription_id and status columns to subscriptions table,
|
||||
and an invoices / grace-period tracking table.
|
||||
|
||||
Run once:
|
||||
./venv/bin/python billing_db.py
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
logger = logging.getLogger("turf_saas.billing_db")
|
||||
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return conn
|
||||
|
||||
|
||||
def migrate_billing_tables():
|
||||
"""Idempotent migration: add billing columns and billing_events table.
|
||||
|
||||
Requires auth tables (users, subscriptions) to exist first.
|
||||
Calls init_auth_tables() automatically if subscriptions is absent.
|
||||
"""
|
||||
from auth_db import init_auth_tables as _init_auth
|
||||
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
# Ensure base auth tables exist
|
||||
tables = {
|
||||
row[0] for row in c.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
}
|
||||
conn.close()
|
||||
|
||||
if "subscriptions" not in tables:
|
||||
_init_auth()
|
||||
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
# Add stripe_subscription_id if missing
|
||||
columns = {row[1] for row in c.execute("PRAGMA table_info(subscriptions)")}
|
||||
|
||||
if "stripe_subscription_id" not in columns:
|
||||
c.execute("ALTER TABLE subscriptions ADD COLUMN stripe_subscription_id TEXT")
|
||||
logger.info("[billing_db] Added stripe_subscription_id column to subscriptions")
|
||||
|
||||
if "status" not in columns:
|
||||
c.execute(
|
||||
"ALTER TABLE subscriptions ADD COLUMN "
|
||||
"status TEXT NOT NULL DEFAULT 'active' "
|
||||
"CHECK(status IN ('active','past_due','canceled','trialing','incomplete'))"
|
||||
)
|
||||
logger.info("[billing_db] Added status column to subscriptions")
|
||||
|
||||
if "grace_period_end" not in columns:
|
||||
c.execute("ALTER TABLE subscriptions ADD COLUMN grace_period_end DATETIME")
|
||||
logger.info("[billing_db] Added grace_period_end column to subscriptions")
|
||||
|
||||
if "current_period_end" not in columns:
|
||||
c.execute("ALTER TABLE subscriptions ADD COLUMN current_period_end DATETIME")
|
||||
logger.info("[billing_db] Added current_period_end column to subscriptions")
|
||||
|
||||
# billing_events table — audit trail for all webhook events
|
||||
c.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS billing_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
stripe_event_id TEXT NOT NULL UNIQUE,
|
||||
event_type TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
payload TEXT,
|
||||
processed_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
-- HRT-202: Billing tables (invoices, transactions, consumption_log)
|
||||
CREATE TABLE IF NOT EXISTS invoices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
invoice_number TEXT NOT NULL UNIQUE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
period_start TEXT NOT NULL,
|
||||
period_end TEXT NOT NULL,
|
||||
plan TEXT NOT NULL,
|
||||
amount_cents INTEGER NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'EUR',
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK(status IN ('pending','paid','overdue','cancelled','refunded')),
|
||||
pdf_path TEXT,
|
||||
stripe_invoice_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
paid_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
invoice_id INTEGER REFERENCES invoices(id),
|
||||
type TEXT NOT NULL
|
||||
CHECK(type IN ('subscription','overage','credit','refund')),
|
||||
amount_cents INTEGER NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'EUR',
|
||||
stripe_payment_intent_id TEXT,
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS consumption_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
date TEXT NOT NULL,
|
||||
api_calls INTEGER NOT NULL DEFAULT 0,
|
||||
endpoint TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, date, endpoint)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_user ON invoices(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_user ON transactions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_consumption_user ON consumption_log(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_consumption_date ON consumption_log(date);
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(
|
||||
"[billing_db] Migration complete: subscriptions + billing_events + invoices + transactions + consumption_log ready."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
migrate_billing_tables()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Model classes (documentation / type hints)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class Invoice:
|
||||
id: Optional[int] = None
|
||||
invoice_number: str = ""
|
||||
user_id: int = 0
|
||||
period_start: str = ""
|
||||
period_end: str = ""
|
||||
plan: str = ""
|
||||
amount_cents: int = 0
|
||||
currency: str = "EUR"
|
||||
status: str = "pending"
|
||||
pdf_path: Optional[str] = None
|
||||
stripe_invoice_id: Optional[str] = None
|
||||
created_at: str = ""
|
||||
paid_at: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Transaction:
|
||||
id: Optional[int] = None
|
||||
user_id: int = 0
|
||||
invoice_id: Optional[int] = None
|
||||
type: str = "subscription"
|
||||
amount_cents: int = 0
|
||||
currency: str = "EUR"
|
||||
stripe_payment_intent_id: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
created_at: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConsumptionLog:
|
||||
id: Optional[int] = None
|
||||
user_id: int = 0
|
||||
date: str = ""
|
||||
api_calls: int = 0
|
||||
endpoint: Optional[str] = None
|
||||
created_at: str = ""
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Re-exported helpers for test usage
|
||||
# (primary implementations live in api_v1/routes/billing.py)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _upsert_subscription(db, user_id: int, **fields):
|
||||
"""
|
||||
Update existing subscription row or insert a new one.
|
||||
Convenience re-export for test helpers.
|
||||
"""
|
||||
existing = db.execute(
|
||||
"SELECT id FROM subscriptions WHERE user_id = ? ORDER BY start_date DESC LIMIT 1",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if existing:
|
||||
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)
|
||||
else:
|
||||
cols = ", ".join(["user_id"] + list(fields.keys()))
|
||||
placeholders = ", ".join(["?"] * (1 + len(fields)))
|
||||
values = [user_id] + list(fields.values())
|
||||
db.execute(
|
||||
f"INSERT INTO subscriptions ({cols}) VALUES ({placeholders})", values
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
335
consumption_alerts.html
Normal file
335
consumption_alerts.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>Consommation IA — Alertes</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--green: #00c853; --green-d: #009624; --blue: #1e88e5;
|
||||
--gold: #ffd600; --orange: #ff6d00;
|
||||
--dark: #0d1117; --dark2: #161b22; --dark3: #21262d;
|
||||
--text: #e6edf3; --muted: #8b949e; --border: #30363d;
|
||||
--radius: 10px; --error: #f85149; --purple: #7c3aed;
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); min-height: 100vh; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
.topbar { display: flex; align-items: center; justify-content: space-between; padding: 16px 28px; border-bottom: 1px solid var(--border); background: var(--dark); position: sticky; top: 0; z-index: 10; flex-wrap: wrap; gap: 10px; }
|
||||
.topbar-title { font-size: 1.1rem; font-weight: 700; display: flex; align-items: center; gap: 10px; }
|
||||
.topbar-title a { color: var(--muted); font-weight: 400; font-size: .9rem; }
|
||||
.topbar-title a:hover { color: var(--text); }
|
||||
.topbar-right { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 16px; border-radius: 8px; font-size: .85rem; 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-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||
.btn-ghost:hover { border-color: var(--muted); }
|
||||
.btn-danger { background: rgba(248,81,73,.15); color: var(--error); border: 1px solid rgba(248,81,73,.3); }
|
||||
.btn-danger:hover { background: rgba(248,81,73,.25); }
|
||||
.btn-sm { padding: 5px 12px; font-size: .8rem; }
|
||||
|
||||
.content { padding: 28px; max-width: 1000px; margin: 0 auto; }
|
||||
|
||||
.section-title { font-size: 1rem; font-weight: 700; margin-bottom: 16px; display: flex; align-items: center; justify-content: space-between; }
|
||||
|
||||
.alert-card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 20px; margin-bottom: 12px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
.alert-card.inactive { opacity: .6; }
|
||||
.alert-icon { font-size: 1.3rem; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; background: var(--dark3); border-radius: 8px; flex-shrink: 0; }
|
||||
.alert-info { flex: 1; min-width: 200px; }
|
||||
.alert-info .alert-name { font-weight: 700; font-size: .93rem; }
|
||||
.alert-info .alert-desc { font-size: .82rem; color: var(--muted); margin-top: 2px; }
|
||||
.alert-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.toggle-switch { width: 40px; height: 22px; border-radius: 11px; background: var(--border); cursor: pointer; position: relative; transition: background .2s; flex-shrink: 0; }
|
||||
.toggle-switch.active { background: var(--green); }
|
||||
.toggle-switch::after { content: ''; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: #fff; transition: transform .2s; }
|
||||
.toggle-switch.active::after { transform: translateX(18px); }
|
||||
|
||||
.empty-state { text-align: center; padding: 60px 20px; color: var(--muted); }
|
||||
.empty-state .icon { font-size: 2.5rem; margin-bottom: 12px; }
|
||||
.empty-state p { font-size: .9rem; margin-bottom: 16px; }
|
||||
|
||||
.loading { text-align: center; padding: 60px 20px; color: var(--muted); }
|
||||
.loading .spinner { display: inline-block; width: 32px; height: 32px; border: 3px solid var(--border); border-top-color: var(--green); border-radius: 50%; animation: spin .8s linear infinite; margin-bottom: 12px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
|
||||
|
||||
.modal-overlay {
|
||||
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,.6); z-index: 1000;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.modal-overlay.show { display: flex; }
|
||||
.modal {
|
||||
background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 24px; width: 90%; max-width: 480px; max-height: 90vh; overflow-y: auto;
|
||||
}
|
||||
.modal h3 { font-size: 1.05rem; margin-bottom: 20px; }
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label { display: block; font-size: .8rem; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .4px; margin-bottom: 6px; }
|
||||
.form-group input, .form-group select {
|
||||
width: 100%; background: var(--dark3); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 9px 12px; color: var(--text); font-size: .88rem;
|
||||
outline: none; transition: border-color .2s; font-family: inherit;
|
||||
}
|
||||
.form-group input:focus, .form-group select:focus { border-color: var(--green); }
|
||||
.form-actions { display: flex; gap: 10px; margin-top: 20px; justify-content: flex-end; }
|
||||
|
||||
.toast {
|
||||
position: fixed; bottom: 20px; right: 20px; z-index: 9999;
|
||||
background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 14px 20px; font-size: .88rem; box-shadow: 0 8px 24px rgba(0,0,0,.4);
|
||||
display: none; max-width: 400px;
|
||||
}
|
||||
.toast.show { display: block; animation: slideIn .3s ease; }
|
||||
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
.toast-success { border-color: var(--green); }
|
||||
.toast-error { border-color: var(--error); }
|
||||
|
||||
.threshold-badge { padding: 3px 10px; border-radius: 12px; font-size: .72rem; font-weight: 700; }
|
||||
.threshold-info { background: rgba(30,136,229,.15); color: var(--blue); }
|
||||
.threshold-warn { background: rgba(255,214,0,.15); color: var(--gold); }
|
||||
.threshold-danger { background: rgba(248,81,73,.15); color: var(--error); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a href="/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
|
||||
|
||||
<div class="topbar">
|
||||
<div class="topbar-title">
|
||||
<span>🔔 Gestion des Alertes</span>
|
||||
<a href="/dashboard/consumption">← Dashboard</a>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<button class="btn btn-primary btn-sm" onclick="openCreateModal()">+ Nouvelle alerte</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="section-title">
|
||||
<span>Règles d'alerte consommation</span>
|
||||
</div>
|
||||
|
||||
<div class="loading" id="loading">
|
||||
<div class="spinner"></div>
|
||||
<div>Chargement des alertes...</div>
|
||||
</div>
|
||||
|
||||
<div id="alerts-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="modal">
|
||||
<div class="modal">
|
||||
<h3 id="modal-title">Nouvelle alerte</h3>
|
||||
<input type="hidden" id="edit-id">
|
||||
<div class="form-group">
|
||||
<label>Type de seuil</label>
|
||||
<select id="form-type">
|
||||
<option value="tokens">Tokens</option>
|
||||
<option value="cost">Coût (cents)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Valeur du seuil</label>
|
||||
<input type="number" id="form-value" min="1" step="0.01" placeholder="1000">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Période</label>
|
||||
<select id="form-period">
|
||||
<option value="daily">Journalier</option>
|
||||
<option value="monthly">Mensuel</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Email notification (optionnel)</label>
|
||||
<input type="email" id="form-email" placeholder="admin@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Webhook notification (optionnel)</label>
|
||||
<input type="url" id="form-webhook" placeholder="https://hooks.example.com/alert">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-ghost" onclick="closeModal()">Annuler</button>
|
||||
<button class="btn btn-primary" onclick="saveAlert()">Enregistrer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
function showToast(msg, type) {
|
||||
var t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.className = 'toast show toast-' + type;
|
||||
setTimeout(function() { t.classList.remove('show'); }, 4000);
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
document.getElementById('modal').classList.add('show');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('modal').classList.remove('show');
|
||||
document.getElementById('edit-id').value = '';
|
||||
document.getElementById('modal-title').textContent = 'Nouvelle alerte';
|
||||
document.getElementById('form-type').value = 'tokens';
|
||||
document.getElementById('form-value').value = '';
|
||||
document.getElementById('form-period').value = 'daily';
|
||||
document.getElementById('form-email').value = '';
|
||||
document.getElementById('form-webhook').value = '';
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
closeModal();
|
||||
openModal();
|
||||
}
|
||||
|
||||
function getThresholdClass(value, type) {
|
||||
if (type === 'tokens') {
|
||||
if (value >= 100000) return 'threshold-danger';
|
||||
if (value >= 50000) return 'threshold-warn';
|
||||
return 'threshold-info';
|
||||
}
|
||||
if (type === 'cost') {
|
||||
if (value >= 10000) return 'threshold-danger';
|
||||
if (value >= 5000) return 'threshold-warn';
|
||||
return 'threshold-info';
|
||||
}
|
||||
return 'threshold-info';
|
||||
}
|
||||
|
||||
function formatThreshold(value, type) {
|
||||
if (type === 'tokens') {
|
||||
if (value >= 1000000) return (value/1000000).toFixed(1) + 'M tokens';
|
||||
if (value >= 1000) return (value/1000).toFixed(1) + 'k tokens';
|
||||
return value + ' tokens';
|
||||
}
|
||||
if (type === 'cost') {
|
||||
return (value / 100).toFixed(2) + '€';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function renderAlerts(alerts) {
|
||||
var container = document.getElementById('alerts-container');
|
||||
if (!alerts || alerts.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state"><div class="icon">🔔</div><p>Aucune alerte configurée</p><button class="btn btn-primary" onclick="openCreateModal()">+ Créer une alerte</button></div>';
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
alerts.forEach(function(a) {
|
||||
var activeClass = a.is_active ? '' : 'inactive';
|
||||
var toggleClass = a.is_active ? 'active' : '';
|
||||
var thresholdClass = getThresholdClass(a.threshold_value, a.threshold_type);
|
||||
html += '<div class="alert-card ' + activeClass + '" data-id="' + a.id + '">' +
|
||||
'<div class="alert-icon">' + (a.threshold_type === 'tokens' ? '🔷' : '💰') + '</div>' +
|
||||
'<div class="alert-info">' +
|
||||
'<div class="alert-name">' + (a.threshold_type === 'tokens' ? 'Seuil tokens' : 'Seuil coût') + ' <span class="threshold-badge ' + thresholdClass + '">' + formatThreshold(a.threshold_value, a.threshold_type) + '</span></div>' +
|
||||
'<div class="alert-desc">Période: ' + (a.period === 'daily' ? 'Journalier' : 'Mensuel') + (a.notify_email ? ' · 📧 ' + a.notify_email : '') + (a.notify_webhook ? ' · 🔗 webhook' : '') + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="alert-actions">' +
|
||||
'<div class="toggle-switch ' + toggleClass + '" onclick="toggleAlert(' + a.id + ', ' + !a.is_active + ')"></div>' +
|
||||
'<button class="btn btn-ghost btn-sm" onclick="editAlert(' + a.id + ')">✏️</button>' +
|
||||
'<button class="btn btn-danger btn-sm" onclick="deleteAlert(' + a.id + ')">🗑️</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
});
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
async function loadAlerts() {
|
||||
document.getElementById('loading').style.display = '';
|
||||
try {
|
||||
var r = await fetch('/api/v1/consumption/alerts');
|
||||
var data = await r.json();
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
renderAlerts(data.alerts || []);
|
||||
} catch (e) {
|
||||
document.getElementById('loading').innerHTML = '<div style="color:var(--error);padding:40px">❌ Erreur: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAlert(id, newState) {
|
||||
try {
|
||||
var r = await fetch('/api/v1/consumption/alerts/' + id, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ is_active: newState })
|
||||
});
|
||||
if (!r.ok) { showToast('Erreur lors de la modification', 'error'); return; }
|
||||
showToast('Alerte ' + (newState ? 'activée' : 'désactivée'), 'success');
|
||||
loadAlerts();
|
||||
} catch (e) {
|
||||
showToast('Erreur: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function editAlert(id) {
|
||||
var card = document.querySelector('.alert-card[data-id="' + id + '"]');
|
||||
if (!card) return;
|
||||
// Re-fetch from API to get full data
|
||||
fetch('/api/v1/consumption/alerts/' + id)
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(a) {
|
||||
document.getElementById('edit-id').value = a.id;
|
||||
document.getElementById('modal-title').textContent = 'Modifier l\'alerte #' + a.id;
|
||||
document.getElementById('form-type').value = a.threshold_type;
|
||||
document.getElementById('form-value').value = a.threshold_value;
|
||||
document.getElementById('form-period').value = a.period;
|
||||
document.getElementById('form-email').value = a.notify_email || '';
|
||||
document.getElementById('form-webhook').value = a.notify_webhook || '';
|
||||
openModal();
|
||||
})
|
||||
.catch(function(e) { showToast('Erreur: ' + e.message, 'error'); });
|
||||
}
|
||||
|
||||
async function saveAlert() {
|
||||
var id = document.getElementById('edit-id').value;
|
||||
var data = {
|
||||
client_id: 'internal',
|
||||
threshold_type: document.getElementById('form-type').value,
|
||||
threshold_value: parseFloat(document.getElementById('form-value').value),
|
||||
period: document.getElementById('form-period').value,
|
||||
notify_email: document.getElementById('form-email').value || null,
|
||||
notify_webhook: document.getElementById('form-webhook').value || null,
|
||||
};
|
||||
if (!data.threshold_value || data.threshold_value <= 0) {
|
||||
showToast('Veuillez entrer une valeur de seuil valide', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var url = id ? '/api/v1/consumption/alerts/' + id : '/api/v1/consumption/alerts';
|
||||
var method = id ? 'PUT' : 'POST';
|
||||
var r = await fetch(url, {
|
||||
method: method,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!r.ok) { showToast('Erreur lors de l\'enregistrement', 'error'); return; }
|
||||
showToast(id ? 'Alerte modifiée' : 'Alerte créée', 'success');
|
||||
closeModal();
|
||||
loadAlerts();
|
||||
} catch (e) {
|
||||
showToast('Erreur: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAlert(id) {
|
||||
if (!confirm('Supprimer cette alerte ?')) return;
|
||||
try {
|
||||
var r = await fetch('/api/v1/consumption/alerts/' + id, { method: 'DELETE' });
|
||||
if (!r.ok) { showToast('Erreur lors de la suppression', 'error'); return; }
|
||||
showToast('Alerte supprimée', 'success');
|
||||
loadAlerts();
|
||||
} catch (e) {
|
||||
showToast('Erreur: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
loadAlerts();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
415
consumption_dashboard.html
Normal file
415
consumption_dashboard.html
Normal file
@@ -0,0 +1,415 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Consommation IA — Dashboard</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--green: #00c853; --green-d: #009624; --blue: #1e88e5;
|
||||
--gold: #ffd600; --orange: #ff6d00;
|
||||
--dark: #0d1117; --dark2: #161b22; --dark3: #21262d;
|
||||
--text: #e6edf3; --muted: #8b949e; --border: #30363d;
|
||||
--radius: 10px; --error: #f85149; --purple: #7c3aed; --cyan: #00d9ff;
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); min-height: 100vh; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
.topbar { display: flex; align-items: center; justify-content: space-between; padding: 16px 28px; border-bottom: 1px solid var(--border); background: var(--dark); position: sticky; top: 0; z-index: 10; flex-wrap: wrap; gap: 10px; }
|
||||
.topbar-title { font-size: 1.1rem; font-weight: 700; display: flex; align-items: center; gap: 10px; }
|
||||
.topbar-title a { color: var(--muted); font-weight: 400; font-size: .9rem; }
|
||||
.topbar-title a:hover { color: var(--text); }
|
||||
.topbar-right { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 16px; border-radius: 8px; font-size: .85rem; 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-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||
.btn-ghost:hover { border-color: var(--muted); }
|
||||
.btn-sm { padding: 5px 12px; font-size: .8rem; }
|
||||
.btn-active { background: var(--green); color: #000; border-color: var(--green); }
|
||||
|
||||
.content { padding: 28px; max-width: 1400px; margin: 0 auto; }
|
||||
|
||||
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px; margin-bottom: 24px; }
|
||||
.stat-card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 20px; }
|
||||
.stat-label { font-size: .78rem; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
|
||||
.stat-value { font-size: 1.8rem; font-weight: 800; }
|
||||
.stat-sub { font-size: .78rem; color: var(--muted); margin-top: 4px; }
|
||||
.stat-warn { color: var(--gold); }
|
||||
.stat-err { color: var(--error); }
|
||||
|
||||
.period-bar { display: flex; gap: 6px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.period-btn { padding: 6px 16px; border-radius: 20px; font-size: .82rem; font-weight: 600; cursor: pointer; border: 1px solid var(--border); background: transparent; color: var(--muted); transition: all .15s; }
|
||||
.period-btn:hover { border-color: var(--muted); color: var(--text); }
|
||||
.period-btn.active { background: var(--green); color: #000; border-color: var(--green); }
|
||||
|
||||
.charts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 24px; }
|
||||
@media (max-width: 900px) { .charts-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.chart-card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; }
|
||||
.chart-title { font-size: .9rem; font-weight: 700; margin-bottom: 14px; color: var(--muted); }
|
||||
.chart-container { height: 280px; position: relative; }
|
||||
|
||||
.alert-banner {
|
||||
background: linear-gradient(135deg, rgba(248,81,73,.1), rgba(255,109,0,.08));
|
||||
border: 1px solid rgba(248,81,73,.25); border-radius: var(--radius);
|
||||
padding: 12px 18px; margin-bottom: 20px;
|
||||
display: none; align-items: center; gap: 12px;
|
||||
}
|
||||
.alert-banner.visible { display: flex; }
|
||||
.alert-banner .alert-icon { font-size: 1.3rem; }
|
||||
.alert-banner .alert-text { flex: 1; font-size: .9rem; }
|
||||
.alert-banner .alert-text strong { color: var(--error); }
|
||||
.alert-banner .alert-close { cursor: pointer; font-size: 1.2rem; color: var(--muted); }
|
||||
|
||||
.loading { text-align: center; padding: 60px 20px; color: var(--muted); }
|
||||
.loading .spinner { display: inline-block; width: 32px; height: 32px; border: 3px solid var(--border); border-top-color: var(--green); border-radius: 50%; animation: spin .8s linear infinite; margin-bottom: 12px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.provider-breakdown { margin-top: 12px; }
|
||||
.provider-row { display: flex; align-items: center; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border); }
|
||||
.provider-row:last-child { border-bottom: none; }
|
||||
.provider-name { flex: 1; font-weight: 600; font-size: .88rem; }
|
||||
.provider-bar-wrap { flex: 2; height: 8px; border-radius: 4px; background: var(--border); overflow: hidden; }
|
||||
.provider-bar-fill { height: 100%; border-radius: 4px; transition: width .5s; }
|
||||
.provider-stats { flex: 1; text-align: right; font-size: .82rem; color: var(--muted); }
|
||||
|
||||
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a href="/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
|
||||
|
||||
<div class="topbar">
|
||||
<div class="topbar-title">
|
||||
<span>📊 Consommation IA</span>
|
||||
<a href="/dashboard/consumption/history">Historique →</a>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<a href="/dashboard/consumption/alerts" class="btn btn-ghost btn-sm">⚙️ Alertes</a>
|
||||
<span id="last-refresh" style="font-size:.78rem;color:var(--muted)">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="alert-banner" id="alert-banner">
|
||||
<span class="alert-icon">⚠️</span>
|
||||
<div class="alert-text" id="alert-text"></div>
|
||||
<span class="alert-close" onclick="this.parentElement.classList.remove('visible')">✕</span>
|
||||
</div>
|
||||
|
||||
<div class="period-bar" id="period-bar">
|
||||
<button class="period-btn" data-period="24h" onclick="setPeriod('24h')">24h</button>
|
||||
<button class="period-btn active" data-period="7d" onclick="setPeriod('7d')">7 jours</button>
|
||||
<button class="period-btn" data-period="30d" onclick="setPeriod('30d')">30 jours</button>
|
||||
</div>
|
||||
|
||||
<div class="loading" id="loading">
|
||||
<div class="spinner"></div>
|
||||
<div>Chargement des données...</div>
|
||||
</div>
|
||||
|
||||
<div id="dashboard-content" style="display:none">
|
||||
<div class="stats-row" id="stats-row"></div>
|
||||
|
||||
<div class="charts-grid">
|
||||
<div class="chart-card">
|
||||
<div class="chart-title">🔷 Tokens par jour</div>
|
||||
<div class="chart-container"><canvas id="tokensChart"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-title">💰 Coût par jour (€)</div>
|
||||
<div class="chart-container"><canvas id="costChart"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-title">🏢 Répartition par provider</div>
|
||||
<div class="chart-container"><canvas id="providerChart"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-title">📞 Appels par jour</div>
|
||||
<div class="chart-container"><canvas id="callsChart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var currentPeriod = '7d';
|
||||
var statsData = null;
|
||||
|
||||
function formatDate(isoStr) {
|
||||
var d = new Date(isoStr);
|
||||
return d.toLocaleDateString('fr-FR', {day:'2-digit', month:'short'});
|
||||
}
|
||||
|
||||
function formatNumber(n) {
|
||||
if (n >= 1000000) return (n/1000000).toFixed(1) + 'M';
|
||||
if (n >= 1000) return (n/1000).toFixed(1) + 'k';
|
||||
return n.toLocaleString('fr-FR');
|
||||
}
|
||||
|
||||
function getDateRange(period) {
|
||||
var now = new Date();
|
||||
var start = new Date(now);
|
||||
if (period === '24h') start.setDate(start.getDate() - 1);
|
||||
else if (period === '7d') start.setDate(start.getDate() - 7);
|
||||
else if (period === '30d') start.setDate(start.getDate() - 30);
|
||||
return {
|
||||
start: start.toISOString().split('T')[0],
|
||||
end: now.toISOString().split('T')[0]
|
||||
};
|
||||
}
|
||||
|
||||
function setPeriod(period) {
|
||||
currentPeriod = period;
|
||||
document.querySelectorAll('.period-btn').forEach(function(b) {
|
||||
b.classList.toggle('active', b.dataset.period === period);
|
||||
});
|
||||
loadData();
|
||||
}
|
||||
|
||||
function checkAlerts(totals) {
|
||||
var banner = document.getElementById('alert-banner');
|
||||
var text = document.getElementById('alert-text');
|
||||
|
||||
var dailyTokens = 0;
|
||||
var dailyCost = 0;
|
||||
if (statsData && statsData.by_day && statsData.by_day.length > 0) {
|
||||
var today = statsData.by_day[0];
|
||||
dailyTokens = today.tokens || 0;
|
||||
dailyCost = (today.cost_cents || 0) / 100;
|
||||
}
|
||||
|
||||
if (dailyTokens > 0) {
|
||||
var tokensPct = 100;
|
||||
var costPct = 100;
|
||||
banner.classList.add('visible');
|
||||
if (dailyTokens > 100000) {
|
||||
text.innerHTML = '<strong>⚠️ Seuil critique</strong> — ' + formatNumber(dailyTokens) + ' tokens aujourd\'hui (dépassement >100k)';
|
||||
} else if (dailyCost > 5) {
|
||||
text.innerHTML = '<strong>⚠️ Alerte coût</strong> — ' + dailyCost.toFixed(2) + '€ aujourd\'hui (seuil >5€)';
|
||||
} else {
|
||||
banner.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderStats(totals) {
|
||||
var html = '';
|
||||
var cards = [
|
||||
{ label: 'Requêtes totales', value: formatNumber(totals.calls_count || 0), sub: 'période sélectionnée' },
|
||||
{ label: 'Tokens totaux', value: formatNumber(totals.total_tokens || 0), sub: 'entrée + sortie' },
|
||||
{ label: 'Coût estimé', value: (totals.total_cost_cents / 100).toFixed(2) + '€', sub: 'coût total période', cls: totals.total_cost_cents > 500 ? 'stat-warn' : '' },
|
||||
{ label: 'Latence moyenne', value: (totals.avg_latency_ms || 0).toFixed(0) + 'ms', sub: 'temps de réponse' },
|
||||
{ label: 'Erreurs', value: totals.error_count || 0, sub: 'requêtes échouées', cls: totals.error_count > 0 ? 'stat-err' : '' },
|
||||
];
|
||||
cards.forEach(function(c) {
|
||||
html += '<div class="stat-card"><div class="stat-label">' + c.label + '</div><div class="stat-value ' + (c.cls || '') + '">' + c.value + '</div><div class="stat-sub">' + c.sub + '</div></div>';
|
||||
});
|
||||
document.getElementById('stats-row').innerHTML = html;
|
||||
}
|
||||
|
||||
function renderTokensChart(byDay) {
|
||||
var ctx = document.getElementById('tokensChart').getContext('2d');
|
||||
var labels = byDay.map(function(d) { return formatDate(d.date); }).reverse();
|
||||
var data = byDay.map(function(d) { return d.tokens; }).reverse();
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Tokens',
|
||||
data: data,
|
||||
borderColor: '#00c853',
|
||||
backgroundColor: 'rgba(0,200,83,0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false }
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: 'rgba(255,255,255,0.05)' },
|
||||
ticks: { color: '#8b949e', callback: function(v) { return formatNumber(v); } }
|
||||
},
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: '#8b949e', maxTicksLimit: 10 }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderCostChart(byDay) {
|
||||
var ctx = document.getElementById('costChart').getContext('2d');
|
||||
var labels = byDay.map(function(d) { return formatDate(d.date); }).reverse();
|
||||
var data = byDay.map(function(d) { return (d.cost_cents / 100).toFixed(2); }).reverse();
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Coût (€)',
|
||||
data: data,
|
||||
backgroundColor: 'rgba(30,136,229,0.6)',
|
||||
borderColor: '#1e88e5',
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false }
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: 'rgba(255,255,255,0.05)' },
|
||||
ticks: { color: '#8b949e', callback: function(v) { return v + '€'; } }
|
||||
},
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: '#8b949e', maxTicksLimit: 10 }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderProviderChart(byProvider) {
|
||||
var ctx = document.getElementById('providerChart').getContext('2d');
|
||||
var labels = byProvider.map(function(p) { return p.provider; });
|
||||
var data = byProvider.map(function(p) { return p.cost_cents / 100; });
|
||||
var colors = ['#00c853', '#1e88e5', '#ffd600', '#7c3aed', '#ff6d00', '#f85149'];
|
||||
new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: data,
|
||||
backgroundColor: colors.slice(0, labels.length),
|
||||
borderWidth: 0
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
labels: { color: '#8b949e', padding: 12, font: { size: 11 } }
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(ctx) {
|
||||
return ctx.label + ': ' + ctx.parsed.toFixed(2) + '€';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderCallsChart(byDay) {
|
||||
var ctx = document.getElementById('callsChart').getContext('2d');
|
||||
var labels = byDay.map(function(d) { return formatDate(d.date); }).reverse();
|
||||
var data = byDay.map(function(d) { return d.calls; }).reverse();
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Appels',
|
||||
data: data,
|
||||
backgroundColor: 'rgba(124,58,237,0.5)',
|
||||
borderColor: '#7c3aed',
|
||||
borderWidth: 1,
|
||||
borderRadius: 3
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false }
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: 'rgba(255,255,255,0.05)' },
|
||||
ticks: { color: '#8b949e' }
|
||||
},
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: '#8b949e', maxTicksLimit: 10 }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
var range = getDateRange(currentPeriod);
|
||||
document.getElementById('loading').style.display = '';
|
||||
document.getElementById('dashboard-content').style.display = 'none';
|
||||
|
||||
try {
|
||||
var url = '/api/v1/consumption/stats?client_id=internal&start_date=' + range.start + '&end_date=' + range.end;
|
||||
var r = await fetch(url);
|
||||
statsData = await r.json();
|
||||
|
||||
document.getElementById('last-refresh').textContent = '🔄 ' + new Date().toLocaleTimeString('fr-FR');
|
||||
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
document.getElementById('dashboard-content').style.display = '';
|
||||
|
||||
var totals = statsData.totals || {};
|
||||
var byDay = statsData.by_day || [];
|
||||
var byProvider = statsData.by_provider || [];
|
||||
|
||||
renderStats(totals);
|
||||
checkAlerts(totals);
|
||||
|
||||
if (byDay.length > 0) {
|
||||
renderTokensChart(byDay);
|
||||
renderCostChart(byDay);
|
||||
renderCallsChart(byDay);
|
||||
} else {
|
||||
['tokensChart','costChart','callsChart'].forEach(function(id) {
|
||||
var ctx = document.getElementById(id).getContext('2d');
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: { labels: ['Aucune donnée'], datasets: [{ data: [0], backgroundColor: 'rgba(139,148,158,0.2)', borderColor: '#8b949e' }] },
|
||||
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } } }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (byProvider.length > 0) {
|
||||
renderProviderChart(byProvider);
|
||||
} else {
|
||||
var ctx = document.getElementById('providerChart').getContext('2d');
|
||||
new Chart(ctx, { type: 'doughnut', data: { labels: ['Aucune donnée'], datasets: [{ data: [1], backgroundColor: ['#8b949e'], borderWidth: 0 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { labels: { color: '#8b949e' } } } } });
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('loading').innerHTML = '<div style="color:var(--error);padding:40px">❌ Erreur de chargement: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
loadData();
|
||||
setInterval(loadData, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
327
consumption_history.html
Normal file
327
consumption_history.html
Normal file
@@ -0,0 +1,327 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Consommation IA — Historique</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--green: #00c853; --green-d: #009624; --blue: #1e88e5;
|
||||
--gold: #ffd600; --orange: #ff6d00;
|
||||
--dark: #0d1117; --dark2: #161b22; --dark3: #21262d;
|
||||
--text: #e6edf3; --muted: #8b949e; --border: #30363d;
|
||||
--radius: 10px; --error: #f85149; --purple: #7c3aed;
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); min-height: 100vh; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
.topbar { display: flex; align-items: center; justify-content: space-between; padding: 16px 28px; border-bottom: 1px solid var(--border); background: var(--dark); position: sticky; top: 0; z-index: 10; flex-wrap: wrap; gap: 10px; }
|
||||
.topbar-title { font-size: 1.1rem; font-weight: 700; display: flex; align-items: center; gap: 10px; }
|
||||
.topbar-title a { color: var(--muted); font-weight: 400; font-size: .9rem; }
|
||||
.topbar-title a:hover { color: var(--text); }
|
||||
.topbar-right { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 16px; border-radius: 8px; font-size: .85rem; 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-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||
.btn-ghost:hover { border-color: var(--muted); }
|
||||
.btn-sm { padding: 5px 12px; font-size: .8rem; }
|
||||
|
||||
.content { padding: 28px; max-width: 1400px; margin: 0 auto; }
|
||||
|
||||
.filter-bar { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; align-items: center; }
|
||||
.filter-group { display: flex; flex-direction: column; gap: 4px; }
|
||||
.filter-group label { font-size: .72rem; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; font-weight: 600; }
|
||||
.filter-group select, .filter-group input {
|
||||
background: var(--dark3); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 8px 12px; color: var(--text); font-size: .85rem; outline: none;
|
||||
transition: border-color .2s; font-family: inherit;
|
||||
}
|
||||
.filter-group select:focus, .filter-group input:focus { border-color: var(--green); }
|
||||
|
||||
.table-card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; margin-bottom: 20px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
thead th { padding: 10px 14px; font-size: .75rem; text-transform: uppercase; letter-spacing: .5px; color: var(--muted); text-align: left; border-bottom: 1px solid var(--border); background: var(--dark3); white-space: nowrap; cursor: pointer; user-select: none; }
|
||||
thead th:hover { color: var(--text); }
|
||||
tbody tr { border-bottom: 1px solid var(--border); transition: background .15s; }
|
||||
tbody tr:last-child { border-bottom: none; }
|
||||
tbody tr:hover { background: var(--dark3); }
|
||||
tbody td { padding: 10px 14px; font-size: .85rem; white-space: nowrap; }
|
||||
.status-badge { padding: 2px 8px; border-radius: 10px; font-size: .72rem; font-weight: 700; }
|
||||
.status-success { background: rgba(0,200,83,.15); color: var(--green); }
|
||||
.status-error { background: rgba(248,81,73,.15); color: var(--error); }
|
||||
|
||||
.pagination { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 16px; flex-wrap: wrap; }
|
||||
.page-btn { padding: 6px 14px; border-radius: 6px; font-size: .85rem; cursor: pointer; border: 1px solid var(--border); background: transparent; color: var(--muted); transition: all .15s; }
|
||||
.page-btn:hover { border-color: var(--muted); color: var(--text); }
|
||||
.page-btn.active { background: var(--green); color: #000; border-color: var(--green); }
|
||||
.page-btn:disabled { opacity: .4; cursor: default; }
|
||||
.page-info { font-size: .82rem; color: var(--muted); }
|
||||
|
||||
.loading { text-align: center; padding: 60px 20px; color: var(--muted); }
|
||||
.loading .spinner { display: inline-block; width: 32px; height: 32px; border: 3px solid var(--border); border-top-color: var(--green); border-radius: 50%; animation: spin .8s linear infinite; margin-bottom: 12px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
|
||||
|
||||
.empty-state { text-align: center; padding: 60px 20px; color: var(--muted); }
|
||||
.empty-state .icon { font-size: 2.5rem; margin-bottom: 12px; }
|
||||
.empty-state p { font-size: .9rem; }
|
||||
|
||||
.toast {
|
||||
position: fixed; bottom: 20px; right: 20px; z-index: 9999;
|
||||
background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 14px 20px; font-size: .88rem; box-shadow: 0 8px 24px rgba(0,0,0,.4);
|
||||
display: none; max-width: 400px;
|
||||
}
|
||||
.toast.show { display: block; animation: slideIn .3s ease; }
|
||||
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
.toast-success { border-color: var(--green); }
|
||||
.toast-error { border-color: var(--error); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a href="/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
|
||||
|
||||
<div class="topbar">
|
||||
<div class="topbar-title">
|
||||
<span>📋 Historique Consommation</span>
|
||||
<a href="/dashboard/consumption">← Dashboard</a>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<button class="btn btn-ghost btn-sm" onclick="exportCSV()">📥 Export CSV</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="loadHistory()">🔄 Rafraîchir</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="filter-bar">
|
||||
<div class="filter-group">
|
||||
<label>Provider</label>
|
||||
<select id="filter-provider" onchange="loadHistory()">
|
||||
<option value="">Tous</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="google">Google</option>
|
||||
<option value="mistral">Mistral</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="meta">Meta</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Statut</label>
|
||||
<select id="filter-status" onchange="loadHistory()">
|
||||
<option value="">Tous</option>
|
||||
<option value="success">Succès</option>
|
||||
<option value="error">Erreur</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Du</label>
|
||||
<input type="date" id="filter-date-from" onchange="loadHistory()">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Au</label>
|
||||
<input type="date" id="filter-date-to" onchange="loadHistory()">
|
||||
</div>
|
||||
<div class="filter-group" style="align-self:flex-end">
|
||||
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('filter-provider').value='';document.getElementById('filter-status').value='';document.getElementById('filter-date-from').value='';document.getElementById('filter-date-to').value='';loadHistory()">✕ Réinitialiser</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="loading" id="loading">
|
||||
<div class="spinner"></div>
|
||||
<div>Chargement de l'historique...</div>
|
||||
</div>
|
||||
|
||||
<div class="table-card" id="table-wrap" style="display:none">
|
||||
<div style="overflow-x:auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th onclick="sortBy('created_at')">Date ⬍</th>
|
||||
<th onclick="sortBy('provider')">Provider ⬍</th>
|
||||
<th onclick="sortBy('model')">Modèle ⬍</th>
|
||||
<th onclick="sortBy('tokens_in')">Tokens In ⬍</th>
|
||||
<th onclick="sortBy('tokens_out')">Tokens Out ⬍</th>
|
||||
<th onclick="sortBy('tokens_total')">Total ⬍</th>
|
||||
<th onclick="sortBy('cost_cents')">Coût ⬍</th>
|
||||
<th onclick="sortBy('latency_ms')">Latence ⬍</th>
|
||||
<th onclick="sortBy('status')">Statut ⬍</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="history-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="pagination"></div>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" id="empty-state" style="display:none">
|
||||
<div class="icon">📭</div>
|
||||
<p>Aucune donnée de consommation pour cette période.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
var currentPage = 1;
|
||||
var totalPages = 0;
|
||||
var totalItems = 0;
|
||||
var historyData = [];
|
||||
var sortField = 'created_at';
|
||||
var sortDir = 'desc';
|
||||
|
||||
function formatDate(isoStr) {
|
||||
var d = new Date(isoStr);
|
||||
return d.toLocaleDateString('fr-FR', {day:'2-digit', month:'short', year:'numeric'}) + ' ' +
|
||||
d.toLocaleTimeString('fr-FR', {hour:'2-digit', minute:'2-digit'});
|
||||
}
|
||||
|
||||
function formatNumber(n) {
|
||||
if (n >= 1000000) return (n/1000000).toFixed(1) + 'M';
|
||||
if (n >= 1000) return (n/1000).toFixed(1) + 'k';
|
||||
return n.toLocaleString('fr-FR');
|
||||
}
|
||||
|
||||
function showToast(msg, type) {
|
||||
var t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.className = 'toast show toast-' + type;
|
||||
setTimeout(function() { t.classList.remove('show'); }, 4000);
|
||||
}
|
||||
|
||||
function sortBy(field) {
|
||||
if (sortField === field) {
|
||||
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortField = field;
|
||||
sortDir = 'desc';
|
||||
}
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
var tbody = document.getElementById('history-body');
|
||||
var sorted = [...historyData].sort(function(a, b) {
|
||||
var va = a[sortField], vb = b[sortField];
|
||||
if (typeof va === 'string') va = va.toLowerCase();
|
||||
if (typeof vb === 'string') vb = vb.toLowerCase();
|
||||
if (va < vb) return sortDir === 'asc' ? -1 : 1;
|
||||
if (va > vb) return sortDir === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
var html = '';
|
||||
sorted.forEach(function(r) {
|
||||
var cost = ((r.cost_cents || 0) / 100);
|
||||
var statusClass = r.status === 'success' ? 'status-success' : 'status-error';
|
||||
html += '<tr>' +
|
||||
'<td>' + formatDate(r.created_at) + '</td>' +
|
||||
'<td>' + (r.provider || '—') + '</td>' +
|
||||
'<td>' + (r.model || '—') + '</td>' +
|
||||
'<td>' + formatNumber(r.tokens_in || 0) + '</td>' +
|
||||
'<td>' + formatNumber(r.tokens_out || 0) + '</td>' +
|
||||
'<td><strong>' + formatNumber(r.tokens_total || 0) + '</strong></td>' +
|
||||
'<td>' + cost.toFixed(4) + '€</td>' +
|
||||
'<td>' + (r.latency_ms ? r.latency_ms + 'ms' : '—') + '</td>' +
|
||||
'<td><span class="status-badge ' + statusClass + '">' + (r.status || '—') + '</span></td>' +
|
||||
'</tr>';
|
||||
});
|
||||
if (!html) {
|
||||
html = '<tr><td colspan="9" style="text-align:center;padding:40px;color:var(--muted)">Aucun résultat</td></tr>';
|
||||
}
|
||||
tbody.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderPagination() {
|
||||
var el = document.getElementById('pagination');
|
||||
if (totalPages <= 1) { el.innerHTML = ''; return; }
|
||||
var html = '<span class="page-info">Page ' + currentPage + ' / ' + totalPages + ' (' + totalItems + ' entrées)</span>';
|
||||
html += '<button class="page-btn" onclick="goPage(1)" ' + (currentPage <= 1 ? 'disabled' : '') + '>«</button>';
|
||||
html += '<button class="page-btn" onclick="goPage(' + (currentPage - 1) + ')" ' + (currentPage <= 1 ? 'disabled' : '') + '>‹</button>';
|
||||
var start = Math.max(1, currentPage - 2);
|
||||
var end = Math.min(totalPages, currentPage + 2);
|
||||
for (var i = start; i <= end; i++) {
|
||||
html += '<button class="page-btn' + (i === currentPage ? ' active' : '') + '" onclick="goPage(' + i + ')">' + i + '</button>';
|
||||
}
|
||||
html += '<button class="page-btn" onclick="goPage(' + (currentPage + 1) + ')" ' + (currentPage >= totalPages ? 'disabled' : '') + '>›</button>';
|
||||
html += '<button class="page-btn" onclick="goPage(' + totalPages + ')" ' + (currentPage >= totalPages ? 'disabled' : '') + '>»</button>';
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
function goPage(page) {
|
||||
if (page < 1 || page > totalPages) return;
|
||||
currentPage = page;
|
||||
loadHistory();
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
document.getElementById('loading').style.display = '';
|
||||
document.getElementById('table-wrap').style.display = 'none';
|
||||
document.getElementById('empty-state').style.display = 'none';
|
||||
|
||||
var params = 'client_id=internal&page=' + currentPage + '&per_page=25';
|
||||
var provider = document.getElementById('filter-provider').value;
|
||||
var status = document.getElementById('filter-status').value;
|
||||
var dateFrom = document.getElementById('filter-date-from').value;
|
||||
var dateTo = document.getElementById('filter-date-to').value;
|
||||
if (provider) params += '&provider=' + encodeURIComponent(provider);
|
||||
if (dateFrom) params += '&start_date=' + dateFrom;
|
||||
if (dateTo) params += '&end_date=' + dateTo;
|
||||
|
||||
try {
|
||||
var r = await fetch('/api/v1/consumption/history?' + params);
|
||||
var data = await r.json();
|
||||
var items = data.history || [];
|
||||
totalItems = data.total || 0;
|
||||
totalPages = data.total_pages || 0;
|
||||
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
|
||||
if (items.length === 0) {
|
||||
document.getElementById('empty-state').style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('table-wrap').style.display = '';
|
||||
historyData = items;
|
||||
renderTable();
|
||||
renderPagination();
|
||||
} catch (e) {
|
||||
document.getElementById('loading').innerHTML = '<div style="color:var(--error);padding:40px">❌ Erreur: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
if (!historyData || historyData.length === 0) {
|
||||
showToast('Aucune donnée à exporter', 'error');
|
||||
return;
|
||||
}
|
||||
var headers = ['Date','Provider','Modèle','Tokens In','Tokens Out','Tokens Total','Coût (€)','Latence (ms)','Statut'];
|
||||
var rows = historyData.map(function(r) {
|
||||
return [
|
||||
r.created_at || '',
|
||||
r.provider || '',
|
||||
r.model || '',
|
||||
r.tokens_in || 0,
|
||||
r.tokens_out || 0,
|
||||
r.tokens_total || 0,
|
||||
((r.cost_cents || 0) / 100).toFixed(4),
|
||||
r.latency_ms || '',
|
||||
r.status || ''
|
||||
].join(',');
|
||||
});
|
||||
var csv = '\uFEFF' + headers.join(',') + '\n' + rows.join('\n');
|
||||
var blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
var link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = 'consommation_ia_' + new Date().toISOString().split('T')[0] + '.csv';
|
||||
link.click();
|
||||
showToast('CSV téléchargé', 'success');
|
||||
}
|
||||
|
||||
loadHistory();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
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)
|
||||
|
||||
1741
dashboard_saas.html
Normal file
1741
dashboard_saas.html
Normal file
File diff suppressed because it is too large
Load Diff
32
docker-compose.broker.yml
Normal file
32
docker-compose.broker.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
# Token Broker Infrastructure
|
||||
# PostgreSQL dedicated instance on port 5434
|
||||
networks:
|
||||
turf-net:
|
||||
driver: bridge
|
||||
|
||||
services:
|
||||
token-broker-db:
|
||||
image: postgres:16-alpine
|
||||
container_name: token-broker-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: token_broker
|
||||
POSTGRES_USER: token_broker
|
||||
POSTGRES_PASSWORD: ${TOKEN_BROKER_DB_PASSWORD:-CHANGE_ME_PASSWORD}
|
||||
volumes:
|
||||
- token-broker-pgdata:/var/lib/postgresql/data
|
||||
- ./infra/postgres/token_broker_init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U token_broker -d token_broker"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
networks:
|
||||
- turf-net
|
||||
ports:
|
||||
- "127.0.0.1:5434:5432"
|
||||
|
||||
volumes:
|
||||
token-broker-pgdata:
|
||||
driver: local
|
||||
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;
|
||||
94
infra/postgres/token_broker_init.sql
Normal file
94
infra/postgres/token_broker_init.sql
Normal file
@@ -0,0 +1,94 @@
|
||||
-- Token Broker PostgreSQL init script
|
||||
-- 6 tables: api_tokens, refresh_tokens, token_audit_log, clients, providers, token_usage
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT 'default',
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
token_prefix TEXT NOT NULL,
|
||||
scopes TEXT[] DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
metadata JSONB DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id INTEGER NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
token_prefix TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
replaced_by UUID
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS token_audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id INTEGER,
|
||||
action TEXT NOT NULL,
|
||||
token_prefix TEXT,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
details JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id TEXT NOT NULL UNIQUE,
|
||||
client_secret TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
redirect_uris TEXT[] DEFAULT '{}',
|
||||
scopes TEXT[] DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS providers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
provider_type TEXT NOT NULL DEFAULT 'oauth2',
|
||||
issuer_url TEXT,
|
||||
client_id TEXT,
|
||||
client_secret TEXT,
|
||||
scopes TEXT[] DEFAULT '{}',
|
||||
config JSONB DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS token_usage (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
token_id UUID,
|
||||
action TEXT NOT NULL DEFAULT 'verify',
|
||||
endpoint TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'success',
|
||||
response_time_ms INTEGER,
|
||||
ip_address TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON api_tokens(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_audit_log_user_id ON token_audit_log(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_audit_log_created_at ON token_audit_log(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_clients_client_id ON clients(client_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_providers_name ON providers(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_usage_user_id ON token_usage(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_usage_created_at ON token_usage(created_at);
|
||||
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO token_broker;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO token_broker;
|
||||
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
|
||||
90
infra/scripts/deploy_token_broker.sh
Executable file
90
infra/scripts/deploy_token_broker.sh
Executable file
@@ -0,0 +1,90 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# Deploy Token Broker — systemd service + Docker PG
|
||||
# ============================================================
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="/home/h3r7/turf_saas"
|
||||
SERVICE_NAME="token-broker"
|
||||
PID_FILE="/tmp/token_broker.pid"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
echo "[$(date -Iseconds)] === Deploying Token Broker ==="
|
||||
|
||||
# Step 1: Backup current code
|
||||
echo "[$(date -Iseconds)] Backing up current code..."
|
||||
mkdir -p /home/h3r7/backups/token-broker
|
||||
cp "${APP_DIR}/services/token-broker/token_broker_api.py" \
|
||||
"/home/h3r7/backups/token-broker/token_broker_api_${TIMESTAMP}.py"
|
||||
|
||||
# Step 2: Ensure Docker PG is running
|
||||
echo "[$(date -Iseconds)] Ensuring PostgreSQL container..."
|
||||
if ! docker inspect token-broker-db >/dev/null 2>&1; then
|
||||
echo "Creating PG container..."
|
||||
docker run -d \
|
||||
--name token-broker-db \
|
||||
--restart unless-stopped \
|
||||
-e POSTGRES_DB=token_broker \
|
||||
-e POSTGRES_USER=token_broker \
|
||||
-e POSTGRES_PASSWORD="${TOKEN_BROKER_DB_PASSWORD}" \
|
||||
-v token-broker-pgdata:/var/lib/postgresql/data \
|
||||
-v "${APP_DIR}/infra/postgres/token_broker_init.sql:/docker-entrypoint-initdb.d/init.sql:ro" \
|
||||
-p 127.0.0.1:5434:5432 \
|
||||
postgres:16-alpine
|
||||
elif ! docker ps --filter name=token-broker-db --format '{{.Status}}' | grep -q Up; then
|
||||
echo "Starting existing PG container..."
|
||||
docker start token-broker-db
|
||||
else
|
||||
echo "PG container already running."
|
||||
fi
|
||||
|
||||
# Wait for PG readiness
|
||||
echo "[$(date -Iseconds)] Waiting for PG to be ready..."
|
||||
for i in $(seq 1 20); do
|
||||
if docker exec token-broker-db pg_isready -U token_broker -d token_broker >/dev/null 2>&1; then
|
||||
echo "PG ready."
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Step 3: Ensure psycopg2-binary is installed
|
||||
echo "[$(date -Iseconds)] Checking Python deps..."
|
||||
source "${APP_DIR}/venv/bin/activate"
|
||||
pip install -q psycopg2-binary PyJWT flask-cors python-dotenv gunicorn 2>/dev/null || true
|
||||
|
||||
# Step 4: Stop current service
|
||||
echo "[$(date -Iseconds)] Stopping current service..."
|
||||
if systemctl is-active --quiet ${SERVICE_NAME} 2>/dev/null; then
|
||||
systemctl stop ${SERVICE_NAME}
|
||||
elif [ -f "$PID_FILE" ] && kill -0 $(cat "$PID_FILE") 2>/dev/null; then
|
||||
kill $(cat "$PID_FILE") 2>/dev/null || true
|
||||
fi
|
||||
sleep 2
|
||||
|
||||
# Step 5: Copy systemd unit and start
|
||||
echo "[$(date -Iseconds)] Starting via systemd..."
|
||||
cp "${APP_DIR}/services/token-broker/token-broker.service" /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable ${SERVICE_NAME}
|
||||
systemctl start ${SERVICE_NAME}
|
||||
|
||||
# Wait for startup
|
||||
sleep 3
|
||||
|
||||
# Step 6: Health check
|
||||
echo "[$(date -Iseconds)] Running health check..."
|
||||
HEALTH=$(curl -s http://127.0.0.1:8783/health 2>/dev/null || echo '{"status":"failed"}')
|
||||
STATUS=$(echo "$HEALTH" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','unknown'))" 2>/dev/null || echo "unknown")
|
||||
|
||||
if [ "$STATUS" = "ok" ]; then
|
||||
echo "[$(date -Iseconds)] ✅ Health check passed: ${HEALTH}"
|
||||
echo "[$(date -Iseconds)] === Token Broker deploy SUCCESS ==="
|
||||
else
|
||||
echo "[$(date -Iseconds)] ❌ Health check failed: ${HEALTH}"
|
||||
echo "[$(date -Iseconds)] === Token Broker deploy FAILED ==="
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 7: Clean old backups (keep last 30)
|
||||
find /home/h3r7/backups/token-broker -name "*.py" -mtime +30 -delete
|
||||
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>
|
||||
358
leadhunter_api.py
Normal file
358
leadhunter_api.py
Normal file
@@ -0,0 +1,358 @@
|
||||
#!/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,
|
||||
update_lead_status,
|
||||
delete_lead,
|
||||
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("/api/leads/<int:lead_id>", methods=["GET"])
|
||||
def api_get_lead(lead_id: int):
|
||||
"""
|
||||
Retourne le detail d'un lead par son ID.
|
||||
|
||||
Returns:
|
||||
JSON avec les informations completes du lead, ou 404.
|
||||
"""
|
||||
lead = get_lead_by_id(lead_id)
|
||||
if not lead:
|
||||
return jsonify({"error": f"Lead id={lead_id} introuvable"}), 404
|
||||
return jsonify(lead)
|
||||
|
||||
|
||||
@app.route("/api/leads/<int:lead_id>", methods=["PUT"])
|
||||
def api_put_lead(lead_id: int):
|
||||
"""
|
||||
Met a jour completement un lead.
|
||||
|
||||
Body JSON : dict avec les champs a mettre a jour.
|
||||
"""
|
||||
body = request.get_json(silent=True)
|
||||
if not body:
|
||||
return jsonify({"error": "Body JSON requis"}), 400
|
||||
|
||||
lead = get_lead_by_id(lead_id)
|
||||
if not lead:
|
||||
return jsonify({"error": f"Lead id={lead_id} introuvable"}), 404
|
||||
|
||||
success = update_lead(lead_id, body)
|
||||
if not success:
|
||||
return jsonify({"error": "Mise a jour echouee"}), 500
|
||||
|
||||
updated_lead = get_lead_by_id(lead_id)
|
||||
return jsonify({"success": True, "lead": updated_lead})
|
||||
|
||||
|
||||
@app.route("/api/leads/<int:lead_id>", methods=["DELETE"])
|
||||
def api_delete_lead(lead_id: int):
|
||||
"""
|
||||
Supprime un lead physiquement.
|
||||
"""
|
||||
lead = get_lead_by_id(lead_id)
|
||||
if not lead:
|
||||
return jsonify({"error": f"Lead id={lead_id} introuvable"}), 404
|
||||
|
||||
success = delete_lead(lead_id)
|
||||
if not success:
|
||||
return jsonify({"error": "Suppression echouee"}), 500
|
||||
|
||||
return jsonify({"success": True, "lead_id": lead_id, "deleted": True})
|
||||
|
||||
|
||||
@app.route("/health", methods=["GET"])
|
||||
def health():
|
||||
"""Healthcheck pour systemd / monitoring."""
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "leadhunter-api",
|
||||
"port": 8775,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ─── Entrypoint ──────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8775, debug=False)
|
||||
436
leadhunter_crm.py
Normal file
436
leadhunter_crm.py
Normal file
@@ -0,0 +1,436 @@
|
||||
#!/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 (7 etapes Kanban)
|
||||
VALID_STATUSES = {
|
||||
"nouveau", # NOUVEAU
|
||||
"contacte", # CONTACTÉ
|
||||
"interesse", # INTÉRESSÉ
|
||||
"demo_planifiee", # DÉMO PLANIFIÉE
|
||||
"proposition_envoyee", # PROPOSITION ENVOYÉE
|
||||
"negotiation", # NÉGOCIATION
|
||||
"signe_ou_refuse", # SIGNÉ / REFUSÉ
|
||||
}
|
||||
|
||||
# Mapping des anciens statuts vers les nouveaux (pour migration)
|
||||
LEGACY_STATUS_MAP = {
|
||||
"new": "nouveau",
|
||||
"contacted": "contacte",
|
||||
"closed": "signe_ou_refuse",
|
||||
"rejected": "signe_ou_refuse",
|
||||
}
|
||||
|
||||
|
||||
# ─── Initialisation ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
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(lead_id: int, data: dict, db_path: str = DB_PATH) -> bool:
|
||||
"""
|
||||
Met à jour un lead avec les champs fournis.
|
||||
|
||||
Args:
|
||||
lead_id: id du lead.
|
||||
data: dict avec les champs a mettre a jour (name, address, phone, etc.)
|
||||
|
||||
Returns:
|
||||
True si mise a jour reussie, False sinon.
|
||||
"""
|
||||
allowed_fields = {
|
||||
"name",
|
||||
"address",
|
||||
"phone",
|
||||
"rating",
|
||||
"reviews_count",
|
||||
"website",
|
||||
"score",
|
||||
"rgpd_ok",
|
||||
"status",
|
||||
}
|
||||
fields_to_update = {k: v for k, v in data.items() if k in allowed_fields}
|
||||
|
||||
if not fields_to_update:
|
||||
logger.warning(
|
||||
f"update_lead : aucun champ valide fourni pour lead_id={lead_id}"
|
||||
)
|
||||
return False
|
||||
|
||||
if (
|
||||
"status" in fields_to_update
|
||||
and fields_to_update["status"] not in VALID_STATUSES
|
||||
):
|
||||
logger.warning(f"update_lead : statut invalide '{fields_to_update['status']}'")
|
||||
return False
|
||||
|
||||
try:
|
||||
with _get_conn(db_path) as conn:
|
||||
set_clause = ", ".join([f"{k} = ?" for k in fields_to_update])
|
||||
values = list(fields_to_update.values()) + [lead_id]
|
||||
conn.execute(f"UPDATE leads SET {set_clause} WHERE id = ?", values)
|
||||
logger.info(
|
||||
f"Lead id={lead_id} mis a jour : {list(fields_to_update.keys())}"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"update_lead error : {e}")
|
||||
return False
|
||||
|
||||
|
||||
def delete_lead(lead_id: int, db_path: str = DB_PATH) -> bool:
|
||||
"""
|
||||
Supprime un lead physiquement.
|
||||
|
||||
Args:
|
||||
lead_id: id du lead a supprimer.
|
||||
|
||||
Returns:
|
||||
True si suppression reussie, False sinon.
|
||||
"""
|
||||
try:
|
||||
with _get_conn(db_path) as conn:
|
||||
conn.execute("DELETE FROM leads WHERE id = ?", (lead_id,))
|
||||
logger.info(f"Lead id={lead_id} supprime")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"delete_lead error : {e}")
|
||||
return False
|
||||
|
||||
|
||||
def update_lead_status(lead_id: int, status: str, db_path: str = DB_PATH) -> bool:
|
||||
"""
|
||||
Met à jour le statut d'un lead.
|
||||
|
||||
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)
|
||||
90
middleware.py
Normal file
90
middleware.py
Normal file
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Middleware — rate limiting, CORS, and access logging
|
||||
Sprint 2-3: HRT-28
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from functools import wraps
|
||||
from threading import Lock
|
||||
|
||||
from flask import request, jsonify, g
|
||||
|
||||
logger = logging.getLogger("turf_saas.middleware")
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# In-memory rate limiter (100 req/min per IP)
|
||||
# For production: replace with Redis-backed counter
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
_rate_store: dict = defaultdict(lambda: {"count": 0, "window_start": 0.0})
|
||||
_rate_lock = Lock()
|
||||
|
||||
RATE_LIMIT = 100 # max requests
|
||||
RATE_WINDOW = 60 # seconds
|
||||
|
||||
|
||||
def rate_limit_middleware(app):
|
||||
"""Register before_request rate limiting on the Flask app."""
|
||||
|
||||
@app.before_request
|
||||
def check_rate_limit():
|
||||
ip = request.remote_addr or "unknown"
|
||||
now = time.time()
|
||||
|
||||
with _rate_lock:
|
||||
bucket = _rate_store[ip]
|
||||
if now - bucket["window_start"] >= RATE_WINDOW:
|
||||
bucket["count"] = 0
|
||||
bucket["window_start"] = now
|
||||
bucket["count"] += 1
|
||||
count = bucket["count"]
|
||||
remaining = max(0, RATE_LIMIT - count)
|
||||
|
||||
if count > RATE_LIMIT:
|
||||
logger.warning("Rate limit exceeded for IP %s", ip)
|
||||
resp = jsonify({"error": "Trop de requêtes. Limite: 100/min par IP."})
|
||||
resp.status_code = 429
|
||||
resp.headers["X-RateLimit-Limit"] = str(RATE_LIMIT)
|
||||
resp.headers["X-RateLimit-Remaining"] = "0"
|
||||
resp.headers["Retry-After"] = str(RATE_WINDOW)
|
||||
return resp
|
||||
|
||||
# Attach headers on all responses via after_request
|
||||
g.rl_remaining = remaining
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Access logs (timestamped)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
access_log = logging.getLogger("turf_saas.access")
|
||||
|
||||
|
||||
def access_log_middleware(app):
|
||||
"""Register after_request access logging on the Flask app."""
|
||||
|
||||
@app.after_request
|
||||
def log_access(response):
|
||||
ip = request.remote_addr or "unknown"
|
||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
user_id = getattr(g, "current_user_id", "-")
|
||||
access_log.info(
|
||||
'%s %s %s "%s %s" %s %s',
|
||||
ts,
|
||||
ip,
|
||||
user_id,
|
||||
request.method,
|
||||
request.path,
|
||||
response.status_code,
|
||||
response.content_length or 0,
|
||||
)
|
||||
# Attach rate-limit headers
|
||||
remaining = getattr(g, "rl_remaining", None)
|
||||
if remaining is not None:
|
||||
response.headers["X-RateLimit-Limit"] = str(RATE_LIMIT)
|
||||
response.headers["X-RateLimit-Remaining"] = str(remaining)
|
||||
return response
|
||||
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")
|
||||
600
ml_feedback_saas.py
Normal file
600
ml_feedback_saas.py
Normal file
@@ -0,0 +1,600 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ml_feedback_saas.py — Feedback loop ML pour turf_saas.
|
||||
Enregistre les paris virtuels XGBoost depuis ml_predictions_cache
|
||||
et met à jour les résultats/dividendes depuis pmu_partants + pmu_rapports.
|
||||
|
||||
DB cible : /home/h3r7/turf_saas/turf_saas.db
|
||||
|
||||
Stratégies :
|
||||
A) xgboost_sg : simple_gagnant — top1 ML par course, ml_score >= 70, mise 1€
|
||||
B) xgboost_value : simple_gagnant — is_value_bet = 1, mise 1€
|
||||
C) xgboost_sp : simple_place — top1 ML par course, ml_score >= 50, mise 1€
|
||||
D) xgboost_2sur4 : deux_sur_quatre — top4 ML par course, 6 combos x 1€ = mise 6€
|
||||
|
||||
Usage :
|
||||
python3 ml_feedback_saas.py # Traite aujourd'hui
|
||||
python3 ml_feedback_saas.py --backfill 2026-04-25
|
||||
python3 ml_feedback_saas.py --date 2026-04-25
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
DB_PATH = "/home/h3r7/turf_saas/turf_saas.db"
|
||||
|
||||
os.makedirs("/home/h3r7/turf_saas/logs", exist_ok=True)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [ml_feedback_saas] %(levelname)s %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler("/home/h3r7/turf_saas/logs/ml_feedback_saas.log"),
|
||||
logging.StreamHandler(),
|
||||
],
|
||||
)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# UTILITAIRES
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def pari_existe(cursor, date, num_reunion, num_course, numero1, type_pari, source_reco):
|
||||
"""Vérifie si un pari identique existe déjà (idempotence)."""
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id FROM paris
|
||||
WHERE date_course = ? AND source_reco = ?
|
||||
AND type_pari = ? AND numero1 = ?
|
||||
AND race_label = ?
|
||||
""",
|
||||
(date, source_reco, type_pari, numero1, f"R{num_reunion}C{num_course}"),
|
||||
)
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
|
||||
def pari_2sur4_existe(cursor, date, num_reunion, num_course, source_reco):
|
||||
"""Vérifie si un pari 2sur4 existe déjà pour cette course."""
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id FROM paris
|
||||
WHERE date_course = ? AND source_reco = ?
|
||||
AND race_label = ?
|
||||
""",
|
||||
(date, source_reco, f"R{num_reunion}C{num_course}"),
|
||||
)
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
|
||||
def get_top_ml_par_course(cursor, date, n=4, min_score=0):
|
||||
"""Retourne les n meilleurs chevaux ML par course pour une date."""
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT num_reunion, num_course, horse_name, horse_number,
|
||||
ml_score, odds, recommendation, is_value_bet,
|
||||
race_label, race_name, hippodrome, heure,
|
||||
discipline, distance
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ?
|
||||
AND ml_score >= ?
|
||||
ORDER BY num_reunion, num_course, ml_score DESC
|
||||
""",
|
||||
(date, min_score),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
courses = {}
|
||||
for r in rows:
|
||||
key = (r["num_reunion"], r["num_course"])
|
||||
if key not in courses:
|
||||
courses[key] = []
|
||||
if len(courses[key]) < n:
|
||||
courses[key].append(dict(r))
|
||||
return courses
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# STRATÉGIE A — Simple Gagnant top1 ML (score >= 70)
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def save_ml_paris_sg(conn, date):
|
||||
"""Insère 1 pari simple_gagnant par course : top1 ML avec ml_score >= 70."""
|
||||
cursor = conn.cursor()
|
||||
courses = get_top_ml_par_course(cursor, date, n=1, min_score=70)
|
||||
inseres = 0
|
||||
|
||||
for (num_reunion, num_course), chevaux in courses.items():
|
||||
cheval = chevaux[0]
|
||||
if pari_existe(
|
||||
cursor,
|
||||
date,
|
||||
num_reunion,
|
||||
num_course,
|
||||
cheval["horse_number"],
|
||||
"simple_gagnant",
|
||||
"xgboost_sg",
|
||||
):
|
||||
continue
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO paris
|
||||
(date_pari, date_course, race_name, race_label, hippodrome,
|
||||
type_pari, chevaux, cheval1, numero1, cote, mise,
|
||||
statut, gain, source_reco, model_source)
|
||||
VALUES (?, ?, ?, ?, ?, 'simple_gagnant', ?, ?, ?, ?, 1.0,
|
||||
'EN_ATTENTE', 0.0, 'xgboost_sg', 'xgboost_v1')
|
||||
""",
|
||||
(
|
||||
date,
|
||||
date,
|
||||
cheval.get("race_name") or "",
|
||||
f"R{num_reunion}C{num_course}",
|
||||
cheval.get("hippodrome") or "",
|
||||
cheval["horse_name"],
|
||||
cheval["horse_name"],
|
||||
cheval["horse_number"],
|
||||
cheval["odds"],
|
||||
),
|
||||
)
|
||||
inseres += 1
|
||||
|
||||
conn.commit()
|
||||
log.info(f"[SG] {date} → {inseres} paris simple_gagnant insérés (score>=70)")
|
||||
return inseres
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# STRATÉGIE B — Value Bet (is_value_bet = 1)
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def save_ml_paris_value(conn, date):
|
||||
"""Insère 1 pari simple_gagnant pour chaque cheval is_value_bet=1."""
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT num_reunion, num_course, horse_name, horse_number,
|
||||
ml_score, odds, race_label, race_name, hippodrome
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ? AND is_value_bet = 1
|
||||
ORDER BY num_reunion, num_course, ml_score DESC
|
||||
""",
|
||||
(date,),
|
||||
)
|
||||
rows = [dict(r) for r in cursor.fetchall()]
|
||||
inseres = 0
|
||||
|
||||
for r in rows:
|
||||
if pari_existe(
|
||||
cursor,
|
||||
date,
|
||||
r["num_reunion"],
|
||||
r["num_course"],
|
||||
r["horse_number"],
|
||||
"simple_gagnant",
|
||||
"xgboost_value",
|
||||
):
|
||||
continue
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO paris
|
||||
(date_pari, date_course, race_name, race_label, hippodrome,
|
||||
type_pari, chevaux, cheval1, numero1, cote, mise,
|
||||
statut, gain, source_reco, model_source)
|
||||
VALUES (?, ?, ?, ?, ?, 'simple_gagnant', ?, ?, ?, ?, 1.0,
|
||||
'EN_ATTENTE', 0.0, 'xgboost_value', 'xgboost_v1')
|
||||
""",
|
||||
(
|
||||
date,
|
||||
date,
|
||||
r.get("race_name") or "",
|
||||
r.get("race_label") or f"R{r['num_reunion']}C{r['num_course']}",
|
||||
r.get("hippodrome") or "",
|
||||
r["horse_name"],
|
||||
r["horse_name"],
|
||||
r["horse_number"],
|
||||
r["odds"],
|
||||
),
|
||||
)
|
||||
inseres += 1
|
||||
|
||||
conn.commit()
|
||||
log.info(f"[VALUE] {date} → {inseres} paris value_bet insérés")
|
||||
return inseres
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# STRATÉGIE C — Simple Placé top1 ML (score >= 50)
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def save_ml_paris_sp(conn, date):
|
||||
"""Insère 1 pari simple_place par course : top1 ML avec ml_score >= 50."""
|
||||
cursor = conn.cursor()
|
||||
courses = get_top_ml_par_course(cursor, date, n=1, min_score=50)
|
||||
inseres = 0
|
||||
|
||||
for (num_reunion, num_course), chevaux in courses.items():
|
||||
cheval = chevaux[0]
|
||||
if pari_existe(
|
||||
cursor,
|
||||
date,
|
||||
num_reunion,
|
||||
num_course,
|
||||
cheval["horse_number"],
|
||||
"simple_place",
|
||||
"xgboost_sp",
|
||||
):
|
||||
continue
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO paris
|
||||
(date_pari, date_course, race_name, race_label, hippodrome,
|
||||
type_pari, chevaux, cheval1, numero1, cote, mise,
|
||||
statut, gain, source_reco, model_source)
|
||||
VALUES (?, ?, ?, ?, ?, 'simple_place', ?, ?, ?, ?, 1.0,
|
||||
'EN_ATTENTE', 0.0, 'xgboost_sp', 'xgboost_v1')
|
||||
""",
|
||||
(
|
||||
date,
|
||||
date,
|
||||
cheval.get("race_name") or "",
|
||||
f"R{num_reunion}C{num_course}",
|
||||
cheval.get("hippodrome") or "",
|
||||
cheval["horse_name"],
|
||||
cheval["horse_name"],
|
||||
cheval["horse_number"],
|
||||
cheval["odds"],
|
||||
),
|
||||
)
|
||||
inseres += 1
|
||||
|
||||
conn.commit()
|
||||
log.info(f"[SP] {date} → {inseres} paris simple_place insérés (score>=50)")
|
||||
return inseres
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# STRATÉGIE D — 2sur4 top4 ML (6 combinaisons x 1€)
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def save_ml_paris_2sur4(conn, date):
|
||||
"""Insère 1 pari deux_sur_quatre par course : top4 ML, mise 6€."""
|
||||
cursor = conn.cursor()
|
||||
courses = get_top_ml_par_course(cursor, date, n=4, min_score=0)
|
||||
inseres = 0
|
||||
|
||||
for (num_reunion, num_course), chevaux in courses.items():
|
||||
if len(chevaux) < 4:
|
||||
continue
|
||||
if pari_2sur4_existe(cursor, date, num_reunion, num_course, "xgboost_2sur4"):
|
||||
continue
|
||||
|
||||
top4 = chevaux[:4]
|
||||
nums = [str(c["horse_number"]) for c in top4]
|
||||
noms = [c["horse_name"] for c in top4]
|
||||
chevaux_str = "/".join(noms)
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO paris
|
||||
(date_pari, date_course, race_name, race_label, hippodrome,
|
||||
type_pari, chevaux, cheval1, numero1, cote, mise,
|
||||
statut, gain, source_reco, model_source, commentaire)
|
||||
VALUES (?, ?, ?, ?, ?, 'deux_sur_quatre', ?, ?, ?, 0.0, 6.0,
|
||||
'EN_ATTENTE', 0.0, 'xgboost_2sur4', 'xgboost_v1', ?)
|
||||
""",
|
||||
(
|
||||
date,
|
||||
date,
|
||||
top4[0].get("race_name") or "",
|
||||
f"R{num_reunion}C{num_course}",
|
||||
top4[0].get("hippodrome") or "",
|
||||
chevaux_str,
|
||||
top4[0]["horse_name"],
|
||||
top4[0]["horse_number"],
|
||||
f"top4 ML: {'/'.join(nums)}",
|
||||
),
|
||||
)
|
||||
inseres += 1
|
||||
|
||||
conn.commit()
|
||||
log.info(f"[2S4] {date} → {inseres} paris deux_sur_quatre insérés")
|
||||
return inseres
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# UPDATE RÉSULTATS + DIVIDENDES
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def update_ml_paris_results(conn, date):
|
||||
"""
|
||||
Met à jour statut + gain (dividende PMU réel) pour tous les paris ML EN_ATTENTE.
|
||||
Sources: pmu_partants (ordre_arrivee) + pmu_rapports (dividende_euro).
|
||||
"""
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id, race_label, type_pari, numero1, chevaux, mise, source_reco, commentaire
|
||||
FROM paris
|
||||
WHERE date_course = ? AND statut = 'EN_ATTENTE'
|
||||
AND source_reco LIKE 'xgboost%'
|
||||
""",
|
||||
(date,),
|
||||
)
|
||||
paris = [dict(r) for r in cursor.fetchall()]
|
||||
|
||||
if not paris:
|
||||
log.info(f"[UPDATE] {date} → aucun pari ML EN_ATTENTE")
|
||||
return 0
|
||||
|
||||
maj = 0
|
||||
for pari in paris:
|
||||
pari_id = pari["id"]
|
||||
race_label = pari["race_label"] or ""
|
||||
type_pari = pari["type_pari"]
|
||||
numero1 = pari["numero1"]
|
||||
mise = pari["mise"]
|
||||
|
||||
# Extraire num_reunion / num_course depuis le race_label "R{r}C{c}"
|
||||
try:
|
||||
parts = race_label.replace("R", "").split("C")
|
||||
num_reunion = int(parts[0])
|
||||
num_course = int(parts[1])
|
||||
except Exception:
|
||||
log.warning(f"[UPDATE] race_label invalide : {race_label}")
|
||||
continue
|
||||
|
||||
if type_pari == "simple_gagnant":
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT ordre_arrivee FROM pmu_partants
|
||||
WHERE date_programme = ? AND num_reunion = ?
|
||||
AND num_course = ? AND num_pmu = ?
|
||||
""",
|
||||
(date, num_reunion, num_course, numero1),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row or row["ordre_arrivee"] is None or row["ordre_arrivee"] == 0:
|
||||
continue
|
||||
|
||||
gagne = row["ordre_arrivee"] == 1
|
||||
gain = 0.0
|
||||
if gagne:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT dividende_euro FROM pmu_rapports
|
||||
WHERE date_programme = ? AND num_reunion = ?
|
||||
AND num_course = ? AND type_pari = 'SIMPLE_GAGNANT'
|
||||
AND CAST(combinaison AS INTEGER) = ?
|
||||
AND libelle NOT LIKE '%NP%'
|
||||
""",
|
||||
(date, num_reunion, num_course, numero1),
|
||||
)
|
||||
div = cursor.fetchone()
|
||||
gain = div["dividende_euro"] if div and div["dividende_euro"] else 0.0
|
||||
|
||||
cursor.execute(
|
||||
"UPDATE paris SET statut=?, gain=? WHERE id=?",
|
||||
("GAGNE" if gagne else "PERDU", gain, pari_id),
|
||||
)
|
||||
maj += 1
|
||||
|
||||
elif type_pari == "simple_place":
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT ordre_arrivee FROM pmu_partants
|
||||
WHERE date_programme = ? AND num_reunion = ?
|
||||
AND num_course = ? AND num_pmu = ?
|
||||
""",
|
||||
(date, num_reunion, num_course, numero1),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row or not row["ordre_arrivee"]:
|
||||
continue
|
||||
|
||||
gagne = 1 <= row["ordre_arrivee"] <= 3
|
||||
gain = 0.0
|
||||
if gagne:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT dividende_euro FROM pmu_rapports
|
||||
WHERE date_programme = ? AND num_reunion = ?
|
||||
AND num_course = ? AND type_pari = 'SIMPLE_PLACE'
|
||||
AND CAST(combinaison AS INTEGER) = ?
|
||||
AND libelle NOT LIKE '%NP%'
|
||||
""",
|
||||
(date, num_reunion, num_course, numero1),
|
||||
)
|
||||
div = cursor.fetchone()
|
||||
gain = div["dividende_euro"] if div and div["dividende_euro"] else 0.0
|
||||
|
||||
cursor.execute(
|
||||
"UPDATE paris SET statut=?, gain=? WHERE id=?",
|
||||
("GAGNE" if gagne else "PERDU", gain, pari_id),
|
||||
)
|
||||
maj += 1
|
||||
|
||||
elif type_pari == "deux_sur_quatre":
|
||||
# Récupère les 4 numéros depuis commentaire "top4 ML: n1/n2/n3/n4"
|
||||
try:
|
||||
nums_str = (
|
||||
pari["commentaire"].split(": ")[1]
|
||||
if pari.get("commentaire")
|
||||
else ""
|
||||
)
|
||||
nums_top4 = [int(n) for n in nums_str.split("/") if n.strip().isdigit()]
|
||||
except Exception:
|
||||
nums_top4 = []
|
||||
|
||||
if len(nums_top4) < 4:
|
||||
# Fallback : reconstituer top4 depuis ml_predictions_cache
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT horse_number FROM ml_predictions_cache
|
||||
WHERE date = ? AND num_reunion = ? AND num_course = ?
|
||||
ORDER BY ml_score DESC LIMIT 4
|
||||
""",
|
||||
(date, num_reunion, num_course),
|
||||
)
|
||||
nums_top4 = [r["horse_number"] for r in cursor.fetchall()]
|
||||
|
||||
if len(nums_top4) < 2:
|
||||
continue
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT combinaison, dividende_euro FROM pmu_rapports
|
||||
WHERE date_programme = ? AND num_reunion = ?
|
||||
AND num_course = ? AND type_pari = 'DEUX_SUR_QUATRE'
|
||||
AND libelle NOT LIKE '%NP%'
|
||||
""",
|
||||
(date, num_reunion, num_course),
|
||||
)
|
||||
rapports = [dict(r) for r in cursor.fetchall()]
|
||||
gain_total = 0.0
|
||||
|
||||
for rap in rapports:
|
||||
try:
|
||||
n1, n2 = [int(x) for x in rap["combinaison"].split("-")]
|
||||
except Exception:
|
||||
continue
|
||||
if n1 in nums_top4 and n2 in nums_top4:
|
||||
gain_total += rap["dividende_euro"]
|
||||
|
||||
gagne = gain_total > 0
|
||||
cursor.execute(
|
||||
"UPDATE paris SET statut=?, gain=? WHERE id=?",
|
||||
("GAGNE" if gagne else "PERDU", round(gain_total, 2), pari_id),
|
||||
)
|
||||
maj += 1
|
||||
|
||||
conn.commit()
|
||||
log.info(f"[UPDATE] {date} → {maj}/{len(paris)} paris ML mis à jour")
|
||||
return maj
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# STATS PAR STRATÉGIE
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_feedback_stats(conn, date_debut=None, date_fin=None):
|
||||
"""Stats performances ML par stratégie (source_reco)."""
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT source_reco,
|
||||
COUNT(*) as n_paris,
|
||||
SUM(CASE WHEN statut='GAGNE' THEN 1 ELSE 0 END) as n_gagne,
|
||||
SUM(CASE WHEN statut='PERDU' THEN 1 ELSE 0 END) as n_perdu,
|
||||
SUM(CASE WHEN statut='EN_ATTENTE' THEN 1 ELSE 0 END) as n_attente,
|
||||
ROUND(100.0 * SUM(CASE WHEN statut='GAGNE' THEN 1 ELSE 0 END)
|
||||
/ NULLIF(SUM(CASE WHEN statut IN ('GAGNE','PERDU') THEN 1 ELSE 0 END), 0), 1) as win_rate_pct,
|
||||
ROUND(SUM(gain), 2) as gain_total,
|
||||
ROUND(SUM(mise), 2) as mise_totale,
|
||||
ROUND(SUM(gain) - SUM(mise), 2) as roi_net
|
||||
FROM paris
|
||||
WHERE source_reco LIKE 'xgboost%'
|
||||
AND (:debut IS NULL OR date_course >= :debut)
|
||||
AND (:fin IS NULL OR date_course <= :fin)
|
||||
GROUP BY source_reco
|
||||
ORDER BY source_reco
|
||||
""",
|
||||
{"debut": date_debut, "fin": date_fin},
|
||||
)
|
||||
return [dict(r) for r in cursor.fetchall()]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# PIPELINE COMPLET
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def run(date):
|
||||
"""Enregistre les paris ML du jour + met à jour les résultats de J-1."""
|
||||
conn = get_db()
|
||||
log.info(f"=== ml_feedback_saas.run({date}) ===")
|
||||
|
||||
# 1. Enregistre les paris ML pour la date (depuis le cache du jour)
|
||||
sg = save_ml_paris_sg(conn, date)
|
||||
vb = save_ml_paris_value(conn, date)
|
||||
sp = save_ml_paris_sp(conn, date)
|
||||
s4 = save_ml_paris_2sur4(conn, date)
|
||||
log.info(f"[SAVE] {date} → total insérés : SG={sg} VALUE={vb} SP={sp} 2S4={s4}")
|
||||
|
||||
# 2. Met à jour les résultats de J-1 (résultats PMU disponibles)
|
||||
yesterday = (datetime.strptime(date, "%Y-%m-%d") - timedelta(days=1)).strftime(
|
||||
"%Y-%m-%d"
|
||||
)
|
||||
maj = update_ml_paris_results(conn, yesterday)
|
||||
log.info(f"[UPDATE] {yesterday} → {maj} paris mis à jour")
|
||||
|
||||
conn.close()
|
||||
return {"inseres": {"sg": sg, "value": vb, "sp": sp, "2sur4": s4}, "maj": maj}
|
||||
|
||||
|
||||
def backfill(date):
|
||||
"""Backfill : insère ET met à jour les résultats pour une date passée."""
|
||||
conn = get_db()
|
||||
log.info(f"=== ml_feedback_saas.backfill({date}) ===")
|
||||
|
||||
sg = save_ml_paris_sg(conn, date)
|
||||
vb = save_ml_paris_value(conn, date)
|
||||
sp = save_ml_paris_sp(conn, date)
|
||||
s4 = save_ml_paris_2sur4(conn, date)
|
||||
log.info(f"[SAVE] {date} → SG={sg} VALUE={vb} SP={sp} 2S4={s4}")
|
||||
|
||||
maj = update_ml_paris_results(conn, date)
|
||||
log.info(f"[UPDATE] {date} → {maj} paris mis à jour")
|
||||
|
||||
conn.close()
|
||||
return sg + vb + sp + s4, maj
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# MAIN
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--backfill" in sys.argv:
|
||||
idx = sys.argv.index("--backfill")
|
||||
date = sys.argv[idx + 1] if idx + 1 < len(sys.argv) else None
|
||||
if not date:
|
||||
print("Usage: python3 ml_feedback_saas.py --backfill YYYY-MM-DD")
|
||||
sys.exit(1)
|
||||
inseres, maj = backfill(date)
|
||||
print(f"Backfill {date} : {inseres} paris insérés, {maj} mis à jour")
|
||||
|
||||
elif "--date" in sys.argv:
|
||||
idx = sys.argv.index("--date")
|
||||
date = sys.argv[idx + 1] if idx + 1 < len(sys.argv) else None
|
||||
if not date:
|
||||
print("Usage: python3 ml_feedback_saas.py --date YYYY-MM-DD")
|
||||
sys.exit(1)
|
||||
result = run(date)
|
||||
total = sum(result["inseres"].values())
|
||||
print(f"Run {date} : {total} paris insérés, {result['maj']} mis à jour")
|
||||
|
||||
else:
|
||||
result = run(datetime.now().strftime("%Y-%m-%d"))
|
||||
total = sum(result["inseres"].values())
|
||||
print(f"Run today : {total} paris insérés, {result['maj']} mis à jour")
|
||||
@@ -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"
|
||||
|
||||
|
||||
281
portal_server.py
281
portal_server.py
@@ -5,22 +5,82 @@ 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 saas_api_v1_bp
|
||||
from api_v1 import register_api_v1
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(saas_api_v1_bp)
|
||||
register_api_v1(app)
|
||||
print("[portal] SaaS auth & API v1 blueprints registered ✅")
|
||||
except Exception as e:
|
||||
print(f"[portal] Warning: could not register SaaS blueprints: {e}")
|
||||
|
||||
|
||||
# ─── 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 +329,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 +346,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")
|
||||
@@ -298,11 +354,32 @@ def template_complet():
|
||||
return send_from_directory("/home/h3r7/turf_saas", "template_complet.html")
|
||||
|
||||
|
||||
@app.route("/leadhunter/clients/le-big-ben/")
|
||||
@app.route("/leadhunter/clients/le-big-ben")
|
||||
def big_ben():
|
||||
return send_from_directory(
|
||||
"/home/h3r7/turf_saas/templates/leadhunter/clients/le-big-ben", "index.html"
|
||||
)
|
||||
|
||||
|
||||
@app.route("/leadhunter/clients/le-big-ben/sitemap.xml")
|
||||
def big_ben_sitemap():
|
||||
return send_from_directory(
|
||||
"/home/h3r7/turf_saas/templates/leadhunter/clients/le-big-ben",
|
||||
"sitemap.xml",
|
||||
mimetype="application/xml",
|
||||
)
|
||||
|
||||
|
||||
@app.route("/formation/ai102")
|
||||
@app.route("/formation/ai102/")
|
||||
def certif_ai102():
|
||||
return send_from_directory("/home/h3r7/turf_saas/pitch", "certif-ai102.html")
|
||||
|
||||
|
||||
@app.route("/boite_a_idees_dashboard")
|
||||
def boite_a_idees_dashboard():
|
||||
return send_from_directory(
|
||||
"/home/h3r7/turf_saas", "boite_a_idees_dashboard.html"
|
||||
)
|
||||
return send_from_directory("/home/h3r7/turf_saas", "boite_a_idees_dashboard.html")
|
||||
|
||||
|
||||
@app.route("/datagouv_explorer.html")
|
||||
@@ -345,13 +422,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 +544,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 +565,6 @@ NVIDIA_MODELS = {
|
||||
}
|
||||
|
||||
|
||||
|
||||
@app.route("/webhook/telegram", methods=["POST"])
|
||||
def telegram_webhook():
|
||||
try:
|
||||
@@ -542,25 +630,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(
|
||||
@@ -667,6 +755,63 @@ def turf_static(filename):
|
||||
return send_from_directory("/home/h3r7/turf_saas", filename)
|
||||
|
||||
|
||||
# --- Consumption Dashboard (HRT-226) ---
|
||||
CONSUMPTION_API_URL = "http://localhost:8784"
|
||||
CONSUMPTION_API_KEY = os.environ.get("CONSUMPTION_API_KEY", "dev-key-change-in-production")
|
||||
|
||||
|
||||
@app.route("/dashboard/consumption")
|
||||
def consumption_dashboard():
|
||||
return send_from_directory(SAAS_DIR, "consumption_dashboard.html")
|
||||
|
||||
|
||||
@app.route("/dashboard/consumption/history")
|
||||
def consumption_history():
|
||||
return send_from_directory(SAAS_DIR, "consumption_history.html")
|
||||
|
||||
|
||||
@app.route("/dashboard/consumption/alerts")
|
||||
def consumption_alerts():
|
||||
return send_from_directory(SAAS_DIR, "consumption_alerts.html")
|
||||
|
||||
|
||||
# Proxy: /api/v1/consumption/* -> consumption-tracker (port 8784)
|
||||
@app.route("/api/v1/consumption/<path:subpath>", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||
def proxy_consumption_api(subpath):
|
||||
full_url = f"{CONSUMPTION_API_URL}/api/v1/consumption/{subpath}"
|
||||
if request.query_string:
|
||||
full_url += "?" + request.query_string.decode()
|
||||
try:
|
||||
headers = {k: v for k, v in request.headers if k.lower() not in ("host", "content-length", "transfer-encoding", "connection")}
|
||||
if not any(k.lower() == "authorization" for k in headers):
|
||||
headers["Authorization"] = f"Bearer {CONSUMPTION_API_KEY}"
|
||||
raw_body = request.get_data()
|
||||
resp = requests.request(request.method, full_url, headers=headers, data=raw_body, cookies=request.cookies, allow_redirects=False, timeout=15)
|
||||
response = make_response(resp.content, resp.status_code)
|
||||
for k, v in resp.headers.items():
|
||||
if k.lower() not in ("content-encoding", "transfer-encoding", "connection"):
|
||||
response.headers[k] = v
|
||||
return response
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"Consumption proxy error: {e}"}), 502
|
||||
|
||||
|
||||
# Proxy: /api/v1/ai/usage* -> AI Router (port 8783)
|
||||
@app.route("/api/v1/ai/usage")
|
||||
@app.route("/api/v1/ai/usage/<path:subpath>")
|
||||
def proxy_ai_usage(subpath=""):
|
||||
full_url = f"http://localhost:8783/api/v1/ai/usage"
|
||||
if subpath:
|
||||
full_url += "/" + subpath
|
||||
if request.query_string:
|
||||
full_url += "?" + request.query_string.decode()
|
||||
try:
|
||||
resp = requests.get(full_url, timeout=15)
|
||||
return resp.content, resp.status_code, {"Content-Type": "application/json"}
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"AI usage proxy error: {e}"}), 502
|
||||
|
||||
|
||||
# --- POD Routes ---
|
||||
@app.route("/pod/")
|
||||
@app.route("/pod/<path:filename>")
|
||||
@@ -680,19 +825,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 +857,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 +910,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 +996,3 @@ def proxy_prompts_test():
|
||||
return response
|
||||
except Exception as e:
|
||||
return f"Erreur proxy prompts: {e}", 502
|
||||
|
||||
|
||||
|
||||
@@ -10,3 +10,4 @@ markers =
|
||||
load: Tests de charge Locust
|
||||
security: Tests de sécurité
|
||||
smoke: Tests rapides de smoke
|
||||
integration: Tests d'intégration DB et pipeline ML
|
||||
|
||||
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>
|
||||
36
requirements.txt
Normal file
36
requirements.txt
Normal file
@@ -0,0 +1,36 @@
|
||||
# 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
|
||||
|
||||
# Hyperparameter optimization (ML ensemble tuning — HRT-136)
|
||||
optuna>=4.0.0
|
||||
247
saas_api.py
Normal file
247
saas_api.py
Normal file
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Turf SaaS API v1 — Auth JWT + Multi-tenant
|
||||
Sprint 2-3: HRT-28
|
||||
|
||||
Run:
|
||||
FLASK_ENV=development ./venv/bin/python saas_api.py
|
||||
|
||||
Ports (isolated from production):
|
||||
Portal: 8793
|
||||
SaaS API: 8792 ← this file
|
||||
Dashboard: 8791
|
||||
Combined API: 8790
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import logging.handlers
|
||||
import sys
|
||||
|
||||
from flask import Flask, jsonify, g, request
|
||||
from flask_cors import CORS
|
||||
from flask_jwt_extended import JWTManager, get_jwt
|
||||
|
||||
from auth_db import init_auth_tables
|
||||
from auth import (
|
||||
auth_bp,
|
||||
jwt_required_middleware,
|
||||
plan_required,
|
||||
free_daily_limit_check,
|
||||
_get_user_by_id,
|
||||
)
|
||||
from middleware import rate_limit_middleware, access_log_middleware
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Logging setup
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
LOG_DIR = os.path.join(os.path.dirname(__file__), "logs")
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.handlers.RotatingFileHandler(
|
||||
os.path.join(LOG_DIR, "saas_api.log"),
|
||||
maxBytes=5 * 1024 * 1024,
|
||||
backupCount=3,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# App factory
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def create_app(test_config=None):
|
||||
app = Flask(__name__)
|
||||
|
||||
# JWT config
|
||||
app.config["JWT_SECRET_KEY"] = os.environ.get(
|
||||
"JWT_SECRET_KEY", "CHANGE_ME_IN_PRODUCTION_" + os.urandom(24).hex()
|
||||
)
|
||||
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = 900 # 15 minutes
|
||||
app.config["JWT_REFRESH_TOKEN_EXPIRES"] = 2592000 # 30 days
|
||||
|
||||
if test_config:
|
||||
app.config.update(test_config)
|
||||
|
||||
# CORS — SaaS domain + localhost for dev
|
||||
CORS(
|
||||
app,
|
||||
origins=os.environ.get(
|
||||
"CORS_ORIGINS",
|
||||
"http://localhost:8793,http://127.0.0.1:8793,https://turf-ia.h3r7.tech",
|
||||
).split(","),
|
||||
methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "Authorization"],
|
||||
supports_credentials=True,
|
||||
)
|
||||
|
||||
# JWT
|
||||
jwt = JWTManager(app)
|
||||
|
||||
# ── JWT error handlers ────────────────────────────────────
|
||||
@jwt.expired_token_loader
|
||||
def expired_token(_jwt_header, _jwt_payload):
|
||||
return jsonify({"error": "Token expiré"}), 401
|
||||
|
||||
@jwt.invalid_token_loader
|
||||
def invalid_token(reason):
|
||||
return jsonify({"error": "Token invalide", "detail": reason}), 422
|
||||
|
||||
@jwt.unauthorized_loader
|
||||
def unauthorized(reason):
|
||||
return jsonify({"error": "Token manquant ou invalide", "detail": reason}), 401
|
||||
|
||||
# ── Register middleware ───────────────────────────────────
|
||||
rate_limit_middleware(app)
|
||||
access_log_middleware(app)
|
||||
|
||||
# ── Blueprints ────────────────────────────────────────────
|
||||
app.register_blueprint(auth_bp)
|
||||
|
||||
# ── Predictions routes (multi-tenant plan check) ──────────
|
||||
|
||||
@app.route("/api/v1/predictions", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@free_daily_limit_check
|
||||
def predictions():
|
||||
"""
|
||||
GET /api/v1/predictions
|
||||
- free: Top 3 uniquement (déjà filtrées par le moteur ML)
|
||||
- premium: toutes courses + alertes Telegram
|
||||
- pro: API complète + export CSV disponible
|
||||
"""
|
||||
user = g.current_user
|
||||
plan = user["plan"]
|
||||
|
||||
# Forward to combined_api for actual predictions
|
||||
import requests as req
|
||||
|
||||
try:
|
||||
params = dict(request.args)
|
||||
resp = req.get(
|
||||
"http://localhost:8790/api/predictions",
|
||||
params=params,
|
||||
timeout=10,
|
||||
)
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
return jsonify(
|
||||
{"error": "Service prédictions indisponible", "detail": str(e)}
|
||||
), 503
|
||||
|
||||
# Plan filtering
|
||||
if plan == "free":
|
||||
# Top 3 only
|
||||
if isinstance(data, list):
|
||||
data = [
|
||||
{k: v for k, v in p.items() if k not in ("score_detaille",)}
|
||||
for p in data[:3]
|
||||
]
|
||||
return jsonify({"plan": plan, "predictions": data, "limit": "Top 3"}), 200
|
||||
|
||||
elif plan == "premium":
|
||||
# All courses, but no CSV export
|
||||
return jsonify(
|
||||
{"plan": plan, "predictions": data, "telegram_alerts": True}
|
||||
), 200
|
||||
|
||||
else: # pro
|
||||
return jsonify(
|
||||
{
|
||||
"plan": plan,
|
||||
"predictions": data,
|
||||
"telegram_alerts": True,
|
||||
"csv_export_url": "/api/v1/predictions/export",
|
||||
}
|
||||
), 200
|
||||
|
||||
@app.route("/api/v1/predictions/export", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("pro")
|
||||
def predictions_export():
|
||||
"""CSV export — pro plan only."""
|
||||
import requests as req
|
||||
import io
|
||||
|
||||
try:
|
||||
resp = req.get(
|
||||
"http://localhost:8790/api/predictions/export",
|
||||
params=dict(request.args),
|
||||
timeout=15,
|
||||
)
|
||||
from flask import Response
|
||||
|
||||
return Response(
|
||||
resp.content,
|
||||
mimetype="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=predictions.csv"},
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"error": "Export indisponible", "detail": str(e)}), 503
|
||||
|
||||
@app.route("/api/v1/subscription/upgrade", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
def subscription_info():
|
||||
"""Return available plans and current user plan."""
|
||||
user = g.current_user
|
||||
return jsonify(
|
||||
{
|
||||
"current_plan": user["plan"],
|
||||
"plans": {
|
||||
"free": {
|
||||
"price": "0€/mois",
|
||||
"features": ["Top 3 prédictions", "1 course/jour"],
|
||||
},
|
||||
"premium": {
|
||||
"price": "9.99€/mois",
|
||||
"features": [
|
||||
"Toutes les courses",
|
||||
"Alertes Telegram",
|
||||
"Historique 30j",
|
||||
],
|
||||
},
|
||||
"pro": {
|
||||
"price": "29.99€/mois",
|
||||
"features": [
|
||||
"API complète",
|
||||
"Export CSV",
|
||||
"Alertes Telegram",
|
||||
"Historique illimité",
|
||||
"Support prioritaire",
|
||||
],
|
||||
},
|
||||
},
|
||||
"upgrade_contact": "contact@h3r7.tech",
|
||||
}
|
||||
), 200
|
||||
|
||||
# ── Health check ──────────────────────────────────────────
|
||||
|
||||
@app.route("/api/v1/health", methods=["GET"])
|
||||
def health():
|
||||
return jsonify(
|
||||
{"status": "ok", "service": "turf-saas-api", "version": "2.3.0"}
|
||||
), 200
|
||||
|
||||
# Init DB tables on startup
|
||||
with app.app_context():
|
||||
init_auth_tables()
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Entrypoint
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = create_app()
|
||||
port = int(os.environ.get("SAAS_API_PORT", 8792))
|
||||
app.run(host="0.0.0.0", port=port, debug=False)
|
||||
279
saas_api_v1.py
Normal file
279
saas_api_v1.py
Normal file
@@ -0,0 +1,279 @@
|
||||
#!/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")
|
||||
|
||||
saas_api_v1_bp = Blueprint("saas_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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@saas_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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@saas_api_v1_bp.route("/predictions/today", methods=["GET"])
|
||||
@require_auth
|
||||
def predictions_today():
|
||||
"""GET /api/v1/predictions/today — prédictions du jour selon le plan."""
|
||||
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
|
||||
|
||||
|
||||
@saas_api_v1_bp.route("/predictions/race/<race_label>", methods=["GET"])
|
||||
@require_auth
|
||||
def predictions_race(race_label):
|
||||
"""GET /api/v1/predictions/race/<label> — prédictions d'une course."""
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@saas_api_v1_bp.route("/value-bets/today", methods=["GET"])
|
||||
@require_auth
|
||||
def value_bets_today():
|
||||
"""GET /api/v1/value-bets/today — value bets (Premium+)."""
|
||||
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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@saas_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"
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ─── JWT init — HRT-49 ────────────────────────────────────────────────────────
|
||||
# Initialize JWTManager on the Flask app (required for jwt_required_middleware)
|
||||
# Called when saas_api_v1_bp is registered (portal_server.py)
|
||||
try:
|
||||
from flask_jwt_extended import JWTManager
|
||||
|
||||
@saas_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)
|
||||
print("[saas_api_v1] JWT init registered ✅")
|
||||
except Exception as _jwt_err:
|
||||
print(f"[saas_api_v1] Warning: JWT init not loaded: {_jwt_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
|
||||
415
scoring_v2.py
415
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,29 +46,102 @@ def parse_musique(musique):
|
||||
nb_places = sum(1 for p in positions if 1 <= p <= 3)
|
||||
recentes = [p for p in positions[:3] if p != 99]
|
||||
forme_recente = sum(recentes) / len(recentes) if recentes else 99
|
||||
tendance = (sum(positions[-4:]) / 4 - sum(positions[:4]) / 4) if len(positions) >= 4 else 0
|
||||
tendance = (
|
||||
(sum(positions[-4:]) / 4 - sum(positions[:4]) / 4) if len(positions) >= 4 else 0
|
||||
)
|
||||
return {
|
||||
'forme_recente': round(forme_recente, 1),
|
||||
'tendance': round(tendance, 1),
|
||||
'tx_victoire': round(nb_victoires / nb_courses * 100, 1) if nb_courses else 0,
|
||||
'tx_place': round(nb_places / nb_courses * 100, 1) if nb_courses else 0,
|
||||
"forme_recente": round(forme_recente, 1),
|
||||
"tendance": round(tendance, 1),
|
||||
"tx_victoire": round(nb_victoires / nb_courses * 100, 1) if nb_courses else 0,
|
||||
"tx_place": round(nb_places / nb_courses * 100, 1) if nb_courses else 0,
|
||||
}
|
||||
|
||||
def score_cheval_v2(p, all_participants, today):
|
||||
|
||||
def get_terrain_condition(penetrometre_intitule: str | None) -> str:
|
||||
"""Normalise le pénétromètre PMU en condition terrain standardisée."""
|
||||
if not penetrometre_intitule:
|
||||
return "inconnu"
|
||||
val = penetrometre_intitule.upper()
|
||||
if any(k in val for k in ("TRES BON", "TRÈS BON", "FERME", "FIRM")):
|
||||
return "bon"
|
||||
if any(k in val for k in ("BON", "GOOD", "STANDARD")):
|
||||
return "bon"
|
||||
if any(k in val for k in ("SOUPLE", "YIELDING", "COLLANT")):
|
||||
return "souple"
|
||||
if any(k in val for k in ("LOURD", "HEAVY", "TRES SOUPLE", "TRÈS SOUPLE")):
|
||||
return "lourd"
|
||||
if any(k in val for k in ("SOFT", "MOU")):
|
||||
return "souple"
|
||||
return "inconnu"
|
||||
|
||||
|
||||
def compute_weather_impact(weather_data: dict | None, terrain_condition: str) -> float:
|
||||
"""
|
||||
Calcule un score d'impact météo/terrain sur [−5, +5].
|
||||
weather_data keys attendues : nebulositecode, temperature, force_vent
|
||||
terrain_condition : 'bon' | 'souple' | 'lourd' | 'inconnu'
|
||||
Retourne un delta de score ML (positif = favorable, négatif = défavorable).
|
||||
"""
|
||||
if not weather_data:
|
||||
return 0.0
|
||||
|
||||
delta = 0.0
|
||||
|
||||
# Terrain
|
||||
if terrain_condition == "lourd":
|
||||
delta -= 3.0
|
||||
elif terrain_condition == "souple":
|
||||
delta -= 1.5
|
||||
elif terrain_condition == "bon":
|
||||
delta += 1.0
|
||||
# inconnu → 0
|
||||
|
||||
# Vent
|
||||
force_vent = weather_data.get("force_vent") or 0
|
||||
try:
|
||||
force_vent = float(force_vent)
|
||||
except (TypeError, ValueError):
|
||||
force_vent = 0.0
|
||||
if force_vent >= 50:
|
||||
delta -= 2.0
|
||||
elif force_vent >= 30:
|
||||
delta -= 1.0
|
||||
|
||||
# Températures extrêmes
|
||||
temperature = weather_data.get("temperature")
|
||||
try:
|
||||
temperature = float(temperature) if temperature is not None else None
|
||||
except (TypeError, ValueError):
|
||||
temperature = None
|
||||
if temperature is not None:
|
||||
if temperature <= 0:
|
||||
delta -= 1.0
|
||||
elif temperature >= 35:
|
||||
delta -= 1.0
|
||||
|
||||
return round(max(-5.0, min(5.0, delta)), 2)
|
||||
|
||||
|
||||
def score_cheval_v2(p, all_participants, today, weather_data=None):
|
||||
"""
|
||||
Score un cheval pour le modèle V2.
|
||||
weather_data (optionnel) : dict issu de pmu_meteo pour cette réunion.
|
||||
Backward-compatible : weather_data=None → comportement identique à avant HRT-83.
|
||||
"""
|
||||
score = 0
|
||||
details = {}
|
||||
|
||||
# 1. COTE - Essaye PMU API, sinon DB
|
||||
horse_name = p.get('nom', '')
|
||||
horse_name = p.get("nom", "")
|
||||
cote = 0
|
||||
|
||||
# Essayer d'abord depuis l'API PMU
|
||||
rapport = p.get('dernierRapportDirect', {})
|
||||
rapport = p.get("dernierRapportDirect", {})
|
||||
if rapport:
|
||||
cote = rapport.get('rapport', 0)
|
||||
cote = rapport.get("rapport", 0)
|
||||
if not cote:
|
||||
rapport_ref = p.get('dernierRapportReference', {})
|
||||
cote = rapport_ref.get('rapport', 0) if rapport_ref else 0
|
||||
rapport_ref = p.get("dernierRapportReference", {})
|
||||
cote = rapport_ref.get("rapport", 0) if rapport_ref else 0
|
||||
|
||||
# Fallback: aller chercher dans la DB
|
||||
if not cote or cote == 0:
|
||||
@@ -75,94 +153,136 @@ def score_cheval_v2(p, all_participants, today):
|
||||
|
||||
score_cote = max(2, min(10, 20 / (1 + cote * 0.15))) if cote > 0 else 2
|
||||
score += score_cote
|
||||
details['cote'] = round(cote, 1)
|
||||
details['score_cote'] = round(score_cote, 1)
|
||||
details["cote"] = round(cote, 1)
|
||||
details["score_cote"] = round(score_cote, 1)
|
||||
|
||||
# 2. FORME - AUGMENTE a 30 pts
|
||||
musique_stats = parse_musique(p.get('musique', ''))
|
||||
forme = musique_stats.get('forme_recente', 99)
|
||||
score_forme = 30 if forme <= 1 else 25 if forme <= 2 else 20 if forme <= 3 else 15 if forme <= 5 else 8 if forme <= 8 else 0
|
||||
musique_stats = parse_musique(p.get("musique", ""))
|
||||
forme = musique_stats.get("forme_recente", 99)
|
||||
score_forme = (
|
||||
30
|
||||
if forme <= 1
|
||||
else 25
|
||||
if forme <= 2
|
||||
else 20
|
||||
if forme <= 3
|
||||
else 15
|
||||
if forme <= 5
|
||||
else 8
|
||||
if forme <= 8
|
||||
else 0
|
||||
)
|
||||
score += score_forme
|
||||
details['forme_recente'] = forme
|
||||
details['score_forme'] = score_forme
|
||||
details["forme_recente"] = forme
|
||||
details["score_forme"] = score_forme
|
||||
|
||||
# 3. TAUX VICTOIRE (15 pts)
|
||||
nb_courses_total = p.get('nombreCourses', 0)
|
||||
nb_victoires_total = p.get('nombreVictoires', 0)
|
||||
nb_courses_total = p.get("nombreCourses", 0)
|
||||
nb_victoires_total = p.get("nombreVictoires", 0)
|
||||
tx_vic = (nb_victoires_total / nb_courses_total * 100) if nb_courses_total else 0
|
||||
score_vic = min(15, tx_vic * 0.5)
|
||||
score += score_vic
|
||||
details['tx_victoire'] = round(tx_vic, 1)
|
||||
details['score_victoire'] = round(score_vic, 1)
|
||||
details["tx_victoire"] = round(tx_vic, 1)
|
||||
details["score_victoire"] = round(score_vic, 1)
|
||||
|
||||
# 4. TAUX PLACE (15 pts)
|
||||
nb_places_total = p.get('nombrePlaces', 0)
|
||||
nb_places_total = p.get("nombrePlaces", 0)
|
||||
tx_place = (nb_places_total / nb_courses_total * 100) if nb_courses_total else 0
|
||||
score_place = min(15, tx_place * 0.2)
|
||||
score += score_place
|
||||
details['tx_place'] = round(tx_place, 1)
|
||||
details['score_place'] = round(score_place, 1)
|
||||
details["tx_place"] = round(tx_place, 1)
|
||||
details["score_place"] = round(score_place, 1)
|
||||
|
||||
# 5. REDUCTION KM (10 pts)
|
||||
rk = p.get('reductionKilometrique', 0)
|
||||
all_rk = [x.get('reductionKilometrique', 0) for x in all_participants if x.get('reductionKilometrique', 0) > 0]
|
||||
rk = p.get("reductionKilometrique", 0)
|
||||
all_rk = [
|
||||
x.get("reductionKilometrique", 0)
|
||||
for x in all_participants
|
||||
if x.get("reductionKilometrique", 0) > 0
|
||||
]
|
||||
if rk > 0 and all_rk:
|
||||
score_rk = 10 * (1 - (rk - min(all_rk)) / (max(all_rk) - min(all_rk))) if max(all_rk) > min(all_rk) else 5
|
||||
score_rk = (
|
||||
10 * (1 - (rk - min(all_rk)) / (max(all_rk) - min(all_rk)))
|
||||
if max(all_rk) > min(all_rk)
|
||||
else 5
|
||||
)
|
||||
else:
|
||||
score_rk = 0
|
||||
score += score_rk
|
||||
details['rk'] = rk
|
||||
details['score_rk'] = round(score_rk, 1)
|
||||
details["rk"] = rk
|
||||
details["score_rk"] = round(score_rk, 1)
|
||||
|
||||
# 6. TENDANCE (10 pts)
|
||||
tendance = musique_stats.get('tendance', 0)
|
||||
tendance = musique_stats.get("tendance", 0)
|
||||
score_tendance = min(10, max(0, 5 + tendance))
|
||||
score += score_tendance
|
||||
details['tendance'] = tendance
|
||||
details['score_tendance'] = round(score_tendance, 1)
|
||||
details["tendance"] = tendance
|
||||
details["score_tendance"] = round(score_tendance, 1)
|
||||
|
||||
# 7. AVIS ENTRAINEUR (5 pts)
|
||||
avis = p.get('avisEntraineur', 'NEUTRE')
|
||||
score_avis = {'POSITIF': 5, 'TRES_POSITIF': 5, 'NEUTRE': 2, 'NEGATIF': 0, 'TRES_NEGATIF': 0}.get(avis, 2)
|
||||
avis = p.get("avisEntraineur", "NEUTRE")
|
||||
score_avis = {
|
||||
"POSITIF": 5,
|
||||
"TRES_POSITIF": 5,
|
||||
"NEUTRE": 2,
|
||||
"NEGATIF": 0,
|
||||
"TRES_NEGATIF": 0,
|
||||
}.get(avis, 2)
|
||||
score += score_avis
|
||||
details['avis_entraineur'] = avis
|
||||
details['score_avis'] = score_avis
|
||||
details["avis_entraineur"] = avis
|
||||
details["score_avis"] = score_avis
|
||||
|
||||
# 8. BONUS OUTSIDER (5 pts)
|
||||
bonus_outsider = 5 if forme <= 3 and cote >= 10 else 0
|
||||
score += bonus_outsider
|
||||
details['bonus_outsider'] = bonus_outsider
|
||||
details["bonus_outsider"] = bonus_outsider
|
||||
|
||||
# Driver change penalty
|
||||
if p.get('driverChange', False):
|
||||
if p.get("driverChange", False):
|
||||
score -= 3
|
||||
details['driver_change'] = True
|
||||
details["driver_change"] = True
|
||||
|
||||
details['score_total'] = round(score, 1)
|
||||
details['musique'] = p.get('musique', '')
|
||||
details['nb_victoires'] = nb_victoires_total
|
||||
details['nb_places'] = nb_places_total
|
||||
details['nb_courses'] = nb_courses_total
|
||||
# 9. METEO & TERRAIN (HRT-83) — premium feature, weather_data=None → skip
|
||||
penetrometre = p.get("penetrometre_intitule", "") or ""
|
||||
terrain_condition = (
|
||||
get_terrain_condition(penetrometre) if penetrometre else "inconnu"
|
||||
)
|
||||
weather_impact = 0.0
|
||||
if weather_data is not None:
|
||||
weather_impact = compute_weather_impact(weather_data, terrain_condition)
|
||||
score += weather_impact
|
||||
details["terrain_condition"] = terrain_condition
|
||||
details["weather_impact"] = weather_impact
|
||||
|
||||
details["score_total"] = round(score, 1)
|
||||
details["musique"] = p.get("musique", "")
|
||||
details["nb_victoires"] = nb_victoires_total
|
||||
details["nb_places"] = nb_places_total
|
||||
details["nb_courses"] = nb_courses_total
|
||||
|
||||
return round(score, 1), details
|
||||
|
||||
|
||||
def get_ze2sur4_combinaisons(top4):
|
||||
combinaisons = []
|
||||
for i in range(4):
|
||||
for j in range(i+1, 4):
|
||||
for j in range(i + 1, 4):
|
||||
c1 = top4[i]
|
||||
c2 = top4[j]
|
||||
combinaisons.append({
|
||||
'cheval1': c1['nom'],
|
||||
'numero1': c1['numero'],
|
||||
'cheval2': c2['nom'],
|
||||
'numero2': c2['numero'],
|
||||
'mise': 1.0,
|
||||
})
|
||||
combinaisons.append(
|
||||
{
|
||||
"cheval1": c1["nom"],
|
||||
"numero1": c1["numero"],
|
||||
"cheval2": c2["nom"],
|
||||
"numero2": c2["numero"],
|
||||
"mise": 1.0,
|
||||
}
|
||||
)
|
||||
return combinaisons
|
||||
|
||||
|
||||
def build_recommendations_v2(scored_horses):
|
||||
ranked = sorted(scored_horses, key=lambda x: x['score'], reverse=True)
|
||||
ranked = sorted(scored_horses, key=lambda x: x["score"], reverse=True)
|
||||
if len(ranked) < 4:
|
||||
return None
|
||||
|
||||
@@ -170,39 +290,58 @@ def build_recommendations_v2(scored_horses):
|
||||
top4_list = ranked[:4]
|
||||
|
||||
def confiance(s):
|
||||
return "FORTE" if s >= 55 else "BONNE" if s >= 45 else "MOYENNE" if s >= 35 else "FAIBLE"
|
||||
return (
|
||||
"FORTE"
|
||||
if s >= 55
|
||||
else "BONNE"
|
||||
if s >= 45
|
||||
else "MOYENNE"
|
||||
if s >= 35
|
||||
else "FAIBLE"
|
||||
)
|
||||
|
||||
ze2_combinaisons = get_ze2sur4_combinaisons(top4_list)
|
||||
mise_ze2 = len(ze2_combinaisons) * 1.0
|
||||
|
||||
return {
|
||||
'simple_gagnant': {
|
||||
'cheval': top1['nom'], 'numero': top1['numero'], 'cote': top1['details']['cote'],
|
||||
'score': top1['score'], 'confiance': confiance(top1['score']),
|
||||
'mise_suggeree': 2.0, 'gain_potentiel': round(2.0 * top1['details']['cote'], 2)
|
||||
"simple_gagnant": {
|
||||
"cheval": top1["nom"],
|
||||
"numero": top1["numero"],
|
||||
"cote": top1["details"]["cote"],
|
||||
"score": top1["score"],
|
||||
"confiance": confiance(top1["score"]),
|
||||
"mise_suggeree": 2.0,
|
||||
"gain_potentiel": round(2.0 * top1["details"]["cote"], 2),
|
||||
},
|
||||
'ze2_sur_4': {
|
||||
'top4': [{'nom': h['nom'], 'numero': h['numero']} for h in top4_list],
|
||||
'combinaisons': ze2_combinaisons,
|
||||
'mise_totale': mise_ze2,
|
||||
'nb_combinaisons': len(ze2_combinaisons),
|
||||
'confiance': confiance((top1['score'] + top2['score'] + top3['score'] + top4['score']) / 4),
|
||||
'explication': 'Jouer les 6 combinaisons de 2 chevaux parmi les 4 premiers'
|
||||
"ze2_sur_4": {
|
||||
"top4": [{"nom": h["nom"], "numero": h["numero"]} for h in top4_list],
|
||||
"combinaisons": ze2_combinaisons,
|
||||
"mise_totale": mise_ze2,
|
||||
"nb_combinaisons": len(ze2_combinaisons),
|
||||
"confiance": confiance(
|
||||
(top1["score"] + top2["score"] + top3["score"] + top4["score"]) / 4
|
||||
),
|
||||
"explication": "Jouer les 6 combinaisons de 2 chevaux parmi les 4 premiers",
|
||||
},
|
||||
'outsider': _find_outsider(ranked),
|
||||
'budget_total': 2.0 + mise_ze2,
|
||||
"outsider": _find_outsider(ranked),
|
||||
"budget_total": 2.0 + mise_ze2,
|
||||
}
|
||||
|
||||
|
||||
def _find_outsider(ranked):
|
||||
for h in ranked[3:7]:
|
||||
d = h['details']
|
||||
if d['cote'] >= 12 and d['forme_recente'] <= 4 and d['bonus_outsider'] == 5:
|
||||
d = h["details"]
|
||||
if d["cote"] >= 12 and d["forme_recente"] <= 4 and d["bonus_outsider"] == 5:
|
||||
return {
|
||||
'cheval': h['nom'], 'numero': h['numero'], 'cote': d['cote'],
|
||||
'mise_suggeree': 1.0, 'gain_potentiel': round(1.0 * d['cote'], 2)
|
||||
"cheval": h["nom"],
|
||||
"numero": h["numero"],
|
||||
"cote": d["cote"],
|
||||
"mise_suggeree": 1.0,
|
||||
"gain_potentiel": round(1.0 * d["cote"], 2),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def save_to_db(scored_horses, date_course, hippodrome, libelle):
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
@@ -210,44 +349,72 @@ def save_to_db(scored_horses, date_course, hippodrome, libelle):
|
||||
cursor.execute("DELETE FROM scoring WHERE date = ?", (date_course,))
|
||||
|
||||
for i, h in enumerate(scored_horses, 1):
|
||||
d = h['details']
|
||||
cursor.execute("""
|
||||
d = h["details"]
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO scoring (date, race_name, horse_number, horse_name, score,
|
||||
score_cote, score_forme, score_victoire, score_place, score_rk,
|
||||
score_tendance, score_avis, cote, forme_recente, tx_victoire, tx_place,
|
||||
avis_entraineur, musique, rang_scoring, scoring_version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'v2')
|
||||
""", (date_course, libelle, h['numero'], h['nom'], h['score'],
|
||||
d.get('score_cote', 0), d.get('score_forme', 0), d.get('score_victoire', 0),
|
||||
d.get('score_place', 0), d.get('score_rk', 0), d.get('score_tendance', 0),
|
||||
d.get('score_avis', 0), d.get('cote', 0), d.get('forme_recente', 0),
|
||||
d.get('tx_victoire', 0), d.get('tx_place', 0), d.get('avis_entraineur', ''),
|
||||
d.get('musique', ''), i))
|
||||
""",
|
||||
(
|
||||
date_course,
|
||||
libelle,
|
||||
h["numero"],
|
||||
h["nom"],
|
||||
h["score"],
|
||||
d.get("score_cote", 0),
|
||||
d.get("score_forme", 0),
|
||||
d.get("score_victoire", 0),
|
||||
d.get("score_place", 0),
|
||||
d.get("score_rk", 0),
|
||||
d.get("score_tendance", 0),
|
||||
d.get("score_avis", 0),
|
||||
d.get("cote", 0),
|
||||
d.get("forme_recente", 0),
|
||||
d.get("tx_victoire", 0),
|
||||
d.get("tx_place", 0),
|
||||
d.get("avis_entraineur", ""),
|
||||
d.get("musique", ""),
|
||||
i,
|
||||
),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"💾 {len(scored_horses)} scores enregistres en BDD pour {date_course}")
|
||||
|
||||
|
||||
def main():
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
date_pmu = datetime.now().strftime('%d%m%Y')
|
||||
print(f"=== SCORING V2 - ZE2 SUR4 OPTIMISE === {datetime.now().strftime('%d/%m/%Y %H:%M')} ===")
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
date_pmu = datetime.now().strftime("%d%m%Y")
|
||||
print(
|
||||
f"=== SCORING V2 - ZE2 SUR4 OPTIMISE === {datetime.now().strftime('%d/%m/%Y %H:%M')} ==="
|
||||
)
|
||||
|
||||
try:
|
||||
url = f"https://turfinfo.api.pmu.fr/rest/client/1/programme/{date_pmu}/reunions"
|
||||
r = requests.get(url, headers=HEADERS, timeout=15)
|
||||
reunions = r.json().get('programme', {}).get('reunions', [])
|
||||
reunions = r.json().get("programme", {}).get("reunions", [])
|
||||
except Exception as e:
|
||||
print(f"Erreur: {e}")
|
||||
return
|
||||
|
||||
quinte = None
|
||||
for reunion in reunions:
|
||||
for course in reunion.get('courses', []):
|
||||
for course in reunion.get("courses", []):
|
||||
paris_types = [p["typePari"] for p in course.get("paris", [])]
|
||||
if any("QUINTE" in p for p in paris_types) or "PARIS-TURF" in course.get('libelle', ''):
|
||||
quinte = (reunion['numOfficiel'], course['numOrdre'], course.get('libelle', ''),
|
||||
reunion['hippodrome']['libelleCourt'], course.get('heureDepart', 0))
|
||||
if any("QUINTE" in p for p in paris_types) or "PARIS-TURF" in course.get(
|
||||
"libelle", ""
|
||||
):
|
||||
quinte = (
|
||||
reunion["numOfficiel"],
|
||||
course["numOrdre"],
|
||||
course.get("libelle", ""),
|
||||
reunion["hippodrome"]["libelleCourt"],
|
||||
course.get("heureDepart", 0),
|
||||
)
|
||||
break
|
||||
if quinte:
|
||||
break
|
||||
@@ -256,7 +423,8 @@ def main():
|
||||
# Fallback: utiliser la premiere reunion francaise avec predictions
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
r = conn.execute("""
|
||||
r = conn.execute(
|
||||
"""
|
||||
SELECT r.num_reunion, r.hippodrome_court, c.num_course, c.libelle
|
||||
FROM pmu_courses c
|
||||
JOIN pmu_reunions r ON r.date_programme=c.date_programme AND r.num_reunion=c.num_reunion
|
||||
@@ -264,22 +432,36 @@ def main():
|
||||
AND EXISTS (SELECT 1 FROM predictions p WHERE p.date=? AND p.source='canalturf_partants'
|
||||
AND p.race_name LIKE '%' || c.libelle || '%')
|
||||
ORDER BY c.heure_depart_str ASC LIMIT 1
|
||||
""", (today, today)).fetchone()
|
||||
""",
|
||||
(today, today),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if r:
|
||||
quinte = (r['num_reunion'], r['num_course'], r['libelle'], r['hippodrome_court'], 0)
|
||||
quinte = (
|
||||
r["num_reunion"],
|
||||
r["num_course"],
|
||||
r["libelle"],
|
||||
r["hippodrome_court"],
|
||||
0,
|
||||
)
|
||||
else:
|
||||
print("Aucune course trouvee")
|
||||
return
|
||||
|
||||
num_r, num_c, libelle, hippodrome, heure_ts = quinte
|
||||
heure = datetime.fromtimestamp(heure_ts/1000).strftime('%H:%M') if heure_ts else '13:55'
|
||||
heure = (
|
||||
datetime.fromtimestamp(heure_ts / 1000).strftime("%H:%M")
|
||||
if heure_ts
|
||||
else "13:55"
|
||||
)
|
||||
print(f"Course: {libelle} - {hippodrome} {heure}")
|
||||
|
||||
try:
|
||||
url = f"https://turfinfo.api.pmu.fr/rest/client/1/programme/{date_pmu}/R{num_r}/C{num_c}/participants"
|
||||
r = requests.get(url, headers=HEADERS, timeout=15)
|
||||
participants = [p for p in r.json().get('participants', []) if p.get('statut') == 'PARTANT']
|
||||
participants = [
|
||||
p for p in r.json().get("participants", []) if p.get("statut") == "PARTANT"
|
||||
]
|
||||
except Exception as e:
|
||||
print(f"Erreur: {e}")
|
||||
return
|
||||
@@ -287,34 +469,45 @@ def main():
|
||||
scored_horses = []
|
||||
for p in participants:
|
||||
score, details = score_cheval_v2(p, participants, today)
|
||||
scored_horses.append({'nom': p['nom'], 'numero': p['numPmu'], 'score': score, 'details': details})
|
||||
scored_horses.append(
|
||||
{"nom": p["nom"], "numero": p["numPmu"], "score": score, "details": details}
|
||||
)
|
||||
|
||||
ranked = sorted(scored_horses, key=lambda x: x['score'], reverse=True)
|
||||
ranked = sorted(scored_horses, key=lambda x: x["score"], reverse=True)
|
||||
print(f"\n=== TOP 4 ===")
|
||||
for i, h in enumerate(ranked[:4], 1):
|
||||
d = h['details']
|
||||
print(f"{i}. #{h['numero']:>2} {h['nom']:<20} Score:{h['score']:.1f} Cote:{d['cote']:.1f}")
|
||||
d = h["details"]
|
||||
print(
|
||||
f"{i}. #{h['numero']:>2} {h['nom']:<20} Score:{h['score']:.1f} Cote:{d['cote']:.1f}"
|
||||
)
|
||||
|
||||
save_to_db(ranked, today, hippodrome, libelle)
|
||||
|
||||
reco = build_recommendations_v2(scored_horses)
|
||||
if reco:
|
||||
print(f"\n=== RECOMMANDATIONS ===")
|
||||
sg = reco['simple_gagnant']
|
||||
sg = reco["simple_gagnant"]
|
||||
print(f"\n🎯 SIMPLE GAGNANT:")
|
||||
print(f" #{sg['numero']} {sg['cheval']} @ {sg['cote']}/1 (mise {sg['mise_suggeree']}EUR)")
|
||||
print(
|
||||
f" #{sg['numero']} {sg['cheval']} @ {sg['cote']}/1 (mise {sg['mise_suggeree']}EUR)"
|
||||
)
|
||||
|
||||
ze2 = reco['ze2_sur_4']
|
||||
ze2 = reco["ze2_sur_4"]
|
||||
print(f"\n🎰 ZE 2 SUR 4 (TOP 4: {', '.join([h['nom'] for h in ze2['top4']])}")
|
||||
print(f" Mise totale: {ze2['mise_totale']}EUR ({ze2['nb_combinaisons']} combis x 1EUR)")
|
||||
print(
|
||||
f" Mise totale: {ze2['mise_totale']}EUR ({ze2['nb_combinaisons']} combis x 1EUR)"
|
||||
)
|
||||
print(f" Confiance: {ze2['confiance']}")
|
||||
print(f" Combinaisons:")
|
||||
for c in ze2['combinaisons']:
|
||||
print(f" {c['numero1']}-{c['cheval1']} + {c['numero2']}-{c['cheval2']}")
|
||||
for c in ze2["combinaisons"]:
|
||||
print(
|
||||
f" {c['numero1']}-{c['cheval1']} + {c['numero2']}-{c['cheval2']}"
|
||||
)
|
||||
|
||||
print(f"\n💰 BUDGET TOTAL: {reco['budget_total']}EUR")
|
||||
print(f" - Simple Gagnant: 2EUR")
|
||||
print(f" - ZE 2 sur 4: {ze2['mise_totale']}EUR")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
10
services/token-broker/.env.example
Normal file
10
services/token-broker/.env.example
Normal file
@@ -0,0 +1,10 @@
|
||||
# Token Broker API — Configuration
|
||||
TOKEN_BROKER_PORT=8783
|
||||
TOKEN_BROKER_DB_HOST=127.0.0.1
|
||||
TOKEN_BROKER_DB_PORT=5434
|
||||
TOKEN_BROKER_DB_NAME=token_broker
|
||||
TOKEN_BROKER_DB_USER=token_broker
|
||||
TOKEN_BROKER_DB_PASSWORD=CHANGE_ME
|
||||
TOKEN_BROKER_JWT_SECRET=CHANGE_ME_GENERATE_64_HEX
|
||||
TOKEN_BROKER_ACCESS_EXPIRY=900
|
||||
TOKEN_BROKER_REFRESH_EXPIRY=2592000
|
||||
6
services/token-broker/requirements.txt
Normal file
6
services/token-broker/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Flask==3.1.3
|
||||
flask-cors==5.0.1
|
||||
gunicorn==23.0.0
|
||||
psycopg2-binary==2.9.12
|
||||
PyJWT==2.10.1
|
||||
python-dotenv==1.1.0
|
||||
21
services/token-broker/token-broker.service
Normal file
21
services/token-broker/token-broker.service
Normal file
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=Token Broker API (Port 8783)
|
||||
Documentation=https://portal-kolifee.duckdns.org
|
||||
After=network.target postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=h3r7
|
||||
WorkingDirectory=/home/h3r7/turf_saas/services/token-broker
|
||||
|
||||
EnvironmentFile=/home/h3r7/turf_saas/services/token-broker/.env
|
||||
Environment=PYTHONPATH=/home/h3r7/turf_saas
|
||||
Environment=FLASK_ENV=production
|
||||
|
||||
ExecStart=/home/h3r7/turf_saas/venv/bin/python3 /home/h3r7/turf_saas/services/token-broker/token_broker_api.py
|
||||
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
679
services/token-broker/token_broker_api.py
Normal file
679
services/token-broker/token_broker_api.py
Normal file
@@ -0,0 +1,679 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Token Broker API — JWT token management service
|
||||
Port: 8783 | DB: PostgreSQL 5434
|
||||
HRT-198 — Setup infra (PostgreSQL + Flask scaffold)
|
||||
|
||||
Endpoints:
|
||||
GET /health — Healthcheck
|
||||
POST /api/v1/tokens — Issue new token (create)
|
||||
GET /api/v1/tokens/:id — Get token by ID
|
||||
POST /api/v1/tokens/verify — Verify token
|
||||
POST /api/v1/tokens/revoke/:id — Revoke token
|
||||
GET /api/v1/tokens/user/:userId — List tokens for user
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
import hashlib
|
||||
import secrets
|
||||
import logging
|
||||
import logging.handlers
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import wraps
|
||||
|
||||
from flask import Flask, request, jsonify, g
|
||||
from flask_cors import CORS
|
||||
|
||||
LOG_DIR = os.path.join(os.path.dirname(__file__), "logs")
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] token-broker: %(name)s: %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.handlers.RotatingFileHandler(
|
||||
os.path.join(LOG_DIR, "token_broker.log"),
|
||||
maxBytes=5 * 1024 * 1024,
|
||||
backupCount=3,
|
||||
),
|
||||
],
|
||||
)
|
||||
logger = logging.getLogger("token_broker")
|
||||
|
||||
DB_HOST = os.environ.get("TOKEN_BROKER_DB_HOST", "127.0.0.1")
|
||||
DB_PORT = int(os.environ.get("TOKEN_BROKER_DB_PORT", "5434"))
|
||||
DB_NAME = os.environ.get("TOKEN_BROKER_DB_NAME", "token_broker")
|
||||
DB_USER = os.environ.get("TOKEN_BROKER_DB_USER", "token_broker")
|
||||
DB_PASSWORD = os.environ.get("TOKEN_BROKER_DB_PASSWORD", "")
|
||||
JWT_SECRET = os.environ.get(
|
||||
"TOKEN_BROKER_JWT_SECRET", "CHANGE_ME_" + secrets.token_hex(32)
|
||||
)
|
||||
ACCESS_TOKEN_EXPIRY = int(os.environ.get("TOKEN_BROKER_ACCESS_EXPIRY", "900"))
|
||||
REFRESH_TOKEN_EXPIRY = int(os.environ.get("TOKEN_BROKER_REFRESH_EXPIRY", "2592000"))
|
||||
|
||||
|
||||
def get_pg_conn():
|
||||
try:
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
conn = psycopg2.connect(
|
||||
host=DB_HOST,
|
||||
port=DB_PORT,
|
||||
dbname=DB_NAME,
|
||||
user=DB_USER,
|
||||
password=DB_PASSWORD,
|
||||
)
|
||||
conn.autocommit = True
|
||||
return conn
|
||||
except Exception as e:
|
||||
logger.error(f"PostgreSQL connection failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def init_db():
|
||||
conn = get_pg_conn()
|
||||
if not conn:
|
||||
logger.error("Cannot initialize DB — no connection")
|
||||
return False
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT 'default',
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
token_prefix TEXT NOT NULL,
|
||||
scopes TEXT[] DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
metadata JSONB DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id INTEGER NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
token_prefix TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
replaced_by UUID
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS token_audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id INTEGER,
|
||||
action TEXT NOT NULL,
|
||||
token_prefix TEXT,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
details JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id TEXT NOT NULL UNIQUE,
|
||||
client_secret TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
redirect_uris TEXT[] DEFAULT '{}',
|
||||
scopes TEXT[] DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS providers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
provider_type TEXT NOT NULL DEFAULT 'oauth2',
|
||||
issuer_url TEXT,
|
||||
client_id TEXT,
|
||||
client_secret TEXT,
|
||||
scopes TEXT[] DEFAULT '{}',
|
||||
config JSONB DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS token_usage (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
token_id UUID,
|
||||
action TEXT NOT NULL DEFAULT 'verify',
|
||||
endpoint TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'success',
|
||||
response_time_ms INTEGER,
|
||||
ip_address TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON api_tokens(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_audit_log_user_id ON token_audit_log(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_audit_log_created_at ON token_audit_log(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_clients_client_id ON clients(client_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_providers_name ON providers(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_usage_user_id ON token_usage(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_usage_created_at ON token_usage(created_at);
|
||||
""")
|
||||
cur.close()
|
||||
conn.close()
|
||||
logger.info("Database tables initialized successfully")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Database initialization failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
|
||||
app.config["JWT_SECRET"] = JWT_SECRET
|
||||
app.config["ACCESS_TOKEN_EXPIRY"] = ACCESS_TOKEN_EXPIRY
|
||||
app.config["REFRESH_TOKEN_EXPIRY"] = REFRESH_TOKEN_EXPIRY
|
||||
|
||||
CORS(app)
|
||||
register_routes(app)
|
||||
register_error_handlers(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def token_required(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
return jsonify({"error": "missing_token", "message": "Bearer token required"}), 401
|
||||
token = auth_header.split(" ", 1)[1]
|
||||
payload = verify_jwt_token(token)
|
||||
if not payload:
|
||||
return jsonify({"error": "invalid_token", "message": "Token invalid or expired"}), 401
|
||||
g.user_id = payload.get("user_id")
|
||||
g.token_id = payload.get("token_id")
|
||||
g.scopes = payload.get("scopes", [])
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
def generate_token_pair(user_id, scopes=None, metadata=None):
|
||||
import jwt as pyjwt
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
access_payload = {
|
||||
"user_id": user_id,
|
||||
"token_id": str(uuid.uuid4()),
|
||||
"scopes": scopes or [],
|
||||
"type": "access",
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=ACCESS_TOKEN_EXPIRY),
|
||||
}
|
||||
access_token = pyjwt.encode(access_payload, JWT_SECRET, algorithm="HS256")
|
||||
|
||||
refresh_id = str(uuid.uuid4())
|
||||
refresh_raw = secrets.token_urlsafe(48)
|
||||
refresh_payload = {
|
||||
"user_id": user_id,
|
||||
"refresh_id": refresh_id,
|
||||
"token_hash": hashlib.sha256(refresh_raw.encode()).hexdigest(),
|
||||
"type": "refresh",
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=REFRESH_TOKEN_EXPIRY),
|
||||
}
|
||||
refresh_token = pyjwt.encode(refresh_payload, JWT_SECRET, algorithm="HS256")
|
||||
|
||||
store_refresh_token(user_id, refresh_id, refresh_payload["token_hash"])
|
||||
log_audit(user_id, "token_issued", access_payload["token_id"][:8])
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_raw,
|
||||
"expires_in": ACCESS_TOKEN_EXPIRY,
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
|
||||
|
||||
def verify_jwt_token(token):
|
||||
import jwt as pyjwt
|
||||
try:
|
||||
payload = pyjwt.decode(token, JWT_SECRET, algorithms=["HS256"])
|
||||
if payload.get("type") == "refresh":
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
conn = get_pg_conn()
|
||||
if conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT revoked FROM refresh_tokens WHERE token_hash = %s AND expires_at > NOW()",
|
||||
(token_hash,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
if not row or row[0]:
|
||||
return None
|
||||
return payload
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def store_refresh_token(user_id, refresh_id, token_hash):
|
||||
conn = get_pg_conn()
|
||||
if not conn:
|
||||
return
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""INSERT INTO refresh_tokens (id, user_id, token_hash, token_prefix, expires_at)
|
||||
VALUES (%s, %s, %s, %s, NOW() + INTERVAL '30 days')""",
|
||||
(refresh_id, user_id, token_hash, token_hash[:8]),
|
||||
)
|
||||
cur.close()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store refresh token: {e}")
|
||||
|
||||
|
||||
def log_audit(user_id, action, token_prefix, details=None):
|
||||
conn = get_pg_conn()
|
||||
if not conn:
|
||||
return
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""INSERT INTO token_audit_log (user_id, action, token_prefix, ip_address, user_agent, details)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)""",
|
||||
(
|
||||
user_id,
|
||||
action,
|
||||
token_prefix,
|
||||
request.remote_addr if request else None,
|
||||
request.user_agent.string if request and request.user_agent else None,
|
||||
"{}" if details is None else details,
|
||||
),
|
||||
)
|
||||
cur.close()
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
@app.route("/health", methods=["GET"])
|
||||
def healthcheck():
|
||||
conn = get_pg_conn()
|
||||
db_ok = conn is not None
|
||||
if conn:
|
||||
conn.close()
|
||||
return jsonify({
|
||||
"status": "ok" if db_ok else "degraded",
|
||||
"service": "token-broker",
|
||||
"version": "1.0.0",
|
||||
"database": "connected" if db_ok else "disconnected",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}), 200 if db_ok else 503
|
||||
|
||||
@app.route("/api/v1/tokens", methods=["POST"])
|
||||
@token_required
|
||||
def issue_token():
|
||||
data = request.get_json(silent=True) or {}
|
||||
user_id = g.user_id
|
||||
scopes = data.get("scopes", [])
|
||||
name = data.get("name", "default")
|
||||
metadata = data.get("metadata", {})
|
||||
|
||||
conn = get_pg_conn()
|
||||
if not conn:
|
||||
return jsonify({"error": "db_error", "message": "Database unavailable"}), 503
|
||||
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
import psycopg2.extras
|
||||
raw_token = "tb_" + secrets.token_urlsafe(32)
|
||||
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
|
||||
token_prefix = raw_token[:12] + "..."
|
||||
|
||||
cur.execute(
|
||||
"""INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes, metadata)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING id, created_at, expires_at""",
|
||||
(user_id, name, token_hash, token_prefix, scopes,
|
||||
psycopg2.extras.Json(metadata)),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
log_audit(user_id, "api_token_created", token_prefix)
|
||||
return jsonify({
|
||||
"id": str(row[0]),
|
||||
"token": raw_token,
|
||||
"name": name,
|
||||
"scopes": scopes,
|
||||
"created_at": row[1].isoformat(),
|
||||
"expires_at": row[2].isoformat() if row[2] else None,
|
||||
}), 201
|
||||
except Exception as e:
|
||||
logger.error(f"Token creation failed: {e}")
|
||||
return jsonify({"error": "creation_failed", "message": str(e)}), 500
|
||||
|
||||
@app.route("/api/v1/tokens/verify", methods=["POST"])
|
||||
def verify_token():
|
||||
data = request.get_json(silent=True) or {}
|
||||
raw_token = data.get("token", "")
|
||||
|
||||
if not raw_token:
|
||||
return jsonify({"valid": False, "error": "token_required"}), 400
|
||||
|
||||
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
|
||||
conn = get_pg_conn()
|
||||
if not conn:
|
||||
return jsonify({"valid": False, "error": "db_error"}), 503
|
||||
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""SELECT id, user_id, name, scopes, is_active, created_at, expires_at, last_used_at
|
||||
FROM api_tokens
|
||||
WHERE token_hash = %s""",
|
||||
(token_hash,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row:
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({"valid": False, "error": "token_not_found"}), 404
|
||||
|
||||
token_id, user_id, name, scopes, is_active, created_at, expires_at, last_used_at = row
|
||||
|
||||
if not is_active:
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({"valid": False, "error": "token_revoked"}), 403
|
||||
|
||||
if expires_at and expires_at < datetime.now(timezone.utc):
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({"valid": False, "error": "token_expired"}), 403
|
||||
|
||||
cur.execute(
|
||||
"UPDATE api_tokens SET last_used_at = NOW() WHERE id = %s",
|
||||
(token_id,),
|
||||
)
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
"valid": True,
|
||||
"token_id": str(token_id),
|
||||
"user_id": user_id,
|
||||
"name": name,
|
||||
"scopes": scopes,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Token verification failed: {e}")
|
||||
return jsonify({"valid": False, "error": "verification_failed"}), 500
|
||||
|
||||
@app.route("/api/v1/tokens/<token_id>", methods=["GET"])
|
||||
@token_required
|
||||
def get_token(token_id):
|
||||
conn = get_pg_conn()
|
||||
if not conn:
|
||||
return jsonify({"error": "db_error"}), 503
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""SELECT id, user_id, name, scopes, is_active, created_at, expires_at, last_used_at, metadata
|
||||
FROM api_tokens WHERE id = %s AND user_id = %s""",
|
||||
(token_id, g.user_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
|
||||
return jsonify({
|
||||
"id": str(row[0]),
|
||||
"user_id": row[1],
|
||||
"name": row[2],
|
||||
"scopes": row[3],
|
||||
"is_active": row[4],
|
||||
"created_at": row[5].isoformat(),
|
||||
"expires_at": row[6].isoformat() if row[6] else None,
|
||||
"last_used_at": row[7].isoformat() if row[7] else None,
|
||||
"metadata": row[8] if row[8] else {},
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Get token failed: {e}")
|
||||
return jsonify({"error": "query_failed"}), 500
|
||||
|
||||
@app.route("/api/v1/tokens/revoke/<token_id>", methods=["POST"])
|
||||
@token_required
|
||||
def revoke_token(token_id):
|
||||
conn = get_pg_conn()
|
||||
if not conn:
|
||||
return jsonify({"error": "db_error"}), 503
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""UPDATE api_tokens SET is_active = FALSE WHERE id = %s AND user_id = %s
|
||||
RETURNING id, name""",
|
||||
(token_id, g.user_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
|
||||
log_audit(g.user_id, "api_token_revoked", str(row[0])[:8])
|
||||
return jsonify({"status": "revoked", "token_id": str(row[0])})
|
||||
except Exception as e:
|
||||
logger.error(f"Revoke token failed: {e}")
|
||||
return jsonify({"error": "revoke_failed"}), 500
|
||||
|
||||
@app.route("/api/v1/tokens/user/<int:user_id>", methods=["GET"])
|
||||
@token_required
|
||||
def list_user_tokens(user_id):
|
||||
if g.user_id != user_id and "admin" not in g.scopes:
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
conn = get_pg_conn()
|
||||
if not conn:
|
||||
return jsonify({"error": "db_error"}), 503
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""SELECT id, user_id, name, scopes, is_active, created_at, expires_at, last_used_at
|
||||
FROM api_tokens
|
||||
WHERE user_id = %s
|
||||
ORDER BY created_at DESC""",
|
||||
(user_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
tokens = []
|
||||
for row in rows:
|
||||
tokens.append({
|
||||
"id": str(row[0]),
|
||||
"user_id": row[1],
|
||||
"name": row[2],
|
||||
"scopes": row[3],
|
||||
"is_active": row[4],
|
||||
"created_at": row[5].isoformat(),
|
||||
"expires_at": row[6].isoformat() if row[6] else None,
|
||||
"last_used_at": row[7].isoformat() if row[7] else None,
|
||||
})
|
||||
return jsonify({"tokens": tokens, "total": len(tokens)})
|
||||
except Exception as e:
|
||||
logger.error(f"List tokens failed: {e}")
|
||||
return jsonify({"error": "query_failed"}), 500
|
||||
|
||||
@app.route("/api/v1/auth/token", methods=["POST"])
|
||||
def exchange_token():
|
||||
data = request.get_json(silent=True) or {}
|
||||
grant_type = data.get("grant_type", "client_credentials")
|
||||
raw_token = data.get("client_token", "") or data.get("token", "")
|
||||
refresh_raw = data.get("refresh_token", "")
|
||||
|
||||
if grant_type == "refresh_token" and refresh_raw:
|
||||
return refresh_access_token(refresh_raw)
|
||||
|
||||
if not raw_token:
|
||||
return jsonify({"error": "invalid_request", "message": "client_token required"}), 400
|
||||
|
||||
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
|
||||
conn = get_pg_conn()
|
||||
if not conn:
|
||||
return jsonify({"error": "db_error"}), 503
|
||||
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""SELECT id, user_id, scopes, is_active, expires_at
|
||||
FROM api_tokens WHERE token_hash = %s""",
|
||||
(token_hash,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
return jsonify({"error": "invalid_token"}), 401
|
||||
if not row[3]:
|
||||
return jsonify({"error": "token_revoked"}), 403
|
||||
if row[4] and row[4] < datetime.now(timezone.utc):
|
||||
return jsonify({"error": "token_expired"}), 403
|
||||
|
||||
token_pair = generate_token_pair(row[1], row[2])
|
||||
return jsonify(token_pair), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Token exchange failed: {e}")
|
||||
return jsonify({"error": "exchange_failed"}), 500
|
||||
|
||||
@app.route("/api/v1/auth/refresh", methods=["POST"])
|
||||
def refresh_token_endpoint():
|
||||
data = request.get_json(silent=True) or {}
|
||||
refresh_raw = data.get("refresh_token", "")
|
||||
return refresh_access_token(refresh_raw)
|
||||
|
||||
@app.route("/api/v1/auth/revoke", methods=["POST"])
|
||||
@token_required
|
||||
def revoke_refresh_token():
|
||||
data = request.get_json(silent=True) or {}
|
||||
refresh_raw = data.get("refresh_token", "")
|
||||
|
||||
if not refresh_raw:
|
||||
return jsonify({"error": "refresh_token_required"}), 400
|
||||
|
||||
token_hash = hashlib.sha256(refresh_raw.encode()).hexdigest()
|
||||
conn = get_pg_conn()
|
||||
if not conn:
|
||||
return jsonify({"error": "db_error"}), 503
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"UPDATE refresh_tokens SET revoked = TRUE, revoked_at = NOW() WHERE token_hash = %s",
|
||||
(token_hash,),
|
||||
)
|
||||
cur.close()
|
||||
conn.close()
|
||||
log_audit(g.user_id, "refresh_token_revoked", token_hash[:8])
|
||||
return jsonify({"status": "revoked"})
|
||||
except Exception as e:
|
||||
logger.error(f"Revoke refresh token failed: {e}")
|
||||
return jsonify({"error": "revoke_failed"}), 500
|
||||
|
||||
|
||||
def refresh_access_token(refresh_raw):
|
||||
if not refresh_raw:
|
||||
return jsonify({"error": "refresh_token_required"}), 400
|
||||
|
||||
token_hash = hashlib.sha256(refresh_raw.encode()).hexdigest()
|
||||
conn = get_pg_conn()
|
||||
if not conn:
|
||||
return jsonify({"error": "db_error"}), 503
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""SELECT id, user_id, revoked, expires_at
|
||||
FROM refresh_tokens WHERE token_hash = %s""",
|
||||
(token_hash,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row:
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({"error": "invalid_token"}), 401
|
||||
if row[2]:
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({"error": "token_revoked"}), 403
|
||||
if row[3] < datetime.now(timezone.utc):
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({"error": "token_expired"}), 403
|
||||
|
||||
refresh_id = row[0]
|
||||
user_id = row[1]
|
||||
|
||||
cur.execute(
|
||||
"UPDATE refresh_tokens SET revoked = TRUE, revoked_at = NOW() WHERE id = %s",
|
||||
(refresh_id,),
|
||||
)
|
||||
|
||||
pairs = generate_token_pair(user_id)
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify(pairs), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Refresh token failed: {e}")
|
||||
return jsonify({"error": "refresh_failed"}), 500
|
||||
|
||||
|
||||
def register_error_handlers(app):
|
||||
@app.errorhandler(404)
|
||||
def not_found(e):
|
||||
return jsonify({"error": "not_found", "message": "Route not found"}), 404
|
||||
|
||||
@app.errorhandler(405)
|
||||
def method_not_allowed(e):
|
||||
return jsonify({"error": "method_not_allowed", "message": "Method not allowed"}), 405
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(e):
|
||||
logger.error(f"Internal error: {e}")
|
||||
return jsonify({"error": "internal_error", "message": "Internal server error"}), 500
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("=" * 60)
|
||||
logger.info("Token Broker API starting...")
|
||||
logger.info(f"DB: {DB_HOST}:{DB_PORT}/{DB_NAME}")
|
||||
logger.info(f"Port: {os.environ.get('TOKEN_BROKER_PORT', '8783')}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
init_db()
|
||||
|
||||
port = int(os.environ.get("TOKEN_BROKER_PORT", "8783"))
|
||||
debug = os.environ.get("FLASK_ENV", "production") == "development"
|
||||
app = create_app()
|
||||
app.run(host="0.0.0.0", port=port, debug=debug)
|
||||
284
telegram_alerts.py
Normal file
284
telegram_alerts.py
Normal file
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Telegram Alerts — Service d'alertes pré-course pour les utilisateurs Premium/Pro
|
||||
HRT-79: Alertes Telegram configurables (Premium)
|
||||
|
||||
Fonctionnement :
|
||||
- 30 minutes avant chaque course détectée, envoie un message Telegram
|
||||
aux utilisateurs Premium/Pro ayant configuré leur chat_id.
|
||||
- Les préférences individuelles (value_bets, top1, quinte_only) sont respectées.
|
||||
- Requiert la variable d'environnement TELEGRAM_BOT_TOKEN.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||
|
||||
TELEGRAM_API_BASE = "https://api.telegram.org/bot{token}/sendMessage"
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def send_telegram_message(chat_id: str, text: str) -> bool:
|
||||
"""
|
||||
Envoie un message Telegram à un chat_id donné.
|
||||
|
||||
Returns True si succès, False sinon.
|
||||
Ne lève pas d'exception pour ne pas crasher le scheduler.
|
||||
"""
|
||||
if not BOT_TOKEN:
|
||||
logger.warning("[TELEGRAM] TELEGRAM_BOT_TOKEN non configuré — envoi ignoré")
|
||||
return False
|
||||
|
||||
url = TELEGRAM_API_BASE.format(token=BOT_TOKEN)
|
||||
payload = {
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
"parse_mode": "Markdown",
|
||||
"disable_web_page_preview": True,
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, json=payload, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
return True
|
||||
logger.warning(
|
||||
"[TELEGRAM] Echec envoi chat_id=%s status=%d body=%s",
|
||||
chat_id,
|
||||
resp.status_code,
|
||||
resp.text[:200],
|
||||
)
|
||||
return False
|
||||
except requests.RequestException as exc:
|
||||
logger.error("[TELEGRAM] Exception HTTP chat_id=%s: %s", chat_id, exc)
|
||||
return False
|
||||
|
||||
|
||||
# ── Alert builder ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_race_alert(race_data: dict, predictions: list) -> str:
|
||||
"""
|
||||
Construit le message Markdown de l'alerte pré-course.
|
||||
|
||||
Args:
|
||||
race_data: dict avec les clés 'hippo', 'num_course', 'heure', 'type_course'
|
||||
predictions: liste de dicts {'num_cheval', 'nom_cheval', 'prob_top3', 'is_value_bet', 'ml_score'}
|
||||
|
||||
Returns: texte Markdown formaté
|
||||
"""
|
||||
hippo = race_data.get("hippo", "?")
|
||||
num_course = race_data.get("num_course", "?")
|
||||
heure = race_data.get("heure", "?")
|
||||
type_course = race_data.get("type_course", "")
|
||||
|
||||
lines = [
|
||||
f"🏇 *Alerte course — {hippo} R{num_course}*",
|
||||
f"⏰ Départ prévu : *{heure}*",
|
||||
]
|
||||
if type_course:
|
||||
lines.append(f"📋 Type : {type_course}")
|
||||
lines.append("")
|
||||
|
||||
top3 = [p for p in predictions if p.get("prob_top3", 0) > 0][:3]
|
||||
value_bets = [p for p in predictions if p.get("is_value_bet")]
|
||||
|
||||
if top3:
|
||||
lines.append("📊 *Top-3 ML :*")
|
||||
for i, p in enumerate(top3, 1):
|
||||
nom = p.get("nom_cheval", f"#{p.get('num_cheval', '?')}")
|
||||
prob = p.get("prob_top3", 0)
|
||||
lines.append(f" {i}. {nom} — {prob:.0%} prob top-3")
|
||||
lines.append("")
|
||||
|
||||
if value_bets:
|
||||
lines.append("💡 *Value bets :*")
|
||||
for p in value_bets[:3]:
|
||||
nom = p.get("nom_cheval", f"#{p.get('num_cheval', '?')}")
|
||||
score = p.get("ml_score", 0)
|
||||
lines.append(f" ✅ {nom} (score {score:.2f})")
|
||||
lines.append("")
|
||||
|
||||
lines.append("_Alerte automatique Turf SaaS — 30min avant départ_")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── Main send function ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def send_pre_race_alerts(minutes_before: int = 30) -> dict:
|
||||
"""
|
||||
Interroge la DB pour récupérer les courses du jour, puis envoie
|
||||
des alertes Telegram aux utilisateurs Premium/Pro éligibles.
|
||||
|
||||
Args:
|
||||
minutes_before: non utilisé directement (la planification est gérée
|
||||
par le scheduler), présent pour documentation.
|
||||
|
||||
Returns: dict {'sent': int, 'skipped': int, 'errors': int}
|
||||
"""
|
||||
if not BOT_TOKEN:
|
||||
logger.warning(
|
||||
"[TELEGRAM] TELEGRAM_BOT_TOKEN absent — send_pre_race_alerts ignoré"
|
||||
)
|
||||
return {"sent": 0, "skipped": 0, "errors": 0}
|
||||
|
||||
stats = {"sent": 0, "skipped": 0, "errors": 0}
|
||||
|
||||
try:
|
||||
conn = _get_db()
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
# Récupère les courses du jour
|
||||
try:
|
||||
courses_rows = conn.execute(
|
||||
"""
|
||||
SELECT DISTINCT
|
||||
hippo, num_course, heure_depart, type_course
|
||||
FROM pmu_courses
|
||||
WHERE date_programme = ?
|
||||
AND heure_depart IS NOT NULL
|
||||
ORDER BY heure_depart ASC
|
||||
LIMIT 20
|
||||
""",
|
||||
(today,),
|
||||
).fetchall()
|
||||
except sqlite3.OperationalError as exc:
|
||||
logger.warning("[TELEGRAM] Table pmu_courses introuvable: %s", exc)
|
||||
conn.close()
|
||||
return stats
|
||||
|
||||
if not courses_rows:
|
||||
logger.info("[TELEGRAM] Aucune course aujourd'hui — pas d'alerte")
|
||||
conn.close()
|
||||
return stats
|
||||
|
||||
# Récupère les utilisateurs Premium/Pro avec chat_id configuré
|
||||
try:
|
||||
users = conn.execute(
|
||||
"""
|
||||
SELECT id, telegram_chat_id,
|
||||
alert_value_bets, alert_top1, alert_quinte_only
|
||||
FROM users
|
||||
WHERE plan IN ('premium', 'pro')
|
||||
AND is_active = 1
|
||||
AND telegram_chat_id IS NOT NULL
|
||||
AND telegram_chat_id != ''
|
||||
""",
|
||||
).fetchall()
|
||||
except sqlite3.OperationalError as exc:
|
||||
logger.warning(
|
||||
"[TELEGRAM] Colonnes Telegram absentes (migration non appliquée?): %s",
|
||||
exc,
|
||||
)
|
||||
conn.close()
|
||||
return stats
|
||||
|
||||
if not users:
|
||||
logger.info("[TELEGRAM] Aucun utilisateur avec chat_id configuré")
|
||||
conn.close()
|
||||
return stats
|
||||
|
||||
for course_row in courses_rows:
|
||||
hippo = course_row["hippo"] or "?"
|
||||
num_course = course_row["num_course"] or "?"
|
||||
heure_ts = course_row["heure_depart"]
|
||||
type_course = course_row["type_course"] or ""
|
||||
|
||||
try:
|
||||
dt = datetime.fromtimestamp(heure_ts / 1000)
|
||||
heure_str = dt.strftime("%H:%M")
|
||||
except Exception:
|
||||
heure_str = str(heure_ts)
|
||||
|
||||
race_data = {
|
||||
"hippo": hippo,
|
||||
"num_course": num_course,
|
||||
"heure": heure_str,
|
||||
"type_course": type_course,
|
||||
}
|
||||
|
||||
# Récupère les prédictions ML pour cette course
|
||||
predictions = []
|
||||
try:
|
||||
pred_rows = conn.execute(
|
||||
"""
|
||||
SELECT num_cheval, nom_cheval, prob_top3, is_value_bet, ml_score
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ?
|
||||
AND hippo = ?
|
||||
AND num_course = ?
|
||||
ORDER BY prob_top3 DESC
|
||||
LIMIT 10
|
||||
""",
|
||||
(today, hippo, num_course),
|
||||
).fetchall()
|
||||
predictions = [dict(r) for r in pred_rows]
|
||||
except sqlite3.OperationalError:
|
||||
pass # table absente, on envoie quand même avec données minimales
|
||||
|
||||
is_quinte = (
|
||||
"quinté" in type_course.lower() or "quinte" in type_course.lower()
|
||||
)
|
||||
|
||||
for user in users:
|
||||
chat_id = user["telegram_chat_id"]
|
||||
alert_quinte_only = bool(user["alert_quinte_only"])
|
||||
alert_top1 = bool(user["alert_top1"])
|
||||
alert_value_bets = bool(user["alert_value_bets"])
|
||||
|
||||
# Filtre quinte_only
|
||||
if alert_quinte_only and not is_quinte:
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
|
||||
# Construit le message selon préférences
|
||||
filtered_preds = []
|
||||
if predictions:
|
||||
for p in predictions:
|
||||
include = False
|
||||
if alert_top1 and p.get("prob_top3", 0) > 0:
|
||||
include = True
|
||||
if alert_value_bets and p.get("is_value_bet"):
|
||||
include = True
|
||||
if include:
|
||||
filtered_preds.append(p)
|
||||
|
||||
text = build_race_alert(race_data, filtered_preds)
|
||||
ok = send_telegram_message(chat_id, text)
|
||||
if ok:
|
||||
stats["sent"] += 1
|
||||
else:
|
||||
stats["errors"] += 1
|
||||
|
||||
conn.close()
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("[TELEGRAM] Erreur inattendue dans send_pre_race_alerts: %s", exc)
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
stats["errors"] += 1
|
||||
|
||||
logger.info(
|
||||
"[TELEGRAM] Alertes pré-course: %d envoyées, %d ignorées, %d erreurs",
|
||||
stats["sent"],
|
||||
stats["skipped"],
|
||||
stats["errors"],
|
||||
)
|
||||
return stats
|
||||
@@ -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
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user