Files
Chris CoutinhoandClaude Opus 4.8 d887181307 docs(deck): document assignedUsers preservation on cross-board move
Round-6 review: the docstrings listed preserved fields but omitted
assignedUsers. Verified empirically (Deck 1.15.9) that the update route's
board-change handling only remaps labels and leaves user assignments
untouched, so assignees carry over. Documented in both the client and MCP
tool docstrings, with the caveat that an assignee lacking access to the
target board stays assigned but cannot act on the card. Added
test_move_card_to_board_preserves_assigned_users to lock it in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 00:12:35 +02:00

736 lines
28 KiB
Python

import logging
from typing import Any, Dict, List, Optional
from httpx import HTTPStatusError, RequestError
from nextcloud_mcp_server.client.base import BaseNextcloudClient
from nextcloud_mcp_server.models.deck import (
DeckACL,
DeckAttachment,
DeckBoard,
DeckCard,
DeckComment,
DeckConfig,
DeckLabel,
DeckSession,
DeckStack,
)
logger = logging.getLogger(__name__)
class DeckClient(BaseNextcloudClient):
"""Client for Nextcloud Deck app operations."""
app_name = "deck"
def _get_deck_headers(
self, additional_headers: Optional[Dict[str, str]] = None
) -> Dict[str, str]:
"""Get standard headers required for Deck API calls."""
headers = {"OCS-APIRequest": "true", "Content-Type": "application/json"}
if additional_headers:
headers.update(additional_headers)
return headers
# Boards
async def get_boards(
self, details: bool = False, if_modified_since: Optional[str] = None
) -> List[DeckBoard]:
additional_headers = {}
if if_modified_since:
additional_headers["If-Modified-Since"] = if_modified_since
headers = self._get_deck_headers(additional_headers)
params = {"details": "true"} if details else {}
response = await self._make_request(
"GET", "/apps/deck/api/v1.0/boards", headers=headers, params=params
)
return [DeckBoard(**board) for board in response.json()]
async def create_board(self, title: str, color: str) -> DeckBoard:
json_data = {"title": title, "color": color}
headers = self._get_deck_headers()
response = await self._make_request(
"POST", "/apps/deck/api/v1.0/boards", json=json_data, headers=headers
)
return DeckBoard(**response.json())
async def get_board(self, board_id: int) -> DeckBoard:
headers = self._get_deck_headers()
response = await self._make_request(
"GET", f"/apps/deck/api/v1.0/boards/{board_id}", headers=headers
)
return DeckBoard(**response.json())
async def update_board(
self,
board_id: int,
title: Optional[str] = None,
color: Optional[str] = None,
archived: Optional[bool] = None,
) -> None:
json_data = {}
if title is not None:
json_data["title"] = title
if color is not None:
json_data["color"] = color
if archived is not None:
json_data["archived"] = archived
headers = self._get_deck_headers()
await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}",
json=json_data,
headers=headers,
)
async def delete_board(self, board_id: int) -> None:
headers = self._get_deck_headers()
await self._make_request(
"DELETE", f"/apps/deck/api/v1.0/boards/{board_id}", headers=headers
)
async def undo_delete_board(self, board_id: int) -> None:
headers = self._get_deck_headers()
await self._make_request(
"POST",
f"/apps/deck/api/v1.0/boards/{board_id}/undo_delete",
headers=headers,
)
async def add_acl_rule(
self,
board_id: int,
type: int,
participant: str,
permission_edit: bool,
permission_share: bool,
permission_manage: bool,
) -> DeckACL:
json_data = {
"type": type,
"participant": participant,
"permissionEdit": permission_edit,
"permissionShare": permission_share,
"permissionManage": permission_manage,
}
headers = self._get_deck_headers()
response = await self._make_request(
"POST",
f"/apps/deck/api/v1.0/boards/{board_id}/acl",
json=json_data,
headers=headers,
)
return DeckACL(**response.json())
async def update_acl_rule(
self,
board_id: int,
acl_id: int,
permission_edit: Optional[bool] = None,
permission_share: Optional[bool] = None,
permission_manage: Optional[bool] = None,
) -> None:
json_data = {}
if permission_edit is not None:
json_data["permissionEdit"] = permission_edit
if permission_share is not None:
json_data["permissionShare"] = permission_share
if permission_manage is not None:
json_data["permissionManage"] = permission_manage
headers = self._get_deck_headers()
await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}/acl/{acl_id}",
json=json_data,
headers=headers,
)
async def delete_acl_rule(self, board_id: int, acl_id: int) -> None:
headers = self._get_deck_headers()
await self._make_request(
"DELETE",
f"/apps/deck/api/v1.0/boards/{board_id}/acl/{acl_id}",
headers=headers,
)
async def clone_board(
self,
board_id: int,
with_cards: bool = False,
with_assignments: bool = False,
with_labels: bool = False,
with_due_date: bool = False,
move_cards_to_left_stack: bool = False,
restore_archived_cards: bool = False,
) -> DeckBoard:
json_data = {
"withCards": with_cards,
"withAssignments": with_assignments,
"withLabels": with_labels,
"withDueDate": with_due_date,
"moveCardsToLeftStack": move_cards_to_left_stack,
"restoreArchivedCards": restore_archived_cards,
}
response = await self._make_request(
"POST", f"/apps/deck/api/v1.0/boards/{board_id}/clone", json=json_data
)
return DeckBoard(**response.json())
# Stacks
async def get_stacks(
self, board_id: int, if_modified_since: Optional[str] = None
) -> List[DeckStack]:
additional_headers = {}
if if_modified_since:
additional_headers["If-Modified-Since"] = if_modified_since
headers = self._get_deck_headers(additional_headers)
response = await self._make_request(
"GET", f"/apps/deck/api/v1.0/boards/{board_id}/stacks", headers=headers
)
return [DeckStack(**stack) for stack in response.json()]
async def get_archived_stacks(self, board_id: int) -> List[DeckStack]:
response = await self._make_request(
"GET", f"/apps/deck/api/v1.0/boards/{board_id}/stacks/archived"
)
return [DeckStack(**stack) for stack in response.json()]
async def get_stack(self, board_id: int, stack_id: int) -> DeckStack:
response = await self._make_request(
"GET", f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}"
)
return DeckStack(**response.json())
async def create_stack(self, board_id: int, title: str, order: int) -> DeckStack:
json_data = {"title": title, "order": order}
headers = self._get_deck_headers()
response = await self._make_request(
"POST",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks",
json=json_data,
headers=headers,
)
return DeckStack(**response.json())
async def update_stack(
self,
board_id: int,
stack_id: int,
title: Optional[str] = None,
order: Optional[int] = None,
) -> None:
json_data = {}
if title is not None:
json_data["title"] = title
if order is not None:
json_data["order"] = order
headers = self._get_deck_headers()
await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}",
json=json_data,
headers=headers,
)
async def delete_stack(self, board_id: int, stack_id: int) -> None:
await self._make_request(
"DELETE", f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}"
)
# Cards
async def get_card(self, board_id: int, stack_id: int, card_id: int) -> DeckCard:
headers = self._get_deck_headers()
response = await self._make_request(
"GET",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}",
headers=headers,
)
return DeckCard(**response.json())
async def create_card(
self,
board_id: int,
stack_id: int,
title: str,
type: str = "plain",
order: int = 999,
description: Optional[str] = None,
duedate: Optional[str] = None,
) -> DeckCard:
json_data = {
"title": title,
"type": type,
"order": order,
}
if description is not None:
json_data["description"] = description
if duedate is not None:
json_data["duedate"] = duedate
headers = self._get_deck_headers()
response = await self._make_request(
"POST",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards",
json=json_data,
headers=headers,
)
return DeckCard(**response.json())
async def update_card(
self,
board_id: int,
stack_id: int,
card_id: int,
title: Optional[str] = None,
description: Optional[str] = None,
type: Optional[str] = None,
owner: Optional[str] = None,
order: Optional[int] = None,
duedate: Optional[str] = None,
archived: Optional[bool] = None,
done: Optional[str] = None,
) -> None:
# Deck PUT API is a full replacement - all required fields must be sent.
# Fetch current card to preserve values for fields not being updated.
current_card = await self.get_card(board_id, stack_id, card_id)
# Build payload with required fields always included
json_data = {
# Title is required by the API
"title": title if title is not None else current_card.title,
# Type is required by the API
"type": type if type is not None else current_card.type,
# Owner is required by the API (model validator ensures it's a string)
"owner": owner if owner is not None else current_card.owner,
# Description must be sent to preserve it (PUT clears omitted fields)
"description": description
if description is not None
else (current_card.description or ""),
}
if order is not None:
json_data["order"] = order
if duedate is not None:
json_data["duedate"] = duedate
if archived is not None:
json_data["archived"] = archived
if done is not None:
json_data["done"] = done
headers = self._get_deck_headers()
await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}",
json=json_data,
headers=headers,
)
async def delete_card(self, board_id: int, stack_id: int, card_id: int) -> None:
headers = self._get_deck_headers()
await self._make_request(
"DELETE",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}",
headers=headers,
)
async def archive_card(self, board_id: int, stack_id: int, card_id: int) -> None:
await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/archive",
)
async def unarchive_card(self, board_id: int, stack_id: int, card_id: int) -> None:
await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/unarchive",
)
async def assign_label_to_card(
self, board_id: int, stack_id: int, card_id: int, label_id: int
) -> None:
json_data = {"labelId": label_id}
await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/assignLabel",
json=json_data,
)
async def remove_label_from_card(
self, board_id: int, stack_id: int, card_id: int, label_id: int
) -> None:
json_data = {"labelId": label_id}
await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/removeLabel",
json=json_data,
)
async def assign_user_to_card(
self, board_id: int, stack_id: int, card_id: int, user_id: str
) -> None:
json_data = {"userId": user_id}
await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/assignUser",
json=json_data,
)
async def unassign_user_from_card(
self, board_id: int, stack_id: int, card_id: int, user_id: str
) -> None:
json_data = {"userId": user_id}
await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/unassignUser",
json=json_data,
)
async def reorder_card(
self,
board_id: int,
stack_id: int,
card_id: int,
order: int,
target_stack_id: int,
) -> None:
# Reorder is intentionally restricted to moves *within* a single board.
# Deck's reorder route (CardService::reorder) only reassigns stackId and
# does NOT remap board-scoped labels, so handing it a stack on another
# board silently orphans the card's labels (they keep the old boardId).
# Cross-board moves must go through move_card_to_board(), which uses the
# card-update route (CardService::update) and remaps labels by title.
# A same-stack reorder can't cross a board boundary, so skip the lookup.
if target_stack_id != stack_id:
board_stack_ids = {stack.id for stack in await self.get_stacks(board_id)}
if target_stack_id not in board_stack_ids:
raise ValueError(
f"target_stack_id {target_stack_id} is not a stack on board "
f"{board_id}; reorder_card only moves cards within a board. "
"Use move_card_to_board() to move a card to another board."
)
# Use the non-API route /cards/{cardId}/reorder which correctly reads
# stackId from the body. The API route /api/.../stacks/{stackId}/cards/...
# has a parameter conflict where URL stackId overrides body stackId.
# See: https://github.com/cbcoutinho/nextcloud-mcp-server/issues/469
json_data = {"order": order, "stackId": target_stack_id}
headers = self._get_deck_headers()
await self._make_request(
"PUT",
f"/apps/deck/cards/{card_id}/reorder",
json=json_data,
headers=headers,
)
async def move_card_to_board(
self,
source_board_id: int,
source_stack_id: int,
card_id: int,
target_board_id: int,
target_stack_id: int,
order: int = 0,
) -> DeckCard:
"""Move a card to a stack on a different (or the same) board.
Unlike :meth:`reorder_card`, this goes through the card-update route
(``CardService::update``). When the destination stack is on a different
board, Deck remaps the card's board-scoped labels by title — assigning
the same-titled label on the destination board, or cloning it there
when the user has board-manage permission — instead of leaving orphaned
labels behind. This mirrors Deck's native "Move/copy card" action. Card
identity (id, comments, attachments), ``archived`` state, the due date
and ``assignedUsers`` are preserved — the move does not re-validate
assignees against the destination board, so an assigned user without
access to the target board stays assigned but may not be able to act on
the card.
The internal ``/apps/deck/cards/{cardId}`` route is used: it reads the
target ``stackId`` from the body (the board/stack-scoped API route
instead binds ``stackId`` from the URL — issue #469 — and 404s for a
card that isn't already on that board). The controller derives ``owner``
from the session user and does not accept a ``done`` value, so a "done"
card is re-marked done after the move; Deck stamps the current time
there, so the original done timestamp is not preserved (a limitation of
what this route exposes).
The done re-mark is best-effort: the move itself has already committed
by then, so if that follow-up fails the card is left on the target
board without its done state (logged as a warning) rather than raising
and implying the move failed.
"""
# Validate the destination so target_board_id is load-bearing: the move
# itself is driven by stackId, so without this a stack on another board
# would relocate the card while the reported board is wrong.
target_stack_ids = {
stack.id for stack in await self.get_stacks(target_board_id)
}
if target_stack_id not in target_stack_ids:
raise ValueError(
f"target_stack_id {target_stack_id} is not a stack on target "
f"board {target_board_id}."
)
current = await self.get_card(source_board_id, source_stack_id, card_id)
json_data: dict[str, Any] = {
# The route placeholder is {cardId} but the controller reads the
# card id from the body, so it must be sent explicitly.
"id": card_id,
"title": current.title,
"type": current.type,
"stackId": target_stack_id,
"order": order,
"description": current.description or "",
# Sent explicitly as None when absent (update_card omits the key);
# both are equivalent here — the route reads duedate and there's
# nothing to clear on a card that never had one.
"duedate": current.duedate.isoformat() if current.duedate else None,
# 0 keeps the card live (a positive value would soft-delete it)
"deletedAt": current.deletedAt or 0,
}
headers = self._get_deck_headers()
response = await self._make_request(
"PUT",
f"/apps/deck/cards/{card_id}",
json=json_data,
headers=headers,
)
moved = DeckCard(**response.json())
# This route clears `done`; restore the done *state* if the card had it
# (the timestamp is refreshed to now — see the docstring note). The move
# has already committed, so this is best-effort: on failure, warn with
# the card's new location rather than raising as if the move failed.
if current.done is not None:
try:
await self._make_request(
"PUT", f"/apps/deck/cards/{card_id}/done", headers=headers
)
moved = await self.get_card(target_board_id, target_stack_id, card_id)
except (HTTPStatusError, RequestError) as e:
logger.warning(
"Card %s moved to board %s stack %s but restoring done "
"state failed: %s",
card_id,
target_board_id,
target_stack_id,
e,
)
return moved
# Labels
async def get_label(self, board_id: int, label_id: int) -> DeckLabel:
headers = self._get_deck_headers()
response = await self._make_request(
"GET",
f"/apps/deck/api/v1.0/boards/{board_id}/labels/{label_id}",
headers=headers,
)
return DeckLabel(**response.json())
async def create_label(self, board_id: int, title: str, color: str) -> DeckLabel:
json_data = {"title": title, "color": color}
headers = self._get_deck_headers()
response = await self._make_request(
"POST",
f"/apps/deck/api/v1.0/boards/{board_id}/labels",
json=json_data,
headers=headers,
)
return DeckLabel(**response.json())
async def update_label(
self,
board_id: int,
label_id: int,
title: Optional[str] = None,
color: Optional[str] = None,
) -> None:
json_data = {}
if title is not None:
json_data["title"] = title
if color is not None:
json_data["color"] = color
await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}/labels/{label_id}",
json=json_data,
)
async def delete_label(self, board_id: int, label_id: int) -> None:
await self._make_request(
"DELETE", f"/apps/deck/api/v1.0/boards/{board_id}/labels/{label_id}"
)
# Attachments
async def get_attachments(
self, board_id: int, stack_id: int, card_id: int
) -> List[DeckAttachment]:
response = await self._make_request(
"GET",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments",
)
return [DeckAttachment(**attachment) for attachment in response.json()]
async def get_attachment_file(
self, board_id: int, stack_id: int, card_id: int, attachment_id: int
) -> Any:
# This endpoint returns the raw file, so we return the raw response content
response = await self._make_request(
"GET",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments/{attachment_id}",
)
return response.content
async def upload_attachment(
self,
board_id: int,
stack_id: int,
card_id: int,
file_data: bytes,
file_type: str = "file",
) -> DeckAttachment:
# The API expects binary data directly, not JSON
headers = {"Content-Type": "application/octet-stream"}
params = {"type": file_type}
response = await self._make_request(
"POST",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments",
headers=headers,
params=params,
data=file_data,
)
return DeckAttachment(**response.json())
async def update_attachment(
self,
board_id: int,
stack_id: int,
card_id: int,
attachment_id: int,
file_data: bytes,
file_type: str = "deck_file",
) -> DeckAttachment:
headers = {"Content-Type": "application/octet-stream"}
params = {"type": file_type}
response = await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments/{attachment_id}",
headers=headers,
params=params,
data=file_data,
)
return DeckAttachment(**response.json())
async def delete_attachment(
self, board_id: int, stack_id: int, card_id: int, attachment_id: int
) -> None:
await self._make_request(
"DELETE",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments/{attachment_id}",
)
async def restore_attachment(
self, board_id: int, stack_id: int, card_id: int, attachment_id: int
) -> None:
await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments/{attachment_id}/restore",
)
# OCS API Endpoints (Config, Comments, Sessions)
async def get_config(self) -> DeckConfig:
headers = {"OCS-APIRequest": "true", "Accept": "application/json"}
response = await self._make_request(
"GET", "/ocs/v2.php/apps/deck/api/v1.0/config", headers=headers
)
return DeckConfig(**response.json()["ocs"]["data"])
async def set_config_value(
self, key: str, value: Any, board_id: Optional[int] = None
) -> Any:
path = f"/ocs/v2.php/apps/deck/api/v1.0/config/{key}"
if board_id:
path = f"/ocs/v2.php/apps/deck/api/v1.0/config/board:{board_id}:{key}"
json_data = {"value": value}
response = await self._make_request(
"POST",
path,
json=json_data,
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
return response.json()["ocs"]["data"]
async def get_comments(
self, card_id: int, limit: int = 20, offset: int = 0
) -> List[DeckComment]:
params = {"limit": limit, "offset": offset}
response = await self._make_request(
"GET",
f"/ocs/v2.php/apps/deck/api/v1.0/cards/{card_id}/comments",
params=params,
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
return [DeckComment(**comment) for comment in response.json()["ocs"]["data"]]
async def create_comment(
self, card_id: int, message: str, parent_id: Optional[int] = None
) -> DeckComment:
json_data = {"message": message}
if parent_id is not None:
json_data["parentId"] = parent_id
response = await self._make_request(
"POST",
f"/ocs/v2.php/apps/deck/api/v1.0/cards/{card_id}/comments",
json=json_data,
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
return DeckComment(**response.json()["ocs"]["data"])
async def update_comment(
self, card_id: int, comment_id: int, message: str
) -> DeckComment:
json_data = {"message": message}
response = await self._make_request(
"PUT",
f"/ocs/v2.php/apps/deck/api/v1.0/cards/{card_id}/comments/{comment_id}",
json=json_data,
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
return DeckComment(**response.json()["ocs"]["data"])
async def delete_comment(self, card_id: int, comment_id: int) -> None:
await self._make_request(
"DELETE",
f"/ocs/v2.php/apps/deck/api/v1.0/cards/{card_id}/comments/{comment_id}",
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
async def create_session(self, board_id: int) -> DeckSession:
json_data = {"boardId": board_id}
response = await self._make_request(
"PUT",
"/ocs/v2.php/apps/deck/api/v1.0/session/create",
json=json_data,
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
return DeckSession(**response.json()["ocs"]["data"])
async def sync_session(self, board_id: int, token: str) -> None:
json_data = {"boardId": board_id, "token": token}
await self._make_request(
"POST",
"/ocs/v2.php/apps/deck/api/v1.0/session/sync",
json=json_data,
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
async def close_session(self, board_id: int, token: str) -> None:
json_data = {"boardId": board_id, "token": token}
await self._make_request(
"POST",
"/ocs/v2.php/apps/deck/api/v1.0/session/close",
json=json_data,
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)