feat: add update_order tool to edit customer/description/due date
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V57jSQPqwkGG8BuAXg59X5
This commit is contained in:
parent
bd73effc73
commit
69bf530ff3
|
|
@ -556,6 +556,14 @@ async def list_orders(status: str = None) -> list:
|
|||
return orders
|
||||
|
||||
|
||||
def _find_card_in_stacks(stacks: list, card_id: int):
|
||||
for stack in stacks:
|
||||
for card in stack.get("cards", []):
|
||||
if card["id"] == card_id:
|
||||
return stack, card
|
||||
return None, None
|
||||
|
||||
|
||||
async def update_order_status(order_id: int, status: str) -> dict:
|
||||
board = await _ensure_deck_board()
|
||||
if status not in board["stacks"]:
|
||||
|
|
@ -563,22 +571,46 @@ async def update_order_status(order_id: int, status: str) -> dict:
|
|||
target_stack_id = board["stacks"][status]
|
||||
|
||||
stacks = await _deck_request("GET", f"/boards/{board['board_id']}/stacks")
|
||||
current_stack_id = None
|
||||
for stack in stacks:
|
||||
if any(c["id"] == order_id for c in stack.get("cards", [])):
|
||||
current_stack_id = stack["id"]
|
||||
break
|
||||
if current_stack_id is None:
|
||||
current_stack, _ = _find_card_in_stacks(stacks, order_id)
|
||||
if current_stack is None:
|
||||
raise ValueError(f"Order {order_id} not found")
|
||||
|
||||
await _deck_request(
|
||||
"PUT",
|
||||
f"/boards/{board['board_id']}/stacks/{current_stack_id}/cards/{order_id}/reorder",
|
||||
f"/boards/{board['board_id']}/stacks/{current_stack['id']}/cards/{order_id}/reorder",
|
||||
{"stackId": target_stack_id, "order": 999},
|
||||
)
|
||||
return {"id": order_id, "status": status}
|
||||
|
||||
|
||||
async def update_order(order_id: int, customer: str = None, description: str = None, due_date: str = None) -> dict:
|
||||
board = await _ensure_deck_board()
|
||||
stacks = await _deck_request("GET", f"/boards/{board['board_id']}/stacks")
|
||||
current_stack, current_card = _find_card_in_stacks(stacks, order_id)
|
||||
if current_card is None:
|
||||
raise ValueError(f"Order {order_id} not found")
|
||||
|
||||
body = {
|
||||
"title": customer if customer is not None else current_card["title"],
|
||||
"description": description if description is not None else current_card.get("description", ""),
|
||||
"type": current_card.get("type", "plain"),
|
||||
"order": current_card.get("order", 999),
|
||||
}
|
||||
if due_date is not None:
|
||||
body["duedate"] = _as_calendar_local(datetime.fromisoformat(due_date)).isoformat()
|
||||
elif current_card.get("duedate"):
|
||||
body["duedate"] = current_card["duedate"]
|
||||
|
||||
await _deck_request("PUT", f"/boards/{board['board_id']}/stacks/{current_stack['id']}/cards/{order_id}", body)
|
||||
return {
|
||||
"id": order_id,
|
||||
"customer": body["title"],
|
||||
"description": body["description"],
|
||||
"status": current_stack["title"],
|
||||
"due_date": body.get("duedate"),
|
||||
}
|
||||
|
||||
|
||||
def _decode_mime_words(value: str) -> str:
|
||||
parts = decode_header(value)
|
||||
decoded = []
|
||||
|
|
@ -820,6 +852,20 @@ ORDER_TOOLS = [
|
|||
"required": ["order_id", "status"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "update_order",
|
||||
"description": "Edit an order's customer name, description or due date without changing its status column. Call list_orders first if you don't already know the order's id.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"order_id": {"type": "integer", "description": "The order's id, from list_orders or create_order"},
|
||||
"customer": {"type": "string", "description": "New customer or ordering party name"},
|
||||
"description": {"type": "string", "description": "New description of what was ordered"},
|
||||
"due_date": {"type": "string", "description": "New due date in ISO 8601, e.g. 2026-09-20"},
|
||||
},
|
||||
"required": ["order_id"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
CALENDAR_ASSISTANT_INSTRUCTIONS = (
|
||||
|
|
@ -856,13 +902,16 @@ MEMORY_ASSISTANT_INSTRUCTIONS = (
|
|||
|
||||
ORDER_ASSISTANT_INSTRUCTIONS = (
|
||||
"Du hast ausserdem Zugriff auf Bestellungen ueber die Tools "
|
||||
"create_order, list_orders und update_order_status - sie leben als "
|
||||
"Karten im Nextcloud-Deck-Board 'Bestellungen' mit den Spalten Offen, "
|
||||
"In Arbeit und Erledigt. Du kannst alle drei Tools direkt aufrufen, "
|
||||
"sobald du die noetigen Angaben hast - keine Rueckfrage noetig, da "
|
||||
"nichts davon zerstoerend ist (es gibt kein Loeschen). Fuer "
|
||||
"update_order_status brauchst du die order_id - ruf dafuer zuerst "
|
||||
"list_orders auf, falls du sie noch nicht aus dem Gespraech kennst."
|
||||
"create_order, list_orders, update_order_status und update_order - sie "
|
||||
"leben als Karten im Nextcloud-Deck-Board 'Bestellungen' mit den "
|
||||
"Spalten Offen, In Arbeit und Erledigt. update_order_status aendert "
|
||||
"nur die Spalte/den Status; update_order aendert Kunde, Beschreibung "
|
||||
"und/oder Faelligkeitsdatum, ohne die Spalte zu wechseln. Du kannst "
|
||||
"alle vier Tools direkt aufrufen, sobald du die noetigen Angaben hast "
|
||||
"- keine Rueckfrage noetig, da nichts davon zerstoerend ist (es gibt "
|
||||
"kein Loeschen). Fuer update_order_status und update_order brauchst "
|
||||
"du die order_id - ruf dafuer zuerst list_orders auf, falls du sie "
|
||||
"noch nicht aus dem Gespraech kennst."
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -913,6 +962,14 @@ async def execute_tool(name: str, tool_input: dict) -> str:
|
|||
if name == "update_order_status":
|
||||
result = await update_order_status(tool_input["order_id"], tool_input["status"])
|
||||
return json.dumps(result)
|
||||
if name == "update_order":
|
||||
result = await update_order(
|
||||
tool_input["order_id"],
|
||||
tool_input.get("customer"),
|
||||
tool_input.get("description"),
|
||||
tool_input.get("due_date"),
|
||||
)
|
||||
return json.dumps(result)
|
||||
raise ValueError(f"Unknown tool: {name}")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -281,6 +281,11 @@ async def test_execute_tool_dispatches_order_tools():
|
|||
result = await main.execute_tool("update_order_status", {"order_id": 1, "status": "Erledigt"})
|
||||
assert json.loads(result) == {"id": 1, "status": "Erledigt"}
|
||||
|
||||
with patch.object(main, "update_order", new=AsyncMock(return_value={"id": 1, "customer": "Neu"})) as mock_update:
|
||||
result = await main.execute_tool("update_order", {"order_id": 1, "customer": "Neu"})
|
||||
assert json.loads(result) == {"id": 1, "customer": "Neu"}
|
||||
mock_update.assert_called_once_with(1, "Neu", None, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_chat_completion_includes_order_tools_and_instructions():
|
||||
|
|
@ -345,3 +350,78 @@ def test_orders_endpoint_returns_503_on_deck_error(client):
|
|||
with patch.object(main, "list_orders", new=AsyncMock(side_effect=RuntimeError("Deck down"))):
|
||||
response = client.get("/api/v1/orders", headers=HEADERS)
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
_STACKS_WITH_CARD_55 = [
|
||||
{"id": 1, "title": "Offen", "cards": [
|
||||
{"id": 55, "title": "Feuerwehr Onza", "description": "Helme", "duedate": None, "type": "plain", "order": 999}
|
||||
]},
|
||||
{"id": 2, "title": "In Arbeit", "cards": []},
|
||||
{"id": 3, "title": "Erledigt", "cards": []},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_order_merges_description_and_keeps_other_fields():
|
||||
with patch.object(main, "_ensure_deck_board", new=AsyncMock(return_value=_BOARD)), patch.object(
|
||||
main, "_deck_request", new=AsyncMock(return_value=_STACKS_WITH_CARD_55)
|
||||
) as mock_req:
|
||||
order = await main.update_order(55, description="20x Handschuhe Groesse L")
|
||||
|
||||
assert order == {
|
||||
"id": 55,
|
||||
"customer": "Feuerwehr Onza",
|
||||
"description": "20x Handschuhe Groesse L",
|
||||
"status": "Offen",
|
||||
"due_date": None,
|
||||
}
|
||||
put_calls = [c for c in mock_req.call_args_list if c.args[0] == "PUT"]
|
||||
assert len(put_calls) == 1
|
||||
_, path, body = put_calls[0].args
|
||||
assert path == "/boards/10/stacks/1/cards/55"
|
||||
assert body["title"] == "Feuerwehr Onza"
|
||||
assert body["description"] == "20x Handschuhe Groesse L"
|
||||
assert "duedate" not in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_order_localizes_new_due_date():
|
||||
with patch.object(main, "_ensure_deck_board", new=AsyncMock(return_value=_BOARD)), patch.object(
|
||||
main, "_deck_request", new=AsyncMock(return_value=_STACKS_WITH_CARD_55)
|
||||
) as mock_req:
|
||||
order = await main.update_order(55, due_date="2026-10-01")
|
||||
|
||||
assert order["due_date"] == datetime(2026, 10, 1, tzinfo=main.CALENDAR_TIMEZONE).isoformat()
|
||||
put_calls = [c for c in mock_req.call_args_list if c.args[0] == "PUT"]
|
||||
_, _, body = put_calls[0].args
|
||||
assert body["duedate"] == datetime(2026, 10, 1, tzinfo=main.CALENDAR_TIMEZONE).isoformat()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_order_keeps_existing_due_date_when_not_given():
|
||||
stacks_with_due_date = [
|
||||
{"id": 1, "title": "Offen", "cards": [
|
||||
{"id": 55, "title": "A", "description": "B", "duedate": "2026-09-20T00:00:00+02:00", "type": "plain", "order": 999}
|
||||
]},
|
||||
{"id": 2, "title": "In Arbeit", "cards": []},
|
||||
{"id": 3, "title": "Erledigt", "cards": []},
|
||||
]
|
||||
with patch.object(main, "_ensure_deck_board", new=AsyncMock(return_value=_BOARD)), patch.object(
|
||||
main, "_deck_request", new=AsyncMock(return_value=stacks_with_due_date)
|
||||
) as mock_req:
|
||||
order = await main.update_order(55, customer="Neuer Name")
|
||||
|
||||
assert order["due_date"] == "2026-09-20T00:00:00+02:00"
|
||||
put_calls = [c for c in mock_req.call_args_list if c.args[0] == "PUT"]
|
||||
_, _, body = put_calls[0].args
|
||||
assert body["duedate"] == "2026-09-20T00:00:00+02:00"
|
||||
assert body["title"] == "Neuer Name"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_order_raises_when_card_not_found():
|
||||
with patch.object(main, "_ensure_deck_board", new=AsyncMock(return_value=_BOARD)), patch.object(
|
||||
main, "_deck_request", new=AsyncMock(return_value=_STACKS_WITH_CARD_55)
|
||||
):
|
||||
with pytest.raises(ValueError):
|
||||
await main.update_order(999, description="X")
|
||||
|
|
|
|||
Loading…
Reference in New Issue