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
+1 -1
View File
@@ -84,7 +84,7 @@ def filter_idea(content: str) -> dict:
result = json.loads(raw) result = json.loads(raw)
return result return result
except json.JSONDecodeError: except json.JSONDecodeError:
logger.warning("Impossible de parser la réponse JSON du filtre, raw=%r", raw if 'raw' in dir() else 'N/A') logger.warning("Impossible de parser la réponse JSON du filtre, raw=%r", raw if 'raw' in locals() else 'N/A')
return {"accepted": False, "reason": "Erreur interne de filtrage"} return {"accepted": False, "reason": "Erreur interne de filtrage"}
except BadRequestError as e: except BadRequestError as e:
if "content_filter" in str(e) or "content management policy" in str(e): if "content_filter" in str(e) or "content management policy" in str(e):
+76 -10
View File
@@ -43,7 +43,7 @@ from flask_limiter import Limiter
from flask_limiter.util import get_remote_address from flask_limiter.util import get_remote_address
from database import ( 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, get_synthesis, get_all_ideas, get_ideas_admin, delete_idea, bulk_delete_ideas,
override_idea, flag_idea, unflag_idea, override_idea, flag_idea, unflag_idea,
create_consent, get_public_contributions, get_public_stats, create_consent, get_public_contributions, get_public_stats,
@@ -102,10 +102,16 @@ init_db()
# ─── Boucle de fermeture automatique des consultations ─────────────────────── # ─── Boucle de fermeture automatique des consultations ───────────────────────
def _autoclose_consultations_loop() -> None: def _try_autoclose() -> None:
"""Vérifie toutes les 60 s les consultations expirées et les ferme automatiquement.""" """Tente d'acquérir un verrou consultatif PostgreSQL pour éviter que plusieurs workers
while True: Gunicorn exécutent le check simultanément. Le verrou est relâché à la fin de l'appel."""
time.sleep(60) 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: try:
expired = get_consultations_to_autoclose() expired = get_consultations_to_autoclose()
for c in expired: for c in expired:
@@ -120,8 +126,26 @@ def _autoclose_consultations_loop() -> None:
args=(closed, synthesis), args=(closed, synthesis),
daemon=True, daemon=True,
).start() ).start()
finally:
with conn.cursor() as cur:
cur.execute("SELECT pg_advisory_unlock(743209897)")
conn.commit()
except Exception: except Exception:
logger.exception("Erreur dans la boucle de fermeture automatique des consultations") logger.exception("Erreur dans la boucle de fermeture automatique des consultations")
finally:
try:
conn.close()
except Exception:
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() threading.Thread(target=_autoclose_consultations_loop, daemon=True).start()
@@ -196,7 +220,7 @@ def require_admin(f):
if not secret: if not secret:
return jsonify({"error": "admin_not_configured", "message": "ADMIN_SECRET non configuré."}), 503 return jsonify({"error": "admin_not_configured", "message": "ADMIN_SECRET non configuré."}), 503
auth = request.headers.get("Authorization", "") 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 jsonify({"error": "unauthorized", "message": "Accès non autorisé."}), 401
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated return decorated
@@ -251,6 +275,28 @@ def _verify_consent_token(token: str, secret: str) -> bool:
return False 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: def _verify_hcaptcha(token: str) -> bool:
""" """
Vérifie un token hCaptcha. Vérifie un token hCaptcha.
@@ -817,7 +863,7 @@ def export_consultation_print(slug: str):
subject_esc = html_module.escape(consultation["subject"]) subject_esc = html_module.escape(consultation["subject"])
organizer_esc = html_module.escape(consultation.get("organizer_name") or "") organizer_esc = html_module.escape(consultation.get("organizer_name") or "")
synthesis_esc = html_module.escape(synthesis_text).replace("\n", "<br>") 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_content = f"""<!DOCTYPE html>
<html lang="fr"> <html lang="fr">
@@ -874,7 +920,7 @@ def admin_login():
logger.warning("Tentative de connexion admin échouée") logger.warning("Tentative de connexion admin échouée")
return jsonify({"error": "unauthorized", "message": "Mot de passe incorrect."}), 401 return jsonify({"error": "unauthorized", "message": "Mot de passe incorrect."}), 401
logger.info("Connexion admin réussie") 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") @app.get("/api/admin/stats")
@@ -915,7 +961,12 @@ def admin_delete_idea(idea_id: int):
deleted = delete_idea(idea_id) deleted = delete_idea(idea_id)
if not deleted: if not deleted:
return jsonify({"error": "not_found", "message": "Contribution introuvable."}), 404 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) logger.info("Admin — contribution #%d supprimée", idea_id)
return jsonify({"ok": True, "synthesisUpdating": True}) return jsonify({"ok": True, "synthesisUpdating": True})
@@ -928,7 +979,18 @@ def admin_bulk_delete():
ids = data.get("ids", []) ids = data.get("ids", [])
if not isinstance(ids, list) or not ids: if not isinstance(ids, list) or not ids:
return jsonify({"error": "validation_error", "message": "ids doit être une liste non vide."}), 400 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) count = bulk_delete_ideas(ids)
if count > 0: if count > 0:
threading.Thread(target=_update_synthesis_background, daemon=True).start() threading.Thread(target=_update_synthesis_background, daemon=True).start()
@@ -1048,8 +1110,12 @@ def admin_create_consultation():
try: try:
starts_at_str = data.get("startsAt", "") 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) 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_str = data.get("endsAt", "")
ends_at = dt.datetime.fromisoformat(ends_at_str) if ends_at_str else None 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): except (ValueError, TypeError):
return jsonify({"error": "validation_error", "message": "Format de date invalide (ISO 8601 requis)."}), 400 return jsonify({"error": "validation_error", "message": "Format de date invalide (ISO 8601 requis)."}), 400
+5 -3
View File
@@ -193,10 +193,12 @@ def get_ideas_admin(
return rows, total return rows, total
def delete_idea(idea_id: int) -> bool: def delete_idea(idea_id: int) -> dict | None:
"""Supprime une idée et retourne son enregistrement (avec consultation_id) ou None si absente."""
with db_cursor() as cur: with db_cursor() as cur:
cur.execute("DELETE FROM ideas WHERE id = %s RETURNING id", (idea_id,)) cur.execute("DELETE FROM ideas WHERE id = %s RETURNING id, consultation_id", (idea_id,))
return cur.fetchone() is not None row = cur.fetchone()
return dict(row) if row else None
def bulk_delete_ideas(idea_ids: list[int]) -> int: def bulk_delete_ideas(idea_ids: list[int]) -> int:
@@ -658,6 +658,10 @@ export default function Admin() {
req.setRequestHeader("Authorization", `Bearer ${token}`); req.setRequestHeader("Authorization", `Bearer ${token}`);
req.responseType = "blob"; req.responseType = "blob";
req.onload = () => { req.onload = () => {
if (req.status !== 200) {
toast({ title: "Erreur export", description: `Erreur ${req.status} lors de l'export CSV.`, variant: "destructive" });
return;
}
const a = document.createElement("a"); const a = document.createElement("a");
a.href = URL.createObjectURL(req.response); a.href = URL.createObjectURL(req.response);
a.setAttribute("download", "contributions.csv"); a.setAttribute("download", "contributions.csv");
@@ -235,6 +235,7 @@ export default function ConsultationPage() {
const isOpen = consultation.isOpen; const isOpen = consultation.isOpen;
const isClosed = !!consultation.closedAt; const isClosed = !!consultation.closedAt;
const endsAt = consultation.endsAt ? new Date(consultation.endsAt) : null; const endsAt = consultation.endsAt ? new Date(consultation.endsAt) : null;
const startsAt = consultation.startsAt ? new Date(consultation.startsAt) : null;
const closedAt = consultation.closedAt ? new Date(consultation.closedAt) : null; const closedAt = consultation.closedAt ? new Date(consultation.closedAt) : null;
return ( return (
@@ -299,8 +300,8 @@ export default function ConsultationPage() {
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3" />
{isOpen {isOpen
? `Fermeture ${formatDistanceToNow(endsAt, { locale: fr, addSuffix: true })}` ? `Fermeture ${formatDistanceToNow(endsAt!, { locale: fr, addSuffix: true })}`
: `Ouverture ${formatDistanceToNow(endsAt, { locale: fr, addSuffix: true })}`} : `Ouverture ${formatDistanceToNow(startsAt ?? endsAt!, { locale: fr, addSuffix: true })}`}
{" · "} {" · "}
{format(endsAt, "d MMMM yyyy à HH:mm", { locale: fr })} UTC {format(endsAt, "d MMMM yyyy à HH:mm", { locale: fr })} UTC
</span> </span>
@@ -115,7 +115,7 @@ export default function ConsultationsList() {
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3" />
{c.isOpen {c.isOpen
? `Ferme ${formatDistanceToNow(new Date(c.endsAt), { locale: fr, addSuffix: true })}` ? `Ferme ${formatDistanceToNow(new Date(c.endsAt), { locale: fr, addSuffix: true })}`
: `Ouvre ${formatDistanceToNow(new Date(c.endsAt), { locale: fr, addSuffix: true })}`} : `Ouvre ${formatDistanceToNow(new Date(c.startsAt ?? c.endsAt), { locale: fr, addSuffix: true })}`}
</span> </span>
)} )}
{c.closedAt && ( {c.closedAt && (
+1 -1
View File
@@ -484,7 +484,7 @@ export default function Home() {
</span> </span>
<span>&bull;</span> <span>&bull;</span>
<span> <span>
{format(new Date(idea.createdAt), "d MMM, HH:mm", { locale: fr })} {idea.createdAt ? format(new Date(idea.createdAt), "d MMM, HH:mm", { locale: fr }) : "—"}
</span> </span>
<button <button
onClick={() => handleFlag(idea.id)} onClick={() => handleFlag(idea.id)}