Merge branch 'master' into docs/backend-doc-pass
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@ from app.db.pagination import paginate
|
||||
from app.db.session import async_session_maker, get_session
|
||||
from app.models.agents import Agent
|
||||
from app.models.approvals import Approval
|
||||
from app.models.tasks import Task
|
||||
from app.schemas.approvals import ApprovalCreate, ApprovalRead, ApprovalStatus, ApprovalUpdate
|
||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
||||
from app.services.activity_log import record_activity
|
||||
@@ -96,10 +97,36 @@ async def _approval_task_ids_map(
|
||||
return mapping
|
||||
|
||||
|
||||
def _approval_to_read(approval: Approval, *, task_ids: list[UUID]) -> ApprovalRead:
|
||||
async def _task_titles_by_id(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
task_ids: set[UUID],
|
||||
) -> dict[UUID, str]:
|
||||
if not task_ids:
|
||||
return {}
|
||||
rows = list(
|
||||
await session.exec(
|
||||
select(col(Task.id), col(Task.title)).where(col(Task.id).in_(task_ids)),
|
||||
),
|
||||
)
|
||||
return {task_id: title for task_id, title in rows}
|
||||
|
||||
|
||||
def _approval_to_read(
|
||||
approval: Approval,
|
||||
*,
|
||||
task_ids: list[UUID],
|
||||
task_titles: list[str],
|
||||
) -> ApprovalRead:
|
||||
primary_task_id = task_ids[0] if task_ids else None
|
||||
model = ApprovalRead.model_validate(approval, from_attributes=True)
|
||||
return model.model_copy(update={"task_id": primary_task_id, "task_ids": task_ids})
|
||||
return model.model_copy(
|
||||
update={
|
||||
"task_id": primary_task_id,
|
||||
"task_ids": task_ids,
|
||||
"task_titles": task_titles,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _approval_reads(
|
||||
@@ -107,8 +134,17 @@ async def _approval_reads(
|
||||
approvals: Sequence[Approval],
|
||||
) -> list[ApprovalRead]:
|
||||
mapping = await _approval_task_ids_map(session, approvals)
|
||||
title_by_id = await _task_titles_by_id(
|
||||
session,
|
||||
task_ids={task_id for task_ids in mapping.values() for task_id in task_ids},
|
||||
)
|
||||
return [
|
||||
_approval_to_read(approval, task_ids=mapping.get(approval.id, [])) for approval in approvals
|
||||
_approval_to_read(
|
||||
approval,
|
||||
task_ids=(task_ids := mapping.get(approval.id, [])),
|
||||
task_titles=[title_by_id[task_id] for task_id in task_ids if task_id in title_by_id],
|
||||
)
|
||||
for approval in approvals
|
||||
]
|
||||
|
||||
|
||||
@@ -389,7 +425,12 @@ async def create_approval(
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(approval)
|
||||
return _approval_to_read(approval, task_ids=task_ids)
|
||||
title_by_id = await _task_titles_by_id(session, task_ids=set(task_ids))
|
||||
return _approval_to_read(
|
||||
approval,
|
||||
task_ids=task_ids,
|
||||
task_titles=[title_by_id[task_id] for task_id in task_ids if task_id in title_by_id],
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{approval_id}", response_model=ApprovalRead)
|
||||
|
||||
@@ -5,13 +5,56 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from app.core.auth import AuthContext, get_auth_context
|
||||
from app.schemas.errors import LLMErrorResponse
|
||||
from app.schemas.users import UserRead
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
AUTH_CONTEXT_DEP = Depends(get_auth_context)
|
||||
|
||||
|
||||
@router.post("/bootstrap", response_model=UserRead)
|
||||
@router.post(
|
||||
"/bootstrap",
|
||||
response_model=UserRead,
|
||||
summary="Bootstrap Authenticated User Context",
|
||||
description=(
|
||||
"Resolve caller identity from auth headers and return the canonical user profile. "
|
||||
"This endpoint does not accept a request body."
|
||||
),
|
||||
responses={
|
||||
status.HTTP_200_OK: {
|
||||
"description": "Authenticated user profile resolved from token claims.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"clerk_user_id": "user_2abcXYZ",
|
||||
"email": "alex@example.com",
|
||||
"name": "Alex Chen",
|
||||
"preferred_name": "Alex",
|
||||
"pronouns": "they/them",
|
||||
"timezone": "America/Los_Angeles",
|
||||
"notes": "Primary operator for board triage.",
|
||||
"context": "Handles incident coordination and escalation.",
|
||||
"is_super_admin": False,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
status.HTTP_401_UNAUTHORIZED: {
|
||||
"model": LLMErrorResponse,
|
||||
"description": "Caller is not authenticated as a user actor.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"detail": {"code": "unauthorized", "message": "Not authenticated"},
|
||||
"code": "unauthorized",
|
||||
"retryable": False,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
async def bootstrap_user(auth: AuthContext = AUTH_CONTEXT_DEP) -> UserRead:
|
||||
"""Return the authenticated user profile from token claims."""
|
||||
if auth.actor_type != "user" or auth.user is None:
|
||||
|
||||
@@ -6,7 +6,8 @@ import asyncio
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
@@ -68,6 +69,48 @@ ACTOR_DEP = Depends(require_admin_or_agent)
|
||||
IS_CHAT_QUERY = Query(default=None)
|
||||
SINCE_QUERY = Query(default=None)
|
||||
_RUNTIME_TYPE_REFERENCES = (UUID,)
|
||||
AGENT_BOARD_ROLE_TAGS = cast("list[str | Enum]", ["agent-lead", "agent-worker"])
|
||||
|
||||
|
||||
def _agent_group_memory_openapi_hints(
|
||||
*,
|
||||
intent: str,
|
||||
when_to_use: list[str],
|
||||
routing_examples: list[dict[str, object]],
|
||||
required_actor: str = "any_agent",
|
||||
when_not_to_use: list[str] | None = None,
|
||||
routing_policy: list[str] | None = None,
|
||||
negative_guidance: list[str] | None = None,
|
||||
prerequisites: list[str] | None = None,
|
||||
side_effects: list[str] | None = None,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"x-llm-intent": intent,
|
||||
"x-when-to-use": when_to_use,
|
||||
"x-when-not-to-use": when_not_to_use
|
||||
or [
|
||||
"Use a more specific endpoint when targeting a single actor or broadcast scope.",
|
||||
],
|
||||
"x-required-actor": required_actor,
|
||||
"x-prerequisites": prerequisites
|
||||
or [
|
||||
"Authenticated actor token",
|
||||
"Accessible board context",
|
||||
],
|
||||
"x-side-effects": side_effects
|
||||
or ["Persisted memory visibility changes may be observable across linked boards."],
|
||||
"x-negative-guidance": negative_guidance
|
||||
or [
|
||||
"Do not use as a replacement for direct task-specific commentary.",
|
||||
"Do not assume infinite retention when group storage policies apply.",
|
||||
],
|
||||
"x-routing-policy": routing_policy
|
||||
or [
|
||||
"Use when board context requires shared memory discovery or posting.",
|
||||
"Prefer narrow board endpoints for one-off lead/agent coordination needs.",
|
||||
],
|
||||
"x-routing-policy-examples": routing_examples,
|
||||
}
|
||||
|
||||
|
||||
def _parse_since(value: str | None) -> datetime | None:
|
||||
@@ -402,14 +445,42 @@ async def create_board_group_memory(
|
||||
return memory
|
||||
|
||||
|
||||
@board_router.get("", response_model=DefaultLimitOffsetPage[BoardGroupMemoryRead])
|
||||
@board_router.get(
|
||||
"",
|
||||
response_model=DefaultLimitOffsetPage[BoardGroupMemoryRead],
|
||||
tags=AGENT_BOARD_ROLE_TAGS,
|
||||
openapi_extra=_agent_group_memory_openapi_hints(
|
||||
intent="agent_board_group_memory_discovery",
|
||||
when_to_use=[
|
||||
"Inspect shared group memory for cross-board context before making decisions.",
|
||||
"Collect active chat snapshots for a linked group before coordination actions.",
|
||||
],
|
||||
routing_examples=[
|
||||
{
|
||||
"input": {
|
||||
"intent": "recover recent team memory for task framing",
|
||||
"required_privilege": "agent_lead_or_worker",
|
||||
},
|
||||
"decision": "agent_board_group_memory_discovery",
|
||||
}
|
||||
],
|
||||
side_effects=["No persisted side effects."],
|
||||
routing_policy=[
|
||||
"Use as a shared-context discovery step before decisioning.",
|
||||
"Use board-specific memory endpoints for direct board persistence updates.",
|
||||
],
|
||||
),
|
||||
)
|
||||
async def list_board_group_memory_for_board(
|
||||
*,
|
||||
is_chat: bool | None = IS_CHAT_QUERY,
|
||||
board: Board = BOARD_READ_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
) -> LimitOffsetPage[BoardGroupMemoryRead]:
|
||||
"""List memory entries for the board's linked group."""
|
||||
"""List shared memory for the board's linked group.
|
||||
|
||||
Use this for cross-board context and coordination signals.
|
||||
"""
|
||||
group_id = board.board_group_id
|
||||
if group_id is None:
|
||||
return await paginate(session, BoardGroupMemory.objects.by_ids([]).statement)
|
||||
@@ -426,7 +497,31 @@ async def list_board_group_memory_for_board(
|
||||
return await paginate(session, queryset.statement)
|
||||
|
||||
|
||||
@board_router.get("/stream")
|
||||
@board_router.get(
|
||||
"/stream",
|
||||
tags=AGENT_BOARD_ROLE_TAGS,
|
||||
openapi_extra=_agent_group_memory_openapi_hints(
|
||||
intent="agent_board_group_memory_stream",
|
||||
when_to_use=[
|
||||
"Track shared group memory updates in near-real-time for live coordination.",
|
||||
"React to newly added group messages without polling.",
|
||||
],
|
||||
routing_examples=[
|
||||
{
|
||||
"input": {
|
||||
"intent": "subscribe to group memory updates for routing",
|
||||
"required_privilege": "agent_lead_or_worker",
|
||||
},
|
||||
"decision": "agent_board_group_memory_stream",
|
||||
}
|
||||
],
|
||||
side_effects=["No persisted side effects, streaming updates are read-only."],
|
||||
routing_policy=[
|
||||
"Use when coordinated decisions need continuous group context.",
|
||||
"Prefer bounded history reads when a snapshot is sufficient.",
|
||||
],
|
||||
),
|
||||
)
|
||||
async def stream_board_group_memory_for_board(
|
||||
request: Request,
|
||||
*,
|
||||
@@ -434,7 +529,7 @@ async def stream_board_group_memory_for_board(
|
||||
since: str | None = SINCE_QUERY,
|
||||
is_chat: bool | None = IS_CHAT_QUERY,
|
||||
) -> EventSourceResponse:
|
||||
"""Stream memory entries for the board's linked group."""
|
||||
"""Stream linked-group memory via SSE for near-real-time coordination."""
|
||||
group_id = board.board_group_id
|
||||
since_dt = _parse_since(since) or utcnow()
|
||||
last_seen = since_dt
|
||||
@@ -463,18 +558,49 @@ async def stream_board_group_memory_for_board(
|
||||
return EventSourceResponse(event_generator(), ping=15)
|
||||
|
||||
|
||||
@board_router.post("", response_model=BoardGroupMemoryRead)
|
||||
@board_router.post(
|
||||
"",
|
||||
response_model=BoardGroupMemoryRead,
|
||||
tags=AGENT_BOARD_ROLE_TAGS,
|
||||
openapi_extra=_agent_group_memory_openapi_hints(
|
||||
intent="agent_board_group_memory_record",
|
||||
when_to_use=[
|
||||
"Persist shared group memory for a linked group from board context.",
|
||||
"Broadcast updates/messages to group-linked agents when chat or mention intent is present.",
|
||||
],
|
||||
routing_examples=[
|
||||
{
|
||||
"input": {
|
||||
"intent": "share coordination signal in group memory",
|
||||
"required_privilege": "board_agent",
|
||||
},
|
||||
"decision": "agent_board_group_memory_record",
|
||||
}
|
||||
],
|
||||
side_effects=[
|
||||
"Persist new group-memory entries with optional agent notification dispatch."
|
||||
],
|
||||
routing_policy=[
|
||||
"Use for shared memory writes that should be visible across linked boards.",
|
||||
"Prefer direct board memory endpoints for board-local persistence.",
|
||||
],
|
||||
),
|
||||
)
|
||||
async def create_board_group_memory_for_board(
|
||||
payload: BoardGroupMemoryCreate,
|
||||
board: Board = BOARD_WRITE_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
actor: ActorContext = ACTOR_DEP,
|
||||
) -> BoardGroupMemory:
|
||||
"""Create a group memory entry from a board context and notify recipients."""
|
||||
"""Create shared group memory from a board context.
|
||||
|
||||
When tags/mentions indicate chat or broadcast intent, eligible agents in the
|
||||
linked group are notified.
|
||||
"""
|
||||
group_id = board.board_group_id
|
||||
if group_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Board is not in a board group",
|
||||
)
|
||||
group = await BoardGroup.objects.by_id(group_id).first(session)
|
||||
|
||||
@@ -160,7 +160,7 @@ async def get_board_group_snapshot(
|
||||
write=False,
|
||||
)
|
||||
if per_board_task_limit < 0:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT)
|
||||
snapshot = await build_group_snapshot(
|
||||
session,
|
||||
group=group,
|
||||
@@ -235,10 +235,7 @@ def _update_agent_heartbeat(
|
||||
if isinstance(raw, dict):
|
||||
heartbeat.update(raw)
|
||||
heartbeat["every"] = payload.every
|
||||
if payload.target is not None:
|
||||
heartbeat["target"] = payload.target
|
||||
elif "target" not in heartbeat:
|
||||
heartbeat["target"] = DEFAULT_HEARTBEAT_CONFIG.get("target", "none")
|
||||
heartbeat["target"] = DEFAULT_HEARTBEAT_CONFIG.get("target", "last")
|
||||
agent.heartbeat_config = heartbeat
|
||||
agent.updated_at = utcnow()
|
||||
|
||||
|
||||
@@ -86,6 +86,41 @@ def _parse_draft_lead_agent(
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_autonomy_token(value: object) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
text = value.strip().lower()
|
||||
if not text:
|
||||
return None
|
||||
return text.replace("_", "-")
|
||||
|
||||
|
||||
def _is_fully_autonomous_choice(value: object) -> bool:
|
||||
token = _normalize_autonomy_token(value)
|
||||
if token is None:
|
||||
return False
|
||||
if token in {"autonomous", "fully-autonomous", "full-autonomy"}:
|
||||
return True
|
||||
return "autonom" in token and "fully" in token
|
||||
|
||||
|
||||
def _require_approval_for_done_from_draft(draft_goal: object) -> bool:
|
||||
"""Enable done-approval gate unless onboarding selected fully autonomous mode."""
|
||||
if not isinstance(draft_goal, dict):
|
||||
return True
|
||||
raw_lead = draft_goal.get("lead_agent")
|
||||
if not isinstance(raw_lead, dict):
|
||||
return True
|
||||
if _is_fully_autonomous_choice(raw_lead.get("autonomy_level")):
|
||||
return False
|
||||
raw_identity_profile = raw_lead.get("identity_profile")
|
||||
if isinstance(raw_identity_profile, dict):
|
||||
for key in ("autonomy_level", "autonomy", "mode"):
|
||||
if _is_fully_autonomous_choice(raw_identity_profile.get(key)):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _apply_user_profile(
|
||||
auth: AuthContext,
|
||||
profile: BoardOnboardingUserProfile | None,
|
||||
@@ -408,6 +443,9 @@ async def confirm_onboarding(
|
||||
board.target_date = payload.target_date
|
||||
board.goal_confirmed = True
|
||||
board.goal_source = "lead_agent_onboarding"
|
||||
board.require_approval_for_done = _require_approval_for_done_from_draft(
|
||||
onboarding.draft_goal,
|
||||
)
|
||||
|
||||
onboarding.status = "confirmed"
|
||||
onboarding.updated_at = utcnow()
|
||||
|
||||
525
backend/app/api/board_webhooks.py
Normal file
525
backend/app/api/board_webhooks.py
Normal file
@@ -0,0 +1,525 @@
|
||||
"""Board webhook configuration and inbound payload ingestion endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlmodel import col, select
|
||||
|
||||
from app.api.deps import get_board_for_user_read, get_board_for_user_write, get_board_or_404
|
||||
from app.core.config import settings
|
||||
from app.core.logging import get_logger
|
||||
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.agents import Agent
|
||||
from app.models.board_memory import BoardMemory
|
||||
from app.models.board_webhook_payloads import BoardWebhookPayload
|
||||
from app.models.board_webhooks import BoardWebhook
|
||||
from app.schemas.board_webhooks import (
|
||||
BoardWebhookCreate,
|
||||
BoardWebhookIngestResponse,
|
||||
BoardWebhookPayloadRead,
|
||||
BoardWebhookRead,
|
||||
BoardWebhookUpdate,
|
||||
)
|
||||
from app.schemas.common import OkResponse
|
||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
||||
from app.services.openclaw.gateway_dispatch import GatewayDispatchService
|
||||
from app.services.webhooks.queue import QueuedInboundDelivery, enqueue_webhook_delivery
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from fastapi_pagination.limit_offset import LimitOffsetPage
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.models.boards import Board
|
||||
|
||||
router = APIRouter(prefix="/boards/{board_id}/webhooks", tags=["board-webhooks"])
|
||||
SESSION_DEP = Depends(get_session)
|
||||
BOARD_USER_READ_DEP = Depends(get_board_for_user_read)
|
||||
BOARD_USER_WRITE_DEP = Depends(get_board_for_user_write)
|
||||
BOARD_OR_404_DEP = Depends(get_board_or_404)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _webhook_endpoint_path(board_id: UUID, webhook_id: UUID) -> str:
|
||||
return f"/api/v1/boards/{board_id}/webhooks/{webhook_id}"
|
||||
|
||||
|
||||
def _webhook_endpoint_url(endpoint_path: str) -> str | None:
|
||||
base_url = settings.base_url.rstrip("/")
|
||||
if not base_url:
|
||||
return None
|
||||
return f"{base_url}{endpoint_path}"
|
||||
|
||||
|
||||
def _to_webhook_read(webhook: BoardWebhook) -> BoardWebhookRead:
|
||||
endpoint_path = _webhook_endpoint_path(webhook.board_id, webhook.id)
|
||||
return BoardWebhookRead(
|
||||
id=webhook.id,
|
||||
board_id=webhook.board_id,
|
||||
agent_id=webhook.agent_id,
|
||||
description=webhook.description,
|
||||
enabled=webhook.enabled,
|
||||
endpoint_path=endpoint_path,
|
||||
endpoint_url=_webhook_endpoint_url(endpoint_path),
|
||||
created_at=webhook.created_at,
|
||||
updated_at=webhook.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _to_payload_read(payload: BoardWebhookPayload) -> BoardWebhookPayloadRead:
|
||||
return BoardWebhookPayloadRead.model_validate(payload, from_attributes=True)
|
||||
|
||||
|
||||
def _coerce_webhook_items(items: Sequence[object]) -> list[BoardWebhook]:
|
||||
values: list[BoardWebhook] = []
|
||||
for item in items:
|
||||
if not isinstance(item, BoardWebhook):
|
||||
msg = "Expected BoardWebhook items from paginated query"
|
||||
raise TypeError(msg)
|
||||
values.append(item)
|
||||
return values
|
||||
|
||||
|
||||
def _coerce_payload_items(items: Sequence[object]) -> list[BoardWebhookPayload]:
|
||||
values: list[BoardWebhookPayload] = []
|
||||
for item in items:
|
||||
if not isinstance(item, BoardWebhookPayload):
|
||||
msg = "Expected BoardWebhookPayload items from paginated query"
|
||||
raise TypeError(msg)
|
||||
values.append(item)
|
||||
return values
|
||||
|
||||
|
||||
async def _require_board_webhook(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
board_id: UUID,
|
||||
webhook_id: UUID,
|
||||
) -> BoardWebhook:
|
||||
webhook = (
|
||||
await session.exec(
|
||||
select(BoardWebhook)
|
||||
.where(col(BoardWebhook.id) == webhook_id)
|
||||
.where(col(BoardWebhook.board_id) == board_id),
|
||||
)
|
||||
).first()
|
||||
if webhook is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
return webhook
|
||||
|
||||
|
||||
async def _require_board_webhook_payload(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
board_id: UUID,
|
||||
webhook_id: UUID,
|
||||
payload_id: UUID,
|
||||
) -> BoardWebhookPayload:
|
||||
payload = (
|
||||
await session.exec(
|
||||
select(BoardWebhookPayload)
|
||||
.where(col(BoardWebhookPayload.id) == payload_id)
|
||||
.where(col(BoardWebhookPayload.board_id) == board_id)
|
||||
.where(col(BoardWebhookPayload.webhook_id) == webhook_id),
|
||||
)
|
||||
).first()
|
||||
if payload is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
return payload
|
||||
|
||||
|
||||
def _decode_payload(
|
||||
raw_body: bytes,
|
||||
*,
|
||||
content_type: str | None,
|
||||
) -> dict[str, object] | list[object] | str | int | float | bool | None:
|
||||
if not raw_body:
|
||||
return {}
|
||||
|
||||
body_text = raw_body.decode("utf-8", errors="replace")
|
||||
normalized_content_type = (content_type or "").lower()
|
||||
should_parse_json = "application/json" in normalized_content_type
|
||||
if not should_parse_json:
|
||||
should_parse_json = body_text.startswith(("{", "[", '"')) or body_text in {"true", "false"}
|
||||
|
||||
if should_parse_json:
|
||||
try:
|
||||
parsed = json.loads(body_text)
|
||||
except json.JSONDecodeError:
|
||||
return body_text
|
||||
if isinstance(parsed, (dict, list, str, int, float, bool)) or parsed is None:
|
||||
return parsed
|
||||
return body_text
|
||||
|
||||
|
||||
def _captured_headers(request: Request) -> dict[str, str] | None:
|
||||
captured: dict[str, str] = {}
|
||||
for header, value in request.headers.items():
|
||||
normalized = header.lower()
|
||||
if normalized in {"content-type", "user-agent"} or normalized.startswith("x-"):
|
||||
captured[normalized] = value
|
||||
return captured or None
|
||||
|
||||
|
||||
def _payload_preview(
|
||||
value: dict[str, object] | list[object] | str | int | float | bool | None,
|
||||
) -> str:
|
||||
if isinstance(value, str):
|
||||
preview = value
|
||||
else:
|
||||
try:
|
||||
preview = json.dumps(value, indent=2, ensure_ascii=True)
|
||||
except TypeError:
|
||||
preview = str(value)
|
||||
return preview
|
||||
|
||||
|
||||
def _webhook_memory_content(
|
||||
*,
|
||||
webhook: BoardWebhook,
|
||||
payload: BoardWebhookPayload,
|
||||
) -> str:
|
||||
preview = _payload_preview(payload.payload)
|
||||
inspect_path = f"/api/v1/boards/{webhook.board_id}/webhooks/{webhook.id}/payloads/{payload.id}"
|
||||
return (
|
||||
"WEBHOOK PAYLOAD RECEIVED\n"
|
||||
f"Webhook ID: {webhook.id}\n"
|
||||
f"Payload ID: {payload.id}\n"
|
||||
f"Instruction: {webhook.description}\n"
|
||||
f"Inspect (admin API): {inspect_path}\n\n"
|
||||
"Payload preview:\n"
|
||||
f"{preview}"
|
||||
)
|
||||
|
||||
|
||||
async def _notify_lead_on_webhook_payload(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
board: Board,
|
||||
webhook: BoardWebhook,
|
||||
payload: BoardWebhookPayload,
|
||||
) -> None:
|
||||
target_agent: Agent | None = None
|
||||
if webhook.agent_id is not None:
|
||||
target_agent = await Agent.objects.filter_by(id=webhook.agent_id, board_id=board.id).first(
|
||||
session
|
||||
)
|
||||
if target_agent is None:
|
||||
target_agent = (
|
||||
await Agent.objects.filter_by(board_id=board.id)
|
||||
.filter(col(Agent.is_board_lead).is_(True))
|
||||
.first(session)
|
||||
)
|
||||
if target_agent is None or not target_agent.openclaw_session_id:
|
||||
return
|
||||
|
||||
dispatch = GatewayDispatchService(session)
|
||||
config = await dispatch.optional_gateway_config_for_board(board)
|
||||
if config is None:
|
||||
return
|
||||
|
||||
payload_preview = _payload_preview(payload.payload)
|
||||
message = (
|
||||
"WEBHOOK EVENT RECEIVED\n"
|
||||
f"Board: {board.name}\n"
|
||||
f"Webhook ID: {webhook.id}\n"
|
||||
f"Payload ID: {payload.id}\n"
|
||||
f"Instruction: {webhook.description}\n\n"
|
||||
"Take action:\n"
|
||||
"1) Triage this payload against the webhook instruction.\n"
|
||||
"2) Create/update tasks as needed.\n"
|
||||
f"3) Reference payload ID {payload.id} in task descriptions.\n\n"
|
||||
"Payload preview:\n"
|
||||
f"{payload_preview}\n\n"
|
||||
"To inspect board memory entries:\n"
|
||||
f"GET /api/v1/agent/boards/{board.id}/memory?is_chat=false"
|
||||
)
|
||||
await dispatch.try_send_agent_message(
|
||||
session_key=target_agent.openclaw_session_id,
|
||||
config=config,
|
||||
agent_name=target_agent.name,
|
||||
message=message,
|
||||
deliver=False,
|
||||
)
|
||||
|
||||
|
||||
async def _validate_agent_id(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
board: Board,
|
||||
agent_id: UUID | None,
|
||||
) -> None:
|
||||
if agent_id is None:
|
||||
return
|
||||
agent = await Agent.objects.filter_by(id=agent_id, board_id=board.id).first(session)
|
||||
if agent is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="agent_id must reference an agent on this board.",
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=DefaultLimitOffsetPage[BoardWebhookRead])
|
||||
async def list_board_webhooks(
|
||||
board: Board = BOARD_USER_READ_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
) -> LimitOffsetPage[BoardWebhookRead]:
|
||||
"""List configured webhooks for a board."""
|
||||
statement = (
|
||||
select(BoardWebhook)
|
||||
.where(col(BoardWebhook.board_id) == board.id)
|
||||
.order_by(col(BoardWebhook.created_at).desc())
|
||||
)
|
||||
|
||||
def _transform(items: Sequence[object]) -> Sequence[object]:
|
||||
webhooks = _coerce_webhook_items(items)
|
||||
return [_to_webhook_read(value) for value in webhooks]
|
||||
|
||||
return await paginate(session, statement, transformer=_transform)
|
||||
|
||||
|
||||
@router.post("", response_model=BoardWebhookRead)
|
||||
async def create_board_webhook(
|
||||
payload: BoardWebhookCreate,
|
||||
board: Board = BOARD_USER_WRITE_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
) -> BoardWebhookRead:
|
||||
"""Create a new board webhook with a generated UUID endpoint."""
|
||||
await _validate_agent_id(
|
||||
session=session,
|
||||
board=board,
|
||||
agent_id=payload.agent_id,
|
||||
)
|
||||
webhook = BoardWebhook(
|
||||
board_id=board.id,
|
||||
agent_id=payload.agent_id,
|
||||
description=payload.description,
|
||||
enabled=payload.enabled,
|
||||
)
|
||||
await crud.save(session, webhook)
|
||||
return _to_webhook_read(webhook)
|
||||
|
||||
|
||||
@router.get("/{webhook_id}", response_model=BoardWebhookRead)
|
||||
async def get_board_webhook(
|
||||
webhook_id: UUID,
|
||||
board: Board = BOARD_USER_READ_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
) -> BoardWebhookRead:
|
||||
"""Get one board webhook configuration."""
|
||||
webhook = await _require_board_webhook(
|
||||
session,
|
||||
board_id=board.id,
|
||||
webhook_id=webhook_id,
|
||||
)
|
||||
return _to_webhook_read(webhook)
|
||||
|
||||
|
||||
@router.patch("/{webhook_id}", response_model=BoardWebhookRead)
|
||||
async def update_board_webhook(
|
||||
webhook_id: UUID,
|
||||
payload: BoardWebhookUpdate,
|
||||
board: Board = BOARD_USER_WRITE_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
) -> BoardWebhookRead:
|
||||
"""Update board webhook description or enabled state."""
|
||||
webhook = await _require_board_webhook(
|
||||
session,
|
||||
board_id=board.id,
|
||||
webhook_id=webhook_id,
|
||||
)
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
if updates:
|
||||
await _validate_agent_id(
|
||||
session=session,
|
||||
board=board,
|
||||
agent_id=updates.get("agent_id"),
|
||||
)
|
||||
crud.apply_updates(webhook, updates)
|
||||
webhook.updated_at = utcnow()
|
||||
await crud.save(session, webhook)
|
||||
return _to_webhook_read(webhook)
|
||||
|
||||
|
||||
@router.delete("/{webhook_id}", response_model=OkResponse)
|
||||
async def delete_board_webhook(
|
||||
webhook_id: UUID,
|
||||
board: Board = BOARD_USER_WRITE_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
) -> OkResponse:
|
||||
"""Delete a webhook and its stored payload rows."""
|
||||
webhook = await _require_board_webhook(
|
||||
session,
|
||||
board_id=board.id,
|
||||
webhook_id=webhook_id,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session,
|
||||
BoardWebhookPayload,
|
||||
col(BoardWebhookPayload.webhook_id) == webhook.id,
|
||||
commit=False,
|
||||
)
|
||||
await session.delete(webhook)
|
||||
await session.commit()
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{webhook_id}/payloads", response_model=DefaultLimitOffsetPage[BoardWebhookPayloadRead]
|
||||
)
|
||||
async def list_board_webhook_payloads(
|
||||
webhook_id: UUID,
|
||||
board: Board = BOARD_USER_READ_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
) -> LimitOffsetPage[BoardWebhookPayloadRead]:
|
||||
"""List stored payloads for one board webhook."""
|
||||
await _require_board_webhook(
|
||||
session,
|
||||
board_id=board.id,
|
||||
webhook_id=webhook_id,
|
||||
)
|
||||
statement = (
|
||||
select(BoardWebhookPayload)
|
||||
.where(col(BoardWebhookPayload.board_id) == board.id)
|
||||
.where(col(BoardWebhookPayload.webhook_id) == webhook_id)
|
||||
.order_by(col(BoardWebhookPayload.received_at).desc())
|
||||
)
|
||||
|
||||
def _transform(items: Sequence[object]) -> Sequence[object]:
|
||||
payloads = _coerce_payload_items(items)
|
||||
return [_to_payload_read(value) for value in payloads]
|
||||
|
||||
return await paginate(session, statement, transformer=_transform)
|
||||
|
||||
|
||||
@router.get("/{webhook_id}/payloads/{payload_id}", response_model=BoardWebhookPayloadRead)
|
||||
async def get_board_webhook_payload(
|
||||
webhook_id: UUID,
|
||||
payload_id: UUID,
|
||||
board: Board = BOARD_USER_READ_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
) -> BoardWebhookPayloadRead:
|
||||
"""Get a single stored payload for one board webhook."""
|
||||
await _require_board_webhook(
|
||||
session,
|
||||
board_id=board.id,
|
||||
webhook_id=webhook_id,
|
||||
)
|
||||
payload = await _require_board_webhook_payload(
|
||||
session,
|
||||
board_id=board.id,
|
||||
webhook_id=webhook_id,
|
||||
payload_id=payload_id,
|
||||
)
|
||||
return _to_payload_read(payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{webhook_id}",
|
||||
response_model=BoardWebhookIngestResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
async def ingest_board_webhook(
|
||||
request: Request,
|
||||
webhook_id: UUID,
|
||||
board: Board = BOARD_OR_404_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
) -> BoardWebhookIngestResponse:
|
||||
"""Open inbound webhook endpoint that stores payloads and nudges the board lead."""
|
||||
webhook = await _require_board_webhook(
|
||||
session,
|
||||
board_id=board.id,
|
||||
webhook_id=webhook_id,
|
||||
)
|
||||
logger.info(
|
||||
"webhook.ingest.received",
|
||||
extra={
|
||||
"board_id": str(board.id),
|
||||
"webhook_id": str(webhook.id),
|
||||
"source_ip": request.client.host if request.client else None,
|
||||
"content_type": request.headers.get("content-type"),
|
||||
},
|
||||
)
|
||||
if not webhook.enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_410_GONE,
|
||||
detail="Webhook is disabled.",
|
||||
)
|
||||
|
||||
content_type = request.headers.get("content-type")
|
||||
headers = _captured_headers(request)
|
||||
payload_value = _decode_payload(
|
||||
await request.body(),
|
||||
content_type=content_type,
|
||||
)
|
||||
payload = BoardWebhookPayload(
|
||||
board_id=board.id,
|
||||
webhook_id=webhook.id,
|
||||
payload=payload_value,
|
||||
headers=headers,
|
||||
source_ip=request.client.host if request.client else None,
|
||||
content_type=content_type,
|
||||
)
|
||||
session.add(payload)
|
||||
memory = BoardMemory(
|
||||
board_id=board.id,
|
||||
content=_webhook_memory_content(webhook=webhook, payload=payload),
|
||||
tags=[
|
||||
"webhook",
|
||||
f"webhook:{webhook.id}",
|
||||
f"payload:{payload.id}",
|
||||
],
|
||||
source="webhook",
|
||||
is_chat=False,
|
||||
)
|
||||
session.add(memory)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"webhook.ingest.persisted",
|
||||
extra={
|
||||
"payload_id": str(payload.id),
|
||||
"board_id": str(board.id),
|
||||
"webhook_id": str(webhook.id),
|
||||
"memory_id": str(memory.id),
|
||||
},
|
||||
)
|
||||
|
||||
enqueued = enqueue_webhook_delivery(
|
||||
QueuedInboundDelivery(
|
||||
board_id=board.id,
|
||||
webhook_id=webhook.id,
|
||||
payload_id=payload.id,
|
||||
received_at=payload.received_at,
|
||||
),
|
||||
)
|
||||
logger.info(
|
||||
"webhook.ingest.enqueued",
|
||||
extra={
|
||||
"payload_id": str(payload.id),
|
||||
"board_id": str(board.id),
|
||||
"webhook_id": str(webhook.id),
|
||||
"enqueued": enqueued,
|
||||
},
|
||||
)
|
||||
if not enqueued:
|
||||
# Preserve historical behavior by still notifying synchronously if queueing fails.
|
||||
await _notify_lead_on_webhook_payload(
|
||||
session=session,
|
||||
board=board,
|
||||
webhook=webhook,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
return BoardWebhookIngestResponse(
|
||||
board_id=board.id,
|
||||
webhook_id=webhook.id,
|
||||
payload_id=payload.id,
|
||||
)
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Literal, cast
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
@@ -56,6 +57,23 @@ BOARD_GROUP_ID_QUERY = Query(default=None)
|
||||
INCLUDE_SELF_QUERY = Query(default=False)
|
||||
INCLUDE_DONE_QUERY = Query(default=False)
|
||||
PER_BOARD_TASK_LIMIT_QUERY = Query(default=5, ge=0, le=100)
|
||||
AGENT_BOARD_ROLE_TAGS = cast("list[str | Enum]", ["agent-lead", "agent-worker"])
|
||||
_ERR_GATEWAY_MAIN_AGENT_REQUIRED = (
|
||||
"gateway must have a gateway main agent before boards can be created or updated"
|
||||
)
|
||||
|
||||
|
||||
async def _require_gateway_main_agent(session: AsyncSession, gateway: Gateway) -> None:
|
||||
main_agent = (
|
||||
await Agent.objects.filter_by(gateway_id=gateway.id)
|
||||
.filter(col(Agent.board_id).is_(None))
|
||||
.first(session)
|
||||
)
|
||||
if main_agent is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=_ERR_GATEWAY_MAIN_AGENT_REQUIRED,
|
||||
)
|
||||
|
||||
|
||||
async def _require_gateway(
|
||||
@@ -67,14 +85,15 @@ async def _require_gateway(
|
||||
gateway = await crud.get_by_id(session, Gateway, gateway_id)
|
||||
if gateway is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="gateway_id is invalid",
|
||||
)
|
||||
if organization_id is not None and gateway.organization_id != organization_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="gateway_id is invalid",
|
||||
)
|
||||
await _require_gateway_main_agent(session, gateway)
|
||||
return gateway
|
||||
|
||||
|
||||
@@ -99,12 +118,12 @@ async def _require_board_group(
|
||||
group = await crud.get_by_id(session, BoardGroup, board_group_id)
|
||||
if group is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="board_group_id is invalid",
|
||||
)
|
||||
if organization_id is not None and group.organization_id != organization_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="board_group_id is invalid",
|
||||
)
|
||||
return group
|
||||
@@ -151,14 +170,19 @@ async def _apply_board_update(
|
||||
if updates.get("board_type") == "goal" and (not board.objective or not board.success_metrics):
|
||||
# Validate only when explicitly switching to goal boards.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Goal boards require objective and success_metrics",
|
||||
)
|
||||
if not board.gateway_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="gateway_id is required",
|
||||
)
|
||||
await _require_gateway(
|
||||
session,
|
||||
board.gateway_id,
|
||||
organization_id=board.organization_id,
|
||||
)
|
||||
board.updated_at = utcnow()
|
||||
return await crud.save(session, board)
|
||||
|
||||
@@ -393,7 +417,11 @@ async def get_board_snapshot(
|
||||
return await build_board_snapshot(session, board)
|
||||
|
||||
|
||||
@router.get("/{board_id}/group-snapshot", response_model=BoardGroupSnapshot)
|
||||
@router.get(
|
||||
"/{board_id}/group-snapshot",
|
||||
response_model=BoardGroupSnapshot,
|
||||
tags=AGENT_BOARD_ROLE_TAGS,
|
||||
)
|
||||
async def get_board_group_snapshot(
|
||||
*,
|
||||
include_self: bool = INCLUDE_SELF_QUERY,
|
||||
@@ -402,7 +430,10 @@ async def get_board_group_snapshot(
|
||||
board: Board = BOARD_ACTOR_READ_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
) -> BoardGroupSnapshot:
|
||||
"""Get a grouped snapshot across related boards."""
|
||||
"""Get a grouped snapshot across related boards.
|
||||
|
||||
Returns high-signal cross-board status for dependency and overlap checks.
|
||||
"""
|
||||
return await build_board_group_snapshot(
|
||||
session,
|
||||
board=board,
|
||||
|
||||
@@ -20,6 +20,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
|
||||
@@ -196,7 +197,7 @@ BOARD_READ_DEP = Depends(get_board_for_actor_read)
|
||||
|
||||
|
||||
async def get_task_or_404(
|
||||
task_id: str,
|
||||
task_id: UUID,
|
||||
board: Board = BOARD_READ_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
) -> Task:
|
||||
|
||||
@@ -37,11 +37,15 @@ def _query_to_resolve_input(
|
||||
board_id: str | None = Query(default=None),
|
||||
gateway_url: str | None = Query(default=None),
|
||||
gateway_token: str | None = Query(default=None),
|
||||
gateway_disable_device_pairing: bool = Query(default=False),
|
||||
gateway_allow_insecure_tls: bool = Query(default=False),
|
||||
) -> GatewayResolveQuery:
|
||||
return GatewaySessionService.to_resolve_query(
|
||||
board_id=board_id,
|
||||
gateway_url=gateway_url,
|
||||
gateway_token=gateway_token,
|
||||
gateway_disable_device_pairing=gateway_disable_device_pairing,
|
||||
gateway_allow_insecure_tls=gateway_allow_insecure_tls,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.db.pagination import paginate
|
||||
from app.db.session import get_session
|
||||
from app.models.agents import Agent
|
||||
from app.models.gateways import Gateway
|
||||
from app.models.skills import GatewayInstalledSkill
|
||||
from app.schemas.common import OkResponse
|
||||
from app.schemas.gateways import (
|
||||
GatewayCreate,
|
||||
@@ -40,6 +41,8 @@ INCLUDE_MAIN_QUERY = Query(default=True)
|
||||
RESET_SESSIONS_QUERY = Query(default=False)
|
||||
ROTATE_TOKENS_QUERY = Query(default=False)
|
||||
FORCE_BOOTSTRAP_QUERY = Query(default=False)
|
||||
OVERWRITE_QUERY = Query(default=False)
|
||||
LEAD_ONLY_QUERY = Query(default=False)
|
||||
BOARD_ID_QUERY = Query(default=None)
|
||||
_RUNTIME_TYPE_REFERENCES = (UUID,)
|
||||
|
||||
@@ -47,16 +50,20 @@ _RUNTIME_TYPE_REFERENCES = (UUID,)
|
||||
def _template_sync_query(
|
||||
*,
|
||||
include_main: bool = INCLUDE_MAIN_QUERY,
|
||||
lead_only: bool = LEAD_ONLY_QUERY,
|
||||
reset_sessions: bool = RESET_SESSIONS_QUERY,
|
||||
rotate_tokens: bool = ROTATE_TOKENS_QUERY,
|
||||
force_bootstrap: bool = FORCE_BOOTSTRAP_QUERY,
|
||||
overwrite: bool = OVERWRITE_QUERY,
|
||||
board_id: UUID | None = BOARD_ID_QUERY,
|
||||
) -> GatewayTemplateSyncQuery:
|
||||
return GatewayTemplateSyncQuery(
|
||||
include_main=include_main,
|
||||
lead_only=lead_only,
|
||||
reset_sessions=reset_sessions,
|
||||
rotate_tokens=rotate_tokens,
|
||||
force_bootstrap=force_bootstrap,
|
||||
overwrite=overwrite,
|
||||
board_id=board_id,
|
||||
)
|
||||
|
||||
@@ -87,6 +94,12 @@ async def create_gateway(
|
||||
) -> Gateway:
|
||||
"""Create a gateway and provision or refresh its main agent."""
|
||||
service = GatewayAdminLifecycleService(session)
|
||||
await service.assert_gateway_runtime_compatible(
|
||||
url=payload.url,
|
||||
token=payload.token,
|
||||
allow_insecure_tls=payload.allow_insecure_tls,
|
||||
disable_device_pairing=payload.disable_device_pairing,
|
||||
)
|
||||
data = payload.model_dump()
|
||||
gateway_id = uuid4()
|
||||
data["id"] = gateway_id
|
||||
@@ -126,6 +139,28 @@ async def update_gateway(
|
||||
organization_id=ctx.organization.id,
|
||||
)
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
if (
|
||||
"url" in updates
|
||||
or "token" in updates
|
||||
or "allow_insecure_tls" in updates
|
||||
or "disable_device_pairing" in updates
|
||||
):
|
||||
raw_next_url = updates.get("url", gateway.url)
|
||||
next_url = raw_next_url.strip() if isinstance(raw_next_url, str) else ""
|
||||
next_token = updates.get("token", gateway.token)
|
||||
next_allow_insecure_tls = bool(
|
||||
updates.get("allow_insecure_tls", gateway.allow_insecure_tls),
|
||||
)
|
||||
next_disable_device_pairing = bool(
|
||||
updates.get("disable_device_pairing", gateway.disable_device_pairing),
|
||||
)
|
||||
if next_url:
|
||||
await service.assert_gateway_runtime_compatible(
|
||||
url=next_url,
|
||||
token=next_token,
|
||||
allow_insecure_tls=next_allow_insecure_tls,
|
||||
disable_device_pairing=next_disable_device_pairing,
|
||||
)
|
||||
await crud.patch(session, gateway, updates)
|
||||
await service.ensure_main_agent(gateway, auth, action="update")
|
||||
return gateway
|
||||
@@ -175,6 +210,15 @@ async def delete_gateway(
|
||||
await service.clear_agent_foreign_keys(agent_id=agent.id)
|
||||
await session.delete(agent)
|
||||
|
||||
# NOTE: The migration declares `ondelete="CASCADE"` for gateway_installed_skills.gateway_id,
|
||||
# but some backends/test environments (e.g. SQLite without FK pragma) may not
|
||||
# enforce cascades. Delete rows explicitly to guarantee cleanup semantics.
|
||||
installed_skills = await GatewayInstalledSkill.objects.filter_by(
|
||||
gateway_id=gateway.id,
|
||||
).all(session)
|
||||
for installed_skill in installed_skills:
|
||||
await session.delete(installed_skill)
|
||||
|
||||
await session.delete(gateway)
|
||||
await session.commit()
|
||||
return OkResponse()
|
||||
|
||||
@@ -6,7 +6,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import DateTime, case
|
||||
from sqlalchemy import cast as sql_cast
|
||||
from sqlalchemy import func
|
||||
@@ -18,6 +18,7 @@ from app.core.time import utcnow
|
||||
from app.db.session import get_session
|
||||
from app.models.activity_events import ActivityEvent
|
||||
from app.models.agents import Agent
|
||||
from app.models.boards import Board
|
||||
from app.models.tasks import Task
|
||||
from app.schemas.metrics import (
|
||||
DashboardBucketKey,
|
||||
@@ -38,6 +39,8 @@ router = APIRouter(prefix="/metrics", tags=["metrics"])
|
||||
ERROR_EVENT_PATTERN = "%failed"
|
||||
_RUNTIME_TYPE_REFERENCES = (UUID, AsyncSession)
|
||||
RANGE_QUERY = Query(default="24h")
|
||||
BOARD_ID_QUERY = Query(default=None)
|
||||
GROUP_ID_QUERY = Query(default=None)
|
||||
SESSION_DEP = Depends(get_session)
|
||||
ORG_MEMBER_DEP = Depends(require_org_member)
|
||||
|
||||
@@ -250,9 +253,7 @@ async def _query_wip(
|
||||
if not board_ids:
|
||||
return _wip_series_from_mapping(range_spec, {})
|
||||
|
||||
inbox_bucket_col = func.date_trunc(range_spec.bucket, Task.created_at).label(
|
||||
"inbox_bucket"
|
||||
)
|
||||
inbox_bucket_col = func.date_trunc(range_spec.bucket, Task.created_at).label("inbox_bucket")
|
||||
inbox_statement = (
|
||||
select(inbox_bucket_col, func.count())
|
||||
.where(col(Task.status) == "inbox")
|
||||
@@ -264,9 +265,7 @@ async def _query_wip(
|
||||
)
|
||||
inbox_results = (await session.exec(inbox_statement)).all()
|
||||
|
||||
status_bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label(
|
||||
"status_bucket"
|
||||
)
|
||||
status_bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("status_bucket")
|
||||
progress_case = case((col(Task.status) == "in_progress", 1), else_=0)
|
||||
review_case = case((col(Task.status) == "review", 1), else_=0)
|
||||
done_case = case((col(Task.status) == "done", 1), else_=0)
|
||||
@@ -389,16 +388,54 @@ async def _tasks_in_progress(
|
||||
return int(result)
|
||||
|
||||
|
||||
async def _resolve_dashboard_board_ids(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
ctx: OrganizationContext,
|
||||
board_id: UUID | None,
|
||||
group_id: UUID | None,
|
||||
) -> list[UUID]:
|
||||
board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
|
||||
if not board_ids:
|
||||
return []
|
||||
allowed = set(board_ids)
|
||||
|
||||
if board_id is not None and board_id not in allowed:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
if group_id is None:
|
||||
return [board_id] if board_id is not None else board_ids
|
||||
|
||||
group_board_ids = list(
|
||||
await session.exec(
|
||||
select(Board.id)
|
||||
.where(col(Board.organization_id) == ctx.member.organization_id)
|
||||
.where(col(Board.board_group_id) == group_id)
|
||||
.where(col(Board.id).in_(board_ids)),
|
||||
),
|
||||
)
|
||||
if board_id is not None:
|
||||
return [board_id] if board_id in set(group_board_ids) else []
|
||||
return group_board_ids
|
||||
|
||||
|
||||
@router.get("/dashboard", response_model=DashboardMetrics)
|
||||
async def dashboard_metrics(
|
||||
range_key: DashboardRangeKey = RANGE_QUERY,
|
||||
board_id: UUID | None = BOARD_ID_QUERY,
|
||||
group_id: UUID | None = GROUP_ID_QUERY,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||
) -> DashboardMetrics:
|
||||
"""Return dashboard KPIs and time-series data for accessible boards."""
|
||||
primary = _resolve_range(range_key)
|
||||
comparison = _comparison_range(primary)
|
||||
board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
|
||||
board_ids = await _resolve_dashboard_board_ids(
|
||||
session,
|
||||
ctx=ctx,
|
||||
board_id=board_id,
|
||||
group_id=group_id,
|
||||
)
|
||||
|
||||
throughput_primary = await _query_throughput(session, primary, board_ids)
|
||||
throughput_comparison = await _query_throughput(session, comparison, board_ids)
|
||||
|
||||
@@ -24,6 +24,8 @@ from app.models.board_group_memory import BoardGroupMemory
|
||||
from app.models.board_groups import BoardGroup
|
||||
from app.models.board_memory import BoardMemory
|
||||
from app.models.board_onboarding import BoardOnboardingSession
|
||||
from app.models.board_webhook_payloads import BoardWebhookPayload
|
||||
from app.models.board_webhooks import BoardWebhook
|
||||
from app.models.boards import Board
|
||||
from app.models.gateways import Gateway
|
||||
from app.models.organization_board_access import OrganizationBoardAccess
|
||||
@@ -125,7 +127,7 @@ async def create_organization(
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
name = payload.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT)
|
||||
existing = (
|
||||
await session.exec(
|
||||
select(Organization).where(
|
||||
@@ -290,6 +292,18 @@ async def delete_my_org(
|
||||
col(BoardMemory.board_id).in_(board_ids),
|
||||
commit=False,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session,
|
||||
BoardWebhookPayload,
|
||||
col(BoardWebhookPayload.board_id).in_(board_ids),
|
||||
commit=False,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session,
|
||||
BoardWebhook,
|
||||
col(BoardWebhook.board_id).in_(board_ids),
|
||||
commit=False,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session,
|
||||
BoardOnboardingSession,
|
||||
@@ -499,7 +513,7 @@ async def update_member_access(
|
||||
.all(session)
|
||||
}
|
||||
if valid_board_ids != board_ids:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT)
|
||||
|
||||
await apply_member_access_update(session, member=member, update=payload)
|
||||
await session.commit()
|
||||
@@ -600,7 +614,7 @@ async def create_org_invite(
|
||||
"""Create an organization invite for an email address."""
|
||||
email = normalize_invited_email(payload.invited_email)
|
||||
if not email:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT)
|
||||
|
||||
existing_user = (
|
||||
await session.exec(select(User).where(func.lower(col(User.email)) == email))
|
||||
@@ -640,7 +654,7 @@ async def create_org_invite(
|
||||
.all(session)
|
||||
}
|
||||
if valid_board_ids != board_ids:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT)
|
||||
await apply_invite_board_access(
|
||||
session,
|
||||
invite=invite,
|
||||
|
||||
1338
backend/app/api/skills_marketplace.py
Normal file
1338
backend/app/api/skills_marketplace.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,7 @@ def _validate_segment(value: str, *, field: str) -> str:
|
||||
cleaned = value.strip().strip("/")
|
||||
if not cleaned:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"{field} is required",
|
||||
)
|
||||
if field == "handle":
|
||||
@@ -34,7 +34,7 @@ def _validate_segment(value: str, *, field: str) -> str:
|
||||
ok = bool(_SAFE_SLUG_RE.match(cleaned))
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"{field} contains unsupported characters",
|
||||
)
|
||||
return cleaned
|
||||
|
||||
343
backend/app/api/task_custom_fields.py
Normal file
343
backend/app/api/task_custom_fields.py
Normal 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=["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()
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user