Sprint 1 : scaffolding complet de Glibredecision
Plateforme de decisions collectives pour Duniter/G1. Backend FastAPI async + PostgreSQL (14 tables, 8 routers, 6 services, moteur de vote avec formule d'inertie WoT/Smith/TechComm). Frontend Nuxt 4 + Nuxt UI v3 + Pinia (9 pages, 5 stores). Infrastructure Docker + Woodpecker CI + Traefik. Documentation technique et utilisateur (15 fichiers). Seed : Licence G1, Engagement Forgeron v2.0.0, 4 protocoles de vote. 30 tests unitaires (formules, mode params, vote nuance) -- tous verts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
162
backend/app/routers/auth.py
Normal file
162
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""Auth router: Ed25519 challenge-response authentication for Duniter V2 identities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.user import DuniterIdentity
|
||||
from app.schemas.auth import (
|
||||
ChallengeRequest,
|
||||
ChallengeResponse,
|
||||
IdentityOut,
|
||||
TokenResponse,
|
||||
VerifyRequest,
|
||||
)
|
||||
from app.services.auth_service import (
|
||||
create_session,
|
||||
get_current_identity,
|
||||
get_or_create_identity,
|
||||
invalidate_session,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ── In-memory challenge store (short-lived, no persistence needed) ──────────
|
||||
# Structure: { address: { "challenge": str, "expires_at": datetime } }
|
||||
_pending_challenges: dict[str, dict] = {}
|
||||
|
||||
|
||||
def _cleanup_expired_challenges() -> None:
|
||||
"""Remove expired challenges from the in-memory store."""
|
||||
now = datetime.now(timezone.utc)
|
||||
expired = [addr for addr, data in _pending_challenges.items() if data["expires_at"] < now]
|
||||
for addr in expired:
|
||||
del _pending_challenges[addr]
|
||||
|
||||
|
||||
# ── Routes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/challenge", response_model=ChallengeResponse)
|
||||
async def request_challenge(payload: ChallengeRequest) -> ChallengeResponse:
|
||||
"""Generate a random Ed25519 challenge for the given Duniter address.
|
||||
|
||||
The client must sign this challenge with the private key corresponding
|
||||
to the address, then submit it via POST /verify.
|
||||
"""
|
||||
_cleanup_expired_challenges()
|
||||
|
||||
challenge = secrets.token_hex(32)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(seconds=settings.CHALLENGE_EXPIRE_SECONDS)
|
||||
|
||||
_pending_challenges[payload.address] = {
|
||||
"challenge": challenge,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
|
||||
return ChallengeResponse(challenge=challenge, expires_at=expires_at)
|
||||
|
||||
|
||||
@router.post("/verify", response_model=TokenResponse)
|
||||
async def verify_challenge(
|
||||
payload: VerifyRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TokenResponse:
|
||||
"""Verify the Ed25519 signature of a challenge and return a session token.
|
||||
|
||||
Steps:
|
||||
1. Check that a pending challenge exists for the address.
|
||||
2. Verify the challenge string matches.
|
||||
3. Verify the Ed25519 signature against the address public key.
|
||||
4. Create or retrieve the DuniterIdentity.
|
||||
5. Create a session and return the bearer token.
|
||||
"""
|
||||
# 1. Retrieve pending challenge
|
||||
pending = _pending_challenges.get(payload.address)
|
||||
if pending is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Aucun challenge en attente pour cette adresse",
|
||||
)
|
||||
|
||||
# 2. Check expiry
|
||||
if pending["expires_at"] < datetime.now(timezone.utc):
|
||||
del _pending_challenges[payload.address]
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Challenge expire, veuillez en demander un nouveau",
|
||||
)
|
||||
|
||||
# 3. Verify challenge string matches
|
||||
if pending["challenge"] != payload.challenge:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Challenge invalide",
|
||||
)
|
||||
|
||||
# 4. Verify Ed25519 signature
|
||||
# TODO: Implement actual Ed25519 verification using substrate-interface
|
||||
# For now we accept any signature to allow development/testing.
|
||||
# In production this MUST verify: verify(address_pubkey, challenge_bytes, signature_bytes)
|
||||
#
|
||||
# from substrateinterface import Keypair
|
||||
# keypair = Keypair(ss58_address=payload.address)
|
||||
# if not keypair.verify(payload.challenge.encode(), bytes.fromhex(payload.signature)):
|
||||
# raise HTTPException(status_code=401, detail="Signature invalide")
|
||||
|
||||
# 5. Consume the challenge
|
||||
del _pending_challenges[payload.address]
|
||||
|
||||
# 6. Get or create identity
|
||||
identity = await get_or_create_identity(db, payload.address)
|
||||
|
||||
# 7. Create session token
|
||||
token = await create_session(db, identity)
|
||||
|
||||
return TokenResponse(
|
||||
token=token,
|
||||
identity=IdentityOut.model_validate(identity),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=IdentityOut)
|
||||
async def get_me(
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> IdentityOut:
|
||||
"""Return the currently authenticated identity."""
|
||||
return IdentityOut.model_validate(identity)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> None:
|
||||
"""Invalidate the current session token.
|
||||
|
||||
Note: get_current_identity already validated the token, so we know it exists.
|
||||
We re-extract it from the Authorization header to invalidate it.
|
||||
"""
|
||||
# We need the raw token to invalidate -- re-extract from the dependency chain.
|
||||
# Since get_current_identity already validated, we know the request has a valid Bearer token.
|
||||
# We use a slightly different approach: delete all sessions for this identity
|
||||
# that match. For a cleaner approach, we accept the token via a dedicated dependency.
|
||||
from fastapi import Request
|
||||
|
||||
# This is handled by getting the token from the auth service
|
||||
# For simplicity, we delete all sessions for the identity
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.user import Session
|
||||
|
||||
result = await db.execute(select(Session).where(Session.identity_id == identity.id))
|
||||
sessions = result.scalars().all()
|
||||
for session in sessions:
|
||||
await db.delete(session)
|
||||
await db.commit()
|
||||
143
backend/app/routers/decisions.py
Normal file
143
backend/app/routers/decisions.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Decisions router: CRUD for decision processes and their steps."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.decision import Decision, DecisionStep
|
||||
from app.models.user import DuniterIdentity
|
||||
from app.schemas.decision import (
|
||||
DecisionCreate,
|
||||
DecisionOut,
|
||||
DecisionStepCreate,
|
||||
DecisionStepOut,
|
||||
DecisionUpdate,
|
||||
)
|
||||
from app.services.auth_service import get_current_identity
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _get_decision(db: AsyncSession, decision_id: uuid.UUID) -> Decision:
|
||||
"""Fetch a decision by ID with its steps eagerly loaded, or raise 404."""
|
||||
result = await db.execute(
|
||||
select(Decision)
|
||||
.options(selectinload(Decision.steps))
|
||||
.where(Decision.id == decision_id)
|
||||
)
|
||||
decision = result.scalar_one_or_none()
|
||||
if decision is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Decision introuvable")
|
||||
return decision
|
||||
|
||||
|
||||
# ── Decision routes ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/", response_model=list[DecisionOut])
|
||||
async def list_decisions(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
decision_type: str | None = Query(default=None, description="Filtrer par type de decision"),
|
||||
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[DecisionOut]:
|
||||
"""List all decisions with optional filters."""
|
||||
stmt = select(Decision).options(selectinload(Decision.steps))
|
||||
|
||||
if decision_type is not None:
|
||||
stmt = stmt.where(Decision.decision_type == decision_type)
|
||||
if status_filter is not None:
|
||||
stmt = stmt.where(Decision.status == status_filter)
|
||||
|
||||
stmt = stmt.order_by(Decision.created_at.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
decisions = result.scalars().unique().all()
|
||||
|
||||
return [DecisionOut.model_validate(d) for d in decisions]
|
||||
|
||||
|
||||
@router.post("/", response_model=DecisionOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_decision(
|
||||
payload: DecisionCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> DecisionOut:
|
||||
"""Create a new decision process."""
|
||||
decision = Decision(
|
||||
**payload.model_dump(),
|
||||
created_by_id=identity.id,
|
||||
)
|
||||
db.add(decision)
|
||||
await db.commit()
|
||||
await db.refresh(decision)
|
||||
|
||||
# Reload with steps (empty at creation)
|
||||
decision = await _get_decision(db, decision.id)
|
||||
return DecisionOut.model_validate(decision)
|
||||
|
||||
|
||||
@router.get("/{id}", response_model=DecisionOut)
|
||||
async def get_decision(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DecisionOut:
|
||||
"""Get a single decision with all its steps."""
|
||||
decision = await _get_decision(db, id)
|
||||
return DecisionOut.model_validate(decision)
|
||||
|
||||
|
||||
@router.put("/{id}", response_model=DecisionOut)
|
||||
async def update_decision(
|
||||
id: uuid.UUID,
|
||||
payload: DecisionUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> DecisionOut:
|
||||
"""Update a decision's metadata (title, description, status, protocol)."""
|
||||
decision = await _get_decision(db, id)
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(decision, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(decision)
|
||||
|
||||
# Reload with steps
|
||||
decision = await _get_decision(db, decision.id)
|
||||
return DecisionOut.model_validate(decision)
|
||||
|
||||
|
||||
# ── Decision Step routes ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/{id}/steps", response_model=DecisionStepOut, status_code=status.HTTP_201_CREATED)
|
||||
async def add_step(
|
||||
id: uuid.UUID,
|
||||
payload: DecisionStepCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> DecisionStepOut:
|
||||
"""Add a step to a decision process."""
|
||||
# Verify decision exists
|
||||
decision = await _get_decision(db, id)
|
||||
|
||||
step = DecisionStep(
|
||||
decision_id=decision.id,
|
||||
**payload.model_dump(),
|
||||
)
|
||||
db.add(step)
|
||||
await db.commit()
|
||||
await db.refresh(step)
|
||||
|
||||
return DecisionStepOut.model_validate(step)
|
||||
262
backend/app/routers/documents.py
Normal file
262
backend/app/routers/documents.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""Documents router: CRUD for reference documents, items, and item versions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.document import Document, DocumentItem, ItemVersion
|
||||
from app.models.user import DuniterIdentity
|
||||
from app.schemas.document import (
|
||||
DocumentCreate,
|
||||
DocumentItemCreate,
|
||||
DocumentItemOut,
|
||||
DocumentOut,
|
||||
DocumentUpdate,
|
||||
ItemVersionCreate,
|
||||
ItemVersionOut,
|
||||
)
|
||||
from app.services.auth_service import get_current_identity
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _get_document_by_slug(db: AsyncSession, slug: str) -> Document:
|
||||
"""Fetch a document by slug or raise 404."""
|
||||
result = await db.execute(select(Document).where(Document.slug == slug))
|
||||
doc = result.scalar_one_or_none()
|
||||
if doc is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document introuvable")
|
||||
return doc
|
||||
|
||||
|
||||
async def _get_item(db: AsyncSession, document_id: uuid.UUID, item_id: uuid.UUID) -> DocumentItem:
|
||||
"""Fetch a document item by ID within a document, or raise 404."""
|
||||
result = await db.execute(
|
||||
select(DocumentItem).where(
|
||||
DocumentItem.id == item_id,
|
||||
DocumentItem.document_id == document_id,
|
||||
)
|
||||
)
|
||||
item = result.scalar_one_or_none()
|
||||
if item is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item introuvable")
|
||||
return item
|
||||
|
||||
|
||||
# ── Document routes ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/", response_model=list[DocumentOut])
|
||||
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]:
|
||||
"""List all reference documents, with optional filters."""
|
||||
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()
|
||||
|
||||
# Compute items_count for each document
|
||||
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.post("/", response_model=DocumentOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_document(
|
||||
payload: DocumentCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> DocumentOut:
|
||||
"""Create a new reference document."""
|
||||
# Check slug uniqueness
|
||||
existing = await db.execute(select(Document).where(Document.slug == payload.slug))
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Un document avec ce slug existe deja",
|
||||
)
|
||||
|
||||
doc = Document(**payload.model_dump())
|
||||
db.add(doc)
|
||||
await db.commit()
|
||||
await db.refresh(doc)
|
||||
|
||||
doc_out = DocumentOut.model_validate(doc)
|
||||
doc_out.items_count = 0
|
||||
return doc_out
|
||||
|
||||
|
||||
@router.get("/{slug}", response_model=DocumentOut)
|
||||
async def get_document(
|
||||
slug: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DocumentOut:
|
||||
"""Get a single document by its slug."""
|
||||
doc = await _get_document_by_slug(db, slug)
|
||||
|
||||
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
|
||||
return doc_out
|
||||
|
||||
|
||||
@router.put("/{slug}", response_model=DocumentOut)
|
||||
async def update_document(
|
||||
slug: str,
|
||||
payload: DocumentUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> DocumentOut:
|
||||
"""Update a document's metadata (title, status, description, version)."""
|
||||
doc = await _get_document_by_slug(db, slug)
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(doc, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(doc)
|
||||
|
||||
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
|
||||
return doc_out
|
||||
|
||||
|
||||
# ── Document Item routes ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/{slug}/items", response_model=DocumentItemOut, status_code=status.HTTP_201_CREATED)
|
||||
async def add_item(
|
||||
slug: str,
|
||||
payload: DocumentItemCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> DocumentItemOut:
|
||||
"""Add a new item (clause, rule, etc.) to a document."""
|
||||
doc = await _get_document_by_slug(db, slug)
|
||||
|
||||
# Determine sort_order: max existing + 1
|
||||
max_order_result = await db.execute(
|
||||
select(func.max(DocumentItem.sort_order)).where(DocumentItem.document_id == doc.id)
|
||||
)
|
||||
max_order = max_order_result.scalar() or 0
|
||||
|
||||
item = DocumentItem(
|
||||
document_id=doc.id,
|
||||
sort_order=max_order + 1,
|
||||
**payload.model_dump(),
|
||||
)
|
||||
db.add(item)
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
|
||||
return DocumentItemOut.model_validate(item)
|
||||
|
||||
|
||||
@router.get("/{slug}/items", response_model=list[DocumentItemOut])
|
||||
async def list_items(
|
||||
slug: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[DocumentItemOut]:
|
||||
"""List all items in a document, ordered by sort_order."""
|
||||
doc = await _get_document_by_slug(db, slug)
|
||||
|
||||
result = await db.execute(
|
||||
select(DocumentItem)
|
||||
.where(DocumentItem.document_id == doc.id)
|
||||
.order_by(DocumentItem.sort_order)
|
||||
)
|
||||
items = result.scalars().all()
|
||||
return [DocumentItemOut.model_validate(item) for item in items]
|
||||
|
||||
|
||||
@router.get("/{slug}/items/{item_id}", response_model=DocumentItemOut)
|
||||
async def get_item(
|
||||
slug: str,
|
||||
item_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DocumentItemOut:
|
||||
"""Get a single item with its version history."""
|
||||
doc = await _get_document_by_slug(db, slug)
|
||||
item = await _get_item(db, doc.id, item_id)
|
||||
return DocumentItemOut.model_validate(item)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{slug}/items/{item_id}/versions",
|
||||
response_model=ItemVersionOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def propose_version(
|
||||
slug: str,
|
||||
item_id: uuid.UUID,
|
||||
payload: ItemVersionCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> ItemVersionOut:
|
||||
"""Propose a new version of a document item.
|
||||
|
||||
Automatically computes a unified diff between the current text and the proposed text.
|
||||
"""
|
||||
doc = await _get_document_by_slug(db, slug)
|
||||
item = await _get_item(db, doc.id, item_id)
|
||||
|
||||
# Compute diff
|
||||
diff_lines = difflib.unified_diff(
|
||||
item.current_text.splitlines(keepends=True),
|
||||
payload.proposed_text.splitlines(keepends=True),
|
||||
fromfile="actuel",
|
||||
tofile="propose",
|
||||
)
|
||||
diff_text = "".join(diff_lines) or None
|
||||
|
||||
version = ItemVersion(
|
||||
item_id=item.id,
|
||||
proposed_text=payload.proposed_text,
|
||||
diff_text=diff_text,
|
||||
rationale=payload.rationale,
|
||||
proposed_by_id=identity.id,
|
||||
)
|
||||
db.add(version)
|
||||
await db.commit()
|
||||
await db.refresh(version)
|
||||
|
||||
return ItemVersionOut.model_validate(version)
|
||||
167
backend/app/routers/mandates.py
Normal file
167
backend/app/routers/mandates.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""Mandates router: CRUD for mandates and their steps."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.mandate import Mandate, MandateStep
|
||||
from app.models.user import DuniterIdentity
|
||||
from app.schemas.mandate import (
|
||||
MandateCreate,
|
||||
MandateOut,
|
||||
MandateStepCreate,
|
||||
MandateStepOut,
|
||||
)
|
||||
from app.services.auth_service import get_current_identity
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate:
|
||||
"""Fetch a mandate by ID with its steps eagerly loaded, or raise 404."""
|
||||
result = await db.execute(
|
||||
select(Mandate)
|
||||
.options(selectinload(Mandate.steps))
|
||||
.where(Mandate.id == mandate_id)
|
||||
)
|
||||
mandate = result.scalar_one_or_none()
|
||||
if mandate is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mandat introuvable")
|
||||
return mandate
|
||||
|
||||
|
||||
# ── Mandate routes ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/", response_model=list[MandateOut])
|
||||
async def list_mandates(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
mandate_type: str | None = Query(default=None, description="Filtrer par type de mandat"),
|
||||
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[MandateOut]:
|
||||
"""List all mandates with optional filters."""
|
||||
stmt = select(Mandate).options(selectinload(Mandate.steps))
|
||||
|
||||
if mandate_type is not None:
|
||||
stmt = stmt.where(Mandate.mandate_type == mandate_type)
|
||||
if status_filter is not None:
|
||||
stmt = stmt.where(Mandate.status == status_filter)
|
||||
|
||||
stmt = stmt.order_by(Mandate.created_at.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
mandates = result.scalars().unique().all()
|
||||
|
||||
return [MandateOut.model_validate(m) for m in mandates]
|
||||
|
||||
|
||||
@router.post("/", response_model=MandateOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_mandate(
|
||||
payload: MandateCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> MandateOut:
|
||||
"""Create a new mandate."""
|
||||
mandate = Mandate(**payload.model_dump())
|
||||
db.add(mandate)
|
||||
await db.commit()
|
||||
await db.refresh(mandate)
|
||||
|
||||
# Reload with steps (empty at creation)
|
||||
mandate = await _get_mandate(db, mandate.id)
|
||||
return MandateOut.model_validate(mandate)
|
||||
|
||||
|
||||
@router.get("/{id}", response_model=MandateOut)
|
||||
async def get_mandate(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> MandateOut:
|
||||
"""Get a single mandate with all its steps."""
|
||||
mandate = await _get_mandate(db, id)
|
||||
return MandateOut.model_validate(mandate)
|
||||
|
||||
|
||||
@router.put("/{id}", response_model=MandateOut)
|
||||
async def update_mandate(
|
||||
id: uuid.UUID,
|
||||
payload: MandateCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> MandateOut:
|
||||
"""Update a mandate's metadata."""
|
||||
mandate = await _get_mandate(db, id)
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(mandate, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(mandate)
|
||||
|
||||
# Reload with steps
|
||||
mandate = await _get_mandate(db, mandate.id)
|
||||
return MandateOut.model_validate(mandate)
|
||||
|
||||
|
||||
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_mandate(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> None:
|
||||
"""Delete a mandate (only if in draft status)."""
|
||||
mandate = await _get_mandate(db, id)
|
||||
|
||||
if mandate.status != "draft":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Seuls les mandats en brouillon peuvent etre supprimes",
|
||||
)
|
||||
|
||||
await db.delete(mandate)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Mandate Step routes ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/{id}/steps", response_model=MandateStepOut, status_code=status.HTTP_201_CREATED)
|
||||
async def add_step(
|
||||
id: uuid.UUID,
|
||||
payload: MandateStepCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> MandateStepOut:
|
||||
"""Add a step to a mandate process."""
|
||||
mandate = await _get_mandate(db, id)
|
||||
|
||||
step = MandateStep(
|
||||
mandate_id=mandate.id,
|
||||
**payload.model_dump(),
|
||||
)
|
||||
db.add(step)
|
||||
await db.commit()
|
||||
await db.refresh(step)
|
||||
|
||||
return MandateStepOut.model_validate(step)
|
||||
|
||||
|
||||
@router.get("/{id}/steps", response_model=list[MandateStepOut])
|
||||
async def list_steps(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[MandateStepOut]:
|
||||
"""List all steps for a mandate, ordered by step_order."""
|
||||
mandate = await _get_mandate(db, id)
|
||||
return [MandateStepOut.model_validate(s) for s in mandate.steps]
|
||||
139
backend/app/routers/protocols.py
Normal file
139
backend/app/routers/protocols.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Protocols router: voting protocols and formula configurations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.protocol import FormulaConfig, VotingProtocol
|
||||
from app.models.user import DuniterIdentity
|
||||
from app.schemas.protocol import (
|
||||
FormulaConfigCreate,
|
||||
FormulaConfigOut,
|
||||
VotingProtocolCreate,
|
||||
VotingProtocolOut,
|
||||
)
|
||||
from app.services.auth_service import get_current_identity
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _get_protocol(db: AsyncSession, protocol_id: uuid.UUID) -> VotingProtocol:
|
||||
"""Fetch a voting protocol by ID with its formula config, or raise 404."""
|
||||
result = await db.execute(
|
||||
select(VotingProtocol)
|
||||
.options(selectinload(VotingProtocol.formula_config))
|
||||
.where(VotingProtocol.id == protocol_id)
|
||||
)
|
||||
protocol = result.scalar_one_or_none()
|
||||
if protocol is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Protocole introuvable")
|
||||
return protocol
|
||||
|
||||
|
||||
# ── Voting Protocol routes ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/", response_model=list[VotingProtocolOut])
|
||||
async def list_protocols(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
vote_type: str | None = Query(default=None, description="Filtrer par type de vote (binary, nuanced)"),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
) -> list[VotingProtocolOut]:
|
||||
"""List all voting protocols with their formula configurations."""
|
||||
stmt = select(VotingProtocol).options(selectinload(VotingProtocol.formula_config))
|
||||
|
||||
if vote_type is not None:
|
||||
stmt = stmt.where(VotingProtocol.vote_type == vote_type)
|
||||
|
||||
stmt = stmt.order_by(VotingProtocol.created_at.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
protocols = result.scalars().unique().all()
|
||||
|
||||
return [VotingProtocolOut.model_validate(p) for p in protocols]
|
||||
|
||||
|
||||
@router.post("/", response_model=VotingProtocolOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_protocol(
|
||||
payload: VotingProtocolCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> VotingProtocolOut:
|
||||
"""Create a new voting protocol.
|
||||
|
||||
The formula_config_id must reference an existing FormulaConfig.
|
||||
"""
|
||||
# Verify formula config exists
|
||||
fc_result = await db.execute(
|
||||
select(FormulaConfig).where(FormulaConfig.id == payload.formula_config_id)
|
||||
)
|
||||
if fc_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Configuration de formule introuvable",
|
||||
)
|
||||
|
||||
protocol = VotingProtocol(**payload.model_dump())
|
||||
db.add(protocol)
|
||||
await db.commit()
|
||||
await db.refresh(protocol)
|
||||
|
||||
# Reload with formula config
|
||||
protocol = await _get_protocol(db, protocol.id)
|
||||
return VotingProtocolOut.model_validate(protocol)
|
||||
|
||||
|
||||
@router.get("/{id}", response_model=VotingProtocolOut)
|
||||
async def get_protocol(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> VotingProtocolOut:
|
||||
"""Get a single voting protocol with its formula configuration."""
|
||||
protocol = await _get_protocol(db, id)
|
||||
return VotingProtocolOut.model_validate(protocol)
|
||||
|
||||
|
||||
# ── Formula Config routes ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/formulas", response_model=list[FormulaConfigOut])
|
||||
async def list_formulas(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
) -> list[FormulaConfigOut]:
|
||||
"""List all formula configurations."""
|
||||
stmt = (
|
||||
select(FormulaConfig)
|
||||
.order_by(FormulaConfig.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
formulas = result.scalars().all()
|
||||
|
||||
return [FormulaConfigOut.model_validate(f) for f in formulas]
|
||||
|
||||
|
||||
@router.post("/formulas", response_model=FormulaConfigOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_formula(
|
||||
payload: FormulaConfigCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> FormulaConfigOut:
|
||||
"""Create a new formula configuration for WoT threshold computation."""
|
||||
formula = FormulaConfig(**payload.model_dump())
|
||||
db.add(formula)
|
||||
await db.commit()
|
||||
await db.refresh(formula)
|
||||
|
||||
return FormulaConfigOut.model_validate(formula)
|
||||
73
backend/app/routers/sanctuary.py
Normal file
73
backend/app/routers/sanctuary.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Sanctuary router: IPFS + on-chain anchoring entries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.sanctuary import SanctuaryEntry
|
||||
from app.models.user import DuniterIdentity
|
||||
from app.schemas.sanctuary import SanctuaryEntryCreate, SanctuaryEntryOut
|
||||
from app.services.auth_service import get_current_identity
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=list[SanctuaryEntryOut])
|
||||
async def list_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]:
|
||||
"""List all sanctuary entries with optional type filter."""
|
||||
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("/{id}", response_model=SanctuaryEntryOut)
|
||||
async def get_entry(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SanctuaryEntryOut:
|
||||
"""Get a single sanctuary entry by ID."""
|
||||
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.post("/", response_model=SanctuaryEntryOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_entry(
|
||||
payload: SanctuaryEntryCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> SanctuaryEntryOut:
|
||||
"""Create a new sanctuary entry.
|
||||
|
||||
This endpoint is typically called by internal services after:
|
||||
1. Content is hashed (SHA-256)
|
||||
2. Content is pinned to IPFS
|
||||
3. Hash is anchored on-chain via system.remark
|
||||
|
||||
The IPFS CID and chain TX hash can be added later via updates.
|
||||
"""
|
||||
entry = SanctuaryEntry(**payload.model_dump())
|
||||
db.add(entry)
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
|
||||
return SanctuaryEntryOut.model_validate(entry)
|
||||
306
backend/app/routers/votes.py
Normal file
306
backend/app/routers/votes.py
Normal file
@@ -0,0 +1,306 @@
|
||||
"""Votes router: vote sessions, individual votes, and result computation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
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.services.auth_service import get_current_identity
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _get_session(db: AsyncSession, session_id: uuid.UUID) -> VoteSession:
|
||||
"""Fetch a vote session by ID with votes eagerly loaded, or raise 404."""
|
||||
result = await db.execute(
|
||||
select(VoteSession)
|
||||
.options(selectinload(VoteSession.votes))
|
||||
.where(VoteSession.id == session_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
|
||||
|
||||
|
||||
async def _get_protocol_with_formula(db: AsyncSession, protocol_id: uuid.UUID) -> VotingProtocol:
|
||||
"""Fetch a voting protocol with its formula config, or raise 404."""
|
||||
result = await db.execute(
|
||||
select(VotingProtocol)
|
||||
.options(selectinload(VotingProtocol.formula_config))
|
||||
.where(VotingProtocol.id == protocol_id)
|
||||
)
|
||||
protocol = result.scalar_one_or_none()
|
||||
if protocol is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Protocole de vote introuvable")
|
||||
return protocol
|
||||
|
||||
|
||||
def _compute_threshold(formula: FormulaConfig, wot_size: int, votes_total: int) -> float:
|
||||
"""Compute the WoT-based threshold using the core formula.
|
||||
|
||||
Result = C + B^W + (M + (1-M) * (1 - (T/W)^G)) * max(0, T-C)
|
||||
|
||||
Where:
|
||||
- C = constant_base
|
||||
- B = base_exponent
|
||||
- W = wot_size
|
||||
- M = majority_pct / 100
|
||||
- G = gradient_exponent
|
||||
- T = votes_total (turnout)
|
||||
"""
|
||||
c = formula.constant_base
|
||||
b = formula.base_exponent
|
||||
w = max(wot_size, 1)
|
||||
m = formula.majority_pct / 100.0
|
||||
g = formula.gradient_exponent
|
||||
t = votes_total
|
||||
|
||||
# Inertia-based threshold
|
||||
base_power = b ** w if b > 0 else 0.0
|
||||
turnout_ratio = min(t / w, 1.0) if w > 0 else 0.0
|
||||
inertia = m + (1 - m) * (1 - turnout_ratio ** g)
|
||||
threshold = c + base_power + inertia * max(0, t - c)
|
||||
|
||||
return threshold
|
||||
|
||||
|
||||
def _compute_result(
|
||||
session: VoteSession,
|
||||
formula: FormulaConfig,
|
||||
) -> dict:
|
||||
"""Compute the vote result based on tallies and formula.
|
||||
|
||||
Returns a dict with threshold_required, result ("adopted" or "rejected"),
|
||||
and whether Smith/TechComm criteria are met.
|
||||
"""
|
||||
threshold = _compute_threshold(formula, session.wot_size, session.votes_total)
|
||||
|
||||
# Main criterion: votes_for >= threshold
|
||||
main_pass = session.votes_for >= threshold
|
||||
|
||||
# Smith criterion (if configured)
|
||||
smith_pass = True
|
||||
smith_threshold = None
|
||||
if formula.smith_exponent is not None and session.smith_size > 0:
|
||||
smith_threshold = math.ceil(session.smith_size ** formula.smith_exponent)
|
||||
smith_pass = session.smith_votes_for >= smith_threshold
|
||||
|
||||
# TechComm criterion (if configured)
|
||||
techcomm_pass = True
|
||||
techcomm_threshold = None
|
||||
if formula.techcomm_exponent is not None and session.techcomm_size > 0:
|
||||
techcomm_threshold = math.ceil(session.techcomm_size ** formula.techcomm_exponent)
|
||||
techcomm_pass = session.techcomm_votes_for >= techcomm_threshold
|
||||
|
||||
result = "adopted" if (main_pass and smith_pass and techcomm_pass) else "rejected"
|
||||
|
||||
return {
|
||||
"threshold_required": threshold,
|
||||
"result": result,
|
||||
"smith_threshold": smith_threshold,
|
||||
"smith_pass": smith_pass,
|
||||
"techcomm_threshold": techcomm_threshold,
|
||||
"techcomm_pass": techcomm_pass,
|
||||
}
|
||||
|
||||
|
||||
# ── Routes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/sessions", response_model=VoteSessionOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_vote_session(
|
||||
payload: VoteSessionCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> VoteSessionOut:
|
||||
"""Create a new vote session.
|
||||
|
||||
The session duration is derived from the linked protocol's formula config.
|
||||
WoT/Smith/TechComm sizes should be snapshotted from the blockchain at creation time.
|
||||
"""
|
||||
# Validate protocol exists and get formula for duration
|
||||
protocol = await _get_protocol_with_formula(db, payload.voting_protocol_id)
|
||||
formula = protocol.formula_config
|
||||
|
||||
starts_at = datetime.now(timezone.utc)
|
||||
ends_at = starts_at + timedelta(days=formula.duration_days)
|
||||
|
||||
session = VoteSession(
|
||||
decision_id=payload.decision_id,
|
||||
item_version_id=payload.item_version_id,
|
||||
voting_protocol_id=payload.voting_protocol_id,
|
||||
starts_at=starts_at,
|
||||
ends_at=ends_at,
|
||||
# TODO: Snapshot actual WoT sizes from blockchain via Duniter RPC
|
||||
wot_size=0,
|
||||
smith_size=0,
|
||||
techcomm_size=0,
|
||||
)
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
|
||||
return VoteSessionOut.model_validate(session)
|
||||
|
||||
|
||||
@router.get("/sessions/{id}", response_model=VoteSessionOut)
|
||||
async def get_vote_session(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> VoteSessionOut:
|
||||
"""Get a vote session with current tallies."""
|
||||
session = await _get_session(db, id)
|
||||
return VoteSessionOut.model_validate(session)
|
||||
|
||||
|
||||
@router.post("/sessions/{id}/vote", response_model=VoteOut, status_code=status.HTTP_201_CREATED)
|
||||
async def submit_vote(
|
||||
id: uuid.UUID,
|
||||
payload: VoteCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> VoteOut:
|
||||
"""Submit a vote to a session.
|
||||
|
||||
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.
|
||||
"""
|
||||
session = await _get_session(db, id)
|
||||
|
||||
# Verify session is open
|
||||
if session.status != "open":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cette session de vote n'est pas ouverte",
|
||||
)
|
||||
|
||||
# Verify session hasn't ended
|
||||
if datetime.now(timezone.utc) > session.ends_at:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cette session de vote est terminee",
|
||||
)
|
||||
|
||||
# Check if voter already voted -- replace if so
|
||||
existing_result = await db.execute(
|
||||
select(Vote).where(
|
||||
Vote.session_id == session.id,
|
||||
Vote.voter_id == identity.id,
|
||||
)
|
||||
)
|
||||
existing_vote = existing_result.scalar_one_or_none()
|
||||
|
||||
if existing_vote is not None:
|
||||
# Deactivate old vote (keep for audit trail)
|
||||
existing_vote.is_active = False
|
||||
|
||||
# Update tallies: subtract old vote
|
||||
session.votes_total -= 1
|
||||
if existing_vote.vote_value == "for":
|
||||
session.votes_for -= 1
|
||||
if existing_vote.voter_is_smith:
|
||||
session.smith_votes_for -= 1
|
||||
if existing_vote.voter_is_techcomm:
|
||||
session.techcomm_votes_for -= 1
|
||||
elif existing_vote.vote_value == "against":
|
||||
session.votes_against -= 1
|
||||
|
||||
# Create new vote
|
||||
vote = Vote(
|
||||
session_id=session.id,
|
||||
voter_id=identity.id,
|
||||
vote_value=payload.vote_value,
|
||||
nuanced_level=payload.nuanced_level,
|
||||
comment=payload.comment,
|
||||
signature=payload.signature,
|
||||
signed_payload=payload.signed_payload,
|
||||
voter_wot_status=identity.wot_status,
|
||||
voter_is_smith=identity.is_smith,
|
||||
voter_is_techcomm=identity.is_techcomm,
|
||||
)
|
||||
db.add(vote)
|
||||
|
||||
# Update tallies: add new vote
|
||||
session.votes_total += 1
|
||||
if payload.vote_value == "for":
|
||||
session.votes_for += 1
|
||||
if identity.is_smith:
|
||||
session.smith_votes_for += 1
|
||||
if identity.is_techcomm:
|
||||
session.techcomm_votes_for += 1
|
||||
elif payload.vote_value == "against":
|
||||
session.votes_against += 1
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(vote)
|
||||
|
||||
return VoteOut.model_validate(vote)
|
||||
|
||||
|
||||
@router.get("/sessions/{id}/votes", response_model=list[VoteOut])
|
||||
async def list_votes(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
active_only: bool = Query(default=True, description="Ne montrer que les votes actifs"),
|
||||
) -> list[VoteOut]:
|
||||
"""List all votes in a session."""
|
||||
# Verify session exists
|
||||
await _get_session(db, id)
|
||||
|
||||
stmt = select(Vote).where(Vote.session_id == id)
|
||||
if active_only:
|
||||
stmt = stmt.where(Vote.is_active.is_(True))
|
||||
|
||||
stmt = stmt.order_by(Vote.created_at.asc())
|
||||
result = await db.execute(stmt)
|
||||
votes = result.scalars().all()
|
||||
|
||||
return [VoteOut.model_validate(v) for v in votes]
|
||||
|
||||
|
||||
@router.get("/sessions/{id}/result")
|
||||
async def get_vote_result(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Compute and return the current result for a vote session.
|
||||
|
||||
Uses the WoT threshold formula linked through the voting protocol.
|
||||
Returns current tallies, computed threshold, and whether the vote passes.
|
||||
"""
|
||||
session = await _get_session(db, id)
|
||||
|
||||
# Get the protocol and formula
|
||||
protocol = await _get_protocol_with_formula(db, session.voting_protocol_id)
|
||||
formula = protocol.formula_config
|
||||
|
||||
result_data = _compute_result(session, formula)
|
||||
|
||||
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,
|
||||
"smith_votes_for": session.smith_votes_for,
|
||||
"techcomm_votes_for": session.techcomm_votes_for,
|
||||
**result_data,
|
||||
}
|
||||
140
backend/app/routers/websocket.py
Normal file
140
backend/app/routers/websocket.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""WebSocket router: live vote updates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Connection manager ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""Manages active WebSocket connections grouped by vote session ID."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# session_id -> list of connected websockets
|
||||
self._connections: dict[uuid.UUID, list[WebSocket]] = {}
|
||||
|
||||
async def connect(self, websocket: WebSocket, session_id: uuid.UUID) -> None:
|
||||
"""Accept a WebSocket connection and register it for a vote session."""
|
||||
await websocket.accept()
|
||||
if session_id not in self._connections:
|
||||
self._connections[session_id] = []
|
||||
self._connections[session_id].append(websocket)
|
||||
|
||||
def disconnect(self, websocket: WebSocket, session_id: uuid.UUID) -> None:
|
||||
"""Remove a WebSocket connection from the session group."""
|
||||
if session_id in self._connections:
|
||||
self._connections[session_id] = [
|
||||
ws for ws in self._connections[session_id] if ws is not websocket
|
||||
]
|
||||
if not self._connections[session_id]:
|
||||
del self._connections[session_id]
|
||||
|
||||
async def broadcast(self, session_id: uuid.UUID, data: dict[str, Any]) -> None:
|
||||
"""Broadcast a message to all connections watching a given vote session."""
|
||||
if session_id not in self._connections:
|
||||
return
|
||||
|
||||
message = json.dumps(data, default=str)
|
||||
dead: list[WebSocket] = []
|
||||
|
||||
for ws in self._connections[session_id]:
|
||||
try:
|
||||
await ws.send_text(message)
|
||||
except Exception:
|
||||
dead.append(ws)
|
||||
|
||||
# Clean up dead connections
|
||||
for ws in dead:
|
||||
self.disconnect(ws, session_id)
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
|
||||
# ── WebSocket endpoint ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.websocket("/live")
|
||||
async def live_updates(websocket: WebSocket) -> None:
|
||||
"""WebSocket endpoint for live vote session updates.
|
||||
|
||||
The client connects and sends a JSON message with the session_id
|
||||
they want to subscribe to:
|
||||
|
||||
{ "action": "subscribe", "session_id": "<uuid>" }
|
||||
|
||||
The server will then push vote update events to the client:
|
||||
|
||||
{ "event": "vote_update", "session_id": "...", "votes_for": N, "votes_against": N, "votes_total": N }
|
||||
{ "event": "session_closed", "session_id": "...", "result": "adopted|rejected" }
|
||||
|
||||
The client can also unsubscribe:
|
||||
|
||||
{ "action": "unsubscribe", "session_id": "<uuid>" }
|
||||
"""
|
||||
await websocket.accept()
|
||||
subscribed_sessions: set[uuid.UUID] = set()
|
||||
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
await websocket.send_text(json.dumps({"error": "JSON invalide"}))
|
||||
continue
|
||||
|
||||
action = data.get("action")
|
||||
session_id_str = data.get("session_id")
|
||||
|
||||
if not action or not session_id_str:
|
||||
await websocket.send_text(
|
||||
json.dumps({"error": "Champs 'action' et 'session_id' requis"})
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
session_id = uuid.UUID(session_id_str)
|
||||
except ValueError:
|
||||
await websocket.send_text(json.dumps({"error": "session_id invalide"}))
|
||||
continue
|
||||
|
||||
if action == "subscribe":
|
||||
if session_id not in subscribed_sessions:
|
||||
# Register this websocket in the manager for this session
|
||||
if session_id not in manager._connections:
|
||||
manager._connections[session_id] = []
|
||||
manager._connections[session_id].append(websocket)
|
||||
subscribed_sessions.add(session_id)
|
||||
|
||||
await websocket.send_text(
|
||||
json.dumps({"event": "subscribed", "session_id": str(session_id)})
|
||||
)
|
||||
|
||||
elif action == "unsubscribe":
|
||||
if session_id in subscribed_sessions:
|
||||
manager.disconnect(websocket, session_id)
|
||||
subscribed_sessions.discard(session_id)
|
||||
|
||||
await websocket.send_text(
|
||||
json.dumps({"event": "unsubscribed", "session_id": str(session_id)})
|
||||
)
|
||||
|
||||
else:
|
||||
await websocket.send_text(
|
||||
json.dumps({"error": f"Action inconnue: {action}"})
|
||||
)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
# Clean up all subscriptions for this client
|
||||
for session_id in subscribed_sessions:
|
||||
manager.disconnect(websocket, session_id)
|
||||
Reference in New Issue
Block a user