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:
parent
73dd14d695
commit
d27bd6af51
5 changed files with 226 additions and 4 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue