feat(api): add delete task endpoint for board leads with authorization checks
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
78
backend/tests/test_agent_task_delete_api.py
Normal file
78
backend/tests/test_agent_task_delete_api.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user