From 3c1f89d91d095533f58fc931fb65e06c46bf3a79 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sun, 15 Feb 2026 03:19:45 +0530 Subject: [PATCH] feat(api): add delete task endpoint for board leads with authorization checks --- backend/app/api/agent.py | 46 ++++++++++++ backend/app/api/tasks.py | 39 +++++++---- backend/tests/test_agent_task_delete_api.py | 78 +++++++++++++++++++++ 3 files changed, 148 insertions(+), 15 deletions(-) create mode 100644 backend/tests/test_agent_task_delete_api.py diff --git a/backend/app/api/agent.py b/backend/app/api/agent.py index f9cec9f5..fdfaf4a8 100644 --- a/backend/app/api/agent.py +++ b/backend/app/api/agent.py @@ -742,6 +742,52 @@ async def update_task( ) +@router.delete( + "/boards/{board_id}/tasks/{task_id}", + response_model=OkResponse, + tags=AGENT_BOARD_TAGS, + summary="Delete a task as board lead", + description=( + "Delete a board task and related records.\n\n" + "This action is restricted to board lead agents." + ), + openapi_extra=_agent_board_openapi_hints( + intent="agent_task_delete", + when_to_use=[ + "Board lead needs to permanently remove an obsolete, duplicate, or invalid task.", + ], + when_not_to_use=[ + "Use task updates when status changes or reassignment is sufficient.", + ], + required_actor="board_lead", + side_effects=[ + "Deletes task comments, dependencies, tags, custom field values, and linked records.", + ], + routing_examples=[ + { + "input": { + "intent": "lead removes a duplicate task", + "required_privilege": "board_lead", + }, + "decision": "agent_task_delete", + } + ], + ), +) +async def delete_task( + task: Task = TASK_DEP, + session: AsyncSession = SESSION_DEP, + agent_ctx: AgentAuthContext = AGENT_CTX_DEP, +) -> OkResponse: + """Delete a task after board-lead authorization checks.""" + _guard_task_access(agent_ctx, task) + _require_board_lead(agent_ctx) + if task.board_id is None: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) + await tasks_api.delete_task_and_related_records(session, task=task) + return OkResponse() + + @router.get( "/boards/{board_id}/tasks/{task_id}/comments", response_model=DefaultLimitOffsetPage[TaskCommentRead], diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 32f49127..cf37ea3b 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -1426,21 +1426,12 @@ async def update_task( ) -@router.delete("/{task_id}", response_model=OkResponse) -async def delete_task( - session: AsyncSession = SESSION_DEP, - task: Task = TASK_DEP, - auth: AuthContext = ADMIN_AUTH_DEP, -) -> OkResponse: - """Delete a task and related records.""" - if task.board_id is None: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) - board = await Board.objects.by_id(task.board_id).first(session) - if board is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - if auth.user is None: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) - await require_board_access(session, user=auth.user, board=board, write=True) +async def delete_task_and_related_records( + session: AsyncSession, + *, + task: Task, +) -> None: + """Delete a task and associated relational records, then commit.""" await crud.delete_where( session, ActivityEvent, @@ -1496,6 +1487,24 @@ async def delete_task( ) await session.delete(task) await session.commit() + + +@router.delete("/{task_id}", response_model=OkResponse) +async def delete_task( + session: AsyncSession = SESSION_DEP, + task: Task = TASK_DEP, + auth: AuthContext = ADMIN_AUTH_DEP, +) -> OkResponse: + """Delete a task and related records.""" + if task.board_id is None: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) + board = await Board.objects.by_id(task.board_id).first(session) + if board is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if auth.user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + await require_board_access(session, user=auth.user, board=board, write=True) + await delete_task_and_related_records(session, task=task) return OkResponse() diff --git a/backend/tests/test_agent_task_delete_api.py b/backend/tests/test_agent_task_delete_api.py new file mode 100644 index 00000000..592a9475 --- /dev/null +++ b/backend/tests/test_agent_task_delete_api.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from uuid import UUID, uuid4 + +import pytest +from fastapi import HTTPException + +from app.api import agent as agent_api +from app.core.agent_auth import AgentAuthContext +from app.models.agents import Agent +from app.models.tasks import Task + + +def _agent_ctx(*, board_id: UUID, is_board_lead: bool) -> AgentAuthContext: + return AgentAuthContext( + actor_type="agent", + agent=Agent( + id=uuid4(), + board_id=board_id, + gateway_id=uuid4(), + name="Worker", + is_board_lead=is_board_lead, + ), + ) + + +@pytest.mark.asyncio +async def test_delete_task_rejects_non_lead_agent() -> None: + board_id = uuid4() + task = Task( + id=uuid4(), + board_id=board_id, + title="Obsolete task", + ) + + with pytest.raises(HTTPException) as exc: + await agent_api.delete_task( + task=task, + session=object(), # type: ignore[arg-type] + agent_ctx=_agent_ctx(board_id=board_id, is_board_lead=False), + ) + + assert exc.value.status_code == 403 + assert exc.value.detail == "Only board leads can perform this action" + + +@pytest.mark.asyncio +async def test_delete_task_allows_board_lead_and_calls_delete_helper( + monkeypatch: pytest.MonkeyPatch, +) -> None: + board_id = uuid4() + task = Task( + id=uuid4(), + board_id=board_id, + title="Obsolete task", + ) + session = object() + called: dict[str, object] = {} + + async def _fake_delete_task_and_related_records(_session: object, *, task: Task) -> None: + called["session"] = _session + called["task_id"] = task.id + + monkeypatch.setattr( + agent_api.tasks_api, + "delete_task_and_related_records", + _fake_delete_task_and_related_records, + ) + + response = await agent_api.delete_task( + task=task, + session=session, # type: ignore[arg-type] + agent_ctx=_agent_ctx(board_id=board_id, is_board_lead=True), + ) + + assert response.ok is True + assert called["session"] is session + assert called["task_id"] == task.id