From 6069a2da1f276f3cd572027074ed6026b6854e24 Mon Sep 17 00:00:00 2001 From: markov Date: Fri, 27 Feb 2026 09:37:16 +0100 Subject: [PATCH] UUID everywhere: all API URLs use ID instead of slug - /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 --- src/tracker/api/members.py | 69 ++++++--------- src/tracker/api/project_files.py | 145 +++++++++++++------------------ src/tracker/api/projects.py | 133 ++++++++++++---------------- src/tracker/api/tasks.py | 10 +-- src/tracker/app.py | 9 +- 5 files changed, 150 insertions(+), 216 deletions(-) diff --git a/src/tracker/api/members.py b/src/tracker/api/members.py index 914e6a5..81c2f0b 100644 --- a/src/tracker/api/members.py +++ b/src/tracker/api/members.py @@ -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} diff --git a/src/tracker/api/project_files.py b/src/tracker/api/project_files.py index 15a02dc..d5ae2e9 100644 --- a/src/tracker/api/project_files.py +++ b/src/tracker/api/project_files.py @@ -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} diff --git a/src/tracker/api/projects.py b/src/tracker/api/projects.py index e0929a8..febb44a 100644 --- a/src/tracker/api/projects.py +++ b/src/tracker/api/projects.py @@ -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} diff --git a/src/tracker/api/tasks.py b/src/tracker/api/tasks.py index c6e3dcb..9662b82 100644 --- a/src/tracker/api/tasks.py +++ b/src/tracker/api/tasks.py @@ -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: diff --git a/src/tracker/app.py b/src/tracker/app.py index a1223d9..8c316ef 100644 --- a/src/tracker/app.py +++ b/src/tracker/app.py @@ -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)