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