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/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
96
backend/app/services/auth_service.py
Normal file
96
backend/app/services/auth_service.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Authentication service: challenge generation, token management, current user resolution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.user import DuniterIdentity, Session
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
"""SHA-256 hash of a bearer token for storage."""
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
async def create_session(db: AsyncSession, identity: DuniterIdentity) -> str:
|
||||
"""Create a new session for the given identity, return the raw bearer token."""
|
||||
raw_token = secrets.token_urlsafe(48)
|
||||
token_hash = _hash_token(raw_token)
|
||||
|
||||
session = Session(
|
||||
token_hash=token_hash,
|
||||
identity_id=identity.id,
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(hours=settings.TOKEN_EXPIRE_HOURS),
|
||||
)
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
|
||||
return raw_token
|
||||
|
||||
|
||||
async def invalidate_session(db: AsyncSession, token: str) -> None:
|
||||
"""Delete the session matching the given raw token."""
|
||||
token_hash = _hash_token(token)
|
||||
result = await db.execute(select(Session).where(Session.token_hash == token_hash))
|
||||
session = result.scalar_one_or_none()
|
||||
if session:
|
||||
await db.delete(session)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_current_identity(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
) -> DuniterIdentity:
|
||||
"""Dependency: resolve the current authenticated identity from the bearer token.
|
||||
|
||||
Raises 401 if the token is missing, invalid, or expired.
|
||||
"""
|
||||
if credentials is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentification requise")
|
||||
|
||||
token_hash = _hash_token(credentials.credentials)
|
||||
result = await db.execute(
|
||||
select(Session).where(
|
||||
Session.token_hash == token_hash,
|
||||
Session.expires_at > datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
|
||||
if session is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token invalide ou expire")
|
||||
|
||||
result = await db.execute(select(DuniterIdentity).where(DuniterIdentity.id == session.identity_id))
|
||||
identity = result.scalar_one_or_none()
|
||||
|
||||
if identity is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Identite introuvable")
|
||||
|
||||
return identity
|
||||
|
||||
|
||||
async def get_or_create_identity(db: AsyncSession, address: str) -> DuniterIdentity:
|
||||
"""Get an existing identity by address or create a new one."""
|
||||
result = await db.execute(select(DuniterIdentity).where(DuniterIdentity.address == address))
|
||||
identity = result.scalar_one_or_none()
|
||||
|
||||
if identity is None:
|
||||
identity = DuniterIdentity(address=address)
|
||||
db.add(identity)
|
||||
await db.commit()
|
||||
await db.refresh(identity)
|
||||
|
||||
return identity
|
||||
87
backend/app/services/blockchain_service.py
Normal file
87
backend/app/services/blockchain_service.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Blockchain service: retrieve on-chain data from Duniter V2.
|
||||
|
||||
Provides functions to query WoT size, Smith sub-WoT size, and
|
||||
Technical Committee size from the Duniter V2 blockchain.
|
||||
|
||||
Currently stubbed with hardcoded values matching GDev test data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
async def get_wot_size() -> int:
|
||||
"""Return the current number of WoT members.
|
||||
|
||||
TODO: Implement real RPC call using substrate-interface::
|
||||
|
||||
from substrateinterface import SubstrateInterface
|
||||
from app.config import settings
|
||||
|
||||
substrate = SubstrateInterface(url=settings.DUNITER_RPC_URL)
|
||||
|
||||
# Query membership count
|
||||
result = substrate.query(
|
||||
module="Membership",
|
||||
storage_function="MembershipCount",
|
||||
)
|
||||
return int(result.value)
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
Number of WoT members. Currently returns 7224 (GDev snapshot).
|
||||
"""
|
||||
# TODO: Replace with real substrate-interface RPC call
|
||||
return 7224
|
||||
|
||||
|
||||
async def get_smith_size() -> int:
|
||||
"""Return the current number of Smith members (forgerons).
|
||||
|
||||
TODO: Implement real RPC call using substrate-interface::
|
||||
|
||||
from substrateinterface import SubstrateInterface
|
||||
from app.config import settings
|
||||
|
||||
substrate = SubstrateInterface(url=settings.DUNITER_RPC_URL)
|
||||
|
||||
# Query Smith membership count
|
||||
result = substrate.query(
|
||||
module="SmithMembers",
|
||||
storage_function="SmithMembershipCount",
|
||||
)
|
||||
return int(result.value)
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
Number of Smith members. Currently returns 20 (GDev snapshot).
|
||||
"""
|
||||
# TODO: Replace with real substrate-interface RPC call
|
||||
return 20
|
||||
|
||||
|
||||
async def get_techcomm_size() -> int:
|
||||
"""Return the current number of Technical Committee members.
|
||||
|
||||
TODO: Implement real RPC call using substrate-interface::
|
||||
|
||||
from substrateinterface import SubstrateInterface
|
||||
from app.config import settings
|
||||
|
||||
substrate = SubstrateInterface(url=settings.DUNITER_RPC_URL)
|
||||
|
||||
# Query TechComm member count
|
||||
result = substrate.query(
|
||||
module="TechnicalCommittee",
|
||||
storage_function="Members",
|
||||
)
|
||||
return len(result.value) if result.value else 0
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
Number of TechComm members. Currently returns 5 (GDev snapshot).
|
||||
"""
|
||||
# TODO: Replace with real substrate-interface RPC call
|
||||
return 5
|
||||
117
backend/app/services/decision_service.py
Normal file
117
backend/app/services/decision_service.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Decision service: step advancement logic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.decision import Decision, DecisionStep
|
||||
|
||||
|
||||
# Valid status transitions for decisions
|
||||
_DECISION_STATUS_ORDER = [
|
||||
"draft",
|
||||
"qualification",
|
||||
"review",
|
||||
"voting",
|
||||
"executed",
|
||||
"closed",
|
||||
]
|
||||
|
||||
|
||||
async def advance_decision(decision_id: uuid.UUID, db: AsyncSession) -> Decision:
|
||||
"""Move a decision to its next step.
|
||||
|
||||
Completes the current active step and activates the next pending step.
|
||||
If no more steps remain, the decision status advances to the next phase.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
decision_id:
|
||||
UUID of the Decision to advance.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Decision
|
||||
The updated decision.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the decision is not found, or no further advancement is possible.
|
||||
"""
|
||||
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 ValueError(f"Decision introuvable : {decision_id}")
|
||||
|
||||
if decision.status == "closed":
|
||||
raise ValueError("La decision est deja cloturee")
|
||||
|
||||
steps: list[DecisionStep] = sorted(decision.steps, key=lambda s: s.step_order)
|
||||
|
||||
# Find the current active step
|
||||
active_step: DecisionStep | None = None
|
||||
for step in steps:
|
||||
if step.status == "active":
|
||||
active_step = step
|
||||
break
|
||||
|
||||
if active_step is not None:
|
||||
# Complete the active step
|
||||
active_step.status = "completed"
|
||||
|
||||
# Activate the next pending step
|
||||
next_step: DecisionStep | None = None
|
||||
for step in steps:
|
||||
if step.step_order > active_step.step_order and step.status == "pending":
|
||||
next_step = step
|
||||
break
|
||||
|
||||
if next_step is not None:
|
||||
next_step.status = "active"
|
||||
else:
|
||||
# No more steps: advance the decision status
|
||||
_advance_decision_status(decision)
|
||||
else:
|
||||
# No active step: try to activate the first pending step
|
||||
first_pending: DecisionStep | None = None
|
||||
for step in steps:
|
||||
if step.status == "pending":
|
||||
first_pending = step
|
||||
break
|
||||
|
||||
if first_pending is not None:
|
||||
first_pending.status = "active"
|
||||
# Also advance decision out of draft if needed
|
||||
if decision.status == "draft":
|
||||
decision.status = "qualification"
|
||||
else:
|
||||
# All steps are completed: advance the decision status
|
||||
_advance_decision_status(decision)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(decision)
|
||||
|
||||
return decision
|
||||
|
||||
|
||||
def _advance_decision_status(decision: Decision) -> None:
|
||||
"""Move a decision to its next status in the lifecycle."""
|
||||
try:
|
||||
current_index = _DECISION_STATUS_ORDER.index(decision.status)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
next_index = current_index + 1
|
||||
if next_index < len(_DECISION_STATUS_ORDER):
|
||||
decision.status = _DECISION_STATUS_ORDER[next_index]
|
||||
108
backend/app/services/document_service.py
Normal file
108
backend/app/services/document_service.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Document service: retrieval and version management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.document import Document, DocumentItem, ItemVersion
|
||||
|
||||
|
||||
async def get_document_with_items(slug: str, db: AsyncSession) -> Document | None:
|
||||
"""Load a document with all its items and their versions, eagerly.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
slug:
|
||||
Unique slug of the document.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Document | None
|
||||
The document with items and versions loaded, or None if not found.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Document)
|
||||
.options(
|
||||
selectinload(Document.items).selectinload(DocumentItem.versions)
|
||||
)
|
||||
.where(Document.slug == slug)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def apply_version(
|
||||
item_id: uuid.UUID,
|
||||
version_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> DocumentItem:
|
||||
"""Apply an accepted version to a document item.
|
||||
|
||||
This replaces the item's current_text with the version's proposed_text
|
||||
and marks the version as 'accepted'.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
item_id:
|
||||
UUID of the DocumentItem to update.
|
||||
version_id:
|
||||
UUID of the ItemVersion to apply.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
DocumentItem
|
||||
The updated document item.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the item or version is not found, or the version does not
|
||||
belong to the item.
|
||||
"""
|
||||
# Load item
|
||||
item_result = await db.execute(
|
||||
select(DocumentItem).where(DocumentItem.id == item_id)
|
||||
)
|
||||
item = item_result.scalar_one_or_none()
|
||||
if item is None:
|
||||
raise ValueError(f"Element de document introuvable : {item_id}")
|
||||
|
||||
# Load version
|
||||
version_result = await db.execute(
|
||||
select(ItemVersion).where(ItemVersion.id == version_id)
|
||||
)
|
||||
version = version_result.scalar_one_or_none()
|
||||
if version is None:
|
||||
raise ValueError(f"Version introuvable : {version_id}")
|
||||
|
||||
if version.item_id != item.id:
|
||||
raise ValueError(
|
||||
f"La version {version_id} n'appartient pas a l'element {item_id}"
|
||||
)
|
||||
|
||||
# Apply the version
|
||||
item.current_text = version.proposed_text
|
||||
version.status = "accepted"
|
||||
|
||||
# Mark all other pending/voting versions for this item as rejected
|
||||
other_versions_result = await db.execute(
|
||||
select(ItemVersion).where(
|
||||
ItemVersion.item_id == item_id,
|
||||
ItemVersion.id != version_id,
|
||||
ItemVersion.status.in_(["proposed", "voting"]),
|
||||
)
|
||||
)
|
||||
for other_version in other_versions_result.scalars():
|
||||
other_version.status = "rejected"
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
|
||||
return item
|
||||
118
backend/app/services/mandate_service.py
Normal file
118
backend/app/services/mandate_service.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Mandate service: step advancement logic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.mandate import Mandate, MandateStep
|
||||
|
||||
|
||||
# Valid status transitions for mandates
|
||||
_MANDATE_STATUS_ORDER = [
|
||||
"draft",
|
||||
"candidacy",
|
||||
"voting",
|
||||
"active",
|
||||
"reporting",
|
||||
"completed",
|
||||
]
|
||||
|
||||
|
||||
async def advance_mandate(mandate_id: uuid.UUID, db: AsyncSession) -> Mandate:
|
||||
"""Move a mandate to its next step.
|
||||
|
||||
Completes the current active step and activates the next pending step.
|
||||
If no more steps remain, the mandate status advances to the next phase.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
mandate_id:
|
||||
UUID of the Mandate to advance.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Mandate
|
||||
The updated mandate.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the mandate is not found, already completed/revoked, or
|
||||
no further advancement is possible.
|
||||
"""
|
||||
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 ValueError(f"Mandat introuvable : {mandate_id}")
|
||||
|
||||
if mandate.status in ("completed", "revoked"):
|
||||
raise ValueError(f"Le mandat est deja en statut terminal : {mandate.status}")
|
||||
|
||||
steps: list[MandateStep] = sorted(mandate.steps, key=lambda s: s.step_order)
|
||||
|
||||
# Find the current active step
|
||||
active_step: MandateStep | None = None
|
||||
for step in steps:
|
||||
if step.status == "active":
|
||||
active_step = step
|
||||
break
|
||||
|
||||
if active_step is not None:
|
||||
# Complete the active step
|
||||
active_step.status = "completed"
|
||||
|
||||
# Activate the next pending step
|
||||
next_step: MandateStep | None = None
|
||||
for step in steps:
|
||||
if step.step_order > active_step.step_order and step.status == "pending":
|
||||
next_step = step
|
||||
break
|
||||
|
||||
if next_step is not None:
|
||||
next_step.status = "active"
|
||||
else:
|
||||
# No more steps: advance mandate status
|
||||
_advance_mandate_status(mandate)
|
||||
else:
|
||||
# No active step: activate the first pending one
|
||||
first_pending: MandateStep | None = None
|
||||
for step in steps:
|
||||
if step.status == "pending":
|
||||
first_pending = step
|
||||
break
|
||||
|
||||
if first_pending is not None:
|
||||
first_pending.status = "active"
|
||||
# Move out of draft
|
||||
if mandate.status == "draft":
|
||||
mandate.status = "candidacy"
|
||||
else:
|
||||
# All steps completed: advance status
|
||||
_advance_mandate_status(mandate)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(mandate)
|
||||
|
||||
return mandate
|
||||
|
||||
|
||||
def _advance_mandate_status(mandate: Mandate) -> None:
|
||||
"""Move a mandate to its next status in the lifecycle."""
|
||||
try:
|
||||
current_index = _MANDATE_STATUS_ORDER.index(mandate.status)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
next_index = current_index + 1
|
||||
if next_index < len(_MANDATE_STATUS_ORDER):
|
||||
mandate.status = _MANDATE_STATUS_ORDER[next_index]
|
||||
123
backend/app/services/sanctuary_service.py
Normal file
123
backend/app/services/sanctuary_service.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Sanctuary service: immutable archival to IPFS + on-chain hash.
|
||||
|
||||
The sanctuary is the immutable layer of Glibredecision. Every adopted
|
||||
document version, decision result, or vote tally is hashed (SHA-256),
|
||||
stored on IPFS, and anchored on-chain via system.remark.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.sanctuary import SanctuaryEntry
|
||||
|
||||
|
||||
async def archive_to_sanctuary(
|
||||
entry_type: str,
|
||||
reference_id: uuid.UUID,
|
||||
content: str,
|
||||
title: str,
|
||||
db: AsyncSession,
|
||||
) -> SanctuaryEntry:
|
||||
"""Hash content and create a sanctuary entry.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
entry_type:
|
||||
Type of the archived entity (``"document"``, ``"decision"``,
|
||||
``"vote_result"``).
|
||||
reference_id:
|
||||
UUID of the source entity (document, decision, or vote session).
|
||||
content:
|
||||
The full text content to archive and hash.
|
||||
title:
|
||||
Human-readable title for the archive entry.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
SanctuaryEntry
|
||||
The newly created sanctuary entry with content_hash set.
|
||||
"""
|
||||
# Compute SHA-256 hash of the content
|
||||
content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
|
||||
# Build metadata
|
||||
metadata = {
|
||||
"archived_at": datetime.now(timezone.utc).isoformat(),
|
||||
"entry_type": entry_type,
|
||||
"content_length": len(content),
|
||||
}
|
||||
|
||||
entry = SanctuaryEntry(
|
||||
entry_type=entry_type,
|
||||
reference_id=reference_id,
|
||||
title=title,
|
||||
content_hash=content_hash,
|
||||
metadata_json=json.dumps(metadata, ensure_ascii=False),
|
||||
)
|
||||
|
||||
# TODO: Upload content to IPFS via kubo HTTP API
|
||||
# ipfs_cid = await _upload_to_ipfs(content)
|
||||
# entry.ipfs_cid = ipfs_cid
|
||||
|
||||
# TODO: Anchor hash on-chain via system.remark
|
||||
# tx_hash, block_number = await _anchor_on_chain(content_hash)
|
||||
# entry.chain_tx_hash = tx_hash
|
||||
# entry.chain_block = block_number
|
||||
|
||||
db.add(entry)
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
async def _upload_to_ipfs(content: str) -> str:
|
||||
"""Upload content to IPFS via kubo HTTP API.
|
||||
|
||||
TODO: Implement using httpx against settings.IPFS_API_URL.
|
||||
|
||||
Example::
|
||||
|
||||
import httpx
|
||||
from app.config import settings
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{settings.IPFS_API_URL}/api/v0/add",
|
||||
files={"file": ("content.txt", content.encode("utf-8"))},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["Hash"]
|
||||
"""
|
||||
raise NotImplementedError("IPFS upload pas encore implemente")
|
||||
|
||||
|
||||
async def _anchor_on_chain(content_hash: str) -> tuple[str, int]:
|
||||
"""Anchor a content hash on-chain via system.remark.
|
||||
|
||||
TODO: Implement using substrate-interface.
|
||||
|
||||
Example::
|
||||
|
||||
from substrateinterface import SubstrateInterface
|
||||
from app.config import settings
|
||||
|
||||
substrate = SubstrateInterface(url=settings.DUNITER_RPC_URL)
|
||||
call = substrate.compose_call(
|
||||
call_module="System",
|
||||
call_function="remark",
|
||||
call_params={"remark": f"glibredecision:sanctuary:{content_hash}"},
|
||||
)
|
||||
extrinsic = substrate.create_signed_extrinsic(call=call, keypair=keypair)
|
||||
receipt = substrate.submit_extrinsic(extrinsic, wait_for_inclusion=True)
|
||||
return receipt.extrinsic_hash, receipt.block_number
|
||||
"""
|
||||
raise NotImplementedError("Ancrage on-chain pas encore implemente")
|
||||
199
backend/app/services/vote_service.py
Normal file
199
backend/app/services/vote_service.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""Vote service: compute results and verify vote signatures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.engine.mode_params import parse_mode_params
|
||||
from app.engine.nuanced_vote import evaluate_nuanced
|
||||
from app.engine.smith_threshold import smith_threshold
|
||||
from app.engine.techcomm_threshold import techcomm_threshold
|
||||
from app.engine.threshold import wot_threshold
|
||||
from app.models.protocol import FormulaConfig, VotingProtocol
|
||||
from app.models.vote import Vote, VoteSession
|
||||
|
||||
|
||||
async def compute_result(session_id: uuid.UUID, db: AsyncSession) -> dict:
|
||||
"""Load a vote session, its protocol and formula, compute thresholds, and tally.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
session_id:
|
||||
UUID of the VoteSession to tally.
|
||||
db:
|
||||
Async database session.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Result dict with keys: threshold, votes_for, votes_against,
|
||||
votes_total, adopted, smith_ok, techcomm_ok, details.
|
||||
"""
|
||||
# Load session with votes eagerly
|
||||
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 ValueError(f"Session de vote introuvable : {session_id}")
|
||||
|
||||
# Load protocol + formula config
|
||||
proto_result = await db.execute(
|
||||
select(VotingProtocol)
|
||||
.options(selectinload(VotingProtocol.formula_config))
|
||||
.where(VotingProtocol.id == session.voting_protocol_id)
|
||||
)
|
||||
protocol = proto_result.scalar_one_or_none()
|
||||
if protocol is None:
|
||||
raise ValueError(f"Protocole de vote introuvable pour la session {session_id}")
|
||||
|
||||
formula: FormulaConfig = protocol.formula_config
|
||||
|
||||
# If mode_params is set on the protocol, it overrides formula_config values
|
||||
if protocol.mode_params:
|
||||
params = parse_mode_params(protocol.mode_params)
|
||||
else:
|
||||
params = {
|
||||
"majority_pct": formula.majority_pct,
|
||||
"base_exponent": formula.base_exponent,
|
||||
"gradient_exponent": formula.gradient_exponent,
|
||||
"constant_base": formula.constant_base,
|
||||
"smith_exponent": formula.smith_exponent,
|
||||
"techcomm_exponent": formula.techcomm_exponent,
|
||||
}
|
||||
|
||||
# Separate vote types
|
||||
active_votes: list[Vote] = [v for v in session.votes if v.is_active]
|
||||
|
||||
if protocol.vote_type == "nuanced":
|
||||
return await _compute_nuanced(session, active_votes, formula, params, db)
|
||||
|
||||
# --- Binary vote ---
|
||||
votes_for = sum(1 for v in active_votes if v.vote_value == "for")
|
||||
votes_against = sum(1 for v in active_votes if v.vote_value == "against")
|
||||
total = votes_for + votes_against
|
||||
|
||||
# WoT threshold
|
||||
threshold = wot_threshold(
|
||||
wot_size=session.wot_size,
|
||||
total_votes=total,
|
||||
majority_pct=params.get("majority_pct", 50),
|
||||
base_exponent=params.get("base_exponent", 0.1),
|
||||
gradient_exponent=params.get("gradient_exponent", 0.2),
|
||||
constant_base=params.get("constant_base", 0.0),
|
||||
)
|
||||
|
||||
# Smith criterion (optional)
|
||||
smith_ok = True
|
||||
smith_required = None
|
||||
if params.get("smith_exponent") is not None and session.smith_size > 0:
|
||||
smith_required = smith_threshold(session.smith_size, params["smith_exponent"])
|
||||
smith_votes = sum(1 for v in active_votes if v.voter_is_smith and v.vote_value == "for")
|
||||
smith_ok = smith_votes >= smith_required
|
||||
|
||||
# TechComm criterion (optional)
|
||||
techcomm_ok = True
|
||||
techcomm_required = None
|
||||
if params.get("techcomm_exponent") is not None and session.techcomm_size > 0:
|
||||
techcomm_required = techcomm_threshold(session.techcomm_size, params["techcomm_exponent"])
|
||||
techcomm_votes = sum(1 for v in active_votes if v.voter_is_techcomm and v.vote_value == "for")
|
||||
techcomm_ok = techcomm_votes >= techcomm_required
|
||||
|
||||
adopted = votes_for >= threshold and smith_ok and techcomm_ok
|
||||
vote_result = "adopted" if adopted else "rejected"
|
||||
|
||||
# Update session tallies
|
||||
session.votes_for = votes_for
|
||||
session.votes_against = votes_against
|
||||
session.votes_total = total
|
||||
session.threshold_required = float(threshold)
|
||||
session.result = vote_result
|
||||
session.status = "tallied"
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"threshold": threshold,
|
||||
"votes_for": votes_for,
|
||||
"votes_against": votes_against,
|
||||
"votes_total": total,
|
||||
"adopted": adopted,
|
||||
"smith_ok": smith_ok,
|
||||
"smith_required": smith_required,
|
||||
"techcomm_ok": techcomm_ok,
|
||||
"techcomm_required": techcomm_required,
|
||||
"result": vote_result,
|
||||
}
|
||||
|
||||
|
||||
async def _compute_nuanced(
|
||||
session: VoteSession,
|
||||
active_votes: list[Vote],
|
||||
formula: FormulaConfig,
|
||||
params: dict,
|
||||
db: AsyncSession,
|
||||
) -> dict:
|
||||
"""Compute a nuanced vote result."""
|
||||
vote_levels = [v.nuanced_level for v in active_votes if v.nuanced_level is not None]
|
||||
|
||||
threshold_pct = formula.nuanced_threshold_pct or 80
|
||||
min_participants = formula.nuanced_min_participants or 59
|
||||
|
||||
evaluation = evaluate_nuanced(
|
||||
votes=vote_levels,
|
||||
threshold_pct=threshold_pct,
|
||||
min_participants=min_participants,
|
||||
)
|
||||
|
||||
vote_result = "adopted" if evaluation["adopted"] else "rejected"
|
||||
|
||||
session.votes_total = evaluation["total"]
|
||||
session.votes_for = evaluation["positive_count"]
|
||||
session.votes_against = evaluation["total"] - evaluation["positive_count"]
|
||||
session.threshold_required = float(threshold_pct)
|
||||
session.result = vote_result
|
||||
session.status = "tallied"
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"vote_type": "nuanced",
|
||||
"result": vote_result,
|
||||
**evaluation,
|
||||
}
|
||||
|
||||
|
||||
async def verify_vote_signature(address: str, signature: str, payload: str) -> bool:
|
||||
"""Verify an Ed25519 signature from a Duniter V2 address.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
address:
|
||||
SS58 address of the voter.
|
||||
signature:
|
||||
Hex-encoded Ed25519 signature.
|
||||
payload:
|
||||
The original message that was signed.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if the signature is valid.
|
||||
|
||||
TODO
|
||||
----
|
||||
Implement actual Ed25519 verification using substrate-interface:
|
||||
|
||||
from substrateinterface import Keypair
|
||||
keypair = Keypair(ss58_address=address, crypto_type=KeypairType.ED25519)
|
||||
return keypair.verify(payload.encode(), bytes.fromhex(signature))
|
||||
"""
|
||||
# TODO: Implement real Ed25519 verification with substrate-interface
|
||||
# For now, accept all signatures in development mode
|
||||
if not address or not signature or not payload:
|
||||
return False
|
||||
return True
|
||||
Reference in New Issue
Block a user