Fix 10 bugs found in general code review

Backend (app.py / database.py / ai_agent.py):
- [Critique] Autoclose loop: add pg_try_advisory_lock so only one Gunicorn
  worker runs the check per 60s cycle; add random startup jitter
- [Critique] admin_delete_idea: pass consultation_id to
  _update_synthesis_background so the right synthesis is regenerated
- [Majeur] admin_login: return HMAC-signed session token instead of raw
  ADMIN_SECRET; require_admin verifies the signature (TTL 8h)
- [Majeur] bulk_delete: replace str.isdigit() (Unicode-unsafe) with
  try/except int() to prevent crash on Unicode digit characters
- [Majeur] create_consultation: force UTC timezone on naive datetime from
  fromisoformat() to prevent TypeError when comparing with UTC-aware now()
- [Majeur] ai_agent.py: fix 'raw' in dir() -> 'raw' in locals() so the
  JSON parse error log actually shows the raw response
- [Mineur] export print: use datetime.now(UTC) instead of datetime.now()

Frontend (React):
- [Majeur] consultation.tsx: show startsAt (not endsAt) for upcoming
  consultations; add startsAt variable
- [Majeur] consultations-list.tsx: same fix for the list view
- [Mineur] home.tsx: guard new Date(idea.createdAt) against null
- [Mineur] admin.tsx: check HTTP status in exportCsv XHR before creating
  download link; show error toast on non-200

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 16:26:52 +02:00
parent 18bb0efc30
commit 9553115d8c
7 changed files with 92 additions and 19 deletions
+77 -11
View File
@@ -43,7 +43,7 @@ from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from database import (
init_db, insert_idea, get_accepted_ideas, get_stats, upsert_synthesis,
init_db, get_connection, insert_idea, get_accepted_ideas, get_stats, upsert_synthesis,
get_synthesis, get_all_ideas, get_ideas_admin, delete_idea, bulk_delete_ideas,
override_idea, flag_idea, unflag_idea,
create_consent, get_public_contributions, get_public_stats,
@@ -102,10 +102,16 @@ init_db()
# ─── Boucle de fermeture automatique des consultations ───────────────────────
def _autoclose_consultations_loop() -> None:
"""Vérifie toutes les 60 s les consultations expirées et les ferme automatiquement."""
while True:
time.sleep(60)
def _try_autoclose() -> None:
"""Tente d'acquérir un verrou consultatif PostgreSQL pour éviter que plusieurs workers
Gunicorn exécutent le check simultanément. Le verrou est relâché à la fin de l'appel."""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("SELECT pg_try_advisory_lock(743209897)")
conn.commit()
if not cur.fetchone()[0]:
return # Un autre worker gère déjà ce cycle
try:
expired = get_consultations_to_autoclose()
for c in expired:
@@ -120,8 +126,26 @@ def _autoclose_consultations_loop() -> None:
args=(closed, synthesis),
daemon=True,
).start()
finally:
with conn.cursor() as cur:
cur.execute("SELECT pg_advisory_unlock(743209897)")
conn.commit()
except Exception:
logger.exception("Erreur dans la boucle de fermeture automatique des consultations")
finally:
try:
conn.close()
except Exception:
logger.exception("Erreur dans la boucle de fermeture automatique des consultations")
pass
def _autoclose_consultations_loop() -> None:
"""Vérifie toutes les 60 s les consultations expirées et les ferme automatiquement."""
import random as _random
time.sleep(_random.uniform(0, 15)) # Décale le démarrage de chaque worker
while True:
time.sleep(60)
_try_autoclose()
threading.Thread(target=_autoclose_consultations_loop, daemon=True).start()
@@ -196,7 +220,7 @@ def require_admin(f):
if not secret:
return jsonify({"error": "admin_not_configured", "message": "ADMIN_SECRET non configuré."}), 503
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer ") or auth[7:] != secret:
if not auth.startswith("Bearer ") or not _verify_admin_token(auth[7:], secret):
return jsonify({"error": "unauthorized", "message": "Accès non autorisé."}), 401
return f(*args, **kwargs)
return decorated
@@ -251,6 +275,28 @@ def _verify_consent_token(token: str, secret: str) -> bool:
return False
def _sign_admin_token(secret: str) -> str:
"""Émet un token de session admin signé HMAC-SHA256 (TTL 8h)."""
ts = int(time.time())
msg = f"admin:{ts}".encode()
sig = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()[:16]
return f"{ts}.{sig}"
def _verify_admin_token(token: str, secret: str, max_age: int = 28800) -> bool:
"""Vérifie la signature et la durée de validité (défaut 8h) d'un token admin."""
try:
ts_str, sig = token.rsplit(".", 1)
ts = int(ts_str)
msg = f"admin:{ts}".encode()
expected = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()[:16]
if not hmac.compare_digest(sig, expected):
return False
return (time.time() - ts) < max_age
except Exception:
return False
def _verify_hcaptcha(token: str) -> bool:
"""
Vérifie un token hCaptcha.
@@ -817,7 +863,7 @@ def export_consultation_print(slug: str):
subject_esc = html_module.escape(consultation["subject"])
organizer_esc = html_module.escape(consultation.get("organizer_name") or "")
synthesis_esc = html_module.escape(synthesis_text).replace("\n", "<br>")
now_str = dt.datetime.now().strftime("%d/%m/%Y %H:%M")
now_str = dt.datetime.now(dt.timezone.utc).strftime("%d/%m/%Y %H:%M")
html_content = f"""<!DOCTYPE html>
<html lang="fr">
@@ -874,7 +920,7 @@ def admin_login():
logger.warning("Tentative de connexion admin échouée")
return jsonify({"error": "unauthorized", "message": "Mot de passe incorrect."}), 401
logger.info("Connexion admin réussie")
return jsonify({"ok": True, "token": secret})
return jsonify({"ok": True, "token": _sign_admin_token(secret)})
@app.get("/api/admin/stats")
@@ -915,7 +961,12 @@ def admin_delete_idea(idea_id: int):
deleted = delete_idea(idea_id)
if not deleted:
return jsonify({"error": "not_found", "message": "Contribution introuvable."}), 404
threading.Thread(target=_update_synthesis_background, daemon=True).start()
consultation_id = deleted.get("consultation_id")
threading.Thread(
target=_update_synthesis_background,
kwargs={"consultation_id": consultation_id},
daemon=True,
).start()
logger.info("Admin — contribution #%d supprimée", idea_id)
return jsonify({"ok": True, "synthesisUpdating": True})
@@ -928,7 +979,18 @@ def admin_bulk_delete():
ids = data.get("ids", [])
if not isinstance(ids, list) or not ids:
return jsonify({"error": "validation_error", "message": "ids doit être une liste non vide."}), 400
ids = [int(i) for i in ids if isinstance(i, (int, str)) and str(i).isdigit()]
safe_ids = []
for i in ids:
if isinstance(i, int) and i > 0:
safe_ids.append(i)
elif isinstance(i, str):
try:
n = int(i)
if n > 0:
safe_ids.append(n)
except ValueError:
pass
ids = safe_ids
count = bulk_delete_ideas(ids)
if count > 0:
threading.Thread(target=_update_synthesis_background, daemon=True).start()
@@ -1048,8 +1110,12 @@ def admin_create_consultation():
try:
starts_at_str = data.get("startsAt", "")
starts_at = dt.datetime.fromisoformat(starts_at_str) if starts_at_str else dt.datetime.now(dt.timezone.utc)
if starts_at.tzinfo is None:
starts_at = starts_at.replace(tzinfo=dt.timezone.utc)
ends_at_str = data.get("endsAt", "")
ends_at = dt.datetime.fromisoformat(ends_at_str) if ends_at_str else None
if ends_at is not None and ends_at.tzinfo is None:
ends_at = ends_at.replace(tzinfo=dt.timezone.utc)
except (ValueError, TypeError):
return jsonify({"error": "validation_error", "message": "Format de date invalide (ISO 8601 requis)."}), 400