v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/

- crates/ld-engine : formule inertielle (parité avec la maquette, garde C),
  coquille / changement de fond, assistant d'édition à règles, composition
  d'un document sous vote permanent (adoptions, aperçus, rivales).
- crates/trustin-access : qui voit, qui ouvre, qui gère les accès.
- crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de
  connect.monnaie-libre.fr), invitations, entrée en vigueur automatique,
  signalements de contradiction, traductions éditoriales, publication git,
  taille de la toile lue sur la chaîne.
- seeds/acte-engagement-certification.json : 33 articles, fr + en + es ;
  en vigueur : préambule, E1, E2 (seed a minima).
- maquette/ : frontend, backend et docker v2 déplacés tels quels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 02:04:42 +02:00
co-authored by Claude Opus 5.5
parent 4b1732916d
commit 67ddee28b2
275 changed files with 8979 additions and 0 deletions
@@ -0,0 +1,119 @@
"""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,
dev_profile: dict | None = None,
) -> DuniterIdentity:
"""Get an existing identity by address or create a new one.
If dev_profile is provided, apply the profile attributes on create or update.
"""
result = await db.execute(select(DuniterIdentity).where(DuniterIdentity.address == address))
identity = result.scalar_one_or_none()
if identity is None:
kwargs: dict = {"address": address}
if dev_profile:
kwargs.update({
"display_name": dev_profile.get("display_name"),
"wot_status": dev_profile.get("wot_status", "unknown"),
"is_smith": dev_profile.get("is_smith", False),
"is_techcomm": dev_profile.get("is_techcomm", False),
})
identity = DuniterIdentity(**kwargs)
db.add(identity)
await db.commit()
await db.refresh(identity)
elif dev_profile:
# Update existing identity with dev profile data
identity.display_name = dev_profile.get("display_name", identity.display_name)
identity.wot_status = dev_profile.get("wot_status", identity.wot_status)
identity.is_smith = dev_profile.get("is_smith", identity.is_smith)
identity.is_techcomm = dev_profile.get("is_techcomm", identity.is_techcomm)
await db.commit()
await db.refresh(identity)
return identity
@@ -0,0 +1,304 @@
"""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.
Architecture:
1. Check database cache (via cache_service)
2. Try JSON-RPC call to Duniter node
3. Fall back to hardcoded GDev test values (with warning log)
All public functions accept a db session for cache access.
"""
from __future__ import annotations
import logging
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.services import cache_service
logger = logging.getLogger(__name__)
# Hardcoded fallback values from GDev snapshot
_FALLBACK_WOT_SIZE = 7224
_FALLBACK_SMITH_SIZE = 20
_FALLBACK_TECHCOMM_SIZE = 5
# Cache key prefixes
_CACHE_KEY_WOT = "blockchain:wot_size"
_CACHE_KEY_SMITH = "blockchain:smith_size"
_CACHE_KEY_TECHCOMM = "blockchain:techcomm_size"
async def _fetch_from_rpc(method: str, params: list | None = None) -> dict | None:
"""Send a JSON-RPC POST request to the Duniter node.
Uses the HTTP variant of the RPC URL. If DUNITER_RPC_URL starts with
``wss://`` or ``ws://``, it is converted to ``https://`` or ``http://``
for the HTTP JSON-RPC endpoint.
Parameters
----------
method:
The RPC method name (e.g. ``"state_getStorage"``).
params:
Optional list of parameters for the RPC call.
Returns
-------
dict | None
The ``"result"`` field from the JSON-RPC response, or None on error.
"""
# Convert WebSocket URL to HTTP for JSON-RPC
rpc_url = settings.DUNITER_RPC_URL
if rpc_url.startswith("wss://"):
rpc_url = rpc_url.replace("wss://", "https://", 1)
elif rpc_url.startswith("ws://"):
rpc_url = rpc_url.replace("ws://", "http://", 1)
# Strip /ws suffix if present
if rpc_url.endswith("/ws"):
rpc_url = rpc_url[:-3]
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params or [],
}
try:
async with httpx.AsyncClient(
timeout=settings.DUNITER_RPC_TIMEOUT_SECONDS
) as client:
response = await client.post(rpc_url, json=payload)
response.raise_for_status()
data = response.json()
if "error" in data:
logger.warning(
"Erreur RPC Duniter pour %s: %s",
method, data["error"],
)
return None
return data.get("result")
except httpx.ConnectError:
logger.warning(
"Impossible de se connecter au noeud Duniter (%s)", rpc_url
)
return None
except httpx.TimeoutException:
logger.warning(
"Timeout lors de l'appel RPC Duniter pour %s (%s)",
method, rpc_url,
)
return None
except httpx.HTTPStatusError as exc:
logger.warning(
"Erreur HTTP Duniter RPC pour %s: %s",
method, exc.response.status_code,
)
return None
except Exception:
logger.warning(
"Erreur inattendue lors de l'appel RPC Duniter pour %s",
method,
exc_info=True,
)
return None
async def _fetch_membership_count(db: AsyncSession) -> int | None:
"""Fetch WoT membership count from the Duniter RPC.
Queries ``membership_membershipsCount`` via state RPC.
Returns
-------
int | None
The membership count, or None if the RPC call failed.
"""
# Try runtime API call for membership count
result = await _fetch_from_rpc("membership_membershipsCount", [])
if result is not None:
try:
count = int(result)
# Cache the result
await cache_service.set_cached(
_CACHE_KEY_WOT,
{"value": count},
db,
ttl_seconds=settings.BLOCKCHAIN_CACHE_TTL_SECONDS,
)
return count
except (ValueError, TypeError):
logger.warning("Reponse RPC invalide pour membership count: %s", result)
return None
async def _fetch_smith_count(db: AsyncSession) -> int | None:
"""Fetch Smith membership count from the Duniter RPC.
Returns
-------
int | None
The Smith member count, or None if the RPC call failed.
"""
result = await _fetch_from_rpc("smithMembers_smithMembersCount", [])
if result is not None:
try:
count = int(result)
await cache_service.set_cached(
_CACHE_KEY_SMITH,
{"value": count},
db,
ttl_seconds=settings.BLOCKCHAIN_CACHE_TTL_SECONDS,
)
return count
except (ValueError, TypeError):
logger.warning("Reponse RPC invalide pour smith count: %s", result)
return None
async def _fetch_techcomm_count(db: AsyncSession) -> int | None:
"""Fetch Technical Committee member count from the Duniter RPC.
Returns
-------
int | None
The TechComm member count, or None if the RPC call failed.
"""
result = await _fetch_from_rpc("technicalCommittee_members", [])
if result is not None:
try:
if isinstance(result, list):
count = len(result)
else:
count = int(result)
await cache_service.set_cached(
_CACHE_KEY_TECHCOMM,
{"value": count},
db,
ttl_seconds=settings.BLOCKCHAIN_CACHE_TTL_SECONDS,
)
return count
except (ValueError, TypeError):
logger.warning("Reponse RPC invalide pour techcomm count: %s", result)
return None
async def get_wot_size(db: AsyncSession) -> int:
"""Return the current number of WoT members.
Resolution order:
1. Database cache (if not expired)
2. Duniter RPC call
3. Hardcoded fallback (7224, GDev snapshot)
Parameters
----------
db:
Async database session (for cache access).
Returns
-------
int
Number of WoT members.
"""
# 1. Try cache
cached = await cache_service.get_cached(_CACHE_KEY_WOT, db)
if cached is not None:
return cached["value"]
# 2. Try RPC
rpc_value = await _fetch_membership_count(db)
if rpc_value is not None:
return rpc_value
# 3. Fallback
logger.warning(
"Utilisation de la valeur WoT par defaut (%d) - "
"cache et RPC indisponibles",
_FALLBACK_WOT_SIZE,
)
return _FALLBACK_WOT_SIZE
async def get_smith_size(db: AsyncSession) -> int:
"""Return the current number of Smith members (forgerons).
Resolution order:
1. Database cache (if not expired)
2. Duniter RPC call
3. Hardcoded fallback (20, GDev snapshot)
Parameters
----------
db:
Async database session (for cache access).
Returns
-------
int
Number of Smith members.
"""
# 1. Try cache
cached = await cache_service.get_cached(_CACHE_KEY_SMITH, db)
if cached is not None:
return cached["value"]
# 2. Try RPC
rpc_value = await _fetch_smith_count(db)
if rpc_value is not None:
return rpc_value
# 3. Fallback
logger.warning(
"Utilisation de la valeur Smith par defaut (%d) - "
"cache et RPC indisponibles",
_FALLBACK_SMITH_SIZE,
)
return _FALLBACK_SMITH_SIZE
async def get_techcomm_size(db: AsyncSession) -> int:
"""Return the current number of Technical Committee members.
Resolution order:
1. Database cache (if not expired)
2. Duniter RPC call
3. Hardcoded fallback (5, GDev snapshot)
Parameters
----------
db:
Async database session (for cache access).
Returns
-------
int
Number of TechComm members.
"""
# 1. Try cache
cached = await cache_service.get_cached(_CACHE_KEY_TECHCOMM, db)
if cached is not None:
return cached["value"]
# 2. Try RPC
rpc_value = await _fetch_techcomm_count(db)
if rpc_value is not None:
return rpc_value
# 3. Fallback
logger.warning(
"Utilisation de la valeur TechComm par defaut (%d) - "
"cache et RPC indisponibles",
_FALLBACK_TECHCOMM_SIZE,
)
return _FALLBACK_TECHCOMM_SIZE
@@ -0,0 +1,140 @@
"""Cache service: blockchain data caching with TTL expiry.
Uses the BlockchainCache model (PostgreSQL/JSONB) to cache
on-chain data like WoT size, Smith size, and TechComm size.
Avoids repeated RPC calls to the Duniter node.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.cache import BlockchainCache
logger = logging.getLogger(__name__)
async def get_cached(key: str, db: AsyncSession) -> dict | None:
"""Retrieve a cached value by key if it has not expired.
Parameters
----------
key:
The cache key to look up.
db:
Async database session.
Returns
-------
dict | None
The cached value as a dict, or None if missing/expired.
"""
result = await db.execute(
select(BlockchainCache).where(
BlockchainCache.cache_key == key,
BlockchainCache.expires_at > datetime.now(timezone.utc),
)
)
entry = result.scalar_one_or_none()
if entry is None:
logger.debug("Cache miss pour la cle '%s'", key)
return None
logger.debug("Cache hit pour la cle '%s'", key)
return entry.cache_value
async def set_cached(
key: str,
value: dict,
db: AsyncSession,
ttl_seconds: int = 3600,
) -> None:
"""Store a value in the cache with the given TTL.
If the key already exists, it is replaced (upsert).
Parameters
----------
key:
The cache key.
value:
The value to store (must be JSON-serializable).
db:
Async database session.
ttl_seconds:
Time-to-live in seconds (default: 1 hour).
"""
now = datetime.now(timezone.utc)
expires_at = now + timedelta(seconds=ttl_seconds)
# Check if key already exists
result = await db.execute(
select(BlockchainCache).where(BlockchainCache.cache_key == key)
)
existing = result.scalar_one_or_none()
if existing is not None:
existing.cache_value = value
existing.fetched_at = now
existing.expires_at = expires_at
logger.debug("Cache mis a jour pour la cle '%s' (TTL=%ds)", key, ttl_seconds)
else:
entry = BlockchainCache(
cache_key=key,
cache_value=value,
fetched_at=now,
expires_at=expires_at,
)
db.add(entry)
logger.debug("Cache cree pour la cle '%s' (TTL=%ds)", key, ttl_seconds)
await db.commit()
async def invalidate(key: str, db: AsyncSession) -> None:
"""Remove a specific cache entry by key.
Parameters
----------
key:
The cache key to invalidate.
db:
Async database session.
"""
await db.execute(
delete(BlockchainCache).where(BlockchainCache.cache_key == key)
)
await db.commit()
logger.debug("Cache invalide pour la cle '%s'", key)
async def cleanup_expired(db: AsyncSession) -> int:
"""Remove all expired cache entries.
Parameters
----------
db:
Async database session.
Returns
-------
int
Number of entries removed.
"""
result = await db.execute(
delete(BlockchainCache).where(
BlockchainCache.expires_at <= datetime.now(timezone.utc)
)
)
await db.commit()
count = result.rowcount
if count > 0:
logger.info("Nettoyage cache: %d entrees expirees supprimees", count)
return count
@@ -0,0 +1,207 @@
"""Decision service: step advancement logic and vote session integration."""
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.decision import Decision, DecisionStep
from app.models.protocol import VotingProtocol, FormulaConfig
from app.models.vote import VoteSession
# 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]
async def create_vote_session_for_step(
decision_id: uuid.UUID,
step_id: uuid.UUID,
db: AsyncSession,
) -> VoteSession:
"""Create a VoteSession linked to a decision step.
Uses the decision's voting_protocol to configure the session.
Raises ValueError if the decision or step is not found, the step
already has a vote session, or no voting protocol is configured.
Parameters
----------
decision_id:
UUID of the Decision.
step_id:
UUID of the DecisionStep to attach the vote session to.
db:
Async database session.
Returns
-------
VoteSession
The newly created vote session.
"""
# Fetch decision with steps
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}")
# Find the step
step: DecisionStep | None = None
for s in decision.steps:
if s.id == step_id:
step = s
break
if step is None:
raise ValueError(f"Etape introuvable : {step_id}")
# Check step doesn't already have a vote session
if step.vote_session_id is not None:
raise ValueError("Cette etape possede deja une session de vote")
# Check voting protocol is configured
if decision.voting_protocol_id is None:
raise ValueError("Aucun protocole de vote configure pour cette decision")
# Fetch the voting protocol and its formula config for duration
proto_result = await db.execute(
select(VotingProtocol)
.options(selectinload(VotingProtocol.formula_config))
.where(VotingProtocol.id == decision.voting_protocol_id)
)
protocol = proto_result.scalar_one_or_none()
if protocol is None:
raise ValueError("Protocole de vote introuvable")
duration_days = protocol.formula_config.duration_days if protocol.formula_config else 30
now = datetime.now(timezone.utc)
# Create the vote session with explicit UUID (needed before flush)
session_id = uuid.uuid4()
session = VoteSession(
id=session_id,
decision_id=decision.id,
voting_protocol_id=protocol.id,
starts_at=now,
ends_at=now + timedelta(days=duration_days),
status="open",
)
db.add(session)
# Link session to step
step.vote_session_id = session_id
await db.commit()
await db.refresh(session)
return session
@@ -0,0 +1,329 @@
"""Document service: retrieval and version management."""
from __future__ import annotations
import logging
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
logger = logging.getLogger(__name__)
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
async def reject_version(
item_id: uuid.UUID,
version_id: uuid.UUID,
db: AsyncSession,
) -> ItemVersion:
"""Mark a version as rejected.
Parameters
----------
item_id:
UUID of the DocumentItem the version belongs to.
version_id:
UUID of the ItemVersion to reject.
db:
Async database session.
Returns
-------
ItemVersion
The rejected version.
Raises
------
ValueError
If the item or version is not found, or the version does not
belong to the item.
"""
# Load item to verify existence
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}"
)
version.status = "rejected"
await db.commit()
await db.refresh(version)
return version
async def get_item_with_versions(
item_id: uuid.UUID,
db: AsyncSession,
) -> DocumentItem | None:
"""Eager-load a document item with all its versions.
Parameters
----------
item_id:
UUID of the DocumentItem.
db:
Async database session.
Returns
-------
DocumentItem | None
The item with versions loaded, or None if not found.
"""
result = await db.execute(
select(DocumentItem)
.options(selectinload(DocumentItem.versions))
.where(DocumentItem.id == item_id)
)
return result.scalar_one_or_none()
async def reorder_items(
document_id: uuid.UUID,
items_order: list[tuple[uuid.UUID, int]],
db: AsyncSession,
) -> list[DocumentItem]:
"""Update sort_order for multiple items in a document.
Parameters
----------
document_id:
UUID of the document.
items_order:
List of (item_id, sort_order) tuples.
db:
Async database session.
Returns
-------
list[DocumentItem]
The updated items, ordered by sort_order.
Raises
------
ValueError
If any item is not found or does not belong to the document.
"""
for item_id, sort_order in items_order:
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 ValueError(
f"Element {item_id} introuvable dans le document {document_id}"
)
item.sort_order = sort_order
await db.commit()
# Return all items in new order
result = await db.execute(
select(DocumentItem)
.where(DocumentItem.document_id == document_id)
.order_by(DocumentItem.sort_order)
)
return list(result.scalars().all())
def serialize_document_to_text(doc: Document) -> str:
"""Serialize a document and its items to a plain-text representation.
The items must be eagerly loaded on the document before calling this.
Parameters
----------
doc:
Document with items loaded.
Returns
-------
str
Plain-text serialization suitable for hashing and archival.
"""
lines: list[str] = []
lines.append(f"# {doc.title}")
lines.append(f"Version: {doc.version}")
lines.append(f"Type: {doc.doc_type}")
lines.append(f"Statut: {doc.status}")
if doc.description:
lines.append(f"Description: {doc.description}")
lines.append("")
# Sort items by sort_order
sorted_items = sorted(doc.items, key=lambda i: i.sort_order)
for item in sorted_items:
header = f"## {item.position}"
if item.title:
header += f" - {item.title}"
header += f" [{item.item_type}]"
lines.append(header)
lines.append(item.current_text)
lines.append("")
return "\n".join(lines)
async def archive_document(slug: str, db: AsyncSession):
"""Serialize a document to text and archive it to the sanctuary.
Parameters
----------
slug:
Slug of the document to archive.
db:
Async database session.
Returns
-------
SanctuaryEntry
The newly created sanctuary entry.
Raises
------
ValueError
If the document is not found.
"""
from app.services import sanctuary_service
doc = await get_document_with_items(slug, db)
if doc is None:
raise ValueError(f"Document introuvable : {slug}")
content = serialize_document_to_text(doc)
entry = await sanctuary_service.archive_to_sanctuary(
entry_type="document",
reference_id=doc.id,
content=content,
title=f"{doc.title} v{doc.version}",
db=db,
)
# Update document with sanctuary references
if entry.ipfs_cid:
doc.ipfs_cid = entry.ipfs_cid
if entry.chain_tx_hash:
doc.chain_anchor = entry.chain_tx_hash
await db.commit()
await db.refresh(doc)
return entry
@@ -0,0 +1,125 @@
"""IPFS service: upload, retrieve, and pin content via kubo HTTP API.
Uses httpx async client to communicate with the local kubo node.
All operations handle connection errors gracefully: they log a warning
and return None instead of crashing the caller.
"""
from __future__ import annotations
import logging
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
# Timeout for IPFS operations (seconds)
_IPFS_TIMEOUT = 30.0
async def upload_to_ipfs(content: str | bytes) -> str | None:
"""Upload content to IPFS via kubo HTTP API (POST /api/v0/add).
Parameters
----------
content:
The content to upload. Strings are encoded as UTF-8.
Returns
-------
str | None
The IPFS CID (Content Identifier) of the uploaded content,
or None if the upload failed.
"""
if isinstance(content, str):
content = content.encode("utf-8")
try:
async with httpx.AsyncClient(timeout=_IPFS_TIMEOUT) as client:
response = await client.post(
f"{settings.IPFS_API_URL}/api/v0/add",
files={"file": ("content.txt", content, "application/octet-stream")},
)
response.raise_for_status()
data = response.json()
cid = data.get("Hash")
if cid:
logger.info("Contenu uploade sur IPFS: CID=%s", cid)
return cid
except httpx.ConnectError:
logger.warning("Impossible de se connecter au noeud IPFS (%s)", settings.IPFS_API_URL)
return None
except httpx.HTTPStatusError as exc:
logger.warning("Erreur HTTP IPFS lors de l'upload: %s", exc.response.status_code)
return None
except Exception:
logger.warning("Erreur inattendue lors de l'upload IPFS", exc_info=True)
return None
async def get_from_ipfs(cid: str) -> bytes | None:
"""Retrieve content from IPFS by CID via the gateway.
Parameters
----------
cid:
The IPFS Content Identifier to retrieve.
Returns
-------
bytes | None
The raw content bytes, or None if retrieval failed.
"""
try:
async with httpx.AsyncClient(timeout=_IPFS_TIMEOUT) as client:
response = await client.post(
f"{settings.IPFS_API_URL}/api/v0/cat",
params={"arg": cid},
)
response.raise_for_status()
logger.info("Contenu recupere depuis IPFS: CID=%s", cid)
return response.content
except httpx.ConnectError:
logger.warning("Impossible de se connecter au noeud IPFS (%s)", settings.IPFS_API_URL)
return None
except httpx.HTTPStatusError as exc:
logger.warning("Erreur HTTP IPFS lors de la recuperation (CID=%s): %s", cid, exc.response.status_code)
return None
except Exception:
logger.warning("Erreur inattendue lors de la recuperation IPFS (CID=%s)", cid, exc_info=True)
return None
async def pin(cid: str) -> bool:
"""Pin content on the local IPFS node to prevent garbage collection.
Parameters
----------
cid:
The IPFS Content Identifier to pin.
Returns
-------
bool
True if pinning succeeded, False otherwise.
"""
try:
async with httpx.AsyncClient(timeout=_IPFS_TIMEOUT) as client:
response = await client.post(
f"{settings.IPFS_API_URL}/api/v0/pin/add",
params={"arg": cid},
)
response.raise_for_status()
logger.info("Contenu epingle sur IPFS: CID=%s", cid)
return True
except httpx.ConnectError:
logger.warning("Impossible de se connecter au noeud IPFS pour l'epinglage (%s)", settings.IPFS_API_URL)
return False
except httpx.HTTPStatusError as exc:
logger.warning("Erreur HTTP IPFS lors de l'epinglage (CID=%s): %s", cid, exc.response.status_code)
return False
except Exception:
logger.warning("Erreur inattendue lors de l'epinglage IPFS (CID=%s)", cid, exc_info=True)
return False
@@ -0,0 +1,333 @@
"""Mandate service: step advancement logic, assignment and revocation."""
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.mandate import Mandate, MandateStep
from app.models.protocol import VotingProtocol
from app.models.user import DuniterIdentity
from app.models.vote import VoteSession
# 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]
async def assign_mandatee(
mandate_id: uuid.UUID,
mandatee_id: uuid.UUID,
db: AsyncSession,
) -> Mandate:
"""Assign a mandatee (DuniterIdentity) to a mandate.
The mandate must be in a state that accepts assignment (not completed/revoked).
The mandatee must exist in the duniter_identities table.
Parameters
----------
mandate_id:
UUID of the Mandate.
mandatee_id:
UUID of the DuniterIdentity to assign.
db:
Async database session.
Returns
-------
Mandate
The updated mandate.
Raises
------
ValueError
If the mandate or mandatee is not found, or the mandate
is in a terminal state.
"""
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"Impossible d'assigner un mandataire : le mandat est en statut terminal ({mandate.status})")
# Verify mandatee exists
identity_result = await db.execute(
select(DuniterIdentity).where(DuniterIdentity.id == mandatee_id)
)
identity = identity_result.scalar_one_or_none()
if identity is None:
raise ValueError(f"Identite Duniter introuvable : {mandatee_id}")
mandate.mandatee_id = mandatee_id
mandate.starts_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(mandate)
return mandate
async def revoke_mandate(
mandate_id: uuid.UUID,
db: AsyncSession,
) -> Mandate:
"""Revoke an active mandate.
Sets the mandate status to 'revoked' and cancels any active/pending steps.
Parameters
----------
mandate_id:
UUID of the Mandate to revoke.
db:
Async database session.
Returns
-------
Mandate
The updated mandate.
Raises
------
ValueError
If the mandate is not found or already in a terminal state.
"""
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}")
# Cancel active and pending steps
for step in mandate.steps:
if step.status in ("active", "pending"):
step.status = "cancelled"
mandate.status = "revoked"
mandate.ends_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(mandate)
return mandate
async def create_vote_session_for_step(
mandate_id: uuid.UUID,
step_id: uuid.UUID,
db: AsyncSession,
) -> VoteSession:
"""Create a VoteSession linked to a mandate step.
Uses the mandate's linked decision's voting_protocol to configure
the session, or requires a voting_protocol_id on the mandate's decision.
Parameters
----------
mandate_id:
UUID of the Mandate.
step_id:
UUID of the MandateStep to attach the vote session to.
db:
Async database session.
Returns
-------
VoteSession
The newly created vote session.
Raises
------
ValueError
If the mandate, step, or protocol is not found, or the step
already has a vote session.
"""
# Fetch mandate with steps
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}")
# Find the step
step: MandateStep | None = None
for s in mandate.steps:
if s.id == step_id:
step = s
break
if step is None:
raise ValueError(f"Etape introuvable : {step_id}")
# Check step doesn't already have a vote session
if step.vote_session_id is not None:
raise ValueError("Cette etape possede deja une session de vote")
# Resolve the voting protocol via the linked decision
if mandate.decision_id is None:
raise ValueError("Aucune decision liee a ce mandat pour determiner le protocole de vote")
from app.models.decision import Decision
decision_result = await db.execute(
select(Decision).where(Decision.id == mandate.decision_id)
)
decision = decision_result.scalar_one_or_none()
if decision is None or decision.voting_protocol_id is None:
raise ValueError("Aucun protocole de vote configure pour la decision liee")
# Fetch the voting protocol and its formula config for duration
proto_result = await db.execute(
select(VotingProtocol)
.options(selectinload(VotingProtocol.formula_config))
.where(VotingProtocol.id == decision.voting_protocol_id)
)
protocol = proto_result.scalar_one_or_none()
if protocol is None:
raise ValueError("Protocole de vote introuvable")
duration_days = protocol.formula_config.duration_days if protocol.formula_config else 30
now = datetime.now(timezone.utc)
# Create the vote session with explicit UUID (needed before flush)
session_id = uuid.uuid4()
session = VoteSession(
id=session_id,
decision_id=mandate.decision_id,
voting_protocol_id=protocol.id,
starts_at=now,
ends_at=now + timedelta(days=duration_days),
status="open",
)
db.add(session)
# Link session to step
step.vote_session_id = session_id
await db.commit()
await db.refresh(session)
return session
@@ -0,0 +1,60 @@
"""Organization service: CRUD + membership helpers."""
from __future__ import annotations
import uuid
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.organization import OrgMember, Organization
async def list_organizations(db: AsyncSession) -> Sequence[Organization]:
result = await db.execute(select(Organization).order_by(Organization.name))
return result.scalars().all()
async def get_organization(db: AsyncSession, org_id: uuid.UUID) -> Organization | None:
return await db.get(Organization, org_id)
async def get_organization_by_slug(db: AsyncSession, slug: str) -> Organization | None:
result = await db.execute(select(Organization).where(Organization.slug == slug))
return result.scalar_one_or_none()
async def create_organization(db: AsyncSession, **kwargs) -> Organization:
org = Organization(**kwargs)
db.add(org)
await db.commit()
await db.refresh(org)
return org
async def is_member(db: AsyncSession, org_id: uuid.UUID, identity_id: uuid.UUID) -> bool:
result = await db.execute(
select(OrgMember).where(
OrgMember.org_id == org_id,
OrgMember.identity_id == identity_id,
)
)
return result.scalar_one_or_none() is not None
async def add_member(
db: AsyncSession, org_id: uuid.UUID, identity_id: uuid.UUID, role: str = "member"
) -> OrgMember:
member = OrgMember(org_id=org_id, identity_id=identity_id, role=role)
db.add(member)
await db.commit()
await db.refresh(member)
return member
async def list_members(db: AsyncSession, org_id: uuid.UUID) -> Sequence[OrgMember]:
result = await db.execute(
select(OrgMember).where(OrgMember.org_id == org_id).order_by(OrgMember.created_at)
)
return result.scalars().all()
@@ -0,0 +1,196 @@
"""AI framing service for decision qualification.
Orchestrates a 2-round conversation that clarifies reversibility and urgency
before producing a final QualificationResult.
Rule-based stub — Qwen3.6 (MacStudio) calls will replace ai_frame() internals
once the local endpoint is available. The interface is stable.
"""
from __future__ import annotations
from dataclasses import dataclass
# ---------------------------------------------------------------------------
# Schemas (dataclasses — no Pydantic dependency in the engine layer)
# ---------------------------------------------------------------------------
@dataclass
class AIMessage:
role: str # "user" | "assistant"
content: str
@dataclass
class AIQuestion:
id: str
text: str
options: list[str]
@dataclass
class AIQualifyResult:
decision_type: str
process: str
recommended_modalities: list[str]
recommend_onchain: bool
onchain_reason: str | None
confidence: str
collective_available: bool
record_in_observatory: bool
reasons: list[str]
@dataclass
class AIFrameRequest:
within_mandate: bool = False
affected_count: int | None = None
is_structural: bool = False
context: str | None = None
messages: list[AIMessage] | None = None
def __post_init__(self):
if self.messages is None:
self.messages = []
@dataclass
class AIFrameResponse:
done: bool
questions: list[AIQuestion]
result: AIQualifyResult | None
explanation: str | None
# ---------------------------------------------------------------------------
# Standard clarifying questions
# ---------------------------------------------------------------------------
_CLARIFYING_QUESTIONS: list[AIQuestion] = [
AIQuestion(
id="reversibility",
text="Si cette décision s'avère inappropriée dans 6 mois, peut-on facilement revenir en arrière ?",
options=[
"Oui, facilement",
"Difficilement",
"Non, c'est irréversible",
],
),
AIQuestion(
id="urgency",
text="Y a-t-il une contrainte temporelle sur cette décision ?",
options=[
"Urgente (< 1 semaine)",
"Délai raisonnable (quelques semaines)",
"Pas d'urgence",
],
),
]
# ---------------------------------------------------------------------------
# Core function
# ---------------------------------------------------------------------------
def ai_frame(request: AIFrameRequest) -> AIFrameResponse:
"""Run one round of AI framing.
Round 1 (messages=[]) → return 2 clarifying questions, done=False
Round 2 (messages set) → parse answers, qualify, return result, done=True
"""
messages = request.messages or []
if not messages:
return AIFrameResponse(
done=False,
questions=list(_CLARIFYING_QUESTIONS),
result=None,
explanation=None,
)
answers = _parse_answers(messages)
result = _build_result(request, answers)
explanation = _build_explanation(answers)
return AIFrameResponse(
done=True,
questions=[],
result=result,
explanation=explanation,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _parse_answers(messages: list[AIMessage]) -> dict[str, str]:
"""Extract question answers from the last user message.
Expected format: "reversibility:<answer>|urgency:<answer>"
"""
answers: dict[str, str] = {}
for msg in reversed(messages):
if msg.role == "user" and "|" in msg.content and ":" in msg.content:
for part in msg.content.split("|"):
if ":" in part:
key, _, val = part.partition(":")
answers[key.strip()] = val.strip()
break
return answers
def _build_result(request: AIFrameRequest, answers: dict[str, str]) -> AIQualifyResult:
"""Produce a qualification result enriched by the AI answers."""
from app.engine.qualifier import (
QualificationConfig,
QualificationInput,
qualify,
)
config = QualificationConfig()
inp = QualificationInput(
within_mandate=request.within_mandate,
affected_count=request.affected_count,
is_structural=request.is_structural,
context_description=request.context,
)
base = qualify(inp, config)
reasons = list(base.reasons)
reversibility = answers.get("reversibility", "")
if "irréversible" in reversibility.lower():
reasons.append("Décision irréversible : consensus élevé recommandé.")
urgency = answers.get("urgency", "")
if "urgente" in urgency.lower() or "< 1" in urgency:
reasons.append("Urgence signalée : privilégier un protocole à délai court.")
return AIQualifyResult(
decision_type=base.decision_type.value,
process=base.process,
recommended_modalities=base.recommended_modalities,
recommend_onchain=base.recommend_onchain,
onchain_reason=base.onchain_reason,
confidence=base.confidence,
collective_available=base.collective_available,
record_in_observatory=base.record_in_observatory,
reasons=reasons,
)
def _build_explanation(answers: dict[str, str]) -> str:
parts = []
rev = answers.get("reversibility", "")
urg = answers.get("urgency", "")
if rev:
parts.append(f"Réversibilité : {rev}.")
if urg:
parts.append(f"Urgence : {urg}.")
return " ".join(parts) if parts else "Qualification basée sur les éléments fournis."
@@ -0,0 +1,260 @@
"""Sanctuary service: immutable archival to IPFS + on-chain hash.
The sanctuary is the immutable layer of libreDecision. 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 logging
import uuid
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.sanctuary import SanctuaryEntry
from app.services import ipfs_service
logger = logging.getLogger(__name__)
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.
Pipeline:
1. Hash content (SHA-256)
2. Try to upload to IPFS via ipfs_service (catch errors, log, continue)
3. Try to anchor on-chain via blockchain_service (catch errors, log, continue)
4. Create SanctuaryEntry with whatever succeeded
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.
"""
# 1. Compute SHA-256 hash of the content
content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()
# Build metadata
metadata: dict = {
"archived_at": datetime.now(timezone.utc).isoformat(),
"entry_type": entry_type,
"content_length": len(content),
}
ipfs_cid: str | None = None
chain_tx_hash: str | None = None
chain_block: int | None = None
# 2. Try to upload to IPFS
try:
ipfs_cid = await ipfs_service.upload_to_ipfs(content)
if ipfs_cid:
# Pin the content to keep it available
await ipfs_service.pin(ipfs_cid)
metadata["ipfs_cid"] = ipfs_cid
logger.info("Contenu archive sur IPFS: CID=%s", ipfs_cid)
else:
logger.warning("Upload IPFS echoue (retour None) pour %s:%s", entry_type, reference_id)
except Exception:
logger.warning(
"Erreur lors de l'upload IPFS pour %s:%s",
entry_type, reference_id,
exc_info=True,
)
# 3. Try to anchor on-chain (still a structured stub)
try:
chain_tx_hash, chain_block = await _anchor_on_chain(content_hash)
if chain_tx_hash:
metadata["chain_tx_hash"] = chain_tx_hash
metadata["chain_block"] = chain_block
logger.info("Hash ancre on-chain: tx=%s block=%s", chain_tx_hash, chain_block)
except NotImplementedError:
logger.info("Ancrage on-chain pas encore implemente, etape ignoree")
except Exception:
logger.warning(
"Erreur lors de l'ancrage on-chain pour %s:%s",
entry_type, reference_id,
exc_info=True,
)
# 4. Create SanctuaryEntry with whatever succeeded
entry = SanctuaryEntry(
entry_type=entry_type,
reference_id=reference_id,
title=title,
content_hash=content_hash,
ipfs_cid=ipfs_cid,
chain_tx_hash=chain_tx_hash,
chain_block=chain_block,
metadata_json=json.dumps(metadata, ensure_ascii=False),
)
db.add(entry)
await db.commit()
await db.refresh(entry)
return entry
async def verify_entry(
entry_id: uuid.UUID,
db: AsyncSession,
) -> dict:
"""Verify the integrity of a sanctuary entry.
Re-fetches the content (from IPFS if available) and re-hashes it
to compare with the stored content_hash.
Parameters
----------
entry_id:
UUID of the SanctuaryEntry to verify.
db:
Async database session.
Returns
-------
dict
Verification result with keys:
- ``entry_id``: UUID of the entry
- ``valid``: bool indicating if the hash matches
- ``stored_hash``: the stored content_hash
- ``computed_hash``: the re-computed hash (or None if content unavailable)
- ``source``: where the content was fetched from (``"ipfs"`` or ``"unavailable"``)
- ``detail``: human-readable detail message
Raises
------
ValueError
If the entry is not found.
"""
result = await db.execute(
select(SanctuaryEntry).where(SanctuaryEntry.id == entry_id)
)
entry = result.scalar_one_or_none()
if entry is None:
raise ValueError(f"Entree sanctuaire introuvable : {entry_id}")
stored_hash = entry.content_hash
computed_hash: str | None = None
source = "unavailable"
# Try to re-fetch content from IPFS
if entry.ipfs_cid:
try:
content_bytes = await ipfs_service.get_from_ipfs(entry.ipfs_cid)
if content_bytes is not None:
computed_hash = hashlib.sha256(content_bytes).hexdigest()
source = "ipfs"
except Exception:
logger.warning(
"Impossible de recuperer le contenu IPFS pour verification (CID=%s)",
entry.ipfs_cid,
exc_info=True,
)
if computed_hash is None:
return {
"entry_id": entry.id,
"valid": False,
"stored_hash": stored_hash,
"computed_hash": None,
"source": source,
"detail": "Contenu indisponible pour la verification",
}
is_valid = computed_hash == stored_hash
return {
"entry_id": entry.id,
"valid": is_valid,
"stored_hash": stored_hash,
"computed_hash": computed_hash,
"source": source,
"detail": "Integrite verifiee" if is_valid else "Hash different - contenu potentiellement altere",
}
async def get_entries_by_reference(
reference_id: uuid.UUID,
db: AsyncSession,
) -> list[SanctuaryEntry]:
"""Query all sanctuary entries for a given reference_id.
Parameters
----------
reference_id:
UUID of the referenced entity (document, decision, etc.).
db:
Async database session.
Returns
-------
list[SanctuaryEntry]
All entries matching the reference_id, ordered by creation date desc.
"""
result = await db.execute(
select(SanctuaryEntry)
.where(SanctuaryEntry.reference_id == reference_id)
.order_by(SanctuaryEntry.created_at.desc())
)
return list(result.scalars().all())
async def _anchor_on_chain(content_hash: str) -> tuple[str | None, int | None]:
"""Anchor a content hash on-chain via system.remark.
Currently a stub. When implemented, this will use substrate-interface
to submit a system.remark extrinsic containing the content hash.
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"libredecision: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
Parameters
----------
content_hash:
The SHA-256 hash to anchor.
Returns
-------
tuple[str | None, int | None]
(tx_hash, block_number) or (None, None) if not implemented.
"""
raise NotImplementedError("Ancrage on-chain pas encore implemente")
@@ -0,0 +1,363 @@
"""Vote service: compute results, close sessions, threshold details, and signature verification."""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
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
# ── Helpers ──────────────────────────────────────────────────────
async def _load_session_with_votes(db: AsyncSession, session_id: uuid.UUID) -> VoteSession:
"""Load a vote session with its votes eagerly, raising ValueError if not found."""
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}")
return session
async def _load_protocol_with_formula(db: AsyncSession, protocol_id: uuid.UUID) -> VotingProtocol:
"""Load a voting protocol with its formula config, raising ValueError if not found."""
proto_result = await db.execute(
select(VotingProtocol)
.options(selectinload(VotingProtocol.formula_config))
.where(VotingProtocol.id == protocol_id)
)
protocol = proto_result.scalar_one_or_none()
if protocol is None:
raise ValueError(f"Protocole de vote introuvable : {protocol_id}")
return protocol
def _extract_params(protocol: VotingProtocol, formula: FormulaConfig) -> dict:
"""Extract formula parameters from mode_params or formula_config."""
if protocol.mode_params:
return parse_mode_params(protocol.mode_params)
return {
"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,
}
# ── Compute Result ───────────────────────────────────────────────
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.
"""
session = await _load_session_with_votes(db, session_id)
protocol = await _load_protocol_with_formula(db, session.voting_protocol_id)
formula: FormulaConfig = protocol.formula_config
params = _extract_params(protocol, formula)
# 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 with full per-level breakdown."""
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,
"nuanced_breakdown": {
"per_level_counts": evaluation["per_level_counts"],
"positive_count": evaluation["positive_count"],
"positive_pct": evaluation["positive_pct"],
"threshold_met": evaluation["threshold_met"],
"min_participants_met": evaluation["min_participants_met"],
"threshold_pct": threshold_pct,
"min_participants": min_participants,
},
**evaluation,
}
# ── Close Session ────────────────────────────────────────────────
async def close_session(session_id: uuid.UUID, db: AsyncSession) -> dict:
"""Close a vote session and compute its final result.
Parameters
----------
session_id:
UUID of the VoteSession to close.
db:
Async database session.
Returns
-------
dict
Result dict from compute_result, augmented with close metadata.
Raises
------
ValueError
If the session is not found or already closed/tallied.
"""
session = await _load_session_with_votes(db, session_id)
if session.status not in ("open",):
raise ValueError(
f"Impossible de fermer la session {session_id} : statut actuel '{session.status}'"
)
# Mark as closed before tallying
session.status = "closed"
await db.commit()
# Compute the final result (this will set status to "tallied")
result = await compute_result(session_id, db)
return {
"closed_at": datetime.now(timezone.utc).isoformat(),
**result,
}
# ── Threshold Details ────────────────────────────────────────────
async def get_threshold_details(session_id: uuid.UUID, db: AsyncSession) -> dict:
"""Return detailed threshold computation for a vote session.
Parameters
----------
session_id:
UUID of the VoteSession.
db:
Async database session.
Returns
-------
dict
Detailed breakdown including wot/smith/techcomm thresholds,
pass/fail booleans, participation rate, and formula params.
"""
session = await _load_session_with_votes(db, session_id)
protocol = await _load_protocol_with_formula(db, session.voting_protocol_id)
formula: FormulaConfig = protocol.formula_config
params = _extract_params(protocol, formula)
active_votes: list[Vote] = [v for v in session.votes if v.is_active]
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
wot_thresh = 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),
)
wot_passed = votes_for >= wot_thresh
# Smith criterion (optional)
smith_thresh = None
smith_passed = None
if params.get("smith_exponent") is not None and session.smith_size > 0:
smith_thresh = 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_passed = smith_votes >= smith_thresh
# TechComm criterion (optional)
techcomm_thresh = None
techcomm_passed = None
if params.get("techcomm_exponent") is not None and session.techcomm_size > 0:
techcomm_thresh = 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_passed = techcomm_votes >= techcomm_thresh
# Overall pass: all applicable criteria must pass
overall_passed = wot_passed
if smith_passed is not None:
overall_passed = overall_passed and smith_passed
if techcomm_passed is not None:
overall_passed = overall_passed and techcomm_passed
# Participation rate
participation_rate = (total / session.wot_size) if session.wot_size > 0 else 0.0
# Formula params used
formula_params = {
"M": params.get("majority_pct", 50),
"B": params.get("base_exponent", 0.1),
"G": params.get("gradient_exponent", 0.2),
"C": params.get("constant_base", 0.0),
"S": params.get("smith_exponent"),
"T": params.get("techcomm_exponent"),
}
return {
"wot_threshold": wot_thresh,
"smith_threshold": smith_thresh,
"techcomm_threshold": techcomm_thresh,
"votes_for": votes_for,
"votes_against": votes_against,
"votes_total": total,
"wot_passed": wot_passed,
"smith_passed": smith_passed,
"techcomm_passed": techcomm_passed,
"overall_passed": overall_passed,
"participation_rate": round(participation_rate, 6),
"formula_params": formula_params,
}
# ── Signature Verification ───────────────────────────────────────
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