v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/
- crates/ld-engine : formule inertielle (parité avec la maquette, garde C), coquille / changement de fond, assistant d'édition à règles, composition d'un document sous vote permanent (adoptions, aperçus, rivales). - crates/trustin-access : qui voit, qui ouvre, qui gère les accès. - crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de connect.monnaie-libre.fr), invitations, entrée en vigueur automatique, signalements de contradiction, traductions éditoriales, publication git, taille de la toile lue sur la chaîne. - seeds/acte-engagement-certification.json : 33 articles, fr + en + es ; en vigueur : préambule, E1, E2 (seed a minima). - maquette/ : frontend, backend et docker v2 déplacés tels quels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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()]
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user