Compare commits
2 Commits
feature/la
...
feature/de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
793ee82c29 | ||
|
|
dce1e9b744 |
68
.dockerignore
Normal file
68
.dockerignore
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Files/dirs excluded from Docker build context
|
||||||
|
# Keep image small; sensitive files never baked in
|
||||||
|
|
||||||
|
# Python artifacts
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.eggs/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Databases (use volumes)
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# ML models (use volumes)
|
||||||
|
*.pkl
|
||||||
|
*.joblib
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Backups & temp files
|
||||||
|
*.backup*
|
||||||
|
*.bak*
|
||||||
|
*.tmp
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
# Secrets & env files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Exports
|
||||||
|
exports/
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editor files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
.pytest_cache/
|
||||||
|
htmlcov/
|
||||||
|
.coverage
|
||||||
|
coverage.xml
|
||||||
|
|
||||||
|
# AWS
|
||||||
|
awscliv2.zip
|
||||||
82
.env.example
Normal file
82
.env.example
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# =============================================================
|
||||||
|
# H3R7Tech Turf SaaS — Environment Variables Template
|
||||||
|
# Copy this file to .env and fill in your values.
|
||||||
|
# NEVER commit .env to version control.
|
||||||
|
# =============================================================
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# PostgreSQL
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
POSTGRES_HOST=postgres
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
POSTGRES_DB=turf_saas
|
||||||
|
POSTGRES_USER=turf
|
||||||
|
POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD
|
||||||
|
|
||||||
|
# Full DSN used by SQLAlchemy / Alembic
|
||||||
|
DATABASE_URL=postgresql://turf:CHANGE_ME_STRONG_PASSWORD@postgres:5432/turf_saas
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# Redis
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
REDIS_HOST=redis
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=CHANGE_ME_REDIS_PASSWORD
|
||||||
|
REDIS_URL=redis://:CHANGE_ME_REDIS_PASSWORD@redis:6379/0
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# Flask / App
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
FLASK_ENV=production
|
||||||
|
SECRET_KEY=CHANGE_ME_FLASK_SECRET_KEY_64CHARS
|
||||||
|
DEBUG=false
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# DB path for legacy SQLite (kept for migration, set to /app/data/db/)
|
||||||
|
DB_PATH=/app/data/db/turf_saas.db
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# Domain & TLS
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
DOMAIN=turf.h3r7.tech
|
||||||
|
ADMIN_EMAIL=admin@h3r7.tech
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# Stripe (Billing)
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
STRIPE_SECRET_KEY=sk_live_CHANGE_ME
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_CHANGE_ME
|
||||||
|
STRIPE_PUBLISHABLE_KEY=pk_live_CHANGE_ME
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# LLM / AI API keys
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
OPENROUTER_API_KEY=CHANGE_ME
|
||||||
|
OPENAI_API_KEY=CHANGE_ME
|
||||||
|
LLM_BASE_URL=https://openrouter.ai/v1
|
||||||
|
LLM_MODEL=liquid/lfm-2.5-1.2b-instruct:free
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# External APIs
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
RESEND_API=CHANGE_ME
|
||||||
|
BRAVE_SEARCH_API=CHANGE_ME
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# Monitoring
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
GRAFANA_ADMIN_USER=admin
|
||||||
|
GRAFANA_ADMIN_PASSWORD=CHANGE_ME_GRAFANA_PASSWORD
|
||||||
|
|
||||||
|
# Slack webhook for CI/CD notifications (optional)
|
||||||
|
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/CHANGE_ME
|
||||||
|
|
||||||
|
# Telegram bot for notifications (optional)
|
||||||
|
TELEGRAM_BOT_TOKEN=CHANGE_ME
|
||||||
|
TELEGRAM_CHAT_ID=CHANGE_ME
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# Docker registry (for CD pipeline)
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
REGISTRY=ghcr.io
|
||||||
|
IMAGE_NAME=h3r7tech/turf-saas
|
||||||
205
.github/workflows/cd.yml
vendored
Normal file
205
.github/workflows/cd.yml
vendored
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
# ============================================================
|
||||||
|
# CD Pipeline — deploy to staging then production
|
||||||
|
# Triggers on push to main/master
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
name: CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
environment:
|
||||||
|
description: "Target environment"
|
||||||
|
required: true
|
||||||
|
default: staging
|
||||||
|
type: choice
|
||||||
|
options: [staging, production]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: cd-${{ github.ref }}
|
||||||
|
cancel-in-progress: false # Never cancel an active deploy
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# Job 1: Deploy to Staging
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
deploy-staging:
|
||||||
|
name: Deploy → Staging
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: staging
|
||||||
|
url: https://staging.turf.h3r7.tech
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy to staging server via SSH
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.STAGING_HOST }}
|
||||||
|
username: ${{ secrets.STAGING_USER }}
|
||||||
|
key: ${{ secrets.STAGING_SSH_KEY }}
|
||||||
|
port: ${{ secrets.STAGING_PORT || 22 }}
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
echo "=== Deploying to STAGING ==="
|
||||||
|
cd /opt/turf-saas
|
||||||
|
|
||||||
|
# Pull latest code
|
||||||
|
git fetch origin
|
||||||
|
git checkout ${{ github.sha }}
|
||||||
|
|
||||||
|
# Pull latest Docker images
|
||||||
|
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||||
|
docker compose pull
|
||||||
|
|
||||||
|
# Run DB migrations
|
||||||
|
docker compose run --rm combined-api alembic upgrade head
|
||||||
|
|
||||||
|
# Rolling restart — zero downtime
|
||||||
|
docker compose up -d --no-deps --scale combined-api=2 combined-api
|
||||||
|
sleep 15
|
||||||
|
docker compose up -d --no-deps --scale combined-api=1 combined-api
|
||||||
|
|
||||||
|
# Restart other services
|
||||||
|
docker compose up -d --no-deps dashboard-api portal scheduler
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
sleep 20
|
||||||
|
curl -f https://staging.turf.h3r7.tech/health || exit 1
|
||||||
|
|
||||||
|
echo "=== Staging deploy OK ==="
|
||||||
|
|
||||||
|
- name: Notify Staging Deploy
|
||||||
|
run: |
|
||||||
|
MSG="✅ Staging deployed: \`${{ github.repository }}\` commit=\`${{ github.sha }}\`"
|
||||||
|
curl -s -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \
|
||||||
|
-H 'Content-type: application/json' \
|
||||||
|
--data "{\"text\":\"${MSG}\"}" || true
|
||||||
|
curl -s -X POST \
|
||||||
|
"https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
|
||||||
|
-d chat_id="${{ secrets.TELEGRAM_CHAT_ID }}" \
|
||||||
|
-d text="${MSG}" || true
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# Job 2: Smoke Tests on Staging
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
smoke-test-staging:
|
||||||
|
name: Smoke Tests on Staging
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: deploy-staging
|
||||||
|
steps:
|
||||||
|
- name: Health endpoints check
|
||||||
|
run: |
|
||||||
|
BASE="https://staging.turf.h3r7.tech"
|
||||||
|
echo "Checking ${BASE}/health ..."
|
||||||
|
curl -f "${BASE}/health" -o /dev/null -s -w "%{http_code}\n"
|
||||||
|
echo "Checking ${BASE}/api/predictions ..."
|
||||||
|
curl -f "${BASE}/api/predictions" -o /dev/null -s -w "%{http_code}\n" || true
|
||||||
|
echo "Smoke tests passed"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# Job 3: Deploy to Production (manual approval gate)
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
deploy-production:
|
||||||
|
name: Deploy → Production
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: smoke-test-staging
|
||||||
|
environment:
|
||||||
|
name: production
|
||||||
|
url: https://turf.h3r7.tech
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy to production server via SSH
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.PROD_HOST }}
|
||||||
|
username: ${{ secrets.PROD_USER }}
|
||||||
|
key: ${{ secrets.PROD_SSH_KEY }}
|
||||||
|
port: ${{ secrets.PROD_PORT || 22 }}
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
echo "=== Deploying to PRODUCTION ==="
|
||||||
|
cd /opt/turf-saas
|
||||||
|
|
||||||
|
# Backup current state
|
||||||
|
docker compose exec -T postgres pg_dumpall -U turf > /opt/backups/turf_saas_pre_deploy_$(date +%Y%m%d_%H%M%S).sql
|
||||||
|
|
||||||
|
# Pull latest code
|
||||||
|
git fetch origin
|
||||||
|
git checkout ${{ github.sha }}
|
||||||
|
|
||||||
|
# Pull latest Docker images
|
||||||
|
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||||
|
docker compose pull
|
||||||
|
|
||||||
|
# Run DB migrations
|
||||||
|
docker compose run --rm combined-api alembic upgrade head
|
||||||
|
|
||||||
|
# Rolling restart
|
||||||
|
docker compose up -d --no-deps --scale combined-api=2 combined-api
|
||||||
|
sleep 20
|
||||||
|
docker compose up -d --no-deps --scale combined-api=1 combined-api
|
||||||
|
docker compose up -d --no-deps dashboard-api portal scheduler
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
sleep 30
|
||||||
|
curl -f https://turf.h3r7.tech/health || exit 1
|
||||||
|
|
||||||
|
# Clean old images
|
||||||
|
docker image prune -f
|
||||||
|
|
||||||
|
echo "=== Production deploy OK ==="
|
||||||
|
|
||||||
|
- name: Notify Production Deploy
|
||||||
|
run: |
|
||||||
|
MSG="🚀 Production deployed: \`${{ github.repository }}\` commit=\`${{ github.sha }}\`"
|
||||||
|
curl -s -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \
|
||||||
|
-H 'Content-type: application/json' \
|
||||||
|
--data "{\"text\":\"${MSG}\"}" || true
|
||||||
|
curl -s -X POST \
|
||||||
|
"https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
|
||||||
|
-d chat_id="${{ secrets.TELEGRAM_CHAT_ID }}" \
|
||||||
|
-d text="${MSG}" || true
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# Rollback job (triggered manually on failure)
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
rollback:
|
||||||
|
name: Rollback Production
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: failure() && needs.deploy-production.result == 'failure'
|
||||||
|
needs: deploy-production
|
||||||
|
environment: production
|
||||||
|
steps:
|
||||||
|
- name: Rollback via SSH
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.PROD_HOST }}
|
||||||
|
username: ${{ secrets.PROD_USER }}
|
||||||
|
key: ${{ secrets.PROD_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
cd /opt/turf-saas
|
||||||
|
git checkout HEAD~1
|
||||||
|
docker compose up -d --force-recreate
|
||||||
|
echo "Rollback complete"
|
||||||
|
|
||||||
|
- name: Notify Rollback
|
||||||
|
run: |
|
||||||
|
curl -s -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \
|
||||||
|
-H 'Content-type: application/json' \
|
||||||
|
--data '{"text":"⚠️ Production ROLLED BACK due to deploy failure!"}' || true
|
||||||
236
.github/workflows/ci.yml
vendored
Normal file
236
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
# ============================================================
|
||||||
|
# CI Pipeline — lint + tests + Docker build
|
||||||
|
# Runs on every push and pull request
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, master, develop]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
PYTHON_VERSION: "3.12"
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# Job 1: Lint & Static Analysis
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
lint:
|
||||||
|
name: Lint & Security Scan
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
|
cache: pip
|
||||||
|
|
||||||
|
- name: Install lint tools
|
||||||
|
run: pip install flake8 bandit safety
|
||||||
|
|
||||||
|
- name: Flake8 linting
|
||||||
|
run: |
|
||||||
|
flake8 . \
|
||||||
|
--exclude=venv,migrations,__pycache__,.git \
|
||||||
|
--max-line-length=120 \
|
||||||
|
--ignore=E501,W503,E302,E303 \
|
||||||
|
--count --statistics
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Bandit security scan
|
||||||
|
run: |
|
||||||
|
bandit -r . \
|
||||||
|
--exclude ./venv,./migrations,./infra \
|
||||||
|
-ll -ii \
|
||||||
|
-f json -o bandit-report.json || true
|
||||||
|
cat bandit-report.json
|
||||||
|
|
||||||
|
- name: Safety dependency vulnerability check
|
||||||
|
run: |
|
||||||
|
safety check -r requirements.txt --json || true
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# Job 2: Tests
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
test:
|
||||||
|
name: Unit & Integration Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: lint
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: turf_test
|
||||||
|
POSTGRES_USER: turf
|
||||||
|
POSTGRES_PASSWORD: testpassword
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://turf:testpassword@localhost:5432/turf_test
|
||||||
|
POSTGRES_HOST: localhost
|
||||||
|
POSTGRES_PORT: 5432
|
||||||
|
POSTGRES_DB: turf_test
|
||||||
|
POSTGRES_USER: turf
|
||||||
|
POSTGRES_PASSWORD: testpassword
|
||||||
|
FLASK_ENV: testing
|
||||||
|
SECRET_KEY: test-secret-key-not-for-production
|
||||||
|
DB_PATH: /tmp/turf_test.db
|
||||||
|
LOG_LEVEL: WARNING
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
|
cache: pip
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pip install -r requirements.txt pytest pytest-cov pytest-flask
|
||||||
|
|
||||||
|
- name: Run Alembic migrations
|
||||||
|
run: |
|
||||||
|
if [ -f alembic.ini ]; then
|
||||||
|
alembic upgrade head
|
||||||
|
else
|
||||||
|
echo "No alembic.ini found, skipping migrations"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: |
|
||||||
|
if [ -d tests ]; then
|
||||||
|
pytest tests/ -v --cov=. --cov-report=xml --cov-report=term-missing
|
||||||
|
else
|
||||||
|
echo "No tests directory found — creating basic smoke test"
|
||||||
|
python -c "
|
||||||
|
import sys, os
|
||||||
|
os.environ['FLASK_ENV'] = 'testing'
|
||||||
|
os.environ['SECRET_KEY'] = 'test'
|
||||||
|
os.environ['DB_PATH'] = '/tmp/smoke_test.db'
|
||||||
|
print('Import check...')
|
||||||
|
try:
|
||||||
|
import combined_api
|
||||||
|
print('combined_api: OK')
|
||||||
|
except Exception as e:
|
||||||
|
print(f'combined_api: ERROR - {e}')
|
||||||
|
try:
|
||||||
|
import dashboard_api
|
||||||
|
print('dashboard_api: OK')
|
||||||
|
except Exception as e:
|
||||||
|
print(f'dashboard_api: ERROR - {e}')
|
||||||
|
try:
|
||||||
|
import portal_server
|
||||||
|
print('portal_server: OK')
|
||||||
|
except Exception as e:
|
||||||
|
print(f'portal_server: ERROR - {e}')
|
||||||
|
print('All checks done.')
|
||||||
|
"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload coverage report
|
||||||
|
uses: codecov/codecov-action@v4
|
||||||
|
if: hashFiles('coverage.xml') != ''
|
||||||
|
with:
|
||||||
|
file: ./coverage.xml
|
||||||
|
fail_ci_if_error: false
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# Job 3: Docker Build
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
docker-build:
|
||||||
|
name: Docker Build & Push
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=sha,prefix=sha-
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- name: Build (and push on non-PR)
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
target: runner
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Verify image size
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
run: |
|
||||||
|
SIZE=$(docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest --format='{{.Size}}' 2>/dev/null || echo "0")
|
||||||
|
SIZE_MB=$((SIZE / 1024 / 1024))
|
||||||
|
echo "Image size: ${SIZE_MB}MB"
|
||||||
|
if [ "$SIZE_MB" -gt 500 ]; then
|
||||||
|
echo "::warning::Image size ${SIZE_MB}MB exceeds 500MB limit"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# Job 4: Notify on failure
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
notify-failure:
|
||||||
|
name: Notify on Failure
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint, test, docker-build]
|
||||||
|
if: failure() && github.event_name == 'push'
|
||||||
|
steps:
|
||||||
|
- name: Notify Telegram
|
||||||
|
if: vars.TELEGRAM_BOT_TOKEN != ''
|
||||||
|
run: |
|
||||||
|
curl -s -X POST \
|
||||||
|
"https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
|
||||||
|
-d chat_id="${{ secrets.TELEGRAM_CHAT_ID }}" \
|
||||||
|
-d text="❌ CI FAILED: ${{ github.repository }} branch=${{ github.ref_name }} commit=${{ github.sha }}" \
|
||||||
|
-d parse_mode="Markdown" || true
|
||||||
|
|
||||||
|
- name: Notify Slack
|
||||||
|
if: vars.SLACK_WEBHOOK_URL != ''
|
||||||
|
run: |
|
||||||
|
curl -s -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \
|
||||||
|
-H 'Content-type: application/json' \
|
||||||
|
--data "{\"text\":\"❌ CI FAILED: \`${{ github.repository }}\` branch=\`${{ github.ref_name }}\` commit=\`${{ github.sha }}\`\"}" || true
|
||||||
28
.gitignore
vendored
28
.gitignore
vendored
@@ -78,3 +78,31 @@ patch_*.py
|
|||||||
# Données scraping brutes
|
# Données scraping brutes
|
||||||
v3_*.json
|
v3_*.json
|
||||||
v4_*.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/
|
||||||
|
|||||||
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"]
|
||||||
383
account.html
Normal file
383
account.html
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Mon Compte — Turf IA</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
:root {
|
||||||
|
--green: #00c853; --green-d: #009624; --blue: #1e88e5;
|
||||||
|
--dark: #0d1117; --dark2: #161b22; --dark3: #21262d;
|
||||||
|
--text: #e6edf3; --muted: #8b949e; --border: #30363d;
|
||||||
|
--radius: 10px; --error: #f85149; --gold: #ffd600;
|
||||||
|
}
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); min-height: 100vh; display: flex; flex-direction: column; }
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
nav { display: flex; align-items: center; justify-content: space-between; padding: 14px 5%; border-bottom: 1px solid var(--border); }
|
||||||
|
.nav-logo { font-weight: 700; font-size: 1.1rem; }
|
||||||
|
.nav-back { color: var(--muted); font-size: .9rem; }
|
||||||
|
.nav-back:hover { color: var(--text); }
|
||||||
|
|
||||||
|
main { flex: 1; padding: 40px 5%; max-width: 900px; margin: 0 auto; width: 100%; }
|
||||||
|
h1 { font-size: 1.6rem; font-weight: 800; margin-bottom: 28px; }
|
||||||
|
|
||||||
|
/* TABS */
|
||||||
|
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 28px; }
|
||||||
|
.tab-btn {
|
||||||
|
padding: 10px 18px; border: none; background: transparent; color: var(--muted);
|
||||||
|
font-size: .9rem; font-weight: 600; cursor: pointer; border-bottom: 2px solid transparent;
|
||||||
|
transition: all .2s; margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
.tab-btn:hover { color: var(--text); }
|
||||||
|
.tab-btn.active { color: var(--green); border-bottom-color: var(--green); }
|
||||||
|
.tab-panel { display: none; }
|
||||||
|
.tab-panel.active { display: block; }
|
||||||
|
|
||||||
|
/* CARDS */
|
||||||
|
.card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px; margin-bottom: 20px; }
|
||||||
|
.card-title { font-size: 1rem; font-weight: 700; margin-bottom: 18px; padding-bottom: 12px; border-bottom: 1px solid var(--border); }
|
||||||
|
.form-group { margin-bottom: 16px; }
|
||||||
|
label { display: block; font-size: .83rem; font-weight: 600; color: var(--muted); margin-bottom: 6px; }
|
||||||
|
input {
|
||||||
|
width: 100%; padding: 10px 14px; background: var(--dark3);
|
||||||
|
border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
color: var(--text); font-size: .9rem; outline: none; transition: border-color .2s;
|
||||||
|
}
|
||||||
|
input:focus { border-color: var(--green); }
|
||||||
|
input:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||||
|
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 9px 20px; border-radius: 8px; font-size: .88rem; font-weight: 600; cursor: pointer; border: none; transition: all .2s; }
|
||||||
|
.btn-primary { background: var(--green); color: #000; }
|
||||||
|
.btn-primary:hover { background: var(--green-d); }
|
||||||
|
.btn-primary:disabled { opacity: .6; cursor: not-allowed; }
|
||||||
|
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||||
|
.btn-ghost:hover { border-color: var(--muted); }
|
||||||
|
.btn-danger { background: transparent; border: 1px solid var(--error); color: var(--error); }
|
||||||
|
.btn-danger:hover { background: rgba(248,81,73,.1); }
|
||||||
|
.btn-upgrade { background: linear-gradient(135deg, var(--gold), #ff6d00); color: #000; }
|
||||||
|
|
||||||
|
/* PLAN CARDS */
|
||||||
|
.plan-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin-bottom: 24px; }
|
||||||
|
.plan-card { background: var(--dark3); border: 2px solid var(--border); border-radius: var(--radius); padding: 20px; position: relative; transition: border-color .2s; }
|
||||||
|
.plan-card.current { border-color: var(--green); }
|
||||||
|
.plan-current-badge { position: absolute; top: -10px; left: 16px; background: var(--green); color: #000; padding: 2px 10px; border-radius: 10px; font-size: .7rem; font-weight: 700; }
|
||||||
|
.plan-name { font-weight: 800; font-size: 1rem; margin-bottom: 6px; }
|
||||||
|
.plan-price { font-size: 1.5rem; font-weight: 800; color: var(--green); }
|
||||||
|
.plan-price span { font-size: .85rem; font-weight: 400; color: var(--muted); }
|
||||||
|
.plan-features-mini { list-style: none; margin-top: 12px; }
|
||||||
|
.plan-features-mini li { font-size: .8rem; color: var(--muted); padding: 3px 0; }
|
||||||
|
.plan-features-mini li::before { content: "✓ "; color: var(--green); }
|
||||||
|
.plan-card .btn { width: 100%; justify-content: center; margin-top: 14px; font-size: .82rem; padding: 8px; }
|
||||||
|
|
||||||
|
/* ALERT */
|
||||||
|
.alert { padding: 12px 16px; border-radius: 8px; font-size: .88rem; margin-bottom: 14px; display: none; }
|
||||||
|
.alert.show { display: block; }
|
||||||
|
.alert-success { background: rgba(0,200,83,.1); border: 1px solid rgba(0,200,83,.3); color: var(--green); }
|
||||||
|
.alert-error { background: rgba(248,81,73,.1); border: 1px solid rgba(248,81,73,.3); color: var(--error); }
|
||||||
|
|
||||||
|
/* DANGER ZONE */
|
||||||
|
.danger-card { border-color: rgba(248,81,73,.3); }
|
||||||
|
|
||||||
|
/* Toast */
|
||||||
|
#toast { position: fixed; bottom: 24px; right: 24px; z-index: 999; padding: 12px 20px; border-radius: 10px; font-size: .88rem; font-weight: 600; transform: translateY(60px); opacity: 0; transition: all .3s; pointer-events: none; }
|
||||||
|
#toast.show { transform: translateY(0); opacity: 1; }
|
||||||
|
#toast.success { background: var(--green); color: #000; }
|
||||||
|
#toast.error { background: var(--error); color: #fff; }
|
||||||
|
|
||||||
|
@media (max-width: 600px) { .form-row { grid-template-columns: 1fr; } main { padding: 20px 4%; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<a href="/" class="nav-logo">🏇 Turf IA</a>
|
||||||
|
<a href="/dashboard" class="nav-back">← Retour au dashboard</a>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
<h1>⚙️ Mon compte</h1>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab-btn active" data-tab="profile">Profil</button>
|
||||||
|
<button class="tab-btn" data-tab="security">Sécurité</button>
|
||||||
|
<button class="tab-btn" data-tab="upgrade">Mon plan</button>
|
||||||
|
<button class="tab-btn" data-tab="notifications">Notifications</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PROFIL -->
|
||||||
|
<div class="tab-panel active" id="tab-profile">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Informations personnelles</div>
|
||||||
|
<div class="alert alert-success" id="profile-success">Profil mis à jour avec succès.</div>
|
||||||
|
<div class="alert alert-error" id="profile-error"></div>
|
||||||
|
<form id="profile-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Prénom</label><input type="text" id="p-firstname" placeholder="Jean"></div>
|
||||||
|
<div class="form-group"><label>Nom</label><input type="text" id="p-lastname" placeholder="Dupont"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>Adresse email</label><input type="email" id="p-email" placeholder="vous@exemple.fr"></div>
|
||||||
|
<button type="submit" class="btn btn-primary" id="profile-btn">Enregistrer les modifications</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Informations de compte</div>
|
||||||
|
<div class="form-group"><label>Plan actuel</label><input type="text" id="p-plan-display" disabled></div>
|
||||||
|
<div class="form-group"><label>Membre depuis</label><input type="text" id="p-created" disabled></div>
|
||||||
|
<div class="form-group"><label>Identifiant utilisateur</label><input type="text" id="p-id" disabled></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SECURITE -->
|
||||||
|
<div class="tab-panel" id="tab-security">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Changer le mot de passe</div>
|
||||||
|
<div class="alert alert-success" id="pwd-success">Mot de passe mis à jour.</div>
|
||||||
|
<div class="alert alert-error" id="pwd-error"></div>
|
||||||
|
<form id="password-form">
|
||||||
|
<div class="form-group"><label>Mot de passe actuel</label><input type="password" id="s-current-pwd" placeholder="••••••••" autocomplete="current-password" required></div>
|
||||||
|
<div class="form-group"><label>Nouveau mot de passe</label><input type="password" id="s-new-pwd" placeholder="8 caractères minimum" autocomplete="new-password" required></div>
|
||||||
|
<div class="form-group"><label>Confirmer le nouveau mot de passe</label><input type="password" id="s-confirm-pwd" placeholder="••••••••" autocomplete="new-password" required></div>
|
||||||
|
<button type="submit" class="btn btn-primary" id="pwd-btn">Mettre à jour le mot de passe</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="card danger-card">
|
||||||
|
<div class="card-title">Zone dangereuse</div>
|
||||||
|
<p style="font-size:.9rem;color:var(--muted);margin-bottom:16px">La suppression de votre compte est irréversible. Toutes vos données seront perdues.</p>
|
||||||
|
<button class="btn btn-danger" id="delete-account-btn">Supprimer mon compte</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- UPGRADE / PLAN -->
|
||||||
|
<div class="tab-panel" id="tab-upgrade">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Votre plan actuel</div>
|
||||||
|
<div class="plan-grid">
|
||||||
|
<div class="plan-card" id="plan-card-free">
|
||||||
|
<div class="plan-name">Free</div>
|
||||||
|
<div class="plan-price">0€ <span>/mois</span></div>
|
||||||
|
<ul class="plan-features-mini">
|
||||||
|
<li>1 course complète/jour</li>
|
||||||
|
<li>Aperçu Top-3</li>
|
||||||
|
</ul>
|
||||||
|
<button class="btn btn-ghost" id="select-free-btn">Plan actuel</button>
|
||||||
|
</div>
|
||||||
|
<div class="plan-card" id="plan-card-premium">
|
||||||
|
<div class="plan-name">Premium ⭐</div>
|
||||||
|
<div class="plan-price">9,90€ <span>/mois</span></div>
|
||||||
|
<ul class="plan-features-mini">
|
||||||
|
<li>Toutes les courses</li>
|
||||||
|
<li>Alertes Telegram</li>
|
||||||
|
<li>Value bets</li>
|
||||||
|
<li>Historique 90j</li>
|
||||||
|
</ul>
|
||||||
|
<button class="btn btn-upgrade" id="select-premium-btn">Choisir Premium</button>
|
||||||
|
</div>
|
||||||
|
<div class="plan-card" id="plan-card-pro">
|
||||||
|
<div class="plan-name">Pro 🚀</div>
|
||||||
|
<div class="plan-price">24,90€ <span>/mois</span></div>
|
||||||
|
<ul class="plan-features-mini">
|
||||||
|
<li>Tout Premium</li>
|
||||||
|
<li>Export CSV</li>
|
||||||
|
<li>API REST</li>
|
||||||
|
<li>Support prioritaire</li>
|
||||||
|
</ul>
|
||||||
|
<button class="btn btn-ghost" id="select-pro-btn">Choisir Pro</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:.82rem;color:var(--muted)">💳 Paiement sécurisé via Stripe (disponible dans le Sprint 5-6). Pour l'instant, contactez-nous pour activer un plan payant.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NOTIFICATIONS -->
|
||||||
|
<div class="tab-panel" id="tab-notifications">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Alertes Telegram</div>
|
||||||
|
<div class="alert alert-success" id="notif-success">Préférences enregistrées.</div>
|
||||||
|
<form id="notif-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Chat ID Telegram</label>
|
||||||
|
<input type="text" id="n-telegram-id" placeholder="Votre Chat ID (ex: 123456789)">
|
||||||
|
<p style="font-size:.78rem;color:var(--muted);margin-top:5px">Envoyez /start à @TurfIABot pour obtenir votre Chat ID.</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<p style="font-size:.88rem;font-weight:600;margin-bottom:10px;color:var(--muted)">Recevoir des alertes pour :</p>
|
||||||
|
<label style="display:flex;gap:10px;align-items:center;margin-bottom:10px;cursor:pointer;font-size:.9rem">
|
||||||
|
<input type="checkbox" id="n-alert-vb" style="width:auto;accent-color:var(--green)"> Value bets identifiés
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;gap:10px;align-items:center;margin-bottom:10px;cursor:pointer;font-size:.9rem">
|
||||||
|
<input type="checkbox" id="n-alert-top1" style="width:auto;accent-color:var(--green)"> Favori IA Top-1
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;gap:10px;align-items:center;cursor:pointer;font-size:.9rem">
|
||||||
|
<input type="checkbox" id="n-alert-quinte" style="width:auto;accent-color:var(--green)"> Quinté+ uniquement
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Enregistrer les préférences</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = '/api/v1';
|
||||||
|
function getToken() { return localStorage.getItem('turf_token'); }
|
||||||
|
|
||||||
|
async function fetchJson(url, opts = {}) {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(url, {
|
||||||
|
...opts,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) }
|
||||||
|
});
|
||||||
|
if (res.status === 401) { location.href = '/login'; return null; }
|
||||||
|
return res.json().catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(msg, type = 'success') {
|
||||||
|
const t = document.getElementById('toast');
|
||||||
|
t.textContent = msg; t.className = `show ${type}`;
|
||||||
|
setTimeout(() => t.className = '', 3500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAlert(id, show = true) {
|
||||||
|
document.getElementById(id)?.classList.toggle('show', show);
|
||||||
|
}
|
||||||
|
function setAlertMsg(id, msg, show = true) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) { el.textContent = msg; el.classList.toggle('show', show); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// TABS
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
document.getElementById(`tab-${btn.dataset.tab}`)?.classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// URL param: ?tab=upgrade
|
||||||
|
(function() {
|
||||||
|
const tab = new URLSearchParams(location.search).get('tab');
|
||||||
|
if (tab) {
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(p => p.classList.toggle('active', p.id === `tab-${tab}`));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Load user
|
||||||
|
async function loadUser() {
|
||||||
|
if (!getToken()) { location.href = '/login'; return; }
|
||||||
|
const data = await fetchJson(`${API}/auth/me`);
|
||||||
|
if (!data) return;
|
||||||
|
const user = data.user || data;
|
||||||
|
document.getElementById('p-firstname').value = user.firstname || '';
|
||||||
|
document.getElementById('p-lastname').value = user.lastname || '';
|
||||||
|
document.getElementById('p-email').value = user.email || '';
|
||||||
|
document.getElementById('p-plan-display').value = { free: 'Free', premium: 'Premium', pro: 'Pro' }[user.plan] || 'Free';
|
||||||
|
document.getElementById('p-created').value = user.created_at ? new Date(user.created_at).toLocaleDateString('fr-FR') : '—';
|
||||||
|
document.getElementById('p-id').value = user.id || '—';
|
||||||
|
document.getElementById('n-telegram-id').value = user.telegram_chat_id || '';
|
||||||
|
document.getElementById('n-alert-vb').checked = user.alert_value_bets !== false;
|
||||||
|
document.getElementById('n-alert-top1').checked = user.alert_top1 !== false;
|
||||||
|
document.getElementById('n-alert-quinte').checked = !!user.alert_quinte_only;
|
||||||
|
setPlanCards(user.plan || 'free');
|
||||||
|
localStorage.setItem('turf_user', JSON.stringify(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPlanCards(plan) {
|
||||||
|
['free','premium','pro'].forEach(p => {
|
||||||
|
const card = document.getElementById(`plan-card-${p}`);
|
||||||
|
if (!card) return;
|
||||||
|
card.classList.toggle('current', p === plan);
|
||||||
|
const existing = card.querySelector('.plan-current-badge');
|
||||||
|
if (p === plan && !existing) {
|
||||||
|
const badge = document.createElement('div');
|
||||||
|
badge.className = 'plan-current-badge'; badge.textContent = '✓ Actuel';
|
||||||
|
card.prepend(badge);
|
||||||
|
} else if (p !== plan && existing) existing.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile form
|
||||||
|
document.getElementById('profile-form').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
showAlert('profile-success', false); showAlert('profile-error', false);
|
||||||
|
const btn = document.getElementById('profile-btn');
|
||||||
|
btn.disabled = true; btn.textContent = 'Enregistrement…';
|
||||||
|
const res = await fetchJson(`${API}/auth/update-profile`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
firstname: document.getElementById('p-firstname').value.trim(),
|
||||||
|
lastname: document.getElementById('p-lastname').value.trim(),
|
||||||
|
email: document.getElementById('p-email').value.trim()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
btn.disabled = false; btn.textContent = 'Enregistrer les modifications';
|
||||||
|
if (res && res.ok !== false) { showAlert('profile-success'); showToast('Profil mis à jour.'); }
|
||||||
|
else setAlertMsg('profile-error', res?.error || 'Erreur lors de la mise à jour.');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Password form
|
||||||
|
document.getElementById('password-form').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
showAlert('pwd-success', false); showAlert('pwd-error', false);
|
||||||
|
const np = document.getElementById('s-new-pwd').value;
|
||||||
|
const cp = document.getElementById('s-confirm-pwd').value;
|
||||||
|
if (np !== cp) { setAlertMsg('pwd-error', 'Les mots de passe ne correspondent pas.'); return; }
|
||||||
|
if (np.length < 8) { setAlertMsg('pwd-error', 'Minimum 8 caractères.'); return; }
|
||||||
|
const btn = document.getElementById('pwd-btn');
|
||||||
|
btn.disabled = true; btn.textContent = 'Mise à jour…';
|
||||||
|
const res = await fetchJson(`${API}/auth/change-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ current_password: document.getElementById('s-current-pwd').value, new_password: np })
|
||||||
|
});
|
||||||
|
btn.disabled = false; btn.textContent = 'Mettre à jour le mot de passe';
|
||||||
|
if (res && res.ok !== false) { showAlert('pwd-success'); showToast('Mot de passe mis à jour.'); document.getElementById('password-form').reset(); }
|
||||||
|
else setAlertMsg('pwd-error', res?.error || 'Mot de passe actuel incorrect.');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notifications form
|
||||||
|
document.getElementById('notif-form').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
showAlert('notif-success', false);
|
||||||
|
const res = await fetchJson(`${API}/auth/update-preferences`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
telegram_chat_id: document.getElementById('n-telegram-id').value.trim(),
|
||||||
|
alert_value_bets: document.getElementById('n-alert-vb').checked,
|
||||||
|
alert_top1: document.getElementById('n-alert-top1').checked,
|
||||||
|
alert_quinte_only: document.getElementById('n-alert-quinte').checked
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (res && res.ok !== false) { showAlert('notif-success'); showToast('Préférences enregistrées.'); }
|
||||||
|
else showToast('Erreur lors de la sauvegarde.', 'error');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Plan selection (placeholder until Stripe Sprint 5-6)
|
||||||
|
['free','premium','pro'].forEach(p => {
|
||||||
|
document.getElementById(`select-${p}-btn`)?.addEventListener('click', () => {
|
||||||
|
if (p === 'free') {
|
||||||
|
showToast('Vous êtes déjà sur le plan Free.', 'info');
|
||||||
|
} else {
|
||||||
|
showToast('Paiement Stripe disponible dans le Sprint 5-6. Contactez-nous pour activer ce plan.', 'info');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete account
|
||||||
|
document.getElementById('delete-account-btn').addEventListener('click', () => {
|
||||||
|
if (confirm('⚠️ Êtes-vous sûr de vouloir supprimer votre compte ? Cette action est irréversible.')) {
|
||||||
|
if (confirm('Confirmez la suppression définitive de votre compte Turf IA.')) {
|
||||||
|
fetchJson(`${API}/auth/delete-account`, { method: 'DELETE' }).then(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
location.href = '/';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loadUser();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
48
alembic.ini
Normal file
48
alembic.ini
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Alembic configuration for Turf SaaS
|
||||||
|
# https://alembic.sqlalchemy.org/en/latest/
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# Path to migration scripts
|
||||||
|
script_location = migrations
|
||||||
|
|
||||||
|
# Template used to generate new migration files
|
||||||
|
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# Connection string — uses DATABASE_URL env var
|
||||||
|
sqlalchemy.url = postgresql://%(POSTGRES_USER)s:%(POSTGRES_PASSWORD)s@%(POSTGRES_HOST)s:%(POSTGRES_PORT)s/%(POSTGRES_DB)s
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -131,6 +131,24 @@ def get_db():
|
|||||||
return conn
|
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("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
return send_file("/home/h3r7/turf_saas/dashboard.html")
|
return send_file("/home/h3r7/turf_saas/dashboard.html")
|
||||||
@@ -3519,7 +3537,6 @@ def brave_search():
|
|||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/turf/api/predictions_analysis", methods=["GET"])
|
@app.route("/turf/api/predictions_analysis", methods=["GET"])
|
||||||
def api_predictions_analysis():
|
def api_predictions_analysis():
|
||||||
"""Analyse des predictions vs resultats reels"""
|
"""Analyse des predictions vs resultats reels"""
|
||||||
@@ -3533,13 +3550,25 @@ def api_predictions_analysis():
|
|||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
stats = {
|
stats = {
|
||||||
"canalturf": {"total": 0, "top1_pct": 0, "top3_pct": 0, "top5_pct": 0, "ze2_pct": 0},
|
"canalturf": {
|
||||||
"scoring": {"total": 0, "top1_pct": 0, "top3_pct": 0, "top5_pct": 0, "ze2_pct": 0},
|
"total": 0,
|
||||||
|
"top1_pct": 0,
|
||||||
|
"top3_pct": 0,
|
||||||
|
"top5_pct": 0,
|
||||||
|
"ze2_pct": 0,
|
||||||
|
},
|
||||||
|
"scoring": {
|
||||||
|
"total": 0,
|
||||||
|
"top1_pct": 0,
|
||||||
|
"top3_pct": 0,
|
||||||
|
"top5_pct": 0,
|
||||||
|
"ze2_pct": 0,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for source in ["canalturf", "scoring"]:
|
for source in ["canalturf", "scoring"]:
|
||||||
pred_table = "predictions" if source == "canalturf" else "scoring"
|
pred_table = "predictions" if source == "canalturf" else "scoring"
|
||||||
pred_col = "predicted_1" if source == "canalturf" else "horse_number"
|
pred_col = "predicted_1" if source == "canalturf" else "horse_number"
|
||||||
try:
|
try:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
f"""
|
f"""
|
||||||
@@ -3566,16 +3595,16 @@ def api_predictions_analysis():
|
|||||||
top1_hit = top3_hit = 0
|
top1_hit = top3_hit = 0
|
||||||
total = len(races)
|
total = len(races)
|
||||||
for race, data in races.items():
|
for race, data in races.items():
|
||||||
actual = set(data["actual"][:3])
|
actual = set(data["actual"][:3])
|
||||||
pred_top1 = data["predicted"][0] if data["predicted"] else None
|
pred_top1 = data["predicted"][0] if data["predicted"] else None
|
||||||
actual_top1 = data["actual"][0] if data["actual"] else None
|
actual_top1 = data["actual"][0] if data["actual"] else None
|
||||||
if pred_top1 and actual_top1 and pred_top1 == actual_top1:
|
if pred_top1 and actual_top1 and pred_top1 == actual_top1:
|
||||||
top1_hit += 1
|
top1_hit += 1
|
||||||
if len(set(data["predicted"][:3]) & actual) >= 1:
|
if len(set(data["predicted"][:3]) & actual) >= 1:
|
||||||
top3_hit += 1
|
top3_hit += 1
|
||||||
|
|
||||||
if total > 0:
|
if total > 0:
|
||||||
stats[source]["total"] = total
|
stats[source]["total"] = total
|
||||||
stats[source]["top1_pct"] = round(top1_hit / total * 100, 1)
|
stats[source]["top1_pct"] = round(top1_hit / total * 100, 1)
|
||||||
stats[source]["top3_pct"] = round(top3_hit / total * 100, 1)
|
stats[source]["top3_pct"] = round(top3_hit / total * 100, 1)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
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
|
# Migration : ajouter colonnes risque si table existante sans elles
|
||||||
try:
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try:
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -101,7 +105,7 @@ def get_ml_from_cache(conn, date):
|
|||||||
ensure_ml_cache_table(conn)
|
ensure_ml_cache_table(conn)
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"""SELECT * FROM ml_predictions_cache WHERE date = ? ORDER BY ml_score DESC""",
|
"""SELECT * FROM ml_predictions_cache WHERE date = ? ORDER BY ml_score DESC""",
|
||||||
(date,)
|
(date,),
|
||||||
)
|
)
|
||||||
rows = cursor.fetchall()
|
rows = cursor.fetchall()
|
||||||
if not rows:
|
if not rows:
|
||||||
@@ -112,34 +116,36 @@ def get_ml_from_cache(conn, date):
|
|||||||
for row in rows:
|
for row in rows:
|
||||||
r = dict(row)
|
r = dict(row)
|
||||||
pred = {
|
pred = {
|
||||||
"horse_name": r["horse_name"],
|
"horse_name": r["horse_name"],
|
||||||
"horse_number": r["horse_number"],
|
"horse_number": r["horse_number"],
|
||||||
"odds": r["odds"],
|
"odds": r["odds"],
|
||||||
"prob_top1": r["prob_top1"],
|
"prob_top1": r["prob_top1"],
|
||||||
"prob_top3": r["prob_top3"],
|
"prob_top3": r["prob_top3"],
|
||||||
"ml_score": r["ml_score"],
|
"ml_score": r["ml_score"],
|
||||||
"recommendation": r["recommendation"],
|
"recommendation": r["recommendation"],
|
||||||
"is_value_bet": r["is_value_bet"],
|
"is_value_bet": r["is_value_bet"],
|
||||||
"is_outlier": r["is_outlier"],
|
"is_outlier": r["is_outlier"],
|
||||||
"num_reunion": r["num_reunion"],
|
"num_reunion": r["num_reunion"],
|
||||||
"num_course": r["num_course"],
|
"num_course": r["num_course"],
|
||||||
"race_label": r["race_label"],
|
"race_label": r["race_label"],
|
||||||
"race_name": r["race_name"],
|
"race_name": r["race_name"],
|
||||||
"hippodrome": r["hippodrome"],
|
"hippodrome": r["hippodrome"],
|
||||||
"discipline": r["discipline"],
|
"discipline": r["discipline"],
|
||||||
"distance": r["distance"],
|
"distance": r["distance"],
|
||||||
"heure": r["heure"],
|
"heure": r["heure"],
|
||||||
"risque_label": r["risque_label"] if "risque_label" in r.keys() else "neutral",
|
"risque_label": r["risque_label"]
|
||||||
"risque_score": r["risque_score"] if "risque_score" in r.keys() else 50,
|
if "risque_label" in r.keys()
|
||||||
|
else "neutral",
|
||||||
|
"risque_score": r["risque_score"] if "risque_score" in r.keys() else 50,
|
||||||
}
|
}
|
||||||
predictions.append(pred)
|
predictions.append(pred)
|
||||||
key = f"{r['num_reunion']}_{r['num_course']}"
|
key = f"{r['num_reunion']}_{r['num_course']}"
|
||||||
if key not in course_info:
|
if key not in course_info:
|
||||||
course_info[key] = {
|
course_info[key] = {
|
||||||
"libelle": r["race_name"],
|
"libelle": r["race_name"],
|
||||||
"libelle_court": r["hippodrome"],
|
"libelle_court": r["hippodrome"],
|
||||||
"discipline": r["discipline"],
|
"discipline": r["discipline"],
|
||||||
"distance": r["distance"],
|
"distance": r["distance"],
|
||||||
"heure_depart_str": r["heure"],
|
"heure_depart_str": r["heure"],
|
||||||
}
|
}
|
||||||
return predictions, course_info
|
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,))
|
conn.execute("DELETE FROM ml_predictions_cache WHERE date = ?", (date,))
|
||||||
# Calculer le risque par course (grouper les chevaux avec tous leurs scores ML)
|
# Calculer le risque par course (grouper les chevaux avec tous leurs scores ML)
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
race_horses = defaultdict(list)
|
race_horses = defaultdict(list)
|
||||||
for p in predictions:
|
for p in predictions:
|
||||||
key = (p.get("num_reunion"), p.get("num_course"))
|
key = (p.get("num_reunion"), p.get("num_course"))
|
||||||
race_horses[key].append({
|
race_horses[key].append(
|
||||||
"odds": p.get("odds", 999),
|
{
|
||||||
"ml_score": p.get("ml_score", 0),
|
"odds": p.get("odds", 999),
|
||||||
"prob_top1":p.get("prob_top1", 0),
|
"ml_score": p.get("ml_score", 0),
|
||||||
"prob_top3":p.get("prob_top3", 0),
|
"prob_top1": p.get("prob_top1", 0),
|
||||||
})
|
"prob_top3": p.get("prob_top3", 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
race_risque = {}
|
race_risque = {}
|
||||||
for key, partants in race_horses.items():
|
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:
|
for p in predictions:
|
||||||
rkey = (p.get("num_reunion"), p.get("num_course"))
|
rkey = (p.get("num_reunion"), p.get("num_course"))
|
||||||
rl, rs = race_risque.get(rkey, ("neutral", 50))
|
rl, rs = race_risque.get(rkey, ("neutral", 50))
|
||||||
conn.execute("""
|
conn.execute(
|
||||||
|
"""
|
||||||
INSERT INTO ml_predictions_cache
|
INSERT INTO ml_predictions_cache
|
||||||
(date, num_reunion, num_course, horse_name, horse_number, odds,
|
(date, num_reunion, num_course, horse_name, horse_number, odds,
|
||||||
prob_top1, prob_top3, ml_score, recommendation, is_value_bet, is_outlier,
|
prob_top1, prob_top3, ml_score, recommendation, is_value_bet, is_outlier,
|
||||||
race_label, race_name, hippodrome, discipline, distance, heure,
|
race_label, race_name, hippodrome, discipline, distance, heure,
|
||||||
risque_label, risque_score, model_version)
|
risque_label, risque_score, model_version)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
""", (
|
""",
|
||||||
date,
|
(
|
||||||
p.get("num_reunion"),
|
date,
|
||||||
p.get("num_course"),
|
p.get("num_reunion"),
|
||||||
p.get("horse_name"),
|
p.get("num_course"),
|
||||||
p.get("horse_number"),
|
p.get("horse_name"),
|
||||||
p.get("odds"),
|
p.get("horse_number"),
|
||||||
p.get("prob_top1"),
|
p.get("odds"),
|
||||||
p.get("prob_top3"),
|
p.get("prob_top1"),
|
||||||
p.get("ml_score"),
|
p.get("prob_top3"),
|
||||||
p.get("recommendation"),
|
p.get("ml_score"),
|
||||||
p.get("is_value_bet", 0),
|
p.get("recommendation"),
|
||||||
p.get("is_outlier", 0),
|
p.get("is_value_bet", 0),
|
||||||
p.get("race_label"),
|
p.get("is_outlier", 0),
|
||||||
p.get("race_name"),
|
p.get("race_label"),
|
||||||
p.get("hippodrome"),
|
p.get("race_name"),
|
||||||
p.get("discipline"),
|
p.get("hippodrome"),
|
||||||
p.get("distance"),
|
p.get("discipline"),
|
||||||
p.get("heure"),
|
p.get("distance"),
|
||||||
rl,
|
p.get("heure"),
|
||||||
rs,
|
rl,
|
||||||
model_version,
|
rs,
|
||||||
))
|
model_version,
|
||||||
|
),
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -219,22 +231,38 @@ def calculate_risque(partants):
|
|||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
# Trier par ml_score desc (ou prob_top1 si ml_score absent)
|
# 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
|
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
|
top2_score = (
|
||||||
top3_score = sorted_p[2].get("ml_score") or sorted_p[2].get("prob_top1") or 0 if len(sorted_p) > 2 else 0
|
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_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_3 = top1_score - top3_score # écart entre 1er et 3e ML
|
||||||
|
|
||||||
# Nombre de concurrents avec ml_score > 40 (dangereux)
|
# 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)
|
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
|
# Détection favori de cote surpris par le ML
|
||||||
odds_fav = sorted(partants, key=lambda x: x.get("odds") or 999)
|
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_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_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
|
fav_surprise = fav_odds < 5 and fav_ml < 25 # favori de cote ignoré par le ML
|
||||||
|
|
||||||
# --- SAFE : domination claire ---
|
# --- SAFE : domination claire ---
|
||||||
@@ -256,7 +284,6 @@ def calculate_risque(partants):
|
|||||||
return "neutral", score
|
return "neutral", score
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def table_exists(conn, table_name):
|
def table_exists(conn, table_name):
|
||||||
c = conn.execute(
|
c = conn.execute(
|
||||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table_name,)
|
"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
|
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("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
return send_file("/home/h3r7/turf_saas/dashboard.html")
|
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)
|
cached_preds, cached_courses = get_ml_from_cache(conn, today)
|
||||||
if cached_preds:
|
if cached_preds:
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({
|
return jsonify(
|
||||||
"date": today,
|
{
|
||||||
"model_version": "xgboost_v1",
|
"date": today,
|
||||||
"predictions": cached_preds,
|
"model_version": "xgboost_v1",
|
||||||
"courses": cached_courses,
|
"predictions": cached_preds,
|
||||||
"from_cache": True,
|
"courses": cached_courses,
|
||||||
})
|
"from_cache": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# --- CALCUL ML ---
|
# --- CALCUL ML ---
|
||||||
models = load_models()
|
models = load_models()
|
||||||
@@ -946,15 +997,18 @@ def api_ml_predictions():
|
|||||||
|
|
||||||
# --- CALCUL RISQUE PAR COURSE + INJECTION DANS PREDICTIONS ---
|
# --- CALCUL RISQUE PAR COURSE + INJECTION DANS PREDICTIONS ---
|
||||||
from collections import defaultdict as _dd
|
from collections import defaultdict as _dd
|
||||||
|
|
||||||
_race_horses_ml = _dd(list)
|
_race_horses_ml = _dd(list)
|
||||||
for p in predictions:
|
for p in predictions:
|
||||||
key = (p.get("num_reunion"), p.get("num_course"))
|
key = (p.get("num_reunion"), p.get("num_course"))
|
||||||
_race_horses_ml[key].append({
|
_race_horses_ml[key].append(
|
||||||
"odds": p.get("odds", 999),
|
{
|
||||||
"ml_score": p.get("ml_score", 0),
|
"odds": p.get("odds", 999),
|
||||||
"prob_top1": p.get("prob_top1", 0),
|
"ml_score": p.get("ml_score", 0),
|
||||||
"prob_top3": p.get("prob_top3", 0),
|
"prob_top1": p.get("prob_top1", 0),
|
||||||
})
|
"prob_top3": p.get("prob_top3", 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
_race_risque_map = {}
|
_race_risque_map = {}
|
||||||
for key, partants in _race_horses_ml.items():
|
for key, partants in _race_horses_ml.items():
|
||||||
label, score = calculate_risque(partants)
|
label, score = calculate_risque(partants)
|
||||||
@@ -996,6 +1050,7 @@ def api_ml_predictions_refresh():
|
|||||||
conn.close()
|
conn.close()
|
||||||
# Déléguer au endpoint principal avec force_refresh
|
# Déléguer au endpoint principal avec force_refresh
|
||||||
from flask import redirect, url_for
|
from flask import redirect, url_for
|
||||||
|
|
||||||
return redirect(url_for("api_ml_predictions") + "?refresh=1")
|
return redirect(url_for("api_ml_predictions") + "?refresh=1")
|
||||||
|
|
||||||
|
|
||||||
@@ -1107,8 +1162,6 @@ def api_suggestions():
|
|||||||
return jsonify({"suggestions": suggestions})
|
return jsonify({"suggestions": suggestions})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/turf/api/metrics/summary")
|
@app.route("/turf/api/metrics/summary")
|
||||||
@app.route("/turf/api/metrics/summary/")
|
@app.route("/turf/api/metrics/summary/")
|
||||||
def 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, "
|
"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 "
|
"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",
|
"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]
|
cols = [d[0] for d in cur.description]
|
||||||
rows = [dict(zip(cols, row)) for row in cur.fetchall()]
|
rows = [dict(zip(cols, row)) for row in cur.fetchall()]
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -1132,6 +1186,7 @@ def metrics_summary():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": True, "message": str(e)})
|
return jsonify({"error": True, "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/turf/api/metrics/daily")
|
@app.route("/turf/api/metrics/daily")
|
||||||
@app.route("/turf/api/metrics/daily/")
|
@app.route("/turf/api/metrics/daily/")
|
||||||
def 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, "
|
"ROUND(AVG(roi_sp_net), 3) as roi_sp, SUM(quinte_5sur5) as quinte_5sur5, "
|
||||||
"SUM(quinte_4sur5) as quinte_4sur5 "
|
"SUM(quinte_4sur5) as quinte_4sur5 "
|
||||||
"FROM prediction_metrics WHERE date >= date('now', ?) GROUP BY date, source ORDER BY date DESC",
|
"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]
|
cols = [d[0] for d in cur.description]
|
||||||
rows = [dict(zip(cols, row)) for row in cur.fetchall()]
|
rows = [dict(zip(cols, row)) for row in cur.fetchall()]
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -1154,6 +1210,7 @@ def metrics_daily():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": True, "message": str(e)})
|
return jsonify({"error": True, "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
load_models()
|
load_models()
|
||||||
app.run(host="0.0.0.0", port=8791, debug=False)
|
app.run(host="0.0.0.0", port=8791, debug=False)
|
||||||
|
|||||||
441
dashboard_saas.html
Normal file
441
dashboard_saas.html
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Dashboard — Turf IA</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;
|
||||||
|
}
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); min-height: 100vh; display: flex; }
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
|
||||||
|
/* SIDEBAR */
|
||||||
|
.sidebar {
|
||||||
|
width: 240px; flex-shrink: 0; background: var(--dark2);
|
||||||
|
border-right: 1px solid var(--border); padding: 20px 0;
|
||||||
|
display: flex; flex-direction: column; height: 100vh;
|
||||||
|
position: sticky; top: 0; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.sidebar-logo { padding: 0 20px 20px; font-weight: 800; font-size: 1.1rem; border-bottom: 1px solid var(--border); margin-bottom: 16px; }
|
||||||
|
.sidebar-logo span { color: var(--green); }
|
||||||
|
.nav-section { padding: 0 12px 8px; font-size: .72rem; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); font-weight: 600; margin-top: 12px; }
|
||||||
|
.nav-item {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 9px 20px; font-size: .9rem; cursor: pointer;
|
||||||
|
border-radius: 8px; margin: 1px 8px; color: var(--muted); transition: all .15s;
|
||||||
|
}
|
||||||
|
.nav-item:hover { background: var(--dark3); color: var(--text); }
|
||||||
|
.nav-item.active { background: rgba(0,200,83,.1); color: var(--green); }
|
||||||
|
.nav-item .icon { font-size: 1.1rem; width: 22px; text-align: center; }
|
||||||
|
.sidebar-bottom { margin-top: auto; padding: 16px; border-top: 1px solid var(--border); }
|
||||||
|
.user-chip { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.user-avatar { width: 32px; height: 32px; border-radius: 50%; background: var(--green); display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: .9rem; color: #000; flex-shrink: 0; }
|
||||||
|
.user-info { flex: 1; min-width: 0; }
|
||||||
|
.user-name { font-size: .85rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.user-plan { font-size: .72rem; color: var(--muted); text-transform: uppercase; }
|
||||||
|
|
||||||
|
/* MAIN */
|
||||||
|
.main { flex: 1; overflow-y: auto; }
|
||||||
|
.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; }
|
||||||
|
.topbar-title { font-size: 1.1rem; font-weight: 700; }
|
||||||
|
.topbar-right { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.badge { padding: 3px 10px; border-radius: 20px; font-size: .75rem; font-weight: 700; }
|
||||||
|
.badge-free { background: rgba(139,148,158,.15); color: var(--muted); }
|
||||||
|
.badge-premium { background: rgba(255,214,0,.15); color: var(--gold); }
|
||||||
|
.badge-pro { background: rgba(30,136,229,.15); color: var(--blue); }
|
||||||
|
.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-upgrade { background: linear-gradient(135deg, var(--gold), var(--orange)); color: #000; }
|
||||||
|
|
||||||
|
/* CONTENT */
|
||||||
|
.content { padding: 28px; }
|
||||||
|
|
||||||
|
/* UPGRADE BANNER */
|
||||||
|
.upgrade-banner {
|
||||||
|
background: linear-gradient(135deg, rgba(255,214,0,.1), rgba(255,109,0,.08));
|
||||||
|
border: 1px solid rgba(255,214,0,.25); border-radius: var(--radius);
|
||||||
|
padding: 16px 20px; margin-bottom: 24px;
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.upgrade-banner p { font-size: .9rem; }
|
||||||
|
.upgrade-banner strong { color: var(--gold); }
|
||||||
|
|
||||||
|
/* STAT CARDS */
|
||||||
|
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 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; }
|
||||||
|
.stat-value { font-size: 1.8rem; font-weight: 800; }
|
||||||
|
.stat-sub { font-size: .78rem; color: var(--muted); margin-top: 4px; }
|
||||||
|
.stat-up { color: var(--green); }
|
||||||
|
.stat-down { color: var(--error); }
|
||||||
|
|
||||||
|
/* SECTION TITLE */
|
||||||
|
.section-title { font-size: 1rem; font-weight: 700; margin-bottom: 14px; display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.section-title small { font-size: .78rem; color: var(--muted); font-weight: 400; }
|
||||||
|
|
||||||
|
/* RACE TABLE */
|
||||||
|
.race-table-wrap { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; margin-bottom: 24px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
thead th { padding: 10px 14px; font-size: .78rem; text-transform: uppercase; letter-spacing: .5px; color: var(--muted); text-align: left; border-bottom: 1px solid var(--border); background: var(--dark3); }
|
||||||
|
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: 11px 14px; font-size: .88rem; }
|
||||||
|
.horse-name { font-weight: 600; }
|
||||||
|
.prob-bar-wrap { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.prob-bar { width: 80px; height: 6px; border-radius: 3px; background: var(--border); overflow: hidden; }
|
||||||
|
.prob-bar-fill { height: 100%; border-radius: 3px; background: var(--green); }
|
||||||
|
.value-bet { background: rgba(0,200,83,.15); color: var(--green); padding: 2px 8px; border-radius: 12px; font-size: .75rem; font-weight: 700; }
|
||||||
|
.cote { font-weight: 700; }
|
||||||
|
.rank-1 { color: var(--gold); font-weight: 800; }
|
||||||
|
.rank-2 { color: var(--muted); }
|
||||||
|
.rank-3 { color: #cd7f32; }
|
||||||
|
|
||||||
|
/* BLURRED (locked) */
|
||||||
|
.locked { filter: blur(6px); pointer-events: none; user-select: none; position: relative; }
|
||||||
|
.locked-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(13,17,23,.7); border-radius: var(--radius); z-index: 2; }
|
||||||
|
.locked-overlay-msg { text-align: center; }
|
||||||
|
.locked-overlay-msg h3 { font-size: 1rem; margin-bottom: 8px; }
|
||||||
|
.lock-wrap { position: relative; }
|
||||||
|
|
||||||
|
/* RACE CARD grid */
|
||||||
|
.races-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
||||||
|
.race-card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; transition: border-color .2s; }
|
||||||
|
.race-card:hover { border-color: var(--muted); }
|
||||||
|
.race-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
|
||||||
|
.race-name { font-weight: 700; font-size: .93rem; }
|
||||||
|
.race-meta { font-size: .78rem; color: var(--muted); margin-top: 2px; }
|
||||||
|
.race-time { font-size: .8rem; font-weight: 700; color: var(--green); }
|
||||||
|
.top3-row { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
||||||
|
.horse-chip {
|
||||||
|
padding: 4px 10px; border-radius: 20px; font-size: .78rem; font-weight: 600;
|
||||||
|
background: var(--dark3); border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.horse-chip.top1 { border-color: var(--gold); color: var(--gold); background: rgba(255,214,0,.08); }
|
||||||
|
.horse-chip.top2 { border-color: var(--muted); }
|
||||||
|
.horse-chip.top3 { border-color: #cd7f32; color: #cd7f32; background: rgba(205,127,50,.08); }
|
||||||
|
|
||||||
|
/* EMPTY STATE */
|
||||||
|
.empty-state { text-align: center; padding: 60px 20px; color: var(--muted); }
|
||||||
|
.empty-state .icon { font-size: 3rem; margin-bottom: 14px; }
|
||||||
|
.empty-state h3 { font-size: 1.1rem; font-weight: 700; color: var(--text); margin-bottom: 8px; }
|
||||||
|
.empty-state p { font-size: .9rem; max-width: 360px; margin: 0 auto 20px; }
|
||||||
|
|
||||||
|
/* 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.info { background: var(--blue); color: #fff; }
|
||||||
|
|
||||||
|
/* LOADING */
|
||||||
|
.loader-row { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 40px; color: var(--muted); }
|
||||||
|
.spinner { width: 20px; height: 20px; border: 2px solid var(--border); border-top-color: var(--green); border-radius: 50%; animation: spin .7s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* RESPONSIVE */
|
||||||
|
@media (max-width: 900px) { .sidebar { display: none; } }
|
||||||
|
@media (max-width: 600px) { .stats-row { grid-template-columns: 1fr 1fr; } .content { padding: 16px; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- SIDEBAR -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-logo">🏇 <span>Turf</span> IA</div>
|
||||||
|
|
||||||
|
<div class="nav-section">Principal</div>
|
||||||
|
<a class="nav-item active" href="/dashboard"><span class="icon">📊</span> Dashboard</a>
|
||||||
|
<a class="nav-item" href="#races"><span class="icon">🏁</span> Courses du jour</a>
|
||||||
|
<a class="nav-item" href="#predictions"><span class="icon">🧠</span> Prédictions</a>
|
||||||
|
<a class="nav-item" id="nav-value-bets" href="#value-bets"><span class="icon">💎</span> Value Bets</a>
|
||||||
|
|
||||||
|
<div class="nav-section">Analyse</div>
|
||||||
|
<a class="nav-item" id="nav-history" href="#history"><span class="icon">📅</span> Historique</a>
|
||||||
|
<a class="nav-item" id="nav-export" href="#export"><span class="icon">📤</span> Export CSV</a>
|
||||||
|
<a class="nav-item" id="nav-api" href="/docs/api"><span class="icon">⚡</span> API Docs</a>
|
||||||
|
|
||||||
|
<div class="nav-section">Compte</div>
|
||||||
|
<a class="nav-item" href="/account"><span class="icon">⚙️</span> Mon compte</a>
|
||||||
|
<a class="nav-item" href="#" id="logout-btn"><span class="icon">🚪</span> Déconnexion</a>
|
||||||
|
|
||||||
|
<div class="sidebar-bottom">
|
||||||
|
<div class="user-chip">
|
||||||
|
<div class="user-avatar" id="sidebar-avatar">?</div>
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-name" id="sidebar-name">Chargement…</div>
|
||||||
|
<div class="user-plan" id="sidebar-plan">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- MAIN -->
|
||||||
|
<div class="main">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="topbar-title">Tableau de bord</div>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<span class="badge" id="plan-badge">—</span>
|
||||||
|
<a href="/account?tab=upgrade" class="btn btn-upgrade" id="upgrade-btn" style="display:none">⭐ Upgrader</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<!-- Upgrade banner (shown for free users) -->
|
||||||
|
<div class="upgrade-banner" id="upgrade-banner" style="display:none">
|
||||||
|
<p>🔒 Plan <strong>Free</strong> — Vous voyez un aperçu limité. Passez à <strong>Premium</strong> pour toutes les courses, value bets et alertes Telegram.</p>
|
||||||
|
<a href="/account?tab=upgrade" class="btn btn-upgrade">Upgrader — 9,90€/mois</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats row -->
|
||||||
|
<div class="stats-row" id="stats-row">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Courses analysées</div>
|
||||||
|
<div class="stat-value" id="stat-courses">—</div>
|
||||||
|
<div class="stat-sub">aujourd'hui</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Précision Top-3</div>
|
||||||
|
<div class="stat-value stat-up" id="stat-accuracy">—</div>
|
||||||
|
<div class="stat-sub">30 derniers jours</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Value bets du jour</div>
|
||||||
|
<div class="stat-value" id="stat-vb">—</div>
|
||||||
|
<div class="stat-sub" id="stat-vb-sub">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Prochaine course</div>
|
||||||
|
<div class="stat-value stat-up" id="stat-next">—</div>
|
||||||
|
<div class="stat-sub" id="stat-next-hip">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Today's races -->
|
||||||
|
<div class="section-title">
|
||||||
|
🏁 Prédictions du jour
|
||||||
|
<small id="race-count-label">Chargement…</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="races-container">
|
||||||
|
<div class="loader-row"><div class="spinner"></div> Chargement des prédictions…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Locked section for free users -->
|
||||||
|
<div id="locked-section" style="display:none">
|
||||||
|
<div class="lock-wrap" style="position:relative; min-height:200px;">
|
||||||
|
<div style="filter:blur(5px)">
|
||||||
|
<div class="races-grid">
|
||||||
|
<div class="race-card"><div class="race-name">R3C5 - Quinté+</div><div class="race-meta">16 partants · Plat · 2400m</div></div>
|
||||||
|
<div class="race-card"><div class="race-name">R2C3 - Prix du Président</div><div class="race-meta">12 partants · Trot · 2100m</div></div>
|
||||||
|
<div class="race-card"><div class="race-name">R4C7 - Prix Deauville</div><div class="race-meta">10 partants · Galop · 1600m</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="locked-overlay">
|
||||||
|
<div class="locked-overlay-msg">
|
||||||
|
<div style="font-size:2rem;margin-bottom:10px">🔒</div>
|
||||||
|
<h3>+120 courses cachées</h3>
|
||||||
|
<p style="color:var(--muted);font-size:.88rem;margin-bottom:14px">Passez à Premium pour débloquer toutes les courses, les value bets et les alertes Telegram.</p>
|
||||||
|
<a href="/account?tab=upgrade" class="btn btn-primary">Débloquer — 9,90€/mois</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = '/api/v1';
|
||||||
|
let currentUser = null;
|
||||||
|
|
||||||
|
function showToast(msg, type = 'success') {
|
||||||
|
const t = document.getElementById('toast');
|
||||||
|
t.textContent = msg; t.className = `show ${type}`;
|
||||||
|
setTimeout(() => t.className = '', 3500);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) { logout(); return null; }
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
localStorage.removeItem('turf_token');
|
||||||
|
localStorage.removeItem('turf_user');
|
||||||
|
location.href = '/login';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('logout-btn').addEventListener('click', e => { e.preventDefault(); logout(); });
|
||||||
|
|
||||||
|
function setPlanUI(plan) {
|
||||||
|
const badge = document.getElementById('plan-badge');
|
||||||
|
const upgradeBtn = document.getElementById('upgrade-btn');
|
||||||
|
const upgradeBanner = document.getElementById('upgrade-banner');
|
||||||
|
const navExport = document.getElementById('nav-export');
|
||||||
|
const navApi = document.getElementById('nav-api');
|
||||||
|
|
||||||
|
badge.className = `badge badge-${plan}`;
|
||||||
|
badge.textContent = { free: 'Free', premium: 'Premium ⭐', pro: 'Pro 🚀' }[plan] || plan;
|
||||||
|
|
||||||
|
if (plan === 'free') {
|
||||||
|
upgradeBtn.style.display = '';
|
||||||
|
upgradeBanner.style.display = '';
|
||||||
|
document.getElementById('locked-section').style.display = '';
|
||||||
|
navExport.style.opacity = '.4';
|
||||||
|
navApi.style.opacity = '.4';
|
||||||
|
document.getElementById('nav-value-bets').style.opacity = '.4';
|
||||||
|
} else if (plan === 'premium') {
|
||||||
|
upgradeBtn.style.display = '';
|
||||||
|
upgradeBtn.innerHTML = '🚀 Passer Pro';
|
||||||
|
navExport.style.opacity = '.4';
|
||||||
|
navApi.style.opacity = '.4';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRaceCards(predictions, plan) {
|
||||||
|
const container = document.getElementById('races-container');
|
||||||
|
if (!predictions || predictions.length === 0) {
|
||||||
|
container.innerHTML = `<div class="empty-state"><div class="icon">🏇</div><h3>Aucune prédiction disponible</h3><p>Les prédictions d'aujourd'hui ne sont pas encore disponibles. Revenez plus tard.</p></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by race
|
||||||
|
const races = {};
|
||||||
|
predictions.forEach(p => {
|
||||||
|
const key = `${p.num_reunion}-${p.num_course}`;
|
||||||
|
if (!races[key]) races[key] = { label: p.race_label || `R${p.num_reunion}C${p.num_course}`, name: p.race_name || '', hippodrome: p.hippodrome || '', discipline: p.discipline || '', heure: p.heure || '', horses: [] };
|
||||||
|
races[key].horses.push(p);
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxRaces = plan === 'free' ? 1 : 999;
|
||||||
|
const raceKeys = Object.keys(races).slice(0, maxRaces);
|
||||||
|
|
||||||
|
document.getElementById('race-count-label').textContent = `${raceKeys.length} course${raceKeys.length>1?'s':''} affichée${raceKeys.length>1?'s':''}`;
|
||||||
|
|
||||||
|
let html = '<div class="races-grid">';
|
||||||
|
raceKeys.forEach(key => {
|
||||||
|
const race = races[key];
|
||||||
|
const sorted = [...race.horses].sort((a, b) => b.ml_score - a.ml_score).slice(0, 3);
|
||||||
|
const vbCount = race.horses.filter(h => h.is_value_bet).length;
|
||||||
|
|
||||||
|
html += `<div class="race-card">
|
||||||
|
<div class="race-header">
|
||||||
|
<div>
|
||||||
|
<div class="race-name">${race.label}${race.name ? ' — ' + race.name : ''}</div>
|
||||||
|
<div class="race-meta">${race.hippodrome ? race.hippodrome + ' · ' : ''}${race.discipline || ''}</div>
|
||||||
|
</div>
|
||||||
|
<div class="race-time">${race.heure || ''}</div>
|
||||||
|
</div>
|
||||||
|
${vbCount > 0 ? `<span class="value-bet">💎 ${vbCount} value bet${vbCount>1?'s':''}</span>` : ''}
|
||||||
|
<div class="top3-row">
|
||||||
|
${sorted.map((h, i) => `<span class="horse-chip top${i+1}">${i===0?'🥇':i===1?'🥈':'🥉'} ${h.horse_name} (${h.odds ? h.odds.toFixed(1) : '—'})</span>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
// Detailed table for first race
|
||||||
|
if (raceKeys.length > 0) {
|
||||||
|
const firstRace = races[raceKeys[0]];
|
||||||
|
const sorted = [...firstRace.horses].sort((a, b) => b.ml_score - a.ml_score);
|
||||||
|
html += `<div class="section-title" style="margin-top:8px">📋 Détail — ${firstRace.label}${firstRace.name ? ' · ' + firstRace.name : ''}</div>
|
||||||
|
<div class="race-table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr>
|
||||||
|
<th>#</th><th>Cheval</th><th>Cote</th><th>Prob Top-1</th><th>Prob Top-3</th><th>Score IA</th><th>Value</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>`;
|
||||||
|
sorted.forEach((h, i) => {
|
||||||
|
const prob1 = h.prob_top1 ? (h.prob_top1 * 100).toFixed(1) : '—';
|
||||||
|
const prob3 = h.prob_top3 ? (h.prob_top3 * 100).toFixed(1) : '—';
|
||||||
|
const score = h.ml_score ? h.ml_score.toFixed(2) : '—';
|
||||||
|
html += `<tr>
|
||||||
|
<td class="${i===0?'rank-1':i===1?'rank-2':i===2?'rank-3':''}">${i+1}</td>
|
||||||
|
<td class="horse-name">${h.horse_name || '—'}</td>
|
||||||
|
<td class="cote">${h.odds ? h.odds.toFixed(1) : '—'}</td>
|
||||||
|
<td><div class="prob-bar-wrap"><div class="prob-bar"><div class="prob-bar-fill" style="width:${prob1}%"></div></div>${prob1}%</div></td>
|
||||||
|
<td><div class="prob-bar-wrap"><div class="prob-bar"><div class="prob-bar-fill" style="width:${prob3}%;background:var(--blue)"></div></div>${prob3}%</div></td>
|
||||||
|
<td>${score}</td>
|
||||||
|
<td>${h.is_value_bet ? '<span class="value-bet">💎 VB</span>' : '—'}</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDashboard() {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) { location.href = '/login'; return; }
|
||||||
|
|
||||||
|
// Try loading from localStorage first
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem('turf_user') || '{}');
|
||||||
|
if (saved.firstname) {
|
||||||
|
document.getElementById('sidebar-name').textContent = `${saved.firstname} ${saved.lastname || ''}`;
|
||||||
|
document.getElementById('sidebar-avatar').textContent = saved.firstname[0].toUpperCase();
|
||||||
|
document.getElementById('sidebar-plan').textContent = (saved.plan || 'free').toUpperCase();
|
||||||
|
setPlanUI(saved.plan || 'free');
|
||||||
|
currentUser = saved;
|
||||||
|
}
|
||||||
|
} catch(_) {}
|
||||||
|
|
||||||
|
// Fetch fresh user profile
|
||||||
|
const profile = await fetchJson(`${API}/auth/me`);
|
||||||
|
if (!profile) return;
|
||||||
|
currentUser = profile.user || profile;
|
||||||
|
const plan = currentUser.plan || 'free';
|
||||||
|
document.getElementById('sidebar-name').textContent = `${currentUser.firstname || ''} ${currentUser.lastname || ''}`.trim() || currentUser.email;
|
||||||
|
document.getElementById('sidebar-avatar').textContent = (currentUser.firstname || currentUser.email || '?')[0].toUpperCase();
|
||||||
|
document.getElementById('sidebar-plan').textContent = plan.toUpperCase();
|
||||||
|
localStorage.setItem('turf_user', JSON.stringify(currentUser));
|
||||||
|
setPlanUI(plan);
|
||||||
|
|
||||||
|
// Fetch stats
|
||||||
|
const statsData = await fetchJson(`${API}/stats/summary`);
|
||||||
|
if (statsData) {
|
||||||
|
document.getElementById('stat-courses').textContent = statsData.courses_today || '—';
|
||||||
|
document.getElementById('stat-accuracy').textContent = statsData.accuracy_top3 ? statsData.accuracy_top3 + '%' : '—';
|
||||||
|
document.getElementById('stat-vb').textContent = statsData.value_bets_today ?? '—';
|
||||||
|
document.getElementById('stat-vb-sub').textContent = plan === 'free' ? '(limité)' : 'identifiés';
|
||||||
|
if (statsData.next_race_time) {
|
||||||
|
document.getElementById('stat-next').textContent = statsData.next_race_time;
|
||||||
|
document.getElementById('stat-next-hip').textContent = statsData.next_race_hippodrome || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch predictions
|
||||||
|
const predsData = await fetchJson(`${API}/predictions/today`);
|
||||||
|
if (predsData && predsData.predictions) {
|
||||||
|
renderRaceCards(predsData.predictions, plan);
|
||||||
|
} else {
|
||||||
|
document.getElementById('races-container').innerHTML = '<div class="empty-state"><div class="icon">🏇</div><h3>Prédictions non disponibles</h3><p>L\'API de prédictions est en cours de démarrage. Réessayez dans quelques instants.</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDashboard();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
250
docker-compose.yml
Normal file
250
docker-compose.yml
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# H3R7Tech Turf SaaS — Docker Compose
|
||||||
|
# Services: app (x4) + postgres + redis + prometheus + grafana + nginx
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
x-app-common: &app-common
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: runner
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- turf-net
|
||||||
|
volumes:
|
||||||
|
- ml-models:/app/data/models
|
||||||
|
- app-logs:/app/logs
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# PostgreSQL — primary database
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-turf_saas}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-turf}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
- ./infra/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-turf} -d ${POSTGRES_DB:-turf_saas}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- turf-net
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5432:5432"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# Redis — caching & session store
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "--pass", "${REDIS_PASSWORD}", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
networks:
|
||||||
|
- turf-net
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:6379:6379"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# Combined API — main predictions + ideas API (port 8790)
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
combined-api:
|
||||||
|
<<: *app-common
|
||||||
|
container_name: turf-combined-api
|
||||||
|
command: gunicorn --bind 0.0.0.0:8790 --workers 2 --timeout 120 --access-logfile - --error-logfile - combined_api:app
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8790:8790"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8790/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
environment:
|
||||||
|
PORT: 8790
|
||||||
|
SERVICE_NAME: combined-api
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# Dashboard API — analytics & ML scoring (port 8791)
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
dashboard-api:
|
||||||
|
<<: *app-common
|
||||||
|
container_name: turf-dashboard-api
|
||||||
|
command: gunicorn --bind 0.0.0.0:8791 --workers 2 --timeout 120 --access-logfile - --error-logfile - dashboard_api:app
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8791:8791"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8791/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
environment:
|
||||||
|
PORT: 8791
|
||||||
|
SERVICE_NAME: dashboard-api
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# Portal Server — frontend portal (port 8792)
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
portal:
|
||||||
|
<<: *app-common
|
||||||
|
container_name: turf-portal
|
||||||
|
command: gunicorn --bind 0.0.0.0:8792 --workers 2 --timeout 60 --access-logfile - --error-logfile - portal_server:app
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8792:8792"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8792/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
environment:
|
||||||
|
PORT: 8792
|
||||||
|
SERVICE_NAME: portal
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# Scheduler — background jobs (no external port)
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
scheduler:
|
||||||
|
<<: *app-common
|
||||||
|
container_name: turf-scheduler
|
||||||
|
command: python turf_scheduler.py
|
||||||
|
environment:
|
||||||
|
SERVICE_NAME: scheduler
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# Prometheus — metrics scraping
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:v2.53.4
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||||
|
- "--storage.tsdb.path=/prometheus"
|
||||||
|
- "--storage.tsdb.retention.time=30d"
|
||||||
|
- "--web.enable-lifecycle"
|
||||||
|
volumes:
|
||||||
|
- ./infra/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
- ./infra/prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro
|
||||||
|
- prometheus-data:/prometheus
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:9090:9090"
|
||||||
|
networks:
|
||||||
|
- turf-net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "--tries=1", "--spider", "http://localhost:9090/-/healthy"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# Grafana — dashboards
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:11.5.2
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
|
||||||
|
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD}
|
||||||
|
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||||
|
GF_SERVER_DOMAIN: ${DOMAIN:-localhost}
|
||||||
|
GF_SERVER_ROOT_URL: https://${DOMAIN:-localhost}/grafana/
|
||||||
|
GF_SERVER_SERVE_FROM_SUB_PATH: "true"
|
||||||
|
volumes:
|
||||||
|
- grafana-data:/var/lib/grafana
|
||||||
|
- ./infra/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||||
|
- ./infra/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3000:3000"
|
||||||
|
networks:
|
||||||
|
- turf-net
|
||||||
|
depends_on:
|
||||||
|
- prometheus
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# Nginx — reverse proxy + TLS termination
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
nginx:
|
||||||
|
image: nginx:1.27-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./infra/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||||
|
- certbot-www:/var/www/certbot:ro
|
||||||
|
- certbot-certs:/etc/letsencrypt:ro
|
||||||
|
networks:
|
||||||
|
- turf-net
|
||||||
|
depends_on:
|
||||||
|
- combined-api
|
||||||
|
- dashboard-api
|
||||||
|
- portal
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "nginx", "-t"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# Certbot — Let's Encrypt TLS certificate renewal
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
certbot:
|
||||||
|
image: certbot/certbot:latest
|
||||||
|
restart: "no"
|
||||||
|
volumes:
|
||||||
|
- certbot-www:/var/www/certbot
|
||||||
|
- certbot-certs:/etc/letsencrypt
|
||||||
|
command: certonly --webroot --webroot-path=/var/www/certbot --email ${ADMIN_EMAIL} --agree-tos --no-eff-email -d ${DOMAIN}
|
||||||
|
networks:
|
||||||
|
- turf-net
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Named volumes — persistent storage
|
||||||
|
# ============================================================
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
driver: local
|
||||||
|
redis-data:
|
||||||
|
driver: local
|
||||||
|
ml-models:
|
||||||
|
driver: local
|
||||||
|
app-logs:
|
||||||
|
driver: local
|
||||||
|
prometheus-data:
|
||||||
|
driver: local
|
||||||
|
grafana-data:
|
||||||
|
driver: local
|
||||||
|
certbot-www:
|
||||||
|
driver: local
|
||||||
|
certbot-certs:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Network
|
||||||
|
# ============================================================
|
||||||
|
networks:
|
||||||
|
turf-net:
|
||||||
|
driver: bridge
|
||||||
174
infra/grafana/dashboards/turf-saas-overview.json
Normal file
174
infra/grafana/dashboards/turf-saas-overview.json
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
{
|
||||||
|
"title": "Turf SaaS — Overview",
|
||||||
|
"uid": "turf-saas-overview",
|
||||||
|
"schemaVersion": 38,
|
||||||
|
"version": 1,
|
||||||
|
"refresh": "30s",
|
||||||
|
"time": { "from": "now-6h", "to": "now" },
|
||||||
|
"tags": ["turf-saas"],
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Request Rate (req/s)",
|
||||||
|
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": "Prometheus",
|
||||||
|
"expr": "sum(rate(http_requests_total[5m]))",
|
||||||
|
"legendFormat": "req/s"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options": { "colorMode": "background", "graphMode": "area" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Error Rate (5xx)",
|
||||||
|
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": "Prometheus",
|
||||||
|
"expr": "sum(rate(http_requests_total{status_code=~\"5..\"}[5m])) / sum(rate(http_requests_total[5m])) * 100",
|
||||||
|
"legendFormat": "error %"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "percent",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{ "color": "green", "value": null },
|
||||||
|
{ "color": "yellow", "value": 0.5 },
|
||||||
|
{ "color": "red", "value": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "p95 Latency",
|
||||||
|
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": "Prometheus",
|
||||||
|
"expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))",
|
||||||
|
"legendFormat": "p95"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "s",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{ "color": "green", "value": null },
|
||||||
|
{ "color": "yellow", "value": 1 },
|
||||||
|
{ "color": "red", "value": 2 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"type": "stat",
|
||||||
|
"title": "ML Top-1 Accuracy",
|
||||||
|
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": "Prometheus",
|
||||||
|
"expr": "ml_prediction_accuracy_ratio{accuracy_type=\"top1\"} * 100",
|
||||||
|
"legendFormat": "top-1 %"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "percent",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{ "color": "red", "value": null },
|
||||||
|
{ "color": "yellow", "value": 25 },
|
||||||
|
{ "color": "green", "value": 35 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "HTTP Requests by Service",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": "Prometheus",
|
||||||
|
"expr": "sum(rate(http_requests_total[5m])) by (service)",
|
||||||
|
"legendFormat": "{{ service }}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": { "unit": "reqps" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Request Duration p50/p95/p99",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": "Prometheus",
|
||||||
|
"expr": "histogram_quantile(0.50, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))",
|
||||||
|
"legendFormat": "p50"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": "Prometheus",
|
||||||
|
"expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))",
|
||||||
|
"legendFormat": "p95"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": "Prometheus",
|
||||||
|
"expr": "histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))",
|
||||||
|
"legendFormat": "p99"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": { "unit": "s" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "ML Predictions per Hour",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": "Prometheus",
|
||||||
|
"expr": "sum(increase(ml_predictions_total[1h])) by (model_type)",
|
||||||
|
"legendFormat": "{{ model_type }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "DB Query Duration",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": "Prometheus",
|
||||||
|
"expr": "histogram_quantile(0.95, sum(rate(db_query_duration_seconds_bucket[5m])) by (le, operation))",
|
||||||
|
"legendFormat": "{{ operation }} p95"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": { "unit": "s" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
11
infra/grafana/provisioning/dashboards/dashboards.yml
Normal file
11
infra/grafana/provisioning/dashboards/dashboards.yml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- name: turf-saas-dashboards
|
||||||
|
type: file
|
||||||
|
disableDeletion: false
|
||||||
|
updateIntervalSeconds: 30
|
||||||
|
allowUiUpdates: true
|
||||||
|
options:
|
||||||
|
path: /var/lib/grafana/dashboards
|
||||||
|
foldersFromFilesStructure: true
|
||||||
13
infra/grafana/provisioning/datasources/prometheus.yml
Normal file
13
infra/grafana/provisioning/datasources/prometheus.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
datasources:
|
||||||
|
- name: Prometheus
|
||||||
|
type: prometheus
|
||||||
|
uid: prometheus-main
|
||||||
|
access: proxy
|
||||||
|
url: http://prometheus:9090
|
||||||
|
isDefault: true
|
||||||
|
editable: false
|
||||||
|
jsonData:
|
||||||
|
httpMethod: POST
|
||||||
|
timeInterval: "15s"
|
||||||
157
infra/nginx/conf.d/turf.conf
Normal file
157
infra/nginx/conf.d/turf.conf
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Nginx Virtual Host — Turf SaaS
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# Upstream service pools
|
||||||
|
upstream combined_api {
|
||||||
|
server combined-api:8790;
|
||||||
|
keepalive 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream dashboard_api {
|
||||||
|
server dashboard-api:8791;
|
||||||
|
keepalive 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream portal {
|
||||||
|
server portal:8792;
|
||||||
|
keepalive 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream grafana {
|
||||||
|
server grafana:3000;
|
||||||
|
keepalive 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# HTTP → HTTPS redirect
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# Let's Encrypt ACME challenge
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# HTTPS main server
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name ${DOMAIN};
|
||||||
|
|
||||||
|
# TLS configuration
|
||||||
|
ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 10m;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
ssl_stapling on;
|
||||||
|
ssl_stapling_verify on;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header X-Frame-Options DENY always;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;" always;
|
||||||
|
|
||||||
|
# Limits
|
||||||
|
client_max_body_size 10M;
|
||||||
|
limit_conn conn_limit 20;
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# Portal (root)
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
location / {
|
||||||
|
limit_req zone=global burst=50 nodelay;
|
||||||
|
proxy_pass http://portal;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# Combined API
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
location /api/ {
|
||||||
|
limit_req zone=api burst=20 nodelay;
|
||||||
|
proxy_pass http://combined_api;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# Dashboard API
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
location /dashboard-api/ {
|
||||||
|
limit_req zone=api burst=20 nodelay;
|
||||||
|
proxy_pass http://dashboard_api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# Grafana (restricted to internal/admin)
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
location /grafana/ {
|
||||||
|
# Restrict to admin IPs in production
|
||||||
|
# allow 10.0.0.0/8;
|
||||||
|
# deny all;
|
||||||
|
|
||||||
|
proxy_pass http://grafana;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# Health check (no rate limiting)
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
location /health {
|
||||||
|
proxy_pass http://combined_api/health;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Block common attack vectors
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
access_log off;
|
||||||
|
log_not_found off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.(env|git|bak|sql|log)$ {
|
||||||
|
deny all;
|
||||||
|
access_log off;
|
||||||
|
log_not_found off;
|
||||||
|
}
|
||||||
|
}
|
||||||
65
infra/nginx/nginx.conf
Normal file
65
infra/nginx/nginx.conf
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Nginx — Main config
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
use epoll;
|
||||||
|
multi_accept on;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log_format json_combined escape=json
|
||||||
|
'{"time":"$time_iso8601",'
|
||||||
|
'"remote_addr":"$remote_addr",'
|
||||||
|
'"method":"$request_method",'
|
||||||
|
'"uri":"$request_uri",'
|
||||||
|
'"status":$status,'
|
||||||
|
'"body_bytes":$body_bytes_sent,'
|
||||||
|
'"duration":$request_time,'
|
||||||
|
'"referrer":"$http_referer",'
|
||||||
|
'"user_agent":"$http_user_agent",'
|
||||||
|
'"x_forwarded_for":"$http_x_forwarded_for"}';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log json_combined;
|
||||||
|
|
||||||
|
# Performance
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
types_hash_max_size 2048;
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
# Gzip
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 5;
|
||||||
|
gzip_types
|
||||||
|
text/plain
|
||||||
|
text/css
|
||||||
|
text/javascript
|
||||||
|
application/javascript
|
||||||
|
application/json
|
||||||
|
application/xml
|
||||||
|
image/svg+xml;
|
||||||
|
|
||||||
|
# Rate limiting zones
|
||||||
|
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/m;
|
||||||
|
limit_req_zone $binary_remote_addr zone=global:20m rate=100r/m;
|
||||||
|
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
|
||||||
|
|
||||||
|
# Include virtual hosts
|
||||||
|
include /etc/nginx/conf.d/*.conf;
|
||||||
|
}
|
||||||
12
infra/postgres/init.sql
Normal file
12
infra/postgres/init.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- PostgreSQL init script for Turf SaaS
|
||||||
|
-- Runs on first container start (docker-entrypoint-initdb.d)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Create extensions
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
||||||
|
|
||||||
|
-- Grant privileges to the app user
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE turf_saas TO turf;
|
||||||
|
GRANT ALL ON SCHEMA public TO turf;
|
||||||
109
infra/prometheus/alerts.yml
Normal file
109
infra/prometheus/alerts.yml
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Prometheus Alert Rules — Turf SaaS
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
groups:
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# HTTP / API Alerts
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
- name: http_alerts
|
||||||
|
rules:
|
||||||
|
- alert: HighErrorRate
|
||||||
|
expr: |
|
||||||
|
sum(rate(http_requests_total{status_code=~"5.."}[5m])) by (service)
|
||||||
|
/
|
||||||
|
sum(rate(http_requests_total[5m])) by (service)
|
||||||
|
> 0.01
|
||||||
|
for: 2m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "High 5xx error rate on {{ $labels.service }}"
|
||||||
|
description: "Error rate is {{ $value | humanizePercentage }} (threshold: 1%)"
|
||||||
|
|
||||||
|
- alert: HighLatency
|
||||||
|
expr: |
|
||||||
|
histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, service))
|
||||||
|
> 2
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "High p95 latency on {{ $labels.service }}"
|
||||||
|
description: "p95 latency is {{ $value | humanizeDuration }} (threshold: 2s)"
|
||||||
|
|
||||||
|
- alert: ServiceDown
|
||||||
|
expr: up == 0
|
||||||
|
for: 1m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "Service {{ $labels.job }} is down"
|
||||||
|
description: "{{ $labels.instance }} has been unreachable for >1 minute"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# Database Alerts
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
- name: database_alerts
|
||||||
|
rules:
|
||||||
|
- alert: PostgresDown
|
||||||
|
expr: pg_up == 0
|
||||||
|
for: 1m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "PostgreSQL is down"
|
||||||
|
description: "Cannot connect to PostgreSQL database"
|
||||||
|
|
||||||
|
- alert: PostgresDiskUsageHigh
|
||||||
|
expr: |
|
||||||
|
(pg_database_size_bytes / (1024 * 1024 * 1024)) > 10
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "PostgreSQL database size > 10GB"
|
||||||
|
description: "Database {{ $labels.datname }} is {{ $value | humanize }}GB"
|
||||||
|
|
||||||
|
- alert: DiskSpaceHigh
|
||||||
|
expr: |
|
||||||
|
(node_filesystem_size_bytes - node_filesystem_free_bytes) / node_filesystem_size_bytes * 100
|
||||||
|
> 80
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Disk usage > 80% on {{ $labels.instance }}"
|
||||||
|
description: "{{ $labels.mountpoint }} is at {{ $value | humanizePercentage }}"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# ML Prediction Alerts
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
- name: ml_alerts
|
||||||
|
rules:
|
||||||
|
- alert: MLAccuracyDegraded
|
||||||
|
expr: ml_prediction_accuracy_ratio{accuracy_type="top1"} < 0.30
|
||||||
|
for: 60m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "ML top-1 accuracy below 30%"
|
||||||
|
description: "Current accuracy: {{ $value | humanizePercentage }}"
|
||||||
|
|
||||||
|
- alert: MLPredictionDriftHigh
|
||||||
|
expr: ml_prediction_drift_score > 0.5
|
||||||
|
for: 30m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "ML feature drift detected"
|
||||||
|
description: "Drift score for {{ $labels.feature_group }}: {{ $value }}"
|
||||||
|
|
||||||
|
- alert: NoPredictionsGenerated
|
||||||
|
expr: increase(ml_predictions_total[1h]) == 0
|
||||||
|
for: 2h
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "No ML predictions generated in the last 2 hours"
|
||||||
|
description: "Check if the scheduler is running and PMU data is being scraped"
|
||||||
68
infra/prometheus/prometheus.yml
Normal file
68
infra/prometheus/prometheus.yml
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Prometheus Configuration — Turf SaaS
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
evaluation_interval: 15s
|
||||||
|
external_labels:
|
||||||
|
project: turf-saas
|
||||||
|
env: production
|
||||||
|
|
||||||
|
# Alertmanager — wire up when available
|
||||||
|
alerting:
|
||||||
|
alertmanagers:
|
||||||
|
- static_configs:
|
||||||
|
- targets: []
|
||||||
|
|
||||||
|
# Load alert rules
|
||||||
|
rule_files:
|
||||||
|
- "alerts.yml"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Scrape targets
|
||||||
|
# ============================================================
|
||||||
|
scrape_configs:
|
||||||
|
# Prometheus self-monitoring
|
||||||
|
- job_name: prometheus
|
||||||
|
static_configs:
|
||||||
|
- targets: [localhost:9090]
|
||||||
|
|
||||||
|
# Combined API
|
||||||
|
- job_name: combined-api
|
||||||
|
static_configs:
|
||||||
|
- targets: [combined-api:8790]
|
||||||
|
metrics_path: /metrics
|
||||||
|
scrape_interval: 15s
|
||||||
|
|
||||||
|
# Dashboard API
|
||||||
|
- job_name: dashboard-api
|
||||||
|
static_configs:
|
||||||
|
- targets: [dashboard-api:8791]
|
||||||
|
metrics_path: /metrics
|
||||||
|
scrape_interval: 15s
|
||||||
|
|
||||||
|
# Portal
|
||||||
|
- job_name: portal
|
||||||
|
static_configs:
|
||||||
|
- targets: [portal:8792]
|
||||||
|
metrics_path: /metrics
|
||||||
|
scrape_interval: 30s
|
||||||
|
|
||||||
|
# PostgreSQL exporter (if deployed)
|
||||||
|
- job_name: postgres
|
||||||
|
static_configs:
|
||||||
|
- targets: [postgres-exporter:9187]
|
||||||
|
scrape_interval: 30s
|
||||||
|
|
||||||
|
# Redis exporter (if deployed)
|
||||||
|
- job_name: redis
|
||||||
|
static_configs:
|
||||||
|
- targets: [redis-exporter:9121]
|
||||||
|
scrape_interval: 30s
|
||||||
|
|
||||||
|
# Node exporter (host metrics)
|
||||||
|
- job_name: node
|
||||||
|
static_configs:
|
||||||
|
- targets: [host.docker.internal:9100]
|
||||||
|
scrape_interval: 30s
|
||||||
45
infra/scripts/backup_db.sh
Executable file
45
infra/scripts/backup_db.sh
Executable file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ============================================================
|
||||||
|
# Automated PostgreSQL Backup Script
|
||||||
|
# Run daily via cron: 0 2 * * * /opt/turf-saas/infra/scripts/backup_db.sh
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-/opt/backups/turf-saas}"
|
||||||
|
KEEP_DAYS="${KEEP_DAYS:-30}"
|
||||||
|
DB_NAME="${POSTGRES_DB:-turf_saas}"
|
||||||
|
DB_USER="${POSTGRES_USER:-turf}"
|
||||||
|
DB_HOST="${POSTGRES_HOST:-postgres}"
|
||||||
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||||
|
BACKUP_FILE="${BACKUP_DIR}/turf_saas_${TIMESTAMP}.sql.gz"
|
||||||
|
|
||||||
|
echo "[$(date -Iseconds)] Starting backup: ${BACKUP_FILE}"
|
||||||
|
|
||||||
|
# Ensure backup directory exists
|
||||||
|
mkdir -p "${BACKUP_DIR}"
|
||||||
|
|
||||||
|
# Perform backup
|
||||||
|
PGPASSWORD="${POSTGRES_PASSWORD}" pg_dump \
|
||||||
|
-h "${DB_HOST}" \
|
||||||
|
-U "${DB_USER}" \
|
||||||
|
-d "${DB_NAME}" \
|
||||||
|
--no-owner \
|
||||||
|
--no-acl \
|
||||||
|
| gzip > "${BACKUP_FILE}"
|
||||||
|
|
||||||
|
SIZE=$(du -sh "${BACKUP_FILE}" | cut -f1)
|
||||||
|
echo "[$(date -Iseconds)] Backup complete: ${BACKUP_FILE} (${SIZE})"
|
||||||
|
|
||||||
|
# Remove backups older than KEEP_DAYS
|
||||||
|
find "${BACKUP_DIR}" -name "turf_saas_*.sql.gz" -mtime "+${KEEP_DAYS}" -delete
|
||||||
|
echo "[$(date -Iseconds)] Old backups cleaned (kept last ${KEEP_DAYS} days)"
|
||||||
|
|
||||||
|
# Optional: notify on completion
|
||||||
|
if [ -n "${TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${TELEGRAM_CHAT_ID:-}" ]; then
|
||||||
|
curl -s -X POST \
|
||||||
|
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||||
|
-d chat_id="${TELEGRAM_CHAT_ID}" \
|
||||||
|
-d text="✅ DB Backup OK: turf_saas ${TIMESTAMP} (${SIZE})" \
|
||||||
|
> /dev/null || true
|
||||||
|
fi
|
||||||
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>
|
||||||
112
log_config.py
Normal file
112
log_config.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Structured JSON logging for Turf SaaS.
|
||||||
|
Replaces default Flask/Python logging with JSON output suitable for log aggregation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import traceback
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class JSONFormatter(logging.Formatter):
|
||||||
|
"""Format log records as JSON lines."""
|
||||||
|
|
||||||
|
def __init__(self, service_name: str = "turf-saas", env: str = "production"):
|
||||||
|
super().__init__()
|
||||||
|
self.service_name = service_name
|
||||||
|
self.env = env
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
log_entry = {
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"level": record.levelname,
|
||||||
|
"service": self.service_name,
|
||||||
|
"env": self.env,
|
||||||
|
"logger": record.name,
|
||||||
|
"message": record.getMessage(),
|
||||||
|
"module": record.module,
|
||||||
|
"function": record.funcName,
|
||||||
|
"line": record.lineno,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add extra fields if present
|
||||||
|
if hasattr(record, "request_id"):
|
||||||
|
log_entry["request_id"] = record.request_id
|
||||||
|
if hasattr(record, "user_id"):
|
||||||
|
log_entry["user_id"] = record.user_id
|
||||||
|
if hasattr(record, "duration_ms"):
|
||||||
|
log_entry["duration_ms"] = record.duration_ms
|
||||||
|
if hasattr(record, "status_code"):
|
||||||
|
log_entry["status_code"] = record.status_code
|
||||||
|
if hasattr(record, "endpoint"):
|
||||||
|
log_entry["endpoint"] = record.endpoint
|
||||||
|
|
||||||
|
# Exception info
|
||||||
|
if record.exc_info:
|
||||||
|
log_entry["exception"] = {
|
||||||
|
"type": record.exc_info[0].__name__ if record.exc_info[0] else None,
|
||||||
|
"message": str(record.exc_info[1]) if record.exc_info[1] else None,
|
||||||
|
"traceback": traceback.format_exception(*record.exc_info),
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.dumps(log_entry, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(
|
||||||
|
service_name: str = "turf-saas",
|
||||||
|
level: Optional[str] = None,
|
||||||
|
use_json: bool = True,
|
||||||
|
) -> logging.Logger:
|
||||||
|
"""
|
||||||
|
Configure root logger with JSON or plain formatting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service_name: Service name embedded in each log record.
|
||||||
|
level: Log level (default: from LOG_LEVEL env var, fallback INFO).
|
||||||
|
use_json: Use JSON formatter (True in production, False in dev).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Root logger.
|
||||||
|
"""
|
||||||
|
log_level = level or os.environ.get("LOG_LEVEL", "INFO")
|
||||||
|
env = os.environ.get("FLASK_ENV", "production")
|
||||||
|
|
||||||
|
# Force plain text in dev/testing
|
||||||
|
if env in ("development", "testing"):
|
||||||
|
use_json = False
|
||||||
|
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
root_logger.setLevel(getattr(logging, log_level.upper(), logging.INFO))
|
||||||
|
|
||||||
|
# Remove existing handlers
|
||||||
|
root_logger.handlers.clear()
|
||||||
|
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
|
||||||
|
if use_json:
|
||||||
|
handler.setFormatter(JSONFormatter(service_name=service_name, env=env))
|
||||||
|
else:
|
||||||
|
handler.setFormatter(
|
||||||
|
logging.Formatter(
|
||||||
|
fmt="%(asctime)s [%(levelname)s] %(name)s — %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
root_logger.addHandler(handler)
|
||||||
|
|
||||||
|
# Silence noisy third-party loggers
|
||||||
|
for noisy in ["werkzeug", "urllib3", "requests", "gunicorn.access"]:
|
||||||
|
logging.getLogger(noisy).setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
return root_logger
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str) -> logging.Logger:
|
||||||
|
"""Get a named logger."""
|
||||||
|
return logging.getLogger(name)
|
||||||
182
login.html
Normal file
182
login.html
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Connexion — Turf IA</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
:root {
|
||||||
|
--green: #00c853; --green-d: #009624;
|
||||||
|
--dark: #0d1117; --dark2: #161b22; --dark3: #21262d;
|
||||||
|
--text: #e6edf3; --muted: #8b949e; --border: #30363d;
|
||||||
|
--error: #f85149; --radius: 10px;
|
||||||
|
}
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); min-height: 100vh; display: flex; flex-direction: column; }
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
nav { display: flex; align-items: center; justify-content: space-between; padding: 16px 5%; border-bottom: 1px solid var(--border); }
|
||||||
|
.nav-logo { font-weight: 700; font-size: 1.1rem; }
|
||||||
|
.nav-link { color: var(--muted); font-size: .9rem; }
|
||||||
|
.nav-link:hover { color: var(--text); }
|
||||||
|
|
||||||
|
main { flex: 1; display: flex; align-items: center; justify-content: center; padding: 40px 20px; }
|
||||||
|
.auth-card {
|
||||||
|
width: 100%; max-width: 420px;
|
||||||
|
background: var(--dark2); border: 1px solid var(--border);
|
||||||
|
border-radius: 14px; padding: 40px;
|
||||||
|
}
|
||||||
|
.auth-title { font-size: 1.5rem; font-weight: 800; margin-bottom: 6px; }
|
||||||
|
.auth-subtitle { color: var(--muted); font-size: .9rem; margin-bottom: 28px; }
|
||||||
|
.form-group { margin-bottom: 18px; }
|
||||||
|
label { display: block; font-size: .85rem; font-weight: 600; color: var(--muted); margin-bottom: 6px; }
|
||||||
|
input {
|
||||||
|
width: 100%; padding: 11px 14px; background: var(--dark3);
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
color: var(--text); font-size: .95rem; outline: none; transition: border-color .2s;
|
||||||
|
}
|
||||||
|
input:focus { border-color: var(--green); }
|
||||||
|
input.error { border-color: var(--error); }
|
||||||
|
.field-error { color: var(--error); font-size: .8rem; margin-top: 4px; display: none; }
|
||||||
|
.field-error.show { display: block; }
|
||||||
|
.forgot { float: right; font-size: .82rem; color: var(--muted); }
|
||||||
|
.forgot:hover { color: var(--text); }
|
||||||
|
.btn {
|
||||||
|
width: 100%; padding: 12px; border: none; border-radius: var(--radius);
|
||||||
|
font-size: 1rem; font-weight: 700; cursor: pointer; transition: all .2s;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.btn-primary { background: var(--green); color: #000; }
|
||||||
|
.btn-primary:hover { background: var(--green-d); }
|
||||||
|
.btn-primary:disabled { opacity: .6; cursor: not-allowed; }
|
||||||
|
.divider { display: flex; align-items: center; gap: 12px; margin: 20px 0; color: var(--muted); font-size: .82rem; }
|
||||||
|
.divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||||
|
.auth-footer { text-align: center; margin-top: 20px; color: var(--muted); font-size: .9rem; }
|
||||||
|
.auth-footer a { color: var(--green); font-weight: 600; }
|
||||||
|
.alert { padding: 12px 16px; border-radius: var(--radius); font-size: .88rem; margin-bottom: 18px; display: none; }
|
||||||
|
.alert.show { display: block; }
|
||||||
|
.alert-error { background: rgba(248,81,73,.12); border: 1px solid rgba(248,81,73,.3); color: #f85149; }
|
||||||
|
.loader { display: inline-block; width: 16px; height: 16px; border: 2px solid rgba(0,0,0,.3); border-top-color: #000; border-radius: 50%; animation: spin .7s linear infinite; vertical-align: middle; margin-right: 6px; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
footer { text-align: center; padding: 20px; color: var(--muted); font-size: .8rem; border-top: 1px solid var(--border); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<a href="/" class="nav-logo">🏇 Turf IA</a>
|
||||||
|
<a href="/register" class="nav-link">Pas encore de compte ? S'inscrire →</a>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
<div class="auth-card">
|
||||||
|
<h1 class="auth-title">Bon retour !</h1>
|
||||||
|
<p class="auth-subtitle">Connectez-vous à votre compte Turf IA.</p>
|
||||||
|
|
||||||
|
<div class="alert alert-error" id="alert-error"></div>
|
||||||
|
|
||||||
|
<form id="login-form" novalidate>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Adresse email</label>
|
||||||
|
<input type="email" id="email" name="email" placeholder="vous@exemple.fr" autocomplete="email" required>
|
||||||
|
<div class="field-error" id="email-error">Email invalide.</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">
|
||||||
|
Mot de passe
|
||||||
|
<a href="/forgot-password" class="forgot">Mot de passe oublié ?</a>
|
||||||
|
</label>
|
||||||
|
<input type="password" id="password" name="password" placeholder="••••••••" autocomplete="current-password" required>
|
||||||
|
<div class="field-error" id="password-error">Mot de passe requis.</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" id="submit-btn">Se connecter</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="divider">ou</div>
|
||||||
|
<div class="auth-footer">
|
||||||
|
Pas encore de compte ? <a href="/register">Créer un compte gratuit</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<footer>© 2026 Turf IA — H3R7 Tech. Jouez de façon responsable. 18+</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = '/api/v1';
|
||||||
|
|
||||||
|
const form = document.getElementById('login-form');
|
||||||
|
const emailInput = document.getElementById('email');
|
||||||
|
const passInput = document.getElementById('password');
|
||||||
|
const submitBtn = document.getElementById('submit-btn');
|
||||||
|
const alertBox = document.getElementById('alert-error');
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
alertBox.textContent = msg;
|
||||||
|
alertBox.classList.add('show');
|
||||||
|
}
|
||||||
|
function hideError() { alertBox.classList.remove('show'); }
|
||||||
|
|
||||||
|
function setLoading(on) {
|
||||||
|
submitBtn.disabled = on;
|
||||||
|
submitBtn.innerHTML = on ? '<span class="loader"></span>Connexion…' : 'Se connecter';
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateField(input, errId, condition, msg) {
|
||||||
|
const err = document.getElementById(errId);
|
||||||
|
if (condition) {
|
||||||
|
input.classList.add('error');
|
||||||
|
err.textContent = msg;
|
||||||
|
err.classList.add('show');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
input.classList.remove('error');
|
||||||
|
err.classList.remove('show');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
hideError();
|
||||||
|
|
||||||
|
const email = emailInput.value.trim();
|
||||||
|
const pass = passInput.value;
|
||||||
|
|
||||||
|
const v1 = validateField(emailInput, 'email-error', !email || !/^[^@]+@[^@]+\.[^@]+$/.test(email), 'Adresse email invalide.');
|
||||||
|
const v2 = validateField(passInput, 'password-error', !pass, 'Mot de passe requis.');
|
||||||
|
if (!v1 || !v2) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password: pass })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
showError(data.error || 'Identifiants incorrects. Veuillez réessayer.');
|
||||||
|
} else {
|
||||||
|
localStorage.setItem('turf_token', data.token);
|
||||||
|
localStorage.setItem('turf_user', JSON.stringify(data.user));
|
||||||
|
const next = new URLSearchParams(location.search).get('next') || '/dashboard';
|
||||||
|
location.href = next;
|
||||||
|
}
|
||||||
|
} catch(_) {
|
||||||
|
showError('Erreur de connexion. Vérifiez votre réseau.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove errors on typing
|
||||||
|
[emailInput, passInput].forEach(el => el.addEventListener('input', () => {
|
||||||
|
el.classList.remove('error');
|
||||||
|
document.getElementById(el.id + '-error')?.classList.remove('show');
|
||||||
|
hideError();
|
||||||
|
}));
|
||||||
|
|
||||||
|
// If already logged in, redirect
|
||||||
|
(function() {
|
||||||
|
const token = localStorage.getItem('turf_token');
|
||||||
|
if (token) location.href = '/dashboard';
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
255
metrics.py
Normal file
255
metrics.py
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Prometheus metrics instrumentation for Turf SaaS.
|
||||||
|
Import this module in Flask apps to expose /metrics endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import functools
|
||||||
|
import logging
|
||||||
|
from typing import Callable, Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
from prometheus_client import (
|
||||||
|
Counter,
|
||||||
|
Histogram,
|
||||||
|
Gauge,
|
||||||
|
Summary,
|
||||||
|
generate_latest,
|
||||||
|
CONTENT_TYPE_LATEST,
|
||||||
|
CollectorRegistry,
|
||||||
|
multiprocess,
|
||||||
|
REGISTRY,
|
||||||
|
)
|
||||||
|
|
||||||
|
PROMETHEUS_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
PROMETHEUS_AVAILABLE = False
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Metric definitions
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
if PROMETHEUS_AVAILABLE:
|
||||||
|
# HTTP metrics
|
||||||
|
HTTP_REQUESTS_TOTAL = Counter(
|
||||||
|
"http_requests_total",
|
||||||
|
"Total number of HTTP requests",
|
||||||
|
["method", "endpoint", "status_code", "service"],
|
||||||
|
)
|
||||||
|
|
||||||
|
HTTP_REQUEST_DURATION = Histogram(
|
||||||
|
"http_request_duration_seconds",
|
||||||
|
"HTTP request duration in seconds",
|
||||||
|
["method", "endpoint", "service"],
|
||||||
|
buckets=[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0],
|
||||||
|
)
|
||||||
|
|
||||||
|
HTTP_REQUESTS_IN_PROGRESS = Gauge(
|
||||||
|
"http_requests_in_progress",
|
||||||
|
"Number of HTTP requests currently being processed",
|
||||||
|
["method", "endpoint", "service"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ML prediction metrics
|
||||||
|
ML_PREDICTIONS_TOTAL = Counter(
|
||||||
|
"ml_predictions_total",
|
||||||
|
"Total ML prediction requests",
|
||||||
|
["model_type", "race_type"],
|
||||||
|
)
|
||||||
|
|
||||||
|
ML_PREDICTION_DURATION = Histogram(
|
||||||
|
"ml_prediction_duration_seconds",
|
||||||
|
"ML prediction duration in seconds",
|
||||||
|
["model_type"],
|
||||||
|
buckets=[0.01, 0.05, 0.1, 0.5, 1.0, 2.0, 5.0],
|
||||||
|
)
|
||||||
|
|
||||||
|
ML_PREDICTION_ACCURACY = Gauge(
|
||||||
|
"ml_prediction_accuracy_ratio",
|
||||||
|
"Rolling ML prediction accuracy (top-1, top-3)",
|
||||||
|
["accuracy_type"],
|
||||||
|
)
|
||||||
|
|
||||||
|
ML_PREDICTION_DRIFT = Gauge(
|
||||||
|
"ml_prediction_drift_score",
|
||||||
|
"Feature drift score for ML models (0=no drift, 1=full drift)",
|
||||||
|
["feature_group"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Database metrics
|
||||||
|
DB_QUERIES_TOTAL = Counter(
|
||||||
|
"db_queries_total", "Total database queries", ["operation", "table"]
|
||||||
|
)
|
||||||
|
|
||||||
|
DB_QUERY_DURATION = Histogram(
|
||||||
|
"db_query_duration_seconds",
|
||||||
|
"Database query duration",
|
||||||
|
["operation"],
|
||||||
|
buckets=[0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0],
|
||||||
|
)
|
||||||
|
|
||||||
|
DB_CONNECTION_POOL_SIZE = Gauge(
|
||||||
|
"db_connection_pool_size", "Current database connection pool size"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Business metrics
|
||||||
|
RACES_SCRAPED_TOTAL = Counter(
|
||||||
|
"races_scraped_total", "Total number of races scraped", ["source", "discipline"]
|
||||||
|
)
|
||||||
|
|
||||||
|
PREDICTIONS_ACCURACY_DAILY = Gauge(
|
||||||
|
"predictions_accuracy_daily_ratio",
|
||||||
|
"Daily prediction accuracy ratio",
|
||||||
|
["date", "race_type"],
|
||||||
|
)
|
||||||
|
|
||||||
|
ACTIVE_SUBSCRIPTIONS = Gauge(
|
||||||
|
"active_subscriptions_total", "Number of active SaaS subscriptions", ["plan"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# App health
|
||||||
|
APP_INFO = Gauge(
|
||||||
|
"app_info", "Application build information", ["version", "service", "env"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Flask integration
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
def init_metrics(app, service_name: str = "unknown"):
|
||||||
|
"""
|
||||||
|
Register Prometheus metrics middleware on a Flask app.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from metrics import init_metrics
|
||||||
|
init_metrics(app, service_name="combined-api")
|
||||||
|
"""
|
||||||
|
if not PROMETHEUS_AVAILABLE:
|
||||||
|
logger.warning("prometheus_client not installed — metrics disabled")
|
||||||
|
return
|
||||||
|
|
||||||
|
from flask import request, Response
|
||||||
|
|
||||||
|
# Set app info gauge
|
||||||
|
APP_INFO.labels(
|
||||||
|
version=app.config.get("VERSION", "unknown"),
|
||||||
|
service=service_name,
|
||||||
|
env=app.config.get("ENV", "unknown"),
|
||||||
|
).set(1)
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def before_request():
|
||||||
|
request._start_time = time.time()
|
||||||
|
HTTP_REQUESTS_IN_PROGRESS.labels(
|
||||||
|
method=request.method, endpoint=request.path, service=service_name
|
||||||
|
).inc()
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def after_request(response):
|
||||||
|
duration = time.time() - getattr(request, "_start_time", time.time())
|
||||||
|
endpoint = request.path
|
||||||
|
|
||||||
|
HTTP_REQUESTS_TOTAL.labels(
|
||||||
|
method=request.method,
|
||||||
|
endpoint=endpoint,
|
||||||
|
status_code=str(response.status_code),
|
||||||
|
service=service_name,
|
||||||
|
).inc()
|
||||||
|
|
||||||
|
HTTP_REQUEST_DURATION.labels(
|
||||||
|
method=request.method, endpoint=endpoint, service=service_name
|
||||||
|
).observe(duration)
|
||||||
|
|
||||||
|
HTTP_REQUESTS_IN_PROGRESS.labels(
|
||||||
|
method=request.method, endpoint=endpoint, service=service_name
|
||||||
|
).dec()
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
@app.route("/metrics")
|
||||||
|
def metrics_endpoint():
|
||||||
|
"""Prometheus metrics scrape endpoint."""
|
||||||
|
return Response(generate_latest(), mimetype=CONTENT_TYPE_LATEST)
|
||||||
|
|
||||||
|
@app.route("/health")
|
||||||
|
def health_endpoint():
|
||||||
|
"""Docker / load-balancer health check endpoint."""
|
||||||
|
from flask import jsonify
|
||||||
|
|
||||||
|
return jsonify({"status": "ok", "service": service_name})
|
||||||
|
|
||||||
|
logger.info(f"Prometheus metrics initialized for service: {service_name}")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Decorator helpers
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
def track_ml_prediction(model_type: str = "xgboost", race_type: str = "flat"):
|
||||||
|
"""Decorator to track ML prediction calls."""
|
||||||
|
|
||||||
|
def decorator(func: Callable) -> Callable:
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
if not PROMETHEUS_AVAILABLE:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
start = time.time()
|
||||||
|
try:
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
ML_PREDICTIONS_TOTAL.labels(
|
||||||
|
model_type=model_type, race_type=race_type
|
||||||
|
).inc()
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
ML_PREDICTION_DURATION.labels(model_type=model_type).observe(
|
||||||
|
time.time() - start
|
||||||
|
)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def track_db_query(operation: str = "select", table: str = "unknown"):
|
||||||
|
"""Decorator to track DB query calls."""
|
||||||
|
|
||||||
|
def decorator(func: Callable) -> Callable:
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
if not PROMETHEUS_AVAILABLE:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
start = time.time()
|
||||||
|
try:
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
DB_QUERIES_TOTAL.labels(operation=operation, table=table).inc()
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
DB_QUERY_DURATION.labels(operation=operation).observe(
|
||||||
|
time.time() - start
|
||||||
|
)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def update_ml_accuracy(top1_accuracy: float, top3_accuracy: float):
|
||||||
|
"""Update ML accuracy gauges (call from scheduler)."""
|
||||||
|
if not PROMETHEUS_AVAILABLE:
|
||||||
|
return
|
||||||
|
ML_PREDICTION_ACCURACY.labels(accuracy_type="top1").set(top1_accuracy)
|
||||||
|
ML_PREDICTION_ACCURACY.labels(accuracy_type="top3").set(top3_accuracy)
|
||||||
|
|
||||||
|
|
||||||
|
def update_subscription_count(plan_counts: dict):
|
||||||
|
"""Update subscription count gauges."""
|
||||||
|
if not PROMETHEUS_AVAILABLE:
|
||||||
|
return
|
||||||
|
for plan, count in plan_counts.items():
|
||||||
|
ACTIVE_SUBSCRIPTIONS.labels(plan=plan).set(count)
|
||||||
1
migrations/README
Normal file
1
migrations/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration file
|
||||||
68
migrations/env.py
Normal file
68
migrations/env.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""Alembic env.py — Turf SaaS database migrations."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# Alembic Config object — gives access to .ini values
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Set logging from config
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
|
||||||
|
# Override sqlalchemy.url from environment variables
|
||||||
|
def get_db_url():
|
||||||
|
user = os.environ.get("POSTGRES_USER", "turf")
|
||||||
|
password = os.environ.get("POSTGRES_PASSWORD", "")
|
||||||
|
host = os.environ.get("POSTGRES_HOST", "localhost")
|
||||||
|
port = os.environ.get("POSTGRES_PORT", "5432")
|
||||||
|
db = os.environ.get("POSTGRES_DB", "turf_saas")
|
||||||
|
url = os.environ.get(
|
||||||
|
"DATABASE_URL", f"postgresql://{user}:{password}@{host}:{port}/{db}"
|
||||||
|
)
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
config.set_main_option("sqlalchemy.url", get_db_url())
|
||||||
|
|
||||||
|
# No declarative model — we use raw DDL migrations
|
||||||
|
target_metadata = None
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode (no live DB connection needed)."""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode (uses live DB connection)."""
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
180
migrations/migrate_sqlite_to_postgres.py
Normal file
180
migrations/migrate_sqlite_to_postgres.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SQLite → PostgreSQL Data Migration Script
|
||||||
|
Migrates existing turf_saas.db data to PostgreSQL.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python migrations/migrate_sqlite_to_postgres.py \
|
||||||
|
--sqlite /path/to/turf_saas.db \
|
||||||
|
--pg-url postgresql://turf:password@localhost:5432/turf_saas
|
||||||
|
|
||||||
|
Run AFTER alembic upgrade head.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
logger = logging.getLogger("migrate")
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
|
||||||
|
|
||||||
|
# Tables to migrate (in order to respect FK constraints)
|
||||||
|
TABLES = [
|
||||||
|
"predictions",
|
||||||
|
"results",
|
||||||
|
"performance",
|
||||||
|
"scraping_logs",
|
||||||
|
"pmu_reunions",
|
||||||
|
"pmu_meteo",
|
||||||
|
"pmu_courses",
|
||||||
|
"pmu_partants",
|
||||||
|
"ml_predictions_cache",
|
||||||
|
"users",
|
||||||
|
"subscriptions",
|
||||||
|
"refresh_tokens",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_sqlite_conn(sqlite_path: str):
|
||||||
|
conn = sqlite3.connect(sqlite_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def get_pg_conn(pg_url: str):
|
||||||
|
try:
|
||||||
|
import psycopg2
|
||||||
|
import psycopg2.extras
|
||||||
|
|
||||||
|
conn = psycopg2.connect(pg_url)
|
||||||
|
return conn
|
||||||
|
except ImportError:
|
||||||
|
logger.error("psycopg2 not installed. Run: pip install psycopg2-binary")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_table(sqlite_conn, pg_conn, table: str, batch_size: int = 500) -> int:
|
||||||
|
"""Migrate a single table from SQLite to PostgreSQL. Returns row count."""
|
||||||
|
import psycopg2.extras
|
||||||
|
|
||||||
|
sqlite_cur = sqlite_conn.cursor()
|
||||||
|
pg_cur = pg_conn.cursor()
|
||||||
|
|
||||||
|
# Get rows from SQLite
|
||||||
|
try:
|
||||||
|
sqlite_cur.execute(f"SELECT * FROM {table}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f" Skipping {table}: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
rows = sqlite_cur.fetchall()
|
||||||
|
if not rows:
|
||||||
|
logger.info(f" {table}: empty — skipping")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Get column names
|
||||||
|
columns = [desc[0] for desc in sqlite_cur.description]
|
||||||
|
# Exclude 'id' to let PostgreSQL generate SERIAL
|
||||||
|
non_id_columns = [c for c in columns if c != "id"]
|
||||||
|
|
||||||
|
if not non_id_columns:
|
||||||
|
logger.warning(f" {table}: no columns to insert")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
placeholders = ", ".join(["%s"] * len(non_id_columns))
|
||||||
|
col_list = ", ".join(non_id_columns)
|
||||||
|
insert_sql = f"INSERT INTO {table} ({col_list}) VALUES ({placeholders}) ON CONFLICT DO NOTHING"
|
||||||
|
|
||||||
|
inserted = 0
|
||||||
|
batch = []
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
row_dict = dict(row)
|
||||||
|
values = tuple(row_dict.get(c) for c in non_id_columns)
|
||||||
|
batch.append(values)
|
||||||
|
|
||||||
|
if len(batch) >= batch_size:
|
||||||
|
try:
|
||||||
|
pg_cur.executemany(insert_sql, batch)
|
||||||
|
pg_conn.commit()
|
||||||
|
inserted += len(batch)
|
||||||
|
except Exception as e:
|
||||||
|
pg_conn.rollback()
|
||||||
|
logger.error(f" {table} batch error: {e}")
|
||||||
|
batch = []
|
||||||
|
|
||||||
|
# Final batch
|
||||||
|
if batch:
|
||||||
|
try:
|
||||||
|
pg_cur.executemany(insert_sql, batch)
|
||||||
|
pg_conn.commit()
|
||||||
|
inserted += len(batch)
|
||||||
|
except Exception as e:
|
||||||
|
pg_conn.rollback()
|
||||||
|
logger.error(f" {table} final batch error: {e}")
|
||||||
|
|
||||||
|
# Sync PostgreSQL sequence to max id
|
||||||
|
try:
|
||||||
|
pg_cur.execute(f"SELECT MAX(id) FROM {table}")
|
||||||
|
max_id = pg_cur.fetchone()[0]
|
||||||
|
if max_id:
|
||||||
|
seq_name = f"{table}_id_seq"
|
||||||
|
pg_cur.execute(f"SELECT setval('{seq_name}', {max_id})")
|
||||||
|
pg_conn.commit()
|
||||||
|
except Exception:
|
||||||
|
pass # Table may not have a sequence
|
||||||
|
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
|
||||||
|
def run_migration(sqlite_path: str, pg_url: str):
|
||||||
|
logger.info(f"=== SQLite → PostgreSQL Migration ===")
|
||||||
|
logger.info(f"SQLite: {sqlite_path}")
|
||||||
|
logger.info(f"PostgreSQL: {pg_url.split('@')[-1]}") # Hide credentials in log
|
||||||
|
logger.info(f"Started: {datetime.now().isoformat()}")
|
||||||
|
|
||||||
|
if not os.path.exists(sqlite_path):
|
||||||
|
logger.error(f"SQLite file not found: {sqlite_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
sqlite_conn = get_sqlite_conn(sqlite_path)
|
||||||
|
pg_conn = get_pg_conn(pg_url)
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for table in TABLES:
|
||||||
|
logger.info(f" Migrating: {table}...")
|
||||||
|
count = migrate_table(sqlite_conn, pg_conn, table)
|
||||||
|
logger.info(f" → {table}: {count} rows migrated")
|
||||||
|
total += count
|
||||||
|
|
||||||
|
sqlite_conn.close()
|
||||||
|
pg_conn.close()
|
||||||
|
|
||||||
|
logger.info(f"=== Migration complete: {total} total rows ===")
|
||||||
|
logger.info(f"Finished: {datetime.now().isoformat()}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description="Migrate SQLite → PostgreSQL")
|
||||||
|
parser.add_argument(
|
||||||
|
"--sqlite",
|
||||||
|
default=os.environ.get("DB_PATH", "/home/h3r7/turf_saas/turf_saas.db"),
|
||||||
|
help="Path to SQLite database file",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--pg-url",
|
||||||
|
default=os.environ.get("DATABASE_URL", ""),
|
||||||
|
help="PostgreSQL connection URL",
|
||||||
|
)
|
||||||
|
parser.add_argument("--batch-size", type=int, default=500)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.pg_url:
|
||||||
|
logger.error("--pg-url or DATABASE_URL env var required")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
run_migration(args.sqlite, args.pg_url)
|
||||||
26
migrations/script.py.mako
Normal file
26
migrations/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
345
migrations/versions/001_initial_schema.py
Normal file
345
migrations/versions/001_initial_schema.py
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
"""Initial schema — PostgreSQL migration from SQLite
|
||||||
|
|
||||||
|
Revision ID: 001_initial_schema
|
||||||
|
Revises: None
|
||||||
|
Create Date: 2026-04-25
|
||||||
|
|
||||||
|
Full migration of turf_saas SQLite schema to PostgreSQL.
|
||||||
|
Tables: predictions, results, performance, scraping_logs,
|
||||||
|
pmu_reunions, pmu_meteo, pmu_courses, pmu_partants,
|
||||||
|
ml_predictions_cache, users, subscriptions, refresh_tokens
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers
|
||||||
|
revision: str = "001_initial_schema"
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# predictions
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
op.create_table(
|
||||||
|
"predictions",
|
||||||
|
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("date", sa.Text, nullable=False),
|
||||||
|
sa.Column("race_name", sa.Text),
|
||||||
|
sa.Column("race_hippodrome", sa.Text),
|
||||||
|
sa.Column("race_time", sa.Text),
|
||||||
|
sa.Column("horse_number", sa.Integer),
|
||||||
|
sa.Column("horse_name", sa.Text),
|
||||||
|
sa.Column("odds", sa.Numeric(10, 2)),
|
||||||
|
sa.Column("prediction_rank", sa.Integer),
|
||||||
|
sa.Column("source", sa.Text),
|
||||||
|
sa.Column("jockey", sa.Text),
|
||||||
|
sa.Column("odds_time", sa.Text),
|
||||||
|
sa.Column("odds_prev", sa.Numeric(10, 2)),
|
||||||
|
sa.Column("created_at", sa.TIMESTAMP, server_default=sa.text("NOW()")),
|
||||||
|
)
|
||||||
|
op.create_index("idx_predictions_date", "predictions", ["date"])
|
||||||
|
op.create_index("idx_predictions_horse", "predictions", ["horse_name"])
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# results
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
op.create_table(
|
||||||
|
"results",
|
||||||
|
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("date", sa.Text, nullable=False),
|
||||||
|
sa.Column("race_name", sa.Text),
|
||||||
|
sa.Column("race_hippodrome", sa.Text),
|
||||||
|
sa.Column("position", sa.Integer),
|
||||||
|
sa.Column("horse_name", sa.Text),
|
||||||
|
sa.Column("odds", sa.Numeric(10, 2)),
|
||||||
|
sa.Column("created_at", sa.TIMESTAMP, server_default=sa.text("NOW()")),
|
||||||
|
)
|
||||||
|
op.create_index("idx_results_date", "results", ["date"])
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# performance
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
op.create_table(
|
||||||
|
"performance",
|
||||||
|
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("prediction_date", sa.Text),
|
||||||
|
sa.Column("race_date", sa.Text),
|
||||||
|
sa.Column("horse_name", sa.Text),
|
||||||
|
sa.Column("predicted_rank", sa.Integer),
|
||||||
|
sa.Column("actual_position", sa.Integer),
|
||||||
|
sa.Column("hit", sa.Boolean),
|
||||||
|
sa.Column("created_at", sa.TIMESTAMP, server_default=sa.text("NOW()")),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# scraping_logs
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
op.create_table(
|
||||||
|
"scraping_logs",
|
||||||
|
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("timestamp", sa.Text),
|
||||||
|
sa.Column("runtime_sec", sa.Numeric(10, 3)),
|
||||||
|
sa.Column("total_pages", sa.Integer),
|
||||||
|
sa.Column("url", sa.Text),
|
||||||
|
sa.Column("site", sa.Text),
|
||||||
|
sa.Column("status", sa.Text),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# pmu_reunions
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
op.create_table(
|
||||||
|
"pmu_reunions",
|
||||||
|
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("date_programme", sa.Text, nullable=False),
|
||||||
|
sa.Column("num_reunion", sa.Integer, nullable=False),
|
||||||
|
sa.Column("num_externe", sa.Integer),
|
||||||
|
sa.Column("nature", sa.Text),
|
||||||
|
sa.Column("statut", sa.Text),
|
||||||
|
sa.Column("audience", sa.Text),
|
||||||
|
sa.Column("hippodrome_code", sa.Text),
|
||||||
|
sa.Column("hippodrome_court", sa.Text),
|
||||||
|
sa.Column("hippodrome_long", sa.Text),
|
||||||
|
sa.Column("pays_code", sa.Text),
|
||||||
|
sa.Column("pays_libelle", sa.Text),
|
||||||
|
sa.Column("created_at", sa.TIMESTAMP, server_default=sa.text("NOW()")),
|
||||||
|
sa.UniqueConstraint("date_programme", "num_reunion", name="uq_pmu_reunions"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_reunions_date", "pmu_reunions", ["date_programme"])
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# pmu_meteo
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
op.create_table(
|
||||||
|
"pmu_meteo",
|
||||||
|
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("date_programme", sa.Text, nullable=False),
|
||||||
|
sa.Column("num_reunion", sa.Integer, nullable=False),
|
||||||
|
sa.Column("nebulositecode", sa.Text),
|
||||||
|
sa.Column("nebulosite_court", sa.Text),
|
||||||
|
sa.Column("nebulosite_long", sa.Text),
|
||||||
|
sa.Column("temperature", sa.Integer),
|
||||||
|
sa.Column("force_vent", sa.Integer),
|
||||||
|
sa.Column("direction_vent", sa.Text),
|
||||||
|
sa.Column("date_prevision", sa.BigInteger),
|
||||||
|
sa.Column("created_at", sa.TIMESTAMP, server_default=sa.text("NOW()")),
|
||||||
|
sa.UniqueConstraint("date_programme", "num_reunion", name="uq_pmu_meteo"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# pmu_courses
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
op.create_table(
|
||||||
|
"pmu_courses",
|
||||||
|
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("date_programme", sa.Text, nullable=False),
|
||||||
|
sa.Column("num_reunion", sa.Integer, nullable=False),
|
||||||
|
sa.Column("num_course", sa.Integer, nullable=False),
|
||||||
|
sa.Column("num_externe", sa.Integer),
|
||||||
|
sa.Column("libelle", sa.Text),
|
||||||
|
sa.Column("libelle_court", sa.Text),
|
||||||
|
sa.Column("heure_depart", sa.BigInteger),
|
||||||
|
sa.Column("heure_depart_str", sa.Text),
|
||||||
|
sa.Column("distance", sa.Integer),
|
||||||
|
sa.Column("distance_unit", sa.Text),
|
||||||
|
sa.Column("parcours", sa.Text),
|
||||||
|
sa.Column("discipline", sa.Text),
|
||||||
|
sa.Column("specialite", sa.Text),
|
||||||
|
sa.Column("type_piste", sa.Text),
|
||||||
|
sa.Column("corde", sa.Text),
|
||||||
|
sa.Column("condition_age", sa.Text),
|
||||||
|
sa.Column("condition_sexe", sa.Text),
|
||||||
|
sa.Column("categorie_particularite", sa.Text),
|
||||||
|
sa.Column("nb_declares_partants", sa.Integer),
|
||||||
|
sa.Column("montant_prix", sa.Integer),
|
||||||
|
sa.Column("montant_1er", sa.Integer),
|
||||||
|
sa.Column("montant_2eme", sa.Integer),
|
||||||
|
sa.Column("montant_3eme", sa.Integer),
|
||||||
|
sa.Column("montant_4eme", sa.Integer),
|
||||||
|
sa.Column("montant_5eme", sa.Integer),
|
||||||
|
sa.Column("created_at", sa.TIMESTAMP, server_default=sa.text("NOW()")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"date_programme", "num_reunion", "num_course", name="uq_pmu_courses"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("idx_courses_date", "pmu_courses", ["date_programme"])
|
||||||
|
op.create_index("idx_courses_discipline", "pmu_courses", ["discipline"])
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# pmu_partants
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
op.create_table(
|
||||||
|
"pmu_partants",
|
||||||
|
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("date_programme", sa.Text, nullable=False),
|
||||||
|
sa.Column("num_reunion", sa.Integer, nullable=False),
|
||||||
|
sa.Column("num_course", sa.Integer, nullable=False),
|
||||||
|
sa.Column("num_pmu", sa.Integer),
|
||||||
|
sa.Column("id_cheval", sa.BigInteger),
|
||||||
|
sa.Column("nom", sa.Text),
|
||||||
|
sa.Column("age", sa.Integer),
|
||||||
|
sa.Column("sexe", sa.Text),
|
||||||
|
sa.Column("race", sa.Text),
|
||||||
|
sa.Column("robe", sa.Text),
|
||||||
|
sa.Column("pays", sa.Text),
|
||||||
|
sa.Column("place_corde", sa.Integer),
|
||||||
|
sa.Column("nom_pere", sa.Text),
|
||||||
|
sa.Column("nom_mere", sa.Text),
|
||||||
|
sa.Column("nom_pere_mere", sa.Text),
|
||||||
|
sa.Column("driver", sa.Text),
|
||||||
|
sa.Column("driver_change", sa.Boolean),
|
||||||
|
sa.Column("entraineur", sa.Text),
|
||||||
|
sa.Column("proprietaire", sa.Text),
|
||||||
|
sa.Column("eleveur", sa.Text),
|
||||||
|
sa.Column("oeilleres", sa.Text),
|
||||||
|
sa.Column("supplement", sa.Boolean),
|
||||||
|
sa.Column("handicap_valeur", sa.Numeric(8, 2)),
|
||||||
|
sa.Column("handicap_poids", sa.Numeric(8, 2)),
|
||||||
|
sa.Column("musique", sa.Text),
|
||||||
|
sa.Column("nombre_courses", sa.Integer),
|
||||||
|
sa.Column("nombre_victoires", sa.Integer),
|
||||||
|
sa.Column("nombre_places", sa.Integer),
|
||||||
|
sa.Column("cote_direct", sa.Numeric(10, 2)),
|
||||||
|
sa.Column("cote_reference", sa.Numeric(10, 2)),
|
||||||
|
sa.Column("tendance_cote", sa.Text),
|
||||||
|
sa.Column("favoris", sa.Boolean),
|
||||||
|
sa.Column("ordre_arrivee", sa.Integer),
|
||||||
|
sa.Column("tx_victoire", sa.Numeric(6, 3)),
|
||||||
|
sa.Column("tx_place", sa.Numeric(6, 3)),
|
||||||
|
sa.Column("forme_recente", sa.Text),
|
||||||
|
sa.Column("gains_carriere", sa.BigInteger),
|
||||||
|
sa.Column("gains_annee_en_cours", sa.BigInteger),
|
||||||
|
sa.Column("tendance_forme", sa.Text),
|
||||||
|
sa.Column("distance_cheval_prec", sa.Integer),
|
||||||
|
sa.Column("commentaire_apres_course", sa.Text),
|
||||||
|
sa.Column("pays_entrainement", sa.Text),
|
||||||
|
sa.Column("created_at", sa.TIMESTAMP, server_default=sa.text("NOW()")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"date_programme",
|
||||||
|
"num_reunion",
|
||||||
|
"num_course",
|
||||||
|
"num_pmu",
|
||||||
|
name="uq_pmu_partants",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("idx_partants_date", "pmu_partants", ["date_programme"])
|
||||||
|
op.create_index("idx_partants_nom", "pmu_partants", ["nom"])
|
||||||
|
op.create_index("idx_partants_entraineur", "pmu_partants", ["entraineur"])
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# ml_predictions_cache
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
op.create_table(
|
||||||
|
"ml_predictions_cache",
|
||||||
|
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("date", sa.Text, nullable=False),
|
||||||
|
sa.Column("num_reunion", sa.Integer),
|
||||||
|
sa.Column("num_course", sa.Integer),
|
||||||
|
sa.Column("horse_name", sa.Text),
|
||||||
|
sa.Column("horse_number", sa.Integer),
|
||||||
|
sa.Column("odds", sa.Numeric(10, 2)),
|
||||||
|
sa.Column("prob_top1", sa.Numeric(6, 4)),
|
||||||
|
sa.Column("prob_top3", sa.Numeric(6, 4)),
|
||||||
|
sa.Column("ml_score", sa.Numeric(6, 4)),
|
||||||
|
sa.Column("recommendation", sa.Text),
|
||||||
|
sa.Column("is_value_bet", sa.Integer, server_default="0"),
|
||||||
|
sa.Column("is_outlier", sa.Integer, server_default="0"),
|
||||||
|
sa.Column("race_label", sa.Text),
|
||||||
|
sa.Column("race_name", sa.Text),
|
||||||
|
sa.Column("hippodrome", sa.Text),
|
||||||
|
sa.Column("discipline", sa.Text),
|
||||||
|
sa.Column("distance", sa.Numeric(8, 1)),
|
||||||
|
sa.Column("heure", sa.Text),
|
||||||
|
sa.Column("model_version", sa.Text, server_default="'xgboost_v1'"),
|
||||||
|
sa.Column("risque_label", sa.Text, server_default="'neutral'"),
|
||||||
|
sa.Column("risque_score", sa.Integer, server_default="50"),
|
||||||
|
sa.Column("created_at", sa.TIMESTAMP, server_default=sa.text("NOW()")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"date", "num_reunion", "num_course", "horse_name", name="uq_ml_cache"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("idx_ml_cache_date", "ml_predictions_cache", ["date"])
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# users
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
op.create_table(
|
||||||
|
"users",
|
||||||
|
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("email", sa.Text, nullable=False, unique=True),
|
||||||
|
sa.Column("password_hash", sa.Text, nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"plan",
|
||||||
|
sa.Text,
|
||||||
|
nullable=False,
|
||||||
|
server_default="'free'",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.TIMESTAMP, nullable=False, server_default=sa.text("NOW()")
|
||||||
|
),
|
||||||
|
sa.Column("is_active", sa.Integer, nullable=False, server_default="1"),
|
||||||
|
sa.Column("daily_usage", sa.Integer, nullable=False, server_default="0"),
|
||||||
|
sa.Column("last_usage_date", sa.Text),
|
||||||
|
sa.CheckConstraint("plan IN ('free','premium','pro')", name="ck_users_plan"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_users_email", "users", ["email"], unique=True)
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# subscriptions
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
op.create_table(
|
||||||
|
"subscriptions",
|
||||||
|
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("user_id", sa.BigInteger, sa.ForeignKey("users.id"), nullable=False),
|
||||||
|
sa.Column("plan", sa.Text, nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"start_date", sa.TIMESTAMP, nullable=False, server_default=sa.text("NOW()")
|
||||||
|
),
|
||||||
|
sa.Column("end_date", sa.TIMESTAMP),
|
||||||
|
sa.Column("stripe_customer_id", sa.Text),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"plan IN ('free','premium','pro')", name="ck_subscriptions_plan"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("idx_subscriptions_user", "subscriptions", ["user_id"])
|
||||||
|
op.create_index("idx_subscriptions_stripe", "subscriptions", ["stripe_customer_id"])
|
||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# refresh_tokens
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
op.create_table(
|
||||||
|
"refresh_tokens",
|
||||||
|
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("user_id", sa.BigInteger, sa.ForeignKey("users.id"), nullable=False),
|
||||||
|
sa.Column("token_hash", sa.Text, nullable=False, unique=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.TIMESTAMP, nullable=False, server_default=sa.text("NOW()")
|
||||||
|
),
|
||||||
|
sa.Column("expires_at", sa.TIMESTAMP, nullable=False),
|
||||||
|
sa.Column("revoked", sa.Integer, nullable=False, server_default="0"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_refresh_tokens_user", "refresh_tokens", ["user_id"])
|
||||||
|
op.create_index(
|
||||||
|
"idx_refresh_tokens_hash", "refresh_tokens", ["token_hash"], unique=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("refresh_tokens")
|
||||||
|
op.drop_table("subscriptions")
|
||||||
|
op.drop_table("users")
|
||||||
|
op.drop_table("ml_predictions_cache")
|
||||||
|
op.drop_table("pmu_partants")
|
||||||
|
op.drop_table("pmu_courses")
|
||||||
|
op.drop_table("pmu_meteo")
|
||||||
|
op.drop_table("pmu_reunions")
|
||||||
|
op.drop_table("scraping_logs")
|
||||||
|
op.drop_table("performance")
|
||||||
|
op.drop_table("results")
|
||||||
|
op.drop_table("predictions")
|
||||||
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>
|
||||||
150
portal_server.py
150
portal_server.py
@@ -10,17 +10,72 @@ app = Flask(__name__)
|
|||||||
|
|
||||||
DASHBOARD_API_URL = "http://localhost:8791"
|
DASHBOARD_API_URL = "http://localhost:8791"
|
||||||
COMBINED_API_URL = "http://localhost:8790"
|
COMBINED_API_URL = "http://localhost:8790"
|
||||||
COMBINED_API_URL = "http://localhost:8790"
|
SAAS_DIR = "/home/h3r7/turf_saas"
|
||||||
|
|
||||||
|
# ─── SaaS Auth & API v1 blueprints ────────────────────────────────────────────
|
||||||
|
try:
|
||||||
|
from saas_auth import auth_bp
|
||||||
|
from saas_api_v1 import api_v1_bp
|
||||||
|
|
||||||
|
app.register_blueprint(auth_bp)
|
||||||
|
app.register_blueprint(api_v1_bp)
|
||||||
|
print("[portal] SaaS auth & API v1 blueprints registered ✅")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[portal] Warning: could not register SaaS blueprints: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Landing & SaaS pages ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/health")
|
||||||
|
def health():
|
||||||
|
"""Health check endpoint for Docker/load balancer. Returns 200 if app is running."""
|
||||||
|
return {"status": "ok", "service": "portal"}, 200
|
||||||
|
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def portal():
|
def landing():
|
||||||
return send_from_directory("/home/h3r7/turf_saas", "portail.html")
|
"""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")
|
@app.route("/favicon.ico")
|
||||||
def favicon():
|
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/", 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"])
|
@app.route("/prompts/<path:subpath>", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||||
@@ -269,9 +324,7 @@ def niches_business():
|
|||||||
|
|
||||||
@app.route("/template_restaurant_json.html")
|
@app.route("/template_restaurant_json.html")
|
||||||
def template_restaurant():
|
def template_restaurant():
|
||||||
return send_from_directory(
|
return send_from_directory("/home/h3r7/turf_saas", "template_restaurant_json.html")
|
||||||
"/home/h3r7/turf_saas", "template_restaurant_json.html"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/template_boulangerie_final.html")
|
@app.route("/template_boulangerie_final.html")
|
||||||
@@ -288,9 +341,7 @@ def template_artisan():
|
|||||||
|
|
||||||
@app.route("/template_restaurant_final.html")
|
@app.route("/template_restaurant_final.html")
|
||||||
def template_restaurant_final():
|
def template_restaurant_final():
|
||||||
return send_from_directory(
|
return send_from_directory("/home/h3r7/turf_saas", "template_restaurant_final.html")
|
||||||
"/home/h3r7/turf_saas", "template_restaurant_final.html"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/template_complet.html")
|
@app.route("/template_complet.html")
|
||||||
@@ -300,9 +351,7 @@ def template_complet():
|
|||||||
|
|
||||||
@app.route("/boite_a_idees_dashboard")
|
@app.route("/boite_a_idees_dashboard")
|
||||||
def boite_a_idees_dashboard():
|
def boite_a_idees_dashboard():
|
||||||
return send_from_directory(
|
return send_from_directory("/home/h3r7/turf_saas", "boite_a_idees_dashboard.html")
|
||||||
"/home/h3r7/turf_saas", "boite_a_idees_dashboard.html"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/datagouv_explorer.html")
|
@app.route("/datagouv_explorer.html")
|
||||||
@@ -345,13 +394,23 @@ def api_chat_workflows():
|
|||||||
return jsonify([dict(w) for w in workflows])
|
return jsonify([dict(w) for w in workflows])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/chat/nvidia-models", methods=["GET"])
|
@app.route("/api/chat/nvidia-models", methods=["GET"])
|
||||||
def api_nvidia_models():
|
def api_nvidia_models():
|
||||||
return jsonify([
|
return jsonify(
|
||||||
{"id": k, "name": v.split("/")[-1].replace("-instruct", "").replace("-", " ").title(), "full_id": v}
|
[
|
||||||
for k, v in NVIDIA_MODELS.items()
|
{
|
||||||
])
|
"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"])
|
@app.route("/api/chat/sessions", methods=["GET"])
|
||||||
@@ -457,7 +516,9 @@ def api_chat_cleanup():
|
|||||||
|
|
||||||
OPENCLAW_TOKEN = "szDd75yG15ZRADWEhGus3dfNoQve7ZhdxpGq9gudeEzoVhUqB0TomVJd90XcwkUz"
|
OPENCLAW_TOKEN = "szDd75yG15ZRADWEhGus3dfNoQve7ZhdxpGq9gudeEzoVhUqB0TomVJd90XcwkUz"
|
||||||
OPENCLAW_CONTAINER = "openclaw-ow404080wwkkgkgc4oswwssc"
|
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_API_URL = "https://integrate.api.nvidia.com/v1/chat/completions"
|
||||||
NVIDIA_MODEL = "meta/llama-3.1-8b-instruct" # Default model
|
NVIDIA_MODEL = "meta/llama-3.1-8b-instruct" # Default model
|
||||||
NVIDIA_MODELS = {
|
NVIDIA_MODELS = {
|
||||||
@@ -476,7 +537,6 @@ NVIDIA_MODELS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/webhook/telegram", methods=["POST"])
|
@app.route("/webhook/telegram", methods=["POST"])
|
||||||
def telegram_webhook():
|
def telegram_webhook():
|
||||||
try:
|
try:
|
||||||
@@ -542,25 +602,25 @@ def webhook_proxy(workflow_slug):
|
|||||||
model_key = request.json.get("model", "llama-3.1-8b")
|
model_key = request.json.get("model", "llama-3.1-8b")
|
||||||
model_id = NVIDIA_MODELS.get(model_key, NVIDIA_MODEL)
|
model_id = NVIDIA_MODELS.get(model_key, NVIDIA_MODEL)
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
NVIDIA_API_URL,
|
NVIDIA_API_URL,
|
||||||
headers={
|
headers={
|
||||||
"Authorization": f"Bearer {NVIDIA_API_KEY}",
|
"Authorization": f"Bearer {NVIDIA_API_KEY}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
json={
|
json={
|
||||||
"model": model_id,
|
"model": model_id,
|
||||||
"messages": [{"role": "user", "content": user_message}],
|
"messages": [{"role": "user", "content": user_message}],
|
||||||
"max_tokens": 1024,
|
"max_tokens": 1024,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
},
|
},
|
||||||
timeout=60,
|
timeout=60,
|
||||||
)
|
)
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
ai_response = (
|
ai_response = (
|
||||||
data.get("choices", [{}])[0]
|
data.get("choices", [{}])[0]
|
||||||
.get("message", {})
|
.get("message", {})
|
||||||
.get("content", str(data))
|
.get("content", str(data))
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Proxy vers webhook n8n
|
# Proxy vers webhook n8n
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
@@ -702,12 +762,17 @@ def api_proxy(api_path=""):
|
|||||||
url = f"{DASHBOARD_API_URL}/turf/api"
|
url = f"{DASHBOARD_API_URL}/turf/api"
|
||||||
try:
|
try:
|
||||||
fwd_method = request.method
|
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
|
||||||
|
)
|
||||||
fwd_headers = {"Content-Type": "application/json"}
|
fwd_headers = {"Content-Type": "application/json"}
|
||||||
if request.headers.get("Authorization"):
|
if request.headers.get("Authorization"):
|
||||||
fwd_headers["Authorization"] = request.headers.get("Authorization")
|
fwd_headers["Authorization"] = request.headers.get("Authorization")
|
||||||
resp = requests.request(method=fwd_method, url=url, json=fwd_json, timeout=30,
|
resp = requests.request(
|
||||||
headers=fwd_headers)
|
method=fwd_method, url=url, json=fwd_json, timeout=30, headers=fwd_headers
|
||||||
|
)
|
||||||
return resp.content, resp.status_code, {"Content-Type": "application/json"}
|
return resp.content, resp.status_code, {"Content-Type": "application/json"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e), "url": url}), 500
|
return jsonify({"error": str(e), "url": url}), 500
|
||||||
@@ -744,23 +809,26 @@ def opencode_api():
|
|||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/candidatures/")
|
@app.route("/candidatures/")
|
||||||
def candidatures_index():
|
def candidatures_index():
|
||||||
return send_from_directory("/home/h3r7/turf_saas", "crm_candidatures.html")
|
return send_from_directory("/home/h3r7/turf_saas", "crm_candidatures.html")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/candidatures/<path:filename>")
|
@app.route("/candidatures/<path:filename>")
|
||||||
def candidatures_static(filename):
|
def candidatures_static(filename):
|
||||||
return send_from_directory("/home/h3r7/turf_saas", filename)
|
return send_from_directory("/home/h3r7/turf_saas", filename)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/map")
|
@app.route("/map")
|
||||||
def map_visual():
|
def map_visual():
|
||||||
return send_from_directory("/home/h3r7/turf_saas", "map_visual.html")
|
return send_from_directory("/home/h3r7/turf_saas", "map_visual.html")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/architecture.json")
|
@app.route("/architecture.json")
|
||||||
def architecture_json():
|
def architecture_json():
|
||||||
return send_from_directory("/home/h3r7/turf_saas", "architecture.json")
|
return send_from_directory("/home/h3r7/turf_saas", "architecture.json")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(host="0.0.0.0", port=8792, debug=False)
|
app.run(host="0.0.0.0", port=8792, debug=False)
|
||||||
|
|
||||||
@@ -827,5 +895,3 @@ def proxy_prompts_test():
|
|||||||
return response
|
return response
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Erreur proxy prompts: {e}", 502
|
return f"Erreur proxy prompts: {e}", 502
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
271
register.html
Normal file
271
register.html
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Inscription — Turf IA</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
:root {
|
||||||
|
--green: #00c853; --green-d: #009624;
|
||||||
|
--dark: #0d1117; --dark2: #161b22; --dark3: #21262d;
|
||||||
|
--text: #e6edf3; --muted: #8b949e; --border: #30363d;
|
||||||
|
--error: #f85149; --radius: 10px;
|
||||||
|
}
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); min-height: 100vh; display: flex; flex-direction: column; }
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
nav { display: flex; align-items: center; justify-content: space-between; padding: 16px 5%; border-bottom: 1px solid var(--border); }
|
||||||
|
.nav-logo { font-weight: 700; font-size: 1.1rem; }
|
||||||
|
.nav-link { color: var(--muted); font-size: .9rem; }
|
||||||
|
.nav-link:hover { color: var(--text); }
|
||||||
|
main { flex: 1; display: flex; align-items: flex-start; justify-content: center; padding: 40px 20px; gap: 40px; flex-wrap: wrap; }
|
||||||
|
.auth-card { width: 100%; max-width: 440px; background: var(--dark2); border: 1px solid var(--border); border-radius: 14px; padding: 40px; }
|
||||||
|
.plan-preview { width: 100%; max-width: 300px; }
|
||||||
|
.auth-title { font-size: 1.5rem; font-weight: 800; margin-bottom: 6px; }
|
||||||
|
.auth-subtitle { color: var(--muted); font-size: .9rem; margin-bottom: 28px; }
|
||||||
|
.form-group { margin-bottom: 16px; }
|
||||||
|
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||||
|
label { display: block; font-size: .85rem; font-weight: 600; color: var(--muted); margin-bottom: 6px; }
|
||||||
|
input, select {
|
||||||
|
width: 100%; padding: 11px 14px; background: var(--dark3);
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
color: var(--text); font-size: .95rem; outline: none; transition: border-color .2s;
|
||||||
|
}
|
||||||
|
input:focus, select:focus { border-color: var(--green); }
|
||||||
|
input.error { border-color: var(--error); }
|
||||||
|
select option { background: var(--dark3); }
|
||||||
|
.field-error { color: var(--error); font-size: .8rem; margin-top: 4px; display: none; }
|
||||||
|
.field-error.show { display: block; }
|
||||||
|
.btn { width: 100%; padding: 12px; border: none; border-radius: var(--radius); font-size: 1rem; font-weight: 700; cursor: pointer; transition: all .2s; margin-top: 4px; }
|
||||||
|
.btn-primary { background: var(--green); color: #000; }
|
||||||
|
.btn-primary:hover { background: var(--green-d); }
|
||||||
|
.btn-primary:disabled { opacity: .6; cursor: not-allowed; }
|
||||||
|
.divider { display: flex; align-items: center; gap: 12px; margin: 20px 0; color: var(--muted); font-size: .82rem; }
|
||||||
|
.divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||||
|
.auth-footer { text-align: center; margin-top: 20px; color: var(--muted); font-size: .9rem; }
|
||||||
|
.auth-footer a { color: var(--green); font-weight: 600; }
|
||||||
|
.alert { padding: 12px 16px; border-radius: var(--radius); font-size: .88rem; margin-bottom: 18px; display: none; }
|
||||||
|
.alert.show { display: block; }
|
||||||
|
.alert-error { background: rgba(248,81,73,.12); border: 1px solid rgba(248,81,73,.3); color: #f85149; }
|
||||||
|
.loader { display: inline-block; width: 16px; height: 16px; border: 2px solid rgba(0,0,0,.3); border-top-color: #000; border-radius: 50%; animation: spin .7s linear infinite; vertical-align: middle; margin-right: 6px; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.terms { font-size: .8rem; color: var(--muted); line-height: 1.6; }
|
||||||
|
.terms a { color: var(--green); }
|
||||||
|
.password-strength { height: 4px; border-radius: 2px; margin-top: 6px; background: var(--border); overflow: hidden; }
|
||||||
|
.strength-bar { height: 100%; width: 0; transition: width .3s, background .3s; }
|
||||||
|
|
||||||
|
/* Plan preview sidebar */
|
||||||
|
.plan-card {
|
||||||
|
background: var(--dark2); border: 1px solid var(--border);
|
||||||
|
border-radius: 14px; padding: 24px; margin-bottom: 16px;
|
||||||
|
transition: border-color .3s;
|
||||||
|
}
|
||||||
|
.plan-card.selected { border-color: var(--green); }
|
||||||
|
.plan-card-name { font-weight: 700; font-size: 1rem; display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.plan-card-price { font-size: 1.5rem; font-weight: 800; margin: 8px 0; }
|
||||||
|
.plan-card-features { list-style: none; margin-top: 12px; }
|
||||||
|
.plan-card-features li { font-size: .83rem; color: var(--muted); padding: 4px 0; }
|
||||||
|
.plan-card-features li::before { content: "✓ "; color: var(--green); }
|
||||||
|
.plan-badge { background: var(--green); color: #000; padding: 2px 8px; border-radius: 10px; font-size: .7rem; font-weight: 700; }
|
||||||
|
|
||||||
|
footer { text-align: center; padding: 20px; color: var(--muted); font-size: .8rem; border-top: 1px solid var(--border); }
|
||||||
|
@media (max-width: 768px) { .plan-preview { display: none; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<a href="/" class="nav-logo">🏇 Turf IA</a>
|
||||||
|
<a href="/login" class="nav-link">Déjà un compte ? Se connecter →</a>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
<div class="auth-card">
|
||||||
|
<h1 class="auth-title">Créer votre compte</h1>
|
||||||
|
<p class="auth-subtitle">Commencez gratuitement, sans carte bancaire.</p>
|
||||||
|
|
||||||
|
<div class="alert alert-error" id="alert-error"></div>
|
||||||
|
|
||||||
|
<form id="register-form" novalidate>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="firstname">Prénom</label>
|
||||||
|
<input type="text" id="firstname" name="firstname" placeholder="Jean" autocomplete="given-name" required>
|
||||||
|
<div class="field-error" id="firstname-error">Requis.</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="lastname">Nom</label>
|
||||||
|
<input type="text" id="lastname" name="lastname" placeholder="Dupont" autocomplete="family-name" required>
|
||||||
|
<div class="field-error" id="lastname-error">Requis.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Adresse email</label>
|
||||||
|
<input type="email" id="email" name="email" placeholder="vous@exemple.fr" autocomplete="email" required>
|
||||||
|
<div class="field-error" id="email-error">Email invalide.</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Mot de passe</label>
|
||||||
|
<input type="password" id="password" name="password" placeholder="8 caractères minimum" autocomplete="new-password" required>
|
||||||
|
<div class="password-strength"><div class="strength-bar" id="strength-bar"></div></div>
|
||||||
|
<div class="field-error" id="password-error">8 caractères minimum requis.</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="plan">Plan de départ</label>
|
||||||
|
<select id="plan" name="plan">
|
||||||
|
<option value="free">Free — Gratuit</option>
|
||||||
|
<option value="premium">Premium — 9,90€/mois</option>
|
||||||
|
<option value="pro">Pro — 24,90€/mois</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<p class="terms">En vous inscrivant, vous acceptez nos <a href="/legal/cgu">Conditions Générales d'Utilisation</a> et notre <a href="/legal/privacy">Politique de Confidentialité</a>. Vous devez avoir 18 ans ou plus.</p>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" id="submit-btn">Créer mon compte gratuit</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="auth-footer">
|
||||||
|
Déjà un compte ? <a href="/login">Se connecter</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Plan preview sidebar -->
|
||||||
|
<div class="plan-preview">
|
||||||
|
<div id="plan-card-free" class="plan-card selected">
|
||||||
|
<div class="plan-card-name">Free <span>—</span></div>
|
||||||
|
<div class="plan-card-price">0€<span style="font-size:.9rem;font-weight:400;color:var(--muted)">/mois</span></div>
|
||||||
|
<ul class="plan-card-features">
|
||||||
|
<li>Aperçu Top-3 du jour</li>
|
||||||
|
<li>1 course complète/jour</li>
|
||||||
|
<li>Statistiques basiques</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div id="plan-card-premium" class="plan-card" style="display:none">
|
||||||
|
<div class="plan-card-name">Premium <span class="plan-badge">⭐</span></div>
|
||||||
|
<div class="plan-card-price">9,90€<span style="font-size:.9rem;font-weight:400;color:var(--muted)">/mois</span></div>
|
||||||
|
<ul class="plan-card-features">
|
||||||
|
<li>Toutes les courses</li>
|
||||||
|
<li>Value bets identifiés</li>
|
||||||
|
<li>Alertes Telegram</li>
|
||||||
|
<li>Historique 90 jours</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div id="plan-card-pro" class="plan-card" style="display:none">
|
||||||
|
<div class="plan-card-name">Pro <span></span></div>
|
||||||
|
<div class="plan-card-price">24,90€<span style="font-size:.9rem;font-weight:400;color:var(--muted)">/mois</span></div>
|
||||||
|
<ul class="plan-card-features">
|
||||||
|
<li>Tout du Premium</li>
|
||||||
|
<li>Export CSV illimité</li>
|
||||||
|
<li>API REST documentée</li>
|
||||||
|
<li>Backtest personnalisé</li>
|
||||||
|
<li>Support prioritaire</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:.78rem;color:var(--muted);text-align:center;">⚡ Pas de carte bancaire requise pour le plan Free</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<footer>© 2026 Turf IA — H3R7 Tech. Jouez de façon responsable. 18+</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = '/api/v1';
|
||||||
|
const form = document.getElementById('register-form');
|
||||||
|
const submitBtn = document.getElementById('submit-btn');
|
||||||
|
const alertBox = document.getElementById('alert-error');
|
||||||
|
const planSelect = document.getElementById('plan');
|
||||||
|
|
||||||
|
// Pre-select plan from URL param
|
||||||
|
const urlPlan = new URLSearchParams(location.search).get('plan');
|
||||||
|
if (urlPlan && ['free','premium','pro'].includes(urlPlan)) planSelect.value = urlPlan;
|
||||||
|
|
||||||
|
function updatePlanPreview() {
|
||||||
|
['free','premium','pro'].forEach(p => {
|
||||||
|
const el = document.getElementById(`plan-card-${p}`);
|
||||||
|
if (!el) return;
|
||||||
|
if (planSelect.value === p) {
|
||||||
|
el.style.display = '';
|
||||||
|
el.classList.add('selected');
|
||||||
|
} else {
|
||||||
|
el.style.display = 'none';
|
||||||
|
el.classList.remove('selected');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
planSelect.addEventListener('change', updatePlanPreview);
|
||||||
|
updatePlanPreview();
|
||||||
|
|
||||||
|
function showError(msg) { alertBox.textContent = msg; alertBox.classList.add('show'); }
|
||||||
|
function hideError() { alertBox.classList.remove('show'); }
|
||||||
|
function setLoading(on) {
|
||||||
|
submitBtn.disabled = on;
|
||||||
|
submitBtn.innerHTML = on ? '<span class="loader"></span>Création…' : 'Créer mon compte gratuit';
|
||||||
|
}
|
||||||
|
function validateField(input, errId, condition, msg) {
|
||||||
|
const err = document.getElementById(errId);
|
||||||
|
if (condition) { input.classList.add('error'); err.textContent = msg; err.classList.add('show'); return false; }
|
||||||
|
input.classList.remove('error'); err.classList.remove('show'); return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password strength
|
||||||
|
const passInput = document.getElementById('password');
|
||||||
|
const bar = document.getElementById('strength-bar');
|
||||||
|
passInput.addEventListener('input', () => {
|
||||||
|
const v = passInput.value;
|
||||||
|
let score = 0;
|
||||||
|
if (v.length >= 8) score++;
|
||||||
|
if (/[A-Z]/.test(v)) score++;
|
||||||
|
if (/[0-9]/.test(v)) score++;
|
||||||
|
if (/[^A-Za-z0-9]/.test(v)) score++;
|
||||||
|
const colors = ['#f85149','#e3b341','#58a6ff','#00c853'];
|
||||||
|
bar.style.width = (score * 25) + '%';
|
||||||
|
bar.style.background = colors[score - 1] || '#30363d';
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
hideError();
|
||||||
|
const firstname = document.getElementById('firstname').value.trim();
|
||||||
|
const lastname = document.getElementById('lastname').value.trim();
|
||||||
|
const email = document.getElementById('email').value.trim();
|
||||||
|
const password = passInput.value;
|
||||||
|
const plan = planSelect.value;
|
||||||
|
|
||||||
|
let ok = true;
|
||||||
|
ok = validateField(document.getElementById('firstname'), 'firstname-error', !firstname, 'Prénom requis.') && ok;
|
||||||
|
ok = validateField(document.getElementById('lastname'), 'lastname-error', !lastname, 'Nom requis.') && ok;
|
||||||
|
ok = validateField(document.getElementById('email'), 'email-error', !email || !/^[^@]+@[^@]+\.[^@]+$/.test(email), 'Email invalide.') && ok;
|
||||||
|
ok = validateField(passInput, 'password-error', password.length < 8, '8 caractères minimum.') && ok;
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/auth/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ firstname, lastname, email, password, plan })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
showError(data.error || 'Erreur lors de la création du compte.');
|
||||||
|
} else {
|
||||||
|
localStorage.setItem('turf_token', data.token);
|
||||||
|
localStorage.setItem('turf_user', JSON.stringify(data.user));
|
||||||
|
localStorage.setItem('turf_onboarding', 'pending');
|
||||||
|
location.href = '/onboarding';
|
||||||
|
}
|
||||||
|
} catch(_) {
|
||||||
|
showError('Erreur de connexion. Vérifiez votre réseau.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('input').forEach(el => el.addEventListener('input', () => {
|
||||||
|
el.classList.remove('error');
|
||||||
|
document.getElementById(el.id + '-error')?.classList.remove('show');
|
||||||
|
hideError();
|
||||||
|
}));
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
if (localStorage.getItem('turf_token')) location.href = '/dashboard';
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
33
requirements.txt
Normal file
33
requirements.txt
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Core web framework
|
||||||
|
Flask==3.1.3
|
||||||
|
flask-cors==6.0.2
|
||||||
|
gunicorn==23.0.0
|
||||||
|
|
||||||
|
# HTTP client
|
||||||
|
requests==2.32.3
|
||||||
|
|
||||||
|
# Data processing & ML
|
||||||
|
pandas==3.0.1
|
||||||
|
numpy==2.4.3
|
||||||
|
scikit-learn==1.6.1
|
||||||
|
xgboost==3.2.0
|
||||||
|
|
||||||
|
# Database - PostgreSQL
|
||||||
|
psycopg2-binary==2.9.12
|
||||||
|
SQLAlchemy==2.0.40
|
||||||
|
alembic==1.16.1
|
||||||
|
|
||||||
|
# Scheduling
|
||||||
|
schedule==1.2.2
|
||||||
|
|
||||||
|
# Monitoring
|
||||||
|
prometheus-client==0.21.1
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
python-json-logger==3.3.0
|
||||||
|
|
||||||
|
# Security
|
||||||
|
python-dotenv==1.1.0
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
python-dateutil==2.9.0
|
||||||
257
saas_api_v1.py
Normal file
257
saas_api_v1.py
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SaaS API v1 Blueprint — /api/v1/*
|
||||||
|
Stats, prédictions, résumés pour le dashboard SaaS.
|
||||||
|
Sprint 4-5 — HRT-30
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from .saas_auth import require_auth
|
||||||
|
|
||||||
|
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||||
|
|
||||||
|
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def plan_allows(user_plan: str, required: str) -> bool:
|
||||||
|
order = {"free": 0, "premium": 1, "pro": 2}
|
||||||
|
return order.get(user_plan, 0) >= order.get(required, 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Stats ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/stats/summary", methods=["GET"])
|
||||||
|
@require_auth
|
||||||
|
def stats_summary():
|
||||||
|
"""GET /api/v1/stats/summary — résumé dashboard."""
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
conn = get_db()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Courses today
|
||||||
|
courses_today = (
|
||||||
|
conn.execute(
|
||||||
|
"SELECT COUNT(DISTINCT num_reunion||'-'||num_course) FROM ml_predictions_cache WHERE date=?",
|
||||||
|
(today,),
|
||||||
|
).fetchone()[0]
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Value bets today
|
||||||
|
value_bets_today = (
|
||||||
|
conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM ml_predictions_cache WHERE date=? AND is_value_bet=1",
|
||||||
|
(today,),
|
||||||
|
).fetchone()[0]
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Accuracy top3 (30 days)
|
||||||
|
acc_row = conn.execute("""
|
||||||
|
SELECT
|
||||||
|
CAST(SUM(CASE WHEN p.ordre_arrivee BETWEEN 1 AND 3 AND m.recommendation='top3' THEN 1 ELSE 0 END) AS FLOAT)
|
||||||
|
/ NULLIF(COUNT(CASE WHEN m.recommendation='top3' THEN 1 END), 0) * 100 AS acc
|
||||||
|
FROM ml_predictions_cache m
|
||||||
|
JOIN pmu_partants p ON m.horse_name=p.nom AND m.date=p.date_programme
|
||||||
|
WHERE m.date >= date('now', '-30 days')
|
||||||
|
""").fetchone()
|
||||||
|
accuracy_top3 = round(acc_row[0], 1) if acc_row and acc_row[0] else None
|
||||||
|
|
||||||
|
# Next race
|
||||||
|
next_race = conn.execute(
|
||||||
|
"SELECT heure, hippodrome FROM ml_predictions_cache WHERE date=? AND heure IS NOT NULL ORDER BY heure LIMIT 1",
|
||||||
|
(today,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"courses_today": courses_today,
|
||||||
|
"value_bets_today": value_bets_today,
|
||||||
|
"accuracy_top3": accuracy_top3,
|
||||||
|
"next_race_time": next_race["heure"] if next_race else None,
|
||||||
|
"next_race_hippodrome": next_race["hippodrome"] if next_race else None,
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
return jsonify(
|
||||||
|
{"error": str(e), "courses_today": 0, "value_bets_today": 0}
|
||||||
|
), 200
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Predictions ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/predictions/today", methods=["GET"])
|
||||||
|
@require_auth
|
||||||
|
def predictions_today():
|
||||||
|
"""GET /api/v1/predictions/today — prédictions du jour selon le plan."""
|
||||||
|
user = request.current_user
|
||||||
|
plan = user.get("plan", "free")
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
conn = get_db()
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT horse_name, horse_number, odds, prob_top1, prob_top3,
|
||||||
|
ml_score, recommendation, is_value_bet, is_outlier,
|
||||||
|
race_label, race_name, hippodrome, discipline, distance,
|
||||||
|
heure, risque_label, risque_score, num_reunion, num_course
|
||||||
|
FROM ml_predictions_cache
|
||||||
|
WHERE date=?
|
||||||
|
ORDER BY num_reunion, num_course, ml_score DESC
|
||||||
|
""",
|
||||||
|
(today,),
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
predictions = [dict(r) for r in rows]
|
||||||
|
|
||||||
|
# Free plan: return only 1 race
|
||||||
|
if plan == "free":
|
||||||
|
if predictions:
|
||||||
|
first = predictions[0]
|
||||||
|
first_key = (first["num_reunion"], first["num_course"])
|
||||||
|
predictions = [
|
||||||
|
p
|
||||||
|
for p in predictions
|
||||||
|
if (p["num_reunion"], p["num_course"]) == first_key
|
||||||
|
]
|
||||||
|
# Mask value bet flag in free
|
||||||
|
for p in predictions:
|
||||||
|
p["is_value_bet"] = 0
|
||||||
|
|
||||||
|
# Premium/Pro: full predictions
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"date": today,
|
||||||
|
"plan": plan,
|
||||||
|
"count": len(predictions),
|
||||||
|
"predictions": predictions,
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"error": str(e), "predictions": []}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/predictions/race/<race_label>", methods=["GET"])
|
||||||
|
@require_auth
|
||||||
|
def predictions_race(race_label):
|
||||||
|
"""GET /api/v1/predictions/race/<label> — prédictions d'une course."""
|
||||||
|
user = request.current_user
|
||||||
|
plan = user.get("plan", "free")
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# Parse label like R1C3 → num_reunion=1, num_course=3
|
||||||
|
import re
|
||||||
|
|
||||||
|
m = re.match(r"R(\d+)C(\d+)", race_label.upper())
|
||||||
|
if not m:
|
||||||
|
return jsonify({"error": "Format invalide, attendu: R{n}C{n}"}), 400
|
||||||
|
nr, nc = int(m.group(1)), int(m.group(2))
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM ml_predictions_cache
|
||||||
|
WHERE date=? AND num_reunion=? AND num_course=?
|
||||||
|
ORDER BY ml_score DESC
|
||||||
|
""",
|
||||||
|
(today, nr, nc),
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
predictions = [dict(r) for r in rows]
|
||||||
|
if plan == "free" and predictions:
|
||||||
|
# Only show first race
|
||||||
|
pass # allowed in detail view if they know the race label
|
||||||
|
|
||||||
|
return jsonify({"predictions": predictions, "count": len(predictions)}), 200
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Value Bets ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/value-bets/today", methods=["GET"])
|
||||||
|
@require_auth
|
||||||
|
def value_bets_today():
|
||||||
|
"""GET /api/v1/value-bets/today — value bets (Premium+)."""
|
||||||
|
user = request.current_user
|
||||||
|
plan = user.get("plan", "free")
|
||||||
|
if not plan_allows(plan, "premium"):
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"error": "Cette fonctionnalité requiert un plan Premium ou Pro.",
|
||||||
|
"upgrade_required": True,
|
||||||
|
}
|
||||||
|
), 403
|
||||||
|
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
conn = get_db()
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT horse_name, race_label, race_name, hippodrome, odds,
|
||||||
|
prob_top3, ml_score, risque_label, heure
|
||||||
|
FROM ml_predictions_cache
|
||||||
|
WHERE date=? AND is_value_bet=1
|
||||||
|
ORDER BY ml_score DESC
|
||||||
|
""",
|
||||||
|
(today,),
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"value_bets": [dict(r) for r in rows], "count": len(rows)}), 200
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Export ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/export/csv", methods=["GET"])
|
||||||
|
@require_auth
|
||||||
|
def export_csv():
|
||||||
|
"""GET /api/v1/export/csv — export CSV (Pro only)."""
|
||||||
|
from flask import Response
|
||||||
|
import csv, io
|
||||||
|
|
||||||
|
user = request.current_user
|
||||||
|
plan = user.get("plan", "free")
|
||||||
|
if not plan_allows(plan, "pro"):
|
||||||
|
return jsonify(
|
||||||
|
{"error": "L'export CSV requiert un plan Pro.", "upgrade_required": True}
|
||||||
|
), 403
|
||||||
|
|
||||||
|
date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
|
||||||
|
conn = get_db()
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM ml_predictions_cache WHERE date=? ORDER BY num_reunion, num_course, ml_score DESC",
|
||||||
|
(date_param,),
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
if rows:
|
||||||
|
writer = csv.DictWriter(output, fieldnames=rows[0].keys())
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows([dict(r) for r in rows])
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
output.getvalue(),
|
||||||
|
mimetype="text/csv",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f"attachment; filename=turf_ia_{date_param}.csv"
|
||||||
|
},
|
||||||
|
)
|
||||||
344
saas_auth.py
Normal file
344
saas_auth.py
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
#!/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 secrets
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
from functools import wraps
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# ─── 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 require_auth(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
token = (
|
||||||
|
auth.removeprefix("Bearer ").strip() if auth.startswith("Bearer ") else None
|
||||||
|
)
|
||||||
|
user = validate_token(token)
|
||||||
|
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
|
||||||
|
if len(password) < 8:
|
||||||
|
return jsonify(
|
||||||
|
{"error": "Mot de passe trop court (8 caractères minimum)."}
|
||||||
|
), 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
|
||||||
|
|
||||||
|
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 ""
|
||||||
|
|
||||||
|
if len(new_pwd) < 8:
|
||||||
|
return jsonify({"error": "Nouveau mot de passe trop court."}), 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
|
||||||
Reference in New Issue
Block a user