Add ProjectMember model and API endpoints for project membership management
Some checks failed
Deploy Tracker / deploy (push) Failing after 2s

This commit is contained in:
markov 2026-02-24 23:18:44 +01:00
parent 904c105174
commit cab0baca11
6 changed files with 210 additions and 8 deletions

View File

@ -4,9 +4,10 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from tracker.database import get_db
from tracker.models import Project, Chat
from tracker.models import Project, Chat, Member, ProjectMember
router = APIRouter(tags=["projects"])
@ -39,6 +40,21 @@ class ProjectOut(BaseModel):
from_attributes = True
class ProjectMemberAdd(BaseModel):
slug: str
class ProjectMemberOut(BaseModel):
id: str
name: str
slug: str
type: str
role: str
class Config:
from_attributes = True
async def _project_out(p: Project, db: AsyncSession) -> dict:
# Get project chat
chat_result = await db.execute(
@ -124,3 +140,105 @@ async def delete_project(slug: str, db: AsyncSession = Depends(get_db)):
await db.delete(project)
await db.commit()
return {"ok": True}
@router.get("/projects/{slug}/members", response_model=list[ProjectMemberOut])
async def get_project_members(slug: str, db: AsyncSession = Depends(get_db)):
"""Get members of a project."""
result = await db.execute(select(Project).where(Project.slug == slug))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(404, "Project not found")
# Join ProjectMember with Member to get member details
result = await db.execute(
select(Member, ProjectMember.role)
.join(ProjectMember, Member.id == ProjectMember.member_id)
.where(ProjectMember.project_id == project.id)
.options(selectinload(Member.agent_config))
)
members = []
for member, role in result.all():
members.append({
"id": str(member.id),
"name": member.name,
"slug": member.slug,
"type": member.type,
"role": role,
})
return members
@router.post("/projects/{slug}/members")
async def add_project_member(slug: str, req: ProjectMemberAdd, db: AsyncSession = Depends(get_db)):
"""Add a member to a project."""
# Get project
result = await db.execute(select(Project).where(Project.slug == slug))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(404, "Project not found")
# Get member
result = await db.execute(select(Member).where(Member.slug == req.slug))
member = result.scalar_one_or_none()
if not member:
raise HTTPException(404, "Member not found")
# Check if already a member
result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project.id,
ProjectMember.member_id == member.id
)
)
if result.scalar_one_or_none():
raise HTTPException(409, "Member already in project")
# Add member
project_member = ProjectMember(
project_id=project.id,
member_id=member.id,
role="member"
)
db.add(project_member)
await db.commit()
return {"ok": True}
@router.delete("/projects/{slug}/members/{member_slug}")
async def remove_project_member(slug: str, member_slug: str, db: AsyncSession = Depends(get_db)):
"""Remove a member from a project."""
# Get project
result = await db.execute(select(Project).where(Project.slug == slug))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(404, "Project not found")
# Get member
result = await db.execute(select(Member).where(Member.slug == member_slug))
member = result.scalar_one_or_none()
if not member:
raise HTTPException(404, "Member not found")
# Get project member
result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project.id,
ProjectMember.member_id == member.id
)
)
project_member = result.scalar_one_or_none()
if not project_member:
raise HTTPException(404, "Member not in project")
# Don't allow removing owner
if project_member.role == "owner":
raise HTTPException(400, "Cannot remove project owner")
await db.delete(project_member)
await db.commit()
return {"ok": True}

View File

@ -6,7 +6,7 @@ import logging
import uuid
from tracker.database import engine, async_session
from tracker.models import Base, Member, Chat
from tracker.models import Base, Member, Chat, Project, ProjectMember, AgentConfig
logger = logging.getLogger("tracker.init_db")
@ -36,6 +36,18 @@ async def init_db():
)
session.add(admin)
# Create coder agent
coder = Member(
name="Coder",
slug="coder",
type="agent",
role="member",
auth_method="token",
token="tb-coder-dev-token",
status="offline",
)
session.add(coder)
# Create BFF service member with TRACKER_TOKEN
bff_service = Member(
name="BFF Service",
@ -48,6 +60,41 @@ async def init_db():
)
session.add(bff_service)
# Flush to get member IDs
await session.flush()
# Create team-board project
project = Project(
name="Team Board",
slug="team-board",
description="AI agent collaboration platform",
status="active",
)
session.add(project)
await session.flush()
# Create project chat
project_chat = Chat(
kind="project",
project_id=project.id,
)
session.add(project_chat)
# Create project members
admin_member = ProjectMember(
project_id=project.id,
member_id=admin.id,
role="owner",
)
session.add(admin_member)
coder_member = ProjectMember(
project_id=project.id,
member_id=coder.id,
role="member",
)
session.add(coder_member)
# Create lobby chat
lobby = Chat(
kind="lobby",
@ -56,7 +103,7 @@ async def init_db():
session.add(lobby)
await session.commit()
logger.info("Seed data created: admin user, BFF service, lobby chat (id=%s)", lobby.id)
logger.info("Seed data created: admin user, coder agent, BFF service, team-board project (id=%s), lobby chat (id=%s)", project.id, lobby.id)
if __name__ == "__main__":

View File

@ -2,7 +2,7 @@
from tracker.models.base import Base
from tracker.models.member import AgentConfig, Member
from tracker.models.project import Project
from tracker.models.project import Project, ProjectMember
from tracker.models.task import Step, Task
from tracker.models.chat import Attachment, Chat, Message
@ -11,6 +11,7 @@ __all__ = [
"Member",
"AgentConfig",
"Project",
"ProjectMember",
"Task",
"Step",
"Chat",

View File

@ -1,6 +1,7 @@
"""Member model — unified human + agent."""
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import ARRAY, UUID
@ -8,6 +9,9 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from tracker.models.base import Base
if TYPE_CHECKING:
from tracker.models.project import ProjectMember
class Member(Base):
__tablename__ = "members"
@ -27,6 +31,9 @@ class Member(Base):
back_populates="member", uselist=False, cascade="all, delete-orphan"
)
# Project memberships
project_memberships: Mapped[list["ProjectMember"]] = relationship(back_populates="member")
class AgentConfig(Base):
__tablename__ = "agent_configs"

View File

@ -1,9 +1,10 @@
"""Project model."""
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import Integer, String, Text
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import ARRAY, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from tracker.models.base import Base
@ -11,6 +12,7 @@ from tracker.models.base import Base
if TYPE_CHECKING:
from tracker.models.task import Task
from tracker.models.chat import Chat
from tracker.models.member import Member
class Project(Base):
@ -25,3 +27,19 @@ class Project(Base):
tasks: Mapped[list["Task"]] = relationship(back_populates="project", cascade="all, delete-orphan")
chats: Mapped[list["Chat"]] = relationship(back_populates="project", cascade="all, delete-orphan")
members: Mapped[list["ProjectMember"]] = relationship(back_populates="project", cascade="all, delete-orphan")
class ProjectMember(Base):
__tablename__ = "project_members"
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True
)
member_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), primary_key=True
)
role: Mapped[str] = mapped_column(String(20), default="member") # owner | member
project: Mapped["Project"] = relationship(back_populates="members")
member: Mapped["Member"] = relationship()

View File

@ -8,7 +8,7 @@ from sqlalchemy import select
from sqlalchemy.orm import selectinload
from tracker.database import async_session
from tracker.models import Member, AgentConfig, Chat, Message, Project
from tracker.models import Member, AgentConfig, Chat, Message, Project, ProjectMember
from tracker.ws.manager import ConnectedClient, manager
logger = logging.getLogger("tracker.ws")
@ -174,7 +174,18 @@ async def _authenticate(ws: WebSocket, token: str, on_behalf_of: str | None = No
lobby = await db.execute(select(Chat).where(Chat.kind == "lobby"))
lobby_chat = lobby.scalar_one_or_none()
projects = await db.execute(select(Project).where(Project.status == "active"))
# Filter projects by membership (show all for owners)
if member.role == "owner":
# Owners see all projects
projects = await db.execute(select(Project).where(Project.status == "active"))
else:
# Members see only projects they belong to
projects = await db.execute(
select(Project)
.join(ProjectMember, Project.id == ProjectMember.project_id)
.where(Project.status == "active", ProjectMember.member_id == member.id)
)
project_list = []
for p in projects.scalars():
chat_result = await db.execute(