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
+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