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)
|
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):
|
||||||
|
|||||||
+77
-11
@@ -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:
|
||||||
|
logger.exception("Erreur dans la boucle de fermeture automatique des consultations")
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
except Exception:
|
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()
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -484,7 +484,7 @@ export default function Home() {
|
|||||||
</span>
|
</span>
|
||||||
<span>•</span>
|
<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>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleFlag(idea.id)}
|
onClick={() => handleFlag(idea.id)}
|
||||||
|
|||||||
Reference in New Issue
Block a user