chore: update generated files to orval v8.3.0 and adjust related interfaces
This commit is contained in:
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func
|
||||
from sqlmodel import SQLModel, col, select
|
||||
|
||||
from app.api import agents as agents_api
|
||||
@@ -20,6 +21,7 @@ from app.db.session import get_session
|
||||
from app.models.agents import Agent
|
||||
from app.models.boards import Board
|
||||
from app.models.task_dependencies import TaskDependency
|
||||
from app.models.task_tags import TaskTag
|
||||
from app.models.tasks import Task
|
||||
from app.schemas.agents import (
|
||||
AgentCreate,
|
||||
@@ -42,6 +44,7 @@ from app.schemas.gateway_coordination import (
|
||||
GatewayMainAskUserResponse,
|
||||
)
|
||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
||||
from app.schemas.task_tags import TaskTagRef
|
||||
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
|
||||
from app.services.activity_log import record_activity
|
||||
from app.services.openclaw.coordination_service import GatewayCoordinationService
|
||||
@@ -52,6 +55,7 @@ from app.services.task_dependencies import (
|
||||
dependency_status_by_id,
|
||||
validate_dependency_update,
|
||||
)
|
||||
from app.services.task_tags import replace_task_tags, validate_task_tag_ids
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
@@ -210,6 +214,32 @@ async def list_tasks(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/boards/{board_id}/tags", response_model=list[TaskTagRef])
|
||||
async def list_task_tags(
|
||||
board: Board = BOARD_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> list[TaskTagRef]:
|
||||
"""List task tags available to the board's organization."""
|
||||
_guard_board_access(agent_ctx, board)
|
||||
tags = (
|
||||
await session.exec(
|
||||
select(TaskTag)
|
||||
.where(col(TaskTag.organization_id) == board.organization_id)
|
||||
.order_by(func.lower(col(TaskTag.name)).asc(), col(TaskTag.created_at).asc()),
|
||||
)
|
||||
).all()
|
||||
return [
|
||||
TaskTagRef(
|
||||
id=tag.id,
|
||||
name=tag.name,
|
||||
slug=tag.slug,
|
||||
color=tag.color,
|
||||
)
|
||||
for tag in tags
|
||||
]
|
||||
|
||||
|
||||
@router.post("/boards/{board_id}/tasks", response_model=TaskRead)
|
||||
async def create_task(
|
||||
payload: TaskCreate,
|
||||
@@ -220,8 +250,9 @@ async def create_task(
|
||||
"""Create a task on the board as the lead agent."""
|
||||
_guard_board_access(agent_ctx, board)
|
||||
_require_board_lead(agent_ctx)
|
||||
data = payload.model_dump(exclude={"depends_on_task_ids"})
|
||||
data = payload.model_dump(exclude={"depends_on_task_ids", "tag_ids"})
|
||||
depends_on_task_ids = list(payload.depends_on_task_ids)
|
||||
tag_ids = list(payload.tag_ids)
|
||||
|
||||
task = Task.model_validate(data)
|
||||
task.board_id = board.id
|
||||
@@ -234,6 +265,11 @@ async def create_task(
|
||||
task_id=task.id,
|
||||
depends_on_task_ids=depends_on_task_ids,
|
||||
)
|
||||
normalized_tag_ids = await validate_task_tag_ids(
|
||||
session,
|
||||
organization_id=board.organization_id,
|
||||
tag_ids=tag_ids,
|
||||
)
|
||||
dep_status = await dependency_status_by_id(
|
||||
session,
|
||||
board_id=board.id,
|
||||
@@ -274,6 +310,11 @@ async def create_task(
|
||||
depends_on_task_id=dep_id,
|
||||
),
|
||||
)
|
||||
await replace_task_tags(
|
||||
session,
|
||||
task_id=task.id,
|
||||
tag_ids=normalized_tag_ids,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(task)
|
||||
record_activity(
|
||||
@@ -295,12 +336,10 @@ async def create_task(
|
||||
task=task,
|
||||
agent=assigned_agent,
|
||||
)
|
||||
return TaskRead.model_validate(task, from_attributes=True).model_copy(
|
||||
update={
|
||||
"depends_on_task_ids": normalized_deps,
|
||||
"blocked_by_task_ids": blocked_by,
|
||||
"is_blocked": bool(blocked_by),
|
||||
},
|
||||
return await tasks_api._task_read_response(
|
||||
session,
|
||||
task=task,
|
||||
board_id=board.id,
|
||||
)
|
||||
|
||||
|
||||
|
||||
220
backend/app/api/task_tags.py
Normal file
220
backend/app/api/task_tags.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""Task-tag CRUD endpoints for organization-scoped task categorization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func
|
||||
from sqlmodel import col, select
|
||||
|
||||
from app.api.deps import require_org_admin, require_org_member
|
||||
from app.core.time import utcnow
|
||||
from app.db import crud
|
||||
from app.db.pagination import paginate
|
||||
from app.db.session import get_session
|
||||
from app.models.task_tag_assignments import TaskTagAssignment
|
||||
from app.models.task_tags import TaskTag
|
||||
from app.schemas.common import OkResponse
|
||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
||||
from app.schemas.task_tags import TaskTagCreate, TaskTagRead, TaskTagUpdate
|
||||
from app.services.organizations import OrganizationContext
|
||||
from app.services.task_tags import slugify_task_tag, task_counts_for_tags
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from fastapi_pagination.limit_offset import LimitOffsetPage
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
router = APIRouter(prefix="/tags", tags=["tags"])
|
||||
SESSION_DEP = Depends(get_session)
|
||||
ORG_MEMBER_DEP = Depends(require_org_member)
|
||||
ORG_ADMIN_DEP = Depends(require_org_admin)
|
||||
|
||||
|
||||
def _normalize_slug(slug: str | None, *, fallback_name: str) -> str:
|
||||
source = (slug or "").strip() or fallback_name
|
||||
return slugify_task_tag(source)
|
||||
|
||||
|
||||
async def _require_org_task_tag(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
tag_id: UUID,
|
||||
ctx: OrganizationContext,
|
||||
) -> TaskTag:
|
||||
tag = await TaskTag.objects.by_id(tag_id).first(session)
|
||||
if tag is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
if tag.organization_id != ctx.organization.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
return tag
|
||||
|
||||
|
||||
async def _ensure_slug_available(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
organization_id: UUID,
|
||||
slug: str,
|
||||
exclude_tag_id: UUID | None = None,
|
||||
) -> None:
|
||||
existing = await TaskTag.objects.filter_by(organization_id=organization_id, slug=slug).first(
|
||||
session
|
||||
)
|
||||
if existing is None:
|
||||
return
|
||||
if exclude_tag_id is not None and existing.id == exclude_tag_id:
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Task tag slug already exists in this organization.",
|
||||
)
|
||||
|
||||
|
||||
async def _tag_read_page(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
items: Sequence[TaskTag],
|
||||
) -> list[TaskTagRead]:
|
||||
if not items:
|
||||
return []
|
||||
counts = await task_counts_for_tags(
|
||||
session,
|
||||
tag_ids=[item.id for item in items],
|
||||
)
|
||||
return [
|
||||
TaskTagRead.model_validate(item, from_attributes=True).model_copy(
|
||||
update={"task_count": counts.get(item.id, 0)},
|
||||
)
|
||||
for item in items
|
||||
]
|
||||
|
||||
|
||||
@router.get("", response_model=DefaultLimitOffsetPage[TaskTagRead])
|
||||
async def list_task_tags(
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||
) -> LimitOffsetPage[TaskTagRead]:
|
||||
"""List task tags for the active organization."""
|
||||
statement = (
|
||||
select(TaskTag)
|
||||
.where(col(TaskTag.organization_id) == ctx.organization.id)
|
||||
.order_by(func.lower(col(TaskTag.name)).asc(), col(TaskTag.created_at).asc())
|
||||
)
|
||||
|
||||
async def _transform(items: Sequence[object]) -> Sequence[object]:
|
||||
tags: list[TaskTag] = []
|
||||
for item in items:
|
||||
if not isinstance(item, TaskTag):
|
||||
msg = "Expected TaskTag items from paginated query"
|
||||
raise TypeError(msg)
|
||||
tags.append(item)
|
||||
return await _tag_read_page(session=session, items=tags)
|
||||
|
||||
return await paginate(session, statement, transformer=_transform)
|
||||
|
||||
|
||||
@router.post("", response_model=TaskTagRead)
|
||||
async def create_task_tag(
|
||||
payload: TaskTagCreate,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> TaskTagRead:
|
||||
"""Create a task tag within the active organization."""
|
||||
slug = _normalize_slug(payload.slug, fallback_name=payload.name)
|
||||
await _ensure_slug_available(
|
||||
session,
|
||||
organization_id=ctx.organization.id,
|
||||
slug=slug,
|
||||
)
|
||||
tag = await crud.create(
|
||||
session,
|
||||
TaskTag,
|
||||
organization_id=ctx.organization.id,
|
||||
name=payload.name,
|
||||
slug=slug,
|
||||
color=payload.color,
|
||||
description=payload.description,
|
||||
)
|
||||
return TaskTagRead.model_validate(tag, from_attributes=True)
|
||||
|
||||
|
||||
@router.get("/{tag_id}", response_model=TaskTagRead)
|
||||
async def get_task_tag(
|
||||
tag_id: UUID,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||
) -> TaskTagRead:
|
||||
"""Get a single task tag in the active organization."""
|
||||
tag = await _require_org_task_tag(
|
||||
session,
|
||||
tag_id=tag_id,
|
||||
ctx=ctx,
|
||||
)
|
||||
count = (
|
||||
await session.exec(
|
||||
select(func.count(col(TaskTagAssignment.task_id))).where(
|
||||
col(TaskTagAssignment.tag_id) == tag.id,
|
||||
),
|
||||
)
|
||||
).one()
|
||||
return TaskTagRead.model_validate(tag, from_attributes=True).model_copy(
|
||||
update={"task_count": int(count or 0)},
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{tag_id}", response_model=TaskTagRead)
|
||||
async def update_task_tag(
|
||||
tag_id: UUID,
|
||||
payload: TaskTagUpdate,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> TaskTagRead:
|
||||
"""Update a task tag in the active organization."""
|
||||
tag = await _require_org_task_tag(
|
||||
session,
|
||||
tag_id=tag_id,
|
||||
ctx=ctx,
|
||||
)
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
|
||||
if "slug" in payload.model_fields_set:
|
||||
updates["slug"] = _normalize_slug(
|
||||
updates.get("slug"),
|
||||
fallback_name=str(updates.get("name") or tag.name),
|
||||
)
|
||||
if "slug" in updates and isinstance(updates["slug"], str):
|
||||
await _ensure_slug_available(
|
||||
session,
|
||||
organization_id=ctx.organization.id,
|
||||
slug=updates["slug"],
|
||||
exclude_tag_id=tag.id,
|
||||
)
|
||||
updates["updated_at"] = utcnow()
|
||||
updated = await crud.patch(session, tag, updates)
|
||||
return TaskTagRead.model_validate(updated, from_attributes=True)
|
||||
|
||||
|
||||
@router.delete("/{tag_id}", response_model=OkResponse)
|
||||
async def delete_task_tag(
|
||||
tag_id: UUID,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> OkResponse:
|
||||
"""Delete a task tag and remove all associated task-tag links."""
|
||||
tag = await _require_org_task_tag(
|
||||
session,
|
||||
tag_id=tag_id,
|
||||
ctx=ctx,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session,
|
||||
TaskTagAssignment,
|
||||
col(TaskTagAssignment.tag_id) == tag.id,
|
||||
commit=False,
|
||||
)
|
||||
await session.delete(tag)
|
||||
await session.commit()
|
||||
return OkResponse()
|
||||
@@ -34,6 +34,7 @@ from app.models.approvals import Approval
|
||||
from app.models.boards import Board
|
||||
from app.models.task_dependencies import TaskDependency
|
||||
from app.models.task_fingerprints import TaskFingerprint
|
||||
from app.models.task_tag_assignments import TaskTagAssignment
|
||||
from app.models.tasks import Task
|
||||
from app.schemas.activity_events import ActivityEventRead
|
||||
from app.schemas.common import OkResponse
|
||||
@@ -55,6 +56,12 @@ from app.services.task_dependencies import (
|
||||
replace_task_dependencies,
|
||||
validate_dependency_update,
|
||||
)
|
||||
from app.services.task_tags import (
|
||||
TaskTagState,
|
||||
load_task_tag_state,
|
||||
replace_task_tags,
|
||||
validate_task_tag_ids,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
@@ -576,6 +583,10 @@ async def _task_read_page(
|
||||
return []
|
||||
|
||||
task_ids = [task.id for task in tasks]
|
||||
tag_state_by_task_id = await load_task_tag_state(
|
||||
session,
|
||||
task_ids=task_ids,
|
||||
)
|
||||
deps_map = await dependency_ids_by_task_id(
|
||||
session,
|
||||
board_id=board_id,
|
||||
@@ -592,6 +603,7 @@ async def _task_read_page(
|
||||
|
||||
output: list[TaskRead] = []
|
||||
for task in tasks:
|
||||
tag_state = tag_state_by_task_id.get(task.id, TaskTagState())
|
||||
dep_list = deps_map.get(task.id, [])
|
||||
blocked_by = blocked_by_dependency_ids(
|
||||
dependency_ids=dep_list,
|
||||
@@ -603,6 +615,8 @@ async def _task_read_page(
|
||||
TaskRead.model_validate(task, from_attributes=True).model_copy(
|
||||
update={
|
||||
"depends_on_task_ids": dep_list,
|
||||
"tag_ids": tag_state.tag_ids,
|
||||
"tags": tag_state.tags,
|
||||
"blocked_by_task_ids": blocked_by,
|
||||
"is_blocked": bool(blocked_by),
|
||||
},
|
||||
@@ -611,18 +625,22 @@ async def _task_read_page(
|
||||
return output
|
||||
|
||||
|
||||
async def _stream_dependency_state(
|
||||
async def _stream_task_state(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
board_id: UUID,
|
||||
rows: list[tuple[ActivityEvent, Task | None]],
|
||||
) -> tuple[dict[UUID, list[UUID]], dict[UUID, str]]:
|
||||
) -> tuple[dict[UUID, list[UUID]], dict[UUID, str], dict[UUID, TaskTagState]]:
|
||||
task_ids = [
|
||||
task.id for event, task in rows if task is not None and event.event_type != "task.comment"
|
||||
]
|
||||
if not task_ids:
|
||||
return {}, {}
|
||||
return {}, {}, {}
|
||||
|
||||
tag_state_by_task_id = await load_task_tag_state(
|
||||
session,
|
||||
task_ids=list({*task_ids}),
|
||||
)
|
||||
deps_map = await dependency_ids_by_task_id(
|
||||
session,
|
||||
board_id=board_id,
|
||||
@@ -632,14 +650,14 @@ async def _stream_dependency_state(
|
||||
for value in deps_map.values():
|
||||
dep_ids.extend(value)
|
||||
if not dep_ids:
|
||||
return deps_map, {}
|
||||
return deps_map, {}, tag_state_by_task_id
|
||||
|
||||
dep_status = await dependency_status_by_id(
|
||||
session,
|
||||
board_id=board_id,
|
||||
dependency_ids=list({*dep_ids}),
|
||||
)
|
||||
return deps_map, dep_status
|
||||
return deps_map, dep_status, tag_state_by_task_id
|
||||
|
||||
|
||||
def _task_event_payload(
|
||||
@@ -648,6 +666,7 @@ def _task_event_payload(
|
||||
*,
|
||||
deps_map: dict[UUID, list[UUID]],
|
||||
dep_status: dict[UUID, str],
|
||||
tag_state_by_task_id: dict[UUID, TaskTagState],
|
||||
) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"type": event.event_type,
|
||||
@@ -660,6 +679,7 @@ def _task_event_payload(
|
||||
payload["task"] = None
|
||||
return payload
|
||||
|
||||
tag_state = tag_state_by_task_id.get(task.id, TaskTagState())
|
||||
dep_list = deps_map.get(task.id, [])
|
||||
blocked_by = blocked_by_dependency_ids(
|
||||
dependency_ids=dep_list,
|
||||
@@ -672,6 +692,8 @@ def _task_event_payload(
|
||||
.model_copy(
|
||||
update={
|
||||
"depends_on_task_ids": dep_list,
|
||||
"tag_ids": tag_state.tag_ids,
|
||||
"tags": tag_state.tags,
|
||||
"blocked_by_task_ids": blocked_by,
|
||||
"is_blocked": bool(blocked_by),
|
||||
},
|
||||
@@ -697,7 +719,7 @@ async def _task_event_generator(
|
||||
|
||||
async with async_session_maker() as session:
|
||||
rows = await _fetch_task_events(session, board_id, last_seen)
|
||||
deps_map, dep_status = await _stream_dependency_state(
|
||||
deps_map, dep_status, tag_state_by_task_id = await _stream_task_state(
|
||||
session,
|
||||
board_id=board_id,
|
||||
rows=rows,
|
||||
@@ -718,6 +740,7 @@ async def _task_event_generator(
|
||||
task,
|
||||
deps_map=deps_map,
|
||||
dep_status=dep_status,
|
||||
tag_state_by_task_id=tag_state_by_task_id,
|
||||
)
|
||||
yield {"event": "task", "data": json.dumps(payload)}
|
||||
await asyncio.sleep(2)
|
||||
@@ -778,8 +801,9 @@ async def create_task(
|
||||
auth: AuthContext = ADMIN_AUTH_DEP,
|
||||
) -> TaskRead:
|
||||
"""Create a task and initialize dependency rows."""
|
||||
data = payload.model_dump(exclude={"depends_on_task_ids"})
|
||||
data = payload.model_dump(exclude={"depends_on_task_ids", "tag_ids"})
|
||||
depends_on_task_ids = list(payload.depends_on_task_ids)
|
||||
tag_ids = list(payload.tag_ids)
|
||||
|
||||
task = Task.model_validate(data)
|
||||
task.board_id = board.id
|
||||
@@ -792,6 +816,11 @@ async def create_task(
|
||||
task_id=task.id,
|
||||
depends_on_task_ids=depends_on_task_ids,
|
||||
)
|
||||
normalized_tag_ids = await validate_task_tag_ids(
|
||||
session,
|
||||
organization_id=board.organization_id,
|
||||
tag_ids=tag_ids,
|
||||
)
|
||||
dep_status = await dependency_status_by_id(
|
||||
session,
|
||||
board_id=board.id,
|
||||
@@ -814,6 +843,11 @@ async def create_task(
|
||||
depends_on_task_id=dep_id,
|
||||
),
|
||||
)
|
||||
await replace_task_tags(
|
||||
session,
|
||||
task_id=task.id,
|
||||
tag_ids=normalized_tag_ids,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(task)
|
||||
|
||||
@@ -836,12 +870,10 @@ async def create_task(
|
||||
task=task,
|
||||
agent=assigned_agent,
|
||||
)
|
||||
return TaskRead.model_validate(task, from_attributes=True).model_copy(
|
||||
update={
|
||||
"depends_on_task_ids": normalized_deps,
|
||||
"blocked_by_task_ids": blocked_by,
|
||||
"is_blocked": bool(blocked_by),
|
||||
},
|
||||
return await _task_read_response(
|
||||
session,
|
||||
task=task,
|
||||
board_id=board.id,
|
||||
)
|
||||
|
||||
|
||||
@@ -876,8 +908,10 @@ async def update_task(
|
||||
depends_on_task_ids = (
|
||||
payload.depends_on_task_ids if "depends_on_task_ids" in payload.model_fields_set else None
|
||||
)
|
||||
tag_ids = payload.tag_ids if "tag_ids" in payload.model_fields_set else None
|
||||
updates.pop("comment", None)
|
||||
updates.pop("depends_on_task_ids", None)
|
||||
updates.pop("tag_ids", None)
|
||||
update = _TaskUpdateInput(
|
||||
task=task,
|
||||
actor=actor,
|
||||
@@ -887,6 +921,7 @@ async def update_task(
|
||||
updates=updates,
|
||||
comment=comment,
|
||||
depends_on_task_ids=depends_on_task_ids,
|
||||
tag_ids=tag_ids,
|
||||
)
|
||||
if actor.actor_type == "agent" and actor.agent and actor.agent.is_board_lead:
|
||||
return await _apply_lead_task_update(session, update=update)
|
||||
@@ -957,6 +992,12 @@ async def delete_task(
|
||||
),
|
||||
commit=False,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session,
|
||||
TaskTagAssignment,
|
||||
col(TaskTagAssignment.task_id) == task.id,
|
||||
commit=False,
|
||||
)
|
||||
await session.delete(task)
|
||||
await session.commit()
|
||||
return OkResponse()
|
||||
@@ -1119,6 +1160,8 @@ class _TaskUpdateInput:
|
||||
updates: dict[str, object]
|
||||
comment: str | None
|
||||
depends_on_task_ids: list[UUID] | None
|
||||
tag_ids: list[UUID] | None
|
||||
normalized_tag_ids: list[UUID] | None = None
|
||||
|
||||
|
||||
def _required_status_value(value: object) -> str:
|
||||
@@ -1133,6 +1176,21 @@ def _optional_assigned_agent_id(value: object) -> UUID | None:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
|
||||
|
||||
async def _board_organization_id(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
board_id: UUID,
|
||||
) -> UUID:
|
||||
organization_id = (
|
||||
await session.exec(
|
||||
select(Board.organization_id).where(col(Board.id) == board_id),
|
||||
)
|
||||
).first()
|
||||
if organization_id is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
return organization_id
|
||||
|
||||
|
||||
async def _task_dep_ids(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
@@ -1173,6 +1231,10 @@ async def _task_read_response(
|
||||
board_id: UUID,
|
||||
) -> TaskRead:
|
||||
dep_ids = await _task_dep_ids(session, board_id=board_id, task_id=task.id)
|
||||
tag_state = (await load_task_tag_state(session, task_ids=[task.id])).get(
|
||||
task.id,
|
||||
TaskTagState(),
|
||||
)
|
||||
blocked_ids = await _task_blocked_ids(
|
||||
session,
|
||||
board_id=board_id,
|
||||
@@ -1183,6 +1245,8 @@ async def _task_read_response(
|
||||
return TaskRead.model_validate(task, from_attributes=True).model_copy(
|
||||
update={
|
||||
"depends_on_task_ids": dep_ids,
|
||||
"tag_ids": tag_state.tag_ids,
|
||||
"tags": tag_state.tags,
|
||||
"blocked_by_task_ids": blocked_ids,
|
||||
"is_blocked": bool(blocked_ids),
|
||||
},
|
||||
@@ -1209,11 +1273,13 @@ def _lead_requested_fields(update: _TaskUpdateInput) -> set[str]:
|
||||
requested_fields.add("comment")
|
||||
if update.depends_on_task_ids is not None:
|
||||
requested_fields.add("depends_on_task_ids")
|
||||
if update.tag_ids is not None:
|
||||
requested_fields.add("tag_ids")
|
||||
return requested_fields
|
||||
|
||||
|
||||
def _validate_lead_update_request(update: _TaskUpdateInput) -> None:
|
||||
allowed_fields = {"assigned_agent_id", "status", "depends_on_task_ids"}
|
||||
allowed_fields = {"assigned_agent_id", "status", "depends_on_task_ids", "tag_ids"}
|
||||
requested_fields = _lead_requested_fields(update)
|
||||
if update.comment is not None or not requested_fields.issubset(allowed_fields):
|
||||
raise HTTPException(
|
||||
@@ -1260,6 +1326,24 @@ async def _lead_effective_dependencies(
|
||||
return effective_deps, blocked_by
|
||||
|
||||
|
||||
async def _normalized_update_tag_ids(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
update: _TaskUpdateInput,
|
||||
) -> list[UUID] | None:
|
||||
if update.tag_ids is None:
|
||||
return None
|
||||
organization_id = await _board_organization_id(
|
||||
session,
|
||||
board_id=update.board_id,
|
||||
)
|
||||
return await validate_task_tag_ids(
|
||||
session,
|
||||
organization_id=organization_id,
|
||||
tag_ids=update.tag_ids,
|
||||
)
|
||||
|
||||
|
||||
async def _lead_apply_assignment(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
@@ -1351,6 +1435,10 @@ async def _apply_lead_task_update(
|
||||
session,
|
||||
update=update,
|
||||
)
|
||||
normalized_tag_ids = await _normalized_update_tag_ids(
|
||||
session,
|
||||
update=update,
|
||||
)
|
||||
|
||||
if blocked_by and update.task.status != "done":
|
||||
update.task.status = "inbox"
|
||||
@@ -1360,6 +1448,13 @@ async def _apply_lead_task_update(
|
||||
await _lead_apply_assignment(session, update=update)
|
||||
_lead_apply_status(update)
|
||||
|
||||
if normalized_tag_ids is not None:
|
||||
await replace_task_tags(
|
||||
session,
|
||||
task_id=update.task.id,
|
||||
tag_ids=normalized_tag_ids,
|
||||
)
|
||||
|
||||
update.task.updated_at = utcnow()
|
||||
session.add(update.task)
|
||||
event_type, message = _task_event_details(update.task, update.previous_status)
|
||||
@@ -1402,8 +1497,12 @@ async def _apply_non_lead_agent_task_rules(
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
allowed_fields = {"status", "comment"}
|
||||
if update.depends_on_task_ids is not None or not set(update.updates).issubset(
|
||||
allowed_fields,
|
||||
if (
|
||||
update.depends_on_task_ids is not None
|
||||
or update.tag_ids is not None
|
||||
or not set(update.updates).issubset(
|
||||
allowed_fields,
|
||||
)
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
if "status" in update.updates:
|
||||
@@ -1436,6 +1535,10 @@ async def _apply_admin_task_rules(
|
||||
update: _TaskUpdateInput,
|
||||
) -> None:
|
||||
admin_normalized_deps: list[UUID] | None = None
|
||||
update.normalized_tag_ids = await _normalized_update_tag_ids(
|
||||
session,
|
||||
update=update,
|
||||
)
|
||||
if update.depends_on_task_ids is not None:
|
||||
if update.task.status == "done":
|
||||
raise HTTPException(
|
||||
@@ -1611,6 +1714,21 @@ async def _finalize_updated_task(
|
||||
):
|
||||
raise _comment_validation_error()
|
||||
|
||||
if update.tag_ids is not None:
|
||||
normalized = (
|
||||
update.normalized_tag_ids
|
||||
if update.normalized_tag_ids is not None
|
||||
else await _normalized_update_tag_ids(
|
||||
session,
|
||||
update=update,
|
||||
)
|
||||
)
|
||||
await replace_task_tags(
|
||||
session,
|
||||
task_id=update.task.id,
|
||||
tag_ids=normalized or [],
|
||||
)
|
||||
|
||||
session.add(update.task)
|
||||
await session.commit()
|
||||
await session.refresh(update.task)
|
||||
|
||||
Reference in New Issue
Block a user