From cab0baca11ec800bfde04477b35bacfd20099254 Mon Sep 17 00:00:00 2001 From: markov Date: Tue, 24 Feb 2026 23:18:44 +0100 Subject: [PATCH] Add ProjectMember model and API endpoints for project membership management --- src/tracker/api/projects.py | 120 ++++++++++++++++++++++++++++++++- src/tracker/init_db.py | 51 +++++++++++++- src/tracker/models/__init__.py | 3 +- src/tracker/models/member.py | 7 ++ src/tracker/models/project.py | 22 +++++- src/tracker/ws/handler.py | 15 ++++- 6 files changed, 210 insertions(+), 8 deletions(-) diff --git a/src/tracker/api/projects.py b/src/tracker/api/projects.py index 6561b32..f919c8a 100644 --- a/src/tracker/api/projects.py +++ b/src/tracker/api/projects.py @@ -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} diff --git a/src/tracker/init_db.py b/src/tracker/init_db.py index 7f0654e..6d57821 100644 --- a/src/tracker/init_db.py +++ b/src/tracker/init_db.py @@ -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__": diff --git a/src/tracker/models/__init__.py b/src/tracker/models/__init__.py index 043bfc3..7c2c35b 100644 --- a/src/tracker/models/__init__.py +++ b/src/tracker/models/__init__.py @@ -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", diff --git a/src/tracker/models/member.py b/src/tracker/models/member.py index 8c67ff8..8838700 100644 --- a/src/tracker/models/member.py +++ b/src/tracker/models/member.py @@ -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" diff --git a/src/tracker/models/project.py b/src/tracker/models/project.py index 3b55e02..aeb4278 100644 --- a/src/tracker/models/project.py +++ b/src/tracker/models/project.py @@ -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() diff --git a/src/tracker/ws/handler.py b/src/tracker/ws/handler.py index d435ddb..61d6d90 100644 --- a/src/tracker/ws/handler.py +++ b/src/tracker/ws/handler.py @@ -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(