SNOW external ID (#4076)
# What this PR does - Adds a new model `AlertGroupExternalID` to keep track of incident IDs in external systems - Adds calls to integration config specific functions on alert group creation and webhook response ## Which issue(s) this PR closes Related to https://github.com/grafana/oncall-private/issues/2541 ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] Added the relevant release notes label (see labels prefixed w/ `release:`). These labels dictate how your PR will show up in the autogenerated release notes.
This commit is contained in:
parent
5bcb438012
commit
5074a16861
13 changed files with 210 additions and 10 deletions
|
|
@ -61,6 +61,7 @@ repos:
|
|||
- eslint@^8.25.0
|
||||
- eslint-plugin-import@^2.25.4
|
||||
- eslint-plugin-rulesdir@^0.2.1
|
||||
- eslint-plugin-unused-imports@^3.1.0
|
||||
- "@grafana/eslint-config@^5.0.0"
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
|
|
|
|||
27
engine/apps/alerts/migrations/0048_alertgroupexternalid.py
Normal file
27
engine/apps/alerts/migrations/0048_alertgroupexternalid.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Generated by Django 4.2.10 on 2024-03-19 19:01
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('alerts', '0047_alertreceivechannel_additional_settings'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AlertGroupExternalID',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('value', models.CharField(max_length=512)),
|
||||
('alert_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='external_ids', to='alerts.alertgroup')),
|
||||
('source_alert_receive_channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='external_ids', to='alerts.alertreceivechannel')),
|
||||
],
|
||||
options={
|
||||
'indexes': [models.Index(fields=['value'], name='alerts_aler_value_69ba4f_idx')],
|
||||
'unique_together': {('source_alert_receive_channel', 'alert_group')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -4,6 +4,7 @@ from .alert_group_counter import AlertGroupCounter # noqa: F401
|
|||
from .alert_group_log_record import AlertGroupLogRecord, listen_for_alertgrouplogrecord # noqa: F401
|
||||
from .alert_manager_models import AlertForAlertManager, AlertGroupForAlertManager # noqa: F401
|
||||
from .alert_receive_channel import AlertReceiveChannel, listen_for_alertreceivechannel_model_save # noqa: F401
|
||||
from .alert_receive_channel_connection import AlertGroupExternalID # noqa: F401
|
||||
from .alert_receive_channel_connection import AlertReceiveChannelConnection # noqa: F401
|
||||
from .channel_filter import ChannelFilter # noqa: F401
|
||||
from .custom_button import CustomButton # noqa: F401
|
||||
|
|
|
|||
|
|
@ -18,3 +18,22 @@ class AlertReceiveChannelConnection(models.Model):
|
|||
class Meta:
|
||||
ordering = ["source_alert_receive_channel", "connected_alert_receive_channel"]
|
||||
unique_together = ("source_alert_receive_channel", "connected_alert_receive_channel")
|
||||
|
||||
|
||||
class AlertGroupExternalID(models.Model):
|
||||
"""
|
||||
This model represents an external ID for an alert group. This is used to keep track of the alert group in
|
||||
the external system (e.g. ServiceNow).
|
||||
"""
|
||||
|
||||
source_alert_receive_channel = models.ForeignKey(
|
||||
"AlertReceiveChannel", on_delete=models.CASCADE, related_name="external_ids"
|
||||
)
|
||||
alert_group = models.ForeignKey("AlertGroup", on_delete=models.CASCADE, related_name="external_ids")
|
||||
value = models.CharField(max_length=512)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("source_alert_receive_channel", "alert_group")
|
||||
indexes = [
|
||||
models.Index(fields=["value"]),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -47,3 +47,14 @@ alert_group_update_resolution_note_signal.connect(
|
|||
user_notification_action_triggered_signal.connect(
|
||||
UserSlackRepresentative.on_user_action_triggered,
|
||||
)
|
||||
|
||||
|
||||
def integration_config_on_alert_group_created(**kwargs):
|
||||
alert_group = kwargs["alert_group"]
|
||||
config = alert_group.channel.config
|
||||
if hasattr(config, "on_alert_group_created"):
|
||||
config.on_alert_group_created(alert_group)
|
||||
|
||||
|
||||
# using the "alert_group_escalation_snapshot_built" signal to make sure at least one alert exists for the alert group
|
||||
alert_group_escalation_snapshot_built.connect(integration_config_on_alert_group_created)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import pytest
|
|||
|
||||
from apps.alerts.constants import ActionSource
|
||||
from apps.alerts.incident_appearance.renderers.phone_call_renderer import AlertGroupPhoneCallRenderer
|
||||
from apps.alerts.models import AlertGroup, AlertGroupLogRecord
|
||||
from apps.alerts.models import Alert, AlertGroup, AlertGroupLogRecord
|
||||
from apps.alerts.tasks import wipe
|
||||
from apps.alerts.tasks.delete_alert_group import (
|
||||
delete_alert_group,
|
||||
|
|
@ -665,3 +665,26 @@ def test_delete_by_user(
|
|||
|
||||
for dependent_alert_group in dependent_alert_groups:
|
||||
dependent_alert_group.un_attach_by_delete.assert_called_with()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_integration_config_on_alert_group_created(make_organization, make_alert_receive_channel, make_channel_filter):
|
||||
organization = make_organization()
|
||||
alert_receive_channel = make_alert_receive_channel(organization, grouping_id_template="group_to_one_group")
|
||||
|
||||
with patch.object(
|
||||
alert_receive_channel.config, "on_alert_group_created", create=True
|
||||
) as mock_on_alert_group_created:
|
||||
for _ in range(2):
|
||||
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.alerts.count() == 2
|
||||
mock_on_alert_group_created.assert_called_once_with(alert.group)
|
||||
|
|
|
|||
|
|
@ -2231,10 +2231,15 @@ def test_connected_alert_receive_channels_get(
|
|||
def test_connected_alert_receive_channels_post(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_alert_receive_channel,
|
||||
make_custom_webhook,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
|
||||
source_alert_receive_channel = make_alert_receive_channel(organization)
|
||||
webhook = make_custom_webhook(organization, is_from_connected_integration=True)
|
||||
webhook.filtered_integrations.set([source_alert_receive_channel])
|
||||
|
||||
alert_receive_channel_to_connect_1 = make_alert_receive_channel(organization)
|
||||
alert_receive_channel_to_connect_2 = make_alert_receive_channel(organization)
|
||||
|
||||
|
|
@ -2278,6 +2283,11 @@ def test_connected_alert_receive_channels_post(
|
|||
],
|
||||
}
|
||||
assert source_alert_receive_channel.connected_alert_receive_channels.count() == 2
|
||||
assert set(webhook.filtered_integrations.values_list("id", flat=True)) == {
|
||||
source_alert_receive_channel.id,
|
||||
alert_receive_channel_to_connect_1.id,
|
||||
alert_receive_channel_to_connect_2.id,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -2322,6 +2332,7 @@ def test_connected_alert_receive_channels_delete(
|
|||
make_organization_and_user_with_plugin_token,
|
||||
make_alert_receive_channel,
|
||||
make_alert_receive_channel_connection,
|
||||
make_custom_webhook,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
|
|
@ -2333,6 +2344,11 @@ def test_connected_alert_receive_channels_delete(
|
|||
make_alert_receive_channel_connection(source_alert_receive_channel, connected_alert_receive_channel_1)
|
||||
make_alert_receive_channel_connection(source_alert_receive_channel, connected_alert_receive_channel_2)
|
||||
|
||||
webhook = make_custom_webhook(organization, is_from_connected_integration=True)
|
||||
webhook.filtered_integrations.set(
|
||||
[source_alert_receive_channel, connected_alert_receive_channel_1, connected_alert_receive_channel_2]
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse(
|
||||
"api-internal:alert_receive_channel-connected-alert-receive-channels-put",
|
||||
|
|
@ -2349,6 +2365,10 @@ def test_connected_alert_receive_channels_delete(
|
|||
source_alert_receive_channel.connected_alert_receive_channels.first().connected_alert_receive_channel
|
||||
== connected_alert_receive_channel_2
|
||||
)
|
||||
assert set(webhook.filtered_integrations.values_list("id", flat=True)) == {
|
||||
source_alert_receive_channel.id,
|
||||
connected_alert_receive_channel_2.id,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
|
|||
|
|
@ -777,6 +777,9 @@ class AlertReceiveChannelView(
|
|||
backsync_map = {connection["id"]: connection["backsync"] for connection in serializer.validated_data}
|
||||
|
||||
# bulk create connections
|
||||
alert_receive_channels = instance.organization.alert_receive_channels.filter(
|
||||
public_primary_key__in=backsync_map.keys()
|
||||
)
|
||||
AlertReceiveChannelConnection.objects.bulk_create(
|
||||
[
|
||||
AlertReceiveChannelConnection(
|
||||
|
|
@ -784,14 +787,16 @@ class AlertReceiveChannelView(
|
|||
connected_alert_receive_channel=alert_receive_channel,
|
||||
backsync=backsync_map[alert_receive_channel.public_primary_key],
|
||||
)
|
||||
for alert_receive_channel in instance.organization.alert_receive_channels.filter(
|
||||
public_primary_key__in=backsync_map.keys()
|
||||
)
|
||||
for alert_receive_channel in alert_receive_channels
|
||||
],
|
||||
ignore_conflicts=True,
|
||||
batch_size=5000,
|
||||
)
|
||||
|
||||
# add connected integrations to filtered_integrations
|
||||
for webhook in instance.webhooks.filter(is_from_connected_integration=True):
|
||||
webhook.filtered_integrations.add(*alert_receive_channels)
|
||||
|
||||
return Response(AlertReceiveChannelConnectionSerializer(instance).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@extend_schema(
|
||||
|
|
@ -829,6 +834,11 @@ class AlertReceiveChannelView(
|
|||
raise NotFound
|
||||
|
||||
connection.delete()
|
||||
|
||||
# remove the connected integration from filtered_integrations
|
||||
for webhook in instance.webhooks.filter(is_from_connected_integration=True):
|
||||
webhook.filtered_integrations.remove(connection.connected_alert_receive_channel)
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@extend_schema(responses={status.HTTP_200_OK: None})
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ from django.conf import settings
|
|||
from django.core.validators import MinLengthValidator
|
||||
from django.db import models
|
||||
from django.db.models import F
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
from mirage import fields as mirage_fields
|
||||
from requests.auth import HTTPBasicAuth
|
||||
|
|
@ -337,3 +339,15 @@ class WebhookResponse(models.Model):
|
|||
def json(self):
|
||||
if self.content:
|
||||
return json.loads(self.content)
|
||||
|
||||
|
||||
@receiver(post_save, sender=WebhookResponse)
|
||||
def webhook_response_post_save(sender, instance, created, *args, **kwargs):
|
||||
if not created:
|
||||
return
|
||||
|
||||
source_alert_receive_channel = instance.webhook.filtered_integrations.filter(
|
||||
connected_alert_receive_channels__isnull=False
|
||||
).first() # TODO: is it possible to have more than one?
|
||||
if source_alert_receive_channel and hasattr(source_alert_receive_channel.config, "on_webhook_response_created"):
|
||||
source_alert_receive_channel.config.on_webhook_response_created(instance, source_alert_receive_channel)
|
||||
|
|
|
|||
|
|
@ -120,9 +120,7 @@ def _build_payload(
|
|||
response_data = r.content
|
||||
responses_data[r.webhook.public_primary_key] = response_data
|
||||
|
||||
data = serialize_event(event, alert_group, user, webhook, responses_data)
|
||||
|
||||
return data
|
||||
return serialize_event(event, alert_group, user, webhook, responses_data)
|
||||
|
||||
|
||||
def mask_authorization_header(
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import pytest
|
|||
import requests
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.alerts.models import AlertGroupLogRecord, EscalationPolicy
|
||||
from apps.alerts.models import AlertGroupExternalID, AlertGroupLogRecord, EscalationPolicy
|
||||
from apps.base.models import UserNotificationPolicyLogRecord
|
||||
from apps.public_api.serializers import IncidentSerializer
|
||||
from apps.webhooks.models import Webhook
|
||||
|
|
@ -689,3 +689,65 @@ def test_manually_retried_exceptions(
|
|||
|
||||
mock_requests.post.assert_called_once_with("https://test/", timeout=TIMEOUT, headers={})
|
||||
spy_execute_webhook.apply_async.assert_not_called()
|
||||
|
||||
|
||||
@patch("apps.webhooks.models.webhook.requests.post", return_value=MockResponse())
|
||||
@patch("apps.webhooks.utils.socket.gethostbyname", return_value="8.8.8.8")
|
||||
@pytest.mark.django_db
|
||||
def test_execute_webhook_integration_config(
|
||||
_,
|
||||
mock_requests_post,
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
make_alert_receive_channel,
|
||||
make_alert_receive_channel_connection,
|
||||
make_alert_group,
|
||||
make_user_notification_policy_log_record,
|
||||
make_custom_webhook,
|
||||
):
|
||||
organization = make_organization()
|
||||
user = make_user_for_organization(organization)
|
||||
|
||||
# create connected integrations
|
||||
source_alert_receive_channel = make_alert_receive_channel(organization)
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
make_alert_receive_channel_connection(source_alert_receive_channel, alert_receive_channel)
|
||||
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
webhook = make_custom_webhook(
|
||||
organization=organization,
|
||||
url="https://something/{{ external_id }}",
|
||||
http_method="POST",
|
||||
trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED,
|
||||
forward_all=True,
|
||||
)
|
||||
webhook.filtered_integrations.set([source_alert_receive_channel, alert_receive_channel])
|
||||
|
||||
# create external ID entry
|
||||
AlertGroupExternalID.objects.create(
|
||||
source_alert_receive_channel=source_alert_receive_channel, alert_group=alert_group, value="test123"
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
source_alert_receive_channel.config,
|
||||
"additional_webhook_data",
|
||||
create=True,
|
||||
return_value={"additional_field": "additional_value"},
|
||||
) as mock_additional_webhook_data:
|
||||
with patch.object(
|
||||
source_alert_receive_channel.config, "on_webhook_response_created", create=True
|
||||
) as mock_on_webhook_response_created:
|
||||
execute_webhook(webhook.pk, alert_group.pk, user.pk, None, trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED)
|
||||
|
||||
assert mock_requests_post.called
|
||||
|
||||
# check external ID
|
||||
assert mock_requests_post.call_args[0][0] == "https://something/test123"
|
||||
assert mock_requests_post.call_args[1]["json"]["external_id"] == "test123"
|
||||
|
||||
# check additional webhook data
|
||||
assert mock_requests_post.call_args[1]["json"]["additional_field"] == "additional_value"
|
||||
mock_additional_webhook_data.assert_called_once_with(source_alert_receive_channel)
|
||||
|
||||
# check on_webhook_response_created is called
|
||||
mock_on_webhook_response_created.assert_called_once_with(webhook.responses.all()[0], source_alert_receive_channel)
|
||||
|
|
|
|||
|
|
@ -150,6 +150,7 @@ def _extract_users_from_escalation_snapshot(escalation_snapshot):
|
|||
|
||||
|
||||
def serialize_event(event, alert_group, user, webhook, responses=None):
|
||||
from apps.alerts.models import AlertGroupExternalID
|
||||
from apps.public_api.serializers import IncidentSerializer
|
||||
|
||||
alert_payload = alert_group.alerts.first()
|
||||
|
|
@ -184,4 +185,19 @@ def serialize_event(event, alert_group, user, webhook, responses=None):
|
|||
data["webhook"] = {"id": webhook.public_primary_key, "name": webhook.name, "labels": get_labels_dict(webhook)}
|
||||
data["integration"]["labels"] = get_labels_dict(alert_group.channel)
|
||||
data["alert_group"]["labels"] = get_alert_group_labels_dict(alert_group)
|
||||
|
||||
# Add additional webhook data if the integration has it
|
||||
source_alert_receive_channel = webhook.filtered_integrations.filter(
|
||||
connected_alert_receive_channels__isnull=False
|
||||
).first() # TODO: is it possible to have more than one?
|
||||
if source_alert_receive_channel and hasattr(source_alert_receive_channel.config, "additional_webhook_data"):
|
||||
data.update(source_alert_receive_channel.config.additional_webhook_data(source_alert_receive_channel))
|
||||
|
||||
# Add external ID (e.g. ServiceNow incident ID) to webhook data
|
||||
if source_alert_receive_channel:
|
||||
external_id = AlertGroupExternalID.objects.filter(
|
||||
source_alert_receive_channel=source_alert_receive_channel, alert_group=alert_group
|
||||
).first()
|
||||
data["external_id"] = external_id.value if external_id else None
|
||||
|
||||
return data
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import { MonacoEditor } from 'components/MonacoEditor/MonacoEditor';
|
|||
import { MONACO_READONLY_CONFIG } from 'components/MonacoEditor/MonacoEditor.config';
|
||||
import { WebhooksTemplateEditor } from 'containers/WebhooksTemplateEditor/WebhooksTemplateEditor';
|
||||
import { HTTP_METHOD_OPTIONS, WEBHOOK_TRIGGGER_TYPE_OPTIONS } from 'models/outgoing_webhook/outgoing_webhook.types';
|
||||
import { VALID_URL_PATTERN } from 'utils/string';
|
||||
|
||||
import { getStyles } from './OutgoingTab.styles';
|
||||
import { OutgoingTabFormValues } from './OutgoingTab.types';
|
||||
|
|
@ -101,7 +100,6 @@ export const OutgoingWebhookFormFields: FC<OutgoingWebhookFormFieldsProps> = ({
|
|||
<Input
|
||||
{...register('url', {
|
||||
required: 'URL is required',
|
||||
pattern: { value: VALID_URL_PATTERN, message: 'URL is invalid' },
|
||||
})}
|
||||
/>
|
||||
</Field>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue