diff --git a/Claude outputs/main.py b/Claude outputs/main.py index 918bf92..67eaa6b 100644 --- a/Claude outputs/main.py +++ b/Claude outputs/main.py @@ -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}") diff --git a/Claude outputs/tests/test_orders.py b/Claude outputs/tests/test_orders.py index 2b23e8b..8303354 100644 --- a/Claude outputs/tests/test_orders.py +++ b/Claude outputs/tests/test_orders.py @@ -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")