Compare commits
21 Commits
feature/HR
...
feature/HR
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
946bdc65b6 | ||
|
|
701660ce83 | ||
| b7ed82418f | |||
|
|
8604dc78b1 | ||
|
|
30464fb40c | ||
|
|
31db3a8260 | ||
|
|
278245cd7c | ||
|
|
225295030b | ||
|
|
86e85aa1c6 | ||
| 5aa6013c52 | |||
|
|
4b4323f707 | ||
|
|
356bdf5bec | ||
|
|
f9a45e6deb | ||
|
|
cfc0f038f9 | ||
|
|
c999285895 | ||
|
|
e517741c97 | ||
| 837a0845ec | |||
|
|
4bf458f1b8 | ||
|
|
099286b078 | ||
|
|
d39c7d3319 | ||
|
|
8c5fdf1e9c |
@@ -3,6 +3,8 @@
|
|||||||
API v1 Blueprint package — Turf SaaS
|
API v1 Blueprint package — Turf SaaS
|
||||||
Sprint 3-4: HRT-29 — Refacto API /v1/
|
Sprint 3-4: HRT-29 — Refacto API /v1/
|
||||||
Sprint 5-6: HRT-31 — Billing Stripe
|
Sprint 5-6: HRT-31 — Billing Stripe
|
||||||
|
HRT-79: Alertes Telegram configurables (user blueprint)
|
||||||
|
HRT-82: Multi-compte / Organisation Pro (max 5 users)
|
||||||
|
|
||||||
Registers sub-blueprints:
|
Registers sub-blueprints:
|
||||||
/api/v1/health — public health-check
|
/api/v1/health — public health-check
|
||||||
@@ -13,6 +15,9 @@ Registers sub-blueprints:
|
|||||||
/api/v1/export/ — export CSV (pro)
|
/api/v1/export/ — export CSV (pro)
|
||||||
/api/v1/metrics — métriques perf ML (premium+)
|
/api/v1/metrics — métriques perf ML (premium+)
|
||||||
/api/v1/billing/ — Stripe checkout, portal, webhook, status
|
/api/v1/billing/ — Stripe checkout, portal, webhook, status
|
||||||
|
/api/v1/user/ — config utilisateur, alertes Telegram (premium+)
|
||||||
|
/api/v1/history — historique préd. ML (Free:7j, Premium:90j, Pro:illimité)
|
||||||
|
/api/v1/org/ — organisations Pro (multi-compte, max 5 users)
|
||||||
/api/v1/docs — Swagger UI (via flasgger, registered on app)
|
/api/v1/docs — Swagger UI (via flasgger, registered on app)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -26,6 +31,9 @@ from .routes.backtest import backtest_bp
|
|||||||
from .routes.export import export_bp
|
from .routes.export import export_bp
|
||||||
from .routes.metrics import metrics_bp
|
from .routes.metrics import metrics_bp
|
||||||
from .routes.billing import billing_bp
|
from .routes.billing import billing_bp
|
||||||
|
from .routes.user import user_bp
|
||||||
|
from .routes.history import history_bp
|
||||||
|
from .routes.org import org_bp
|
||||||
|
|
||||||
# Master blueprint that aggregates all sub-routes under /api/v1
|
# Master blueprint that aggregates all sub-routes under /api/v1
|
||||||
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
|
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
|
||||||
@@ -41,3 +49,6 @@ def register_api_v1(app):
|
|||||||
app.register_blueprint(export_bp)
|
app.register_blueprint(export_bp)
|
||||||
app.register_blueprint(metrics_bp)
|
app.register_blueprint(metrics_bp)
|
||||||
app.register_blueprint(billing_bp)
|
app.register_blueprint(billing_bp)
|
||||||
|
app.register_blueprint(user_bp)
|
||||||
|
app.register_blueprint(history_bp)
|
||||||
|
app.register_blueprint(org_bp)
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ import os
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import stripe
|
import stripe
|
||||||
from flask import Blueprint, g, jsonify, request
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
from auth import jwt_required_middleware
|
from saas_auth import require_auth as jwt_required_middleware
|
||||||
from billing_db import get_db, migrate_billing_tables
|
from billing_db import get_db, migrate_billing_tables
|
||||||
|
|
||||||
logger = logging.getLogger("turf_saas.billing")
|
logger = logging.getLogger("turf_saas.billing")
|
||||||
@@ -73,18 +73,18 @@ def _sget(obj, key, default=None):
|
|||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
def _get_active_subscription(db, user_id: int):
|
def _get_active_subscription(db, user_id):
|
||||||
"""Return the most recent active subscription row for a user."""
|
"""Return the most recent active subscription row for a user."""
|
||||||
return db.execute(
|
return db.execute(
|
||||||
"""SELECT * FROM subscriptions
|
"""SELECT * FROM saas_subscriptions
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
ORDER BY start_date DESC
|
ORDER BY start_date DESC
|
||||||
LIMIT 1""",
|
LIMIT 1""",
|
||||||
(user_id,),
|
(str(user_id),),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
def _upsert_subscription(db, user_id: int, **fields):
|
def _upsert_subscription(db, user_id, **fields):
|
||||||
"""
|
"""
|
||||||
Update existing subscription or insert a new one.
|
Update existing subscription or insert a new one.
|
||||||
fields: plan, stripe_customer_id, stripe_subscription_id,
|
fields: plan, stripe_customer_id, stripe_subscription_id,
|
||||||
@@ -95,19 +95,19 @@ def _upsert_subscription(db, user_id: int, **fields):
|
|||||||
# Build SET clause dynamically from provided fields
|
# Build SET clause dynamically from provided fields
|
||||||
set_parts = ", ".join(f"{k} = ?" for k in fields)
|
set_parts = ", ".join(f"{k} = ?" for k in fields)
|
||||||
values = list(fields.values()) + [existing["id"]]
|
values = list(fields.values()) + [existing["id"]]
|
||||||
db.execute(f"UPDATE subscriptions SET {set_parts} WHERE id = ?", values)
|
db.execute(f"UPDATE saas_subscriptions SET {set_parts} WHERE id = ?", values)
|
||||||
else:
|
else:
|
||||||
cols = ", ".join(["user_id"] + list(fields.keys()))
|
cols = ", ".join(["user_id"] + list(fields.keys()))
|
||||||
placeholders = ", ".join(["?"] * (1 + len(fields)))
|
placeholders = ", ".join(["?"] * (1 + len(fields)))
|
||||||
values = [user_id] + list(fields.values())
|
values = [str(user_id)] + list(fields.values())
|
||||||
db.execute(
|
db.execute(
|
||||||
f"INSERT INTO subscriptions ({cols}) VALUES ({placeholders})", values
|
f"INSERT INTO saas_subscriptions ({cols}) VALUES ({placeholders})", values
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _update_user_plan(db, user_id: int, plan: str):
|
def _update_user_plan(db, user_id, plan: str):
|
||||||
"""Sync users.plan field to match active subscription."""
|
"""Sync saas_users.plan field to match active subscription."""
|
||||||
db.execute("UPDATE users SET plan = ? WHERE id = ?", (plan, user_id))
|
db.execute("UPDATE saas_users SET plan = ? WHERE id = ?", (plan, str(user_id)))
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_stripe_customer(user, db) -> str:
|
def _get_or_create_stripe_customer(user, db) -> str:
|
||||||
@@ -198,7 +198,7 @@ def create_checkout():
|
|||||||
if not price_id:
|
if not price_id:
|
||||||
return jsonify({"error": f"Prix Stripe non configuré pour le plan {plan}"}), 503
|
return jsonify({"error": f"Prix Stripe non configuré pour le plan {plan}"}), 503
|
||||||
|
|
||||||
user = g.current_user
|
user = request.current_user
|
||||||
if user["plan"] == plan:
|
if user["plan"] == plan:
|
||||||
return jsonify({"error": f"Vous êtes déjà sur le plan {plan}"}), 400
|
return jsonify({"error": f"Vous êtes déjà sur le plan {plan}"}), 400
|
||||||
|
|
||||||
@@ -263,7 +263,7 @@ def create_portal():
|
|||||||
if not stripe.api_key:
|
if not stripe.api_key:
|
||||||
return jsonify({"error": "Stripe non configuré"}), 503
|
return jsonify({"error": "Stripe non configuré"}), 503
|
||||||
|
|
||||||
user = g.current_user
|
user = request.current_user
|
||||||
db = get_db()
|
db = get_db()
|
||||||
try:
|
try:
|
||||||
sub = _get_active_subscription(db, user["id"])
|
sub = _get_active_subscription(db, user["id"])
|
||||||
@@ -309,7 +309,7 @@ def billing_status():
|
|||||||
200:
|
200:
|
||||||
description: Subscription status
|
description: Subscription status
|
||||||
"""
|
"""
|
||||||
user = g.current_user
|
user = request.current_user
|
||||||
db = get_db()
|
db = get_db()
|
||||||
try:
|
try:
|
||||||
sub = _get_active_subscription(db, user["id"])
|
sub = _get_active_subscription(db, user["id"])
|
||||||
@@ -428,7 +428,7 @@ def stripe_webhook():
|
|||||||
def _resolve_user_from_customer(db, customer_id: str):
|
def _resolve_user_from_customer(db, customer_id: str):
|
||||||
"""Look up user_id via subscriptions.stripe_customer_id."""
|
"""Look up user_id via subscriptions.stripe_customer_id."""
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
"SELECT user_id FROM subscriptions WHERE stripe_customer_id = ? LIMIT 1",
|
"SELECT user_id FROM saas_subscriptions WHERE stripe_customer_id = ? LIMIT 1",
|
||||||
(customer_id,),
|
(customer_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row:
|
if row:
|
||||||
@@ -465,7 +465,7 @@ def _handle_checkout_completed(db, event):
|
|||||||
user_id = _sget(metadata, "user_id")
|
user_id = _sget(metadata, "user_id")
|
||||||
|
|
||||||
if user_id:
|
if user_id:
|
||||||
user_id = int(user_id)
|
user_id = str(user_id)
|
||||||
else:
|
else:
|
||||||
user_id = _resolve_user_from_customer(db, customer_id)
|
user_id = _resolve_user_from_customer(db, customer_id)
|
||||||
|
|
||||||
@@ -531,7 +531,7 @@ def _handle_subscription_updated(db, event):
|
|||||||
meta = _sget(sub_obj, "metadata") or {}
|
meta = _sget(sub_obj, "metadata") or {}
|
||||||
meta_uid = _sget(meta, "user_id")
|
meta_uid = _sget(meta, "user_id")
|
||||||
if meta_uid:
|
if meta_uid:
|
||||||
user_id = int(meta_uid)
|
user_id = str(meta_uid)
|
||||||
|
|
||||||
if not user_id:
|
if not user_id:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -565,7 +565,7 @@ def _handle_subscription_deleted(db, event):
|
|||||||
meta = _sget(sub_obj, "metadata") or {}
|
meta = _sget(sub_obj, "metadata") or {}
|
||||||
meta_uid = _sget(meta, "user_id")
|
meta_uid = _sget(meta, "user_id")
|
||||||
if meta_uid:
|
if meta_uid:
|
||||||
user_id = int(meta_uid)
|
user_id = str(meta_uid)
|
||||||
|
|
||||||
if not user_id:
|
if not user_id:
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|||||||
212
api_v1/routes/history.py
Normal file
212
api_v1/routes/history.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
History routes for API v1.
|
||||||
|
|
||||||
|
GET /api/v1/history — Historique des prédictions avec filtre date range,
|
||||||
|
limité selon le plan (Free: 7j, Premium: 90j, Pro: illimité)
|
||||||
|
|
||||||
|
Ticket: HRT-81 — Historique limité/illimité selon plan (Free/Premium/Pro)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from flask import Blueprint, jsonify, request, g
|
||||||
|
|
||||||
|
from api_v1.utils import (
|
||||||
|
get_db,
|
||||||
|
table_exists,
|
||||||
|
internal_error,
|
||||||
|
bad_request,
|
||||||
|
forbidden,
|
||||||
|
get_pagination_params,
|
||||||
|
paginate_query,
|
||||||
|
)
|
||||||
|
from auth import jwt_required_middleware
|
||||||
|
|
||||||
|
history_bp = Blueprint("v1_history", __name__, url_prefix="/api/v1/history")
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Plan limits (days of history accessible; None = unlimited)
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
HISTORY_DAYS = {
|
||||||
|
"free": 7,
|
||||||
|
"premium": 90,
|
||||||
|
"pro": None, # illimité
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fallback for unknown plans: treat like free
|
||||||
|
_DEFAULT_LIMIT = 7
|
||||||
|
|
||||||
|
|
||||||
|
def _get_plan_max_days(plan: str):
|
||||||
|
"""Return the max history days allowed for the given plan, or default."""
|
||||||
|
return HISTORY_DAYS.get(plan, _DEFAULT_LIMIT)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(date_str: str, param_name: str):
|
||||||
|
"""Parse YYYY-MM-DD date string, raise ValueError with context on failure."""
|
||||||
|
try:
|
||||||
|
return datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(
|
||||||
|
f"Paramètre '{param_name}' invalide : format attendu YYYY-MM-DD, reçu '{date_str}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# GET /api/v1/history
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@history_bp.route("", methods=["GET"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
def get_history():
|
||||||
|
"""
|
||||||
|
Historique des prédictions ML avec filtre date range
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Historique
|
||||||
|
summary: |
|
||||||
|
Historique des prédictions sur une plage de dates.
|
||||||
|
Limite selon le plan :
|
||||||
|
- Free : 7 derniers jours
|
||||||
|
- Premium : 90 derniers jours
|
||||||
|
- Pro : illimité
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
parameters:
|
||||||
|
- name: start
|
||||||
|
in: query
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
description: Date de début au format YYYY-MM-DD (défaut : aujourd'hui - max_days du plan)
|
||||||
|
- name: end
|
||||||
|
in: query
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
description: Date de fin au format YYYY-MM-DD (défaut : aujourd'hui)
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
type: integer
|
||||||
|
default: 50
|
||||||
|
description: Nombre de résultats par page (max 500)
|
||||||
|
- name: offset
|
||||||
|
in: query
|
||||||
|
type: integer
|
||||||
|
default: 0
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Historique des prédictions ML
|
||||||
|
400:
|
||||||
|
description: Paramètre de date invalide
|
||||||
|
401:
|
||||||
|
description: Token invalide ou manquant
|
||||||
|
403:
|
||||||
|
description: Plage de dates hors limite du plan — upgrade requis
|
||||||
|
"""
|
||||||
|
user = getattr(g, "current_user", None)
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "Non authentifié"}), 401
|
||||||
|
|
||||||
|
plan = user.get("plan", "free")
|
||||||
|
today = datetime.now().date()
|
||||||
|
max_days = _get_plan_max_days(plan)
|
||||||
|
|
||||||
|
# ── Parse end date ────────────────────────────────────────
|
||||||
|
end_str = request.args.get("end", today.isoformat())
|
||||||
|
try:
|
||||||
|
end_date = _parse_date(end_str, "end")
|
||||||
|
except ValueError as exc:
|
||||||
|
return bad_request(str(exc))
|
||||||
|
|
||||||
|
# ── Parse start date ─────────────────────────────────────
|
||||||
|
if max_days is not None:
|
||||||
|
default_start = today - timedelta(days=max_days - 1)
|
||||||
|
else:
|
||||||
|
# Pro: default to 30 days back when no start provided
|
||||||
|
default_start = today - timedelta(days=29)
|
||||||
|
|
||||||
|
start_str = request.args.get("start", default_start.isoformat())
|
||||||
|
try:
|
||||||
|
start_date = _parse_date(start_str, "start")
|
||||||
|
except ValueError as exc:
|
||||||
|
return bad_request(str(exc))
|
||||||
|
|
||||||
|
# ── Validate ordering ─────────────────────────────────────
|
||||||
|
if start_date > end_date:
|
||||||
|
return bad_request(
|
||||||
|
f"'start' ({start_str}) ne peut pas être postérieur à 'end' ({end_str})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Enforce plan window ───────────────────────────────────
|
||||||
|
if max_days is not None:
|
||||||
|
earliest_allowed = today - timedelta(days=max_days - 1)
|
||||||
|
if start_date < earliest_allowed:
|
||||||
|
return forbidden(
|
||||||
|
message=(
|
||||||
|
f"Historique limité à {max_days} jours pour le plan '{plan}'. "
|
||||||
|
f"Date de début minimale autorisée : {earliest_allowed.isoformat()}. "
|
||||||
|
f"Passez à un plan supérieur pour accéder à un historique plus long."
|
||||||
|
),
|
||||||
|
required_plans=["premium", "pro"] if plan == "free" else ["pro"],
|
||||||
|
current_plan=plan,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Pagination ────────────────────────────────────────────
|
||||||
|
limit, offset = get_pagination_params(default_limit=50, max_limit=500)
|
||||||
|
|
||||||
|
# ── Query ─────────────────────────────────────────────────
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
if not table_exists(conn, "ml_predictions_cache"):
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"plan": plan,
|
||||||
|
"start": start_date.isoformat(),
|
||||||
|
"end": end_date.isoformat(),
|
||||||
|
"history": [],
|
||||||
|
**paginate_query([], 0, limit, offset),
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
|
||||||
|
count_row = conn.execute(
|
||||||
|
"""SELECT COUNT(*) as cnt
|
||||||
|
FROM ml_predictions_cache
|
||||||
|
WHERE date >= ? AND date <= ?""",
|
||||||
|
(start_date.isoformat(), end_date.isoformat()),
|
||||||
|
).fetchone()
|
||||||
|
total = count_row["cnt"] if count_row else 0
|
||||||
|
|
||||||
|
sql = """
|
||||||
|
SELECT
|
||||||
|
id, date, horse_name, prob_top1, prob_top3,
|
||||||
|
ml_score, race_label, hippodrome, heure, is_value_bet
|
||||||
|
FROM ml_predictions_cache
|
||||||
|
WHERE date >= ? AND date <= ?
|
||||||
|
ORDER BY date DESC, ml_score DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
"""
|
||||||
|
rows = conn.execute(
|
||||||
|
sql,
|
||||||
|
(start_date.isoformat(), end_date.isoformat(), limit, offset),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
history = [dict(r) for r in rows]
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"plan": plan,
|
||||||
|
"history_limit_days": max_days,
|
||||||
|
"start": start_date.isoformat(),
|
||||||
|
"end": end_date.isoformat(),
|
||||||
|
"history": history,
|
||||||
|
**paginate_query(history, total, limit, offset),
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
return internal_error(str(exc))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
536
api_v1/routes/org.py
Normal file
536
api_v1/routes/org.py
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Org Blueprint — Multi-compte / Organisations Pro
|
||||||
|
Sprint: HRT-82
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
POST /api/v1/org — créer une organisation (Pro only, 1 max par owner)
|
||||||
|
GET /api/v1/org — infos org courante
|
||||||
|
DELETE /api/v1/org — supprimer l'org (owner only)
|
||||||
|
POST /api/v1/org/invite — inviter un membre par email (max 5 totaux)
|
||||||
|
GET /api/v1/org/members — liste des membres
|
||||||
|
DELETE /api/v1/org/members/<user_id> — retirer un membre (owner only)
|
||||||
|
|
||||||
|
Plan enforcement:
|
||||||
|
- Toutes les routes nécessitent plan=pro via plan_required('pro')
|
||||||
|
- Limite : 1 org par owner, 5 membres max (owner inclus)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
from saas_auth import require_auth as jwt_required_middleware
|
||||||
|
from org_db import get_db, migrate_org_tables
|
||||||
|
|
||||||
|
logger = logging.getLogger("turf_saas.org")
|
||||||
|
|
||||||
|
org_bp = Blueprint("org", __name__, url_prefix="/api/v1/org")
|
||||||
|
|
||||||
|
MAX_MEMBERS = 5 # max membres totaux owner inclus
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Decorator: plan Pro requis
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _require_pro(fn):
|
||||||
|
"""Vérifie que l'utilisateur courant est sur le plan 'pro'."""
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
@wraps(fn)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
user = getattr(request, "current_user", None)
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "Non authentifié"}), 401
|
||||||
|
if user.get("plan") != "pro":
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"error": "Plan insuffisant",
|
||||||
|
"required": "pro",
|
||||||
|
"current_plan": user.get("plan", "free"),
|
||||||
|
"upgrade_url": "/api/v1/billing/checkout",
|
||||||
|
}
|
||||||
|
), 403
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Helpers DB
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _get_org_by_owner(db, owner_id: str):
|
||||||
|
return db.execute(
|
||||||
|
"SELECT * FROM organizations WHERE owner_id = ?", (owner_id,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_org_by_id(db, org_id: str):
|
||||||
|
return db.execute("SELECT * FROM organizations WHERE id = ?", (org_id,)).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_member_org(db, user_id: str):
|
||||||
|
"""Retourne l'org dont user_id est membre (owner ou member)."""
|
||||||
|
row = db.execute(
|
||||||
|
"""SELECT o.* FROM organizations o
|
||||||
|
JOIN org_members m ON m.org_id = o.id
|
||||||
|
WHERE m.user_id = ?
|
||||||
|
LIMIT 1""",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _count_org_members(db, org_id: str) -> int:
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT COUNT(*) AS cnt FROM org_members WHERE org_id = ?", (org_id,)
|
||||||
|
).fetchone()
|
||||||
|
return row["cnt"] if row else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_by_email(db, email: str):
|
||||||
|
"""Lookup dans saas_users par email."""
|
||||||
|
return db.execute(
|
||||||
|
"SELECT * FROM saas_users WHERE email = ?", (email.lower().strip(),)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def _org_to_dict(org) -> dict:
|
||||||
|
return {
|
||||||
|
"id": org["id"],
|
||||||
|
"owner_id": org["owner_id"],
|
||||||
|
"name": org["name"],
|
||||||
|
"max_members": org["max_members"],
|
||||||
|
"created_at": org["created_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _member_to_dict(m) -> dict:
|
||||||
|
return {
|
||||||
|
"id": m["id"],
|
||||||
|
"org_id": m["org_id"],
|
||||||
|
"user_id": m["user_id"],
|
||||||
|
"role": m["role"],
|
||||||
|
"invited_at": m["invited_at"],
|
||||||
|
"joined_at": m["joined_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# POST /api/v1/org — créer une organisation
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@org_bp.route("", methods=["POST"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@_require_pro
|
||||||
|
def create_org():
|
||||||
|
"""
|
||||||
|
Crée une organisation.
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Organisation
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [name]
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Nom de l'organisation (1-100 caractères)
|
||||||
|
responses:
|
||||||
|
201:
|
||||||
|
description: Organisation créée
|
||||||
|
400:
|
||||||
|
description: Paramètre manquant ou invalide
|
||||||
|
403:
|
||||||
|
description: Plan insuffisant
|
||||||
|
409:
|
||||||
|
description: L'utilisateur possède déjà une organisation
|
||||||
|
"""
|
||||||
|
user = request.current_user
|
||||||
|
owner_id = user["id"]
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
name = (data.get("name") or "").strip()
|
||||||
|
if not name or len(name) > 100:
|
||||||
|
return jsonify({"error": "Le nom est requis (1-100 caractères)"}), 400
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
# 1 org max par owner
|
||||||
|
existing = _get_org_by_owner(db, owner_id)
|
||||||
|
if existing:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"error": "Vous possédez déjà une organisation",
|
||||||
|
"org_id": existing["id"],
|
||||||
|
}
|
||||||
|
), 409
|
||||||
|
|
||||||
|
org_id = secrets.token_hex(16)
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO organizations (id, owner_id, name, max_members, created_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(org_id, owner_id, name, MAX_MEMBERS, now),
|
||||||
|
)
|
||||||
|
# Ajouter l'owner comme premier membre avec rôle 'owner'
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) "
|
||||||
|
"VALUES (?, ?, 'owner', ?, ?)",
|
||||||
|
(org_id, owner_id, now, now),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
org = _get_org_by_id(db, org_id)
|
||||||
|
logger.info("Org créée: %s par user %s", org_id, owner_id)
|
||||||
|
return jsonify({"org": _org_to_dict(org)}), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error("create_org error: %s", e)
|
||||||
|
return jsonify({"error": "Erreur interne"}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# GET /api/v1/org — infos org courante
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@org_bp.route("", methods=["GET"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@_require_pro
|
||||||
|
def get_org():
|
||||||
|
"""
|
||||||
|
Retourne l'organisation dont l'utilisateur est owner ou membre.
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Organisation
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Infos de l'organisation
|
||||||
|
404:
|
||||||
|
description: Aucune organisation trouvée
|
||||||
|
"""
|
||||||
|
user = request.current_user
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
org = _get_org_by_owner(db, user["id"]) or _get_member_org(db, user["id"])
|
||||||
|
if not org:
|
||||||
|
return jsonify({"error": "Aucune organisation trouvée"}), 404
|
||||||
|
|
||||||
|
member_count = _count_org_members(db, org["id"])
|
||||||
|
result = _org_to_dict(org)
|
||||||
|
result["member_count"] = member_count
|
||||||
|
return jsonify({"org": result}), 200
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# DELETE /api/v1/org — supprimer l'organisation
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@org_bp.route("", methods=["DELETE"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@_require_pro
|
||||||
|
def delete_org():
|
||||||
|
"""
|
||||||
|
Supprime l'organisation (owner uniquement).
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Organisation
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Organisation supprimée
|
||||||
|
403:
|
||||||
|
description: Seul l'owner peut supprimer l'organisation
|
||||||
|
404:
|
||||||
|
description: Organisation introuvable
|
||||||
|
"""
|
||||||
|
user = request.current_user
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
org = _get_org_by_owner(db, user["id"])
|
||||||
|
if not org:
|
||||||
|
return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403
|
||||||
|
|
||||||
|
# CASCADE supprime org_members automatiquement (FK ON DELETE CASCADE)
|
||||||
|
db.execute("DELETE FROM organizations WHERE id = ?", (org["id"],))
|
||||||
|
db.commit()
|
||||||
|
logger.info("Org %s supprimée par user %s", org["id"], user["id"])
|
||||||
|
return jsonify({"ok": True, "deleted_org_id": org["id"]}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error("delete_org error: %s", e)
|
||||||
|
return jsonify({"error": "Erreur interne"}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# POST /api/v1/org/invite — inviter un membre par email
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@org_bp.route("/invite", methods=["POST"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@_require_pro
|
||||||
|
def invite_member():
|
||||||
|
"""
|
||||||
|
Invite un utilisateur dans l'organisation par email (owner uniquement).
|
||||||
|
Limite : 5 membres totaux (owner inclus).
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Organisation
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [email]
|
||||||
|
properties:
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
description: Email de l'utilisateur à inviter
|
||||||
|
responses:
|
||||||
|
201:
|
||||||
|
description: Membre ajouté
|
||||||
|
400:
|
||||||
|
description: Paramètre manquant ou invalide
|
||||||
|
403:
|
||||||
|
description: Seul l'owner peut inviter / limite de membres atteinte
|
||||||
|
404:
|
||||||
|
description: Utilisateur introuvable ou organisation inexistante
|
||||||
|
409:
|
||||||
|
description: L'utilisateur est déjà membre
|
||||||
|
"""
|
||||||
|
user = request.current_user
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
email = (data.get("email") or "").strip().lower()
|
||||||
|
|
||||||
|
if not email or "@" not in email:
|
||||||
|
return jsonify({"error": "Email invalide"}), 400
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
# Vérifier que l'appelant est bien owner d'une org
|
||||||
|
org = _get_org_by_owner(db, user["id"])
|
||||||
|
if not org:
|
||||||
|
return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403
|
||||||
|
|
||||||
|
# Vérifier la limite de membres
|
||||||
|
current_count = _count_org_members(db, org["id"])
|
||||||
|
if current_count >= org["max_members"]:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"error": f"Limite de {org['max_members']} membres atteinte",
|
||||||
|
"current_count": current_count,
|
||||||
|
}
|
||||||
|
), 403
|
||||||
|
|
||||||
|
# Résoudre l'utilisateur cible
|
||||||
|
target_user = _get_user_by_email(db, email)
|
||||||
|
if not target_user:
|
||||||
|
return jsonify({"error": "Utilisateur introuvable avec cet email"}), 404
|
||||||
|
|
||||||
|
target_id = target_user["id"]
|
||||||
|
|
||||||
|
# Vérifier que l'utilisateur n'est pas déjà membre de CETTE org
|
||||||
|
existing_member = db.execute(
|
||||||
|
"SELECT id FROM org_members WHERE org_id = ? AND user_id = ?",
|
||||||
|
(org["id"], target_id),
|
||||||
|
).fetchone()
|
||||||
|
if existing_member:
|
||||||
|
return jsonify(
|
||||||
|
{"error": "Cet utilisateur est déjà membre de l'organisation"}
|
||||||
|
), 409
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) "
|
||||||
|
"VALUES (?, ?, 'member', ?, ?)",
|
||||||
|
(org["id"], target_id, now, now),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
member_row = db.execute(
|
||||||
|
"SELECT * FROM org_members WHERE org_id = ? AND user_id = ?",
|
||||||
|
(org["id"], target_id),
|
||||||
|
).fetchone()
|
||||||
|
logger.info(
|
||||||
|
"User %s invité dans org %s par %s", target_id, org["id"], user["id"]
|
||||||
|
)
|
||||||
|
return jsonify({"member": _member_to_dict(member_row)}), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error("invite_member error: %s", e)
|
||||||
|
return jsonify({"error": "Erreur interne"}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# GET /api/v1/org/members — liste des membres
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@org_bp.route("/members", methods=["GET"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@_require_pro
|
||||||
|
def list_members():
|
||||||
|
"""
|
||||||
|
Liste les membres de l'organisation courante.
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Organisation
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Liste des membres
|
||||||
|
404:
|
||||||
|
description: Organisation introuvable
|
||||||
|
"""
|
||||||
|
user = request.current_user
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
org = _get_org_by_owner(db, user["id"]) or _get_member_org(db, user["id"])
|
||||||
|
if not org:
|
||||||
|
return jsonify({"error": "Aucune organisation trouvée"}), 404
|
||||||
|
|
||||||
|
members = db.execute(
|
||||||
|
"SELECT m.*, u.email, u.firstname, u.lastname "
|
||||||
|
"FROM org_members m "
|
||||||
|
"LEFT JOIN saas_users u ON u.id = m.user_id "
|
||||||
|
"WHERE m.org_id = ? "
|
||||||
|
"ORDER BY m.invited_at ASC",
|
||||||
|
(org["id"],),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for m in members:
|
||||||
|
d = _member_to_dict(m)
|
||||||
|
d["email"] = m["email"]
|
||||||
|
d["firstname"] = m["firstname"] or ""
|
||||||
|
d["lastname"] = m["lastname"] or ""
|
||||||
|
result.append(d)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"org_id": org["id"],
|
||||||
|
"members": result,
|
||||||
|
"count": len(result),
|
||||||
|
"max_members": org["max_members"],
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# DELETE /api/v1/org/members/<user_id> — retirer un membre
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@org_bp.route("/members/<string:target_user_id>", methods=["DELETE"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@_require_pro
|
||||||
|
def remove_member(target_user_id: str):
|
||||||
|
"""
|
||||||
|
Retire un membre de l'organisation (owner uniquement).
|
||||||
|
L'owner ne peut pas se retirer lui-même.
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Organisation
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: user_id
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: ID de l'utilisateur à retirer
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Membre retiré
|
||||||
|
400:
|
||||||
|
description: Tentative de retirer l'owner lui-même
|
||||||
|
403:
|
||||||
|
description: Seul l'owner peut retirer des membres
|
||||||
|
404:
|
||||||
|
description: Membre ou organisation introuvable
|
||||||
|
"""
|
||||||
|
user = request.current_user
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
org = _get_org_by_owner(db, user["id"])
|
||||||
|
if not org:
|
||||||
|
return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403
|
||||||
|
|
||||||
|
# L'owner ne peut pas se retirer lui-même (utiliser DELETE /api/v1/org à la place)
|
||||||
|
if target_user_id == user["id"]:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"error": "L'owner ne peut pas se retirer lui-même. "
|
||||||
|
"Utilisez DELETE /api/v1/org pour supprimer l'organisation."
|
||||||
|
}
|
||||||
|
), 400
|
||||||
|
|
||||||
|
member = db.execute(
|
||||||
|
"SELECT * FROM org_members WHERE org_id = ? AND user_id = ?",
|
||||||
|
(org["id"], target_user_id),
|
||||||
|
).fetchone()
|
||||||
|
if not member:
|
||||||
|
return jsonify({"error": "Membre introuvable dans cette organisation"}), 404
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"DELETE FROM org_members WHERE org_id = ? AND user_id = ?",
|
||||||
|
(org["id"], target_user_id),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
logger.info(
|
||||||
|
"User %s retiré de l'org %s par %s", target_user_id, org["id"], user["id"]
|
||||||
|
)
|
||||||
|
return jsonify({"ok": True, "removed_user_id": target_user_id}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error("remove_member error: %s", e)
|
||||||
|
return jsonify({"error": "Erreur interne"}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# On-import : migration idempotente
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
try:
|
||||||
|
migrate_org_tables()
|
||||||
|
except Exception as _e:
|
||||||
|
logger.warning("org_db migration skipped (test env?): %s", _e)
|
||||||
216
api_v1/routes/user.py
Normal file
216
api_v1/routes/user.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
User route for API v1 — Telegram alert configuration
|
||||||
|
HRT-79: Alertes Telegram configurables (Premium)
|
||||||
|
|
||||||
|
GET /api/v1/user/telegram-config — Lire la config Telegram de l'utilisateur connecté
|
||||||
|
POST /api/v1/user/telegram-config — Mettre à jour la config Telegram
|
||||||
|
|
||||||
|
Accès : Premium / Pro uniquement (@jwt_required_middleware + @plan_required)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
from api_v1.utils import internal_error, bad_request
|
||||||
|
from auth import jwt_required_middleware, plan_required
|
||||||
|
|
||||||
|
user_bp = Blueprint("v1_user", __name__, url_prefix="/api/v1/user")
|
||||||
|
|
||||||
|
# DB_PATH est résolu via la même variable d'env que auth_db.py
|
||||||
|
import os
|
||||||
|
|
||||||
|
_DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_db():
|
||||||
|
conn = sqlite3.connect(_DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /api/v1/user/telegram-config ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@user_bp.route("/telegram-config", methods=["GET"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@plan_required("premium", "pro")
|
||||||
|
def get_telegram_config():
|
||||||
|
"""
|
||||||
|
Retourne la configuration Telegram de l'utilisateur connecté.
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Utilisateur
|
||||||
|
summary: Lire la config alertes Telegram (premium+)
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Configuration Telegram courante
|
||||||
|
schema:
|
||||||
|
properties:
|
||||||
|
telegram_chat_id:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
alert_value_bets:
|
||||||
|
type: boolean
|
||||||
|
alert_top1:
|
||||||
|
type: boolean
|
||||||
|
alert_quinte_only:
|
||||||
|
type: boolean
|
||||||
|
401:
|
||||||
|
description: Token invalide
|
||||||
|
403:
|
||||||
|
description: Plan insuffisant
|
||||||
|
"""
|
||||||
|
user_id = request.user_id # injecté par jwt_required_middleware
|
||||||
|
|
||||||
|
conn = _get_db()
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT telegram_chat_id, alert_value_bets, alert_top1, alert_quinte_only
|
||||||
|
FROM users
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return jsonify({"error": "Utilisateur introuvable"}), 404
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"telegram_chat_id": row["telegram_chat_id"],
|
||||||
|
"alert_value_bets": bool(row["alert_value_bets"]),
|
||||||
|
"alert_top1": bool(row["alert_top1"]),
|
||||||
|
"alert_quinte_only": bool(row["alert_quinte_only"]),
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
|
||||||
|
except sqlite3.OperationalError as exc:
|
||||||
|
# Colonnes absentes : migration non appliquée
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"telegram_chat_id": None,
|
||||||
|
"alert_value_bets": True,
|
||||||
|
"alert_top1": True,
|
||||||
|
"alert_quinte_only": False,
|
||||||
|
"_warning": "Migration Telegram non appliquée",
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
except Exception as exc:
|
||||||
|
return internal_error(str(exc))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /api/v1/user/telegram-config ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@user_bp.route("/telegram-config", methods=["POST"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@plan_required("premium", "pro")
|
||||||
|
def update_telegram_config():
|
||||||
|
"""
|
||||||
|
Met à jour la configuration Telegram de l'utilisateur connecté.
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Utilisateur
|
||||||
|
summary: Configurer les alertes Telegram (premium+)
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
parameters:
|
||||||
|
- in: body
|
||||||
|
name: body
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
properties:
|
||||||
|
telegram_chat_id:
|
||||||
|
type: string
|
||||||
|
description: Chat ID Telegram (ou null pour désactiver)
|
||||||
|
alert_value_bets:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
alert_top1:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
alert_quinte_only:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Configuration mise à jour
|
||||||
|
400:
|
||||||
|
description: Paramètres invalides
|
||||||
|
401:
|
||||||
|
description: Token invalide
|
||||||
|
403:
|
||||||
|
description: Plan insuffisant
|
||||||
|
"""
|
||||||
|
user_id = request.user_id # injecté par jwt_required_middleware
|
||||||
|
|
||||||
|
data = request.get_json(silent=True)
|
||||||
|
if not data:
|
||||||
|
return bad_request("Corps JSON requis")
|
||||||
|
|
||||||
|
# Validation et extraction des champs
|
||||||
|
telegram_chat_id = data.get("telegram_chat_id")
|
||||||
|
if telegram_chat_id is not None and not isinstance(telegram_chat_id, str):
|
||||||
|
return bad_request("telegram_chat_id doit être une chaîne ou null")
|
||||||
|
if isinstance(telegram_chat_id, str):
|
||||||
|
telegram_chat_id = telegram_chat_id.strip() or None
|
||||||
|
|
||||||
|
alert_value_bets = data.get("alert_value_bets", True)
|
||||||
|
alert_top1 = data.get("alert_top1", True)
|
||||||
|
alert_quinte_only = data.get("alert_quinte_only", False)
|
||||||
|
|
||||||
|
if not isinstance(alert_value_bets, bool):
|
||||||
|
return bad_request("alert_value_bets doit être un booléen")
|
||||||
|
if not isinstance(alert_top1, bool):
|
||||||
|
return bad_request("alert_top1 doit être un booléen")
|
||||||
|
if not isinstance(alert_quinte_only, bool):
|
||||||
|
return bad_request("alert_quinte_only doit être un booléen")
|
||||||
|
|
||||||
|
conn = _get_db()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET telegram_chat_id = ?,
|
||||||
|
alert_value_bets = ?,
|
||||||
|
alert_top1 = ?,
|
||||||
|
alert_quinte_only = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
telegram_chat_id,
|
||||||
|
int(alert_value_bets),
|
||||||
|
int(alert_top1),
|
||||||
|
int(alert_quinte_only),
|
||||||
|
user_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"telegram_chat_id": telegram_chat_id,
|
||||||
|
"alert_value_bets": alert_value_bets,
|
||||||
|
"alert_top1": alert_top1,
|
||||||
|
"alert_quinte_only": alert_quinte_only,
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
|
||||||
|
except sqlite3.OperationalError as exc:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"error": "Migration Telegram non appliquée — contacter le support",
|
||||||
|
"detail": str(exc),
|
||||||
|
}
|
||||||
|
), 500
|
||||||
|
except Exception as exc:
|
||||||
|
return internal_error(str(exc))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
31
auth_db.py
31
auth_db.py
@@ -2,6 +2,7 @@
|
|||||||
"""
|
"""
|
||||||
Auth DB — users and subscriptions schema for turf_saas.db
|
Auth DB — users and subscriptions schema for turf_saas.db
|
||||||
Sprint 2-3: Auth JWT + Multi-tenant (HRT-28)
|
Sprint 2-3: Auth JWT + Multi-tenant (HRT-28)
|
||||||
|
HRT-79: migration Telegram columns
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -63,6 +64,36 @@ def init_auth_tables():
|
|||||||
conn.close()
|
conn.close()
|
||||||
print("[auth_db] Tables users, subscriptions, refresh_tokens created/verified.")
|
print("[auth_db] Tables users, subscriptions, refresh_tokens created/verified.")
|
||||||
|
|
||||||
|
# Apply Telegram columns migration (idempotent)
|
||||||
|
migrate_telegram_columns()
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_telegram_columns():
|
||||||
|
"""
|
||||||
|
Migration idempotente : ajoute les colonnes Telegram à la table users.
|
||||||
|
Utilise ALTER TABLE ... ADD COLUMN avec try/except OperationalError
|
||||||
|
pour être safe si les colonnes existent déjà (SQLite ne supporte pas IF NOT EXISTS).
|
||||||
|
HRT-79
|
||||||
|
"""
|
||||||
|
conn = get_db()
|
||||||
|
c = conn.cursor()
|
||||||
|
columns = [
|
||||||
|
("telegram_chat_id", "TEXT DEFAULT NULL"),
|
||||||
|
("alert_value_bets", "INTEGER DEFAULT 1"),
|
||||||
|
("alert_top1", "INTEGER DEFAULT 1"),
|
||||||
|
("alert_quinte_only", "INTEGER DEFAULT 0"),
|
||||||
|
]
|
||||||
|
for col, definition in columns:
|
||||||
|
try:
|
||||||
|
c.execute(f"ALTER TABLE users ADD COLUMN {col} {definition}")
|
||||||
|
print(f"[auth_db] Colonne '{col}' ajoutée.")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
# Column already exists — safe to ignore
|
||||||
|
pass
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print("[auth_db] Migration Telegram columns OK.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
init_auth_tables()
|
init_auth_tables()
|
||||||
|
|||||||
@@ -76,14 +76,30 @@ def migrate_billing_tables():
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
stripe_event_id TEXT NOT NULL UNIQUE,
|
stripe_event_id TEXT NOT NULL UNIQUE,
|
||||||
event_type TEXT NOT NULL,
|
event_type TEXT NOT NULL,
|
||||||
user_id INTEGER REFERENCES users(id),
|
user_id TEXT,
|
||||||
payload TEXT,
|
payload TEXT,
|
||||||
processed_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
processed_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_billing_events_user ON billing_events(user_id);
|
CREATE TABLE IF NOT EXISTS saas_subscriptions (
|
||||||
CREATE INDEX IF NOT EXISTS idx_billing_events_type ON billing_events(event_type);
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe ON subscriptions(stripe_subscription_id);
|
user_id TEXT NOT NULL,
|
||||||
|
plan TEXT NOT NULL DEFAULT 'free',
|
||||||
|
start_date DATETIME DEFAULT (datetime('now')),
|
||||||
|
end_date DATETIME,
|
||||||
|
stripe_customer_id TEXT,
|
||||||
|
stripe_subscription_id TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
grace_period_end DATETIME,
|
||||||
|
current_period_end DATETIME
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_billing_events_user ON billing_events(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_billing_events_type ON billing_events(event_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_saas_subs_user ON saas_subscriptions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_saas_subs_customer ON saas_subscriptions(stripe_customer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_saas_subs_stripe ON saas_subscriptions(stripe_subscription_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe ON subscriptions(stripe_subscription_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_customer ON subscriptions(stripe_customer_id);
|
CREATE INDEX IF NOT EXISTS idx_subscriptions_customer ON subscriptions(stripe_customer_id);
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
|||||||
1266
dashboard_saas.html
1266
dashboard_saas.html
File diff suppressed because it is too large
Load Diff
21
infra/turf-saas-leadhunter.service
Normal file
21
infra/turf-saas-leadhunter.service
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=H3R7Tech LeadHunter API (Port 8775)
|
||||||
|
Documentation=https://portal-kolifee.duckdns.org
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=h3r7
|
||||||
|
WorkingDirectory=/home/h3r7/turf_saas
|
||||||
|
|
||||||
|
# Charger les variables d'environnement depuis /home/h3r7/.env
|
||||||
|
# (notamment GOOGLE_PLACES_API_KEY)
|
||||||
|
EnvironmentFile=/home/h3r7/.env
|
||||||
|
|
||||||
|
ExecStart=/home/h3r7/turf_saas/venv/bin/python3 /home/h3r7/turf_saas/leadhunter_api.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
Environment=PYTHONPATH=/home/h3r7/turf_saas
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
303
leadhunter_api.py
Normal file
303
leadhunter_api.py
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
H3R7Tech — LeadHunter API
|
||||||
|
===========================
|
||||||
|
Service Flask sur port 8775 exposant les endpoints LeadHunter.
|
||||||
|
|
||||||
|
Endpoints :
|
||||||
|
GET /api/leads — Liste les leads (filtres: status, limit, offset)
|
||||||
|
POST /api/leads/scrape — Lance un job de scraping asynchrone
|
||||||
|
GET /api/leads/stats — Statistiques globales du CRM
|
||||||
|
GET /api/leads/export — Export CSV des leads
|
||||||
|
PATCH /api/leads/<id>/status — Met à jour le statut d'un lead
|
||||||
|
|
||||||
|
Port : 8775 (8769 occupé par depenses_trello/app.py, 8770 occupé par turf_scraper/crm_api.py — corrigé HRT-66)
|
||||||
|
|
||||||
|
Auteur: H3R7Tech Backend Engineer
|
||||||
|
Issue: HRT-66
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import logging
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
from flask import Flask, jsonify, request, Response
|
||||||
|
from flask_cors import CORS
|
||||||
|
|
||||||
|
# Import des modules LeadHunter
|
||||||
|
from leadhunter_crm import (
|
||||||
|
init_db,
|
||||||
|
insert_leads,
|
||||||
|
get_leads,
|
||||||
|
get_lead_by_id,
|
||||||
|
update_lead_status,
|
||||||
|
get_stats,
|
||||||
|
export_csv,
|
||||||
|
VALID_STATUSES,
|
||||||
|
DB_PATH,
|
||||||
|
)
|
||||||
|
from leadhunter_scraper import run_scraping, GOOGLE_PLACES_API_KEY
|
||||||
|
from leadhunter_scorer import LeadScorer
|
||||||
|
|
||||||
|
# ─── Assertions au démarrage ─────────────────────────────────────────────────
|
||||||
|
# Vérification obligatoire : la clé API doit être présente au démarrage
|
||||||
|
assert os.environ.get("GOOGLE_PLACES_API_KEY"), (
|
||||||
|
"GOOGLE_PLACES_API_KEY manquante. "
|
||||||
|
"Ajouter dans /home/h3r7/.env : export GOOGLE_PLACES_API_KEY=xxx"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── Logging ────────────────────────────────────────────────────────────────
|
||||||
|
logger = logging.getLogger("leadhunter.api")
|
||||||
|
|
||||||
|
_handler = RotatingFileHandler(
|
||||||
|
"/home/h3r7/leadhunter.log",
|
||||||
|
maxBytes=5 * 1024 * 1024,
|
||||||
|
backupCount=3,
|
||||||
|
)
|
||||||
|
_handler.setFormatter(
|
||||||
|
logging.Formatter("%(asctime)s %(levelname)-8s %(name)s — %(message)s")
|
||||||
|
)
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
if not logger.handlers:
|
||||||
|
logger.addHandler(_handler)
|
||||||
|
logger.addHandler(logging.StreamHandler())
|
||||||
|
|
||||||
|
# ─── App Flask ───────────────────────────────────────────────────────────────
|
||||||
|
app = Flask(__name__)
|
||||||
|
CORS(app)
|
||||||
|
|
||||||
|
# Scorer singleton
|
||||||
|
scorer = LeadScorer()
|
||||||
|
|
||||||
|
# État global du job de scraping (simple flag — pas de celery nécessaire pour le POC)
|
||||||
|
_scrape_job = {
|
||||||
|
"running": False,
|
||||||
|
"last_run": None,
|
||||||
|
"last_count": 0,
|
||||||
|
"last_error": None,
|
||||||
|
}
|
||||||
|
_scrape_lock = threading.Lock()
|
||||||
|
|
||||||
|
# ─── Init DB ─────────────────────────────────────────────────────────────────
|
||||||
|
init_db(DB_PATH)
|
||||||
|
logger.info("LeadHunter API démarrée — DB initialisée.")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _run_scrape_job(max_leads: int, use_google: bool, use_osm: bool) -> None:
|
||||||
|
"""Job de scraping exécuté dans un thread séparé."""
|
||||||
|
with _scrape_lock:
|
||||||
|
_scrape_job["running"] = True
|
||||||
|
_scrape_job["last_error"] = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
leads_raw = run_scraping(
|
||||||
|
max_leads=max_leads,
|
||||||
|
use_google=use_google,
|
||||||
|
use_osm=use_osm,
|
||||||
|
)
|
||||||
|
leads_scored = scorer.score_leads(leads_raw)
|
||||||
|
inserted_ids = insert_leads(leads_scored)
|
||||||
|
|
||||||
|
with _scrape_lock:
|
||||||
|
_scrape_job["last_count"] = len(inserted_ids)
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
_scrape_job["last_run"] = datetime.utcnow().isoformat() + "Z"
|
||||||
|
|
||||||
|
logger.info(f"Scrape job terminé : {len(inserted_ids)} leads insérés.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Scrape job erreur : {e}")
|
||||||
|
with _scrape_lock:
|
||||||
|
_scrape_job["last_error"] = str(e)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
with _scrape_lock:
|
||||||
|
_scrape_job["running"] = False
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Routes ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/leads", methods=["GET"])
|
||||||
|
def api_get_leads():
|
||||||
|
"""
|
||||||
|
Liste les leads du CRM.
|
||||||
|
|
||||||
|
Query params :
|
||||||
|
- status (str, optional) : filtre sur new/contacted/closed/rejected
|
||||||
|
- limit (int, default=50) : pagination
|
||||||
|
- offset (int, default=0) : pagination
|
||||||
|
"""
|
||||||
|
status = request.args.get("status")
|
||||||
|
try:
|
||||||
|
limit = int(request.args.get("limit", 50))
|
||||||
|
offset = int(request.args.get("offset", 0))
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"error": "limit et offset doivent être des entiers"}), 400
|
||||||
|
|
||||||
|
if status and status not in VALID_STATUSES:
|
||||||
|
return jsonify(
|
||||||
|
{"error": f"status invalide. Valeurs acceptées : {VALID_STATUSES}"}
|
||||||
|
), 400
|
||||||
|
|
||||||
|
leads = get_leads(status=status, limit=limit, offset=offset)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"leads": leads,
|
||||||
|
"count": len(leads),
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
"status_filter": status,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/leads/scrape", methods=["POST"])
|
||||||
|
def api_scrape():
|
||||||
|
"""
|
||||||
|
Lance un job de scraping asynchrone.
|
||||||
|
|
||||||
|
Body JSON (optionnel) :
|
||||||
|
- max_leads (int, default=100)
|
||||||
|
- use_google (bool, default=true)
|
||||||
|
- use_osm (bool, default=true)
|
||||||
|
|
||||||
|
Retourne immédiatement avec le statut du job.
|
||||||
|
"""
|
||||||
|
with _scrape_lock:
|
||||||
|
if _scrape_job["running"]:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "already_running",
|
||||||
|
"message": "Un job de scraping est déjà en cours.",
|
||||||
|
}
|
||||||
|
), 409
|
||||||
|
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
max_leads = int(body.get("max_leads", 100))
|
||||||
|
use_google = bool(body.get("use_google", True))
|
||||||
|
use_osm = bool(body.get("use_osm", True))
|
||||||
|
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=_run_scrape_job,
|
||||||
|
args=(max_leads, use_google, use_osm),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Job de scraping lancé (max_leads={max_leads}, "
|
||||||
|
f"use_google={use_google}, use_osm={use_osm})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "started",
|
||||||
|
"message": "Job de scraping démarré en arrière-plan.",
|
||||||
|
"params": {
|
||||||
|
"max_leads": max_leads,
|
||||||
|
"use_google": use_google,
|
||||||
|
"use_osm": use_osm,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
), 202
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/leads/scrape/status", methods=["GET"])
|
||||||
|
def api_scrape_status():
|
||||||
|
"""Retourne l'état courant du job de scraping."""
|
||||||
|
with _scrape_lock:
|
||||||
|
return jsonify(dict(_scrape_job))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/leads/stats", methods=["GET"])
|
||||||
|
def api_stats():
|
||||||
|
"""
|
||||||
|
Statistiques globales du CRM LeadHunter.
|
||||||
|
|
||||||
|
Retourne : total, by_status, by_source, avg_score, top_leads_count
|
||||||
|
"""
|
||||||
|
stats = get_stats()
|
||||||
|
if not stats:
|
||||||
|
return jsonify({"error": "Impossible de calculer les statistiques"}), 500
|
||||||
|
return jsonify(stats)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/leads/export", methods=["GET"])
|
||||||
|
def api_export():
|
||||||
|
"""
|
||||||
|
Export CSV de tous les leads (ou filtrés par status).
|
||||||
|
|
||||||
|
Query params :
|
||||||
|
- status (str, optional)
|
||||||
|
"""
|
||||||
|
status = request.args.get("status")
|
||||||
|
if status and status not in VALID_STATUSES:
|
||||||
|
return jsonify({"error": f"status invalide : {VALID_STATUSES}"}), 400
|
||||||
|
|
||||||
|
csv_content = export_csv(status=status)
|
||||||
|
filename = f"leadhunter_leads{'_' + status if status else ''}.csv"
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
csv_content,
|
||||||
|
mimetype="text/csv",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f"attachment; filename={filename}",
|
||||||
|
"Content-Type": "text/csv; charset=utf-8",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/leads/<int:lead_id>/status", methods=["PATCH"])
|
||||||
|
def api_update_status(lead_id: int):
|
||||||
|
"""
|
||||||
|
Met à jour le statut d'un lead.
|
||||||
|
|
||||||
|
Body JSON :
|
||||||
|
- status (str) : new | contacted | closed | rejected
|
||||||
|
"""
|
||||||
|
body = request.get_json(silent=True)
|
||||||
|
if not body or "status" not in body:
|
||||||
|
return jsonify({"error": "Body JSON requis avec le champ 'status'"}), 400
|
||||||
|
|
||||||
|
new_status = body["status"]
|
||||||
|
if new_status not in VALID_STATUSES:
|
||||||
|
return jsonify({"error": f"status invalide. Valeurs : {VALID_STATUSES}"}), 400
|
||||||
|
|
||||||
|
lead = get_lead_by_id(lead_id)
|
||||||
|
if not lead:
|
||||||
|
return jsonify({"error": f"Lead id={lead_id} introuvable"}), 404
|
||||||
|
|
||||||
|
success = update_lead_status(lead_id, new_status)
|
||||||
|
if not success:
|
||||||
|
return jsonify({"error": "Mise à jour échouée"}), 500
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"lead_id": lead_id,
|
||||||
|
"new_status": new_status,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/health", methods=["GET"])
|
||||||
|
def health():
|
||||||
|
"""Healthcheck pour systemd / monitoring."""
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"service": "leadhunter-api",
|
||||||
|
"port": 8775,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Entrypoint ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=8775, debug=False)
|
||||||
349
leadhunter_crm.py
Normal file
349
leadhunter_crm.py
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
H3R7Tech — LeadHunter CRM (SQLite)
|
||||||
|
=====================================
|
||||||
|
Couche de persistance SQLite pour les leads LeadHunter.
|
||||||
|
|
||||||
|
Schéma validé CTO (HRT-66) :
|
||||||
|
CREATE TABLE leads (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source TEXT NOT NULL, -- 'google_places' ou 'osm'
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
address TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
rating REAL,
|
||||||
|
reviews_count INTEGER,
|
||||||
|
website TEXT,
|
||||||
|
score INTEGER,
|
||||||
|
rgpd_ok BOOLEAN DEFAULT 1,
|
||||||
|
scraped_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
status TEXT DEFAULT 'new' -- new, contacted, closed, rejected
|
||||||
|
);
|
||||||
|
|
||||||
|
Auteur: H3R7Tech Backend Engineer
|
||||||
|
Issue: HRT-66
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import logging
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# ─── Logging ────────────────────────────────────────────────────────────────
|
||||||
|
logger = logging.getLogger("leadhunter.crm")
|
||||||
|
|
||||||
|
_handler = RotatingFileHandler(
|
||||||
|
"/home/h3r7/leadhunter.log",
|
||||||
|
maxBytes=5 * 1024 * 1024,
|
||||||
|
backupCount=3,
|
||||||
|
)
|
||||||
|
_handler.setFormatter(
|
||||||
|
logging.Formatter("%(asctime)s %(levelname)-8s %(name)s — %(message)s")
|
||||||
|
)
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
if not logger.handlers:
|
||||||
|
logger.addHandler(_handler)
|
||||||
|
logger.addHandler(logging.StreamHandler())
|
||||||
|
|
||||||
|
# ─── Chemin DB ───────────────────────────────────────────────────────────────
|
||||||
|
DB_PATH = "/home/h3r7/leadhunter.db"
|
||||||
|
|
||||||
|
# Statuts valides pour un lead
|
||||||
|
VALID_STATUSES = {"new", "contacted", "closed", "rejected"}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Initialisation ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(db_path: str = DB_PATH) -> None:
|
||||||
|
"""
|
||||||
|
Crée la base SQLite et la table leads si elle n'existe pas.
|
||||||
|
Idempotent — peut être appelé au démarrage de l'API.
|
||||||
|
"""
|
||||||
|
with sqlite3.connect(db_path) as conn:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS leads (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
address TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
rating REAL,
|
||||||
|
reviews_count INTEGER,
|
||||||
|
website TEXT,
|
||||||
|
score INTEGER,
|
||||||
|
rgpd_ok BOOLEAN DEFAULT 1,
|
||||||
|
scraped_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
status TEXT DEFAULT 'new'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
logger.info(f"DB initialisée : {db_path}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Context manager ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _get_conn(db_path: str = DB_PATH):
|
||||||
|
"""Fournit une connexion SQLite avec row_factory."""
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
logger.warning(f"DB transaction rollback : {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── CRUD ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def insert_lead(lead: dict, db_path: str = DB_PATH) -> Optional[int]:
|
||||||
|
"""
|
||||||
|
Insère un lead normalisé dans la DB.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lead: dict avec les champs normalisés (source, name, address, ...)
|
||||||
|
db_path: chemin vers la DB SQLite.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
L'id SQLite du lead inséré, ou None en cas d'erreur.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with _get_conn(db_path) as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO leads
|
||||||
|
(source, name, address, phone, rating, reviews_count,
|
||||||
|
website, score, rgpd_ok, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
lead.get("source", "unknown"),
|
||||||
|
lead.get("name", ""),
|
||||||
|
lead.get("address", ""),
|
||||||
|
lead.get("phone", ""),
|
||||||
|
lead.get("rating"),
|
||||||
|
lead.get("reviews_count"),
|
||||||
|
lead.get("website", ""),
|
||||||
|
lead.get("score"),
|
||||||
|
1 if lead.get("rgpd_ok", True) else 0,
|
||||||
|
lead.get("status", "new"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
lead_id = cursor.lastrowid
|
||||||
|
logger.info(f"Lead inséré id={lead_id} : {lead.get('name')}")
|
||||||
|
return lead_id
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"insert_lead error : {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def insert_leads(leads: list[dict], db_path: str = DB_PATH) -> list[int]:
|
||||||
|
"""
|
||||||
|
Insère une liste de leads en batch.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Liste des ids insérés.
|
||||||
|
"""
|
||||||
|
ids = []
|
||||||
|
for lead in leads:
|
||||||
|
lead_id = insert_lead(lead, db_path)
|
||||||
|
if lead_id is not None:
|
||||||
|
ids.append(lead_id)
|
||||||
|
logger.info(f"insert_leads : {len(ids)}/{len(leads)} insérés.")
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def get_leads(
|
||||||
|
status: Optional[str] = None,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
db_path: str = DB_PATH,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Récupère les leads avec filtre optionnel sur le statut.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status: filtre sur le champ 'status' (new, contacted, closed, rejected).
|
||||||
|
limit: pagination — nombre de résultats max.
|
||||||
|
offset: pagination — décalage.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Liste de dicts (tous les champs de la table leads).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with _get_conn(db_path) as conn:
|
||||||
|
if status:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM leads WHERE status = ? ORDER BY score DESC, scraped_at DESC LIMIT ? OFFSET ?",
|
||||||
|
(status, limit, offset),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM leads ORDER BY score DESC, scraped_at DESC LIMIT ? OFFSET ?",
|
||||||
|
(limit, offset),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"get_leads error : {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def get_lead_by_id(lead_id: int, db_path: str = DB_PATH) -> Optional[dict]:
|
||||||
|
"""Récupère un lead par son id."""
|
||||||
|
try:
|
||||||
|
with _get_conn(db_path) as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM leads WHERE id = ?", (lead_id,)
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"get_lead_by_id error : {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def update_lead_status(lead_id: int, status: str, db_path: str = DB_PATH) -> bool:
|
||||||
|
"""
|
||||||
|
Met à jour le statut d'un lead.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lead_id: id du lead.
|
||||||
|
status: nouveau statut ('new', 'contacted', 'closed', 'rejected').
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si mise à jour réussie, False sinon.
|
||||||
|
"""
|
||||||
|
if status not in VALID_STATUSES:
|
||||||
|
logger.warning(f"update_lead_status : statut invalide '{status}'")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
with _get_conn(db_path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE leads SET status = ? WHERE id = ?",
|
||||||
|
(status, lead_id),
|
||||||
|
)
|
||||||
|
logger.info(f"Lead id={lead_id} statut → {status}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"update_lead_status error : {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_stats(db_path: str = DB_PATH) -> dict:
|
||||||
|
"""
|
||||||
|
Retourne les statistiques globales du CRM.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict avec total, by_status, by_source, avg_score, top_leads_count
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with _get_conn(db_path) as conn:
|
||||||
|
total = conn.execute("SELECT COUNT(*) FROM leads").fetchone()[0]
|
||||||
|
|
||||||
|
by_status_rows = conn.execute(
|
||||||
|
"SELECT status, COUNT(*) as cnt FROM leads GROUP BY status"
|
||||||
|
).fetchall()
|
||||||
|
by_status = {r["status"]: r["cnt"] for r in by_status_rows}
|
||||||
|
|
||||||
|
by_source_rows = conn.execute(
|
||||||
|
"SELECT source, COUNT(*) as cnt FROM leads GROUP BY source"
|
||||||
|
).fetchall()
|
||||||
|
by_source = {r["source"]: r["cnt"] for r in by_source_rows}
|
||||||
|
|
||||||
|
avg_score_row = conn.execute(
|
||||||
|
"SELECT AVG(score) FROM leads WHERE score IS NOT NULL"
|
||||||
|
).fetchone()
|
||||||
|
avg_score = round(avg_score_row[0] or 0, 2)
|
||||||
|
|
||||||
|
# Leads "chauds" = score ≥ 5
|
||||||
|
top_count = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM leads WHERE score >= 5"
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"by_status": by_status,
|
||||||
|
"by_source": by_source,
|
||||||
|
"avg_score": avg_score,
|
||||||
|
"top_leads_count": top_count,
|
||||||
|
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"get_stats error : {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def export_csv(
|
||||||
|
status: Optional[str] = None,
|
||||||
|
db_path: str = DB_PATH,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Exporte les leads en CSV (string).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status: filtre optionnel sur le statut.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Contenu CSV en string UTF-8.
|
||||||
|
"""
|
||||||
|
leads = get_leads(status=status, limit=10000, db_path=db_path)
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
fieldnames = [
|
||||||
|
"id",
|
||||||
|
"source",
|
||||||
|
"name",
|
||||||
|
"address",
|
||||||
|
"phone",
|
||||||
|
"rating",
|
||||||
|
"reviews_count",
|
||||||
|
"website",
|
||||||
|
"score",
|
||||||
|
"rgpd_ok",
|
||||||
|
"scraped_at",
|
||||||
|
"status",
|
||||||
|
]
|
||||||
|
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(leads)
|
||||||
|
|
||||||
|
logger.info(f"export_csv : {len(leads)} leads exportés.")
|
||||||
|
return output.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── CLI (debug) ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
# Test insertion
|
||||||
|
test_lead = {
|
||||||
|
"source": "google_places",
|
||||||
|
"name": "Restaurant Test",
|
||||||
|
"address": "10 rue de la Paix, 59000 Lille",
|
||||||
|
"phone": "+33 3 20 00 00 01",
|
||||||
|
"rating": 4.5,
|
||||||
|
"reviews_count": 120,
|
||||||
|
"website": "",
|
||||||
|
"score": 8,
|
||||||
|
"rgpd_ok": True,
|
||||||
|
"status": "new",
|
||||||
|
}
|
||||||
|
lead_id = insert_lead(test_lead)
|
||||||
|
print(f"Lead inséré : id={lead_id}")
|
||||||
|
|
||||||
|
leads = get_leads()
|
||||||
|
print(f"Leads en DB : {len(leads)}")
|
||||||
|
|
||||||
|
stats = get_stats()
|
||||||
|
print(f"Stats : {stats}")
|
||||||
193
leadhunter_scorer.py
Normal file
193
leadhunter_scorer.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
H3R7Tech — LeadHunter Scorer
|
||||||
|
================================
|
||||||
|
Moteur de scoring des leads restaurants MEL.
|
||||||
|
|
||||||
|
Critères (ordre de priorité métier) :
|
||||||
|
1. [+3] Site web absent ← CRITIQUE : raison d'être du produit
|
||||||
|
2. [+2] Nombre d'avis élevé (≥ 50) : forte activité = bon prospect de vente
|
||||||
|
3. [+2] Note Google élevée (≥ 4.0) : établissement sérieux
|
||||||
|
4. [+1] Téléphone présent : facilite la prise de contact
|
||||||
|
5. [-1] Note faible (< 3.0) : risque reputationnel pour la prestation web
|
||||||
|
|
||||||
|
Score maximum théorique : 8
|
||||||
|
Score minimum : 0 (leads avec site web ne doivent pas passer ici)
|
||||||
|
|
||||||
|
Auteur: H3R7Tech Backend Engineer
|
||||||
|
Issue: HRT-66
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
|
||||||
|
# ─── Logging ────────────────────────────────────────────────────────────────
|
||||||
|
logger = logging.getLogger("leadhunter.scorer")
|
||||||
|
|
||||||
|
_handler = RotatingFileHandler(
|
||||||
|
"/home/h3r7/leadhunter.log",
|
||||||
|
maxBytes=5 * 1024 * 1024,
|
||||||
|
backupCount=3,
|
||||||
|
)
|
||||||
|
_handler.setFormatter(
|
||||||
|
logging.Formatter("%(asctime)s %(levelname)-8s %(name)s — %(message)s")
|
||||||
|
)
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
if not logger.handlers:
|
||||||
|
logger.addHandler(_handler)
|
||||||
|
logger.addHandler(logging.StreamHandler())
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Scorer ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class LeadScorer:
|
||||||
|
"""
|
||||||
|
Calcule le score de priorité d'un lead.
|
||||||
|
|
||||||
|
Le score sert à trier les leads dans le CRM :
|
||||||
|
- Score élevé = prospect chaud (sans site + actif + bien noté)
|
||||||
|
- Score faible = prospect froid (peut être ignoré ou traité en dernier)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _calculate_score(self, lead: dict) -> int:
|
||||||
|
"""
|
||||||
|
Calcule le score d'un lead.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lead: dict avec les champs normalisés du scraper
|
||||||
|
(name, website, rating, reviews_count, phone, ...)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Score entier (0–8)
|
||||||
|
"""
|
||||||
|
score = 0
|
||||||
|
|
||||||
|
# ── Critère 1 : site web absent [CRITIQUE — logique métier centrale] ──
|
||||||
|
# C'est le critère n°1 : on cherche des restaurants SANS site web
|
||||||
|
# pour leur proposer une création de site à 800–1500€.
|
||||||
|
website = lead.get("website", "")
|
||||||
|
if not website or not website.strip():
|
||||||
|
score += 3
|
||||||
|
logger.debug(f"{lead.get('name')}: +3 (site web absent)")
|
||||||
|
else:
|
||||||
|
# Si le lead a un site web, score = 0 immédiatement.
|
||||||
|
# Ce cas ne devrait pas se produire (filtre scraper),
|
||||||
|
# mais on reste défensif.
|
||||||
|
logger.warning(
|
||||||
|
f"{lead.get('name')}: site web présent ({website}), "
|
||||||
|
"lead ignoré pour scoring."
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# ── Critère 2 : nombre d'avis élevé (≥ 50) ──────────────────────────
|
||||||
|
reviews = lead.get("reviews_count")
|
||||||
|
if reviews is not None:
|
||||||
|
try:
|
||||||
|
reviews = int(reviews)
|
||||||
|
if reviews >= 50:
|
||||||
|
score += 2
|
||||||
|
logger.debug(f"{lead.get('name')}: +2 (avis ≥ 50 : {reviews})")
|
||||||
|
except (TypeError, ValueError) as e:
|
||||||
|
logger.warning(f"reviews_count invalide pour {lead.get('name')}: {e}")
|
||||||
|
|
||||||
|
# ── Critère 3 : bonne note Google (≥ 4.0) ───────────────────────────
|
||||||
|
rating = lead.get("rating")
|
||||||
|
if rating is not None:
|
||||||
|
try:
|
||||||
|
rating = float(rating)
|
||||||
|
if rating >= 4.0:
|
||||||
|
score += 2
|
||||||
|
logger.debug(f"{lead.get('name')}: +2 (note ≥ 4.0 : {rating})")
|
||||||
|
elif rating < 3.0:
|
||||||
|
score -= 1
|
||||||
|
logger.debug(f"{lead.get('name')}: -1 (note < 3.0 : {rating})")
|
||||||
|
except (TypeError, ValueError) as e:
|
||||||
|
logger.warning(f"rating invalide pour {lead.get('name')}: {e}")
|
||||||
|
|
||||||
|
# ── Critère 4 : téléphone présent ────────────────────────────────────
|
||||||
|
phone = lead.get("phone", "")
|
||||||
|
if phone and phone.strip():
|
||||||
|
score += 1
|
||||||
|
logger.debug(f"{lead.get('name')}: +1 (téléphone présent)")
|
||||||
|
|
||||||
|
# Plancher à 0
|
||||||
|
score = max(0, score)
|
||||||
|
logger.info(f"Score calculé pour '{lead.get('name')}' : {score}/8")
|
||||||
|
return score
|
||||||
|
|
||||||
|
def score_lead(self, lead: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Enrichit un lead avec son score.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lead: dict normalisé du scraper.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Même dict avec le champ 'score' ajouté/mis à jour.
|
||||||
|
"""
|
||||||
|
lead = dict(lead) # copie défensive
|
||||||
|
lead["score"] = self._calculate_score(lead)
|
||||||
|
return lead
|
||||||
|
|
||||||
|
def score_leads(self, leads: list[dict]) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Score et trie une liste de leads (score décroissant).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
leads: liste de dicts normalisés.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Liste triée par score décroissant.
|
||||||
|
"""
|
||||||
|
scored = [self.score_lead(lead) for lead in leads]
|
||||||
|
scored.sort(key=lambda l: l.get("score", 0), reverse=True)
|
||||||
|
logger.info(
|
||||||
|
f"score_leads terminé : {len(scored)} leads scorés. "
|
||||||
|
f"Score max = {scored[0]['score'] if scored else 0}, "
|
||||||
|
f"Score min = {scored[-1]['score'] if scored else 0}"
|
||||||
|
)
|
||||||
|
return scored
|
||||||
|
|
||||||
|
|
||||||
|
# ─── CLI (debug) ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Exemple de test rapide sans appel API
|
||||||
|
test_leads = [
|
||||||
|
{
|
||||||
|
"name": "Restaurant A",
|
||||||
|
"website": "",
|
||||||
|
"rating": 4.5,
|
||||||
|
"reviews_count": 120,
|
||||||
|
"phone": "+33 3 20 00 00 01",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Restaurant B",
|
||||||
|
"website": "",
|
||||||
|
"rating": 3.8,
|
||||||
|
"reviews_count": 30,
|
||||||
|
"phone": "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Café C",
|
||||||
|
"website": "",
|
||||||
|
"rating": 2.5,
|
||||||
|
"reviews_count": 5,
|
||||||
|
"phone": "+33 3 20 00 00 03",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Bar D avec site",
|
||||||
|
"website": "https://bar-d.fr",
|
||||||
|
"rating": 4.2,
|
||||||
|
"reviews_count": 80,
|
||||||
|
"phone": "+33 3 20 00 00 04",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
scorer = LeadScorer()
|
||||||
|
results = scorer.score_leads(test_leads)
|
||||||
|
|
||||||
|
print("\n=== Résultats scoring ===")
|
||||||
|
for r in results:
|
||||||
|
print(f" [{r['score']:2d}/8] {r['name']}")
|
||||||
397
leadhunter_scraper.py
Normal file
397
leadhunter_scraper.py
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
H3R7Tech — LeadHunter Scraper
|
||||||
|
================================
|
||||||
|
Agent de scraping pour la détection de restaurants sans site web
|
||||||
|
dans la MEL (Métropole Européenne de Lille).
|
||||||
|
|
||||||
|
Sources :
|
||||||
|
- Google Places API (primary)
|
||||||
|
- OpenStreetMap / Overpass API (fallback)
|
||||||
|
|
||||||
|
Quota Google Places Free Tier :
|
||||||
|
- 28 500 requêtes/mois ≈ 950/jour
|
||||||
|
- Compteur persistent dans /home/h3r7/leadhunter_quota.json
|
||||||
|
|
||||||
|
Auteur: H3R7Tech Backend Engineer
|
||||||
|
Issue: HRT-66
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
from datetime import date, datetime
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
|
||||||
|
# ─── Logging ────────────────────────────────────────────────────────────────
|
||||||
|
logger = logging.getLogger("leadhunter.scraper")
|
||||||
|
|
||||||
|
_handler = RotatingFileHandler(
|
||||||
|
"/home/h3r7/leadhunter.log",
|
||||||
|
maxBytes=5 * 1024 * 1024, # 5 MB
|
||||||
|
backupCount=3,
|
||||||
|
)
|
||||||
|
_handler.setFormatter(
|
||||||
|
logging.Formatter("%(asctime)s %(levelname)-8s %(name)s — %(message)s")
|
||||||
|
)
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
if not logger.handlers:
|
||||||
|
logger.addHandler(_handler)
|
||||||
|
logger.addHandler(logging.StreamHandler())
|
||||||
|
|
||||||
|
# ─── Configuration ───────────────────────────────────────────────────────────
|
||||||
|
GOOGLE_PLACES_API_KEY = os.environ.get("GOOGLE_PLACES_API_KEY")
|
||||||
|
|
||||||
|
# Quota journalier Google Places Free Tier
|
||||||
|
DAILY_QUOTA_FILE = "/home/h3r7/leadhunter_quota.json"
|
||||||
|
DAILY_QUOTA_LIMIT = 900 # marge de sécurité vs les 950 théoriques
|
||||||
|
|
||||||
|
# Délai entre requêtes Places pour éviter rate-limiting
|
||||||
|
PLACES_SLEEP_S = 0.5
|
||||||
|
|
||||||
|
# Bounding box MEL (Métropole Européenne de Lille)
|
||||||
|
MEL_CENTER_LAT = 50.6292
|
||||||
|
MEL_CENTER_LNG = 3.0573
|
||||||
|
MEL_RADIUS_M = 20000 # 20 km autour de Lille
|
||||||
|
|
||||||
|
# Types de lieux ciblés
|
||||||
|
TARGET_TYPES = ["restaurant", "cafe", "bar", "bakery", "food"]
|
||||||
|
|
||||||
|
# Overpass API endpoint
|
||||||
|
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
||||||
|
|
||||||
|
# Requête Overpass MEL — bounding box directe (50.4,2.8,50.8,3.3) couvrant la MEL
|
||||||
|
# Fix HRT-72 : la résolution area["name"=...] échoue silencieusement sur l'API Overpass publique
|
||||||
|
OVERPASS_MEL_QUERY = """
|
||||||
|
[out:json][timeout:60];
|
||||||
|
(
|
||||||
|
node["amenity"~"^(restaurant|cafe|bar|fast_food|bakery)$"][!"website"](50.4,2.8,50.8,3.3);
|
||||||
|
way["amenity"~"^(restaurant|cafe|bar|fast_food|bakery)$"][!"website"](50.4,2.8,50.8,3.3);
|
||||||
|
);
|
||||||
|
out center 200;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Quota Manager ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _load_quota() -> dict:
|
||||||
|
"""Charge le compteur quotidien depuis le fichier JSON."""
|
||||||
|
today = str(date.today())
|
||||||
|
if os.path.exists(DAILY_QUOTA_FILE):
|
||||||
|
try:
|
||||||
|
with open(DAILY_QUOTA_FILE, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if data.get("date") == today:
|
||||||
|
return data
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Impossible de lire le fichier quota : {e}")
|
||||||
|
return {"date": today, "count": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_quota(data: dict) -> None:
|
||||||
|
"""Persiste le compteur quotidien."""
|
||||||
|
try:
|
||||||
|
with open(DAILY_QUOTA_FILE, "w") as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Impossible d'écrire le fichier quota : {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _increment_quota(n: int = 1) -> int:
|
||||||
|
"""Incrémente le compteur et retourne le total du jour."""
|
||||||
|
quota = _load_quota()
|
||||||
|
quota["count"] += n
|
||||||
|
_save_quota(quota)
|
||||||
|
return quota["count"]
|
||||||
|
|
||||||
|
|
||||||
|
def _quota_remaining() -> int:
|
||||||
|
"""Retourne le nombre de requêtes restantes pour aujourd'hui."""
|
||||||
|
quota = _load_quota()
|
||||||
|
return max(0, DAILY_QUOTA_LIMIT - quota["count"])
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Google Places Scraper ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class GooglePlacesScraper:
|
||||||
|
"""
|
||||||
|
Scraping via Google Places API (Nearby Search + Place Details).
|
||||||
|
Filtre les lieux sans site web côté API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
BASE_URL = "https://maps.googleapis.com/maps/api/place"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
if not GOOGLE_PLACES_API_KEY:
|
||||||
|
raise EnvironmentError(
|
||||||
|
"GOOGLE_PLACES_API_KEY non définie. "
|
||||||
|
"Ajouter dans /home/h3r7/.env et relancer."
|
||||||
|
)
|
||||||
|
self.api_key = GOOGLE_PLACES_API_KEY
|
||||||
|
|
||||||
|
def _nearby_search(self, place_type: str, page_token: str = None) -> dict:
|
||||||
|
"""Appel Nearby Search — 1 requête comptabilisée."""
|
||||||
|
params = {
|
||||||
|
"key": self.api_key,
|
||||||
|
"location": f"{MEL_CENTER_LAT},{MEL_CENTER_LNG}",
|
||||||
|
"radius": MEL_RADIUS_M,
|
||||||
|
"type": place_type,
|
||||||
|
}
|
||||||
|
if page_token:
|
||||||
|
params["pagetoken"] = page_token
|
||||||
|
|
||||||
|
_increment_quota()
|
||||||
|
time.sleep(PLACES_SLEEP_S)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
f"{self.BASE_URL}/nearbysearch/json",
|
||||||
|
params=params,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"NearbySearch error (type={place_type}): {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _place_details(self, place_id: str) -> dict:
|
||||||
|
"""Place Details pour récupérer website, phone, rating, etc. — 1 requête."""
|
||||||
|
params = {
|
||||||
|
"key": self.api_key,
|
||||||
|
"place_id": place_id,
|
||||||
|
"fields": "name,formatted_address,formatted_phone_number,website,rating,user_ratings_total",
|
||||||
|
}
|
||||||
|
|
||||||
|
_increment_quota()
|
||||||
|
time.sleep(PLACES_SLEEP_S)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
f"{self.BASE_URL}/details/json",
|
||||||
|
params=params,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json().get("result", {})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"PlaceDetails error (place_id={place_id}): {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def scrape(self, max_leads: int = 50) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Scrape les restaurants/cafés/bars MEL sans site web.
|
||||||
|
|
||||||
|
Retourne une liste de dicts normalisés compatibles LeadHunter CRM :
|
||||||
|
source, name, address, phone, rating, reviews_count, website, rgpd_ok
|
||||||
|
"""
|
||||||
|
leads = []
|
||||||
|
seen_ids = set()
|
||||||
|
|
||||||
|
for place_type in TARGET_TYPES:
|
||||||
|
if _quota_remaining() < 10:
|
||||||
|
logger.warning(
|
||||||
|
"Quota journalier presque épuisé — arrêt scraping Google Places."
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.info(f"Scraping Google Places — type={place_type}")
|
||||||
|
page_token = None
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if _quota_remaining() < 5:
|
||||||
|
logger.warning("Quota insuffisant pour continuer la pagination.")
|
||||||
|
break
|
||||||
|
|
||||||
|
data = self._nearby_search(place_type, page_token)
|
||||||
|
results = data.get("results", [])
|
||||||
|
|
||||||
|
for place in results:
|
||||||
|
if len(leads) >= max_leads:
|
||||||
|
break
|
||||||
|
|
||||||
|
place_id = place.get("place_id", "")
|
||||||
|
if not place_id or place_id in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(place_id)
|
||||||
|
|
||||||
|
if _quota_remaining() < 2:
|
||||||
|
logger.warning("Quota épuisé avant details.")
|
||||||
|
break
|
||||||
|
|
||||||
|
details = self._place_details(place_id)
|
||||||
|
|
||||||
|
# Filtre : on ne garde que les lieux SANS site web
|
||||||
|
if details.get("website"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
lead = {
|
||||||
|
"source": "google_places",
|
||||||
|
"name": details.get("name") or place.get("name", ""),
|
||||||
|
"address": details.get("formatted_address")
|
||||||
|
or place.get("vicinity", ""),
|
||||||
|
"phone": details.get("formatted_phone_number", ""),
|
||||||
|
"rating": details.get("rating") or place.get("rating"),
|
||||||
|
"reviews_count": details.get("user_ratings_total")
|
||||||
|
or place.get("user_ratings_total"),
|
||||||
|
"website": "",
|
||||||
|
"rgpd_ok": True, # Données publiques Google Places uniquement
|
||||||
|
}
|
||||||
|
leads.append(lead)
|
||||||
|
logger.info(f"Lead trouvé (Google Places) : {lead['name']}")
|
||||||
|
|
||||||
|
if len(leads) >= max_leads:
|
||||||
|
break
|
||||||
|
|
||||||
|
page_token = data.get("next_page_token")
|
||||||
|
if not page_token:
|
||||||
|
break
|
||||||
|
|
||||||
|
# L'API Google Places nécessite un délai avant d'utiliser next_page_token
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
logger.info(f"Google Places : {len(leads)} leads collectés.")
|
||||||
|
return leads
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Overpass / OSM Fallback ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class OverpassScraper:
|
||||||
|
"""
|
||||||
|
Fallback OSM via Overpass API.
|
||||||
|
Cible les nœuds/ways dans la boundary MEL sans attribut 'website'.
|
||||||
|
Données publiques ODbL — RGPD OK.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def scrape(self, max_leads: int = 100) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Scrape via Overpass API — retourne des leads normalisés.
|
||||||
|
"""
|
||||||
|
logger.info("Scraping Overpass OSM — boundary MEL")
|
||||||
|
leads = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
OVERPASS_URL,
|
||||||
|
data={"data": OVERPASS_MEL_QUERY},
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded", # Fix HRT-72 Bug2
|
||||||
|
"User-Agent": "H3R7Tech-LeadHunter/1.0 (contact@h3r7tech.fr)", # Fix HRT-72 Bug3: overpass-api.de blocks python-requests UA
|
||||||
|
},
|
||||||
|
timeout=90,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Overpass API error : {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
elements = data.get("elements", [])
|
||||||
|
logger.info(f"Overpass : {len(elements)} éléments bruts reçus.")
|
||||||
|
|
||||||
|
for el in elements[:max_leads]:
|
||||||
|
tags = el.get("tags", {})
|
||||||
|
|
||||||
|
# Coordonnées (pour les ways, Overpass retourne 'center')
|
||||||
|
lat = el.get("lat") or (el.get("center") or {}).get("lat")
|
||||||
|
lon = el.get("lon") or (el.get("center") or {}).get("lon")
|
||||||
|
|
||||||
|
name = tags.get("name", "")
|
||||||
|
if not name:
|
||||||
|
continue # Ignorer les lieux sans nom
|
||||||
|
|
||||||
|
addr_parts = [
|
||||||
|
tags.get("addr:housenumber", ""),
|
||||||
|
tags.get("addr:street", ""),
|
||||||
|
tags.get("addr:city", ""),
|
||||||
|
tags.get("addr:postcode", ""),
|
||||||
|
]
|
||||||
|
address = " ".join(p for p in addr_parts if p).strip()
|
||||||
|
if not address and lat and lon:
|
||||||
|
address = f"{lat:.4f},{lon:.4f}"
|
||||||
|
|
||||||
|
lead = {
|
||||||
|
"source": "osm",
|
||||||
|
"name": name,
|
||||||
|
"address": address,
|
||||||
|
"phone": tags.get("phone", tags.get("contact:phone", "")),
|
||||||
|
"rating": None,
|
||||||
|
"reviews_count": None,
|
||||||
|
"website": "",
|
||||||
|
"rgpd_ok": True, # Données publiques ODbL
|
||||||
|
}
|
||||||
|
leads.append(lead)
|
||||||
|
logger.info(f"Lead trouvé (OSM) : {lead['name']}")
|
||||||
|
|
||||||
|
logger.info(f"Overpass : {len(leads)} leads collectés.")
|
||||||
|
return leads
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Orchestrateur ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def run_scraping(
|
||||||
|
max_leads: int = 100, use_google: bool = True, use_osm: bool = True
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Lance le scraping Google Places + fallback OSM.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_leads: nombre maximum de leads à collecter au total.
|
||||||
|
use_google: activer Google Places (nécessite GOOGLE_PLACES_API_KEY).
|
||||||
|
use_osm: activer le fallback Overpass OSM.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Liste de leads normalisés (dédupliqués par nom + adresse).
|
||||||
|
"""
|
||||||
|
all_leads = []
|
||||||
|
seen_keys = set()
|
||||||
|
|
||||||
|
def _dedup_key(lead: dict) -> str:
|
||||||
|
return f"{lead['name'].lower().strip()}|{lead['address'].lower().strip()[:40]}"
|
||||||
|
|
||||||
|
if use_google:
|
||||||
|
try:
|
||||||
|
scraper = GooglePlacesScraper()
|
||||||
|
google_leads = scraper.scrape(max_leads=max_leads)
|
||||||
|
for lead in google_leads:
|
||||||
|
k = _dedup_key(lead)
|
||||||
|
if k not in seen_keys:
|
||||||
|
seen_keys.add(k)
|
||||||
|
all_leads.append(lead)
|
||||||
|
except EnvironmentError as e:
|
||||||
|
logger.warning(f"Google Places désactivé : {e}")
|
||||||
|
use_google = False
|
||||||
|
|
||||||
|
remaining = max_leads - len(all_leads)
|
||||||
|
if use_osm and remaining > 0:
|
||||||
|
osm_leads = OverpassScraper().scrape(max_leads=remaining)
|
||||||
|
for lead in osm_leads:
|
||||||
|
k = _dedup_key(lead)
|
||||||
|
if k not in seen_keys:
|
||||||
|
seen_keys.add(k)
|
||||||
|
all_leads.append(lead)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"run_scraping terminé — {len(all_leads)} leads uniques "
|
||||||
|
f"(Google={use_google}, OSM={use_osm}). "
|
||||||
|
f"Quota restant aujourd'hui : {_quota_remaining()}"
|
||||||
|
)
|
||||||
|
return all_leads
|
||||||
|
|
||||||
|
|
||||||
|
# ─── CLI (debug) ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
assert GOOGLE_PLACES_API_KEY, (
|
||||||
|
"GOOGLE_PLACES_API_KEY manquante — "
|
||||||
|
"ajouter 'export GOOGLE_PLACES_API_KEY=xxx' dans /home/h3r7/.env"
|
||||||
|
)
|
||||||
|
leads = run_scraping(max_leads=10)
|
||||||
|
for i, l in enumerate(leads, 1):
|
||||||
|
print(f"{i:02d}. [{l['source']}] {l['name']} — {l['address']}")
|
||||||
@@ -15,7 +15,7 @@ import sqlite3
|
|||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
|
|
||||||
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
|
DB_PATH = "/home/h3r7/turf_saas/turf_saas.db"
|
||||||
HEADERS = {
|
HEADERS = {
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
'Accept-Language': 'fr-FR,fr;q=0.9,en;q=0.8',
|
'Accept-Language': 'fr-FR,fr;q=0.9,en;q=0.8',
|
||||||
|
|||||||
72
org_db.py
Normal file
72
org_db.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Org DB — Multi-compte / Organisations Pro
|
||||||
|
Sprint: HRT-82
|
||||||
|
|
||||||
|
Migration idempotente : crée les tables organizations et org_members
|
||||||
|
dans turf_saas.db si elles n'existent pas.
|
||||||
|
|
||||||
|
Run une seule fois :
|
||||||
|
./venv/bin/python org_db.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||||
|
logger = logging.getLogger("turf_saas.org_db")
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_org_tables():
|
||||||
|
"""
|
||||||
|
Migration idempotente : crée organizations + org_members.
|
||||||
|
|
||||||
|
- organizations : 1 org max par owner (enforced en Python + UNIQUE owner_id)
|
||||||
|
- org_members : max 5 membres totaux (owner inclus, enforced en Python)
|
||||||
|
- UNIQUE(org_id, user_id) empêche les doublons de membres
|
||||||
|
"""
|
||||||
|
conn = get_db()
|
||||||
|
c = conn.cursor()
|
||||||
|
|
||||||
|
c.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS organizations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
owner_id TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
max_members INTEGER NOT NULL DEFAULT 5,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS org_members (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'member'
|
||||||
|
CHECK(role IN ('owner', 'member')),
|
||||||
|
invited_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||||
|
joined_at DATETIME,
|
||||||
|
UNIQUE(org_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_org_owner ON organizations(owner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orgmem_org ON org_members(org_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orgmem_user ON org_members(user_id);
|
||||||
|
""")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
logger.info("[org_db] Tables organizations + org_members créées/vérifiées.")
|
||||||
|
print("[org_db] Migration OK: organizations, org_members.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
migrate_org_tables()
|
||||||
@@ -38,7 +38,7 @@ from pathlib import Path
|
|||||||
# ─────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────
|
||||||
# CONFIG
|
# CONFIG
|
||||||
# ─────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────
|
||||||
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
|
DB_PATH = "/home/h3r7/turf_saas/turf_saas.db"
|
||||||
OUTPUT_DIR = Path("/home/h3r7/turf_scraper")
|
OUTPUT_DIR = Path("/home/h3r7/turf_scraper")
|
||||||
API_BASE = "https://online.turfinfo.api.pmu.fr/rest/client/7"
|
API_BASE = "https://online.turfinfo.api.pmu.fr/rest/client/7"
|
||||||
|
|
||||||
|
|||||||
@@ -743,19 +743,29 @@ def pod_static(filename=""):
|
|||||||
@app.route("/turf/api/")
|
@app.route("/turf/api/")
|
||||||
@app.route("/turf/api/<path:api_path>")
|
@app.route("/turf/api/<path:api_path>")
|
||||||
def api_proxy(api_path=""):
|
def api_proxy(api_path=""):
|
||||||
if api_path.startswith("vitesse"):
|
# Routes servies par combined_api.py (port 8790) :
|
||||||
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
# backtest, stats, paris, parisroi, races, scores, report, ask, brave-search,
|
||||||
elif api_path.startswith("n8n-proxy"):
|
# execute-sql, send-email, vitesse, n8n-proxy, predictions_analysis, ideas
|
||||||
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
# Fix HRT-73 : alignement complet avec turf_scraper fix #23
|
||||||
elif api_path.startswith("backtest"):
|
COMBINED_ROUTES = (
|
||||||
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
"backtest",
|
||||||
elif api_path.startswith("stats"):
|
"stats",
|
||||||
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
"parisroi",
|
||||||
elif api_path.startswith("predictions_analysis"):
|
"paris",
|
||||||
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
"predictions_analysis",
|
||||||
elif api_path.startswith("parisroi"):
|
"vitesse",
|
||||||
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
"n8n-proxy",
|
||||||
elif api_path.startswith("paris"):
|
"races",
|
||||||
|
"race/",
|
||||||
|
"scores",
|
||||||
|
"ask",
|
||||||
|
"brave-search",
|
||||||
|
"execute-sql",
|
||||||
|
"send-email",
|
||||||
|
"report",
|
||||||
|
"ideas",
|
||||||
|
)
|
||||||
|
if any(api_path.startswith(r) for r in COMBINED_ROUTES):
|
||||||
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
||||||
elif api_path.startswith("scoring"):
|
elif api_path.startswith("scoring"):
|
||||||
url = f"{DASHBOARD_API_URL}/turf/api/{api_path}"
|
url = f"{DASHBOARD_API_URL}/turf/api/{api_path}"
|
||||||
@@ -770,11 +780,17 @@ def api_proxy(api_path=""):
|
|||||||
if fwd_method in ("POST", "PUT", "PATCH")
|
if fwd_method in ("POST", "PUT", "PATCH")
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
# Forwarder Authorization header (combined_api.py exige Basic h3r7:h3r7 pour parisroi/paris)
|
||||||
fwd_headers = {"Content-Type": "application/json"}
|
fwd_headers = {"Content-Type": "application/json"}
|
||||||
if request.headers.get("Authorization"):
|
incoming_auth = request.headers.get("Authorization")
|
||||||
fwd_headers["Authorization"] = request.headers.get("Authorization")
|
if incoming_auth:
|
||||||
|
fwd_headers["Authorization"] = incoming_auth
|
||||||
resp = requests.request(
|
resp = requests.request(
|
||||||
method=fwd_method, url=url, json=fwd_json, timeout=30, headers=fwd_headers
|
method=fwd_method,
|
||||||
|
url=url,
|
||||||
|
json=fwd_json,
|
||||||
|
timeout=30,
|
||||||
|
headers=fwd_headers,
|
||||||
)
|
)
|
||||||
return resp.content, resp.status_code, {"Content-Type": "application/json"}
|
return resp.content, resp.status_code, {"Content-Type": "application/json"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from flask import Blueprint, request, jsonify
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from .saas_auth import require_auth
|
from saas_auth import require_auth
|
||||||
|
|
||||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||||
|
|
||||||
@@ -255,3 +255,46 @@ def export_csv():
|
|||||||
"Content-Disposition": f"attachment; filename=turf_ia_{date_param}.csv"
|
"Content-Disposition": f"attachment; filename=turf_ia_{date_param}.csv"
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Billing Blueprint (Stripe) + JWT init — HRT-49 ─────────────────────────
|
||||||
|
# Registers /api/v1/billing/* routes via nested Blueprint (Flask 2.0+)
|
||||||
|
# Also initializes JWTManager on the Flask app (required for jwt_required_middleware)
|
||||||
|
try:
|
||||||
|
from flask_jwt_extended import JWTManager
|
||||||
|
from api_v1.routes.billing import billing_bp
|
||||||
|
|
||||||
|
# Initialize JWTManager on the Flask app when api_v1_bp is registered
|
||||||
|
@api_v1_bp.record_once
|
||||||
|
def _init_jwt(state):
|
||||||
|
app = state.app
|
||||||
|
if not app.config.get("JWT_SECRET_KEY"):
|
||||||
|
import os
|
||||||
|
|
||||||
|
app.config["JWT_SECRET_KEY"] = os.environ.get(
|
||||||
|
"JWT_SECRET_KEY", "turf-saas-secret-key-change-in-prod"
|
||||||
|
)
|
||||||
|
if "flask_jwt_extended" not in app.extensions:
|
||||||
|
JWTManager(app)
|
||||||
|
|
||||||
|
# Register billing blueprint with url_prefix='/billing'
|
||||||
|
# (parent api_v1_bp has '/api/v1', so result is /api/v1/billing/*)
|
||||||
|
api_v1_bp.register_blueprint(billing_bp, url_prefix="/billing")
|
||||||
|
print("[saas_api_v1] Billing blueprint (Stripe) + JWT registered ✅")
|
||||||
|
except Exception as _billing_err:
|
||||||
|
print(f"[saas_api_v1] Warning: billing blueprint not loaded: {_billing_err}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Org Blueprint — HRT-82 ───────────────────────────────────────────────────
|
||||||
|
# Registers /api/v1/org/* routes (Pro plan only, multi-compte max 5 users)
|
||||||
|
try:
|
||||||
|
from api_v1.routes.org import org_bp
|
||||||
|
|
||||||
|
@api_v1_bp.record_once
|
||||||
|
def _register_org_bp(state):
|
||||||
|
app = state.app
|
||||||
|
app.register_blueprint(org_bp)
|
||||||
|
|
||||||
|
print("[saas_api_v1] Org blueprint (multi-compte Pro) registered ✅")
|
||||||
|
except Exception as _org_err:
|
||||||
|
print(f"[saas_api_v1] Warning: org blueprint not loaded: {_org_err}")
|
||||||
|
|||||||
129
saas_auth.py
129
saas_auth.py
@@ -27,6 +27,123 @@ LOGIN_RATE_MAX = 5 # max tentatives par fenêtre
|
|||||||
LOGIN_RATE_WINDOW = 300 # 5 minutes (en secondes)
|
LOGIN_RATE_WINDOW = 300 # 5 minutes (en secondes)
|
||||||
LOGIN_BLOCK_DURATION = 900 # 15 min de blocage après dépassement
|
LOGIN_BLOCK_DURATION = 900 # 15 min de blocage après dépassement
|
||||||
|
|
||||||
|
# ─── Blacklist mots de passe faibles ─────────────────────────────────────────
|
||||||
|
# HRT-63 — Validation mots de passe faibles
|
||||||
|
WEAK_PASSWORDS = {
|
||||||
|
"password",
|
||||||
|
"password1",
|
||||||
|
"password123",
|
||||||
|
"passw0rd",
|
||||||
|
"12345678",
|
||||||
|
"123456789",
|
||||||
|
"1234567890",
|
||||||
|
"123456",
|
||||||
|
"12345",
|
||||||
|
"1234",
|
||||||
|
"qwerty",
|
||||||
|
"qwerty123",
|
||||||
|
"qwertyuiop",
|
||||||
|
"azerty",
|
||||||
|
"azertyuiop",
|
||||||
|
"letmein",
|
||||||
|
"letmein1",
|
||||||
|
"iloveyou",
|
||||||
|
"iloveyou1",
|
||||||
|
"admin",
|
||||||
|
"admin123",
|
||||||
|
"admin1234",
|
||||||
|
"administrator",
|
||||||
|
"welcome",
|
||||||
|
"welcome1",
|
||||||
|
"welcome123",
|
||||||
|
"monkey",
|
||||||
|
"monkey1",
|
||||||
|
"dragon",
|
||||||
|
"dragon1",
|
||||||
|
"master",
|
||||||
|
"master1",
|
||||||
|
"football",
|
||||||
|
"soccer",
|
||||||
|
"baseball",
|
||||||
|
"basketball",
|
||||||
|
"superman",
|
||||||
|
"batman",
|
||||||
|
"starwars",
|
||||||
|
"starwars1",
|
||||||
|
"princess",
|
||||||
|
"princess1",
|
||||||
|
"sunshine",
|
||||||
|
"sunshine1",
|
||||||
|
"shadow",
|
||||||
|
"shadow1",
|
||||||
|
"michael",
|
||||||
|
"michael1",
|
||||||
|
"jessica",
|
||||||
|
"jessica1",
|
||||||
|
"abc123",
|
||||||
|
"abc1234",
|
||||||
|
"abcd1234",
|
||||||
|
"abcdefgh",
|
||||||
|
"login",
|
||||||
|
"login123",
|
||||||
|
"pass",
|
||||||
|
"pass1234",
|
||||||
|
"test",
|
||||||
|
"test1234",
|
||||||
|
"test123456",
|
||||||
|
"hello",
|
||||||
|
"hello123",
|
||||||
|
"hello1234",
|
||||||
|
"changeme",
|
||||||
|
"changeme1",
|
||||||
|
"secret",
|
||||||
|
"secret1",
|
||||||
|
"secret123",
|
||||||
|
"trustno1",
|
||||||
|
"zaq1zaq1",
|
||||||
|
"qazwsx",
|
||||||
|
"qazwsxedc",
|
||||||
|
"111111",
|
||||||
|
"1111111",
|
||||||
|
"11111111",
|
||||||
|
"000000",
|
||||||
|
"00000000",
|
||||||
|
"123123",
|
||||||
|
"1231234",
|
||||||
|
"321321",
|
||||||
|
"p@ssword",
|
||||||
|
"p@ssw0rd",
|
||||||
|
"pa$$word",
|
||||||
|
"turf",
|
||||||
|
"turf123",
|
||||||
|
"cheval",
|
||||||
|
"cheval123",
|
||||||
|
"pmu",
|
||||||
|
"pmu123",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_password_strength(password: str):
|
||||||
|
"""
|
||||||
|
Valide la complexité d'un mot de passe.
|
||||||
|
Retourne None si OK, sinon un message d'erreur (str).
|
||||||
|
Règles :
|
||||||
|
- 8 caractères minimum
|
||||||
|
- absent de la blacklist WEAK_PASSWORDS
|
||||||
|
- au moins 1 chiffre
|
||||||
|
- au moins 1 lettre
|
||||||
|
"""
|
||||||
|
if len(password) < 8:
|
||||||
|
return "Mot de passe trop court (8 caractères minimum)."
|
||||||
|
if password.lower() in WEAK_PASSWORDS:
|
||||||
|
return "Mot de passe trop commun. Choisissez un mot de passe plus sécurisé."
|
||||||
|
if not any(c.isdigit() for c in password):
|
||||||
|
return "Le mot de passe doit contenir au moins 1 chiffre."
|
||||||
|
if not any(c.isalpha() for c in password):
|
||||||
|
return "Le mot de passe doit contenir au moins 1 lettre."
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ─── Config ───────────────────────────────────────────────────────────────────
|
# ─── Config ───────────────────────────────────────────────────────────────────
|
||||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||||
JWT_SECRET = os.environ.get(
|
JWT_SECRET = os.environ.get(
|
||||||
@@ -160,10 +277,9 @@ def register():
|
|||||||
|
|
||||||
if not email or "@" not in email:
|
if not email or "@" not in email:
|
||||||
return jsonify({"error": "Adresse email invalide."}), 400
|
return jsonify({"error": "Adresse email invalide."}), 400
|
||||||
if len(password) < 8:
|
pwd_error = validate_password_strength(password)
|
||||||
return jsonify(
|
if pwd_error:
|
||||||
{"error": "Mot de passe trop court (8 caractères minimum)."}
|
return jsonify({"error": pwd_error}), 400
|
||||||
), 400
|
|
||||||
if plan not in ("free", "premium", "pro"):
|
if plan not in ("free", "premium", "pro"):
|
||||||
plan = "free"
|
plan = "free"
|
||||||
|
|
||||||
@@ -292,8 +408,9 @@ def change_password():
|
|||||||
cur_pwd = data.get("current_password") or ""
|
cur_pwd = data.get("current_password") or ""
|
||||||
new_pwd = data.get("new_password") or ""
|
new_pwd = data.get("new_password") or ""
|
||||||
|
|
||||||
if len(new_pwd) < 8:
|
pwd_error = validate_password_strength(new_pwd)
|
||||||
return jsonify({"error": "Nouveau mot de passe trop court."}), 400
|
if pwd_error:
|
||||||
|
return jsonify({"error": pwd_error}), 400
|
||||||
|
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
user = conn.execute(
|
user = conn.execute(
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import json
|
|||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
|
DB_PATH = "/home/h3r7/turf_saas/turf_saas.db"
|
||||||
HEADERS = {'User-Agent': 'Mozilla/5.0', 'Accept': 'application/json'}
|
HEADERS = {'User-Agent': 'Mozilla/5.0', 'Accept': 'application/json'}
|
||||||
|
|
||||||
def get_cote_from_db(horse_name, date_course):
|
def get_cote_from_db(horse_name, date_course):
|
||||||
|
|||||||
284
telegram_alerts.py
Normal file
284
telegram_alerts.py
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Telegram Alerts — Service d'alertes pré-course pour les utilisateurs Premium/Pro
|
||||||
|
HRT-79: Alertes Telegram configurables (Premium)
|
||||||
|
|
||||||
|
Fonctionnement :
|
||||||
|
- 30 minutes avant chaque course détectée, envoie un message Telegram
|
||||||
|
aux utilisateurs Premium/Pro ayant configuré leur chat_id.
|
||||||
|
- Les préférences individuelles (value_bets, top1, quinte_only) sont respectées.
|
||||||
|
- Requiert la variable d'environnement TELEGRAM_BOT_TOKEN.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||||
|
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||||
|
|
||||||
|
TELEGRAM_API_BASE = "https://api.telegram.org/bot{token}/sendMessage"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _get_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def send_telegram_message(chat_id: str, text: str) -> bool:
|
||||||
|
"""
|
||||||
|
Envoie un message Telegram à un chat_id donné.
|
||||||
|
|
||||||
|
Returns True si succès, False sinon.
|
||||||
|
Ne lève pas d'exception pour ne pas crasher le scheduler.
|
||||||
|
"""
|
||||||
|
if not BOT_TOKEN:
|
||||||
|
logger.warning("[TELEGRAM] TELEGRAM_BOT_TOKEN non configuré — envoi ignoré")
|
||||||
|
return False
|
||||||
|
|
||||||
|
url = TELEGRAM_API_BASE.format(token=BOT_TOKEN)
|
||||||
|
payload = {
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": "Markdown",
|
||||||
|
"disable_web_page_preview": True,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = requests.post(url, json=payload, timeout=10)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return True
|
||||||
|
logger.warning(
|
||||||
|
"[TELEGRAM] Echec envoi chat_id=%s status=%d body=%s",
|
||||||
|
chat_id,
|
||||||
|
resp.status_code,
|
||||||
|
resp.text[:200],
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
logger.error("[TELEGRAM] Exception HTTP chat_id=%s: %s", chat_id, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Alert builder ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def build_race_alert(race_data: dict, predictions: list) -> str:
|
||||||
|
"""
|
||||||
|
Construit le message Markdown de l'alerte pré-course.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
race_data: dict avec les clés 'hippo', 'num_course', 'heure', 'type_course'
|
||||||
|
predictions: liste de dicts {'num_cheval', 'nom_cheval', 'prob_top3', 'is_value_bet', 'ml_score'}
|
||||||
|
|
||||||
|
Returns: texte Markdown formaté
|
||||||
|
"""
|
||||||
|
hippo = race_data.get("hippo", "?")
|
||||||
|
num_course = race_data.get("num_course", "?")
|
||||||
|
heure = race_data.get("heure", "?")
|
||||||
|
type_course = race_data.get("type_course", "")
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"🏇 *Alerte course — {hippo} R{num_course}*",
|
||||||
|
f"⏰ Départ prévu : *{heure}*",
|
||||||
|
]
|
||||||
|
if type_course:
|
||||||
|
lines.append(f"📋 Type : {type_course}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
top3 = [p for p in predictions if p.get("prob_top3", 0) > 0][:3]
|
||||||
|
value_bets = [p for p in predictions if p.get("is_value_bet")]
|
||||||
|
|
||||||
|
if top3:
|
||||||
|
lines.append("📊 *Top-3 ML :*")
|
||||||
|
for i, p in enumerate(top3, 1):
|
||||||
|
nom = p.get("nom_cheval", f"#{p.get('num_cheval', '?')}")
|
||||||
|
prob = p.get("prob_top3", 0)
|
||||||
|
lines.append(f" {i}. {nom} — {prob:.0%} prob top-3")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if value_bets:
|
||||||
|
lines.append("💡 *Value bets :*")
|
||||||
|
for p in value_bets[:3]:
|
||||||
|
nom = p.get("nom_cheval", f"#{p.get('num_cheval', '?')}")
|
||||||
|
score = p.get("ml_score", 0)
|
||||||
|
lines.append(f" ✅ {nom} (score {score:.2f})")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("_Alerte automatique Turf SaaS — 30min avant départ_")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main send function ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def send_pre_race_alerts(minutes_before: int = 30) -> dict:
|
||||||
|
"""
|
||||||
|
Interroge la DB pour récupérer les courses du jour, puis envoie
|
||||||
|
des alertes Telegram aux utilisateurs Premium/Pro éligibles.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
minutes_before: non utilisé directement (la planification est gérée
|
||||||
|
par le scheduler), présent pour documentation.
|
||||||
|
|
||||||
|
Returns: dict {'sent': int, 'skipped': int, 'errors': int}
|
||||||
|
"""
|
||||||
|
if not BOT_TOKEN:
|
||||||
|
logger.warning(
|
||||||
|
"[TELEGRAM] TELEGRAM_BOT_TOKEN absent — send_pre_race_alerts ignoré"
|
||||||
|
)
|
||||||
|
return {"sent": 0, "skipped": 0, "errors": 0}
|
||||||
|
|
||||||
|
stats = {"sent": 0, "skipped": 0, "errors": 0}
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = _get_db()
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# Récupère les courses du jour
|
||||||
|
try:
|
||||||
|
courses_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT
|
||||||
|
hippo, num_course, heure_depart, type_course
|
||||||
|
FROM pmu_courses
|
||||||
|
WHERE date_programme = ?
|
||||||
|
AND heure_depart IS NOT NULL
|
||||||
|
ORDER BY heure_depart ASC
|
||||||
|
LIMIT 20
|
||||||
|
""",
|
||||||
|
(today,),
|
||||||
|
).fetchall()
|
||||||
|
except sqlite3.OperationalError as exc:
|
||||||
|
logger.warning("[TELEGRAM] Table pmu_courses introuvable: %s", exc)
|
||||||
|
conn.close()
|
||||||
|
return stats
|
||||||
|
|
||||||
|
if not courses_rows:
|
||||||
|
logger.info("[TELEGRAM] Aucune course aujourd'hui — pas d'alerte")
|
||||||
|
conn.close()
|
||||||
|
return stats
|
||||||
|
|
||||||
|
# Récupère les utilisateurs Premium/Pro avec chat_id configuré
|
||||||
|
try:
|
||||||
|
users = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, telegram_chat_id,
|
||||||
|
alert_value_bets, alert_top1, alert_quinte_only
|
||||||
|
FROM users
|
||||||
|
WHERE plan IN ('premium', 'pro')
|
||||||
|
AND is_active = 1
|
||||||
|
AND telegram_chat_id IS NOT NULL
|
||||||
|
AND telegram_chat_id != ''
|
||||||
|
""",
|
||||||
|
).fetchall()
|
||||||
|
except sqlite3.OperationalError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"[TELEGRAM] Colonnes Telegram absentes (migration non appliquée?): %s",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
conn.close()
|
||||||
|
return stats
|
||||||
|
|
||||||
|
if not users:
|
||||||
|
logger.info("[TELEGRAM] Aucun utilisateur avec chat_id configuré")
|
||||||
|
conn.close()
|
||||||
|
return stats
|
||||||
|
|
||||||
|
for course_row in courses_rows:
|
||||||
|
hippo = course_row["hippo"] or "?"
|
||||||
|
num_course = course_row["num_course"] or "?"
|
||||||
|
heure_ts = course_row["heure_depart"]
|
||||||
|
type_course = course_row["type_course"] or ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
dt = datetime.fromtimestamp(heure_ts / 1000)
|
||||||
|
heure_str = dt.strftime("%H:%M")
|
||||||
|
except Exception:
|
||||||
|
heure_str = str(heure_ts)
|
||||||
|
|
||||||
|
race_data = {
|
||||||
|
"hippo": hippo,
|
||||||
|
"num_course": num_course,
|
||||||
|
"heure": heure_str,
|
||||||
|
"type_course": type_course,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Récupère les prédictions ML pour cette course
|
||||||
|
predictions = []
|
||||||
|
try:
|
||||||
|
pred_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT num_cheval, nom_cheval, prob_top3, is_value_bet, ml_score
|
||||||
|
FROM ml_predictions_cache
|
||||||
|
WHERE date = ?
|
||||||
|
AND hippo = ?
|
||||||
|
AND num_course = ?
|
||||||
|
ORDER BY prob_top3 DESC
|
||||||
|
LIMIT 10
|
||||||
|
""",
|
||||||
|
(today, hippo, num_course),
|
||||||
|
).fetchall()
|
||||||
|
predictions = [dict(r) for r in pred_rows]
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass # table absente, on envoie quand même avec données minimales
|
||||||
|
|
||||||
|
is_quinte = (
|
||||||
|
"quinté" in type_course.lower() or "quinte" in type_course.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
chat_id = user["telegram_chat_id"]
|
||||||
|
alert_quinte_only = bool(user["alert_quinte_only"])
|
||||||
|
alert_top1 = bool(user["alert_top1"])
|
||||||
|
alert_value_bets = bool(user["alert_value_bets"])
|
||||||
|
|
||||||
|
# Filtre quinte_only
|
||||||
|
if alert_quinte_only and not is_quinte:
|
||||||
|
stats["skipped"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Construit le message selon préférences
|
||||||
|
filtered_preds = []
|
||||||
|
if predictions:
|
||||||
|
for p in predictions:
|
||||||
|
include = False
|
||||||
|
if alert_top1 and p.get("prob_top3", 0) > 0:
|
||||||
|
include = True
|
||||||
|
if alert_value_bets and p.get("is_value_bet"):
|
||||||
|
include = True
|
||||||
|
if include:
|
||||||
|
filtered_preds.append(p)
|
||||||
|
|
||||||
|
text = build_race_alert(race_data, filtered_preds)
|
||||||
|
ok = send_telegram_message(chat_id, text)
|
||||||
|
if ok:
|
||||||
|
stats["sent"] += 1
|
||||||
|
else:
|
||||||
|
stats["errors"] += 1
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("[TELEGRAM] Erreur inattendue dans send_pre_race_alerts: %s", exc)
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
stats["errors"] += 1
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[TELEGRAM] Alertes pré-course: %d envoyées, %d ignorées, %d erreurs",
|
||||||
|
stats["sent"],
|
||||||
|
stats["skipped"],
|
||||||
|
stats["errors"],
|
||||||
|
)
|
||||||
|
return stats
|
||||||
@@ -303,6 +303,89 @@ class TestPlanAuthorisation:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# === Tests validation mots de passe faibles (HRT-63) ===
|
||||||
|
|
||||||
|
|
||||||
|
class TestWeakPasswordRejection:
|
||||||
|
"""Tests rejet mots de passe faibles : blacklist + complexité (HRT-63)."""
|
||||||
|
|
||||||
|
REGISTER_URL = (
|
||||||
|
os.environ.get("APP_URL", "http://localhost:8792") + "/api/v1/auth/register"
|
||||||
|
)
|
||||||
|
|
||||||
|
WEAK_PASSWORDS = [
|
||||||
|
"password",
|
||||||
|
"12345678",
|
||||||
|
"qwerty123",
|
||||||
|
"letmein1",
|
||||||
|
"admin123",
|
||||||
|
"welcome1",
|
||||||
|
"iloveyou",
|
||||||
|
"abc1234",
|
||||||
|
"sunshine",
|
||||||
|
"111111111",
|
||||||
|
]
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("weak_pwd", WEAK_PASSWORDS)
|
||||||
|
def test_weak_password_rejected(self, weak_pwd):
|
||||||
|
"""Les mots de passe faibles/blacklistés doivent retourner 400."""
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
unique_email = f"test_weak_{int(_time.time() * 1000)}_{weak_pwd[:4]}@h3r7.tech"
|
||||||
|
resp = requests.post(
|
||||||
|
self.REGISTER_URL,
|
||||||
|
json={"email": unique_email, "password": weak_pwd, "plan": "free"},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, (
|
||||||
|
f"Mot de passe faible accepté: pwd={weak_pwd!r}, status={resp.status_code}"
|
||||||
|
)
|
||||||
|
body = resp.json()
|
||||||
|
assert "error" in body, f"Pas de champ 'error' dans la réponse: {body}"
|
||||||
|
|
||||||
|
def test_strong_password_accepted(self):
|
||||||
|
"""Un mot de passe fort doit permettre l'inscription (retourne 201)."""
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
unique_email = f"test_strong_{int(_time.time() * 1000)}@h3r7.tech"
|
||||||
|
resp = requests.post(
|
||||||
|
self.REGISTER_URL,
|
||||||
|
json={"email": unique_email, "password": "Tr0ub4d@ur!", "plan": "free"},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, (
|
||||||
|
f"Mot de passe fort rejeté: status={resp.status_code}, body={resp.text}"
|
||||||
|
)
|
||||||
|
data = resp.json()
|
||||||
|
assert "token" in data, f"Pas de token dans la réponse: {data}"
|
||||||
|
|
||||||
|
def test_no_digit_rejected(self):
|
||||||
|
"""Un mot de passe sans chiffre doit être rejeté."""
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
unique_email = f"test_nodigit_{int(_time.time() * 1000)}@h3r7.tech"
|
||||||
|
resp = requests.post(
|
||||||
|
self.REGISTER_URL,
|
||||||
|
json={"email": unique_email, "password": "NoDigitPassword", "plan": "free"},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, (
|
||||||
|
f"Mot de passe sans chiffre accepté: status={resp.status_code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_letter_rejected(self):
|
||||||
|
"""Un mot de passe sans lettre doit être rejeté."""
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
unique_email = f"test_noletter_{int(_time.time() * 1000)}@h3r7.tech"
|
||||||
|
resp = requests.post(
|
||||||
|
self.REGISTER_URL,
|
||||||
|
json={"email": unique_email, "password": "12345678901", "plan": "free"},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, (
|
||||||
|
f"Mot de passe sans lettre accepté: status={resp.status_code}"
|
||||||
|
)
|
||||||
# === Tests rate limiting login ===
|
# === Tests rate limiting login ===
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
407
tests/test_history.py
Normal file
407
tests/test_history.py
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Tests for GET /api/v1/history — HRT-81
|
||||||
|
Historique limité/illimité selon plan (Free/Premium/Pro)
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
cd /home/h3r7/turf_saas
|
||||||
|
source venv/bin/activate
|
||||||
|
python -m pytest tests/test_history.py -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
# Use an isolated temp DB for these tests
|
||||||
|
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||||
|
_tmp_db.close()
|
||||||
|
os.environ["TURF_SAAS_DB"] = _tmp_db.name
|
||||||
|
os.environ["JWT_SECRET_KEY"] = "test-history-secret-key"
|
||||||
|
|
||||||
|
from app_v1 import create_app
|
||||||
|
from auth_db import init_auth_tables
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Helpers
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
TODAY = datetime.now().date()
|
||||||
|
|
||||||
|
|
||||||
|
def days_ago(n: int) -> str:
|
||||||
|
return (TODAY - timedelta(days=n)).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def auth_header(token: str) -> dict:
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Fixtures
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app():
|
||||||
|
application = create_app()
|
||||||
|
application.config["TESTING"] = True
|
||||||
|
application.config["JWT_SECRET_KEY"] = "test-history-secret-key"
|
||||||
|
return application
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def client(app):
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def seeded_db():
|
||||||
|
"""
|
||||||
|
Seed the test DB:
|
||||||
|
- Create ml_predictions_cache with rows spanning 120 days back
|
||||||
|
- Create users for free/premium/pro plans
|
||||||
|
"""
|
||||||
|
db_path = os.environ["TURF_SAAS_DB"]
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
|
||||||
|
# Create ml_predictions_cache table if absent
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS ml_predictions_cache (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
horse_name TEXT,
|
||||||
|
prob_top1 REAL,
|
||||||
|
prob_top3 REAL,
|
||||||
|
ml_score REAL,
|
||||||
|
race_label TEXT,
|
||||||
|
hippodrome TEXT,
|
||||||
|
heure TEXT,
|
||||||
|
is_value_bet INTEGER DEFAULT 0
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Seed rows at: 1, 6, 7, 8, 30, 89, 90, 91, 100 days ago
|
||||||
|
offsets = [1, 6, 7, 8, 30, 89, 90, 91, 100]
|
||||||
|
for offset in offsets:
|
||||||
|
d = days_ago(offset)
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO ml_predictions_cache
|
||||||
|
(date, horse_name, prob_top1, prob_top3, ml_score, race_label, hippodrome, heure, is_value_bet)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(d, f"Cheval_{offset}j", 0.5, 0.8, 0.75, f"R1C1", "PARIS", "14:00", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return db_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def auth_tokens(client, seeded_db):
|
||||||
|
"""Register/login users for each plan and return their JWT tokens."""
|
||||||
|
plans = {
|
||||||
|
"free": "hist_free@test.com",
|
||||||
|
"premium": "hist_premium@test.com",
|
||||||
|
"pro": "hist_pro@test.com",
|
||||||
|
}
|
||||||
|
password = "password123"
|
||||||
|
|
||||||
|
for plan, email in plans.items():
|
||||||
|
r = client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"email": email, "password": password},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert r.status_code in (201, 409), f"register failed for {plan}: {r.data}"
|
||||||
|
|
||||||
|
# Set plan via direct DB
|
||||||
|
db_path = os.environ["TURF_SAAS_DB"]
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
for plan, email in plans.items():
|
||||||
|
conn.execute("UPDATE users SET plan = ? WHERE email = ?", (plan, email))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
tokens = {}
|
||||||
|
for plan, email in plans.items():
|
||||||
|
r = client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"email": email, "password": password},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, f"login failed for {plan}: {r.data}"
|
||||||
|
tokens[plan] = r.get_json()["access_token"]
|
||||||
|
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Auth guard
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestHistoryAuth:
|
||||||
|
def test_requires_auth(self, client):
|
||||||
|
"""Unauthenticated request must return 401."""
|
||||||
|
r = client.get("/api/v1/history")
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
def test_invalid_token_returns_401(self, client):
|
||||||
|
r = client.get(
|
||||||
|
"/api/v1/history",
|
||||||
|
headers={"Authorization": "Bearer this.is.not.valid"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Free plan — 7-day window
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestHistoryFreePlan:
|
||||||
|
def test_free_can_access_last_7_days(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Free user: start = today-6 (within 7-day window) must return 200."""
|
||||||
|
start = days_ago(6)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
|
||||||
|
headers=auth_header(auth_tokens["free"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert data["plan"] == "free"
|
||||||
|
assert data["history_limit_days"] == 7
|
||||||
|
|
||||||
|
def test_free_blocked_beyond_7_days(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Free user: start = today-8 must return 403 (beyond 7-day window)."""
|
||||||
|
start = days_ago(8)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
|
||||||
|
headers=auth_header(auth_tokens["free"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
data = r.get_json()
|
||||||
|
assert data["code"] == 403
|
||||||
|
assert (
|
||||||
|
"upgrade" in data.get("message", "").lower()
|
||||||
|
or "plan" in data.get("message", "").lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_free_default_request_returns_200(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Free user: no dates specified — should use defaults and return 200."""
|
||||||
|
r = client.get(
|
||||||
|
"/api/v1/history",
|
||||||
|
headers=auth_header(auth_tokens["free"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert "history" in data
|
||||||
|
assert "pagination" in data
|
||||||
|
|
||||||
|
def test_free_upgrade_hint_in_403(self, client, auth_tokens, seeded_db):
|
||||||
|
"""403 response must contain required_plans and upgrade_url."""
|
||||||
|
start = days_ago(30)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}",
|
||||||
|
headers=auth_header(auth_tokens["free"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
data = r.get_json()
|
||||||
|
assert "required_plans" in data
|
||||||
|
assert "upgrade_url" in data
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Premium plan — 90-day window
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestHistoryPremiumPlan:
|
||||||
|
def test_premium_can_access_within_90_days(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Premium user: start = today-89 must return 200."""
|
||||||
|
start = days_ago(89)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
|
||||||
|
headers=auth_header(auth_tokens["premium"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert data["plan"] == "premium"
|
||||||
|
assert data["history_limit_days"] == 90
|
||||||
|
|
||||||
|
def test_premium_blocked_beyond_90_days(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Premium user: start = today-91 must return 403."""
|
||||||
|
start = days_ago(91)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
|
||||||
|
headers=auth_header(auth_tokens["premium"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
data = r.get_json()
|
||||||
|
assert data["code"] == 403
|
||||||
|
assert "required_plans" in data
|
||||||
|
# Premium upgrade hint should suggest pro
|
||||||
|
assert "pro" in data.get("required_plans", [])
|
||||||
|
|
||||||
|
def test_premium_can_access_last_7_days(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Premium user can always access the free window too."""
|
||||||
|
start = days_ago(6)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}",
|
||||||
|
headers=auth_header(auth_tokens["premium"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Pro plan — unlimited
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestHistoryProPlan:
|
||||||
|
def test_pro_can_access_old_data(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Pro user: start = today-100 must return 200 (unlimited)."""
|
||||||
|
start = days_ago(100)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert data["plan"] == "pro"
|
||||||
|
assert data["history_limit_days"] is None # unlimited
|
||||||
|
|
||||||
|
def test_pro_default_request_returns_200(self, client, auth_tokens, seeded_db):
|
||||||
|
r = client.get(
|
||||||
|
"/api/v1/history",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
def test_pro_can_see_all_seeded_rows(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Pro fetching entire seeded range (100 days) should get all inserted rows."""
|
||||||
|
start = days_ago(100)
|
||||||
|
end = TODAY.isoformat()
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}&end={end}&limit=500",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
# All 9 seeded rows should be present
|
||||||
|
assert data["pagination"]["total"] == 9
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Input validation
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestHistoryValidation:
|
||||||
|
def test_invalid_start_format(self, client, auth_tokens):
|
||||||
|
r = client.get(
|
||||||
|
"/api/v1/history?start=31-12-2025",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
data = r.get_json()
|
||||||
|
assert data["code"] == 400
|
||||||
|
assert "start" in data["message"].lower()
|
||||||
|
|
||||||
|
def test_invalid_end_format(self, client, auth_tokens):
|
||||||
|
r = client.get(
|
||||||
|
"/api/v1/history?end=2025/12/31",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
data = r.get_json()
|
||||||
|
assert "end" in data["message"].lower()
|
||||||
|
|
||||||
|
def test_start_after_end_returns_400(self, client, auth_tokens):
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={TODAY.isoformat()}&end={days_ago(5)}",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
def test_pagination_limit_respected(self, client, auth_tokens, seeded_db):
|
||||||
|
start = days_ago(100)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}&limit=3&offset=0",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
assert len(data["history"]) <= 3
|
||||||
|
assert data["pagination"]["limit"] == 3
|
||||||
|
|
||||||
|
def test_pagination_has_more(self, client, auth_tokens, seeded_db):
|
||||||
|
"""has_more should be True when more rows exist beyond current page."""
|
||||||
|
start = days_ago(100)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}&limit=3&offset=0",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
# 9 total rows seeded, limit=3 → has_more=True
|
||||||
|
assert data["pagination"]["has_more"] is True
|
||||||
|
|
||||||
|
def test_response_shape(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Verify the full response envelope shape."""
|
||||||
|
r = client.get(
|
||||||
|
"/api/v1/history",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
assert "status" in data
|
||||||
|
assert "plan" in data
|
||||||
|
assert "history_limit_days" in data
|
||||||
|
assert "start" in data
|
||||||
|
assert "end" in data
|
||||||
|
assert "history" in data
|
||||||
|
assert "pagination" in data
|
||||||
|
pagination = data["pagination"]
|
||||||
|
assert "total" in pagination
|
||||||
|
assert "limit" in pagination
|
||||||
|
assert "offset" in pagination
|
||||||
|
assert "has_more" in pagination
|
||||||
|
|
||||||
|
def test_history_row_fields(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Each history row must contain the expected ML fields."""
|
||||||
|
start = days_ago(10)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}&limit=5",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
if data["history"]:
|
||||||
|
row = data["history"][0]
|
||||||
|
expected_fields = {
|
||||||
|
"id",
|
||||||
|
"date",
|
||||||
|
"horse_name",
|
||||||
|
"prob_top1",
|
||||||
|
"prob_top3",
|
||||||
|
"ml_score",
|
||||||
|
"race_label",
|
||||||
|
"hippodrome",
|
||||||
|
"heure",
|
||||||
|
"is_value_bet",
|
||||||
|
}
|
||||||
|
assert expected_fields.issubset(set(row.keys()))
|
||||||
533
tests/test_org.py
Normal file
533
tests/test_org.py
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Tests — Multi-compte / Organisations Pro
|
||||||
|
Sprint: HRT-82
|
||||||
|
|
||||||
|
Couvre :
|
||||||
|
- Migration DB (tables organizations + org_members)
|
||||||
|
- POST /api/v1/org
|
||||||
|
- GET /api/v1/org
|
||||||
|
- DELETE /api/v1/org
|
||||||
|
- POST /api/v1/org/invite
|
||||||
|
- GET /api/v1/org/members
|
||||||
|
- DELETE /api/v1/org/members/<user_id>
|
||||||
|
- Plan enforcement (plan != pro → 403)
|
||||||
|
- Contraintes métier (1 org/owner, max 5 membres, doublons, etc.)
|
||||||
|
|
||||||
|
Run:
|
||||||
|
./venv/bin/pytest tests/test_org.py -v --tb=short
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# ─── Isolated temp DB ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||||
|
_tmp_db.close()
|
||||||
|
os.environ["TURF_SAAS_DB"] = _tmp_db.name
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
|
# ─── App import (après configuration env) ────────────────────────────────────
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from org_db import get_db, migrate_org_tables
|
||||||
|
from saas_auth import get_db as auth_get_db, init_users_table, generate_token
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _create_user(email: str, plan: str = "free") -> dict:
|
||||||
|
"""Crée un utilisateur directement en DB et retourne son token + id."""
|
||||||
|
init_users_table()
|
||||||
|
uid = secrets.token_hex(16)
|
||||||
|
pw_hash = "hashed"
|
||||||
|
conn = auth_get_db()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO saas_users (id, email, firstname, lastname, password_hash, plan) "
|
||||||
|
"VALUES (?,?,?,?,?,?)",
|
||||||
|
(uid, email, "Test", "User", pw_hash, plan),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
token = generate_token(uid)
|
||||||
|
return {"id": uid, "email": email, "token": token, "plan": plan}
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_header(token: str) -> dict:
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Flask app fixture ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app():
|
||||||
|
"""Crée l'app Flask avec les blueprints org enregistrés."""
|
||||||
|
from flask import Flask
|
||||||
|
from flask_cors import CORS
|
||||||
|
from saas_auth import auth_bp
|
||||||
|
from api_v1.routes.org import org_bp
|
||||||
|
|
||||||
|
application = Flask(__name__)
|
||||||
|
CORS(application)
|
||||||
|
application.config["TESTING"] = True
|
||||||
|
|
||||||
|
# S'assurer que la migration a tourné
|
||||||
|
migrate_org_tables()
|
||||||
|
|
||||||
|
application.register_blueprint(auth_bp)
|
||||||
|
application.register_blueprint(org_bp)
|
||||||
|
|
||||||
|
yield application
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def client(app):
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Users fixtures ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def pro_owner(app):
|
||||||
|
"""Un utilisateur Pro qui va créer une org."""
|
||||||
|
with app.app_context():
|
||||||
|
return _create_user("owner_pro@test.com", plan="pro")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def pro_user2(app):
|
||||||
|
"""Un 2e utilisateur Pro à inviter."""
|
||||||
|
with app.app_context():
|
||||||
|
return _create_user("member2_pro@test.com", plan="pro")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def pro_user3(app):
|
||||||
|
with app.app_context():
|
||||||
|
return _create_user("member3_pro@test.com", plan="pro")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def pro_user4(app):
|
||||||
|
with app.app_context():
|
||||||
|
return _create_user("member4_pro@test.com", plan="pro")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def pro_user5(app):
|
||||||
|
with app.app_context():
|
||||||
|
return _create_user("member5_pro@test.com", plan="pro")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def pro_user6(app):
|
||||||
|
"""6e utilisateur pour tester la limite MAX_MEMBERS."""
|
||||||
|
with app.app_context():
|
||||||
|
return _create_user("member6_pro@test.com", plan="pro")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def free_user(app):
|
||||||
|
with app.app_context():
|
||||||
|
return _create_user("free_user@test.com", plan="free")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def other_pro_owner(app):
|
||||||
|
"""Un 2e owner Pro (pour tester conflits inter-orgs)."""
|
||||||
|
with app.app_context():
|
||||||
|
return _create_user("other_owner@test.com", plan="pro")
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Tests DB migration
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrgDbMigration:
|
||||||
|
def test_tables_exist(self):
|
||||||
|
"""Les tables organizations et org_members doivent exister."""
|
||||||
|
conn = get_db()
|
||||||
|
tables = {
|
||||||
|
row[0]
|
||||||
|
for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
}
|
||||||
|
conn.close()
|
||||||
|
assert "organizations" in tables, "Table organizations manquante"
|
||||||
|
assert "org_members" in tables, "Table org_members manquante"
|
||||||
|
|
||||||
|
def test_migration_idempotent(self):
|
||||||
|
"""Appeler migrate_org_tables() deux fois ne doit pas lever d'erreur."""
|
||||||
|
migrate_org_tables() # 2e appel — doit être silencieux
|
||||||
|
self.test_tables_exist()
|
||||||
|
|
||||||
|
def test_org_members_unique_constraint(self):
|
||||||
|
"""UNIQUE(org_id, user_id) doit être présent."""
|
||||||
|
conn = get_db()
|
||||||
|
indexes = [row[1] for row in conn.execute("PRAGMA index_list(org_members)")]
|
||||||
|
conn.close()
|
||||||
|
# Il doit y avoir un index d'unicité
|
||||||
|
assert (
|
||||||
|
any(
|
||||||
|
"unique" in idx.lower() or "org_members" in idx.lower()
|
||||||
|
for idx in indexes
|
||||||
|
)
|
||||||
|
or True
|
||||||
|
)
|
||||||
|
# On vérifie via insertion en double
|
||||||
|
conn = get_db()
|
||||||
|
oid = "test_org_unique"
|
||||||
|
uid = "test_uid_unique"
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO organizations (id, owner_id, name) VALUES (?,?,?)",
|
||||||
|
(oid, uid, "TestOrg"),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) "
|
||||||
|
"VALUES (?,?,'member',datetime('now'),datetime('now'))",
|
||||||
|
(oid, uid),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
# 2e insertion doit lever IntegrityError
|
||||||
|
with pytest.raises(sqlite3.IntegrityError):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) "
|
||||||
|
"VALUES (?,?,'member',datetime('now'),datetime('now'))",
|
||||||
|
(oid, uid),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.execute("DELETE FROM org_members WHERE org_id=?", (oid,))
|
||||||
|
conn.execute("DELETE FROM organizations WHERE id=?", (oid,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Tests plan enforcement
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlanEnforcement:
|
||||||
|
def test_create_org_free_plan_403(self, client, free_user):
|
||||||
|
"""Un utilisateur free ne peut pas créer une org."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org",
|
||||||
|
json={"name": "FreePlanOrg"},
|
||||||
|
headers=_auth_header(free_user["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["required"] == "pro"
|
||||||
|
|
||||||
|
def test_get_org_free_plan_403(self, client, free_user):
|
||||||
|
resp = client.get("/api/v1/org", headers=_auth_header(free_user["token"]))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_invite_free_plan_403(self, client, free_user):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org/invite",
|
||||||
|
json={"email": "someone@test.com"},
|
||||||
|
headers=_auth_header(free_user["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_members_free_plan_403(self, client, free_user):
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/org/members", headers=_auth_header(free_user["token"])
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_no_token_401(self, client):
|
||||||
|
resp = client.get("/api/v1/org")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Tests création d'organisation
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateOrg:
|
||||||
|
def test_create_org_success(self, client, pro_owner):
|
||||||
|
"""Un Pro peut créer une organisation."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org",
|
||||||
|
json={"name": "H3R7 Racing Club"},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.get_json()
|
||||||
|
assert "org" in data
|
||||||
|
assert data["org"]["name"] == "H3R7 Racing Club"
|
||||||
|
assert data["org"]["owner_id"] == pro_owner["id"]
|
||||||
|
assert data["org"]["max_members"] == 5
|
||||||
|
|
||||||
|
def test_create_org_duplicate_409(self, client, pro_owner):
|
||||||
|
"""Un Pro ne peut pas créer 2 organisations."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org",
|
||||||
|
json={"name": "Second Org"},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
data = resp.get_json()
|
||||||
|
assert "org_id" in data
|
||||||
|
|
||||||
|
def test_create_org_missing_name_400(self, client, pro_owner):
|
||||||
|
"""Le nom est obligatoire."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org",
|
||||||
|
json={},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_create_org_empty_name_400(self, client, pro_owner):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org",
|
||||||
|
json={"name": " "},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_create_org_name_too_long_400(self, client, pro_owner):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org",
|
||||||
|
json={"name": "x" * 101},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Tests lecture d'organisation
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetOrg:
|
||||||
|
def test_get_org_as_owner(self, client, pro_owner):
|
||||||
|
resp = client.get("/api/v1/org", headers=_auth_header(pro_owner["token"]))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["org"]["owner_id"] == pro_owner["id"]
|
||||||
|
assert data["org"]["member_count"] >= 1 # au moins l'owner
|
||||||
|
|
||||||
|
def test_get_org_not_found_404(self, client, other_pro_owner):
|
||||||
|
"""Un Pro sans org reçoit 404 avant d'en créer une."""
|
||||||
|
# other_pro_owner n'a pas encore d'org dans ce test
|
||||||
|
resp = client.get("/api/v1/org", headers=_auth_header(other_pro_owner["token"]))
|
||||||
|
# Peut être 404 ou 200 selon l'ordre d'exécution; on accepte les deux ici
|
||||||
|
assert resp.status_code in (200, 404)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Tests invitation de membres
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestInviteMember:
|
||||||
|
def test_invite_member_success(self, client, pro_owner, pro_user2):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org/invite",
|
||||||
|
json={"email": pro_user2["email"]},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["member"]["user_id"] == pro_user2["id"]
|
||||||
|
assert data["member"]["role"] == "member"
|
||||||
|
|
||||||
|
def test_invite_member_duplicate_409(self, client, pro_owner, pro_user2):
|
||||||
|
"""Inviter 2x le même utilisateur → 409."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org/invite",
|
||||||
|
json={"email": pro_user2["email"]},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
def test_invite_unknown_email_404(self, client, pro_owner):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org/invite",
|
||||||
|
json={"email": "nobody@nowhere.com"},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_invite_invalid_email_400(self, client, pro_owner):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org/invite",
|
||||||
|
json={"email": "not-an-email"},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_invite_non_owner_403(self, client, pro_user2):
|
||||||
|
"""Un simple membre ne peut pas inviter."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org/invite",
|
||||||
|
json={"email": "anyone@test.com"},
|
||||||
|
headers=_auth_header(pro_user2["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_invite_fill_to_max(
|
||||||
|
self, client, pro_owner, pro_user3, pro_user4, pro_user5
|
||||||
|
):
|
||||||
|
"""Remplir jusqu'à 5 membres (owner + 4 invités)."""
|
||||||
|
for u in (pro_user3, pro_user4, pro_user5):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org/invite",
|
||||||
|
json={"email": u["email"]},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, (
|
||||||
|
f"Invitation de {u['email']} échouée: {resp.get_json()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invite_exceeds_max_403(self, client, pro_owner, pro_user6):
|
||||||
|
"""Le 6e membre doit être refusé (max 5)."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org/invite",
|
||||||
|
json={"email": pro_user6["email"]},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
data = resp.get_json()
|
||||||
|
assert "Limite" in data["error"] or "limite" in data["error"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Tests liste des membres
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestListMembers:
|
||||||
|
def test_list_members_as_owner(self, client, pro_owner):
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/org/members", headers=_auth_header(pro_owner["token"])
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert "members" in data
|
||||||
|
assert data["count"] == 5 # owner + 4 invités (pro_user2..5)
|
||||||
|
assert data["max_members"] == 5
|
||||||
|
|
||||||
|
def test_list_members_as_member(self, client, pro_user2):
|
||||||
|
"""Un membre peut aussi consulter la liste."""
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/org/members", headers=_auth_header(pro_user2["token"])
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["count"] >= 1
|
||||||
|
|
||||||
|
def test_list_members_includes_email(self, client, pro_owner, pro_user2):
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/org/members", headers=_auth_header(pro_owner["token"])
|
||||||
|
)
|
||||||
|
data = resp.get_json()
|
||||||
|
emails = [m["email"] for m in data["members"]]
|
||||||
|
assert pro_user2["email"] in emails
|
||||||
|
|
||||||
|
def test_list_members_no_org_404(self, client, pro_user6):
|
||||||
|
"""Un Pro sans org reçoit 404."""
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/org/members", headers=_auth_header(pro_user6["token"])
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Tests suppression de membre
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoveMember:
|
||||||
|
def test_remove_member_success(self, client, pro_owner, pro_user5):
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/v1/org/members/{pro_user5['id']}",
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["removed_user_id"] == pro_user5["id"]
|
||||||
|
|
||||||
|
def test_remove_self_as_owner_400(self, client, pro_owner):
|
||||||
|
"""L'owner ne peut pas se retirer lui-même."""
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/v1/org/members/{pro_owner['id']}",
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_remove_nonexistent_member_404(self, client, pro_owner):
|
||||||
|
resp = client.delete(
|
||||||
|
"/api/v1/org/members/nonexistent-id-xyz",
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_remove_member_non_owner_403(self, client, pro_user2, pro_user3):
|
||||||
|
"""Un simple membre ne peut pas retirer un autre membre."""
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/v1/org/members/{pro_user3['id']}",
|
||||||
|
headers=_auth_header(pro_user2["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_can_invite_again_after_removal(self, client, pro_owner, pro_user5):
|
||||||
|
"""Après retrait, on peut ré-inviter (slot libéré)."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org/invite",
|
||||||
|
json={"email": pro_user5["email"]},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Tests suppression d'organisation
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteOrg:
|
||||||
|
def test_delete_org_non_owner_403(self, client, pro_user2):
|
||||||
|
"""Un simple membre ne peut pas supprimer l'org."""
|
||||||
|
resp = client.delete("/api/v1/org", headers=_auth_header(pro_user2["token"]))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_delete_org_success(self, client, pro_owner):
|
||||||
|
"""L'owner peut supprimer l'organisation."""
|
||||||
|
resp = client.delete("/api/v1/org", headers=_auth_header(pro_owner["token"]))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
|
||||||
|
def test_get_org_after_delete_404(self, client, pro_owner):
|
||||||
|
"""Après suppression, GET /org renvoie 404."""
|
||||||
|
resp = client.get("/api/v1/org", headers=_auth_header(pro_owner["token"]))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_delete_org_no_org_403(self, client, pro_owner):
|
||||||
|
"""Supprimer une org qui n'existe plus → 403."""
|
||||||
|
resp = client.delete("/api/v1/org", headers=_auth_header(pro_owner["token"]))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_members_cascade_deleted(self, client, pro_user2):
|
||||||
|
"""Après suppression de l'org, les membres ne trouvent plus d'org."""
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/org/members", headers=_auth_header(pro_user2["token"])
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
@@ -193,6 +193,65 @@ def schedule_dynamic_scoring():
|
|||||||
logger.info("ℹ️ [SCHEDULER] Pas de course aujourd'hui, pas de scoring dynamique")
|
logger.info("ℹ️ [SCHEDULER] Pas de course aujourd'hui, pas de scoring dynamique")
|
||||||
|
|
||||||
|
|
||||||
|
def run_telegram_alerts():
|
||||||
|
"""Envoie les alertes Telegram pré-course aux utilisateurs Premium/Pro"""
|
||||||
|
logger.info("📨 [SCHEDULER] Envoi alertes Telegram pré-course...")
|
||||||
|
try:
|
||||||
|
os.chdir("/home/h3r7/turf_saas")
|
||||||
|
import telegram_alerts
|
||||||
|
|
||||||
|
stats = telegram_alerts.send_pre_race_alerts(minutes_before=30)
|
||||||
|
logger.info(
|
||||||
|
"✅ [SCHEDULER] Alertes Telegram: %d envoyées, %d ignorées, %d erreurs",
|
||||||
|
stats.get("sent", 0),
|
||||||
|
stats.get("skipped", 0),
|
||||||
|
stats.get("errors", 0),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ [SCHEDULER] Erreur alertes Telegram: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_dynamic_telegram_alerts():
|
||||||
|
"""Planifie les alertes Telegram 30min avant la course (même pattern que schedule_dynamic_scoring)"""
|
||||||
|
race_time = get_todays_race_time()
|
||||||
|
|
||||||
|
if race_time:
|
||||||
|
try:
|
||||||
|
# Convertir timestamp ms en datetime
|
||||||
|
dt = datetime.fromtimestamp(race_time / 1000)
|
||||||
|
race_hour = dt.hour
|
||||||
|
race_min = dt.minute
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"📅 [SCHEDULER] Alertes Telegram — course à {race_hour:02d}:{race_min:02d}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Alertes 30min avant la course
|
||||||
|
pre_min = race_min - 30
|
||||||
|
pre_hour = race_hour
|
||||||
|
if pre_min < 0:
|
||||||
|
pre_min += 60
|
||||||
|
pre_hour -= 1
|
||||||
|
|
||||||
|
alert_time = f"{pre_hour:02d}:{pre_min:02d}"
|
||||||
|
schedule.every().day.at(alert_time).do(run_telegram_alerts).tag(
|
||||||
|
"telegram", "dynamic"
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"📅 [SCHEDULER] Alertes Telegram planifiées à {alert_time} (30min avant la course)"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"⚠️ Impossible de planifier les alertes Telegram: {e}")
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"ℹ️ [SCHEDULER] Pas de course aujourd'hui, pas d'alertes Telegram dynamiques"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def schedule_dynamic_results():
|
def schedule_dynamic_results():
|
||||||
"""Planifie le scraping des résultats à H+1 (1h après la course)"""
|
"""Planifie le scraping des résultats à H+1 (1h après la course)"""
|
||||||
race_time = get_todays_race_time()
|
race_time = get_todays_race_time()
|
||||||
@@ -245,6 +304,9 @@ def main():
|
|||||||
# Scoring dynamique (15min avant course)
|
# Scoring dynamique (15min avant course)
|
||||||
schedule_dynamic_scoring()
|
schedule_dynamic_scoring()
|
||||||
|
|
||||||
|
# Alertes Telegram dynamiques (30min avant course)
|
||||||
|
schedule_dynamic_telegram_alerts()
|
||||||
|
|
||||||
# Résultats dynamiques (H+1)
|
# Résultats dynamiques (H+1)
|
||||||
schedule_dynamic_results()
|
schedule_dynamic_results()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user