diff --git a/artifacts/flask-api/ai_agent.py b/artifacts/flask-api/ai_agent.py
index c1efa17..35abeba 100644
--- a/artifacts/flask-api/ai_agent.py
+++ b/artifacts/flask-api/ai_agent.py
@@ -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):
diff --git a/artifacts/flask-api/app.py b/artifacts/flask-api/app.py
index 3841894..6715cb9 100644
--- a/artifacts/flask-api/app.py
+++ b/artifacts/flask-api/app.py
@@ -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", "
")
- 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"""
@@ -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
diff --git a/artifacts/flask-api/database.py b/artifacts/flask-api/database.py
index cf61490..1a4c548 100644
--- a/artifacts/flask-api/database.py
+++ b/artifacts/flask-api/database.py
@@ -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:
diff --git a/artifacts/voix-du-peuple/src/pages/admin.tsx b/artifacts/voix-du-peuple/src/pages/admin.tsx
index dab9224..c84188d 100644
--- a/artifacts/voix-du-peuple/src/pages/admin.tsx
+++ b/artifacts/voix-du-peuple/src/pages/admin.tsx
@@ -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");
diff --git a/artifacts/voix-du-peuple/src/pages/consultation.tsx b/artifacts/voix-du-peuple/src/pages/consultation.tsx
index 594cf26..ba52147 100644
--- a/artifacts/voix-du-peuple/src/pages/consultation.tsx
+++ b/artifacts/voix-du-peuple/src/pages/consultation.tsx
@@ -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() {
{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
diff --git a/artifacts/voix-du-peuple/src/pages/consultations-list.tsx b/artifacts/voix-du-peuple/src/pages/consultations-list.tsx
index b05ac78..405ae23 100644
--- a/artifacts/voix-du-peuple/src/pages/consultations-list.tsx
+++ b/artifacts/voix-du-peuple/src/pages/consultations-list.tsx
@@ -115,7 +115,7 @@ export default function ConsultationsList() {
{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 })}`}
)}
{c.closedAt && (
diff --git a/artifacts/voix-du-peuple/src/pages/home.tsx b/artifacts/voix-du-peuple/src/pages/home.tsx
index 13fbc0a..5c707ec 100644
--- a/artifacts/voix-du-peuple/src/pages/home.tsx
+++ b/artifacts/voix-du-peuple/src/pages/home.tsx
@@ -484,7 +484,7 @@ export default function Home() {
•
- {format(new Date(idea.createdAt), "d MMM, HH:mm", { locale: fr })}
+ {idea.createdAt ? format(new Date(idea.createdAt), "d MMM, HH:mm", { locale: fr }) : "—"}