UUID everywhere: all API URLs use ID instead of slug
Some checks failed
Deploy Tracker / deploy (push) Failing after 3s
Some checks failed
Deploy Tracker / deploy (push) Failing after 3s
- /projects/{project_id} instead of /projects/{slug}
- /members/{member_id} instead of /members/{slug}
- /projects/{project_id}/files/* instead of /projects/{slug}/files/*
- /projects/{project_id}/members/* instead of /projects/{slug}/members/*
- create_task: ?project_id= instead of ?project_slug=
- list_tasks: removed project_slug filter (use project_id)
- addProjectMember: accepts member_id in body
- Heartbeat timeout: lookup by Member.id
- Slug remains only for: display, uniqueness check, login form
This commit is contained in:
parent
b8e836fd07
commit
6069a2da1f
@ -56,6 +56,18 @@ class MemberUpdate(BaseModel):
|
||||
agent_config: AgentConfigSchema | None = None
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
async def _get_member_by_id(member_id: str, db: AsyncSession) -> Member:
|
||||
result = await db.execute(
|
||||
select(Member).where(Member.id == uuid.UUID(member_id)).options(selectinload(Member.agent_config))
|
||||
)
|
||||
member = result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(404, "Member not found")
|
||||
return member
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.get("/members", response_model=list[MemberOut])
|
||||
@ -72,15 +84,9 @@ async def list_members(
|
||||
return [member_out(m) for m in result.scalars()]
|
||||
|
||||
|
||||
@router.get("/members/{slug}", response_model=MemberOut)
|
||||
async def get_member(slug: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(Member).where(Member.slug == slug).options(selectinload(Member.agent_config))
|
||||
)
|
||||
member = result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(404, "Member not found")
|
||||
return member_out(member)
|
||||
@router.get("/members/{member_id}", response_model=MemberOut)
|
||||
async def get_member(member_id: str, db: AsyncSession = Depends(get_db)):
|
||||
return member_out(await _get_member_by_id(member_id, db))
|
||||
|
||||
|
||||
@router.post("/members", response_model=MemberCreateResponse)
|
||||
@ -104,7 +110,7 @@ async def create_member(req: MemberCreate, db: AsyncSession = Depends(get_db)):
|
||||
status=MemberStatus.OFFLINE,
|
||||
)
|
||||
db.add(member)
|
||||
await db.flush() # get member.id
|
||||
await db.flush()
|
||||
|
||||
if req.agent_config and req.type == MemberType.AGENT:
|
||||
config = AgentConfig(
|
||||
@ -140,14 +146,9 @@ async def create_member(req: MemberCreate, db: AsyncSession = Depends(get_db)):
|
||||
).model_dump()
|
||||
|
||||
|
||||
@router.patch("/members/{slug}", response_model=MemberOut)
|
||||
async def update_member(slug: str, req: MemberUpdate, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(Member).where(Member.slug == slug).options(selectinload(Member.agent_config))
|
||||
)
|
||||
member = result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(404, "Member not found")
|
||||
@router.patch("/members/{member_id}", response_model=MemberOut)
|
||||
async def update_member(member_id: str, req: MemberUpdate, db: AsyncSession = Depends(get_db)):
|
||||
member = await _get_member_by_id(member_id, db)
|
||||
|
||||
if req.name is not None:
|
||||
member.name = req.name
|
||||
@ -178,14 +179,9 @@ async def update_member(slug: str, req: MemberUpdate, db: AsyncSession = Depends
|
||||
return member_out(member)
|
||||
|
||||
|
||||
@router.post("/members/{slug}/regenerate-token")
|
||||
async def regenerate_token(slug: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(Member).where(Member.slug == slug)
|
||||
)
|
||||
member = result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(404, "Member not found")
|
||||
@router.post("/members/{member_id}/regenerate-token")
|
||||
async def regenerate_token(member_id: str, db: AsyncSession = Depends(get_db)):
|
||||
member = await _get_member_by_id(member_id, db)
|
||||
if member.type != MemberType.AGENT:
|
||||
raise HTTPException(400, "Only agent tokens can be regenerated")
|
||||
token = f"tb-{secrets.token_hex(32)}"
|
||||
@ -194,14 +190,9 @@ async def regenerate_token(slug: str, db: AsyncSession = Depends(get_db)):
|
||||
return {"token": token}
|
||||
|
||||
|
||||
@router.post("/members/{slug}/revoke-token")
|
||||
async def revoke_token(slug: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(Member).where(Member.slug == slug)
|
||||
)
|
||||
member = result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(404, "Member not found")
|
||||
@router.post("/members/{member_id}/revoke-token")
|
||||
async def revoke_token(member_id: str, db: AsyncSession = Depends(get_db)):
|
||||
member = await _get_member_by_id(member_id, db)
|
||||
if member.type != MemberType.AGENT:
|
||||
raise HTTPException(400, "Only agent tokens can be revoked")
|
||||
member.token = None
|
||||
@ -227,20 +218,16 @@ async def update_my_status(
|
||||
return {"status": status}
|
||||
|
||||
|
||||
@router.delete("/members/{slug}")
|
||||
@router.delete("/members/{member_id}")
|
||||
async def delete_member(
|
||||
slug: str,
|
||||
member_id: str,
|
||||
current_member: Member = Depends(get_current_member),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Soft delete member by setting is_active=False. Only owners can delete."""
|
||||
if current_member.role != MemberRole.OWNER:
|
||||
raise HTTPException(403, "Only owners can delete members")
|
||||
result = await db.execute(select(Member).where(Member.slug == slug))
|
||||
member = result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(404, "Member not found")
|
||||
|
||||
member = await _get_member_by_id(member_id, db)
|
||||
member.is_active = False
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@ -8,7 +8,7 @@ from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request, Query, Form
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@ -33,15 +33,10 @@ class ProjectFileUpdate(BaseModel):
|
||||
def _to_member_brief(member: Member | None) -> MemberBrief | None:
|
||||
if not member:
|
||||
return None
|
||||
return MemberBrief(
|
||||
id=str(member.id),
|
||||
slug=member.slug,
|
||||
name=member.name
|
||||
)
|
||||
return MemberBrief(id=str(member.id), slug=member.slug, name=member.name)
|
||||
|
||||
|
||||
def project_file_out(f: ProjectFile) -> ProjectFileOut:
|
||||
"""Convert ProjectFile to ProjectFileOut schema."""
|
||||
return ProjectFileOut(
|
||||
id=str(f.id),
|
||||
filename=f.filename,
|
||||
@ -53,8 +48,8 @@ def project_file_out(f: ProjectFile) -> ProjectFileOut:
|
||||
updated_at=f.updated_at.isoformat() if f.updated_at else "",
|
||||
)
|
||||
|
||||
|
||||
async def _get_member(request: Request, db: AsyncSession) -> Member:
|
||||
"""Get member from request state, re-fetched from DB to avoid detached objects."""
|
||||
member_id = getattr(request.state, "member_id", None)
|
||||
if not member_id:
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
@ -65,64 +60,66 @@ async def _get_member(request: Request, db: AsyncSession) -> Member:
|
||||
return member
|
||||
|
||||
|
||||
async def _get_project(slug: str, db: AsyncSession) -> Project:
|
||||
result = await db.execute(select(Project).where(Project.slug == slug))
|
||||
async def _get_project(project_id: str, db: AsyncSession) -> Project:
|
||||
result = await db.execute(select(Project).where(Project.id == uuid.UUID(project_id)))
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(404, "Project not found")
|
||||
return project
|
||||
|
||||
|
||||
def _project_dir(project: Project) -> str:
|
||||
"""Get filesystem directory for project files. Uses slug for human-readable paths."""
|
||||
d = os.path.join(PROJECTS_DIR, project.slug)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.get("/projects/{slug}/files", response_model=list[ProjectFileOut])
|
||||
@router.get("/projects/{project_id}/files", response_model=list[ProjectFileOut])
|
||||
async def list_project_files(
|
||||
slug: str,
|
||||
search: Optional[str] = Query(None, description="Search by filename"),
|
||||
project_id: str,
|
||||
search: Optional[str] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
project = await _get_project(slug, db)
|
||||
|
||||
project = await _get_project(project_id, db)
|
||||
|
||||
q = select(ProjectFile).where(ProjectFile.project_id == project.id).options(selectinload(ProjectFile.uploader))
|
||||
|
||||
if search:
|
||||
q = q.where(ProjectFile.filename.ilike(f"%{search}%"))
|
||||
|
||||
q = q.order_by(ProjectFile.created_at.desc())
|
||||
result = await db.execute(q)
|
||||
|
||||
return [project_file_out(f) for f in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/projects/{slug}/files", response_model=ProjectFileOut)
|
||||
@router.post("/projects/{project_id}/files", response_model=ProjectFileOut)
|
||||
async def upload_project_file(
|
||||
request: Request,
|
||||
slug: str,
|
||||
project_id: str,
|
||||
file: UploadFile = File(...),
|
||||
description: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
member = await _get_member(request, db)
|
||||
project = await _get_project(slug, db)
|
||||
|
||||
project = await _get_project(project_id, db)
|
||||
|
||||
content = await file.read()
|
||||
if len(content) > MAX_FILE_SIZE:
|
||||
raise HTTPException(413, f"File too large (max {MAX_FILE_SIZE // 1024 // 1024}MB)")
|
||||
|
||||
|
||||
if not file.filename:
|
||||
raise HTTPException(400, "Filename is required")
|
||||
|
||||
|
||||
safe_filename = os.path.basename(file.filename).strip()
|
||||
if not safe_filename or safe_filename.startswith('.'):
|
||||
raise HTTPException(400, "Invalid filename")
|
||||
|
||||
project_dir = os.path.join(PROJECTS_DIR, slug)
|
||||
os.makedirs(project_dir, exist_ok=True)
|
||||
|
||||
full_path = os.path.realpath(os.path.join(project_dir, safe_filename))
|
||||
if not full_path.startswith(os.path.realpath(project_dir) + os.sep):
|
||||
|
||||
proj_dir = _project_dir(project)
|
||||
full_path = os.path.realpath(os.path.join(proj_dir, safe_filename))
|
||||
if not full_path.startswith(os.path.realpath(proj_dir) + os.sep):
|
||||
raise HTTPException(400, "Invalid filename")
|
||||
|
||||
|
||||
result = await db.execute(
|
||||
select(ProjectFile).where(
|
||||
ProjectFile.project_id == project.id,
|
||||
@ -130,24 +127,20 @@ async def upload_project_file(
|
||||
)
|
||||
)
|
||||
existing_file = result.scalar_one_or_none()
|
||||
|
||||
storage_path = safe_filename
|
||||
|
||||
|
||||
with open(full_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
mime_type = file.content_type or mimetypes.guess_type(file.filename)[0]
|
||||
|
||||
|
||||
if existing_file:
|
||||
existing_file.description = description
|
||||
existing_file.mime_type = mime_type
|
||||
existing_file.size = len(content)
|
||||
existing_file.uploaded_by = member.id
|
||||
existing_file.storage_path = storage_path
|
||||
|
||||
existing_file.storage_path = safe_filename
|
||||
await db.commit()
|
||||
await db.refresh(existing_file, ["uploader"])
|
||||
|
||||
return project_file_out(existing_file)
|
||||
else:
|
||||
pf = ProjectFile(
|
||||
@ -157,24 +150,21 @@ async def upload_project_file(
|
||||
mime_type=mime_type,
|
||||
size=len(content),
|
||||
uploaded_by=member.id,
|
||||
storage_path=storage_path,
|
||||
storage_path=safe_filename,
|
||||
)
|
||||
|
||||
db.add(pf)
|
||||
await db.commit()
|
||||
await db.refresh(pf, ["uploader"])
|
||||
|
||||
return project_file_out(pf)
|
||||
|
||||
|
||||
@router.get("/projects/{slug}/files/{file_id}")
|
||||
@router.get("/projects/{project_id}/files/{file_id}")
|
||||
async def get_project_file(
|
||||
slug: str,
|
||||
project_id: str,
|
||||
file_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
project = await _get_project(slug, db)
|
||||
|
||||
project = await _get_project(project_id, db)
|
||||
result = await db.execute(
|
||||
select(ProjectFile).where(
|
||||
ProjectFile.id == uuid.UUID(file_id),
|
||||
@ -182,22 +172,19 @@ async def get_project_file(
|
||||
).options(selectinload(ProjectFile.uploader))
|
||||
)
|
||||
pf = result.scalar_one_or_none()
|
||||
|
||||
if not pf:
|
||||
raise HTTPException(404, "File not found")
|
||||
|
||||
return project_file_out(pf)
|
||||
|
||||
|
||||
@router.get("/projects/{slug}/files/{file_id}/download")
|
||||
@router.get("/projects/{project_id}/files/{file_id}/download")
|
||||
async def download_project_file(
|
||||
slug: str,
|
||||
project_id: str,
|
||||
file_id: str,
|
||||
token: Optional[str] = Query(None), # Auth handled by middleware (query param for img src)
|
||||
token: Optional[str] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
project = await _get_project(slug, db)
|
||||
|
||||
project = await _get_project(project_id, db)
|
||||
result = await db.execute(
|
||||
select(ProjectFile).where(
|
||||
ProjectFile.id == uuid.UUID(file_id),
|
||||
@ -205,36 +192,30 @@ async def download_project_file(
|
||||
)
|
||||
)
|
||||
pf = result.scalar_one_or_none()
|
||||
|
||||
if not pf:
|
||||
raise HTTPException(404, "File not found")
|
||||
|
||||
project_dir = os.path.realpath(os.path.join(PROJECTS_DIR, slug))
|
||||
full_path = os.path.realpath(os.path.join(project_dir, pf.storage_path))
|
||||
if not full_path.startswith(project_dir + os.sep):
|
||||
|
||||
proj_dir = os.path.realpath(_project_dir(project))
|
||||
full_path = os.path.realpath(os.path.join(proj_dir, pf.storage_path))
|
||||
if not full_path.startswith(proj_dir + os.sep):
|
||||
raise HTTPException(400, "Invalid file path")
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
raise HTTPException(404, "File not found on disk")
|
||||
|
||||
return FileResponse(
|
||||
full_path,
|
||||
filename=pf.filename,
|
||||
media_type=pf.mime_type or "application/octet-stream",
|
||||
)
|
||||
|
||||
return FileResponse(full_path, filename=pf.filename, media_type=pf.mime_type or "application/octet-stream")
|
||||
|
||||
|
||||
@router.patch("/projects/{slug}/files/{file_id}", response_model=ProjectFileOut)
|
||||
@router.patch("/projects/{project_id}/files/{file_id}", response_model=ProjectFileOut)
|
||||
async def update_project_file(
|
||||
request: Request,
|
||||
slug: str,
|
||||
project_id: str,
|
||||
file_id: str,
|
||||
data: ProjectFileUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
member = await _get_member(request, db)
|
||||
project = await _get_project(slug, db)
|
||||
|
||||
project = await _get_project(project_id, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(ProjectFile).where(
|
||||
ProjectFile.id == uuid.UUID(file_id),
|
||||
@ -242,29 +223,27 @@ async def update_project_file(
|
||||
).options(selectinload(ProjectFile.uploader))
|
||||
)
|
||||
pf = result.scalar_one_or_none()
|
||||
|
||||
if not pf:
|
||||
raise HTTPException(404, "File not found")
|
||||
|
||||
|
||||
if data.description is not None:
|
||||
pf.description = data.description
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(pf)
|
||||
|
||||
return project_file_out(pf)
|
||||
|
||||
|
||||
@router.delete("/projects/{slug}/files/{file_id}")
|
||||
@router.delete("/projects/{project_id}/files/{file_id}")
|
||||
async def delete_project_file(
|
||||
request: Request,
|
||||
slug: str,
|
||||
project_id: str,
|
||||
file_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
member = await _get_member(request, db)
|
||||
project = await _get_project(slug, db)
|
||||
|
||||
project = await _get_project(project_id, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(ProjectFile).where(
|
||||
ProjectFile.id == uuid.UUID(file_id),
|
||||
@ -272,16 +251,14 @@ async def delete_project_file(
|
||||
)
|
||||
)
|
||||
pf = result.scalar_one_or_none()
|
||||
|
||||
if not pf:
|
||||
raise HTTPException(404, "File not found")
|
||||
|
||||
project_dir = os.path.realpath(os.path.join(PROJECTS_DIR, slug))
|
||||
full_path = os.path.realpath(os.path.join(project_dir, pf.storage_path))
|
||||
if full_path.startswith(project_dir + os.sep) and os.path.exists(full_path):
|
||||
|
||||
proj_dir = os.path.realpath(_project_dir(project))
|
||||
full_path = os.path.realpath(os.path.join(proj_dir, pf.storage_path))
|
||||
if full_path.startswith(proj_dir + os.sep) and os.path.exists(full_path):
|
||||
os.remove(full_path)
|
||||
|
||||
|
||||
await db.delete(pf)
|
||||
await db.commit()
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
"""Projects API."""
|
||||
|
||||
import uuid as _uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
@ -23,13 +25,14 @@ class ProjectCreate(BaseModel):
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
slug: str | None = None
|
||||
description: str | None = None
|
||||
repo_urls: list[str] | None = None
|
||||
status: str | None = None
|
||||
|
||||
|
||||
class ProjectMemberAdd(BaseModel):
|
||||
slug: str
|
||||
member_id: str # UUID
|
||||
|
||||
|
||||
class ProjectMemberOut(BaseModel):
|
||||
@ -43,6 +46,16 @@ class ProjectMemberOut(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
async def _get_project(project_id: str, db: AsyncSession) -> Project:
|
||||
result = await db.execute(select(Project).where(Project.id == _uuid.UUID(project_id)))
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(404, "Project not found")
|
||||
return project
|
||||
|
||||
|
||||
async def _project_out(p: Project, db: AsyncSession) -> ProjectOut:
|
||||
chat_result = await db.execute(
|
||||
select(Chat).where(Chat.project_id == p.id, Chat.kind == ChatKind.PROJECT)
|
||||
@ -60,19 +73,17 @@ async def _project_out(p: Project, db: AsyncSession) -> ProjectOut:
|
||||
)
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.get("/projects", response_model=list[ProjectOut])
|
||||
async def list_projects(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Project).where(Project.status == ProjectStatus.ACTIVE))
|
||||
return [await _project_out(p, db) for p in result.scalars()]
|
||||
|
||||
|
||||
@router.get("/projects/{slug}", response_model=ProjectOut)
|
||||
async def get_project(slug: str, db: AsyncSession = Depends(get_db)):
|
||||
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")
|
||||
return await _project_out(project, db)
|
||||
@router.get("/projects/{project_id}", response_model=ProjectOut)
|
||||
async def get_project(project_id: str, db: AsyncSession = Depends(get_db)):
|
||||
return await _project_out(await _get_project(project_id, db), db)
|
||||
|
||||
|
||||
@router.post("/projects", response_model=ProjectOut)
|
||||
@ -90,7 +101,6 @@ async def create_project(req: ProjectCreate, db: AsyncSession = Depends(get_db))
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
|
||||
# Create project chat
|
||||
chat = Chat(project_id=project.id, kind=ChatKind.PROJECT)
|
||||
db.add(chat)
|
||||
|
||||
@ -98,15 +108,18 @@ async def create_project(req: ProjectCreate, db: AsyncSession = Depends(get_db))
|
||||
return await _project_out(project, db)
|
||||
|
||||
|
||||
@router.patch("/projects/{slug}", response_model=ProjectOut)
|
||||
async def update_project(slug: str, req: ProjectUpdate, db: AsyncSession = Depends(get_db)):
|
||||
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")
|
||||
@router.patch("/projects/{project_id}", response_model=ProjectOut)
|
||||
async def update_project(project_id: str, req: ProjectUpdate, db: AsyncSession = Depends(get_db)):
|
||||
project = await _get_project(project_id, db)
|
||||
|
||||
if req.name is not None:
|
||||
project.name = req.name
|
||||
if req.slug is not None:
|
||||
# Check slug uniqueness
|
||||
existing = await db.execute(select(Project).where(Project.slug == req.slug, Project.id != project.id))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(409, f"Slug '{req.slug}' already taken")
|
||||
project.slug = req.slug
|
||||
if req.description is not None:
|
||||
project.description = req.description
|
||||
if req.repo_urls is not None:
|
||||
@ -118,65 +131,43 @@ async def update_project(slug: str, req: ProjectUpdate, db: AsyncSession = Depen
|
||||
return await _project_out(project, db)
|
||||
|
||||
|
||||
@router.delete("/projects/{slug}")
|
||||
async def delete_project(slug: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
@router.delete("/projects/{project_id}")
|
||||
async def delete_project(project_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
member = getattr(request.state, "member", None)
|
||||
if not member or member.role != MemberRole.OWNER:
|
||||
raise HTTPException(403, "Only owners can delete projects")
|
||||
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")
|
||||
project = await _get_project(project_id, 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
|
||||
@router.get("/projects/{project_id}/members", response_model=list[ProjectMemberOut])
|
||||
async def get_project_members(project_id: str, db: AsyncSession = Depends(get_db)):
|
||||
project = await _get_project(project_id, db)
|
||||
|
||||
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
|
||||
|
||||
return [
|
||||
{"id": str(m.id), "name": m.name, "slug": m.slug, "type": m.type, "role": role}
|
||||
for m, role in result.all()
|
||||
]
|
||||
|
||||
|
||||
@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))
|
||||
@router.post("/projects/{project_id}/members")
|
||||
async def add_project_member(project_id: str, req: ProjectMemberAdd, db: AsyncSession = Depends(get_db)):
|
||||
project = await _get_project(project_id, db)
|
||||
|
||||
result = await db.execute(select(Member).where(Member.id == _uuid.UUID(req.member_id)))
|
||||
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,
|
||||
@ -185,8 +176,7 @@ async def add_project_member(slug: str, req: ProjectMemberAdd, db: AsyncSession
|
||||
)
|
||||
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,
|
||||
@ -194,41 +184,26 @@ async def add_project_member(slug: str, req: ProjectMemberAdd, db: AsyncSession
|
||||
)
|
||||
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
|
||||
@router.delete("/projects/{project_id}/members/{member_id}")
|
||||
async def remove_project_member(project_id: str, member_id: str, db: AsyncSession = Depends(get_db)):
|
||||
project = await _get_project(project_id, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project.id,
|
||||
ProjectMember.member_id == member.id
|
||||
ProjectMember.member_id == _uuid.UUID(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 == MemberRole.OWNER:
|
||||
raise HTTPException(400, "Cannot remove project owner")
|
||||
|
||||
|
||||
await db.delete(project_member)
|
||||
await db.commit()
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
@ -210,7 +210,6 @@ async def _resolve_member(db: AsyncSession, member_id: str) -> Member:
|
||||
@router.get("/tasks", response_model=list[TaskOut])
|
||||
async def list_tasks(
|
||||
project_id: Optional[str] = Query(None),
|
||||
project_slug: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
assignee_id: Optional[str] = Query(None),
|
||||
label: Optional[str] = Query(None),
|
||||
@ -224,11 +223,6 @@ async def list_tasks(
|
||||
)
|
||||
if project_id:
|
||||
q = q.where(Task.project_id == uuid.UUID(project_id))
|
||||
elif project_slug:
|
||||
result = await db.execute(select(Project).where(Project.slug == project_slug))
|
||||
project = result.scalar_one_or_none()
|
||||
if project:
|
||||
q = q.where(Task.project_id == project.id)
|
||||
if status:
|
||||
q = q.where(Task.status == status)
|
||||
if assignee_id:
|
||||
@ -248,13 +242,13 @@ async def get_task(task_id: str, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
@router.post("/tasks", response_model=TaskOut)
|
||||
async def create_task(
|
||||
project_slug: str = Query(...),
|
||||
project_id: str = Query(...),
|
||||
req: TaskCreate = ...,
|
||||
current_member: Member = Depends(get_current_member),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.slug == project_slug).with_for_update()
|
||||
select(Project).where(Project.id == uuid.UUID(project_id)).with_for_update()
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
import traceback
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
@ -41,15 +42,15 @@ async def heartbeat_monitor():
|
||||
|
||||
for session_id in timed_out:
|
||||
client = await manager.disconnect(session_id)
|
||||
if client and not manager.is_online(client.member_slug):
|
||||
if client and not manager.is_online(client.member_id):
|
||||
async with async_session() as db:
|
||||
await db.execute(
|
||||
update(Member).where(Member.slug == client.member_slug).values(status="offline")
|
||||
update(Member).where(Member.id == uuid.UUID(client.member_id)).values(status="offline")
|
||||
)
|
||||
await db.commit()
|
||||
await manager.broadcast_all(
|
||||
{"type": "agent.status", "data": {"slug": client.member_slug, "status": "offline"}},
|
||||
exclude_slug=client.member_slug,
|
||||
{"type": "agent.status", "data": {"id": client.member_id, "slug": client.member_slug, "status": "offline"}},
|
||||
exclude_member_id=client.member_id,
|
||||
)
|
||||
logger.info("Heartbeat timeout: %s set offline", client.member_slug)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user