ernestmindres commited on
Commit
e0a103e
·
verified ·
1 Parent(s): c6c123c

Upload 10 files

Browse files
Files changed (10) hide show
  1. Dockerfile +57 -0
  2. app.py +486 -0
  3. auth_backend.py +359 -0
  4. baserow_storage.py +584 -0
  5. billing_routes.py +137 -0
  6. config.py +114 -0
  7. decorators.py +57 -0
  8. requirements.txt +10 -0
  9. user_routes.py +171 -0
  10. 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)