Files
microdao-daarion/services/dao-service/routes_proposals.py
Apple 3de3c8cb36 feat: Add presence heartbeat for Matrix online status
- 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
2025-11-27 00:19:40 -08:00

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