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 pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from tracker.database import get_db from tracker.database import get_db
from tracker.models import Project, Chat from tracker.models import Project, Chat, Member, ProjectMember
router = APIRouter(tags=["projects"]) router = APIRouter(tags=["projects"])
@ -39,6 +40,21 @@ class ProjectOut(BaseModel):
from_attributes = True 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: async def _project_out(p: Project, db: AsyncSession) -> dict:
# Get project chat # Get project chat
chat_result = await db.execute( 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.delete(project)
await db.commit() await db.commit()
return {"ok": True} 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 import uuid
from tracker.database import engine, async_session 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") logger = logging.getLogger("tracker.init_db")
@ -36,6 +36,18 @@ async def init_db():
) )
session.add(admin) 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 # Create BFF service member with TRACKER_TOKEN
bff_service = Member( bff_service = Member(
name="BFF Service", name="BFF Service",
@ -48,6 +60,41 @@ async def init_db():
) )
session.add(bff_service) 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 # Create lobby chat
lobby = Chat( lobby = Chat(
kind="lobby", kind="lobby",
@ -56,7 +103,7 @@ async def init_db():
session.add(lobby) session.add(lobby)
await session.commit() 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__": if __name__ == "__main__":

View File

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

View File

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

View File

@ -1,9 +1,10 @@
"""Project model.""" """Project model."""
import uuid
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import Integer, String, Text from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import ARRAY from sqlalchemy.dialects.postgresql import ARRAY, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from tracker.models.base import Base from tracker.models.base import Base
@ -11,6 +12,7 @@ from tracker.models.base import Base
if TYPE_CHECKING: if TYPE_CHECKING:
from tracker.models.task import Task from tracker.models.task import Task
from tracker.models.chat import Chat from tracker.models.chat import Chat
from tracker.models.member import Member
class Project(Base): class Project(Base):
@ -25,3 +27,19 @@ class Project(Base):
tasks: Mapped[list["Task"]] = relationship(back_populates="project", cascade="all, delete-orphan") tasks: Mapped[list["Task"]] = relationship(back_populates="project", cascade="all, delete-orphan")
chats: Mapped[list["Chat"]] = 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 sqlalchemy.orm import selectinload
from tracker.database import async_session 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 from tracker.ws.manager import ConnectedClient, manager
logger = logging.getLogger("tracker.ws") 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 = await db.execute(select(Chat).where(Chat.kind == "lobby"))
lobby_chat = lobby.scalar_one_or_none() lobby_chat = lobby.scalar_one_or_none()
# 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")) 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 = [] project_list = []
for p in projects.scalars(): for p in projects.scalars():
chat_result = await db.execute( chat_result = await db.execute(