Add escalation step to notify all members from a team (#3908)

Based on https://github.com/grafana/oncall/pull/3477

---------

Co-authored-by: xssfox <xss@sprocketfox.io>
This commit is contained in:
Matias Bordese 2024-02-20 10:02:23 -03:00 committed by GitHub
parent 6da36b3c0b
commit d6467e9cb7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 448 additions and 6 deletions

View file

@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Update OnCall Insights dashboard @Ferril ([#3875](https://github.com/grafana/oncall/pull/3875))
- Do not delete webhook if its team is deleted @mderynck ([#3873](https://github.com/grafana/oncall/pull/3873))
- Update user details internal API perms ([#3900](https://github.com/grafana/oncall/pull/3900))
- Add escalation to notify entire Grafana team @xssfox ([#3477](https://github.com/grafana/oncall/pull/3477))
## v1.3.105 (2024-02-13)

View file

@ -80,6 +80,7 @@ need a larger time interval, use multiple wait steps in a row.
* `Notify users` - send a notification to a user or a group of users.
* `Notify users from on-call schedule` - send a notification to a user or a group of users
from an on-call schedule.
* `Notify all users from a team` - send a notification to all users in a team.
* `Resolve incident automatically` - resolve the alert group right now with status
`Resolved automatically`.
* `Notify whole slack channel` - send a notification to the users in the slack channel.

View file

@ -35,7 +35,7 @@ The above command returns JSON structured in the following way:
| `escalation_chain_id` | Yes | Each escalation policy is assigned to a specific escalation chain. |
| `position` | Optional | Escalation policies execute one after another starting from `position=0`. `Position=-1` will put the escalation policy to the end of the list. A new escalation policy created with a position of an existing escalation policy will move the old one (and all following) down in the list. |
| `type` | Yes | One of: `wait`, `notify_persons`, `notify_person_next_each_time`, `notify_on_call_from_schedule`, `notify_user_group`, `trigger_action`, `resolve`, `notify_whole_channel`, `notify_if_time_from_to`. |
| `important` | Optional | Default is `false`. Will assign "important" to personal notification rules if `true`. This can be used to distinguish alerts on which you want to be notified immediately by phone. Applicable for types `notify_persons`, `notify_on_call_from_schedule`, and `notify_user_group`. |
| `important` | Optional | Default is `false`. Will assign "important" to personal notification rules if `true`. This can be used to distinguish alerts on which you want to be notified immediately by phone. Applicable for types `notify_persons`, `notify_team_members`, `notify_on_call_from_schedule`, and `notify_user_group`. |
| `duration` | If type = `wait` | The duration, in seconds, when type `wait` is chosen. Valid values are: `60`, `300`, `900`, `1800`, `3600`. |
| `action_to_trigger` | If type = `trigger_action` | ID of a webhook. |
| `group_to_notify` | If type = `notify_user_group` | ID of a `User Group`. |
@ -44,6 +44,7 @@ The above command returns JSON structured in the following way:
| `notify_on_call _from_schedule` | If type = `notify_on_call_from_schedule` | ID of a Schedule. |
| `notify_if_time_from` | If type = `notify_if_time_from_to` | UTC time represents the beginning of the time period, for example `09:00:00Z`. |
| `notify_if_time_to` | If type = `notify_if_time_from_to` | UTC time represents the end of the time period, for example `18:00:00Z`. |
| `team_to_notify` | If type = `notify_team_members` | ID of a team. |
**HTTP request**
@ -70,6 +71,35 @@ The above command returns JSON structured in the following way:
}
```
# Update an escalation policy
```shell
curl "{{API_URL}}/api/v1/escalation_policies/E3GA6SJETWWJS/" \
--request PUT \
--header "Authorization: meowmeowmeow" \
--header "Content-Type: application/json" \
--data '{
"type": "wait",
"duration": 300,
}'
```
The above command returns JSON structured in the following way:
```json
{
"id": "E3GA6SJETWWJS",
"escalation_chain_id": "F5JU6KJET33FE",
"position": 0,
"type": "wait",
"duration": 300
}
```
**HTTP request**
`PUT {{API_URL}}/api/v1/on_call_shifts/<ON_CALL_SHIFT_ID>/`
**HTTP request**
`GET {{API_URL}}/api/v1/escalation_policies/<ESCALATION_POLICY_ID>/`

View file

@ -67,6 +67,7 @@ class EscalationSnapshotMixin:
'wait_delay': None,
'notify_schedule': None,
'notify_to_group': None,
'notify_to_team_members': None,
'passed_last_time': None,
'escalation_counter': 0,
'last_notified_user': None,
@ -84,6 +85,7 @@ class EscalationSnapshotMixin:
'wait_delay': '00:05:00',
'notify_schedule': None,
'notify_to_group': None,
'notify_to_team_members': None,
'passed_last_time': None,
'escalation_counter': 0,
'last_notified_user': None,

View file

@ -83,6 +83,7 @@ class EscalationPolicySnapshotSerializer(serializers.ModelSerializer):
"custom_webhook",
"notify_schedule",
"notify_to_group",
"notify_to_team_members",
"escalation_counter",
"passed_last_time",
"pause_escalation",

View file

@ -41,6 +41,7 @@ class EscalationPolicySnapshot:
"custom_webhook",
"notify_schedule",
"notify_to_group",
"notify_to_team_members",
"escalation_counter",
"passed_last_time",
"pause_escalation",
@ -72,6 +73,7 @@ class EscalationPolicySnapshot:
escalation_counter,
passed_last_time,
pause_escalation,
notify_to_team_members=None,
):
self.id = id
self.order = order
@ -87,6 +89,7 @@ class EscalationPolicySnapshot:
self.custom_webhook = custom_webhook
self.notify_schedule = notify_schedule
self.notify_to_group = notify_to_group
self.notify_to_team_members = notify_to_team_members
self.escalation_counter = escalation_counter # used for STEP_REPEAT_ESCALATION_N_TIMES
self.passed_last_time = passed_last_time # used for building escalation plan
self.pause_escalation = pause_escalation # used for STEP_NOTIFY_IF_NUM_ALERTS_IN_TIME_WINDOW
@ -124,6 +127,8 @@ class EscalationPolicySnapshot:
EscalationPolicy.STEP_FINAL_RESOLVE: self._escalation_step_resolve,
EscalationPolicy.STEP_NOTIFY_GROUP: self._escalation_step_notify_user_group,
EscalationPolicy.STEP_NOTIFY_GROUP_IMPORTANT: self._escalation_step_notify_user_group,
EscalationPolicy.STEP_NOTIFY_TEAM_MEMBERS: self._escalation_step_notify_team_members,
EscalationPolicy.STEP_NOTIFY_TEAM_MEMBERS_IMPORTANT: self._escalation_step_notify_team_members,
EscalationPolicy.STEP_NOTIFY_SCHEDULE: self._escalation_step_notify_on_call_schedule,
EscalationPolicy.STEP_NOTIFY_SCHEDULE_IMPORTANT: self._escalation_step_notify_on_call_schedule,
EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON: self._escalation_step_trigger_custom_button,
@ -358,6 +363,55 @@ class EscalationPolicySnapshot:
tasks.append(notify_group)
self._execute_tasks(tasks)
def _escalation_step_notify_team_members(self, alert_group: "AlertGroup", reason: str) -> None:
tasks = []
if self.notify_to_team_members is None:
log_record = AlertGroupLogRecord(
type=AlertGroupLogRecord.TYPE_ESCALATION_FAILED,
alert_group=alert_group,
reason=reason,
escalation_policy=self.escalation_policy,
escalation_error_code=AlertGroupLogRecord.ERROR_ESCALATION_NOTIFY_TEAM_MEMBERS_STEP_IS_NOT_CONFIGURED,
escalation_policy_step=self.step,
)
log_record.save()
else:
log_record = AlertGroupLogRecord(
type=AlertGroupLogRecord.TYPE_ESCALATION_TRIGGERED,
alert_group=alert_group,
reason=reason,
escalation_policy=self.escalation_policy,
escalation_policy_step=self.step,
step_specific_info={"team": self.notify_to_team_members.name},
)
log_record.save()
self.notify_to_users_queue = self.notify_to_team_members.users.all()
reason = "user belongs to team {}".format(self.notify_to_team_members.name)
for notify_to_user in self.notify_to_users_queue:
notify_task = notify_user_task.signature(
(
notify_to_user.pk,
alert_group.pk,
),
{
"reason": reason,
"important": self.step == EscalationPolicy.STEP_NOTIFY_TEAM_MEMBERS_IMPORTANT,
},
immutable=True,
)
tasks.append(notify_task)
AlertGroupLogRecord.objects.create(
type=AlertGroupLogRecord.TYPE_ESCALATION_TRIGGERED,
author=notify_to_user,
alert_group=alert_group,
reason=reason,
escalation_policy=self.escalation_policy,
escalation_policy_step=self.step,
)
self._execute_tasks(tasks)
def _escalation_step_notify_if_time(self, alert_group: "AlertGroup", _reason: str) -> StepExecutionResultData:
eta = None

View file

@ -0,0 +1,25 @@
# Generated by Django 4.2.10 on 2024-02-16 17:24
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('user_management', '0020_organization_is_grafana_labels_enabled'),
('alerts', '0044_alertreceivechannel_alertmanager_v2_backup_templates_and_more'),
]
operations = [
migrations.AddField(
model_name='escalationpolicy',
name='notify_to_team_members',
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='escalation_policies', to='user_management.team'),
),
migrations.AlterField(
model_name='escalationpolicy',
name='step',
field=models.IntegerField(choices=[(0, 'Wait'), (1, 'Notify User'), (2, 'Notify Whole Channel'), (3, 'Repeat Escalation (5 times max)'), (4, 'Resolve'), (5, 'Notify Group'), (6, 'Notify Schedule'), (7, 'Notify User (Important)'), (8, 'Notify Group (Important)'), (9, 'Notify Schedule (Important)'), (10, 'Trigger Outgoing Webhook'), (11, 'Notify User (next each time)'), (12, 'Continue escalation only if time is from'), (13, 'Notify multiple Users'), (14, 'Notify multiple Users (Important)'), (15, 'Continue escalation if >X alerts per Y minutes'), (16, 'Trigger Webhook'), (17, 'Notify all users in a Team'), (18, 'Notify all users in a Team (Important)')], default=None, null=True),
),
]

View file

@ -159,7 +159,8 @@ class AlertGroupLogRecord(models.Model):
ERROR_ESCALATION_NOTIFY_IN_SLACK,
ERROR_ESCALATION_NOTIFY_IF_NUM_ALERTS_IN_WINDOW_STEP_IS_NOT_CONFIGURED,
ERROR_ESCALATION_TRIGGER_CUSTOM_WEBHOOK_ERROR,
) = range(18)
ERROR_ESCALATION_NOTIFY_TEAM_MEMBERS_STEP_IS_NOT_CONFIGURED,
) = range(19)
type = models.IntegerField(choices=TYPE_CHOICES)
@ -519,6 +520,11 @@ class AlertGroupLogRecord(models.Model):
result += 'skipped escalation step "Notify Schedule" because it is not configured'
elif self.escalation_error_code == AlertGroupLogRecord.ERROR_ESCALATION_NOTIFY_GROUP_STEP_IS_NOT_CONFIGURED:
result += 'skipped escalation step "Notify Group" because it is not configured'
elif (
self.escalation_error_code
== AlertGroupLogRecord.ERROR_ESCALATION_NOTIFY_TEAM_MEMBERS_STEP_IS_NOT_CONFIGURED
):
result += 'skipped escalation step "Notify Team Members" because it is not configured'
elif (
self.escalation_error_code
== AlertGroupLogRecord.ERROR_ESCALATION_TRIGGER_CUSTOM_BUTTON_STEP_IS_NOT_CONFIGURED

View file

@ -45,7 +45,9 @@ class EscalationPolicy(OrderedModel):
STEP_NOTIFY_MULTIPLE_USERS_IMPORTANT,
STEP_NOTIFY_IF_NUM_ALERTS_IN_TIME_WINDOW,
STEP_TRIGGER_CUSTOM_WEBHOOK,
) = range(17)
STEP_NOTIFY_TEAM_MEMBERS,
STEP_NOTIFY_TEAM_MEMBERS_IMPORTANT,
) = range(19)
# Must be the same order as previous
STEP_CHOICES = (
@ -66,6 +68,8 @@ class EscalationPolicy(OrderedModel):
(STEP_NOTIFY_MULTIPLE_USERS_IMPORTANT, "Notify multiple Users (Important)"),
(STEP_NOTIFY_IF_NUM_ALERTS_IN_TIME_WINDOW, "Continue escalation if >X alerts per Y minutes"),
(STEP_TRIGGER_CUSTOM_WEBHOOK, "Trigger Webhook"),
(STEP_NOTIFY_TEAM_MEMBERS, "Notify all users in a Team"),
(STEP_NOTIFY_TEAM_MEMBERS_IMPORTANT, "Notify all users in a Team (Important)"),
)
# Ordered step choices available for internal api.
@ -74,6 +78,7 @@ class EscalationPolicy(OrderedModel):
# Common
STEP_WAIT,
STEP_NOTIFY_MULTIPLE_USERS,
STEP_NOTIFY_TEAM_MEMBERS,
STEP_NOTIFY_SCHEDULE,
STEP_FINAL_RESOLVE,
# Slack
@ -100,6 +105,8 @@ class EscalationPolicy(OrderedModel):
STEP_NOTIFY_USERS_QUEUE,
STEP_NOTIFY_IF_TIME,
STEP_NOTIFY_IF_NUM_ALERTS_IN_TIME_WINDOW,
STEP_NOTIFY_TEAM_MEMBERS,
STEP_NOTIFY_TEAM_MEMBERS_IMPORTANT,
STEP_NOTIFY_MULTIPLE_USERS,
STEP_NOTIFY_MULTIPLE_USERS_IMPORTANT,
STEP_TRIGGER_CUSTOM_BUTTON,
@ -113,6 +120,10 @@ class EscalationPolicy(OrderedModel):
# Common steps
STEP_WAIT: ("Wait {{wait_delay}} minute(s)", "Wait"),
STEP_NOTIFY_MULTIPLE_USERS: ("Start {{importance}} notification for {{users}}", "Notify users"),
STEP_NOTIFY_TEAM_MEMBERS: (
"Start {{importance}} notification for {{team}} team members",
"Notify all team members",
),
STEP_NOTIFY_SCHEDULE: (
"Start {{importance}} notification for schedule {{schedule}}",
"Notify users from on-call schedule",
@ -157,11 +168,13 @@ class EscalationPolicy(OrderedModel):
STEP_NOTIFY_GROUP: STEP_NOTIFY_GROUP_IMPORTANT,
STEP_NOTIFY_SCHEDULE: STEP_NOTIFY_SCHEDULE_IMPORTANT,
STEP_NOTIFY_MULTIPLE_USERS: STEP_NOTIFY_MULTIPLE_USERS_IMPORTANT,
STEP_NOTIFY_TEAM_MEMBERS: STEP_NOTIFY_TEAM_MEMBERS_IMPORTANT,
}
IMPORTANT_TO_DEFAULT_STEP_MAPPING = {
STEP_NOTIFY_GROUP_IMPORTANT: STEP_NOTIFY_GROUP,
STEP_NOTIFY_SCHEDULE_IMPORTANT: STEP_NOTIFY_SCHEDULE,
STEP_NOTIFY_MULTIPLE_USERS_IMPORTANT: STEP_NOTIFY_MULTIPLE_USERS,
STEP_NOTIFY_TEAM_MEMBERS_IMPORTANT: STEP_NOTIFY_TEAM_MEMBERS,
}
# Default steps are just usual version of important steps. E.g. notify group - notify group important
@ -169,12 +182,14 @@ class EscalationPolicy(OrderedModel):
STEP_NOTIFY_GROUP,
STEP_NOTIFY_SCHEDULE,
STEP_NOTIFY_MULTIPLE_USERS,
STEP_NOTIFY_TEAM_MEMBERS,
}
IMPORTANT_STEPS_SET = {
STEP_NOTIFY_GROUP_IMPORTANT,
STEP_NOTIFY_SCHEDULE_IMPORTANT,
STEP_NOTIFY_MULTIPLE_USERS_IMPORTANT,
STEP_NOTIFY_TEAM_MEMBERS_IMPORTANT,
}
SLACK_INTEGRATION_REQUIRED_STEPS = [
@ -187,6 +202,7 @@ class EscalationPolicy(OrderedModel):
STEP_WAIT,
STEP_NOTIFY_SCHEDULE,
STEP_NOTIFY_MULTIPLE_USERS,
STEP_NOTIFY_TEAM_MEMBERS,
STEP_NOTIFY_USERS_QUEUE,
STEP_NOTIFY_GROUP,
STEP_FINAL_RESOLVE,
@ -213,6 +229,8 @@ class EscalationPolicy(OrderedModel):
STEP_NOTIFY_USERS_QUEUE: "notify_person_next_each_time",
STEP_NOTIFY_MULTIPLE_USERS: "notify_persons",
STEP_NOTIFY_MULTIPLE_USERS_IMPORTANT: "notify_persons",
STEP_NOTIFY_TEAM_MEMBERS: "notify_team_members",
STEP_NOTIFY_TEAM_MEMBERS_IMPORTANT: "notify_team_members",
STEP_NOTIFY_IF_TIME: "notify_if_time_from_to",
STEP_NOTIFY_IF_NUM_ALERTS_IN_TIME_WINDOW: "notify_if_num_alerts_in_window",
STEP_REPEAT_ESCALATION_N_TIMES: "repeat_escalation",
@ -244,6 +262,14 @@ class EscalationPolicy(OrderedModel):
step = models.IntegerField(choices=STEP_CHOICES, default=None, null=True)
notify_to_team_members = models.ForeignKey(
"user_management.Team",
on_delete=models.SET_NULL,
related_name="escalation_policies",
default=None,
null=True,
)
notify_to_group = models.ForeignKey(
"slack.SlackUserGroup",
on_delete=models.SET_NULL,
@ -368,6 +394,13 @@ class EscalationPolicy(OrderedModel):
if self.notify_to_group:
result["user_group"] = self.notify_to_group.name
result["user_group_id"] = self.notify_to_group.public_primary_key
elif self.step in [
EscalationPolicy.STEP_NOTIFY_TEAM_MEMBERS,
EscalationPolicy.STEP_NOTIFY_TEAM_MEMBERS_IMPORTANT,
]:
if self.notify_to_team_members:
result["team"] = self.notify_to_team_members.name
result["team_id"] = self.notify_to_team_members.public_primary_key
elif self.step in [EscalationPolicy.STEP_NOTIFY_SCHEDULE, EscalationPolicy.STEP_NOTIFY_SCHEDULE_IMPORTANT]:
if self.notify_schedule:
result["on-call_schedule"] = self.notify_schedule.insight_logs_verbal

View file

@ -1,4 +1,4 @@
from unittest.mock import patch
from unittest.mock import call, patch
import pytest
from django.utils import timezone
@ -636,3 +636,55 @@ def test_escalation_step_with_deleted_user(
deserialized_escalation_snapshot = EscalationPolicySnapshotSerializer().to_internal_value(raw_snapshot)
assert deserialized_escalation_snapshot["notify_to_users_queue"] == [user]
@patch("apps.alerts.escalation_snapshot.snapshot_classes.EscalationPolicySnapshot._execute_tasks", return_value=None)
@pytest.mark.django_db
@pytest.mark.parametrize(
"step",
(EscalationPolicy.STEP_NOTIFY_TEAM_MEMBERS, EscalationPolicy.STEP_NOTIFY_TEAM_MEMBERS_IMPORTANT),
)
def test_notify_team_members(
mocked_execute_tasks, escalation_step_test_setup, make_user, make_team, make_escalation_policy, step
):
organization, user, _, channel_filter, alert_group, reason = escalation_step_test_setup
user_1 = make_user(organization=organization)
user_2 = make_user(organization=organization)
team_1 = make_team(organization=organization)
team_1.users.add(user_1)
team_1.users.add(user_2)
notify_team_members_step = make_escalation_policy(
escalation_chain=channel_filter.escalation_chain,
escalation_policy_step=step,
notify_to_team_members=team_1,
)
escalation_policy_snapshot = get_escalation_policy_snapshot_from_model(notify_team_members_step)
expected_eta = timezone.now() + timezone.timedelta(seconds=NEXT_ESCALATION_DELAY)
with patch(
"apps.alerts.escalation_snapshot.snapshot_classes.escalation_policy_snapshot.notify_user_task"
) as mock_execute:
result = escalation_policy_snapshot.execute(alert_group, reason)
expected_result = EscalationPolicySnapshot.StepExecutionResultData(
eta=result.eta,
stop_escalation=False,
pause_escalation=False,
start_from_beginning=False,
)
assert expected_eta + timezone.timedelta(seconds=15) > result.eta > expected_eta - timezone.timedelta(seconds=15)
assert result == expected_result
assert notify_team_members_step.log_records.filter(type=AlertGroupLogRecord.TYPE_ESCALATION_TRIGGERED).exists()
assert list(escalation_policy_snapshot.notify_to_users_queue) == list(team_1.users.all())
assert mocked_execute_tasks.called
expected_kwargs = {
"reason": f"user belongs to team {team_1.name}",
"important": step == EscalationPolicy.STEP_NOTIFY_TEAM_MEMBERS_IMPORTANT,
}
assert mock_execute.signature.call_args_list[0] == call(
(user_1.pk, alert_group.pk), expected_kwargs, immutable=True
)
assert mock_execute.signature.call_args_list[1] == call(
(user_2.pk, alert_group.pk), expected_kwargs, immutable=True
)
assert mock_execute.signature.call_count == 2

View file

@ -40,6 +40,7 @@ def test_raw_escalation_snapshot(escalation_snapshot_test_setup):
"last_notified_user": None,
"notify_schedule": None,
"notify_to_group": None,
"notify_to_team_members": None,
"from_time": None,
"to_time": None,
"num_alerts_in_window": None,
@ -59,6 +60,7 @@ def test_raw_escalation_snapshot(escalation_snapshot_test_setup):
"last_notified_user": None,
"notify_schedule": None,
"notify_to_group": None,
"notify_to_team_members": None,
"from_time": None,
"to_time": None,
"num_alerts_in_window": None,
@ -78,6 +80,7 @@ def test_raw_escalation_snapshot(escalation_snapshot_test_setup):
"last_notified_user": None,
"notify_schedule": None,
"notify_to_group": None,
"notify_to_team_members": None,
"from_time": notify_if_time_step.from_time.isoformat(),
"to_time": notify_if_time_step.to_time.isoformat(),
"num_alerts_in_window": None,

View file

@ -499,6 +499,66 @@ def test_deserialize_escalation_snapshot(
assert deserialized_escalation_snapshot.stop_escalation is False
@pytest.mark.django_db
def test_deserialize_escalation_snapshot_missing_notify_to_team_members(
make_organization_and_user,
make_alert_receive_channel,
make_channel_filter,
make_escalation_chain,
make_escalation_policy,
make_alert_group,
):
organization, _ = make_organization_and_user()
alert_receive_channel = make_alert_receive_channel(organization)
escalation_chain = make_escalation_chain(organization=organization)
channel_filter = make_channel_filter(alert_receive_channel, escalation_chain=escalation_chain)
make_escalation_policy(
escalation_chain=channel_filter.escalation_chain,
escalation_policy_step=EscalationPolicy.STEP_WAIT,
wait_delay=EscalationPolicy.FIFTEEN_MINUTES,
)
alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter)
alert_group.raw_escalation_snapshot = alert_group.build_raw_escalation_snapshot()
del alert_group.raw_escalation_snapshot["escalation_policies_snapshots"][0]["notify_to_team_members"]
deserialized_escalation_snapshot = alert_group._deserialize_escalation_snapshot(alert_group.raw_escalation_snapshot)
assert deserialized_escalation_snapshot.escalation_policies_snapshots[0].notify_to_team_members is None
@patch("apps.alerts.models.alert_group.AlertGroup.slack_channel_id", new_callable=PropertyMock)
@pytest.mark.django_db
def test_deserialize_escalation_snapshot_notify_to_team_members(
mock_alert_group_slack_channel_id,
make_organization_and_user,
make_alert_receive_channel,
make_channel_filter,
make_escalation_chain,
make_escalation_policy,
make_alert_group,
make_team,
):
mock_alert_group_slack_channel_id.return_value = MOCK_SLACK_CHANNEL_ID
organization, _ = make_organization_and_user()
alert_receive_channel = make_alert_receive_channel(organization)
escalation_chain = make_escalation_chain(organization=organization)
channel_filter = make_channel_filter(alert_receive_channel, escalation_chain=escalation_chain)
team = make_team(organization)
make_escalation_policy(
escalation_chain=channel_filter.escalation_chain,
escalation_policy_step=EscalationPolicy.STEP_NOTIFY_TEAM_MEMBERS,
notify_to_team_members=team,
)
alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter)
alert_group.raw_escalation_snapshot = alert_group.build_raw_escalation_snapshot()
alert_group.raw_escalation_snapshot["escalation_policies_snapshots"][0]["notify_to_team_members"]
deserialized_escalation_snapshot = alert_group._deserialize_escalation_snapshot(alert_group.raw_escalation_snapshot)
assert deserialized_escalation_snapshot.escalation_policies_snapshots[0].notify_to_team_members.id == team.id
@pytest.mark.django_db
def test_escalation_chain_exists(
make_organization_and_user,

View file

@ -6,7 +6,7 @@ from rest_framework import serializers
from apps.alerts.models import CustomButton, EscalationChain, EscalationPolicy
from apps.schedules.models import OnCallSchedule
from apps.slack.models import SlackUserGroup
from apps.user_management.models import User
from apps.user_management.models import Team, User
from apps.webhooks.models import Webhook
from common.api_helpers.custom_fields import (
OrganizationFilteredPrimaryKeyRelatedField,
@ -18,6 +18,7 @@ WAIT_DELAY = "wait_delay"
NOTIFY_SCHEDULE = "notify_schedule"
NOTIFY_TO_USERS_QUEUE = "notify_to_users_queue"
NOTIFY_GROUP = "notify_to_group"
NOTIFY_TEAM_MEMBERS = "notify_to_team_members"
FROM_TIME = "from_time"
TO_TIME = "to_time"
NUM_ALERTS_IN_WINDOW = "num_alerts_in_window"
@ -31,6 +32,7 @@ STEP_TYPE_TO_RELATED_FIELD_MAP = {
EscalationPolicy.STEP_NOTIFY_USERS_QUEUE: [NOTIFY_TO_USERS_QUEUE],
EscalationPolicy.STEP_NOTIFY_MULTIPLE_USERS: [NOTIFY_TO_USERS_QUEUE],
EscalationPolicy.STEP_NOTIFY_GROUP: [NOTIFY_GROUP],
EscalationPolicy.STEP_NOTIFY_TEAM_MEMBERS: [NOTIFY_TEAM_MEMBERS],
EscalationPolicy.STEP_NOTIFY_IF_TIME: [FROM_TIME, TO_TIME],
EscalationPolicy.STEP_NOTIFY_IF_NUM_ALERTS_IN_TIME_WINDOW: [NUM_ALERTS_IN_WINDOW, NUM_MINUTES_IN_WINDOW],
EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON: [CUSTOM_BUTTON_TRIGGER],
@ -62,6 +64,11 @@ class EscalationPolicySerializer(EagerLoadingMixin, serializers.ModelSerializer)
required=False,
allow_null=True,
)
notify_to_team_members = OrganizationFilteredPrimaryKeyRelatedField(
queryset=Team.objects,
required=False,
allow_null=True,
)
notify_to_group = OrganizationFilteredPrimaryKeyRelatedField(
queryset=SlackUserGroup.objects,
required=False,
@ -98,6 +105,7 @@ class EscalationPolicySerializer(EagerLoadingMixin, serializers.ModelSerializer)
"custom_webhook",
"notify_schedule",
"notify_to_group",
"notify_to_team_members",
"important",
]
@ -105,6 +113,7 @@ class EscalationPolicySerializer(EagerLoadingMixin, serializers.ModelSerializer)
"escalation_chain",
"notify_schedule",
"notify_to_group",
"notify_to_team_members",
"custom_button_trigger",
"custom_webhook",
]
@ -115,6 +124,7 @@ class EscalationPolicySerializer(EagerLoadingMixin, serializers.ModelSerializer)
WAIT_DELAY,
NOTIFY_SCHEDULE,
NOTIFY_TO_USERS_QUEUE,
NOTIFY_TEAM_MEMBERS,
NOTIFY_GROUP,
FROM_TIME,
TO_TIME,
@ -224,6 +234,7 @@ class EscalationPolicyUpdateSerializer(EscalationPolicySerializer):
NOTIFY_SCHEDULE,
NOTIFY_TO_USERS_QUEUE,
NOTIFY_GROUP,
NOTIFY_TEAM_MEMBERS,
FROM_TIME,
TO_TIME,
NUM_ALERTS_IN_WINDOW,

View file

@ -101,6 +101,43 @@ def test_update_notify_multiple_users_step(escalation_policy_internal_api_setup,
)
@pytest.mark.django_db
def test_manage_escalation_policy_notify_team(escalation_policy_internal_api_setup, make_team, make_user_auth_headers):
token, escalation_chain, _, user, _ = escalation_policy_internal_api_setup
client = APIClient()
url = reverse("api-internal:escalation_policy-list")
team = make_team(organization=user.organization)
data = {
"step": EscalationPolicy.STEP_NOTIFY_TEAM_MEMBERS,
"escalation_chain": escalation_chain.public_primary_key,
"notify_to_team_members": team.public_primary_key,
}
max_order = EscalationPolicy.objects.filter(escalation_chain=escalation_chain).aggregate(maxorder=Max("order"))[
"maxorder"
]
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_201_CREATED
assert response.data["notify_to_team_members"] == team.public_primary_key
escalation_policy = EscalationPolicy.objects.get(public_primary_key=response.data["id"])
assert escalation_policy.order == max_order + 1
assert escalation_policy.notify_to_team_members == team
# update team in policy
url = reverse("api-internal:escalation_policy-detail", kwargs={"pk": escalation_policy.public_primary_key})
another_team = make_team(organization=user.organization)
data = {
"step": EscalationPolicy.STEP_NOTIFY_TEAM_MEMBERS,
"notify_to_team_members": another_team.public_primary_key,
}
response = client.put(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.json()["step"] == EscalationPolicy.STEP_NOTIFY_TEAM_MEMBERS
assert response.json()["notify_to_team_members"] == another_team.public_primary_key
@pytest.mark.django_db
def test_move_to_position(escalation_policy_internal_api_setup, make_user_auth_headers):
token, _, escalation_policy, user, _ = escalation_policy_internal_api_setup
@ -713,6 +750,7 @@ def test_escalation_policy_update_drop_non_step_type_related_data(
"notify_schedule",
"notify_to_users_queue",
"notify_to_group",
"notify_to_team_members",
"from_time",
"to_time",
"custom_button_trigger",
@ -770,6 +808,7 @@ def test_escalation_policy_switch_importance(
"custom_webhook": None,
"notify_schedule": None,
"notify_to_group": None,
"notify_to_team_members": None,
"important": True,
"wait_delay": None,
}
@ -827,6 +866,7 @@ def test_escalation_policy_filter_by_user(
"custom_webhook": None,
"notify_schedule": None,
"notify_to_group": None,
"notify_to_team_members": None,
"important": False,
},
{
@ -844,6 +884,7 @@ def test_escalation_policy_filter_by_user(
"custom_webhook": None,
"notify_schedule": None,
"notify_to_group": None,
"notify_to_team_members": None,
"important": False,
},
]
@ -909,6 +950,7 @@ def test_escalation_policy_filter_by_slack_channel(
"custom_webhook": None,
"notify_schedule": None,
"notify_to_group": None,
"notify_to_team_members": None,
"important": False,
},
]

View file

@ -7,7 +7,7 @@ from rest_framework import fields, serializers
from apps.alerts.models import EscalationChain, EscalationPolicy
from apps.schedules.models import OnCallSchedule
from apps.slack.models import SlackUserGroup
from apps.user_management.models import User
from apps.user_management.models import Team, User
from apps.webhooks.models import Webhook
from common.api_helpers.custom_fields import (
OrganizationFilteredPrimaryKeyRelatedField,
@ -57,6 +57,11 @@ class EscalationPolicySerializer(EagerLoadingMixin, OrderedModelSerializer):
required=False,
source="notify_to_users_queue",
)
team_to_notify = OrganizationFilteredPrimaryKeyRelatedField(
queryset=Team.objects,
required=False,
source="notify_to_team_members",
)
persons_to_notify_next_each_time = UsersFilteredByOrganizationField(
queryset=User.objects,
required=False,
@ -95,6 +100,7 @@ class EscalationPolicySerializer(EagerLoadingMixin, OrderedModelSerializer):
"duration",
"important",
"persons_to_notify",
"team_to_notify",
"persons_to_notify_next_each_time",
"notify_on_call_from_schedule",
"group_to_notify",
@ -154,6 +160,7 @@ class EscalationPolicySerializer(EagerLoadingMixin, OrderedModelSerializer):
def _get_field_to_represent(self, step, result):
fields_to_remove = [
"duration",
"team_to_notify",
"persons_to_notify",
"persons_to_notify_next_each_time",
"notify_on_call_from_schedule",
@ -174,6 +181,11 @@ class EscalationPolicySerializer(EagerLoadingMixin, OrderedModelSerializer):
EscalationPolicy.STEP_NOTIFY_MULTIPLE_USERS_IMPORTANT,
]:
fields_to_remove.remove("persons_to_notify")
elif step in [
EscalationPolicy.STEP_NOTIFY_TEAM_MEMBERS,
EscalationPolicy.STEP_NOTIFY_TEAM_MEMBERS_IMPORTANT,
]:
fields_to_remove.remove("team_to_notify")
elif step == EscalationPolicy.STEP_NOTIFY_USERS_QUEUE:
fields_to_remove.remove("persons_to_notify_next_each_time")
elif step in [EscalationPolicy.STEP_NOTIFY_GROUP, EscalationPolicy.STEP_NOTIFY_GROUP_IMPORTANT]:
@ -203,6 +215,7 @@ class EscalationPolicySerializer(EagerLoadingMixin, OrderedModelSerializer):
"wait_delay",
"notify_schedule",
"notify_to_group",
"notify_to_team_members",
"custom_button_trigger",
"custom_webhook",
"from_time",
@ -229,6 +242,8 @@ class EscalationPolicySerializer(EagerLoadingMixin, OrderedModelSerializer):
validated_data_fields_to_remove.remove("notify_to_users_queue")
elif step in [EscalationPolicy.STEP_NOTIFY_GROUP, EscalationPolicy.STEP_NOTIFY_GROUP_IMPORTANT]:
validated_data_fields_to_remove.remove("notify_to_group")
elif step in [EscalationPolicy.STEP_NOTIFY_TEAM_MEMBERS, EscalationPolicy.STEP_NOTIFY_TEAM_MEMBERS_IMPORTANT]:
validated_data_fields_to_remove.remove("notify_to_team_members")
elif step == EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON:
validated_data_fields_to_remove.remove("custom_button_trigger")
elif step == EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK:
@ -280,6 +295,11 @@ class EscalationPolicyUpdateSerializer(EscalationPolicySerializer):
EscalationPolicy.STEP_NOTIFY_MULTIPLE_USERS_IMPORTANT,
]:
instance.notify_to_users_queue.clear()
if step not in [
EscalationPolicy.STEP_NOTIFY_TEAM_MEMBERS,
EscalationPolicy.STEP_NOTIFY_TEAM_MEMBERS_IMPORTANT,
]:
instance.notify_to_team_members = None
if step not in [EscalationPolicy.STEP_NOTIFY_GROUP, EscalationPolicy.STEP_NOTIFY_GROUP_IMPORTANT]:
instance.notify_to_group = None
if step != EscalationPolicy.STEP_TRIGGER_CUSTOM_BUTTON:

View file

@ -456,3 +456,71 @@ def test_update_escalation_policy_from_and_to_time(
assert response.data == serializer.data
else:
assert response.json()[field][0] == "Time has wrong format. Use one of these formats instead: hh:mm:ssZ."
@pytest.mark.django_db
def test_create_escalation_policy_using_notify_team_members(
make_organization_and_user_with_token,
make_team,
escalation_policies_setup,
):
organization, user, token = make_organization_and_user_with_token()
escalation_chain, _, _ = escalation_policies_setup(organization, user)
team = make_team(organization)
data_for_create = {
"escalation_chain_id": escalation_chain.public_primary_key,
"type": "notify_team_members",
"position": 0,
"notify_to_team_members": team.team_id,
}
client = APIClient()
url = reverse("api-public:escalation_policies-list")
response = client.post(url, data=data_for_create, format="json", HTTP_AUTHORIZATION=token)
assert response.status_code == status.HTTP_201_CREATED
escalation_policy = EscalationPolicy.objects.get(public_primary_key=response.data["id"])
serializer = EscalationPolicySerializer(escalation_policy)
assert response.data == serializer.data
# update to important
data_to_change = {"important": True}
url = reverse("api-public:escalation_policies-detail", kwargs={"pk": escalation_policy.public_primary_key})
response = client.put(url, data=data_to_change, format="json", HTTP_AUTHORIZATION=token)
assert response.status_code == status.HTTP_200_OK
escalation_policy.refresh_from_db()
serializer = EscalationPolicySerializer(escalation_policy)
assert response.data == serializer.data
# step is migrated
assert escalation_policy.step == EscalationPolicy.STEP_NOTIFY_TEAM_MEMBERS_IMPORTANT
@pytest.mark.django_db
def test_update_escalation_policy_using_notify_team_members(
make_organization_and_user_with_token,
make_team,
escalation_policies_setup,
):
organization, user, token = make_organization_and_user_with_token()
escalation_chain, _, _ = escalation_policies_setup(organization, user)
team = make_team(organization)
data_for_create = {
"escalation_chain_id": escalation_chain.public_primary_key,
"type": "notify_team_members",
"position": 0,
"notify_to_team_members": team.team_id,
}
client = APIClient()
url = reverse("api-public:escalation_policies-list")
response = client.post(url, data=data_for_create, format="json", HTTP_AUTHORIZATION=token)
assert response.status_code == status.HTTP_201_CREATED
escalation_policy = EscalationPolicy.objects.get(public_primary_key=response.data["id"])
serializer = EscalationPolicySerializer(escalation_policy)
assert response.data == serializer.data

View file

@ -98,6 +98,7 @@ CELERY_TASK_ROUTES = {
"apps.alerts.tasks.maintenance.disable_maintenance": {"queue": "critical"},
"apps.alerts.tasks.notify_all.notify_all_task": {"queue": "critical"},
"apps.alerts.tasks.notify_group.notify_group_task": {"queue": "critical"},
"apps.alerts.tasks.notify_team_members.notify_team_members_task": {"queue": "critical"},
"apps.alerts.tasks.notify_ical_schedule_shift.notify_ical_schedule_shift": {"queue": "critical"},
"apps.alerts.tasks.notify_user.notify_user_task": {"queue": "critical"},
"apps.alerts.tasks.notify_user.perform_notification": {"queue": "critical"},

View file

@ -20,6 +20,7 @@ import {
EscalationPolicy as EscalationPolicyType,
EscalationPolicyOption,
} from 'models/escalation_policy/escalation_policy.types';
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
import { Schedule } from 'models/schedule/schedule.types';
import { User } from 'models/user/user.types';
@ -109,6 +110,8 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
return this.renderWaitDelays();
case 'slack_user_group':
return this.renderNotifyUserGroup();
case 'team':
return this.renderNotifyTeam();
case 'schedule':
return this.renderNotifySchedule();
case 'custom_webhook':
@ -420,6 +423,33 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
);
}
renderNotifyTeam() {
const {
data,
isDisabled,
store: { grafanaTeamStore },
} = this.props;
const { notify_to_team_members } = data;
return (
<WithPermissionControlTooltip key="notify_to_team_members" userAction={UserActions.EscalationChainsWrite}>
<GSelect<GrafanaTeam>
disabled={isDisabled}
items={grafanaTeamStore.items}
fetchItemsFn={grafanaTeamStore.updateItems}
getSearchResult={grafanaTeamStore.getSearchResult}
displayField="name"
valueField="id"
placeholder="Select Team"
className={cx('select', 'control')}
value={notify_to_team_members}
onChange={this.getOnChangeHandler('notify_to_team_members')}
width={'auto'}
/>
</WithPermissionControlTooltip>
);
}
getOnSelectChangeHandler = (field: string) => {
return (option: SelectableValue) => {
const { data, onChange = () => {} } = this.props;

View file

@ -1,5 +1,6 @@
import { Channel } from 'models/channel/channel';
import { EscalationChain } from 'models/escalation_chain/escalation_chain.types';
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
import { Schedule } from 'models/schedule/schedule.types';
import { User } from 'models/user/user.types';
@ -19,6 +20,7 @@ export interface EscalationPolicy {
notify_to_channel: Channel['id'] | null;
custom_webhook: OutgoingWebhook['id'] | null;
notify_to_group: UserGroup['id'] | null;
notify_to_team_members: GrafanaTeam['id'] | null;
notify_schedule: Schedule['id'] | null;
important: boolean | null;
num_alerts_in_window: number;