- matrix-gateway: POST /internal/matrix/presence/online endpoint - usePresenceHeartbeat hook with activity tracking - Auto away after 5 min inactivity - Offline on page close/visibility change - Integrated in MatrixChatRoom component
367 lines
11 KiB
Python
367 lines
11 KiB
Python
"""
|
|
Proposal & Voting Routes
|
|
Phase 8: DAO Dashboard
|
|
"""
|
|
from fastapi import APIRouter, HTTPException, Depends
|
|
from typing import List
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
|
|
from models import (
|
|
ProposalCreate, ProposalUpdate, ProposalRead, ProposalWithVotes,
|
|
VoteCreate, VoteRead
|
|
)
|
|
from auth_client import get_actor_from_token
|
|
from pdp_client import require_permission
|
|
|
|
router = APIRouter(prefix="/dao", tags=["proposals"])
|
|
|
|
# Repositories (will be injected)
|
|
dao_repo = None
|
|
proposal_repo = None
|
|
vote_repo = None
|
|
governance_engine = None
|
|
nats_publisher = None
|
|
|
|
# ============================================================================
|
|
# Proposals
|
|
# ============================================================================
|
|
|
|
@router.get("/{slug}/proposals", response_model=List[ProposalRead])
|
|
async def list_proposals(
|
|
slug: str,
|
|
status: str = None,
|
|
actor: dict = Depends(get_actor_from_token)
|
|
):
|
|
"""List proposals for a DAO"""
|
|
dao = await dao_repo.get_dao_by_slug(slug)
|
|
if not dao:
|
|
raise HTTPException(status_code=404, detail=f"DAO '{slug}' not found")
|
|
|
|
# Check PDP
|
|
await require_permission(
|
|
action="DAO_READ",
|
|
resource={"type": "DAO", "id": dao.id},
|
|
context={},
|
|
actor=actor
|
|
)
|
|
|
|
return await proposal_repo.list_proposals(dao.id, status)
|
|
|
|
@router.post("/{slug}/proposals", response_model=ProposalRead, status_code=201)
|
|
async def create_proposal(
|
|
slug: str,
|
|
data: ProposalCreate,
|
|
actor: dict = Depends(get_actor_from_token)
|
|
):
|
|
"""Create new proposal"""
|
|
dao = await dao_repo.get_dao_by_slug(slug)
|
|
if not dao:
|
|
raise HTTPException(status_code=404, detail=f"DAO '{slug}' not found")
|
|
|
|
user_id = actor.get("user_id")
|
|
if not user_id:
|
|
raise HTTPException(status_code=401, detail="Invalid actor identity")
|
|
|
|
# Check PDP
|
|
await require_permission(
|
|
action="DAO_PROPOSAL_CREATE",
|
|
resource={"type": "DAO", "id": dao.id},
|
|
context={"operation": "create_proposal"},
|
|
actor=actor
|
|
)
|
|
|
|
# Check if slug already exists
|
|
existing = await proposal_repo.get_proposal_by_slug(dao.id, data.slug)
|
|
if existing:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"Proposal with slug '{data.slug}' already exists in this DAO"
|
|
)
|
|
|
|
# Create proposal
|
|
proposal = await proposal_repo.create_proposal(dao.id, user_id, data)
|
|
|
|
# Publish NATS event
|
|
if nats_publisher:
|
|
await nats_publisher.publish(
|
|
"dao.event.proposal_created",
|
|
{
|
|
"dao_id": dao.id,
|
|
"proposal_id": proposal.id,
|
|
"slug": proposal.slug,
|
|
"title": proposal.title,
|
|
"created_by_user_id": user_id,
|
|
"status": proposal.status,
|
|
"ts": proposal.created_at.isoformat()
|
|
}
|
|
)
|
|
|
|
return proposal
|
|
|
|
@router.get("/{slug}/proposals/{proposal_slug}", response_model=ProposalWithVotes)
|
|
async def get_proposal(
|
|
slug: str,
|
|
proposal_slug: str,
|
|
actor: dict = Depends(get_actor_from_token)
|
|
):
|
|
"""Get proposal details with voting stats"""
|
|
dao = await dao_repo.get_dao_by_slug(slug)
|
|
if not dao:
|
|
raise HTTPException(status_code=404, detail=f"DAO '{slug}' not found")
|
|
|
|
proposal = await proposal_repo.get_proposal_by_slug(dao.id, proposal_slug)
|
|
if not proposal:
|
|
raise HTTPException(status_code=404, detail=f"Proposal '{proposal_slug}' not found")
|
|
|
|
# Check PDP
|
|
await require_permission(
|
|
action="DAO_READ",
|
|
resource={"type": "DAO", "id": dao.id},
|
|
context={},
|
|
actor=actor
|
|
)
|
|
|
|
# Get proposal with votes
|
|
proposal_with_votes = await proposal_repo.get_proposal_with_votes(proposal.id)
|
|
return proposal_with_votes
|
|
|
|
@router.post("/{slug}/proposals/{proposal_slug}/activate", response_model=ProposalRead)
|
|
async def activate_proposal(
|
|
slug: str,
|
|
proposal_slug: str,
|
|
actor: dict = Depends(get_actor_from_token)
|
|
):
|
|
"""Activate proposal (start voting)"""
|
|
dao = await dao_repo.get_dao_by_slug(slug)
|
|
if not dao:
|
|
raise HTTPException(status_code=404, detail=f"DAO '{slug}' not found")
|
|
|
|
proposal = await proposal_repo.get_proposal_by_slug(dao.id, proposal_slug)
|
|
if not proposal:
|
|
raise HTTPException(status_code=404, detail=f"Proposal '{proposal_slug}' not found")
|
|
|
|
# Check PDP
|
|
await require_permission(
|
|
action="DAO_PROPOSAL_MANAGE",
|
|
resource={"type": "DAO", "id": dao.id},
|
|
context={"operation": "activate_proposal"},
|
|
actor=actor
|
|
)
|
|
|
|
if proposal.status != "draft":
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Proposal must be in 'draft' status to activate (current: {proposal.status})"
|
|
)
|
|
|
|
# Activate
|
|
updated = await proposal_repo.update_proposal_status(proposal.id, "active")
|
|
|
|
# Publish NATS event
|
|
if nats_publisher:
|
|
await nats_publisher.publish(
|
|
"dao.event.proposal_activated",
|
|
{
|
|
"dao_id": dao.id,
|
|
"proposal_id": proposal.id,
|
|
"slug": proposal.slug,
|
|
"actor_id": actor.get("user_id"),
|
|
"ts": datetime.now().isoformat()
|
|
}
|
|
)
|
|
|
|
return updated
|
|
|
|
@router.post("/{slug}/proposals/{proposal_slug}/close", response_model=ProposalWithVotes)
|
|
async def close_proposal(
|
|
slug: str,
|
|
proposal_slug: str,
|
|
actor: dict = Depends(get_actor_from_token)
|
|
):
|
|
"""Close proposal and evaluate result"""
|
|
dao = await dao_repo.get_dao_by_slug(slug)
|
|
if not dao:
|
|
raise HTTPException(status_code=404, detail=f"DAO '{slug}' not found")
|
|
|
|
proposal = await proposal_repo.get_proposal_by_slug(dao.id, proposal_slug)
|
|
if not proposal:
|
|
raise HTTPException(status_code=404, detail=f"Proposal '{proposal_slug}' not found")
|
|
|
|
# Check PDP
|
|
await require_permission(
|
|
action="DAO_PROPOSAL_MANAGE",
|
|
resource={"type": "DAO", "id": dao.id},
|
|
context={"operation": "close_proposal"},
|
|
actor=actor
|
|
)
|
|
|
|
if proposal.status != "active":
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Proposal must be 'active' to close (current: {proposal.status})"
|
|
)
|
|
|
|
# Get all votes
|
|
votes = await vote_repo.list_votes_for_proposal(proposal.id)
|
|
|
|
# Get total eligible voters
|
|
members = await dao_repo.list_members(dao.id)
|
|
total_eligible = len([m for m in members if m.role in ["owner", "admin", "member"]])
|
|
|
|
# Evaluate proposal
|
|
result = await governance_engine.evaluate_proposal(dao, proposal, votes, total_eligible)
|
|
|
|
# Update status
|
|
updated = await proposal_repo.update_proposal_status(proposal.id, result.status)
|
|
|
|
# Publish NATS event
|
|
if nats_publisher:
|
|
await nats_publisher.publish(
|
|
"dao.event.proposal_closed",
|
|
{
|
|
"dao_id": dao.id,
|
|
"proposal_id": proposal.id,
|
|
"slug": proposal.slug,
|
|
"final_status": result.status,
|
|
"is_passed": result.is_passed,
|
|
"quorum_reached": result.quorum_reached,
|
|
"actor_id": actor.get("user_id"),
|
|
"ts": datetime.now().isoformat()
|
|
}
|
|
)
|
|
|
|
# Return with votes
|
|
return await proposal_repo.get_proposal_with_votes(proposal.id)
|
|
|
|
# ============================================================================
|
|
# Voting
|
|
# ============================================================================
|
|
|
|
@router.get("/{slug}/proposals/{proposal_slug}/votes", response_model=List[VoteRead])
|
|
async def list_votes(
|
|
slug: str,
|
|
proposal_slug: str,
|
|
actor: dict = Depends(get_actor_from_token)
|
|
):
|
|
"""List all votes for a proposal"""
|
|
dao = await dao_repo.get_dao_by_slug(slug)
|
|
if not dao:
|
|
raise HTTPException(status_code=404, detail=f"DAO '{slug}' not found")
|
|
|
|
proposal = await proposal_repo.get_proposal_by_slug(dao.id, proposal_slug)
|
|
if not proposal:
|
|
raise HTTPException(status_code=404, detail=f"Proposal '{proposal_slug}' not found")
|
|
|
|
# Check PDP
|
|
await require_permission(
|
|
action="DAO_READ",
|
|
resource={"type": "DAO", "id": dao.id},
|
|
context={},
|
|
actor=actor
|
|
)
|
|
|
|
return await vote_repo.list_votes_for_proposal(proposal.id)
|
|
|
|
@router.post("/{slug}/proposals/{proposal_slug}/votes", response_model=VoteRead)
|
|
async def cast_vote(
|
|
slug: str,
|
|
proposal_slug: str,
|
|
data: VoteCreate,
|
|
actor: dict = Depends(get_actor_from_token)
|
|
):
|
|
"""Cast vote on a proposal"""
|
|
dao = await dao_repo.get_dao_by_slug(slug)
|
|
if not dao:
|
|
raise HTTPException(status_code=404, detail=f"DAO '{slug}' not found")
|
|
|
|
proposal = await proposal_repo.get_proposal_by_slug(dao.id, proposal_slug)
|
|
if not proposal:
|
|
raise HTTPException(status_code=404, detail=f"Proposal '{proposal_slug}' not found")
|
|
|
|
user_id = actor.get("user_id")
|
|
if not user_id:
|
|
raise HTTPException(status_code=401, detail="Invalid actor identity")
|
|
|
|
# Check PDP
|
|
await require_permission(
|
|
action="DAO_VOTE",
|
|
resource={"type": "DAO", "id": dao.id},
|
|
context={"operation": "cast_vote"},
|
|
actor=actor
|
|
)
|
|
|
|
# Check if proposal is active
|
|
if proposal.status != "active":
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Cannot vote on proposal with status '{proposal.status}'"
|
|
)
|
|
|
|
# Check if voting period has ended
|
|
if proposal.end_at and datetime.now() > proposal.end_at:
|
|
raise HTTPException(status_code=400, detail="Voting period has ended")
|
|
|
|
# Calculate voting power
|
|
voting_power = await governance_engine.calculate_voting_power(
|
|
user_id,
|
|
dao,
|
|
Decimal('1') # For MVP, base power is 1
|
|
)
|
|
|
|
# Cast vote
|
|
vote = await vote_repo.create_or_update_vote(
|
|
proposal.id,
|
|
user_id,
|
|
data.vote_value,
|
|
voting_power,
|
|
Decimal('1') # raw power
|
|
)
|
|
|
|
# Publish NATS event
|
|
if nats_publisher:
|
|
await nats_publisher.publish(
|
|
"dao.event.vote_cast",
|
|
{
|
|
"dao_id": dao.id,
|
|
"proposal_id": proposal.id,
|
|
"vote_id": vote.id,
|
|
"voter_user_id": user_id,
|
|
"vote_value": data.vote_value,
|
|
"weight": str(voting_power),
|
|
"ts": vote.created_at.isoformat()
|
|
}
|
|
)
|
|
|
|
return vote
|
|
|
|
# ============================================================================
|
|
# Helpers
|
|
# ============================================================================
|
|
|
|
def set_dao_repository(repo):
|
|
"""Set DAO repository"""
|
|
global dao_repo
|
|
dao_repo = repo
|
|
|
|
def set_proposal_repository(repo):
|
|
"""Set proposal repository"""
|
|
global proposal_repo
|
|
proposal_repo = repo
|
|
|
|
def set_vote_repository(repo):
|
|
"""Set vote repository"""
|
|
global vote_repo
|
|
vote_repo = repo
|
|
|
|
def set_governance_engine(engine):
|
|
"""Set governance engine"""
|
|
global governance_engine
|
|
governance_engine = engine
|
|
|
|
def set_nats_publisher(publisher):
|
|
"""Set NATS publisher"""
|
|
global nats_publisher
|
|
nats_publisher = publisher
|
|
|