UUID everywhere: all API URLs use ID instead of slug
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:
markov 2026-02-27 09:37:16 +01:00
parent b8e836fd07
commit 6069a2da1f
5 changed files with 150 additions and 216 deletions

View File

@ -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}

View File

@ -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,45 +60,49 @@ 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:
@ -116,11 +115,9 @@ async def upload_project_file(
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(
@ -131,8 +128,6 @@ 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)
@ -143,11 +138,9 @@ async def upload_project_file(
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,35 +192,29 @@ 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(
@ -242,7 +223,6 @@ async def update_project_file(
).options(selectinload(ProjectFile.uploader))
)
pf = result.scalar_one_or_none()
if not pf:
raise HTTPException(404, "File not found")
@ -251,19 +231,18 @@ async def update_project_file(
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(
@ -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}

View File

@ -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,29 +131,21 @@ 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")
@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)
# Join ProjectMember with Member to get member details
result = await db.execute(
select(Member, ProjectMember.role)
.join(ProjectMember, Member.id == ProjectMember.member_id)
@ -148,35 +153,21 @@ async def get_project_members(slug: str, db: AsyncSession = Depends(get_db)):
.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")
@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)
# Get member
result = await db.execute(select(Member).where(Member.slug == req.slug))
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,
@ -186,7 +177,6 @@ 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")
@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)
# 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
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}

View File

@ -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:

View File

@ -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)