feat(tags): add tag management interfaces and update related schemas
This commit is contained in:
@@ -20,8 +20,8 @@ from app.db.pagination import paginate
|
||||
from app.db.session import get_session
|
||||
from app.models.agents import Agent
|
||||
from app.models.boards import Board
|
||||
from app.models.tags import Tag
|
||||
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,
|
||||
@@ -44,18 +44,18 @@ from app.schemas.gateway_coordination import (
|
||||
GatewayMainAskUserResponse,
|
||||
)
|
||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
||||
from app.schemas.task_tags import TaskTagRef
|
||||
from app.schemas.tags import TagRef
|
||||
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
|
||||
from app.services.openclaw.policies import OpenClawAuthorizationPolicy
|
||||
from app.services.openclaw.provisioning_db import AgentLifecycleService
|
||||
from app.services.tags import replace_tags, validate_tag_ids
|
||||
from app.services.task_dependencies import (
|
||||
blocked_by_dependency_ids,
|
||||
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
|
||||
@@ -214,23 +214,23 @@ async def list_tasks(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/boards/{board_id}/tags", response_model=list[TaskTagRef])
|
||||
async def list_task_tags(
|
||||
@router.get("/boards/{board_id}/tags", response_model=list[TagRef])
|
||||
async def list_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."""
|
||||
) -> list[TagRef]:
|
||||
"""List 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()),
|
||||
select(Tag)
|
||||
.where(col(Tag.organization_id) == board.organization_id)
|
||||
.order_by(func.lower(col(Tag.name)).asc(), col(Tag.created_at).asc()),
|
||||
)
|
||||
).all()
|
||||
return [
|
||||
TaskTagRef(
|
||||
TagRef(
|
||||
id=tag.id,
|
||||
name=tag.name,
|
||||
slug=tag.slug,
|
||||
@@ -265,7 +265,7 @@ async def create_task(
|
||||
task_id=task.id,
|
||||
depends_on_task_ids=depends_on_task_ids,
|
||||
)
|
||||
normalized_tag_ids = await validate_task_tag_ids(
|
||||
normalized_tag_ids = await validate_tag_ids(
|
||||
session,
|
||||
organization_id=board.organization_id,
|
||||
tag_ids=tag_ids,
|
||||
@@ -310,7 +310,7 @@ async def create_task(
|
||||
depends_on_task_id=dep_id,
|
||||
),
|
||||
)
|
||||
await replace_task_tags(
|
||||
await replace_tags(
|
||||
session,
|
||||
task_id=task.id,
|
||||
tag_ids=normalized_tag_ids,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Task-tag CRUD endpoints for organization-scoped task categorization."""
|
||||
"""Tag CRUD endpoints for organization-scoped task categorization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -14,13 +14,13 @@ 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.models.tag_assignments import TagAssignment
|
||||
from app.models.tags import Tag
|
||||
from app.schemas.common import OkResponse
|
||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
||||
from app.schemas.task_tags import TaskTagCreate, TaskTagRead, TaskTagUpdate
|
||||
from app.schemas.tags import TagCreate, TagRead, TagUpdate
|
||||
from app.services.organizations import OrganizationContext
|
||||
from app.services.task_tags import slugify_task_tag, task_counts_for_tags
|
||||
from app.services.tags import slugify_tag, task_counts_for_tags
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
@@ -36,16 +36,16 @@ 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)
|
||||
return slugify_tag(source)
|
||||
|
||||
|
||||
async def _require_org_task_tag(
|
||||
async def _require_org_tag(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
tag_id: UUID,
|
||||
ctx: OrganizationContext,
|
||||
) -> TaskTag:
|
||||
tag = await TaskTag.objects.by_id(tag_id).first(session)
|
||||
) -> Tag:
|
||||
tag = await Tag.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:
|
||||
@@ -60,7 +60,7 @@ async def _ensure_slug_available(
|
||||
slug: str,
|
||||
exclude_tag_id: UUID | None = None,
|
||||
) -> None:
|
||||
existing = await TaskTag.objects.filter_by(organization_id=organization_id, slug=slug).first(
|
||||
existing = await Tag.objects.filter_by(organization_id=organization_id, slug=slug).first(
|
||||
session
|
||||
)
|
||||
if existing is None:
|
||||
@@ -69,15 +69,15 @@ async def _ensure_slug_available(
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Task tag slug already exists in this organization.",
|
||||
detail="Tag slug already exists in this organization.",
|
||||
)
|
||||
|
||||
|
||||
async def _tag_read_page(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
items: Sequence[TaskTag],
|
||||
) -> list[TaskTagRead]:
|
||||
items: Sequence[Tag],
|
||||
) -> list[TagRead]:
|
||||
if not items:
|
||||
return []
|
||||
counts = await task_counts_for_tags(
|
||||
@@ -85,30 +85,30 @@ async def _tag_read_page(
|
||||
tag_ids=[item.id for item in items],
|
||||
)
|
||||
return [
|
||||
TaskTagRead.model_validate(item, from_attributes=True).model_copy(
|
||||
TagRead.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(
|
||||
@router.get("", response_model=DefaultLimitOffsetPage[TagRead])
|
||||
async def list_tags(
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||
) -> LimitOffsetPage[TaskTagRead]:
|
||||
"""List task tags for the active organization."""
|
||||
) -> LimitOffsetPage[TagRead]:
|
||||
"""List 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())
|
||||
select(Tag)
|
||||
.where(col(Tag.organization_id) == ctx.organization.id)
|
||||
.order_by(func.lower(col(Tag.name)).asc(), col(Tag.created_at).asc())
|
||||
)
|
||||
|
||||
async def _transform(items: Sequence[object]) -> Sequence[object]:
|
||||
tags: list[TaskTag] = []
|
||||
tags: list[Tag] = []
|
||||
for item in items:
|
||||
if not isinstance(item, TaskTag):
|
||||
msg = "Expected TaskTag items from paginated query"
|
||||
if not isinstance(item, Tag):
|
||||
msg = "Expected Tag items from paginated query"
|
||||
raise TypeError(msg)
|
||||
tags.append(item)
|
||||
return await _tag_read_page(session=session, items=tags)
|
||||
@@ -116,13 +116,13 @@ async def list_task_tags(
|
||||
return await paginate(session, statement, transformer=_transform)
|
||||
|
||||
|
||||
@router.post("", response_model=TaskTagRead)
|
||||
async def create_task_tag(
|
||||
payload: TaskTagCreate,
|
||||
@router.post("", response_model=TagRead)
|
||||
async def create_tag(
|
||||
payload: TagCreate,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> TaskTagRead:
|
||||
"""Create a task tag within the active organization."""
|
||||
) -> TagRead:
|
||||
"""Create a tag within the active organization."""
|
||||
slug = _normalize_slug(payload.slug, fallback_name=payload.name)
|
||||
await _ensure_slug_available(
|
||||
session,
|
||||
@@ -131,49 +131,49 @@ async def create_task_tag(
|
||||
)
|
||||
tag = await crud.create(
|
||||
session,
|
||||
TaskTag,
|
||||
Tag,
|
||||
organization_id=ctx.organization.id,
|
||||
name=payload.name,
|
||||
slug=slug,
|
||||
color=payload.color,
|
||||
description=payload.description,
|
||||
)
|
||||
return TaskTagRead.model_validate(tag, from_attributes=True)
|
||||
return TagRead.model_validate(tag, from_attributes=True)
|
||||
|
||||
|
||||
@router.get("/{tag_id}", response_model=TaskTagRead)
|
||||
async def get_task_tag(
|
||||
@router.get("/{tag_id}", response_model=TagRead)
|
||||
async def get_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(
|
||||
) -> TagRead:
|
||||
"""Get a single tag in the active organization."""
|
||||
tag = await _require_org_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,
|
||||
select(func.count(col(TagAssignment.task_id))).where(
|
||||
col(TagAssignment.tag_id) == tag.id,
|
||||
),
|
||||
)
|
||||
).one()
|
||||
return TaskTagRead.model_validate(tag, from_attributes=True).model_copy(
|
||||
return TagRead.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(
|
||||
@router.patch("/{tag_id}", response_model=TagRead)
|
||||
async def update_tag(
|
||||
tag_id: UUID,
|
||||
payload: TaskTagUpdate,
|
||||
payload: TagUpdate,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> TaskTagRead:
|
||||
"""Update a task tag in the active organization."""
|
||||
tag = await _require_org_task_tag(
|
||||
) -> TagRead:
|
||||
"""Update a tag in the active organization."""
|
||||
tag = await _require_org_tag(
|
||||
session,
|
||||
tag_id=tag_id,
|
||||
ctx=ctx,
|
||||
@@ -194,25 +194,25 @@ async def update_task_tag(
|
||||
)
|
||||
updates["updated_at"] = utcnow()
|
||||
updated = await crud.patch(session, tag, updates)
|
||||
return TaskTagRead.model_validate(updated, from_attributes=True)
|
||||
return TagRead.model_validate(updated, from_attributes=True)
|
||||
|
||||
|
||||
@router.delete("/{tag_id}", response_model=OkResponse)
|
||||
async def delete_task_tag(
|
||||
async def delete_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(
|
||||
"""Delete a tag and remove all associated tag links."""
|
||||
tag = await _require_org_tag(
|
||||
session,
|
||||
tag_id=tag_id,
|
||||
ctx=ctx,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session,
|
||||
TaskTagAssignment,
|
||||
col(TaskTagAssignment.tag_id) == tag.id,
|
||||
TagAssignment,
|
||||
col(TagAssignment.tag_id) == tag.id,
|
||||
commit=False,
|
||||
)
|
||||
await session.delete(tag)
|
||||
@@ -32,9 +32,9 @@ from app.models.agents import Agent
|
||||
from app.models.approval_task_links import ApprovalTaskLink
|
||||
from app.models.approvals import Approval
|
||||
from app.models.boards import Board
|
||||
from app.models.tag_assignments import TagAssignment
|
||||
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
|
||||
@@ -48,6 +48,12 @@ from app.services.openclaw.gateway_dispatch import GatewayDispatchService
|
||||
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
|
||||
from app.services.openclaw.gateway_rpc import OpenClawGatewayError
|
||||
from app.services.organizations import require_board_access
|
||||
from app.services.tags import (
|
||||
TagState,
|
||||
load_tag_state,
|
||||
replace_tags,
|
||||
validate_tag_ids,
|
||||
)
|
||||
from app.services.task_dependencies import (
|
||||
blocked_by_dependency_ids,
|
||||
dependency_ids_by_task_id,
|
||||
@@ -56,12 +62,6 @@ 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
|
||||
@@ -583,7 +583,7 @@ async def _task_read_page(
|
||||
return []
|
||||
|
||||
task_ids = [task.id for task in tasks]
|
||||
tag_state_by_task_id = await load_task_tag_state(
|
||||
tag_state_by_task_id = await load_tag_state(
|
||||
session,
|
||||
task_ids=task_ids,
|
||||
)
|
||||
@@ -603,7 +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())
|
||||
tag_state = tag_state_by_task_id.get(task.id, TagState())
|
||||
dep_list = deps_map.get(task.id, [])
|
||||
blocked_by = blocked_by_dependency_ids(
|
||||
dependency_ids=dep_list,
|
||||
@@ -630,14 +630,14 @@ async def _stream_task_state(
|
||||
*,
|
||||
board_id: UUID,
|
||||
rows: list[tuple[ActivityEvent, Task | None]],
|
||||
) -> tuple[dict[UUID, list[UUID]], dict[UUID, str], dict[UUID, TaskTagState]]:
|
||||
) -> tuple[dict[UUID, list[UUID]], dict[UUID, str], dict[UUID, TagState]]:
|
||||
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 {}, {}, {}
|
||||
|
||||
tag_state_by_task_id = await load_task_tag_state(
|
||||
tag_state_by_task_id = await load_tag_state(
|
||||
session,
|
||||
task_ids=list({*task_ids}),
|
||||
)
|
||||
@@ -666,7 +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],
|
||||
tag_state_by_task_id: dict[UUID, TagState],
|
||||
) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"type": event.event_type,
|
||||
@@ -679,7 +679,7 @@ def _task_event_payload(
|
||||
payload["task"] = None
|
||||
return payload
|
||||
|
||||
tag_state = tag_state_by_task_id.get(task.id, TaskTagState())
|
||||
tag_state = tag_state_by_task_id.get(task.id, TagState())
|
||||
dep_list = deps_map.get(task.id, [])
|
||||
blocked_by = blocked_by_dependency_ids(
|
||||
dependency_ids=dep_list,
|
||||
@@ -816,7 +816,7 @@ async def create_task(
|
||||
task_id=task.id,
|
||||
depends_on_task_ids=depends_on_task_ids,
|
||||
)
|
||||
normalized_tag_ids = await validate_task_tag_ids(
|
||||
normalized_tag_ids = await validate_tag_ids(
|
||||
session,
|
||||
organization_id=board.organization_id,
|
||||
tag_ids=tag_ids,
|
||||
@@ -843,7 +843,7 @@ async def create_task(
|
||||
depends_on_task_id=dep_id,
|
||||
),
|
||||
)
|
||||
await replace_task_tags(
|
||||
await replace_tags(
|
||||
session,
|
||||
task_id=task.id,
|
||||
tag_ids=normalized_tag_ids,
|
||||
@@ -994,8 +994,8 @@ async def delete_task(
|
||||
)
|
||||
await crud.delete_where(
|
||||
session,
|
||||
TaskTagAssignment,
|
||||
col(TaskTagAssignment.task_id) == task.id,
|
||||
TagAssignment,
|
||||
col(TagAssignment.task_id) == task.id,
|
||||
commit=False,
|
||||
)
|
||||
await session.delete(task)
|
||||
@@ -1231,9 +1231,9 @@ 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(
|
||||
tag_state = (await load_tag_state(session, task_ids=[task.id])).get(
|
||||
task.id,
|
||||
TaskTagState(),
|
||||
TagState(),
|
||||
)
|
||||
blocked_ids = await _task_blocked_ids(
|
||||
session,
|
||||
@@ -1337,7 +1337,7 @@ async def _normalized_update_tag_ids(
|
||||
session,
|
||||
board_id=update.board_id,
|
||||
)
|
||||
return await validate_task_tag_ids(
|
||||
return await validate_tag_ids(
|
||||
session,
|
||||
organization_id=organization_id,
|
||||
tag_ids=update.tag_ids,
|
||||
@@ -1449,7 +1449,7 @@ async def _apply_lead_task_update(
|
||||
_lead_apply_status(update)
|
||||
|
||||
if normalized_tag_ids is not None:
|
||||
await replace_task_tags(
|
||||
await replace_tags(
|
||||
session,
|
||||
task_id=update.task.id,
|
||||
tag_ids=normalized_tag_ids,
|
||||
@@ -1723,7 +1723,7 @@ async def _finalize_updated_task(
|
||||
update=update,
|
||||
)
|
||||
)
|
||||
await replace_task_tags(
|
||||
await replace_tags(
|
||||
session,
|
||||
task_id=update.task.id,
|
||||
tag_ids=normalized or [],
|
||||
|
||||
@@ -24,7 +24,7 @@ from app.api.gateways import router as gateways_router
|
||||
from app.api.metrics import router as metrics_router
|
||||
from app.api.organizations import router as organizations_router
|
||||
from app.api.souls_directory import router as souls_directory_router
|
||||
from app.api.task_tags import router as task_tags_router
|
||||
from app.api.tags import router as tags_router
|
||||
from app.api.tasks import router as tasks_router
|
||||
from app.api.users import router as users_router
|
||||
from app.core.config import settings
|
||||
@@ -108,7 +108,7 @@ api_v1.include_router(board_memory_router)
|
||||
api_v1.include_router(board_onboarding_router)
|
||||
api_v1.include_router(approvals_router)
|
||||
api_v1.include_router(tasks_router)
|
||||
api_v1.include_router(task_tags_router)
|
||||
api_v1.include_router(tags_router)
|
||||
api_v1.include_router(users_router)
|
||||
app.include_router(api_v1)
|
||||
|
||||
|
||||
@@ -15,10 +15,10 @@ from app.models.organization_invite_board_access import OrganizationInviteBoardA
|
||||
from app.models.organization_invites import OrganizationInvite
|
||||
from app.models.organization_members import OrganizationMember
|
||||
from app.models.organizations import Organization
|
||||
from app.models.tag_assignments import TagAssignment
|
||||
from app.models.tags import Tag
|
||||
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.task_tags import TaskTag
|
||||
from app.models.tasks import Task
|
||||
from app.models.users import User
|
||||
|
||||
@@ -41,7 +41,7 @@ __all__ = [
|
||||
"TaskDependency",
|
||||
"Task",
|
||||
"TaskFingerprint",
|
||||
"TaskTag",
|
||||
"TaskTagAssignment",
|
||||
"Tag",
|
||||
"TagAssignment",
|
||||
"User",
|
||||
]
|
||||
|
||||
@@ -14,19 +14,19 @@ from app.models.base import QueryModel
|
||||
RUNTIME_ANNOTATION_TYPES = (datetime,)
|
||||
|
||||
|
||||
class TaskTagAssignment(QueryModel, table=True):
|
||||
class TagAssignment(QueryModel, table=True):
|
||||
"""Association row mapping one task to one tag."""
|
||||
|
||||
__tablename__ = "task_tag_assignments" # pyright: ignore[reportAssignmentType]
|
||||
__tablename__ = "tag_assignments" # pyright: ignore[reportAssignmentType]
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"task_id",
|
||||
"tag_id",
|
||||
name="uq_task_tag_assignments_task_id_tag_id",
|
||||
name="uq_tag_assignments_task_id_tag_id",
|
||||
),
|
||||
)
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
task_id: UUID = Field(foreign_key="tasks.id", index=True)
|
||||
tag_id: UUID = Field(foreign_key="task_tags.id", index=True)
|
||||
tag_id: UUID = Field(foreign_key="tags.id", index=True)
|
||||
created_at: datetime = Field(default_factory=utcnow)
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Task tag model for organization-scoped task categorization."""
|
||||
"""Tag model for organization-scoped task categorization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -14,15 +14,15 @@ from app.models.tenancy import TenantScoped
|
||||
RUNTIME_ANNOTATION_TYPES = (datetime,)
|
||||
|
||||
|
||||
class TaskTag(TenantScoped, table=True):
|
||||
"""Organization-scoped task tag used to classify and group tasks."""
|
||||
class Tag(TenantScoped, table=True):
|
||||
"""Organization-scoped tag used to classify and group tasks."""
|
||||
|
||||
__tablename__ = "task_tags" # pyright: ignore[reportAssignmentType]
|
||||
__tablename__ = "tags" # pyright: ignore[reportAssignmentType]
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"organization_id",
|
||||
"slug",
|
||||
name="uq_task_tags_organization_id_slug",
|
||||
name="uq_tags_organization_id_slug",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -31,7 +31,7 @@ from app.schemas.souls_directory import (
|
||||
SoulsDirectorySearchResponse,
|
||||
SoulsDirectorySoulRef,
|
||||
)
|
||||
from app.schemas.task_tags import TaskTagCreate, TaskTagRead, TaskTagRef, TaskTagUpdate
|
||||
from app.schemas.tags import TagCreate, TagRead, TagRef, TagUpdate
|
||||
from app.schemas.tasks import TaskCreate, TaskRead, TaskUpdate
|
||||
from app.schemas.users import UserCreate, UserRead, UserUpdate
|
||||
|
||||
@@ -71,10 +71,10 @@ __all__ = [
|
||||
"SoulsDirectoryMarkdownResponse",
|
||||
"SoulsDirectorySearchResponse",
|
||||
"SoulsDirectorySoulRef",
|
||||
"TaskTagCreate",
|
||||
"TaskTagRead",
|
||||
"TaskTagRef",
|
||||
"TaskTagUpdate",
|
||||
"TagCreate",
|
||||
"TagRead",
|
||||
"TagRef",
|
||||
"TagUpdate",
|
||||
"TaskCreate",
|
||||
"TaskRead",
|
||||
"TaskUpdate",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Schemas for task-tag CRUD payloads."""
|
||||
"""Schemas for tag CRUD payloads."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -27,8 +27,8 @@ def _normalize_color(value: str | None) -> str | None:
|
||||
return cleaned
|
||||
|
||||
|
||||
class TaskTagBase(SQLModel):
|
||||
"""Shared task-tag fields for create/read payloads."""
|
||||
class TagBase(SQLModel):
|
||||
"""Shared tag fields for create/read payloads."""
|
||||
|
||||
name: str
|
||||
slug: str
|
||||
@@ -36,8 +36,8 @@ class TaskTagBase(SQLModel):
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class TaskTagRef(SQLModel):
|
||||
"""Compact task-tag representation embedded in task payloads."""
|
||||
class TagRef(SQLModel):
|
||||
"""Compact tag representation embedded in task payloads."""
|
||||
|
||||
id: UUID
|
||||
name: str
|
||||
@@ -45,8 +45,8 @@ class TaskTagRef(SQLModel):
|
||||
color: str
|
||||
|
||||
|
||||
class TaskTagCreate(SQLModel):
|
||||
"""Payload for creating a task tag."""
|
||||
class TagCreate(SQLModel):
|
||||
"""Payload for creating a tag."""
|
||||
|
||||
name: NonEmptyStr
|
||||
slug: str | None = None
|
||||
@@ -76,8 +76,8 @@ class TaskTagCreate(SQLModel):
|
||||
return value
|
||||
|
||||
|
||||
class TaskTagUpdate(SQLModel):
|
||||
"""Payload for partial task-tag updates."""
|
||||
class TagUpdate(SQLModel):
|
||||
"""Payload for partial tag updates."""
|
||||
|
||||
name: NonEmptyStr | None = None
|
||||
slug: str | None = None
|
||||
@@ -116,8 +116,8 @@ class TaskTagUpdate(SQLModel):
|
||||
return self
|
||||
|
||||
|
||||
class TaskTagRead(TaskTagBase):
|
||||
"""Task-tag payload returned from API endpoints."""
|
||||
class TagRead(TagBase):
|
||||
"""Tag payload returned from API endpoints."""
|
||||
|
||||
id: UUID
|
||||
organization_id: UUID
|
||||
@@ -10,13 +10,13 @@ from pydantic import field_validator, model_validator
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from app.schemas.common import NonEmptyStr
|
||||
from app.schemas.task_tags import TaskTagRef
|
||||
from app.schemas.tags import TagRef
|
||||
|
||||
TaskStatus = Literal["inbox", "in_progress", "review", "done"]
|
||||
STATUS_REQUIRED_ERROR = "status is required"
|
||||
# Keep these symbols as runtime globals so Pydantic can resolve
|
||||
# deferred annotations reliably.
|
||||
RUNTIME_ANNOTATION_TYPES = (datetime, UUID, NonEmptyStr, TaskTagRef)
|
||||
RUNTIME_ANNOTATION_TYPES = (datetime, UUID, NonEmptyStr, TagRef)
|
||||
|
||||
|
||||
class TaskBase(SQLModel):
|
||||
@@ -80,7 +80,7 @@ class TaskRead(TaskBase):
|
||||
updated_at: datetime
|
||||
blocked_by_task_ids: list[UUID] = Field(default_factory=list)
|
||||
is_blocked: bool = False
|
||||
tags: list[TaskTagRef] = Field(default_factory=list)
|
||||
tags: list[TagRef] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TaskCommentCreate(SQLModel):
|
||||
|
||||
@@ -12,7 +12,7 @@ from app.schemas.approvals import ApprovalRead
|
||||
from app.schemas.board_groups import BoardGroupRead
|
||||
from app.schemas.board_memory import BoardMemoryRead
|
||||
from app.schemas.boards import BoardRead
|
||||
from app.schemas.task_tags import TaskTagRef
|
||||
from app.schemas.tags import TagRef
|
||||
from app.schemas.tasks import TaskRead
|
||||
|
||||
RUNTIME_ANNOTATION_TYPES = (
|
||||
@@ -23,7 +23,7 @@ RUNTIME_ANNOTATION_TYPES = (
|
||||
BoardGroupRead,
|
||||
BoardMemoryRead,
|
||||
BoardRead,
|
||||
TaskTagRef,
|
||||
TagRef,
|
||||
)
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ class BoardGroupTaskSummary(SQLModel):
|
||||
assignee: str | None = None
|
||||
due_at: datetime | None = None
|
||||
in_progress_at: datetime | None = None
|
||||
tags: list[TaskTagRef] = Field(default_factory=list)
|
||||
tags: list[TagRef] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ from app.schemas.view_models import (
|
||||
BoardGroupSnapshot,
|
||||
BoardGroupTaskSummary,
|
||||
)
|
||||
from app.services.task_tags import TaskTagState, load_task_tag_state
|
||||
from app.services.tags import TagState, load_tag_state
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
@@ -123,7 +123,7 @@ def _task_summaries_by_board(
|
||||
boards_by_id: dict[UUID, Board],
|
||||
tasks: list[Task],
|
||||
agent_name_by_id: dict[UUID, str],
|
||||
tag_state_by_task_id: dict[UUID, TaskTagState],
|
||||
tag_state_by_task_id: dict[UUID, TagState],
|
||||
per_board_task_limit: int,
|
||||
) -> dict[UUID, list[BoardGroupTaskSummary]]:
|
||||
"""Build limited per-board task summary lists."""
|
||||
@@ -156,7 +156,7 @@ def _task_summaries_by_board(
|
||||
),
|
||||
due_at=task.due_at,
|
||||
in_progress_at=task.in_progress_at,
|
||||
tags=tag_state_by_task_id.get(task.id, TaskTagState()).tags,
|
||||
tags=tag_state_by_task_id.get(task.id, TagState()).tags,
|
||||
created_at=task.created_at,
|
||||
updated_at=task.updated_at,
|
||||
),
|
||||
@@ -191,7 +191,7 @@ async def build_group_snapshot(
|
||||
include_done=include_done,
|
||||
)
|
||||
agent_name_by_id = await _agent_names(session, tasks)
|
||||
tag_state_by_task_id = await load_task_tag_state(
|
||||
tag_state_by_task_id = await load_tag_state(
|
||||
session,
|
||||
task_ids=[task.id for task in tasks],
|
||||
)
|
||||
|
||||
@@ -17,12 +17,12 @@ from app.schemas.boards import BoardRead
|
||||
from app.schemas.view_models import BoardSnapshot, TaskCardRead
|
||||
from app.services.approval_task_links import load_task_ids_by_approval, task_counts_for_board
|
||||
from app.services.openclaw.provisioning_db import AgentLifecycleService
|
||||
from app.services.tags import TagState, load_tag_state
|
||||
from app.services.task_dependencies import (
|
||||
blocked_by_dependency_ids,
|
||||
dependency_ids_by_task_id,
|
||||
dependency_status_by_id,
|
||||
)
|
||||
from app.services.task_tags import TaskTagState, load_task_tag_state
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from uuid import UUID
|
||||
@@ -49,13 +49,13 @@ def _task_to_card(
|
||||
counts_by_task_id: dict[UUID, tuple[int, int]],
|
||||
deps_by_task_id: dict[UUID, list[UUID]],
|
||||
dependency_status_by_id_map: dict[UUID, str],
|
||||
tag_state_by_task_id: dict[UUID, TaskTagState],
|
||||
tag_state_by_task_id: dict[UUID, TagState],
|
||||
) -> TaskCardRead:
|
||||
card = TaskCardRead.model_validate(task, from_attributes=True)
|
||||
approvals_count, approvals_pending_count = counts_by_task_id.get(task.id, (0, 0))
|
||||
assignee = agent_name_by_id.get(task.assigned_agent_id) if task.assigned_agent_id else None
|
||||
depends_on_task_ids = deps_by_task_id.get(task.id, [])
|
||||
tag_state = tag_state_by_task_id.get(task.id, TaskTagState())
|
||||
tag_state = tag_state_by_task_id.get(task.id, TagState())
|
||||
blocked_by_task_ids = blocked_by_dependency_ids(
|
||||
dependency_ids=depends_on_task_ids,
|
||||
status_by_id=dependency_status_by_id_map,
|
||||
@@ -86,7 +86,7 @@ async def build_board_snapshot(session: AsyncSession, board: Board) -> BoardSnap
|
||||
.all(session),
|
||||
)
|
||||
task_ids = [task.id for task in tasks]
|
||||
tag_state_by_task_id = await load_task_tag_state(
|
||||
tag_state_by_task_id = await load_tag_state(
|
||||
session,
|
||||
task_ids=task_ids,
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Helpers for validating and loading task tags and task-tag mappings."""
|
||||
"""Helpers for validating and loading tags and tag mappings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,9 +13,9 @@ from fastapi import HTTPException, status
|
||||
from sqlalchemy import delete, func
|
||||
from sqlmodel import col, select
|
||||
|
||||
from app.models.task_tag_assignments import TaskTagAssignment
|
||||
from app.models.task_tags import TaskTag
|
||||
from app.schemas.task_tags import TaskTagRef
|
||||
from app.models.tag_assignments import TagAssignment
|
||||
from app.models.tags import Tag
|
||||
from app.schemas.tags import TagRef
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
@@ -23,7 +23,7 @@ if TYPE_CHECKING:
|
||||
SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
def slugify_task_tag(value: str) -> str:
|
||||
def slugify_tag(value: str) -> str:
|
||||
"""Build a slug from arbitrary text using lowercase alphanumeric groups."""
|
||||
slug = SLUG_RE.sub("-", value.lower()).strip("-")
|
||||
return slug or "tag"
|
||||
@@ -40,22 +40,22 @@ def _dedupe_uuid_list(values: Sequence[UUID]) -> list[UUID]:
|
||||
return deduped
|
||||
|
||||
|
||||
async def validate_task_tag_ids(
|
||||
async def validate_tag_ids(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
organization_id: UUID,
|
||||
tag_ids: Sequence[UUID],
|
||||
) -> list[UUID]:
|
||||
"""Validate task-tag IDs within an organization and return deduped IDs."""
|
||||
"""Validate tag IDs within an organization and return deduped IDs."""
|
||||
normalized = _dedupe_uuid_list(tag_ids)
|
||||
if not normalized:
|
||||
return []
|
||||
|
||||
existing_ids = set(
|
||||
await session.exec(
|
||||
select(TaskTag.id)
|
||||
.where(col(TaskTag.organization_id) == organization_id)
|
||||
.where(col(TaskTag.id).in_(normalized)),
|
||||
select(Tag.id)
|
||||
.where(col(Tag.organization_id) == organization_id)
|
||||
.where(col(Tag.id).in_(normalized)),
|
||||
),
|
||||
)
|
||||
missing = [tag_id for tag_id in normalized if tag_id not in existing_ids]
|
||||
@@ -63,7 +63,7 @@ async def validate_task_tag_ids(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={
|
||||
"message": "One or more task tags do not exist in this organization.",
|
||||
"message": "One or more tags do not exist in this organization.",
|
||||
"missing_tag_ids": [str(tag_id) for tag_id in missing],
|
||||
},
|
||||
)
|
||||
@@ -71,18 +71,18 @@ async def validate_task_tag_ids(
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TaskTagState:
|
||||
"""Ordered task-tag state for a task payload."""
|
||||
class TagState:
|
||||
"""Ordered tag state for a task payload."""
|
||||
|
||||
tag_ids: list[UUID] = field(default_factory=list)
|
||||
tags: list[TaskTagRef] = field(default_factory=list)
|
||||
tags: list[TagRef] = field(default_factory=list)
|
||||
|
||||
|
||||
async def load_task_tag_state(
|
||||
async def load_tag_state(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
task_ids: Sequence[UUID],
|
||||
) -> dict[UUID, TaskTagState]:
|
||||
) -> dict[UUID, TagState]:
|
||||
"""Return ordered tag IDs and refs for each task id."""
|
||||
normalized_task_ids = _dedupe_uuid_list(task_ids)
|
||||
if not normalized_task_ids:
|
||||
@@ -91,25 +91,25 @@ async def load_task_tag_state(
|
||||
rows = list(
|
||||
await session.exec(
|
||||
select(
|
||||
col(TaskTagAssignment.task_id),
|
||||
TaskTag,
|
||||
col(TagAssignment.task_id),
|
||||
Tag,
|
||||
)
|
||||
.join(TaskTag, col(TaskTag.id) == col(TaskTagAssignment.tag_id))
|
||||
.where(col(TaskTagAssignment.task_id).in_(normalized_task_ids))
|
||||
.join(Tag, col(Tag.id) == col(TagAssignment.tag_id))
|
||||
.where(col(TagAssignment.task_id).in_(normalized_task_ids))
|
||||
.order_by(
|
||||
col(TaskTagAssignment.task_id).asc(),
|
||||
col(TaskTagAssignment.created_at).asc(),
|
||||
col(TagAssignment.task_id).asc(),
|
||||
col(TagAssignment.created_at).asc(),
|
||||
),
|
||||
),
|
||||
)
|
||||
state_by_task_id: dict[UUID, TaskTagState] = defaultdict(TaskTagState)
|
||||
state_by_task_id: dict[UUID, TagState] = defaultdict(TagState)
|
||||
for task_id, tag in rows:
|
||||
if task_id is None:
|
||||
continue
|
||||
state = state_by_task_id[task_id]
|
||||
state.tag_ids.append(tag.id)
|
||||
state.tags.append(
|
||||
TaskTagRef(
|
||||
TagRef(
|
||||
id=tag.id,
|
||||
name=tag.name,
|
||||
slug=tag.slug,
|
||||
@@ -119,7 +119,7 @@ async def load_task_tag_state(
|
||||
return dict(state_by_task_id)
|
||||
|
||||
|
||||
async def replace_task_tags(
|
||||
async def replace_tags(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
task_id: UUID,
|
||||
@@ -128,12 +128,12 @@ async def replace_task_tags(
|
||||
"""Replace all tag-assignment rows for a task."""
|
||||
normalized = _dedupe_uuid_list(tag_ids)
|
||||
await session.exec(
|
||||
delete(TaskTagAssignment).where(
|
||||
col(TaskTagAssignment.task_id) == task_id,
|
||||
delete(TagAssignment).where(
|
||||
col(TagAssignment.task_id) == task_id,
|
||||
),
|
||||
)
|
||||
for tag_id in normalized:
|
||||
session.add(TaskTagAssignment(task_id=task_id, tag_id=tag_id))
|
||||
session.add(TagAssignment(task_id=task_id, tag_id=tag_id))
|
||||
|
||||
|
||||
async def task_counts_for_tags(
|
||||
@@ -148,11 +148,11 @@ async def task_counts_for_tags(
|
||||
rows = list(
|
||||
await session.exec(
|
||||
select(
|
||||
col(TaskTagAssignment.tag_id),
|
||||
func.count(col(TaskTagAssignment.task_id)),
|
||||
col(TagAssignment.tag_id),
|
||||
func.count(col(TagAssignment.task_id)),
|
||||
)
|
||||
.where(col(TaskTagAssignment.tag_id).in_(normalized))
|
||||
.group_by(col(TaskTagAssignment.tag_id)),
|
||||
.where(col(TagAssignment.tag_id).in_(normalized))
|
||||
.group_by(col(TagAssignment.tag_id)),
|
||||
),
|
||||
)
|
||||
return {tag_id: int(count or 0) for tag_id, count in rows}
|
||||
Reference in New Issue
Block a user