commit
db1259b7bc
14 changed files with 277 additions and 33 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## v1.1.18 (2023-01-18)
|
||||
|
||||
### Added
|
||||
|
||||
- Allow messaging backends to be enabled/disabled per organization ([#1151](https://github.com/grafana/oncall/pull/1151))
|
||||
|
||||
### Changed
|
||||
|
||||
- Send a Slack DM when user is not in channel ([#1144](https://github.com/grafana/oncall/pull/1144))
|
||||
|
||||
## v1.1.17 (2023-01-18)
|
||||
|
||||
### Changed
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ class Alert(models.Model):
|
|||
raw_request_data,
|
||||
enable_autoresolve=True,
|
||||
is_demo=False,
|
||||
channel_filter=None,
|
||||
force_route_id=None,
|
||||
):
|
||||
ChannelFilter = apps.get_model("alerts", "ChannelFilter")
|
||||
|
|
@ -87,9 +88,10 @@ class Alert(models.Model):
|
|||
AlertGroupLogRecord = apps.get_model("alerts", "AlertGroupLogRecord")
|
||||
|
||||
group_data = Alert.render_group_data(alert_receive_channel, raw_request_data, is_demo)
|
||||
channel_filter = ChannelFilter.select_filter(
|
||||
alert_receive_channel, raw_request_data, title, message, force_route_id
|
||||
)
|
||||
if channel_filter is None:
|
||||
channel_filter = ChannelFilter.select_filter(
|
||||
alert_receive_channel, raw_request_data, title, message, force_route_id
|
||||
)
|
||||
|
||||
group, group_created = AlertGroup.all_objects.get_or_create_grouping(
|
||||
channel=alert_receive_channel,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,15 @@ from typing import Any
|
|||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
|
||||
from apps.alerts.models import Alert, AlertGroup, AlertGroupLogRecord, AlertReceiveChannel, UserHasNotification
|
||||
from apps.alerts.models import (
|
||||
Alert,
|
||||
AlertGroup,
|
||||
AlertGroupLogRecord,
|
||||
AlertReceiveChannel,
|
||||
ChannelFilter,
|
||||
EscalationChain,
|
||||
UserHasNotification,
|
||||
)
|
||||
from apps.alerts.tasks.notify_user import notify_user_task
|
||||
from apps.schedules.ical_utils import list_users_to_notify_from_ical
|
||||
from apps.schedules.models import OnCallSchedule
|
||||
|
|
@ -17,18 +25,43 @@ UserNotifications = list[tuple[User, bool]]
|
|||
ScheduleNotifications = list[tuple[OnCallSchedule, bool]]
|
||||
|
||||
|
||||
def _trigger_alert(organization: Organization, team: Team, title: str, message: str, from_user: User) -> AlertGroup:
|
||||
def _trigger_alert(
|
||||
organization: Organization,
|
||||
team: Team,
|
||||
title: str,
|
||||
message: str,
|
||||
from_user: User,
|
||||
escalation_chain: EscalationChain = None,
|
||||
) -> AlertGroup:
|
||||
"""Trigger manual integration alert from params."""
|
||||
alert_receive_channel = AlertReceiveChannel.get_or_create_manual_integration(
|
||||
organization=organization,
|
||||
team=team,
|
||||
integration=AlertReceiveChannel.INTEGRATION_MANUAL,
|
||||
integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING,
|
||||
deleted_at=None,
|
||||
defaults={
|
||||
"author": from_user,
|
||||
"verbal_name": f"Manual alert groups ({team.name if team else 'General'} team)",
|
||||
"verbal_name": f"Direct paging ({team.name if team else 'General'} team)",
|
||||
},
|
||||
)
|
||||
if alert_receive_channel.default_channel_filter is None:
|
||||
ChannelFilter.objects.create(
|
||||
alert_receive_channel=alert_receive_channel,
|
||||
notify_in_slack=True,
|
||||
is_default=True,
|
||||
)
|
||||
|
||||
channel_filter = None
|
||||
if escalation_chain is not None:
|
||||
channel_filter, _ = ChannelFilter.objects.get_or_create(
|
||||
alert_receive_channel=alert_receive_channel,
|
||||
escalation_chain=escalation_chain,
|
||||
is_default=False,
|
||||
defaults={
|
||||
"filtering_term": f"escalate to {escalation_chain.name}",
|
||||
"notify_in_slack": True,
|
||||
},
|
||||
)
|
||||
|
||||
permalink = None
|
||||
if not title:
|
||||
|
|
@ -49,6 +82,7 @@ def _trigger_alert(organization: Organization, team: Team, title: str, message:
|
|||
integration_unique_data={"created_by": from_user.username},
|
||||
image_url=None,
|
||||
link_to_upstream_details=None,
|
||||
channel_filter=channel_filter,
|
||||
)
|
||||
return alert.group
|
||||
|
||||
|
|
@ -103,6 +137,7 @@ def direct_paging(
|
|||
message: str = None,
|
||||
users: UserNotifications = None,
|
||||
schedules: ScheduleNotifications = None,
|
||||
escalation_chain: EscalationChain = None,
|
||||
alert_group: AlertGroup = None,
|
||||
) -> None:
|
||||
"""Trigger escalation targeting given users/schedules.
|
||||
|
|
@ -111,7 +146,7 @@ def direct_paging(
|
|||
Otherwise, create a new alert using given title and message.
|
||||
|
||||
"""
|
||||
if not users and not schedules:
|
||||
if not users and not schedules and not escalation_chain:
|
||||
return
|
||||
|
||||
if users is None:
|
||||
|
|
@ -120,9 +155,12 @@ def direct_paging(
|
|||
if schedules is None:
|
||||
schedules = []
|
||||
|
||||
if escalation_chain is not None and alert_group is not None:
|
||||
raise ValueError("Cannot change an existing alert group escalation chain")
|
||||
|
||||
# create alert group if needed
|
||||
if alert_group is None:
|
||||
alert_group = _trigger_alert(organization, team, title, message, from_user)
|
||||
alert_group = _trigger_alert(organization, team, title, message, from_user, escalation_chain=escalation_chain)
|
||||
|
||||
# get on call users, add log entry for each schedule
|
||||
for (s, important) in schedules:
|
||||
|
|
|
|||
43
engine/apps/alerts/tests/test_alert.py
Normal file
43
engine/apps/alerts/tests/test_alert.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import pytest
|
||||
|
||||
from apps.alerts.models import Alert
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_alert_create_default_channel_filter(make_organization, make_alert_receive_channel, make_channel_filter):
|
||||
organization = make_organization()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
channel_filter = make_channel_filter(alert_receive_channel, is_default=True)
|
||||
|
||||
alert = Alert.create(
|
||||
title="the title",
|
||||
message="the message",
|
||||
alert_receive_channel=alert_receive_channel,
|
||||
raw_request_data={},
|
||||
integration_unique_data={},
|
||||
image_url=None,
|
||||
link_to_upstream_details=None,
|
||||
)
|
||||
|
||||
assert alert.group.channel_filter == channel_filter
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_alert_create_custom_channel_filter(make_organization, make_alert_receive_channel, make_channel_filter):
|
||||
organization = make_organization()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
make_channel_filter(alert_receive_channel, is_default=True)
|
||||
other_channel_filter = make_channel_filter(alert_receive_channel)
|
||||
|
||||
alert = Alert.create(
|
||||
title="the title",
|
||||
message="the message",
|
||||
alert_receive_channel=alert_receive_channel,
|
||||
raw_request_data={},
|
||||
integration_unique_data={},
|
||||
image_url=None,
|
||||
link_to_upstream_details=None,
|
||||
channel_filter=other_channel_filter,
|
||||
)
|
||||
|
||||
assert alert.group.channel_filter == other_channel_filter
|
||||
|
|
@ -244,6 +244,41 @@ def test_direct_paging_reusing_alert_group(
|
|||
assert notify_task.apply_async.called_with((user.pk, ag.pk), {"important": False})
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_direct_paging_reusing_alert_group_custom_chain_raises(
|
||||
make_organization, make_user_for_organization, make_alert_receive_channel, make_alert_group, make_escalation_chain
|
||||
):
|
||||
organization = make_organization()
|
||||
from_user = make_user_for_organization(organization)
|
||||
alert_receive_channel = make_alert_receive_channel(organization=organization)
|
||||
alert_group = make_alert_group(alert_receive_channel=alert_receive_channel)
|
||||
custom_chain = make_escalation_chain(organization)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
direct_paging(organization, None, from_user, alert_group=alert_group, escalation_chain=custom_chain)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_direct_paging_custom_chain(
|
||||
make_organization, make_user_for_organization, make_alert_receive_channel, make_alert_group, make_escalation_chain
|
||||
):
|
||||
organization = make_organization()
|
||||
from_user = make_user_for_organization(organization)
|
||||
custom_chain = make_escalation_chain(organization)
|
||||
|
||||
direct_paging(organization, None, from_user, escalation_chain=custom_chain)
|
||||
|
||||
# alert group created
|
||||
alert_groups = AlertGroup.all_objects.all()
|
||||
assert alert_groups.count() == 1
|
||||
ag = alert_groups.get()
|
||||
channel_filter = ag.channel_filter_with_respect_to_escalation_snapshot
|
||||
assert channel_filter is not None
|
||||
assert not channel_filter.is_default
|
||||
assert channel_filter.notify_in_slack
|
||||
assert ag.escalation_chain_with_respect_to_escalation_snapshot == custom_chain
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unpage_user_not_exists(
|
||||
make_organization, make_user_for_organization, make_alert_receive_channel, make_alert_group
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
|
|
@ -8,6 +9,7 @@ from rest_framework.test import APIClient
|
|||
|
||||
from apps.api.permissions import LegacyAccessControlRole
|
||||
from apps.base.models import UserNotificationPolicy
|
||||
from apps.base.tests.messaging_backend import TestOnlyBackend
|
||||
|
||||
DEFAULT_NOTIFICATION_CHANNEL = UserNotificationPolicy.NotificationChannel.SLACK
|
||||
|
||||
|
|
@ -463,3 +465,22 @@ def test_notification_policy_backends_enabled(
|
|||
assert response.status_code == status.HTTP_200_OK
|
||||
options = [opt["display_name"] for opt in response.json()]
|
||||
assert "Test Only Backend" in options
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_notification_policy_backends_disabled_for_organization(
|
||||
user_notification_policy_internal_api_setup, settings, make_user_auth_headers
|
||||
):
|
||||
token, _, users = user_notification_policy_internal_api_setup
|
||||
admin, _ = users
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:notification_policy-notify-by-options")
|
||||
|
||||
with patch.object(TestOnlyBackend, "is_enabled_for_organization", return_value=False):
|
||||
response = client.get(url, **make_user_auth_headers(admin, token))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
options = [opt["display_name"] for opt in response.json()]
|
||||
assert "Test Only Backend" not in options
|
||||
|
|
|
|||
|
|
@ -176,7 +176,9 @@ class UserNotificationPolicyView(UpdateSerializerMixin, ModelViewSet):
|
|||
built_in_backend_names = {b[0] for b in BUILT_IN_BACKENDS}
|
||||
if notification_channel.name not in built_in_backend_names:
|
||||
extra_messaging_backend = get_messaging_backend_from_id(notification_channel.name)
|
||||
if extra_messaging_backend is None:
|
||||
if extra_messaging_backend is None or not extra_messaging_backend.is_enabled_for_organization(
|
||||
request.auth.organization
|
||||
):
|
||||
continue
|
||||
|
||||
choices.append(
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ class BaseMessagingBackend:
|
|||
"""Remove backend link to user account."""
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def is_enabled_for_organization(organization):
|
||||
return True
|
||||
|
||||
def serialize_user(self, user):
|
||||
"""Return a serialized backend user representation."""
|
||||
raise NotImplementedError("serialize_user method missing implementation")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
|
||||
<p>You can create a direct page alert group from the web UI</p>
|
||||
|
|
@ -4,6 +4,7 @@ from django.conf import settings
|
|||
from fcm_django.models import FCMDevice
|
||||
|
||||
from apps.base.messaging import BaseMessagingBackend
|
||||
from apps.base.models import DynamicSetting
|
||||
from apps.mobile_app.tasks import notify_user_async
|
||||
|
||||
|
||||
|
|
@ -50,6 +51,14 @@ class MobileAppBackend(BaseMessagingBackend):
|
|||
critical=critical,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_enabled_for_organization(organization):
|
||||
mobile_app_settings, _ = DynamicSetting.objects.get_or_create(
|
||||
name="mobile_app_settings", defaults={"json_value": {"org_ids": []}}
|
||||
)
|
||||
|
||||
return organization.pk in mobile_app_settings.json_value["org_ids"]
|
||||
|
||||
|
||||
class MobileAppCriticalBackend(MobileAppBackend):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -212,29 +212,7 @@ class SlackMessage(models.Model):
|
|||
|
||||
if slack_user_identity.slack_id not in channel_members:
|
||||
time.sleep(5) # 2 messages in the same moment are ratelimited by Slack. Dirty hack.
|
||||
result = sc.api_call(
|
||||
"chat.postMessage",
|
||||
channel=channel_id,
|
||||
text=f":warning: Tried to ask {user_verbal} to look at incident. "
|
||||
f"Unfortunately {user_verbal} is not in this channel. Please, invite.",
|
||||
)
|
||||
SlackMessage(
|
||||
slack_id=result["ts"],
|
||||
organization=self.organization,
|
||||
_slack_team_identity=self.slack_team_identity,
|
||||
channel_id=channel_id,
|
||||
alert_group=alert_group,
|
||||
).save()
|
||||
UserNotificationPolicyLogRecord(
|
||||
author=user,
|
||||
type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED,
|
||||
notification_policy=notification_policy,
|
||||
alert_group=alert_group,
|
||||
reason="User is not in Slack channel",
|
||||
notification_step=notification_policy.step,
|
||||
notification_channel=notification_policy.notify_by,
|
||||
notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_SLACK_USER_NOT_IN_CHANNEL,
|
||||
).save()
|
||||
slack_user_identity.send_link_to_slack_message(slack_message)
|
||||
except SlackAPITokenException as e:
|
||||
print(e)
|
||||
except SlackAPIException as e:
|
||||
|
|
|
|||
|
|
@ -92,6 +92,49 @@ class SlackUserIdentity(models.Model):
|
|||
def __str__(self):
|
||||
return self.slack_login
|
||||
|
||||
def send_link_to_slack_message(self, slack_message):
|
||||
blocks = [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "You are invited to look at an alert group!",
|
||||
"emoji": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "actions",
|
||||
"elements": [
|
||||
{
|
||||
"type": "button",
|
||||
"text": {"type": "plain_text", "text": "➡️ Go to the alert group"},
|
||||
"url": slack_message.permalink,
|
||||
"style": "primary",
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "context",
|
||||
"elements": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": (
|
||||
f"You received this message because you're not a member of <#{slack_message.channel_id}>.\n"
|
||||
"Please join the channel to get notified right in the alert group thread."
|
||||
),
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
sc = SlackClientWithErrorHandling(self.slack_team_identity.bot_access_token)
|
||||
return sc.api_call(
|
||||
"chat.postMessage",
|
||||
channel=self.im_channel_id,
|
||||
text="You are invited to look at an alert group!",
|
||||
blocks=blocks,
|
||||
)
|
||||
|
||||
@property
|
||||
def slack_verbal(self):
|
||||
return (
|
||||
|
|
|
|||
56
engine/config_integrations/direct_paging.py
Normal file
56
engine/config_integrations/direct_paging.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# Main
|
||||
enabled = True
|
||||
title = "Direct paging"
|
||||
slug = "direct_paging"
|
||||
short_description = None
|
||||
description = None
|
||||
is_displayed_on_web = False
|
||||
is_featured = False
|
||||
is_able_to_autoresolve = False
|
||||
is_demo_alert_enabled = False
|
||||
|
||||
description = None
|
||||
|
||||
# Default templates
|
||||
slack_title = """\
|
||||
*<{{ grafana_oncall_link }}|#{{ grafana_oncall_incident_id }} {{ payload.oncall.title }}>* via {{ integration_name }}
|
||||
{% if source_link %}
|
||||
(*<{{ source_link }}|source>*)
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
slack_message = """{{ payload.oncall.message }}
|
||||
|
||||
created by {{ payload.oncall.author_username }}
|
||||
"""
|
||||
|
||||
slack_image_url = None
|
||||
|
||||
web_title = "{{ payload.oncall.title }}"
|
||||
|
||||
web_message = """{{ payload.oncall.message }}
|
||||
{% if source_link %}
|
||||
<{{ source_link }} | Link to the original message >
|
||||
{% endif %}
|
||||
created by {{ payload.oncall.author_username }}
|
||||
"""
|
||||
|
||||
web_image_url = slack_image_url
|
||||
|
||||
sms_title = web_title
|
||||
|
||||
phone_call_title = sms_title
|
||||
|
||||
telegram_title = sms_title
|
||||
|
||||
telegram_message = slack_message
|
||||
|
||||
telegram_image_url = slack_image_url
|
||||
|
||||
source_link = "{{ payload.oncall.permalink }}"
|
||||
|
||||
grouping_id = """{{ payload }}"""
|
||||
|
||||
resolve_condition = None
|
||||
|
||||
acknowledge_condition = None
|
||||
|
|
@ -617,6 +617,7 @@ INSTALLED_ONCALL_INTEGRATIONS = [
|
|||
"config_integrations.manual",
|
||||
"config_integrations.slack_channel",
|
||||
"config_integrations.zabbix",
|
||||
"config_integrations.direct_paging",
|
||||
]
|
||||
|
||||
if OSS_INSTALLATION:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue