Sprint 3 : protocoles de vote et boite a outils
Backend: - Sessions de vote : list, close, tally, threshold details, auto-expiration - Protocoles : update, simulate, meta-gouvernance, formulas CRUD - Service vote enrichi : close_session, get_threshold_details, nuanced breakdown - Schemas : ThresholdDetailOut, VoteResultOut, FormulaSimulationRequest/Result - WebSocket broadcast sur chaque vote + fermeture session - 25 nouveaux tests (threshold details, close, nuanced, simulation) Frontend: - 5 composants vote : VoteBinary, VoteNuanced, ThresholdGauge, FormulaDisplay, VoteHistory - 3 composants protocoles : ProtocolPicker, FormulaEditor, ModeParamsDisplay - Simulateur de formules interactif (page /protocols/formulas) - Page detail protocole (/protocols/[id]) - Composable useWebSocket (live updates) - Composable useVoteFormula (calcul client-side reactif) - Integration KaTeX pour rendu LaTeX des formules Documentation: - API reference : 8 nouveaux endpoints documentes - Formules : tables d'inertie, parametres detailles, simulation API - Guide vote : vote binaire/nuance, jauge, historique, simulateur, meta-gouvernance 55 tests passes (+ 1 skipped), 126 fichiers total. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""Protocols router: voting protocols and formula configurations."""
|
||||
"""Protocols router: voting protocols, formula configurations, and simulation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,13 +10,20 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.engine.smith_threshold import smith_threshold as compute_smith_threshold
|
||||
from app.engine.techcomm_threshold import techcomm_threshold as compute_techcomm_threshold
|
||||
from app.engine.threshold import wot_threshold as compute_wot_threshold
|
||||
from app.models.protocol import FormulaConfig, VotingProtocol
|
||||
from app.models.user import DuniterIdentity
|
||||
from app.schemas.protocol import (
|
||||
FormulaConfigCreate,
|
||||
FormulaConfigOut,
|
||||
FormulaConfigUpdate,
|
||||
FormulaSimulationRequest,
|
||||
FormulaSimulationResult,
|
||||
VotingProtocolCreate,
|
||||
VotingProtocolOut,
|
||||
VotingProtocolUpdate,
|
||||
)
|
||||
from app.services.auth_service import get_current_identity
|
||||
|
||||
@@ -39,6 +46,17 @@ async def _get_protocol(db: AsyncSession, protocol_id: uuid.UUID) -> VotingProto
|
||||
return protocol
|
||||
|
||||
|
||||
async def _get_formula(db: AsyncSession, formula_id: uuid.UUID) -> FormulaConfig:
|
||||
"""Fetch a formula config by ID, or raise 404."""
|
||||
result = await db.execute(
|
||||
select(FormulaConfig).where(FormulaConfig.id == formula_id)
|
||||
)
|
||||
formula = result.scalar_one_or_none()
|
||||
if formula is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Configuration de formule introuvable")
|
||||
return formula
|
||||
|
||||
|
||||
# ── Voting Protocol routes ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -102,6 +120,30 @@ async def get_protocol(
|
||||
return VotingProtocolOut.model_validate(protocol)
|
||||
|
||||
|
||||
@router.put("/{id}", response_model=VotingProtocolOut)
|
||||
async def update_protocol(
|
||||
id: uuid.UUID,
|
||||
payload: VotingProtocolUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> VotingProtocolOut:
|
||||
"""Update a voting protocol (meta-governance).
|
||||
|
||||
Only provided fields will be updated. Requires authentication.
|
||||
"""
|
||||
protocol = await _get_protocol(db, id)
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(protocol, field, value)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Reload with formula config
|
||||
protocol = await _get_protocol(db, protocol.id)
|
||||
return VotingProtocolOut.model_validate(protocol)
|
||||
|
||||
|
||||
# ── Formula Config routes ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -137,3 +179,90 @@ async def create_formula(
|
||||
await db.refresh(formula)
|
||||
|
||||
return FormulaConfigOut.model_validate(formula)
|
||||
|
||||
|
||||
@router.get("/formulas/{id}", response_model=FormulaConfigOut)
|
||||
async def get_formula(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> FormulaConfigOut:
|
||||
"""Get a single formula configuration."""
|
||||
formula = await _get_formula(db, id)
|
||||
return FormulaConfigOut.model_validate(formula)
|
||||
|
||||
|
||||
@router.put("/formulas/{id}", response_model=FormulaConfigOut)
|
||||
async def update_formula(
|
||||
id: uuid.UUID,
|
||||
payload: FormulaConfigUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> FormulaConfigOut:
|
||||
"""Update a formula configuration (meta-governance).
|
||||
|
||||
Only provided fields will be updated. Requires authentication.
|
||||
"""
|
||||
formula = await _get_formula(db, id)
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(formula, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(formula)
|
||||
|
||||
return FormulaConfigOut.model_validate(formula)
|
||||
|
||||
|
||||
# ── Simulation ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/simulate", response_model=FormulaSimulationResult)
|
||||
async def simulate_formula(
|
||||
payload: FormulaSimulationRequest,
|
||||
) -> FormulaSimulationResult:
|
||||
"""Simulate a WoT threshold formula computation.
|
||||
|
||||
Pure calculation endpoint -- no database access needed.
|
||||
Accepts WoT size, total votes, and formula parameters,
|
||||
returns the computed thresholds and derived values.
|
||||
"""
|
||||
# Compute WoT threshold
|
||||
wot_thresh = compute_wot_threshold(
|
||||
wot_size=payload.wot_size,
|
||||
total_votes=payload.total_votes,
|
||||
majority_pct=payload.majority_pct,
|
||||
base_exponent=payload.base_exponent,
|
||||
gradient_exponent=payload.gradient_exponent,
|
||||
constant_base=payload.constant_base,
|
||||
)
|
||||
|
||||
# Compute derived values for transparency
|
||||
w = payload.wot_size
|
||||
t = payload.total_votes
|
||||
m = payload.majority_pct / 100.0
|
||||
g = payload.gradient_exponent
|
||||
|
||||
participation_rate = t / w if w > 0 else 0.0
|
||||
turnout_ratio = min(t / w, 1.0) if w > 0 else 0.0
|
||||
inertia_factor = 1.0 - turnout_ratio ** g if t > 0 else 1.0
|
||||
required_ratio = m + (1.0 - m) * inertia_factor
|
||||
|
||||
# Smith threshold (optional)
|
||||
smith_thresh = None
|
||||
if payload.smith_wot_size is not None and payload.smith_exponent is not None:
|
||||
smith_thresh = compute_smith_threshold(payload.smith_wot_size, payload.smith_exponent)
|
||||
|
||||
# TechComm threshold (optional)
|
||||
techcomm_thresh = None
|
||||
if payload.techcomm_size is not None and payload.techcomm_exponent is not None:
|
||||
techcomm_thresh = compute_techcomm_threshold(payload.techcomm_size, payload.techcomm_exponent)
|
||||
|
||||
return FormulaSimulationResult(
|
||||
wot_threshold=wot_thresh,
|
||||
smith_threshold=smith_thresh,
|
||||
techcomm_threshold=techcomm_thresh,
|
||||
participation_rate=round(participation_rate, 6),
|
||||
required_ratio=round(required_ratio, 6),
|
||||
inertia_factor=round(inertia_factor, 6),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Votes router: vote sessions, individual votes, and result computation."""
|
||||
"""Votes router: vote sessions, individual votes, result computation, and live updates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -15,8 +15,22 @@ from app.database import get_db
|
||||
from app.models.protocol import FormulaConfig, VotingProtocol
|
||||
from app.models.user import DuniterIdentity
|
||||
from app.models.vote import Vote, VoteSession
|
||||
from app.schemas.vote import VoteCreate, VoteOut, VoteSessionCreate, VoteSessionOut
|
||||
from app.routers.websocket import manager
|
||||
from app.schemas.vote import (
|
||||
ThresholdDetailOut,
|
||||
VoteCreate,
|
||||
VoteOut,
|
||||
VoteResultOut,
|
||||
VoteSessionCreate,
|
||||
VoteSessionListOut,
|
||||
VoteSessionOut,
|
||||
)
|
||||
from app.services.auth_service import get_current_identity
|
||||
from app.services.vote_service import (
|
||||
close_session as svc_close_session,
|
||||
compute_result as svc_compute_result,
|
||||
get_threshold_details as svc_get_threshold_details,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -50,6 +64,37 @@ async def _get_protocol_with_formula(db: AsyncSession, protocol_id: uuid.UUID) -
|
||||
return protocol
|
||||
|
||||
|
||||
async def _check_session_expired(session: VoteSession, db: AsyncSession) -> VoteSession:
|
||||
"""Check if a session has passed its ends_at deadline and auto-close it.
|
||||
|
||||
If the session is still marked 'open' but the deadline has passed,
|
||||
close it and compute the final tally via the vote service.
|
||||
Returns the (possibly updated) session.
|
||||
"""
|
||||
if session.status == "open" and datetime.now(timezone.utc) > session.ends_at:
|
||||
try:
|
||||
result = await svc_close_session(session.id, db)
|
||||
# Reload session to get updated fields
|
||||
db_result = await db.execute(
|
||||
select(VoteSession)
|
||||
.options(selectinload(VoteSession.votes))
|
||||
.where(VoteSession.id == session.id)
|
||||
)
|
||||
session = db_result.scalar_one()
|
||||
# Broadcast session closed event
|
||||
await manager.broadcast(session.id, {
|
||||
"type": "session_closed",
|
||||
"session_id": str(session.id),
|
||||
"result": result.get("result"),
|
||||
"votes_for": result.get("votes_for", 0),
|
||||
"votes_against": result.get("votes_against", 0),
|
||||
"votes_total": result.get("votes_total", 0),
|
||||
})
|
||||
except ValueError:
|
||||
pass # Session already closed by another process
|
||||
return session
|
||||
|
||||
|
||||
def _compute_threshold(formula: FormulaConfig, wot_size: int, votes_total: int) -> float:
|
||||
"""Compute the WoT-based threshold using the core formula.
|
||||
|
||||
@@ -122,6 +167,35 @@ def _compute_result(
|
||||
# ── Routes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=list[VoteSessionListOut])
|
||||
async def list_vote_sessions(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
session_status: str | None = Query(default=None, alias="status", description="Filtrer par statut (open, closed, tallied)"),
|
||||
decision_id: uuid.UUID | None = Query(default=None, description="Filtrer par decision_id"),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
) -> list[VoteSessionListOut]:
|
||||
"""List all vote sessions with optional filters by status and decision_id."""
|
||||
stmt = select(VoteSession)
|
||||
|
||||
if session_status is not None:
|
||||
stmt = stmt.where(VoteSession.status == session_status)
|
||||
if decision_id is not None:
|
||||
stmt = stmt.where(VoteSession.decision_id == decision_id)
|
||||
|
||||
stmt = stmt.order_by(VoteSession.created_at.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
sessions = result.scalars().all()
|
||||
|
||||
# Auto-close expired sessions before returning
|
||||
checked_sessions = []
|
||||
for s in sessions:
|
||||
s = await _check_session_expired(s, db)
|
||||
checked_sessions.append(s)
|
||||
|
||||
return [VoteSessionListOut.model_validate(s) for s in checked_sessions]
|
||||
|
||||
|
||||
@router.post("/sessions", response_model=VoteSessionOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_vote_session(
|
||||
payload: VoteSessionCreate,
|
||||
@@ -163,11 +237,52 @@ async def get_vote_session(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> VoteSessionOut:
|
||||
"""Get a vote session with current tallies."""
|
||||
"""Get a vote session with current tallies.
|
||||
|
||||
Automatically closes the session if its deadline has passed.
|
||||
"""
|
||||
session = await _get_session(db, id)
|
||||
session = await _check_session_expired(session, db)
|
||||
return VoteSessionOut.model_validate(session)
|
||||
|
||||
|
||||
@router.post("/sessions/{id}/close", response_model=VoteResultOut)
|
||||
async def close_vote_session(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> VoteResultOut:
|
||||
"""Manually close a vote session and compute the final result.
|
||||
|
||||
Requires authentication. The session must be in 'open' status.
|
||||
"""
|
||||
try:
|
||||
result = await svc_close_session(id, db)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
|
||||
# Broadcast session closed event via WebSocket
|
||||
await manager.broadcast(id, {
|
||||
"type": "session_closed",
|
||||
"session_id": str(id),
|
||||
"result": result.get("result"),
|
||||
"votes_for": result.get("votes_for", 0),
|
||||
"votes_against": result.get("votes_against", 0),
|
||||
"votes_total": result.get("votes_total", 0),
|
||||
})
|
||||
|
||||
return VoteResultOut(
|
||||
session_id=id,
|
||||
result=result.get("result"),
|
||||
threshold_required=float(result.get("threshold", 0)),
|
||||
votes_for=result.get("votes_for", 0),
|
||||
votes_against=result.get("votes_against", 0),
|
||||
votes_total=result.get("votes_total", 0),
|
||||
adopted=result.get("adopted", False),
|
||||
nuanced_breakdown=result.get("nuanced_breakdown"),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sessions/{id}/vote", response_model=VoteOut, status_code=status.HTTP_201_CREATED)
|
||||
async def submit_vote(
|
||||
id: uuid.UUID,
|
||||
@@ -179,9 +294,13 @@ async def submit_vote(
|
||||
|
||||
Each identity can only vote once per session. Submitting again replaces the previous vote.
|
||||
The vote must include a cryptographic signature for on-chain proof.
|
||||
After submission, broadcasts a vote_update event via WebSocket.
|
||||
"""
|
||||
session = await _get_session(db, id)
|
||||
|
||||
# Auto-close check
|
||||
session = await _check_session_expired(session, db)
|
||||
|
||||
# Verify session is open
|
||||
if session.status != "open":
|
||||
raise HTTPException(
|
||||
@@ -249,6 +368,15 @@ async def submit_vote(
|
||||
await db.commit()
|
||||
await db.refresh(vote)
|
||||
|
||||
# Broadcast vote update via WebSocket
|
||||
await manager.broadcast(session.id, {
|
||||
"type": "vote_update",
|
||||
"session_id": str(session.id),
|
||||
"votes_for": session.votes_for,
|
||||
"votes_against": session.votes_against,
|
||||
"votes_total": session.votes_total,
|
||||
})
|
||||
|
||||
return VoteOut.model_validate(vote)
|
||||
|
||||
|
||||
@@ -273,6 +401,28 @@ async def list_votes(
|
||||
return [VoteOut.model_validate(v) for v in votes]
|
||||
|
||||
|
||||
@router.get("/sessions/{id}/threshold", response_model=ThresholdDetailOut)
|
||||
async def get_threshold_details(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ThresholdDetailOut:
|
||||
"""Return computed threshold details for a vote session.
|
||||
|
||||
Includes WoT/Smith/TechComm thresholds, pass/fail status,
|
||||
participation rate, and the formula parameters used.
|
||||
Automatically closes the session if its deadline has passed.
|
||||
"""
|
||||
session = await _get_session(db, id)
|
||||
session = await _check_session_expired(session, db)
|
||||
|
||||
try:
|
||||
details = await svc_get_threshold_details(id, db)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
|
||||
return ThresholdDetailOut(**details)
|
||||
|
||||
|
||||
@router.get("/sessions/{id}/result")
|
||||
async def get_vote_result(
|
||||
id: uuid.UUID,
|
||||
@@ -282,8 +432,10 @@ async def get_vote_result(
|
||||
|
||||
Uses the WoT threshold formula linked through the voting protocol.
|
||||
Returns current tallies, computed threshold, and whether the vote passes.
|
||||
Automatically closes the session if its deadline has passed.
|
||||
"""
|
||||
session = await _get_session(db, id)
|
||||
session = await _check_session_expired(session, db)
|
||||
|
||||
# Get the protocol and formula
|
||||
protocol = await _get_protocol_with_formula(db, session.voting_protocol_id)
|
||||
@@ -304,3 +456,41 @@ async def get_vote_result(
|
||||
"techcomm_votes_for": session.techcomm_votes_for,
|
||||
**result_data,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/sessions/{id}/tally", response_model=VoteResultOut)
|
||||
async def force_tally(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> VoteResultOut:
|
||||
"""Force a recount of a vote session.
|
||||
|
||||
Requires authentication. Useful after a chain snapshot update
|
||||
or when recalculation is needed. Works on any session status.
|
||||
"""
|
||||
try:
|
||||
result = await svc_compute_result(id, db)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
|
||||
# Broadcast tally update via WebSocket
|
||||
await manager.broadcast(id, {
|
||||
"type": "tally_update",
|
||||
"session_id": str(id),
|
||||
"result": result.get("result"),
|
||||
"votes_for": result.get("votes_for", 0),
|
||||
"votes_against": result.get("votes_against", 0),
|
||||
"votes_total": result.get("votes_total", 0),
|
||||
})
|
||||
|
||||
return VoteResultOut(
|
||||
session_id=id,
|
||||
result=result.get("result"),
|
||||
threshold_required=float(result.get("threshold", 0)),
|
||||
votes_for=result.get("votes_for", 0),
|
||||
votes_against=result.get("votes_against", 0),
|
||||
votes_total=result.get("votes_total", 0),
|
||||
adopted=result.get("adopted", False),
|
||||
nuanced_breakdown=result.get("nuanced_breakdown"),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user