Conformité RGPD (P3) + transparence éditoriale (P4)

P3 — RGPD :
- Table `consents` + `POST /api/consent` (art. 7.1 — preuve du consentement)
- Dialogue de consentement explicite avant la première contribution (art. 9.2.a)
- Pages `/mentions-legales` et `/politique-confidentialite`
- `docs/RGPD.md` — registre des traitements, bases légales, sous-traitants
- `getVisitorId()` exporté depuis l'API client React

P4 — Transparence éditoriale :
- Page `/contributions-brutes` avec pagination et export JSON/CSV
- `GET /api/contributions`, `GET /api/contributions/export/{json,csv}`
- `GET /api/stats/public` — stats publiques sans données de rejet
- Label de transparence IA sur la colonne de synthèse
- Compteurs (acceptées / soumises) dans le bandeau d'intro
- `docs/PROMPTS_IA.md` — prompts intégraux publiés + analyse des biais
- Pied de page avec liens légaux et transparence

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 22:30:30 +02:00
parent 45edc1fa77
commit a7b7684e87
12 changed files with 1354 additions and 50 deletions
+98
View File
@@ -44,6 +44,7 @@ from database import (
init_db, 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,
)
from ai_agent import filter_idea, synthesize_ideas
@@ -422,6 +423,103 @@ def get_synthesis_route():
})
# ─── Routes publiques : consentement, stats, contributions ───────────────────
@app.post("/api/consent")
@limiter.limit("5 per minute")
def record_consent():
"""Enregistre le consentement explicite d'un citoyen (art. 9.2.a RGPD)."""
if not request.is_json:
return jsonify({"error": "bad_request", "message": "Content-Type doit être application/json."}), 400
data = request.get_json(silent=True) or {}
consent_version = sanitize_text(str(data.get("consent_version", "1.0") or "1.0"))[:20]
raw_fp = request.headers.get("X-Visitor-Id", "").strip()
fingerprint_hash = (
hashlib.sha256(raw_fp.encode()).hexdigest()[:32] if raw_fp else "anonymous"
)
create_consent(fingerprint_hash, consent_version)
logger.info("Consentement enregistré — fingerprint: %s... | version: %s", fingerprint_hash[:8], consent_version)
return jsonify({"ok": True}), 201
@app.get("/api/stats/public")
@limiter.limit("120 per minute")
def public_stats():
"""Statistiques publiques : total soumis et acceptées (sans données de rejet)."""
return jsonify(get_public_stats())
@app.get("/api/contributions")
@limiter.limit("60 per minute")
def public_contributions():
"""Liste paginée des contributions acceptées — vue publique anti-chronologique."""
try:
page = max(1, int(request.args.get("page", 1)))
per_page = min(50, max(5, int(request.args.get("per_page", 20))))
except (ValueError, TypeError):
page, per_page = 1, 20
contributions, total = get_public_contributions(page=page, per_page=per_page)
pages = max(1, -(-total // per_page))
return jsonify({
"contributions": [
{
"id": c["id"],
"content": c["content"],
"author": c.get("author"),
"createdAt": c["created_at"].isoformat() if c.get("created_at") else None,
}
for c in contributions
],
"total": total,
"page": page,
"perPage": per_page,
"pages": pages,
})
@app.get("/api/contributions/export/json")
@limiter.limit("10 per minute")
def export_contributions_json():
"""Exporte l'intégralité des contributions acceptées en JSON (champs publics)."""
contributions, _ = get_public_contributions(page=1, per_page=10000)
payload = [
{
"id": c["id"],
"content": c["content"],
"author": c.get("author"),
"createdAt": c["created_at"].isoformat() if c.get("created_at") else None,
}
for c in contributions
]
return Response(
json.dumps(payload, ensure_ascii=False, indent=2),
mimetype="application/json",
headers={"Content-Disposition": "attachment; filename=contributions.json"},
)
@app.get("/api/contributions/export/csv")
@limiter.limit("10 per minute")
def export_contributions_csv():
"""Exporte les contributions acceptées en CSV (champs publics uniquement — pas de fingerprint)."""
contributions, _ = get_public_contributions(page=1, per_page=10000)
output = io.StringIO()
writer = csv.writer(output, quoting=csv.QUOTE_ALL)
writer.writerow(["id", "content", "author", "created_at"])
for c in contributions:
writer.writerow([
c.get("id"),
c.get("content", ""),
c.get("author", ""),
c["created_at"].isoformat() if c.get("created_at") else "",
])
return Response(
output.getvalue().encode("utf-8-sig"),
mimetype="text/csv",
headers={"Content-Disposition": "attachment; filename=contributions.csv"},
)
# ─── Route publique : signalement ────────────────────────────────────────────
@app.post("/api/ideas/<int:idea_id>/flag")
+61
View File
@@ -62,6 +62,18 @@ def init_db() -> None:
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
# Table de traçabilité des consentements RGPD (art. 7.1 — charge de la preuve)
cur.execute("""
CREATE TABLE IF NOT EXISTS consents (
id SERIAL PRIMARY KEY,
fingerprint_hash VARCHAR(64) NOT NULL,
consent_version VARCHAR(20) NOT NULL,
consented_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_consents_fingerprint ON consents(fingerprint_hash)"
)
logger.info("Base de données initialisée.")
@@ -85,6 +97,55 @@ def insert_idea(
return dict(cur.fetchone())
def create_consent(fingerprint_hash: str, consent_version: str) -> dict:
"""Enregistre un consentement explicite (art. 7.1 RGPD — preuve)."""
with db_cursor() as cur:
cur.execute(
"""
INSERT INTO consents (fingerprint_hash, consent_version)
VALUES (%s, %s)
RETURNING *
""",
(fingerprint_hash, consent_version),
)
return dict(cur.fetchone())
def get_public_contributions(page: int = 1, per_page: int = 20) -> tuple[list[dict], int]:
"""Retourne les contributions acceptées paginées pour la vue publique."""
offset = (page - 1) * per_page
with db_cursor() as cur:
cur.execute(
"SELECT COUNT(*) as total FROM ideas WHERE accepted = TRUE AND flagged = FALSE"
)
total = cur.fetchone()["total"]
cur.execute(
"""
SELECT id, content, author, created_at
FROM ideas
WHERE accepted = TRUE AND flagged = FALSE
ORDER BY created_at DESC
LIMIT %s OFFSET %s
""",
(per_page, offset),
)
rows = [dict(row) for row in cur.fetchall()]
return rows, total
def get_public_stats() -> dict:
"""Statistiques publiques — ne révèle pas les chiffres de rejet."""
with db_cursor() as cur:
cur.execute("SELECT COUNT(*) as total FROM ideas")
total = cur.fetchone()["total"]
cur.execute("SELECT COUNT(*) as accepted FROM ideas WHERE accepted = TRUE")
accepted = cur.fetchone()["accepted"]
cur.execute("SELECT updated_at FROM synthesis LIMIT 1")
row = cur.fetchone()
last_updated = row["updated_at"].isoformat() if row and row.get("updated_at") else None
return {"total": total, "accepted": accepted, "lastUpdated": last_updated}
def get_accepted_ideas() -> list[dict]:
with db_cursor() as cur:
cur.execute(