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
|
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 ---
|
# --- Endpoints ---
|
||||||
|
|
||||||
@router.get("/members", response_model=list[MemberOut])
|
@router.get("/members", response_model=list[MemberOut])
|
||||||
@ -72,15 +84,9 @@ async def list_members(
|
|||||||
return [member_out(m) for m in result.scalars()]
|
return [member_out(m) for m in result.scalars()]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/members/{slug}", response_model=MemberOut)
|
@router.get("/members/{member_id}", response_model=MemberOut)
|
||||||
async def get_member(slug: str, db: AsyncSession = Depends(get_db)):
|
async def get_member(member_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
result = await db.execute(
|
return member_out(await _get_member_by_id(member_id, db))
|
||||||
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.post("/members", response_model=MemberCreateResponse)
|
@router.post("/members", response_model=MemberCreateResponse)
|
||||||
@ -104,7 +110,7 @@ async def create_member(req: MemberCreate, db: AsyncSession = Depends(get_db)):
|
|||||||
status=MemberStatus.OFFLINE,
|
status=MemberStatus.OFFLINE,
|
||||||
)
|
)
|
||||||
db.add(member)
|
db.add(member)
|
||||||
await db.flush() # get member.id
|
await db.flush()
|
||||||
|
|
||||||
if req.agent_config and req.type == MemberType.AGENT:
|
if req.agent_config and req.type == MemberType.AGENT:
|
||||||
config = AgentConfig(
|
config = AgentConfig(
|
||||||
@ -140,14 +146,9 @@ async def create_member(req: MemberCreate, db: AsyncSession = Depends(get_db)):
|
|||||||
).model_dump()
|
).model_dump()
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/members/{slug}", response_model=MemberOut)
|
@router.patch("/members/{member_id}", response_model=MemberOut)
|
||||||
async def update_member(slug: str, req: MemberUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_member(member_id: str, req: MemberUpdate, db: AsyncSession = Depends(get_db)):
|
||||||
result = await db.execute(
|
member = await _get_member_by_id(member_id, db)
|
||||||
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")
|
|
||||||
|
|
||||||
if req.name is not None:
|
if req.name is not None:
|
||||||
member.name = req.name
|
member.name = req.name
|
||||||
@ -178,14 +179,9 @@ async def update_member(slug: str, req: MemberUpdate, db: AsyncSession = Depends
|
|||||||
return member_out(member)
|
return member_out(member)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/members/{slug}/regenerate-token")
|
@router.post("/members/{member_id}/regenerate-token")
|
||||||
async def regenerate_token(slug: str, db: AsyncSession = Depends(get_db)):
|
async def regenerate_token(member_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
result = await db.execute(
|
member = await _get_member_by_id(member_id, db)
|
||||||
select(Member).where(Member.slug == slug)
|
|
||||||
)
|
|
||||||
member = result.scalar_one_or_none()
|
|
||||||
if not member:
|
|
||||||
raise HTTPException(404, "Member not found")
|
|
||||||
if member.type != MemberType.AGENT:
|
if member.type != MemberType.AGENT:
|
||||||
raise HTTPException(400, "Only agent tokens can be regenerated")
|
raise HTTPException(400, "Only agent tokens can be regenerated")
|
||||||
token = f"tb-{secrets.token_hex(32)}"
|
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}
|
return {"token": token}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/members/{slug}/revoke-token")
|
@router.post("/members/{member_id}/revoke-token")
|
||||||
async def revoke_token(slug: str, db: AsyncSession = Depends(get_db)):
|
async def revoke_token(member_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
result = await db.execute(
|
member = await _get_member_by_id(member_id, db)
|
||||||
select(Member).where(Member.slug == slug)
|
|
||||||
)
|
|
||||||
member = result.scalar_one_or_none()
|
|
||||||
if not member:
|
|
||||||
raise HTTPException(404, "Member not found")
|
|
||||||
if member.type != MemberType.AGENT:
|
if member.type != MemberType.AGENT:
|
||||||
raise HTTPException(400, "Only agent tokens can be revoked")
|
raise HTTPException(400, "Only agent tokens can be revoked")
|
||||||
member.token = None
|
member.token = None
|
||||||
@ -227,20 +218,16 @@ async def update_my_status(
|
|||||||
return {"status": status}
|
return {"status": status}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/members/{slug}")
|
@router.delete("/members/{member_id}")
|
||||||
async def delete_member(
|
async def delete_member(
|
||||||
slug: str,
|
member_id: str,
|
||||||
current_member: Member = Depends(get_current_member),
|
current_member: Member = Depends(get_current_member),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Soft delete member by setting is_active=False. Only owners can delete."""
|
"""Soft delete member by setting is_active=False. Only owners can delete."""
|
||||||
if current_member.role != MemberRole.OWNER:
|
if current_member.role != MemberRole.OWNER:
|
||||||
raise HTTPException(403, "Only owners can delete members")
|
raise HTTPException(403, "Only owners can delete members")
|
||||||
result = await db.execute(select(Member).where(Member.slug == slug))
|
member = await _get_member_by_id(member_id, db)
|
||||||
member = result.scalar_one_or_none()
|
|
||||||
if not member:
|
|
||||||
raise HTTPException(404, "Member not found")
|
|
||||||
|
|
||||||
member.is_active = False
|
member.is_active = False
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from typing import Optional
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request, Query, Form
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request, Query, Form
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select, or_
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
@ -33,15 +33,10 @@ class ProjectFileUpdate(BaseModel):
|
|||||||
def _to_member_brief(member: Member | None) -> MemberBrief | None:
|
def _to_member_brief(member: Member | None) -> MemberBrief | None:
|
||||||
if not member:
|
if not member:
|
||||||
return None
|
return None
|
||||||
return MemberBrief(
|
return MemberBrief(id=str(member.id), slug=member.slug, name=member.name)
|
||||||
id=str(member.id),
|
|
||||||
slug=member.slug,
|
|
||||||
name=member.name
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def project_file_out(f: ProjectFile) -> ProjectFileOut:
|
def project_file_out(f: ProjectFile) -> ProjectFileOut:
|
||||||
"""Convert ProjectFile to ProjectFileOut schema."""
|
|
||||||
return ProjectFileOut(
|
return ProjectFileOut(
|
||||||
id=str(f.id),
|
id=str(f.id),
|
||||||
filename=f.filename,
|
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 "",
|
updated_at=f.updated_at.isoformat() if f.updated_at else "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _get_member(request: Request, db: AsyncSession) -> Member:
|
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)
|
member_id = getattr(request.state, "member_id", None)
|
||||||
if not member_id:
|
if not member_id:
|
||||||
raise HTTPException(401, "Not authenticated")
|
raise HTTPException(401, "Not authenticated")
|
||||||
@ -65,45 +60,49 @@ async def _get_member(request: Request, db: AsyncSession) -> Member:
|
|||||||
return member
|
return member
|
||||||
|
|
||||||
|
|
||||||
async def _get_project(slug: str, db: AsyncSession) -> Project:
|
async def _get_project(project_id: str, db: AsyncSession) -> Project:
|
||||||
result = await db.execute(select(Project).where(Project.slug == slug))
|
result = await db.execute(select(Project).where(Project.id == uuid.UUID(project_id)))
|
||||||
project = result.scalar_one_or_none()
|
project = result.scalar_one_or_none()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(404, "Project not found")
|
raise HTTPException(404, "Project not found")
|
||||||
return project
|
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 ---
|
# --- 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(
|
async def list_project_files(
|
||||||
slug: str,
|
project_id: str,
|
||||||
search: Optional[str] = Query(None, description="Search by filename"),
|
search: Optional[str] = Query(None),
|
||||||
db: AsyncSession = Depends(get_db),
|
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))
|
q = select(ProjectFile).where(ProjectFile.project_id == project.id).options(selectinload(ProjectFile.uploader))
|
||||||
|
|
||||||
if search:
|
if search:
|
||||||
q = q.where(ProjectFile.filename.ilike(f"%{search}%"))
|
q = q.where(ProjectFile.filename.ilike(f"%{search}%"))
|
||||||
|
|
||||||
q = q.order_by(ProjectFile.created_at.desc())
|
q = q.order_by(ProjectFile.created_at.desc())
|
||||||
result = await db.execute(q)
|
result = await db.execute(q)
|
||||||
|
|
||||||
return [project_file_out(f) for f in result.scalars().all()]
|
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(
|
async def upload_project_file(
|
||||||
request: Request,
|
request: Request,
|
||||||
slug: str,
|
project_id: str,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
description: Optional[str] = Form(None),
|
description: Optional[str] = Form(None),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
member = await _get_member(request, db)
|
member = await _get_member(request, db)
|
||||||
project = await _get_project(slug, db)
|
project = await _get_project(project_id, db)
|
||||||
|
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
if len(content) > MAX_FILE_SIZE:
|
if len(content) > MAX_FILE_SIZE:
|
||||||
@ -116,11 +115,9 @@ async def upload_project_file(
|
|||||||
if not safe_filename or safe_filename.startswith('.'):
|
if not safe_filename or safe_filename.startswith('.'):
|
||||||
raise HTTPException(400, "Invalid filename")
|
raise HTTPException(400, "Invalid filename")
|
||||||
|
|
||||||
project_dir = os.path.join(PROJECTS_DIR, slug)
|
proj_dir = _project_dir(project)
|
||||||
os.makedirs(project_dir, exist_ok=True)
|
full_path = os.path.realpath(os.path.join(proj_dir, safe_filename))
|
||||||
|
if not full_path.startswith(os.path.realpath(proj_dir) + os.sep):
|
||||||
full_path = os.path.realpath(os.path.join(project_dir, safe_filename))
|
|
||||||
if not full_path.startswith(os.path.realpath(project_dir) + os.sep):
|
|
||||||
raise HTTPException(400, "Invalid filename")
|
raise HTTPException(400, "Invalid filename")
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@ -131,8 +128,6 @@ async def upload_project_file(
|
|||||||
)
|
)
|
||||||
existing_file = result.scalar_one_or_none()
|
existing_file = result.scalar_one_or_none()
|
||||||
|
|
||||||
storage_path = safe_filename
|
|
||||||
|
|
||||||
with open(full_path, "wb") as f:
|
with open(full_path, "wb") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
|
|
||||||
@ -143,11 +138,9 @@ async def upload_project_file(
|
|||||||
existing_file.mime_type = mime_type
|
existing_file.mime_type = mime_type
|
||||||
existing_file.size = len(content)
|
existing_file.size = len(content)
|
||||||
existing_file.uploaded_by = member.id
|
existing_file.uploaded_by = member.id
|
||||||
existing_file.storage_path = storage_path
|
existing_file.storage_path = safe_filename
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(existing_file, ["uploader"])
|
await db.refresh(existing_file, ["uploader"])
|
||||||
|
|
||||||
return project_file_out(existing_file)
|
return project_file_out(existing_file)
|
||||||
else:
|
else:
|
||||||
pf = ProjectFile(
|
pf = ProjectFile(
|
||||||
@ -157,24 +150,21 @@ async def upload_project_file(
|
|||||||
mime_type=mime_type,
|
mime_type=mime_type,
|
||||||
size=len(content),
|
size=len(content),
|
||||||
uploaded_by=member.id,
|
uploaded_by=member.id,
|
||||||
storage_path=storage_path,
|
storage_path=safe_filename,
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(pf)
|
db.add(pf)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(pf, ["uploader"])
|
await db.refresh(pf, ["uploader"])
|
||||||
|
|
||||||
return project_file_out(pf)
|
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(
|
async def get_project_file(
|
||||||
slug: str,
|
project_id: str,
|
||||||
file_id: str,
|
file_id: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
project = await _get_project(slug, db)
|
project = await _get_project(project_id, db)
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(ProjectFile).where(
|
select(ProjectFile).where(
|
||||||
ProjectFile.id == uuid.UUID(file_id),
|
ProjectFile.id == uuid.UUID(file_id),
|
||||||
@ -182,22 +172,19 @@ async def get_project_file(
|
|||||||
).options(selectinload(ProjectFile.uploader))
|
).options(selectinload(ProjectFile.uploader))
|
||||||
)
|
)
|
||||||
pf = result.scalar_one_or_none()
|
pf = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not pf:
|
if not pf:
|
||||||
raise HTTPException(404, "File not found")
|
raise HTTPException(404, "File not found")
|
||||||
|
|
||||||
return project_file_out(pf)
|
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(
|
async def download_project_file(
|
||||||
slug: str,
|
project_id: str,
|
||||||
file_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),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
project = await _get_project(slug, db)
|
project = await _get_project(project_id, db)
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(ProjectFile).where(
|
select(ProjectFile).where(
|
||||||
ProjectFile.id == uuid.UUID(file_id),
|
ProjectFile.id == uuid.UUID(file_id),
|
||||||
@ -205,35 +192,29 @@ async def download_project_file(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
pf = result.scalar_one_or_none()
|
pf = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not pf:
|
if not pf:
|
||||||
raise HTTPException(404, "File not found")
|
raise HTTPException(404, "File not found")
|
||||||
|
|
||||||
project_dir = os.path.realpath(os.path.join(PROJECTS_DIR, slug))
|
proj_dir = os.path.realpath(_project_dir(project))
|
||||||
full_path = os.path.realpath(os.path.join(project_dir, pf.storage_path))
|
full_path = os.path.realpath(os.path.join(proj_dir, pf.storage_path))
|
||||||
if not full_path.startswith(project_dir + os.sep):
|
if not full_path.startswith(proj_dir + os.sep):
|
||||||
raise HTTPException(400, "Invalid file path")
|
raise HTTPException(400, "Invalid file path")
|
||||||
|
|
||||||
if not os.path.exists(full_path):
|
if not os.path.exists(full_path):
|
||||||
raise HTTPException(404, "File not found on disk")
|
raise HTTPException(404, "File not found on disk")
|
||||||
|
|
||||||
return FileResponse(
|
return FileResponse(full_path, filename=pf.filename, media_type=pf.mime_type or "application/octet-stream")
|
||||||
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(
|
async def update_project_file(
|
||||||
request: Request,
|
request: Request,
|
||||||
slug: str,
|
project_id: str,
|
||||||
file_id: str,
|
file_id: str,
|
||||||
data: ProjectFileUpdate,
|
data: ProjectFileUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
member = await _get_member(request, db)
|
member = await _get_member(request, db)
|
||||||
project = await _get_project(slug, db)
|
project = await _get_project(project_id, db)
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(ProjectFile).where(
|
select(ProjectFile).where(
|
||||||
@ -242,7 +223,6 @@ async def update_project_file(
|
|||||||
).options(selectinload(ProjectFile.uploader))
|
).options(selectinload(ProjectFile.uploader))
|
||||||
)
|
)
|
||||||
pf = result.scalar_one_or_none()
|
pf = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not pf:
|
if not pf:
|
||||||
raise HTTPException(404, "File not found")
|
raise HTTPException(404, "File not found")
|
||||||
|
|
||||||
@ -251,19 +231,18 @@ async def update_project_file(
|
|||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(pf)
|
await db.refresh(pf)
|
||||||
|
|
||||||
return project_file_out(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(
|
async def delete_project_file(
|
||||||
request: Request,
|
request: Request,
|
||||||
slug: str,
|
project_id: str,
|
||||||
file_id: str,
|
file_id: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
member = await _get_member(request, db)
|
member = await _get_member(request, db)
|
||||||
project = await _get_project(slug, db)
|
project = await _get_project(project_id, db)
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(ProjectFile).where(
|
select(ProjectFile).where(
|
||||||
@ -272,16 +251,14 @@ async def delete_project_file(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
pf = result.scalar_one_or_none()
|
pf = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not pf:
|
if not pf:
|
||||||
raise HTTPException(404, "File not found")
|
raise HTTPException(404, "File not found")
|
||||||
|
|
||||||
project_dir = os.path.realpath(os.path.join(PROJECTS_DIR, slug))
|
proj_dir = os.path.realpath(_project_dir(project))
|
||||||
full_path = os.path.realpath(os.path.join(project_dir, pf.storage_path))
|
full_path = os.path.realpath(os.path.join(proj_dir, pf.storage_path))
|
||||||
if full_path.startswith(project_dir + os.sep) and os.path.exists(full_path):
|
if full_path.startswith(proj_dir + os.sep) and os.path.exists(full_path):
|
||||||
os.remove(full_path)
|
os.remove(full_path)
|
||||||
|
|
||||||
await db.delete(pf)
|
await db.delete(pf)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
"""Projects API."""
|
"""Projects API."""
|
||||||
|
|
||||||
|
import uuid as _uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@ -23,13 +25,14 @@ class ProjectCreate(BaseModel):
|
|||||||
|
|
||||||
class ProjectUpdate(BaseModel):
|
class ProjectUpdate(BaseModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
|
slug: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
repo_urls: list[str] | None = None
|
repo_urls: list[str] | None = None
|
||||||
status: str | None = None
|
status: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProjectMemberAdd(BaseModel):
|
class ProjectMemberAdd(BaseModel):
|
||||||
slug: str
|
member_id: str # UUID
|
||||||
|
|
||||||
|
|
||||||
class ProjectMemberOut(BaseModel):
|
class ProjectMemberOut(BaseModel):
|
||||||
@ -43,6 +46,16 @@ class ProjectMemberOut(BaseModel):
|
|||||||
from_attributes = True
|
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:
|
async def _project_out(p: Project, db: AsyncSession) -> ProjectOut:
|
||||||
chat_result = await db.execute(
|
chat_result = await db.execute(
|
||||||
select(Chat).where(Chat.project_id == p.id, Chat.kind == ChatKind.PROJECT)
|
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])
|
@router.get("/projects", response_model=list[ProjectOut])
|
||||||
async def list_projects(db: AsyncSession = Depends(get_db)):
|
async def list_projects(db: AsyncSession = Depends(get_db)):
|
||||||
result = await db.execute(select(Project).where(Project.status == ProjectStatus.ACTIVE))
|
result = await db.execute(select(Project).where(Project.status == ProjectStatus.ACTIVE))
|
||||||
return [await _project_out(p, db) for p in result.scalars()]
|
return [await _project_out(p, db) for p in result.scalars()]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/projects/{slug}", response_model=ProjectOut)
|
@router.get("/projects/{project_id}", response_model=ProjectOut)
|
||||||
async def get_project(slug: str, db: AsyncSession = Depends(get_db)):
|
async def get_project(project_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
result = await db.execute(select(Project).where(Project.slug == slug))
|
return await _project_out(await _get_project(project_id, db), db)
|
||||||
project = result.scalar_one_or_none()
|
|
||||||
if not project:
|
|
||||||
raise HTTPException(404, "Project not found")
|
|
||||||
return await _project_out(project, db)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/projects", response_model=ProjectOut)
|
@router.post("/projects", response_model=ProjectOut)
|
||||||
@ -90,7 +101,6 @@ async def create_project(req: ProjectCreate, db: AsyncSession = Depends(get_db))
|
|||||||
db.add(project)
|
db.add(project)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
# Create project chat
|
|
||||||
chat = Chat(project_id=project.id, kind=ChatKind.PROJECT)
|
chat = Chat(project_id=project.id, kind=ChatKind.PROJECT)
|
||||||
db.add(chat)
|
db.add(chat)
|
||||||
|
|
||||||
@ -98,15 +108,18 @@ async def create_project(req: ProjectCreate, db: AsyncSession = Depends(get_db))
|
|||||||
return await _project_out(project, db)
|
return await _project_out(project, db)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/projects/{slug}", response_model=ProjectOut)
|
@router.patch("/projects/{project_id}", response_model=ProjectOut)
|
||||||
async def update_project(slug: str, req: ProjectUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_project(project_id: str, req: ProjectUpdate, db: AsyncSession = Depends(get_db)):
|
||||||
result = await db.execute(select(Project).where(Project.slug == slug))
|
project = await _get_project(project_id, db)
|
||||||
project = result.scalar_one_or_none()
|
|
||||||
if not project:
|
|
||||||
raise HTTPException(404, "Project not found")
|
|
||||||
|
|
||||||
if req.name is not None:
|
if req.name is not None:
|
||||||
project.name = req.name
|
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:
|
if req.description is not None:
|
||||||
project.description = req.description
|
project.description = req.description
|
||||||
if req.repo_urls is not None:
|
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)
|
return await _project_out(project, db)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/projects/{slug}")
|
@router.delete("/projects/{project_id}")
|
||||||
async def delete_project(slug: str, request: Request, db: AsyncSession = Depends(get_db)):
|
async def delete_project(project_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||||
member = getattr(request.state, "member", None)
|
member = getattr(request.state, "member", None)
|
||||||
if not member or member.role != MemberRole.OWNER:
|
if not member or member.role != MemberRole.OWNER:
|
||||||
raise HTTPException(403, "Only owners can delete projects")
|
raise HTTPException(403, "Only owners can delete projects")
|
||||||
result = await db.execute(select(Project).where(Project.slug == slug))
|
project = await _get_project(project_id, db)
|
||||||
project = result.scalar_one_or_none()
|
|
||||||
if not project:
|
|
||||||
raise HTTPException(404, "Project not found")
|
|
||||||
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])
|
@router.get("/projects/{project_id}/members", response_model=list[ProjectMemberOut])
|
||||||
async def get_project_members(slug: str, db: AsyncSession = Depends(get_db)):
|
async def get_project_members(project_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
"""Get members of a project."""
|
project = await _get_project(project_id, 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")
|
|
||||||
|
|
||||||
# Join ProjectMember with Member to get member details
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Member, ProjectMember.role)
|
select(Member, ProjectMember.role)
|
||||||
.join(ProjectMember, Member.id == ProjectMember.member_id)
|
.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))
|
.options(selectinload(Member.agent_config))
|
||||||
)
|
)
|
||||||
|
|
||||||
members = []
|
return [
|
||||||
for member, role in result.all():
|
{"id": str(m.id), "name": m.name, "slug": m.slug, "type": m.type, "role": role}
|
||||||
members.append({
|
for m, role in result.all()
|
||||||
"id": str(member.id),
|
]
|
||||||
"name": member.name,
|
|
||||||
"slug": member.slug,
|
|
||||||
"type": member.type,
|
|
||||||
"role": role,
|
|
||||||
})
|
|
||||||
|
|
||||||
return members
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/projects/{slug}/members")
|
@router.post("/projects/{project_id}/members")
|
||||||
async def add_project_member(slug: str, req: ProjectMemberAdd, db: AsyncSession = Depends(get_db)):
|
async def add_project_member(project_id: str, req: ProjectMemberAdd, db: AsyncSession = Depends(get_db)):
|
||||||
"""Add a member to a project."""
|
project = await _get_project(project_id, db)
|
||||||
# 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.id == _uuid.UUID(req.member_id)))
|
||||||
result = await db.execute(select(Member).where(Member.slug == req.slug))
|
|
||||||
member = result.scalar_one_or_none()
|
member = result.scalar_one_or_none()
|
||||||
if not member:
|
if not member:
|
||||||
raise HTTPException(404, "Member not found")
|
raise HTTPException(404, "Member not found")
|
||||||
|
|
||||||
# Check if already a member
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(ProjectMember).where(
|
select(ProjectMember).where(
|
||||||
ProjectMember.project_id == project.id,
|
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():
|
if result.scalar_one_or_none():
|
||||||
raise HTTPException(409, "Member already in project")
|
raise HTTPException(409, "Member already in project")
|
||||||
|
|
||||||
# Add member
|
|
||||||
project_member = ProjectMember(
|
project_member = ProjectMember(
|
||||||
project_id=project.id,
|
project_id=project.id,
|
||||||
member_id=member.id,
|
member_id=member.id,
|
||||||
@ -194,41 +184,26 @@ async def add_project_member(slug: str, req: ProjectMemberAdd, db: AsyncSession
|
|||||||
)
|
)
|
||||||
db.add(project_member)
|
db.add(project_member)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/projects/{slug}/members/{member_slug}")
|
@router.delete("/projects/{project_id}/members/{member_id}")
|
||||||
async def remove_project_member(slug: str, member_slug: str, db: AsyncSession = Depends(get_db)):
|
async def remove_project_member(project_id: str, member_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
"""Remove a member from a project."""
|
project = await _get_project(project_id, db)
|
||||||
# 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(
|
result = await db.execute(
|
||||||
select(ProjectMember).where(
|
select(ProjectMember).where(
|
||||||
ProjectMember.project_id == project.id,
|
ProjectMember.project_id == project.id,
|
||||||
ProjectMember.member_id == member.id
|
ProjectMember.member_id == _uuid.UUID(member_id)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
project_member = result.scalar_one_or_none()
|
project_member = result.scalar_one_or_none()
|
||||||
if not project_member:
|
if not project_member:
|
||||||
raise HTTPException(404, "Member not in project")
|
raise HTTPException(404, "Member not in project")
|
||||||
|
|
||||||
# Don't allow removing owner
|
|
||||||
if project_member.role == MemberRole.OWNER:
|
if project_member.role == MemberRole.OWNER:
|
||||||
raise HTTPException(400, "Cannot remove project owner")
|
raise HTTPException(400, "Cannot remove project owner")
|
||||||
|
|
||||||
await db.delete(project_member)
|
await db.delete(project_member)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
@ -210,7 +210,6 @@ async def _resolve_member(db: AsyncSession, member_id: str) -> Member:
|
|||||||
@router.get("/tasks", response_model=list[TaskOut])
|
@router.get("/tasks", response_model=list[TaskOut])
|
||||||
async def list_tasks(
|
async def list_tasks(
|
||||||
project_id: Optional[str] = Query(None),
|
project_id: Optional[str] = Query(None),
|
||||||
project_slug: Optional[str] = Query(None),
|
|
||||||
status: Optional[str] = Query(None),
|
status: Optional[str] = Query(None),
|
||||||
assignee_id: Optional[str] = Query(None),
|
assignee_id: Optional[str] = Query(None),
|
||||||
label: Optional[str] = Query(None),
|
label: Optional[str] = Query(None),
|
||||||
@ -224,11 +223,6 @@ async def list_tasks(
|
|||||||
)
|
)
|
||||||
if project_id:
|
if project_id:
|
||||||
q = q.where(Task.project_id == uuid.UUID(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:
|
if status:
|
||||||
q = q.where(Task.status == status)
|
q = q.where(Task.status == status)
|
||||||
if assignee_id:
|
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)
|
@router.post("/tasks", response_model=TaskOut)
|
||||||
async def create_task(
|
async def create_task(
|
||||||
project_slug: str = Query(...),
|
project_id: str = Query(...),
|
||||||
req: TaskCreate = ...,
|
req: TaskCreate = ...,
|
||||||
current_member: Member = Depends(get_current_member),
|
current_member: Member = Depends(get_current_member),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
result = await db.execute(
|
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()
|
project = result.scalar_one_or_none()
|
||||||
if not project:
|
if not project:
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
@ -41,15 +42,15 @@ async def heartbeat_monitor():
|
|||||||
|
|
||||||
for session_id in timed_out:
|
for session_id in timed_out:
|
||||||
client = await manager.disconnect(session_id)
|
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:
|
async with async_session() as db:
|
||||||
await db.execute(
|
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 db.commit()
|
||||||
await manager.broadcast_all(
|
await manager.broadcast_all(
|
||||||
{"type": "agent.status", "data": {"slug": client.member_slug, "status": "offline"}},
|
{"type": "agent.status", "data": {"id": client.member_id, "slug": client.member_slug, "status": "offline"}},
|
||||||
exclude_slug=client.member_slug,
|
exclude_member_id=client.member_id,
|
||||||
)
|
)
|
||||||
logger.info("Heartbeat timeout: %s set offline", client.member_slug)
|
logger.info("Heartbeat timeout: %s set offline", client.member_slug)
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user