v1.14.0
This commit is contained in:
commit
95ad2f236c
29 changed files with 732 additions and 111 deletions
|
|
@ -6,6 +6,20 @@
|
|||
# [Semantic versioning](https://semver.org/) is used to help the reader identify the significance of changes.
|
||||
# Changes are relevant to this script and the support docs.mk GNU Make interface.
|
||||
#
|
||||
# ## 8.3.0 (2024-12-27)
|
||||
#
|
||||
# ### Added
|
||||
#
|
||||
# - Debug output of the final command when DEBUG=true.
|
||||
#
|
||||
# Useful to inspect if the script is correctly constructing the final command.
|
||||
#
|
||||
# ## 8.2.0 (2024-12-22)
|
||||
#
|
||||
# ### Removed
|
||||
#
|
||||
# - Special cases for Oracle and Datadog plugins now that they exist in the plugins monorepo.
|
||||
#
|
||||
# ## 8.1.0 (2024-08-22)
|
||||
#
|
||||
# ### Added
|
||||
|
|
@ -13,7 +27,7 @@
|
|||
# - Additional website mounts for projects that use the website repository.
|
||||
#
|
||||
# Mounts are required for `make docs` to work in the website repository or with the website project.
|
||||
# The Makefile is also mounted for convenient development of the procedure that repository.
|
||||
# The Makefile is also mounted for convenient development of the procedure in that repository.
|
||||
#
|
||||
# ## 8.0.1 (2024-07-01)
|
||||
#
|
||||
|
|
@ -355,8 +369,6 @@ SOURCES_grafana_cloud_frontend_observability_faro_web_sdk='faro-web-sdk'
|
|||
SOURCES_helm_charts_mimir_distributed='mimir'
|
||||
SOURCES_helm_charts_tempo_distributed='tempo'
|
||||
SOURCES_opentelemetry='opentelemetry-docs'
|
||||
SOURCES_plugins_grafana_datadog_datasource='datadog-datasource'
|
||||
SOURCES_plugins_grafana_oracle_datasource='oracle-datasource'
|
||||
SOURCES_resources='website'
|
||||
|
||||
VERSIONS_as_code='UNVERSIONED'
|
||||
|
|
@ -367,8 +379,6 @@ VERSIONS_grafana_cloud_k6='UNVERSIONED'
|
|||
VERSIONS_grafana_cloud_data_configuration_integrations='UNVERSIONED'
|
||||
VERSIONS_grafana_cloud_frontend_observability_faro_web_sdk='UNVERSIONED'
|
||||
VERSIONS_opentelemetry='UNVERSIONED'
|
||||
VERSIONS_plugins_grafana_datadog_datasource='latest'
|
||||
VERSIONS_plugins_grafana_oracle_datasource='latest'
|
||||
VERSIONS_resources='UNVERSIONED'
|
||||
VERSIONS_technical_documentation='UNVERSIONED'
|
||||
VERSIONS_website='UNVERSIONED'
|
||||
|
|
@ -378,8 +388,6 @@ PATHS_grafana_cloud='content/docs/grafana-cloud'
|
|||
PATHS_helm_charts_mimir_distributed='docs/sources/helm-charts/mimir-distributed'
|
||||
PATHS_helm_charts_tempo_distributed='docs/sources/helm-charts/tempo-distributed'
|
||||
PATHS_mimir='docs/sources/mimir'
|
||||
PATHS_plugins_grafana_datadog_datasource='docs/sources'
|
||||
PATHS_plugins_grafana_oracle_datasource='docs/sources'
|
||||
PATHS_resources='content'
|
||||
PATHS_tempo='docs/sources/tempo'
|
||||
PATHS_website='content'
|
||||
|
|
@ -631,7 +639,7 @@ POSIX_HERESTRING
|
|||
|
||||
case "${_project}" in
|
||||
# Workaround for arbitrary mounts where the version field is expected to be the local directory
|
||||
# and the repo field is expected to be the container directory.
|
||||
# and the repo field is expected to be the container directory.
|
||||
arbitrary)
|
||||
echo "${_project}^${_version}^${_repo}^" # TODO
|
||||
;;
|
||||
|
|
@ -801,6 +809,10 @@ case "${image}" in
|
|||
| sed "s#$(proj_dst "${proj}")#sources#"
|
||||
EOF
|
||||
|
||||
if [ -n "${DEBUG}" ]; then
|
||||
debg "${cmd}"
|
||||
fi
|
||||
|
||||
case "${OUTPUT_FORMAT}" in
|
||||
human)
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
|
|
@ -837,6 +849,10 @@ EOF
|
|||
/hugo/content/docs
|
||||
EOF
|
||||
|
||||
if [ -n "${DEBUG}" ]; then
|
||||
debg "${cmd}"
|
||||
fi
|
||||
|
||||
case "${OUTPUT_FORMAT}" in
|
||||
human)
|
||||
${cmd} --output=line \
|
||||
|
|
|
|||
|
|
@ -89,9 +89,16 @@ to the team's ChatOps channels and start an appropriate escalation chain.
|
|||
|
||||
## Set up direct paging for a team
|
||||
|
||||
By default all teams will have a direct paging integration created for them. However, these are not configured by default.
|
||||
If a team does not have their direct paging integration configured, such that it is "contactable" (ie. it has an
|
||||
escalation chain assigned to it, or has at least one Chatops integration connected to send notifications to), you will
|
||||
By default all teams will have a direct paging integration created for them. Each direct paging integration will be
|
||||
created with two routes:
|
||||
|
||||
- a non-default route which has a Jinja2 filtering term of `{{ payload.oncall.important }}`
|
||||
(see [Important Escalations](#important-escalations) below for more details)
|
||||
- a default route to capture all other alerts
|
||||
|
||||
However, these integrations are not configured by default to be "contactable" (ie. their routes will have no
|
||||
escalation chains assigned to them, nor any Chatops integrations connected to send notifications to).
|
||||
If a team does not have their direct paging integration configured, such that it is "contactable" , you will
|
||||
not be able to direct page this team. If this happens, consider following the following steps for the team (or reach out
|
||||
to the relevant team and suggest doing so).
|
||||
|
||||
|
|
@ -99,3 +106,30 @@ Navigate to the **Integrations** page and find the "Direct paging" integration f
|
|||
integration's detail page, you can customize its settings, link it to an escalation chain, and configure associated
|
||||
ChatOps channels. To confirm that the integration is functioning as intended, [create a new alert group](#page-a-team)
|
||||
and select the same team for a test run.
|
||||
|
||||
### Important escalations
|
||||
|
||||
Sometimes you really need to get the attention of a particular team. When directly paging a team, it is possible to
|
||||
page them using an "important escalation". Practically speaking, this will create an alert, using the specified team's
|
||||
direct paging integration as such:
|
||||
|
||||
```json
|
||||
{
|
||||
"oncall": {
|
||||
"title": "IRM is paging Network team to join escalation",
|
||||
"message": "I really need someone from your team to come take a look! The k8s cluster is down!",
|
||||
"uid": "8a20b8d1-56fd-482e-824e-43fbd1bd7b10",
|
||||
"author_username": "irm",
|
||||
"permalink": null,
|
||||
"important": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When you are directly paging a team, either via the web UI, chatops apps, or the API, you can specify that this
|
||||
esclation be "important", which will effectively set the value of `oncall.important` to `true`. As mentioned above in
|
||||
[Set up direct paging for a team](#set-up-direct-paging-for-a-team), direct paging integrations come pre-configured with
|
||||
two routes, with the non-default route having a Jinja2 filtering term of `{{ payload.oncall.important }}`.
|
||||
|
||||
This allows teams to be contacted via different escalation chains, depending on whether or not the user paging them
|
||||
believes that this is an "important escalation".
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ refs:
|
|||
destination: /docs/oncall/<ONCALL_VERSION>/configure/integrations/references/manual
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/configure/integrations/references/manual
|
||||
manual-paging-team-important:
|
||||
- pattern: /docs/oncall/
|
||||
destination: /docs/oncall/<ONCALL_VERSION>/configure/integrations/references/manual#important-escalations
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/configure/integrations/references/manual#important-escalations
|
||||
---
|
||||
|
||||
# Escalation HTTP API
|
||||
|
|
@ -90,7 +95,8 @@ curl "{{API_URL}}/api/v1/escalation/" \
|
|||
"title": "We are seeing a network outage in the datacenter",
|
||||
"message": "I need help investigating, can you join the investigation?",
|
||||
"source_url": "https://github.com/myorg/myrepo/issues/123",
|
||||
"team": "TI73TDU19W48J"
|
||||
"team": "TI73TDU19W48J",
|
||||
"important_team_escalation": true
|
||||
}'
|
||||
```
|
||||
|
||||
|
|
@ -176,6 +182,7 @@ The above command returns JSON structured in the following way:
|
|||
| `team` | No | Yes (see [Things to Note](#things-to-note)) | Grafana OnCall team ID. If specified, will use the "Direct Paging" Integration associated with this Grafana OnCall team, to create the Alert Group. |
|
||||
| `users` | No | Yes (see [Things to Note](#things-to-note)) | List of user(s) to escalate to. See above request example for object schema. `id` represents the Grafana OnCall user's ID. `important` is a boolean representing whether to escalate the Alert Group using this user's default or important personal notification policy. |
|
||||
| `alert_group_id` | No | No | If specified, will escalate the specified users for this Alert Group. |
|
||||
| `important_team_escalation` | No | No | Sets the value of `payload.oncall.important` to the value specified here (default is `False`; see [Things to Note](#things-to-note) for more details). |
|
||||
|
||||
## Things to note
|
||||
|
||||
|
|
@ -186,6 +193,10 @@ existing Alert Group
|
|||
if you are trying to escalate to a set of users on an existing Alert Group, you cannot update the `title`, `message`, or
|
||||
`source_url` of that Alert Group
|
||||
- If escalating to a set of users for an existing Alert Group, the Alert Group cannot be in a resolved state
|
||||
- Regarding `important_team_escalation`; this can be useful to send an "important" escalation to the specified team.
|
||||
Teams can configure their Direct Paging Integration to route to different escalation chains based on the value of
|
||||
`payload.oncall.important`. See [Manual paging integration - important escalations](ref:manual-paging-team-important)
|
||||
for more details.
|
||||
|
||||
**HTTP request**
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
# Generated by Django 4.2.17 on 2024-12-20 14:19
|
||||
|
||||
import logging
|
||||
|
||||
from django.db import migrations
|
||||
from django.db.models import Count
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def upsert_direct_paging_integration_routes(apps, schema_editor):
|
||||
AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel")
|
||||
ChannelFilter = apps.get_model("alerts", "ChannelFilter")
|
||||
|
||||
DIRECT_PAGING_INTEGRATION_TYPE = "direct_paging"
|
||||
IMPORTANT_FILTERING_TERM = "{{ payload.oncall.important }}"
|
||||
|
||||
# Fetch all direct paging integrations
|
||||
logger.info("Fetching direct paging integrations which have not had their routes updated.")
|
||||
|
||||
# Ignore updating Direct Paging integrations that have > 1 route, as this means that users have
|
||||
# gone ahead and created their own routes. We don't want to overwrite these.
|
||||
unedited_direct_paging_integrations = (
|
||||
AlertReceiveChannel.objects
|
||||
.filter(integration=DIRECT_PAGING_INTEGRATION_TYPE)
|
||||
.annotate(num_routes=Count("channel_filters"))
|
||||
.filter(num_routes=1)
|
||||
)
|
||||
|
||||
integration_count = unedited_direct_paging_integrations.count()
|
||||
if integration_count == 0:
|
||||
logger.info("No integrations found which meet this criteria. No routes will be upserted.")
|
||||
return
|
||||
|
||||
logger.info(f"Found {integration_count} direct paging integrations that meet this criteria.")
|
||||
|
||||
# Direct Paging Integrations are currently created with a single default route (order=0)
|
||||
# see AlertReceiveChannelManager.create_missing_direct_paging_integrations
|
||||
#
|
||||
# we first need to update this route to be order=1, and then we will subsequently bulk-create the
|
||||
# non-default route (order=0) which will have a filtering term set
|
||||
routes = ChannelFilter.objects.filter(
|
||||
alert_receive_channel__in=unedited_direct_paging_integrations,
|
||||
is_default=True,
|
||||
order=0,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Swapping the order=0 value to order=1 for {routes.count()} Direct Paging Integrations default routes"
|
||||
)
|
||||
|
||||
updated_rows = routes.update(order=1)
|
||||
logger.info(f"Swapped order=0 to order=1 for {updated_rows} Direct Paging Integrations default routes")
|
||||
|
||||
# Bulk create the new non-default routes
|
||||
logger.info(
|
||||
f"Creating new non-default routes for {len(unedited_direct_paging_integrations)} Direct Paging Integrations"
|
||||
)
|
||||
created_objs = ChannelFilter.objects.bulk_create(
|
||||
[
|
||||
ChannelFilter(
|
||||
alert_receive_channel=integration,
|
||||
filtering_term=IMPORTANT_FILTERING_TERM,
|
||||
filtering_term_type=1, # 1 = ChannelFilter.FILTERING_TERM_TYPE_JINJA2
|
||||
is_default=False,
|
||||
order=0,
|
||||
) for integration in unedited_direct_paging_integrations
|
||||
],
|
||||
batch_size=5000,
|
||||
)
|
||||
logger.info(f"Created {len(created_objs)} new non-default routes for Direct Paging Integrations")
|
||||
|
||||
logger.info("Migration for direct paging integration routes completed.")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("alerts", "0071_migrate_labels"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(upsert_direct_paging_integration_routes, migrations.RunPython.noop),
|
||||
]
|
||||
|
|
@ -126,6 +126,8 @@ class AlertReceiveChannelManager(models.Manager):
|
|||
def create_missing_direct_paging_integrations(organization: "Organization") -> None:
|
||||
from apps.alerts.models import ChannelFilter
|
||||
|
||||
logger.info(f"Starting create_missing_direct_paging_integrations for organization: {organization.id}")
|
||||
|
||||
# fetch teams without direct paging integration
|
||||
teams_missing_direct_paging = list(
|
||||
organization.teams.exclude(
|
||||
|
|
@ -134,10 +136,17 @@ class AlertReceiveChannelManager(models.Manager):
|
|||
).values_list("team_id", flat=True)
|
||||
)
|
||||
)
|
||||
number_of_teams_missing_direct_paging = len(teams_missing_direct_paging)
|
||||
logger.info(
|
||||
f"Found {number_of_teams_missing_direct_paging} teams missing direct paging integrations.",
|
||||
)
|
||||
|
||||
if not teams_missing_direct_paging:
|
||||
logger.info("No missing direct paging integrations found. Exiting.")
|
||||
return
|
||||
|
||||
# create missing integrations
|
||||
logger.info(f"Creating missing direct paging integrations for {number_of_teams_missing_direct_paging} teams.")
|
||||
AlertReceiveChannel.objects.bulk_create(
|
||||
[
|
||||
AlertReceiveChannel(
|
||||
|
|
@ -151,29 +160,49 @@ class AlertReceiveChannelManager(models.Manager):
|
|||
batch_size=5000,
|
||||
ignore_conflicts=True, # ignore if direct paging integration already exists for team
|
||||
)
|
||||
logger.info("Missing direct paging integrations creation step completed.")
|
||||
|
||||
# fetch integrations for teams (some of them are created above, but some may already exist previously)
|
||||
alert_receive_channels = organization.alert_receive_channels.filter(
|
||||
team__in=teams_missing_direct_paging, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING
|
||||
)
|
||||
logger.info(f"Fetched {alert_receive_channels.count()} direct paging integrations for the specified teams.")
|
||||
|
||||
# create default routes
|
||||
# we create two routes for each Direct Paging Integration
|
||||
# 1. route for important alerts (using the payload.oncall.important alert field value) - non-default
|
||||
# 2. route for all other alerts - default
|
||||
routes_to_create = []
|
||||
for alert_receive_channel in alert_receive_channels:
|
||||
routes_to_create.extend(
|
||||
[
|
||||
ChannelFilter(
|
||||
alert_receive_channel=alert_receive_channel,
|
||||
filtering_term="{{ payload.oncall.important }}",
|
||||
filtering_term_type=ChannelFilter.FILTERING_TERM_TYPE_JINJA2,
|
||||
is_default=False,
|
||||
order=0,
|
||||
),
|
||||
ChannelFilter(
|
||||
alert_receive_channel=alert_receive_channel,
|
||||
filtering_term=None,
|
||||
is_default=True,
|
||||
order=1,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
logger.info(f"Creating {len(routes_to_create)} channel filter routes.")
|
||||
ChannelFilter.objects.bulk_create(
|
||||
[
|
||||
ChannelFilter(
|
||||
alert_receive_channel=alert_receive_channel,
|
||||
filtering_term=None,
|
||||
is_default=True,
|
||||
order=0,
|
||||
)
|
||||
for alert_receive_channel in alert_receive_channels
|
||||
],
|
||||
routes_to_create,
|
||||
batch_size=5000,
|
||||
ignore_conflicts=True, # ignore if default route already exists for integration
|
||||
ignore_conflicts=True, # ignore if routes already exist for integration
|
||||
)
|
||||
logger.info("Direct paging routes creation completed.")
|
||||
|
||||
# add integrations to metrics cache
|
||||
logger.info("Adding integrations to metrics cache.")
|
||||
metrics_add_integrations_to_cache(list(alert_receive_channels), organization)
|
||||
logger.info("Integrations have been added to the metrics cache.")
|
||||
|
||||
def get_queryset(self):
|
||||
return AlertReceiveChannelQueryset(self.model, using=self._db).filter(
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ class DirectPagingAlertPayload(typing.TypedDict):
|
|||
def _trigger_alert(
|
||||
organization: Organization,
|
||||
team: Team | None,
|
||||
important_team_escalation: bool,
|
||||
message: str,
|
||||
title: str,
|
||||
permalink: str | None,
|
||||
|
|
@ -82,6 +83,13 @@ def _trigger_alert(
|
|||
"uid": str(uuid4()), # avoid grouping
|
||||
"author_username": from_user.username,
|
||||
"permalink": permalink,
|
||||
# NOTE: this field is mostly being added for purposes of escalating to a team
|
||||
# this field is provided via the web UI/API/slack as a checkbox, indicating that the user doing the paging
|
||||
# would like to send an "important" page to the team.
|
||||
#
|
||||
# Teams can configure routing in their Direct Paging Integration to route based on this field to different
|
||||
# escalation chains
|
||||
"important": important_team_escalation,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -128,6 +136,7 @@ def direct_paging(
|
|||
source_url: str | None = None,
|
||||
grafana_incident_id: str | None = None,
|
||||
team: Team | None = None,
|
||||
important_team_escalation: bool = False,
|
||||
users: UserNotifications | None = None,
|
||||
alert_group: AlertGroup | None = None,
|
||||
) -> AlertGroup | None:
|
||||
|
|
@ -156,7 +165,16 @@ def direct_paging(
|
|||
# create alert group if needed
|
||||
with transaction.atomic():
|
||||
if alert_group is None:
|
||||
alert_group = _trigger_alert(organization, team, message, title, source_url, grafana_incident_id, from_user)
|
||||
alert_group = _trigger_alert(
|
||||
organization,
|
||||
team,
|
||||
important_team_escalation,
|
||||
message,
|
||||
title,
|
||||
source_url,
|
||||
grafana_incident_id,
|
||||
from_user,
|
||||
)
|
||||
|
||||
for u, important in users:
|
||||
alert_group.log_records.create(
|
||||
|
|
|
|||
|
|
@ -259,27 +259,47 @@ def test_create_missing_direct_paging_integrations(
|
|||
):
|
||||
organization = make_organization()
|
||||
|
||||
# team with no direct paging integration
|
||||
# two teams with no direct paging integration
|
||||
team1 = make_team(organization)
|
||||
team2 = make_team(organization)
|
||||
|
||||
# team with direct paging integration
|
||||
team2 = make_team(organization)
|
||||
team3 = make_team(organization)
|
||||
alert_receive_channel = make_alert_receive_channel(
|
||||
organization, team=team2, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING
|
||||
organization, team=team3, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING
|
||||
)
|
||||
make_channel_filter(alert_receive_channel, is_default=True, order=0)
|
||||
|
||||
# create missing direct paging integration for organization
|
||||
AlertReceiveChannel.objects.create_missing_direct_paging_integrations(organization)
|
||||
|
||||
assert organization.alert_receive_channels.count() == 3
|
||||
|
||||
# check that missing integrations and default routes were created
|
||||
assert organization.alert_receive_channels.count() == 2
|
||||
mock_metrics_add_integrations_to_cache.assert_called_once()
|
||||
#
|
||||
# NOTE: we explicitly don't test team3, it already has a Direct Paging integraiton associated with it
|
||||
# and AlertReceiveChannel.objects.create_missing_direct_paging_integrations is not responsible for filling
|
||||
# in missing routes.
|
||||
#
|
||||
# See apps/alerts/migrations/0072_upsert_direct_paging_integration_routes.py which is a data migration that does
|
||||
# exactly this.
|
||||
for team in [team1, team2]:
|
||||
alert_receive_channel = organization.alert_receive_channels.get(
|
||||
team=team, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING
|
||||
)
|
||||
assert alert_receive_channel.channel_filters.get().is_default
|
||||
alert_receive_channel = organization.alert_receive_channels.get(team=team)
|
||||
|
||||
direct_paging_integration_routes = alert_receive_channel.channel_filters.all()
|
||||
|
||||
assert direct_paging_integration_routes.count() == 2
|
||||
|
||||
for route in direct_paging_integration_routes:
|
||||
if route.is_default:
|
||||
assert route.order == 1
|
||||
assert route.filtering_term is None
|
||||
else:
|
||||
assert route.order == 0
|
||||
assert route.filtering_term == "{{ payload.oncall.important }}"
|
||||
assert route.filtering_term_type == route.FILTERING_TERM_TYPE_JINJA2
|
||||
|
||||
mock_metrics_add_integrations_to_cache.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from unittest.mock import call, patch
|
||||
from unittest.mock import ANY, call, patch
|
||||
|
||||
import pytest
|
||||
from django.utils import timezone
|
||||
|
|
@ -86,23 +86,46 @@ def test_direct_paging_user(make_organization, make_user_for_organization, djang
|
|||
assert_log_record(ag, f"{from_user.username} paged user {u.username}", expected_info=expected_info)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("important_team_escalation", [True, False])
|
||||
@pytest.mark.django_db
|
||||
def test_direct_paging_team(make_organization, make_team, make_user_for_organization):
|
||||
def test_direct_paging_team(make_organization, make_team, make_user_for_organization, important_team_escalation):
|
||||
organization = make_organization()
|
||||
from_user = make_user_for_organization(organization)
|
||||
team = make_team(organization)
|
||||
|
||||
from_author_username = from_user.username
|
||||
source_url = "https://www.example.com"
|
||||
title = f"{from_author_username} is paging {team.name} to join escalation"
|
||||
msg = "Fire"
|
||||
|
||||
direct_paging(organization, from_user, msg, team=team)
|
||||
direct_paging(
|
||||
organization,
|
||||
from_user,
|
||||
msg,
|
||||
source_url=source_url,
|
||||
team=team,
|
||||
important_team_escalation=important_team_escalation,
|
||||
)
|
||||
|
||||
# alert group created
|
||||
alert_groups = AlertGroup.objects.all()
|
||||
assert alert_groups.count() == 1
|
||||
ag = alert_groups.get()
|
||||
alert = ag.alerts.get()
|
||||
assert alert.title == f"{from_user.username} is paging {team.name} to join escalation"
|
||||
assert alert.title == title
|
||||
assert alert.message == msg
|
||||
|
||||
assert alert.raw_request_data == {
|
||||
"oncall": {
|
||||
"title": title,
|
||||
"message": msg,
|
||||
"uid": ANY,
|
||||
"author_username": from_author_username,
|
||||
"permalink": source_url,
|
||||
"important": important_team_escalation,
|
||||
},
|
||||
}
|
||||
|
||||
assert ag.channel.verbal_name == f"Direct paging ({team.name} team)"
|
||||
assert ag.channel.team == team
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ class BasePagingSerializer(serializers.Serializer):
|
|||
|
||||
users = UserReferenceSerializer(many=True, required=False, default=list)
|
||||
team = TeamPrimaryKeyRelatedField(allow_null=True, default=CurrentTeamDefault())
|
||||
important_team_escalation = serializers.BooleanField(required=False, default=False)
|
||||
|
||||
alert_group_id = serializers.CharField(required=False, default=None)
|
||||
alert_group = serializers.HiddenField(default=None) # set in DirectPagingSerializer.validate
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from unittest.mock import ANY
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
|
@ -59,11 +61,13 @@ def test_direct_paging_new_alert_group(
|
|||
assert alert.message == message
|
||||
|
||||
|
||||
@pytest.mark.parametrize("important_team_escalation", [True, False])
|
||||
@pytest.mark.django_db
|
||||
def test_direct_paging_page_team(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_team,
|
||||
make_user_auth_headers,
|
||||
important_team_escalation,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR)
|
||||
team = make_team(organization=organization)
|
||||
|
|
@ -81,6 +85,7 @@ def test_direct_paging_page_team(
|
|||
"message": message,
|
||||
"source_url": source_url,
|
||||
"grafana_incident_id": grafana_incident_id,
|
||||
"important_team_escalation": important_team_escalation,
|
||||
},
|
||||
format="json",
|
||||
**make_user_auth_headers(user, token),
|
||||
|
|
@ -92,7 +97,16 @@ def test_direct_paging_page_team(
|
|||
alert = alert_group.alerts.first()
|
||||
|
||||
assert alert_group.grafana_incident_id == grafana_incident_id
|
||||
assert alert.raw_request_data["oncall"]["permalink"] == source_url
|
||||
assert alert.raw_request_data == {
|
||||
"oncall": {
|
||||
"title": ANY,
|
||||
"message": message,
|
||||
"uid": ANY,
|
||||
"author_username": ANY,
|
||||
"permalink": source_url,
|
||||
"important": important_team_escalation,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ class DirectPagingAPIView(APIView):
|
|||
source_url=validated_data["source_url"],
|
||||
grafana_incident_id=validated_data["grafana_incident_id"],
|
||||
team=validated_data["team"],
|
||||
important_team_escalation=validated_data["important_team_escalation"],
|
||||
users=[(user["instance"], user["important"]) for user in validated_data["users"]],
|
||||
alert_group=validated_data["alert_group"],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -381,6 +381,9 @@ class GrafanaServiceAccountAuthentication(BaseAuthentication):
|
|||
# if organization exists, we are good)
|
||||
setup_organization(url, auth)
|
||||
organization = Organization.objects.filter(grafana_url=url).first()
|
||||
if organization is None:
|
||||
# sync may still be in progress, client should retry
|
||||
raise exceptions.Throttled(detail="Organization being synced, please retry.")
|
||||
return organization
|
||||
|
||||
if settings.LICENSE == settings.CLOUD_LICENSE_NAME:
|
||||
|
|
|
|||
|
|
@ -110,9 +110,9 @@ def test_grafana_authentication_no_org_grafana_url():
|
|||
request_sync_url = f"{grafana_url}/api/plugins/{PluginID.ONCALL}/resources/plugin/sync?wait=true&force=true"
|
||||
httpretty.register_uri(httpretty.POST, request_sync_url, status=404)
|
||||
|
||||
with pytest.raises(exceptions.AuthenticationFailed) as exc:
|
||||
with pytest.raises(exceptions.Throttled) as exc:
|
||||
GrafanaServiceAccountAuthentication().authenticate(request)
|
||||
assert exc.value.detail == "Organization not found."
|
||||
assert exc.value.detail == "Organization being synced, please retry."
|
||||
|
||||
|
||||
@pytest.mark.parametrize("grafana_url", ["null;", "foo", ""])
|
||||
|
|
|
|||
|
|
@ -89,11 +89,13 @@ def test_escalation_new_alert_group(
|
|||
assert alert.message == message
|
||||
|
||||
|
||||
@pytest.mark.parametrize("important_team_escalation", [True, False])
|
||||
@pytest.mark.django_db
|
||||
def test_escalation_team(
|
||||
make_organization_and_user_with_token,
|
||||
make_team,
|
||||
make_user_auth_headers,
|
||||
important_team_escalation,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_token()
|
||||
team = make_team(organization=organization)
|
||||
|
|
@ -110,6 +112,7 @@ def test_escalation_team(
|
|||
"team": team.public_primary_key,
|
||||
"message": message,
|
||||
"source_url": source_url,
|
||||
"important_team_escalation": important_team_escalation,
|
||||
},
|
||||
format="json",
|
||||
**make_user_auth_headers(user, token),
|
||||
|
|
@ -120,7 +123,16 @@ def test_escalation_team(
|
|||
alert_group = AlertGroup.objects.get(public_primary_key=response.json()["id"])
|
||||
alert = alert_group.alerts.first()
|
||||
|
||||
assert alert.raw_request_data["oncall"]["permalink"] == source_url
|
||||
assert alert.raw_request_data == {
|
||||
"oncall": {
|
||||
"title": mock.ANY,
|
||||
"message": message,
|
||||
"uid": mock.ANY,
|
||||
"author_username": mock.ANY,
|
||||
"permalink": source_url,
|
||||
"important": important_team_escalation,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ class EscalationView(APIView):
|
|||
title=validated_data["title"],
|
||||
source_url=validated_data["source_url"],
|
||||
team=validated_data["team"],
|
||||
important_team_escalation=validated_data["important_team_escalation"],
|
||||
users=[(user["instance"], user["important"]) for user in validated_data["users"]],
|
||||
alert_group=validated_data["alert_group"],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -54,6 +54,19 @@ logger = logging.getLogger(__name__)
|
|||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
class MissingUser:
|
||||
"""Represent a missing user in a rolling users shift."""
|
||||
|
||||
DISPLAY_NAME = "(missing)"
|
||||
|
||||
def __init__(self, pk):
|
||||
self.pk = pk
|
||||
|
||||
@property
|
||||
def username(self):
|
||||
return self.DISPLAY_NAME
|
||||
|
||||
|
||||
EmptyShift = namedtuple(
|
||||
"EmptyShift",
|
||||
["start", "end", "summary", "description", "attendee", "all_day", "calendar_type", "calendar_tz", "shift_pk"],
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from django.utils import timezone
|
|||
from django.utils.functional import cached_property
|
||||
from icalendar.cal import Event
|
||||
|
||||
from apps.schedules.ical_utils import MissingUser
|
||||
from apps.schedules.tasks import (
|
||||
check_gaps_and_empty_shifts_in_schedule,
|
||||
drop_cached_ical_task,
|
||||
|
|
@ -645,10 +646,6 @@ class CustomOnCallShift(models.Model):
|
|||
all_users_pks = set()
|
||||
users_queue = []
|
||||
if self.rolling_users is not None:
|
||||
# get all users pks from rolling_users field
|
||||
for users_dict in self.rolling_users:
|
||||
all_users_pks.update(users_dict.keys())
|
||||
users = User.objects.filter(pk__in=all_users_pks)
|
||||
# generate users_queue list with user objects
|
||||
if self.start_rotation_from_user_index is not None:
|
||||
rolling_users = (
|
||||
|
|
@ -657,10 +654,22 @@ class CustomOnCallShift(models.Model):
|
|||
)
|
||||
else:
|
||||
rolling_users = self.rolling_users
|
||||
|
||||
# get all users pks from rolling_users field
|
||||
for users_dict in self.rolling_users:
|
||||
all_users_pks.update(users_dict.keys())
|
||||
users = User.objects.filter(pk__in=all_users_pks)
|
||||
users_by_id = {user.pk: user for user in users}
|
||||
for users_dict in rolling_users:
|
||||
users_list = list(users.filter(pk__in=users_dict.keys()))
|
||||
if users_list:
|
||||
users_queue.append(users_list)
|
||||
users_list = []
|
||||
for user_pk in users_dict.keys():
|
||||
try:
|
||||
user_pk = int(user_pk)
|
||||
users_list.append(users_by_id.get(user_pk, MissingUser(user_pk)))
|
||||
except ValueError:
|
||||
users_list.append(MissingUser(user_pk))
|
||||
users_queue.append(users_list)
|
||||
|
||||
return users_queue
|
||||
|
||||
def add_rolling_users(self, rolling_users_list):
|
||||
|
|
|
|||
|
|
@ -1,17 +1,13 @@
|
|||
from .check_gaps_and_empty_shifts import check_gaps_and_empty_shifts_in_schedule # noqa: F401
|
||||
from .drop_cached_ical import drop_cached_ical_for_custom_events_for_organization, drop_cached_ical_task # noqa: F401
|
||||
from .notify_about_empty_shifts_in_schedule import ( # noqa: F401
|
||||
check_empty_shifts_in_schedule,
|
||||
notify_about_empty_shifts_in_schedule_task,
|
||||
schedule_notify_about_empty_shifts_in_schedule,
|
||||
start_check_empty_shifts_in_schedule,
|
||||
start_notify_about_empty_shifts_in_schedule,
|
||||
)
|
||||
from .notify_about_gaps_in_schedule import ( # noqa: F401
|
||||
check_gaps_in_schedule,
|
||||
notify_about_gaps_in_schedule_task,
|
||||
schedule_notify_about_gaps_in_schedule,
|
||||
start_check_gaps_in_schedule,
|
||||
start_notify_about_gaps_in_schedule,
|
||||
)
|
||||
from .refresh_ical_files import ( # noqa: F401
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import pytz
|
||||
from celery.utils.log import get_task_logger
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.slack.utils import format_datetime_to_slack_with_time, post_message_to_channel
|
||||
|
|
@ -10,28 +11,16 @@ from common.utils import trim_if_needed
|
|||
task_logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
# deprecated # todo: delete this task from here and from task routes after the next release
|
||||
@shared_dedicated_queue_retry_task()
|
||||
def start_check_empty_shifts_in_schedule():
|
||||
return
|
||||
|
||||
|
||||
# deprecated # todo: delete this task from here and from task routes after the next release
|
||||
@shared_dedicated_queue_retry_task()
|
||||
def check_empty_shifts_in_schedule(schedule_pk):
|
||||
return
|
||||
|
||||
|
||||
@shared_dedicated_queue_retry_task()
|
||||
def start_notify_about_empty_shifts_in_schedule():
|
||||
from apps.schedules.models import OnCallScheduleICal
|
||||
from apps.schedules.models import OnCallSchedule
|
||||
|
||||
task_logger.info("Start start_notify_about_empty_shifts_in_schedule")
|
||||
|
||||
today = timezone.now().date()
|
||||
week_ago = today - timezone.timedelta(days=7)
|
||||
schedules = OnCallScheduleICal.objects.filter(
|
||||
empty_shifts_report_sent_at__lte=week_ago,
|
||||
schedules = OnCallSchedule.objects.filter(
|
||||
Q(empty_shifts_report_sent_at__lte=week_ago) | Q(empty_shifts_report_sent_at__isnull=True),
|
||||
slack_channel__isnull=False,
|
||||
organization__deleted_at__isnull=True,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import pytz
|
||||
from celery.utils.log import get_task_logger
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.slack.utils import format_datetime_to_slack_with_time, post_message_to_channel
|
||||
|
|
@ -9,18 +10,6 @@ from common.custom_celery_tasks import shared_dedicated_queue_retry_task
|
|||
task_logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
# deprecated # todo: delete this task from here and from task routes after the next release
|
||||
@shared_dedicated_queue_retry_task()
|
||||
def start_check_gaps_in_schedule():
|
||||
return
|
||||
|
||||
|
||||
# deprecated # todo: delete this task from here and from task routes after the next release
|
||||
@shared_dedicated_queue_retry_task()
|
||||
def check_gaps_in_schedule(schedule_pk):
|
||||
return
|
||||
|
||||
|
||||
@shared_dedicated_queue_retry_task()
|
||||
def start_notify_about_gaps_in_schedule():
|
||||
from apps.schedules.models import OnCallSchedule
|
||||
|
|
@ -30,7 +19,7 @@ def start_notify_about_gaps_in_schedule():
|
|||
today = timezone.now().date()
|
||||
week_ago = today - timezone.timedelta(days=7)
|
||||
schedules = OnCallSchedule.objects.filter(
|
||||
gaps_report_sent_at__lte=week_ago,
|
||||
Q(gaps_report_sent_at__lte=week_ago) | Q(gaps_report_sent_at__isnull=True),
|
||||
slack_channel__isnull=False,
|
||||
organization__deleted_at__isnull=True,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import pytest
|
|||
from django.utils import timezone
|
||||
|
||||
from apps.api.permissions import LegacyAccessControlRole
|
||||
from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb
|
||||
from apps.schedules.tasks import notify_about_empty_shifts_in_schedule_task
|
||||
from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar, OnCallScheduleICal, OnCallScheduleWeb
|
||||
from apps.schedules.tasks import notify_about_empty_shifts_in_schedule_task, start_notify_about_empty_shifts_in_schedule
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -174,3 +174,45 @@ def test_empty_non_empty_shifts_trigger_notification(
|
|||
schedule.refresh_from_db()
|
||||
assert empty_shifts_report_sent_at != schedule.empty_shifts_report_sent_at
|
||||
assert schedule.has_empty_shifts
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"schedule_class",
|
||||
[OnCallScheduleWeb, OnCallScheduleICal, OnCallScheduleCalendar],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"report_sent_days_ago,expected_call",
|
||||
[(8, True), (6, False), (None, True)],
|
||||
)
|
||||
@pytest.mark.django_db
|
||||
def test_start_notify_about_empty_shifts(
|
||||
make_slack_team_identity,
|
||||
make_slack_channel,
|
||||
make_organization,
|
||||
make_schedule,
|
||||
schedule_class,
|
||||
report_sent_days_ago,
|
||||
expected_call,
|
||||
):
|
||||
slack_team_identity = make_slack_team_identity()
|
||||
slack_channel = make_slack_channel(slack_team_identity)
|
||||
organization = make_organization(slack_team_identity=slack_team_identity)
|
||||
|
||||
sent = timezone.now() - datetime.timedelta(days=report_sent_days_ago) if report_sent_days_ago else None
|
||||
schedule = make_schedule(
|
||||
organization,
|
||||
schedule_class=schedule_class,
|
||||
name="test_schedule",
|
||||
slack_channel=slack_channel,
|
||||
empty_shifts_report_sent_at=sent,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"apps.schedules.tasks.notify_about_empty_shifts_in_schedule.notify_about_empty_shifts_in_schedule_task.apply_async"
|
||||
) as mock_notify:
|
||||
start_notify_about_empty_shifts_in_schedule()
|
||||
|
||||
if expected_call:
|
||||
mock_notify.assert_called_once_with((schedule.pk,))
|
||||
else:
|
||||
mock_notify.assert_not_called()
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ from unittest.mock import patch
|
|||
import pytest
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb
|
||||
from apps.schedules.tasks import notify_about_gaps_in_schedule_task
|
||||
from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar, OnCallScheduleICal, OnCallScheduleWeb
|
||||
from apps.schedules.tasks import notify_about_gaps_in_schedule_task, start_notify_about_gaps_in_schedule
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -286,3 +286,45 @@ def test_gaps_later_than_7_days_no_triggering_notification(
|
|||
schedule.refresh_from_db()
|
||||
assert gaps_report_sent_at != schedule.gaps_report_sent_at
|
||||
assert schedule.has_gaps is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"schedule_class",
|
||||
[OnCallScheduleWeb, OnCallScheduleICal, OnCallScheduleCalendar],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"report_sent_days_ago,expected_call",
|
||||
[(8, True), (6, False), (None, True)],
|
||||
)
|
||||
@pytest.mark.django_db
|
||||
def test_start_notify_about_gaps(
|
||||
make_slack_team_identity,
|
||||
make_slack_channel,
|
||||
make_organization,
|
||||
make_schedule,
|
||||
schedule_class,
|
||||
report_sent_days_ago,
|
||||
expected_call,
|
||||
):
|
||||
slack_team_identity = make_slack_team_identity()
|
||||
slack_channel = make_slack_channel(slack_team_identity)
|
||||
organization = make_organization(slack_team_identity=slack_team_identity)
|
||||
|
||||
sent = timezone.now() - datetime.timedelta(days=report_sent_days_ago) if report_sent_days_ago else None
|
||||
schedule = make_schedule(
|
||||
organization,
|
||||
schedule_class=schedule_class,
|
||||
name="test_schedule",
|
||||
slack_channel=slack_channel,
|
||||
gaps_report_sent_at=sent,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"apps.schedules.tasks.notify_about_gaps_in_schedule.notify_about_gaps_in_schedule_task.apply_async"
|
||||
) as mock_notify:
|
||||
start_notify_about_gaps_in_schedule()
|
||||
|
||||
if expected_call:
|
||||
mock_notify.assert_called_once_with((schedule.pk,))
|
||||
else:
|
||||
mock_notify.assert_not_called()
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from apps.schedules.constants import (
|
|||
ICAL_STATUS_CANCELLED,
|
||||
ICAL_SUMMARY,
|
||||
)
|
||||
from apps.schedules.ical_utils import MissingUser
|
||||
from apps.schedules.models import (
|
||||
CustomOnCallShift,
|
||||
OnCallSchedule,
|
||||
|
|
@ -358,6 +359,57 @@ def test_filter_events_include_empty(make_organization, make_user_for_organizati
|
|||
assert events == expected
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_filter_events_include_empty_if_deleted(
|
||||
make_organization, make_user_for_organization, make_schedule, make_on_call_shift
|
||||
):
|
||||
organization = make_organization()
|
||||
schedule = make_schedule(
|
||||
organization,
|
||||
schedule_class=OnCallScheduleWeb,
|
||||
name="test_web_schedule",
|
||||
)
|
||||
user = make_user_for_organization(organization)
|
||||
now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_date = now - timezone.timedelta(days=7)
|
||||
|
||||
data = {
|
||||
"start": start_date + timezone.timedelta(hours=10),
|
||||
"rotation_start": start_date + timezone.timedelta(hours=10),
|
||||
"duration": timezone.timedelta(hours=8),
|
||||
"priority_level": 1,
|
||||
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
|
||||
"schedule": schedule,
|
||||
}
|
||||
on_call_shift = make_on_call_shift(
|
||||
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
|
||||
)
|
||||
on_call_shift.add_rolling_users([[user]])
|
||||
|
||||
# user is deleted, shift data still exists but the shift is empty
|
||||
user.delete()
|
||||
|
||||
end_date = start_date + timezone.timedelta(days=1)
|
||||
events = schedule.filter_events(start_date, end_date, filter_by=OnCallSchedule.TYPE_ICAL_PRIMARY, with_empty=True)
|
||||
expected = [
|
||||
{
|
||||
"calendar_type": OnCallSchedule.TYPE_ICAL_PRIMARY,
|
||||
"start": on_call_shift.start,
|
||||
"end": on_call_shift.start + on_call_shift.duration,
|
||||
"all_day": False,
|
||||
"is_override": False,
|
||||
"is_empty": True,
|
||||
"is_gap": False,
|
||||
"priority_level": on_call_shift.priority_level,
|
||||
"missing_users": [MissingUser.DISPLAY_NAME],
|
||||
"users": [],
|
||||
"shift": {"pk": on_call_shift.public_primary_key},
|
||||
"source": "api",
|
||||
}
|
||||
]
|
||||
assert events == expected
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_filter_events_ical_all_day(make_organization, make_user_for_organization, make_schedule, get_ical):
|
||||
calendar = get_ical("calendar_with_all_day_event.ics")
|
||||
|
|
|
|||
|
|
@ -37,12 +37,14 @@ if typing.TYPE_CHECKING:
|
|||
from apps.slack.models import SlackTeamIdentity, SlackUserIdentity
|
||||
from apps.user_management.models import Organization, Team, User
|
||||
|
||||
|
||||
DIRECT_PAGING_TEAM_SELECT_ID = "paging_team_select"
|
||||
DIRECT_PAGING_TEAM_SEVERITY_CHECKBOXES_ID = "paging_team_severity_checkboxes"
|
||||
DIRECT_PAGING_ORG_SELECT_ID = "paging_org_select"
|
||||
DIRECT_PAGING_USER_SELECT_ID = "paging_user_select"
|
||||
DIRECT_PAGING_MESSAGE_INPUT_ID = "paging_message_input"
|
||||
|
||||
DIRECT_PAGING_TEAM_SEVERITY_CHECKBOX_VALUE = "important"
|
||||
|
||||
DEFAULT_TEAM_VALUE = "default_team"
|
||||
|
||||
|
||||
|
|
@ -248,6 +250,7 @@ class FinishDirectPaging(scenario_step.ScenarioStep):
|
|||
from_user=user,
|
||||
message=message,
|
||||
team=selected_team,
|
||||
important_team_escalation=_get_team_escalation_severity_from_payload(payload, input_id_prefix),
|
||||
users=selected_users,
|
||||
)
|
||||
except DirectPagingUserTeamValidationError:
|
||||
|
|
@ -331,6 +334,14 @@ class OnPagingTeamChange(scenario_step.ScenarioStep):
|
|||
)
|
||||
|
||||
|
||||
class OnPagingTeamSeverityCheckboxChange(OnPagingTeamChange):
|
||||
"""
|
||||
Specify alert severity when escalating to a team.
|
||||
|
||||
NOTE: we simply reuse `OnPagingTeamChange` step, since the behavior is the same.
|
||||
"""
|
||||
|
||||
|
||||
class OnPagingUserChange(scenario_step.ScenarioStep):
|
||||
"""Add selected to user to the list.
|
||||
|
||||
|
|
@ -491,6 +502,7 @@ def render_dialog(
|
|||
new_private_metadata["input_id_prefix"] = new_input_id_prefix
|
||||
selected_organization = predefined_org if predefined_org else available_organizations.first()
|
||||
is_team_selected, selected_team = False, None
|
||||
is_team_escalation_important = False
|
||||
else:
|
||||
# setup form using data/state
|
||||
old_input_id_prefix, new_input_id_prefix, new_private_metadata = _get_and_change_input_id_prefix_from_metadata(
|
||||
|
|
@ -502,6 +514,7 @@ def render_dialog(
|
|||
else _get_selected_org_from_payload(payload, old_input_id_prefix, slack_team_identity, slack_user_identity)
|
||||
)
|
||||
is_team_selected, selected_team = _get_selected_team_from_payload(payload, old_input_id_prefix)
|
||||
is_team_escalation_important = _get_team_escalation_severity_from_payload(payload, old_input_id_prefix)
|
||||
|
||||
blocks: Block.AnyBlocks = []
|
||||
|
||||
|
|
@ -523,9 +536,14 @@ def render_dialog(
|
|||
)
|
||||
blocks.append(organization_select)
|
||||
|
||||
# Add team select and additional responders blocks
|
||||
# Add team select/severity and additional responders blocks
|
||||
blocks += _get_team_select_blocks(
|
||||
slack_user_identity, selected_organization, is_team_selected, selected_team, new_input_id_prefix
|
||||
slack_user_identity,
|
||||
selected_organization,
|
||||
is_team_selected,
|
||||
selected_team,
|
||||
is_team_escalation_important,
|
||||
new_input_id_prefix,
|
||||
)
|
||||
blocks += _get_user_select_blocks(payload, selected_organization, new_input_id_prefix, error_msg)
|
||||
|
||||
|
|
@ -629,6 +647,25 @@ def _get_select_field_value(payload: EventPayload, prefix_id: str, routing_uid:
|
|||
return json.loads(field["value"])["id"] if field else None
|
||||
|
||||
|
||||
def _get_first_selected_checkbox_option_value(
|
||||
payload: EventPayload,
|
||||
prefix_id: str,
|
||||
routing_uid: str,
|
||||
field_id: str,
|
||||
) -> str | None:
|
||||
"""
|
||||
NOTE: if reusing this for other logic outside of the team severity checkboxes, note that this function
|
||||
will only return the value of the first checkbox option...
|
||||
"""
|
||||
try:
|
||||
selected_options = payload["view"]["state"]["values"][prefix_id + field_id][routing_uid]["selected_options"]
|
||||
if not selected_options:
|
||||
return None
|
||||
return selected_options[0]["value"]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
|
||||
def _get_selected_org_from_payload(
|
||||
payload: EventPayload,
|
||||
input_id_prefix: str,
|
||||
|
|
@ -676,6 +713,7 @@ def _get_team_select_blocks(
|
|||
organization: "Organization",
|
||||
is_selected: bool,
|
||||
value: typing.Optional["Team"],
|
||||
is_team_escalation_important: bool,
|
||||
input_id_prefix: str,
|
||||
) -> Block.AnyBlocks:
|
||||
blocks: Block.AnyBlocks = []
|
||||
|
|
@ -702,7 +740,7 @@ def _get_team_select_blocks(
|
|||
if not teams:
|
||||
direct_paging_info_msg["elements"][0][
|
||||
"text"
|
||||
] += ". There are currently no teams which have a Direct Paging integration that is configured."
|
||||
] += ".\n\nThere are currently no teams which have a Direct Paging integration that is configured."
|
||||
blocks.append(direct_paging_info_msg)
|
||||
return blocks
|
||||
|
||||
|
|
@ -769,6 +807,62 @@ def _get_team_select_blocks(
|
|||
}
|
||||
)
|
||||
|
||||
team_severity_important_checkbox_option: CompositionObjectOption = {
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "Important escalation",
|
||||
},
|
||||
"value": DIRECT_PAGING_TEAM_SEVERITY_CHECKBOX_VALUE,
|
||||
}
|
||||
|
||||
team_severity_checkboxes_element: Block.Section = {
|
||||
"type": "section",
|
||||
"block_id": input_id_prefix + DIRECT_PAGING_TEAM_SEVERITY_CHECKBOXES_ID,
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
# NOTE: this is a bit of a hack. Slack requires us to specify this text object, and it cannot be empty
|
||||
# hence the empty space. We do this so that we can render the text instead in a context block below
|
||||
# (which allows us to render it in a slightly smaller font size)
|
||||
# https://api.slack.com/reference/block-kit/blocks#section
|
||||
"text": " ",
|
||||
},
|
||||
"accessory": {
|
||||
"type": "checkboxes",
|
||||
"options": [team_severity_important_checkbox_option],
|
||||
"action_id": OnPagingTeamSeverityCheckboxChange.routing_uid(),
|
||||
},
|
||||
}
|
||||
|
||||
if is_team_escalation_important:
|
||||
# From the docs https://api.slack.com/reference/block-kit/block-elements#checkboxes__fields
|
||||
# An array of option objects that EXACTLY matches one or more of the options within options
|
||||
team_severity_checkboxes_element["accessory"]["initial_options"] = [team_severity_important_checkbox_option]
|
||||
|
||||
blocks.extend(
|
||||
[
|
||||
team_severity_checkboxes_element,
|
||||
typing.cast(
|
||||
Block.Context,
|
||||
{
|
||||
# NOTE: we add this here instead of as a checkbox option description because those can only
|
||||
# be defined as plain text (ie. not markdown where links are supported)
|
||||
"type": "context",
|
||||
"elements": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": (
|
||||
"Check the above box if you would like to escalate to this team as an 'important' "
|
||||
"escalation. Teams can configure their Direct Paging Integration to route to different "
|
||||
"escalation chains based on this. "
|
||||
"<https://grafana.com/docs/oncall/latest/integrations/manual/#important-escalations|Learn more>"
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
|
|
@ -951,6 +1045,16 @@ def _get_selected_team_from_payload(
|
|||
return selected_team_id, Team.objects.filter(pk=selected_team_id).first()
|
||||
|
||||
|
||||
def _get_team_escalation_severity_from_payload(payload: EventPayload, input_id_prefix: str) -> bool:
|
||||
checkbox_value = _get_first_selected_checkbox_option_value(
|
||||
payload,
|
||||
input_id_prefix,
|
||||
OnPagingTeamSeverityCheckboxChange.routing_uid(),
|
||||
DIRECT_PAGING_TEAM_SEVERITY_CHECKBOXES_ID,
|
||||
)
|
||||
return checkbox_value == DIRECT_PAGING_TEAM_SEVERITY_CHECKBOX_VALUE
|
||||
|
||||
|
||||
def _get_selected_user_from_payload(payload: EventPayload, input_id_prefix: str) -> typing.Optional["User"]:
|
||||
from apps.user_management.models import User
|
||||
|
||||
|
|
@ -1035,6 +1139,12 @@ STEPS_ROUTING: ScenarioRoute.RoutingSteps = [
|
|||
"block_action_id": OnPagingTeamChange.routing_uid(),
|
||||
"step": OnPagingTeamChange,
|
||||
},
|
||||
{
|
||||
"payload_type": PayloadType.BLOCK_ACTIONS,
|
||||
"block_action_type": BlockActionType.CHECKBOXES,
|
||||
"block_action_id": OnPagingTeamSeverityCheckboxChange.routing_uid(),
|
||||
"step": OnPagingTeamSeverityCheckboxChange,
|
||||
},
|
||||
{
|
||||
"payload_type": PayloadType.BLOCK_ACTIONS,
|
||||
"block_action_type": BlockActionType.STATIC_SELECT,
|
||||
|
|
|
|||
|
|
@ -12,12 +12,14 @@ from apps.slack.scenarios.paging import (
|
|||
DIRECT_PAGING_MESSAGE_INPUT_ID,
|
||||
DIRECT_PAGING_ORG_SELECT_ID,
|
||||
DIRECT_PAGING_TEAM_SELECT_ID,
|
||||
DIRECT_PAGING_TEAM_SEVERITY_CHECKBOXES_ID,
|
||||
DIRECT_PAGING_USER_SELECT_ID,
|
||||
DataKey,
|
||||
FinishDirectPaging,
|
||||
OnPagingItemActionChange,
|
||||
OnPagingOrgChange,
|
||||
OnPagingTeamChange,
|
||||
OnPagingTeamSeverityCheckboxChange,
|
||||
OnPagingUserChange,
|
||||
Policy,
|
||||
StartDirectPaging,
|
||||
|
|
@ -28,7 +30,13 @@ from apps.user_management.models import Organization
|
|||
|
||||
|
||||
def make_paging_view_slack_payload(
|
||||
selected_org=None, predefined_org=None, team=None, user=None, current_users=None, actions=None
|
||||
selected_org=None,
|
||||
predefined_org=None,
|
||||
team=None,
|
||||
important_team_escalation=False,
|
||||
user=None,
|
||||
current_users=None,
|
||||
actions=None,
|
||||
):
|
||||
"""
|
||||
Helper function to create a payload for paging view.
|
||||
|
|
@ -66,6 +74,15 @@ def make_paging_view_slack_payload(
|
|||
}
|
||||
}
|
||||
},
|
||||
DIRECT_PAGING_TEAM_SEVERITY_CHECKBOXES_ID: {
|
||||
OnPagingTeamSeverityCheckboxChange.routing_uid(): {
|
||||
"selected_options": [
|
||||
{"value": "important"},
|
||||
]
|
||||
if important_team_escalation
|
||||
else []
|
||||
},
|
||||
},
|
||||
DIRECT_PAGING_TEAM_SELECT_ID: {
|
||||
OnPagingTeamChange.routing_uid(): {
|
||||
"selected_option": {"value": make_value({"id": team.pk if team else None}, organization)}
|
||||
|
|
@ -141,6 +158,7 @@ def test_page_team_with_predefined_org(make_organization_and_user_with_slack_ide
|
|||
from_user=user,
|
||||
message="The Message",
|
||||
team=team,
|
||||
important_team_escalation=False,
|
||||
users=[],
|
||||
)
|
||||
|
||||
|
|
@ -385,15 +403,21 @@ def test_trigger_paging_additional_responders(make_organization_and_user_with_sl
|
|||
from_user=user,
|
||||
message="The Message",
|
||||
team=team,
|
||||
important_team_escalation=False,
|
||||
users=[(user, True)],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("important_team_escalation", [True, False])
|
||||
@pytest.mark.django_db
|
||||
def test_page_team(make_organization_and_user_with_slack_identities, make_team):
|
||||
def test_page_team(make_organization_and_user_with_slack_identities, make_team, important_team_escalation):
|
||||
organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities()
|
||||
team = make_team(organization)
|
||||
payload = make_paging_view_slack_payload(selected_org=organization, team=team)
|
||||
payload = make_paging_view_slack_payload(
|
||||
selected_org=organization,
|
||||
team=team,
|
||||
important_team_escalation=important_team_escalation,
|
||||
)
|
||||
|
||||
step = FinishDirectPaging(slack_team_identity)
|
||||
with patch("apps.slack.scenarios.paging.direct_paging") as mock_direct_paging:
|
||||
|
|
@ -405,6 +429,7 @@ def test_page_team(make_organization_and_user_with_slack_identities, make_team):
|
|||
from_user=user,
|
||||
message="The Message",
|
||||
team=team,
|
||||
important_team_escalation=important_team_escalation,
|
||||
users=[],
|
||||
)
|
||||
|
||||
|
|
@ -421,6 +446,7 @@ def test_get_organization_select(make_organization):
|
|||
assert select["element"]["options"][0]["text"]["text"] == "Organization (stack_slug)"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_team_escalation_important", [True, False])
|
||||
@pytest.mark.django_db
|
||||
def test_get_team_select_blocks(
|
||||
make_organization_and_user_with_slack_identities,
|
||||
|
|
@ -428,6 +454,7 @@ def test_get_team_select_blocks(
|
|||
make_alert_receive_channel,
|
||||
make_escalation_chain,
|
||||
make_channel_filter,
|
||||
is_team_escalation_important,
|
||||
):
|
||||
info_msg = (
|
||||
"*Note*: You can only page teams which have a Direct Paging integration that is configured. "
|
||||
|
|
@ -444,7 +471,14 @@ def test_get_team_select_blocks(
|
|||
|
||||
# no team selected - no team direct paging integrations available
|
||||
organization, _, _, slack_user_identity = make_organization_and_user_with_slack_identities()
|
||||
blocks = _get_team_select_blocks(slack_user_identity, organization, False, None, input_id_prefix)
|
||||
blocks = _get_team_select_blocks(
|
||||
slack_user_identity,
|
||||
organization,
|
||||
False,
|
||||
None,
|
||||
is_team_escalation_important,
|
||||
input_id_prefix,
|
||||
)
|
||||
|
||||
assert len(blocks) == 1
|
||||
|
||||
|
|
@ -452,7 +486,7 @@ def test_get_team_select_blocks(
|
|||
assert context_block["type"] == "context"
|
||||
assert (
|
||||
context_block["elements"][0]["text"]
|
||||
== info_msg + ". There are currently no teams which have a Direct Paging integration that is configured."
|
||||
== info_msg + ".\n\nThere are currently no teams which have a Direct Paging integration that is configured."
|
||||
)
|
||||
|
||||
# no team selected - 1 team direct paging integration available
|
||||
|
|
@ -462,7 +496,14 @@ def test_get_team_select_blocks(
|
|||
escalation_chain = make_escalation_chain(organization)
|
||||
make_channel_filter(arc, is_default=True, escalation_chain=escalation_chain)
|
||||
|
||||
blocks = _get_team_select_blocks(slack_user_identity, organization, False, None, input_id_prefix)
|
||||
blocks = _get_team_select_blocks(
|
||||
slack_user_identity,
|
||||
organization,
|
||||
False,
|
||||
None,
|
||||
is_team_escalation_important,
|
||||
input_id_prefix,
|
||||
)
|
||||
|
||||
assert len(blocks) == 2
|
||||
input_block, context_block = blocks
|
||||
|
|
@ -472,7 +513,7 @@ def test_get_team_select_blocks(
|
|||
assert input_block["element"]["options"] == [_contstruct_team_option(team)]
|
||||
assert context_block["elements"][0]["text"] == info_msg
|
||||
|
||||
# team selected
|
||||
# team selected - team severity checkbox should also now appear
|
||||
organization, _, _, slack_user_identity = make_organization_and_user_with_slack_identities()
|
||||
team1 = make_team(organization)
|
||||
team2 = make_team(organization)
|
||||
|
|
@ -488,10 +529,25 @@ def test_get_team_select_blocks(
|
|||
_setup_direct_paging_integration(team1)
|
||||
team2_direct_paging_arc = _setup_direct_paging_integration(team2)
|
||||
|
||||
blocks = _get_team_select_blocks(slack_user_identity, organization, True, team2, input_id_prefix)
|
||||
blocks = _get_team_select_blocks(
|
||||
slack_user_identity,
|
||||
organization,
|
||||
True,
|
||||
team2,
|
||||
is_team_escalation_important,
|
||||
input_id_prefix,
|
||||
)
|
||||
|
||||
assert len(blocks) == 2
|
||||
input_block, context_block = blocks
|
||||
assert len(blocks) == 4
|
||||
input_block, context_block, team_severity_checkboxes, team_severity_context_block = blocks
|
||||
|
||||
team_severity_important_checkbox_option = {
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "Important escalation",
|
||||
},
|
||||
"value": "important",
|
||||
}
|
||||
|
||||
team1_option = _contstruct_team_option(team1)
|
||||
team2_option = _contstruct_team_option(team2)
|
||||
|
|
@ -509,6 +565,20 @@ def test_get_team_select_blocks(
|
|||
== f"Integration <{team2_direct_paging_arc.web_link}|{team2_direct_paging_arc.verbal_name}> will be used for notification."
|
||||
)
|
||||
|
||||
assert team_severity_context_block["elements"][0]["text"] == (
|
||||
"Check the above box if you would like to escalate to this team as an 'important' "
|
||||
"escalation. Teams can configure their Direct Paging Integration to route to different "
|
||||
"escalation chains based on this. "
|
||||
"<https://grafana.com/docs/oncall/latest/integrations/manual/#important-escalations|Learn more>"
|
||||
)
|
||||
assert team_severity_checkboxes["accessory"]["type"] == "checkboxes"
|
||||
assert team_severity_checkboxes["accessory"]["options"] == [team_severity_important_checkbox_option]
|
||||
|
||||
if is_team_escalation_important:
|
||||
assert team_severity_checkboxes["accessory"]["initial_options"] == [team_severity_important_checkbox_option]
|
||||
else:
|
||||
assert "initial_options" not in team_severity_checkboxes["accessory"]
|
||||
|
||||
# team's direct paging integration has two routes associated with it
|
||||
# the team should only be displayed once
|
||||
organization, _, _, slack_user_identity = make_organization_and_user_with_slack_identities()
|
||||
|
|
@ -519,7 +589,14 @@ def test_get_team_select_blocks(
|
|||
make_channel_filter(arc, is_default=True, escalation_chain=escalation_chain)
|
||||
make_channel_filter(arc, escalation_chain=escalation_chain)
|
||||
|
||||
blocks = _get_team_select_blocks(slack_user_identity, organization, False, None, input_id_prefix)
|
||||
blocks = _get_team_select_blocks(
|
||||
slack_user_identity,
|
||||
organization,
|
||||
False,
|
||||
None,
|
||||
is_team_escalation_important,
|
||||
input_id_prefix,
|
||||
)
|
||||
|
||||
assert len(blocks) == 2
|
||||
input_block, context_block = blocks
|
||||
|
|
|
|||
|
|
@ -203,23 +203,33 @@ def test_sync_teams_for_organization(make_organization, make_team, make_alert_re
|
|||
assert created_team.team_id == api_teams[2]["id"]
|
||||
assert created_team.name == api_teams[2]["name"]
|
||||
|
||||
def _assert_teams_direct_paging_integration_is_configured_properly(integration):
|
||||
assert integration.channel_filters.count() == 2
|
||||
|
||||
for route in integration.channel_filters.all():
|
||||
if route.is_default:
|
||||
assert route.order == 1
|
||||
assert route.filtering_term is None
|
||||
else:
|
||||
assert route.order == 0
|
||||
assert route.filtering_term == "{{ payload.oncall.important }}"
|
||||
assert route.filtering_term_type == route.FILTERING_TERM_TYPE_JINJA2
|
||||
|
||||
# check that direct paging is created for created team
|
||||
direct_paging_integration = AlertReceiveChannel.objects.get(
|
||||
organization=organization,
|
||||
integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING,
|
||||
team=created_team,
|
||||
)
|
||||
assert direct_paging_integration.channel_filters.count() == 1
|
||||
assert direct_paging_integration.channel_filters.first().order == 0
|
||||
assert direct_paging_integration.channel_filters.first().is_default
|
||||
_assert_teams_direct_paging_integration_is_configured_properly(direct_paging_integration)
|
||||
|
||||
# check that direct paging is created for existing team
|
||||
direct_paging_integration = AlertReceiveChannel.objects.get(
|
||||
organization=organization, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING, team=teams[2]
|
||||
organization=organization,
|
||||
integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING,
|
||||
team=teams[2],
|
||||
)
|
||||
assert direct_paging_integration.channel_filters.count() == 1
|
||||
assert direct_paging_integration.channel_filters.first().order == 0
|
||||
assert direct_paging_integration.channel_filters.first().is_default
|
||||
_assert_teams_direct_paging_integration_is_configured_properly(direct_paging_integration)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
|
|||
|
|
@ -33,13 +33,9 @@ CELERY_TASK_ROUTES = {
|
|||
"apps.schedules.tasks.refresh_ical_files.refresh_ical_final_schedule": {"queue": "default"},
|
||||
"apps.schedules.tasks.refresh_ical_files.start_refresh_ical_final_schedules": {"queue": "default"},
|
||||
"apps.schedules.tasks.check_gaps_and_empty_shifts.check_gaps_and_empty_shifts_in_schedule": {"queue": "default"},
|
||||
"apps.schedules.tasks.notify_about_gaps_in_schedule.check_empty_shifts_in_schedule": {"queue": "default"},
|
||||
"apps.schedules.tasks.notify_about_gaps_in_schedule.start_notify_about_gaps_in_schedule": {"queue": "default"},
|
||||
"apps.schedules.tasks.notify_about_gaps_in_schedule.check_gaps_in_schedule": {"queue": "default"},
|
||||
"apps.schedules.tasks.notify_about_gaps_in_schedule.notify_about_gaps_in_schedule_task": {"queue": "default"},
|
||||
"apps.schedules.tasks.notify_about_gaps_in_schedule.schedule_notify_about_gaps_in_schedule": {"queue": "default"},
|
||||
"apps.schedules.tasks.notify_about_gaps_in_schedule.start_check_empty_shifts_in_schedule": {"queue": "default"},
|
||||
"apps.schedules.tasks.notify_about_gaps_in_schedule.start_check_gaps_in_schedule": {"queue": "default"},
|
||||
"apps.schedules.tasks.notify_about_gaps_in_schedule.start_notify_about_empty_shifts_in_schedule": {
|
||||
"queue": "default"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -151,7 +151,9 @@ def migrate() -> None:
|
|||
migrate_notification_rules(user)
|
||||
print(TAB + format_user(user))
|
||||
else:
|
||||
print("▶ Skipping migrating user notification rules as MIGRATE_USERS is false...")
|
||||
print(
|
||||
"▶ Skipping migrating user notification rules as MIGRATE_USERS is false..."
|
||||
)
|
||||
|
||||
print("▶ Migrating schedules...")
|
||||
for schedule in schedules:
|
||||
|
|
|
|||
27
tools/migrators/lib/tests/pagerduty/test_migrate.py
Normal file
27
tools/migrators/lib/tests/pagerduty/test_migrate.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from unittest.mock import call, patch
|
||||
|
||||
from lib.pagerduty.migrate import migrate
|
||||
|
||||
|
||||
@patch("lib.pagerduty.migrate.MIGRATE_USERS", False)
|
||||
@patch("lib.pagerduty.migrate.APISession")
|
||||
@patch("lib.pagerduty.migrate.OnCallAPIClient")
|
||||
def test_users_are_skipped_when_migrate_users_is_false(
|
||||
MockOnCallAPIClient, MockAPISession
|
||||
):
|
||||
mock_session = MockAPISession.return_value
|
||||
mock_session.list_all.return_value = []
|
||||
mock_oncall_client = MockOnCallAPIClient.return_value
|
||||
|
||||
migrate()
|
||||
|
||||
# Assert no user-related fetching or migration occurs
|
||||
assert mock_session.list_all.call_args_list == [
|
||||
call("schedules", params={"include[]": "schedule_layers", "time_zone": "UTC"}),
|
||||
call("escalation_policies"),
|
||||
call("services", params={"include[]": "integrations"}),
|
||||
call("vendors"),
|
||||
# no user notification rules fetching
|
||||
]
|
||||
|
||||
mock_oncall_client.list_users_with_notification_rules.assert_not_called()
|
||||
Loading…
Add table
Reference in a new issue