#!/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/ — client detail + subscription PUT /api/v1/admin/clients/ — update client (plan, name, email) DELETE /api/v1/admin/clients/ — delete client + tokens + subscription POST /api/v1/admin/clients//suspend — suspend client (set plan=suspended) POST /api/v1/admin/clients//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/ ──────────────────────────── @admin_bp.route("/clients/", 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/ ──────────────────────────── @admin_bp.route("/clients/", 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/ ───────────────────────── @admin_bp.route("/clients/", 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//suspend ─────────────────── @admin_bp.route("/clients//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//activate ────────────────── @admin_bp.route("/clients//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()