Files
openclaw-mission-control/backend/app/api/task_custom_fields.py

344 lines
12 KiB
Python

"""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=["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_CONTENT,
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_CONTENT,
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_CONTENT,
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_CONTENT,
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()