Sprint 5 : integration et production -- securite, performance, API publique, documentation
Backend: rate limiter, security headers, blockchain cache service avec RPC, public API (7 endpoints read-only), WebSocket auth + heartbeat, DB connection pooling, structured logging, health check DB. Frontend: API retry/timeout, WebSocket auth + heartbeat + typed events, notifications toast, mobile hamburger + drawer, error boundary, offline banner, loading skeletons, dashboard enrichi. Documentation: guides utilisateur complets (demarrage, vote, sanctuaire, FAQ 30+), guide deploiement, politique securite. 123 tests, 155 fichiers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
249
backend/app/routers/public.py
Normal file
249
backend/app/routers/public.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""Public API router: read-only endpoints for external consumption.
|
||||
|
||||
All endpoints are accessible without authentication.
|
||||
No mutations allowed -- strictly read-only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.decision import Decision
|
||||
from app.models.document import Document, DocumentItem
|
||||
from app.models.sanctuary import SanctuaryEntry
|
||||
from app.models.vote import VoteSession
|
||||
from app.schemas.document import DocumentFullOut, DocumentItemOut, DocumentOut
|
||||
from app.schemas.sanctuary import SanctuaryEntryOut
|
||||
from app.services import document_service, sanctuary_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Documents (public, read-only) ─────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/documents",
|
||||
response_model=list[DocumentOut],
|
||||
tags=["public-documents"],
|
||||
summary="Liste publique des documents actifs",
|
||||
)
|
||||
async def list_documents(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
doc_type: str | None = Query(default=None, description="Filtrer par type de document"),
|
||||
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
) -> list[DocumentOut]:
|
||||
"""Liste les documents de reference avec leurs items (lecture seule)."""
|
||||
stmt = select(Document)
|
||||
|
||||
if doc_type is not None:
|
||||
stmt = stmt.where(Document.doc_type == doc_type)
|
||||
if status_filter is not None:
|
||||
stmt = stmt.where(Document.status == status_filter)
|
||||
|
||||
stmt = stmt.order_by(Document.created_at.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
documents = result.scalars().all()
|
||||
|
||||
out = []
|
||||
for doc in documents:
|
||||
count_result = await db.execute(
|
||||
select(func.count()).select_from(DocumentItem).where(DocumentItem.document_id == doc.id)
|
||||
)
|
||||
items_count = count_result.scalar() or 0
|
||||
doc_out = DocumentOut.model_validate(doc)
|
||||
doc_out.items_count = items_count
|
||||
out.append(doc_out)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
@router.get(
|
||||
"/documents/{slug}",
|
||||
response_model=DocumentFullOut,
|
||||
tags=["public-documents"],
|
||||
summary="Document complet avec ses items",
|
||||
)
|
||||
async def get_document(
|
||||
slug: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DocumentFullOut:
|
||||
"""Recupere un document avec tous ses items (texte complet, serialise)."""
|
||||
doc = await document_service.get_document_with_items(slug, db)
|
||||
if doc is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Document introuvable",
|
||||
)
|
||||
return DocumentFullOut.model_validate(doc)
|
||||
|
||||
|
||||
# ── Sanctuary (public, read-only) ─────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sanctuary/entries",
|
||||
response_model=list[SanctuaryEntryOut],
|
||||
tags=["public-sanctuary"],
|
||||
summary="Liste des entrees du sanctuaire",
|
||||
)
|
||||
async def list_sanctuary_entries(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
entry_type: str | None = Query(default=None, description="Filtrer par type (document, decision, vote_result)"),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
) -> list[SanctuaryEntryOut]:
|
||||
"""Liste les entrees du sanctuaire (archives verifiees)."""
|
||||
stmt = select(SanctuaryEntry)
|
||||
|
||||
if entry_type is not None:
|
||||
stmt = stmt.where(SanctuaryEntry.entry_type == entry_type)
|
||||
|
||||
stmt = stmt.order_by(SanctuaryEntry.created_at.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
entries = result.scalars().all()
|
||||
|
||||
return [SanctuaryEntryOut.model_validate(e) for e in entries]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sanctuary/entries/{id}",
|
||||
response_model=SanctuaryEntryOut,
|
||||
tags=["public-sanctuary"],
|
||||
summary="Entree du sanctuaire par ID",
|
||||
)
|
||||
async def get_sanctuary_entry(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SanctuaryEntryOut:
|
||||
"""Recupere une entree du sanctuaire avec lien IPFS et ancrage on-chain."""
|
||||
result = await db.execute(
|
||||
select(SanctuaryEntry).where(SanctuaryEntry.id == id)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
if entry is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Entree sanctuaire introuvable",
|
||||
)
|
||||
return SanctuaryEntryOut.model_validate(entry)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sanctuary/verify/{id}",
|
||||
tags=["public-sanctuary"],
|
||||
summary="Verification d'integrite d'une entree",
|
||||
)
|
||||
async def verify_sanctuary_entry(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Verifie l'integrite d'une entree du sanctuaire (comparaison de hash)."""
|
||||
try:
|
||||
result = await sanctuary_service.verify_entry(id, db)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ── Votes (public, read-only) ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/votes/sessions/{id}/result",
|
||||
tags=["public-votes"],
|
||||
summary="Resultat d'une session de vote",
|
||||
)
|
||||
async def get_vote_result(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Recupere le resultat d'une session de vote (lecture seule, public)."""
|
||||
result = await db.execute(
|
||||
select(VoteSession).where(VoteSession.id == id)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if session is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session de vote introuvable",
|
||||
)
|
||||
|
||||
return {
|
||||
"session_id": str(session.id),
|
||||
"status": session.status,
|
||||
"votes_for": session.votes_for,
|
||||
"votes_against": session.votes_against,
|
||||
"votes_total": session.votes_total,
|
||||
"wot_size": session.wot_size,
|
||||
"smith_size": session.smith_size,
|
||||
"techcomm_size": session.techcomm_size,
|
||||
"threshold_required": session.threshold_required,
|
||||
"result": session.result,
|
||||
"starts_at": session.starts_at.isoformat() if session.starts_at else None,
|
||||
"ends_at": session.ends_at.isoformat() if session.ends_at else None,
|
||||
"chain_recorded": session.chain_recorded,
|
||||
"chain_tx_hash": session.chain_tx_hash,
|
||||
}
|
||||
|
||||
|
||||
# ── Platform status ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/status",
|
||||
tags=["public-status"],
|
||||
summary="Statut de la plateforme",
|
||||
)
|
||||
async def platform_status(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Statut general de la plateforme: compteurs de documents, decisions, votes actifs."""
|
||||
# Count documents
|
||||
doc_count_result = await db.execute(
|
||||
select(func.count()).select_from(Document)
|
||||
)
|
||||
documents_count = doc_count_result.scalar() or 0
|
||||
|
||||
# Count decisions
|
||||
decision_count_result = await db.execute(
|
||||
select(func.count()).select_from(Decision)
|
||||
)
|
||||
decisions_count = decision_count_result.scalar() or 0
|
||||
|
||||
# Count active vote sessions
|
||||
active_votes_result = await db.execute(
|
||||
select(func.count()).select_from(VoteSession).where(VoteSession.status == "open")
|
||||
)
|
||||
active_votes_count = active_votes_result.scalar() or 0
|
||||
|
||||
# Count total vote sessions
|
||||
total_votes_result = await db.execute(
|
||||
select(func.count()).select_from(VoteSession)
|
||||
)
|
||||
total_votes_count = total_votes_result.scalar() or 0
|
||||
|
||||
# Count sanctuary entries
|
||||
sanctuary_count_result = await db.execute(
|
||||
select(func.count()).select_from(SanctuaryEntry)
|
||||
)
|
||||
sanctuary_count = sanctuary_count_result.scalar() or 0
|
||||
|
||||
return {
|
||||
"platform": "Glibredecision",
|
||||
"documents_count": documents_count,
|
||||
"decisions_count": decisions_count,
|
||||
"active_votes_count": active_votes_count,
|
||||
"total_votes_count": total_votes_count,
|
||||
"sanctuary_entries_count": sanctuary_count,
|
||||
}
|
||||
Reference in New Issue
Block a user