From 69ca20f22547cf7ec7f20d842f85619667d9759e Mon Sep 17 00:00:00 2001 From: PieterCK Date: Tue, 2 Apr 2024 03:40:04 +0000 Subject: [PATCH] integrations: Add ClickUp integration. Creates an incoming webhook integration for ClickUp. The main use case is getting notifications when new ClickUp items such as task, list, folder, space, goals are created, updated or deleted. Fixes zulip#26529. --- zerver/lib/integrations.py | 1 + zerver/webhooks/clickup/__init__.py | 45 ++++++ zerver/webhooks/clickup/api_endpoints.py | 74 +++++++-- .../clickup/callback_fixtures/get_folder.json | 24 +-- .../clickup/callback_fixtures/get_goal.json | 60 +++---- .../clickup/callback_fixtures/get_list.json | 50 +++--- .../clickup/callback_fixtures/get_space.json | 86 +++++------ .../clickup/callback_fixtures/get_task.json | 70 ++++----- zerver/webhooks/clickup/doc.md | 13 +- zerver/webhooks/clickup/tests.py | 146 +++++++++++++++++- zerver/webhooks/clickup/view.py | 109 +++---------- 11 files changed, 419 insertions(+), 259 deletions(-) diff --git a/zerver/lib/integrations.py b/zerver/lib/integrations.py index 8a6d9b7ee66d56..357ee66a6d7fec 100644 --- a/zerver/lib/integrations.py +++ b/zerver/lib/integrations.py @@ -8,6 +8,7 @@ from django.utils.translation import gettext_lazy from django_stubs_ext import StrPromise from typing_extensions import TypeAlias + from zerver.lib.storage import static_path """This module declares all of the (documented) integrations available diff --git a/zerver/webhooks/clickup/__init__.py b/zerver/webhooks/clickup/__init__.py index e69de29bb2d1d6..e3acaa089e4ac8 100644 --- a/zerver/webhooks/clickup/__init__.py +++ b/zerver/webhooks/clickup/__init__.py @@ -0,0 +1,45 @@ +from enum import Enum +from typing import List + + +class ConstantVariable(Enum): + @classmethod + def as_list(cls) -> List[str]: + return [item.value for item in cls] + + +class EventItemType(ConstantVariable): + TASK: str = "task" + LIST: str = "list" + FOLDER: str = "folder" + GOAL: str = "goal" + SPACE: str = "space" + + +class EventAcion(ConstantVariable): + CREATED: str = "Created" + UPDATED: str = "Updated" + DELETED: str = "Deleted" + + +class SimpleFields(ConstantVariable): + # Events with identical payload format + PRIORITY: str = "priority" + STATUS: str = "status" + + +class SpecialFields(ConstantVariable): + # Event with unique payload + NAME: str = "name" + ASSIGNEE: str = "assignee_add" + COMMENT: str = "comment" + DUE_DATE: str = "due_date" + MOVED: str = "section_moved" + TIME_ESTIMATE: str = "time_estimate" + TIME_SPENT: str = "time_spent" + + +class SpammyFields(ConstantVariable): + TAG: str = "tag" + TAG_REMOVED: str = "tag_removed" + UNASSIGN: str = "assignee_rem" diff --git a/zerver/webhooks/clickup/api_endpoints.py b/zerver/webhooks/clickup/api_endpoints.py index a74ab91ea70811..36ec6877b9f9fd 100644 --- a/zerver/webhooks/clickup/api_endpoints.py +++ b/zerver/webhooks/clickup/api_endpoints.py @@ -1,28 +1,78 @@ -from typing import Any, Dict +import re +from typing import Any, Dict, Optional, Union +from urllib.parse import urljoin import requests -from urllib.parse import urljoin +from django.utils.translation import gettext as _ +from typing_extensions import override + +from zerver.lib.exceptions import ErrorCode, WebhookError from zerver.lib.outgoing_http import OutgoingSession +from zerver.webhooks.clickup import EventItemType + + +class APIUnavailableCallBackError(WebhookError): + """Intended as an exception for when an integration + couldn't reach external API server when calling back + from Zulip app. + + Exception when callback request has timed out or received + connection error. + """ + code = ErrorCode.REQUEST_TIMEOUT + http_status_code = 200 + data_fields = ["webhook_name"] -class Error(Exception): - pass + def __init__(self) -> None: + super().__init__() + @staticmethod + @override + def msg_format() -> str: + return _("{webhook_name} integration couldn't reach an external API service; ignoring") -class APIUnavailableError(Error): - pass +class BadRequestCallBackError(WebhookError): + """Intended as an exception for when an integration + makes a bad request to external API server. -class BadRequestError(Error): - pass + Exception when callback request has an invalid format. + """ + + code = ErrorCode.BAD_REQUEST + http_status_code = 200 + data_fields = ["webhook_name", "error_detail"] + + def __init__(self, error_detail: Optional[Union[str, int]]) -> None: + super().__init__() + self.error_detail = error_detail + + @staticmethod + @override + def msg_format() -> str: + return _( + "{webhook_name} integration tries to make a bad outgoing request: {error_detail}; ignoring" + ) class ClickUpSession(OutgoingSession): def __init__(self, **kwargs: Any) -> None: - super().__init__(role="clickup", timeout=5, **kwargs) + super().__init__(role="clickup", timeout=5, **kwargs) # nocoverage + + +def verify_url_path(path: str) -> bool: + parts = path.split("/") + if len(parts) < 2 or parts[0] not in EventItemType.as_list() or parts[1] == "": + return False + pattern = r"^[a-zA-Z0-9_-]+$" + match = re.match(pattern, parts[1]) + return match is not None and match.group() == parts[1] def make_clickup_request(path: str, api_key: str) -> Dict[str, Any]: + if verify_url_path(path) is False: + raise BadRequestCallBackError("Invalid path") headers: Dict[str, str] = { "Content-Type": "application/json", "Authorization": api_key, @@ -35,10 +85,10 @@ def make_clickup_request(path: str, api_key: str) -> Dict[str, Any]: api_endpoint, ) response.raise_for_status() - except (requests.ConnectionError, requests.Timeout) as e: - raise APIUnavailableError from e + except (requests.ConnectionError, requests.Timeout): + raise APIUnavailableCallBackError except requests.HTTPError as e: - raise BadRequestError from e + raise BadRequestCallBackError(e.response.status_code) return response.json() diff --git a/zerver/webhooks/clickup/callback_fixtures/get_folder.json b/zerver/webhooks/clickup/callback_fixtures/get_folder.json index 6f873ac0ec94d1..f646c9fdac0520 100644 --- a/zerver/webhooks/clickup/callback_fixtures/get_folder.json +++ b/zerver/webhooks/clickup/callback_fixtures/get_folder.json @@ -1,14 +1,14 @@ { -"id": "457", -"name": "Lord Foldemort", -"orderindex": 0, -"override_statuses": false, -"hidden": false, -"space": { - "id": "789", - "name": "Space Name", - "access": true -}, -"task_count": "0", -"lists": [] + "id": "457", + "name": "Lord Foldemort", + "orderindex": 0, + "override_statuses": false, + "hidden": false, + "space": { + "id": "789", + "name": "Space Name", + "access": true + }, + "task_count": "0", + "lists": [] } diff --git a/zerver/webhooks/clickup/callback_fixtures/get_goal.json b/zerver/webhooks/clickup/callback_fixtures/get_goal.json index afcf3af54b617c..733317c1e2be02 100644 --- a/zerver/webhooks/clickup/callback_fixtures/get_goal.json +++ b/zerver/webhooks/clickup/callback_fixtures/get_goal.json @@ -1,33 +1,33 @@ { "goal": { - "id": "e53a033c-900e-462d-a849-4a216b06d930", - "name": "hat-trick", - "team_id": "512", - "date_created": "1568044355026", - "start_date": null, - "due_date": "1568036964079", - "description": "Updated Goal Description", - "private": false, - "archived": false, - "creator": 183, - "color": "#32a852", - "pretty_id": "6", - "multiple_owners": true, - "folder_id": null, - "members": [], - "owners": [ - { - "id": 182, - "username": "Pieter CK", - "email": "kwok.pieter@gmail.com", - "color": "#7b68ee", - "initials": "PK", - "profilePicture": "https://attachments-public.clickup.com/profilePictures/182_abc.jpg" - } - ], - "key_results": [], - "percent_completed": 0, - "history": [], - "pretty_url": "https://app.clickup.com/512/goals/6" + "id": "e53a033c-900e-462d-a849-4a216b06d930", + "name": "hat-trick", + "team_id": "512", + "date_created": "1568044355026", + "start_date": null, + "due_date": "1568036964079", + "description": "Updated Goal Description", + "private": false, + "archived": false, + "creator": 183, + "color": "#32a852", + "pretty_id": "6", + "multiple_owners": true, + "folder_id": null, + "members": [], + "owners": [ + { + "id": 182, + "username": "Pieter CK", + "email": "kwok.pieter@gmail.com", + "color": "#7b68ee", + "initials": "PK", + "profilePicture": "https://attachments-public.clickup.com/profilePictures/182_abc.jpg" + } + ], + "key_results": [], + "percent_completed": 0, + "history": [], + "pretty_url": "https://app.clickup.com/512/goals/6" } - } +} diff --git a/zerver/webhooks/clickup/callback_fixtures/get_list.json b/zerver/webhooks/clickup/callback_fixtures/get_list.json index 657f3cd1d6bf27..b23ba40f55f3ce 100644 --- a/zerver/webhooks/clickup/callback_fixtures/get_list.json +++ b/zerver/webhooks/clickup/callback_fixtures/get_list.json @@ -4,13 +4,13 @@ "orderindex": 1, "content": "Updated List Content", "status": { - "status": "red", - "color": "#e50000", - "hide_label": true + "status": "red", + "color": "#e50000", + "hide_label": true }, "priority": { - "priority": "high", - "color": "#f50000" + "priority": "high", + "color": "#f50000" }, "assignee": null, "due_date": "1567780450202", @@ -18,32 +18,32 @@ "start_date": null, "start_date_time": null, "folder": { - "id": "456", - "name": "Folder Name", - "hidden": false, - "access": true + "id": "456", + "name": "Folder Name", + "hidden": false, + "access": true }, "space": { - "id": "789", - "name": "Space Name", - "access": true + "id": "789", + "name": "Space Name", + "access": true }, "inbound_address": "add.task.124.ac725f.31518a6a-05bb-4997-92a6-1dcfe2f527ca@tasks.clickup.com", "archived": false, "override_statuses": false, "statuses": [ - { - "status": "to do", - "orderindex": 0, - "color": "#d3d3d3", - "type": "open" - }, - { - "status": "complete", - "orderindex": 1, - "color": "#6bc950", - "type": "closed" - } + { + "status": "to do", + "orderindex": 0, + "color": "#d3d3d3", + "type": "open" + }, + { + "status": "complete", + "orderindex": 1, + "color": "#6bc950", + "type": "closed" + } ], "permission_level": "create" - } +} diff --git a/zerver/webhooks/clickup/callback_fixtures/get_space.json b/zerver/webhooks/clickup/callback_fixtures/get_space.json index e12d535cdb0812..d19af504b23fb4 100644 --- a/zerver/webhooks/clickup/callback_fixtures/get_space.json +++ b/zerver/webhooks/clickup/callback_fixtures/get_space.json @@ -3,50 +3,50 @@ "name": "the Milky Way", "private": false, "statuses": [ - { - "status": "to do", - "type": "open", - "orderindex": 0, - "color": "#d3d3d3" - }, - { - "status": "complete", - "type": "closed", - "orderindex": 1, - "color": "#6bc950" - } + { + "status": "to do", + "type": "open", + "orderindex": 0, + "color": "#d3d3d3" + }, + { + "status": "complete", + "type": "closed", + "orderindex": 1, + "color": "#6bc950" + } ], "multiple_assignees": false, "features": { - "due_dates": { - "enabled": false, - "start_date": false, - "remap_due_dates": false, - "remap_closed_due_date": false - }, - "time_tracking": { - "enabled": false - }, - "tags": { - "enabled": false - }, - "time_estimates": { - "enabled": false - }, - "checklists": { - "enabled": true - }, - "custom_fields": { - "enabled": true - }, - "remap_dependencies": { - "enabled": false - }, - "dependency_warning": { - "enabled": false - }, - "portfolios": { - "enabled": false - } + "due_dates": { + "enabled": false, + "start_date": false, + "remap_due_dates": false, + "remap_closed_due_date": false + }, + "time_tracking": { + "enabled": false + }, + "tags": { + "enabled": false + }, + "time_estimates": { + "enabled": false + }, + "checklists": { + "enabled": true + }, + "custom_fields": { + "enabled": true + }, + "remap_dependencies": { + "enabled": false + }, + "dependency_warning": { + "enabled": false + }, + "portfolios": { + "enabled": false + } } - } +} diff --git a/zerver/webhooks/clickup/callback_fixtures/get_task.json b/zerver/webhooks/clickup/callback_fixtures/get_task.json index 3284ac99ff1c57..146db98e6ad868 100644 --- a/zerver/webhooks/clickup/callback_fixtures/get_task.json +++ b/zerver/webhooks/clickup/callback_fixtures/get_task.json @@ -6,30 +6,24 @@ "text_content": "string", "description": "string", "status": { - "status": "in progress", - "color": "#d3d3d3", - "orderindex": 1, - "type": "custom" + "status": "in progress", + "color": "#d3d3d3", + "orderindex": 1, + "type": "custom" }, "orderindex": "string", "date_created": "string", "date_updated": "string", "date_closed": "string", "creator": { - "id": 183, - "username": "Pieter CK", - "color": "#827718", - "profilePicture": "https://attachments-public.clickup.com/profilePictures/183_abc.jpg" + "id": 183, + "username": "Pieter CK", + "color": "#827718", + "profilePicture": "https://attachments-public.clickup.com/profilePictures/183_abc.jpg" }, - "assignees": [ - "string" - ], - "checklists": [ - "string" - ], - "tags": [ - "string" - ], + "assignees": ["string"], + "checklists": ["string"], + "tags": ["string"], "parent": "string", "priority": "string", "due_date": "string", @@ -37,33 +31,33 @@ "time_estimate": "string", "time_spent": "string", "custom_fields": [ - { - "id": "string", - "name": "string", - "type": "string", - "type_config": {}, - "date_created": "string", - "hide_from_guests": true, - "value": { - "id": 183, - "username": "Pieter CK", - "email": "kwok.pieter@gmail.com", - "color": "#7b68ee", - "initials": "PK", - "profilePicture": null - }, - "required": true - } + { + "id": "string", + "name": "string", + "type": "string", + "type_config": {}, + "date_created": "string", + "hide_from_guests": true, + "value": { + "id": 183, + "username": "Pieter CK", + "email": "kwok.pieter@gmail.com", + "color": "#7b68ee", + "initials": "PK", + "profilePicture": null + }, + "required": true + } ], "list": { - "id": "123" + "id": "123" }, "folder": { - "id": "456" + "id": "456" }, "space": { - "id": "789" + "id": "789" }, "url": "https://app.clickup.com/XXXXXXXX/home", "markdown_description": "string" - } +} diff --git a/zerver/webhooks/clickup/doc.md b/zerver/webhooks/clickup/doc.md index cd29c5744c970e..1db478df6f2e9e 100644 --- a/zerver/webhooks/clickup/doc.md +++ b/zerver/webhooks/clickup/doc.md @@ -16,7 +16,7 @@ Get Zulip notifications from your ClickUp space! You're now going to need to run a ClickUp Integration configuration script from a computer (any computer) connected to the internet. It won't make any changes to the computer. - + 1. Make sure you have a working copy of Python. If you're running macOS or Linux, you very likely already do. If you're running Windows you may or may not. If you don't have Python, follow the @@ -34,11 +34,10 @@ Get Zulip notifications from your ClickUp space! ``. * **Client ID & Client Secret**: Please follow the instructions below: - - Go to and click **Create an App** button. - After that, you will be prompted for Redirect URL(s). You must enter your zulip app URL. - e.g. `YourZulipApp.com`. + e.g. `YourZulipApp.com`. - Finally, note down the **Client ID** and **Client Secret** @@ -50,17 +49,17 @@ Get Zulip notifications from your ClickUp space! --clickup-client-id CLIENT_ID \ --clickup-client-secret CLIENT_SECRET ``` - + The `zulip-clickup.py` script only needs to be run once, and can be run on any computer with python. -1. Follow the instructions given by the script. +1. Follow the instructions given by the script. **Note:** You will be prompted for the **integration url** you just generated in step 2 and watch your browser since you will be redirected to a ClickUp authorization page to proceed. -1. You can delete `zulip-clickup.py` from your computer if you'd like or run it again to +1. You can delete `zulip-clickup.py` from your computer if you'd like or run it again to reconfigure your ClickUp integration. -[2]: https://raw.githubusercontent.com/zulip/python-zulip-api/main/zulip/integrations/trello/zulip_trello.py +[2]: https://raw.githubusercontent.com/zulip/python-zulip-api/main/zulip/integrations/clickup/zulip_clickup.py {!congrats.md!} diff --git a/zerver/webhooks/clickup/tests.py b/zerver/webhooks/clickup/tests.py index 743c6d73a9627c..95f9b7cfdbd6d5 100644 --- a/zerver/webhooks/clickup/tests.py +++ b/zerver/webhooks/clickup/tests.py @@ -1,7 +1,25 @@ -from zerver.lib.test_classes import WebhookTestCase -from unittest.mock import patch - import json +from typing import Any, Callable, Dict +from unittest.mock import MagicMock, patch + +from django.http import HttpRequest, HttpResponse +from requests.exceptions import ConnectionError, HTTPError, Timeout + +from zerver.decorator import webhook_view +from zerver.lib.test_classes import WebhookTestCase +from zerver.lib.test_helpers import HostRequestMock +from zerver.lib.users import get_api_key +from zerver.models import UserProfile +from zerver.webhooks.clickup.api_endpoints import ( + APIUnavailableCallBackError, + BadRequestCallBackError, + get_folder, + get_goal, + get_list, + get_space, + get_task, + make_clickup_request, +) EXPECTED_TOPIC = "ClickUp Notification" @@ -341,3 +359,125 @@ def test_goal_deleted(self) -> None: expected_topic_name=EXPECTED_TOPIC, expected_message=expected_message, ) + + def test_missing_request_variable(self) -> None: + self.url = self.build_webhook_url() + exception_msg = "Missing 'clickup_api_key' argument" + with self.assertRaisesRegex(AssertionError, exception_msg): + expected_message = ":trash_can: A Goal has been deleted from your ClickUp space!" + self.check_webhook( + fixture_name="goal_deleted", + expected_topic_name=EXPECTED_TOPIC, + expected_message=expected_message, + ) + + def test_webhook_api_callback_unavailable_error(self) -> None: + @webhook_view("ClientName") + def my_webhook_raises_exception( + request: HttpRequest, user_profile: UserProfile + ) -> HttpResponse: + raise APIUnavailableCallBackError + + request = HostRequestMock() + request.method = "POST" + request.host = "zulip.testserver" + + request._body = b"{}" + request.content_type = "text/plain" + request.POST["api_key"] = get_api_key(self.example_user("hamlet")) + exception_msg = "ClientName integration couldn't reach an external API service; ignoring" + with patch( + "zerver.decorator.webhook_logger.exception" + ) as mock_exception, self.assertRaisesRegex(APIUnavailableCallBackError, exception_msg): + my_webhook_raises_exception(request) + mock_exception.assert_called_once() + self.assertIsInstance(mock_exception.call_args.args[0], APIUnavailableCallBackError) + self.assertEqual(mock_exception.call_args.args[0].msg, exception_msg) + self.assertEqual( + mock_exception.call_args.kwargs, {"extra": {"request": request}, "stack_info": True} + ) + + def test_webhook_api_callback_bad_request_error(self) -> None: + @webhook_view(webhook_client_name="ClientName") + def my_webhook_raises_exception( + request: HttpRequest, user_profile: UserProfile + ) -> HttpResponse: + raise BadRequestCallBackError("") + + request = HostRequestMock() + request.method = "POST" + request.host = "zulip.testserver" + + request._body = b"{}" + request.content_type = "text/plain" + request.POST["api_key"] = get_api_key(self.example_user("hamlet")) + exception_msg = ( + "ClientName integration tries to make a bad outgoing request: ; ignoring" + ) + with patch( + "zerver.decorator.webhook_logger.exception" + ) as mock_exception, self.assertRaisesRegex(BadRequestCallBackError, exception_msg): + my_webhook_raises_exception(request) + mock_exception.assert_called_once() + self.assertIsInstance(mock_exception.call_args.args[0], BadRequestCallBackError) + self.assertEqual(mock_exception.call_args.args[0].msg, exception_msg) + self.assertEqual( + mock_exception.call_args.kwargs, {"extra": {"request": request}, "stack_info": True} + ) + + def test_verify_url_path(self) -> None: + invalid_paths = ["oauth/token", "user", "webhook"] + for path in invalid_paths: + with self.assertRaises(BadRequestCallBackError): + make_clickup_request(path, api_key="123") + + def test_clickup_request_http_error(self) -> None: + with patch("zerver.webhooks.clickup.api_endpoints.ClickUpSession") as mock_clickup_session: + mock_response = MagicMock() + mock_response.status_code = 404 + mock_clickup_session.return_value.get.side_effect = HTTPError(response=mock_response) + with self.assertRaises(BadRequestCallBackError): + make_clickup_request("list/123123", api_key="123") + mock_clickup_session.return_value.get.assert_called_once() + + def test_clickup_request_connection_error(self) -> None: + with patch("zerver.webhooks.clickup.api_endpoints.ClickUpSession") as mock_clickup_session: + mock_response = MagicMock() + mock_clickup_session.return_value.get.side_effect = ConnectionError( + response=mock_response + ) + with self.assertRaises(APIUnavailableCallBackError): + make_clickup_request("list/123123", api_key="123") + mock_clickup_session.return_value.get.assert_called_once() + + def test_clickup_request_timeout_error(self) -> None: + with patch("zerver.webhooks.clickup.api_endpoints.ClickUpSession") as mock_clickup_session: + mock_response = MagicMock() + mock_clickup_session.return_value.get.side_effect = Timeout(response=mock_response) + with self.assertRaises(APIUnavailableCallBackError): + make_clickup_request("list/123123", api_key="123") + mock_clickup_session.return_value.get.assert_called_once() + + def test_clickup_api_endpoints(self) -> None: + endpoint_map: Dict[str, Callable[[str, str], Dict[str, Any]]] = { + "folder": get_folder, + "list": get_list, + "space": get_space, + "task": get_task, + "goal": get_goal, + } + for item, call_api in endpoint_map.items(): + mock_fixtures_path = f"zerver/webhooks/clickup/callback_fixtures/get_{item}.json" + with patch( + "zerver.webhooks.clickup.api_endpoints.ClickUpSession" + ) as mock_clickup_session, open(mock_fixtures_path) as f: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.raise_for_status.side_effect = None + item_fixture = json.load(f) + mock_response.json.return_value = item_fixture + mock_clickup_session.return_value.get.return_value = mock_response + item_data = call_api("123", "XXXX") + + self.assertDictEqual(item_data, item_fixture) + mock_clickup_session.return_value.get.assert_called_once() diff --git a/zerver/webhooks/clickup/view.py b/zerver/webhooks/clickup/view.py index 507f0b6644ff49..12a83e0ef01e28 100644 --- a/zerver/webhooks/clickup/view.py +++ b/zerver/webhooks/clickup/view.py @@ -1,78 +1,30 @@ # Webhooks for external integrations. -from enum import Enum -from typing import Any, Dict, List, Tuple - -from django.http import HttpRequest, HttpResponse - import logging import re +from typing import Any, Dict, Tuple + +from django.http import HttpRequest, HttpResponse from zerver.decorator import webhook_view from zerver.lib.exceptions import UnsupportedWebhookEventTypeError -from zerver.lib.request import REQ, RequestVariableMissingError, has_request_variables +from zerver.lib.request import REQ, has_request_variables from zerver.lib.response import json_success from zerver.lib.typed_endpoint import JsonBodyPayload, typed_endpoint from zerver.lib.validator import WildValue, check_string from zerver.lib.webhooks.common import check_send_webhook_message, unix_milliseconds_to_timestamp from zerver.models import UserProfile - -from .api_endpoints import ( - APIUnavailableError, - BadRequestError, - get_folder, - get_goal, - get_list, - get_space, - get_task, +from zerver.webhooks.clickup import ( + EventAcion, + EventItemType, + SimpleFields, + SpammyFields, + SpecialFields, ) +from .api_endpoints import get_folder, get_goal, get_list, get_space, get_task logger = logging.getLogger(__name__) - -class ConstantVariable(Enum): - @classmethod - def as_list(cls) -> List[str]: - return [item.value for item in cls] - - -class EventItemType(ConstantVariable): - TASK: str = "task" - LIST: str = "list" - FOLDER: str = "folder" - GOAL: str = "goal" - SPACE: str = "space" - - -class EventAcion(ConstantVariable): - CREATED: str = "Created" - UPDATED: str = "Updated" - DELETED: str = "Deleted" - - -class SimpleFields(ConstantVariable): - # Events with identical payload format - PRIORITY: str = "priority" - STATUS: str = "status" - - -class SpecialFields(ConstantVariable): - # Event with unique payload - NAME: str = "name" - ASSIGNEE: str = "assignee_add" - COMMENT: str = "comment" - DUE_DATE: str = "due_date" - MOVED: str = "section_moved" - TIME_ESTIMATE: str = "time_estimate" - TIME_SPENT: str = "time_spent" - - -class SpammyFields(ConstantVariable): - TAG: str = "tag" - TAG_REMOVED: str = "tag_removed" - UNASSIGN: str = "assignee_rem" - - EVENT_NAME_TEMPLATE: str = "**[{event_item_type}: {event_item_name}]({item_url})**" @@ -87,23 +39,9 @@ def api_clickup_webhook( *, payload: JsonBodyPayload[WildValue], ) -> HttpResponse: - if not team_id: - raise RequestVariableMissingError("team_id") - if not clickup_api_key: - raise RequestVariableMissingError("clickup_api_key") - - try: - topic, body = topic_and_body(payload, clickup_api_key, team_id) - except APIUnavailableError: - logger.warning("Could not reach ClickUp API.") - except BadRequestError: - logger.warning("ClickUp service had internal error") - except Exception as e: - logger.warning(e) - else: - check_send_webhook_message(request, user_profile, topic, body) - finally: - return json_success(request) + topic, body = topic_and_body(payload, clickup_api_key, team_id) + check_send_webhook_message(request, user_profile, topic, body) + return json_success(request) def topic_and_body(payload: WildValue, clickup_api_key: str, team_id: str) -> Tuple[str, str]: @@ -178,8 +116,8 @@ def generate_updated_event_message( for history_data in history_items: updated_field = history_data["field"].tame(check_string) if updated_field in SpammyFields.as_list(): - # Updating these fields may trigger multiple notifications at a time. - raise UnsupportedWebhookEventTypeError(updated_field) + # Updating these fields may trigger multiple identical notifications at a time. + continue # nocoverage elif updated_field in SimpleFields.as_list(): body += body_message_for_simple_field( history_data=history_data, event_item_type=event_item_type @@ -208,14 +146,7 @@ def body_message_for_simple_field(history_data: WildValue, event_item_type: str) if history_data.get("after") else None ) - return ( - "\n~~~ quote\n :note: Updated {event_item_type} {updated_field} from **{old_value}** to **{new_value}**\n~~~\n" - ).format( - event_item_type=event_item_type, - updated_field=updated_field, - old_value=old_value, - new_value=new_value, - ) + return f"\n~~~ quote\n :note: Updated {event_item_type} {updated_field} from **{old_value}** to **{new_value}**\n~~~\n" def body_message_for_special_field(history_data: WildValue) -> str: @@ -304,9 +235,9 @@ def get_item_data( goal_data: Dict[str, Any] = get_goal( api_key=api_key, goal_id=payload["goal_id"].tame(check_string) ) - item_data.update( - goal_data.get("goal") - ) # in case of Goal payload, useful datas are stored 1 level deeper + item_data = goal_data.get( + "goal", {} + ) # in case of Goal payload, useful data are stored 1 level deeper elif event_item_type == EventItemType.SPACE.value: item_data = get_space(api_key=api_key, space_id=payload["space_id"].tame(check_string)) else: