feat: add custom-fields

This commit is contained in:
Abhimanyu Saharan
2026-02-13 21:24:36 +05:30
parent b032e94ca1
commit 277bfcb33a
127 changed files with 11305 additions and 6643 deletions

View File

@@ -287,13 +287,16 @@ async def create_task(
"""Create a task as the board lead.
Lead-only endpoint. Supports dependency-aware creation via
`depends_on_task_ids` and optional `tag_ids`.
`depends_on_task_ids`, optional `tag_ids`, and `custom_field_values`.
"""
_guard_board_access(agent_ctx, board)
_require_board_lead(agent_ctx)
data = payload.model_dump(exclude={"depends_on_task_ids", "tag_ids"})
data = payload.model_dump(
exclude={"depends_on_task_ids", "tag_ids", "custom_field_values"},
)
depends_on_task_ids = list(payload.depends_on_task_ids)
tag_ids = list(payload.tag_ids)
custom_field_values = dict(payload.custom_field_values)
task = Task.model_validate(data)
task.board_id = board.id
@@ -343,6 +346,12 @@ async def create_task(
session.add(task)
# Ensure the task exists in the DB before inserting dependency rows.
await session.flush()
await tasks_api._set_task_custom_field_values_for_create(
session,
board_id=board.id,
task_id=task.id,
custom_field_values=custom_field_values,
)
for dep_id in normalized_deps:
session.add(
TaskDependency(

View File

@@ -0,0 +1,343 @@
"""Organization-level task custom field definition management."""
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 sqlalchemy.exc import IntegrityError
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.session import get_session
from app.models.boards import Board
from app.models.task_custom_fields import (
BoardTaskCustomField,
TaskCustomFieldDefinition,
TaskCustomFieldValue,
)
from app.schemas.common import OkResponse
from app.schemas.task_custom_fields import (
TaskCustomFieldDefinitionCreate,
TaskCustomFieldDefinitionRead,
TaskCustomFieldDefinitionUpdate,
validate_custom_field_definition,
)
from app.services.organizations import OrganizationContext
if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession
router = APIRouter(prefix="/organizations/me/custom-fields", tags=["org-custom-fields"])
SESSION_DEP = Depends(get_session)
ORG_MEMBER_DEP = Depends(require_org_member)
ORG_ADMIN_DEP = Depends(require_org_admin)
def _to_definition_read_payload(
*,
definition: TaskCustomFieldDefinition,
board_ids: list[UUID],
) -> TaskCustomFieldDefinitionRead:
payload = TaskCustomFieldDefinitionRead.model_validate(definition, from_attributes=True)
payload.board_ids = board_ids
return payload
async def _board_ids_by_definition_id(
*,
session: AsyncSession,
definition_ids: list[UUID],
) -> dict[UUID, list[UUID]]:
if not definition_ids:
return {}
rows = (
await session.exec(
select(
col(BoardTaskCustomField.task_custom_field_definition_id),
col(BoardTaskCustomField.board_id),
).where(
col(BoardTaskCustomField.task_custom_field_definition_id).in_(definition_ids),
),
)
).all()
board_ids_by_definition_id: dict[UUID, list[UUID]] = {
definition_id: [] for definition_id in definition_ids
}
for definition_id, board_id in rows:
board_ids_by_definition_id.setdefault(definition_id, []).append(board_id)
for definition_id in board_ids_by_definition_id:
board_ids_by_definition_id[definition_id].sort(key=str)
return board_ids_by_definition_id
async def _validated_board_ids_for_org(
*,
session: AsyncSession,
ctx: OrganizationContext,
board_ids: list[UUID],
) -> list[UUID]:
normalized_board_ids = list(dict.fromkeys(board_ids))
if not normalized_board_ids:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="At least one board must be selected.",
)
valid_board_ids = set(
(
await session.exec(
select(col(Board.id)).where(
col(Board.organization_id) == ctx.organization.id,
col(Board.id).in_(normalized_board_ids),
),
)
).all(),
)
missing_board_ids = sorted(
{board_id for board_id in normalized_board_ids if board_id not in valid_board_ids},
key=str,
)
if missing_board_ids:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"message": "Some selected boards are invalid for this organization.",
"invalid_board_ids": [str(value) for value in missing_board_ids],
},
)
return normalized_board_ids
async def _get_org_definition(
*,
session: AsyncSession,
ctx: OrganizationContext,
definition_id: UUID,
) -> TaskCustomFieldDefinition:
definition = (
await session.exec(
select(TaskCustomFieldDefinition).where(
col(TaskCustomFieldDefinition.id) == definition_id,
col(TaskCustomFieldDefinition.organization_id) == ctx.organization.id,
),
)
).first()
if definition is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return definition
@router.get("", response_model=list[TaskCustomFieldDefinitionRead])
async def list_org_custom_fields(
ctx: OrganizationContext = ORG_MEMBER_DEP,
session: AsyncSession = SESSION_DEP,
) -> list[TaskCustomFieldDefinitionRead]:
"""List task custom field definitions for the authenticated organization."""
definitions = list(
await session.exec(
select(TaskCustomFieldDefinition)
.where(col(TaskCustomFieldDefinition.organization_id) == ctx.organization.id)
.order_by(func.lower(col(TaskCustomFieldDefinition.label)).asc()),
),
)
board_ids_by_definition_id = await _board_ids_by_definition_id(
session=session,
definition_ids=[definition.id for definition in definitions],
)
return [
_to_definition_read_payload(
definition=definition,
board_ids=board_ids_by_definition_id.get(definition.id, []),
)
for definition in definitions
]
@router.post("", response_model=TaskCustomFieldDefinitionRead)
async def create_org_custom_field(
payload: TaskCustomFieldDefinitionCreate,
ctx: OrganizationContext = ORG_ADMIN_DEP,
session: AsyncSession = SESSION_DEP,
) -> TaskCustomFieldDefinitionRead:
"""Create an organization-level task custom field definition."""
board_ids = await _validated_board_ids_for_org(
session=session,
ctx=ctx,
board_ids=payload.board_ids,
)
try:
validate_custom_field_definition(
field_type=payload.field_type,
validation_regex=payload.validation_regex,
default_value=payload.default_value,
)
except ValueError as err:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(err),
) from err
definition = TaskCustomFieldDefinition(
organization_id=ctx.organization.id,
field_key=payload.field_key,
label=payload.label or payload.field_key,
field_type=payload.field_type,
ui_visibility=payload.ui_visibility,
validation_regex=payload.validation_regex,
description=payload.description,
required=payload.required,
default_value=payload.default_value,
)
session.add(definition)
await session.flush()
for board_id in board_ids:
session.add(
BoardTaskCustomField(
board_id=board_id,
task_custom_field_definition_id=definition.id,
),
)
try:
await session.commit()
except IntegrityError as err:
await session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Field key already exists in this organization.",
) from err
await session.refresh(definition)
return _to_definition_read_payload(definition=definition, board_ids=board_ids)
@router.patch("/{task_custom_field_definition_id}", response_model=TaskCustomFieldDefinitionRead)
async def update_org_custom_field(
task_custom_field_definition_id: UUID,
payload: TaskCustomFieldDefinitionUpdate,
ctx: OrganizationContext = ORG_ADMIN_DEP,
session: AsyncSession = SESSION_DEP,
) -> TaskCustomFieldDefinitionRead:
"""Update an organization-level task custom field definition."""
definition = await _get_org_definition(
session=session,
ctx=ctx,
definition_id=task_custom_field_definition_id,
)
updates = payload.model_dump(exclude_unset=True)
board_ids = updates.pop("board_ids", None)
validated_board_ids: list[UUID] | None = None
if board_ids is not None:
validated_board_ids = await _validated_board_ids_for_org(
session=session,
ctx=ctx,
board_ids=board_ids,
)
next_field_type = updates.get("field_type", definition.field_type)
next_validation_regex = (
updates["validation_regex"]
if "validation_regex" in updates
else definition.validation_regex
)
next_default_value = (
updates["default_value"] if "default_value" in updates else definition.default_value
)
try:
validate_custom_field_definition(
field_type=next_field_type,
validation_regex=next_validation_regex,
default_value=next_default_value,
)
except ValueError as err:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(err),
) from err
for key, value in updates.items():
setattr(definition, key, value)
if validated_board_ids is not None:
bindings = list(
await session.exec(
select(BoardTaskCustomField).where(
col(BoardTaskCustomField.task_custom_field_definition_id) == definition.id,
),
),
)
current_board_ids = {binding.board_id for binding in bindings}
target_board_ids = set(validated_board_ids)
for binding in bindings:
if binding.board_id not in target_board_ids:
await session.delete(binding)
for board_id in validated_board_ids:
if board_id in current_board_ids:
continue
session.add(
BoardTaskCustomField(
board_id=board_id,
task_custom_field_definition_id=definition.id,
),
)
definition.updated_at = utcnow()
session.add(definition)
try:
await session.commit()
except IntegrityError as err:
await session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Field key already exists in this organization.",
) from err
await session.refresh(definition)
if validated_board_ids is None:
board_ids = (
await _board_ids_by_definition_id(
session=session,
definition_ids=[definition.id],
)
).get(definition.id, [])
else:
board_ids = validated_board_ids
return _to_definition_read_payload(definition=definition, board_ids=board_ids)
@router.delete("/{task_custom_field_definition_id}", response_model=OkResponse)
async def delete_org_custom_field(
task_custom_field_definition_id: UUID,
ctx: OrganizationContext = ORG_ADMIN_DEP,
session: AsyncSession = SESSION_DEP,
) -> OkResponse:
"""Delete an org-level definition when it has no persisted task values."""
definition = await _get_org_definition(
session=session,
ctx=ctx,
definition_id=task_custom_field_definition_id,
)
value_ids = (
await session.exec(
select(col(TaskCustomFieldValue.id)).where(
col(TaskCustomFieldValue.task_custom_field_definition_id) == definition.id,
),
)
).all()
if value_ids:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Cannot delete a custom field definition while task values exist.",
)
bindings = list(
await session.exec(
select(BoardTaskCustomField).where(
col(BoardTaskCustomField.task_custom_field_definition_id) == definition.id,
),
),
)
for binding in bindings:
await session.delete(binding)
await session.delete(definition)
await session.commit()
return OkResponse()

View File

@@ -7,7 +7,7 @@ import json
from collections import deque
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, cast
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
@@ -33,6 +33,11 @@ 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_custom_fields import (
BoardTaskCustomField,
TaskCustomFieldDefinition,
TaskCustomFieldValue,
)
from app.models.task_dependencies import TaskDependency
from app.models.task_fingerprints import TaskFingerprint
from app.models.tasks import Task
@@ -40,6 +45,11 @@ from app.schemas.activity_events import ActivityEventRead
from app.schemas.common import OkResponse
from app.schemas.errors import BlockedTaskError
from app.schemas.pagination import DefaultLimitOffsetPage
from app.schemas.task_custom_fields import (
TaskCustomFieldType,
TaskCustomFieldValues,
validate_custom_field_value,
)
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
from app.services.activity_log import record_activity
from app.services.approval_task_links import (
@@ -99,6 +109,16 @@ ADMIN_AUTH_DEP = Depends(require_admin_auth)
TASK_DEP = Depends(get_task_or_404)
@dataclass(frozen=True, slots=True)
class _BoardCustomFieldDefinition:
id: UUID
field_key: str
field_type: TaskCustomFieldType
validation_regex: str | None
required: bool
default_value: object | None
def _comment_validation_error() -> HTTPException:
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -697,6 +717,281 @@ def _status_values(status_filter: str | None) -> list[str]:
return values
async def _organization_custom_field_definitions_for_board(
session: AsyncSession,
*,
board_id: UUID,
) -> dict[str, _BoardCustomFieldDefinition]:
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)
definitions = list(
await session.exec(
select(TaskCustomFieldDefinition)
.join(
BoardTaskCustomField,
col(BoardTaskCustomField.task_custom_field_definition_id)
== col(TaskCustomFieldDefinition.id),
)
.where(
col(BoardTaskCustomField.board_id) == board_id,
col(TaskCustomFieldDefinition.organization_id) == organization_id,
),
),
)
return {
definition.field_key: _BoardCustomFieldDefinition(
id=definition.id,
field_key=definition.field_key,
field_type=cast(TaskCustomFieldType, definition.field_type),
validation_regex=definition.validation_regex,
required=definition.required,
default_value=definition.default_value,
)
for definition in definitions
}
def _reject_unknown_custom_field_keys(
*,
custom_field_values: TaskCustomFieldValues,
definitions_by_key: dict[str, _BoardCustomFieldDefinition],
) -> None:
unknown_field_keys = sorted(set(custom_field_values) - set(definitions_by_key))
if not unknown_field_keys:
return
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"message": "Unknown custom field keys for this board.",
"unknown_field_keys": unknown_field_keys,
},
)
def _reject_missing_required_custom_field_keys(
*,
effective_values: TaskCustomFieldValues,
definitions_by_key: dict[str, _BoardCustomFieldDefinition],
) -> None:
missing_field_keys = [
definition.field_key
for definition in definitions_by_key.values()
if definition.required and effective_values.get(definition.field_key) is None
]
if not missing_field_keys:
return
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"message": "Required custom fields must have values.",
"missing_field_keys": sorted(missing_field_keys),
},
)
def _reject_invalid_custom_field_values(
*,
custom_field_values: TaskCustomFieldValues,
definitions_by_key: dict[str, _BoardCustomFieldDefinition],
) -> None:
for field_key, value in custom_field_values.items():
definition = definitions_by_key[field_key]
try:
validate_custom_field_value(
field_type=definition.field_type,
value=value,
validation_regex=definition.validation_regex,
)
except ValueError as err:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"message": "Invalid custom field value.",
"field_key": field_key,
"field_type": definition.field_type,
"reason": str(err),
},
) from err
async def _task_custom_field_rows_by_definition_id(
session: AsyncSession,
*,
task_id: UUID,
definition_ids: list[UUID],
) -> dict[UUID, TaskCustomFieldValue]:
if not definition_ids:
return {}
rows = list(
await session.exec(
select(TaskCustomFieldValue).where(
col(TaskCustomFieldValue.task_id) == task_id,
col(TaskCustomFieldValue.task_custom_field_definition_id).in_(definition_ids),
),
),
)
return {row.task_custom_field_definition_id: row for row in rows}
async def _set_task_custom_field_values_for_create(
session: AsyncSession,
*,
board_id: UUID,
task_id: UUID,
custom_field_values: TaskCustomFieldValues,
) -> None:
definitions_by_key = await _organization_custom_field_definitions_for_board(
session,
board_id=board_id,
)
_reject_unknown_custom_field_keys(
custom_field_values=custom_field_values,
definitions_by_key=definitions_by_key,
)
_reject_invalid_custom_field_values(
custom_field_values=custom_field_values,
definitions_by_key=definitions_by_key,
)
effective_values: TaskCustomFieldValues = {}
for field_key, definition in definitions_by_key.items():
if field_key in custom_field_values:
effective_values[field_key] = custom_field_values[field_key]
else:
effective_values[field_key] = definition.default_value
_reject_missing_required_custom_field_keys(
effective_values=effective_values,
definitions_by_key=definitions_by_key,
)
for field_key, definition in definitions_by_key.items():
value = effective_values.get(field_key)
if value is None:
continue
session.add(
TaskCustomFieldValue(
task_id=task_id,
task_custom_field_definition_id=definition.id,
value=value,
),
)
async def _set_task_custom_field_values_for_update(
session: AsyncSession,
*,
board_id: UUID,
task_id: UUID,
custom_field_values: TaskCustomFieldValues,
) -> None:
definitions_by_key = await _organization_custom_field_definitions_for_board(
session,
board_id=board_id,
)
_reject_unknown_custom_field_keys(
custom_field_values=custom_field_values,
definitions_by_key=definitions_by_key,
)
_reject_invalid_custom_field_values(
custom_field_values=custom_field_values,
definitions_by_key=definitions_by_key,
)
definitions_by_id = {definition.id: definition for definition in definitions_by_key.values()}
rows_by_definition_id = await _task_custom_field_rows_by_definition_id(
session,
task_id=task_id,
definition_ids=list(definitions_by_id),
)
effective_values: TaskCustomFieldValues = {}
for field_key, definition in definitions_by_key.items():
current_row = rows_by_definition_id.get(definition.id)
if field_key in custom_field_values:
effective_values[field_key] = custom_field_values[field_key]
elif current_row is not None:
effective_values[field_key] = current_row.value
else:
effective_values[field_key] = definition.default_value
_reject_missing_required_custom_field_keys(
effective_values=effective_values,
definitions_by_key=definitions_by_key,
)
for field_key, value in custom_field_values.items():
definition = definitions_by_key[field_key]
row = rows_by_definition_id.get(definition.id)
if value is None:
if row is not None:
await session.delete(row)
continue
if row is None:
session.add(
TaskCustomFieldValue(
task_id=task_id,
task_custom_field_definition_id=definition.id,
value=value,
),
)
continue
row.value = value
row.updated_at = utcnow()
session.add(row)
async def _task_custom_field_values_by_task_id(
session: AsyncSession,
*,
board_id: UUID,
task_ids: Sequence[UUID],
) -> dict[UUID, TaskCustomFieldValues]:
unique_task_ids = list({*task_ids})
if not unique_task_ids:
return {}
definitions_by_key = await _organization_custom_field_definitions_for_board(
session,
board_id=board_id,
)
if not definitions_by_key:
return {task_id: {} for task_id in unique_task_ids}
definitions_by_id = {definition.id: definition for definition in definitions_by_key.values()}
default_values = {
field_key: definition.default_value for field_key, definition in definitions_by_key.items()
}
values_by_task_id: dict[UUID, TaskCustomFieldValues] = {
task_id: dict(default_values) for task_id in unique_task_ids
}
rows = (
await session.exec(
select(
col(TaskCustomFieldValue.task_id),
col(TaskCustomFieldValue.task_custom_field_definition_id),
col(TaskCustomFieldValue.value),
).where(
col(TaskCustomFieldValue.task_id).in_(unique_task_ids),
col(TaskCustomFieldValue.task_custom_field_definition_id).in_(
list(definitions_by_id),
),
),
)
).all()
for task_id, definition_id, value in rows:
definition = definitions_by_id.get(definition_id)
if definition is None:
continue
values_by_task_id[task_id][definition.field_key] = value
return values_by_task_id
def _task_list_statement(
*,
board_id: UUID,
@@ -742,6 +1037,11 @@ async def _task_read_page(
board_id=board_id,
dependency_ids=list({*dep_ids}),
)
custom_field_values_by_task_id = await _task_custom_field_values_by_task_id(
session,
board_id=board_id,
task_ids=task_ids,
)
output: list[TaskRead] = []
for task in tasks:
@@ -761,6 +1061,7 @@ async def _task_read_page(
"tags": tag_state.tags,
"blocked_by_task_ids": blocked_by,
"is_blocked": bool(blocked_by),
"custom_field_values": custom_field_values_by_task_id.get(task.id, {}),
},
),
)
@@ -772,12 +1073,17 @@ async def _stream_task_state(
*,
board_id: UUID,
rows: list[tuple[ActivityEvent, Task | None]],
) -> tuple[dict[UUID, list[UUID]], dict[UUID, str], dict[UUID, TagState]]:
) -> tuple[
dict[UUID, list[UUID]],
dict[UUID, str],
dict[UUID, TagState],
dict[UUID, TaskCustomFieldValues],
]:
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_tag_state(
session,
@@ -791,15 +1097,20 @@ async def _stream_task_state(
dep_ids: list[UUID] = []
for value in deps_map.values():
dep_ids.extend(value)
custom_field_values_by_task_id = await _task_custom_field_values_by_task_id(
session,
board_id=board_id,
task_ids=list({*task_ids}),
)
if not dep_ids:
return deps_map, {}, tag_state_by_task_id
return deps_map, {}, tag_state_by_task_id, custom_field_values_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, tag_state_by_task_id
return deps_map, dep_status, tag_state_by_task_id, custom_field_values_by_task_id
def _task_event_payload(
@@ -809,7 +1120,9 @@ def _task_event_payload(
deps_map: dict[UUID, list[UUID]],
dep_status: dict[UUID, str],
tag_state_by_task_id: dict[UUID, TagState],
custom_field_values_by_task_id: dict[UUID, TaskCustomFieldValues] | None = None,
) -> dict[str, object]:
resolved_custom_field_values_by_task_id = custom_field_values_by_task_id or {}
payload: dict[str, object] = {
"type": event.event_type,
"activity": ActivityEventRead.model_validate(event).model_dump(mode="json"),
@@ -838,6 +1151,10 @@ def _task_event_payload(
"tags": tag_state.tags,
"blocked_by_task_ids": blocked_by,
"is_blocked": bool(blocked_by),
"custom_field_values": resolved_custom_field_values_by_task_id.get(
task.id,
{},
),
},
)
.model_dump(mode="json")
@@ -861,10 +1178,12 @@ 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, tag_state_by_task_id = await _stream_task_state(
session,
board_id=board_id,
rows=rows,
deps_map, dep_status, tag_state_by_task_id, custom_field_values_by_task_id = (
await _stream_task_state(
session,
board_id=board_id,
rows=rows,
)
)
for event, task in rows:
@@ -883,6 +1202,7 @@ async def _task_event_generator(
deps_map=deps_map,
dep_status=dep_status,
tag_state_by_task_id=tag_state_by_task_id,
custom_field_values_by_task_id=custom_field_values_by_task_id,
)
yield {"event": "task", "data": json.dumps(payload)}
await asyncio.sleep(2)
@@ -943,9 +1263,10 @@ 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", "tag_ids"})
data = payload.model_dump(exclude={"depends_on_task_ids", "tag_ids", "custom_field_values"})
depends_on_task_ids = list(payload.depends_on_task_ids)
tag_ids = list(payload.tag_ids)
custom_field_values = dict(payload.custom_field_values)
task = Task.model_validate(data)
task.board_id = board.id
@@ -977,6 +1298,12 @@ async def create_task(
session.add(task)
# Ensure the task exists in the DB before inserting dependency rows.
await session.flush()
await _set_task_custom_field_values_for_create(
session,
board_id=board.id,
task_id=task.id,
custom_field_values=custom_field_values,
)
for dep_id in normalized_deps:
session.add(
TaskDependency(
@@ -1051,9 +1378,14 @@ async def update_task(
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
custom_field_values = (
payload.custom_field_values if "custom_field_values" in payload.model_fields_set else None
)
custom_field_values_set = "custom_field_values" in payload.model_fields_set
updates.pop("comment", None)
updates.pop("depends_on_task_ids", None)
updates.pop("tag_ids", None)
updates.pop("custom_field_values", None)
requested_status = payload.status if "status" in payload.model_fields_set else None
update = _TaskUpdateInput(
task=task,
@@ -1066,6 +1398,8 @@ async def update_task(
comment=comment,
depends_on_task_ids=depends_on_task_ids,
tag_ids=tag_ids,
custom_field_values=custom_field_values or {},
custom_field_values_set=custom_field_values_set,
)
if actor.actor_type == "agent" and actor.agent and actor.agent.is_board_lead:
return await _apply_lead_task_update(session, update=update)
@@ -1142,6 +1476,12 @@ async def delete_task(
col(TagAssignment.task_id) == task.id,
commit=False,
)
await crud.delete_where(
session,
TaskCustomFieldValue,
col(TaskCustomFieldValue.task_id) == task.id,
commit=False,
)
await session.delete(task)
await session.commit()
return OkResponse()
@@ -1306,6 +1646,8 @@ class _TaskUpdateInput:
comment: str | None
depends_on_task_ids: list[UUID] | None
tag_ids: list[UUID] | None
custom_field_values: TaskCustomFieldValues
custom_field_values_set: bool
normalized_tag_ids: list[UUID] | None = None
@@ -1385,6 +1727,11 @@ async def _task_read_response(
board_id=board_id,
dep_ids=dep_ids,
)
custom_field_values_by_task_id = await _task_custom_field_values_by_task_id(
session,
board_id=board_id,
task_ids=[task.id],
)
if task.status == "done":
blocked_ids = []
return TaskRead.model_validate(task, from_attributes=True).model_copy(
@@ -1394,6 +1741,7 @@ async def _task_read_response(
"tags": tag_state.tags,
"blocked_by_task_ids": blocked_ids,
"is_blocked": bool(blocked_ids),
"custom_field_values": custom_field_values_by_task_id.get(task.id, {}),
},
)
@@ -1420,18 +1768,26 @@ def _lead_requested_fields(update: _TaskUpdateInput) -> set[str]:
requested_fields.add("depends_on_task_ids")
if update.tag_ids is not None:
requested_fields.add("tag_ids")
if update.custom_field_values_set:
requested_fields.add("custom_field_values")
return requested_fields
def _validate_lead_update_request(update: _TaskUpdateInput) -> None:
allowed_fields = {"assigned_agent_id", "status", "depends_on_task_ids", "tag_ids"}
allowed_fields = {
"assigned_agent_id",
"status",
"depends_on_task_ids",
"tag_ids",
"custom_field_values",
}
requested_fields = _lead_requested_fields(update)
if update.comment is not None or not requested_fields.issubset(allowed_fields):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
"Board leads can only assign/unassign tasks, update "
"dependencies, or resolve review tasks."
"dependencies/custom fields, or resolve review tasks."
),
)
@@ -1622,6 +1978,13 @@ async def _apply_lead_task_update(
task_id=update.task.id,
tag_ids=normalized_tag_ids,
)
if update.custom_field_values_set:
await _set_task_custom_field_values_for_update(
session,
board_id=update.board_id,
task_id=update.task.id,
custom_field_values=update.custom_field_values,
)
update.task.updated_at = utcnow()
session.add(update.task)
@@ -1666,7 +2029,7 @@ async def _apply_non_lead_agent_task_rules(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
# Agents are limited to status/comment updates, and non-inbox status moves
# must pass dependency checks before they can proceed.
allowed_fields = {"status", "comment"}
allowed_fields = {"status", "comment", "custom_field_values"}
if (
update.depends_on_task_ids is not None
or update.tag_ids is not None
@@ -1938,6 +2301,14 @@ async def _finalize_updated_task(
tag_ids=normalized or [],
)
if update.custom_field_values_set:
await _set_task_custom_field_values_for_update(
session,
board_id=update.board_id,
task_id=update.task.id,
custom_field_values=update.custom_field_values,
)
session.add(update.task)
await session.commit()
await session.refresh(update.task)