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>
736 lines
28 KiB
Python
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"},
|
|
)
|