v1.8.10
This commit is contained in:
commit
e72d036c42
36 changed files with 561 additions and 91 deletions
|
|
@ -122,6 +122,8 @@ This set of permissions is supporting the ability of Grafana OnCall to match use
|
|||
(deprecated) slack commands.
|
||||
- **Create and manage user groups** — the permission is used to automatically update user groups linked to on-call
|
||||
schedules. It will add users once their on-call shift starts and remove them once the on-call shift ends.
|
||||
- **NOTE**: per [Slack's documentation](https://slack.com/help/articles/212906697-Create-a-user-group), you must have
|
||||
a paid plan for this feature to work properly
|
||||
- **Set presence for Grafana OnCall**
|
||||
|
||||
## Post-install configuration for Slack integration
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/alertgroups/
|
||||
title: Alert groups HTTP API
|
||||
weight: 400
|
||||
refs:
|
||||
pagination:
|
||||
- pattern: /docs/oncall/
|
||||
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
|
||||
---
|
||||
|
||||
# Alert groups HTTP API
|
||||
|
|
@ -48,6 +54,8 @@ The above command returns JSON structured in the following way:
|
|||
}
|
||||
```
|
||||
|
||||
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
|
||||
|
||||
These available filter parameters should be provided as `GET` arguments:
|
||||
|
||||
- `id`
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/alerts/
|
||||
title: Alerts HTTP API
|
||||
weight: 100
|
||||
refs:
|
||||
pagination:
|
||||
- pattern: /docs/oncall/
|
||||
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
|
||||
---
|
||||
|
||||
# Alerts HTTP API
|
||||
|
|
@ -105,6 +111,8 @@ The above command returns JSON structured in the following way:
|
|||
}
|
||||
```
|
||||
|
||||
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
|
||||
|
||||
The following available filter parameters should be provided as `GET` arguments:
|
||||
|
||||
- `id`
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/escalation_chains/
|
||||
title: Escalation chains HTTP API
|
||||
weight: 200
|
||||
refs:
|
||||
pagination:
|
||||
- pattern: /docs/oncall/
|
||||
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
|
||||
---
|
||||
|
||||
# Escalation chains HTTP API
|
||||
|
|
@ -89,6 +95,8 @@ The above command returns JSON structured in the following way:
|
|||
}
|
||||
```
|
||||
|
||||
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
|
||||
|
||||
**HTTP request**
|
||||
|
||||
`GET {{API_URL}}/api/v1/escalation_chains/`
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/escalation_policies/
|
||||
title: Escalation policies HTTP API
|
||||
weight: 300
|
||||
refs:
|
||||
pagination:
|
||||
- pattern: /docs/oncall/
|
||||
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
|
||||
---
|
||||
|
||||
# Escalation policies HTTP API
|
||||
|
|
@ -144,6 +150,8 @@ The above command returns JSON structured in the following way:
|
|||
}
|
||||
```
|
||||
|
||||
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
|
||||
|
||||
The following available filter parameter should be provided as a `GET` argument:
|
||||
|
||||
- `escalation_chain_id`
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ refs:
|
|||
destination: /docs/oncall/<ONCALL_VERSION>/configure/integrations/references/alertmanager/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/oncall/configure/integrations/references/alertmanager/
|
||||
pagination:
|
||||
- pattern: /docs/oncall/
|
||||
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
|
||||
---
|
||||
|
||||
# Integrations HTTP API
|
||||
|
|
@ -233,6 +238,8 @@ The above command returns JSON structured in the following way:
|
|||
}
|
||||
```
|
||||
|
||||
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
|
||||
|
||||
**HTTP request**
|
||||
|
||||
`GET {{API_URL}}/api/v1/integrations/`
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/on_call_shifts/
|
||||
title: OnCall shifts HTTP API
|
||||
weight: 600
|
||||
refs:
|
||||
pagination:
|
||||
- pattern: /docs/oncall/
|
||||
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
|
||||
---
|
||||
|
||||
# OnCall shifts HTTP API
|
||||
|
|
@ -150,6 +156,8 @@ The above command returns JSON structured in the following way:
|
|||
}
|
||||
```
|
||||
|
||||
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
|
||||
|
||||
The following available filter parameters should be provided as `GET` arguments:
|
||||
|
||||
- `name` (Exact match)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ refs:
|
|||
destination: /docs/oncall/<ONCALL_VERSION>/configure/integrations/outgoing-webhooks/#event-types
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/oncall/configure/integrations/outgoing-webhooks/#event-types
|
||||
pagination:
|
||||
- pattern: /docs/oncall/
|
||||
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
|
||||
---
|
||||
|
||||
# Outgoing webhooks
|
||||
|
|
@ -66,6 +71,8 @@ The above command returns JSON structured in the following way:
|
|||
}
|
||||
```
|
||||
|
||||
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
|
||||
|
||||
## Get webhook
|
||||
|
||||
```shell
|
||||
|
|
@ -244,3 +251,5 @@ The above command returns JSON structured in the following way:
|
|||
"total_pages": 1
|
||||
}
|
||||
```
|
||||
|
||||
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/personal_notification_rules/
|
||||
title: Personal notification rules HTTP API
|
||||
weight: 800
|
||||
refs:
|
||||
pagination:
|
||||
- pattern: /docs/oncall/
|
||||
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
|
||||
---
|
||||
|
||||
# Personal notification rules HTTP API
|
||||
|
|
@ -122,6 +128,8 @@ The above command returns JSON structured in the following ways:
|
|||
}
|
||||
```
|
||||
|
||||
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
|
||||
|
||||
The following available filter parameters should be provided as `GET` arguments:
|
||||
|
||||
- `user_id`
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/resolution_notes/
|
||||
title: Resolution notes HTTP API
|
||||
weight: 900
|
||||
refs:
|
||||
pagination:
|
||||
- pattern: /docs/oncall/
|
||||
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
|
||||
---
|
||||
|
||||
# Resolution notes HTTP API
|
||||
|
|
@ -99,6 +105,8 @@ The above command returns JSON structured in the following way:
|
|||
}
|
||||
```
|
||||
|
||||
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
|
||||
|
||||
The following available filter parameter should be provided as a `GET` argument:
|
||||
|
||||
- `alert_group_id`
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/routes/
|
||||
title: Routes HTTP API
|
||||
weight: 1100
|
||||
refs:
|
||||
pagination:
|
||||
- pattern: /docs/oncall/
|
||||
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
|
||||
---
|
||||
|
||||
# Routes HTTP API
|
||||
|
|
@ -134,6 +140,8 @@ The above command returns JSON structured in the following way:
|
|||
}
|
||||
```
|
||||
|
||||
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
|
||||
|
||||
The following available filter parameters should be provided as `GET` arguments:
|
||||
|
||||
- `integration_id`
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/schedules/
|
||||
title: Schedules HTTP API
|
||||
weight: 1200
|
||||
refs:
|
||||
pagination:
|
||||
- pattern: /docs/oncall/
|
||||
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
|
||||
---
|
||||
|
||||
# Schedules HTTP API
|
||||
|
|
@ -139,6 +145,8 @@ The above command returns JSON structured in the following way:
|
|||
}
|
||||
```
|
||||
|
||||
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
|
||||
|
||||
The following available filter parameter should be provided as a `GET` argument:
|
||||
|
||||
- `name` (Exact match)
|
||||
|
|
@ -307,6 +315,8 @@ The above command returns JSON structured in the following way:
|
|||
}
|
||||
```
|
||||
|
||||
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
|
||||
|
||||
### Caveats
|
||||
|
||||
Some notes on the `start_date` and `end_date` query parameters:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/shift_swaps/
|
||||
title: Shift swap requests HTTP API
|
||||
weight: 1200
|
||||
refs:
|
||||
pagination:
|
||||
- pattern: /docs/oncall/
|
||||
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
|
||||
---
|
||||
|
||||
# Shift swap requests HTTP API
|
||||
|
|
@ -185,6 +191,8 @@ The above command returns JSON structured in the following way:
|
|||
}
|
||||
```
|
||||
|
||||
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
|
||||
|
||||
The following available filter parameters may be provided as a `GET` arguments:
|
||||
|
||||
- `starting_after` (an ISO 8601 timestamp string, filter requests starting after the specified datetime)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/slack_channels/
|
||||
title: Slack channels HTTP API
|
||||
weight: 1300
|
||||
refs:
|
||||
pagination:
|
||||
- pattern: /docs/oncall/
|
||||
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
|
||||
---
|
||||
|
||||
# Slack channels HTTP API
|
||||
|
|
@ -34,6 +40,8 @@ The above command returns JSON structured in the following way:
|
|||
}
|
||||
```
|
||||
|
||||
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
|
||||
|
||||
The following available filter parameter should be provided as a `GET` argument:
|
||||
|
||||
- `channel_name`
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/user_groups/
|
||||
title: OnCall user groups HTTP API
|
||||
weight: 1400
|
||||
refs:
|
||||
pagination:
|
||||
- pattern: /docs/oncall/
|
||||
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
|
||||
---
|
||||
|
||||
<!--Used in escalation policies with type = `notify_user_group` and in schedules.-->
|
||||
|
|
@ -41,6 +47,8 @@ The above command returns JSON structured in the following way:
|
|||
}
|
||||
```
|
||||
|
||||
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
|
||||
|
||||
| Parameter | Unique | Description |
|
||||
| --------- | :----: | :---------------------------------------------------------------------------------------------------- |
|
||||
| `id` | Yes | User Group ID |
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/users/
|
||||
title: Grafana OnCall users HTTP API
|
||||
weight: 1500
|
||||
refs:
|
||||
pagination:
|
||||
- pattern: /docs/oncall/
|
||||
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
|
||||
---
|
||||
|
||||
# Grafana OnCall users HTTP API
|
||||
|
|
@ -90,7 +96,7 @@ The above command returns JSON structured in the following way:
|
|||
}
|
||||
```
|
||||
|
||||
This endpoint retrieves all users.
|
||||
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
|
||||
|
||||
The following available filter parameter should be provided as a `GET` argument:
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from apps.labels.utils import is_labels_feature_enabled
|
|||
class Feature(enum.StrEnum):
|
||||
MSTEAMS = "msteams"
|
||||
SLACK = "slack"
|
||||
UNIFIED_SLACK = "unified_slack"
|
||||
TELEGRAM = "telegram"
|
||||
LIVE_SETTINGS = "live_settings"
|
||||
GRAFANA_CLOUD_NOTIFICATIONS = "grafana_cloud_notifications"
|
||||
|
|
@ -46,6 +47,9 @@ class FeaturesAPIView(APIView):
|
|||
if settings.FEATURE_SLACK_INTEGRATION_ENABLED:
|
||||
enabled_features.append(Feature.SLACK)
|
||||
|
||||
if settings.UNIFIED_SLACK_APP_ENABLED:
|
||||
enabled_features.append(Feature.UNIFIED_SLACK)
|
||||
|
||||
if settings.FEATURE_TELEGRAM_INTEGRATION_ENABLED:
|
||||
enabled_features.append(Feature.TELEGRAM)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import logging
|
|||
import typing
|
||||
|
||||
from django.conf import settings
|
||||
from google.auth.exceptions import RefreshError
|
||||
from google.oauth2.credentials import Credentials
|
||||
from googleapiclient.discovery import build
|
||||
from googleapiclient.errors import HttpError
|
||||
|
|
@ -24,11 +25,24 @@ class GoogleCalendarEvent:
|
|||
self.end_time_utc = self._end_time.astimezone(datetime.timezone.utc)
|
||||
|
||||
|
||||
class GoogleCalendarHTTPError(Exception):
|
||||
class _GoogleCalendarHTTPError(Exception):
|
||||
def __init__(self, http_error) -> None:
|
||||
self.error = http_error
|
||||
|
||||
|
||||
class GoogleCalendarGenericHTTPError(_GoogleCalendarHTTPError):
|
||||
"""Raised when a generic HTTP error occurs when communicating with the Google Calendar API"""
|
||||
|
||||
|
||||
class GoogleCalendarUnauthorizedHTTPError(_GoogleCalendarHTTPError):
|
||||
"""Raised when an HTTP 403 error occurs when communicating with the Google Calendar API"""
|
||||
|
||||
|
||||
class GoogleCalendarRefreshError(Exception):
|
||||
def __init__(self, refresh_error) -> None:
|
||||
self.error = refresh_error
|
||||
|
||||
|
||||
class GoogleCalendarAPIClient:
|
||||
MAX_NUMBER_OF_CALENDAR_EVENTS_TO_FETCH = 250
|
||||
"""
|
||||
|
|
@ -89,7 +103,27 @@ class GoogleCalendarAPIClient:
|
|||
.execute()
|
||||
)
|
||||
except HttpError as e:
|
||||
logger.error(f"GoogleCalendarAPIClient - Error fetching out of office events: {e}")
|
||||
raise GoogleCalendarHTTPError(e)
|
||||
if e.status_code == 403:
|
||||
# this scenario can be encountered when, for some reason, the OAuth2 token that we have
|
||||
# does not contain the https://www.googleapis.com/auth/calendar.events.readonly scope
|
||||
# example error:
|
||||
# <HttpError 403 when requesting https://www.googleapis.com/calendar/v3/calendars/primary/events?timeMin=2024-08-08T14%3A00%3A00%2B0000&timeMax=2024-09-07T14%3A00%3A00%2B0000&maxResults=250&singleEvents=true&orderBy=startTime&eventTypes=outOfOffice&alt=json returned "Request had insufficient authentication scopes.". Details: "[{'message': 'Insufficient Permission', 'domain': 'global', 'reason': 'insufficientPermissions'}]"> # noqa: E501
|
||||
logger.error(f"GoogleCalendarAPIClient - HttpError 403 when fetching out of office events: {e}")
|
||||
raise GoogleCalendarUnauthorizedHTTPError(e)
|
||||
|
||||
logger.error(f"GoogleCalendarAPIClient - HttpError when fetching out of office events: {e}")
|
||||
raise GoogleCalendarGenericHTTPError(e)
|
||||
except RefreshError as e:
|
||||
# TODO: come back and solve this properly once we get better logging output
|
||||
# it seems like right now we are seeing RefreshError in two different scenarios:
|
||||
# 1. RefreshError('invalid_grant: Account has been deleted', {'error': 'invalid_grant', 'error_description': 'Account has been deleted'})
|
||||
# 2. RefreshError('invalid_grant: Token has been expired or revoked.', {'error': 'invalid_grant', 'error_description': 'Token has been expired or revoked.'})
|
||||
# https://stackoverflow.com/a/49024030/3902555
|
||||
logger.error(
|
||||
f"GoogleCalendarAPIClient - RefreshError when fetching out of office events: {e} "
|
||||
# NOTE: remove e.args after debugging how to dig into the error details
|
||||
f"args={e.args}"
|
||||
)
|
||||
raise GoogleCalendarRefreshError(e)
|
||||
|
||||
return [GoogleCalendarEvent(event) for event in events_result.get("items", [])]
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@ import logging
|
|||
from celery.utils.log import get_task_logger
|
||||
|
||||
from apps.google import constants
|
||||
from apps.google.client import GoogleCalendarAPIClient, GoogleCalendarHTTPError
|
||||
from apps.google.client import (
|
||||
GoogleCalendarAPIClient,
|
||||
GoogleCalendarGenericHTTPError,
|
||||
GoogleCalendarRefreshError,
|
||||
GoogleCalendarUnauthorizedHTTPError,
|
||||
)
|
||||
from apps.google.models import GoogleOAuth2User
|
||||
from apps.schedules.models import OnCallSchedule, ShiftSwapRequest
|
||||
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
|
||||
|
|
@ -33,8 +38,16 @@ def sync_out_of_office_calendar_events_for_user(google_oauth2_user_pk: int) -> N
|
|||
|
||||
try:
|
||||
out_of_office_events = google_api_client.fetch_out_of_office_events()
|
||||
except GoogleCalendarHTTPError:
|
||||
logger.info(f"Failed to fetch out of office events for user {user_id}")
|
||||
except GoogleCalendarUnauthorizedHTTPError:
|
||||
# this happens because the user's access token is (somehow) missing the
|
||||
# https://www.googleapis.com/auth/calendar.events.readonly scope
|
||||
# they will need to reconnect their Google account and grant us the necessary scopes, retrying will not help
|
||||
logger.exception(f"Failed to fetch out of office events for user {user_id} due to an unauthorized HTTP error")
|
||||
# TODO: come back and solve this properly once we get better logging output
|
||||
# user.reset_google_oauth2_settings()
|
||||
return
|
||||
except (GoogleCalendarRefreshError, GoogleCalendarGenericHTTPError):
|
||||
logger.exception(f"Failed to fetch out of office events for user {user_id}")
|
||||
return
|
||||
|
||||
for out_of_office_event in out_of_office_events:
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ def test_setup(
|
|||
user_name = "Bob Smith"
|
||||
user = make_user_for_organization(
|
||||
organization,
|
||||
# normally this 👇 is done via User.finish_google_oauth2_connection_flow.. but since we're creating
|
||||
# normally this 👇 is done via User.save_google_oauth2_settings.. but since we're creating
|
||||
# the user via a fixture we need to manually add this
|
||||
google_calendar_settings={
|
||||
"oncall_schedules_to_consider_for_shift_swaps": [],
|
||||
|
|
|
|||
|
|
@ -355,19 +355,43 @@ class GcomAPIClient(APIClient):
|
|||
return data
|
||||
|
||||
def get_instances(self, query: str, page_size=None):
|
||||
MAX_RETRIES = 3
|
||||
|
||||
if not page_size:
|
||||
page, _ = self.api_get(query)
|
||||
yield page
|
||||
else:
|
||||
previous_cursor = None
|
||||
retry_count = 0
|
||||
cursor = 0
|
||||
|
||||
while cursor is not None:
|
||||
if query:
|
||||
page_query = query + f"&cursor={cursor}&pageSize={page_size}"
|
||||
previous_cursor = cursor
|
||||
page, call_status = self.api_get(f"{query}&cursor={cursor}&pageSize={page_size}")
|
||||
|
||||
if "nextCursor" in page:
|
||||
cursor = page["nextCursor"]
|
||||
yield page
|
||||
elif retry_count == MAX_RETRIES:
|
||||
break
|
||||
else:
|
||||
page_query = f"?cursor={cursor}&pageSize={page_size}"
|
||||
page, _ = self.api_get(page_query)
|
||||
yield page
|
||||
cursor = page["nextCursor"]
|
||||
# nextCursor is missing from the response JSON, lets retry the request..
|
||||
#
|
||||
# NOTE: this is here because there seems to be a bug in GCOM's API where when using cursor based
|
||||
# pagination, the request is aborted on the GCOM side but still sends HTTP 200 w/ a partial
|
||||
# JSON response. This was leading to KeyErrors when trying to read the nextCursor key.
|
||||
#
|
||||
# How the JSON is actually properly decoded is aside me 🤷♂️, but for now lets simply retry the
|
||||
# request if this scenario arises
|
||||
#
|
||||
# See this conversation for more context
|
||||
# https://raintank-corp.slack.com/archives/C0K031RP1/p1723158123932529
|
||||
logger.warning(
|
||||
f"GcomAPIClient.get_instances response was missing nextCursor key, likely a decoding error. "
|
||||
f"http_response={page} call_status={call_status}"
|
||||
)
|
||||
cursor = previous_cursor # retry the request using the previous nextCursor value
|
||||
retry_count += 1
|
||||
|
||||
def _is_stack_in_certain_state(self, stack_id: str, state: str) -> bool:
|
||||
instance_info = self.get_instance_info(stack_id)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import uuid
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -54,6 +54,80 @@ def test_get_instances_pagination(page_size, expected_pages, expected_items):
|
|||
assert items == expected_items
|
||||
|
||||
|
||||
@patch("apps.grafana_plugin.helpers.client.APIClient.api_get")
|
||||
def test_get_instances_pagination_handles_streaming_errors_with_cursor_pagination(mock_api_get):
|
||||
query = GcomAPIClient.ACTIVE_INSTANCE_QUERY
|
||||
page_size = 10
|
||||
next_cursor1 = "abcd1234"
|
||||
next_cursor2 = "efgh5678"
|
||||
instance1 = {"id": "1"}
|
||||
instance2 = {"id": "2"}
|
||||
instance3 = {"id": "3"}
|
||||
|
||||
mock_api_get.side_effect = [
|
||||
({"items": [instance1], "nextCursor": next_cursor1}, {}),
|
||||
({"items": [instance2]}, {}), # failed request for the second page (missing nextCursor key)
|
||||
({"items": [instance2], "nextCursor": next_cursor2}, {}), # retried second page request has nextCursor key
|
||||
({"items": [instance3], "nextCursor": None}, {}), # last page
|
||||
]
|
||||
client = GcomAPIClient("someToken")
|
||||
|
||||
objects = []
|
||||
for page in client.get_instances(query, page_size):
|
||||
objects.extend(page["items"])
|
||||
|
||||
assert instance1 in objects
|
||||
assert instance2 in objects
|
||||
assert instance3 in objects
|
||||
|
||||
mock_api_get.assert_has_calls(
|
||||
[
|
||||
call(f"{query}&cursor=0&pageSize={page_size}"), # 1st page
|
||||
call(f"{query}&cursor={next_cursor1}&pageSize={page_size}"), # 2nd page, first try
|
||||
call(f"{query}&cursor={next_cursor1}&pageSize={page_size}"), # 2nd page, retry
|
||||
call(f"{query}&cursor={next_cursor2}&pageSize={page_size}"), # 3rd page
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@patch("apps.grafana_plugin.helpers.client.APIClient.api_get")
|
||||
def test_get_instances_pagination_doesnt_infinitely_retry_on_streaming_errors(mock_api_get):
|
||||
query = GcomAPIClient.ACTIVE_INSTANCE_QUERY
|
||||
page_size = 10
|
||||
next_cursor1 = "abcd1234"
|
||||
instance1 = {"id": "1"}
|
||||
instance2 = {"id": "2"}
|
||||
|
||||
mock_api_get.side_effect = [
|
||||
({"items": [instance1], "nextCursor": next_cursor1}, {}),
|
||||
({"items": [instance2]}, {}), # failed request for the second page (missing nextCursor key)
|
||||
({"items": [instance2]}, {}), # 2nd failed request for the second page
|
||||
({"items": [instance2]}, {}), # 3rd failed request for the second page
|
||||
({"items": [instance2]}, {}), # 4th failed request for the second page
|
||||
]
|
||||
client = GcomAPIClient("someToken")
|
||||
|
||||
objects = []
|
||||
for page in client.get_instances(query, page_size):
|
||||
objects.extend(page["items"])
|
||||
|
||||
assert instance1 in objects
|
||||
assert instance2 not in objects
|
||||
|
||||
second_page_call = call(f"{query}&cursor={next_cursor1}&pageSize={page_size}")
|
||||
|
||||
assert len(mock_api_get.mock_calls) == 5
|
||||
mock_api_get.assert_has_calls(
|
||||
[
|
||||
call(f"{query}&cursor=0&pageSize={page_size}"), # 1st page
|
||||
second_page_call, # 2nd page, 1st try
|
||||
second_page_call, # 2nd page, 1st retry
|
||||
second_page_call, # 2nd page, 2nd retry
|
||||
second_page_call, # 2nd page, 3rd retry
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query, expected_pages, expected_items",
|
||||
[
|
||||
|
|
|
|||
|
|
@ -56,6 +56,15 @@ class SlackAPIUsergroupNotFoundError(SlackAPIError):
|
|||
errors = ("no_such_subteam", "subteam_not_found")
|
||||
|
||||
|
||||
class SlackAPIUsergroupPaidTeamOnlyError(SlackAPIError):
|
||||
"""
|
||||
https://api.slack.com/methods/usergroups.create#:~:text=Name%20too%20long.-,paid_teams_only,-Usergroups%20can%20only
|
||||
https://slack.com/help/articles/212906697-Create-a-user-group
|
||||
"""
|
||||
|
||||
errors = ("paid_teams_only",)
|
||||
|
||||
|
||||
class SlackAPIInvalidUsersError(SlackAPIError):
|
||||
errors = ("invalid_users",)
|
||||
|
||||
|
|
|
|||
|
|
@ -16,9 +16,10 @@ from apps.slack.errors import (
|
|||
SlackAPIPermissionDeniedError,
|
||||
SlackAPITokenError,
|
||||
SlackAPIUsergroupNotFoundError,
|
||||
SlackAPIUsergroupPaidTeamOnlyError,
|
||||
)
|
||||
from apps.slack.models import SlackTeamIdentity
|
||||
from apps.user_management.models.user import User
|
||||
from apps.slack.models import SlackTeamIdentity, SlackUserIdentity
|
||||
from apps.user_management.models import User
|
||||
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
|
|
@ -85,9 +86,11 @@ class SlackUserGroup(models.Model):
|
|||
return False
|
||||
|
||||
@property
|
||||
def oncall_slack_user_identities(self):
|
||||
users = set(user for schedule in self.oncall_schedules.get_oncall_users().values() for user in schedule)
|
||||
slack_user_identities = []
|
||||
def oncall_slack_user_identities(self) -> list[SlackUserIdentity]:
|
||||
users: set[User] = set(
|
||||
user for schedule in self.oncall_schedules.get_oncall_users().values() for user in schedule
|
||||
)
|
||||
slack_user_identities: list[SlackUserIdentity] = []
|
||||
for user in users:
|
||||
if user.slack_user_identity is not None:
|
||||
slack_user_identities.append(user.slack_user_identity)
|
||||
|
|
@ -96,7 +99,7 @@ class SlackUserGroup(models.Model):
|
|||
|
||||
return slack_user_identities
|
||||
|
||||
def update_oncall_members(self):
|
||||
def update_oncall_members(self) -> None:
|
||||
slack_ids = [slack_user_identity.slack_id for slack_user_identity in self.oncall_slack_user_identities]
|
||||
logger.info(f"Updating usergroup {self.slack_id}, members {slack_ids}")
|
||||
|
||||
|
|
@ -110,20 +113,25 @@ class SlackUserGroup(models.Model):
|
|||
logger.info(f"Skipping usergroup {self.slack_id}, already populated correctly")
|
||||
return
|
||||
|
||||
logger.info(f"Slack user group {self.slack_id} memberlist in not up-to-date, updating, members {slack_ids}")
|
||||
logger.info(f"Slack user group {self.slack_id} memberlist in not up-to-date, updating, members {slack_ids}")
|
||||
|
||||
try:
|
||||
self.update_members(slack_ids)
|
||||
except SlackAPIPermissionDeniedError:
|
||||
pass
|
||||
|
||||
def update_members(self, slack_ids):
|
||||
def update_members(self, slack_ids: list[str]) -> None:
|
||||
sc = SlackClient(self.slack_team_identity, enable_ratelimit_retry=True)
|
||||
|
||||
try:
|
||||
sc.usergroups_users_update(usergroup=self.slack_id, users=slack_ids)
|
||||
except (SlackAPITokenError, SlackAPIUsergroupNotFoundError, SlackAPIInvalidUsersError) as err:
|
||||
logger.warning(f"Slack usergroup {self.slack_id} update failed: {err}")
|
||||
except SlackAPIUsergroupPaidTeamOnlyError:
|
||||
logger.warning(
|
||||
f"Slack usergroup {self.slack_id} update failed as this feature is only available for paid teams",
|
||||
exc_info=True,
|
||||
)
|
||||
except SlackAPIError as slack_api_error:
|
||||
logger.warning(f"Slack usergroup {self.slack_id} update failed: {slack_api_error}")
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from apps.slack.errors import (
|
|||
SlackAPIServerError,
|
||||
SlackAPITokenError,
|
||||
SlackAPIUsergroupNotFoundError,
|
||||
SlackAPIUsergroupPaidTeamOnlyError,
|
||||
SlackAPIUserNotFoundError,
|
||||
SlackAPIViewNotFoundError,
|
||||
)
|
||||
|
|
@ -129,6 +130,7 @@ def test_slack_client_generic_error(mock_request, monkeypatch, make_organization
|
|||
("message_not_found", SlackAPIMessageNotFoundError),
|
||||
("method_not_supported_for_channel_type", SlackAPIMethodNotSupportedForChannelTypeError),
|
||||
("no_such_subteam", SlackAPIUsergroupNotFoundError),
|
||||
("paid_team_only", SlackAPIUsergroupPaidTeamOnlyError),
|
||||
("not_found", SlackAPIViewNotFoundError),
|
||||
("permission_denied", SlackAPIPermissionDeniedError),
|
||||
("plan_upgrade_required", SlackAPIPlanUpgradeRequiredError),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from apps.slack.errors import (
|
|||
SlackAPIInvalidUsersError,
|
||||
SlackAPITokenError,
|
||||
SlackAPIUsergroupNotFoundError,
|
||||
SlackAPIUsergroupPaidTeamOnlyError,
|
||||
)
|
||||
from apps.slack.models import SlackUserGroup
|
||||
from apps.slack.tasks import (
|
||||
|
|
@ -22,7 +23,7 @@ from apps.user_management.models import Organization
|
|||
|
||||
@pytest.mark.django_db
|
||||
def test_update_members(make_organization_with_slack_team_identity, make_slack_user_group):
|
||||
organization, slack_team_identity = make_organization_with_slack_team_identity()
|
||||
_, slack_team_identity = make_organization_with_slack_team_identity()
|
||||
user_group = make_slack_user_group(slack_team_identity)
|
||||
|
||||
slack_ids = ["slack_id_1", "slack_id_2"]
|
||||
|
|
@ -34,13 +35,21 @@ def test_update_members(make_organization_with_slack_team_identity, make_slack_u
|
|||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("exception", [SlackAPITokenError, SlackAPIUsergroupNotFoundError, SlackAPIInvalidUsersError])
|
||||
@pytest.mark.parametrize(
|
||||
"exception",
|
||||
[
|
||||
SlackAPITokenError,
|
||||
SlackAPIUsergroupNotFoundError,
|
||||
SlackAPIInvalidUsersError,
|
||||
SlackAPIUsergroupPaidTeamOnlyError,
|
||||
],
|
||||
)
|
||||
def test_slack_user_group_update_errors(
|
||||
make_organization_with_slack_team_identity,
|
||||
make_slack_user_group,
|
||||
exception,
|
||||
):
|
||||
organization, slack_team_identity = make_organization_with_slack_team_identity()
|
||||
_, slack_team_identity = make_organization_with_slack_team_identity()
|
||||
user_group = make_slack_user_group(slack_team_identity=slack_team_identity)
|
||||
|
||||
slack_ids = ["slack_id_1", "slack_id_2"]
|
||||
|
|
@ -56,7 +65,7 @@ def test_slack_user_group_update_errors_raise(
|
|||
make_organization_with_slack_team_identity,
|
||||
make_slack_user_group,
|
||||
):
|
||||
organization, slack_team_identity = make_organization_with_slack_team_identity()
|
||||
_, slack_team_identity = make_organization_with_slack_team_identity()
|
||||
user_group = make_slack_user_group(slack_team_identity=slack_team_identity)
|
||||
|
||||
slack_ids = ["slack_id_1", "slack_id_2"]
|
||||
|
|
@ -100,10 +109,10 @@ def test_update_oncall_members(
|
|||
organization, slack_team_identity = make_organization_with_slack_team_identity()
|
||||
user_group = make_slack_user_group(slack_team_identity)
|
||||
|
||||
user_1, slack_user_identity_1 = make_user_with_slack_user_identity(
|
||||
_, slack_user_identity_1 = make_user_with_slack_user_identity(
|
||||
slack_team_identity, organization, slack_id="slack_id_1"
|
||||
)
|
||||
user_2, slack_user_identity_2 = make_user_with_slack_user_identity(
|
||||
_, slack_user_identity_2 = make_user_with_slack_user_identity(
|
||||
slack_team_identity, organization, slack_id="slack_id_2"
|
||||
)
|
||||
|
||||
|
|
@ -158,7 +167,7 @@ def test_start_update_slack_user_group_for_schedules_organization_deleted(
|
|||
def test_update_or_create_slack_usergroup_from_slack(
|
||||
mock_usergroups_list, mock_usergroups_users_list, make_organization_with_slack_team_identity
|
||||
):
|
||||
organization, slack_team_identity = make_organization_with_slack_team_identity()
|
||||
_, slack_team_identity = make_organization_with_slack_team_identity()
|
||||
SlackUserGroup.update_or_create_slack_usergroup_from_slack("test_slack_id", slack_team_identity)
|
||||
|
||||
usergroup = SlackUserGroup.objects.get()
|
||||
|
|
@ -182,7 +191,7 @@ def test_update_or_create_slack_usergroup_from_slack(
|
|||
def test_update_or_create_slack_usergroup_from_slack_group_not_found(
|
||||
mock_usergroups_list, make_organization_with_slack_team_identity
|
||||
):
|
||||
organization, slack_team_identity = make_organization_with_slack_team_identity()
|
||||
_, slack_team_identity = make_organization_with_slack_team_identity()
|
||||
SlackUserGroup.update_or_create_slack_usergroup_from_slack("other_id", slack_team_identity)
|
||||
|
||||
# no group is created, no error is raised
|
||||
|
|
@ -208,7 +217,7 @@ def test_update_or_create_slack_usergroup_from_slack_group_not_found(
|
|||
def test_populate_slack_usergroups_for_team(
|
||||
mock_usergroups_list, mock_usergroups_users_list, make_organization_with_slack_team_identity
|
||||
):
|
||||
organization, slack_team_identity = make_organization_with_slack_team_identity()
|
||||
_, slack_team_identity = make_organization_with_slack_team_identity()
|
||||
populate_slack_usergroups_for_team(slack_team_identity.pk)
|
||||
|
||||
usergroup = SlackUserGroup.objects.get()
|
||||
|
|
@ -226,12 +235,8 @@ def test_get_users_from_members_for_organization(
|
|||
):
|
||||
organization, slack_team_identity = make_organization_with_slack_team_identity()
|
||||
|
||||
user_1, slack_user_identity_1 = make_user_with_slack_user_identity(
|
||||
slack_team_identity, organization, slack_id="slack_id_1"
|
||||
)
|
||||
user_2, slack_user_identity_2 = make_user_with_slack_user_identity(
|
||||
slack_team_identity, organization, slack_id="slack_id_2"
|
||||
)
|
||||
user_1, _ = make_user_with_slack_user_identity(slack_team_identity, organization, slack_id="slack_id_1")
|
||||
user_2, _ = make_user_with_slack_user_identity(slack_team_identity, organization, slack_id="slack_id_2")
|
||||
user_group = make_slack_user_group(slack_team_identity)
|
||||
user_group.members = ["slack_id_1", "slack_id_2"]
|
||||
user_group.save(update_fields=["members"])
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ def persist_access_and_refresh_tokens(backend: typing.Type[BaseAuth], response:
|
|||
|
||||
https://medium.com/starthinker/google-oauth-2-0-access-token-and-refresh-token-explained-cccf2fc0a6d9
|
||||
"""
|
||||
user.finish_google_oauth2_connection_flow(response)
|
||||
user.save_google_oauth2_settings(response)
|
||||
|
||||
|
||||
def disconnect_user_google_oauth2_settings(backend: typing.Type[BaseAuth], user: User, *args, **kwargs):
|
||||
|
|
@ -58,6 +58,6 @@ def disconnect_user_google_oauth2_settings(backend: typing.Type[BaseAuth], user:
|
|||
else:
|
||||
logger.info(f"Google OAuth2 token for user {user_pk} is already invalid or revoked, ignoring error")
|
||||
|
||||
user.finish_google_oauth2_disconnection_flow()
|
||||
user.reset_google_oauth2_settings()
|
||||
|
||||
logger.info(f"Successfully disconnected user {user.pk} from Google OAuth2")
|
||||
|
|
|
|||
|
|
@ -382,8 +382,14 @@ class User(models.Model):
|
|||
self.alert_group_table_selected_columns = columns
|
||||
self.save(update_fields=["alert_group_table_selected_columns"])
|
||||
|
||||
def finish_google_oauth2_connection_flow(self, google_oauth2_response: "GoogleOauth2Response") -> None:
|
||||
_obj, created = GoogleOAuth2User.objects.update_or_create(
|
||||
def save_google_oauth2_settings(self, google_oauth2_response: "GoogleOauth2Response") -> None:
|
||||
logger.info(
|
||||
f"Saving Google OAuth2 settings for user {self.pk} "
|
||||
f"sub={google_oauth2_response.get('sub')} "
|
||||
f"oauth_scope={google_oauth2_response.get('oauth_scope')}"
|
||||
)
|
||||
|
||||
_, created = GoogleOAuth2User.objects.update_or_create(
|
||||
user=self,
|
||||
defaults={
|
||||
"google_user_id": google_oauth2_response.get("sub"),
|
||||
|
|
@ -398,7 +404,9 @@ class User(models.Model):
|
|||
}
|
||||
self.save(update_fields=["google_calendar_settings"])
|
||||
|
||||
def finish_google_oauth2_disconnection_flow(self) -> None:
|
||||
def reset_google_oauth2_settings(self) -> None:
|
||||
logger.info(f"Resetting Google OAuth2 settings for user {self.pk}")
|
||||
|
||||
GoogleOAuth2User.objects.filter(user=self).delete()
|
||||
|
||||
self.google_calendar_settings = None
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ def test_has_google_oauth2_connected(make_organization_and_user, make_google_oau
|
|||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_finish_google_oauth2_connection_flow(make_organization_and_user):
|
||||
def test_save_google_oauth2_settings(make_organization_and_user):
|
||||
oauth_response = {
|
||||
"access_token": "access",
|
||||
"refresh_token": "refresh",
|
||||
|
|
@ -134,7 +134,7 @@ def test_finish_google_oauth2_connection_flow(make_organization_and_user):
|
|||
assert GoogleOAuth2User.objects.filter(user=user).exists() is False
|
||||
assert user.google_calendar_settings is None
|
||||
|
||||
user.finish_google_oauth2_connection_flow(oauth_response)
|
||||
user.save_google_oauth2_settings(oauth_response)
|
||||
user.refresh_from_db()
|
||||
|
||||
google_oauth_user = user.google_oauth2_user
|
||||
|
|
@ -151,7 +151,7 @@ def test_finish_google_oauth2_connection_flow(make_organization_and_user):
|
|||
"scope": "scope2",
|
||||
}
|
||||
|
||||
user.finish_google_oauth2_connection_flow(oauth_response2)
|
||||
user.save_google_oauth2_settings(oauth_response2)
|
||||
user.refresh_from_db()
|
||||
|
||||
google_oauth_user = user.google_oauth2_user
|
||||
|
|
@ -162,10 +162,10 @@ def test_finish_google_oauth2_connection_flow(make_organization_and_user):
|
|||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_finish_google_oauth2_disconnection_flow(make_organization_and_user):
|
||||
def test_reset_google_oauth2_settings(make_organization_and_user):
|
||||
_, user = make_organization_and_user()
|
||||
|
||||
user.finish_google_oauth2_connection_flow(
|
||||
user.save_google_oauth2_settings(
|
||||
{
|
||||
"access_token": "access",
|
||||
"refresh_token": "refresh",
|
||||
|
|
@ -178,7 +178,7 @@ def test_finish_google_oauth2_disconnection_flow(make_organization_and_user):
|
|||
assert user.google_oauth2_user is not None
|
||||
assert user.google_calendar_settings is not None
|
||||
|
||||
user.finish_google_oauth2_disconnection_flow()
|
||||
user.reset_google_oauth2_settings()
|
||||
user.refresh_from_db()
|
||||
|
||||
assert GoogleOAuth2User.objects.filter(user=user).exists() is False
|
||||
|
|
|
|||
|
|
@ -198,6 +198,13 @@ def make_request(
|
|||
status["request_headers"] = error = e.message
|
||||
except InvalidWebhookData as e:
|
||||
status["request_data"] = error = e.message
|
||||
except requests.exceptions.SSLError as e:
|
||||
# Don't raise an exception for SSL errors, as they are out of our control and retrying
|
||||
# isn't going to help. Just show the error to the user and give up
|
||||
#
|
||||
# from the docs (https://requests.readthedocs.io/en/latest/user/advanced/#ssl-cert-verification)
|
||||
# "Requests will throw a SSLError if it’s unable to verify the certificate"
|
||||
status["content"] = error = str(e)
|
||||
except Exception as e:
|
||||
status["content"] = error = str(e)
|
||||
exception = e
|
||||
|
|
|
|||
|
|
@ -736,6 +736,56 @@ def test_execute_webhook_errors(
|
|||
)
|
||||
|
||||
|
||||
@patch(
|
||||
"apps.webhooks.models.webhook.WebhookSession.request",
|
||||
side_effect=requests.exceptions.SSLError("SSL error - foo bar"),
|
||||
)
|
||||
@patch("apps.webhooks.utils.socket.gethostbyname", return_value="8.8.8.8") # make it a valid URL when resolving name
|
||||
@pytest.mark.django_db
|
||||
def test_execute_webhook_ssl_error(
|
||||
_mock_socket_gethostbyname,
|
||||
mock_request,
|
||||
make_organization,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_custom_webhook,
|
||||
):
|
||||
url = "https://something.cool/"
|
||||
expected_error = "SSL error - foo bar"
|
||||
|
||||
organization = make_organization()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
alert_group = make_alert_group(alert_receive_channel, resolved_at=timezone.now(), resolved=True)
|
||||
webhook = make_custom_webhook(
|
||||
organization=organization,
|
||||
http_method="POST",
|
||||
trigger_type=Webhook.TRIGGER_RESOLVE,
|
||||
forward_all=False,
|
||||
url=url,
|
||||
)
|
||||
|
||||
execute_webhook(webhook.pk, alert_group.pk, None, None)
|
||||
|
||||
mock_request.assert_has_calls([call("POST", url, timeout=4, headers={})])
|
||||
|
||||
log = webhook.responses.all()[0]
|
||||
assert log.status_code is None
|
||||
assert log.content == expected_error
|
||||
|
||||
# check log record
|
||||
log_record = alert_group.log_records.last()
|
||||
assert log_record.type == AlertGroupLogRecord.ERROR_ESCALATION_TRIGGER_CUSTOM_WEBHOOK_ERROR
|
||||
assert log_record.step_specific_info == {
|
||||
"trigger": "resolve",
|
||||
"webhook_id": webhook.public_primary_key,
|
||||
"webhook_name": webhook.name,
|
||||
}
|
||||
assert log_record.reason == expected_error
|
||||
assert (
|
||||
log_record.rendered_log_line_action() == f"skipped resolve outgoing webhook `{webhook.name}`: {expected_error}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_response_content_limit(
|
||||
make_organization, make_user_for_organization, make_alert_receive_channel, make_alert_group, make_custom_webhook
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ module = [
|
|||
"factory.*",
|
||||
"fcm_django.*",
|
||||
"firebase_admin.*",
|
||||
"google.auth.exceptions.*",
|
||||
"googleapiclient.discovery.*",
|
||||
"googleapiclient.errors.*",
|
||||
"google.oauth2.credentials.*",
|
||||
|
|
|
|||
|
|
@ -82,10 +82,10 @@ export class SlackStore extends BaseStore {
|
|||
window.location = url_for_redirect;
|
||||
}
|
||||
|
||||
@action.bound
|
||||
async installSlackIntegration() {
|
||||
try {
|
||||
const response = await makeRequestRaw('/login/slack-install-free/', {});
|
||||
|
||||
if (response.status === 201) {
|
||||
this.rootStore.organizationStore.loadCurrentOrganization();
|
||||
} else if (response.status === 200) {
|
||||
|
|
|
|||
|
|
@ -39,3 +39,18 @@
|
|||
.infoblock-icon {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.upgradeSlackBtn {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.upgradeSlackAlert svg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.linkToIncidentWrapper {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,11 +10,13 @@ import {
|
|||
InlineField,
|
||||
Input,
|
||||
Legend,
|
||||
ConfirmModal,
|
||||
} from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import { Block } from 'components/GBlock/Block';
|
||||
import { PluginBridge, SupportedPlugin } from 'components/PluginBridge/PluginBridge';
|
||||
import { PluginLink } from 'components/PluginLink/PluginLink';
|
||||
import { Text } from 'components/Text/Text';
|
||||
import { WithConfirm } from 'components/WithConfirm/WithConfirm';
|
||||
|
|
@ -26,9 +28,11 @@ import { PRIVATE_CHANNEL_NAME } from 'models/slack_channel/slack_channel.config'
|
|||
import { SlackChannel } from 'models/slack_channel/slack_channel.types';
|
||||
import { AppFeature } from 'state/features';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
import { UserActions } from 'utils/authorization/authorization';
|
||||
import { DOCS_SLACK_SETUP, getPluginId } from 'utils/consts';
|
||||
import { useConfirmModal } from 'utils/hooks';
|
||||
import { showApiError } from 'utils/utils';
|
||||
|
||||
import styles from './SlackSettings.module.css';
|
||||
|
|
@ -116,9 +120,12 @@ class _SlackSettings extends Component<SlackProps, SlackState> {
|
|||
slackChannelStore: { items: slackChannelItems },
|
||||
} = store;
|
||||
|
||||
const isUnifiedSlackInstalled = !currentOrganization.slack_team_identity.needs_reinstall;
|
||||
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<Legend>Slack App settings</Legend>
|
||||
{currentOrganization.slack_team_identity.needs_reinstall && <UpgradeToUnifiedSlackBanner />}
|
||||
<InlineField label="Slack Workspace" grow disabled>
|
||||
<Input value={currentOrganization?.slack_team_identity?.cached_name} />
|
||||
</InlineField>
|
||||
|
|
@ -147,33 +154,58 @@ class _SlackSettings extends Component<SlackProps, SlackState> {
|
|||
/>
|
||||
<InlineField>
|
||||
<WithPermissionControlTooltip userAction={UserActions.ChatOpsUpdateSettings}>
|
||||
<WithConfirm
|
||||
title="Remove Slack Integration for all of OnCall"
|
||||
description={
|
||||
<Alert severity="error" title="WARNING">
|
||||
<p>Are you sure to delete this Slack Integration?</p>
|
||||
<p>
|
||||
Removing the integration will also irreverisbly remove the following data for your OnCall plugin:
|
||||
</p>
|
||||
<ul style={{ marginLeft: '20px' }}>
|
||||
<li>default organization Slack channel</li>
|
||||
<li>default Slack channels for OnCall Integrations</li>
|
||||
<li>Slack channels & Slack user groups for OnCall Schedules</li>
|
||||
<li>linked Slack usernames for OnCall Users</li>
|
||||
</ul>
|
||||
<br />
|
||||
<p>
|
||||
If you would like to instead remove your linked Slack username, please head{' '}
|
||||
<PluginLink query={{ page: 'users/me' }}>here</PluginLink>.
|
||||
</p>
|
||||
</Alert>
|
||||
}
|
||||
confirmationText="DELETE"
|
||||
>
|
||||
<Button variant="destructive" onClick={() => this.removeSlackIntegration()}>
|
||||
Disconnect Slack App
|
||||
</Button>
|
||||
</WithConfirm>
|
||||
{isUnifiedSlackInstalled ? (
|
||||
<WithConfirm
|
||||
title="Remove IRM Slack integration"
|
||||
description={
|
||||
<Alert severity="error" title="WARNING">
|
||||
<p>Are you sure to delete this Slack Integration? It will affect both OnCall & Incident.</p>
|
||||
<p>Removing the integration will irreverisbly remove the following data for IRM;</p>
|
||||
<ul style={{ marginLeft: '20px' }}>
|
||||
<li>OnCall default Slack channel</li>
|
||||
<li>Slack channels for OnCall escalation policies</li>
|
||||
<li>Slack channels & Slack user groups for OnCall Schedules</li>
|
||||
<li>linked Slack usernames for OnCall Users</li>
|
||||
<li>Incident hooks</li>
|
||||
</ul>
|
||||
<br />
|
||||
</Alert>
|
||||
}
|
||||
confirmationText="DELETE"
|
||||
>
|
||||
<Button variant="destructive" onClick={() => this.removeSlackIntegration()}>
|
||||
Disconnect Slack App
|
||||
</Button>
|
||||
</WithConfirm>
|
||||
) : (
|
||||
<WithConfirm
|
||||
title="Remove Slack Integration for all of OnCall"
|
||||
description={
|
||||
<Alert severity="error" title="WARNING">
|
||||
<p>Are you sure to delete this Slack Integration?</p>
|
||||
<p>
|
||||
Removing the integration will also irreverisbly remove the following data for your OnCall plugin:
|
||||
</p>
|
||||
<ul style={{ marginLeft: '20px' }}>
|
||||
<li>default organization Slack channel</li>
|
||||
<li>default Slack channels for OnCall Integrations</li>
|
||||
<li>Slack channels & Slack user groups for OnCall Schedules</li>
|
||||
<li>linked Slack usernames for OnCall Users</li>
|
||||
</ul>
|
||||
<br />
|
||||
<p>
|
||||
If you would like to instead remove your linked Slack username, please head{' '}
|
||||
<PluginLink query={{ page: 'users/me' }}>here</PluginLink>.
|
||||
</p>
|
||||
</Alert>
|
||||
}
|
||||
confirmationText="DELETE"
|
||||
>
|
||||
<Button variant="destructive" onClick={() => this.removeSlackIntegration()}>
|
||||
Disconnect Slack App
|
||||
</Button>
|
||||
</WithConfirm>
|
||||
)}
|
||||
</WithPermissionControlTooltip>
|
||||
</InlineField>
|
||||
<Legend>Additional settings</Legend>
|
||||
|
|
@ -201,19 +233,20 @@ class _SlackSettings extends Component<SlackProps, SlackState> {
|
|||
</WithPermissionControlTooltip>
|
||||
</HorizontalGroup>
|
||||
</InlineField>
|
||||
{currentOrganization.slack_team_identity.needs_reinstall && (
|
||||
<>
|
||||
<Legend>Unified Slack App</Legend>
|
||||
<InlineField>
|
||||
<WithPermissionControlTooltip userAction={UserActions.ChatOpsUpdateSettings}>
|
||||
<Button onClick={this.handleOpenSlackInstructions}>
|
||||
<HorizontalGroup spacing="xs" align="center">
|
||||
<Icon name="external-link-alt" className={cx('external-link-style')} /> Reinstall Slack App
|
||||
</HorizontalGroup>
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
</InlineField>
|
||||
</>
|
||||
{isUnifiedSlackInstalled && (
|
||||
<div className={styles.linkToIncidentWrapper}>
|
||||
<PluginBridge plugin={SupportedPlugin.Incident}>
|
||||
<Text type="secondary">
|
||||
<a
|
||||
href={`/a/${SupportedPlugin.Incident}/integrations/grate.irm.slack`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Text type="link">Open Slack Incident settings</Text>
|
||||
</a>
|
||||
</Text>
|
||||
</PluginBridge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -251,6 +284,7 @@ class _SlackSettings extends Component<SlackProps, SlackState> {
|
|||
const { store } = this.props;
|
||||
const { showENVVariablesButton } = this.state;
|
||||
const isLiveSettingAvailable = store.hasFeature(AppFeature.LiveSettings) && showENVVariablesButton;
|
||||
const isUnifiedSlackEnabled = store.hasFeature(AppFeature.UnifiedSlack);
|
||||
|
||||
return (
|
||||
<VerticalGroup spacing="lg">
|
||||
|
|
@ -261,7 +295,9 @@ class _SlackSettings extends Component<SlackProps, SlackState> {
|
|||
<SlackNewIcon />
|
||||
</div>
|
||||
<Text className={cx('infoblock-text')}>
|
||||
Connecting Slack App will allow you to manage alert groups in your team Slack workspace.
|
||||
{isUnifiedSlackEnabled
|
||||
? 'Connecting Slack App will allow you to manage alert groups and incidents in your team Slack workspace.'
|
||||
: 'Connecting Slack App will allow you to manage alert groups in your team Slack workspace.'}
|
||||
</Text>
|
||||
<Text className={cx('infoblock-text')}>
|
||||
After a basic workspace connection your team members need to connect their personal Slack accounts in
|
||||
|
|
@ -305,4 +341,61 @@ class _SlackSettings extends Component<SlackProps, SlackState> {
|
|||
};
|
||||
}
|
||||
|
||||
const UpgradeToUnifiedSlackBanner = observer(() => {
|
||||
const {
|
||||
slackStore: { installSlackIntegration },
|
||||
} = useStore();
|
||||
const { modalProps, openModal } = useConfirmModal();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmModal {...modalProps} />
|
||||
<Alert
|
||||
className={styles.upgradeSlackAlert}
|
||||
severity="success"
|
||||
title="Upgrade to Grafana IRM unified Slack app"
|
||||
buttonContent={<div>Upgrade</div>}
|
||||
>
|
||||
We've rebranded the OnCall Slack app as the Grafana IRM Slack app, now with incident management features.
|
||||
<p>Click "Upgrade" to reviewn and approve the new permissions and complete the process.</p>
|
||||
<p>For more details, check our documentation.</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className={styles.upgradeSlackBtn}
|
||||
onClick={() =>
|
||||
openModal({
|
||||
confirmText: 'Confirm',
|
||||
onConfirm: installSlackIntegration,
|
||||
confirmButtonVariant: 'primary',
|
||||
title: `Upgrade to Grafana IRM Slack app`,
|
||||
description: (
|
||||
<div>
|
||||
<p>
|
||||
You will be redirected to Slack to approve additional permissions for the Grafana IRM Slack app.{' '}
|
||||
</p>
|
||||
<p>
|
||||
These permissions are necessary for incident management. You can view the detailed list of new
|
||||
permissions here.[LINK]
|
||||
</p>
|
||||
<p>After the upgrade, you'll be able to manage incidents in Slack using the Grafana IRM Slack app.</p>
|
||||
<ul style={{ marginLeft: '20px' }}>
|
||||
<li>Your OnCall Slack configuration will remain unchanged. </li>
|
||||
<li>
|
||||
Your Incident Slack integration will be upgraded to use the Grafana IRM Slack app. Please refer to
|
||||
the documentation for more details.[LINK]
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
confirmVariant: 'secondary',
|
||||
})
|
||||
}
|
||||
>
|
||||
Upgrade
|
||||
</Button>
|
||||
</Alert>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const SlackSettings = withMobXProviderContext(_SlackSettings);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export enum AppFeature {
|
||||
Slack = 'slack',
|
||||
UnifiedSlack = 'unified_slack',
|
||||
Telegram = 'telegram',
|
||||
LiveSettings = 'live_settings',
|
||||
CloudNotifications = 'grafana_cloud_notifications',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue