diff --git a/engine/apps/alerts/migrations/0046_alertreceivechannelconnection.py b/engine/apps/alerts/migrations/0046_alertreceivechannelconnection.py new file mode 100644 index 00000000..1e7db964 --- /dev/null +++ b/engine/apps/alerts/migrations/0046_alertreceivechannelconnection.py @@ -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')}, + }, + ), + ] diff --git a/engine/apps/alerts/models/__init__.py b/engine/apps/alerts/models/__init__.py index 8b22ee86..5ca0bb36 100644 --- a/engine/apps/alerts/models/__init__.py +++ b/engine/apps/alerts/models/__init__.py @@ -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 diff --git a/engine/apps/alerts/models/alert_receive_channel_connection.py b/engine/apps/alerts/models/alert_receive_channel_connection.py new file mode 100644 index 00000000..19d9f73e --- /dev/null +++ b/engine/apps/alerts/models/alert_receive_channel_connection.py @@ -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") diff --git a/engine/apps/alerts/tests/factories.py b/engine/apps/alerts/tests/factories.py index 3c92b866..2b72c805 100644 --- a/engine/apps/alerts/tests/factories.py +++ b/engine/apps/alerts/tests/factories.py @@ -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 diff --git a/engine/apps/api/serializers/alert_receive_channel_connection.py b/engine/apps/api/serializers/alert_receive_channel_connection.py new file mode 100644 index 00000000..d196b7aa --- /dev/null +++ b/engine/apps/api/serializers/alert_receive_channel_connection.py @@ -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() diff --git a/engine/apps/api/tests/test_alert_receive_channel.py b/engine/apps/api/tests/test_alert_receive_channel.py index 06d4405f..3ad4a8e9 100644 --- a/engine/apps/api/tests/test_alert_receive_channel.py +++ b/engine/apps/api/tests/test_alert_receive_channel.py @@ -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 + ) diff --git a/engine/apps/api/views/alert_receive_channel.py b/engine/apps/api/views/alert_receive_channel.py index 268405f1..80b67025 100644 --- a/engine/apps/api/views/alert_receive_channel.py +++ b/engine/apps/api/views/alert_receive_channel.py @@ -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\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) diff --git a/engine/conftest.py b/engine/conftest.py index 92f1a7c9..64fb7bf2 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -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):