Add support for integration additional settings via custom serializer (#4027)

Related to https://github.com/grafana/oncall-private/issues/2540
This commit is contained in:
Matias Bordese 2024-03-07 15:35:11 -03:00 committed by GitHub
parent 73dd14d695
commit d27bd6af51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 226 additions and 4 deletions

View file

@ -17,9 +17,16 @@ class IntegrationOptionsMixin:
super(IntegrationOptionsMixin, self).__init__(*args, **kwargs)
# Object integration configs (imported as submodules earlier) are also available in `config` field,
# e.g. instance.config.id, instance.config.slug, instance.config.description, etc...
for integration in self._config:
if integration.slug == self.integration:
self.config = integration
self.config = IntegrationOptionsMixin.get_config_from_type(self.integration)
@classmethod
def get_config_from_type(cls, integration_type):
config = None
for integration in cls._config:
if integration.slug == integration_type:
config = integration
break
return config
# Define variables for backward compatibility, e.g. INTEGRATION_GRAFANA, INTEGRATION_FORMATTED_WEBHOOK, etc...
for integration_config in _config:

View file

@ -0,0 +1,18 @@
# Generated by Django 4.2.10 on 2024-03-07 18:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('alerts', '0046_alertreceivechannelconnection'),
]
operations = [
migrations.AddField(
model_name='alertreceivechannel',
name='additional_settings',
field=models.JSONField(default=None, null=True),
),
]

View file

@ -305,6 +305,8 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
alert_group_labels_template: str | None = models.TextField(null=True, default=None)
"""Stores a Jinja2 template for "advanced label templating" for alert group labels."""
additional_settings: dict | None = models.JSONField(null=True, default=None)
class Meta:
constraints = [
# This constraint ensures that there's at most one active direct paging integration per team

View file

@ -26,6 +26,14 @@ from .integration_heartbeat import IntegrationHeartBeatSerializer
from .labels import LabelsSerializerMixin
def _additional_settings_serializer_from_type(integration_type: str) -> serializers.Serializer:
"""Return serializer class for given integration_type additional settings."""
cls = None
config = AlertReceiveChannel.get_config_from_type(integration_type)
cls = getattr(config, "additional_settings_serializer", None) if config else None
return cls
# AlertGroupCustomLabelValue represents custom alert group label value for API requests
# It handles two types of label's value:
# 1. Just Label Value from a label repo for a static label
@ -244,6 +252,7 @@ class AlertReceiveChannelSerializer(
inbound_email = serializers.CharField(required=False, read_only=True)
is_legacy = serializers.SerializerMethodField()
alert_group_labels = IntegrationAlertGroupLabelsSerializer(source="*", required=False)
additional_settings = serializers.DictField(allow_null=True, allow_empty=False, required=False, default=None)
# integration heartbeat is in PREFETCH_RELATED not by mistake.
# With using of select_related ORM builds strange join
@ -286,6 +295,7 @@ class AlertReceiveChannelSerializer(
"labels",
"alert_group_labels",
"alertmanager_v2_migrated_at",
"additional_settings",
]
read_only_fields = [
"created_at",
@ -306,6 +316,28 @@ class AlertReceiveChannelSerializer(
]
extra_kwargs = {"integration": {"required": True}}
def to_internal_value(self, data):
settings_serializer_cls = (
_additional_settings_serializer_from_type(self.instance.config.slug) if self.instance else None
)
if settings_serializer_cls:
additional_settings_data = data.get("additional_settings")
settings_serializer = settings_serializer_cls(self.instance, data=additional_settings_data)
settings_serializer.is_valid()
if settings_serializer.errors:
raise ValidationError({"additional_settings": settings_serializer.errors})
data["additional_settings"] = settings_serializer.to_internal_value(additional_settings_data)
return super().to_internal_value(data)
def to_representation(self, instance):
result = super().to_representation(instance)
if instance.additional_settings:
settings_serializer_cls = _additional_settings_serializer_from_type(instance.config.slug)
if settings_serializer_cls:
settings_serializer = settings_serializer_cls(instance)
result["additional_settings"] = settings_serializer.to_representation(instance)
return result
def validate(self, data):
validated_data = super().validate(data)
organization = self.context["request"].auth.organization
@ -396,6 +428,19 @@ class AlertReceiveChannelSerializer(
return integration
def validate_additional_settings(self, data):
integration = self.instance.integration if self.instance else self.initial_data.get("integration")
settings_serializer_cls = _additional_settings_serializer_from_type(integration)
if settings_serializer_cls:
if not data:
raise ValidationError(["This field is required for this integration."])
serializer = settings_serializer_cls(data=data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
elif data is not None:
raise ValidationError(["Invalid data"])
return data
def get_allow_delete(self, obj: "AlertReceiveChannel") -> bool:
# don't allow deleting direct paging integrations
return obj.integration != AlertReceiveChannel.INTEGRATION_DIRECT_PAGING

View file

@ -3,7 +3,7 @@ from unittest.mock import ANY, patch
import pytest
from django.urls import reverse
from rest_framework import status
from rest_framework import serializers, status
from rest_framework.response import Response
from rest_framework.test import APIClient
@ -12,6 +12,23 @@ from apps.api.permissions import LegacyAccessControlRole
from apps.labels.models import LabelKeyCache, LabelValueCache
class AdditionalSettingsTestSerializer(serializers.Serializer):
instance_url = serializers.CharField(required=True)
def validate(self, data):
if hasattr(self, "initial_data"):
unknown_fields = set(self.initial_data.keys()) - set(self.fields.keys())
if unknown_fields:
raise serializers.ValidationError("Unexpected fields: {}".format(unknown_fields))
return data
def to_internal_value(self, data):
return super().to_internal_value(data)
def to_representation(self, instance):
return super().to_representation(instance.additional_settings)
@pytest.fixture()
def alert_receive_channel_internal_api_setup(
make_organization_and_user_with_plugin_token,
@ -1729,6 +1746,139 @@ def test_team_not_updated_if_not_in_data(
assert alert_receive_channel.team == team
@pytest.mark.django_db
def test_create_additional_settings_integration(
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
):
_, user, token = make_organization_and_user_with_plugin_token()
client = APIClient()
# set up additional settings for an integration
integration = AlertReceiveChannel._config[0]
integration.additional_settings_serializer = AdditionalSettingsTestSerializer
url = reverse("api-internal:alert_receive_channel-list")
# create without additional_settings
data = {
"integration": integration.slug,
"team": None,
}
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
# create with empty additional_settings
data = {"integration": integration.slug, "team": None, "additional_settings": {}}
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "additional_settings" in response.json()
# create with wrong additional_settings
data = {
"integration": integration.slug,
"team": None,
"additional_settings": {"test": "test"},
}
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
# create with correct additional_settings
data = {
"integration": integration.slug,
"team": None,
"additional_settings": {"instance_url": "test"},
}
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_201_CREATED
@pytest.mark.django_db
def test_update_additional_settings_integration(
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()
client = APIClient()
settings = {"instance_url": "test"}
# set up additional settings for an integration
integration = AlertReceiveChannel._config[0]
integration.additional_settings_serializer = AdditionalSettingsTestSerializer
alert_receive_channel = make_alert_receive_channel(
organization, integration=integration.slug, additional_settings=settings
)
url = reverse("api-internal:alert_receive_channel-detail", kwargs={"pk": alert_receive_channel.public_primary_key})
# wrong additional_settings
data = {"additional_settings": {"test": "test", "username": "test", "password": "test"}}
response = client.put(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "additional_settings" in response.json()
data = {"additional_settings": {}}
response = client.put(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "additional_settings" in response.json()
data = {"additional_settings": None}
response = client.put(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "additional_settings" in response.json()
data = {
"additional_settings": {
"test": "test",
"instance_url": "test2",
}
}
response = client.put(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "additional_settings" in response.json()
# update with correct settings
data = {
"additional_settings": {
"instance_url": "test2",
}
}
response = client.put(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
alert_receive_channel.refresh_from_db()
assert alert_receive_channel.additional_settings == data["additional_settings"]
@pytest.mark.django_db
def test_update_other_integration_additional_settings(
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()
client = APIClient()
alert_receive_channel = make_alert_receive_channel(organization)
url = reverse("api-internal:alert_receive_channel-detail", kwargs={"pk": alert_receive_channel.public_primary_key})
# integration doesn't have additional_settings
data = {
"additional_settings": {
"instance_url": "test",
"username": "test",
"password": "test",
"is_configured": True,
"state_mapping": {
"firing": [1, "New"],
"acknowledged": None,
"resolved": None,
"silenced": None,
},
}
}
response = client.put(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
def _webhook_data(webhook_id=ANY, webhook_name=ANY, webhook_url=ANY, alert_receive_channel_id=ANY):
return {
"authorization_header": None,