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
View File
+54
View File
@@ -0,0 +1,54 @@
from pydantic_settings import BaseSettings
from pathlib import Path
class Settings(BaseSettings):
APP_NAME: str = "libreDecision"
DEBUG: bool = True
# Environment
ENVIRONMENT: str = "development" # development, staging, production
DEMO_MODE: bool = False # Enable demo profiles (quick login) regardless of ENVIRONMENT
LOG_LEVEL: str = "INFO"
# Database — SQLite by default for local dev, PostgreSQL for Docker/prod
DATABASE_URL: str = "sqlite+aiosqlite:///./libredecision.db"
DATABASE_POOL_SIZE: int = 20
DATABASE_MAX_OVERFLOW: int = 10
# Auth
SECRET_KEY: str = "change-me-in-production-with-a-real-secret-key"
CHALLENGE_EXPIRE_SECONDS: int = 300
TOKEN_EXPIRE_HOURS: int = 24
SESSION_TTL_HOURS: int = 24
# Duniter V2 RPC
DUNITER_RPC_URL: str = "wss://gdev.p2p.legal/ws"
DUNITER_RPC_TIMEOUT_SECONDS: int = 10
# IPFS
IPFS_API_URL: str = "http://localhost:5001"
IPFS_GATEWAY_URL: str = "http://localhost:8080"
IPFS_TIMEOUT_SECONDS: int = 30
# CORS
CORS_ORIGINS: list[str] = ["http://localhost:3002"]
# Rate limiting (requests per minute)
RATE_LIMIT_DEFAULT: int = 60
RATE_LIMIT_AUTH: int = 10
RATE_LIMIT_VOTE: int = 30
# AI — Qwen3.6 (MacStudio) endpoint, branché plus tard
QWEN_API_URL: str = ""
# Blockchain cache
BLOCKCHAIN_CACHE_TTL_SECONDS: int = 3600
# Paths
BASE_DIR: Path = Path(__file__).resolve().parent.parent
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
settings = Settings()
+39
View File
@@ -0,0 +1,39 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
_is_sqlite = settings.DATABASE_URL.startswith("sqlite")
# SQLite doesn't support pool_size/max_overflow/pool_pre_ping/pool_recycle
if _is_sqlite:
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.ENVIRONMENT == "development",
connect_args={"check_same_thread": False},
)
else:
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.ENVIRONMENT == "development",
pool_size=settings.DATABASE_POOL_SIZE,
max_overflow=settings.DATABASE_MAX_OVERFLOW,
pool_pre_ping=True,
pool_recycle=3600,
)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db():
async with async_session() as session:
yield session
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+26
View File
@@ -0,0 +1,26 @@
"""FastAPI dependency: resolve X-Organization header → org UUID."""
from __future__ import annotations
import uuid
from fastapi import Depends, Header
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services.org_service import get_organization_by_slug
async def get_active_org_id(
x_organization: str | None = Header(default=None, alias="X-Organization"),
db: AsyncSession = Depends(get_db),
) -> uuid.UUID | None:
"""Return the UUID of the org named in the X-Organization header, or None.
None means no org filter — used for backward compat and internal tooling.
An unknown slug is silently treated as None (don't break the client).
"""
if not x_organization:
return None
org = await get_organization_by_slug(db, x_organization)
return org.id if org else None
+107
View File
@@ -0,0 +1,107 @@
"""Parse mode-parameter strings into structured dicts.
A mode-params string encodes voting formula parameters in a compact format.
Example: ``"D30M50B.1G.2T.1"``
Supported codes:
D = duration_days (int)
M = majority_pct (int, 0-100)
B = base_exponent (float)
G = gradient_exponent (float)
C = constant_base (float)
S = smith_exponent (float)
T = techcomm_exponent (float)
N = ratio_multiplier (float)
R = ratio_mode (bool, 0 or 1)
Values may start with a dot for decimals < 1, e.g. ``B.1`` means base_exponent=0.1.
"""
from __future__ import annotations
import re
# Ordered list of recognised codes and their target keys + types
_CODES: dict[str, tuple[str, type]] = {
"D": ("duration_days", int),
"M": ("majority_pct", int),
"B": ("base_exponent", float),
"G": ("gradient_exponent", float),
"C": ("constant_base", float),
"S": ("smith_exponent", float),
"T": ("techcomm_exponent", float),
"N": ("ratio_multiplier", float),
"R": ("is_ratio_mode", bool),
}
# Regex: a single uppercase letter followed by a numeric value (int or float,
# possibly starting with '.' for values like .1 meaning 0.1)
_PARAM_RE = re.compile(r"([A-Z])(\d*\.?\d+)")
def parse_mode_params(params_str: str) -> dict:
"""Parse a mode-params string into a parameter dict.
Parameters
----------
params_str:
Compact parameter string, e.g. ``"D30M50B.1G.2T.1"``.
Returns
-------
dict
Keys present depend on codes found in the string. Defaults are
applied for any code not present::
{
"duration_days": 30,
"majority_pct": 50,
"base_exponent": 0.1,
"gradient_exponent": 0.2,
"constant_base": 0.0,
"smith_exponent": None,
"techcomm_exponent": None,
"ratio_multiplier": None,
"is_ratio_mode": False,
}
Raises
------
ValueError
If an unrecognised code letter is found.
"""
defaults: dict = {
"duration_days": 30,
"majority_pct": 50,
"base_exponent": 0.1,
"gradient_exponent": 0.2,
"constant_base": 0.0,
"smith_exponent": None,
"techcomm_exponent": None,
"ratio_multiplier": None,
"is_ratio_mode": False,
}
if not params_str or not params_str.strip():
return dict(defaults)
result = dict(defaults)
for match in _PARAM_RE.finditer(params_str):
code = match.group(1)
raw_value = match.group(2)
if code not in _CODES:
raise ValueError(f"Code de parametre inconnu : '{code}'")
key, target_type = _CODES[code]
if target_type is int:
result[key] = int(float(raw_value))
elif target_type is float:
result[key] = float(raw_value)
elif target_type is bool:
result[key] = float(raw_value) != 0.0
return result
@@ -0,0 +1,95 @@
"""Six-level nuanced vote evaluation.
Levels:
0 - CONTRE
1 - PAS DU TOUT
2 - PAS D'ACCORD
3 - NEUTRE
4 - D'ACCORD
5 - TOUT A FAIT
Adoption rule:
The sum of votes at levels 3 + 4 + 5 must be >= threshold_pct% of total votes.
A minimum number of participants is also required.
"""
from __future__ import annotations
LEVEL_LABELS: dict[int, str] = {
0: "CONTRE",
1: "PAS DU TOUT",
2: "PAS D'ACCORD",
3: "NEUTRE",
4: "D'ACCORD",
5: "TOUT A FAIT",
}
NUM_LEVELS = 6
def evaluate_nuanced(
votes: list[int],
threshold_pct: int = 80,
min_participants: int = 59,
) -> dict:
"""Evaluate a nuanced vote from a list of individual vote levels.
Parameters
----------
votes:
List of vote levels (each 0-5). One entry per voter.
threshold_pct:
Minimum percentage of positive votes (levels 3-5) out of total
for adoption.
min_participants:
Minimum number of participants required for validity.
Returns
-------
dict
{
"total": int,
"per_level_counts": {0: int, 1: int, ..., 5: int},
"positive_count": int, # levels 3 + 4 + 5
"positive_pct": float, # 0.0 - 100.0
"threshold_met": bool,
"min_participants_met": bool,
"adopted": bool,
}
Raises
------
ValueError
If any vote value is outside the 0-5 range.
"""
# Validate vote levels
for v in votes:
if v < 0 or v > 5:
raise ValueError(
f"Niveau de vote invalide : {v}. Les niveaux valides sont 0-5."
)
total = len(votes)
per_level_counts: dict[int, int] = {level: 0 for level in range(NUM_LEVELS)}
for v in votes:
per_level_counts[v] += 1
# Positive = levels 3 (NEUTRE), 4 (D'ACCORD), 5 (TOUT A FAIT)
positive_count = per_level_counts[3] + per_level_counts[4] + per_level_counts[5]
positive_pct = (positive_count / total * 100.0) if total > 0 else 0.0
threshold_met = positive_pct >= threshold_pct
min_participants_met = total >= min_participants
adopted = threshold_met and min_participants_met
return {
"total": total,
"per_level_counts": per_level_counts,
"positive_count": positive_count,
"positive_pct": round(positive_pct, 2),
"threshold_met": threshold_met,
"min_participants_met": min_participants_met,
"adopted": adopted,
}
+211
View File
@@ -0,0 +1,211 @@
"""Decision qualification engine.
Pure functions — no database, no I/O.
Takes a QualificationInput + QualificationConfig and returns a QualificationResult.
LLM integration (suggest_modalities_from_context) is stubbed pending local Qwen deployment.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
class DecisionType(str, Enum):
INDIVIDUAL = "individual"
COLLECTIVE = "collective"
# ---------------------------------------------------------------------------
# Configuration (thresholds — stored as QualificationProtocol in DB)
# ---------------------------------------------------------------------------
@dataclass
class QualificationConfig:
"""Configurable thresholds for the qualification engine.
Seeded as a QualificationProtocol record so they can be adjusted
through the admin interface without code changes.
small_group_max: affected_count <= this → individual recommended, collective available
collective_wot_min: affected_count > this → WoT formula applicable (still recommended, not required)
affected_count must be >= 2 — decisions affecting only the author
have no place in this tool.
"""
small_group_max: int = 5
collective_wot_min: int = 50
default_modalities: list[str] = field(default_factory=lambda: [
"vote_wot",
"vote_smith",
"consultation_avis",
"election",
])
# ---------------------------------------------------------------------------
# Input / Output
# ---------------------------------------------------------------------------
@dataclass
class QualificationInput:
within_mandate: bool = False
affected_count: int | None = None # must be >= 2 when within_mandate=False
is_structural: bool = False
context_description: str | None = None # reserved for LLM suggestion
@dataclass
class QualificationResult:
decision_type: DecisionType
process: str
recommended_modalities: list[str]
recommend_onchain: bool
onchain_reason: str | None
confidence: str # "required" | "recommended" | "optional"
collective_available: bool
record_in_observatory: bool # True → decision must be logged in Observatoire
reasons: list[str]
# ---------------------------------------------------------------------------
# LLM stub
# ---------------------------------------------------------------------------
def suggest_modalities_from_context(
context: str,
config: QualificationConfig,
) -> list[str]:
"""Suggest voting modalities based on a natural-language context description.
Stub — returns empty list until local Qwen (qwen3.6, MacStudio) is integrated.
When implemented, will call the LLM API and return an ordered subset of
config.default_modalities ranked by contextual relevance.
"""
return []
# ---------------------------------------------------------------------------
# Core engine
# ---------------------------------------------------------------------------
def qualify(inp: QualificationInput, config: QualificationConfig) -> QualificationResult:
"""Qualify a decision and recommend a type, process, and modalities.
Rules (in priority order):
R1/R2 within_mandate → individual + consultation_avis, no vote modalities,
decision must be recorded in Observatoire des décisions
R4 2 ≤ affected_count ≤ small_group_max → individual recommended, collective available
R5 small_group_max < affected_count ≤ collective_wot_min → collective recommended
R6 affected_count > collective_wot_min → collective recommended (WoT formula applicable)
R7/R8 is_structural → recommend_onchain with reason
"""
reasons: list[str] = []
# ── R1/R2: mandate scope overrides everything ───────────────────────────
if inp.within_mandate:
reasons.append("Décision dans le périmètre d'un mandat existant.")
return QualificationResult(
decision_type=DecisionType.INDIVIDUAL,
process="consultation_avis",
recommended_modalities=[],
recommend_onchain=_onchain(inp, reasons),
onchain_reason=_onchain_reason(inp),
confidence="required",
collective_available=False,
record_in_observatory=True,
reasons=reasons,
)
count = inp.affected_count if inp.affected_count is not None else 2
# ── R4: small group → individual recommended, collective available ───────
if count <= config.small_group_max:
reasons.append(
f"{count} personnes concernées : décision individuelle recommandée, "
"vote collectif possible."
)
return QualificationResult(
decision_type=DecisionType.INDIVIDUAL,
process="personal",
recommended_modalities=[],
recommend_onchain=_onchain(inp, reasons),
onchain_reason=_onchain_reason(inp),
confidence="recommended",
collective_available=True,
record_in_observatory=False,
reasons=reasons,
)
# ── R5/R6: medium or large group → collective ────────────────────────────
modalities = _collect_modalities(inp, config)
if count <= config.collective_wot_min:
reasons.append(f"{count} personnes concernées : vote collectif recommandé.")
confidence = "recommended"
else:
reasons.append(
f"{count} personnes concernées : vote collectif recommandé "
"(formule WoT applicable à cette échelle)."
)
confidence = "recommended"
if "vote_wot" not in modalities:
modalities = ["vote_wot"] + modalities
return QualificationResult(
decision_type=DecisionType.COLLECTIVE,
process="vote_collective",
recommended_modalities=modalities,
recommend_onchain=_onchain(inp, reasons),
onchain_reason=_onchain_reason(inp),
confidence=confidence,
collective_available=True,
record_in_observatory=False,
reasons=reasons,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _onchain(inp: QualificationInput, reasons: list[str]) -> bool:
if inp.is_structural:
reasons.append(
"Décision structurante : gravure on-chain recommandée "
"(a force de loi ou déclenche une action machine)."
)
return inp.is_structural
def _onchain_reason(inp: QualificationInput) -> str | None:
if not inp.is_structural:
return None
return (
"Cette décision est structurante : elle a valeur de loi au sein de la "
"communauté ou déclenche une action machine (ex : runtime upgrade). "
"La gravure on-chain (IPFS + system.remark) garantit son immuabilité "
"et sa vérifiabilité publique."
)
def _collect_modalities(inp: QualificationInput, config: QualificationConfig) -> list[str]:
"""Combine default modalities with any LLM suggestions (stub for now)."""
llm_suggestions = []
if inp.context_description:
llm_suggestions = suggest_modalities_from_context(inp.context_description, config)
seen: set[str] = set()
result: list[str] = []
for m in llm_suggestions + config.default_modalities:
if m not in seen:
seen.add(m)
result.append(m)
return result
@@ -0,0 +1,31 @@
"""Smith sub-WoT threshold criterion.
The Smith criterion requires a minimum number of votes from Smith members
(forgerons) for certain decisions to be valid.
Formula: ceil(SmithWotSize ^ S)
"""
from __future__ import annotations
import math
def smith_threshold(smith_wot_size: int, exponent: float = 0.1) -> int:
"""Compute the minimum number of Smith member votes required.
Parameters
----------
smith_wot_size:
Number of active Smith members.
exponent:
S in the formula ``ceil(smith_wot_size^S)``.
Returns
-------
int
Minimum Smith votes required.
"""
if smith_wot_size <= 0:
raise ValueError("smith_wot_size doit etre strictement positif")
return math.ceil(smith_wot_size ** exponent)
@@ -0,0 +1,31 @@
"""Technical Committee threshold criterion.
The TechComm criterion requires a minimum number of votes from
Technical Committee members for certain decisions.
Formula: ceil(CoTecSize ^ T)
"""
from __future__ import annotations
import math
def techcomm_threshold(cotec_size: int, exponent: float = 0.1) -> int:
"""Compute the minimum number of TechComm member votes required.
Parameters
----------
cotec_size:
Number of Technical Committee members.
exponent:
T in the formula ``ceil(cotec_size^T)``.
Returns
-------
int
Minimum TechComm votes required.
"""
if cotec_size <= 0:
raise ValueError("cotec_size doit etre strictement positif")
return math.ceil(cotec_size ** exponent)
+85
View File
@@ -0,0 +1,85 @@
"""WoT members threshold formula for binary votes.
Core formula:
Result = C + B^W + (M + (1-M) * (1 - (T/W)^G)) * max(0, T - C)
Where:
C = constant_base
B = base_exponent
W = wot_size (corpus of eligible voters)
T = total_votes (for + against)
M = majority_ratio (majority_pct / 100)
G = gradient_exponent
Inertia behaviour:
- Low participation (T << W) -> near-unanimity required
- High participation (T -> W) -> simple majority M suffices
Reference test case:
wot_size=7224, votes_for=97, votes_against=23 (total=120)
params M50 B.1 G.2 => threshold=94, adopted (97 >= 94)
"""
from __future__ import annotations
import math
def wot_threshold(
wot_size: int,
total_votes: int,
majority_pct: int = 50,
base_exponent: float = 0.1,
gradient_exponent: float = 0.2,
constant_base: float = 0.0,
) -> int:
"""Compute the minimum number of *for* votes required for adoption.
Parameters
----------
wot_size:
Size of the eligible voter corpus (WoT members).
total_votes:
Number of votes cast (for + against).
majority_pct:
Majority percentage (0-100). 50 = simple majority at full participation.
base_exponent:
B in the formula. ``B^W`` contributes a vanishingly small offset
when W is large (0 < B < 1).
gradient_exponent:
G controls how fast the required super-majority decays toward M as
participation increases.
constant_base:
C, a fixed additive floor on the threshold.
Returns
-------
int
The ceiling of the computed threshold. A vote passes when
``votes_for >= wot_threshold(...)``.
"""
if wot_size <= 0:
raise ValueError("wot_size doit etre strictement positif")
if total_votes < 0:
raise ValueError("total_votes ne peut pas etre negatif")
if not (0 <= majority_pct <= 100):
raise ValueError("majority_pct doit etre entre 0 et 100")
C = constant_base
B = base_exponent
W = wot_size
T = total_votes
M = majority_pct / 100.0
G = gradient_exponent
# Guard: if no votes, threshold is at least ceil(C + B^W)
if T == 0:
return math.ceil(C + B ** W)
# Core formula
participation_ratio = T / W
inertia_factor = 1.0 - participation_ratio ** G
required_ratio = M + (1.0 - M) * inertia_factor
result = C + B ** W + required_ratio * max(0.0, T - C)
return math.ceil(result)
+164
View File
@@ -0,0 +1,164 @@
import logging
import sys
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db, init_db
from app.middleware.rate_limiter import RateLimiterMiddleware
from app.middleware.security_headers import SecurityHeadersMiddleware
from app.routers import auth, documents, decisions, votes, mandates, protocols, sanctuary, websocket
from app.routers import public
from app.routers import organizations
from app.routers import qualify
from app.routers import groups
# ── Structured logging setup ───────────────────────────────────────────────
def _setup_logging() -> None:
"""Configure structured logging based on environment.
- Production/staging: JSON-formatted log lines for log aggregation.
- Development: human-readable format with colors.
"""
log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO)
if settings.ENVIRONMENT in ("production", "staging"):
# JSON formatter for structured logging
formatter = logging.Formatter(
'{"timestamp":"%(asctime)s","level":"%(levelname)s",'
'"logger":"%(name)s","message":"%(message)s"}',
datefmt="%Y-%m-%dT%H:%M:%S",
)
else:
# Human-readable format for development
formatter = logging.Formatter(
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
datefmt="%H:%M:%S",
)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(formatter)
root_logger = logging.getLogger()
root_logger.setLevel(log_level)
root_logger.handlers.clear()
root_logger.addHandler(handler)
# Reduce noise from third-party libraries
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.engine").setLevel(
logging.INFO if settings.ENVIRONMENT == "development" else logging.WARNING
)
_setup_logging()
logger = logging.getLogger(__name__)
# ── Application lifespan ───────────────────────────────────────────────────
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info(
"Demarrage %s (env=%s, log_level=%s)",
settings.APP_NAME, settings.ENVIRONMENT, settings.LOG_LEVEL,
)
await init_db()
yield
logger.info("Arret %s", settings.APP_NAME)
# ── FastAPI application ───────────────────────────────────────────────────
app = FastAPI(
title=settings.APP_NAME,
description="Plateforme de decisions collectives pour la communaute Duniter/G1",
version="0.5.0",
lifespan=lifespan,
)
# ── Middleware stack ──────────────────────────────────────────────────────
# add_middleware is LIFO: last added = outermost = first to execute on request,
# last to execute on response (wraps everything inside it).
#
# Required order so CORS headers appear on ALL responses including 429:
# CORS (outermost) → RateLimiter → SecurityHeaders → Application
#
# If RateLimiter were outside CORS, its 429 responses would have no CORS
# headers and the browser would silently discard them as network errors.
app.add_middleware(SecurityHeadersMiddleware)
# Prototype mode: use RATE_LIMIT_DEFAULT for auth so demos/testing don't hit
# the stricter RATE_LIMIT_AUTH (10/min). Set RATE_LIMIT_AUTH >= RATE_LIMIT_DEFAULT
# in .env only when going to real production.
_auth_rate_limit = settings.RATE_LIMIT_DEFAULT
app.add_middleware(
RateLimiterMiddleware,
rate_limit_default=settings.RATE_LIMIT_DEFAULT,
rate_limit_auth=_auth_rate_limit,
rate_limit_vote=settings.RATE_LIMIT_VOTE,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── Routers ──────────────────────────────────────────────────────────────
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(documents.router, prefix="/api/v1/documents", tags=["documents"])
app.include_router(decisions.router, prefix="/api/v1/decisions", tags=["decisions"])
app.include_router(votes.router, prefix="/api/v1/votes", tags=["votes"])
app.include_router(mandates.router, prefix="/api/v1/mandates", tags=["mandates"])
app.include_router(protocols.router, prefix="/api/v1/protocols", tags=["protocols"])
app.include_router(sanctuary.router, prefix="/api/v1/sanctuary", tags=["sanctuary"])
app.include_router(websocket.router, prefix="/api/v1/ws", tags=["websocket"])
app.include_router(public.router, prefix="/api/v1/public", tags=["public"])
app.include_router(organizations.router, prefix="/api/v1/organizations", tags=["organizations"])
app.include_router(qualify.router, prefix="/api/v1/qualify", tags=["qualify"])
app.include_router(groups.router, prefix="/api/v1/groups", tags=["groups"])
# ── Health check ─────────────────────────────────────────────────────────
@app.get("/api/health")
async def health(db: AsyncSession = Depends(get_db)):
"""Health check endpoint that verifies database connectivity.
Returns status "ok" with database connection info if healthy,
or status "degraded" if the database is unreachable.
"""
try:
result = await db.execute(text("SELECT 1"))
result.scalar()
db_status = "connected"
except Exception as exc:
logger.warning("Health check: base de donnees inaccessible - %s", exc)
db_status = "disconnected"
overall_status = "ok" if db_status == "connected" else "degraded"
return {
"status": overall_status,
"environment": settings.ENVIRONMENT,
"database": db_status,
"version": "0.5.0",
}
@@ -0,0 +1,170 @@
"""Rate limiter middleware: in-memory IP-based request throttling.
Tracks requests per IP address using a sliding window approach.
Configurable limits per endpoint category (general, auth, vote).
Returns 429 Too Many Requests with Retry-After header when exceeded.
"""
from __future__ import annotations
import asyncio
import logging
import time
from collections import defaultdict
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
logger = logging.getLogger(__name__)
# Cleanup interval: remove expired entries every 5 minutes
_CLEANUP_INTERVAL_SECONDS = 300
class RateLimiterMiddleware(BaseHTTPMiddleware):
"""In-memory rate limiter middleware.
Tracks request timestamps per IP and enforces configurable limits:
- General endpoints: ``rate_limit_default`` requests/min
- Auth endpoints (``/auth``): ``rate_limit_auth`` requests/min
- Vote endpoints (``/vote``): ``rate_limit_vote`` requests/min
Adds standard rate-limit headers to all responses:
- ``X-RateLimit-Limit``
- ``X-RateLimit-Remaining``
- ``X-RateLimit-Reset``
Parameters
----------
app:
The ASGI application.
rate_limit_default:
Maximum requests per minute for general endpoints.
rate_limit_auth:
Maximum requests per minute for auth endpoints.
rate_limit_vote:
Maximum requests per minute for vote endpoints.
"""
def __init__(
self,
app,
rate_limit_default: int = 60,
rate_limit_auth: int = 10,
rate_limit_vote: int = 30,
) -> None:
super().__init__(app)
self.rate_limit_default = rate_limit_default
self.rate_limit_auth = rate_limit_auth
self.rate_limit_vote = rate_limit_vote
# IP -> list of timestamps (epoch seconds)
self._requests: dict[str, list[float]] = defaultdict(list)
self._last_cleanup: float = time.time()
self._lock = asyncio.Lock()
def _get_client_ip(self, request: Request) -> str:
"""Extract the client IP from the request, respecting X-Forwarded-For."""
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
async def _cleanup_old_entries(self) -> None:
"""Remove request timestamps older than 60 seconds for all IPs."""
now = time.time()
if now - self._last_cleanup < _CLEANUP_INTERVAL_SECONDS:
return
async with self._lock:
cutoff = now - 60
ips_to_delete: list[str] = []
for ip, timestamps in self._requests.items():
self._requests[ip] = [t for t in timestamps if t > cutoff]
if not self._requests[ip]:
ips_to_delete.append(ip)
for ip in ips_to_delete:
del self._requests[ip]
self._last_cleanup = now
if ips_to_delete:
logger.debug("Nettoyage rate limiter: %d IPs supprimees", len(ips_to_delete))
def _get_limit_for_request(self, request: Request) -> int:
"""Return the rate limit applicable to the given request.
CORS preflight (OPTIONS) requests are never rate-limited — blocking them
breaks authenticated cross-origin requests in browsers.
Strict auth limit applies only to POST (login flows), not to GET /auth/me.
"""
if request.method == "OPTIONS":
return 10_000 # effectively unlimited for preflights
path = request.url.path
if request.method == "POST" and "/auth" in path:
return self.rate_limit_auth
if "/vote" in path:
return self.rate_limit_vote
return self.rate_limit_default
async def dispatch(self, request: Request, call_next) -> Response:
"""Check rate limit and either allow the request or return 429."""
# Skip rate limiting for WebSocket upgrades
if request.headers.get("upgrade", "").lower() == "websocket":
return await call_next(request)
# Trigger periodic cleanup
await self._cleanup_old_entries()
client_ip = self._get_client_ip(request)
limit = self._get_limit_for_request(request)
now = time.time()
window_start = now - 60
async with self._lock:
# Filter to requests within the last 60 seconds
self._requests[client_ip] = [
t for t in self._requests[client_ip] if t > window_start
]
request_count = len(self._requests[client_ip])
if request_count >= limit:
# Calculate when the oldest request in the window expires
oldest = min(self._requests[client_ip]) if self._requests[client_ip] else now
retry_after = int(oldest + 60 - now) + 1
retry_after = max(retry_after, 1)
reset_at = int(oldest + 60)
logger.warning(
"Rate limit depasse pour %s sur %s (%d/%d)",
client_ip, request.url.path, request_count, limit,
)
return JSONResponse(
status_code=429,
content={"detail": "Trop de requetes. Veuillez reessayer plus tard."},
headers={
"Retry-After": str(retry_after),
"X-RateLimit-Limit": str(limit),
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": str(reset_at),
},
)
# Record this request
self._requests[client_ip].append(now)
remaining = max(0, limit - request_count - 1)
reset_at = int(now + 60)
# Process the request
response = await call_next(request)
# Add rate limit headers to the response
response.headers["X-RateLimit-Limit"] = str(limit)
response.headers["X-RateLimit-Remaining"] = str(remaining)
response.headers["X-RateLimit-Reset"] = str(reset_at)
return response
@@ -0,0 +1,42 @@
"""Security headers middleware: adds hardening headers to all responses.
Applies OWASP-recommended security headers to prevent common
web vulnerabilities (clickjacking, MIME sniffing, XSS, etc.).
"""
from __future__ import annotations
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Add security headers to all HTTP responses.
Headers applied:
- ``X-Content-Type-Options: nosniff``
- ``X-Frame-Options: DENY``
- ``X-XSS-Protection: 1; mode=block``
- ``Referrer-Policy: strict-origin-when-cross-origin``
- ``Content-Security-Policy: default-src 'self'``
- ``Strict-Transport-Security: max-age=31536000; includeSubDomains``
(only when the request was made over HTTPS)
"""
async def dispatch(self, request: Request, call_next) -> Response:
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Content-Security-Policy"] = "default-src 'self'"
# Only add HSTS header for HTTPS requests
if request.url.scheme == "https":
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains"
)
return response
+25
View File
@@ -0,0 +1,25 @@
from app.models.user import DuniterIdentity, Session
from app.models.organization import Organization, OrgMember
from app.models.document import Document, DocumentItem, ItemVersion
from app.models.decision import Decision, DecisionStep
from app.models.vote import VoteSession, Vote
from app.models.mandate import Mandate, MandateStep
from app.models.protocol import VotingProtocol, FormulaConfig
from app.models.qualification import QualificationProtocol
from app.models.group import Group, GroupMember
from app.models.sanctuary import SanctuaryEntry
from app.models.cache import BlockchainCache
__all__ = [
"DuniterIdentity", "Session",
"Organization", "OrgMember",
"Document", "DocumentItem", "ItemVersion",
"Decision", "DecisionStep",
"VoteSession", "Vote",
"Mandate", "MandateStep",
"VotingProtocol", "FormulaConfig",
"QualificationProtocol",
"Group", "GroupMember",
"SanctuaryEntry",
"BlockchainCache",
]
+17
View File
@@ -0,0 +1,17 @@
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, JSON, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class BlockchainCache(Base):
__tablename__ = "blockchain_cache"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
cache_key: Mapped[str] = mapped_column(String(256), unique=True, nullable=False, index=True)
cache_value: Mapped[dict] = mapped_column(JSON, nullable=False)
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+42
View File
@@ -0,0 +1,42 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Decision(Base):
__tablename__ = "decisions"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
title: Mapped[str] = mapped_column(String(256), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
context: Mapped[str | None] = mapped_column(Text)
decision_type: Mapped[str] = mapped_column(String(64), nullable=False) # runtime_upgrade, document_change, mandate_vote, custom
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, qualification, review, voting, executed, closed
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
voting_protocol_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("voting_protocols.id"))
created_by_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
steps: Mapped[list["DecisionStep"]] = relationship(back_populates="decision", cascade="all, delete-orphan", order_by="DecisionStep.step_order")
class DecisionStep(Base):
__tablename__ = "decision_steps"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
decision_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("decisions.id"), nullable=False)
step_order: Mapped[int] = mapped_column(Integer, nullable=False)
step_type: Mapped[str] = mapped_column(String(32), nullable=False) # qualification, review, vote, execution, reporting
title: Mapped[str | None] = mapped_column(String(256))
description: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(32), default="pending") # pending, active, completed, skipped
vote_session_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("vote_sessions.id"))
outcome: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
decision: Mapped["Decision"] = relationship(back_populates="steps")
+64
View File
@@ -0,0 +1,64 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Uuid, Boolean, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Document(Base):
__tablename__ = "documents"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
slug: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
title: Mapped[str] = mapped_column(String(256), nullable=False)
doc_type: Mapped[str] = mapped_column(String(64), nullable=False) # licence, engagement, reglement, constitution
version: Mapped[str] = mapped_column(String(32), default="0.1.0")
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, active, archived
description: Mapped[str | None] = mapped_column(Text)
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
ipfs_cid: Mapped[str | None] = mapped_column(String(128))
chain_anchor: Mapped[str | None] = mapped_column(String(128))
genesis_json: Mapped[str | None] = mapped_column(Text) # JSON: source files, repos, forum URLs, formula trigger
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
items: Mapped[list["DocumentItem"]] = relationship(back_populates="document", cascade="all, delete-orphan", order_by="DocumentItem.sort_order")
class DocumentItem(Base):
__tablename__ = "document_items"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
document_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("documents.id"), nullable=False)
position: Mapped[str] = mapped_column(String(16), nullable=False) # "1", "1.1", "3.2"
item_type: Mapped[str] = mapped_column(String(32), default="clause") # clause, rule, verification, preamble, section, genesis
title: Mapped[str | None] = mapped_column(String(256))
current_text: Mapped[str] = mapped_column(Text, nullable=False)
voting_protocol_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("voting_protocols.id"))
sort_order: Mapped[int] = mapped_column(Integer, default=0)
section_tag: Mapped[str | None] = mapped_column(String(64)) # genesis, fondamental, technique, annexe, formule, inertie, ordonnancement
inertia_preset: Mapped[str] = mapped_column(String(16), default="standard") # low, standard, high, very_high
is_permanent_vote: Mapped[bool] = mapped_column(default=True) # permanent vote vs time-bounded
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
document: Mapped["Document"] = relationship(back_populates="items")
versions: Mapped[list["ItemVersion"]] = relationship(back_populates="item", cascade="all, delete-orphan")
class ItemVersion(Base):
__tablename__ = "item_versions"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
item_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("document_items.id"), nullable=False)
proposed_text: Mapped[str] = mapped_column(Text, nullable=False)
diff_text: Mapped[str | None] = mapped_column(Text)
rationale: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(32), default="proposed") # proposed, voting, accepted, rejected
decision_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("decisions.id"))
proposed_by_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
item: Mapped["DocumentItem"] = relationship(back_populates="versions")
+41
View File
@@ -0,0 +1,41 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Text, DateTime, ForeignKey, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Group(Base):
__tablename__ = "groups"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(128), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
organization_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("organizations.id"), nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
members: Mapped[list["GroupMember"]] = relationship(
back_populates="group", cascade="all, delete-orphan"
)
class GroupMember(Base):
__tablename__ = "group_members"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
group_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groups.id"), nullable=False, index=True)
# FK to duniter_identities when the member is a known WoT member; nullable for free-text entries
identity_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("duniter_identities.id"), nullable=True
)
display_name: Mapped[str] = mapped_column(String(128), nullable=False)
added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
group: Mapped["Group"] = relationship(back_populates="members")
+68
View File
@@ -0,0 +1,68 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Mandate(Base):
__tablename__ = "mandates"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
title: Mapped[str] = mapped_column(String(256), nullable=False)
origin_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"), nullable=True, index=True)
description: Mapped[str | None] = mapped_column(Text)
mandate_type: Mapped[str] = mapped_column(String(64), nullable=False)
status: Mapped[str] = mapped_column(String(32), default="draft")
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
mandatee_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"))
decision_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("decisions.id"))
starts_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
steps: Mapped[list["MandateStep"]] = relationship(
back_populates="mandate", cascade="all, delete-orphan", order_by="MandateStep.step_order"
)
origin_identity: Mapped["DuniterIdentity | None"] = relationship( # type: ignore[name-defined]
"DuniterIdentity", foreign_keys=[origin_id]
)
mandatee: Mapped["DuniterIdentity | None"] = relationship( # type: ignore[name-defined]
"DuniterIdentity", foreign_keys=[mandatee_id]
)
@property
def origin_display_name(self) -> str | None:
if self.origin_identity is not None:
return self.origin_identity.display_name or self.origin_identity.address
return None
@property
def mandatee_display_name(self) -> str | None:
if self.mandatee is not None:
return self.mandatee.display_name or self.mandatee.address
return None
class MandateStep(Base):
__tablename__ = "mandate_steps"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
mandate_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("mandates.id"), nullable=False)
step_order: Mapped[int] = mapped_column(Integer, nullable=False)
step_type: Mapped[str] = mapped_column(String(32), nullable=False)
title: Mapped[str | None] = mapped_column(String(256))
description: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(32), default="pending")
vote_session_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("vote_sessions.id"))
outcome: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
mandate: Mapped["Mandate"] = relationship(back_populates="steps")
# Avoid circular import — DuniterIdentity imported at runtime by SQLAlchemy relationship resolution
from app.models.user import DuniterIdentity # noqa: E402, F401
@@ -0,0 +1,42 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Organization(Base):
__tablename__ = "organizations"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(128), nullable=False)
slug: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
# commune, enterprise, association, collective, basin, intercommunality, community
org_type: Mapped[str] = mapped_column(String(64), default="community")
# True = all authenticated users see & interact with content (Duniter G1, Axiom Team)
# False = membership required
is_transparent: Mapped[bool] = mapped_column(Boolean, default=False)
color: Mapped[str | None] = mapped_column(String(32)) # CSS color or mood token
icon: Mapped[str | None] = mapped_column(String(64)) # lucide icon name
description: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
members: Mapped[list["OrgMember"]] = relationship(
back_populates="organization", cascade="all, delete-orphan"
)
class OrgMember(Base):
__tablename__ = "org_members"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
org_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("organizations.id"), nullable=False)
identity_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("duniter_identities.id"), nullable=False
)
role: Mapped[str] = mapped_column(String(32), default="member") # admin, member, observer
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
organization: Mapped["Organization"] = relationship(back_populates="members")
+52
View File
@@ -0,0 +1,52 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Integer, Float, Boolean, DateTime, ForeignKey, Text, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class FormulaConfig(Base):
__tablename__ = "formula_configs"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(128), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
# WoT threshold params
duration_days: Mapped[int] = mapped_column(Integer, default=30)
majority_pct: Mapped[int] = mapped_column(Integer, default=50)
base_exponent: Mapped[float] = mapped_column(Float, default=0.1)
gradient_exponent: Mapped[float] = mapped_column(Float, default=0.2)
constant_base: Mapped[float] = mapped_column(Float, default=0.0)
# Smith criterion
smith_exponent: Mapped[float | None] = mapped_column(Float)
# TechComm criterion
techcomm_exponent: Mapped[float | None] = mapped_column(Float)
# Nuanced vote
nuanced_min_participants: Mapped[int | None] = mapped_column(Integer)
nuanced_threshold_pct: Mapped[int | None] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
protocols: Mapped[list["VotingProtocol"]] = relationship(back_populates="formula_config")
class VotingProtocol(Base):
__tablename__ = "voting_protocols"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(128), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
vote_type: Mapped[str] = mapped_column(String(32), nullable=False) # binary, nuanced
formula_config_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("formula_configs.id"), nullable=False)
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
mode_params: Mapped[str | None] = mapped_column(String(64)) # e.g. "D30M50B.1G.2T.1"
is_meta_governed: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
formula_config: Mapped["FormulaConfig"] = relationship(back_populates="protocols")
@@ -0,0 +1,38 @@
import json
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Integer, String, Text, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class QualificationProtocol(Base):
"""Active configuration for the decision qualification engine.
Thresholds stored here override the engine defaults and can be updated
through the admin interface (meta-governance).
Only one record should be active at a time (is_active=True).
"""
__tablename__ = "qualification_protocols"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(128), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
small_group_max: Mapped[int] = mapped_column(Integer, default=5)
collective_wot_min: Mapped[int] = mapped_column(Integer, default=50)
# JSON array of modality slugs, e.g. '["vote_wot","vote_smith","election"]'
default_modalities_json: Mapped[str] = mapped_column(
Text,
default='["vote_wot","vote_smith","consultation_avis","election"]',
)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
@property
def default_modalities(self) -> list[str]:
return json.loads(self.default_modalities_json)
+22
View File
@@ -0,0 +1,22 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Integer, Text, DateTime, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class SanctuaryEntry(Base):
__tablename__ = "sanctuary_entries"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
entry_type: Mapped[str] = mapped_column(String(64), nullable=False) # document, decision, vote_result
reference_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)
title: Mapped[str | None] = mapped_column(String(256))
content_hash: Mapped[str] = mapped_column(String(128), nullable=False) # SHA-256
ipfs_cid: Mapped[str | None] = mapped_column(String(128))
chain_tx_hash: Mapped[str | None] = mapped_column(String(128))
chain_block: Mapped[int | None] = mapped_column(Integer)
metadata_json: Mapped[str | None] = mapped_column(Text) # JSON string for extra data
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+34
View File
@@ -0,0 +1,34 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class DuniterIdentity(Base):
__tablename__ = "duniter_identities"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
address: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
display_name: Mapped[str | None] = mapped_column(String(128))
wot_status: Mapped[str] = mapped_column(String(32), default="unknown") # member, pending, revoked, unknown
is_smith: Mapped[bool] = mapped_column(Boolean, default=False)
is_techcomm: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
sessions: Mapped[list["Session"]] = relationship(back_populates="identity", cascade="all, delete-orphan")
class Session(Base):
__tablename__ = "sessions"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
token_hash: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
identity_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("duniter_identities.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
identity: Mapped["DuniterIdentity"] = relationship(back_populates="sessions")
+70
View File
@@ -0,0 +1,70 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Integer, Float, Boolean, Text, DateTime, ForeignKey, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class VoteSession(Base):
__tablename__ = "vote_sessions"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
decision_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("decisions.id"))
item_version_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("item_versions.id"))
voting_protocol_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("voting_protocols.id"), nullable=False)
# Snapshot at session start
wot_size: Mapped[int] = mapped_column(Integer, default=0)
smith_size: Mapped[int] = mapped_column(Integer, default=0)
techcomm_size: Mapped[int] = mapped_column(Integer, default=0)
# Dates
starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
ends_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# Status
status: Mapped[str] = mapped_column(String(32), default="open") # open, closed, tallied
# Tallies
votes_for: Mapped[int] = mapped_column(Integer, default=0)
votes_against: Mapped[int] = mapped_column(Integer, default=0)
votes_total: Mapped[int] = mapped_column(Integer, default=0)
smith_votes_for: Mapped[int] = mapped_column(Integer, default=0)
techcomm_votes_for: Mapped[int] = mapped_column(Integer, default=0)
threshold_required: Mapped[float] = mapped_column(Float, default=0.0)
result: Mapped[str | None] = mapped_column(String(32)) # adopted, rejected, null
# Chain recording
chain_recorded: Mapped[bool] = mapped_column(Boolean, default=False)
chain_tx_hash: Mapped[str | None] = mapped_column(String(128))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
votes: Mapped[list["Vote"]] = relationship(back_populates="session", cascade="all, delete-orphan")
class Vote(Base):
__tablename__ = "votes"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
session_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("vote_sessions.id"), nullable=False)
voter_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("duniter_identities.id"), nullable=False)
vote_value: Mapped[str] = mapped_column(String(32), nullable=False) # for, against, or nuanced levels
nuanced_level: Mapped[int | None] = mapped_column(Integer) # 0-5 for nuanced votes
comment: Mapped[str | None] = mapped_column(Text)
# Cryptographic proof
signature: Mapped[str] = mapped_column(Text, nullable=False)
signed_payload: Mapped[str] = mapped_column(Text, nullable=False)
# Voter status snapshot
voter_wot_status: Mapped[str] = mapped_column(String(32), default="member")
voter_is_smith: Mapped[bool] = mapped_column(Boolean, default=False)
voter_is_techcomm: Mapped[bool] = mapped_column(Boolean, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
session: Mapped["VoteSession"] = relationship(back_populates="votes")
+256
View File
@@ -0,0 +1,256 @@
"""Auth router: Ed25519 challenge-response authentication for Duniter V2 identities."""
from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.models.user import DuniterIdentity
from app.schemas.auth import (
ChallengeRequest,
ChallengeResponse,
IdentityOut,
TokenResponse,
VerifyRequest,
)
from app.services.auth_service import (
create_session,
get_current_identity,
get_or_create_identity,
invalidate_session,
)
router = APIRouter()
# ── Dev profiles (only available when ENVIRONMENT == "development") ─────────
DEV_PROFILES = [
{
"address": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY",
"display_name": "Alice (Membre WoT)",
"wot_status": "member",
"is_smith": False,
"is_techcomm": False,
},
{
"address": "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty",
"display_name": "Bob (Forgeron)",
"wot_status": "member",
"is_smith": True,
"is_techcomm": False,
},
{
"address": "5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmkP7j4bJa3zN7d8tY",
"display_name": "Charlie (Référent structure)",
"wot_status": "member",
"is_smith": True,
"is_techcomm": True,
},
{
"address": "5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy",
"display_name": "Dave (Auteur)",
"wot_status": "member",
"is_smith": False,
"is_techcomm": False,
},
]
# ── In-memory challenge store (short-lived, no persistence needed) ──────────
# Structure: { address: { "challenge": str, "expires_at": datetime } }
_pending_challenges: dict[str, dict] = {}
def _cleanup_expired_challenges() -> None:
"""Remove expired challenges from the in-memory store."""
now = datetime.now(timezone.utc)
expired = [addr for addr, data in _pending_challenges.items() if data["expires_at"] < now]
for addr in expired:
del _pending_challenges[addr]
# ── Routes ──────────────────────────────────────────────────────────────────
@router.post("/challenge", response_model=ChallengeResponse)
async def request_challenge(payload: ChallengeRequest) -> ChallengeResponse:
"""Generate a random Ed25519 challenge for the given Duniter address.
The client must sign this challenge with the private key corresponding
to the address, then submit it via POST /verify.
"""
_cleanup_expired_challenges()
challenge = secrets.token_hex(32)
expires_at = datetime.now(timezone.utc) + timedelta(seconds=settings.CHALLENGE_EXPIRE_SECONDS)
_pending_challenges[payload.address] = {
"challenge": challenge,
"expires_at": expires_at,
}
return ChallengeResponse(challenge=challenge, expires_at=expires_at)
@router.post("/verify", response_model=TokenResponse)
async def verify_challenge(
payload: VerifyRequest,
db: AsyncSession = Depends(get_db),
) -> TokenResponse:
"""Verify the Ed25519 signature of a challenge and return a session token.
Steps:
1. Check that a pending challenge exists for the address.
2. Verify the challenge string matches.
3. Verify the Ed25519 signature against the address public key.
4. Create or retrieve the DuniterIdentity.
5. Create a session and return the bearer token.
"""
# 1. Retrieve pending challenge
pending = _pending_challenges.get(payload.address)
if pending is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Aucun challenge en attente pour cette adresse",
)
# 2. Check expiry
if pending["expires_at"] < datetime.now(timezone.utc):
del _pending_challenges[payload.address]
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Challenge expire, veuillez en demander un nouveau",
)
# 3. Verify challenge string matches
if pending["challenge"] != payload.challenge:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Challenge invalide",
)
# 4. Verify signature
# TODO: trustWallet — déléguer la vérification au protocole trustWallet (librodrome)
# Quand trustWallet sera disponible : remplacer le bloc ci-dessous par une vérification
# du token signé fourni par trustWallet (JWT ou preuve Ed25519 via iframe postMessage).
# Le bypass DEMO_MODE sera alors supprimé.
_demo_addresses = {p["address"] for p in DEV_PROFILES}
is_demo_bypass = (settings.DEMO_MODE or settings.ENVIRONMENT == "development") and payload.address in _demo_addresses
if not is_demo_bypass:
# polkadot.js / Cesium2 signRaw(type='bytes') wraps: <Bytes>{challenge}</Bytes>
message = f"<Bytes>{payload.challenge}</Bytes>".encode("utf-8")
sig_hex = payload.signature.removeprefix("0x")
try:
sig_bytes = bytes.fromhex(sig_hex)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Format de signature invalide (hex attendu)",
)
from substrateinterface import Keypair, KeypairType
verified = False
# Try Sr25519 first (default Substrate/Cesium2), then Ed25519 (Duniter v1 migration)
for key_type in [KeypairType.SR25519, KeypairType.ED25519]:
try:
kp = Keypair(ss58_address=payload.address, crypto_type=key_type)
if kp.verify(message, sig_bytes):
verified = True
break
except Exception:
continue
if not verified:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Signature invalide",
)
# 5. Consume the challenge
del _pending_challenges[payload.address]
# 6. Get or create identity (apply dev profile if available)
dev_profile = None
if settings.ENVIRONMENT == "development" or settings.DEMO_MODE:
dev_profile = next((p for p in DEV_PROFILES if p["address"] == payload.address), None)
identity = await get_or_create_identity(db, payload.address, dev_profile=dev_profile)
# 7. Create session token
token = await create_session(db, identity)
return TokenResponse(
token=token,
identity=IdentityOut.model_validate(identity),
)
@router.get("/dev/profiles")
async def list_dev_profiles():
"""List available demo profiles for quick login. Available in development or demo mode."""
if settings.ENVIRONMENT != "development" and not settings.DEMO_MODE:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not available")
return DEV_PROFILES
@router.get("/me", response_model=IdentityOut)
async def get_me(
identity: DuniterIdentity = Depends(get_current_identity),
) -> IdentityOut:
"""Return the currently authenticated identity."""
return IdentityOut.model_validate(identity)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None)
async def logout(
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
):
"""Invalidate the current session token.
Note: get_current_identity already validated the token, so we know it exists.
We re-extract it from the Authorization header to invalidate it.
"""
# We need the raw token to invalidate -- re-extract from the dependency chain.
# Since get_current_identity already validated, we know the request has a valid Bearer token.
# We use a slightly different approach: delete all sessions for this identity
# that match. For a cleaner approach, we accept the token via a dedicated dependency.
from fastapi import Request
# This is handled by getting the token from the auth service
# For simplicity, we delete all sessions for the identity
from sqlalchemy import select
from app.models.user import Session
result = await db.execute(select(Session).where(Session.identity_id == identity.id))
sessions = result.scalars().all()
for session in sessions:
await db.delete(session)
await db.commit()
@router.get("/identities", response_model=list[IdentityOut])
async def search_identities(
q: str = Query(..., min_length=1, description="Recherche par adresse ou nom"),
limit: int = Query(default=10, ge=1, le=50),
db: AsyncSession = Depends(get_db),
) -> list[IdentityOut]:
"""Search Duniter identities by address prefix or display_name."""
result = await db.execute(
select(DuniterIdentity)
.where(
or_(
DuniterIdentity.address.ilike(f"{q}%"),
DuniterIdentity.display_name.ilike(f"%{q}%"),
)
)
.order_by(DuniterIdentity.display_name)
.limit(limit)
)
return [IdentityOut.model_validate(i) for i in result.scalars().all()]
+228
View File
@@ -0,0 +1,228 @@
"""Decisions router: CRUD for decision processes and their steps."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.decision import Decision, DecisionStep
from app.models.user import DuniterIdentity
from app.schemas.decision import (
DecisionAdvanceOut,
DecisionCreate,
DecisionOut,
DecisionStepCreate,
DecisionStepOut,
DecisionUpdate,
)
from app.schemas.vote import VoteSessionOut
from app.dependencies.org import get_active_org_id
from app.services.auth_service import get_current_identity
from app.services.decision_service import advance_decision, create_vote_session_for_step
router = APIRouter()
# ── Helpers ─────────────────────────────────────────────────────────────────
async def _get_decision(
db: AsyncSession, decision_id: uuid.UUID, org_id: uuid.UUID | None = None
) -> Decision:
"""Fetch a decision by ID within the active org scope, or raise 404."""
stmt = (
select(Decision)
.options(selectinload(Decision.steps))
.where(Decision.id == decision_id)
)
if org_id is not None:
stmt = stmt.where(Decision.organization_id == org_id)
else:
stmt = stmt.where(Decision.organization_id.is_(None))
result = await db.execute(stmt)
decision = result.scalar_one_or_none()
if decision is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Decision introuvable")
return decision
# ── Decision routes ─────────────────────────────────────────────────────────
@router.get("/", response_model=list[DecisionOut])
async def list_decisions(
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
decision_type: str | None = Query(default=None, description="Filtrer par type de decision"),
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=200),
) -> list[DecisionOut]:
"""List all decisions with optional filters."""
stmt = select(Decision).options(selectinload(Decision.steps))
if org_id is not None:
stmt = stmt.where(Decision.organization_id == org_id)
else:
stmt = stmt.where(Decision.organization_id.is_(None))
if decision_type is not None:
stmt = stmt.where(Decision.decision_type == decision_type)
if status_filter is not None:
stmt = stmt.where(Decision.status == status_filter)
stmt = stmt.order_by(Decision.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt)
decisions = result.scalars().unique().all()
return [DecisionOut.model_validate(d) for d in decisions]
@router.post("/", response_model=DecisionOut, status_code=status.HTTP_201_CREATED)
async def create_decision(
payload: DecisionCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DecisionOut:
"""Create a new decision process."""
decision = Decision(
**payload.model_dump(),
created_by_id=identity.id,
organization_id=org_id,
)
db.add(decision)
await db.commit()
await db.refresh(decision)
# Reload with steps (empty at creation)
decision = await _get_decision(db, decision.id, org_id)
return DecisionOut.model_validate(decision)
@router.get("/{id}", response_model=DecisionOut)
async def get_decision(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DecisionOut:
"""Get a single decision with all its steps."""
decision = await _get_decision(db, id, org_id)
return DecisionOut.model_validate(decision)
@router.put("/{id}", response_model=DecisionOut)
async def update_decision(
id: uuid.UUID,
payload: DecisionUpdate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DecisionOut:
"""Update a decision's metadata (title, description, status, protocol)."""
decision = await _get_decision(db, id, org_id)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(decision, field, value)
await db.commit()
await db.refresh(decision)
# Reload with steps
decision = await _get_decision(db, decision.id, org_id)
return DecisionOut.model_validate(decision)
# ── Decision Step routes ────────────────────────────────────────────────────
@router.post("/{id}/steps", response_model=DecisionStepOut, status_code=status.HTTP_201_CREATED)
async def add_step(
id: uuid.UUID,
payload: DecisionStepCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DecisionStepOut:
"""Add a step to a decision process."""
# Verify decision exists
decision = await _get_decision(db, id, org_id)
step = DecisionStep(
decision_id=decision.id,
**payload.model_dump(),
)
db.add(step)
await db.commit()
await db.refresh(step)
return DecisionStepOut.model_validate(step)
# ── Workflow routes ────────────────────────────────────────────────────────
@router.post("/{id}/advance", response_model=DecisionAdvanceOut)
async def advance_decision_endpoint(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DecisionAdvanceOut:
"""Advance a decision to its next step or status."""
try:
decision = await advance_decision(id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
# Reload with steps for complete output
decision = await _get_decision(db, decision.id, org_id)
data = DecisionOut.model_validate(decision).model_dump()
data["message"] = f"Decision avancee au statut : {decision.status}"
return DecisionAdvanceOut(**data)
@router.post(
"/{id}/steps/{step_id}/create-vote-session",
response_model=VoteSessionOut,
status_code=status.HTTP_201_CREATED,
)
async def create_vote_session_for_step_endpoint(
id: uuid.UUID,
step_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> VoteSessionOut:
"""Create a vote session linked to a decision step."""
try:
session = await create_vote_session_for_step(id, step_id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
return VoteSessionOut.model_validate(session)
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None)
async def delete_decision(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> None:
"""Delete a decision (only if in draft status)."""
decision = await _get_decision(db, id, org_id)
if decision.status != "draft":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Seules les decisions en brouillon peuvent etre supprimees",
)
await db.delete(decision)
await db.commit()
+474
View File
@@ -0,0 +1,474 @@
"""Documents router: CRUD for reference documents, items, and item versions."""
from __future__ import annotations
import difflib
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.document import Document, DocumentItem, ItemVersion
from app.models.user import DuniterIdentity
from app.schemas.document import (
DocumentCreate,
DocumentFullOut,
DocumentItemCreate,
DocumentItemOut,
DocumentItemUpdate,
DocumentOut,
DocumentUpdate,
ItemReorderRequest,
ItemVersionCreate,
ItemVersionOut,
)
from app.dependencies.org import get_active_org_id
from app.services import document_service
from app.services.auth_service import get_current_identity
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Helpers ─────────────────────────────────────────────────────────────────
async def _get_document_by_slug(db: AsyncSession, slug: str) -> Document:
"""Fetch a document by slug or raise 404."""
result = await db.execute(select(Document).where(Document.slug == slug))
doc = result.scalar_one_or_none()
if doc is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document introuvable")
return doc
async def _get_item(db: AsyncSession, document_id: uuid.UUID, item_id: uuid.UUID) -> DocumentItem:
"""Fetch a document item by ID within a document, or raise 404."""
result = await db.execute(
select(DocumentItem).where(
DocumentItem.id == item_id,
DocumentItem.document_id == document_id,
)
)
item = result.scalar_one_or_none()
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item introuvable")
return item
# ── Document routes ─────────────────────────────────────────────────────────
@router.get("/", response_model=list[DocumentOut])
async def list_documents(
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
doc_type: str | None = Query(default=None, description="Filtrer par type de document"),
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=200),
) -> list[DocumentOut]:
"""List all reference documents, with optional filters."""
stmt = select(Document)
if org_id is not None:
stmt = stmt.where(Document.organization_id == org_id)
else:
stmt = stmt.where(Document.organization_id.is_(None))
if doc_type is not None:
stmt = stmt.where(Document.doc_type == doc_type)
if status_filter is not None:
stmt = stmt.where(Document.status == status_filter)
stmt = stmt.order_by(Document.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt)
documents = result.scalars().all()
# Compute items_count for each document
out = []
for doc in documents:
count_result = await db.execute(
select(func.count()).select_from(DocumentItem).where(DocumentItem.document_id == doc.id)
)
items_count = count_result.scalar() or 0
doc_out = DocumentOut.model_validate(doc)
doc_out.items_count = items_count
out.append(doc_out)
return out
@router.post("/", response_model=DocumentOut, status_code=status.HTTP_201_CREATED)
async def create_document(
payload: DocumentCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DocumentOut:
"""Create a new reference document."""
# Check slug uniqueness
existing = await db.execute(select(Document).where(Document.slug == payload.slug))
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Un document avec ce slug existe deja",
)
doc = Document(**payload.model_dump(), organization_id=org_id)
db.add(doc)
await db.commit()
await db.refresh(doc)
doc_out = DocumentOut.model_validate(doc)
doc_out.items_count = 0
return doc_out
@router.get("/{slug}", response_model=DocumentOut)
async def get_document(
slug: str,
db: AsyncSession = Depends(get_db),
) -> DocumentOut:
"""Get a single document by its slug."""
doc = await _get_document_by_slug(db, slug)
count_result = await db.execute(
select(func.count()).select_from(DocumentItem).where(DocumentItem.document_id == doc.id)
)
items_count = count_result.scalar() or 0
doc_out = DocumentOut.model_validate(doc)
doc_out.items_count = items_count
return doc_out
@router.put("/{slug}", response_model=DocumentOut)
async def update_document(
slug: str,
payload: DocumentUpdate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> DocumentOut:
"""Update a document's metadata (title, status, description, version)."""
doc = await _get_document_by_slug(db, slug)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(doc, field, value)
await db.commit()
await db.refresh(doc)
count_result = await db.execute(
select(func.count()).select_from(DocumentItem).where(DocumentItem.document_id == doc.id)
)
items_count = count_result.scalar() or 0
doc_out = DocumentOut.model_validate(doc)
doc_out.items_count = items_count
return doc_out
# ── Document Item routes ────────────────────────────────────────────────────
@router.post("/{slug}/items", response_model=DocumentItemOut, status_code=status.HTTP_201_CREATED)
async def add_item(
slug: str,
payload: DocumentItemCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> DocumentItemOut:
"""Add a new item (clause, rule, etc.) to a document."""
doc = await _get_document_by_slug(db, slug)
# Determine sort_order: max existing + 1
max_order_result = await db.execute(
select(func.max(DocumentItem.sort_order)).where(DocumentItem.document_id == doc.id)
)
max_order = max_order_result.scalar() or 0
item = DocumentItem(
document_id=doc.id,
sort_order=max_order + 1,
**payload.model_dump(),
)
db.add(item)
await db.commit()
await db.refresh(item)
return DocumentItemOut.model_validate(item)
@router.get("/{slug}/items", response_model=list[DocumentItemOut])
async def list_items(
slug: str,
db: AsyncSession = Depends(get_db),
) -> list[DocumentItemOut]:
"""List all items in a document, ordered by sort_order."""
doc = await _get_document_by_slug(db, slug)
result = await db.execute(
select(DocumentItem)
.where(DocumentItem.document_id == doc.id)
.order_by(DocumentItem.sort_order)
)
items = result.scalars().all()
return [DocumentItemOut.model_validate(item) for item in items]
# NOTE: reorder must be declared BEFORE /{slug}/items/{item_id} routes
# to avoid "reorder" being parsed as a UUID path parameter.
@router.put("/{slug}/items/reorder", response_model=list[DocumentItemOut])
async def reorder_items(
slug: str,
payload: ItemReorderRequest,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> list[DocumentItemOut]:
"""Reorder items in a document by updating their sort_order values."""
doc = await _get_document_by_slug(db, slug)
items = await document_service.reorder_items(
doc.id,
[(entry.item_id, entry.sort_order) for entry in payload.items],
db,
)
return [DocumentItemOut.model_validate(item) for item in items]
@router.get("/{slug}/items/{item_id}", response_model=DocumentItemOut)
async def get_item(
slug: str,
item_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> DocumentItemOut:
"""Get a single item with its version history."""
doc = await _get_document_by_slug(db, slug)
item = await _get_item(db, doc.id, item_id)
return DocumentItemOut.model_validate(item)
@router.post(
"/{slug}/items/{item_id}/versions",
response_model=ItemVersionOut,
status_code=status.HTTP_201_CREATED,
)
async def propose_version(
slug: str,
item_id: uuid.UUID,
payload: ItemVersionCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> ItemVersionOut:
"""Propose a new version of a document item.
Automatically computes a unified diff between the current text and the proposed text.
"""
doc = await _get_document_by_slug(db, slug)
item = await _get_item(db, doc.id, item_id)
# Compute diff
diff_lines = difflib.unified_diff(
item.current_text.splitlines(keepends=True),
payload.proposed_text.splitlines(keepends=True),
fromfile="actuel",
tofile="propose",
)
diff_text = "".join(diff_lines) or None
version = ItemVersion(
item_id=item.id,
proposed_text=payload.proposed_text,
diff_text=diff_text,
rationale=payload.rationale,
proposed_by_id=identity.id,
)
db.add(version)
await db.commit()
await db.refresh(version)
return ItemVersionOut.model_validate(version)
# ── Item update & delete ───────────────────────────────────────────────────
@router.put("/{slug}/items/{item_id}", response_model=DocumentItemOut)
async def update_item(
slug: str,
item_id: uuid.UUID,
payload: DocumentItemUpdate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> DocumentItemOut:
"""Update an item's text, title, position, or item_type."""
doc = await _get_document_by_slug(db, slug)
item = await _get_item(db, doc.id, item_id)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(item, field, value)
await db.commit()
await db.refresh(item)
return DocumentItemOut.model_validate(item)
@router.delete("/{slug}/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None)
async def delete_item(
slug: str,
item_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> None:
"""Delete a document item.
Refuses deletion if the item has any active votes (status 'voting').
"""
doc = await _get_document_by_slug(db, slug)
item = await _get_item(db, doc.id, item_id)
# Check for active votes on this item's versions
active_versions_result = await db.execute(
select(func.count()).select_from(ItemVersion).where(
ItemVersion.item_id == item.id,
ItemVersion.status == "voting",
)
)
active_count = active_versions_result.scalar() or 0
if active_count > 0:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Impossible de supprimer un element avec des votes en cours",
)
await db.delete(item)
await db.commit()
# ── Version accept & reject ────────────────────────────────────────────────
@router.put(
"/{slug}/items/{item_id}/versions/{version_id}/accept",
response_model=DocumentItemOut,
)
async def accept_version(
slug: str,
item_id: uuid.UUID,
version_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> DocumentItemOut:
"""Accept a proposed version and apply it to the document item.
Replaces the item's current_text with the version's proposed_text
and rejects all other pending/voting versions for this item.
"""
doc = await _get_document_by_slug(db, slug)
# Verify item belongs to document
await _get_item(db, doc.id, item_id)
try:
updated_item = await document_service.apply_version(item_id, version_id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
return DocumentItemOut.model_validate(updated_item)
@router.put(
"/{slug}/items/{item_id}/versions/{version_id}/reject",
response_model=ItemVersionOut,
)
async def reject_version(
slug: str,
item_id: uuid.UUID,
version_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> ItemVersionOut:
"""Reject a proposed version."""
doc = await _get_document_by_slug(db, slug)
# Verify item belongs to document
await _get_item(db, doc.id, item_id)
try:
version = await document_service.reject_version(item_id, version_id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
return ItemVersionOut.model_validate(version)
# ── Version listing ────────────────────────────────────────────────────────
@router.get(
"/{slug}/items/{item_id}/versions",
response_model=list[ItemVersionOut],
)
async def list_versions(
slug: str,
item_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> list[ItemVersionOut]:
"""List all versions for a document item."""
doc = await _get_document_by_slug(db, slug)
await _get_item(db, doc.id, item_id)
result = await db.execute(
select(ItemVersion)
.where(ItemVersion.item_id == item_id)
.order_by(ItemVersion.created_at.desc())
)
versions = result.scalars().all()
return [ItemVersionOut.model_validate(v) for v in versions]
# ── Document full view ─────────────────────────────────────────────────────
@router.get("/{slug}/full", response_model=DocumentFullOut)
async def get_document_full(
slug: str,
db: AsyncSession = Depends(get_db),
) -> DocumentFullOut:
"""Get a document with all its items (not just count)."""
doc = await document_service.get_document_with_items(slug, db)
if doc is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document introuvable")
return DocumentFullOut.model_validate(doc)
# ── Document archive to sanctuary ──────────────────────────────────────────
@router.post("/{slug}/archive", status_code=status.HTTP_201_CREATED)
async def archive_document(
slug: str,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> dict:
"""Archive a document to the sanctuary (IPFS + on-chain hash).
Serializes the full document text and sends it through the sanctuary pipeline.
"""
entry = await document_service.archive_document(slug, db)
return {
"message": "Document archive avec succes",
"sanctuary_entry_id": str(entry.id),
"content_hash": entry.content_hash,
"ipfs_cid": entry.ipfs_cid,
"chain_tx_hash": entry.chain_tx_hash,
}
+122
View File
@@ -0,0 +1,122 @@
"""Groups router — predefined sets of Duniter identities used in decision circles."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.dependencies.org import get_active_org_id
from app.models.group import Group, GroupMember
from app.schemas.group import GroupCreate, GroupMemberCreate, GroupMemberOut, GroupOut, GroupSummary
from app.services.auth_service import get_current_identity
router = APIRouter()
@router.get("/", response_model=list[GroupSummary])
async def list_groups(
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> list[GroupSummary]:
"""List groups within the active workspace."""
stmt = select(Group).options(selectinload(Group.members)).order_by(Group.name)
if org_id is not None:
stmt = stmt.where(Group.organization_id == org_id)
else:
stmt = stmt.where(Group.organization_id.is_(None))
result = await db.execute(stmt)
groups = result.scalars().all()
return [
GroupSummary(
id=g.id,
name=g.name,
description=g.description,
organization_id=g.organization_id,
member_count=len(g.members),
)
for g in groups
]
@router.post("/", response_model=GroupOut, status_code=201)
async def create_group(
payload: GroupCreate,
db: AsyncSession = Depends(get_db),
_identity=Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> GroupOut:
group = Group(name=payload.name, description=payload.description, organization_id=org_id)
db.add(group)
await db.commit()
await db.refresh(group)
await db.execute(select(Group).where(Group.id == group.id).options(selectinload(Group.members)))
return GroupOut.model_validate(group)
@router.get("/{group_id}", response_model=GroupOut)
async def get_group(group_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> GroupOut:
result = await db.execute(
select(Group).where(Group.id == group_id).options(selectinload(Group.members))
)
group = result.scalar_one_or_none()
if group is None:
raise HTTPException(status_code=404, detail="Group not found")
return GroupOut.model_validate(group)
@router.delete("/{group_id}", status_code=204, response_class=Response, response_model=None)
async def delete_group(
group_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_identity=Depends(get_current_identity),
) -> None:
result = await db.execute(select(Group).where(Group.id == group_id))
group = result.scalar_one_or_none()
if group is None:
raise HTTPException(status_code=404, detail="Group not found")
await db.delete(group)
await db.commit()
@router.post("/{group_id}/members", response_model=GroupMemberOut, status_code=201)
async def add_member(
group_id: uuid.UUID,
payload: GroupMemberCreate,
db: AsyncSession = Depends(get_db),
_identity=Depends(get_current_identity),
) -> GroupMemberOut:
result = await db.execute(select(Group).where(Group.id == group_id))
if result.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Group not found")
member = GroupMember(
group_id=group_id,
display_name=payload.display_name,
identity_id=payload.identity_id,
)
db.add(member)
await db.commit()
await db.refresh(member)
return GroupMemberOut.model_validate(member)
@router.delete("/{group_id}/members/{member_id}", status_code=204, response_class=Response, response_model=None)
async def remove_member(
group_id: uuid.UUID,
member_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_identity=Depends(get_current_identity),
) -> None:
result = await db.execute(
select(GroupMember).where(GroupMember.id == member_id, GroupMember.group_id == group_id)
)
member = result.scalar_one_or_none()
if member is None:
raise HTTPException(status_code=404, detail="Member not found")
await db.delete(member)
await db.commit()
+278
View File
@@ -0,0 +1,278 @@
"""Mandates router: CRUD for mandates and their steps."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.mandate import Mandate, MandateStep
from app.models.user import DuniterIdentity
from app.schemas.mandate import (
MandateAdvanceOut,
MandateAssignRequest,
MandateCreate,
MandateOut,
MandateStepCreate,
MandateStepOut,
MandateUpdate,
)
from app.schemas.vote import VoteSessionOut
from app.dependencies.org import get_active_org_id
from app.services.auth_service import get_current_identity
from app.services.mandate_service import (
advance_mandate,
assign_mandatee,
create_vote_session_for_step,
revoke_mandate,
)
router = APIRouter()
# ── Helpers ─────────────────────────────────────────────────────────────────
async def _get_mandate(
db: AsyncSession, mandate_id: uuid.UUID, org_id: uuid.UUID | None = None
) -> Mandate:
stmt = (
select(Mandate)
.options(
selectinload(Mandate.steps),
selectinload(Mandate.origin_identity),
selectinload(Mandate.mandatee),
)
.where(Mandate.id == mandate_id)
)
if org_id is not None:
stmt = stmt.where(Mandate.organization_id == org_id)
else:
stmt = stmt.where(Mandate.organization_id.is_(None))
result = await db.execute(stmt)
mandate = result.scalar_one_or_none()
if mandate is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mandat introuvable")
return mandate
def _mandate_out(mandate: Mandate) -> MandateOut:
out = MandateOut.model_validate(mandate)
out.origin_display_name = mandate.origin_display_name
out.mandatee_display_name = mandate.mandatee_display_name
return out
# ── Mandate routes ──────────────────────────────────────────────────────────
@router.get("/", response_model=list[MandateOut])
async def list_mandates(
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
mandate_type: str | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=200),
) -> list[MandateOut]:
stmt = select(Mandate).options(
selectinload(Mandate.steps),
selectinload(Mandate.origin_identity),
selectinload(Mandate.mandatee),
)
if org_id is not None:
stmt = stmt.where(Mandate.organization_id == org_id)
else:
stmt = stmt.where(Mandate.organization_id.is_(None))
if mandate_type is not None:
stmt = stmt.where(Mandate.mandate_type == mandate_type)
if status_filter is not None:
stmt = stmt.where(Mandate.status == status_filter)
stmt = stmt.order_by(Mandate.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt)
mandates = result.scalars().unique().all()
return [_mandate_out(m) for m in mandates]
@router.post("/", response_model=MandateOut, status_code=status.HTTP_201_CREATED)
async def create_mandate(
payload: MandateCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateOut:
data = payload.model_dump()
nomination_mode = data.pop("nomination_mode", "postpone")
mandate = Mandate(**data, organization_id=org_id)
if nomination_mode == "auto":
mandate.mandatee_id = identity.id
db.add(mandate)
await db.commit()
await db.refresh(mandate)
mandate = await _get_mandate(db, mandate.id, org_id)
return _mandate_out(mandate)
@router.get("/{id}", response_model=MandateOut)
async def get_mandate(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateOut:
mandate = await _get_mandate(db, id, org_id)
return _mandate_out(mandate)
@router.put("/{id}", response_model=MandateOut)
async def update_mandate(
id: uuid.UUID,
payload: MandateUpdate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateOut:
mandate = await _get_mandate(db, id, org_id)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(mandate, field, value)
await db.commit()
mandate = await _get_mandate(db, mandate.id, org_id)
return _mandate_out(mandate)
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None)
async def delete_mandate(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> None:
mandate = await _get_mandate(db, id, org_id)
if mandate.status != "draft":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Seuls les mandats en brouillon peuvent etre supprimes",
)
await db.delete(mandate)
await db.commit()
# ── Mandate Step routes ─────────────────────────────────────────────────────
@router.post("/{id}/steps", response_model=MandateStepOut, status_code=status.HTTP_201_CREATED)
async def add_step(
id: uuid.UUID,
payload: MandateStepCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateStepOut:
mandate = await _get_mandate(db, id, org_id)
step = MandateStep(mandate_id=mandate.id, **payload.model_dump())
db.add(step)
await db.commit()
await db.refresh(step)
return MandateStepOut.model_validate(step)
@router.get("/{id}/steps", response_model=list[MandateStepOut])
async def list_steps(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> list[MandateStepOut]:
mandate = await _get_mandate(db, id, org_id)
return [MandateStepOut.model_validate(s) for s in mandate.steps]
# ── Workflow routes ────────────────────────────────────────────────────────
@router.post("/{id}/advance", response_model=MandateAdvanceOut)
async def advance_mandate_endpoint(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateAdvanceOut:
try:
mandate = await advance_mandate(id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
mandate = await _get_mandate(db, mandate.id, org_id)
out = _mandate_out(mandate)
return MandateAdvanceOut(
**out.model_dump(),
message=f"Mandat avance au statut : {mandate.status}",
)
@router.post("/{id}/assign", response_model=MandateOut)
async def assign_mandatee_endpoint(
id: uuid.UUID,
payload: MandateAssignRequest,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateOut:
try:
mandate = await assign_mandatee(id, payload.mandatee_id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
mandate = await _get_mandate(db, mandate.id, org_id)
return _mandate_out(mandate)
@router.post("/{id}/revoke", response_model=MandateOut)
async def revoke_mandate_endpoint(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateOut:
try:
mandate = await revoke_mandate(id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
mandate = await _get_mandate(db, mandate.id, org_id)
return _mandate_out(mandate)
@router.post(
"/{id}/steps/{step_id}/create-vote-session",
response_model=VoteSessionOut,
status_code=status.HTTP_201_CREATED,
)
async def create_vote_session_for_step_endpoint(
id: uuid.UUID,
step_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> VoteSessionOut:
try:
session = await create_vote_session_for_step(id, step_id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
return VoteSessionOut.model_validate(session)
@@ -0,0 +1,96 @@
"""Organizations router: list, create, membership."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import DuniterIdentity
from app.schemas.organization import OrgMemberOut, OrganizationCreate, OrganizationOut
from app.services.auth_service import get_current_identity
from app.services.org_service import (
add_member,
create_organization,
get_organization,
get_organization_by_slug,
is_member,
list_members,
list_organizations,
)
router = APIRouter()
@router.get("/", response_model=list[OrganizationOut])
async def get_organizations(db: AsyncSession = Depends(get_db)) -> list[OrganizationOut]:
"""List all organizations (public — transparent ones need no auth)."""
orgs = await list_organizations(db)
return [OrganizationOut.model_validate(o) for o in orgs]
@router.post("/", response_model=OrganizationOut, status_code=status.HTTP_201_CREATED)
async def post_organization(
payload: OrganizationCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> OrganizationOut:
"""Create a new organization (authenticated users only)."""
existing = await get_organization_by_slug(db, payload.slug)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Slug '{payload.slug}' déjà utilisé",
)
org = await create_organization(db, **payload.model_dump())
# Creator becomes admin
await add_member(db, org.id, identity.id, role="admin")
return OrganizationOut.model_validate(org)
@router.get("/{org_id}", response_model=OrganizationOut)
async def get_organization_detail(
org_id: uuid.UUID, db: AsyncSession = Depends(get_db)
) -> OrganizationOut:
org = await get_organization(db, org_id)
if not org:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organisation introuvable")
return OrganizationOut.model_validate(org)
@router.get("/{org_id}/members", response_model=list[OrgMemberOut])
async def get_members(
org_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> list[OrgMemberOut]:
org = await get_organization(db, org_id)
if not org:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organisation introuvable")
if not org.is_transparent and not await is_member(db, org_id, identity.id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Accès refusé")
members = await list_members(db, org_id)
return [OrgMemberOut.model_validate(m) for m in members]
@router.post("/{org_id}/join", response_model=OrgMemberOut, status_code=status.HTTP_201_CREATED)
async def join_organization(
org_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> OrgMemberOut:
"""Join a transparent organization."""
org = await get_organization(db, org_id)
if not org:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organisation introuvable")
if not org.is_transparent:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Rejoindre cette organisation nécessite une invitation",
)
if await is_member(db, org_id, identity.id):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Déjà membre")
member = await add_member(db, org_id, identity.id)
return OrgMemberOut.model_validate(member)
+284
View File
@@ -0,0 +1,284 @@
"""Protocols router: voting protocols, formula configurations, and simulation."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.engine.smith_threshold import smith_threshold as compute_smith_threshold
from app.engine.techcomm_threshold import techcomm_threshold as compute_techcomm_threshold
from app.engine.threshold import wot_threshold as compute_wot_threshold
from app.models.protocol import FormulaConfig, VotingProtocol
from app.models.user import DuniterIdentity
from app.schemas.protocol import (
FormulaConfigCreate,
FormulaConfigOut,
FormulaConfigUpdate,
FormulaSimulationRequest,
FormulaSimulationResult,
VotingProtocolCreate,
VotingProtocolOut,
VotingProtocolUpdate,
)
from app.dependencies.org import get_active_org_id
from app.services.auth_service import get_current_identity
router = APIRouter()
# ── Helpers ─────────────────────────────────────────────────────────────────
async def _get_protocol(
db: AsyncSession, protocol_id: uuid.UUID, org_id: uuid.UUID | None = None
) -> VotingProtocol:
"""Fetch a voting protocol by ID within the active org scope, or raise 404."""
stmt = (
select(VotingProtocol)
.options(selectinload(VotingProtocol.formula_config))
.where(VotingProtocol.id == protocol_id)
)
if org_id is not None:
stmt = stmt.where(VotingProtocol.organization_id == org_id)
else:
stmt = stmt.where(VotingProtocol.organization_id.is_(None))
result = await db.execute(stmt)
protocol = result.scalar_one_or_none()
if protocol is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Protocole introuvable")
return protocol
async def _get_formula(db: AsyncSession, formula_id: uuid.UUID) -> FormulaConfig:
"""Fetch a formula config by ID, or raise 404."""
result = await db.execute(
select(FormulaConfig).where(FormulaConfig.id == formula_id)
)
formula = result.scalar_one_or_none()
if formula is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Configuration de formule introuvable")
return formula
# ── Voting Protocol routes ──────────────────────────────────────────────────
@router.get("/", response_model=list[VotingProtocolOut])
async def list_protocols(
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
vote_type: str | None = Query(default=None, description="Filtrer par type de vote (binary, nuanced)"),
skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=200),
) -> list[VotingProtocolOut]:
"""List all voting protocols with their formula configurations."""
stmt = select(VotingProtocol).options(selectinload(VotingProtocol.formula_config))
if org_id is not None:
stmt = stmt.where(VotingProtocol.organization_id == org_id)
else:
stmt = stmt.where(VotingProtocol.organization_id.is_(None))
if vote_type is not None:
stmt = stmt.where(VotingProtocol.vote_type == vote_type)
stmt = stmt.order_by(VotingProtocol.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt)
protocols = result.scalars().unique().all()
return [VotingProtocolOut.model_validate(p) for p in protocols]
@router.post("/", response_model=VotingProtocolOut, status_code=status.HTTP_201_CREATED)
async def create_protocol(
payload: VotingProtocolCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> VotingProtocolOut:
"""Create a new voting protocol.
The formula_config_id must reference an existing FormulaConfig.
"""
# Verify formula config exists
fc_result = await db.execute(
select(FormulaConfig).where(FormulaConfig.id == payload.formula_config_id)
)
if fc_result.scalar_one_or_none() is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Configuration de formule introuvable",
)
protocol = VotingProtocol(**payload.model_dump(), organization_id=org_id)
db.add(protocol)
await db.commit()
await db.refresh(protocol)
# Reload with formula config
protocol = await _get_protocol(db, protocol.id, org_id)
return VotingProtocolOut.model_validate(protocol)
@router.get("/{id}", response_model=VotingProtocolOut)
async def get_protocol(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> VotingProtocolOut:
"""Get a single voting protocol with its formula configuration."""
protocol = await _get_protocol(db, id, org_id)
return VotingProtocolOut.model_validate(protocol)
@router.put("/{id}", response_model=VotingProtocolOut)
async def update_protocol(
id: uuid.UUID,
payload: VotingProtocolUpdate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> VotingProtocolOut:
"""Update a voting protocol (meta-governance).
Only provided fields will be updated. Requires authentication.
"""
protocol = await _get_protocol(db, id, org_id)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(protocol, field, value)
await db.commit()
# Reload with formula config
protocol = await _get_protocol(db, protocol.id, org_id)
return VotingProtocolOut.model_validate(protocol)
# ── Formula Config routes ───────────────────────────────────────────────────
@router.get("/formulas", response_model=list[FormulaConfigOut])
async def list_formulas(
db: AsyncSession = Depends(get_db),
skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=200),
) -> list[FormulaConfigOut]:
"""List all formula configurations."""
stmt = (
select(FormulaConfig)
.order_by(FormulaConfig.created_at.desc())
.offset(skip)
.limit(limit)
)
result = await db.execute(stmt)
formulas = result.scalars().all()
return [FormulaConfigOut.model_validate(f) for f in formulas]
@router.post("/formulas", response_model=FormulaConfigOut, status_code=status.HTTP_201_CREATED)
async def create_formula(
payload: FormulaConfigCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> FormulaConfigOut:
"""Create a new formula configuration for WoT threshold computation."""
formula = FormulaConfig(**payload.model_dump())
db.add(formula)
await db.commit()
await db.refresh(formula)
return FormulaConfigOut.model_validate(formula)
@router.get("/formulas/{id}", response_model=FormulaConfigOut)
async def get_formula(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> FormulaConfigOut:
"""Get a single formula configuration."""
formula = await _get_formula(db, id)
return FormulaConfigOut.model_validate(formula)
@router.put("/formulas/{id}", response_model=FormulaConfigOut)
async def update_formula(
id: uuid.UUID,
payload: FormulaConfigUpdate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> FormulaConfigOut:
"""Update a formula configuration (meta-governance).
Only provided fields will be updated. Requires authentication.
"""
formula = await _get_formula(db, id)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(formula, field, value)
await db.commit()
await db.refresh(formula)
return FormulaConfigOut.model_validate(formula)
# ── Simulation ──────────────────────────────────────────────────────────────
@router.post("/simulate", response_model=FormulaSimulationResult)
async def simulate_formula(
payload: FormulaSimulationRequest,
) -> FormulaSimulationResult:
"""Simulate a WoT threshold formula computation.
Pure calculation endpoint -- no database access needed.
Accepts WoT size, total votes, and formula parameters,
returns the computed thresholds and derived values.
"""
# Compute WoT threshold
wot_thresh = compute_wot_threshold(
wot_size=payload.wot_size,
total_votes=payload.total_votes,
majority_pct=payload.majority_pct,
base_exponent=payload.base_exponent,
gradient_exponent=payload.gradient_exponent,
constant_base=payload.constant_base,
)
# Compute derived values for transparency
w = payload.wot_size
t = payload.total_votes
m = payload.majority_pct / 100.0
g = payload.gradient_exponent
participation_rate = t / w if w > 0 else 0.0
turnout_ratio = min(t / w, 1.0) if w > 0 else 0.0
inertia_factor = 1.0 - turnout_ratio ** g if t > 0 else 1.0
required_ratio = m + (1.0 - m) * inertia_factor
# Smith threshold (optional)
smith_thresh = None
if payload.smith_wot_size is not None and payload.smith_exponent is not None:
smith_thresh = compute_smith_threshold(payload.smith_wot_size, payload.smith_exponent)
# TechComm threshold (optional)
techcomm_thresh = None
if payload.techcomm_size is not None and payload.techcomm_exponent is not None:
techcomm_thresh = compute_techcomm_threshold(payload.techcomm_size, payload.techcomm_exponent)
return FormulaSimulationResult(
wot_threshold=wot_thresh,
smith_threshold=smith_thresh,
techcomm_threshold=techcomm_thresh,
participation_rate=round(participation_rate, 6),
required_ratio=round(required_ratio, 6),
inertia_factor=round(inertia_factor, 6),
)
+249
View File
@@ -0,0 +1,249 @@
"""Public API router: read-only endpoints for external consumption.
All endpoints are accessible without authentication.
No mutations allowed -- strictly read-only.
"""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.decision import Decision
from app.models.document import Document, DocumentItem
from app.models.sanctuary import SanctuaryEntry
from app.models.vote import VoteSession
from app.schemas.document import DocumentFullOut, DocumentItemOut, DocumentOut
from app.schemas.sanctuary import SanctuaryEntryOut
from app.services import document_service, sanctuary_service
router = APIRouter()
# ── Documents (public, read-only) ─────────────────────────────────────────
@router.get(
"/documents",
response_model=list[DocumentOut],
tags=["public-documents"],
summary="Liste publique des documents actifs",
)
async def list_documents(
db: AsyncSession = Depends(get_db),
doc_type: str | None = Query(default=None, description="Filtrer par type de document"),
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=200),
) -> list[DocumentOut]:
"""Liste les documents de reference avec leurs items (lecture seule)."""
stmt = select(Document)
if doc_type is not None:
stmt = stmt.where(Document.doc_type == doc_type)
if status_filter is not None:
stmt = stmt.where(Document.status == status_filter)
stmt = stmt.order_by(Document.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt)
documents = result.scalars().all()
out = []
for doc in documents:
count_result = await db.execute(
select(func.count()).select_from(DocumentItem).where(DocumentItem.document_id == doc.id)
)
items_count = count_result.scalar() or 0
doc_out = DocumentOut.model_validate(doc)
doc_out.items_count = items_count
out.append(doc_out)
return out
@router.get(
"/documents/{slug}",
response_model=DocumentFullOut,
tags=["public-documents"],
summary="Document complet avec ses items",
)
async def get_document(
slug: str,
db: AsyncSession = Depends(get_db),
) -> DocumentFullOut:
"""Recupere un document avec tous ses items (texte complet, serialise)."""
doc = await document_service.get_document_with_items(slug, db)
if doc is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document introuvable",
)
return DocumentFullOut.model_validate(doc)
# ── Sanctuary (public, read-only) ─────────────────────────────────────────
@router.get(
"/sanctuary/entries",
response_model=list[SanctuaryEntryOut],
tags=["public-sanctuary"],
summary="Liste des entrees du sanctuaire",
)
async def list_sanctuary_entries(
db: AsyncSession = Depends(get_db),
entry_type: str | None = Query(default=None, description="Filtrer par type (document, decision, vote_result)"),
skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=200),
) -> list[SanctuaryEntryOut]:
"""Liste les entrees du sanctuaire (archives verifiees)."""
stmt = select(SanctuaryEntry)
if entry_type is not None:
stmt = stmt.where(SanctuaryEntry.entry_type == entry_type)
stmt = stmt.order_by(SanctuaryEntry.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt)
entries = result.scalars().all()
return [SanctuaryEntryOut.model_validate(e) for e in entries]
@router.get(
"/sanctuary/entries/{id}",
response_model=SanctuaryEntryOut,
tags=["public-sanctuary"],
summary="Entree du sanctuaire par ID",
)
async def get_sanctuary_entry(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> SanctuaryEntryOut:
"""Recupere une entree du sanctuaire avec lien IPFS et ancrage on-chain."""
result = await db.execute(
select(SanctuaryEntry).where(SanctuaryEntry.id == id)
)
entry = result.scalar_one_or_none()
if entry is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Entree sanctuaire introuvable",
)
return SanctuaryEntryOut.model_validate(entry)
@router.get(
"/sanctuary/verify/{id}",
tags=["public-sanctuary"],
summary="Verification d'integrite d'une entree",
)
async def verify_sanctuary_entry(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> dict:
"""Verifie l'integrite d'une entree du sanctuaire (comparaison de hash)."""
try:
result = await sanctuary_service.verify_entry(id, db)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
return result
# ── Votes (public, read-only) ─────────────────────────────────────────────
@router.get(
"/votes/sessions/{id}/result",
tags=["public-votes"],
summary="Resultat d'une session de vote",
)
async def get_vote_result(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> dict:
"""Recupere le resultat d'une session de vote (lecture seule, public)."""
result = await db.execute(
select(VoteSession).where(VoteSession.id == id)
)
session = result.scalar_one_or_none()
if session is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session de vote introuvable",
)
return {
"session_id": str(session.id),
"status": session.status,
"votes_for": session.votes_for,
"votes_against": session.votes_against,
"votes_total": session.votes_total,
"wot_size": session.wot_size,
"smith_size": session.smith_size,
"techcomm_size": session.techcomm_size,
"threshold_required": session.threshold_required,
"result": session.result,
"starts_at": session.starts_at.isoformat() if session.starts_at else None,
"ends_at": session.ends_at.isoformat() if session.ends_at else None,
"chain_recorded": session.chain_recorded,
"chain_tx_hash": session.chain_tx_hash,
}
# ── Platform status ───────────────────────────────────────────────────────
@router.get(
"/status",
tags=["public-status"],
summary="Statut de la plateforme",
)
async def platform_status(
db: AsyncSession = Depends(get_db),
) -> dict:
"""Statut general de la plateforme: compteurs de documents, decisions, votes actifs."""
# Count documents
doc_count_result = await db.execute(
select(func.count()).select_from(Document)
)
documents_count = doc_count_result.scalar() or 0
# Count decisions
decision_count_result = await db.execute(
select(func.count()).select_from(Decision)
)
decisions_count = decision_count_result.scalar() or 0
# Count active vote sessions
active_votes_result = await db.execute(
select(func.count()).select_from(VoteSession).where(VoteSession.status == "open")
)
active_votes_count = active_votes_result.scalar() or 0
# Count total vote sessions
total_votes_result = await db.execute(
select(func.count()).select_from(VoteSession)
)
total_votes_count = total_votes_result.scalar() or 0
# Count sanctuary entries
sanctuary_count_result = await db.execute(
select(func.count()).select_from(SanctuaryEntry)
)
sanctuary_count = sanctuary_count_result.scalar() or 0
return {
"platform": "libreDecision",
"documents_count": documents_count,
"decisions_count": decisions_count,
"active_votes_count": active_votes_count,
"total_votes_count": total_votes_count,
"sanctuary_entries_count": sanctuary_count,
}
+186
View File
@@ -0,0 +1,186 @@
"""Qualify router: decision qualification engine endpoint."""
from __future__ import annotations
from dataclasses import asdict
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.engine.qualifier import QualificationConfig, QualificationInput, qualify
from app.models.qualification import QualificationProtocol
from app.schemas.qualification import (
QualificationProtocolCreate,
QualificationProtocolOut,
QualifyRequest,
QualifyResponse,
)
from app.services.auth_service import get_current_identity
from app.services.qualify_ai_service import (
AIFrameRequest,
AIFrameResponse,
AIMessage,
AIQuestion,
AIQualifyResult,
ai_frame,
)
router = APIRouter()
# ── Pydantic wrappers for AI chat (FastAPI needs Pydantic, not dataclasses) ──
class AIMessagePayload(BaseModel):
role: str
content: str
class AIChatRequest(BaseModel):
within_mandate: bool = False
affected_count: int | None = None
is_structural: bool = False
context: str | None = None
messages: list[AIMessagePayload] = []
class AIQuestionOut(BaseModel):
id: str
text: str
options: list[str]
class AIQualifyResultOut(BaseModel):
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]
class AIChatResponse(BaseModel):
done: bool
questions: list[AIQuestionOut] = []
result: AIQualifyResultOut | None = None
explanation: str | None = None
async def _load_config(db: AsyncSession) -> QualificationConfig:
"""Load the active QualificationProtocol from DB, or fall back to defaults."""
result = await db.execute(
select(QualificationProtocol)
.where(QualificationProtocol.is_active == True) # noqa: E712
.order_by(QualificationProtocol.created_at.desc())
.limit(1)
)
proto = result.scalar_one_or_none()
if proto is None:
return QualificationConfig()
return QualificationConfig(
small_group_max=proto.small_group_max,
collective_wot_min=proto.collective_wot_min,
default_modalities=proto.default_modalities,
)
@router.post("/", response_model=QualifyResponse)
async def qualify_decision(
payload: QualifyRequest,
db: AsyncSession = Depends(get_db),
) -> QualifyResponse:
"""Qualify a decision: determine type, process, and modalities.
No authentication required — this is an advisory endpoint that helps
users understand which decision pathway fits their situation.
"""
config = await _load_config(db)
inp = QualificationInput(
within_mandate=payload.within_mandate,
affected_count=payload.affected_count,
is_structural=payload.is_structural,
context_description=payload.context_description,
)
result = qualify(inp, config)
return QualifyResponse(**asdict(result))
@router.post("/ai-chat", response_model=AIChatResponse)
async def ai_chat(payload: AIChatRequest) -> AIChatResponse:
"""Run one round of AI-assisted qualification framing.
Round 1 (messages=[]) → returns 2 clarifying questions.
Round 2 (messages set) → returns final qualification result.
No auth required — advisory endpoint.
"""
req = AIFrameRequest(
within_mandate=payload.within_mandate,
affected_count=payload.affected_count,
is_structural=payload.is_structural,
context=payload.context,
messages=[AIMessage(role=m.role, content=m.content) for m in payload.messages],
)
resp = ai_frame(req)
return AIChatResponse(
done=resp.done,
questions=[AIQuestionOut(id=q.id, text=q.text, options=q.options) for q in resp.questions],
result=AIQualifyResultOut(**asdict(resp.result)) if resp.result else None,
explanation=resp.explanation,
)
@router.get("/protocol", response_model=QualificationProtocolOut | None)
async def get_active_protocol(
db: AsyncSession = Depends(get_db),
) -> QualificationProtocolOut | None:
"""Return the currently active qualification protocol (thresholds)."""
result = await db.execute(
select(QualificationProtocol)
.where(QualificationProtocol.is_active == True) # noqa: E712
.order_by(QualificationProtocol.created_at.desc())
.limit(1)
)
proto = result.scalar_one_or_none()
if proto is None:
return None
return QualificationProtocolOut.model_validate(proto)
@router.post("/protocol", response_model=QualificationProtocolOut, status_code=201)
async def create_protocol(
payload: QualificationProtocolCreate,
db: AsyncSession = Depends(get_db),
_identity=Depends(get_current_identity),
) -> QualificationProtocolOut:
"""Create a new qualification protocol (requires auth).
Deactivates the current active protocol before saving the new one.
"""
# Deactivate current
current = await db.execute(
select(QualificationProtocol).where(QualificationProtocol.is_active == True) # noqa: E712
)
for proto in current.scalars().all():
proto.is_active = False
import json
proto = QualificationProtocol(
name=payload.name,
description=payload.description,
small_group_max=payload.small_group_max,
collective_wot_min=payload.collective_wot_min,
default_modalities_json=json.dumps(payload.default_modalities),
is_active=True,
)
db.add(proto)
await db.commit()
await db.refresh(proto)
return QualificationProtocolOut.model_validate(proto)
+105
View File
@@ -0,0 +1,105 @@
"""Sanctuary router: IPFS + on-chain anchoring entries."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.sanctuary import SanctuaryEntry
from app.models.user import DuniterIdentity
from app.schemas.sanctuary import SanctuaryEntryCreate, SanctuaryEntryOut
from app.services import sanctuary_service
from app.services.auth_service import get_current_identity
router = APIRouter()
@router.get("/", response_model=list[SanctuaryEntryOut])
async def list_entries(
db: AsyncSession = Depends(get_db),
entry_type: str | None = Query(default=None, description="Filtrer par type (document, decision, vote_result)"),
skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=200),
) -> list[SanctuaryEntryOut]:
"""List all sanctuary entries with optional type filter."""
stmt = select(SanctuaryEntry)
if entry_type is not None:
stmt = stmt.where(SanctuaryEntry.entry_type == entry_type)
stmt = stmt.order_by(SanctuaryEntry.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt)
entries = result.scalars().all()
return [SanctuaryEntryOut.model_validate(e) for e in entries]
@router.post("/", response_model=SanctuaryEntryOut, status_code=status.HTTP_201_CREATED)
async def create_entry(
payload: SanctuaryEntryCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> SanctuaryEntryOut:
"""Create a new sanctuary entry.
This endpoint is typically called by internal services after:
1. Content is hashed (SHA-256)
2. Content is pinned to IPFS
3. Hash is anchored on-chain via system.remark
The IPFS CID and chain TX hash can be added later via updates.
"""
entry = SanctuaryEntry(**payload.model_dump())
db.add(entry)
await db.commit()
await db.refresh(entry)
return SanctuaryEntryOut.model_validate(entry)
@router.get("/by-reference/{reference_id}", response_model=list[SanctuaryEntryOut])
async def get_entries_by_reference(
reference_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> list[SanctuaryEntryOut]:
"""Get all sanctuary entries for a given reference ID.
Useful for finding all sanctuary entries associated with a document,
decision, or vote result.
"""
entries = await sanctuary_service.get_entries_by_reference(reference_id, db)
return [SanctuaryEntryOut.model_validate(e) for e in entries]
@router.get("/{id}/verify")
async def verify_entry(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> dict:
"""Verify integrity of a sanctuary entry.
Re-fetches the content (from IPFS if available), re-hashes it,
and compares with the stored content_hash.
"""
try:
result = await sanctuary_service.verify_entry(id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
return result
@router.get("/{id}", response_model=SanctuaryEntryOut)
async def get_entry(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> SanctuaryEntryOut:
"""Get a single sanctuary entry by ID."""
result = await db.execute(select(SanctuaryEntry).where(SanctuaryEntry.id == id))
entry = result.scalar_one_or_none()
if entry is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Entree sanctuaire introuvable")
return SanctuaryEntryOut.model_validate(entry)
+505
View File
@@ -0,0 +1,505 @@
"""Votes router: vote sessions, individual votes, result computation, and live updates."""
from __future__ import annotations
import math
import uuid
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.protocol import FormulaConfig, VotingProtocol
from app.models.user import DuniterIdentity
from app.models.vote import Vote, VoteSession
from app.routers.websocket import manager
from app.schemas.vote import (
ThresholdDetailOut,
VoteCreate,
VoteOut,
VoteResultOut,
VoteSessionCreate,
VoteSessionListOut,
VoteSessionOut,
)
from app.dependencies.org import get_active_org_id
from app.services.auth_service import get_current_identity
from app.services.vote_service import (
close_session as svc_close_session,
compute_result as svc_compute_result,
get_threshold_details as svc_get_threshold_details,
)
router = APIRouter()
# ── Helpers ─────────────────────────────────────────────────────────────────
async def _get_session(db: AsyncSession, session_id: uuid.UUID) -> VoteSession:
"""Fetch a vote session by ID with votes eagerly loaded, or raise 404."""
result = await db.execute(
select(VoteSession)
.options(selectinload(VoteSession.votes))
.where(VoteSession.id == session_id)
)
session = result.scalar_one_or_none()
if session is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session de vote introuvable")
return session
async def _get_protocol_with_formula(db: AsyncSession, protocol_id: uuid.UUID) -> VotingProtocol:
"""Fetch a voting protocol with its formula config, or raise 404."""
result = await db.execute(
select(VotingProtocol)
.options(selectinload(VotingProtocol.formula_config))
.where(VotingProtocol.id == protocol_id)
)
protocol = result.scalar_one_or_none()
if protocol is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Protocole de vote introuvable")
return protocol
async def _check_session_expired(session: VoteSession, db: AsyncSession) -> VoteSession:
"""Check if a session has passed its ends_at deadline and auto-close it.
If the session is still marked 'open' but the deadline has passed,
close it and compute the final tally via the vote service.
Returns the (possibly updated) session.
"""
if session.status == "open" and datetime.now(timezone.utc) > session.ends_at:
try:
result = await svc_close_session(session.id, db)
# Reload session to get updated fields
db_result = await db.execute(
select(VoteSession)
.options(selectinload(VoteSession.votes))
.where(VoteSession.id == session.id)
)
session = db_result.scalar_one()
# Broadcast session closed event
await manager.broadcast(session.id, {
"type": "session_closed",
"session_id": str(session.id),
"result": result.get("result"),
"votes_for": result.get("votes_for", 0),
"votes_against": result.get("votes_against", 0),
"votes_total": result.get("votes_total", 0),
})
except ValueError:
pass # Session already closed by another process
return session
def _compute_threshold(formula: FormulaConfig, wot_size: int, votes_total: int) -> float:
"""Compute the WoT-based threshold using the core formula.
Result = C + B^W + (M + (1-M) * (1 - (T/W)^G)) * max(0, T-C)
Where:
- C = constant_base
- B = base_exponent
- W = wot_size
- M = majority_pct / 100
- G = gradient_exponent
- T = votes_total (turnout)
"""
c = formula.constant_base
b = formula.base_exponent
w = max(wot_size, 1)
m = formula.majority_pct / 100.0
g = formula.gradient_exponent
t = votes_total
# Inertia-based threshold
base_power = b ** w if b > 0 else 0.0
turnout_ratio = min(t / w, 1.0) if w > 0 else 0.0
inertia = m + (1 - m) * (1 - turnout_ratio ** g)
threshold = c + base_power + inertia * max(0, t - c)
return threshold
def _compute_result(
session: VoteSession,
formula: FormulaConfig,
) -> dict:
"""Compute the vote result based on tallies and formula.
Returns a dict with threshold_required, result ("adopted" or "rejected"),
and whether Smith/TechComm criteria are met.
"""
threshold = _compute_threshold(formula, session.wot_size, session.votes_total)
# Main criterion: votes_for >= threshold
main_pass = session.votes_for >= threshold
# Smith criterion (if configured)
smith_pass = True
smith_threshold = None
if formula.smith_exponent is not None and session.smith_size > 0:
smith_threshold = math.ceil(session.smith_size ** formula.smith_exponent)
smith_pass = session.smith_votes_for >= smith_threshold
# TechComm criterion (if configured)
techcomm_pass = True
techcomm_threshold = None
if formula.techcomm_exponent is not None and session.techcomm_size > 0:
techcomm_threshold = math.ceil(session.techcomm_size ** formula.techcomm_exponent)
techcomm_pass = session.techcomm_votes_for >= techcomm_threshold
result = "adopted" if (main_pass and smith_pass and techcomm_pass) else "rejected"
return {
"threshold_required": threshold,
"result": result,
"smith_threshold": smith_threshold,
"smith_pass": smith_pass,
"techcomm_threshold": techcomm_threshold,
"techcomm_pass": techcomm_pass,
}
# ── Routes ──────────────────────────────────────────────────────────────────
@router.get("/sessions", response_model=list[VoteSessionListOut])
async def list_vote_sessions(
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
session_status: str | None = Query(default=None, alias="status", description="Filtrer par statut (open, closed, tallied)"),
decision_id: uuid.UUID | None = Query(default=None, description="Filtrer par decision_id"),
skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=200),
) -> list[VoteSessionListOut]:
"""List all vote sessions with optional filters by status and decision_id."""
stmt = (
select(VoteSession)
.join(VotingProtocol, VoteSession.voting_protocol_id == VotingProtocol.id)
)
if org_id is not None:
stmt = stmt.where(VotingProtocol.organization_id == org_id)
else:
stmt = stmt.where(VotingProtocol.organization_id.is_(None))
if session_status is not None:
stmt = stmt.where(VoteSession.status == session_status)
if decision_id is not None:
stmt = stmt.where(VoteSession.decision_id == decision_id)
stmt = stmt.order_by(VoteSession.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt)
sessions = result.scalars().all()
# Auto-close expired sessions before returning
checked_sessions = []
for s in sessions:
s = await _check_session_expired(s, db)
checked_sessions.append(s)
return [VoteSessionListOut.model_validate(s) for s in checked_sessions]
@router.post("/sessions", response_model=VoteSessionOut, status_code=status.HTTP_201_CREATED)
async def create_vote_session(
payload: VoteSessionCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> VoteSessionOut:
"""Create a new vote session.
The session duration is derived from the linked protocol's formula config.
WoT/Smith/TechComm sizes should be snapshotted from the blockchain at creation time.
"""
# Validate protocol exists and get formula for duration
protocol = await _get_protocol_with_formula(db, payload.voting_protocol_id)
formula = protocol.formula_config
starts_at = datetime.now(timezone.utc)
ends_at = starts_at + timedelta(days=formula.duration_days)
session = VoteSession(
decision_id=payload.decision_id,
item_version_id=payload.item_version_id,
voting_protocol_id=payload.voting_protocol_id,
starts_at=starts_at,
ends_at=ends_at,
# TODO: Snapshot actual WoT sizes from blockchain via Duniter RPC
wot_size=0,
smith_size=0,
techcomm_size=0,
)
db.add(session)
await db.commit()
await db.refresh(session)
return VoteSessionOut.model_validate(session)
@router.get("/sessions/{id}", response_model=VoteSessionOut)
async def get_vote_session(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> VoteSessionOut:
"""Get a vote session with current tallies.
Automatically closes the session if its deadline has passed.
"""
session = await _get_session(db, id)
session = await _check_session_expired(session, db)
return VoteSessionOut.model_validate(session)
@router.post("/sessions/{id}/close", response_model=VoteResultOut)
async def close_vote_session(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> VoteResultOut:
"""Manually close a vote session and compute the final result.
Requires authentication. The session must be in 'open' status.
"""
try:
result = await svc_close_session(id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
# Broadcast session closed event via WebSocket
await manager.broadcast(id, {
"type": "session_closed",
"session_id": str(id),
"result": result.get("result"),
"votes_for": result.get("votes_for", 0),
"votes_against": result.get("votes_against", 0),
"votes_total": result.get("votes_total", 0),
})
return VoteResultOut(
session_id=id,
result=result.get("result"),
threshold_required=float(result.get("threshold", 0)),
votes_for=result.get("votes_for", 0),
votes_against=result.get("votes_against", 0),
votes_total=result.get("votes_total", 0),
adopted=result.get("adopted", False),
nuanced_breakdown=result.get("nuanced_breakdown"),
)
@router.post("/sessions/{id}/vote", response_model=VoteOut, status_code=status.HTTP_201_CREATED)
async def submit_vote(
id: uuid.UUID,
payload: VoteCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> VoteOut:
"""Submit a vote to a session.
Each identity can only vote once per session. Submitting again replaces the previous vote.
The vote must include a cryptographic signature for on-chain proof.
After submission, broadcasts a vote_update event via WebSocket.
"""
session = await _get_session(db, id)
# Auto-close check
session = await _check_session_expired(session, db)
# Verify session is open
if session.status != "open":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cette session de vote n'est pas ouverte",
)
# Verify session hasn't ended
if datetime.now(timezone.utc) > session.ends_at:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cette session de vote est terminee",
)
# Check if voter already voted -- replace if so
existing_result = await db.execute(
select(Vote).where(
Vote.session_id == session.id,
Vote.voter_id == identity.id,
)
)
existing_vote = existing_result.scalar_one_or_none()
if existing_vote is not None:
# Deactivate old vote (keep for audit trail)
existing_vote.is_active = False
# Update tallies: subtract old vote
session.votes_total -= 1
if existing_vote.vote_value == "for":
session.votes_for -= 1
if existing_vote.voter_is_smith:
session.smith_votes_for -= 1
if existing_vote.voter_is_techcomm:
session.techcomm_votes_for -= 1
elif existing_vote.vote_value == "against":
session.votes_against -= 1
# Create new vote
vote = Vote(
session_id=session.id,
voter_id=identity.id,
vote_value=payload.vote_value,
nuanced_level=payload.nuanced_level,
comment=payload.comment,
signature=payload.signature,
signed_payload=payload.signed_payload,
voter_wot_status=identity.wot_status,
voter_is_smith=identity.is_smith,
voter_is_techcomm=identity.is_techcomm,
)
db.add(vote)
# Update tallies: add new vote
session.votes_total += 1
if payload.vote_value == "for":
session.votes_for += 1
if identity.is_smith:
session.smith_votes_for += 1
if identity.is_techcomm:
session.techcomm_votes_for += 1
elif payload.vote_value == "against":
session.votes_against += 1
await db.commit()
await db.refresh(vote)
# Broadcast vote update via WebSocket
await manager.broadcast(session.id, {
"type": "vote_update",
"session_id": str(session.id),
"votes_for": session.votes_for,
"votes_against": session.votes_against,
"votes_total": session.votes_total,
})
return VoteOut.model_validate(vote)
@router.get("/sessions/{id}/votes", response_model=list[VoteOut])
async def list_votes(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
active_only: bool = Query(default=True, description="Ne montrer que les votes actifs"),
) -> list[VoteOut]:
"""List all votes in a session."""
# Verify session exists
await _get_session(db, id)
stmt = select(Vote).where(Vote.session_id == id)
if active_only:
stmt = stmt.where(Vote.is_active.is_(True))
stmt = stmt.order_by(Vote.created_at.asc())
result = await db.execute(stmt)
votes = result.scalars().all()
return [VoteOut.model_validate(v) for v in votes]
@router.get("/sessions/{id}/threshold", response_model=ThresholdDetailOut)
async def get_threshold_details(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> ThresholdDetailOut:
"""Return computed threshold details for a vote session.
Includes WoT/Smith/TechComm thresholds, pass/fail status,
participation rate, and the formula parameters used.
Automatically closes the session if its deadline has passed.
"""
session = await _get_session(db, id)
session = await _check_session_expired(session, db)
try:
details = await svc_get_threshold_details(id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
return ThresholdDetailOut(**details)
@router.get("/sessions/{id}/result")
async def get_vote_result(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> dict:
"""Compute and return the current result for a vote session.
Uses the WoT threshold formula linked through the voting protocol.
Returns current tallies, computed threshold, and whether the vote passes.
Automatically closes the session if its deadline has passed.
"""
session = await _get_session(db, id)
session = await _check_session_expired(session, db)
# Get the protocol and formula
protocol = await _get_protocol_with_formula(db, session.voting_protocol_id)
formula = protocol.formula_config
result_data = _compute_result(session, formula)
return {
"session_id": str(session.id),
"status": session.status,
"votes_for": session.votes_for,
"votes_against": session.votes_against,
"votes_total": session.votes_total,
"wot_size": session.wot_size,
"smith_size": session.smith_size,
"techcomm_size": session.techcomm_size,
"smith_votes_for": session.smith_votes_for,
"techcomm_votes_for": session.techcomm_votes_for,
**result_data,
}
@router.post("/sessions/{id}/tally", response_model=VoteResultOut)
async def force_tally(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> VoteResultOut:
"""Force a recount of a vote session.
Requires authentication. Useful after a chain snapshot update
or when recalculation is needed. Works on any session status.
"""
try:
result = await svc_compute_result(id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
# Broadcast tally update via WebSocket
await manager.broadcast(id, {
"type": "tally_update",
"session_id": str(id),
"result": result.get("result"),
"votes_for": result.get("votes_for", 0),
"votes_against": result.get("votes_against", 0),
"votes_total": result.get("votes_total", 0),
})
return VoteResultOut(
session_id=id,
result=result.get("result"),
threshold_required=float(result.get("threshold", 0)),
votes_for=result.get("votes_for", 0),
votes_against=result.get("votes_against", 0),
votes_total=result.get("votes_total", 0),
adopted=result.get("adopted", False),
nuanced_breakdown=result.get("nuanced_breakdown"),
)
+319
View File
@@ -0,0 +1,319 @@
"""WebSocket router: live vote updates with authentication and heartbeat."""
from __future__ import annotations
import asyncio
import json
import logging
import uuid
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from sqlalchemy import select
from app.database import async_session
from app.models.user import Session as UserSession
router = APIRouter()
logger = logging.getLogger(__name__)
# Heartbeat interval in seconds
_HEARTBEAT_INTERVAL = 30
# Valid notification event types
EVENT_TYPES = (
"vote_submitted",
"vote_update",
"session_closed",
"tally_update",
"decision_advanced",
"mandate_updated",
"document_changed",
"sanctuary_archived",
)
# ── Connection manager ──────────────────────────────────────────────────────
class ConnectionManager:
"""Manages active WebSocket connections grouped by vote session ID."""
def __init__(self) -> None:
# session_id -> list of connected websockets
self._connections: dict[uuid.UUID, list[WebSocket]] = {}
# websocket -> authenticated identity_id (or None for anonymous)
self._authenticated: dict[WebSocket, uuid.UUID | None] = {}
async def connect(self, websocket: WebSocket, session_id: uuid.UUID) -> None:
"""Accept a WebSocket connection and register it for a vote session."""
await websocket.accept()
if session_id not in self._connections:
self._connections[session_id] = []
self._connections[session_id].append(websocket)
def disconnect(self, websocket: WebSocket, session_id: uuid.UUID) -> None:
"""Remove a WebSocket connection from the session group."""
if session_id in self._connections:
self._connections[session_id] = [
ws for ws in self._connections[session_id] if ws is not websocket
]
if not self._connections[session_id]:
del self._connections[session_id]
self._authenticated.pop(websocket, None)
async def broadcast(self, session_id: uuid.UUID, data: dict[str, Any]) -> None:
"""Broadcast a message to all connections watching a given vote session."""
if session_id not in self._connections:
return
message = json.dumps(data, default=str)
dead: list[WebSocket] = []
for ws in self._connections[session_id]:
try:
await ws.send_text(message)
except Exception:
dead.append(ws)
# Clean up dead connections
for ws in dead:
self.disconnect(ws, session_id)
async def broadcast_all(self, data: dict[str, Any]) -> None:
"""Broadcast a message to all connected WebSockets across all sessions."""
message = json.dumps(data, default=str)
for session_id in list(self._connections.keys()):
dead: list[WebSocket] = []
for ws in self._connections.get(session_id, []):
try:
await ws.send_text(message)
except Exception:
dead.append(ws)
for ws in dead:
self.disconnect(ws, session_id)
def set_authenticated(self, websocket: WebSocket, identity_id: uuid.UUID) -> None:
"""Mark a WebSocket connection as authenticated."""
self._authenticated[websocket] = identity_id
def is_authenticated(self, websocket: WebSocket) -> bool:
"""Check if a WebSocket connection is authenticated."""
return self._authenticated.get(websocket) is not None
manager = ConnectionManager()
# ── Authentication helper ──────────────────────────────────────────────────
async def _validate_token(token: str) -> uuid.UUID | None:
"""Validate a bearer token and return the associated identity_id.
Uses the same token hashing as auth_service but without FastAPI
dependency injection (since WebSocket doesn't use Depends the same way).
Parameters
----------
token:
The raw bearer token from the query parameter.
Returns
-------
uuid.UUID | None
The identity_id if valid, or None if invalid/expired.
"""
import hashlib
token_hash = hashlib.sha256(token.encode()).hexdigest()
try:
async with async_session() as db:
result = await db.execute(
select(UserSession).where(
UserSession.token_hash == token_hash,
UserSession.expires_at > datetime.now(timezone.utc),
)
)
session = result.scalar_one_or_none()
if session is not None:
return session.identity_id
except Exception:
logger.warning("Erreur lors de la validation du token WebSocket", exc_info=True)
return None
# ── Broadcast event helper (importable by other routers) ──────────────────
async def broadcast_event(
event_type: str,
payload: dict[str, Any],
session_id: uuid.UUID | None = None,
) -> None:
"""Broadcast a notification event to connected WebSocket clients.
This function is designed to be imported and called from other routers
(votes, decisions, mandates, etc.) to push real-time notifications.
Parameters
----------
event_type:
One of the valid EVENT_TYPES.
payload:
The event data to send.
session_id:
If provided, broadcast only to clients watching this specific session.
If None, broadcast to all connected clients.
"""
data = {
"event": event_type,
"timestamp": datetime.now(timezone.utc).isoformat(),
**payload,
}
if session_id is not None:
await manager.broadcast(session_id, data)
else:
await manager.broadcast_all(data)
# ── Heartbeat task ─────────────────────────────────────────────────────────
async def _heartbeat(websocket: WebSocket) -> None:
"""Send periodic ping messages to keep the connection alive.
Runs as a background task alongside the main message loop.
Sends a JSON ping every _HEARTBEAT_INTERVAL seconds.
"""
try:
while True:
await asyncio.sleep(_HEARTBEAT_INTERVAL)
try:
await websocket.send_text(
json.dumps({"event": "ping", "timestamp": datetime.now(timezone.utc).isoformat()})
)
except Exception:
break
except asyncio.CancelledError:
pass
# ── WebSocket endpoint ──────────────────────────────────────────────────────
@router.websocket("/live")
async def live_updates(websocket: WebSocket) -> None:
"""WebSocket endpoint for live vote session updates.
Authentication (optional):
Connect with ``?token=<bearer_token>`` query parameter to authenticate.
If the token is valid, the connection is marked as authenticated.
If missing or invalid, the connection is accepted but unauthenticated.
The client sends JSON messages:
{ "action": "subscribe", "session_id": "<uuid>" }
{ "action": "unsubscribe", "session_id": "<uuid>" }
The server pushes events:
{ "event": "vote_update", "session_id": "...", ... }
{ "event": "session_closed", "session_id": "...", ... }
{ "event": "vote_submitted", ... }
{ "event": "decision_advanced", ... }
{ "event": "mandate_updated", ... }
{ "event": "document_changed", ... }
{ "event": "sanctuary_archived", ... }
{ "event": "ping", "timestamp": "..." } (heartbeat)
"""
# Extract token from query parameters
token = websocket.query_params.get("token")
await websocket.accept()
subscribed_sessions: set[uuid.UUID] = set()
# Authenticate if token provided
if token:
identity_id = await _validate_token(token)
if identity_id is not None:
manager.set_authenticated(websocket, identity_id)
await websocket.send_text(
json.dumps({"event": "authenticated", "identity_id": str(identity_id)})
)
logger.debug("WebSocket authentifie: identity=%s", identity_id)
else:
await websocket.send_text(
json.dumps({"event": "auth_failed", "detail": "Token invalide ou expire"})
)
logger.debug("Echec authentification WebSocket (token invalide)")
# Start heartbeat task
heartbeat_task = asyncio.create_task(_heartbeat(websocket))
try:
while True:
raw = await websocket.receive_text()
try:
data = json.loads(raw)
except json.JSONDecodeError:
await websocket.send_text(json.dumps({"error": "JSON invalide"}))
continue
action = data.get("action")
session_id_str = data.get("session_id")
if not action or not session_id_str:
await websocket.send_text(
json.dumps({"error": "Champs 'action' et 'session_id' requis"})
)
continue
try:
session_id = uuid.UUID(session_id_str)
except ValueError:
await websocket.send_text(json.dumps({"error": "session_id invalide"}))
continue
if action == "subscribe":
if session_id not in subscribed_sessions:
# Register this websocket in the manager for this session
if session_id not in manager._connections:
manager._connections[session_id] = []
manager._connections[session_id].append(websocket)
subscribed_sessions.add(session_id)
await websocket.send_text(
json.dumps({"event": "subscribed", "session_id": str(session_id)})
)
elif action == "unsubscribe":
if session_id in subscribed_sessions:
manager.disconnect(websocket, session_id)
subscribed_sessions.discard(session_id)
await websocket.send_text(
json.dumps({"event": "unsubscribed", "session_id": str(session_id)})
)
else:
await websocket.send_text(
json.dumps({"error": f"Action inconnue: {action}"})
)
except WebSocketDisconnect:
# Clean up all subscriptions for this client
for session_id in subscribed_sessions:
manager.disconnect(websocket, session_id)
logger.debug("WebSocket deconnecte, %d souscriptions nettoyees", len(subscribed_sessions))
finally:
# Cancel the heartbeat task
heartbeat_task.cancel()
try:
await heartbeat_task
except asyncio.CancelledError:
pass
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
# ── Request schemas ──────────────────────────────────────────────
class ChallengeRequest(BaseModel):
"""Request a challenge nonce for Ed25519 authentication."""
address: str = Field(..., min_length=1, max_length=64, description="Duniter V2 SS58 address")
class VerifyRequest(BaseModel):
"""Submit the signed challenge to obtain a session token."""
address: str = Field(..., min_length=1, max_length=64)
signature: str = Field(..., description="Ed25519 signature of the challenge (hex)")
challenge: str = Field(..., description="The challenge string that was signed")
# ── Response schemas ─────────────────────────────────────────────
class ChallengeResponse(BaseModel):
"""Returned after requesting a challenge."""
challenge: str
expires_at: datetime
class IdentityOut(BaseModel):
"""Public representation of a Duniter identity."""
model_config = ConfigDict(from_attributes=True)
id: UUID
address: str
display_name: str | None = None
wot_status: str
is_smith: bool
is_techcomm: bool
class TokenResponse(BaseModel):
"""Returned after successful challenge verification."""
token: str
identity: IdentityOut
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
# ── Decision ─────────────────────────────────────────────────────
class DecisionStepCreate(BaseModel):
"""Payload for creating a step within a decision process."""
step_order: int = Field(..., ge=0)
step_type: str = Field(..., max_length=32, description="qualification, review, vote, execution, reporting")
title: str | None = Field(default=None, max_length=256)
description: str | None = None
class DecisionStepOut(BaseModel):
"""Full decision step representation."""
model_config = ConfigDict(from_attributes=True)
id: UUID
decision_id: UUID
step_order: int
step_type: str
title: str | None = None
description: str | None = None
status: str
vote_session_id: UUID | None = None
outcome: str | None = None
created_at: datetime
class DecisionCreate(BaseModel):
"""Payload for creating a new decision."""
title: str = Field(..., min_length=1, max_length=256)
description: str | None = None
context: str | None = None
decision_type: str = Field(..., max_length=64, description="runtime_upgrade, document_change, mandate_vote, custom")
voting_protocol_id: UUID | None = None
class DecisionUpdate(BaseModel):
"""Partial update for a decision."""
title: str | None = Field(default=None, max_length=256)
description: str | None = None
status: str | None = Field(default=None, max_length=32)
voting_protocol_id: UUID | None = None
class DecisionOut(BaseModel):
"""Full decision representation returned by the API."""
model_config = ConfigDict(from_attributes=True)
id: UUID
title: str
description: str | None = None
context: str | None = None
decision_type: str
status: str
voting_protocol_id: UUID | None = None
created_by_id: UUID | None = None
created_at: datetime
updated_at: datetime
steps: list[DecisionStepOut] = Field(default_factory=list)
class DecisionAdvanceOut(BaseModel):
"""Output after advancing a decision through its workflow."""
model_config = ConfigDict(from_attributes=True)
id: UUID
title: str
description: str | None = None
context: str | None = None
decision_type: str
status: str
voting_protocol_id: UUID | None = None
created_by_id: UUID | None = None
created_at: datetime
updated_at: datetime
steps: list[DecisionStepOut] = Field(default_factory=list)
message: str = Field(..., description="Message decrivant l'avancement effectue")
+184
View File
@@ -0,0 +1,184 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
# ── Document ─────────────────────────────────────────────────────
class DocumentCreate(BaseModel):
"""Payload for creating a new reference document."""
slug: str = Field(..., min_length=1, max_length=128)
title: str = Field(..., min_length=1, max_length=256)
doc_type: str = Field(..., max_length=64, description="licence, engagement, reglement, constitution")
description: str | None = None
version: str | None = Field(default="0.1.0", max_length=32)
class DocumentUpdate(BaseModel):
"""Partial update for a document."""
title: str | None = Field(default=None, max_length=256)
status: str | None = Field(default=None, max_length=32)
description: str | None = None
version: str | None = Field(default=None, max_length=32)
class DocumentOut(BaseModel):
"""Full document representation returned by the API."""
model_config = ConfigDict(from_attributes=True)
id: UUID
slug: str
title: str
doc_type: str
version: str
status: str
description: str | None = None
ipfs_cid: str | None = None
chain_anchor: str | None = None
genesis_json: str | None = None
created_at: datetime
updated_at: datetime
items_count: int = Field(default=0, description="Number of items in this document")
# ── Document Item ────────────────────────────────────────────────
class DocumentItemCreate(BaseModel):
"""Payload for creating a document item (clause, rule, etc.)."""
position: str = Field(..., max_length=16, description='Hierarchical position e.g. "1", "1.1", "3.2"')
item_type: str = Field(default="clause", max_length=32, description="clause, rule, verification, preamble, section, genesis")
title: str | None = Field(default=None, max_length=256)
current_text: str = Field(..., min_length=1)
voting_protocol_id: UUID | None = None
section_tag: str | None = Field(default=None, max_length=64)
inertia_preset: str = Field(default="standard", max_length=16)
is_permanent_vote: bool = True
class DocumentItemUpdate(BaseModel):
"""Partial update for a document item."""
title: str | None = Field(default=None, max_length=256)
current_text: str | None = Field(default=None, min_length=1)
position: str | None = Field(default=None, max_length=16)
item_type: str | None = Field(default=None, max_length=32)
class DocumentItemOut(BaseModel):
"""Full document item representation."""
model_config = ConfigDict(from_attributes=True)
id: UUID
document_id: UUID
position: str
item_type: str
title: str | None = None
current_text: str
voting_protocol_id: UUID | None = None
sort_order: int
section_tag: str | None = None
inertia_preset: str = "standard"
is_permanent_vote: bool = True
created_at: datetime
updated_at: datetime
class DocumentItemFullOut(BaseModel):
"""Document item with its full version history."""
model_config = ConfigDict(from_attributes=True)
id: UUID
document_id: UUID
position: str
item_type: str
title: str | None = None
current_text: str
voting_protocol_id: UUID | None = None
sort_order: int
section_tag: str | None = None
inertia_preset: str = "standard"
is_permanent_vote: bool = True
created_at: datetime
updated_at: datetime
versions: list[ItemVersionOut] = Field(default_factory=list)
class DocumentFullOut(BaseModel):
"""Document with full items list (not just count)."""
model_config = ConfigDict(from_attributes=True)
id: UUID
slug: str
title: str
doc_type: str
version: str
status: str
description: str | None = None
ipfs_cid: str | None = None
chain_anchor: str | None = None
genesis_json: str | None = None
created_at: datetime
updated_at: datetime
items: list[DocumentItemOut] = Field(default_factory=list)
# ── Item Reorder ─────────────────────────────────────────────────
class ItemReorderEntry(BaseModel):
"""A single item reorder entry."""
item_id: UUID
sort_order: int = Field(..., ge=0)
class ItemReorderRequest(BaseModel):
"""Payload for reordering items in a document."""
items: list[ItemReorderEntry]
# ── Item Version ─────────────────────────────────────────────────
class ItemVersionCreate(BaseModel):
"""Payload for proposing a new version of a document item."""
proposed_text: str = Field(..., min_length=1)
rationale: str | None = None
class ItemVersionOut(BaseModel):
"""Full item version representation."""
model_config = ConfigDict(from_attributes=True)
id: UUID
item_id: UUID
proposed_text: str
diff_text: str | None = None
rationale: str | None = None
status: str
decision_id: UUID | None = None
proposed_by_id: UUID | None = None
created_at: datetime
# ── Forward reference resolution ─────────────────────────────────
# DocumentItemFullOut references ItemVersionOut which is defined after it.
# With `from __future__ import annotations`, Pydantic needs explicit rebuild.
DocumentItemFullOut.model_rebuild()
DocumentFullOut.model_rebuild()
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class GroupMemberCreate(BaseModel):
display_name: str
identity_id: uuid.UUID | None = None
class GroupMemberOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
display_name: str
identity_id: uuid.UUID | None
added_at: datetime
class GroupCreate(BaseModel):
name: str
description: str | None = None
class GroupOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
description: str | None
organization_id: uuid.UUID | None
created_at: datetime
members: list[GroupMemberOut] = []
class GroupSummary(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
description: str | None
organization_id: uuid.UUID | None
member_count: int = 0
+86
View File
@@ -0,0 +1,86 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
# ── Mandate Step ─────────────────────────────────────────────────
class MandateStepCreate(BaseModel):
step_order: int = Field(..., ge=0)
step_type: str = Field(..., max_length=32)
title: str | None = Field(default=None, max_length=256)
description: str | None = None
class MandateStepOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
mandate_id: UUID
step_order: int
step_type: str
title: str | None = None
description: str | None = None
status: str
vote_session_id: UUID | None = None
outcome: str | None = None
created_at: datetime
# ── Mandate ──────────────────────────────────────────────────────
class MandateCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=256)
origin_id: UUID | None = None
description: str | None = None
mandate_type: str = Field(..., max_length=64)
nomination_mode: str = Field(
default="postpone",
description="auto (auto-assign author), collective, postpone",
)
decision_id: UUID | None = None
starts_at: datetime | None = None
ends_at: datetime | None = None
class MandateUpdate(BaseModel):
title: str | None = Field(default=None, max_length=256)
origin_id: UUID | None = None
description: str | None = None
mandate_type: str | None = Field(default=None, max_length=64)
decision_id: UUID | None = None
starts_at: datetime | None = None
ends_at: datetime | None = None
class MandateAssignRequest(BaseModel):
mandatee_id: UUID = Field(..., description="UUID de l'identite Duniter du mandataire")
class MandateOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
title: str
origin_id: UUID | None = None
origin_display_name: str | None = None
description: str | None = None
mandate_type: str
status: str
mandatee_id: UUID | None = None
mandatee_display_name: str | None = None
decision_id: UUID | None = None
starts_at: datetime | None = None
ends_at: datetime | None = None
created_at: datetime
updated_at: datetime
steps: list[MandateStepOut] = Field(default_factory=list)
class MandateAdvanceOut(MandateOut):
message: str = Field(..., description="Message decrivant l'avancement effectue")
@@ -0,0 +1,42 @@
"""Pydantic v2 schemas for organizations."""
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class OrganizationOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
slug: str
org_type: str
is_transparent: bool
color: str | None
icon: str | None
description: str | None
created_at: datetime
class OrganizationCreate(BaseModel):
name: str
slug: str
org_type: str = "community"
is_transparent: bool = False
color: str | None = None
icon: str | None = None
description: str | None = None
class OrgMemberOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
org_id: uuid.UUID
identity_id: uuid.UUID
role: str
created_at: datetime
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
# ── Formula Config ───────────────────────────────────────────────
class FormulaConfigCreate(BaseModel):
"""Payload for creating a WoT threshold formula configuration."""
name: str = Field(..., min_length=1, max_length=128)
description: str | None = None
# WoT threshold params
duration_days: int = Field(default=30, ge=1, description="Duration of the vote in days")
majority_pct: int = Field(default=50, ge=1, le=100, description="Majority percentage required")
base_exponent: float = Field(default=0.1, ge=0.0, le=1.0, description="Base exponent B in the formula")
gradient_exponent: float = Field(default=0.2, ge=0.0, le=2.0, description="Gradient exponent G in the formula")
constant_base: float = Field(default=0.0, ge=0.0, le=1.0, description="Constant base C in the formula")
# Smith criterion
smith_exponent: float | None = Field(default=None, ge=0.0, le=1.0, description="Smith criterion exponent S")
# TechComm criterion
techcomm_exponent: float | None = Field(default=None, ge=0.0, le=1.0, description="TechComm criterion exponent T")
# Nuanced vote
nuanced_min_participants: int | None = Field(default=None, ge=0, description="Minimum participants for nuanced vote")
nuanced_threshold_pct: int | None = Field(default=None, ge=0, le=100, description="Threshold percentage for nuanced vote")
class FormulaConfigUpdate(BaseModel):
"""Partial update payload for a formula configuration (all fields optional)."""
name: str | None = Field(default=None, min_length=1, max_length=128)
description: str | None = None
duration_days: int | None = Field(default=None, ge=1)
majority_pct: int | None = Field(default=None, ge=1, le=100)
base_exponent: float | None = Field(default=None, ge=0.0, le=1.0)
gradient_exponent: float | None = Field(default=None, ge=0.0, le=2.0)
constant_base: float | None = Field(default=None, ge=0.0, le=1.0)
smith_exponent: float | None = Field(default=None, ge=0.0, le=1.0)
techcomm_exponent: float | None = Field(default=None, ge=0.0, le=1.0)
nuanced_min_participants: int | None = Field(default=None, ge=0)
nuanced_threshold_pct: int | None = Field(default=None, ge=0, le=100)
class FormulaConfigOut(BaseModel):
"""Full formula configuration representation."""
model_config = ConfigDict(from_attributes=True)
id: UUID
name: str
description: str | None = None
duration_days: int
majority_pct: int
base_exponent: float
gradient_exponent: float
constant_base: float
smith_exponent: float | None = None
techcomm_exponent: float | None = None
nuanced_min_participants: int | None = None
nuanced_threshold_pct: int | None = None
created_at: datetime
# ── Voting Protocol ──────────────────────────────────────────────
class VotingProtocolCreate(BaseModel):
"""Payload for creating a voting protocol."""
name: str = Field(..., min_length=1, max_length=128)
description: str | None = None
vote_type: str = Field(..., max_length=32, description="binary, nuanced")
formula_config_id: UUID = Field(..., description="Reference to the formula configuration")
mode_params: str | None = Field(default=None, max_length=64, description='e.g. "D30M50B.1G.2T.1"')
is_meta_governed: bool = Field(default=False, description="Whether this protocol is itself governed by meta-vote")
class VotingProtocolUpdate(BaseModel):
"""Partial update payload for a voting protocol (meta-governance)."""
name: str | None = Field(default=None, min_length=1, max_length=128)
description: str | None = None
vote_type: str | None = Field(default=None, max_length=32)
mode_params: str | None = Field(default=None, max_length=64)
is_meta_governed: bool | None = None
class VotingProtocolOut(BaseModel):
"""Full voting protocol representation including formula config."""
model_config = ConfigDict(from_attributes=True)
id: UUID
name: str
description: str | None = None
vote_type: str
formula_config_id: UUID
mode_params: str | None = None
is_meta_governed: bool
created_at: datetime
formula_config: FormulaConfigOut
# ── Formula Simulation ───────────────────────────────────────────
class FormulaSimulationRequest(BaseModel):
"""Request payload for simulating a WoT threshold formula computation."""
wot_size: int = Field(..., ge=1, description="Size of the WoT (eligible voter corpus)")
total_votes: int = Field(..., ge=0, description="Number of votes cast (for + against)")
majority_pct: int = Field(default=50, ge=1, le=100, description="Majority percentage M")
base_exponent: float = Field(default=0.1, ge=0.0, le=1.0, description="Base exponent B")
gradient_exponent: float = Field(default=0.2, ge=0.0, le=2.0, description="Gradient exponent G")
constant_base: float = Field(default=0.0, ge=0.0, le=1.0, description="Constant base C")
smith_wot_size: int | None = Field(default=None, ge=1, description="Smith sub-WoT size")
smith_exponent: float | None = Field(default=None, ge=0.0, le=1.0, description="Smith exponent S")
techcomm_size: int | None = Field(default=None, ge=1, description="TechComm size")
techcomm_exponent: float | None = Field(default=None, ge=0.0, le=1.0, description="TechComm exponent T")
class FormulaSimulationResult(BaseModel):
"""Result of a formula simulation."""
wot_threshold: int
smith_threshold: int | None = None
techcomm_threshold: int | None = None
participation_rate: float
required_ratio: float
inertia_factor: float
@@ -0,0 +1,48 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
class QualifyRequest(BaseModel):
within_mandate: bool = False
affected_count: int | None = Field(default=None, ge=2, description="Nombre de personnes concernées (minimum 2)")
is_structural: bool = False
context_description: str | None = Field(default=None, max_length=2000)
class QualifyResponse(BaseModel):
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]
class QualificationProtocolOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
name: str
description: str | None = None
small_group_max: int
collective_wot_min: int
default_modalities: list[str]
is_active: bool
created_at: datetime
class QualificationProtocolCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=128)
description: str | None = None
small_group_max: int = Field(default=5, ge=1)
collective_wot_min: int = Field(default=50, ge=1)
default_modalities: list[str] = Field(
default=["vote_wot", "vote_smith", "consultation_avis", "election"]
)
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
# ── Sanctuary Entry ──────────────────────────────────────────────
class SanctuaryEntryCreate(BaseModel):
"""Payload for creating a new sanctuary entry (IPFS + chain anchor)."""
entry_type: str = Field(..., max_length=64, description="document, decision, vote_result")
reference_id: UUID = Field(..., description="ID of the referenced entity")
title: str | None = Field(default=None, max_length=256)
content_hash: str = Field(..., max_length=128, description="SHA-256 hash of the content")
class SanctuaryEntryOut(BaseModel):
"""Full sanctuary entry representation."""
model_config = ConfigDict(from_attributes=True)
id: UUID
entry_type: str
reference_id: UUID
title: str | None = None
content_hash: str
ipfs_cid: str | None = None
chain_tx_hash: str | None = None
chain_block: int | None = None
metadata_json: str | None = None
created_at: datetime
+157
View File
@@ -0,0 +1,157 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
# ── Vote Session ─────────────────────────────────────────────────
class VoteSessionCreate(BaseModel):
"""Payload for opening a new vote session."""
decision_id: UUID | None = None
item_version_id: UUID | None = None
voting_protocol_id: UUID = Field(..., description="ID of the voting protocol to apply")
class VoteSessionOut(BaseModel):
"""Full vote session representation including tallies."""
model_config = ConfigDict(from_attributes=True)
id: UUID
decision_id: UUID | None = None
item_version_id: UUID | None = None
voting_protocol_id: UUID
# Snapshot at session start
wot_size: int
smith_size: int
techcomm_size: int
# Dates
starts_at: datetime
ends_at: datetime
# Status
status: str
# Tallies
votes_for: int
votes_against: int
votes_total: int
smith_votes_for: int
techcomm_votes_for: int
threshold_required: float
result: str | None = None
# Chain recording
chain_recorded: bool
chain_tx_hash: str | None = None
created_at: datetime
class VoteSessionListOut(BaseModel):
"""Lighter vote session representation for list endpoints (no nested votes)."""
model_config = ConfigDict(from_attributes=True)
id: UUID
decision_id: UUID | None = None
item_version_id: UUID | None = None
voting_protocol_id: UUID
# Snapshot at session start
wot_size: int
smith_size: int
techcomm_size: int
# Dates
starts_at: datetime
ends_at: datetime
# Status
status: str
# Tallies
votes_for: int
votes_against: int
votes_total: int
threshold_required: float
result: str | None = None
created_at: datetime
# ── Threshold Details ────────────────────────────────────────────
class ThresholdDetailOut(BaseModel):
"""Detailed threshold computation result for a vote session."""
wot_threshold: int
smith_threshold: int | None = None
techcomm_threshold: int | None = None
votes_for: int
votes_against: int
votes_total: int
wot_passed: bool
smith_passed: bool | None = None
techcomm_passed: bool | None = None
overall_passed: bool
participation_rate: float
formula_params: dict
# ── Vote Result ──────────────────────────────────────────────────
class VoteResultOut(BaseModel):
"""Structured vote result response."""
session_id: UUID
result: str | None = None # adopted, rejected
threshold_required: float
votes_for: int
votes_against: int
votes_total: int
adopted: bool
nuanced_breakdown: dict | None = None # for nuanced votes
# ── Vote ─────────────────────────────────────────────────────────
class VoteCreate(BaseModel):
"""Payload for casting a vote (with cryptographic proof)."""
session_id: UUID
vote_value: str = Field(..., max_length=32, description="for, against, or nuanced level")
nuanced_level: int | None = Field(default=None, ge=0, le=5, description="0-5 for nuanced votes")
comment: str | None = None
signature: str = Field(..., description="Ed25519 signature of signed_payload")
signed_payload: str = Field(..., description="The exact payload that was signed")
class VoteOut(BaseModel):
"""Full vote representation."""
model_config = ConfigDict(from_attributes=True)
id: UUID
session_id: UUID
voter_id: UUID
vote_value: str
nuanced_level: int | None = None
comment: str | None = None
signature: str
signed_payload: str
voter_wot_status: str
voter_is_smith: bool
voter_is_techcomm: bool
is_active: bool
created_at: datetime
@@ -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
@@ -0,0 +1,215 @@
"""Tests for cache_service: get, set, invalidate, and cleanup.
Uses mock database sessions to test cache logic in isolation
without requiring a real PostgreSQL connection.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock
import pytest
sqlalchemy = pytest.importorskip("sqlalchemy", reason="sqlalchemy required for cache service tests")
from app.services.cache_service import ( # noqa: E402
cleanup_expired,
get_cached,
invalidate,
set_cached,
)
# ---------------------------------------------------------------------------
# Helpers: mock objects for BlockchainCache entries
# ---------------------------------------------------------------------------
def _make_cache_entry(
key: str = "test:key",
value: dict | None = None,
expired: bool = False,
) -> MagicMock:
"""Create a mock BlockchainCache entry."""
entry = MagicMock()
entry.id = uuid.uuid4()
entry.cache_key = key
entry.cache_value = value or {"data": 42}
entry.fetched_at = datetime.now(timezone.utc)
if expired:
entry.expires_at = datetime.now(timezone.utc) - timedelta(hours=1)
else:
entry.expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
return entry
def _make_async_db(entry: MagicMock | None = None) -> AsyncMock:
"""Create a mock async database session.
The session's execute() returns a result with scalar_one_or_none()
that returns the given entry (or None).
"""
db = AsyncMock()
result = MagicMock()
result.scalar_one_or_none.return_value = entry
db.execute = AsyncMock(return_value=result)
db.commit = AsyncMock()
db.add = MagicMock()
return db
# ---------------------------------------------------------------------------
# Tests: get_cached
# ---------------------------------------------------------------------------
class TestGetCached:
"""Test cache_service.get_cached."""
@pytest.mark.asyncio
async def test_returns_none_for_missing_key(self):
"""get_cached returns None when no entry exists for the key."""
db = _make_async_db(entry=None)
result = await get_cached("nonexistent:key", db)
assert result is None
@pytest.mark.asyncio
async def test_returns_value_for_valid_entry(self):
"""get_cached returns the cached value when entry exists and is not expired."""
entry = _make_cache_entry(key="blockchain:wot_size", value={"value": 7224})
db = _make_async_db(entry=entry)
result = await get_cached("blockchain:wot_size", db)
assert result == {"value": 7224}
@pytest.mark.asyncio
async def test_returns_none_for_expired_entry(self):
"""get_cached returns None when the entry is expired.
Note: In the real implementation, the SQL query filters by expires_at.
Here we test that when the DB returns None (as it would for expired),
get_cached returns None.
"""
db = _make_async_db(entry=None)
result = await get_cached("expired:key", db)
assert result is None
# ---------------------------------------------------------------------------
# Tests: set_cached + get_cached roundtrip
# ---------------------------------------------------------------------------
class TestSetCached:
"""Test cache_service.set_cached."""
@pytest.mark.asyncio
async def test_set_cached_creates_new_entry(self):
"""set_cached creates a new entry when key does not exist."""
db = _make_async_db(entry=None)
await set_cached("new:key", {"value": 100}, db, ttl_seconds=3600)
db.add.assert_called_once()
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_set_cached_updates_existing_entry(self):
"""set_cached updates the value when key already exists."""
existing = _make_cache_entry(key="existing:key", value={"value": 50})
db = _make_async_db(entry=existing)
await set_cached("existing:key", {"value": 200}, db, ttl_seconds=7200)
# Should update in-place, not add new
db.add.assert_not_called()
assert existing.cache_value == {"value": 200}
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_set_cached_roundtrip(self):
"""set_cached followed by get_cached returns the stored value."""
# First call: set (no existing entry)
db_set = _make_async_db(entry=None)
await set_cached("roundtrip:key", {"data": "test"}, db_set, ttl_seconds=3600)
db_set.add.assert_called_once()
# Extract the added entry from the mock
added_entry = db_set.add.call_args[0][0]
# Second call: get (returns the added entry)
db_get = _make_async_db(entry=added_entry)
result = await get_cached("roundtrip:key", db_get)
assert result == {"data": "test"}
# ---------------------------------------------------------------------------
# Tests: invalidate
# ---------------------------------------------------------------------------
class TestInvalidate:
"""Test cache_service.invalidate."""
@pytest.mark.asyncio
async def test_invalidate_removes_entry(self):
"""invalidate executes a delete and commits."""
db = AsyncMock()
db.execute = AsyncMock()
db.commit = AsyncMock()
await invalidate("some:key", db)
db.execute.assert_awaited_once()
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_invalidate_then_get_returns_none(self):
"""After invalidation, get_cached returns None."""
db = _make_async_db(entry=None)
result = await get_cached("invalidated:key", db)
assert result is None
# ---------------------------------------------------------------------------
# Tests: cleanup_expired
# ---------------------------------------------------------------------------
class TestCleanupExpired:
"""Test cache_service.cleanup_expired."""
@pytest.mark.asyncio
async def test_cleanup_removes_expired_entries(self):
"""cleanup_expired executes a delete for expired entries and returns count."""
db = AsyncMock()
# Mock the execute result with rowcount
exec_result = MagicMock()
exec_result.rowcount = 3
db.execute = AsyncMock(return_value=exec_result)
db.commit = AsyncMock()
count = await cleanup_expired(db)
assert count == 3
db.execute.assert_awaited_once()
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_cleanup_with_no_expired_entries(self):
"""cleanup_expired returns 0 when no expired entries exist."""
db = AsyncMock()
exec_result = MagicMock()
exec_result.rowcount = 0
db.execute = AsyncMock(return_value=exec_result)
db.commit = AsyncMock()
count = await cleanup_expired(db)
assert count == 0
@@ -0,0 +1,386 @@
"""Tests for decision service: advance_decision, create_vote_session_for_step.
These are pure unit tests that mock the database layer to test
the service logic in isolation.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
sqlalchemy = pytest.importorskip("sqlalchemy", reason="sqlalchemy required for decision service tests")
from app.services.decision_service import ( # noqa: E402
advance_decision,
create_vote_session_for_step,
)
# ---------------------------------------------------------------------------
# Helpers: mock objects that behave like SQLAlchemy models
# ---------------------------------------------------------------------------
def _make_step(
step_id: uuid.UUID | None = None,
decision_id: uuid.UUID | None = None,
step_order: int = 0,
step_type: str = "qualification",
status: str = "pending",
vote_session_id: uuid.UUID | None = None,
) -> MagicMock:
"""Create a mock DecisionStep."""
step = MagicMock()
step.id = step_id or uuid.uuid4()
step.decision_id = decision_id or uuid.uuid4()
step.step_order = step_order
step.step_type = step_type
step.title = None
step.description = None
step.status = status
step.vote_session_id = vote_session_id
step.outcome = None
step.created_at = datetime.now(timezone.utc)
return step
def _make_decision(
decision_id: uuid.UUID | None = None,
title: str = "Decision de test",
decision_type: str = "document_change",
status: str = "draft",
voting_protocol_id: uuid.UUID | None = None,
steps: list | None = None,
) -> MagicMock:
"""Create a mock Decision."""
decision = MagicMock()
decision.id = decision_id or uuid.uuid4()
decision.title = title
decision.description = None
decision.context = None
decision.decision_type = decision_type
decision.status = status
decision.voting_protocol_id = voting_protocol_id
decision.created_by_id = None
decision.created_at = datetime.now(timezone.utc)
decision.updated_at = datetime.now(timezone.utc)
decision.steps = steps or []
return decision
def _make_protocol(
protocol_id: uuid.UUID | None = None,
duration_days: int = 30,
) -> MagicMock:
"""Create a mock VotingProtocol with formula_config."""
protocol = MagicMock()
protocol.id = protocol_id or uuid.uuid4()
protocol.name = "Protocole standard"
protocol.vote_type = "binary"
protocol.formula_config = MagicMock()
protocol.formula_config.duration_days = duration_days
return protocol
def _make_db_for_advance(decision: MagicMock | None) -> AsyncMock:
"""Create a mock async database session for advance_decision."""
db = AsyncMock()
result = MagicMock()
result.scalar_one_or_none.return_value = decision
db.execute = AsyncMock(return_value=result)
db.commit = AsyncMock()
db.refresh = AsyncMock()
return db
def _make_db_for_vote_session(
decision: MagicMock | None,
protocol: MagicMock | None = None,
) -> AsyncMock:
"""Create a mock async database session for create_vote_session_for_step.
Sequential execute calls:
1st -> decision lookup
2nd -> protocol lookup
"""
db = AsyncMock()
call_results = []
# Decision result
decision_result = MagicMock()
decision_result.scalar_one_or_none.return_value = decision
call_results.append(decision_result)
# Protocol result
if protocol is not None:
proto_result = MagicMock()
proto_result.scalar_one_or_none.return_value = protocol
call_results.append(proto_result)
db.execute = AsyncMock(side_effect=call_results)
db.commit = AsyncMock()
db.refresh = AsyncMock()
db.flush = AsyncMock()
db.add = MagicMock()
return db
# ---------------------------------------------------------------------------
# Tests: advance_decision
# ---------------------------------------------------------------------------
class TestAdvanceDecision:
"""Test decision_service.advance_decision."""
@pytest.mark.asyncio
async def test_advance_from_draft_activates_first_step(self):
"""Advancing a draft decision with pending steps activates the first one
and moves decision to qualification."""
decision_id = uuid.uuid4()
step1 = _make_step(decision_id=decision_id, step_order=0, status="pending")
step2 = _make_step(decision_id=decision_id, step_order=1, status="pending")
decision = _make_decision(
decision_id=decision_id,
status="draft",
steps=[step1, step2],
)
db = _make_db_for_advance(decision)
result = await advance_decision(decision_id, db)
assert step1.status == "active"
assert step2.status == "pending"
assert decision.status == "qualification"
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_advance_completes_active_and_activates_next(self):
"""Advancing completes the current active step and activates the next pending one."""
decision_id = uuid.uuid4()
step1 = _make_step(decision_id=decision_id, step_order=0, status="active")
step2 = _make_step(decision_id=decision_id, step_order=1, status="pending")
decision = _make_decision(
decision_id=decision_id,
status="qualification",
steps=[step1, step2],
)
db = _make_db_for_advance(decision)
result = await advance_decision(decision_id, db)
assert step1.status == "completed"
assert step2.status == "active"
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_advance_last_step_advances_status(self):
"""When the last active step is completed with no more pending steps,
the decision status advances."""
decision_id = uuid.uuid4()
step1 = _make_step(decision_id=decision_id, step_order=0, status="completed")
step2 = _make_step(decision_id=decision_id, step_order=1, status="active")
decision = _make_decision(
decision_id=decision_id,
status="qualification",
steps=[step1, step2],
)
db = _make_db_for_advance(decision)
result = await advance_decision(decision_id, db)
assert step2.status == "completed"
assert decision.status == "review"
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_advance_no_steps_advances_status(self):
"""A decision with no steps advances its status directly."""
decision_id = uuid.uuid4()
decision = _make_decision(
decision_id=decision_id,
status="draft",
steps=[],
)
db = _make_db_for_advance(decision)
result = await advance_decision(decision_id, db)
assert decision.status == "qualification"
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_advance_full_progression(self):
"""A decision with no steps can progress through all statuses."""
decision_id = uuid.uuid4()
decision = _make_decision(
decision_id=decision_id,
status="draft",
steps=[],
)
statuses_seen = [decision.status]
db = _make_db_for_advance(decision)
# Advance through all states until closed
for _ in range(10): # safety limit
# Recreate db mock for each call (side_effect is consumed)
db = _make_db_for_advance(decision)
await advance_decision(decision_id, db)
statuses_seen.append(decision.status)
if decision.status == "closed":
break
assert statuses_seen == [
"draft",
"qualification",
"review",
"voting",
"executed",
"closed",
]
@pytest.mark.asyncio
async def test_advance_closed_raises_error(self):
"""Advancing a closed decision raises ValueError."""
decision_id = uuid.uuid4()
decision = _make_decision(
decision_id=decision_id,
status="closed",
steps=[],
)
db = _make_db_for_advance(decision)
with pytest.raises(ValueError, match="deja cloturee"):
await advance_decision(decision_id, db)
@pytest.mark.asyncio
async def test_advance_not_found_raises_error(self):
"""Advancing a non-existent decision raises ValueError."""
db = _make_db_for_advance(None)
with pytest.raises(ValueError, match="Decision introuvable"):
await advance_decision(uuid.uuid4(), db)
# ---------------------------------------------------------------------------
# Tests: create_vote_session_for_step
# ---------------------------------------------------------------------------
class TestCreateVoteSessionForStep:
"""Test decision_service.create_vote_session_for_step."""
@pytest.mark.asyncio
async def test_create_vote_session_success(self):
"""A vote session is created and linked to the step."""
decision_id = uuid.uuid4()
step_id = uuid.uuid4()
protocol_id = uuid.uuid4()
step = _make_step(
step_id=step_id,
decision_id=decision_id,
step_type="vote",
status="active",
vote_session_id=None,
)
decision = _make_decision(
decision_id=decision_id,
status="voting",
voting_protocol_id=protocol_id,
steps=[step],
)
protocol = _make_protocol(protocol_id=protocol_id, duration_days=14)
db = _make_db_for_vote_session(decision, protocol)
result = await create_vote_session_for_step(decision_id, step_id, db)
# The step should now have a vote_session_id set
assert step.vote_session_id is not None
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_create_vote_session_decision_not_found(self):
"""ValueError when decision does not exist."""
db = _make_db_for_vote_session(None)
with pytest.raises(ValueError, match="Decision introuvable"):
await create_vote_session_for_step(uuid.uuid4(), uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_create_vote_session_step_not_found(self):
"""ValueError when step does not belong to the decision."""
decision_id = uuid.uuid4()
decision = _make_decision(decision_id=decision_id, steps=[])
db = _make_db_for_vote_session(decision)
with pytest.raises(ValueError, match="Etape introuvable"):
await create_vote_session_for_step(decision_id, uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_create_vote_session_step_already_has_session(self):
"""ValueError when step already has a vote session."""
decision_id = uuid.uuid4()
step_id = uuid.uuid4()
existing_session_id = uuid.uuid4()
step = _make_step(
step_id=step_id,
decision_id=decision_id,
vote_session_id=existing_session_id,
)
decision = _make_decision(
decision_id=decision_id,
voting_protocol_id=uuid.uuid4(),
steps=[step],
)
db = _make_db_for_vote_session(decision)
with pytest.raises(ValueError, match="deja une session de vote"):
await create_vote_session_for_step(decision_id, step_id, db)
@pytest.mark.asyncio
async def test_create_vote_session_no_protocol(self):
"""ValueError when decision has no voting protocol configured."""
decision_id = uuid.uuid4()
step_id = uuid.uuid4()
step = _make_step(
step_id=step_id,
decision_id=decision_id,
vote_session_id=None,
)
decision = _make_decision(
decision_id=decision_id,
voting_protocol_id=None,
steps=[step],
)
db = _make_db_for_vote_session(decision)
with pytest.raises(ValueError, match="Aucun protocole de vote"):
await create_vote_session_for_step(decision_id, step_id, db)
@@ -0,0 +1,856 @@
"""TDD: Document ↔ Protocol ↔ Vote integration tests.
Tests the interrelation between:
- DocumentItem ←→ VotingProtocol (via voting_protocol_id)
- VotingProtocol ←→ FormulaConfig (formula parameters)
- VoteSession creation from DocumentItem context
- Threshold computation using item's protocol (inertia presets)
- Smith vs WoT standard protocol behavior
- ItemVersion lifecycle: propose → vote → accept/reject
- Multi-criteria adoption (WoT + Smith + TechComm)
All tests are pure unit tests exercising engine functions + service logic
without a real database (mocks only).
"""
from __future__ import annotations
import math
import uuid
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
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
# ===========================================================================
# 1. DOCUMENT-PROTOCOL INTERRELATION
# ===========================================================================
class TestInertiaPresetsThresholds:
"""Verify that different inertia presets produce different thresholds.
Inertia presets map to gradient_exponent values:
low → G=0.1 (easy replacement)
standard → G=0.2 (balanced)
high → G=0.4 (hard replacement)
very_high → G=0.6 (very hard replacement)
"""
WOT_SIZE = 7224
TOTAL_VOTES = 120 # ~1.66% participation
INERTIA_MAP = {
"low": {"gradient_exponent": 0.1, "majority_pct": 50},
"standard": {"gradient_exponent": 0.2, "majority_pct": 50},
"high": {"gradient_exponent": 0.4, "majority_pct": 60},
"very_high": {"gradient_exponent": 0.6, "majority_pct": 66},
}
def _threshold_for_preset(self, preset: str) -> int:
params = self.INERTIA_MAP[preset]
return wot_threshold(
wot_size=self.WOT_SIZE,
total_votes=self.TOTAL_VOTES,
majority_pct=params["majority_pct"],
gradient_exponent=params["gradient_exponent"],
)
def test_low_inertia_easiest(self):
"""Low inertia should produce the lowest threshold."""
t_low = self._threshold_for_preset("low")
t_std = self._threshold_for_preset("standard")
assert t_low < t_std, f"Low ({t_low}) should be < standard ({t_std})"
def test_standard_below_high(self):
"""Standard inertia should be below high."""
t_std = self._threshold_for_preset("standard")
t_high = self._threshold_for_preset("high")
assert t_std < t_high, f"Standard ({t_std}) should be < high ({t_high})"
def test_high_below_very_high(self):
"""High inertia should be below very_high."""
t_high = self._threshold_for_preset("high")
t_vh = self._threshold_for_preset("very_high")
assert t_high < t_vh, f"High ({t_high}) should be < very_high ({t_vh})"
def test_monotonic_ordering(self):
"""All 4 presets must be strictly ordered: low < standard < high < very_high."""
thresholds = {p: self._threshold_for_preset(p) for p in self.INERTIA_MAP}
assert thresholds["low"] < thresholds["standard"]
assert thresholds["standard"] < thresholds["high"]
assert thresholds["high"] < thresholds["very_high"]
def test_low_inertia_near_majority(self):
"""With low inertia (G=0.1), even at 1.66% participation,
threshold shouldn't be too far from total votes."""
t = self._threshold_for_preset("low")
# With G=0.1, inertia is mild even at low participation
assert t <= self.TOTAL_VOTES, f"Low inertia threshold ({t}) should be <= total ({self.TOTAL_VOTES})"
def test_very_high_inertia_near_unanimity(self):
"""With very_high inertia (G=0.6, M=66%), at 1.66% participation,
threshold should be very close to total votes (near unanimity)."""
t = self._threshold_for_preset("very_high")
# At very low participation with high G and high M, threshold ≈ total
ratio = t / self.TOTAL_VOTES
assert ratio > 0.85, f"Very high inertia ratio ({ratio:.2f}) should demand near-unanimity"
class TestSmithProtocolVsStandard:
"""Compare the behavior of Smith protocol (D30M50B.1G.2S.1) vs
WoT standard (D30M50B.1G.2) — the only difference is the Smith criterion."""
WOT_SIZE = 7224
SMITH_SIZE = 20
TOTAL = 120
VOTES_FOR = 97
def test_same_wot_threshold(self):
"""Both protocols have M50B.1G.2 → same WoT threshold."""
smith_params = parse_mode_params("D30M50B.1G.2S.1")
std_params = parse_mode_params("D30M50B.1G.2")
t_smith = wot_threshold(
wot_size=self.WOT_SIZE, total_votes=self.TOTAL,
majority_pct=smith_params["majority_pct"],
gradient_exponent=smith_params["gradient_exponent"],
)
t_std = wot_threshold(
wot_size=self.WOT_SIZE, total_votes=self.TOTAL,
majority_pct=std_params["majority_pct"],
gradient_exponent=std_params["gradient_exponent"],
)
assert t_smith == t_std
def test_smith_criterion_present_vs_absent(self):
"""Smith protocol has smith_exponent=0.1, standard has None."""
smith_params = parse_mode_params("D30M50B.1G.2S.1")
std_params = parse_mode_params("D30M50B.1G.2")
assert smith_params["smith_exponent"] == 0.1
assert std_params["smith_exponent"] is None
def test_smith_protocol_can_be_blocked_by_smiths(self):
"""Smith protocol adoption requires both WoT AND Smith criteria."""
wot_thresh = wot_threshold(
wot_size=self.WOT_SIZE, total_votes=self.TOTAL,
majority_pct=50, gradient_exponent=0.2,
)
wot_pass = self.VOTES_FOR >= wot_thresh
smith_thresh = smith_threshold(self.SMITH_SIZE, 0.1) # = 2
smith_votes_for = 1 # Only 1 smith voted → fails
adopted = wot_pass and (smith_votes_for >= smith_thresh)
assert wot_pass is True
assert smith_votes_for < smith_thresh
assert adopted is False
def test_standard_protocol_ignores_smith(self):
"""Standard protocol with no smith_exponent always passes smith criterion."""
params = parse_mode_params("D30M50B.1G.2")
smith_ok = True # Default when smith_exponent is None
if params["smith_exponent"] is not None:
smith_ok = False # Would need smith votes
assert smith_ok is True
def test_smith_threshold_scales_with_smith_size(self):
"""Smith threshold ceil(N^0.1) grows slowly with smith WoT size."""
sizes_and_expected = [
(1, 1), # ceil(1^0.1) = 1
(5, 2), # ceil(5^0.1) = ceil(1.175) = 2
(20, 2), # ceil(20^0.1) = ceil(1.35) = 2
(100, 2), # ceil(100^0.1) = ceil(1.585) = 2
(1000, 2), # ceil(1000^0.1) = ceil(1.995) = 2
(1024, 2), # ceil(1024^0.1) = ceil(2.0) = 2
(1025, 3), # ceil(1025^0.1) > 2
]
for size, expected in sizes_and_expected:
result = smith_threshold(size, 0.1)
assert result == expected, f"smith_threshold({size}, 0.1) = {result}, expected {expected}"
class TestModeParamsRoundtrip:
"""Verify mode_params parsing produces correct formula parameters."""
def test_smith_protocol_params(self):
"""D30M50B.1G.2S.1 — the Forgeron Smith protocol."""
params = parse_mode_params("D30M50B.1G.2S.1")
assert params["duration_days"] == 30
assert params["majority_pct"] == 50
assert params["base_exponent"] == 0.1
assert params["gradient_exponent"] == 0.2
assert params["smith_exponent"] == 0.1
assert params["techcomm_exponent"] is None
def test_techcomm_protocol_params(self):
"""D30M50B.1G.2T.1 — a TechComm protocol."""
params = parse_mode_params("D30M50B.1G.2T.1")
assert params["techcomm_exponent"] == 0.1
assert params["smith_exponent"] is None
def test_full_protocol_params(self):
"""D30M50B.1G.2S.1T.1 — both Smith AND TechComm."""
params = parse_mode_params("D30M50B.1G.2S.1T.1")
assert params["smith_exponent"] == 0.1
assert params["techcomm_exponent"] == 0.1
def test_high_inertia_params(self):
"""D30M60B.1G.4 — high inertia preset."""
params = parse_mode_params("D30M60B.1G.4")
assert params["majority_pct"] == 60
assert params["gradient_exponent"] == 0.4
def test_very_high_inertia_params(self):
"""D30M66B.1G.6 — very high inertia preset."""
params = parse_mode_params("D30M66B.1G.6")
assert params["majority_pct"] == 66
assert params["gradient_exponent"] == 0.6
def test_params_used_in_threshold_match(self):
"""Threshold computed from parsed params must match direct computation."""
params = parse_mode_params("D30M50B.1G.2S.1")
computed = wot_threshold(
wot_size=7224, total_votes=120,
majority_pct=params["majority_pct"],
base_exponent=params["base_exponent"],
gradient_exponent=params["gradient_exponent"],
)
direct = wot_threshold(
wot_size=7224, total_votes=120,
majority_pct=50, base_exponent=0.1, gradient_exponent=0.2,
)
assert computed == direct
# ===========================================================================
# 2. VOTE BEHAVIOR — ADVANCED SCENARIOS
# ===========================================================================
class TestInertiaFormulaBehavior:
"""Deep tests on the inertia formula behavior across participation levels."""
def test_participation_curve_is_monotonically_decreasing(self):
"""As participation increases, required threshold ratio decreases.
This is the fundamental property of inertia-based democracy."""
W = 7224
M = 0.5
G = 0.2
prev_ratio = float("inf")
for t in range(10, W + 1, 100):
participation = t / W
inertia = 1.0 - participation ** G
ratio = M + (1.0 - M) * inertia
assert ratio <= prev_ratio, (
f"Ratio must decrease: at T={t}, ratio={ratio:.4f} > prev={prev_ratio:.4f}"
)
prev_ratio = ratio
def test_at_full_participation_ratio_equals_majority(self):
"""At T=W (100% participation), ratio should equal M exactly."""
W = 7224
M_pct = 50
M = M_pct / 100
threshold = wot_threshold(wot_size=W, total_votes=W, majority_pct=M_pct)
expected = math.ceil(0.1 ** W + M * W)
assert threshold == expected
def test_at_1_percent_participation_near_unanimity(self):
"""At ~1% participation, threshold should be near total votes."""
W = 7224
T = 72 # ~1%
threshold = wot_threshold(wot_size=W, total_votes=T, majority_pct=50)
ratio = threshold / T
assert ratio > 0.75, f"At 1% participation, ratio={ratio:.2f} should be > 0.75"
def test_at_50_percent_participation(self):
"""At 50% participation with G=0.2, threshold is well above simple majority."""
W = 1000
T = 500
threshold = wot_threshold(wot_size=W, total_votes=T, majority_pct=50,
gradient_exponent=0.2)
# (500/1000)^0.2 ≈ 0.87, inertia ≈ 0.13, ratio ≈ 0.565
assert threshold > 250, "Should be above simple majority"
assert threshold < 400, "Should not be near unanimity"
def test_gradient_zero_means_always_majority(self):
"""With G=0, (T/W)^0 = 1, inertia = 0, ratio = M always.
This effectively disables inertia."""
W = 7224
M_pct = 50
for T in [10, 100, 1000, 7224]:
threshold = wot_threshold(wot_size=W, total_votes=T,
majority_pct=M_pct, gradient_exponent=0.0001)
expected_approx = M_pct / 100 * T
# With very small G, threshold should be close to M*T
assert abs(threshold - math.ceil(expected_approx)) <= 2, (
f"At T={T}, threshold={threshold}, expected≈{expected_approx:.0f}"
)
class TestMultiCriteriaAdoption:
"""Test that adoption requires ALL applicable criteria to pass."""
WOT = 7224
TOTAL = 120
VOTES_FOR = 97
SMITH_SIZE = 20
TECHCOMM_SIZE = 5
def _wot_threshold(self):
return wot_threshold(wot_size=self.WOT, total_votes=self.TOTAL,
majority_pct=50, gradient_exponent=0.2)
def test_all_pass(self):
"""WoT pass + Smith pass + TechComm pass → adopted."""
wot_ok = self.VOTES_FOR >= self._wot_threshold()
smith_ok = 3 >= smith_threshold(self.SMITH_SIZE, 0.1) # 3 >= 2
tc_ok = 2 >= techcomm_threshold(self.TECHCOMM_SIZE, 0.1) # 2 >= 2
assert wot_ok and smith_ok and tc_ok
def test_wot_fails(self):
"""WoT fail + Smith pass + TechComm pass → rejected."""
wot_ok = 50 >= self._wot_threshold() # 50 < 94
smith_ok = 3 >= smith_threshold(self.SMITH_SIZE, 0.1)
tc_ok = 2 >= techcomm_threshold(self.TECHCOMM_SIZE, 0.1)
assert not wot_ok
assert not (wot_ok and smith_ok and tc_ok)
def test_smith_fails(self):
"""WoT pass + Smith fail + TechComm pass → rejected."""
wot_ok = self.VOTES_FOR >= self._wot_threshold()
smith_ok = 1 >= smith_threshold(self.SMITH_SIZE, 0.1) # 1 < 2
tc_ok = 2 >= techcomm_threshold(self.TECHCOMM_SIZE, 0.1)
assert wot_ok and tc_ok
assert not smith_ok
assert not (wot_ok and smith_ok and tc_ok)
def test_techcomm_fails(self):
"""WoT pass + Smith pass + TechComm fail → rejected."""
wot_ok = self.VOTES_FOR >= self._wot_threshold()
smith_ok = 3 >= smith_threshold(self.SMITH_SIZE, 0.1)
tc_ok = 1 >= techcomm_threshold(self.TECHCOMM_SIZE, 0.1) # 1 < 2
assert wot_ok and smith_ok
assert not tc_ok
assert not (wot_ok and smith_ok and tc_ok)
def test_all_fail(self):
"""All three fail → rejected."""
wot_ok = 10 >= self._wot_threshold()
smith_ok = 0 >= smith_threshold(self.SMITH_SIZE, 0.1)
tc_ok = 0 >= techcomm_threshold(self.TECHCOMM_SIZE, 0.1)
assert not (wot_ok or smith_ok or tc_ok)
def test_no_smith_no_techcomm(self):
"""When protocol has no Smith/TechComm, only WoT matters."""
params = parse_mode_params("D30M50B.1G.2")
wot_ok = self.VOTES_FOR >= self._wot_threshold()
# smith_exponent and techcomm_exponent are None
smith_ok = True # default when not configured
tc_ok = True
if params["smith_exponent"] is not None:
smith_ok = False
if params["techcomm_exponent"] is not None:
tc_ok = False
assert wot_ok and smith_ok and tc_ok
class TestEdgeCasesVotes:
"""Edge cases in vote behavior."""
def test_single_vote_small_wot(self):
"""1 vote out of 5 WoT members → threshold near 1 (almost unanimity)."""
threshold = wot_threshold(wot_size=5, total_votes=1, majority_pct=50)
# With 1/5 = 20% participation, inertia is high → threshold ≈ 1
assert threshold == 1
def test_single_vote_large_wot(self):
"""1 vote out of 7224 WoT → threshold = 1 (need that 1 vote to be for)."""
threshold = wot_threshold(wot_size=7224, total_votes=1, majority_pct=50)
assert threshold == 1
def test_two_votes_disagree(self):
"""2 votes: 1 for + 1 against. At low participation → need near-unanimity."""
threshold = wot_threshold(wot_size=7224, total_votes=2, majority_pct=50)
# Threshold should be close to 2 (near unanimity)
assert threshold == 2
def test_exact_threshold_boundary(self):
"""votes_for == threshold exactly → adopted (>= comparison)."""
threshold = wot_threshold(wot_size=7224, total_votes=120, majority_pct=50)
assert threshold >= threshold # votes_for == threshold → adopted
def test_one_below_threshold_boundary(self):
"""votes_for == threshold - 1 → rejected."""
threshold = wot_threshold(wot_size=7224, total_votes=120, majority_pct=50)
assert (threshold - 1) < threshold
def test_constant_base_raises_minimum(self):
"""With C=5, threshold is at least 5 even with no participation effect."""
threshold = wot_threshold(wot_size=100, total_votes=0,
majority_pct=50, constant_base=5.0)
assert threshold >= 5
def test_wot_size_1_minimal(self):
"""WoT of 1 member, 1 vote → threshold = 1."""
threshold = wot_threshold(wot_size=1, total_votes=1, majority_pct=50)
assert threshold == 1
class TestNuancedVoteAdvanced:
"""Advanced nuanced vote scenarios."""
def test_all_level_5_adopted(self):
"""60 voters all at level 5 (TOUT A FAIT) → adopted."""
result = evaluate_nuanced([5] * 60)
assert result["adopted"] is True
assert result["positive_pct"] == 100.0
def test_all_level_0_rejected(self):
"""60 voters all at level 0 (CONTRE) → rejected."""
result = evaluate_nuanced([0] * 60)
assert result["adopted"] is False
assert result["positive_pct"] == 0.0
def test_exactly_80_pct_positive(self):
"""Exactly 80% positive (48/60) → threshold_met = True."""
# 48 positive (levels 3-5) + 12 negative (levels 0-2) = 60
votes = [4] * 48 + [1] * 12
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
assert result["positive_pct"] == 80.0
assert result["threshold_met"] is True
assert result["adopted"] is True
def test_just_below_80_pct(self):
"""79.67% positive (47.8/60 ≈ 47/59) → threshold_met = False."""
# 47 positive + 13 negative = 60
votes = [4] * 47 + [1] * 13
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
assert result["positive_pct"] < 80.0
assert result["threshold_met"] is False
assert result["adopted"] is False
def test_min_participants_exactly_met(self):
"""59 participants exactly → min_participants_met = True."""
votes = [5] * 59
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
assert result["min_participants_met"] is True
assert result["adopted"] is True
def test_min_participants_not_met(self):
"""58 participants → min_participants_met = False, even if 100% positive."""
votes = [5] * 58
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
assert result["min_participants_met"] is False
assert result["threshold_met"] is True # 100% > 80%
assert result["adopted"] is False # but quorum not met
def test_neutre_counts_as_positive(self):
"""Level 3 (NEUTRE) counts as positive in the formula."""
votes = [3] * 60
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
assert result["positive_count"] == 60
assert result["positive_pct"] == 100.0
assert result["adopted"] is True
def test_invalid_level_raises(self):
"""Vote level 6 should raise ValueError."""
with pytest.raises(ValueError, match="invalide"):
evaluate_nuanced([6])
def test_negative_level_raises(self):
"""Vote level -1 should raise ValueError."""
with pytest.raises(ValueError, match="invalide"):
evaluate_nuanced([-1])
# ===========================================================================
# 3. ITEM MODIFICATION / DELETION / ADDITION WORKFLOW
# ===========================================================================
class TestItemVersionWorkflow:
"""Test the ItemVersion status lifecycle and apply/reject logic
using the document_service functions with mock database."""
def _make_item(self, item_id=None, text="Original text"):
item = MagicMock()
item.id = item_id or uuid.uuid4()
item.current_text = text
return item
def _make_version(self, version_id=None, item_id=None, proposed_text="New text",
status="proposed"):
version = MagicMock()
version.id = version_id or uuid.uuid4()
version.item_id = item_id or uuid.uuid4()
version.proposed_text = proposed_text
version.status = status
version.rationale = "Test rationale"
return version
@pytest.mark.asyncio
async def test_apply_version_updates_current_text(self):
"""When a version is accepted, item.current_text is updated."""
from app.services.document_service import apply_version
item_id = uuid.uuid4()
version_id = uuid.uuid4()
item = self._make_item(item_id, "Old text")
version = self._make_version(version_id, item_id, "New improved text")
db = AsyncMock()
# Mock item query
item_result = MagicMock()
item_result.scalar_one_or_none.return_value = item
# Mock version query
version_result = MagicMock()
version_result.scalar_one_or_none.return_value = version
# Mock other versions query (no other pending versions)
other_result = MagicMock()
other_result.scalars.return_value = iter([])
db.execute = AsyncMock(side_effect=[item_result, version_result, other_result])
db.commit = AsyncMock()
db.refresh = AsyncMock()
result = await apply_version(item_id, version_id, db)
assert item.current_text == "New improved text"
assert version.status == "accepted"
@pytest.mark.asyncio
async def test_apply_version_rejects_competing_versions(self):
"""When a version is accepted, all other pending versions are rejected."""
from app.services.document_service import apply_version
item_id = uuid.uuid4()
version_id = uuid.uuid4()
item = self._make_item(item_id)
version = self._make_version(version_id, item_id, "Winning text")
other1 = self._make_version(status="proposed")
other2 = self._make_version(status="voting")
db = AsyncMock()
item_result = MagicMock()
item_result.scalar_one_or_none.return_value = item
version_result = MagicMock()
version_result.scalar_one_or_none.return_value = version
other_result = MagicMock()
other_result.scalars.return_value = iter([other1, other2])
db.execute = AsyncMock(side_effect=[item_result, version_result, other_result])
db.commit = AsyncMock()
db.refresh = AsyncMock()
await apply_version(item_id, version_id, db)
assert other1.status == "rejected"
assert other2.status == "rejected"
@pytest.mark.asyncio
async def test_apply_version_wrong_item_raises(self):
"""Applying a version that belongs to a different item should raise."""
from app.services.document_service import apply_version
item_id = uuid.uuid4()
version_id = uuid.uuid4()
item = self._make_item(item_id)
version = self._make_version(version_id, uuid.uuid4(), "Text") # different item_id
db = AsyncMock()
item_result = MagicMock()
item_result.scalar_one_or_none.return_value = item
version_result = MagicMock()
version_result.scalar_one_or_none.return_value = version
db.execute = AsyncMock(side_effect=[item_result, version_result])
with pytest.raises(ValueError, match="n'appartient pas"):
await apply_version(item_id, version_id, db)
@pytest.mark.asyncio
async def test_reject_version_sets_status(self):
"""Rejecting a version sets its status to 'rejected'."""
from app.services.document_service import reject_version
item_id = uuid.uuid4()
version_id = uuid.uuid4()
item = self._make_item(item_id)
version = self._make_version(version_id, item_id, status="proposed")
db = AsyncMock()
item_result = MagicMock()
item_result.scalar_one_or_none.return_value = item
version_result = MagicMock()
version_result.scalar_one_or_none.return_value = version
db.execute = AsyncMock(side_effect=[item_result, version_result])
db.commit = AsyncMock()
db.refresh = AsyncMock()
result = await reject_version(item_id, version_id, db)
assert version.status == "rejected"
@pytest.mark.asyncio
async def test_apply_nonexistent_item_raises(self):
"""Applying a version to a nonexistent item should raise."""
from app.services.document_service import apply_version
db = AsyncMock()
item_result = MagicMock()
item_result.scalar_one_or_none.return_value = None
db.execute = AsyncMock(return_value=item_result)
with pytest.raises(ValueError, match="introuvable"):
await apply_version(uuid.uuid4(), uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_apply_nonexistent_version_raises(self):
"""Applying a nonexistent version should raise."""
from app.services.document_service import apply_version
item_id = uuid.uuid4()
item = self._make_item(item_id)
db = AsyncMock()
item_result = MagicMock()
item_result.scalar_one_or_none.return_value = item
version_result = MagicMock()
version_result.scalar_one_or_none.return_value = None
db.execute = AsyncMock(side_effect=[item_result, version_result])
with pytest.raises(ValueError, match="introuvable"):
await apply_version(item_id, uuid.uuid4(), db)
class TestDocumentSerialization:
"""Test document serialization for IPFS archival with new fields."""
def _make_doc(self, items=None):
from app.services.document_service import serialize_document_to_text
doc = MagicMock()
doc.title = "Acte d'engagement Certification"
doc.version = "1.0.0"
doc.doc_type = "engagement"
doc.status = "active"
doc.description = "Test document"
doc.items = items or []
return doc
def _make_item(self, position, sort_order, title, text, section_tag=None, item_type="clause"):
item = MagicMock()
item.position = position
item.sort_order = sort_order
item.title = title
item.current_text = text
item.item_type = item_type
item.section_tag = section_tag
return item
def test_serialization_includes_section_items(self):
"""Serialization should include items from all sections."""
from app.services.document_service import serialize_document_to_text
items = [
self._make_item("I1", 0, "Preambule", "Texte preambule", "introduction"),
self._make_item("E1", 1, "Clause 1", "Texte clause", "fondamental"),
self._make_item("X1", 2, "Annexe 1", "Texte annexe", "annexe"),
]
doc = self._make_doc(items)
result = serialize_document_to_text(doc)
assert "Preambule" in result
assert "Clause 1" in result
assert "Annexe 1" in result
assert "Texte preambule" in result
def test_serialization_preserves_order(self):
"""Items should appear in sort_order, not insertion order."""
from app.services.document_service import serialize_document_to_text
items = [
self._make_item("X1", 2, "Third", "Text 3"),
self._make_item("I1", 0, "First", "Text 1"),
self._make_item("E1", 1, "Second", "Text 2"),
]
doc = self._make_doc(items)
result = serialize_document_to_text(doc)
first_pos = result.index("First")
second_pos = result.index("Second")
third_pos = result.index("Third")
assert first_pos < second_pos < third_pos
def test_serialization_deterministic(self):
"""Same document serialized twice must produce identical output."""
from app.services.document_service import serialize_document_to_text
items = [self._make_item("E1", 0, "Clause 1", "Text 1")]
doc = self._make_doc(items)
result1 = serialize_document_to_text(doc)
result2 = serialize_document_to_text(doc)
assert result1 == result2
class TestVoteSessionCreationFromItem:
"""Test the logic for creating a VoteSession from a DocumentItem's protocol context.
This simulates what the votes router does when creating a session."""
def test_session_inherits_protocol_params(self):
"""A vote session created for an item should use the item's protocol."""
# Simulate: DocumentItem has voting_protocol_id → VotingProtocol has mode_params
protocol_params = parse_mode_params("D30M50B.1G.2S.1")
# These params should be used in threshold computation
threshold = wot_threshold(
wot_size=7224,
total_votes=120,
majority_pct=protocol_params["majority_pct"],
gradient_exponent=protocol_params["gradient_exponent"],
base_exponent=protocol_params["base_exponent"],
)
assert threshold == 94
def test_different_protocols_different_thresholds(self):
"""Items with different protocols should produce different thresholds."""
# Certification item (standard WoT)
std_params = parse_mode_params("D30M50B.1G.2")
# Forgeron item (Smith protocol)
smith_params = parse_mode_params("D30M50B.1G.2S.1")
# TechComm item
tc_params = parse_mode_params("D30M50B.1G.2T.1")
# WoT thresholds are the same (same M/B/G)
t_std = wot_threshold(wot_size=7224, total_votes=120,
majority_pct=std_params["majority_pct"],
gradient_exponent=std_params["gradient_exponent"])
t_smith = wot_threshold(wot_size=7224, total_votes=120,
majority_pct=smith_params["majority_pct"],
gradient_exponent=smith_params["gradient_exponent"])
assert t_std == t_smith
# But Smith protocol requires additional smith votes
smith_req = smith_threshold(20, smith_params["smith_exponent"])
assert smith_req == 2
# TechComm protocol requires additional techcomm votes
tc_req = techcomm_threshold(5, tc_params["techcomm_exponent"])
assert tc_req == 2
def test_session_duration_from_protocol(self):
"""Session duration should come from protocol's duration_days."""
params = parse_mode_params("D30M50B.1G.2")
assert params["duration_days"] == 30
params_short = parse_mode_params("D7M50B.1G.2")
assert params_short["duration_days"] == 7
class TestRealWorldScenarios:
"""Real-world voting scenarios from Duniter community."""
def test_forgeron_vote_feb_2026(self):
"""Engagement Forgeron v2.0.0 — Feb 2026.
97 pour / 23 contre, WoT 7224, Smith 20.
Mode: D30M50B.1G.2S.1 → threshold=94 → adopted."""
params = parse_mode_params("D30M50B.1G.2S.1")
threshold = wot_threshold(
wot_size=7224, total_votes=120,
majority_pct=params["majority_pct"],
gradient_exponent=params["gradient_exponent"],
)
assert threshold == 94
adopted = 97 >= threshold
assert adopted is True
# Smith criterion
smith_req = smith_threshold(20, params["smith_exponent"])
assert smith_req == 2
# Assume at least 2 smiths voted for → passes
smith_ok = 5 >= smith_req
assert smith_ok is True
def test_forgeron_vote_barely_passes(self):
"""Same scenario but with exactly 94 votes for → still passes."""
threshold = wot_threshold(wot_size=7224, total_votes=117,
majority_pct=50, gradient_exponent=0.2)
# Slightly different total (94+23=117)
assert 94 >= threshold
def test_forgeron_vote_would_fail_at_93(self):
"""93 votes for out of 116 → fails (threshold likely ~93-94)."""
threshold = wot_threshold(wot_size=7224, total_votes=116,
majority_pct=50, gradient_exponent=0.2)
# With 116 total, threshold is still high
# 93 may or may not pass depending on exact computation
if threshold > 93:
assert 93 < threshold
def test_certification_item_low_inertia(self):
"""A certification document item with low inertia (G=0.1) is easier to replace."""
threshold_low = wot_threshold(wot_size=7224, total_votes=120,
majority_pct=50, gradient_exponent=0.1)
threshold_std = wot_threshold(wot_size=7224, total_votes=120,
majority_pct=50, gradient_exponent=0.2)
assert threshold_low < threshold_std
def test_very_high_inertia_item(self):
"""A formule/ordonnancement item with very_high inertia (G=0.6, M=66%)."""
threshold = wot_threshold(wot_size=7224, total_votes=120,
majority_pct=66, gradient_exponent=0.6)
# Should be very close to 120 (near unanimity at such low participation)
assert threshold >= 110, f"Very high inertia should demand near-unanimity, got {threshold}"
def test_full_wot_participation_simple_majority_wins(self):
"""If entire WoT of 7224 votes, simple majority (3613) suffices with standard params."""
threshold = wot_threshold(wot_size=7224, total_votes=7224,
majority_pct=50, gradient_exponent=0.2)
# At full participation: threshold ≈ 0.5 * 7224 = 3612
assert threshold == math.ceil(0.1 ** 7224 + 0.5 * 7224)
assert threshold == 3612
def test_techcomm_vote_cotec_5_members(self):
"""TechComm criterion with 5 members, exponent 0.1 → need 2 votes."""
tc_threshold = techcomm_threshold(5, 0.1)
assert tc_threshold == 2
# 1 TC vote → fails
assert 1 < tc_threshold
# 2 TC votes → passes
assert 2 >= tc_threshold
@@ -0,0 +1,418 @@
"""Tests for document service: apply_version, reject_version, and serialization.
These are pure unit tests that mock the database layer to test
the service logic in isolation.
"""
from __future__ import annotations
import hashlib
import uuid
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
import pytest
sqlalchemy = pytest.importorskip("sqlalchemy", reason="sqlalchemy required for document service tests")
from app.services.document_service import ( # noqa: E402
apply_version,
reject_version,
serialize_document_to_text,
)
# ---------------------------------------------------------------------------
# Helpers: mock objects that behave like SQLAlchemy models
# ---------------------------------------------------------------------------
def _make_item(
item_id: uuid.UUID | None = None,
document_id: uuid.UUID | None = None,
current_text: str = "Texte original",
position: str = "1",
item_type: str = "clause",
title: str | None = None,
sort_order: int = 0,
) -> MagicMock:
"""Create a mock DocumentItem."""
item = MagicMock()
item.id = item_id or uuid.uuid4()
item.document_id = document_id or uuid.uuid4()
item.current_text = current_text
item.position = position
item.item_type = item_type
item.title = title
item.sort_order = sort_order
item.created_at = datetime.now(timezone.utc)
item.updated_at = datetime.now(timezone.utc)
return item
def _make_version(
version_id: uuid.UUID | None = None,
item_id: uuid.UUID | None = None,
proposed_text: str = "Texte propose",
status: str = "proposed",
) -> MagicMock:
"""Create a mock ItemVersion."""
version = MagicMock()
version.id = version_id or uuid.uuid4()
version.item_id = item_id or uuid.uuid4()
version.proposed_text = proposed_text
version.status = status
version.diff_text = None
version.rationale = None
version.decision_id = None
version.proposed_by_id = None
version.created_at = datetime.now(timezone.utc)
return version
def _make_document(
doc_id: uuid.UUID | None = None,
slug: str = "test-doc",
title: str = "Document de test",
doc_type: str = "licence",
version: str = "1.0.0",
status: str = "active",
description: str | None = "Description de test",
items: list | None = None,
) -> MagicMock:
"""Create a mock Document."""
doc = MagicMock()
doc.id = doc_id or uuid.uuid4()
doc.slug = slug
doc.title = title
doc.doc_type = doc_type
doc.version = version
doc.status = status
doc.description = description
doc.ipfs_cid = None
doc.chain_anchor = None
doc.items = items or []
doc.created_at = datetime.now(timezone.utc)
doc.updated_at = datetime.now(timezone.utc)
return doc
def _make_async_db(
item: MagicMock | None = None,
version: MagicMock | None = None,
other_versions: list[MagicMock] | None = None,
) -> AsyncMock:
"""Create a mock async database session.
The mock session's execute() returns appropriate results based on
the query being run. It supports multiple sequential calls:
1st call -> item lookup
2nd call -> version lookup
3rd call (optional) -> other versions lookup (for apply_version)
"""
db = AsyncMock()
call_results = []
# Item result
item_result = MagicMock()
item_result.scalar_one_or_none.return_value = item
call_results.append(item_result)
# Version result
version_result = MagicMock()
version_result.scalar_one_or_none.return_value = version
call_results.append(version_result)
# Other versions result (for apply_version)
if other_versions is not None:
other_result = MagicMock()
other_scalars = MagicMock()
other_scalars.__iter__ = MagicMock(return_value=iter(other_versions))
other_result.scalars.return_value = other_scalars
call_results.append(other_result)
db.execute = AsyncMock(side_effect=call_results)
db.commit = AsyncMock()
db.refresh = AsyncMock()
return db
# ---------------------------------------------------------------------------
# Tests: apply_version
# ---------------------------------------------------------------------------
class TestApplyVersion:
"""Test document_service.apply_version."""
@pytest.mark.asyncio
async def test_apply_version_updates_text(self):
"""Applying a version replaces item's current_text with proposed_text."""
item_id = uuid.uuid4()
version_id = uuid.uuid4()
item = _make_item(item_id=item_id, current_text="Ancien texte")
version = _make_version(
version_id=version_id,
item_id=item_id,
proposed_text="Nouveau texte",
)
db = _make_async_db(item=item, version=version, other_versions=[])
result = await apply_version(item_id, version_id, db)
assert result.current_text == "Nouveau texte"
assert version.status == "accepted"
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_apply_version_rejects_other_pending(self):
"""Applying a version rejects other pending/voting versions."""
item_id = uuid.uuid4()
version_id = uuid.uuid4()
item = _make_item(item_id=item_id)
version = _make_version(version_id=version_id, item_id=item_id)
other_v1 = _make_version(item_id=item_id, status="proposed")
other_v2 = _make_version(item_id=item_id, status="voting")
db = _make_async_db(
item=item,
version=version,
other_versions=[other_v1, other_v2],
)
await apply_version(item_id, version_id, db)
assert other_v1.status == "rejected"
assert other_v2.status == "rejected"
@pytest.mark.asyncio
async def test_apply_version_item_not_found(self):
"""ValueError is raised when item does not exist."""
db = _make_async_db(item=None, version=None)
with pytest.raises(ValueError, match="Element de document introuvable"):
await apply_version(uuid.uuid4(), uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_apply_version_version_not_found(self):
"""ValueError is raised when version does not exist."""
item = _make_item()
db = _make_async_db(item=item, version=None)
with pytest.raises(ValueError, match="Version introuvable"):
await apply_version(item.id, uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_apply_version_wrong_item(self):
"""ValueError is raised when version belongs to a different item."""
item_id = uuid.uuid4()
other_item_id = uuid.uuid4()
version_id = uuid.uuid4()
item = _make_item(item_id=item_id)
version = _make_version(version_id=version_id, item_id=other_item_id)
db = _make_async_db(item=item, version=version)
with pytest.raises(ValueError, match="n'appartient pas"):
await apply_version(item_id, version_id, db)
# ---------------------------------------------------------------------------
# Tests: reject_version
# ---------------------------------------------------------------------------
class TestRejectVersion:
"""Test document_service.reject_version."""
@pytest.mark.asyncio
async def test_reject_version_sets_status(self):
"""Rejecting a version sets its status to 'rejected'."""
item_id = uuid.uuid4()
version_id = uuid.uuid4()
item = _make_item(item_id=item_id)
version = _make_version(
version_id=version_id,
item_id=item_id,
status="proposed",
)
db = _make_async_db(item=item, version=version)
result = await reject_version(item_id, version_id, db)
assert result.status == "rejected"
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_reject_version_item_not_found(self):
"""ValueError is raised when item does not exist."""
db = _make_async_db(item=None, version=None)
with pytest.raises(ValueError, match="Element de document introuvable"):
await reject_version(uuid.uuid4(), uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_reject_version_version_not_found(self):
"""ValueError is raised when version does not exist."""
item = _make_item()
db = _make_async_db(item=item, version=None)
with pytest.raises(ValueError, match="Version introuvable"):
await reject_version(item.id, uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_reject_version_wrong_item(self):
"""ValueError is raised when version belongs to a different item."""
item_id = uuid.uuid4()
other_item_id = uuid.uuid4()
version_id = uuid.uuid4()
item = _make_item(item_id=item_id)
version = _make_version(version_id=version_id, item_id=other_item_id)
db = _make_async_db(item=item, version=version)
with pytest.raises(ValueError, match="n'appartient pas"):
await reject_version(item_id, version_id, db)
# ---------------------------------------------------------------------------
# Tests: serialize_document_to_text
# ---------------------------------------------------------------------------
class TestSerializeDocumentToText:
"""Test document serialization for archival."""
def test_basic_serialization(self):
"""A document with items serializes to the expected text format."""
doc_id = uuid.uuid4()
item1 = _make_item(
document_id=doc_id,
position="1",
title="Preambule",
item_type="preamble",
current_text="Le texte du preambule.",
sort_order=0,
)
item2 = _make_item(
document_id=doc_id,
position="2",
title="Article premier",
item_type="clause",
current_text="Le texte de l'article premier.",
sort_order=1,
)
item3 = _make_item(
document_id=doc_id,
position="2.1",
title=None,
item_type="rule",
current_text="Sous-article sans titre.",
sort_order=2,
)
doc = _make_document(
doc_id=doc_id,
title="Licence G1",
version="2.0.0",
doc_type="licence",
status="active",
description="La licence monetaire de la G1",
items=[item1, item2, item3],
)
text = serialize_document_to_text(doc)
assert "# Licence G1" in text
assert "Version: 2.0.0" in text
assert "Type: licence" in text
assert "Statut: active" in text
assert "Description: La licence monetaire de la G1" in text
# Items
assert "## 1 - Preambule [preamble]" in text
assert "Le texte du preambule." in text
assert "## 2 - Article premier [clause]" in text
assert "Le texte de l'article premier." in text
assert "## 2.1 [rule]" in text
assert "Sous-article sans titre." in text
def test_serialization_ordering(self):
"""Items are serialized in sort_order, not insertion order."""
doc_id = uuid.uuid4()
item_b = _make_item(
document_id=doc_id,
position="2",
title="Second",
current_text="Texte B",
sort_order=1,
)
item_a = _make_item(
document_id=doc_id,
position="1",
title="Premier",
current_text="Texte A",
sort_order=0,
)
# Insert in reverse order
doc = _make_document(doc_id=doc_id, items=[item_b, item_a])
text = serialize_document_to_text(doc)
# "Premier" should appear before "Second"
idx_a = text.index("Texte A")
idx_b = text.index("Texte B")
assert idx_a < idx_b, "Items should be ordered by sort_order"
def test_serialization_without_description(self):
"""Document without description omits that line."""
doc = _make_document(description=None, items=[])
text = serialize_document_to_text(doc)
assert "Description:" not in text
def test_serialization_hash_is_deterministic(self):
"""Same document content produces the same SHA-256 hash."""
doc_id = uuid.uuid4()
item = _make_item(
document_id=doc_id,
position="1",
title="Test",
current_text="Contenu identique",
sort_order=0,
)
doc1 = _make_document(doc_id=doc_id, title="Doc", version="1.0", items=[item])
doc2 = _make_document(doc_id=doc_id, title="Doc", version="1.0", items=[item])
text1 = serialize_document_to_text(doc1)
text2 = serialize_document_to_text(doc2)
hash1 = hashlib.sha256(text1.encode("utf-8")).hexdigest()
hash2 = hashlib.sha256(text2.encode("utf-8")).hexdigest()
assert hash1 == hash2
def test_empty_document(self):
"""A document with no items serializes header only."""
doc = _make_document(items=[])
text = serialize_document_to_text(doc)
assert "# Document de test" in text
assert "Version: 1.0.0" in text
# Should end with just a newline after the header block
lines = text.strip().split("\n")
assert len(lines) >= 4 # title, version, type, status
@@ -0,0 +1,405 @@
"""Integration tests for mandate flows: nomination, lifecycle, assignment, revocation.
Uses a real in-memory SQLite database — no mocks of the DB layer.
Tests the service functions directly to verify interconnected business logic.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import selectinload
from sqlalchemy import select
import app.models # noqa: F401 — registers all models with Base.metadata
from app.database import Base
from app.models.mandate import Mandate, MandateStep
from app.models.user import DuniterIdentity
from app.services.mandate_service import (
advance_mandate,
assign_mandatee,
revoke_mandate,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture
async def engine():
eng = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield eng
await eng.dispose()
@pytest_asyncio.fixture
async def db(engine):
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
yield session
async def _mk_identity(db: AsyncSession, display_name: str = "Alice") -> DuniterIdentity:
ident = DuniterIdentity(
id=uuid.uuid4(),
address=f"5{uuid.uuid4().hex[:46]}",
display_name=display_name,
wot_status="member",
is_smith=False,
is_techcomm=False,
)
db.add(ident)
await db.commit()
await db.refresh(ident)
return ident
async def _mk_mandate(
db: AsyncSession,
*,
mandatee_id: uuid.UUID | None = None,
origin_id: uuid.UUID | None = None,
status: str = "draft",
steps: list[dict] | None = None,
) -> Mandate:
mandate = Mandate(
id=uuid.uuid4(),
title="Mandat test",
mandate_type="functional",
status=status,
mandatee_id=mandatee_id,
origin_id=origin_id,
)
db.add(mandate)
await db.flush()
for i, s in enumerate(steps or []):
step = MandateStep(
id=uuid.uuid4(),
mandate_id=mandate.id,
step_order=i,
step_type=s.get("step_type", "formulation"),
title=s.get("title"),
status=s.get("status", "pending"),
)
db.add(step)
await db.commit()
await db.refresh(mandate)
return mandate
async def _reload(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate:
result = await db.execute(
select(Mandate)
.options(
selectinload(Mandate.steps),
selectinload(Mandate.origin_identity),
selectinload(Mandate.mandatee),
)
.where(Mandate.id == mandate_id)
)
return result.scalar_one()
# ---------------------------------------------------------------------------
# TestMandateOrigin
# ---------------------------------------------------------------------------
class TestMandateOrigin:
"""origin_id must link to a real DuniterIdentity and expose display_name."""
@pytest.mark.asyncio
async def test_origin_id_linked(self, db: AsyncSession):
author = await _mk_identity(db, "Baptiste")
mandate = await _mk_mandate(db, origin_id=author.id)
loaded = await _reload(db, mandate.id)
assert loaded.origin_id == author.id
assert loaded.origin_display_name == "Baptiste"
@pytest.mark.asyncio
async def test_origin_id_optional(self, db: AsyncSession):
mandate = await _mk_mandate(db)
loaded = await _reload(db, mandate.id)
assert loaded.origin_id is None
assert loaded.origin_display_name is None
# ---------------------------------------------------------------------------
# TestAutoNomination
# ---------------------------------------------------------------------------
class TestAutoNomination:
"""Auto-désignation: mandatee = author, no candidacy steps needed."""
@pytest.mark.asyncio
async def test_auto_assign_author(self, db: AsyncSession):
author = await _mk_identity(db, "Constance")
mandate = await _mk_mandate(db, mandatee_id=author.id)
loaded = await _reload(db, mandate.id)
assert loaded.mandatee_id == author.id
assert loaded.mandatee_display_name == "Constance"
@pytest.mark.asyncio
async def test_auto_assign_then_advance_to_active(self, db: AsyncSession):
author = await _mk_identity(db, "David")
mandate = await _mk_mandate(
db,
mandatee_id=author.id,
steps=[
{"step_type": "formulation", "status": "pending"},
{"step_type": "assignment", "status": "pending"},
{"step_type": "reporting", "status": "pending"},
],
)
# First advance: draft → candidacy, activates step 0
result = await advance_mandate(mandate.id, db)
assert result.status == "candidacy"
loaded = await _reload(db, mandate.id)
assert loaded.steps[0].status == "active"
assert loaded.steps[1].status == "pending"
# Second advance: step 0 → completed, step 1 → active
await advance_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.steps[0].status == "completed"
assert loaded.steps[1].status == "active"
# ---------------------------------------------------------------------------
# TestMandateAssign
# ---------------------------------------------------------------------------
class TestMandateAssign:
"""assign_mandatee service: proper UUID lookup, display_name populated."""
@pytest.mark.asyncio
async def test_assign_sets_mandatee_and_starts_at(self, db: AsyncSession):
identity = await _mk_identity(db, "Elodie")
mandate = await _mk_mandate(db, status="active")
await assign_mandatee(mandate.id, identity.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.mandatee_id == identity.id
assert loaded.starts_at is not None
assert loaded.mandatee_display_name == "Elodie"
@pytest.mark.asyncio
async def test_assign_unknown_identity_raises(self, db: AsyncSession):
mandate = await _mk_mandate(db, status="active")
with pytest.raises(ValueError, match="Identite Duniter introuvable"):
await assign_mandatee(mandate.id, uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_assign_completed_mandate_raises(self, db: AsyncSession):
identity = await _mk_identity(db, "Fabien")
mandate = await _mk_mandate(db, status="completed")
with pytest.raises(ValueError, match="statut terminal"):
await assign_mandatee(mandate.id, identity.id, db)
@pytest.mark.asyncio
async def test_reassign_replaces_mandatee(self, db: AsyncSession):
first = await _mk_identity(db, "Gilles")
second = await _mk_identity(db, "Hélène")
mandate = await _mk_mandate(db, mandatee_id=first.id, status="active")
await assign_mandatee(mandate.id, second.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.mandatee_id == second.id
# ---------------------------------------------------------------------------
# TestMandateLifecycle
# ---------------------------------------------------------------------------
class TestMandateLifecycle:
"""advance_mandate: full lifecycle with and without steps."""
@pytest.mark.asyncio
async def test_full_lifecycle_no_steps(self, db: AsyncSession):
mandate = await _mk_mandate(db)
statuses = [mandate.status]
for _ in range(10):
m = await advance_mandate(mandate.id, db)
statuses.append(m.status)
if m.status == "completed":
break
assert statuses == ["draft", "candidacy", "voting", "active", "reporting", "completed"]
@pytest.mark.asyncio
async def test_steps_activate_in_order(self, db: AsyncSession):
mandate = await _mk_mandate(
db,
steps=[
{"step_type": "formulation", "status": "pending"},
{"step_type": "candidacy", "status": "pending"},
{"step_type": "vote", "status": "pending"},
],
)
# Advance 1: activates step 0, moves to candidacy
await advance_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.status == "candidacy"
assert loaded.steps[0].status == "active"
assert loaded.steps[1].status == "pending"
# Advance 2: step 0 → completed, step 1 → active
await advance_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.steps[0].status == "completed"
assert loaded.steps[1].status == "active"
# Advance 3: step 1 → completed, step 2 → active
await advance_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.steps[1].status == "completed"
assert loaded.steps[2].status == "active"
# Advance 4: step 2 → completed, no more pending → advance mandate status
await advance_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.steps[2].status == "completed"
assert loaded.status == "voting"
@pytest.mark.asyncio
async def test_advance_terminal_raises(self, db: AsyncSession):
for terminal in ("completed", "revoked"):
mandate = await _mk_mandate(db, status=terminal)
with pytest.raises(ValueError, match="statut terminal"):
await advance_mandate(mandate.id, db)
# ---------------------------------------------------------------------------
# TestMandateRevocation
# ---------------------------------------------------------------------------
class TestMandateRevocation:
"""revoke_mandate: active/pending steps cancelled, completed steps preserved."""
@pytest.mark.asyncio
async def test_revoke_cancels_active_and_pending(self, db: AsyncSession):
mandate = await _mk_mandate(
db,
status="active",
steps=[
{"step_type": "formulation", "status": "completed"},
{"step_type": "assignment", "status": "active"},
{"step_type": "reporting", "status": "pending"},
],
)
await revoke_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.status == "revoked"
assert loaded.ends_at is not None
assert loaded.steps[0].status == "completed"
assert loaded.steps[1].status == "cancelled"
assert loaded.steps[2].status == "cancelled"
@pytest.mark.asyncio
async def test_revoke_sets_ends_at(self, db: AsyncSession):
mandate = await _mk_mandate(db, status="draft")
await revoke_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.ends_at is not None
@pytest.mark.asyncio
async def test_revoke_already_revoked_raises(self, db: AsyncSession):
mandate = await _mk_mandate(db, status="revoked")
with pytest.raises(ValueError, match="statut terminal"):
await revoke_mandate(mandate.id, db)
# ---------------------------------------------------------------------------
# TestNominationInteractions
# ---------------------------------------------------------------------------
class TestNominationInteractions:
"""Cross-process: nomination + assignment + lifecycle are consistent."""
@pytest.mark.asyncio
async def test_assign_then_advance_full_cycle(self, db: AsyncSession):
"""Assigning a mandatee then running the full lifecycle completes cleanly."""
mandatee = await _mk_identity(db, "Isabelle")
mandate = await _mk_mandate(
db,
steps=[
{"step_type": "formulation", "status": "pending"},
{"step_type": "assignment", "status": "pending"},
{"step_type": "completion", "status": "pending"},
],
)
# Assign before starting
await assign_mandatee(mandate.id, mandatee.id, db)
# Run through all steps
for _ in range(5):
m = await advance_mandate(mandate.id, db)
if m.status in ("completed", "revoked"):
break
loaded = await _reload(db, mandate.id)
# All steps completed, mandate advanced beyond steps
assert all(s.status == "completed" for s in loaded.steps)
assert loaded.mandatee_id == mandatee.id
@pytest.mark.asyncio
async def test_revoke_after_assign_preserves_mandatee_id(self, db: AsyncSession):
"""Revoking a mandate keeps mandatee_id (for audit trail)."""
mandatee = await _mk_identity(db, "Jacques")
mandate = await _mk_mandate(db, mandatee_id=mandatee.id, status="active")
await revoke_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.status == "revoked"
assert loaded.mandatee_id == mandatee.id
@pytest.mark.asyncio
async def test_origin_and_mandatee_can_differ(self, db: AsyncSession):
"""The person who proposed the mandate (origin) is different from the mandatee."""
proposer = await _mk_identity(db, "Kim")
mandatee = await _mk_identity(db, "Laurent")
mandate = await _mk_mandate(db, origin_id=proposer.id)
await assign_mandatee(mandate.id, mandatee.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.origin_id == proposer.id
assert loaded.mandatee_id == mandatee.id
assert loaded.origin_display_name == "Kim"
assert loaded.mandatee_display_name == "Laurent"
+655
View File
@@ -0,0 +1,655 @@
"""Tests for mandate service: advance_mandate, assign_mandatee, revoke_mandate, create_vote_session_for_step.
These are pure unit tests that mock the database layer to test
the service logic in isolation.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
import pytest
sqlalchemy = pytest.importorskip("sqlalchemy", reason="sqlalchemy required for mandate service tests")
from app.services.mandate_service import ( # noqa: E402
advance_mandate,
assign_mandatee,
create_vote_session_for_step,
revoke_mandate,
)
# ---------------------------------------------------------------------------
# Helpers: mock objects that behave like SQLAlchemy models
# ---------------------------------------------------------------------------
def _make_step(
step_id: uuid.UUID | None = None,
mandate_id: uuid.UUID | None = None,
step_order: int = 0,
step_type: str = "formulation",
status: str = "pending",
vote_session_id: uuid.UUID | None = None,
) -> MagicMock:
"""Create a mock MandateStep."""
step = MagicMock()
step.id = step_id or uuid.uuid4()
step.mandate_id = mandate_id or uuid.uuid4()
step.step_order = step_order
step.step_type = step_type
step.title = None
step.description = None
step.status = status
step.vote_session_id = vote_session_id
step.outcome = None
step.created_at = datetime.now(timezone.utc)
return step
def _make_mandate(
mandate_id: uuid.UUID | None = None,
title: str = "Mandat de test",
mandate_type: str = "techcomm",
status: str = "draft",
mandatee_id: uuid.UUID | None = None,
decision_id: uuid.UUID | None = None,
steps: list | None = None,
) -> MagicMock:
"""Create a mock Mandate."""
mandate = MagicMock()
mandate.id = mandate_id or uuid.uuid4()
mandate.title = title
mandate.description = None
mandate.mandate_type = mandate_type
mandate.status = status
mandate.mandatee_id = mandatee_id
mandate.decision_id = decision_id
mandate.starts_at = None
mandate.ends_at = None
mandate.created_at = datetime.now(timezone.utc)
mandate.updated_at = datetime.now(timezone.utc)
mandate.steps = steps or []
return mandate
def _make_identity(identity_id: uuid.UUID | None = None) -> MagicMock:
"""Create a mock DuniterIdentity."""
identity = MagicMock()
identity.id = identity_id or uuid.uuid4()
identity.address = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"
identity.username = "Alice"
return identity
def _make_protocol(
protocol_id: uuid.UUID | None = None,
duration_days: int = 30,
) -> MagicMock:
"""Create a mock VotingProtocol with formula_config."""
protocol = MagicMock()
protocol.id = protocol_id or uuid.uuid4()
protocol.name = "Protocole standard"
protocol.vote_type = "binary"
protocol.formula_config = MagicMock()
protocol.formula_config.duration_days = duration_days
return protocol
def _make_decision_mock(
decision_id: uuid.UUID | None = None,
voting_protocol_id: uuid.UUID | None = None,
) -> MagicMock:
"""Create a mock Decision for the vote session lookup."""
decision = MagicMock()
decision.id = decision_id or uuid.uuid4()
decision.voting_protocol_id = voting_protocol_id
return decision
def _make_db_for_advance(mandate: MagicMock | None) -> AsyncMock:
"""Create a mock async database session for advance_mandate."""
db = AsyncMock()
result = MagicMock()
result.scalar_one_or_none.return_value = mandate
db.execute = AsyncMock(return_value=result)
db.commit = AsyncMock()
db.refresh = AsyncMock()
return db
def _make_db_for_assign(
mandate: MagicMock | None,
identity: MagicMock | None = None,
) -> AsyncMock:
"""Create a mock async database session for assign_mandatee.
Sequential execute calls:
1st -> mandate lookup
2nd -> identity lookup
"""
db = AsyncMock()
call_results = []
# Mandate result
mandate_result = MagicMock()
mandate_result.scalar_one_or_none.return_value = mandate
call_results.append(mandate_result)
# Identity result
if identity is not None or mandate is not None:
identity_result = MagicMock()
identity_result.scalar_one_or_none.return_value = identity
call_results.append(identity_result)
db.execute = AsyncMock(side_effect=call_results)
db.commit = AsyncMock()
db.refresh = AsyncMock()
return db
def _make_db_for_revoke(mandate: MagicMock | None) -> AsyncMock:
"""Create a mock async database session for revoke_mandate."""
db = AsyncMock()
result = MagicMock()
result.scalar_one_or_none.return_value = mandate
db.execute = AsyncMock(return_value=result)
db.commit = AsyncMock()
db.refresh = AsyncMock()
return db
def _make_db_for_vote_session(
mandate: MagicMock | None,
decision: MagicMock | None = None,
protocol: MagicMock | None = None,
) -> AsyncMock:
"""Create a mock async database session for create_vote_session_for_step.
Sequential execute calls:
1st -> mandate lookup
2nd -> decision lookup
3rd -> protocol lookup
"""
db = AsyncMock()
call_results = []
# Mandate result
mandate_result = MagicMock()
mandate_result.scalar_one_or_none.return_value = mandate
call_results.append(mandate_result)
# Decision result
if decision is not None:
decision_result = MagicMock()
decision_result.scalar_one_or_none.return_value = decision
call_results.append(decision_result)
# Protocol result
if protocol is not None:
proto_result = MagicMock()
proto_result.scalar_one_or_none.return_value = protocol
call_results.append(proto_result)
db.execute = AsyncMock(side_effect=call_results)
db.commit = AsyncMock()
db.refresh = AsyncMock()
db.flush = AsyncMock()
db.add = MagicMock()
return db
# ---------------------------------------------------------------------------
# Tests: advance_mandate
# ---------------------------------------------------------------------------
class TestAdvanceMandate:
"""Test mandate_service.advance_mandate."""
@pytest.mark.asyncio
async def test_advance_from_draft_activates_first_step(self):
"""Advancing a draft mandate with pending steps activates the first one
and moves mandate to candidacy."""
mandate_id = uuid.uuid4()
step1 = _make_step(mandate_id=mandate_id, step_order=0, status="pending")
step2 = _make_step(mandate_id=mandate_id, step_order=1, status="pending")
mandate = _make_mandate(
mandate_id=mandate_id,
status="draft",
steps=[step1, step2],
)
db = _make_db_for_advance(mandate)
result = await advance_mandate(mandate_id, db)
assert step1.status == "active"
assert step2.status == "pending"
assert mandate.status == "candidacy"
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_advance_completes_active_and_activates_next(self):
"""Advancing completes the current active step and activates the next pending one."""
mandate_id = uuid.uuid4()
step1 = _make_step(mandate_id=mandate_id, step_order=0, status="active")
step2 = _make_step(mandate_id=mandate_id, step_order=1, status="pending")
mandate = _make_mandate(
mandate_id=mandate_id,
status="candidacy",
steps=[step1, step2],
)
db = _make_db_for_advance(mandate)
result = await advance_mandate(mandate_id, db)
assert step1.status == "completed"
assert step2.status == "active"
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_advance_last_step_advances_status(self):
"""When the last active step is completed with no more pending steps,
the mandate status advances."""
mandate_id = uuid.uuid4()
step1 = _make_step(mandate_id=mandate_id, step_order=0, status="completed")
step2 = _make_step(mandate_id=mandate_id, step_order=1, status="active")
mandate = _make_mandate(
mandate_id=mandate_id,
status="candidacy",
steps=[step1, step2],
)
db = _make_db_for_advance(mandate)
result = await advance_mandate(mandate_id, db)
assert step2.status == "completed"
assert mandate.status == "voting"
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_advance_full_progression(self):
"""A mandate with no steps can progress through all statuses."""
mandate_id = uuid.uuid4()
mandate = _make_mandate(
mandate_id=mandate_id,
status="draft",
steps=[],
)
statuses_seen = [mandate.status]
# Advance through all states until completed
for _ in range(10): # safety limit
db = _make_db_for_advance(mandate)
await advance_mandate(mandate_id, db)
statuses_seen.append(mandate.status)
if mandate.status == "completed":
break
assert statuses_seen == [
"draft",
"candidacy",
"voting",
"active",
"reporting",
"completed",
]
@pytest.mark.asyncio
async def test_advance_completed_raises_error(self):
"""Advancing a completed mandate raises ValueError."""
mandate_id = uuid.uuid4()
mandate = _make_mandate(
mandate_id=mandate_id,
status="completed",
steps=[],
)
db = _make_db_for_advance(mandate)
with pytest.raises(ValueError, match="statut terminal"):
await advance_mandate(mandate_id, db)
@pytest.mark.asyncio
async def test_advance_revoked_raises_error(self):
"""Advancing a revoked mandate raises ValueError."""
mandate_id = uuid.uuid4()
mandate = _make_mandate(
mandate_id=mandate_id,
status="revoked",
steps=[],
)
db = _make_db_for_advance(mandate)
with pytest.raises(ValueError, match="statut terminal"):
await advance_mandate(mandate_id, db)
@pytest.mark.asyncio
async def test_advance_not_found_raises_error(self):
"""Advancing a non-existent mandate raises ValueError."""
db = _make_db_for_advance(None)
with pytest.raises(ValueError, match="Mandat introuvable"):
await advance_mandate(uuid.uuid4(), db)
# ---------------------------------------------------------------------------
# Tests: assign_mandatee
# ---------------------------------------------------------------------------
class TestAssignMandatee:
"""Test mandate_service.assign_mandatee."""
@pytest.mark.asyncio
async def test_assign_success(self):
"""Assigning a valid identity to an active mandate succeeds."""
mandate_id = uuid.uuid4()
mandatee_id = uuid.uuid4()
mandate = _make_mandate(
mandate_id=mandate_id,
status="active",
)
identity = _make_identity(identity_id=mandatee_id)
db = _make_db_for_assign(mandate, identity)
result = await assign_mandatee(mandate_id, mandatee_id, db)
assert mandate.mandatee_id == mandatee_id
assert mandate.starts_at is not None
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_assign_draft_mandate(self):
"""Assigning to a draft mandate is allowed."""
mandate_id = uuid.uuid4()
mandatee_id = uuid.uuid4()
mandate = _make_mandate(
mandate_id=mandate_id,
status="draft",
)
identity = _make_identity(identity_id=mandatee_id)
db = _make_db_for_assign(mandate, identity)
result = await assign_mandatee(mandate_id, mandatee_id, db)
assert mandate.mandatee_id == mandatee_id
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_assign_not_found_raises_error(self):
"""Assigning to a non-existent mandate raises ValueError."""
db = _make_db_for_assign(None)
with pytest.raises(ValueError, match="Mandat introuvable"):
await assign_mandatee(uuid.uuid4(), uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_assign_completed_raises_error(self):
"""Assigning to a completed mandate raises ValueError."""
mandate_id = uuid.uuid4()
mandate = _make_mandate(
mandate_id=mandate_id,
status="completed",
)
db = _make_db_for_assign(mandate)
with pytest.raises(ValueError, match="statut terminal"):
await assign_mandatee(mandate_id, uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_assign_revoked_raises_error(self):
"""Assigning to a revoked mandate raises ValueError."""
mandate_id = uuid.uuid4()
mandate = _make_mandate(
mandate_id=mandate_id,
status="revoked",
)
db = _make_db_for_assign(mandate)
with pytest.raises(ValueError, match="statut terminal"):
await assign_mandatee(mandate_id, uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_assign_identity_not_found_raises_error(self):
"""Assigning a non-existent identity raises ValueError."""
mandate_id = uuid.uuid4()
mandate = _make_mandate(
mandate_id=mandate_id,
status="active",
)
db = _make_db_for_assign(mandate, identity=None)
with pytest.raises(ValueError, match="Identite Duniter introuvable"):
await assign_mandatee(mandate_id, uuid.uuid4(), db)
# ---------------------------------------------------------------------------
# Tests: revoke_mandate
# ---------------------------------------------------------------------------
class TestRevokeMandate:
"""Test mandate_service.revoke_mandate."""
@pytest.mark.asyncio
async def test_revoke_active_mandate(self):
"""Revoking an active mandate sets status to revoked and cancels steps."""
mandate_id = uuid.uuid4()
step1 = _make_step(mandate_id=mandate_id, step_order=0, status="completed")
step2 = _make_step(mandate_id=mandate_id, step_order=1, status="active")
step3 = _make_step(mandate_id=mandate_id, step_order=2, status="pending")
mandate = _make_mandate(
mandate_id=mandate_id,
status="active",
steps=[step1, step2, step3],
)
db = _make_db_for_revoke(mandate)
result = await revoke_mandate(mandate_id, db)
assert mandate.status == "revoked"
assert mandate.ends_at is not None
# Completed steps stay completed
assert step1.status == "completed"
# Active and pending steps are cancelled
assert step2.status == "cancelled"
assert step3.status == "cancelled"
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_revoke_draft_mandate(self):
"""Revoking a draft mandate is allowed."""
mandate_id = uuid.uuid4()
mandate = _make_mandate(
mandate_id=mandate_id,
status="draft",
steps=[],
)
db = _make_db_for_revoke(mandate)
result = await revoke_mandate(mandate_id, db)
assert mandate.status == "revoked"
assert mandate.ends_at is not None
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_revoke_not_found_raises_error(self):
"""Revoking a non-existent mandate raises ValueError."""
db = _make_db_for_revoke(None)
with pytest.raises(ValueError, match="Mandat introuvable"):
await revoke_mandate(uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_revoke_completed_raises_error(self):
"""Revoking an already completed mandate raises ValueError."""
mandate_id = uuid.uuid4()
mandate = _make_mandate(
mandate_id=mandate_id,
status="completed",
)
db = _make_db_for_revoke(mandate)
with pytest.raises(ValueError, match="statut terminal"):
await revoke_mandate(mandate_id, db)
@pytest.mark.asyncio
async def test_revoke_already_revoked_raises_error(self):
"""Revoking an already revoked mandate raises ValueError."""
mandate_id = uuid.uuid4()
mandate = _make_mandate(
mandate_id=mandate_id,
status="revoked",
)
db = _make_db_for_revoke(mandate)
with pytest.raises(ValueError, match="statut terminal"):
await revoke_mandate(mandate_id, db)
# ---------------------------------------------------------------------------
# Tests: create_vote_session_for_step
# ---------------------------------------------------------------------------
class TestCreateVoteSessionForStep:
"""Test mandate_service.create_vote_session_for_step."""
@pytest.mark.asyncio
async def test_create_vote_session_success(self):
"""A vote session is created and linked to the mandate step."""
mandate_id = uuid.uuid4()
step_id = uuid.uuid4()
decision_id = uuid.uuid4()
protocol_id = uuid.uuid4()
step = _make_step(
step_id=step_id,
mandate_id=mandate_id,
step_type="vote",
status="active",
vote_session_id=None,
)
mandate = _make_mandate(
mandate_id=mandate_id,
status="voting",
decision_id=decision_id,
steps=[step],
)
decision = _make_decision_mock(
decision_id=decision_id,
voting_protocol_id=protocol_id,
)
protocol = _make_protocol(protocol_id=protocol_id, duration_days=14)
db = _make_db_for_vote_session(mandate, decision, protocol)
result = await create_vote_session_for_step(mandate_id, step_id, db)
# The step should now have a vote_session_id set
assert step.vote_session_id is not None
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_create_vote_session_mandate_not_found(self):
"""ValueError when mandate does not exist."""
db = _make_db_for_vote_session(None)
with pytest.raises(ValueError, match="Mandat introuvable"):
await create_vote_session_for_step(uuid.uuid4(), uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_create_vote_session_step_not_found(self):
"""ValueError when step does not belong to the mandate."""
mandate_id = uuid.uuid4()
mandate = _make_mandate(mandate_id=mandate_id, decision_id=uuid.uuid4(), steps=[])
db = _make_db_for_vote_session(mandate)
with pytest.raises(ValueError, match="Etape introuvable"):
await create_vote_session_for_step(mandate_id, uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_create_vote_session_step_already_has_session(self):
"""ValueError when step already has a vote session."""
mandate_id = uuid.uuid4()
step_id = uuid.uuid4()
existing_session_id = uuid.uuid4()
step = _make_step(
step_id=step_id,
mandate_id=mandate_id,
vote_session_id=existing_session_id,
)
mandate = _make_mandate(
mandate_id=mandate_id,
decision_id=uuid.uuid4(),
steps=[step],
)
db = _make_db_for_vote_session(mandate)
with pytest.raises(ValueError, match="deja une session de vote"):
await create_vote_session_for_step(mandate_id, step_id, db)
@pytest.mark.asyncio
async def test_create_vote_session_no_decision(self):
"""ValueError when mandate has no linked decision."""
mandate_id = uuid.uuid4()
step_id = uuid.uuid4()
step = _make_step(
step_id=step_id,
mandate_id=mandate_id,
vote_session_id=None,
)
mandate = _make_mandate(
mandate_id=mandate_id,
decision_id=None,
steps=[step],
)
db = _make_db_for_vote_session(mandate)
with pytest.raises(ValueError, match="Aucune decision liee"):
await create_vote_session_for_step(mandate_id, step_id, db)
@@ -0,0 +1,185 @@
"""Tests for middleware stack: CORS headers, rate limiting, dev auth flow.
Critical invariants:
- ALL responses (including 429) must carry CORS headers when origin is allowed
- Dev login flow must survive repeated logins without hitting rate limit
- OPTIONS preflight must never be rate-limited
Note: each test uses a unique X-Forwarded-For IP to isolate rate limit counters,
since the rate limiter is in-memory and shared across the app instance.
"""
from __future__ import annotations
import pytest
from httpx import ASGITransport, AsyncClient
import app.models # noqa: F401 — registers all models with Base.metadata before create_all
from app.database import init_db
from app.main import app
@pytest.fixture(scope="module", autouse=True)
async def _create_tables():
"""Create DB tables once for this module.
ASGITransport does not trigger the FastAPI lifespan, so init_db() would
never run. Tests that hit endpoints backed by the DB need the tables to
exist beforehand.
"""
await init_db()
ORIGIN = "http://localhost:3002"
CHALLENGE_URL = "/api/v1/auth/challenge"
VERIFY_URL = "/api/v1/auth/verify"
ME_URL = "/api/v1/auth/me"
DEV_ADDRESS = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"
# ---------------------------------------------------------------------------
# INVARIANT 1: 429 responses must include CORS headers
# Without this, the browser sees "Failed to fetch" instead of "Too Many Requests"
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_rate_limited_response_has_cors_headers():
"""A 429 from the rate limiter must still carry Access-Control-Allow-Origin.
Root cause of the "no response / Failed to fetch" bug: the rate limiter
sits outside CORS in the middleware stack, so its 429 responses have no
CORS headers and the browser discards them as network errors.
"""
# dev auth limit = 60/min (RATE_LIMIT_DEFAULT), prod = 10/min (RATE_LIMIT_AUTH)
# Send 65 requests to guarantee 429 regardless of environment.
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
last_response = None
for _ in range(65):
resp = await client.post(
CHALLENGE_URL,
json={"address": DEV_ADDRESS},
headers={"Origin": ORIGIN, "X-Forwarded-For": "10.0.1.1"},
)
last_response = resp
if resp.status_code == 429:
break
assert last_response is not None
assert last_response.status_code == 429, (
"Expected 429 after exceeding auth rate limit"
)
assert "access-control-allow-origin" in last_response.headers, (
"429 response must include CORS headers so the browser can read the error"
)
assert last_response.headers["access-control-allow-origin"] == ORIGIN
# ---------------------------------------------------------------------------
# INVARIANT 2: OPTIONS preflight must never be rate-limited
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_options_preflight_never_rate_limited():
"""OPTIONS requests must pass through regardless of request count.
Browsers send a preflight before every cross-origin POST with custom headers.
A 429 on OPTIONS prevents the real request from ever being sent.
"""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
for i in range(20):
resp = await client.options(
CHALLENGE_URL,
headers={
"Origin": ORIGIN,
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "content-type",
"X-Forwarded-For": "10.0.2.1",
},
)
assert resp.status_code != 429, (
f"OPTIONS request #{i + 1} was rate-limited (429) — preflights must never be blocked"
)
# ---------------------------------------------------------------------------
# INVARIANT 3: Dev login flow must survive ≥ 10 consecutive logins
# (challenge + verify cycle, dev profile bypass)
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_dev_login_survives_repeated_cycles():
"""Complete login cycle (challenge → verify) must work ≥ 10 times in a row.
In dev mode, the developer disconnects and reconnects frequently.
With auth rate limit = 10/min, the 6th challenge request would be blocked.
Dev mode must use a higher limit (≥ 60/min) to prevent login lockout.
"""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
tokens = []
for i in range(10):
# Step 1: get challenge
ch_resp = await client.post(
CHALLENGE_URL,
json={"address": DEV_ADDRESS},
headers={"Origin": ORIGIN, "X-Forwarded-For": "10.0.3.1"},
)
assert ch_resp.status_code == 200, (
f"Login cycle #{i + 1}: challenge returned {ch_resp.status_code} — "
f"rate limit likely hit. Dev mode requires RATE_LIMIT_AUTH ≥ 60/min."
)
challenge = ch_resp.json()["challenge"]
# Step 2: verify (dev bypass — any signature accepted for dev addresses)
v_resp = await client.post(
VERIFY_URL,
json={
"address": DEV_ADDRESS,
"challenge": challenge,
"signature": "0x" + "ab" * 64,
},
headers={"Origin": ORIGIN, "X-Forwarded-For": "10.0.3.1"},
)
assert v_resp.status_code == 200, (
f"Login cycle #{i + 1}: verify returned {v_resp.status_code}"
)
tokens.append(v_resp.json()["token"])
assert len(tokens) == 10, "Expected 10 successful login cycles"
assert len(set(tokens)) == 10, "Each login cycle must produce a unique token"
# ---------------------------------------------------------------------------
# INVARIANT 4: /auth/me OPTIONS preflight must return CORS headers
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_auth_me_options_preflight_returns_cors():
"""OPTIONS preflight for /auth/me must return 200 with CORS headers.
This was the root cause of the session-lost-on-reload bug:
repeated /auth/me calls would exhaust the auth rate limit,
the 429 OPTIONS response had no CORS headers,
and the browser threw 'Failed to fetch'.
"""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.options(
ME_URL,
headers={
"Origin": ORIGIN,
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": "authorization",
"X-Forwarded-For": "10.0.4.1",
},
)
assert resp.status_code in (200, 204), (
f"OPTIONS /auth/me returned {resp.status_code}"
)
assert "access-control-allow-origin" in resp.headers
@@ -0,0 +1,75 @@
"""Tests for mode-params string parser."""
from app.engine.mode_params import parse_mode_params
class TestParseModeParams:
"""Parse compact parameter strings into structured dicts."""
def test_standard_params(self):
"""D30M50B.1G.2 => standard Licence G1 params."""
result = parse_mode_params("D30M50B.1G.2")
assert result["duration_days"] == 30
assert result["majority_pct"] == 50
assert result["base_exponent"] == 0.1
assert result["gradient_exponent"] == 0.2
# Optional criteria absent
assert result["smith_exponent"] is None
assert result["techcomm_exponent"] is None
def test_with_smith_exponent(self):
"""D30M50B.1G.2S.1 => standard + smith_exponent=0.1."""
result = parse_mode_params("D30M50B.1G.2S.1")
assert result["duration_days"] == 30
assert result["majority_pct"] == 50
assert result["base_exponent"] == 0.1
assert result["gradient_exponent"] == 0.2
assert result["smith_exponent"] == 0.1
assert result["techcomm_exponent"] is None
def test_with_techcomm_exponent(self):
"""D30M50B.1G.2T.1 => standard + techcomm_exponent=0.1."""
result = parse_mode_params("D30M50B.1G.2T.1")
assert result["duration_days"] == 30
assert result["majority_pct"] == 50
assert result["base_exponent"] == 0.1
assert result["gradient_exponent"] == 0.2
assert result["smith_exponent"] is None
assert result["techcomm_exponent"] == 0.1
def test_full_params_with_constant(self):
"""D30M50B1G.5C10 => integer base, gradient=0.5, constant=10."""
result = parse_mode_params("D30M50B1G.5C10")
assert result["duration_days"] == 30
assert result["majority_pct"] == 50
assert result["base_exponent"] == 1.0
assert result["gradient_exponent"] == 0.5
assert result["constant_base"] == 10.0
def test_empty_string_defaults(self):
"""Empty string returns all defaults."""
result = parse_mode_params("")
assert result["duration_days"] == 30
assert result["majority_pct"] == 50
assert result["base_exponent"] == 0.1
assert result["gradient_exponent"] == 0.2
assert result["constant_base"] == 0.0
assert result["smith_exponent"] is None
assert result["techcomm_exponent"] is None
assert result["ratio_multiplier"] is None
assert result["is_ratio_mode"] is False
def test_whitespace_only_returns_defaults(self):
"""Whitespace-only string treated as empty."""
result = parse_mode_params(" ")
assert result["duration_days"] == 30
def test_roundtrip_consistency(self):
"""Parsing a standard string then re-checking all keys."""
result = parse_mode_params("D30M50B.1G.2")
expected_keys = {
"duration_days", "majority_pct", "base_exponent",
"gradient_exponent", "constant_base", "smith_exponent",
"techcomm_exponent", "ratio_multiplier", "is_ratio_mode",
}
assert set(result.keys()) == expected_keys
+120
View File
@@ -0,0 +1,120 @@
"""Tests for six-level nuanced vote evaluation.
Levels: 0-CONTRE, 1-PAS DU TOUT, 2-PAS D'ACCORD, 3-NEUTRE, 4-D'ACCORD, 5-TOUT A FAIT
Positive = levels 3 + 4 + 5
Adoption requires: positive_pct >= threshold (80%) AND total >= min_participants (59).
"""
import pytest
from app.engine.nuanced_vote import evaluate_nuanced
class TestNuancedVoteAdoption:
"""Cases where the vote should be adopted."""
def test_59_positive_10_negative_adopted(self):
"""59 positive (levels 3-5) + 10 negative = 69 total.
positive_pct = 59/69 ~ 85.5% >= 80% and 69 >= 59 => adopted.
"""
votes = [5] * 20 + [4] * 20 + [3] * 19 + [2] * 5 + [1] * 3 + [0] * 2
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
assert result["total"] == 69
assert result["positive_count"] == 59
assert result["positive_pct"] == pytest.approx(85.51, abs=0.1)
assert result["threshold_met"] is True
assert result["min_participants_met"] is True
assert result["adopted"] is True
def test_all_tout_a_fait_adopted(self):
"""All 59 voters at level 5 => 100% positive, adopted."""
votes = [5] * 59
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
assert result["total"] == 59
assert result["positive_count"] == 59
assert result["positive_pct"] == 100.0
assert result["adopted"] is True
class TestNuancedVoteRejection:
"""Cases where the vote should be rejected."""
def test_40_positive_30_negative_rejected(self):
"""40 positive + 30 negative = 70 total.
positive_pct = 40/70 ~ 57.14% < 80% => threshold not met.
"""
votes = [5] * 15 + [4] * 15 + [3] * 10 + [2] * 10 + [1] * 10 + [0] * 10
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
assert result["total"] == 70
assert result["positive_count"] == 40
assert result["positive_pct"] == pytest.approx(57.14, abs=0.1)
assert result["threshold_met"] is False
assert result["min_participants_met"] is True # 70 >= 59
assert result["adopted"] is False
def test_min_participants_not_met(self):
"""50 positive + 5 negative = 55 total < 59 min_participants.
Even though 50/55 ~ 90.9% > 80%, adoption fails on min_participants.
"""
votes = [5] * 30 + [4] * 10 + [3] * 10 + [1] * 3 + [0] * 2
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
assert result["total"] == 55
assert result["positive_count"] == 50
assert result["positive_pct"] > 80
assert result["threshold_met"] is True
assert result["min_participants_met"] is False
assert result["adopted"] is False
class TestNuancedVoteEdgeCases:
"""Edge cases and exact boundary conditions."""
def test_exact_threshold_80_percent(self):
"""Exactly 80% positive votes should pass the threshold."""
# 80 positive out of 100 = exactly 80%
votes = [5] * 40 + [4] * 20 + [3] * 20 + [2] * 10 + [1] * 5 + [0] * 5
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
assert result["total"] == 100
assert result["positive_count"] == 80
assert result["positive_pct"] == 80.0
assert result["threshold_met"] is True
assert result["min_participants_met"] is True
assert result["adopted"] is True
def test_just_below_threshold(self):
"""79 positive out of 100 = 79% < 80% => rejected."""
votes = [5] * 39 + [4] * 20 + [3] * 20 + [2] * 11 + [1] * 5 + [0] * 5
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
assert result["total"] == 100
assert result["positive_count"] == 79
assert result["positive_pct"] == 79.0
assert result["threshold_met"] is False
assert result["adopted"] is False
def test_empty_votes(self):
"""Zero votes => not adopted."""
result = evaluate_nuanced([], threshold_pct=80, min_participants=59)
assert result["total"] == 0
assert result["positive_count"] == 0
assert result["positive_pct"] == 0.0
assert result["adopted"] is False
def test_invalid_vote_level(self):
"""Vote level outside 0-5 raises ValueError."""
with pytest.raises(ValueError, match="invalide"):
evaluate_nuanced([5, 3, 6])
def test_per_level_counts(self):
"""Verify per-level breakdown is correct."""
votes = [0, 1, 2, 3, 4, 5, 5, 4, 3]
result = evaluate_nuanced(votes, threshold_pct=50, min_participants=1)
assert result["per_level_counts"] == {0: 1, 1: 1, 2: 1, 3: 2, 4: 2, 5: 2}
assert result["positive_count"] == 6 # 2+2+2
assert result["total"] == 9
@@ -0,0 +1,226 @@
"""Tests for public API: basic schema validation and serialization.
Uses mock database sessions to test the public router logic
without requiring a real PostgreSQL connection.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from unittest.mock import MagicMock
import pytest
sqlalchemy = pytest.importorskip("sqlalchemy", reason="sqlalchemy required for public API tests")
from app.schemas.document import DocumentFullOut, DocumentOut # noqa: E402
from app.schemas.sanctuary import SanctuaryEntryOut # noqa: E402
# ---------------------------------------------------------------------------
# Helpers: mock objects
# ---------------------------------------------------------------------------
def _make_document_mock(
doc_id: uuid.UUID | None = None,
slug: str = "licence-g1",
title: str = "Licence G1",
doc_type: str = "licence",
version: str = "2.0.0",
doc_status: str = "active",
description: str | None = "La licence monetaire",
) -> MagicMock:
"""Create a mock Document for schema validation."""
doc = MagicMock()
doc.id = doc_id or uuid.uuid4()
doc.slug = slug
doc.title = title
doc.doc_type = doc_type
doc.version = version
doc.status = doc_status
doc.description = description
doc.ipfs_cid = None
doc.chain_anchor = None
doc.genesis_json = None
doc.created_at = datetime.now(timezone.utc)
doc.updated_at = datetime.now(timezone.utc)
doc.items = []
return doc
def _make_item_mock(
item_id: uuid.UUID | None = None,
document_id: uuid.UUID | None = None,
position: str = "1",
item_type: str = "clause",
title: str | None = "Article 1",
current_text: str = "Texte de l'article",
sort_order: int = 0,
) -> MagicMock:
"""Create a mock DocumentItem for schema validation."""
item = MagicMock()
item.id = item_id or uuid.uuid4()
item.document_id = document_id or uuid.uuid4()
item.position = position
item.item_type = item_type
item.title = title
item.current_text = current_text
item.voting_protocol_id = None
item.sort_order = sort_order
item.section_tag = None
item.inertia_preset = "standard"
item.is_permanent_vote = True
item.created_at = datetime.now(timezone.utc)
item.updated_at = datetime.now(timezone.utc)
return item
def _make_sanctuary_entry_mock(
entry_id: uuid.UUID | None = None,
entry_type: str = "document",
reference_id: uuid.UUID | None = None,
title: str | None = "Licence G1 v2.0.0",
content_hash: str = "abc123def456",
ipfs_cid: str | None = "QmTestCid123",
chain_tx_hash: str | None = "0xdeadbeef",
) -> MagicMock:
"""Create a mock SanctuaryEntry for schema validation."""
entry = MagicMock()
entry.id = entry_id or uuid.uuid4()
entry.entry_type = entry_type
entry.reference_id = reference_id or uuid.uuid4()
entry.title = title
entry.content_hash = content_hash
entry.ipfs_cid = ipfs_cid
entry.chain_tx_hash = chain_tx_hash
entry.chain_block = 12345 if chain_tx_hash else None
entry.metadata_json = None
entry.created_at = datetime.now(timezone.utc)
return entry
# ---------------------------------------------------------------------------
# Tests: DocumentOut schema serialization
# ---------------------------------------------------------------------------
class TestDocumentOutSchema:
"""Test DocumentOut schema validation from mock objects."""
def test_document_out_basic(self):
"""DocumentOut validates from a mock document object."""
doc = _make_document_mock()
out = DocumentOut.model_validate(doc)
assert out.slug == "licence-g1"
assert out.title == "Licence G1"
assert out.doc_type == "licence"
assert out.version == "2.0.0"
assert out.status == "active"
# items_count is set explicitly after validation (not from model)
out.items_count = 0
assert out.items_count == 0
def test_document_out_with_items_count(self):
"""DocumentOut can have items_count set after validation."""
doc = _make_document_mock()
out = DocumentOut.model_validate(doc)
out.items_count = 42
assert out.items_count == 42
def test_document_out_all_fields_present(self):
"""All expected fields are present in the DocumentOut serialization."""
doc = _make_document_mock()
out = DocumentOut.model_validate(doc)
data = out.model_dump()
expected_fields = {
"id", "slug", "title", "doc_type", "version", "status",
"description", "ipfs_cid", "chain_anchor", "genesis_json",
"created_at", "updated_at", "items_count",
}
assert expected_fields.issubset(set(data.keys()))
# ---------------------------------------------------------------------------
# Tests: DocumentFullOut schema serialization
# ---------------------------------------------------------------------------
class TestDocumentFullOutSchema:
"""Test DocumentFullOut schema validation (document with items)."""
def test_document_full_out_empty_items(self):
"""DocumentFullOut works with an empty items list."""
doc = _make_document_mock()
doc.items = []
out = DocumentFullOut.model_validate(doc)
assert out.slug == "licence-g1"
assert out.items == []
def test_document_full_out_with_items(self):
"""DocumentFullOut includes items when present."""
doc_id = uuid.uuid4()
doc = _make_document_mock(doc_id=doc_id)
item1 = _make_item_mock(document_id=doc_id, position="1", sort_order=0)
item2 = _make_item_mock(document_id=doc_id, position="2", sort_order=1)
doc.items = [item1, item2]
out = DocumentFullOut.model_validate(doc)
assert len(out.items) == 2
assert out.items[0].position == "1"
assert out.items[1].position == "2"
# ---------------------------------------------------------------------------
# Tests: SanctuaryEntryOut schema serialization
# ---------------------------------------------------------------------------
class TestSanctuaryEntryOutSchema:
"""Test SanctuaryEntryOut schema validation."""
def test_sanctuary_entry_out_basic(self):
"""SanctuaryEntryOut validates from a mock entry."""
entry = _make_sanctuary_entry_mock()
out = SanctuaryEntryOut.model_validate(entry)
assert out.entry_type == "document"
assert out.content_hash == "abc123def456"
assert out.ipfs_cid == "QmTestCid123"
assert out.chain_tx_hash == "0xdeadbeef"
assert out.chain_block == 12345
def test_sanctuary_entry_out_without_ipfs(self):
"""SanctuaryEntryOut works when IPFS CID is None."""
entry = _make_sanctuary_entry_mock(ipfs_cid=None, chain_tx_hash=None)
out = SanctuaryEntryOut.model_validate(entry)
assert out.ipfs_cid is None
assert out.chain_tx_hash is None
assert out.chain_block is None
def test_sanctuary_entry_out_all_fields(self):
"""All expected fields are present in SanctuaryEntryOut."""
entry = _make_sanctuary_entry_mock()
out = SanctuaryEntryOut.model_validate(entry)
data = out.model_dump()
expected_fields = {
"id", "entry_type", "reference_id", "title",
"content_hash", "ipfs_cid", "chain_tx_hash",
"chain_block", "metadata_json", "created_at",
}
assert expected_fields.issubset(set(data.keys()))
def test_sanctuary_entry_types(self):
"""Different entry_type values are accepted."""
for entry_type in ("document", "decision", "vote_result"):
entry = _make_sanctuary_entry_mock(entry_type=entry_type)
out = SanctuaryEntryOut.model_validate(entry)
assert out.entry_type == entry_type
@@ -0,0 +1,270 @@
"""TDD — Moteur de qualification des décisions.
Source de vérité exécutable des règles métier du tunnel "Décider".
Règles testées :
R1 within_mandate → individual + consultation_avis
R2 within_mandate → aucune modalité de vote + consignation Observatoire
R4 2 ≤ affected_count ≤ small_group_max → individual recommandé, collectif disponible
R5 small_group_max < affected_count ≤ collective_wot_min → collective recommandé
R6 affected_count > collective_wot_min → collective recommandé (WoT applicable, non obligatoire)
R7 is_structural → recommend_onchain + raison explicite
R8 is_structural=False → recommend_onchain=False
GARDE-FOUS (invariants internes qui ne doivent jamais régresser) :
G1 decision_type est toujours dans l'enum autorisé
G2 individual n'expose jamais de modalités de vote
G3 collective expose au moins une modalité
G4 les seuils sont lus depuis QualificationConfig (configurables)
"""
from __future__ import annotations
import pytest
from app.engine.qualifier import (
DecisionType,
QualificationConfig,
QualificationInput,
QualificationResult,
qualify,
)
DEFAULT_CONFIG = QualificationConfig()
# ---------------------------------------------------------------------------
# R1 — within_mandate → individual + consultation_avis
# ---------------------------------------------------------------------------
def test_r1_within_mandate_gives_individual():
result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG)
assert result.decision_type == DecisionType.INDIVIDUAL
def test_r1_within_mandate_gives_consultation_avis():
result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG)
assert result.process == "consultation_avis"
def test_r1_within_mandate_overrides_large_affected_count():
"""Même si de nombreuses personnes sont concernées, un mandat impose individual."""
result = qualify(
QualificationInput(within_mandate=True, affected_count=500),
DEFAULT_CONFIG,
)
assert result.decision_type == DecisionType.INDIVIDUAL
assert result.process == "consultation_avis"
# ---------------------------------------------------------------------------
# R2 — within_mandate → aucune modalité de vote + consignation Observatoire
# ---------------------------------------------------------------------------
def test_r2_within_mandate_no_vote_modalities():
"""Le mandataire décide seul après consultation — pas de vote collectif."""
result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG)
assert result.recommended_modalities == []
def test_r2_within_mandate_records_in_observatory():
"""Une décision dans un mandat doit être consignée dans l'Observatoire."""
result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG)
assert result.record_in_observatory is True
def test_r2_out_of_mandate_does_not_force_observatory():
"""Hors mandat, la consignation dans l'Observatoire n'est pas imposée."""
result = qualify(
QualificationInput(within_mandate=False, affected_count=10),
DEFAULT_CONFIG,
)
assert result.record_in_observatory is False
# ---------------------------------------------------------------------------
# R4 — 2 ≤ affected_count ≤ small_group_max → individual recommandé
# ---------------------------------------------------------------------------
def test_r4_small_group_recommends_individual():
for count in range(2, DEFAULT_CONFIG.small_group_max + 1):
result = qualify(
QualificationInput(within_mandate=False, affected_count=count),
DEFAULT_CONFIG,
)
assert result.decision_type == DecisionType.INDIVIDUAL, (
f"affected_count={count} devrait recommander individual"
)
def test_r4_small_group_collective_is_available():
result = qualify(
QualificationInput(within_mandate=False, affected_count=3),
DEFAULT_CONFIG,
)
assert result.collective_available is True
def test_r4_small_group_confidence_is_recommended():
result = qualify(
QualificationInput(within_mandate=False, affected_count=3),
DEFAULT_CONFIG,
)
assert result.confidence == "recommended"
# ---------------------------------------------------------------------------
# R5 — small_group_max < affected_count ≤ collective_wot_min → collective recommandé
# ---------------------------------------------------------------------------
def test_r5_medium_group_recommends_collective():
mid = DEFAULT_CONFIG.small_group_max + 1
result = qualify(
QualificationInput(within_mandate=False, affected_count=mid),
DEFAULT_CONFIG,
)
assert result.decision_type == DecisionType.COLLECTIVE
def test_r5_medium_group_confidence_is_recommended():
mid = DEFAULT_CONFIG.small_group_max + 1
result = qualify(
QualificationInput(within_mandate=False, affected_count=mid),
DEFAULT_CONFIG,
)
assert result.confidence == "recommended"
# ---------------------------------------------------------------------------
# R6 — affected_count > collective_wot_min → collective recommandé (pas obligatoire)
# ---------------------------------------------------------------------------
def test_r6_large_group_recommends_collective():
result = qualify(
QualificationInput(within_mandate=False, affected_count=DEFAULT_CONFIG.collective_wot_min + 1),
DEFAULT_CONFIG,
)
assert result.decision_type == DecisionType.COLLECTIVE
def test_r6_large_group_confidence_is_recommended_not_required():
"""Au-delà du seuil WoT, le vote collectif est recommandé — pas imposé."""
result = qualify(
QualificationInput(within_mandate=False, affected_count=DEFAULT_CONFIG.collective_wot_min + 1),
DEFAULT_CONFIG,
)
assert result.confidence == "recommended"
def test_r6_large_group_includes_vote_wot_modality():
result = qualify(
QualificationInput(within_mandate=False, affected_count=DEFAULT_CONFIG.collective_wot_min + 1),
DEFAULT_CONFIG,
)
assert "vote_wot" in result.recommended_modalities
# ---------------------------------------------------------------------------
# R7 — is_structural=True → recommend_onchain + raison
# ---------------------------------------------------------------------------
def test_r7_structural_recommends_onchain():
result = qualify(
QualificationInput(within_mandate=False, affected_count=100, is_structural=True),
DEFAULT_CONFIG,
)
assert result.recommend_onchain is True
def test_r7_structural_provides_onchain_reason():
result = qualify(
QualificationInput(within_mandate=False, affected_count=100, is_structural=True),
DEFAULT_CONFIG,
)
assert result.onchain_reason is not None and len(result.onchain_reason) > 0
def test_r7_structural_within_mandate_can_also_recommend_onchain():
"""Même une décision dans un mandat peut être gravée si structurante."""
result = qualify(
QualificationInput(within_mandate=True, is_structural=True),
DEFAULT_CONFIG,
)
assert result.recommend_onchain is True
# ---------------------------------------------------------------------------
# R8 — is_structural=False → recommend_onchain=False
# ---------------------------------------------------------------------------
def test_r8_non_structural_never_recommends_onchain():
for count in [2, 3, 10, 100]:
result = qualify(
QualificationInput(within_mandate=False, affected_count=count, is_structural=False),
DEFAULT_CONFIG,
)
assert result.recommend_onchain is False, (
f"affected_count={count} non structurant : on-chain ne doit pas être proposé"
)
# ---------------------------------------------------------------------------
# GARDE-FOUS internes (régressions silencieuses)
# ---------------------------------------------------------------------------
def test_g1_decision_type_always_valid():
valid_types = set(DecisionType)
for inp in [
QualificationInput(within_mandate=True),
QualificationInput(within_mandate=False, affected_count=2),
QualificationInput(within_mandate=False, affected_count=10),
QualificationInput(within_mandate=False, affected_count=100),
]:
result = qualify(inp, DEFAULT_CONFIG)
assert result.decision_type in valid_types
def test_g2_individual_never_has_vote_modalities():
for inp in [
QualificationInput(within_mandate=True),
QualificationInput(within_mandate=False, affected_count=2),
QualificationInput(within_mandate=False, affected_count=3),
]:
result = qualify(inp, DEFAULT_CONFIG)
if result.decision_type == DecisionType.INDIVIDUAL:
assert result.recommended_modalities == [], (
f"Individual ne doit pas exposer de modalités : {inp}"
)
def test_g3_collective_has_at_least_one_modality():
result = qualify(
QualificationInput(within_mandate=False, affected_count=20),
DEFAULT_CONFIG,
)
assert result.decision_type == DecisionType.COLLECTIVE
assert len(result.recommended_modalities) >= 1
def test_g4_custom_config_overrides_thresholds():
"""Les seuils viennent de QualificationConfig — pas de constantes hardcodées."""
custom = QualificationConfig(small_group_max=2, collective_wot_min=10)
result = qualify(
QualificationInput(within_mandate=False, affected_count=3),
custom,
)
assert result.decision_type == DecisionType.COLLECTIVE
def test_g4_default_thresholds_are_stable():
cfg = QualificationConfig()
assert cfg.small_group_max == 5
assert cfg.collective_wot_min == 50
@@ -0,0 +1,172 @@
"""TDD — Service AI de cadrage des décisions (qualify/ai-chat).
Invariants testés :
A1 Premier appel (messages=[]) → retourne toujours 2 questions, done=False
A2 Les 2 questions couvrent réversibilité et urgence (ids stables)
A3 Deuxième appel (messages=[q+réponse]) → done=True, résultat qualifié
A4 Réponse "irréversible" → recommend_onchain conservé si is_structural
A5 Réponse "urgente" → raison "urgence" présente dans le résultat
A6 La qualification finale respecte les règles du moteur (R1/R2/R4/R5/R6)
A7 Sans contexte, les questions restent les mêmes (stub ne dépend pas du LLM)
"""
from __future__ import annotations
import pytest
from app.services.qualify_ai_service import (
AIFrameRequest,
AIMessage,
ai_frame,
)
DEFAULT_REQUEST = AIFrameRequest(
context="Révision du règlement intérieur de l'association",
within_mandate=False,
affected_count=20,
is_structural=False,
messages=[],
)
# ---------------------------------------------------------------------------
# A1 — Premier appel → 2 questions, done=False
# ---------------------------------------------------------------------------
def test_a1_first_call_returns_questions():
resp = ai_frame(DEFAULT_REQUEST)
assert resp.done is False
assert len(resp.questions) == 2
def test_a1_first_call_result_is_none():
resp = ai_frame(DEFAULT_REQUEST)
assert resp.result is None
# ---------------------------------------------------------------------------
# A2 — Questions couvrent réversibilité et urgence
# ---------------------------------------------------------------------------
def test_a2_questions_have_stable_ids():
resp = ai_frame(DEFAULT_REQUEST)
ids = {q.id for q in resp.questions}
assert "reversibility" in ids
assert "urgency" in ids
def test_a2_questions_have_options():
resp = ai_frame(DEFAULT_REQUEST)
for q in resp.questions:
assert len(q.options) >= 2, f"Question '{q.id}' doit avoir au moins 2 options"
# ---------------------------------------------------------------------------
# A3 — Deuxième appel (avec réponses) → done=True + résultat
# ---------------------------------------------------------------------------
def _make_second_request(reversibility_ans: str, urgency_ans: str, **kwargs) -> AIFrameRequest:
questions = ai_frame(DEFAULT_REQUEST).questions
messages = []
for q in questions:
messages.append(AIMessage(role="assistant", content=q.text))
# One user message bundling all answers
messages.append(AIMessage(
role="user",
content=f"reversibility:{reversibility_ans}|urgency:{urgency_ans}",
))
return AIFrameRequest(
**{**vars(DEFAULT_REQUEST), "messages": messages, **kwargs}
)
def test_a3_second_call_is_done():
req = _make_second_request("Difficilement", "Pas d'urgence")
resp = ai_frame(req)
assert resp.done is True
def test_a3_second_call_has_result():
req = _make_second_request("Difficilement", "Pas d'urgence")
resp = ai_frame(req)
assert resp.result is not None
assert resp.result.decision_type in ("individual", "collective")
# ---------------------------------------------------------------------------
# A4 — Irréversible + structurant → recommend_onchain
# ---------------------------------------------------------------------------
def test_a4_irreversible_structural_recommends_onchain():
req = _make_second_request(
"Non, c'est irréversible",
"Pas d'urgence",
is_structural=True,
)
resp = ai_frame(req)
assert resp.result is not None
assert resp.result.recommend_onchain is True
# ---------------------------------------------------------------------------
# A5 — Urgence → raison présente
# ---------------------------------------------------------------------------
def test_a5_urgent_adds_urgency_reason():
req = _make_second_request("Oui, facilement", "Urgente (< 1 semaine)")
resp = ai_frame(req)
assert resp.result is not None
reasons_text = " ".join(resp.result.reasons).lower()
assert "urgence" in reasons_text or "urgent" in reasons_text
# ---------------------------------------------------------------------------
# A6 — Résultat respecte les règles du moteur
# ---------------------------------------------------------------------------
def test_a6_within_mandate_gives_individual():
req = AIFrameRequest(
within_mandate=True,
affected_count=None,
messages=[
AIMessage(role="assistant", content="q"),
AIMessage(role="user", content="reversibility:Facilement|urgency:Pas d'urgence"),
],
)
resp = ai_frame(req)
assert resp.done is True
assert resp.result is not None
assert resp.result.decision_type == "individual"
assert resp.result.process == "consultation_avis"
def test_a6_large_group_gives_collective():
req = _make_second_request("Difficilement", "Pas d'urgence", affected_count=100)
resp = ai_frame(req)
assert resp.result is not None
assert resp.result.decision_type == "collective"
# ---------------------------------------------------------------------------
# A7 — Sans contexte, mêmes questions (stub ne dépend pas du LLM)
# ---------------------------------------------------------------------------
def test_a7_no_context_same_question_ids():
req_with = DEFAULT_REQUEST
req_without = AIFrameRequest(
context=None,
within_mandate=False,
affected_count=20,
messages=[],
)
ids_with = {q.id for q in ai_frame(req_with).questions}
ids_without = {q.id for q in ai_frame(req_without).questions}
assert ids_with == ids_without
@@ -0,0 +1,180 @@
"""Tests for WoT threshold formula, Smith threshold, and TechComm threshold.
Real-world reference case:
Vote Engagement Forgeron v2.0.0 (Feb 2026)
wot_size=7224, votes_for=97, votes_against=23, total=120
params M=50, B=0.1, G=0.2 => threshold=94 => adopted (97 >= 94)
"""
import math
import pytest
from app.engine.threshold import wot_threshold
from app.engine.smith_threshold import smith_threshold
from app.engine.techcomm_threshold import techcomm_threshold
# ---------------------------------------------------------------------------
# WoT threshold: real-world vote Forgeron
# ---------------------------------------------------------------------------
class TestWotThresholdForgeron:
"""Test with the actual Engagement Forgeron v2.0.0 vote numbers."""
def test_forgeron_vote_passes(self):
"""97 votes_for out of 120 total (wot=7224) must pass."""
threshold = wot_threshold(
wot_size=7224,
total_votes=120,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
)
# With low participation (120/7224 ~ 1.66%), near-unanimity is required.
# The historical threshold was 94, and 97 >= 94.
assert 97 >= threshold
# The threshold should be high relative to total votes (inertia effect)
assert threshold > 60, f"Threshold {threshold} should be well above simple majority"
def test_forgeron_vote_threshold_value(self):
"""Verify the computed threshold is in a reasonable range."""
threshold = wot_threshold(
wot_size=7224,
total_votes=120,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
)
# At ~1.66% participation, inertia should push threshold close to 78-95%
# of total votes. The exact value depends on the formula.
assert 80 <= threshold <= 120
# ---------------------------------------------------------------------------
# WoT threshold: low participation
# ---------------------------------------------------------------------------
class TestWotThresholdLowParticipation:
"""With very low participation, near-unanimity should be required."""
def test_ten_votes_out_of_7224(self):
"""10 voters out of 7224 => nearly all must vote 'for'."""
threshold = wot_threshold(
wot_size=7224,
total_votes=10,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
)
# With participation ratio 10/7224 ~ 0.14%, threshold should be
# very close to total_votes (near-unanimity).
assert threshold >= 9, f"Expected near-unanimity but got threshold={threshold}"
assert threshold <= 10
# ---------------------------------------------------------------------------
# WoT threshold: high participation
# ---------------------------------------------------------------------------
class TestWotThresholdHighParticipation:
"""With high participation, threshold should approach simple majority M."""
def test_3000_votes_out_of_7224(self):
"""3000/7224 ~ 41.5% participation => threshold closer to 50%."""
threshold = wot_threshold(
wot_size=7224,
total_votes=3000,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
)
# With ~42% participation, the inertia factor diminishes.
# threshold should be well below 90% of votes but above simple majority.
simple_majority = math.ceil(3000 * 0.5)
assert threshold >= simple_majority, (
f"Threshold {threshold} should be at least simple majority {simple_majority}"
)
# Should be noticeably less than near-unanimity
assert threshold < 2700, (
f"Threshold {threshold} should be much less than near-unanimity at high participation"
)
# ---------------------------------------------------------------------------
# WoT threshold: edge cases
# ---------------------------------------------------------------------------
class TestWotThresholdEdgeCases:
"""Edge-case behaviour."""
def test_zero_total_votes(self):
"""With zero votes, threshold is ceil(C + B^W)."""
threshold = wot_threshold(
wot_size=7224,
total_votes=0,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
)
# B^W = 0.1^7224 is effectively 0
expected = math.ceil(0.0 + 0.1 ** 7224)
assert threshold == expected
def test_invalid_wot_size_zero(self):
with pytest.raises(ValueError, match="wot_size"):
wot_threshold(wot_size=0, total_votes=10)
def test_invalid_negative_votes(self):
with pytest.raises(ValueError, match="total_votes"):
wot_threshold(wot_size=100, total_votes=-1)
def test_invalid_majority_pct(self):
with pytest.raises(ValueError, match="majority_pct"):
wot_threshold(wot_size=100, total_votes=10, majority_pct=150)
# ---------------------------------------------------------------------------
# Smith threshold
# ---------------------------------------------------------------------------
class TestSmithThreshold:
"""Test Smith sub-WoT threshold: ceil(smith_size ^ S)."""
def test_smith_size_20_exponent_01(self):
"""smith_size=20, exponent=0.1 => ceil(20^0.1)."""
result = smith_threshold(smith_wot_size=20, exponent=0.1)
expected = math.ceil(20 ** 0.1)
assert result == expected
# 20^0.1 ~ 1.35, ceil => 2
assert result == 2
def test_smith_size_1(self):
"""smith_size=1 => ceil(1^0.1) = 1."""
assert smith_threshold(smith_wot_size=1, exponent=0.1) == 1
def test_smith_invalid(self):
with pytest.raises(ValueError):
smith_threshold(smith_wot_size=0)
# ---------------------------------------------------------------------------
# TechComm threshold
# ---------------------------------------------------------------------------
class TestTechcommThreshold:
"""Test TechComm threshold: ceil(cotec_size ^ T)."""
def test_cotec_size_5_exponent_01(self):
"""cotec_size=5, exponent=0.1 => ceil(5^0.1)."""
result = techcomm_threshold(cotec_size=5, exponent=0.1)
expected = math.ceil(5 ** 0.1)
assert result == expected
# 5^0.1 ~ 1.175, ceil => 2
assert result == 2
def test_cotec_size_1(self):
assert techcomm_threshold(cotec_size=1, exponent=0.1) == 1
def test_cotec_invalid(self):
with pytest.raises(ValueError):
techcomm_threshold(cotec_size=0)
+478
View File
@@ -0,0 +1,478 @@
"""Tests for vote service engine functions: threshold details, session logic, nuanced evaluation, and simulation.
All tests are pure unit tests that exercise the engine functions directly
without any database dependency.
Real-world reference case:
Vote Engagement Forgeron v2.0.0 (Feb 2026)
wot_size=7224, votes_for=97, votes_against=23, total=120
params M=50, B=0.1, G=0.2 => threshold=94 => adopted (97 >= 94)
"""
from __future__ import annotations
import math
import pytest
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
# ---------------------------------------------------------------------------
# Threshold details computation: real Forgeron case (97/23/7224)
# ---------------------------------------------------------------------------
class TestThresholdDetailsForgeron:
"""Simulate the threshold details computation that the service would return
for the real Engagement Forgeron v2.0.0 vote."""
WOT_SIZE = 7224
VOTES_FOR = 97
VOTES_AGAINST = 23
TOTAL = 120
SMITH_SIZE = 20
SMITH_EXPONENT = 0.1
TECHCOMM_SIZE = 5
TECHCOMM_EXPONENT = 0.1
def _compute_details(self) -> dict:
"""Reproduce the threshold details logic from vote_service.get_threshold_details."""
wot_thresh = wot_threshold(
wot_size=self.WOT_SIZE,
total_votes=self.TOTAL,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
constant_base=0.0,
)
wot_passed = self.VOTES_FOR >= wot_thresh
smith_thresh = smith_threshold(self.SMITH_SIZE, self.SMITH_EXPONENT)
# Assume all smith members voted for
smith_votes_for = 5
smith_passed = smith_votes_for >= smith_thresh
techcomm_thresh = techcomm_threshold(self.TECHCOMM_SIZE, self.TECHCOMM_EXPONENT)
# Assume 2 techcomm members voted for
techcomm_votes_for = 2
techcomm_passed = techcomm_votes_for >= techcomm_thresh
overall_passed = wot_passed and smith_passed and techcomm_passed
participation_rate = self.TOTAL / self.WOT_SIZE
return {
"wot_threshold": wot_thresh,
"smith_threshold": smith_thresh,
"techcomm_threshold": techcomm_thresh,
"votes_for": self.VOTES_FOR,
"votes_against": self.VOTES_AGAINST,
"votes_total": self.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": {
"M": 50, "B": 0.1, "G": 0.2, "C": 0.0,
"S": self.SMITH_EXPONENT, "T": self.TECHCOMM_EXPONENT,
},
}
def test_wot_threshold_value(self):
"""WoT threshold for Forgeron vote should be in the expected range."""
details = self._compute_details()
assert 80 <= details["wot_threshold"] <= 120
# 97 votes_for must pass
assert details["wot_passed"] is True
def test_smith_threshold_value(self):
"""Smith threshold: ceil(20^0.1) = ceil(1.35) = 2."""
details = self._compute_details()
assert details["smith_threshold"] == 2
assert details["smith_passed"] is True
def test_techcomm_threshold_value(self):
"""TechComm threshold: ceil(5^0.1) = ceil(1.175) = 2."""
details = self._compute_details()
assert details["techcomm_threshold"] == 2
assert details["techcomm_passed"] is True
def test_overall_pass(self):
"""All three criteria pass => overall adopted."""
details = self._compute_details()
assert details["overall_passed"] is True
def test_participation_rate(self):
"""Participation rate for 120/7224 ~ 1.66%."""
details = self._compute_details()
expected = round(120 / 7224, 6)
assert details["participation_rate"] == expected
assert details["participation_rate"] < 0.02 # less than 2%
def test_formula_params_present(self):
"""All formula params must be present and correct."""
details = self._compute_details()
params = details["formula_params"]
assert params["M"] == 50
assert params["B"] == 0.1
assert params["G"] == 0.2
assert params["C"] == 0.0
assert params["S"] == 0.1
assert params["T"] == 0.1
# ---------------------------------------------------------------------------
# Close session behavior (engine-level logic)
# ---------------------------------------------------------------------------
class TestCloseSessionLogic:
"""Test the logic that would execute when a session is closed:
computing the final tally and determining adopted/rejected."""
def test_binary_vote_adopted(self):
"""97 for / 23 against out of 7224 WoT => adopted."""
threshold = wot_threshold(
wot_size=7224,
total_votes=120,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
)
adopted = 97 >= threshold
assert adopted is True
result = "adopted" if adopted else "rejected"
assert result == "adopted"
def test_binary_vote_rejected(self):
"""50 for / 70 against out of 7224 WoT => rejected."""
threshold = wot_threshold(
wot_size=7224,
total_votes=120,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
)
adopted = 50 >= threshold
assert adopted is False
result = "adopted" if adopted else "rejected"
assert result == "rejected"
def test_close_with_zero_votes(self):
"""Session with 0 votes => threshold is ~0 (B^W), effectively rejected
because 0 votes_for cannot meet even a tiny threshold."""
threshold = wot_threshold(
wot_size=7224,
total_votes=0,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
)
# B^W = 0.1^7224 -> effectively 0, ceil(0) = 0
# But with 0 votes_for, 0 >= 0 is True
# This is a degenerate case; in practice sessions with 0 votes
# would be marked invalid
assert threshold == 0 or threshold == math.ceil(0.1 ** 7224)
def test_close_high_participation(self):
"""3000/7224 participating, 2500 for / 500 against => should pass.
At ~41.5% participation with G=0.2, the inertia factor is low,
so a strong supermajority should pass.
"""
threshold = wot_threshold(
wot_size=7224,
total_votes=3000,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
)
# At ~41.5% participation, threshold is lower than near-unanimity
# but still above simple majority (1500)
assert threshold > 1500, f"Threshold {threshold} should be above simple majority"
assert threshold < 3000, f"Threshold {threshold} should be below total votes"
adopted = 2500 >= threshold
assert adopted is True
def test_close_barely_fails(self):
"""Use exact threshold to verify borderline rejection."""
threshold = wot_threshold(
wot_size=7224,
total_votes=120,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
)
# votes_for = threshold - 1 => should fail
barely_fail = (threshold - 1) >= threshold
assert barely_fail is False
def test_close_smith_criterion_blocks(self):
"""Even if WoT passes, failing Smith criterion blocks adoption."""
threshold = wot_threshold(
wot_size=7224,
total_votes=120,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
)
wot_pass = 97 >= threshold
smith_required = smith_threshold(20, 0.1) # ceil(20^0.1) = 2
smith_ok = 1 >= smith_required # Only 1 smith voted -> fails
adopted = wot_pass and smith_ok
assert wot_pass is True
assert smith_ok is False
assert adopted is False
# ---------------------------------------------------------------------------
# Nuanced vote result evaluation
# ---------------------------------------------------------------------------
class TestNuancedVoteResult:
"""Test nuanced vote evaluation with per-level breakdown,
as would be returned by compute_result for nuanced votes."""
def test_nuanced_adopted_with_breakdown(self):
"""Standard nuanced vote that passes threshold and min_participants."""
votes = [5] * 30 + [4] * 20 + [3] * 15 + [2] * 5 + [1] * 3 + [0] * 2
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
# Total = 75, positive = 30+20+15 = 65
assert result["total"] == 75
assert result["positive_count"] == 65
assert result["positive_pct"] == pytest.approx(86.67, abs=0.1)
assert result["adopted"] is True
# Verify per-level breakdown
assert result["per_level_counts"][5] == 30
assert result["per_level_counts"][4] == 20
assert result["per_level_counts"][3] == 15
assert result["per_level_counts"][2] == 5
assert result["per_level_counts"][1] == 3
assert result["per_level_counts"][0] == 2
def test_nuanced_rejected_threshold_not_met(self):
"""Nuanced vote where positive percentage is below threshold."""
votes = [5] * 10 + [4] * 10 + [3] * 10 + [2] * 15 + [1] * 15 + [0] * 10
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
# Total = 70, positive = 10+10+10 = 30
assert result["total"] == 70
assert result["positive_count"] == 30
assert result["positive_pct"] == pytest.approx(42.86, abs=0.1)
assert result["threshold_met"] is False
assert result["min_participants_met"] is True
assert result["adopted"] is False
def test_nuanced_all_neutre(self):
"""All voters at level 3 (NEUTRE) still counts as positive."""
votes = [3] * 60
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
assert result["total"] == 60
assert result["positive_count"] == 60
assert result["positive_pct"] == 100.0
assert result["adopted"] is True
def test_nuanced_mixed_heavy_negative(self):
"""Majority at levels 0-2 => rejected."""
votes = [0] * 20 + [1] * 20 + [2] * 15 + [3] * 2 + [4] * 1 + [5] * 1
result = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
# Total = 59, positive = 2+1+1 = 4
assert result["total"] == 59
assert result["positive_count"] == 4
assert result["positive_pct"] < 10
assert result["adopted"] is False
def test_nuanced_breakdown_dict_structure(self):
"""Verify the breakdown structure matches what the service builds."""
votes = [5] * 20 + [4] * 20 + [3] * 19 + [2] * 5 + [1] * 3 + [0] * 2
evaluation = evaluate_nuanced(votes, threshold_pct=80, min_participants=59)
# Simulate the nuanced_breakdown dict the service builds
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": 80,
"min_participants": 59,
}
assert "per_level_counts" in nuanced_breakdown
assert nuanced_breakdown["threshold_pct"] == 80
assert nuanced_breakdown["min_participants"] == 59
assert nuanced_breakdown["positive_count"] == 59
assert nuanced_breakdown["threshold_met"] is True
# ---------------------------------------------------------------------------
# Simulation endpoint logic (pure engine functions)
# ---------------------------------------------------------------------------
class TestSimulationLogic:
"""Test the formula simulation that the /simulate endpoint performs,
exercising all three threshold engine functions together."""
def test_simulation_forgeron_case(self):
"""Simulate the Forgeron vote: wot=7224, total=120, M50 B.1 G.2."""
wot_thresh = wot_threshold(
wot_size=7224,
total_votes=120,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
)
# Derived values (matching the simulate endpoint logic)
w = 7224
t = 120
m = 0.5
g = 0.2
participation_rate = t / w
turnout_ratio = min(t / w, 1.0)
inertia_factor = 1.0 - turnout_ratio ** g
required_ratio = m + (1.0 - m) * inertia_factor
assert 80 <= wot_thresh <= 120
assert participation_rate == pytest.approx(0.016611, abs=0.001)
assert 0 < inertia_factor < 1
assert required_ratio > m # Inertia pushes above simple majority
def test_simulation_full_participation(self):
"""At 100% participation, threshold approaches simple majority."""
wot_size = 100
total = 100
wot_thresh = wot_threshold(
wot_size=wot_size,
total_votes=total,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
)
# At full participation, turnout_ratio = 1.0
# inertia_factor = 1.0 - 1.0^0.2 = 0
# required_ratio = 0.5 + 0.5*0 = 0.5
# threshold = 0 + 0.1^100 + 0.5 * 100 = ~50
assert wot_thresh == math.ceil(0.1 ** 100 + 0.5 * 100)
assert wot_thresh == 50
def test_simulation_with_smith_and_techcomm(self):
"""Simulate with all three criteria."""
wot_thresh = wot_threshold(
wot_size=7224,
total_votes=120,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
)
smith_thresh = smith_threshold(smith_wot_size=20, exponent=0.1)
techcomm_thresh = techcomm_threshold(cotec_size=5, exponent=0.1)
assert wot_thresh > 0
assert smith_thresh == 2 # ceil(20^0.1) = ceil(1.35) = 2
assert techcomm_thresh == 2 # ceil(5^0.1) = ceil(1.175) = 2
def test_simulation_varying_majority(self):
"""Higher majority_pct increases the threshold at full participation."""
thresh_50 = wot_threshold(wot_size=100, total_votes=100, majority_pct=50)
thresh_66 = wot_threshold(wot_size=100, total_votes=100, majority_pct=66)
thresh_80 = wot_threshold(wot_size=100, total_votes=100, majority_pct=80)
assert thresh_50 < thresh_66 < thresh_80
def test_simulation_varying_gradient(self):
"""Higher gradient exponent means more inertia at partial participation.
With T/W < 1, a higher G makes (T/W)^G smaller, so
inertia_factor = 1 - (T/W)^G becomes larger, raising the threshold.
At 50% participation: (0.5)^0.2 ~ 0.87, (0.5)^1.0 = 0.5.
"""
thresh_g02 = wot_threshold(
wot_size=200, total_votes=100,
majority_pct=50, gradient_exponent=0.2,
)
thresh_g10 = wot_threshold(
wot_size=200, total_votes=100,
majority_pct=50, gradient_exponent=1.0,
)
# Higher G = more inertia at partial participation = higher threshold
assert thresh_g10 > thresh_g02
def test_simulation_zero_votes(self):
"""Zero votes produces a minimal threshold."""
thresh = wot_threshold(
wot_size=7224,
total_votes=0,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
)
# B^W = 0.1^7224 ~ 0, ceil(0) = 0
assert thresh == 0
def test_simulation_small_wot(self):
"""Small WoT size (e.g. 5 members)."""
thresh = wot_threshold(
wot_size=5,
total_votes=3,
majority_pct=50,
base_exponent=0.1,
gradient_exponent=0.2,
)
# With 3/5 participation, should require more than simple majority of 3
assert thresh >= 2
assert thresh <= 3
def test_simulation_result_structure(self):
"""Verify the simulation produces all expected values for the API response."""
w = 7224
t = 120
m_pct = 50
m = m_pct / 100.0
b = 0.1
g = 0.2
c = 0.0
wot_thresh = wot_threshold(
wot_size=w, total_votes=t,
majority_pct=m_pct, base_exponent=b,
gradient_exponent=g, constant_base=c,
)
smith_thresh = smith_threshold(smith_wot_size=20, exponent=0.1)
techcomm_thresh = techcomm_threshold(cotec_size=5, exponent=0.1)
participation_rate = t / w
turnout_ratio = min(t / w, 1.0)
inertia_factor = 1.0 - turnout_ratio ** g
required_ratio = m + (1.0 - m) * inertia_factor
# Build the simulation result dict (matching FormulaSimulationResult schema)
result = {
"wot_threshold": wot_thresh,
"smith_threshold": smith_thresh,
"techcomm_threshold": techcomm_thresh,
"participation_rate": round(participation_rate, 6),
"required_ratio": round(required_ratio, 6),
"inertia_factor": round(inertia_factor, 6),
}
assert isinstance(result["wot_threshold"], int)
assert isinstance(result["smith_threshold"], int)
assert isinstance(result["techcomm_threshold"], int)
assert 0 < result["participation_rate"] < 1
assert 0 < result["required_ratio"] <= 1
assert 0 < result["inertia_factor"] < 1