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:
Jonny 2026-09-13 19:38:49 +02:00
parent bd73effc73
commit 69bf530ff3
2 changed files with 151 additions and 14 deletions

View File

@ -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}")

View File

@ -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")