Spaces:
Build error
Build error
Upload 10 files
Browse files- Dockerfile +57 -0
- app.py +486 -0
- auth_backend.py +359 -0
- baserow_storage.py +584 -0
- billing_routes.py +137 -0
- config.py +114 -0
- decorators.py +57 -0
- requirements.txt +10 -0
- user_routes.py +171 -0
- web_routes.py +80 -0
Dockerfile
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ÉTAPE 1: Image de base
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# ÉTAPE 2: Configuration et Dossier de travail
|
| 5 |
+
# Ligne supprimée (ENV PORT 8080) pour laisser Hugging Face Spaces injecter le port correct ($PORT, généralement 7860).
|
| 6 |
+
ENV FLASK_APP app.py
|
| 7 |
+
ENV GUNICORN_WORKERS 4
|
| 8 |
+
ENV GUNICORN_THREADS 2
|
| 9 |
+
|
| 10 |
+
# Création et utilisation du répertoire /app
|
| 11 |
+
WORKDIR /app
|
| 12 |
+
|
| 13 |
+
# ÉTAPE 3: Installation des dépendances (OPTIMISATION CACHING)
|
| 14 |
+
# Copie uniquement de requirements.txt pour mettre en cache l'installation
|
| 15 |
+
COPY requirements.txt .
|
| 16 |
+
RUN pip install --no-cache-dir -r requirements.txt \
|
| 17 |
+
&& rm requirements.txt
|
| 18 |
+
|
| 19 |
+
# ÉTAPE 4: Copie de l'Application et des Fichiers
|
| 20 |
+
# Nous copions tous les fichiers de l'application et nous assurons que
|
| 21 |
+
# l'utilisateur 'user' en est le propriétaire.
|
| 22 |
+
|
| 23 |
+
# CORRECTION MAJEURE : Ajout du dossier templates
|
| 24 |
+
# Ceci est l'étape essentielle pour que Flask trouve vos fichiers HTML
|
| 25 |
+
COPY templates /app/templates
|
| 26 |
+
|
| 27 |
+
# Copie des autres fichiers (y compris app.py, votre point d'entrée)
|
| 28 |
+
COPY app.py .
|
| 29 |
+
COPY config.py .
|
| 30 |
+
COPY user_routes.py .
|
| 31 |
+
COPY web_routes.py .
|
| 32 |
+
COPY decorators.py .
|
| 33 |
+
COPY billing_routes.py .
|
| 34 |
+
COPY auth_backend.py .
|
| 35 |
+
COPY baserow_storage.py .
|
| 36 |
+
|
| 37 |
+
# Copie du script d'entrée
|
| 38 |
+
COPY entrypoint.sh .
|
| 39 |
+
|
| 40 |
+
# NOUVEAU: CORRECTION DES FINS DE LIGNE (Résout 'exec ./entrypoint.sh: no such file or directory')
|
| 41 |
+
# Supprime le caractère de retour chariot (\r)
|
| 42 |
+
RUN sed -i 's/\r$//' entrypoint.sh
|
| 43 |
+
|
| 44 |
+
# Le rendre exécutable
|
| 45 |
+
RUN chmod +x entrypoint.sh
|
| 46 |
+
|
| 47 |
+
# ÉTAPE 5: Sécurité et Exécution
|
| 48 |
+
# Création et bascule vers l'utilisateur non-root ('user') pour la sécurité
|
| 49 |
+
RUN useradd -ms /bin/bash user
|
| 50 |
+
RUN chown -R user:user /app
|
| 51 |
+
USER user
|
| 52 |
+
|
| 53 |
+
# Indique à Docker que le conteneur écoute sur ce port
|
| 54 |
+
EXPOSE $PORT
|
| 55 |
+
|
| 56 |
+
# Lance l'application via le script d'entrée
|
| 57 |
+
CMD ["./entrypoint.sh"]
|
app.py
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app.py
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import sys
|
| 6 |
+
import io
|
| 7 |
+
import uuid
|
| 8 |
+
from functools import wraps
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from flask import Flask, request, jsonify, Response, session
|
| 11 |
+
from flask_cors import CORS
|
| 12 |
+
import baserow_storage # Assurez-vous que ceci est présent
|
| 13 |
+
|
| 14 |
+
# Importation des modules backend
|
| 15 |
+
from auth_backend import (
|
| 16 |
+
register_user,
|
| 17 |
+
login_user,
|
| 18 |
+
get_user_by_id,
|
| 19 |
+
get_plan_limit,
|
| 20 |
+
reset_password_via_security_question,
|
| 21 |
+
generate_password_hash,
|
| 22 |
+
# Nouvelles fonctions pour la gestion des utilisateurs finaux
|
| 23 |
+
register_end_user,
|
| 24 |
+
login_end_user,
|
| 25 |
+
reset_end_user_password_by_client
|
| 26 |
+
)
|
| 27 |
+
from decorators import api_key_required # <-- NOUVEL IMPORT
|
| 28 |
+
|
| 29 |
+
# Importation des Blueprints
|
| 30 |
+
from web_routes import web_bp
|
| 31 |
+
from user_routes import user_bp
|
| 32 |
+
from billing_routes import billing_bp
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# Valeur par défaut pour la taille max de contenu
|
| 36 |
+
DEFAULT_MAX_CONTENT_LENGTH = 16 * 1024 * 1024
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# --- Initialisation de l'Application Flask ---
|
| 40 |
+
app = Flask(__name__)
|
| 41 |
+
|
| 42 |
+
from werkzeug.middleware.proxy_fix import ProxyFix
|
| 43 |
+
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1)
|
| 44 |
+
# ------------------------------------------------------------------
|
| 45 |
+
|
| 46 |
+
# Configuration
|
| 47 |
+
app.secret_key = os.environ.get("FLASK_SECRET_KEY", "super_secret_dev_key")
|
| 48 |
+
app.config['MAX_CONTENT_LENGTH'] = DEFAULT_MAX_CONTENT_LENGTH
|
| 49 |
+
|
| 50 |
+
# Permettre les requêtes cross-origin (CORS)
|
| 51 |
+
CORS(app, supports_credentials=True, origins="*", allow_headers=["Content-Type", "X-User-API-Key"])
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# Permettre les requêtes cross-origin pour l'API
|
| 55 |
+
CORS(app)
|
| 56 |
+
|
| 57 |
+
# --- Enregistrement des Blueprints (Nouveau) ---
|
| 58 |
+
app.register_blueprint(web_bp)
|
| 59 |
+
app.register_blueprint(user_bp)
|
| 60 |
+
app.register_blueprint(billing_bp) # <-- NOUVEL ENREGISTREMENT
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# --- Décorateurs d'Authentification (Conservés) ---
|
| 64 |
+
def login_required(f):
|
| 65 |
+
@wraps(f)
|
| 66 |
+
def decorated_function(*args, **kwargs):
|
| 67 |
+
if 'user_id' not in session:
|
| 68 |
+
# Redirection HTTP 302 vers la page de connexion pour les requêtes non-API
|
| 69 |
+
if not request.path.startswith('/api/'):
|
| 70 |
+
from flask import redirect, url_for
|
| 71 |
+
return redirect(url_for('user_bp.connexion'))
|
| 72 |
+
# Réponse JSON pour les API
|
| 73 |
+
return jsonify({"status": "Error", "message": "Accès non autorisé. Veuillez vous connecter.", "code": "AUTH_REQUIRED"}), 401
|
| 74 |
+
return f(*args, **kwargs)
|
| 75 |
+
return decorated_function
|
| 76 |
+
|
| 77 |
+
def user_api_key_required(f):
|
| 78 |
+
"""Décorateur pour exiger la clé API dynamique via URL ('api_key') ou en-tête ('X-User-API-Key')."""
|
| 79 |
+
@wraps(f)
|
| 80 |
+
def decorated_function(*args, **kwargs):
|
| 81 |
+
api_key = request.args.get('api_key') or request.headers.get('X-User-API-Key')
|
| 82 |
+
|
| 83 |
+
if not api_key:
|
| 84 |
+
return jsonify({
|
| 85 |
+
"status": "Error",
|
| 86 |
+
"message": "Clé API utilisateur ('api_key' dans l'URL ou 'X-User-API-Key' dans l'en-tête) manquante.",
|
| 87 |
+
"code": "USER_API_KEY_MISSING"
|
| 88 |
+
}), 403
|
| 89 |
+
|
| 90 |
+
user = get_user_by_api_key(api_key)
|
| 91 |
+
|
| 92 |
+
if not user:
|
| 93 |
+
return jsonify({
|
| 94 |
+
"status": "Error",
|
| 95 |
+
"message": "Clé API utilisateur invalide.",
|
| 96 |
+
"code": "USER_API_KEY_INVALID"
|
| 97 |
+
}), 403
|
| 98 |
+
|
| 99 |
+
request.user_client_id = user['user_id']
|
| 100 |
+
request.user_client_data = user
|
| 101 |
+
|
| 102 |
+
return f(*args, **kwargs)
|
| 103 |
+
return decorated_function
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# --- Routes d'Authentification (API - Conservées) ---
|
| 107 |
+
|
| 108 |
+
@app.route("/api/register", methods=["POST"])
|
| 109 |
+
def register():
|
| 110 |
+
data = request.get_json()
|
| 111 |
+
username = data.get("username")
|
| 112 |
+
email = data.get("email")
|
| 113 |
+
password = data.get("password")
|
| 114 |
+
confirm_password = data.get("confirm_password")
|
| 115 |
+
security_question = data.get("security_question")
|
| 116 |
+
security_answer = data.get("security_answer")
|
| 117 |
+
|
| 118 |
+
# CORRECTION ICI: Déballage des 3 valeurs retournées par register_user
|
| 119 |
+
user_id, message, new_user_data = register_user(username, email, password, confirm_password, security_question, security_answer)
|
| 120 |
+
|
| 121 |
+
if user_id and new_user_data: # Vérifier l'ID et les données pour s'assurer du succès
|
| 122 |
+
session['user_id'] = user_id
|
| 123 |
+
|
| 124 |
+
# Réponse JSON pour l'API, incluant la clé API
|
| 125 |
+
return jsonify({
|
| 126 |
+
"message": message,
|
| 127 |
+
"status": "Success",
|
| 128 |
+
"user_id": user_id,
|
| 129 |
+
# On récupère la clé API directement des données utilisateur
|
| 130 |
+
"api_key": new_user_data.get("api_key")
|
| 131 |
+
}), 201
|
| 132 |
+
else:
|
| 133 |
+
# Échec de l'inscription (message d'erreur de register_user)
|
| 134 |
+
return jsonify({"message": message, "status": "Error"}), 400
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@app.route("/api/login", methods=["POST"])
|
| 139 |
+
def login():
|
| 140 |
+
"""
|
| 141 |
+
Route de connexion de l'utilisateur principal.
|
| 142 |
+
Prend le nom d'utilisateur/email et le mot de passe.
|
| 143 |
+
"""
|
| 144 |
+
data = request.get_json()
|
| 145 |
+
username = data.get("username")
|
| 146 |
+
password = data.get("password")
|
| 147 |
+
|
| 148 |
+
# CORRECTION DE L'ERREUR :
|
| 149 |
+
# Nous déballons maintenant 3 valeurs (ID, Message, Données Utilisateur)
|
| 150 |
+
# car la fonction login_user() dans auth_backend.py a été modifiée pour
|
| 151 |
+
# retourner les 3 valeurs.
|
| 152 |
+
user_id, message, user_data = login_user(username, password)
|
| 153 |
+
|
| 154 |
+
# Note: user_data est la 3ème valeur (Dict des données utilisateur ou None)
|
| 155 |
+
if user_id and user_data:
|
| 156 |
+
# La connexion est réussie
|
| 157 |
+
session['user_id'] = user_id
|
| 158 |
+
|
| 159 |
+
# Réponse API avec la clé API de l'utilisateur pour les futures requêtes
|
| 160 |
+
return jsonify({
|
| 161 |
+
"message": message,
|
| 162 |
+
"status": "Success",
|
| 163 |
+
"user_id": user_id,
|
| 164 |
+
# On utilise les données utilisateur (user_data) que nous avons déjà récupérées
|
| 165 |
+
"api_key": user_data.get("api_key")
|
| 166 |
+
}), 200
|
| 167 |
+
else:
|
| 168 |
+
# La connexion a échoué (identifiants invalides ou autre erreur)
|
| 169 |
+
return jsonify({"message": message, "status": "Error"}), 401
|
| 170 |
+
|
| 171 |
+
@app.route("/api/logout", methods=["POST"])
|
| 172 |
+
def logout():
|
| 173 |
+
session.pop('user_id', None)
|
| 174 |
+
return jsonify({"message": "Déconnexion réussie.", "status": "Success"}), 200
|
| 175 |
+
|
| 176 |
+
@app.route("/api/forgot-password", methods=["POST"])
|
| 177 |
+
def forgot_password_api(): # Renommée pour éviter conflit avec la route HTML
|
| 178 |
+
data = request.get_json()
|
| 179 |
+
username_or_email = data.get("username_or_email")
|
| 180 |
+
security_answer = data.get("security_answer")
|
| 181 |
+
new_password = data.get("new_password")
|
| 182 |
+
|
| 183 |
+
if not username_or_email or not security_answer or not new_password:
|
| 184 |
+
return jsonify({"message": "Champs manquants.", "status": "Error"}), 400
|
| 185 |
+
|
| 186 |
+
success, message = reset_password_via_security_question(username_or_email, security_answer, new_password)
|
| 187 |
+
|
| 188 |
+
if success:
|
| 189 |
+
return jsonify({
|
| 190 |
+
"message": message,
|
| 191 |
+
"status": "Success"
|
| 192 |
+
}), 200
|
| 193 |
+
else:
|
| 194 |
+
return jsonify({
|
| 195 |
+
"message": message,
|
| 196 |
+
"status": "Error"
|
| 197 |
+
}), 400
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
# --- Routes de Gestion de Compte (API - Conservées) ---
|
| 201 |
+
|
| 202 |
+
@app.route("/api/user/generate-key", methods=["POST"])
|
| 203 |
+
@login_required
|
| 204 |
+
def generate_user_api_key():
|
| 205 |
+
user_id = session.get('user_id')
|
| 206 |
+
|
| 207 |
+
new_api_key = create_dynamic_api_key()
|
| 208 |
+
|
| 209 |
+
success, message = update_user_data(user_id, {"api_key": new_api_key})
|
| 210 |
+
|
| 211 |
+
if success:
|
| 212 |
+
return jsonify({
|
| 213 |
+
"message": "Clé API utilisateur générée et sauvegardée. Conservez-la en lieu sûr. **Utilisez-la via URL simple ('api_key=...') ou en-tête 'X-User-API-Key'.**",
|
| 214 |
+
"status": "Success",
|
| 215 |
+
"api_key": new_api_key
|
| 216 |
+
}), 200
|
| 217 |
+
else:
|
| 218 |
+
return jsonify({
|
| 219 |
+
"message": f"Erreur lors de la génération de la clé : {message}",
|
| 220 |
+
"status": "Error"
|
| 221 |
+
}), 500
|
| 222 |
+
|
| 223 |
+
@app.route("/api/user/update-info", methods=["POST"])
|
| 224 |
+
@login_required
|
| 225 |
+
def update_user_info():
|
| 226 |
+
user_id = session.get('user_id')
|
| 227 |
+
data = request.get_json()
|
| 228 |
+
|
| 229 |
+
updates = {}
|
| 230 |
+
if 'username' in data:
|
| 231 |
+
updates['username'] = data['username']
|
| 232 |
+
if 'email' in data:
|
| 233 |
+
updates['email'] = data['email']
|
| 234 |
+
if 'plan' in data:
|
| 235 |
+
updates['plan'] = data['plan']
|
| 236 |
+
|
| 237 |
+
if not updates:
|
| 238 |
+
return jsonify({
|
| 239 |
+
"message": "Aucune information à mettre à jour fournie.",
|
| 240 |
+
"status": "Error"
|
| 241 |
+
}), 400
|
| 242 |
+
|
| 243 |
+
success, message = update_user_data(user_id, updates)
|
| 244 |
+
|
| 245 |
+
if success:
|
| 246 |
+
return jsonify({
|
| 247 |
+
"message": message,
|
| 248 |
+
"status": "Success"
|
| 249 |
+
}), 200
|
| 250 |
+
else:
|
| 251 |
+
return jsonify({
|
| 252 |
+
"message": f"Échec de la mise à jour : {message}",
|
| 253 |
+
"status": "Error"
|
| 254 |
+
}), 400
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
# --- Nouvelle Route pour les Clients (API - Conservée) ---
|
| 258 |
+
|
| 259 |
+
@app.route("/api/user-register", methods=["POST"])
|
| 260 |
+
@user_api_key_required
|
| 261 |
+
def user_register_via_api_key():
|
| 262 |
+
client_user_id = request.user_client_id
|
| 263 |
+
client_data = request.user_client_data
|
| 264 |
+
|
| 265 |
+
data = request.get_json()
|
| 266 |
+
username = data.get("username")
|
| 267 |
+
email = data.get("email")
|
| 268 |
+
password = data.get("password")
|
| 269 |
+
|
| 270 |
+
current_count = client_data.get("created_accounts_count", 0)
|
| 271 |
+
plan_limit = get_plan_limit(client_data.get("plan", "free"))
|
| 272 |
+
|
| 273 |
+
if current_count >= plan_limit:
|
| 274 |
+
|
| 275 |
+
if client_data.get("plan", "free") == "free" and plan_limit == 500:
|
| 276 |
+
return jsonify({
|
| 277 |
+
"message": "Erreur: Le plan gratuit ne peut pas prendre plus de 500 comptes utilisateur.",
|
| 278 |
+
"status": "Error",
|
| 279 |
+
"code": "PLAN_LIMIT_EXCEEDED"
|
| 280 |
+
}), 402
|
| 281 |
+
|
| 282 |
+
return jsonify({
|
| 283 |
+
"message": f"Erreur: Votre plan actuel ({client_data.get('plan').upper()}) limite la création de comptes à {plan_limit}. Veuillez passer à un plan supérieur.",
|
| 284 |
+
"status": "Error",
|
| 285 |
+
"code": "PLAN_LIMIT_EXCEEDED"
|
| 286 |
+
}), 402
|
| 287 |
+
|
| 288 |
+
if not username or not email or len(password) < 8:
|
| 289 |
+
return jsonify({"message": "Nom d'utilisateur, email ou mot de passe invalide (min 8 caractères).", "status": "Error"}), 400
|
| 290 |
+
|
| 291 |
+
users = load_users_data()
|
| 292 |
+
if any(u.get("email") == email for u in users.values()) or any(u.get("username") == username for u in users.values()):
|
| 293 |
+
return jsonify({"message": "Cet email ou nom d'utilisateur est déjà enregistré.", "status": "Error"}), 400
|
| 294 |
+
|
| 295 |
+
end_user_id = str(uuid.uuid4())
|
| 296 |
+
hashed_password = generate_password_hash(password)
|
| 297 |
+
|
| 298 |
+
new_end_user = {
|
| 299 |
+
"username": username,
|
| 300 |
+
"email": email,
|
| 301 |
+
"password_hash": hashed_password,
|
| 302 |
+
"user_id": end_user_id,
|
| 303 |
+
"created_at": datetime.now().isoformat(),
|
| 304 |
+
"api_key": None,
|
| 305 |
+
"plan": "end_user",
|
| 306 |
+
"created_accounts_count": 0,
|
| 307 |
+
"security_question": None,
|
| 308 |
+
"security_answer": None
|
| 309 |
+
}
|
| 310 |
+
users[end_user_id] = new_end_user
|
| 311 |
+
|
| 312 |
+
new_count = current_count + 1
|
| 313 |
+
client_data["created_accounts_count"] = new_count
|
| 314 |
+
users[client_user_id] = client_data
|
| 315 |
+
|
| 316 |
+
commit_msg = f"feat: End-user registration via API Key. Client: {client_data.get('username')}. New count: {new_count}"
|
| 317 |
+
success_save = save_users_data(users, commit_message=commit_msg)
|
| 318 |
+
|
| 319 |
+
if not success_save:
|
| 320 |
+
return jsonify({"message": "Erreur critique lors de la sauvegarde (Git).", "status": "Error"}), 500
|
| 321 |
+
|
| 322 |
+
return jsonify({
|
| 323 |
+
"message": "Inscription de l'utilisateur final réussie.",
|
| 324 |
+
"status": "Success",
|
| 325 |
+
"user_id": end_user_id,
|
| 326 |
+
"accounts_remaining": plan_limit - new_count
|
| 327 |
+
}), 201
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
# ----------------------------------------------------------------------
|
| 331 |
+
# --- NOUVELLES ROUTES API POUR LA GESTION DES UTILISATEURS FINAUX ---
|
| 332 |
+
# ----------------------------------------------------------------------
|
| 333 |
+
|
| 334 |
+
@app.route("/api/enduser/register", methods=["POST"])
|
| 335 |
+
@api_key_required
|
| 336 |
+
def api_enduser_register(client_user):
|
| 337 |
+
"""
|
| 338 |
+
Route API pour l'inscription d'un utilisateur final par un client (via sa clé API).
|
| 339 |
+
Le 'client_user' est injecté par le décorateur api_key_required.
|
| 340 |
+
"""
|
| 341 |
+
data = request.get_json()
|
| 342 |
+
username = data.get("username")
|
| 343 |
+
email = data.get("email")
|
| 344 |
+
password = data.get("password")
|
| 345 |
+
|
| 346 |
+
client_user_id = client_user['user_id']
|
| 347 |
+
|
| 348 |
+
end_user_id, success, message = register_end_user(
|
| 349 |
+
client_user_id,
|
| 350 |
+
username,
|
| 351 |
+
email,
|
| 352 |
+
password
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
if success:
|
| 356 |
+
return jsonify({
|
| 357 |
+
"message": message,
|
| 358 |
+
"status": "Success",
|
| 359 |
+
"end_user_id": end_user_id,
|
| 360 |
+
}), 201
|
| 361 |
+
else:
|
| 362 |
+
return jsonify({"message": message, "status": "Error"}), 400
|
| 363 |
+
|
| 364 |
+
@app.route("/api/enduser/login", methods=["POST"])
|
| 365 |
+
@api_key_required
|
| 366 |
+
def api_enduser_login(client_user):
|
| 367 |
+
"""
|
| 368 |
+
Route API pour la connexion d'un utilisateur final par un client (via sa clé API).
|
| 369 |
+
Le 'client_user' est injecté par le décorateur api_key_required.
|
| 370 |
+
"""
|
| 371 |
+
data = request.get_json()
|
| 372 |
+
username_or_email = data.get("username_or_email") # Accepte username ou email
|
| 373 |
+
password = data.get("password")
|
| 374 |
+
|
| 375 |
+
client_user_id = client_user['user_id']
|
| 376 |
+
|
| 377 |
+
if not all([username_or_email, password]):
|
| 378 |
+
return jsonify({"message": "L'identifiant de l'utilisateur final et le mot de passe sont requis.", "status": "Error"}), 400
|
| 379 |
+
|
| 380 |
+
# CORRECTION : La fonction retourne (end_user_id, message, user_info)
|
| 381 |
+
end_user_id, message, user_info = login_end_user(
|
| 382 |
+
client_user_id,
|
| 383 |
+
username_or_email,
|
| 384 |
+
password
|
| 385 |
+
)
|
| 386 |
+
|
| 387 |
+
if end_user_id and user_info: # Si l'ID est présent (succès)
|
| 388 |
+
return jsonify({
|
| 389 |
+
"message": message,
|
| 390 |
+
"status": "Success",
|
| 391 |
+
"user": user_info
|
| 392 |
+
}), 200
|
| 393 |
+
else:
|
| 394 |
+
return jsonify({"message": message, "status": "Error"}), 401
|
| 395 |
+
|
| 396 |
+
@app.route("/api/enduser/recover-password", methods=["POST"])
|
| 397 |
+
@api_key_required
|
| 398 |
+
def api_enduser_recover_password(client_user):
|
| 399 |
+
"""
|
| 400 |
+
Route API pour la récupération/réinitialisation du mot de passe d'un utilisateur final
|
| 401 |
+
par le client (via sa clé API).
|
| 402 |
+
Le 'client_user' est injecté par le décorateur api_key_required.
|
| 403 |
+
"""
|
| 404 |
+
data = request.get_json()
|
| 405 |
+
end_user_identifier = data.get("username_or_email") # Identifiant de l'utilisateur final à réinitialiser
|
| 406 |
+
new_password = data.get("new_password")
|
| 407 |
+
|
| 408 |
+
client_user_id = client_user['user_id']
|
| 409 |
+
|
| 410 |
+
if not all([end_user_identifier, new_password]):
|
| 411 |
+
return jsonify({"message": "L'identifiant de l'utilisateur final et le nouveau mot de passe sont requis.", "status": "Error"}), 400
|
| 412 |
+
|
| 413 |
+
success, message = reset_end_user_password_by_client(
|
| 414 |
+
client_user_id,
|
| 415 |
+
end_user_identifier,
|
| 416 |
+
new_password
|
| 417 |
+
)
|
| 418 |
+
|
| 419 |
+
if success:
|
| 420 |
+
return jsonify({"message": message, "status": "Success"}), 200
|
| 421 |
+
else:
|
| 422 |
+
return jsonify({"message": message, "status": "Error"}), 400
|
| 423 |
+
|
| 424 |
+
# app.py
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
@app.route("/api/user-info", methods=["GET"])
|
| 428 |
+
@api_key_required
|
| 429 |
+
def api_user_info(client_user):
|
| 430 |
+
"""
|
| 431 |
+
Route API pour récupérer les informations de l'utilisateur principal (client)
|
| 432 |
+
à partir de la clé API fournie.
|
| 433 |
+
Le 'client_user' est injecté par le décorateur api_key_required.
|
| 434 |
+
"""
|
| 435 |
+
# client_user est l'objet utilisateur complet injecté par le décorateur
|
| 436 |
+
|
| 437 |
+
# Sécurité : créer une copie et supprimer les données sensibles avant l'envoi
|
| 438 |
+
user_info_safe = client_user.copy()
|
| 439 |
+
user_info_safe.pop('password_hash', None)
|
| 440 |
+
user_info_safe.pop('security_answer_hash', None)
|
| 441 |
+
|
| 442 |
+
return jsonify({
|
| 443 |
+
"message": "Informations utilisateur récupérées avec succès.",
|
| 444 |
+
"status": "Success",
|
| 445 |
+
"user": user_info_safe
|
| 446 |
+
}), 200
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
@app.route("/api/health", methods=["GET"])
|
| 451 |
+
def health_check():
|
| 452 |
+
"""Vérifie l'état du service en utilisant le statut Baserow."""
|
| 453 |
+
|
| 454 |
+
# 1. Tenter d'obtenir le statut Baserow réel
|
| 455 |
+
try:
|
| 456 |
+
# Appelle la fonction de baserow_storage pour vérifier l'état
|
| 457 |
+
health_status = baserow_storage.get_health_status()
|
| 458 |
+
|
| 459 |
+
# Le statut 'data_storage' est la clé pour le frontend
|
| 460 |
+
db_status_message = health_status.get('data_storage', 'Unknown')
|
| 461 |
+
|
| 462 |
+
# Si la DB est 'operational', on envoie 'Ready'
|
| 463 |
+
if db_status_message == 'operational':
|
| 464 |
+
data_status = "Ready"
|
| 465 |
+
else:
|
| 466 |
+
data_status = f"Failed (Baserow: {db_status_message})"
|
| 467 |
+
|
| 468 |
+
except Exception as e:
|
| 469 |
+
# Erreur générale, Baserow inaccessible ou problème de configuration critique
|
| 470 |
+
data_status = f"Failed (Exception: {str(e)})"
|
| 471 |
+
|
| 472 |
+
return jsonify({
|
| 473 |
+
"status": "Online",
|
| 474 |
+
"data_storage": data_status
|
| 475 |
+
}), 200
|
| 476 |
+
|
| 477 |
+
@app.route("/", methods=["GET"])
|
| 478 |
+
def read_root():
|
| 479 |
+
"""Endpoint racine pour le Health Check (Flask version)."""
|
| 480 |
+
|
| 481 |
+
if baserow_storage.is_baserow_up():
|
| 482 |
+
# Statut OK (200) avec le message
|
| 483 |
+
return jsonify({"status": "ok", "message": "Backend and Baserow API are reachable."}), 200
|
| 484 |
+
else:
|
| 485 |
+
# Statut de service non disponible (503) avec le message d'erreur
|
| 486 |
+
return jsonify({"detail": "Baserow service unavailable (Health Check failed)."}), 503
|
auth_backend.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# auth_backend.py
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import uuid
|
| 5 |
+
import secrets
|
| 6 |
+
import string
|
| 7 |
+
import sys # Nécessaire pour print(..., file=sys.stderr)
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from werkzeug.security import generate_password_hash, check_password_hash
|
| 10 |
+
# --- NOUVEL IMPORT: Migration vers Baserow ---
|
| 11 |
+
# Ces fonctions sont maintenant implémentées dans baserow_storage.py
|
| 12 |
+
from baserow_storage import (
|
| 13 |
+
# Fonctions de lecture/écriture pour les utilisateurs principaux (simulant l'ancienne API)
|
| 14 |
+
get_client_user_by_api_key,
|
| 15 |
+
get_end_user_by_email,
|
| 16 |
+
load_primary_user_data,
|
| 17 |
+
save_primary_user_data,
|
| 18 |
+
# Fonctions de recherche indexées (nouvelles et plus efficaces)
|
| 19 |
+
get_user_by_email,
|
| 20 |
+
get_client_user_by_api_key,
|
| 21 |
+
# Fonctions de lecture/écriture pour les utilisateurs finaux (simulant l'ancienne API)
|
| 22 |
+
load_end_user_data,
|
| 23 |
+
save_end_user_data,
|
| 24 |
+
# load_users_data est désormais obsolète et retiré.
|
| 25 |
+
)
|
| 26 |
+
from flask import session
|
| 27 |
+
from config import PLANS_CONFIG
|
| 28 |
+
from typing import Optional, Dict
|
| 29 |
+
|
| 30 |
+
# ----------------------------------------------------------------------
|
| 31 |
+
# --- Fonctions Utilitaires et de Configuration ---
|
| 32 |
+
# ----------------------------------------------------------------------
|
| 33 |
+
|
| 34 |
+
def get_plan_limit(plan: str) -> float:
|
| 35 |
+
"""Retourne la limite de compte pour un plan donné."""
|
| 36 |
+
return PLANS_CONFIG.get(plan, {}).get("limit", PLANS_CONFIG["free"]["limit"])
|
| 37 |
+
|
| 38 |
+
def get_plan_details(plan_id: str) -> Optional[Dict]:
|
| 39 |
+
"""Retourne les détails complets d'un plan à partir de son ID."""
|
| 40 |
+
return PLANS_CONFIG.get(plan_id)
|
| 41 |
+
|
| 42 |
+
def generate_api_key(length: int = 32) -> str:
|
| 43 |
+
"""Génère une clé API sécurisée."""
|
| 44 |
+
chars = string.ascii_letters + string.digits
|
| 45 |
+
return ''.join(secrets.choice(chars) for _ in range(length))
|
| 46 |
+
|
| 47 |
+
# ----------------------------------------------------------------------
|
| 48 |
+
# --- Fonctions d'Authentification WEB (Primary Users) ---
|
| 49 |
+
# ----------------------------------------------------------------------
|
| 50 |
+
|
| 51 |
+
def register_user(username: str, email: str, password: str, confirm_password: str, security_question: str, security_answer: str) -> tuple[Optional[str], str, Optional[Dict]]:
|
| 52 |
+
"""
|
| 53 |
+
Tente d'enregistrer un nouvel utilisateur principal.
|
| 54 |
+
Retourne l'ID utilisateur (str), un message (str), et les données utilisateur complètes (Dict) ou None.
|
| 55 |
+
"""
|
| 56 |
+
email = email.lower().strip()
|
| 57 |
+
|
| 58 |
+
# Validation du formulaire
|
| 59 |
+
if not all([username, email, password, confirm_password, security_question, security_answer]):
|
| 60 |
+
# Retourne (ID, message, Data)
|
| 61 |
+
return None, "Tous les champs sont requis.", None
|
| 62 |
+
|
| 63 |
+
if password != confirm_password:
|
| 64 |
+
return None, "Les mots de passe ne correspondent pas.", None
|
| 65 |
+
|
| 66 |
+
if len(password) < 8:
|
| 67 |
+
return None, "Le mot de passe est trop court (min 8 caractères).", None
|
| 68 |
+
|
| 69 |
+
# 1. Vérification de l'existence de l'utilisateur par email
|
| 70 |
+
if get_user_by_email(email):
|
| 71 |
+
return None, "Un compte avec cette adresse e-mail existe déjà.", None
|
| 72 |
+
|
| 73 |
+
# 2. Hachage des données
|
| 74 |
+
password_hash = generate_password_hash(password)
|
| 75 |
+
security_answer_hash = generate_password_hash(security_answer.lower())
|
| 76 |
+
|
| 77 |
+
# 3. Création des données utilisateur
|
| 78 |
+
user_id = str(uuid.uuid4())
|
| 79 |
+
# Ligne optimisée: on génère les 5 clés en une seule liste
|
| 80 |
+
api_keys = [generate_api_key() for _ in range(5)]
|
| 81 |
+
|
| 82 |
+
new_user = {
|
| 83 |
+
'user_id': user_id,
|
| 84 |
+
'username': username,
|
| 85 |
+
'email': email,
|
| 86 |
+
'password_hash': password_hash,
|
| 87 |
+
# ASSIGNATION DES 5 CLÉS API AUX CHAMPS CORRESPONDANTS
|
| 88 |
+
'api_key': api_keys[0],
|
| 89 |
+
'api_key_2': api_keys[1],
|
| 90 |
+
'api_key_3': api_keys[2],
|
| 91 |
+
'api_key_4': api_keys[3],
|
| 92 |
+
'api_key_5': api_keys[4],
|
| 93 |
+
'security_question': security_question,
|
| 94 |
+
'security_answer_hash': security_answer_hash,
|
| 95 |
+
'plan_id': PLANS_CONFIG['free']['baserow_value'],
|
| 96 |
+
'stripe_subscription_id': None, # Pas d'abonnement Stripe au début
|
| 97 |
+
'date_creation': datetime.now().isoformat(),
|
| 98 |
+
'date_plan_start': datetime.now().isoformat(),
|
| 99 |
+
'api_calls_month': 0,
|
| 100 |
+
'status': 'Active',
|
| 101 |
+
'baserow_row_id': None, # Sera rempli par la fonction save_primary_user_data si c'est une création
|
| 102 |
+
# Note: D'autres champs pourraient être nécessaires ici, selon les besoins non-vus
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
# 4. Sauvegarde dans Baserow
|
| 106 |
+
# La fonction save_primary_user_data mettra à jour 'baserow_row_id' dans new_user si la création est réussie.
|
| 107 |
+
success = save_primary_user_data(new_user, commit_msg=f"feat: Création de l'utilisateur {user_id} ({email})")
|
| 108 |
+
|
| 109 |
+
if success:
|
| 110 |
+
# Retourne : ID utilisateur, message de succès, Dictionnaire utilisateur complet
|
| 111 |
+
return user_id, "Inscription réussie. Vous pouvez maintenant vous connecter.", new_user
|
| 112 |
+
else:
|
| 113 |
+
# Échec de l'écriture dans la BDD (Baserow)
|
| 114 |
+
# Retourne : None, message d'erreur, None
|
| 115 |
+
return None, "Erreur interne lors de l'enregistrement de l'utilisateur.", None
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def login_user(username_or_email: str, password: str) -> tuple[Optional[str], str, Optional[Dict]]: # <-- NOUVEAU: Ajout de Optional[Dict] dans le type hint
|
| 119 |
+
"""
|
| 120 |
+
Tente de connecter un utilisateur principal.
|
| 121 |
+
Retourne l'ID utilisateur (str), un message (str) et les données utilisateur (Dict).
|
| 122 |
+
"""
|
| 123 |
+
username_or_email = username_or_email.lower().strip()
|
| 124 |
+
|
| 125 |
+
# 1. Recherche de l'utilisateur par email (Utilisation de la nouvelle fonction rapide Baserow)
|
| 126 |
+
user = get_user_by_email(username_or_email)
|
| 127 |
+
|
| 128 |
+
if user:
|
| 129 |
+
# 2. Vérification du mot de passe
|
| 130 |
+
if check_password_hash(user['password_hash'], password):
|
| 131 |
+
session['user_id'] = user['user_id']
|
| 132 |
+
# CORRECTION : Retourne 3 valeurs : ID, Message, Données Utilisateur
|
| 133 |
+
return user['user_id'], "Connexion réussie.", user
|
| 134 |
+
|
| 135 |
+
# CORRECTION : Retourne 3 valeurs : None ID, Message, None Data
|
| 136 |
+
return None, "Email/Nom d'utilisateur ou mot de passe invalide.", None
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def get_user_by_id(user_id: str) -> Optional[Dict]:
|
| 140 |
+
"""
|
| 141 |
+
Récupère un utilisateur principal par son ID.
|
| 142 |
+
Utilise la fonction load_primary_user_data (qui est get_user_by_id dans Baserow).
|
| 143 |
+
"""
|
| 144 |
+
return load_primary_user_data(user_id)
|
| 145 |
+
|
| 146 |
+
def get_user_by_api_key(api_key: str) -> Optional[Dict]:
|
| 147 |
+
"""
|
| 148 |
+
Récupère un utilisateur principal par sa Clé API (utilisé par le décorateur).
|
| 149 |
+
Utilise la nouvelle fonction indexée et rapide de Baserow.
|
| 150 |
+
"""
|
| 151 |
+
return get_client_user_by_api_key(api_key)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def reset_password_via_security_question(username_or_email: str, question: str, answer: str, new_password: str) -> tuple[bool, str]:
|
| 155 |
+
"""Réinitialise le mot de passe via la question de sécurité."""
|
| 156 |
+
username_or_email = username_or_email.lower().strip()
|
| 157 |
+
|
| 158 |
+
# Validation du mot de passe
|
| 159 |
+
if len(new_password) < 8:
|
| 160 |
+
return False, "Le nouveau mot de passe est trop court (min 8 caractères)."
|
| 161 |
+
|
| 162 |
+
# 1. Recherche de l'utilisateur
|
| 163 |
+
user = get_user_by_email(username_or_email)
|
| 164 |
+
|
| 165 |
+
if not user:
|
| 166 |
+
return False, "Utilisateur introuvable."
|
| 167 |
+
|
| 168 |
+
# 2. Vérification de la question/réponse
|
| 169 |
+
if user.get('security_question') != question:
|
| 170 |
+
return False, "Question de sécurité incorrecte."
|
| 171 |
+
|
| 172 |
+
if not check_password_hash(user.get('security_answer_hash', ''), answer.lower()):
|
| 173 |
+
return False, "Réponse de sécurité incorrecte."
|
| 174 |
+
|
| 175 |
+
# 3. Hachage du nouveau mot de passe
|
| 176 |
+
new_hashed_password = generate_password_hash(new_password)
|
| 177 |
+
|
| 178 |
+
# 4. Mise à jour et sauvegarde en BDD (Baserow)
|
| 179 |
+
user['password_hash'] = new_hashed_password
|
| 180 |
+
|
| 181 |
+
success = save_primary_user_data(user, commit_msg=f"feat: Réinitialisation MDP utilisateur {user['user_id']}")
|
| 182 |
+
|
| 183 |
+
if success:
|
| 184 |
+
return True, "Mot de passe réinitialisé avec succès."
|
| 185 |
+
else:
|
| 186 |
+
return False, "Erreur interne lors de la mise à jour du mot de passe."
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def update_user_plan(user_id: str, new_plan_id: str, stripe_subscription_id: Optional[str]) -> bool:
|
| 190 |
+
"""Met à jour le plan et l'ID d'abonnement Stripe pour un utilisateur."""
|
| 191 |
+
|
| 192 |
+
user = get_user_by_id(user_id)
|
| 193 |
+
|
| 194 |
+
if not user:
|
| 195 |
+
print(f"Erreur: Utilisateur {user_id} non trouvé pour la mise à jour du plan.", file=sys.stderr)
|
| 196 |
+
return False
|
| 197 |
+
|
| 198 |
+
user['plan_id'] = new_plan_id
|
| 199 |
+
user['stripe_subscription_id'] = stripe_subscription_id
|
| 200 |
+
user['date_plan_start'] = datetime.now().isoformat()
|
| 201 |
+
|
| 202 |
+
success = save_primary_user_data(user, commit_msg=f"feat: Mise à jour du plan pour {user_id} vers {new_plan_id}")
|
| 203 |
+
|
| 204 |
+
return success
|
| 205 |
+
|
| 206 |
+
# ----------------------------------------------------------------------
|
| 207 |
+
# --- Fonctions API (End Users) ---
|
| 208 |
+
# ----------------------------------------------------------------------
|
| 209 |
+
|
| 210 |
+
def register_end_user(client_user_id: str, email: str, username: str, password: str, security_question: Optional[str] = None, security_answer: Optional[str] = None, identifier: Optional[str] = None) -> tuple[Optional[str], str, Optional[Dict]]:
|
| 211 |
+
"""
|
| 212 |
+
Tente d'enregistrer un nouvel utilisateur final pour un client donné.
|
| 213 |
+
Retourne l'ID utilisateur final (str), un message (str), et les données utilisateur complètes (Dict) ou None.
|
| 214 |
+
"""
|
| 215 |
+
email = email.lower().strip()
|
| 216 |
+
|
| 217 |
+
# 1. Validation de base
|
| 218 |
+
if not all([client_user_id, email, username, password]):
|
| 219 |
+
return None, "Les champs Client ID, Email, Nom d'utilisateur et Mot de passe sont requis.", None
|
| 220 |
+
|
| 221 |
+
if len(password) < 8:
|
| 222 |
+
return None, "Le mot de passe est trop court (min 8 caractères).", None
|
| 223 |
+
|
| 224 |
+
# 2. Vérification de l'unicité de l'utilisateur final par email (scopé par client)
|
| 225 |
+
if get_end_user_by_email(client_user_id, email):
|
| 226 |
+
return None, "Un utilisateur final avec cette adresse e-mail existe déjà pour ce client.", None
|
| 227 |
+
|
| 228 |
+
# 3. Hachage des données
|
| 229 |
+
password_hash = generate_password_hash(password)
|
| 230 |
+
security_answer_hash = generate_password_hash(security_answer.lower()) if security_answer else None
|
| 231 |
+
|
| 232 |
+
# 4. Création des données
|
| 233 |
+
end_user_id = str(uuid.uuid4())
|
| 234 |
+
|
| 235 |
+
new_end_user = {
|
| 236 |
+
'end_user_id': end_user_id,
|
| 237 |
+
'client_user_id': client_user_id, # ID du client principal pour le lien
|
| 238 |
+
'identifier': identifier or email,
|
| 239 |
+
'email': email,
|
| 240 |
+
'username': username,
|
| 241 |
+
'password_hash': password_hash,
|
| 242 |
+
'security_question': security_question,
|
| 243 |
+
'security_answer_hash': security_answer_hash,
|
| 244 |
+
'status': 'Active',
|
| 245 |
+
'metadata': '{}', # Initialiser les métadonnées
|
| 246 |
+
'date_creation': datetime.now().isoformat(),
|
| 247 |
+
'baserow_row_id': None,
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
# 5. Sauvegarde dans Baserow
|
| 251 |
+
success = save_end_user_data(new_end_user, commit_msg=f"feat: Création de l'utilisateur final {end_user_id}")
|
| 252 |
+
|
| 253 |
+
if success:
|
| 254 |
+
return end_user_id, "Inscription de l'utilisateur final réussie.", new_end_user
|
| 255 |
+
else:
|
| 256 |
+
return None, "Erreur interne lors de l'enregistrement de l'utilisateur final.", None
|
| 257 |
+
|
| 258 |
+
def login_end_user(client_user_id: str, email: str, password: str) -> tuple[Optional[str], str, Optional[Dict]]:
|
| 259 |
+
"""
|
| 260 |
+
Tente de connecter un utilisateur final (End User) sous l'autorité d'un client principal (Primary User).
|
| 261 |
+
Retourne l'ID utilisateur final (str), un message (str) et les données utilisateur (Dict).
|
| 262 |
+
"""
|
| 263 |
+
email = email.lower().strip()
|
| 264 |
+
|
| 265 |
+
# 1. Recherche de l'utilisateur final par email et client ID
|
| 266 |
+
end_user = get_end_user_by_email(client_user_id, email)
|
| 267 |
+
|
| 268 |
+
if end_user:
|
| 269 |
+
# 2. Vérification du mot de passe
|
| 270 |
+
if check_password_hash(end_user['password_hash'], password):
|
| 271 |
+
# Succès de la connexion.
|
| 272 |
+
return end_user['end_user_id'], "Connexion utilisateur final réussie.", end_user
|
| 273 |
+
|
| 274 |
+
# Échec de l'authentification
|
| 275 |
+
return None, "Email ou mot de passe utilisateur final invalide pour ce client.", None
|
| 276 |
+
|
| 277 |
+
def reset_end_user_password_by_client(client_user_id: str, end_user_id: str, new_password: str) -> tuple[bool, str]:
|
| 278 |
+
"""
|
| 279 |
+
Permet à un client principal (Primary User) de réinitialiser le mot de passe
|
| 280 |
+
d'un de ses utilisateurs finaux (End User) par son ID (API client-side).
|
| 281 |
+
"""
|
| 282 |
+
# 1. Validation du mot de passe
|
| 283 |
+
if len(new_password) < 8:
|
| 284 |
+
return False, "Le nouveau mot de passe est trop court (min 8 caractères)."
|
| 285 |
+
|
| 286 |
+
# 2. Charger les données de l'utilisateur final, en s'assurant qu'il appartient bien au client.
|
| 287 |
+
end_user_data = load_end_user_data(client_user_id, end_user_id)
|
| 288 |
+
|
| 289 |
+
if not end_user_data:
|
| 290 |
+
# L'utilisateur final n'existe pas ou n'est pas lié à ce client_user_id
|
| 291 |
+
return False, "Utilisateur final introuvable ou non autorisé (n'appartient pas à ce client)."
|
| 292 |
+
|
| 293 |
+
# 3. Hachage du nouveau mot de passe
|
| 294 |
+
new_password_hash = generate_password_hash(new_password)
|
| 295 |
+
|
| 296 |
+
# 4. Mise à jour des données
|
| 297 |
+
end_user_data['password_hash'] = new_password_hash
|
| 298 |
+
|
| 299 |
+
# 5. Sauvegarde des données
|
| 300 |
+
success = save_end_user_data(end_user_data, commit_msg=f"action: Réinitialisation du mot de passe de l'utilisateur final {end_user_id} par le client {client_user_id}")
|
| 301 |
+
|
| 302 |
+
if success:
|
| 303 |
+
return True, "Mot de passe de l'utilisateur final réinitialisé avec succès."
|
| 304 |
+
else:
|
| 305 |
+
return False, "Erreur lors de la sauvegarde du nouveau mot de passe de l'utilisateur final."
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def update_user_profile(user_id: str, username: str, email: str, new_password: Optional[str] = None) -> tuple[bool, str]:
|
| 309 |
+
"""
|
| 310 |
+
Met à jour le profil de l'utilisateur principal (client).
|
| 311 |
+
user_id: L'UUID de l'utilisateur.
|
| 312 |
+
username: Le nouveau nom d'utilisateur.
|
| 313 |
+
email: Le nouvel email.
|
| 314 |
+
new_password: Le nouveau mot de passe (si fourni et non vide).
|
| 315 |
+
"""
|
| 316 |
+
|
| 317 |
+
# 1. Chargement des données existantes
|
| 318 |
+
user_data = load_primary_user_data(user_id)
|
| 319 |
+
if not user_data:
|
| 320 |
+
return False, "Erreur critique : Utilisateur introuvable pour la mise à jour."
|
| 321 |
+
|
| 322 |
+
original_email = user_data.get('email')
|
| 323 |
+
|
| 324 |
+
# 2. Validation et mise à jour de l'email
|
| 325 |
+
if email and email != original_email:
|
| 326 |
+
# Vérification si le nouvel email n'est pas déjà utilisé par un autre utilisateur
|
| 327 |
+
# Note: get_user_by_email retourne l'objet utilisateur, pas juste l'ID.
|
| 328 |
+
existing_user_by_email = get_user_by_email(email)
|
| 329 |
+
|
| 330 |
+
# S'il trouve un utilisateur ET que son ID ne correspond pas à l'utilisateur actuel,
|
| 331 |
+
# l'email est déjà pris.
|
| 332 |
+
if existing_user_by_email and existing_user_by_email.get('user_id') != user_id:
|
| 333 |
+
return False, "Cet email est déjà utilisé par un autre compte."
|
| 334 |
+
|
| 335 |
+
# Mise à jour de l'email si la validation passe
|
| 336 |
+
user_data['email'] = email
|
| 337 |
+
|
| 338 |
+
# 3. Mise à jour du nom d'utilisateur (pas de validation d'unicité assumée ici)
|
| 339 |
+
user_data['username'] = username
|
| 340 |
+
|
| 341 |
+
# 4. Mise à jour du mot de passe (si un nouveau est fourni)
|
| 342 |
+
if new_password:
|
| 343 |
+
if len(new_password) < 8:
|
| 344 |
+
# Assurez-vous que cette limite est cohérente avec la fonction register_user
|
| 345 |
+
return False, "Le nouveau mot de passe est trop court (min 8 caractères)."
|
| 346 |
+
|
| 347 |
+
user_data['password_hash'] = generate_password_hash(new_password)
|
| 348 |
+
|
| 349 |
+
# 5. Sauvegarde des données
|
| 350 |
+
# Le row_id est stocké dans l'objet utilisateur après la première sauvegarde.
|
| 351 |
+
baserow_row_id = user_data.get('baserow_row_id')
|
| 352 |
+
|
| 353 |
+
if baserow_row_id is None:
|
| 354 |
+
return False, "Erreur de configuration : ID de ligne Baserow manquant."
|
| 355 |
+
|
| 356 |
+
if save_primary_user_data(user_data, commit_msg=f"feat: Mise à jour du profil utilisateur {user_id}"):
|
| 357 |
+
return True, "Votre profil a été mis à jour avec succès."
|
| 358 |
+
else:
|
| 359 |
+
return False, "Une erreur s'est produite lors de la sauvegarde du profil."
|
baserow_storage.py
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# baserow_storage.py
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import requests
|
| 5 |
+
import json
|
| 6 |
+
import sys
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from typing import Optional, Dict
|
| 9 |
+
import logging
|
| 10 |
+
# Configuration du logger (ajoutez ceci en haut du fichier)
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
# --- Configuration Baserow (Doit être défini dans les secrets) ---
|
| 13 |
+
HEALTH_CHECK_URL = "https://api.baserow.io/api/database/rows/table/"
|
| 14 |
+
|
| 15 |
+
# 2. URL de BASE CORRECTE pour la construction des requêtes de données (connexion, inscription, etc.)
|
| 16 |
+
DATA_BASE_URL = "https://api.baserow.io/api/database/rows/"
|
| 17 |
+
API_TOKEN = os.environ.get("BASEROW_API_TOKEN")
|
| 18 |
+
|
| 19 |
+
# Les IDs de table seront récupérés depuis les variables d'environnement
|
| 20 |
+
PRIMARY_USERS_TABLE_ID = os.environ.get("PRIMARY_USERS_TABLE_ID")
|
| 21 |
+
END_USERS_TABLE_ID = os.environ.get("END_USERS_TABLE_ID")
|
| 22 |
+
|
| 23 |
+
# Headers pour l'authentification
|
| 24 |
+
HEADERS = {
|
| 25 |
+
"Authorization": f"Token {API_TOKEN}",
|
| 26 |
+
"Content-Type": "application/json"
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
# ----------------------------------------------------------------------
|
| 30 |
+
# --- Noms de Colonnes pour la Table des Utilisateurs Principaux (Primary Users) ---
|
| 31 |
+
# ----------------------------------------------------------------------
|
| 32 |
+
FIELD_ID = 'ID' # Correspond à 'user_id' dans le code
|
| 33 |
+
FIELD_EMAIL = 'Email' # Correspond à 'email'
|
| 34 |
+
FIELD_USERNAME = 'Nom d\'utilisateur' # Correspond à 'username'
|
| 35 |
+
FIELD_PASSWORD_HASH = 'Hachage Mot de Passe' # Correspond à 'password_hash'
|
| 36 |
+
FIELD_API_KEY = 'Clé API' # Correspond à 'api_key'
|
| 37 |
+
FIELD_API_KEY_2 = 'Clé API 2'
|
| 38 |
+
FIELD_API_KEY_3 = 'Clé API 3'
|
| 39 |
+
FIELD_API_KEY_4 = 'Clé API 4'
|
| 40 |
+
FIELD_API_KEY_5 = 'Clé API 5'
|
| 41 |
+
FIELD_SECURITY_Q = 'Question de Sécurité'
|
| 42 |
+
FIELD_SECURITY_A_HASH = 'Hachage Réponse Secrète'
|
| 43 |
+
FIELD_PLAN_ID = 'Plan ID'
|
| 44 |
+
FIELD_STRIPE_SUB_ID = 'ID Abonnement Stripe'
|
| 45 |
+
FIELD_DATE_CREATION = 'Date Création'
|
| 46 |
+
FIELD_DATE_PLAN_START = 'Date Plan Start'
|
| 47 |
+
FIELD_API_CALLS_MONTH = 'API Calls Month' # À vérifier avec votre nom exact dans Baserow!
|
| 48 |
+
FIELD_STATUS = 'Status'
|
| 49 |
+
FIELD_END_USER_ID = 'ID Utilisateur Final' # Correspond à 'end_user_id'
|
| 50 |
+
FIELD_END_USER_IDENTIFIER = 'Identifiant' # Correspond à 'identifier'
|
| 51 |
+
FIELD_END_USER_METADATA = 'Métadonnées' # Correspond à 'metadata'
|
| 52 |
+
FIELD_CLIENT_ID_LINK = 'ID Client Principal' # Lien vers Primary_Users
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ----------------------------------------------------------------------
|
| 56 |
+
# --- Noms de Colonnes pour la Table des Utilisateurs Finaux (End Users) ---
|
| 57 |
+
# ----------------------------------------------------------------------
|
| 58 |
+
# Ces champs sont spécifiques à la table END_USERS
|
| 59 |
+
FIELD_END_USER_ID = 'ID Utilisateur Final'
|
| 60 |
+
FIELD_END_USER_IDENTIFIER = 'Identifiant' # Champ pour compatibilité ou recherche
|
| 61 |
+
FIELD_END_USER_EMAIL = 'Email' # NOUVEAU
|
| 62 |
+
FIELD_END_USER_USERNAME = 'Nom d\'utilisateur' # NOUVEAU
|
| 63 |
+
FIELD_END_USER_SECURITY_Q = 'Question de Sécurité' # NOUVEAU (Peut être différent de Primary)
|
| 64 |
+
FIELD_END_USER_SECURITY_A_HASH = 'Hachage Réponse Secrète' # NOUVEAU
|
| 65 |
+
FIELD_END_USER_STATUS = 'Statut' # NOUVEAU
|
| 66 |
+
FIELD_END_USER_METADATA = 'Métadonnées'
|
| 67 |
+
FIELD_PASSWORD_HASH_END_USER = 'Hachage Mot de Passe End User' # Renommer pour éviter le conflit si possible
|
| 68 |
+
FIELD_CLIENT_ID_LINK = 'ID Client Principal'
|
| 69 |
+
|
| 70 |
+
def _get_table_url(table_id: str) -> str:
|
| 71 |
+
"""Construit l'URL d'API pour une table donnée (avec le bon endpoint)."""
|
| 72 |
+
return f"{DATA_BASE_URL}table/{table_id}/"
|
| 73 |
+
|
| 74 |
+
def _baserow_record_to_user(record: Dict, is_end_user: bool) -> Dict:
|
| 75 |
+
"""
|
| 76 |
+
Convertit un enregistrement Baserow (avec noms de champs utilisateur)
|
| 77 |
+
en format de dictionnaire Python attendu par le backend.
|
| 78 |
+
"""
|
| 79 |
+
|
| 80 |
+
# Si c'est un utilisateur final, utilisez la logique existante pour l'utilisateur final.
|
| 81 |
+
if is_end_user:
|
| 82 |
+
user_data = {
|
| 83 |
+
# Champs communs / End Users
|
| 84 |
+
'baserow_row_id': record['id'],
|
| 85 |
+
# Note: J'utilise FIELD_DATE_CREATION mais vous pouvez définir FIELD_END_USER_DATE_CREATION si Baserow a un nom de colonne différent.
|
| 86 |
+
'date_creation': record.get(FIELD_DATE_CREATION),
|
| 87 |
+
# End User specific fields (Ajout des NOUVEAUX champs)
|
| 88 |
+
'end_user_id': record.get(FIELD_END_USER_ID),
|
| 89 |
+
'identifier': record.get(FIELD_END_USER_IDENTIFIER),
|
| 90 |
+
'email': record.get(FIELD_END_USER_EMAIL), # NOUVEAU
|
| 91 |
+
'username': record.get(FIELD_END_USER_USERNAME), # NOUVEAU
|
| 92 |
+
'security_question': record.get(FIELD_END_USER_SECURITY_Q), # NOUVEAU
|
| 93 |
+
'security_answer_hash': record.get(FIELD_END_USER_SECURITY_A_HASH), # NOUVEAU
|
| 94 |
+
'status': record.get(FIELD_END_USER_STATUS), # NOUVEAU
|
| 95 |
+
|
| 96 |
+
# CORRECTION CRUCIALE : Utilisation du nom de champ correct pour l'End User
|
| 97 |
+
'password_hash': record.get(FIELD_PASSWORD_HASH_END_USER),
|
| 98 |
+
|
| 99 |
+
# Le lien est un tableau, on extrait la valeur 'user_id' du client principal
|
| 100 |
+
'client_user_id': record.get(FIELD_CLIENT_ID_LINK)[0]['value'] if record.get(FIELD_CLIENT_ID_LINK) and record.get(FIELD_CLIENT_ID_LINK)[0]['value'] else None,
|
| 101 |
+
# Les métadonnées
|
| 102 |
+
'metadata': record.get(FIELD_END_USER_METADATA),
|
| 103 |
+
}
|
| 104 |
+
# Nettoyage des clés None ou non-pertinentes
|
| 105 |
+
return {k: v for k, v in user_data.items() if v is not None}
|
| 106 |
+
|
| 107 |
+
# LOGIQUE POUR L'UTILISATEUR PRINCIPAL (PRIMARY USER)
|
| 108 |
+
|
| 109 |
+
# 1. Récupération des champs individuels
|
| 110 |
+
user_data = {
|
| 111 |
+
# Champs communs / Primary Users
|
| 112 |
+
'baserow_row_id': record['id'], # ID interne de la ligne Baserow (pour les mises à jour)
|
| 113 |
+
'date_creation': record.get(FIELD_DATE_CREATION),
|
| 114 |
+
|
| 115 |
+
# Primary User specific fields
|
| 116 |
+
'user_id': record.get(FIELD_ID),
|
| 117 |
+
'email': record.get(FIELD_EMAIL),
|
| 118 |
+
'username': record.get(FIELD_USERNAME),
|
| 119 |
+
'password_hash': record.get(FIELD_PASSWORD_HASH),
|
| 120 |
+
|
| 121 |
+
# Récupération des 5 clés individuelles (pour l'authentification par clé)
|
| 122 |
+
'api_key': record.get(FIELD_API_KEY),
|
| 123 |
+
'api_key_2': record.get(FIELD_API_KEY_2),
|
| 124 |
+
'api_key_3': record.get(FIELD_API_KEY_3),
|
| 125 |
+
'api_key_4': record.get(FIELD_API_KEY_4),
|
| 126 |
+
'api_key_5': record.get(FIELD_API_KEY_5),
|
| 127 |
+
|
| 128 |
+
'security_question': record.get(FIELD_SECURITY_Q),
|
| 129 |
+
'security_answer_hash': record.get(FIELD_SECURITY_A_HASH),
|
| 130 |
+
'plan_id': record.get(FIELD_PLAN_ID),
|
| 131 |
+
'stripe_subscription_id': record.get(FIELD_STRIPE_SUB_ID),
|
| 132 |
+
'date_plan_start': record.get(FIELD_DATE_PLAN_START),
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
# 2. ÉTAPE CRUCIALE AJOUTÉE : Création de la liste 'api_keys' pour l'affichage
|
| 136 |
+
# Cette liste est nécessaire pour que la boucle dans api_key.html fonctionne correctement.
|
| 137 |
+
user_data['api_keys'] = [
|
| 138 |
+
user_data['api_key'],
|
| 139 |
+
user_data['api_key_2'],
|
| 140 |
+
user_data['api_key_3'],
|
| 141 |
+
user_data['api_key_4'],
|
| 142 |
+
user_data['api_key_5'],
|
| 143 |
+
]
|
| 144 |
+
|
| 145 |
+
# Nettoyage des clés None ou non-pertinentes
|
| 146 |
+
return {k: v for k, v in user_data.items() if v is not None}
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _user_to_baserow_data(user_data: Dict, is_end_user: bool) -> Dict:
|
| 150 |
+
"""
|
| 151 |
+
Convertit le format de dictionnaire Python du backend en format
|
| 152 |
+
JSON attendu par l'API Baserow (avec noms de champs utilisateur).
|
| 153 |
+
"""
|
| 154 |
+
if is_end_user:
|
| 155 |
+
# End User fields (Ajout des NOUVEAUX champs)
|
| 156 |
+
baserow_data = {
|
| 157 |
+
FIELD_END_USER_ID: user_data.get('end_user_id'),
|
| 158 |
+
FIELD_END_USER_IDENTIFIER: user_data.get('identifier'),
|
| 159 |
+
FIELD_END_USER_EMAIL: user_data.get('email'), # NOUVEAU
|
| 160 |
+
FIELD_END_USER_USERNAME: user_data.get('username'), # NOUVEAU
|
| 161 |
+
FIELD_END_USER_SECURITY_Q: user_data.get('security_question'), # NOUVEAU
|
| 162 |
+
FIELD_END_USER_SECURITY_A_HASH: user_data.get('security_answer_hash'), # NOUVEAU
|
| 163 |
+
FIELD_END_USER_STATUS: user_data.get('status'), # NOUVEAU
|
| 164 |
+
|
| 165 |
+
# CORRECTION CRUCIALE : Utilisation du nom de champ correct pour l'End User
|
| 166 |
+
FIELD_PASSWORD_HASH_END_USER: user_data.get('password_hash'),
|
| 167 |
+
|
| 168 |
+
FIELD_END_USER_METADATA: user_data.get('metadata'),
|
| 169 |
+
FIELD_DATE_CREATION: user_data.get('date_creation'),
|
| 170 |
+
# Le lien vers Primary_Users est géré dans save_end_user_data
|
| 171 |
+
}
|
| 172 |
+
else:
|
| 173 |
+
# Primary User fields
|
| 174 |
+
baserow_data = {
|
| 175 |
+
FIELD_ID: user_data.get('user_id'),
|
| 176 |
+
FIELD_EMAIL: user_data.get('email'),
|
| 177 |
+
FIELD_USERNAME: user_data.get('username'),
|
| 178 |
+
FIELD_PASSWORD_HASH: user_data.get('password_hash'),
|
| 179 |
+
FIELD_API_KEY: user_data.get('api_key'),
|
| 180 |
+
FIELD_API_KEY_2: user_data.get('api_key_2'),
|
| 181 |
+
FIELD_API_KEY_3: user_data.get('api_key_3'),
|
| 182 |
+
FIELD_API_KEY_4: user_data.get('api_key_4'),
|
| 183 |
+
FIELD_API_KEY_5: user_data.get('api_key_5'),
|
| 184 |
+
FIELD_SECURITY_Q: user_data.get('security_question'),
|
| 185 |
+
FIELD_SECURITY_A_HASH: user_data.get('security_answer_hash'),
|
| 186 |
+
FIELD_PLAN_ID: user_data.get('plan_id'),
|
| 187 |
+
FIELD_STRIPE_SUB_ID: user_data.get('stripe_subscription_id'),
|
| 188 |
+
FIELD_DATE_CREATION: user_data.get('date_creation'),
|
| 189 |
+
FIELD_DATE_PLAN_START: user_data.get('date_plan_start'),
|
| 190 |
+
FIELD_API_CALLS_MONTH: user_data.get('api_calls_month', 0),
|
| 191 |
+
FIELD_STATUS: user_data.get('status', 'Active') # Assurez-vous que 'Active' est une option valide dans Baserow
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
# Suppression des clés non-valorisées (None)
|
| 195 |
+
return {k: v for k, v in baserow_data.items() if v is not None}
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _get_single_user_record(table_id: str, field_name: str, value: str, is_end_user: bool) -> Optional[Dict]:
|
| 199 |
+
"""Fonction générique pour rechercher un seul enregistrement par un champ (filtrage Baserow)."""
|
| 200 |
+
url = _get_table_url(table_id)
|
| 201 |
+
# Utilisation du paramètre de filtre de Baserow pour une recherche indexée (plus rapide)
|
| 202 |
+
filter_param = f"filter__{field_name}__equal={value}"
|
| 203 |
+
|
| 204 |
+
try:
|
| 205 |
+
response = requests.get(
|
| 206 |
+
f"{url}?user_field_names=true&{filter_param}",
|
| 207 |
+
headers=HEADERS
|
| 208 |
+
)
|
| 209 |
+
response.raise_for_status()
|
| 210 |
+
|
| 211 |
+
data = response.json()
|
| 212 |
+
if data and data.get('results'):
|
| 213 |
+
# On ne prend que le premier résultat (car ID/Email/API Key sont uniques)
|
| 214 |
+
return _baserow_record_to_user(data['results'][0], is_end_user)
|
| 215 |
+
return None
|
| 216 |
+
|
| 217 |
+
except requests.exceptions.RequestException as e:
|
| 218 |
+
print(f"Erreur de Baserow lors de la recherche par filtre {field_name}: {e}", file=sys.stderr)
|
| 219 |
+
return None
|
| 220 |
+
|
| 221 |
+
# ----------------------------------------------------------------------
|
| 222 |
+
# --- Fonctions CRUD Primary_Users (Nouveau et Remplacement) ---
|
| 223 |
+
# ----------------------------------------------------------------------
|
| 224 |
+
|
| 225 |
+
def get_user_by_email(email: str) -> Optional[Dict]:
|
| 226 |
+
"""Recherche un utilisateur principal par son adresse Email."""
|
| 227 |
+
return _get_single_user_record(PRIMARY_USERS_TABLE_ID, FIELD_EMAIL, email, is_end_user=False)
|
| 228 |
+
|
| 229 |
+
def get_client_user_by_api_key(api_key: str) -> Optional[Dict]:
|
| 230 |
+
"""Recherche un utilisateur principal par sa Clé API."""
|
| 231 |
+
return _get_single_user_record(PRIMARY_USERS_TABLE_ID, FIELD_API_KEY, api_key, is_end_user=False)
|
| 232 |
+
|
| 233 |
+
# Remplacement de l'ancien load_primary_user_data(user_id)
|
| 234 |
+
def load_primary_user_data(user_id: str) -> Optional[Dict]:
|
| 235 |
+
"""Recherche un utilisateur principal par son ID (user_id)."""
|
| 236 |
+
return _get_single_user_record(PRIMARY_USERS_TABLE_ID, FIELD_ID, user_id, is_end_user=False)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def save_primary_user_data(user_data: Dict, commit_msg: str = "") -> bool:
|
| 240 |
+
"""Crée ou met à jour un utilisateur principal, avec détection d'erreur ultra-précise."""
|
| 241 |
+
row_id = user_data.get('baserow_row_id')
|
| 242 |
+
|
| 243 |
+
# Définition de l'URL de base pour la table des utilisateurs principaux
|
| 244 |
+
url = _get_table_url(PRIMARY_USERS_TABLE_ID)
|
| 245 |
+
|
| 246 |
+
# 1. Conversion des données
|
| 247 |
+
baserow_data = _user_to_baserow_data(user_data, is_end_user=False)
|
| 248 |
+
|
| 249 |
+
# 2. Suppression des champs en lecture seule (comme dans la correction précédente)
|
| 250 |
+
if baserow_data.pop(FIELD_ID, None):
|
| 251 |
+
print(f"DEBUG: Suppression du champ '{FIELD_ID}' (UUID auto) avant l'envoi { 'POST' if not row_id else 'PATCH'}.", file=sys.stderr)
|
| 252 |
+
|
| 253 |
+
try:
|
| 254 |
+
# Détermination de l'action (PATCH ou POST)
|
| 255 |
+
if row_id:
|
| 256 |
+
action = "PATCH" # ⬅️ CORRECTION: Définition de 'action'
|
| 257 |
+
# MISE À JOUR (PATCH)
|
| 258 |
+
response = requests.patch(
|
| 259 |
+
f"{url}{row_id}/?user_field_names=true",
|
| 260 |
+
headers=HEADERS,
|
| 261 |
+
json=baserow_data
|
| 262 |
+
)
|
| 263 |
+
else:
|
| 264 |
+
action = "POST" # ⬅️ CORRECTION: Définition de 'action'
|
| 265 |
+
# CRÉATION (POST)
|
| 266 |
+
response = requests.post(
|
| 267 |
+
f"{url}?user_field_names=true", # ⬅️ CORRECTION: Utilise l'URL de table 'url'
|
| 268 |
+
headers=HEADERS,
|
| 269 |
+
json=baserow_data
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
# Déclenche une exception requests.exceptions.HTTPError pour les statuts 4xx/5xx
|
| 273 |
+
response.raise_for_status()
|
| 274 |
+
|
| 275 |
+
# Succès
|
| 276 |
+
if not row_id:
|
| 277 |
+
new_record = response.json()
|
| 278 |
+
# 1. Mettre à jour l'ID de ligne Baserow
|
| 279 |
+
user_data['baserow_row_id'] = new_record.get('id')
|
| 280 |
+
# 2. Mettre à jour l'UUID de l'utilisateur (généré par Baserow)
|
| 281 |
+
user_data['user_id'] = new_record.get(FIELD_ID)
|
| 282 |
+
|
| 283 |
+
print(f"DEBUG: UUID de l'utilisateur généré par Baserow et enregistré: {user_data['user_id']}", file=sys.stderr)
|
| 284 |
+
|
| 285 |
+
print(f"DEBUG: Baserow Primary User action '{action}' réussie. Row ID: {user_data.get('baserow_row_id')}. Message: {commit_msg}", file=sys.stderr)
|
| 286 |
+
return True
|
| 287 |
+
|
| 288 |
+
except requests.exceptions.RequestException as e:
|
| 289 |
+
# --- BLOC DE DÉTECTION D'ERREUR PRÉCISE (Ultra-Complet) ---
|
| 290 |
+
|
| 291 |
+
# Note: 'action' est définie dans le bloc try/except, mais si l'erreur survient
|
| 292 |
+
# AVANT la définition de 'action', nous devons la gérer.
|
| 293 |
+
# Pour être sûr, nous allons la définir ici par défaut si elle n'existe pas.
|
| 294 |
+
if 'action' not in locals():
|
| 295 |
+
action = "INCONNU"
|
| 296 |
+
|
| 297 |
+
error_message = f"🚨 ÉCHEC: Erreur lors de la sauvegarde/mise à jour du Primary User dans Baserow. Requête: {action}"
|
| 298 |
+
error_details = ""
|
| 299 |
+
|
| 300 |
+
if hasattr(e, 'response') and e.response is not None:
|
| 301 |
+
# 1. Statut HTTP et URL
|
| 302 |
+
error_details += f"\n -> STATUT HTTP: {e.response.status_code} ({e.response.reason})"
|
| 303 |
+
error_details += f"\n -> URL de la requête: {e.response.url}"
|
| 304 |
+
|
| 305 |
+
# 2. Tenter de décoder le corps de la réponse en JSON (contient les erreurs Baserow)
|
| 306 |
+
try:
|
| 307 |
+
response_json = e.response.json()
|
| 308 |
+
error_details += f"\n\n -> ERREUR BASEROW DÉTAILLÉE (JSON):\n{json.dumps(response_json, indent=4)}"
|
| 309 |
+
|
| 310 |
+
# Optionnel: Synthèse des erreurs de validation de champ
|
| 311 |
+
if isinstance(response_json, dict):
|
| 312 |
+
validation_errors = {k: v for k, v in response_json.items() if isinstance(v, list) and k != 'detail'}
|
| 313 |
+
if validation_errors:
|
| 314 |
+
error_details += "\n -> SYNTHÈSE DES CHAMPS INVALIDES (Vérifiez les noms de colonnes/IDs de table!):"
|
| 315 |
+
for field_name, errors in validation_errors.items():
|
| 316 |
+
error_details += f"\n - Champ '{field_name}': {', '.join([err.get('error', 'Erreur inconnue') for err in errors])}"
|
| 317 |
+
|
| 318 |
+
except json.JSONDecodeError:
|
| 319 |
+
# 3. Si le corps de la réponse n'est pas du JSON
|
| 320 |
+
error_details += f"\n\n -> ERREUR BRUTE (Réponse non-JSON):\n{e.response.text[:500]}..."
|
| 321 |
+
|
| 322 |
+
# 4. Afficher les données que nous avons tenté d'envoyer (après la suppression de l'ID si c'était une création)
|
| 323 |
+
error_details += f"\n\n -> DONNÉES ENVOYÉES À BASEROW:\n{json.dumps(baserow_data, indent=4)}"
|
| 324 |
+
|
| 325 |
+
# Log complet de l'erreur
|
| 326 |
+
print(error_message + error_details, file=sys.stderr)
|
| 327 |
+
|
| 328 |
+
return False
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
# ----------------------------------------------------------------------
|
| 332 |
+
# --- Fonctions CRUD End_Users (Remplacement) ---
|
| 333 |
+
# ----------------------------------------------------------------------
|
| 334 |
+
|
| 335 |
+
# baserow_storage.py : Dans la section CRUD End_Users
|
| 336 |
+
|
| 337 |
+
def get_end_user_by_email(client_user_id: str, email: str) -> Optional[Dict]:
|
| 338 |
+
"""Recherche un utilisateur final par son Email, lié à un client principal (scope)."""
|
| 339 |
+
client_row_id = _get_client_baserow_row_id(client_user_id)
|
| 340 |
+
if not client_row_id:
|
| 341 |
+
print(f"Avertissement: Client Principal {client_user_id} introuvable pour la recherche de l'utilisateur final.", file=sys.stderr)
|
| 342 |
+
return None
|
| 343 |
+
|
| 344 |
+
url = _get_table_url(END_USERS_TABLE_ID)
|
| 345 |
+
|
| 346 |
+
# Filtre 1: Email de l'utilisateur final = email
|
| 347 |
+
# Filtre 2: Lien vers le Client Principal = client_row_id
|
| 348 |
+
filter_params = f"filter__{FIELD_END_USER_EMAIL}__equal={email}&filter__{FIELD_CLIENT_ID_LINK}__link_row_id={client_row_id}"
|
| 349 |
+
|
| 350 |
+
try:
|
| 351 |
+
response = requests.get(
|
| 352 |
+
f"{url}?user_field_names=true&{filter_params}",
|
| 353 |
+
headers=HEADERS
|
| 354 |
+
)
|
| 355 |
+
response.raise_for_status()
|
| 356 |
+
|
| 357 |
+
data = response.json()
|
| 358 |
+
if data and data.get('results'):
|
| 359 |
+
# On ne prend que le premier résultat
|
| 360 |
+
return _baserow_record_to_user(data['results'][0], is_end_user=True)
|
| 361 |
+
return None
|
| 362 |
+
|
| 363 |
+
except requests.exceptions.RequestException as e:
|
| 364 |
+
print(f"Erreur Baserow lors de la recherche End User par Email: {e}", file=sys.stderr)
|
| 365 |
+
return None
|
| 366 |
+
|
| 367 |
+
def _get_client_baserow_row_id(client_user_id: str) -> Optional[int]:
|
| 368 |
+
"""Récupère l'ID de ligne interne Baserow du client principal pour le lien."""
|
| 369 |
+
client_user = load_primary_user_data(client_user_id) # utilise la fonction déjà créée
|
| 370 |
+
return client_user.get('baserow_row_id') if client_user else None
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
# Remplacement de l'ancien load_end_user_data(client_user_id, end_user_id)
|
| 374 |
+
def load_end_user_data(client_user_id: str, end_user_id: str) -> Optional[Dict]:
|
| 375 |
+
"""Recherche un utilisateur final par ID, lié à un client principal (recherche indexée)."""
|
| 376 |
+
client_row_id = _get_client_baserow_row_id(client_user_id)
|
| 377 |
+
if not client_row_id:
|
| 378 |
+
return None
|
| 379 |
+
|
| 380 |
+
url = _get_table_url(END_USERS_TABLE_ID)
|
| 381 |
+
|
| 382 |
+
# Filtre 1: ID Utilisateur Final = end_user_id
|
| 383 |
+
# Filtre 2: ID Client Principal (Lien) = client_row_id (Lien vers une autre table)
|
| 384 |
+
filter_params = f"filter__{FIELD_END_USER_ID}__equal={end_user_id}&filter__{FIELD_CLIENT_ID_LINK}__link_row_id={client_row_id}"
|
| 385 |
+
|
| 386 |
+
try:
|
| 387 |
+
response = requests.get(
|
| 388 |
+
f"{url}?user_field_names=true&{filter_params}",
|
| 389 |
+
headers=HEADERS
|
| 390 |
+
)
|
| 391 |
+
response.raise_for_status()
|
| 392 |
+
|
| 393 |
+
data = response.json()
|
| 394 |
+
if data and data.get('results'):
|
| 395 |
+
return _baserow_record_to_user(data['results'][0], is_end_user=True)
|
| 396 |
+
return None
|
| 397 |
+
|
| 398 |
+
except requests.exceptions.RequestException as e:
|
| 399 |
+
print(f"Erreur Baserow lors de la recherche End User: {e}", file=sys.stderr)
|
| 400 |
+
return None
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
# Remplacement de l'ancien save_end_user_data(end_user_data)
|
| 404 |
+
def save_end_user_data(end_user_data: Dict, commit_msg: str = "") -> bool:
|
| 405 |
+
"""Crée ou met à jour un utilisateur final."""
|
| 406 |
+
row_id = end_user_data.get('baserow_row_id')
|
| 407 |
+
client_user_id = end_user_data.get('client_user_id')
|
| 408 |
+
|
| 409 |
+
# Étape cruciale: Trouver l'ID de ligne Baserow du client principal
|
| 410 |
+
client_row_id = _get_client_baserow_row_id(client_user_id)
|
| 411 |
+
if not client_row_id:
|
| 412 |
+
print(f"Erreur: Client Principal {client_user_id} introuvable pour la sauvegarde de l'utilisateur final.", file=sys.stderr)
|
| 413 |
+
return False
|
| 414 |
+
|
| 415 |
+
url = _get_table_url(END_USERS_TABLE_ID)
|
| 416 |
+
|
| 417 |
+
# 1. Conversion des données
|
| 418 |
+
baserow_data = _user_to_baserow_data(end_user_data, is_end_user=True)
|
| 419 |
+
|
| 420 |
+
# Ajout du lien vers le client principal (obligatoire pour la création/mise à jour)
|
| 421 |
+
# L'API Baserow pour les champs de type 'Lien vers une autre table' attend une liste d'ID de ligne.
|
| 422 |
+
baserow_data[FIELD_CLIENT_ID_LINK] = [client_row_id]
|
| 423 |
+
|
| 424 |
+
try:
|
| 425 |
+
if row_id:
|
| 426 |
+
# MISE À JOUR (PATCH)
|
| 427 |
+
response = requests.patch(
|
| 428 |
+
f"{url}{row_id}/?user_field_names=true",
|
| 429 |
+
headers=HEADERS,
|
| 430 |
+
json=baserow_data
|
| 431 |
+
)
|
| 432 |
+
else:
|
| 433 |
+
# CRÉATION (POST)
|
| 434 |
+
response = requests.post(
|
| 435 |
+
f"{url}?user_field_names=true",
|
| 436 |
+
headers=HEADERS,
|
| 437 |
+
json=baserow_data
|
| 438 |
+
)
|
| 439 |
+
|
| 440 |
+
response.raise_for_status()
|
| 441 |
+
|
| 442 |
+
if not row_id and response.status_code == 200:
|
| 443 |
+
new_record = response.json()
|
| 444 |
+
end_user_data['baserow_row_id'] = new_record.get('id')
|
| 445 |
+
|
| 446 |
+
print(f"DEBUG: Baserow End User action réussie. Row ID: {row_id or new_record.get('id')}. Message: {commit_msg}", file=sys.stderr)
|
| 447 |
+
return True
|
| 448 |
+
|
| 449 |
+
except requests.exceptions.RequestException as e:
|
| 450 |
+
print(f"Erreur lors de la sauvegarde/mise à jour du End User dans Baserow: {e}", file=sys.stderr)
|
| 451 |
+
return False
|
| 452 |
+
|
| 453 |
+
def check_baserow_connection() -> str:
|
| 454 |
+
"""
|
| 455 |
+
Vérifie l'état de connexion de la base de données Baserow.
|
| 456 |
+
Retourne 'operational' ou 'outage'.
|
| 457 |
+
"""
|
| 458 |
+
# Liste des IDs de tables critiques à vérifier
|
| 459 |
+
CRITICAL_TABLE_IDS = [
|
| 460 |
+
PRIMARY_USERS_TABLE_ID,
|
| 461 |
+
END_USERS_TABLE_ID
|
| 462 |
+
]
|
| 463 |
+
|
| 464 |
+
if not API_TOKEN:
|
| 465 |
+
# Si le token API n'est pas défini, échec immédiat
|
| 466 |
+
print("DEBUG: BASEROW_API_TOKEN manquant.", file=sys.stderr)
|
| 467 |
+
return "outage"
|
| 468 |
+
|
| 469 |
+
for table_id in CRITICAL_TABLE_IDS:
|
| 470 |
+
if not table_id:
|
| 471 |
+
# Si un des IDs de table critiques n'est pas défini, échec
|
| 472 |
+
print(f"DEBUG: Un ID de table critique Baserow est manquant (ID: {table_id}).", file=sys.stderr)
|
| 473 |
+
return "outage"
|
| 474 |
+
|
| 475 |
+
# Tenter de faire un appel très léger (récupérer la première ligne)
|
| 476 |
+
# On utilise page_size=1 pour minimiser la charge
|
| 477 |
+
url = f"{DATA_BASE_URL}table/{table_id}/?page_size=1"
|
| 478 |
+
|
| 479 |
+
try:
|
| 480 |
+
response = requests.get(url, headers=HEADERS, timeout=5)
|
| 481 |
+
|
| 482 |
+
if response.status_code != 200:
|
| 483 |
+
# Si un 404, 403, ou autre erreur est retournée par Baserow pour CETTE table
|
| 484 |
+
print(f"DEBUG: Baserow check failed for table {table_id} with status code {response.status_code}", file=sys.stderr)
|
| 485 |
+
return "outage"
|
| 486 |
+
|
| 487 |
+
except requests.exceptions.RequestException as e:
|
| 488 |
+
# Erreur de réseau (timeout, DNS, etc.)
|
| 489 |
+
print(f"DEBUG: Baserow connection error for table {table_id}: {e}", file=sys.stderr)
|
| 490 |
+
return "outage"
|
| 491 |
+
|
| 492 |
+
# Si toutes les tables critiques ont été vérifiées avec succès
|
| 493 |
+
return "operational"
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
def get_health_status() -> Dict:
|
| 497 |
+
"""
|
| 498 |
+
Collecte l'état de santé de tous les services pour la page /statut.
|
| 499 |
+
"""
|
| 500 |
+
|
| 501 |
+
db_status = check_baserow_connection()
|
| 502 |
+
|
| 503 |
+
# L'état de l'authentification et de l'API principale sont
|
| 504 |
+
# généralement liés à l'état de la DB pour une application simple.
|
| 505 |
+
# Si la DB est HS, l'auth est HS. Sinon, ils sont OK.
|
| 506 |
+
|
| 507 |
+
auth_status = db_status # Lié à la DB (pour charger les utilisateurs)
|
| 508 |
+
api_endpoint_status = "operational" # L'endpoint Flask lui-même est considéré comme OK s'il tourne
|
| 509 |
+
|
| 510 |
+
# Version du service (pour information)
|
| 511 |
+
service_version = os.environ.get("SERVICE_VERSION", "1.0.0 (Baserow)")
|
| 512 |
+
|
| 513 |
+
|
| 514 |
+
return {
|
| 515 |
+
# Ces valeurs correspondent aux attributs 'data-status' dans statut.html
|
| 516 |
+
"auth": auth_status,
|
| 517 |
+
"data_storage": db_status,
|
| 518 |
+
"api_endpoint": api_endpoint_status,
|
| 519 |
+
"version": service_version,
|
| 520 |
+
"last_update": datetime.now().strftime("%Y-%m-%d %H:%M:%S UTC")
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
def is_baserow_up() -> bool:
|
| 524 |
+
"""
|
| 525 |
+
Vérifie l'état de Baserow en utilisant l'URL qui garantit un statut 100% fonctionnel
|
| 526 |
+
sur Hugging Face, SANS utiliser la fonction de construction d'URL de table.
|
| 527 |
+
"""
|
| 528 |
+
try:
|
| 529 |
+
# Envoie une requête GET à l'URL qui répond positivement pour le health check.
|
| 530 |
+
response = requests.get(
|
| 531 |
+
HEALTH_CHECK_URL,
|
| 532 |
+
headers=HEADERS,
|
| 533 |
+
timeout=5
|
| 534 |
+
)
|
| 535 |
+
# On vérifie si la réponse est un succès (code 200).
|
| 536 |
+
return response.status_code == 200
|
| 537 |
+
except requests.exceptions.RequestException as e:
|
| 538 |
+
print(f"DEBUG: Baserow health check failed: {e}")
|
| 539 |
+
return False
|
| 540 |
+
|
| 541 |
+
def log_baserow_api_call(method: str, url: str, headers: Dict, data: Optional[Dict] = None, log_response: bool = True):
|
| 542 |
+
"""
|
| 543 |
+
Fonction utilitaire pour effectuer des appels API à Baserow et journaliser
|
| 544 |
+
les requêtes et les réponses dans les logs du Space Hugging Face.
|
| 545 |
+
"""
|
| 546 |
+
|
| 547 |
+
# 1. Journalisation de la requête
|
| 548 |
+
logger.info(f"BASEROW REQUEST: {method} {url}")
|
| 549 |
+
# ATTENTION: Ne pas logger le token API complet!
|
| 550 |
+
logged_headers = {k: v.replace(API_TOKEN, '[TOKEN_MASKED]') if k == 'Authorization' else v for k, v in headers.items()}
|
| 551 |
+
logger.debug(f"BASEROW REQUEST Headers: {logged_headers}")
|
| 552 |
+
if data:
|
| 553 |
+
# Pour les requêtes POST/PUT, logger les données (sans le hash du mot de passe si possible)
|
| 554 |
+
logged_data = data.copy() if isinstance(data, dict) else data
|
| 555 |
+
if isinstance(logged_data, dict) and 'Hachage du mot de passe' in logged_data:
|
| 556 |
+
logged_data['Hachage du mot de passe'] = '[PASSWORD_HASH_MASKED]'
|
| 557 |
+
logger.debug(f"BASEROW REQUEST Body: {logged_data}")
|
| 558 |
+
|
| 559 |
+
# 2. Exécution de la requête
|
| 560 |
+
try:
|
| 561 |
+
if method == "GET":
|
| 562 |
+
response = requests.get(url, headers=headers)
|
| 563 |
+
elif method == "POST":
|
| 564 |
+
response = requests.post(url, headers=headers, json=data)
|
| 565 |
+
elif method == "PUT":
|
| 566 |
+
response = requests.put(url, headers=headers, json=data)
|
| 567 |
+
elif method == "DELETE":
|
| 568 |
+
response = requests.delete(url, headers=headers)
|
| 569 |
+
else:
|
| 570 |
+
raise ValueError(f"Méthode HTTP non supportée: {method}")
|
| 571 |
+
|
| 572 |
+
# 3. Journalisation de la réponse
|
| 573 |
+
if log_response:
|
| 574 |
+
logger.info(f"BASEROW RESPONSE: Status {response.status_code}")
|
| 575 |
+
# Journaliser le contenu pour les erreurs
|
| 576 |
+
if response.status_code >= 400:
|
| 577 |
+
logger.error(f"BASEROW ERROR RESPONSE Body: {response.text}")
|
| 578 |
+
|
| 579 |
+
# 4. Retourner la réponse
|
| 580 |
+
return response
|
| 581 |
+
|
| 582 |
+
except requests.exceptions.RequestException as e:
|
| 583 |
+
logger.error(f"BASEROW CONNECTION ERROR: {e}")
|
| 584 |
+
return None # Retourne None en cas d'erreur de connexion non gérée par le statut HTTP
|
billing_routes.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# billing_routes.py
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import stripe
|
| 5 |
+
import json
|
| 6 |
+
from flask import Blueprint, request, jsonify, Response, session, current_app, url_for
|
| 7 |
+
from decorators import login_required
|
| 8 |
+
from auth_backend import get_user_by_id, get_plan_details, update_user_plan
|
| 9 |
+
import traceback
|
| 10 |
+
|
| 11 |
+
# Initialisation de Stripe avec la clé secrète
|
| 12 |
+
# La clé sera lue depuis les variables d'environnement (secrets HF)
|
| 13 |
+
stripe.api_key = os.environ.get("STRIPE_SECRET_KEY")
|
| 14 |
+
|
| 15 |
+
# Création du Blueprint 'billing_bp'
|
| 16 |
+
billing_bp = Blueprint('billing_bp', __name__)
|
| 17 |
+
|
| 18 |
+
# --- Route API pour créer la session de paiement (Phase 3) ---
|
| 19 |
+
@billing_bp.route("/api/create-checkout-session", methods=["POST"])
|
| 20 |
+
@login_required
|
| 21 |
+
def create_checkout_session():
|
| 22 |
+
"""
|
| 23 |
+
Crée une session de checkout Stripe pour un plan donné.
|
| 24 |
+
"""
|
| 25 |
+
data = request.get_json()
|
| 26 |
+
# Le plan_id doit être l'ID complet (ex: 'standard_monthly' ou 'illimited_annual')
|
| 27 |
+
final_plan_id = data.get('plan_id')
|
| 28 |
+
user_id = session.get('user_id')
|
| 29 |
+
|
| 30 |
+
plan_details = get_plan_details(final_plan_id)
|
| 31 |
+
|
| 32 |
+
# Détermination de l'ID de prix Stripe (utilise monthly ou annual selon ce qui est défini)
|
| 33 |
+
price_id = plan_details.get('price_id_monthly') or plan_details.get('price_id_annual')
|
| 34 |
+
|
| 35 |
+
# Vérification de l'existence du plan et de l'ID de prix Stripe
|
| 36 |
+
if not plan_details or not price_id:
|
| 37 |
+
if final_plan_id == 'free':
|
| 38 |
+
return jsonify({"message": "Ce plan est gratuit, pas de session de paiement requise.", "url": url_for('user_bp.dashboard')}), 200
|
| 39 |
+
|
| 40 |
+
# Logique de sécurité/erreur si l'ID de prix est manquant pour un plan payant
|
| 41 |
+
current_app.logger.error(f"Erreur: ID de prix Stripe manquant pour le plan {final_plan_id}.")
|
| 42 |
+
return jsonify({"message": "Erreur de configuration du plan.", "status": "Error"}), 500
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
# Création de la session de paiement Stripe
|
| 46 |
+
checkout_session = stripe.checkout.Session.create(
|
| 47 |
+
# Type de paiement pour les abonnements récurrents
|
| 48 |
+
mode='subscription',
|
| 49 |
+
# Les IDs de prix Stripe
|
| 50 |
+
line_items=[
|
| 51 |
+
{
|
| 52 |
+
'price': price_id,
|
| 53 |
+
'quantity': 1
|
| 54 |
+
}
|
| 55 |
+
],
|
| 56 |
+
# URLs de redirection après paiement/annulation
|
| 57 |
+
# _external=True est crucial pour que Stripe puisse rediriger correctement
|
| 58 |
+
success_url=url_for('user_bp.dashboard', payment='success', _external=True),
|
| 59 |
+
cancel_url=url_for('web_bp.checkout', plan=final_plan_id, payment='cancel', _external=True),
|
| 60 |
+
|
| 61 |
+
# Informations personnalisées CRITIQUES pour le Webhook
|
| 62 |
+
metadata={
|
| 63 |
+
'user_id': user_id,
|
| 64 |
+
'plan_id': final_plan_id, # L'ID de plan complet (e.g. 'standard_monthly')
|
| 65 |
+
},
|
| 66 |
+
# Laisse Stripe pré-remplir l'email du client (si on le souhaite)
|
| 67 |
+
# customer_email=get_user_by_id(user_id).get('email'),
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# Retourne l'URL de la session Stripe au frontend
|
| 71 |
+
return jsonify({'url': checkout_session.url}), 200
|
| 72 |
+
|
| 73 |
+
except stripe.error.StripeError as e:
|
| 74 |
+
current_app.logger.error(f"Erreur Stripe lors de la création de session: {e}")
|
| 75 |
+
return jsonify({"message": f"Une erreur Stripe est survenue: {e.user_message}", "status": "Error"}), 400
|
| 76 |
+
except Exception as e:
|
| 77 |
+
# Log toutes les autres erreurs pour le diagnostic
|
| 78 |
+
current_app.logger.error(f"Erreur inattendue lors de la création de session: {e}\n{traceback.format_exc()}")
|
| 79 |
+
return jsonify({"message": "Erreur interne du serveur lors de la création de la session.", "status": "Error"}), 500
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# --- Route Webhook Stripe (Phase 4) ---
|
| 83 |
+
@billing_bp.route('/webhook/stripe', methods=['POST'])
|
| 84 |
+
def stripe_webhook():
|
| 85 |
+
"""
|
| 86 |
+
Gère les événements envoyés par Stripe pour mettre à jour l'abonnement de l'utilisateur.
|
| 87 |
+
"""
|
| 88 |
+
payload = request.data
|
| 89 |
+
sig_header = request.headers.get('stripe-signature')
|
| 90 |
+
endpoint_secret = os.environ.get("STRIPE_WEBHOOK_SECRET")
|
| 91 |
+
|
| 92 |
+
# 1. Vérification que le secret est bien configuré
|
| 93 |
+
if not endpoint_secret:
|
| 94 |
+
current_app.logger.error("Erreur de configuration: STRIPE_WEBHOOK_SECRET est manquant.")
|
| 95 |
+
return jsonify({'message': 'Erreur de configuration serveur.'}), 500
|
| 96 |
+
|
| 97 |
+
try:
|
| 98 |
+
# 2. Validation de la signature du Webhook
|
| 99 |
+
event = stripe.Webhook.construct_event(
|
| 100 |
+
payload, sig_header, endpoint_secret
|
| 101 |
+
)
|
| 102 |
+
except Exception as e:
|
| 103 |
+
current_app.logger.error(f"Erreur Webhook Stripe (Validation): {e}")
|
| 104 |
+
# Retourne 400 pour que Stripe sache qu'il ne doit pas retenter cet événement
|
| 105 |
+
return 'Invalid payload or signature', 400
|
| 106 |
+
|
| 107 |
+
# 3. Traitement des événements
|
| 108 |
+
if event['type'] == 'checkout.session.completed':
|
| 109 |
+
session_data = event['data']['object']
|
| 110 |
+
|
| 111 |
+
# Récupération des métadonnées CRITIQUES
|
| 112 |
+
user_id = session_data.get('metadata', {}).get('user_id')
|
| 113 |
+
plan_id = session_data.get('metadata', {}).get('plan_id') # L'ID de plan complet (e.g. 'standard_monthly')
|
| 114 |
+
subscription_id = session_data.get('subscription') # L'ID d'abonnement Stripe
|
| 115 |
+
|
| 116 |
+
if user_id and plan_id and subscription_id:
|
| 117 |
+
# Appel à la fonction de mise à jour de la base de données (Phase 4)
|
| 118 |
+
success = update_user_plan(user_id, plan_id, subscription_id)
|
| 119 |
+
|
| 120 |
+
if success:
|
| 121 |
+
current_app.logger.info(f"SUCCESS: Utilisateur {user_id} mis à jour au plan {plan_id} (Sub ID: {subscription_id})")
|
| 122 |
+
else:
|
| 123 |
+
# Le paiement a eu lieu, mais la BDD n'a pas été mise à jour: CRITIQUE
|
| 124 |
+
current_app.logger.error(f"FAILURE: Échec de la mise à jour Git pour l'utilisateur {user_id} après paiement Stripe. Plan: {plan_id}")
|
| 125 |
+
else:
|
| 126 |
+
# Manque d'infos critiques dans le webhook
|
| 127 |
+
current_app.logger.error(f"FAILURE: Données critiques manquantes dans le webhook. Session ID: {session_data.get('id')}. User ID: {user_id}. Plan ID: {plan_id}")
|
| 128 |
+
# Retourne 200 pour éviter une boucle de ré-envoi par Stripe
|
| 129 |
+
return jsonify({'message': 'Données utilisateur critiques manquantes dans la session Stripe.'}), 200
|
| 130 |
+
|
| 131 |
+
# Vous pouvez ajouter d'autres événements si nécessaire (ex: 'customer.subscription.deleted')
|
| 132 |
+
# elif event['type'] == 'customer.subscription.deleted':
|
| 133 |
+
# current_app.logger.info(f"INFO: Abonnement Stripe supprimé. ID de souscription: {event['data']['object'].get('id')}")
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# Retourne une réponse pour accuser réception de l'événement (très important)
|
| 137 |
+
return jsonify({'status': 'success'}), 200
|
config.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# config.py
|
| 2 |
+
|
| 3 |
+
# Fichier de configuration pour le backend ErnestMind 2.5
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
# --- Configuration Baserow (Nouveau) ---
|
| 7 |
+
# Clés à définir dans les secrets d'environnement Hugging Face Space
|
| 8 |
+
BASEROW_DATABASE_ID = os.environ.get("BASEROW_DATABASE_ID")
|
| 9 |
+
PRIMARY_USERS_TABLE_ID = os.environ.get("PRIMARY_USERS_TABLE_ID")
|
| 10 |
+
END_USERS_TABLE_ID = os.environ.get("END_USERS_TABLE_ID")
|
| 11 |
+
# Le token d'accès API
|
| 12 |
+
BASEROW_API_TOKEN = os.environ.get("BASEROW_API_TOKEN")
|
| 13 |
+
|
| 14 |
+
# --- Configuration Stripe (Paiement) ---
|
| 15 |
+
# Clés à définir dans les secrets d'environnement Hugging Face Space
|
| 16 |
+
STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY")
|
| 17 |
+
STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET")
|
| 18 |
+
# Clé Publique (utilisée par le Frontend, mais stockée ici pour référence)
|
| 19 |
+
STRIPE_PUBLIC_KEY = os.environ.get("STRIPE_PUBLIC_KEY")
|
| 20 |
+
|
| 21 |
+
# --- Dictionnaire de Prix Central (Phase 1) ---
|
| 22 |
+
# Contient toutes les informations nécessaires pour le Frontend et le Backend.
|
| 23 |
+
# Les 'price_id' doivent correspondre aux IDs créés dans Stripe.
|
| 24 |
+
PLANS_CONFIG = {
|
| 25 |
+
# Plan Gratuit
|
| 26 |
+
"free": {
|
| 27 |
+
"title": "Gratuit",
|
| 28 |
+
"baserow_value": "Free", # <--- NOUVEAU: Mettre la valeur EXACTE attendue par Baserow (ex: GRATUIT, Free, etc.)
|
| 29 |
+
"description": "Idéal pour les tests et les petits projets.",
|
| 30 |
+
"limit": 500,
|
| 31 |
+
"price_monthly": 0.0,
|
| 32 |
+
"price_annual": 0.0,
|
| 33 |
+
"price_id_monthly": None,
|
| 34 |
+
"price_id_annual": None,
|
| 35 |
+
"currency": "EUR"
|
| 36 |
+
},
|
| 37 |
+
# Plan Standard - Mensuel
|
| 38 |
+
"standard_monthly": {
|
| 39 |
+
"title": "Standard (Mensuel)",
|
| 40 |
+
"description": "Pour les utilisateurs réguliers. Paiement mensuel.",
|
| 41 |
+
"limit": 1000,
|
| 42 |
+
"price_monthly": 19.99,
|
| 43 |
+
"price_annual": 0.0,
|
| 44 |
+
"price_id_monthly": "price_1OvXXXXXXX", # REMPLACER PAR VOTRE VRAI ID STRIPE
|
| 45 |
+
"price_id_annual": None,
|
| 46 |
+
"currency": "EUR"
|
| 47 |
+
},
|
| 48 |
+
# Plan Standard - Annuel
|
| 49 |
+
"standard_annual": {
|
| 50 |
+
"title": "Standard (Annuel)",
|
| 51 |
+
"description": "Pour les utilisateurs réguliers. Économisez 20% en payant à l'année.",
|
| 52 |
+
"limit": 1000,
|
| 53 |
+
"price_monthly": 0.0,
|
| 54 |
+
"price_annual": 199.90,
|
| 55 |
+
"price_id_monthly": None,
|
| 56 |
+
"price_id_annual": "price_1OwYYYYYYY", # REMPLACER PAR VOTRE VRAI ID STRIPE
|
| 57 |
+
"currency": "EUR"
|
| 58 |
+
},
|
| 59 |
+
# Plan Pro - Mensuel
|
| 60 |
+
"pro_monthly": {
|
| 61 |
+
"title": "Pro (Mensuel)",
|
| 62 |
+
"description": "Pour les professionnels et les projets importants. Paiement mensuel.",
|
| 63 |
+
"limit": 2000,
|
| 64 |
+
"price_monthly": 49.99,
|
| 65 |
+
"price_annual": 0.0,
|
| 66 |
+
"price_id_monthly": "price_1OxZZZZZZZ", # REMPLACER PAR VOTRE VRAI ID STRIPE
|
| 67 |
+
"price_id_annual": None,
|
| 68 |
+
"currency": "EUR"
|
| 69 |
+
},
|
| 70 |
+
# Plan Pro - Annuel
|
| 71 |
+
"pro_annual": {
|
| 72 |
+
"title": "Pro (Annuel)",
|
| 73 |
+
"description": "Pour les professionnels et les projets importants. Économisez 20% en payant à l'année.",
|
| 74 |
+
"limit": 2000,
|
| 75 |
+
"price_monthly": 0.0,
|
| 76 |
+
"price_annual": 499.90,
|
| 77 |
+
"price_id_monthly": None,
|
| 78 |
+
"price_id_annual": "price_1OyAAAAAAA", # REMPLACER PAR VOTRE VRAI ID STRIPE
|
| 79 |
+
"currency": "EUR"
|
| 80 |
+
},
|
| 81 |
+
# Plan Illimité - Mensuel
|
| 82 |
+
"illimited_monthly": {
|
| 83 |
+
"title": "Illimité (Mensuel)",
|
| 84 |
+
"description": "Sans aucune restriction, pour les grandes entreprises. Paiement mensuel.",
|
| 85 |
+
"limit": float('inf'),
|
| 86 |
+
"price_monthly": 99.99,
|
| 87 |
+
"price_annual": 0.0,
|
| 88 |
+
"price_id_monthly": "price_1OzBBBBBBB", # REMPLACER PAR VOTRE VRAI ID STRIPE
|
| 89 |
+
"price_id_annual": None,
|
| 90 |
+
"currency": "EUR"
|
| 91 |
+
},
|
| 92 |
+
# Plan Illimité - Annuel
|
| 93 |
+
"illimited_annual": {
|
| 94 |
+
"title": "Illimité (Annuel)",
|
| 95 |
+
"description": "Sans aucune restriction, pour les grandes entreprises. Économisez 20% en payant à l'année.",
|
| 96 |
+
"limit": float('inf'),
|
| 97 |
+
"price_monthly": 0.0,
|
| 98 |
+
"price_annual": 999.90,
|
| 99 |
+
"price_id_monthly": None,
|
| 100 |
+
"price_id_annual": "price_1OzAACCCCC", # REMPLACER PAR VOTRE VRAI ID STRIPE
|
| 101 |
+
"currency": "EUR"
|
| 102 |
+
},
|
| 103 |
+
# Plan Spécial pour les End-users (pas listé sur la page de prix)
|
| 104 |
+
"end_user": {
|
| 105 |
+
"title": "End-User",
|
| 106 |
+
"description": "Compte utilisateur créé par un client.",
|
| 107 |
+
"limit": 0,
|
| 108 |
+
"price_monthly": 0.0,
|
| 109 |
+
"price_annual": 0.0,
|
| 110 |
+
"price_id_monthly": None,
|
| 111 |
+
"price_id_annual": None,
|
| 112 |
+
"currency": "EUR"
|
| 113 |
+
},
|
| 114 |
+
}
|
decorators.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# decorators.py
|
| 2 |
+
|
| 3 |
+
from functools import wraps
|
| 4 |
+
from flask import session, redirect, url_for, flash, request, jsonify # <-- AJOUT de request, jsonify
|
| 5 |
+
|
| 6 |
+
# Import nécessaire pour le décorateur API (Importation paresseuse pour éviter les problèmes d'importation circulaire)
|
| 7 |
+
import auth_backend
|
| 8 |
+
|
| 9 |
+
def login_required(f):
|
| 10 |
+
"""
|
| 11 |
+
Décorateur pour les routes nécessitant une connexion (Web UI).
|
| 12 |
+
"""
|
| 13 |
+
@wraps(f)
|
| 14 |
+
def decorated_function(*args, **kwargs):
|
| 15 |
+
# Vérifie si l'ID utilisateur est dans la session
|
| 16 |
+
if session.get('user_id') is None:
|
| 17 |
+
flash("Vous devez être connecté pour accéder à cette page.", "error")
|
| 18 |
+
# Rediriger vers la route de connexion (qui est dans user_bp)
|
| 19 |
+
return redirect(url_for('user_bp.connexion'))
|
| 20 |
+
return f(*args, **kwargs)
|
| 21 |
+
return decorated_function
|
| 22 |
+
|
| 23 |
+
def api_key_required(f):
|
| 24 |
+
"""
|
| 25 |
+
Décorateur pour les routes d'API nécessitant une clé API valide.
|
| 26 |
+
Récupère la clé API de l'en-tête 'X-API-Key' ou du paramètre de requête 'api_key'.
|
| 27 |
+
Injecte les données de l'utilisateur principal (client) dans la fonction décorée.
|
| 28 |
+
"""
|
| 29 |
+
@wraps(f)
|
| 30 |
+
def decorated_function(*args, **kwargs):
|
| 31 |
+
# 1. Récupérer la clé depuis l'en-tête ou les paramètres de requête
|
| 32 |
+
api_key = request.headers.get('X-API-Key')
|
| 33 |
+
if not api_key:
|
| 34 |
+
api_key = request.args.get('api_key')
|
| 35 |
+
|
| 36 |
+
if not api_key:
|
| 37 |
+
return jsonify({
|
| 38 |
+
"message": "Authentification requise. Clé API manquante dans l'en-tête X-API-Key ou le paramètre api_key.",
|
| 39 |
+
"status": "Unauthorized"
|
| 40 |
+
}), 401
|
| 41 |
+
|
| 42 |
+
# 2. Valider la clé et récupérer l'utilisateur principal
|
| 43 |
+
# NOTE: Utilise l'import paresseux 'auth_backend.get_client_user_by_api_key'
|
| 44 |
+
client_user = auth_backend.get_client_user_by_api_key(api_key)
|
| 45 |
+
|
| 46 |
+
if not client_user:
|
| 47 |
+
return jsonify({
|
| 48 |
+
"message": "Clé API invalide ou non reconnue.",
|
| 49 |
+
"status": "Forbidden"
|
| 50 |
+
}), 403
|
| 51 |
+
|
| 52 |
+
# 3. Injecter les données de l'utilisateur principal (le client)
|
| 53 |
+
kwargs['client_user'] = client_user
|
| 54 |
+
|
| 55 |
+
return f(*args, **kwargs)
|
| 56 |
+
|
| 57 |
+
return decorated_function
|
requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# requirements.txt
|
| 2 |
+
flask
|
| 3 |
+
bcrypt
|
| 4 |
+
requests
|
| 5 |
+
gunicorn
|
| 6 |
+
Flask-CORS
|
| 7 |
+
python-dotenv
|
| 8 |
+
stripe
|
| 9 |
+
dnspython
|
| 10 |
+
werkzeug
|
user_routes.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# user_routes.py
|
| 2 |
+
|
| 3 |
+
from flask import Blueprint, render_template, request, redirect, url_for, session, flash, jsonify
|
| 4 |
+
from auth_backend import (
|
| 5 |
+
register_user,
|
| 6 |
+
login_user,
|
| 7 |
+
reset_password_via_security_question,
|
| 8 |
+
get_user_by_id,
|
| 9 |
+
update_user_profile,
|
| 10 |
+
)
|
| 11 |
+
from decorators import login_required
|
| 12 |
+
|
| 13 |
+
# Création du Blueprint 'user_bp'
|
| 14 |
+
user_bp = Blueprint('user_bp', __name__)
|
| 15 |
+
|
| 16 |
+
@user_bp.route("/inscription", methods=['GET', 'POST'])
|
| 17 |
+
def inscription():
|
| 18 |
+
if request.method == 'POST':
|
| 19 |
+
# Traitement du formulaire d'inscription
|
| 20 |
+
username = request.form.get("username")
|
| 21 |
+
email = request.form.get("email")
|
| 22 |
+
password = request.form.get("password")
|
| 23 |
+
confirm_password = request.form.get("confirm_password")
|
| 24 |
+
security_question = request.form.get("security_question")
|
| 25 |
+
security_answer = request.form.get("security_answer")
|
| 26 |
+
|
| 27 |
+
# CORRECTION ICI: Déballage des 3 valeurs. On utilise '_' pour la troisième (new_user_data)
|
| 28 |
+
user_id, message, _ = register_user(username, email, password, confirm_password, security_question, security_answer)
|
| 29 |
+
|
| 30 |
+
if user_id:
|
| 31 |
+
flash(message, "success")
|
| 32 |
+
# Rediriger vers la page de connexion après l'inscription
|
| 33 |
+
return redirect(url_for('user_bp.connexion'))
|
| 34 |
+
else:
|
| 35 |
+
flash(message, "error")
|
| 36 |
+
return render_template("inscription.html",
|
| 37 |
+
username=username,
|
| 38 |
+
email=email,
|
| 39 |
+
security_question=security_question,
|
| 40 |
+
security_answer=security_answer)
|
| 41 |
+
|
| 42 |
+
return render_template("inscription.html")
|
| 43 |
+
|
| 44 |
+
@user_bp.route("/connexion")
|
| 45 |
+
def connexion():
|
| 46 |
+
return render_template("connexion.html")
|
| 47 |
+
|
| 48 |
+
@user_bp.route("/deconnexion")
|
| 49 |
+
@login_required
|
| 50 |
+
def deconnexion():
|
| 51 |
+
# Déconnexion en vidant la session
|
| 52 |
+
session.pop('user_id', None)
|
| 53 |
+
flash("Vous êtes déconnecté avec succès.", "success")
|
| 54 |
+
# Redirection vers la page d'accueil ou de connexion
|
| 55 |
+
return redirect(url_for('web_bp.index'))
|
| 56 |
+
|
| 57 |
+
@user_bp.route("/mot-de-passe-oublie", methods=['GET', 'POST'])
|
| 58 |
+
def mot_de_passe_oublie():
|
| 59 |
+
if request.method == 'POST':
|
| 60 |
+
username_or_email = request.form.get("username_or_email")
|
| 61 |
+
security_answer = request.form.get("security_answer")
|
| 62 |
+
new_password = request.form.get("new_password")
|
| 63 |
+
confirm_password = request.form.get("confirm_password")
|
| 64 |
+
|
| 65 |
+
# Validation rapide
|
| 66 |
+
if new_password != confirm_password:
|
| 67 |
+
flash("Les nouveaux mots de passe ne correspondent pas.", "error")
|
| 68 |
+
return render_template("mot_de_passe_oublie.html", username_or_email=username_or_email)
|
| 69 |
+
|
| 70 |
+
success, message = reset_password_via_security_question(username_or_email, security_answer, new_password)
|
| 71 |
+
|
| 72 |
+
if success:
|
| 73 |
+
flash(message, "success")
|
| 74 |
+
return redirect(url_for('user_bp.connexion'))
|
| 75 |
+
else:
|
| 76 |
+
flash(message, "error")
|
| 77 |
+
return render_template("mot_de_passe_oublie.html", username_or_email=username_or_email)
|
| 78 |
+
|
| 79 |
+
return render_template("mot_de_passe_oublie.html")
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# --- Routes du Dashboard ---
|
| 83 |
+
|
| 84 |
+
@user_bp.route("/dashboard")
|
| 85 |
+
@login_required
|
| 86 |
+
def dashboard():
|
| 87 |
+
"""
|
| 88 |
+
Page du tableau de bord. Gère le message de succès de paiement (Phase 4).
|
| 89 |
+
"""
|
| 90 |
+
user = get_user_by_id(session.get('user_id'))
|
| 91 |
+
|
| 92 |
+
# Logique pour le succès de paiement
|
| 93 |
+
payment_status = request.args.get('payment')
|
| 94 |
+
|
| 95 |
+
if payment_status == 'success':
|
| 96 |
+
# On flashe un message pour l'afficher via Jinja dans le dashboard.html
|
| 97 |
+
flash("Félicitations ! Votre abonnement a été activé avec succès.", "success")
|
| 98 |
+
# Redirection pour nettoyer l'URL du paramètre de paiement
|
| 99 |
+
return redirect(url_for('user_bp.dashboard'))
|
| 100 |
+
|
| 101 |
+
return render_template("dashboard.html", user=user)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@user_bp.route("/profile", methods=['GET', 'POST']) # <--- AJOUTER 'POST'
|
| 105 |
+
@login_required
|
| 106 |
+
def profile():
|
| 107 |
+
user_id = session.get('user_id')
|
| 108 |
+
|
| 109 |
+
if request.method == 'POST':
|
| 110 |
+
# 1. Récupération des données du formulaire POST
|
| 111 |
+
username = request.form.get('username')
|
| 112 |
+
email = request.form.get('email')
|
| 113 |
+
new_password = request.form.get('new_password')
|
| 114 |
+
confirm_password = request.form.get('confirm_password')
|
| 115 |
+
|
| 116 |
+
# 2. Validation simple côté serveur
|
| 117 |
+
if not username or not email:
|
| 118 |
+
flash("Le nom d'utilisateur et l'e-mail sont obligatoires.", "error")
|
| 119 |
+
elif new_password and new_password != confirm_password:
|
| 120 |
+
flash("Le nouveau mot de passe et la confirmation ne correspondent pas.", "error")
|
| 121 |
+
else:
|
| 122 |
+
# 3. Appel de la fonction de mise à jour du backend
|
| 123 |
+
# Note : on passe None si le mot de passe n'est pas fourni/n'est pas validé.
|
| 124 |
+
password_to_update = new_password if new_password and new_password == confirm_password else None
|
| 125 |
+
|
| 126 |
+
success, message = update_user_profile(user_id, username, email, password_to_update)
|
| 127 |
+
|
| 128 |
+
if success:
|
| 129 |
+
flash(message, "success")
|
| 130 |
+
# On pourrait aussi rediriger vers la page /profile GET pour rafraîchir la vue
|
| 131 |
+
# et nettoyer l'URL.
|
| 132 |
+
return redirect(url_for('user_bp.profile'))
|
| 133 |
+
else:
|
| 134 |
+
# Échec de la mise à jour (ex: email déjà pris, mot de passe trop court)
|
| 135 |
+
flash(message, "error")
|
| 136 |
+
|
| 137 |
+
# Logique pour la méthode GET ou après un échec POST
|
| 138 |
+
# On récupère toujours l'utilisateur pour l'affichage (s'assure des données les plus fraîches)
|
| 139 |
+
user = get_user_by_id(user_id)
|
| 140 |
+
|
| 141 |
+
if user is None:
|
| 142 |
+
# Erreur critique de session
|
| 143 |
+
flash("Erreur de session. Veuillez vous reconnecter.", "error")
|
| 144 |
+
session.pop('user_id', None)
|
| 145 |
+
return redirect(url_for('user_bp.connexion'))
|
| 146 |
+
|
| 147 |
+
return render_template("profile.html", user=user)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
@user_bp.route("/api-key", methods=['GET'])
|
| 151 |
+
@login_required
|
| 152 |
+
def api_key():
|
| 153 |
+
"""
|
| 154 |
+
Affiche la page de gestion des clés API.
|
| 155 |
+
L'objet 'user' (retourné par get_user_by_id) contient maintenant la liste 'api_keys' (5 éléments).
|
| 156 |
+
"""
|
| 157 |
+
user_id = session.get('user_id') # Récupération de l'ID depuis la session
|
| 158 |
+
user = get_user_by_id(user_id) # Appel à la fonction qui pourrait retourner None
|
| 159 |
+
|
| 160 |
+
# --- CORRECTION DE L'ERREUR DÉTECTÉE DANS LES LOGS ---
|
| 161 |
+
if user is None:
|
| 162 |
+
# Si l'utilisateur n'est pas trouvé malgré le login_required (session corrompue)
|
| 163 |
+
flash("Erreur critique de session. Veuillez vous reconnecter.", "error")
|
| 164 |
+
# Forcer la déconnexion en vidant la session et rediriger vers la page de connexion
|
| 165 |
+
session.pop('user_id', None)
|
| 166 |
+
# Si vous utilisez un logger, il faudrait logger cette erreur pour investigation
|
| 167 |
+
# current_app.logger.error(f"FAILURE: User ID {user_id} in session but not found in DB.")
|
| 168 |
+
return redirect(url_for('user_bp.connexion'))
|
| 169 |
+
# ----------------------------------------------------
|
| 170 |
+
|
| 171 |
+
return render_template("api_key.html", user=user)
|
web_routes.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# web_routes.py
|
| 2 |
+
|
| 3 |
+
from flask import Blueprint, render_template, request, redirect, url_for
|
| 4 |
+
from auth_backend import get_plan_details # Import nécessaire pour la route /checkout
|
| 5 |
+
from baserow_storage import get_health_status
|
| 6 |
+
|
| 7 |
+
# Création du Blueprint 'web_bp'
|
| 8 |
+
web_bp = Blueprint('web_bp', __name__)
|
| 9 |
+
|
| 10 |
+
@web_bp.route("/")
|
| 11 |
+
def index():
|
| 12 |
+
"""Page d'accueil."""
|
| 13 |
+
return render_template("index.html")
|
| 14 |
+
|
| 15 |
+
@web_bp.route("/a-propos")
|
| 16 |
+
def a_propos():
|
| 17 |
+
"""Page À Propos."""
|
| 18 |
+
return render_template("a_propos.html")
|
| 19 |
+
|
| 20 |
+
@web_bp.route("/documentation")
|
| 21 |
+
def documentation():
|
| 22 |
+
"""Page Documentation."""
|
| 23 |
+
return render_template("documentation.html")
|
| 24 |
+
|
| 25 |
+
@web_bp.route("/tarifs")
|
| 26 |
+
def tarifs():
|
| 27 |
+
"""Page Tarifs."""
|
| 28 |
+
return render_template("tarifs.html")
|
| 29 |
+
|
| 30 |
+
@web_bp.route("/checkout")
|
| 31 |
+
def checkout():
|
| 32 |
+
"""
|
| 33 |
+
Page de paiement. Récupère le plan ID de l'URL pour l'afficher.
|
| 34 |
+
"""
|
| 35 |
+
# Récupère 'plan' du paramètre d'URL /checkout?plan=...
|
| 36 |
+
plan_id = request.args.get('plan')
|
| 37 |
+
plan_details = get_plan_details(plan_id)
|
| 38 |
+
|
| 39 |
+
# Si le plan n'existe pas, ou si le plan est 'free', rediriger vers la page des tarifs
|
| 40 |
+
if not plan_details or plan_id == 'free':
|
| 41 |
+
return redirect(url_for('web_bp.tarifs'))
|
| 42 |
+
|
| 43 |
+
return render_template("checkout.html", plan_id=plan_id, plan=plan_details)
|
| 44 |
+
|
| 45 |
+
@web_bp.route("/support")
|
| 46 |
+
def support():
|
| 47 |
+
"""Page Support."""
|
| 48 |
+
return render_template("support.html")
|
| 49 |
+
|
| 50 |
+
@web_bp.route("/mentions-legales")
|
| 51 |
+
def mentions_legales():
|
| 52 |
+
"""Page Mentions Légales."""
|
| 53 |
+
return render_template("mentions_legales.html")
|
| 54 |
+
|
| 55 |
+
@web_bp.route("/politique-confidentialite")
|
| 56 |
+
def politique_confidentialite():
|
| 57 |
+
"""Page Politique de Confidentialité."""
|
| 58 |
+
return render_template("politique_confidentialite.html")
|
| 59 |
+
|
| 60 |
+
@web_bp.route("/conditions-utilisation")
|
| 61 |
+
def conditions_utilisation():
|
| 62 |
+
"""Page Conditions d'Utilisation."""
|
| 63 |
+
return render_template("conditions_utilisation.html")
|
| 64 |
+
|
| 65 |
+
@web_bp.route("/api_logs")
|
| 66 |
+
def api_logs():
|
| 67 |
+
"""Page Support."""
|
| 68 |
+
return render_template("api_logs.html")
|
| 69 |
+
|
| 70 |
+
@web_bp.route("/statut")
|
| 71 |
+
def statut():
|
| 72 |
+
"""
|
| 73 |
+
Page pour afficher l'état/la santé de l'API en utilisant
|
| 74 |
+
la fonction de vérification d'état.
|
| 75 |
+
"""
|
| 76 |
+
# 1. Appeler la fonction de vérification d'état
|
| 77 |
+
api_status_data = get_health_status()
|
| 78 |
+
|
| 79 |
+
# 2. Passer la variable 'api_status' au template
|
| 80 |
+
return render_template("statut.html", api_status=api_status_data)
|