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
This commit is contained in:
366
services/dao-service/routes_proposals.py
Normal file
366
services/dao-service/routes_proposals.py
Normal file
@@ -0,0 +1,366 @@
|
||||
"""
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user