239 lines
9.4 KiB
Python
239 lines
9.4 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""ClickUp API service (OAuth or personal token via UserConnection)."""
|
|
|
|
import json
|
|
import logging
|
|
import asyncio
|
|
from typing import Any, Callable, Dict, List, Optional, Union
|
|
|
|
import aiohttp
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_CLICKUP_API_BASE = "https://api.clickup.com/api/v2"
|
|
|
|
|
|
def clickup_authorization_header(token: str) -> str:
|
|
"""ClickUp: personal tokens are `pk_...` without Bearer; OAuth uses Bearer."""
|
|
t = (token or "").strip()
|
|
if t.startswith("pk_"):
|
|
return t
|
|
return f"Bearer {t}"
|
|
|
|
|
|
class ClickupService:
|
|
"""ClickUp REST API v2 — teams, hierarchy, lists as tables (tasks + custom fields)."""
|
|
|
|
def __init__(self, context, get_service: Callable[[str], Any]):
|
|
self._context = context
|
|
self._get_service = get_service
|
|
self.accessToken: Optional[str] = None
|
|
|
|
def setAccessTokenFromConnection(self, userConnection) -> bool:
|
|
"""Load OAuth/personal token from SecurityService for this UserConnection."""
|
|
try:
|
|
if not userConnection:
|
|
logger.error("UserConnection is required to set access token")
|
|
return False
|
|
if isinstance(userConnection, dict):
|
|
connection_id = userConnection.get("id")
|
|
else:
|
|
connection_id = getattr(userConnection, "id", None)
|
|
if not connection_id:
|
|
logger.error("UserConnection must have an 'id' field")
|
|
return False
|
|
security = self._get_service("security")
|
|
if not security:
|
|
logger.error("Security service not available for token access")
|
|
return False
|
|
token = security.getFreshToken(connection_id)
|
|
if not token:
|
|
logger.error(f"No token found for connection {connection_id}")
|
|
return False
|
|
self.accessToken = token.tokenAccess
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Error setting ClickUp access token: {e}")
|
|
return False
|
|
|
|
def setAccessToken(self, token: str) -> None:
|
|
"""Set token directly (e.g. connector adapter)."""
|
|
self.accessToken = token
|
|
|
|
async def _request(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
*,
|
|
params: Optional[Dict[str, Any]] = None,
|
|
json_body: Optional[Dict[str, Any]] = None,
|
|
data: Optional[aiohttp.FormData] = None,
|
|
) -> Union[Dict[str, Any], List[Any], bytes, None]:
|
|
if not self.accessToken:
|
|
return {"error": "Access token is not set. Call setAccessTokenFromConnection first."}
|
|
url = f"{_CLICKUP_API_BASE}/{path.lstrip('/')}"
|
|
headers: Dict[str, str] = {
|
|
"Authorization": clickup_authorization_header(self.accessToken),
|
|
}
|
|
if json_body is not None:
|
|
headers["Content-Type"] = "application/json"
|
|
|
|
timeout = aiohttp.ClientTimeout(total=60)
|
|
try:
|
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
kwargs: Dict[str, Any] = {"headers": headers, "params": params}
|
|
if json_body is not None:
|
|
kwargs["json"] = json_body
|
|
if data is not None:
|
|
kwargs["data"] = data
|
|
|
|
async with session.request(method.upper(), url, **kwargs) as resp:
|
|
if resp.status == 204:
|
|
return {}
|
|
text = await resp.text()
|
|
if resp.status >= 400:
|
|
# 404 on GET is common (wrong id / preview) — avoid ERROR noise in logs
|
|
log = logger.warning if resp.status == 404 else logger.error
|
|
log(f"ClickUp API {method} {url} -> {resp.status}: {text[:500]}")
|
|
return {"error": f"HTTP {resp.status}", "body": text}
|
|
if not text:
|
|
return {}
|
|
try:
|
|
return json.loads(text)
|
|
except Exception:
|
|
return {"raw": text}
|
|
except asyncio.TimeoutError:
|
|
return {"error": f"ClickUp API timeout: {path}"}
|
|
except Exception as e:
|
|
logger.error(f"ClickUp API error: {e}")
|
|
return {"error": str(e)}
|
|
|
|
async def requestRaw(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
*,
|
|
params: Optional[Dict[str, Any]] = None,
|
|
json_body: Optional[Dict[str, Any]] = None,
|
|
) -> Union[Dict[str, Any], List[Any], None]:
|
|
"""Escape hatch: call any v2 path under /api/v2 (path without leading /api/v2)."""
|
|
return await self._request(method, path, params=params, json_body=json_body)
|
|
|
|
# --- Teams / user ---
|
|
|
|
async def getAuthorizedUser(self) -> Dict[str, Any]:
|
|
return await self._request("GET", "/user")
|
|
|
|
async def getAuthorizedTeams(self) -> Dict[str, Any]:
|
|
return await self._request("GET", "/team")
|
|
|
|
async def getTeam(self, team_id: str) -> Dict[str, Any]:
|
|
return await self._request("GET", f"/team/{team_id}")
|
|
|
|
# --- Hierarchy ---
|
|
|
|
async def getSpaces(self, team_id: str) -> Dict[str, Any]:
|
|
return await self._request("GET", f"/team/{team_id}/space")
|
|
|
|
async def getSpace(self, space_id: str) -> Dict[str, Any]:
|
|
return await self._request("GET", f"/space/{space_id}")
|
|
|
|
async def getFolders(self, space_id: str) -> Dict[str, Any]:
|
|
return await self._request("GET", f"/space/{space_id}/folder")
|
|
|
|
async def getFolder(self, folder_id: str) -> Dict[str, Any]:
|
|
return await self._request("GET", f"/folder/{folder_id}")
|
|
|
|
async def getListsInFolder(self, folder_id: str) -> Dict[str, Any]:
|
|
return await self._request("GET", f"/folder/{folder_id}/list")
|
|
|
|
async def getFolderlessLists(self, space_id: str) -> Dict[str, Any]:
|
|
return await self._request("GET", f"/space/{space_id}/list")
|
|
|
|
async def getList(self, list_id: str) -> Dict[str, Any]:
|
|
return await self._request("GET", f"/list/{list_id}")
|
|
|
|
async def getListFields(self, list_id: str) -> Dict[str, Any]:
|
|
return await self._request("GET", f"/list/{list_id}/field")
|
|
|
|
# --- Tasks (rows) ---
|
|
|
|
async def getTasksInList(
|
|
self,
|
|
list_id: str,
|
|
*,
|
|
page: int = 0,
|
|
include_closed: bool = False,
|
|
subtasks: bool = True,
|
|
dateCreatedGt: Optional[int] = None,
|
|
dateCreatedLt: Optional[int] = None,
|
|
dateUpdatedGt: Optional[int] = None,
|
|
dateUpdatedLt: Optional[int] = None,
|
|
customFields: Optional[List[Dict[str, Any]]] = None,
|
|
) -> Dict[str, Any]:
|
|
params: Dict[str, Any] = {
|
|
"page": page,
|
|
"subtasks": str(subtasks).lower(),
|
|
"include_closed": str(include_closed).lower(),
|
|
}
|
|
if dateCreatedGt is not None:
|
|
params["date_created_gt"] = dateCreatedGt
|
|
if dateCreatedLt is not None:
|
|
params["date_created_lt"] = dateCreatedLt
|
|
if dateUpdatedGt is not None:
|
|
params["date_updated_gt"] = dateUpdatedGt
|
|
if dateUpdatedLt is not None:
|
|
params["date_updated_lt"] = dateUpdatedLt
|
|
if customFields:
|
|
import json as _json
|
|
params["custom_fields"] = _json.dumps(customFields)
|
|
return await self._request("GET", f"/list/{list_id}/task", params=params)
|
|
|
|
async def getTask(self, task_id: str, *, include_subtasks: bool = True) -> Dict[str, Any]:
|
|
params = {"include_subtasks": str(include_subtasks).lower()}
|
|
return await self._request("GET", f"/task/{task_id}", params=params)
|
|
|
|
async def createTask(self, list_id: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
|
return await self._request("POST", f"/list/{list_id}/task", json_body=body)
|
|
|
|
async def updateTask(self, task_id: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
|
return await self._request("PUT", f"/task/{task_id}", json_body=body)
|
|
|
|
async def deleteTask(self, task_id: str) -> Dict[str, Any]:
|
|
return await self._request("DELETE", f"/task/{task_id}")
|
|
|
|
async def searchTeamTasks(
|
|
self,
|
|
team_id: str,
|
|
*,
|
|
query: str,
|
|
page: int = 0,
|
|
) -> Dict[str, Any]:
|
|
"""Search tasks in a workspace (team)."""
|
|
params = {"query": query, "page": page}
|
|
return await self._request("GET", f"/team/{team_id}/task", params=params)
|
|
|
|
async def uploadTaskAttachment(self, task_id: str, file_bytes: bytes, file_name: str) -> Dict[str, Any]:
|
|
"""Upload a file attachment to a task (multipart)."""
|
|
if not self.accessToken:
|
|
return {"error": "Access token is not set."}
|
|
url = f"{_CLICKUP_API_BASE}/task/{task_id}/attachment"
|
|
headers = {"Authorization": clickup_authorization_header(self.accessToken)}
|
|
data = aiohttp.FormData()
|
|
data.add_field(
|
|
"attachment",
|
|
file_bytes,
|
|
filename=file_name,
|
|
content_type="application/octet-stream",
|
|
)
|
|
timeout = aiohttp.ClientTimeout(total=120)
|
|
try:
|
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
async with session.post(url, headers=headers, data=data) as resp:
|
|
text = await resp.text()
|
|
if resp.status >= 400:
|
|
return {"error": f"HTTP {resp.status}", "body": text}
|
|
return json.loads(text) if text else {}
|
|
except Exception as e:
|
|
return {"error": str(e)}
|