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:
@@ -84,7 +84,7 @@ def filter_idea(content: str) -> dict:
|
||||
result = json.loads(raw)
|
||||
return result
|
||||
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"}
|
||||
except BadRequestError as e:
|
||||
if "content_filter" in str(e) or "content management policy" in str(e):
|
||||
|
||||
+77
-11
@@ -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
|
||||
|
||||
|
||||
@@ -193,10 +193,12 @@ def get_ideas_admin(
|
||||
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:
|
||||
cur.execute("DELETE FROM ideas WHERE id = %s RETURNING id", (idea_id,))
|
||||
return cur.fetchone() is not None
|
||||
cur.execute("DELETE FROM ideas WHERE id = %s RETURNING id, consultation_id", (idea_id,))
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def bulk_delete_ideas(idea_ids: list[int]) -> int:
|
||||
|
||||
@@ -658,6 +658,10 @@ export default function Admin() {
|
||||
req.setRequestHeader("Authorization", `Bearer ${token}`);
|
||||
req.responseType = "blob";
|
||||
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");
|
||||
a.href = URL.createObjectURL(req.response);
|
||||
a.setAttribute("download", "contributions.csv");
|
||||
|
||||
@@ -235,6 +235,7 @@ export default function ConsultationPage() {
|
||||
const isOpen = consultation.isOpen;
|
||||
const isClosed = !!consultation.closedAt;
|
||||
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;
|
||||
|
||||
return (
|
||||
@@ -299,8 +300,8 @@ export default function ConsultationPage() {
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3 w-3" />
|
||||
{isOpen
|
||||
? `Fermeture ${formatDistanceToNow(endsAt, { locale: fr, addSuffix: true })}`
|
||||
: `Ouverture ${formatDistanceToNow(endsAt, { locale: fr, addSuffix: true })}`}
|
||||
? `Fermeture ${formatDistanceToNow(endsAt!, { locale: fr, addSuffix: true })}`
|
||||
: `Ouverture ${formatDistanceToNow(startsAt ?? endsAt!, { locale: fr, addSuffix: true })}`}
|
||||
{" · "}
|
||||
{format(endsAt, "d MMMM yyyy à HH:mm", { locale: fr })} UTC
|
||||
</span>
|
||||
|
||||
@@ -115,7 +115,7 @@ export default function ConsultationsList() {
|
||||
<Clock className="h-3 w-3" />
|
||||
{c.isOpen
|
||||
? `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>
|
||||
)}
|
||||
{c.closedAt && (
|
||||
|
||||
@@ -484,7 +484,7 @@ export default function Home() {
|
||||
</span>
|
||||
<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>
|
||||
<button
|
||||
onClick={() => handleFlag(idea.id)}
|
||||
|
||||
Reference in New Issue
Block a user