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 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}
|
||||||
|
|||||||
@ -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__":
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user