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>
250 lines
8.3 KiB
Python
250 lines
8.3 KiB
Python
"""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,
|
|
}
|