Skip to content

Commit

Permalink
Fix errors in webex alert destination. Add formatting support for QUE…
Browse files Browse the repository at this point in the history
…RY_RESULT_TABLE. (#7296)

* prevent text values in payload being detected as 'set' on send.
Webex send ERROR:: Object of type set is not JSON serializable

Signed-off-by: Matt Nelson <[email protected]>

* add support for formatted QUERY_RESULT_TABLE in webex card

Signed-off-by: Matt Nelson <[email protected]>

* don't try to send to blank destinations

Signed-off-by: Matt Nelson <[email protected]>

* fix handling of the encoded QUERY_RESULTS_TABLE text

Signed-off-by: Matt Nelson <[email protected]>

* re-sort imports for ruff

Signed-off-by: Matt Nelson <[email protected]>

* change formatter to black

Signed-off-by: Matt Nelson <[email protected]>

* Add additional tests for Webex notification handling

ensure blank entries are handled for room IDs and person emails.
ensure that the API is not called when no valid destinations are provided.
ensure proper attachment formatting for alerts containing 2D arrays.

Signed-off-by: Matt Nelson <[email protected]>

* Add test for Webex notification with 1D array handling

This commit introduces a new test case to verify that the Webex
notification function correctly handles a 1D array input in the alert body.
The test ensures that the expected payload is constructed properly and that
the requests.post method is called with the correct parameters.

Signed-off-by: Matt Nelson <[email protected]>

---------

Signed-off-by: Matt Nelson <[email protected]>
  • Loading branch information
metheos authored Feb 4, 2025
1 parent 2776992 commit 96ea019
Show file tree
Hide file tree
Showing 2 changed files with 290 additions and 43 deletions.
168 changes: 130 additions & 38 deletions redash/destinations/webex.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import html
import json
import logging
from copy import deepcopy

Expand Down Expand Up @@ -37,51 +39,137 @@ def api_base_url(self):

@staticmethod
def formatted_attachments_template(subject, description, query_link, alert_link):
# Attempt to parse the description to find a 2D array
try:
# Extract the part of the description that looks like a JSON array
start_index = description.find("[")
end_index = description.rfind("]") + 1
json_array_str = description[start_index:end_index]

# Decode HTML entities
json_array_str = html.unescape(json_array_str)

# Replace single quotes with double quotes for valid JSON
json_array_str = json_array_str.replace("'", '"')

# Load the JSON array
data_array = json.loads(json_array_str)

# Check if it's a 2D array
if isinstance(data_array, list) and all(isinstance(i, list) for i in data_array):
# Create a table for the Adaptive Card
table_rows = []
for row in data_array:
table_rows.append(
{
"type": "ColumnSet",
"columns": [
{"type": "Column", "items": [{"type": "TextBlock", "text": str(item), "wrap": True}]}
for item in row
],
}
)

# Create the body of the card with the table
body = (
[
{
"type": "TextBlock",
"text": f"{subject}",
"weight": "bolder",
"size": "medium",
"wrap": True,
},
{
"type": "TextBlock",
"text": f"{description[:start_index]}",
"isSubtle": True,
"wrap": True,
},
]
+ table_rows
+ [
{
"type": "TextBlock",
"text": f"Click [here]({query_link}) to check your query!",
"wrap": True,
"isSubtle": True,
},
{
"type": "TextBlock",
"text": f"Click [here]({alert_link}) to check your alert!",
"wrap": True,
"isSubtle": True,
},
]
)
else:
# Fallback to the original description if no valid 2D array is found
body = [
{
"type": "TextBlock",
"text": f"{subject}",
"weight": "bolder",
"size": "medium",
"wrap": True,
},
{
"type": "TextBlock",
"text": f"{description}",
"isSubtle": True,
"wrap": True,
},
{
"type": "TextBlock",
"text": f"Click [here]({query_link}) to check your query!",
"wrap": True,
"isSubtle": True,
},
{
"type": "TextBlock",
"text": f"Click [here]({alert_link}) to check your alert!",
"wrap": True,
"isSubtle": True,
},
]
except json.JSONDecodeError:
# If parsing fails, fallback to the original description
body = [
{
"type": "TextBlock",
"text": f"{subject}",
"weight": "bolder",
"size": "medium",
"wrap": True,
},
{
"type": "TextBlock",
"text": f"{description}",
"isSubtle": True,
"wrap": True,
},
{
"type": "TextBlock",
"text": f"Click [here]({query_link}) to check your query!",
"wrap": True,
"isSubtle": True,
},
{
"type": "TextBlock",
"text": f"Click [here]({alert_link}) to check your alert!",
"wrap": True,
"isSubtle": True,
},
]

return [
{
"contentType": "application/vnd.microsoft.card.adaptive",
"content": {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.0",
"body": [
{
"type": "ColumnSet",
"columns": [
{
"type": "Column",
"width": 4,
"items": [
{
"type": "TextBlock",
"text": {subject},
"weight": "bolder",
"size": "medium",
"wrap": True,
},
{
"type": "TextBlock",
"text": {description},
"isSubtle": True,
"wrap": True,
},
{
"type": "TextBlock",
"text": f"Click [here]({query_link}) to check your query!",
"wrap": True,
"isSubtle": True,
},
{
"type": "TextBlock",
"text": f"Click [here]({alert_link}) to check your alert!",
"wrap": True,
"isSubtle": True,
},
],
},
],
}
],
"body": body,
},
}
]
Expand Down Expand Up @@ -116,6 +204,10 @@ def notify(self, alert, query, user, new_state, app, host, metadata, options):

# destinations is guaranteed to be a comma-separated string
for destination_id in destinations.split(","):
destination_id = destination_id.strip() # Remove any leading or trailing whitespace
if not destination_id: # Check if the destination_id is empty or blank
continue # Skip to the next iteration if it's empty or blank

payload = deepcopy(template_payload)
payload[payload_tag] = destination_id
self.post_message(payload, headers)
Expand Down
165 changes: 160 additions & 5 deletions tests/handlers/test_destinations.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,37 +261,139 @@ def test_webex_notify_calls_requests_post():
alert.name = "Test Alert"
alert.custom_subject = "Test custom subject"
alert.custom_body = "Test custom body"
alert.render_template = mock.Mock(return_value={"Rendered": "template"})

query = mock.Mock()
query.id = 1

user = mock.Mock()
app = mock.Mock()
host = "https://localhost:5000"
options = {
"webex_bot_token": "abcd",
"to_room_ids": "1234,5678",
"to_person_emails": "[email protected],[email protected]",
}
metadata = {"Scheduled": False}

new_state = Alert.TRIGGERED_STATE
destination = Webex(options)

with mock.patch("redash.destinations.webex.requests.post") as mock_post:
mock_response = mock.Mock()
mock_response.status_code = 200
mock_post.return_value = mock_response

destination.notify(alert, query, user, new_state, app, host, metadata, options)

query_link = f"{host}/queries/{query.id}"
alert_link = f"{host}/alerts/{alert.id}"

expected_attachments = Webex.formatted_attachments_template(
alert.custom_subject, alert.custom_body, query_link, alert_link
)

expected_payload_room = {
"markdown": alert.custom_subject + "\n" + alert.custom_body,
"attachments": expected_attachments,
"roomId": "1234",
}

expected_payload_email = {
"markdown": alert.custom_subject + "\n" + alert.custom_body,
"attachments": expected_attachments,
"toPersonEmail": "[email protected]",
}

# Check that requests.post was called for both roomId and toPersonEmail destinations
mock_post.assert_any_call(
destination.api_base_url,
json=expected_payload_room,
headers={"Authorization": "Bearer abcd"},
timeout=5.0,
)

mock_post.assert_any_call(
destination.api_base_url,
json=expected_payload_email,
headers={"Authorization": "Bearer abcd"},
timeout=5.0,
)

assert mock_response.status_code == 200


def test_webex_notify_handles_blank_entries():
alert = mock.Mock(spec_set=["id", "name", "custom_subject", "custom_body", "render_template"])
alert.id = 1
alert.name = "Test Alert"
alert.custom_subject = "Test custom subject"
alert.custom_body = "Test custom body"
alert.render_template = mock.Mock(return_value={"Rendered": "template"})

query = mock.Mock()
query.id = 1

user = mock.Mock()
app = mock.Mock()
host = "https://localhost:5000"
options = {
"webex_bot_token": "abcd",
"to_room_ids": "",
"to_person_emails": "",
}
metadata = {"Scheduled": False}

new_state = Alert.TRIGGERED_STATE
destination = Webex(options)

with mock.patch("redash.destinations.webex.requests.post") as mock_post:
destination.notify(alert, query, user, new_state, app, host, metadata, options)

# Ensure no API calls are made when destinations are blank
mock_post.assert_not_called()


def test_webex_notify_handles_2d_array():
alert = mock.Mock(spec_set=["id", "name", "custom_subject", "custom_body", "render_template"])
alert.id = 1
alert.name = "Test Alert"
alert.custom_subject = "Test custom subject"
alert.custom_body = "Test custom body with table [['Col1', 'Col2'], ['Val1', 'Val2']]"
alert.render_template = mock.Mock(return_value={"Rendered": "template"})

query = mock.Mock()
query.id = 1

user = mock.Mock()
app = mock.Mock()
host = "https://localhost:5000"
options = {"webex_bot_token": "abcd", "to_room_ids": "1234"}
options = {
"webex_bot_token": "abcd",
"to_room_ids": "1234",
}
metadata = {"Scheduled": False}

new_state = Alert.TRIGGERED_STATE
destination = Webex(options)

with mock.patch("redash.destinations.webex.requests.post") as mock_post:
mock_response = mock.Mock()
mock_response.status_code = 204
mock_response.status_code = 200
mock_post.return_value = mock_response

destination.notify(alert, query, user, new_state, app, host, metadata, options)

query_link = f"{host}/queries/{query.id}"
alert_link = f"{host}/alerts/{alert.id}"

formatted_attachments = Webex.formatted_attachments_template(
expected_attachments = Webex.formatted_attachments_template(
alert.custom_subject, alert.custom_body, query_link, alert_link
)

expected_payload = {
"markdown": alert.custom_subject + "\n" + alert.custom_body,
"attachments": formatted_attachments,
"attachments": expected_attachments,
"roomId": "1234",
}

Expand All @@ -302,7 +404,60 @@ def test_webex_notify_calls_requests_post():
timeout=5.0,
)

assert mock_response.status_code == 204
assert mock_response.status_code == 200


def test_webex_notify_handles_1d_array():
alert = mock.Mock(spec_set=["id", "name", "custom_subject", "custom_body", "render_template"])
alert.id = 1
alert.name = "Test Alert"
alert.custom_subject = "Test custom subject"
alert.custom_body = "Test custom body with 1D array, however unlikely ['Col1', 'Col2']"
alert.render_template = mock.Mock(return_value={"Rendered": "template"})

query = mock.Mock()
query.id = 1

user = mock.Mock()
app = mock.Mock()
host = "https://localhost:5000"
options = {
"webex_bot_token": "abcd",
"to_room_ids": "1234",
}
metadata = {"Scheduled": False}

new_state = Alert.TRIGGERED_STATE
destination = Webex(options)

with mock.patch("redash.destinations.webex.requests.post") as mock_post:
mock_response = mock.Mock()
mock_response.status_code = 200
mock_post.return_value = mock_response

destination.notify(alert, query, user, new_state, app, host, metadata, options)

query_link = f"{host}/queries/{query.id}"
alert_link = f"{host}/alerts/{alert.id}"

expected_attachments = Webex.formatted_attachments_template(
alert.custom_subject, alert.custom_body, query_link, alert_link
)

expected_payload = {
"markdown": alert.custom_subject + "\n" + alert.custom_body,
"attachments": expected_attachments,
"roomId": "1234",
}

mock_post.assert_called_once_with(
destination.api_base_url,
json=expected_payload,
headers={"Authorization": "Bearer abcd"},
timeout=5.0,
)

assert mock_response.status_code == 200


def test_datadog_notify_calls_requests_post():
Expand Down

0 comments on commit 96ea019

Please sign in to comment.