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:
13
services/microdao-service/Dockerfile
Normal file
13
services/microdao-service/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 7015
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
172
services/microdao-service/main.py
Normal file
172
services/microdao-service/main.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
DAARION MicroDAO Service — Phase 7
|
||||
Port: 7015
|
||||
MicroDAO Console — Backend Complete
|
||||
"""
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import asyncpg
|
||||
|
||||
# Import routes
|
||||
import routes_microdao
|
||||
import routes_members
|
||||
import routes_treasury
|
||||
import routes_settings
|
||||
|
||||
# Import repository and NATS
|
||||
from repository_microdao import MicrodaoRepository
|
||||
from nats_client import NATSPublisher
|
||||
|
||||
# ============================================================================
|
||||
# Configuration
|
||||
# ============================================================================
|
||||
|
||||
PORT = int(os.getenv("PORT", "7015"))
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/daarion")
|
||||
NATS_URL = os.getenv("NATS_URL", "nats://localhost:4222")
|
||||
|
||||
# ============================================================================
|
||||
# Lifespan — Startup & Shutdown
|
||||
# ============================================================================
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""
|
||||
Startup:
|
||||
- Connect to PostgreSQL
|
||||
- Initialize repository
|
||||
- Connect to NATS
|
||||
- Set up routes with dependencies
|
||||
|
||||
Shutdown:
|
||||
- Close DB connection
|
||||
- Close NATS connection
|
||||
"""
|
||||
print("🚀 MicroDAO Service starting...")
|
||||
|
||||
# Connect to PostgreSQL
|
||||
try:
|
||||
db_pool = await asyncpg.create_pool(DATABASE_URL, min_size=2, max_size=10)
|
||||
print(f"✅ PostgreSQL connected")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to connect to PostgreSQL: {e}")
|
||||
raise
|
||||
|
||||
app.state.db_pool = db_pool
|
||||
|
||||
# Initialize repository
|
||||
repo = MicrodaoRepository(db_pool)
|
||||
app.state.repo = repo
|
||||
|
||||
# Set repository for all routes
|
||||
routes_microdao.set_repository(repo)
|
||||
routes_members.set_repository(repo)
|
||||
routes_treasury.set_repository(repo)
|
||||
routes_settings.set_repository(repo)
|
||||
|
||||
# Connect to NATS
|
||||
nats_pub = NATSPublisher(NATS_URL)
|
||||
try:
|
||||
await nats_pub.connect()
|
||||
app.state.nats_pub = nats_pub
|
||||
|
||||
# Set NATS publisher for all routes
|
||||
async def publish_wrapper(subject: str, payload: dict):
|
||||
await nats_pub.publish(subject, payload)
|
||||
|
||||
routes_microdao.set_nats_publisher(publish_wrapper)
|
||||
routes_members.set_nats_publisher(publish_wrapper)
|
||||
routes_treasury.set_nats_publisher(publish_wrapper)
|
||||
routes_settings.set_nats_publisher(publish_wrapper)
|
||||
|
||||
print("✅ NATS publisher configured")
|
||||
except Exception as e:
|
||||
print(f"⚠️ NATS connection failed: {e}")
|
||||
print("⚠️ Service will run without NATS events")
|
||||
app.state.nats_pub = None
|
||||
|
||||
print(f"🎉 MicroDAO Service ready on port {PORT}")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
if hasattr(app.state, 'nats_pub') and app.state.nats_pub:
|
||||
await app.state.nats_pub.close()
|
||||
|
||||
await db_pool.close()
|
||||
print("✅ MicroDAO Service stopped")
|
||||
|
||||
app = FastAPI(
|
||||
title="DAARION MicroDAO Service",
|
||||
description="MicroDAO Console (MVP)",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include routers
|
||||
app.include_router(routes_microdao.router)
|
||||
app.include_router(routes_members.router)
|
||||
app.include_router(routes_treasury.router)
|
||||
app.include_router(routes_settings.router)
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {
|
||||
"service": "microdao-service",
|
||||
"version": "1.0.0",
|
||||
"status": "healthy",
|
||||
"phase": "7"
|
||||
}
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"service": "DAARION MicroDAO Service",
|
||||
"version": "1.0.0",
|
||||
"phase": "7",
|
||||
"endpoints": {
|
||||
"health": "/health",
|
||||
"microdaos": "/microdao",
|
||||
"members": "/microdao/{slug}/members",
|
||||
"treasury": "/microdao/{slug}/treasury"
|
||||
}
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
print(f"""
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ 🏛️ DAARION MICRODAO SERVICE — PHASE 7 🏛️ ║
|
||||
║ ║
|
||||
║ Port: {PORT:<50} ║
|
||||
║ Database: PostgreSQL ║
|
||||
║ ║
|
||||
║ Features: ║
|
||||
║ ✅ MicroDAO CRUD ║
|
||||
║ ✅ Member management ║
|
||||
║ ✅ Treasury tracking ║
|
||||
║ ✅ Settings & roles ║
|
||||
║ ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
""")
|
||||
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host="0.0.0.0",
|
||||
port=PORT,
|
||||
reload=False,
|
||||
log_level="info"
|
||||
)
|
||||
|
||||
83
services/microdao-service/models.py
Normal file
83
services/microdao-service/models.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
MicroDAO Service Models
|
||||
Phase 7: microDAO Console (MVP)
|
||||
"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Any
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
# ============================================================================
|
||||
# MicroDAO Models
|
||||
# ============================================================================
|
||||
|
||||
class MicrodaoBase(BaseModel):
|
||||
slug: str = Field(..., min_length=3, max_length=50)
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
description: Optional[str] = None
|
||||
|
||||
class MicrodaoCreate(MicrodaoBase):
|
||||
pass
|
||||
|
||||
class MicrodaoUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class MicrodaoRead(MicrodaoBase):
|
||||
id: str
|
||||
external_id: str
|
||||
owner_user_id: str
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
member_count: int = 0
|
||||
agent_count: int = 0
|
||||
|
||||
# ============================================================================
|
||||
# Member Models
|
||||
# ============================================================================
|
||||
|
||||
class MemberRole(str):
|
||||
OWNER = "owner"
|
||||
ADMIN = "admin"
|
||||
MEMBER = "member"
|
||||
GUEST = "guest"
|
||||
|
||||
class MicrodaoMember(BaseModel):
|
||||
id: str
|
||||
microdao_id: str
|
||||
user_id: str
|
||||
role: str
|
||||
joined_at: datetime
|
||||
|
||||
class MemberAdd(BaseModel):
|
||||
user_id: str
|
||||
role: str = "member"
|
||||
|
||||
class MemberUpdateRole(BaseModel):
|
||||
role: str
|
||||
|
||||
# ============================================================================
|
||||
# Treasury Models
|
||||
# ============================================================================
|
||||
|
||||
class TreasuryItem(BaseModel):
|
||||
token_symbol: str
|
||||
balance: Decimal
|
||||
|
||||
class TreasuryUpdate(BaseModel):
|
||||
token_symbol: str
|
||||
balance: Decimal
|
||||
|
||||
# ============================================================================
|
||||
# Settings Models
|
||||
# ============================================================================
|
||||
|
||||
class SettingItem(BaseModel):
|
||||
key: str
|
||||
value: dict | str | int | float | bool
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
settings: dict[str, Any]
|
||||
|
||||
44
services/microdao-service/nats_client.py
Normal file
44
services/microdao-service/nats_client.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
NATS Client — Publish microDAO events
|
||||
Phase 7: Backend Completion
|
||||
"""
|
||||
import json
|
||||
from typing import Optional, Dict, Any
|
||||
from nats.aio.client import Client as NATS
|
||||
|
||||
class NATSPublisher:
|
||||
def __init__(self, nats_url: str):
|
||||
self.nats_url = nats_url
|
||||
self.nc: Optional[NATS] = None
|
||||
|
||||
async def connect(self):
|
||||
"""Connect to NATS"""
|
||||
self.nc = NATS()
|
||||
await self.nc.connect(self.nats_url)
|
||||
print(f"✅ NATS connected: {self.nats_url}")
|
||||
|
||||
async def publish(self, subject: str, payload: Dict[str, Any]):
|
||||
"""
|
||||
Publish event to NATS
|
||||
|
||||
Args:
|
||||
subject: NATS subject (e.g., "microdao.event.created")
|
||||
payload: Event payload as dict
|
||||
"""
|
||||
if not self.nc:
|
||||
print(f"⚠️ NATS not connected, skipping publish to {subject}")
|
||||
return
|
||||
|
||||
try:
|
||||
message = json.dumps(payload).encode()
|
||||
await self.nc.publish(subject, message)
|
||||
print(f"📤 Published to {subject}: {payload.get('microdao_id', 'unknown')}")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to publish to {subject}: {e}")
|
||||
|
||||
async def close(self):
|
||||
"""Close NATS connection"""
|
||||
if self.nc:
|
||||
await self.nc.close()
|
||||
print("✅ NATS connection closed")
|
||||
|
||||
463
services/microdao-service/repository_microdao.py
Normal file
463
services/microdao-service/repository_microdao.py
Normal file
@@ -0,0 +1,463 @@
|
||||
"""
|
||||
MicroDAO Repository — Database operations
|
||||
Phase 7: Backend Completion
|
||||
"""
|
||||
import uuid
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
import asyncpg
|
||||
from models import (
|
||||
MicrodaoCreate, MicrodaoUpdate, MicrodaoRead,
|
||||
MicrodaoMember, MemberAdd,
|
||||
TreasuryItem, TreasuryUpdate,
|
||||
SettingItem
|
||||
)
|
||||
|
||||
class MicrodaoRepository:
|
||||
def __init__(self, db_pool: asyncpg.Pool):
|
||||
self.db = db_pool
|
||||
|
||||
# ========================================================================
|
||||
# MicroDAO — CRUD
|
||||
# ========================================================================
|
||||
|
||||
async def create_microdao(
|
||||
self,
|
||||
data: MicrodaoCreate,
|
||||
owner_user_id: str
|
||||
) -> MicrodaoRead:
|
||||
"""Create new microDAO and add owner as member"""
|
||||
microdao_id = uuid.uuid4()
|
||||
external_id = f"microdao:{microdao_id.hex[:8]}"
|
||||
|
||||
async with self.db.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
# Insert microDAO
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO microdaos (id, external_id, slug, name, description, owner_user_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
""",
|
||||
microdao_id,
|
||||
external_id,
|
||||
data.slug,
|
||||
data.name,
|
||||
data.description,
|
||||
uuid.UUID(owner_user_id)
|
||||
)
|
||||
|
||||
# Add owner as member
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO microdao_members (microdao_id, user_id, role)
|
||||
VALUES ($1, $2, $3)
|
||||
""",
|
||||
microdao_id,
|
||||
uuid.UUID(owner_user_id),
|
||||
"owner"
|
||||
)
|
||||
|
||||
# Get created microDAO
|
||||
return await self._get_microdao_by_id(conn, microdao_id)
|
||||
|
||||
async def update_microdao(
|
||||
self,
|
||||
microdao_id: str,
|
||||
data: MicrodaoUpdate
|
||||
) -> Optional[MicrodaoRead]:
|
||||
"""Update microDAO"""
|
||||
# Build update query dynamically
|
||||
updates = []
|
||||
values = []
|
||||
param_idx = 1
|
||||
|
||||
if data.name is not None:
|
||||
updates.append(f"name = ${param_idx}")
|
||||
values.append(data.name)
|
||||
param_idx += 1
|
||||
|
||||
if data.description is not None:
|
||||
updates.append(f"description = ${param_idx}")
|
||||
values.append(data.description)
|
||||
param_idx += 1
|
||||
|
||||
if data.is_active is not None:
|
||||
updates.append(f"is_active = ${param_idx}")
|
||||
values.append(data.is_active)
|
||||
param_idx += 1
|
||||
|
||||
if not updates:
|
||||
return await self.get_microdao_by_id(microdao_id)
|
||||
|
||||
updates.append(f"updated_at = NOW()")
|
||||
values.append(uuid.UUID(microdao_id))
|
||||
|
||||
query = f"""
|
||||
UPDATE microdaos
|
||||
SET {', '.join(updates)}
|
||||
WHERE id = ${param_idx}
|
||||
RETURNING id
|
||||
"""
|
||||
|
||||
async with self.db.acquire() as conn:
|
||||
row = await conn.fetchrow(query, *values)
|
||||
if not row:
|
||||
return None
|
||||
return await self._get_microdao_by_id(conn, row['id'])
|
||||
|
||||
async def delete_microdao(self, microdao_id: str) -> bool:
|
||||
"""Soft delete microDAO (set is_active = false)"""
|
||||
result = await self.db.execute(
|
||||
"""
|
||||
UPDATE microdaos
|
||||
SET is_active = false, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
""",
|
||||
uuid.UUID(microdao_id)
|
||||
)
|
||||
return result == "UPDATE 1"
|
||||
|
||||
async def get_microdao_by_slug(self, slug: str) -> Optional[MicrodaoRead]:
|
||||
"""Get microDAO by slug"""
|
||||
async with self.db.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT m.id, m.external_id, m.slug, m.name, m.description,
|
||||
m.owner_user_id, m.is_active, m.created_at, m.updated_at,
|
||||
COUNT(DISTINCT mm.id) as member_count,
|
||||
COUNT(DISTINCT a.id) as agent_count
|
||||
FROM microdaos m
|
||||
LEFT JOIN microdao_members mm ON mm.microdao_id = m.id
|
||||
LEFT JOIN agents a ON a.microdao_id = m.id AND a.is_active = true
|
||||
WHERE m.slug = $1
|
||||
GROUP BY m.id
|
||||
""",
|
||||
slug
|
||||
)
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
return self._row_to_microdao(row)
|
||||
|
||||
async def get_microdao_by_id(self, microdao_id: str) -> Optional[MicrodaoRead]:
|
||||
"""Get microDAO by ID"""
|
||||
async with self.db.acquire() as conn:
|
||||
return await self._get_microdao_by_id(conn, uuid.UUID(microdao_id))
|
||||
|
||||
async def _get_microdao_by_id(
|
||||
self,
|
||||
conn: asyncpg.Connection,
|
||||
microdao_id: uuid.UUID
|
||||
) -> Optional[MicrodaoRead]:
|
||||
"""Internal: get microDAO by ID within transaction"""
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT m.id, m.external_id, m.slug, m.name, m.description,
|
||||
m.owner_user_id, m.is_active, m.created_at, m.updated_at,
|
||||
COUNT(DISTINCT mm.id) as member_count,
|
||||
COUNT(DISTINCT a.id) as agent_count
|
||||
FROM microdaos m
|
||||
LEFT JOIN microdao_members mm ON mm.microdao_id = m.id
|
||||
LEFT JOIN agents a ON a.microdao_id = m.id AND a.is_active = true
|
||||
WHERE m.id = $1
|
||||
GROUP BY m.id
|
||||
""",
|
||||
microdao_id
|
||||
)
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
return self._row_to_microdao(row)
|
||||
|
||||
async def list_microdaos(self) -> List[MicrodaoRead]:
|
||||
"""List all active microDAOs"""
|
||||
rows = await self.db.fetch(
|
||||
"""
|
||||
SELECT m.id, m.external_id, m.slug, m.name, m.description,
|
||||
m.owner_user_id, m.is_active, m.created_at, m.updated_at,
|
||||
COUNT(DISTINCT mm.id) as member_count,
|
||||
COUNT(DISTINCT a.id) as agent_count
|
||||
FROM microdaos m
|
||||
LEFT JOIN microdao_members mm ON mm.microdao_id = m.id
|
||||
LEFT JOIN agents a ON a.microdao_id = m.id AND a.is_active = true
|
||||
WHERE m.is_active = true
|
||||
GROUP BY m.id
|
||||
ORDER BY m.created_at DESC
|
||||
"""
|
||||
)
|
||||
|
||||
return [self._row_to_microdao(row) for row in rows]
|
||||
|
||||
async def list_microdaos_for_user(self, user_id: str) -> List[MicrodaoRead]:
|
||||
"""List microDAOs where user is a member"""
|
||||
rows = await self.db.fetch(
|
||||
"""
|
||||
SELECT m.id, m.external_id, m.slug, m.name, m.description,
|
||||
m.owner_user_id, m.is_active, m.created_at, m.updated_at,
|
||||
COUNT(DISTINCT mm.id) as member_count,
|
||||
COUNT(DISTINCT a.id) as agent_count
|
||||
FROM microdaos m
|
||||
INNER JOIN microdao_members mm ON mm.microdao_id = m.id
|
||||
LEFT JOIN agents a ON a.microdao_id = m.id AND a.is_active = true
|
||||
WHERE mm.user_id = $1 AND m.is_active = true
|
||||
GROUP BY m.id
|
||||
ORDER BY m.created_at DESC
|
||||
""",
|
||||
uuid.UUID(user_id)
|
||||
)
|
||||
|
||||
return [self._row_to_microdao(row) for row in rows]
|
||||
|
||||
def _row_to_microdao(self, row: asyncpg.Record) -> MicrodaoRead:
|
||||
"""Convert database row to MicrodaoRead"""
|
||||
return MicrodaoRead(
|
||||
id=str(row['id']),
|
||||
external_id=row['external_id'],
|
||||
slug=row['slug'],
|
||||
name=row['name'],
|
||||
description=row['description'],
|
||||
owner_user_id=str(row['owner_user_id']),
|
||||
is_active=row['is_active'],
|
||||
created_at=row['created_at'],
|
||||
updated_at=row['updated_at'],
|
||||
member_count=row['member_count'] or 0,
|
||||
agent_count=row['agent_count'] or 0
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Members
|
||||
# ========================================================================
|
||||
|
||||
async def list_members(self, microdao_id: str) -> List[MicrodaoMember]:
|
||||
"""List all members of a microDAO"""
|
||||
rows = await self.db.fetch(
|
||||
"""
|
||||
SELECT id, microdao_id, user_id, role, joined_at
|
||||
FROM microdao_members
|
||||
WHERE microdao_id = $1
|
||||
ORDER BY joined_at ASC
|
||||
""",
|
||||
uuid.UUID(microdao_id)
|
||||
)
|
||||
|
||||
return [
|
||||
MicrodaoMember(
|
||||
id=str(row['id']),
|
||||
microdao_id=str(row['microdao_id']),
|
||||
user_id=str(row['user_id']),
|
||||
role=row['role'],
|
||||
joined_at=row['joined_at']
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
async def add_member(
|
||||
self,
|
||||
microdao_id: str,
|
||||
user_id: str,
|
||||
role: str = "member"
|
||||
) -> MicrodaoMember:
|
||||
"""Add member to microDAO"""
|
||||
member_id = uuid.uuid4()
|
||||
|
||||
row = await self.db.fetchrow(
|
||||
"""
|
||||
INSERT INTO microdao_members (id, microdao_id, user_id, role)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (microdao_id, user_id) DO UPDATE
|
||||
SET role = EXCLUDED.role
|
||||
RETURNING id, microdao_id, user_id, role, joined_at
|
||||
""",
|
||||
member_id,
|
||||
uuid.UUID(microdao_id),
|
||||
uuid.UUID(user_id),
|
||||
role
|
||||
)
|
||||
|
||||
return MicrodaoMember(
|
||||
id=str(row['id']),
|
||||
microdao_id=str(row['microdao_id']),
|
||||
user_id=str(row['user_id']),
|
||||
role=row['role'],
|
||||
joined_at=row['joined_at']
|
||||
)
|
||||
|
||||
async def remove_member(self, member_id: str) -> bool:
|
||||
"""Remove member from microDAO"""
|
||||
result = await self.db.execute(
|
||||
"""
|
||||
DELETE FROM microdao_members
|
||||
WHERE id = $1
|
||||
""",
|
||||
uuid.UUID(member_id)
|
||||
)
|
||||
return result == "DELETE 1"
|
||||
|
||||
async def get_user_role_in_microdao(
|
||||
self,
|
||||
microdao_id: str,
|
||||
user_id: str
|
||||
) -> Optional[str]:
|
||||
"""Get user's role in microDAO (or None if not a member)"""
|
||||
row = await self.db.fetchrow(
|
||||
"""
|
||||
SELECT role FROM microdao_members
|
||||
WHERE microdao_id = $1 AND user_id = $2
|
||||
""",
|
||||
uuid.UUID(microdao_id),
|
||||
uuid.UUID(user_id)
|
||||
)
|
||||
return row['role'] if row else None
|
||||
|
||||
# ========================================================================
|
||||
# Treasury
|
||||
# ========================================================================
|
||||
|
||||
async def get_treasury_items(self, microdao_id: str) -> List[TreasuryItem]:
|
||||
"""Get all treasury items for a microDAO"""
|
||||
rows = await self.db.fetch(
|
||||
"""
|
||||
SELECT token_symbol, balance
|
||||
FROM microdao_treasury
|
||||
WHERE microdao_id = $1
|
||||
ORDER BY token_symbol
|
||||
""",
|
||||
uuid.UUID(microdao_id)
|
||||
)
|
||||
|
||||
return [
|
||||
TreasuryItem(
|
||||
token_symbol=row['token_symbol'],
|
||||
balance=row['balance']
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
async def apply_treasury_delta(
|
||||
self,
|
||||
microdao_id: str,
|
||||
token_symbol: str,
|
||||
delta: Decimal
|
||||
) -> TreasuryItem:
|
||||
"""
|
||||
Apply delta to treasury balance
|
||||
Creates entry if doesn't exist
|
||||
Raises error if resulting balance would be negative
|
||||
"""
|
||||
async with self.db.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
# Get current balance
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT balance FROM microdao_treasury
|
||||
WHERE microdao_id = $1 AND token_symbol = $2
|
||||
FOR UPDATE
|
||||
""",
|
||||
uuid.UUID(microdao_id),
|
||||
token_symbol
|
||||
)
|
||||
|
||||
current_balance = row['balance'] if row else Decimal('0')
|
||||
new_balance = current_balance + delta
|
||||
|
||||
if new_balance < 0:
|
||||
raise ValueError(f"Insufficient balance: {current_balance} + {delta} = {new_balance} < 0")
|
||||
|
||||
# Upsert
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO microdao_treasury (microdao_id, token_symbol, balance, updated_at)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
ON CONFLICT (microdao_id, token_symbol) DO UPDATE
|
||||
SET balance = $3, updated_at = NOW()
|
||||
RETURNING token_symbol, balance
|
||||
""",
|
||||
uuid.UUID(microdao_id),
|
||||
token_symbol,
|
||||
new_balance
|
||||
)
|
||||
|
||||
return TreasuryItem(
|
||||
token_symbol=row['token_symbol'],
|
||||
balance=row['balance']
|
||||
)
|
||||
|
||||
async def set_treasury_balance(
|
||||
self,
|
||||
microdao_id: str,
|
||||
token_symbol: str,
|
||||
balance: Decimal
|
||||
) -> TreasuryItem:
|
||||
"""Set treasury balance directly (for admin operations)"""
|
||||
if balance < 0:
|
||||
raise ValueError(f"Balance cannot be negative: {balance}")
|
||||
|
||||
row = await self.db.fetchrow(
|
||||
"""
|
||||
INSERT INTO microdao_treasury (microdao_id, token_symbol, balance, updated_at)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
ON CONFLICT (microdao_id, token_symbol) DO UPDATE
|
||||
SET balance = $3, updated_at = NOW()
|
||||
RETURNING token_symbol, balance
|
||||
""",
|
||||
uuid.UUID(microdao_id),
|
||||
token_symbol,
|
||||
balance
|
||||
)
|
||||
|
||||
return TreasuryItem(
|
||||
token_symbol=row['token_symbol'],
|
||||
balance=row['balance']
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Settings
|
||||
# ========================================================================
|
||||
|
||||
async def get_settings(self, microdao_id: str) -> Dict[str, Any]:
|
||||
"""Get all settings for a microDAO as dict"""
|
||||
rows = await self.db.fetch(
|
||||
"""
|
||||
SELECT key, value
|
||||
FROM microdao_settings
|
||||
WHERE microdao_id = $1
|
||||
""",
|
||||
uuid.UUID(microdao_id)
|
||||
)
|
||||
|
||||
return {row['key']: row['value'] for row in rows}
|
||||
|
||||
async def upsert_setting(
|
||||
self,
|
||||
microdao_id: str,
|
||||
key: str,
|
||||
value: Any
|
||||
) -> None:
|
||||
"""Upsert a single setting"""
|
||||
await self.db.execute(
|
||||
"""
|
||||
INSERT INTO microdao_settings (microdao_id, key, value, updated_at)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
ON CONFLICT (microdao_id, key) DO UPDATE
|
||||
SET value = $3, updated_at = NOW()
|
||||
""",
|
||||
uuid.UUID(microdao_id),
|
||||
key,
|
||||
value
|
||||
)
|
||||
|
||||
async def delete_setting(self, microdao_id: str, key: str) -> bool:
|
||||
"""Delete a setting"""
|
||||
result = await self.db.execute(
|
||||
"""
|
||||
DELETE FROM microdao_settings
|
||||
WHERE microdao_id = $1 AND key = $2
|
||||
""",
|
||||
uuid.UUID(microdao_id),
|
||||
key
|
||||
)
|
||||
return result == "DELETE 1"
|
||||
|
||||
8
services/microdao-service/requirements.txt
Normal file
8
services/microdao-service/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn==0.24.0
|
||||
pydantic==2.5.0
|
||||
asyncpg==0.29.0
|
||||
httpx==0.25.1
|
||||
python-dotenv==1.0.0
|
||||
nats-py==2.6.0
|
||||
|
||||
320
services/microdao-service/routes_members.py
Normal file
320
services/microdao-service/routes_members.py
Normal file
@@ -0,0 +1,320 @@
|
||||
"""
|
||||
MicroDAO Members Routes
|
||||
Phase 7: Backend Completion
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Header
|
||||
from typing import List, Optional
|
||||
import httpx
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from models import MicrodaoMember, MemberAdd, MemberUpdateRole
|
||||
from repository_microdao import MicrodaoRepository
|
||||
|
||||
router = APIRouter(tags=["members"])
|
||||
|
||||
# Dependency injection (will be set in main.py)
|
||||
repo: Optional[MicrodaoRepository] = None
|
||||
|
||||
# Service URLs
|
||||
AUTH_SERVICE_URL = os.getenv("AUTH_SERVICE_URL", "http://localhost:7011")
|
||||
PDP_SERVICE_URL = os.getenv("PDP_SERVICE_URL", "http://localhost:7012")
|
||||
|
||||
# NATS publisher
|
||||
nats_publisher = None
|
||||
|
||||
# ============================================================================
|
||||
# Auth & PDP Helpers
|
||||
# ============================================================================
|
||||
|
||||
async def get_actor_from_token(authorization: Optional[str] = Header(None)):
|
||||
"""Get ActorIdentity from auth-service"""
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Missing or invalid authorization header")
|
||||
|
||||
token = authorization.replace("Bearer ", "")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{AUTH_SERVICE_URL}/auth/me",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPError:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
|
||||
async def check_pdp_permission(
|
||||
action: str,
|
||||
resource: dict,
|
||||
context: dict,
|
||||
actor: dict
|
||||
) -> bool:
|
||||
"""Check permission via pdp-service"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{PDP_SERVICE_URL}/internal/pdp/evaluate",
|
||||
json={
|
||||
"action": action,
|
||||
"resource": resource,
|
||||
"context": context,
|
||||
"actor": actor
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get("decision") == "ALLOW"
|
||||
except httpx.HTTPError as e:
|
||||
print(f"⚠️ PDP error: {e}")
|
||||
return False
|
||||
|
||||
# ============================================================================
|
||||
# Members — List
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/microdao/{slug}/members", response_model=List[MicrodaoMember])
|
||||
async def list_members(
|
||||
slug: str,
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""
|
||||
List members of a microDAO
|
||||
Requires: MICRODAO_READ permission
|
||||
"""
|
||||
actor = await get_actor_from_token(authorization)
|
||||
|
||||
# Get microDAO
|
||||
microdao = await repo.get_microdao_by_slug(slug)
|
||||
if not microdao:
|
||||
raise HTTPException(status_code=404, detail=f"MicroDAO '{slug}' not found")
|
||||
|
||||
# Check PDP
|
||||
allowed = await check_pdp_permission(
|
||||
action="MICRODAO_READ",
|
||||
resource={"type": "MICRODAO", "id": microdao.id},
|
||||
context={},
|
||||
actor=actor
|
||||
)
|
||||
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=403, detail="Permission denied: cannot view members")
|
||||
|
||||
return await repo.list_members(microdao.id)
|
||||
|
||||
# ============================================================================
|
||||
# Members — Add
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/microdao/{slug}/members", response_model=MicrodaoMember, status_code=201)
|
||||
async def add_member(
|
||||
slug: str,
|
||||
data: MemberAdd,
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""
|
||||
Add member to microDAO
|
||||
Requires: MICRODAO_MANAGE_MEMBERS permission
|
||||
"""
|
||||
actor = await get_actor_from_token(authorization)
|
||||
|
||||
# Get microDAO
|
||||
microdao = await repo.get_microdao_by_slug(slug)
|
||||
if not microdao:
|
||||
raise HTTPException(status_code=404, detail=f"MicroDAO '{slug}' not found")
|
||||
|
||||
# Check PDP
|
||||
allowed = await check_pdp_permission(
|
||||
action="MICRODAO_MANAGE_MEMBERS",
|
||||
resource={"type": "MICRODAO", "id": microdao.id},
|
||||
context={"operation": "add_member"},
|
||||
actor=actor
|
||||
)
|
||||
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=403, detail="Permission denied: cannot add members")
|
||||
|
||||
# Validate role
|
||||
valid_roles = ["owner", "admin", "member", "guest"]
|
||||
if data.role not in valid_roles:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid role '{data.role}'. Must be one of: {', '.join(valid_roles)}"
|
||||
)
|
||||
|
||||
# Add member
|
||||
member = await repo.add_member(microdao.id, data.user_id, data.role)
|
||||
|
||||
# Publish NATS event
|
||||
if nats_publisher:
|
||||
await nats_publisher(
|
||||
"microdao.event.member_added",
|
||||
{
|
||||
"microdao_id": microdao.id,
|
||||
"slug": microdao.slug,
|
||||
"member_id": member.id,
|
||||
"user_id": data.user_id,
|
||||
"role": data.role,
|
||||
"actor_id": actor.get("user_id"),
|
||||
"ts": datetime.now().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
return member
|
||||
|
||||
# ============================================================================
|
||||
# Members — Update Role
|
||||
# ============================================================================
|
||||
|
||||
@router.patch("/microdao/{slug}/members/{member_id}", response_model=MicrodaoMember)
|
||||
async def update_member_role(
|
||||
slug: str,
|
||||
member_id: str,
|
||||
data: MemberUpdateRole,
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""
|
||||
Update member role in microDAO
|
||||
Requires: MICRODAO_MANAGE_MEMBERS permission
|
||||
"""
|
||||
actor = await get_actor_from_token(authorization)
|
||||
|
||||
# Get microDAO
|
||||
microdao = await repo.get_microdao_by_slug(slug)
|
||||
if not microdao:
|
||||
raise HTTPException(status_code=404, detail=f"MicroDAO '{slug}' not found")
|
||||
|
||||
# Check PDP
|
||||
allowed = await check_pdp_permission(
|
||||
action="MICRODAO_MANAGE_MEMBERS",
|
||||
resource={"type": "MICRODAO", "id": microdao.id},
|
||||
context={"operation": "update_role"},
|
||||
actor=actor
|
||||
)
|
||||
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=403, detail="Permission denied: cannot update member roles")
|
||||
|
||||
# Validate role
|
||||
valid_roles = ["owner", "admin", "member", "guest"]
|
||||
if data.role not in valid_roles:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid role '{data.role}'. Must be one of: {', '.join(valid_roles)}"
|
||||
)
|
||||
|
||||
# Get current members to find user_id
|
||||
members = await repo.list_members(microdao.id)
|
||||
member = next((m for m in members if m.id == member_id), None)
|
||||
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail=f"Member '{member_id}' not found")
|
||||
|
||||
# Remove old member and add with new role (simple approach)
|
||||
await repo.remove_member(member_id)
|
||||
updated_member = await repo.add_member(microdao.id, member.user_id, data.role)
|
||||
|
||||
# Publish NATS event
|
||||
if nats_publisher:
|
||||
await nats_publisher(
|
||||
"microdao.event.member_role_updated",
|
||||
{
|
||||
"microdao_id": microdao.id,
|
||||
"slug": microdao.slug,
|
||||
"member_id": updated_member.id,
|
||||
"user_id": member.user_id,
|
||||
"old_role": member.role,
|
||||
"new_role": data.role,
|
||||
"actor_id": actor.get("user_id"),
|
||||
"ts": datetime.now().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
return updated_member
|
||||
|
||||
# ============================================================================
|
||||
# Members — Remove
|
||||
# ============================================================================
|
||||
|
||||
@router.delete("/microdao/{slug}/members/{member_id}", status_code=204)
|
||||
async def remove_member(
|
||||
slug: str,
|
||||
member_id: str,
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""
|
||||
Remove member from microDAO
|
||||
Requires: MICRODAO_MANAGE_MEMBERS permission
|
||||
"""
|
||||
actor = await get_actor_from_token(authorization)
|
||||
|
||||
# Get microDAO
|
||||
microdao = await repo.get_microdao_by_slug(slug)
|
||||
if not microdao:
|
||||
raise HTTPException(status_code=404, detail=f"MicroDAO '{slug}' not found")
|
||||
|
||||
# Check PDP
|
||||
allowed = await check_pdp_permission(
|
||||
action="MICRODAO_MANAGE_MEMBERS",
|
||||
resource={"type": "MICRODAO", "id": microdao.id},
|
||||
context={"operation": "remove_member"},
|
||||
actor=actor
|
||||
)
|
||||
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=403, detail="Permission denied: cannot remove members")
|
||||
|
||||
# Get member info before removal
|
||||
members = await repo.list_members(microdao.id)
|
||||
member = next((m for m in members if m.id == member_id), None)
|
||||
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail=f"Member '{member_id}' not found")
|
||||
|
||||
# Prevent removing the last owner
|
||||
if member.role == "owner":
|
||||
owners = [m for m in members if m.role == "owner"]
|
||||
if len(owners) <= 1:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot remove the last owner. Transfer ownership first."
|
||||
)
|
||||
|
||||
# Remove member
|
||||
success = await repo.remove_member(member_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to remove member")
|
||||
|
||||
# Publish NATS event
|
||||
if nats_publisher:
|
||||
await nats_publisher(
|
||||
"microdao.event.member_removed",
|
||||
{
|
||||
"microdao_id": microdao.id,
|
||||
"slug": microdao.slug,
|
||||
"member_id": member_id,
|
||||
"user_id": member.user_id,
|
||||
"role": member.role,
|
||||
"actor_id": actor.get("user_id"),
|
||||
"ts": datetime.now().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
# ============================================================================
|
||||
# Helper: Set repository
|
||||
# ============================================================================
|
||||
|
||||
def set_repository(repository: MicrodaoRepository):
|
||||
"""Set repository instance (called from main.py)"""
|
||||
global repo
|
||||
repo = repository
|
||||
|
||||
def set_nats_publisher(publisher_func):
|
||||
"""Set NATS publisher function (called from main.py)"""
|
||||
global nats_publisher
|
||||
nats_publisher = publisher_func
|
||||
|
||||
300
services/microdao-service/routes_microdao.py
Normal file
300
services/microdao-service/routes_microdao.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""
|
||||
MicroDAO CRUD Routes
|
||||
Phase 7: Backend Completion
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Header
|
||||
from typing import List, Optional
|
||||
import httpx
|
||||
import os
|
||||
|
||||
from models import MicrodaoCreate, MicrodaoUpdate, MicrodaoRead
|
||||
from repository_microdao import MicrodaoRepository
|
||||
|
||||
router = APIRouter(prefix="/microdao", tags=["microdao"])
|
||||
|
||||
# Dependency injection (will be set in main.py)
|
||||
repo: Optional[MicrodaoRepository] = None
|
||||
|
||||
# Service URLs
|
||||
AUTH_SERVICE_URL = os.getenv("AUTH_SERVICE_URL", "http://localhost:7011")
|
||||
PDP_SERVICE_URL = os.getenv("PDP_SERVICE_URL", "http://localhost:7012")
|
||||
|
||||
# NATS publisher (will be set in main.py)
|
||||
nats_publisher = None
|
||||
|
||||
# ============================================================================
|
||||
# Auth & PDP Helpers
|
||||
# ============================================================================
|
||||
|
||||
async def get_actor_from_token(authorization: Optional[str] = Header(None)):
|
||||
"""
|
||||
Get ActorIdentity from auth-service
|
||||
Returns actor dict or raises 401
|
||||
"""
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Missing or invalid authorization header")
|
||||
|
||||
token = authorization.replace("Bearer ", "")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{AUTH_SERVICE_URL}/auth/me",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPError:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
|
||||
async def check_pdp_permission(
|
||||
action: str,
|
||||
resource: dict,
|
||||
context: dict,
|
||||
actor: dict
|
||||
) -> bool:
|
||||
"""
|
||||
Check permission via pdp-service
|
||||
Returns True if allowed, False otherwise
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{PDP_SERVICE_URL}/internal/pdp/evaluate",
|
||||
json={
|
||||
"action": action,
|
||||
"resource": resource,
|
||||
"context": context,
|
||||
"actor": actor
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get("decision") == "ALLOW"
|
||||
except httpx.HTTPError as e:
|
||||
print(f"⚠️ PDP error: {e}")
|
||||
return False # Fail closed
|
||||
|
||||
# ============================================================================
|
||||
# CRUD — List
|
||||
# ============================================================================
|
||||
|
||||
@router.get("", response_model=List[MicrodaoRead])
|
||||
async def list_microdaos(authorization: Optional[str] = Header(None)):
|
||||
"""
|
||||
List microDAOs where current user is a member
|
||||
Requires: valid auth token
|
||||
"""
|
||||
actor = await get_actor_from_token(authorization)
|
||||
user_id = actor.get("user_id")
|
||||
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid actor identity")
|
||||
|
||||
return await repo.list_microdaos_for_user(user_id)
|
||||
|
||||
# ============================================================================
|
||||
# CRUD — Create
|
||||
# ============================================================================
|
||||
|
||||
@router.post("", response_model=MicrodaoRead, status_code=201)
|
||||
async def create_microdao(
|
||||
data: MicrodaoCreate,
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""
|
||||
Create new microDAO
|
||||
Requires: MICRODAO_CREATE permission
|
||||
"""
|
||||
actor = await get_actor_from_token(authorization)
|
||||
user_id = actor.get("user_id")
|
||||
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid actor identity")
|
||||
|
||||
# Check PDP
|
||||
allowed = await check_pdp_permission(
|
||||
action="MICRODAO_CREATE",
|
||||
resource={"type": "MICRODAO"},
|
||||
context={"operation": "create"},
|
||||
actor=actor
|
||||
)
|
||||
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=403, detail="Permission denied: cannot create microDAO")
|
||||
|
||||
# Check if slug already exists
|
||||
existing = await repo.get_microdao_by_slug(data.slug)
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail=f"MicroDAO with slug '{data.slug}' already exists")
|
||||
|
||||
# Create microDAO
|
||||
microdao = await repo.create_microdao(data, user_id)
|
||||
|
||||
# Publish NATS event
|
||||
if nats_publisher:
|
||||
await nats_publisher(
|
||||
"microdao.event.created",
|
||||
{
|
||||
"microdao_id": microdao.id,
|
||||
"slug": microdao.slug,
|
||||
"name": microdao.name,
|
||||
"owner_user_id": user_id,
|
||||
"actor_id": user_id,
|
||||
"ts": microdao.created_at.isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
return microdao
|
||||
|
||||
# ============================================================================
|
||||
# CRUD — Read (by slug)
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/{slug}", response_model=MicrodaoRead)
|
||||
async def get_microdao(
|
||||
slug: str,
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""
|
||||
Get microDAO by slug
|
||||
Requires: MICRODAO_READ permission
|
||||
"""
|
||||
actor = await get_actor_from_token(authorization)
|
||||
|
||||
# Get microDAO
|
||||
microdao = await repo.get_microdao_by_slug(slug)
|
||||
if not microdao:
|
||||
raise HTTPException(status_code=404, detail=f"MicroDAO '{slug}' not found")
|
||||
|
||||
# Check PDP
|
||||
allowed = await check_pdp_permission(
|
||||
action="MICRODAO_READ",
|
||||
resource={"type": "MICRODAO", "id": microdao.id},
|
||||
context={},
|
||||
actor=actor
|
||||
)
|
||||
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=403, detail="Permission denied: cannot read this microDAO")
|
||||
|
||||
return microdao
|
||||
|
||||
# ============================================================================
|
||||
# CRUD — Update
|
||||
# ============================================================================
|
||||
|
||||
@router.put("/{slug}", response_model=MicrodaoRead)
|
||||
async def update_microdao(
|
||||
slug: str,
|
||||
data: MicrodaoUpdate,
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""
|
||||
Update microDAO
|
||||
Requires: MICRODAO_MANAGE permission
|
||||
"""
|
||||
actor = await get_actor_from_token(authorization)
|
||||
|
||||
# Get microDAO
|
||||
microdao = await repo.get_microdao_by_slug(slug)
|
||||
if not microdao:
|
||||
raise HTTPException(status_code=404, detail=f"MicroDAO '{slug}' not found")
|
||||
|
||||
# Check PDP
|
||||
allowed = await check_pdp_permission(
|
||||
action="MICRODAO_MANAGE",
|
||||
resource={"type": "MICRODAO", "id": microdao.id},
|
||||
context={"operation": "update"},
|
||||
actor=actor
|
||||
)
|
||||
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=403, detail="Permission denied: cannot manage this microDAO")
|
||||
|
||||
# Update
|
||||
updated = await repo.update_microdao(microdao.id, data)
|
||||
|
||||
if not updated:
|
||||
raise HTTPException(status_code=500, detail="Failed to update microDAO")
|
||||
|
||||
# Publish NATS event
|
||||
if nats_publisher:
|
||||
await nats_publisher(
|
||||
"microdao.event.updated",
|
||||
{
|
||||
"microdao_id": updated.id,
|
||||
"slug": updated.slug,
|
||||
"actor_id": actor.get("user_id"),
|
||||
"changes": data.model_dump(exclude_unset=True),
|
||||
"ts": updated.updated_at.isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
return updated
|
||||
|
||||
# ============================================================================
|
||||
# CRUD — Delete (Soft)
|
||||
# ============================================================================
|
||||
|
||||
@router.delete("/{slug}", status_code=204)
|
||||
async def delete_microdao(
|
||||
slug: str,
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""
|
||||
Soft delete microDAO (set is_active = false)
|
||||
Requires: MICRODAO_MANAGE permission
|
||||
"""
|
||||
actor = await get_actor_from_token(authorization)
|
||||
|
||||
# Get microDAO
|
||||
microdao = await repo.get_microdao_by_slug(slug)
|
||||
if not microdao:
|
||||
raise HTTPException(status_code=404, detail=f"MicroDAO '{slug}' not found")
|
||||
|
||||
# Check PDP
|
||||
allowed = await check_pdp_permission(
|
||||
action="MICRODAO_MANAGE",
|
||||
resource={"type": "MICRODAO", "id": microdao.id},
|
||||
context={"operation": "delete"},
|
||||
actor=actor
|
||||
)
|
||||
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=403, detail="Permission denied: cannot delete this microDAO")
|
||||
|
||||
# Delete
|
||||
success = await repo.delete_microdao(microdao.id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to delete microDAO")
|
||||
|
||||
# Publish NATS event
|
||||
if nats_publisher:
|
||||
await nats_publisher(
|
||||
"microdao.event.deleted",
|
||||
{
|
||||
"microdao_id": microdao.id,
|
||||
"slug": microdao.slug,
|
||||
"actor_id": actor.get("user_id"),
|
||||
"ts": datetime.now().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
# ============================================================================
|
||||
# Helper: Set repository
|
||||
# ============================================================================
|
||||
|
||||
def set_repository(repository: MicrodaoRepository):
|
||||
"""Set repository instance (called from main.py)"""
|
||||
global repo
|
||||
repo = repository
|
||||
|
||||
def set_nats_publisher(publisher_func):
|
||||
"""Set NATS publisher function (called from main.py)"""
|
||||
global nats_publisher
|
||||
nats_publisher = publisher_func
|
||||
|
||||
273
services/microdao-service/routes_settings.py
Normal file
273
services/microdao-service/routes_settings.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
MicroDAO Settings Routes
|
||||
Phase 7: Backend Completion
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Header
|
||||
from typing import Dict, Any, Optional
|
||||
import httpx
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from models import SettingItem
|
||||
from repository_microdao import MicrodaoRepository
|
||||
|
||||
router = APIRouter(tags=["settings"])
|
||||
|
||||
# Dependency injection (will be set in main.py)
|
||||
repo: Optional[MicrodaoRepository] = None
|
||||
|
||||
# Service URLs
|
||||
AUTH_SERVICE_URL = os.getenv("AUTH_SERVICE_URL", "http://localhost:7011")
|
||||
PDP_SERVICE_URL = os.getenv("PDP_SERVICE_URL", "http://localhost:7012")
|
||||
|
||||
# NATS publisher
|
||||
nats_publisher = None
|
||||
|
||||
# ============================================================================
|
||||
# Auth & PDP Helpers
|
||||
# ============================================================================
|
||||
|
||||
async def get_actor_from_token(authorization: Optional[str] = Header(None)):
|
||||
"""Get ActorIdentity from auth-service"""
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Missing or invalid authorization header")
|
||||
|
||||
token = authorization.replace("Bearer ", "")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{AUTH_SERVICE_URL}/auth/me",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPError:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
|
||||
async def check_pdp_permission(
|
||||
action: str,
|
||||
resource: dict,
|
||||
context: dict,
|
||||
actor: dict
|
||||
) -> bool:
|
||||
"""Check permission via pdp-service"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{PDP_SERVICE_URL}/internal/pdp/evaluate",
|
||||
json={
|
||||
"action": action,
|
||||
"resource": resource,
|
||||
"context": context,
|
||||
"actor": actor
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get("decision") == "ALLOW"
|
||||
except httpx.HTTPError as e:
|
||||
print(f"⚠️ PDP error: {e}")
|
||||
return False
|
||||
|
||||
# ============================================================================
|
||||
# Settings — Read
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/microdao/{slug}/settings", response_model=Dict[str, Any])
|
||||
async def get_settings(
|
||||
slug: str,
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""
|
||||
Get all settings for a microDAO
|
||||
Requires: MICRODAO_READ permission
|
||||
"""
|
||||
actor = await get_actor_from_token(authorization)
|
||||
|
||||
# Get microDAO
|
||||
microdao = await repo.get_microdao_by_slug(slug)
|
||||
if not microdao:
|
||||
raise HTTPException(status_code=404, detail=f"MicroDAO '{slug}' not found")
|
||||
|
||||
# Check PDP
|
||||
allowed = await check_pdp_permission(
|
||||
action="MICRODAO_READ",
|
||||
resource={"type": "MICRODAO", "id": microdao.id},
|
||||
context={},
|
||||
actor=actor
|
||||
)
|
||||
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=403, detail="Permission denied: cannot view settings")
|
||||
|
||||
return await repo.get_settings(microdao.id)
|
||||
|
||||
# ============================================================================
|
||||
# Settings — Update (Single Setting)
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/microdao/{slug}/settings", status_code=204)
|
||||
async def update_setting(
|
||||
slug: str,
|
||||
setting: SettingItem,
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""
|
||||
Update a single setting
|
||||
Requires: MICRODAO_MANAGE permission
|
||||
"""
|
||||
actor = await get_actor_from_token(authorization)
|
||||
|
||||
# Get microDAO
|
||||
microdao = await repo.get_microdao_by_slug(slug)
|
||||
if not microdao:
|
||||
raise HTTPException(status_code=404, detail=f"MicroDAO '{slug}' not found")
|
||||
|
||||
# Check PDP
|
||||
allowed = await check_pdp_permission(
|
||||
action="MICRODAO_MANAGE",
|
||||
resource={"type": "MICRODAO", "id": microdao.id},
|
||||
context={"operation": "update_settings"},
|
||||
actor=actor
|
||||
)
|
||||
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=403, detail="Permission denied: cannot manage settings")
|
||||
|
||||
# Update setting
|
||||
await repo.upsert_setting(microdao.id, setting.key, setting.value)
|
||||
|
||||
# Publish NATS event
|
||||
if nats_publisher:
|
||||
await nats_publisher(
|
||||
"microdao.event.settings_updated",
|
||||
{
|
||||
"microdao_id": microdao.id,
|
||||
"slug": microdao.slug,
|
||||
"setting_key": setting.key,
|
||||
"actor_id": actor.get("user_id"),
|
||||
"ts": datetime.now().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
# ============================================================================
|
||||
# Settings — Bulk Update
|
||||
# ============================================================================
|
||||
|
||||
@router.put("/microdao/{slug}/settings", status_code=204)
|
||||
async def update_settings_bulk(
|
||||
slug: str,
|
||||
settings: Dict[str, Any],
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""
|
||||
Update multiple settings at once
|
||||
Requires: MICRODAO_MANAGE permission
|
||||
"""
|
||||
actor = await get_actor_from_token(authorization)
|
||||
|
||||
# Get microDAO
|
||||
microdao = await repo.get_microdao_by_slug(slug)
|
||||
if not microdao:
|
||||
raise HTTPException(status_code=404, detail=f"MicroDAO '{slug}' not found")
|
||||
|
||||
# Check PDP
|
||||
allowed = await check_pdp_permission(
|
||||
action="MICRODAO_MANAGE",
|
||||
resource={"type": "MICRODAO", "id": microdao.id},
|
||||
context={"operation": "update_settings"},
|
||||
actor=actor
|
||||
)
|
||||
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=403, detail="Permission denied: cannot manage settings")
|
||||
|
||||
# Update all settings
|
||||
for key, value in settings.items():
|
||||
await repo.upsert_setting(microdao.id, key, value)
|
||||
|
||||
# Publish NATS event
|
||||
if nats_publisher:
|
||||
await nats_publisher(
|
||||
"microdao.event.settings_updated",
|
||||
{
|
||||
"microdao_id": microdao.id,
|
||||
"slug": microdao.slug,
|
||||
"updated_keys": list(settings.keys()),
|
||||
"actor_id": actor.get("user_id"),
|
||||
"ts": datetime.now().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
# ============================================================================
|
||||
# Settings — Delete
|
||||
# ============================================================================
|
||||
|
||||
@router.delete("/microdao/{slug}/settings/{key}", status_code=204)
|
||||
async def delete_setting(
|
||||
slug: str,
|
||||
key: str,
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""
|
||||
Delete a setting
|
||||
Requires: MICRODAO_MANAGE permission
|
||||
"""
|
||||
actor = await get_actor_from_token(authorization)
|
||||
|
||||
# Get microDAO
|
||||
microdao = await repo.get_microdao_by_slug(slug)
|
||||
if not microdao:
|
||||
raise HTTPException(status_code=404, detail=f"MicroDAO '{slug}' not found")
|
||||
|
||||
# Check PDP
|
||||
allowed = await check_pdp_permission(
|
||||
action="MICRODAO_MANAGE",
|
||||
resource={"type": "MICRODAO", "id": microdao.id},
|
||||
context={"operation": "delete_setting"},
|
||||
actor=actor
|
||||
)
|
||||
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=403, detail="Permission denied: cannot manage settings")
|
||||
|
||||
# Delete setting
|
||||
success = await repo.delete_setting(microdao.id, key)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail=f"Setting '{key}' not found")
|
||||
|
||||
# Publish NATS event
|
||||
if nats_publisher:
|
||||
await nats_publisher(
|
||||
"microdao.event.setting_deleted",
|
||||
{
|
||||
"microdao_id": microdao.id,
|
||||
"slug": microdao.slug,
|
||||
"setting_key": key,
|
||||
"actor_id": actor.get("user_id"),
|
||||
"ts": datetime.now().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
# ============================================================================
|
||||
# Helper: Set repository
|
||||
# ============================================================================
|
||||
|
||||
def set_repository(repository: MicrodaoRepository):
|
||||
"""Set repository instance (called from main.py)"""
|
||||
global repo
|
||||
repo = repository
|
||||
|
||||
def set_nats_publisher(publisher_func):
|
||||
"""Set NATS publisher function (called from main.py)"""
|
||||
global nats_publisher
|
||||
nats_publisher = publisher_func
|
||||
|
||||
236
services/microdao-service/routes_treasury.py
Normal file
236
services/microdao-service/routes_treasury.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
MicroDAO Treasury Routes
|
||||
Phase 7: Backend Completion
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Header
|
||||
from typing import List, Optional
|
||||
from decimal import Decimal
|
||||
import httpx
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from models import TreasuryItem, TreasuryUpdate
|
||||
from repository_microdao import MicrodaoRepository
|
||||
|
||||
router = APIRouter(tags=["treasury"])
|
||||
|
||||
# Dependency injection (will be set in main.py)
|
||||
repo: Optional[MicrodaoRepository] = None
|
||||
|
||||
# Service URLs
|
||||
AUTH_SERVICE_URL = os.getenv("AUTH_SERVICE_URL", "http://localhost:7011")
|
||||
PDP_SERVICE_URL = os.getenv("PDP_SERVICE_URL", "http://localhost:7012")
|
||||
|
||||
# NATS publisher
|
||||
nats_publisher = None
|
||||
|
||||
# ============================================================================
|
||||
# Auth & PDP Helpers
|
||||
# ============================================================================
|
||||
|
||||
async def get_actor_from_token(authorization: Optional[str] = Header(None)):
|
||||
"""Get ActorIdentity from auth-service"""
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Missing or invalid authorization header")
|
||||
|
||||
token = authorization.replace("Bearer ", "")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{AUTH_SERVICE_URL}/auth/me",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPError:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
|
||||
async def check_pdp_permission(
|
||||
action: str,
|
||||
resource: dict,
|
||||
context: dict,
|
||||
actor: dict
|
||||
) -> bool:
|
||||
"""Check permission via pdp-service"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{PDP_SERVICE_URL}/internal/pdp/evaluate",
|
||||
json={
|
||||
"action": action,
|
||||
"resource": resource,
|
||||
"context": context,
|
||||
"actor": actor
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result.get("decision") == "ALLOW"
|
||||
except httpx.HTTPError as e:
|
||||
print(f"⚠️ PDP error: {e}")
|
||||
return False
|
||||
|
||||
# ============================================================================
|
||||
# Treasury — Read
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/microdao/{slug}/treasury", response_model=List[TreasuryItem])
|
||||
async def get_treasury(
|
||||
slug: str,
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""
|
||||
Get treasury balances for a microDAO
|
||||
Requires: MICRODAO_READ_TREASURY permission
|
||||
"""
|
||||
actor = await get_actor_from_token(authorization)
|
||||
|
||||
# Get microDAO
|
||||
microdao = await repo.get_microdao_by_slug(slug)
|
||||
if not microdao:
|
||||
raise HTTPException(status_code=404, detail=f"MicroDAO '{slug}' not found")
|
||||
|
||||
# Check PDP
|
||||
allowed = await check_pdp_permission(
|
||||
action="MICRODAO_READ_TREASURY",
|
||||
resource={"type": "MICRODAO", "id": microdao.id},
|
||||
context={},
|
||||
actor=actor
|
||||
)
|
||||
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=403, detail="Permission denied: cannot view treasury")
|
||||
|
||||
return await repo.get_treasury_items(microdao.id)
|
||||
|
||||
# ============================================================================
|
||||
# Treasury — Update (Delta)
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/microdao/{slug}/treasury", response_model=TreasuryItem)
|
||||
async def update_treasury(
|
||||
slug: str,
|
||||
token_symbol: str,
|
||||
delta: Decimal,
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""
|
||||
Apply delta to treasury balance
|
||||
Requires: MICRODAO_MANAGE_TREASURY permission
|
||||
|
||||
Query params:
|
||||
- token_symbol: Token to update (e.g., "DAARION", "USDT")
|
||||
- delta: Amount to add (positive) or subtract (negative)
|
||||
"""
|
||||
actor = await get_actor_from_token(authorization)
|
||||
|
||||
# Get microDAO
|
||||
microdao = await repo.get_microdao_by_slug(slug)
|
||||
if not microdao:
|
||||
raise HTTPException(status_code=404, detail=f"MicroDAO '{slug}' not found")
|
||||
|
||||
# Check PDP
|
||||
allowed = await check_pdp_permission(
|
||||
action="MICRODAO_MANAGE_TREASURY",
|
||||
resource={"type": "MICRODAO", "id": microdao.id},
|
||||
context={"operation": "update_balance"},
|
||||
actor=actor
|
||||
)
|
||||
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=403, detail="Permission denied: cannot manage treasury")
|
||||
|
||||
# Apply delta
|
||||
try:
|
||||
treasury_item = await repo.apply_treasury_delta(microdao.id, token_symbol, delta)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
# Publish NATS event
|
||||
if nats_publisher:
|
||||
await nats_publisher(
|
||||
"microdao.event.treasury_updated",
|
||||
{
|
||||
"microdao_id": microdao.id,
|
||||
"slug": microdao.slug,
|
||||
"token_symbol": token_symbol,
|
||||
"delta": str(delta),
|
||||
"new_balance": str(treasury_item.balance),
|
||||
"actor_id": actor.get("user_id"),
|
||||
"ts": datetime.now().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
return treasury_item
|
||||
|
||||
# ============================================================================
|
||||
# Treasury — Set Balance (Direct)
|
||||
# ============================================================================
|
||||
|
||||
@router.put("/microdao/{slug}/treasury/{token_symbol}", response_model=TreasuryItem)
|
||||
async def set_treasury_balance(
|
||||
slug: str,
|
||||
token_symbol: str,
|
||||
balance: Decimal,
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""
|
||||
Set treasury balance directly (admin operation)
|
||||
Requires: MICRODAO_MANAGE_TREASURY permission
|
||||
"""
|
||||
actor = await get_actor_from_token(authorization)
|
||||
|
||||
# Get microDAO
|
||||
microdao = await repo.get_microdao_by_slug(slug)
|
||||
if not microdao:
|
||||
raise HTTPException(status_code=404, detail=f"MicroDAO '{slug}' not found")
|
||||
|
||||
# Check PDP
|
||||
allowed = await check_pdp_permission(
|
||||
action="MICRODAO_MANAGE_TREASURY",
|
||||
resource={"type": "MICRODAO", "id": microdao.id},
|
||||
context={"operation": "set_balance"},
|
||||
actor=actor
|
||||
)
|
||||
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=403, detail="Permission denied: cannot manage treasury")
|
||||
|
||||
# Set balance
|
||||
try:
|
||||
treasury_item = await repo.set_treasury_balance(microdao.id, token_symbol, balance)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
# Publish NATS event
|
||||
if nats_publisher:
|
||||
await nats_publisher(
|
||||
"microdao.event.treasury_updated",
|
||||
{
|
||||
"microdao_id": microdao.id,
|
||||
"slug": microdao.slug,
|
||||
"token_symbol": token_symbol,
|
||||
"operation": "set_balance",
|
||||
"new_balance": str(treasury_item.balance),
|
||||
"actor_id": actor.get("user_id"),
|
||||
"ts": datetime.now().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
return treasury_item
|
||||
|
||||
# ============================================================================
|
||||
# Helper: Set repository
|
||||
# ============================================================================
|
||||
|
||||
def set_repository(repository: MicrodaoRepository):
|
||||
"""Set repository instance (called from main.py)"""
|
||||
global repo
|
||||
repo = repository
|
||||
|
||||
def set_nats_publisher(publisher_func):
|
||||
"""Set NATS publisher function (called from main.py)"""
|
||||
global nats_publisher
|
||||
nats_publisher = publisher_func
|
||||
|
||||
Reference in New Issue
Block a user