Backend support for "connected" integrations (#4030)

# What this PR does

Adds a Django model and internal API for connected integrations. Based
on https://github.com/grafana/oncall/pull/3983

## Which issue(s) this PR closes

Related to https://github.com/grafana/oncall-private/issues/2540

## 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:
Vadim Stepanov 2024-03-07 17:47:33 +00:00 committed by GitHub
parent f6b6bb053c
commit cf1fac8997
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 368 additions and 1 deletions

View file

@ -0,0 +1,27 @@
# Generated by Django 4.2.10 on 2024-03-07 13:45
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('alerts', '0045_escalationpolicy_notify_to_team_members_and_more'),
]
operations = [
migrations.CreateModel(
name='AlertReceiveChannelConnection',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('backsync', models.BooleanField(default=False)),
('connected_alert_receive_channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='source_alert_receive_channels', to='alerts.alertreceivechannel')),
('source_alert_receive_channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='connected_alert_receive_channels', to='alerts.alertreceivechannel')),
],
options={
'ordering': ['source_alert_receive_channel', 'connected_alert_receive_channel'],
'unique_together': {('source_alert_receive_channel', 'connected_alert_receive_channel')},
},
),
]

View file

@ -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 AlertReceiveChannelConnection # noqa: F401
from .channel_filter import ChannelFilter # noqa: F401
from .custom_button import CustomButton # noqa: F401
from .escalation_chain import EscalationChain # noqa: F401

View file

@ -0,0 +1,20 @@
from django.db import models
class AlertReceiveChannelConnection(models.Model):
"""
This model represents a connection between two integrations (e.g. when an Alertmanager integration is connected to a
ServiceNow integration).
"""
source_alert_receive_channel = models.ForeignKey(
"AlertReceiveChannel", on_delete=models.CASCADE, related_name="connected_alert_receive_channels"
)
connected_alert_receive_channel = models.ForeignKey(
"AlertReceiveChannel", on_delete=models.CASCADE, related_name="source_alert_receive_channels"
)
backsync = models.BooleanField(default=False)
class Meta:
ordering = ["source_alert_receive_channel", "connected_alert_receive_channel"]
unique_together = ("source_alert_receive_channel", "connected_alert_receive_channel")

View file

@ -5,6 +5,7 @@ from apps.alerts.models import (
AlertGroup,
AlertGroupLogRecord,
AlertReceiveChannel,
AlertReceiveChannelConnection,
ChannelFilter,
CustomButton,
EscalationChain,
@ -24,6 +25,11 @@ class AlertReceiveChannelFactory(factory.DjangoModelFactory):
model = AlertReceiveChannel
class AlertReceiveChannelConnectionFactory(factory.DjangoModelFactory):
class Meta:
model = AlertReceiveChannelConnection
class ChannelFilterFactory(factory.DjangoModelFactory):
class Meta:
model = ChannelFilter

View file

@ -0,0 +1,36 @@
from rest_framework import serializers
from apps.alerts.models import AlertReceiveChannel, AlertReceiveChannelConnection
from apps.api.serializers.alert_receive_channel import FastAlertReceiveChannelSerializer
class AlertReceiveChannelSourceChannelSerializer(serializers.ModelSerializer):
alert_receive_channel = FastAlertReceiveChannelSerializer(source="source_alert_receive_channel", read_only=True)
backsync = serializers.BooleanField()
class Meta:
model = AlertReceiveChannelConnection
fields = ["alert_receive_channel", "backsync"]
class AlertReceiveChannelConnectedChannelSerializer(serializers.ModelSerializer):
alert_receive_channel = FastAlertReceiveChannelSerializer(source="connected_alert_receive_channel", read_only=True)
backsync = serializers.BooleanField()
class Meta:
model = AlertReceiveChannelConnection
fields = ["alert_receive_channel", "backsync"]
class AlertReceiveChannelConnectionSerializer(serializers.ModelSerializer):
source_alert_receive_channels = AlertReceiveChannelSourceChannelSerializer(read_only=True, many=True)
connected_alert_receive_channels = AlertReceiveChannelConnectedChannelSerializer(read_only=True, many=True)
class Meta:
model = AlertReceiveChannel
fields = ["source_alert_receive_channels", "connected_alert_receive_channels"]
class AlertReceiveChannelNewConnectionSerializer(serializers.Serializer):
id = serializers.CharField()
backsync = serializers.BooleanField()

View file

@ -1850,3 +1850,185 @@ def test_alert_receive_channel_webhooks_delete(
webhook.refresh_from_db()
assert webhook.deleted_at is not None
assert alert_receive_channel.webhooks.count() == 0
@pytest.mark.django_db
def test_connected_alert_receive_channels_get(
make_organization_and_user_with_plugin_token,
make_alert_receive_channel,
make_alert_receive_channel_connection,
make_user_auth_headers,
):
organization, user, token = make_organization_and_user_with_plugin_token()
source_alert_receive_channel = make_alert_receive_channel(organization)
connected_alert_receive_channel = make_alert_receive_channel(organization)
make_alert_receive_channel_connection(source_alert_receive_channel, connected_alert_receive_channel)
# get integrations connected to source integration
client = APIClient()
url = reverse(
"api-internal:alert_receive_channel-connected-alert-receive-channels-get",
kwargs={"pk": source_alert_receive_channel.public_primary_key},
)
response = client.get(url, **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"source_alert_receive_channels": [],
"connected_alert_receive_channels": [
{
"alert_receive_channel": {
"id": connected_alert_receive_channel.public_primary_key,
"integration": connected_alert_receive_channel.integration,
"verbal_name": connected_alert_receive_channel.verbal_name,
"deleted": False,
},
"backsync": False,
},
],
}
# get source integrations for particular integration
url = reverse(
"api-internal:alert_receive_channel-connected-alert-receive-channels-get",
kwargs={"pk": connected_alert_receive_channel.public_primary_key},
)
response = client.get(url, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"source_alert_receive_channels": [
{
"alert_receive_channel": {
"id": source_alert_receive_channel.public_primary_key,
"integration": source_alert_receive_channel.integration,
"verbal_name": source_alert_receive_channel.verbal_name,
"deleted": False,
},
"backsync": False,
},
],
"connected_alert_receive_channels": [],
}
@pytest.mark.django_db
def test_connected_alert_receive_channels_post(
make_organization_and_user_with_plugin_token,
make_alert_receive_channel,
make_user_auth_headers,
):
organization, user, token = make_organization_and_user_with_plugin_token()
source_alert_receive_channel = make_alert_receive_channel(organization)
alert_receive_channel_to_connect_1 = make_alert_receive_channel(organization)
alert_receive_channel_to_connect_2 = make_alert_receive_channel(organization)
client = APIClient()
url = reverse(
"api-internal:alert_receive_channel-connected-alert-receive-channels-get",
kwargs={"pk": source_alert_receive_channel.public_primary_key},
)
response = client.post(
url,
data=[
{"id": alert_receive_channel_to_connect_1.public_primary_key, "backsync": False},
{"id": alert_receive_channel_to_connect_2.public_primary_key, "backsync": True},
],
format="json",
**make_user_auth_headers(user, token),
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json() == {
"source_alert_receive_channels": [],
"connected_alert_receive_channels": [
{
"alert_receive_channel": {
"id": alert_receive_channel_to_connect_1.public_primary_key,
"integration": alert_receive_channel_to_connect_1.integration,
"verbal_name": alert_receive_channel_to_connect_1.verbal_name,
"deleted": False,
},
"backsync": False,
},
{
"alert_receive_channel": {
"id": alert_receive_channel_to_connect_2.public_primary_key,
"integration": alert_receive_channel_to_connect_2.integration,
"verbal_name": alert_receive_channel_to_connect_2.verbal_name,
"deleted": False,
},
"backsync": True,
},
],
}
assert source_alert_receive_channel.connected_alert_receive_channels.count() == 2
@pytest.mark.django_db
def test_connected_alert_receive_channels_put(
make_organization_and_user_with_plugin_token,
make_alert_receive_channel,
make_alert_receive_channel_connection,
make_user_auth_headers,
):
organization, user, token = make_organization_and_user_with_plugin_token()
source_alert_receive_channel = make_alert_receive_channel(organization)
connected_alert_receive_channel = make_alert_receive_channel(organization)
connection = make_alert_receive_channel_connection(source_alert_receive_channel, connected_alert_receive_channel)
# update backsync for connected integration
client = APIClient()
url = reverse(
"api-internal:alert_receive_channel-connected-alert-receive-channels-put",
kwargs={
"pk": source_alert_receive_channel.public_primary_key,
"connected_alert_receive_channel_id": connected_alert_receive_channel.public_primary_key,
},
)
response = client.put(url, data={"backsync": True}, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"alert_receive_channel": {
"id": connected_alert_receive_channel.public_primary_key,
"integration": connected_alert_receive_channel.integration,
"verbal_name": connected_alert_receive_channel.verbal_name,
"deleted": False,
},
"backsync": True,
}
connection.refresh_from_db()
assert connection.backsync is True
@pytest.mark.django_db
def test_connected_alert_receive_channels_delete(
make_organization_and_user_with_plugin_token,
make_alert_receive_channel,
make_alert_receive_channel_connection,
make_user_auth_headers,
):
organization, user, token = make_organization_and_user_with_plugin_token()
source_alert_receive_channel = make_alert_receive_channel(organization)
connected_alert_receive_channel_1 = make_alert_receive_channel(organization)
connected_alert_receive_channel_2 = make_alert_receive_channel(organization)
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)
client = APIClient()
url = reverse(
"api-internal:alert_receive_channel-connected-alert-receive-channels-put",
kwargs={
"pk": source_alert_receive_channel.public_primary_key,
"connected_alert_receive_channel_id": connected_alert_receive_channel_1.public_primary_key,
},
)
response = client.delete(url, **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_204_NO_CONTENT
assert source_alert_receive_channel.connected_alert_receive_channels.count() == 1
assert (
source_alert_receive_channel.connected_alert_receive_channels.first().connected_alert_receive_channel
== connected_alert_receive_channel_2
)

View file

@ -15,7 +15,7 @@ from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from apps.alerts.grafana_alerting_sync_manager.grafana_alerting_sync import GrafanaAlertingSyncManager
from apps.alerts.models import Alert, AlertGroup, AlertReceiveChannel
from apps.alerts.models import Alert, AlertGroup, AlertReceiveChannel, AlertReceiveChannelConnection
from apps.alerts.models.maintainable_object import MaintainableObject
from apps.api.label_filtering import parse_label_query
from apps.api.permissions import RBACPermission
@ -24,6 +24,11 @@ from apps.api.serializers.alert_receive_channel import (
AlertReceiveChannelUpdateSerializer,
FilterAlertReceiveChannelSerializer,
)
from apps.api.serializers.alert_receive_channel_connection import (
AlertReceiveChannelConnectedChannelSerializer,
AlertReceiveChannelConnectionSerializer,
AlertReceiveChannelNewConnectionSerializer,
)
from apps.api.serializers.webhook import WebhookSerializer
from apps.api.throttlers import DemoAlertThrottler
from apps.api.views.labels import schedule_update_label_cache
@ -155,6 +160,10 @@ class AlertReceiveChannelView(
"webhooks_post": [RBACPermission.Permissions.INTEGRATIONS_WRITE],
"webhooks_put": [RBACPermission.Permissions.INTEGRATIONS_WRITE],
"webhooks_delete": [RBACPermission.Permissions.INTEGRATIONS_WRITE],
"connected_alert_receive_channels_get": [RBACPermission.Permissions.INTEGRATIONS_READ],
"connected_alert_receive_channels_post": [RBACPermission.Permissions.INTEGRATIONS_WRITE],
"connected_alert_receive_channels_put": [RBACPermission.Permissions.INTEGRATIONS_WRITE],
"connected_alert_receive_channels_delete": [RBACPermission.Permissions.INTEGRATIONS_WRITE],
}
def perform_update(self, serializer):
@ -676,3 +685,74 @@ class AlertReceiveChannelView(
raise NotFound
webhook.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@extend_schema(request=None, responses=AlertReceiveChannelConnectionSerializer)
@action(detail=True, methods=["get"], url_path="connected_alert_receive_channels")
def connected_alert_receive_channels_get(self, request, pk):
instance = self.get_object()
return Response(AlertReceiveChannelConnectionSerializer(instance).data, status=status.HTTP_200_OK)
@extend_schema(
request=AlertReceiveChannelNewConnectionSerializer(many=True), responses=AlertReceiveChannelConnectionSerializer
)
@connected_alert_receive_channels_get.mapping.post
def connected_alert_receive_channels_post(self, request, pk):
instance = self.get_object()
serializer = AlertReceiveChannelNewConnectionSerializer(data=request.data, many=True)
serializer.is_valid(raise_exception=True)
backsync_map = {connection["id"]: connection["backsync"] for connection in serializer.validated_data}
# bulk create connections
AlertReceiveChannelConnection.objects.bulk_create(
[
AlertReceiveChannelConnection(
source_alert_receive_channel=instance,
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()
)
],
ignore_conflicts=True,
batch_size=5000,
)
return Response(AlertReceiveChannelConnectionSerializer(instance).data, status=status.HTTP_201_CREATED)
@extend_schema(
request=AlertReceiveChannelConnectedChannelSerializer,
responses=AlertReceiveChannelConnectedChannelSerializer,
)
@action(
detail=True,
methods=["put"],
url_path=r"connected_alert_receive_channels/(?P<connected_alert_receive_channel_id>\w+)",
)
def connected_alert_receive_channels_put(self, request, pk, connected_alert_receive_channel_id):
instance = self.get_object()
try:
connection = instance.connected_alert_receive_channels.get(
connected_alert_receive_channel_id__public_primary_key=connected_alert_receive_channel_id
)
except ObjectDoesNotExist:
raise NotFound
serializer = AlertReceiveChannelConnectedChannelSerializer(connection, data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
@extend_schema(request=None, responses=None)
@connected_alert_receive_channels_put.mapping.delete
def connected_alert_receive_channels_delete(self, request, pk, connected_alert_receive_channel_id):
instance = self.get_object()
try:
connection = instance.connected_alert_receive_channels.get(
connected_alert_receive_channel_id__public_primary_key=connected_alert_receive_channel_id
)
except ObjectDoesNotExist:
raise NotFound
connection.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View file

@ -28,6 +28,7 @@ from apps.alerts.tests.factories import (
AlertFactory,
AlertGroupFactory,
AlertGroupLogRecordFactory,
AlertReceiveChannelConnectionFactory,
AlertReceiveChannelFactory,
ChannelFilterFactory,
CustomActionFactory,
@ -103,6 +104,7 @@ register(TeamFactory)
register(AlertReceiveChannelFactory)
register(AlertReceiveChannelConnectionFactory)
register(ChannelFilterFactory)
register(EscalationPolicyFactory)
register(OnCallScheduleICalFactory)
@ -481,6 +483,19 @@ def make_alert_receive_channel():
return _make_alert_receive_channel
@pytest.fixture
def make_alert_receive_channel_connection():
def _make_alert_receive_channel_connection(source_alert_receive_channel, connected_alert_receive_channel, **kwargs):
alert_receive_channel_connection = AlertReceiveChannelConnectionFactory(
source_alert_receive_channel=source_alert_receive_channel,
connected_alert_receive_channel=connected_alert_receive_channel,
**kwargs,
)
return alert_receive_channel_connection
return _make_alert_receive_channel_connection
@pytest.fixture
def make_alert_receive_channel_with_post_save_signal():
def _make_alert_receive_channel(organization, **kwargs):