Add ProjectMember model and API endpoints for project membership management
Some checks failed
Deploy Tracker / deploy (push) Failing after 2s
Some checks failed
Deploy Tracker / deploy (push) Failing after 2s
This commit is contained in:
parent
904c105174
commit
cab0baca11
@ -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}
|
||||
|
||||
@ -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__":
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user