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:
@@ -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
|
||||
Reference in New Issue
Block a user