diff --git a/CHANGELOG.md b/CHANGELOG.md index 8893332c..6711b92e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Change Log +## 1.0.2 (2022-06-17) + +- Fix Grafana Alerting integration to handle API changes in Grafana 9 +- Improve public api endpoint for for outgoing webhooks (/actions) by adding ability to create, update and delete outgoing webhook instance + +## 1.0.0 (2022-06-14) + +- First Public Release + ## 0.0.71 (2022-06-06) -- Initial Release \ No newline at end of file +- Initial Commit Release \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 3642e441..894b26fe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,7 @@ services: engine: image: grafana/oncall + restart: always ports: - 8080:8080 command: > @@ -35,6 +36,7 @@ services: celery: # TODO: change to the public image once it's public image: grafana/oncall + restart: always command: sh -c "./celery_with_exporter.sh" environment: BASE_URL: $DOMAIN @@ -122,6 +124,7 @@ services: rabbitmq: image: "rabbitmq:3.7.15-management" + restart: always hostname: rabbitmq mem_limit: 1000m cpus: 0.5 @@ -144,6 +147,7 @@ services: grafana: image: "grafana/grafana:9.0.0-beta3" + restart: always mem_limit: 500m ports: - 3000:3000 diff --git a/engine/apps/alerts/grafana_alerting_sync_manager/grafana_alerting_sync.py b/engine/apps/alerts/grafana_alerting_sync_manager/grafana_alerting_sync.py index a9ca08fb..d72524bf 100644 --- a/engine/apps/alerts/grafana_alerting_sync_manager/grafana_alerting_sync.py +++ b/engine/apps/alerts/grafana_alerting_sync_manager/grafana_alerting_sync.py @@ -54,6 +54,31 @@ class GrafanaAlertingSyncManager: ) return + def alerting_config_with_respect_to_grafana_version( + self, is_grafana_datasource, datasource_id, datasource_uid, client_method, *args + ): + """Quick fix for deprecated grafana alerting api endpoints""" + + if is_grafana_datasource: + datasource_attr = GrafanaAlertingSyncManager.GRAFANA_CONTACT_POINT + config, response_info = client_method(datasource_attr, *args) + else: + # Get config by datasource id for Grafana version < 9 + datasource_attr = datasource_id + config, response_info = client_method(datasource_attr, *args) + + if response_info["status_code"] == status.HTTP_400_BAD_REQUEST: + # Get config by datasource uid for Grafana version >= 9 + datasource_attr = datasource_uid + config, response_info = client_method(datasource_attr, *args) + if config is None: + logger.warning( + f"Got config None in alerting_config_with_respect_to_grafana_version with method " + f"{client_method.__name__} for is_grafana_datasource {is_grafana_datasource} for integration " + f"{self.alert_receive_channel.pk}; response: {response_info}" + ) + return config, response_info + def create_contact_points(self) -> None: """ Get all alertmanager datasources and try to create contact points for them. @@ -84,6 +109,10 @@ class GrafanaAlertingSyncManager: datasources_to_create.append(datasource) if datasources_to_create: + logger.warning( + f"Some contact points were not created for integration {self.alert_receive_channel.pk}, " + f"trying to create async" + ) # create other contact points async schedule_create_contact_points_for_datasource(self.alert_receive_channel.pk, datasources_to_create) else: @@ -98,13 +127,14 @@ class GrafanaAlertingSyncManager: if datasource is None: datasource = {} - datasource_id_or_grafana = datasource.get("id") or GrafanaAlertingSyncManager.GRAFANA_CONTACT_POINT datasource_type = datasource.get("type") or GrafanaAlertingSyncManager.GRAFANA_CONTACT_POINT is_grafana_datasource = datasource.get("id") is None logger.info( f"Create contact point for {datasource_type} datasource, integration {self.alert_receive_channel.pk}" ) - config, response_info = self.client.get_alerting_config(datasource_id_or_grafana) + config, response_info = self.alerting_config_with_respect_to_grafana_version( + is_grafana_datasource, datasource.get("id"), datasource.get("uid"), self.client.get_alerting_config + ) if config is None: logger.warning( @@ -116,7 +146,12 @@ class GrafanaAlertingSyncManager: updated_config = copy.deepcopy(config) if config["alertmanager_config"] is None: - default_config, response_info = self.client.get_alertmanager_status_with_config(datasource_id_or_grafana) + default_config, response_info = self.alerting_config_with_respect_to_grafana_version( + is_grafana_datasource, + datasource.get("id"), + datasource.get("uid"), + self.client.get_alertmanager_status_with_config, + ) if default_config is None: logger.warning( f"Failed to create contact point (alertmanager_config is None) for integration " @@ -144,7 +179,13 @@ class GrafanaAlertingSyncManager: ) updated_config["alertmanager_config"]["receivers"] = receivers + [new_receiver] - response, response_info = self.client.update_alerting_config(updated_config, datasource_id_or_grafana) + response, response_info = self.alerting_config_with_respect_to_grafana_version( + is_grafana_datasource, + datasource.get("id"), + datasource.get("uid"), + self.client.update_alerting_config, + updated_config, + ) if response is None: logger.warning( f"Failed to create contact point for integration {self.alert_receive_channel.pk} (POST): {response_info}" @@ -153,7 +194,9 @@ class GrafanaAlertingSyncManager: logger.warning(f"Config: {config}\nUpdated config: {updated_config}") return - config, response_info = self.client.get_alerting_config(datasource_id_or_grafana) + config, response_info = self.alerting_config_with_respect_to_grafana_version( + is_grafana_datasource, datasource.get("id"), datasource.get("uid"), self.client.get_alerting_config + ) contact_point = self._create_contact_point_from_payload(config, receiver_name, datasource) contact_point_created_text = "created" if contact_point else "not created, creation will be retried" logger.info( @@ -232,6 +275,7 @@ class GrafanaAlertingSyncManager: uid=receiver_config.get("uid"), # uid is None for non-Grafana datasource datasource_name=datasource.get("name") or GrafanaAlertingSyncManager.GRAFANA_CONTACT_POINT, datasource_id=datasource.get("id"), # id is None for Grafana datasource + datasource_uid=datasource.get("uid"), # uid is None for Grafana datasource ) contact_point.save() return contact_point @@ -268,14 +312,23 @@ class GrafanaAlertingSyncManager: def sync_contact_point(self, contact_point) -> None: """Update name of contact point and related routes or delete it if integration was deleted""" - datasource_id = contact_point.datasource_id or GrafanaAlertingSyncManager.GRAFANA_CONTACT_POINT - datasource_type = "grafana" if not contact_point.datasource_id else "nongrafana" + datasource_type = ( + GrafanaAlertingSyncManager.GRAFANA_CONTACT_POINT + if not (contact_point.datasource_id or contact_point.datasource_uid) + else "nongrafana" + ) + is_grafana_datasource = datasource_type == GrafanaAlertingSyncManager.GRAFANA_CONTACT_POINT logger.info( f"Sync contact point for {datasource_type} (name: {contact_point.datasource_name}) datasource, integration " f"{self.alert_receive_channel.pk}" ) - config, response_info = self.client.get_alerting_config(datasource_id) + config, response_info = self.alerting_config_with_respect_to_grafana_version( + is_grafana_datasource, + contact_point.datasource_id, + contact_point.datasource_uid, + self.client.get_alerting_config, + ) if config is None: logger.warning( f"Failed to update contact point (GET) for integration {self.alert_receive_channel.pk}: Is unified " @@ -286,7 +339,7 @@ class GrafanaAlertingSyncManager: receivers = config["alertmanager_config"]["receivers"] name_in_alerting = self.find_name_of_contact_point( contact_point.uid, - datasource_id, + is_grafana_datasource, receivers, ) @@ -300,8 +353,8 @@ class GrafanaAlertingSyncManager: new_name, ) contact_point.name = new_name - if datasource_id != GrafanaAlertingSyncManager.GRAFANA_CONTACT_POINT: - datasource_name = self.get_datasource_name(datasource_id) + if not is_grafana_datasource: + datasource_name = self.get_datasource_name(contact_point) contact_point.datasource_name = datasource_name contact_point.save(update_fields=["name", "datasource_name"]) # if integration was deleted, delete contact point and related routes @@ -310,8 +363,13 @@ class GrafanaAlertingSyncManager: updated_config, name_in_alerting, ) - - response, response_info = self.client.update_alerting_config(updated_config, datasource_id) + response, response_info = self.alerting_config_with_respect_to_grafana_version( + is_grafana_datasource, + contact_point.datasource_id, + contact_point.datasource_uid, + self.client.update_alerting_config, + updated_config, + ) if response is None: logger.warning( f"Failed to update contact point for integration {self.alert_receive_channel.pk} " @@ -379,8 +437,8 @@ class GrafanaAlertingSyncManager: return alerting_route - def find_name_of_contact_point(self, contact_point_uid, datasource_id, receivers) -> str: - if datasource_id == GrafanaAlertingSyncManager.GRAFANA_CONTACT_POINT: + def find_name_of_contact_point(self, contact_point_uid, is_grafana_datasource, receivers) -> str: + if is_grafana_datasource: name_in_alerting = self._find_name_of_contact_point_by_uid(contact_point_uid, receivers) else: name_in_alerting = self._find_name_of_contact_point_by_integration_url(receivers) @@ -415,6 +473,11 @@ class GrafanaAlertingSyncManager: break return name_in_alerting - def get_datasource_name(self, datasource_id) -> str: - datasource, _ = self.client.get_datasource(datasource_id) + def get_datasource_name(self, contact_point) -> str: + datasource_id = contact_point.datasource_id + datasource_uid = contact_point.datasource_uid + datasource, response_info = self.client.get_datasource(datasource_uid) + if response_info["status_code"] != 200: + # For old Grafana versions (< 9) try to use deprecated endpoint + datasource, _ = self.client.get_datasource_by_id(datasource_id) return datasource["name"] diff --git a/engine/apps/alerts/migrations/0003_grafanaalertingcontactpoint_datasource_uid.py b/engine/apps/alerts/migrations/0003_grafanaalertingcontactpoint_datasource_uid.py new file mode 100644 index 00000000..4bdcec63 --- /dev/null +++ b/engine/apps/alerts/migrations/0003_grafanaalertingcontactpoint_datasource_uid.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.13 on 2022-06-14 15:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('alerts', '0002_squashed_initial'), + ] + + operations = [ + migrations.AddField( + model_name='grafanaalertingcontactpoint', + name='datasource_uid', + field=models.CharField(default=None, max_length=100, null=True), + ), + ] diff --git a/engine/apps/alerts/models/grafana_alerting_contact_point.py b/engine/apps/alerts/models/grafana_alerting_contact_point.py index d4cee24c..00f28981 100644 --- a/engine/apps/alerts/models/grafana_alerting_contact_point.py +++ b/engine/apps/alerts/models/grafana_alerting_contact_point.py @@ -16,7 +16,8 @@ class GrafanaAlertingContactPoint(models.Model): default=None, related_name="contact_points", ) - uid = models.CharField(max_length=100, null=True, default=None) # uid is None for non-Grafana datasource + uid = models.CharField(max_length=100, null=True, default=None) # receiver uid is None for non-Grafana datasource name = models.CharField(max_length=100) datasource_name = models.CharField(max_length=100, default="grafana") datasource_id = models.IntegerField(null=True, default=None) # id is None for Grafana datasource + datasource_uid = models.CharField(max_length=100, null=True, default=None) # uid is None for Grafana datasource diff --git a/engine/apps/alerts/tasks/create_contact_points_for_datasource.py b/engine/apps/alerts/tasks/create_contact_points_for_datasource.py index a447a39c..7532d187 100644 --- a/engine/apps/alerts/tasks/create_contact_points_for_datasource.py +++ b/engine/apps/alerts/tasks/create_contact_points_for_datasource.py @@ -42,7 +42,14 @@ def create_contact_points_for_datasource(alert_receive_channel_id, datasource_li AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel") - alert_receive_channel = AlertReceiveChannel.objects.get(pk=alert_receive_channel_id) + alert_receive_channel = AlertReceiveChannel.objects.filter(pk=alert_receive_channel_id).first() + if not alert_receive_channel: + logger.debug( + f"Cannot create contact point for integration {alert_receive_channel_id}: integration does not exist" + ) + return + + grafana_alerting_sync_manager = alert_receive_channel.grafana_alerting_sync_manager client = GrafanaAPIClient( api_url=alert_receive_channel.organization.grafana_url, @@ -52,11 +59,23 @@ def create_contact_points_for_datasource(alert_receive_channel_id, datasource_li datasources_to_create = [] for datasource in datasource_list: contact_point = None - config, response_info = client.get_alerting_config(datasource["id"]) + is_grafana_datasource = not (datasource.get("id") or datasource.get("uid")) + config, response_info = grafana_alerting_sync_manager.alerting_config_with_respect_to_grafana_version( + is_grafana_datasource, datasource.get("id"), datasource.get("uid"), client.get_alerting_config + ) if config is None: + logger.debug( + f"Got config None for is_grafana_datasource {is_grafana_datasource} " + f"for integration {alert_receive_channel_id}; response: {response_info}" + ) if response_info.get("status_code") == status.HTTP_404_NOT_FOUND: - client.get_alertmanager_status_with_config(datasource["id"]) - contact_point = alert_receive_channel.grafana_alerting_sync_manager.create_contact_point(datasource) + grafana_alerting_sync_manager.alerting_config_with_respect_to_grafana_version( + is_grafana_datasource, + datasource.get("id"), + datasource.get("uid"), + client.get_alertmanager_status_with_config, + ) + contact_point = grafana_alerting_sync_manager.create_contact_point(datasource) elif response_info.get("status_code") == status.HTTP_400_BAD_REQUEST: logger.warning( f"Failed to create contact point for integration {alert_receive_channel_id}, " @@ -64,9 +83,13 @@ def create_contact_points_for_datasource(alert_receive_channel_id, datasource_li ) continue else: - contact_point = alert_receive_channel.grafana_alerting_sync_manager.create_contact_point(datasource) + contact_point = grafana_alerting_sync_manager.create_contact_point(datasource) if contact_point is None: - # Failed to create contact point duo to getting wrong alerting config. + logger.warning( + f"Failed to create contact point for integration {alert_receive_channel_id} due to getting wrong " + f"config, datasource info: {datasource}; response: {response_info}. Retrying" + ) + # Failed to create contact point due to getting wrong alerting config. # Add datasource to list and retry to create contact point for it again datasources_to_create.append(datasource) diff --git a/engine/apps/api/serializers/organization.py b/engine/apps/api/serializers/organization.py index 4dc69402..85d63fdf 100644 --- a/engine/apps/api/serializers/organization.py +++ b/engine/apps/api/serializers/organization.py @@ -3,7 +3,6 @@ from datetime import timedelta import humanize import pytz from django.apps import apps -from django.conf import settings from django.utils import timezone from rest_framework import fields, serializers @@ -121,7 +120,6 @@ class CurrentOrganizationSerializer(OrganizationSerializer): return { "telegram_configured": telegram_configured, "twilio_configured": twilio_configured, - "extra_messaging_backends_enabled": settings.FEATURE_EXTRA_MESSAGING_BACKENDS_ENABLED, } def get_stats(self, obj): diff --git a/engine/apps/api/tests/test_organization.py b/engine/apps/api/tests/test_organization.py index 0b97701e..ed13fb2c 100644 --- a/engine/apps/api/tests/test_organization.py +++ b/engine/apps/api/tests/test_organization.py @@ -80,30 +80,6 @@ def test_current_team_update_permissions( assert response.status_code == expected_status -@pytest.mark.django_db -@pytest.mark.parametrize("feature_flag_enabled", [False, True]) -def test_current_team_messaging_backend_status( - settings, - make_organization, - make_user_for_organization, - make_token_for_organization, - make_user_auth_headers, - feature_flag_enabled, -): - org = make_organization() - tester = make_user_for_organization(org, role=Role.ADMIN) - _, token = make_token_for_organization(org) - - client = APIClient() - - settings.FEATURE_EXTRA_MESSAGING_BACKENDS_ENABLED = feature_flag_enabled - url = reverse("api-internal:api-current-team") - response = client.get(url, format="json", **make_user_auth_headers(tester, token)) - - assert response.status_code == status.HTTP_200_OK - assert response.json()["env_status"]["extra_messaging_backends_enabled"] == bool(feature_flag_enabled) - - @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", diff --git a/engine/apps/api/tests/test_user_notification_policy.py b/engine/apps/api/tests/test_user_notification_policy.py index 4760b47c..1eb39e61 100644 --- a/engine/apps/api/tests/test_user_notification_policy.py +++ b/engine/apps/api/tests/test_user_notification_policy.py @@ -450,22 +450,16 @@ def test_switch_wait_delay( @pytest.mark.django_db -@pytest.mark.parametrize("feature_flag_enabled", [False, True]) def test_notification_policy_backends_enabled( - user_notification_policy_internal_api_setup, settings, make_user_auth_headers, feature_flag_enabled + user_notification_policy_internal_api_setup, settings, make_user_auth_headers ): token, _, users = user_notification_policy_internal_api_setup admin, _ = users - settings.FEATURE_EXTRA_MESSAGING_BACKENDS_ENABLED = feature_flag_enabled - client = APIClient() url = reverse("api-internal:notification_policy-notify-by-options") response = client.get(url, **make_user_auth_headers(admin, token)) assert response.status_code == status.HTTP_200_OK options = [opt["display_name"] for opt in response.json()] - if feature_flag_enabled: - assert "Test Only Backend" in options - else: - assert "Test Only Backend" not in options + assert "Test Only Backend" in options diff --git a/engine/apps/base/messaging.py b/engine/apps/base/messaging.py index 694bb221..3b288540 100644 --- a/engine/apps/base/messaging.py +++ b/engine/apps/base/messaging.py @@ -52,9 +52,6 @@ def load_backend(path): def get_messaging_backends(): global _messaging_backends - if not settings.FEATURE_EXTRA_MESSAGING_BACKENDS_ENABLED: - return {} - if _messaging_backends is None: _messaging_backends = {} for backend_path in settings.EXTRA_MESSAGING_BACKENDS: @@ -64,10 +61,7 @@ def get_messaging_backends(): def get_messaging_backend_from_id(backend_id): - backend = None - if settings.FEATURE_EXTRA_MESSAGING_BACKENDS_ENABLED: - backend = _messaging_backends.get(backend_id) - return backend + return _messaging_backends.get(backend_id) _messaging_backends = None diff --git a/engine/apps/base/tests/test_messaging.py b/engine/apps/base/tests/test_messaging.py index 542a8250..ed12b819 100644 --- a/engine/apps/base/tests/test_messaging.py +++ b/engine/apps/base/tests/test_messaging.py @@ -3,17 +3,7 @@ import pytest from apps.base.messaging import get_messaging_backend_from_id, get_messaging_backends -@pytest.mark.django_db -def test_messaging_backends_disabled(settings): - settings.FEATURE_EXTRA_MESSAGING_BACKENDS_ENABLED = False - - assert get_messaging_backends() == {} - assert get_messaging_backend_from_id("TESTONLY") is None - - @pytest.mark.django_db def test_messaging_backends_enabled(settings): - settings.FEATURE_EXTRA_MESSAGING_BACKENDS_ENABLED = True - assert get_messaging_backends() != {} assert get_messaging_backend_from_id("TESTONLY") is not None diff --git a/engine/apps/grafana_plugin/helpers/client.py b/engine/apps/grafana_plugin/helpers/client.py index bb4586da..7c864383 100644 --- a/engine/apps/grafana_plugin/helpers/client.py +++ b/engine/apps/grafana_plugin/helpers/client.py @@ -103,16 +103,20 @@ class GrafanaAPIClient(APIClient): def get_datasources(self): return self.api_get("api/datasources") - def get_datasource(self, datasource_id): + def get_datasource_by_id(self, datasource_id): + # This endpoint is deprecated for Grafana version >= 9. Use get_datasource instead return self.api_get(f"api/datasources/{datasource_id}") + def get_datasource(self, datasource_uid): + return self.api_get(f"api/datasources/uid/{datasource_uid}") + def get_alertmanager_status_with_config(self, recipient): return self.api_get(f"api/alertmanager/{recipient}/api/v2/status") def get_alerting_config(self, recipient): return self.api_get(f"api/alertmanager/{recipient}/config/api/v1/alerts") - def update_alerting_config(self, config, recipient): + def update_alerting_config(self, recipient, config): return self.api_post(f"api/alertmanager/{recipient}/config/api/v1/alerts", config) diff --git a/engine/apps/public_api/serializers/action.py b/engine/apps/public_api/serializers/action.py index db202b22..963aacbc 100644 --- a/engine/apps/public_api/serializers/action.py +++ b/engine/apps/public_api/serializers/action.py @@ -1,17 +1,88 @@ +import json + +from django.core.validators import URLValidator, ValidationError +from jinja2 import Template, TemplateError from rest_framework import serializers +from rest_framework.validators import UniqueTogetherValidator from apps.alerts.models import CustomButton from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField +from common.api_helpers.utils import CurrentOrganizationDefault -class ActionSerializer(serializers.ModelSerializer): +class ActionCreateSerializer(serializers.ModelSerializer): id = serializers.CharField(read_only=True, source="public_primary_key") - team_id = TeamPrimaryKeyRelatedField(allow_null=True, source="team") + organization = serializers.HiddenField(default=CurrentOrganizationDefault()) + team_id = TeamPrimaryKeyRelatedField(required=False, allow_null=True, source="team") class Meta: model = CustomButton fields = [ "id", "name", + "organization", "team_id", + "webhook", + "data", + "user", + "password", + "authorization_header", + "forward_whole_payload", ] + extra_kwargs = { + "name": {"required": True, "allow_null": False, "allow_blank": False}, + "webhook": {"required": True, "allow_null": False, "allow_blank": False}, + "data": {"required": False, "allow_null": True, "allow_blank": False}, + "user": {"required": False, "allow_null": True, "allow_blank": False}, + "password": {"required": False, "allow_null": True, "allow_blank": False}, + "authorization_header": {"required": False, "allow_null": True, "allow_blank": False}, + "forward_whole_payload": {"required": False, "allow_null": True}, + } + + validators = [UniqueTogetherValidator(queryset=CustomButton.objects.all(), fields=["name", "organization"])] + + def validate_webhook(self, webhook): + if webhook: + try: + URLValidator()(webhook) + except ValidationError: + raise serializers.ValidationError("Webhook is incorrect") + return webhook + return None + + def validate_data(self, data): + if not data: + return None + + try: + json.loads(data) + except ValueError: + raise serializers.ValidationError("Data has incorrect format") + + try: + Template(data) + except TemplateError: + raise serializers.ValidationError("Data has incorrect template") + + return data + + def validate_forward_whole_payload(self, data): + if data is None: + return False + return data + + +class ActionUpdateSerializer(ActionCreateSerializer): + team_id = TeamPrimaryKeyRelatedField(source="team", read_only=True) + + class Meta(ActionCreateSerializer.Meta): + + extra_kwargs = { + "name": {"required": False, "allow_null": False, "allow_blank": False}, + "webhook": {"required": False, "allow_null": False, "allow_blank": False}, + "data": {"required": False, "allow_null": True, "allow_blank": False}, + "user": {"required": False, "allow_null": True, "allow_blank": False}, + "password": {"required": False, "allow_null": True, "allow_blank": False}, + "authorization_header": {"required": False, "allow_null": True, "allow_blank": False}, + "forward_whole_payload": {"required": False, "allow_null": True}, + } diff --git a/engine/apps/public_api/tests/test_custom_actions.py b/engine/apps/public_api/tests/test_custom_actions.py index 2fc39f92..ee0e5f67 100644 --- a/engine/apps/public_api/tests/test_custom_actions.py +++ b/engine/apps/public_api/tests/test_custom_actions.py @@ -3,6 +3,8 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient +from apps.alerts.models import CustomButton + @pytest.mark.django_db def test_get_custom_actions( @@ -28,6 +30,12 @@ def test_get_custom_actions( "id": custom_action.public_primary_key, "name": custom_action.name, "team_id": None, + "webhook": custom_action.webhook, + "data": custom_action.data, + "user": custom_action.user, + "password": custom_action.password, + "authorization_header": custom_action.authorization_header, + "forward_whole_payload": custom_action.forward_whole_payload, } ], } @@ -60,6 +68,12 @@ def test_get_custom_actions_filter_by_name( "id": custom_action.public_primary_key, "name": custom_action.name, "team_id": None, + "webhook": custom_action.webhook, + "data": custom_action.data, + "user": custom_action.user, + "password": custom_action.password, + "authorization_header": custom_action.authorization_header, + "forward_whole_payload": custom_action.forward_whole_payload, } ], } @@ -87,3 +101,171 @@ def test_get_custom_actions_filter_by_name_empty_result( assert response.status_code == status.HTTP_200_OK assert response.data == expected_payload + + +@pytest.mark.django_db +def test_get_custom_action( + make_organization_and_user_with_token, + make_custom_action, +): + + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + custom_action = make_custom_action(organization=organization) + + url = reverse("api-public:actions-detail", kwargs={"pk": custom_action.public_primary_key}) + + response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}") + + expected_payload = { + "id": custom_action.public_primary_key, + "name": custom_action.name, + "team_id": None, + "webhook": custom_action.webhook, + "data": custom_action.data, + "user": custom_action.user, + "password": custom_action.password, + "authorization_header": custom_action.authorization_header, + "forward_whole_payload": custom_action.forward_whole_payload, + } + + assert response.status_code == status.HTTP_200_OK + assert response.data == expected_payload + + +@pytest.mark.django_db +def test_create_custom_action(make_organization_and_user_with_token): + + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + url = reverse("api-public:actions-list") + + data = { + "name": "Test outgoing webhook", + "webhook": "https://example.com", + } + + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + + custom_action = CustomButton.objects.get(public_primary_key=response.data["id"]) + + expected_result = { + "id": custom_action.public_primary_key, + "name": custom_action.name, + "team_id": None, + "webhook": custom_action.webhook, + "data": custom_action.data, + "user": custom_action.user, + "password": custom_action.password, + "authorization_header": custom_action.authorization_header, + "forward_whole_payload": custom_action.forward_whole_payload, + } + + assert response.status_code == status.HTTP_201_CREATED + assert response.data == expected_result + + +@pytest.mark.django_db +def test_create_custom_action_invalid_data( + make_organization_and_user_with_token, +): + + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + url = reverse("api-public:actions-list") + + data = { + "name": "Test outgoing webhook", + "webhook": "invalid_url", + } + + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data["webhook"][0] == "Webhook is incorrect" + + data = { + "name": "Test outgoing webhook", + } + + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data["webhook"][0] == "This field is required." + + data = { + "webhook": "https://example.com", + } + + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data["name"][0] == "This field is required." + + +@pytest.mark.django_db +def test_update_custom_action( + make_organization_and_user_with_token, + make_custom_action, +): + + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + custom_action = make_custom_action(organization=organization) + + url = reverse("api-public:actions-detail", kwargs={"pk": custom_action.public_primary_key}) + + data = { + "name": "RENAMED", + } + + assert custom_action.name != data["name"] + + response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + + expected_result = { + "id": custom_action.public_primary_key, + "name": data["name"], + "team_id": None, + "webhook": custom_action.webhook, + "data": custom_action.data, + "user": custom_action.user, + "password": custom_action.password, + "authorization_header": custom_action.authorization_header, + "forward_whole_payload": custom_action.forward_whole_payload, + } + + assert response.status_code == status.HTTP_200_OK + custom_action.refresh_from_db() + assert custom_action.name == expected_result["name"] + assert response.data == expected_result + + +@pytest.mark.django_db +def test_delete_custom_action( + make_organization_and_user_with_token, + make_custom_action, +): + + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + custom_action = make_custom_action(organization=organization) + url = reverse("api-public:actions-detail", kwargs={"pk": custom_action.public_primary_key}) + + assert custom_action.deleted_at is None + + response = client.delete(url, format="json", HTTP_AUTHORIZATION=f"{token}") + assert response.status_code == status.HTTP_204_NO_CONTENT + + custom_action.refresh_from_db() + assert custom_action.deleted_at is not None + + response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}") + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.data["detail"] == "Not found." diff --git a/engine/apps/public_api/views/action.py b/engine/apps/public_api/views/action.py index 60ca1465..0e5944eb 100644 --- a/engine/apps/public_api/views/action.py +++ b/engine/apps/public_api/views/action.py @@ -1,25 +1,26 @@ from django_filters import rest_framework as filters -from rest_framework import mixins from rest_framework.permissions import IsAuthenticated -from rest_framework.viewsets import GenericViewSet +from rest_framework.viewsets import ModelViewSet from apps.alerts.models import CustomButton from apps.auth_token.auth import ApiTokenAuthentication -from apps.public_api.serializers.action import ActionSerializer +from apps.public_api.serializers.action import ActionCreateSerializer, ActionUpdateSerializer from apps.public_api.throttlers.user_throttle import UserThrottle +from apps.user_management.organization_log_creator import OrganizationLogType, create_organization_log from common.api_helpers.filters import ByTeamFilter -from common.api_helpers.mixins import RateLimitHeadersMixin +from common.api_helpers.mixins import PublicPrimaryKeyMixin, RateLimitHeadersMixin, UpdateSerializerMixin from common.api_helpers.paginators import FiftyPageSizePaginator -class ActionView(RateLimitHeadersMixin, mixins.ListModelMixin, GenericViewSet): +class ActionView(RateLimitHeadersMixin, PublicPrimaryKeyMixin, UpdateSerializerMixin, ModelViewSet): authentication_classes = (ApiTokenAuthentication,) permission_classes = (IsAuthenticated,) pagination_class = FiftyPageSizePaginator throttle_classes = [UserThrottle] model = CustomButton - serializer_class = ActionSerializer + serializer_class = ActionCreateSerializer + update_serializer_class = ActionUpdateSerializer filter_backends = (filters.DjangoFilterBackend,) filterset_class = ByTeamFilter @@ -32,3 +33,27 @@ class ActionView(RateLimitHeadersMixin, mixins.ListModelMixin, GenericViewSet): queryset = queryset.filter(name=action_name) return queryset + + def perform_create(self, serializer): + serializer.save() + instance = serializer.instance + organization = self.request.auth.organization + user = self.request.user + description = f"Custom action {instance.name} was created" + create_organization_log(organization, user, OrganizationLogType.TYPE_CUSTOM_ACTION_CREATED, description) + + def perform_update(self, serializer): + organization = self.request.auth.organization + user = self.request.user + old_state = serializer.instance.repr_settings_for_client_side_logging + serializer.save() + new_state = serializer.instance.repr_settings_for_client_side_logging + description = f"Custom action {serializer.instance.name} was changed " f"from:\n{old_state}\nto:\n{new_state}" + create_organization_log(organization, user, OrganizationLogType.TYPE_CUSTOM_ACTION_CHANGED, description) + + def perform_destroy(self, instance): + organization = self.request.auth.organization + user = self.request.user + description = f"Custom action {instance.name} was deleted" + create_organization_log(organization, user, OrganizationLogType.TYPE_CUSTOM_ACTION_DELETED, description) + instance.delete() diff --git a/engine/settings/base.py b/engine/settings/base.py index ad942c30..7dd7fb46 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -424,7 +424,6 @@ DATA_UPLOAD_MAX_MEMORY_SIZE = 5242880 # Log inbound/outbound calls as slow=1 if they exceed threshold SLOW_THRESHOLD_SECONDS = 2.0 -FEATURE_EXTRA_MESSAGING_BACKENDS_ENABLED = getenv_boolean("FEATURE_EXTRA_MESSAGING_BACKENDS_ENABLED", default=False) EXTRA_MESSAGING_BACKENDS = [] INSTALLED_ONCALL_INTEGRATIONS = [ diff --git a/engine/settings/ci-test.py b/engine/settings/ci-test.py index 91b3f7b0..16c655b5 100644 --- a/engine/settings/ci-test.py +++ b/engine/settings/ci-test.py @@ -37,5 +37,4 @@ SENDGRID_SECRET_KEY = "dummy_sendgrid_secret_key" TWILIO_ACCOUNT_SID = "dummy_twilio_account_sid" TWILIO_AUTH_TOKEN = "dummy_twilio_auth_token" -FEATURE_EXTRA_MESSAGING_BACKENDS_ENABLED = True EXTRA_MESSAGING_BACKENDS = ["apps.base.tests.messaging_backend.TestOnlyBackend"] diff --git a/engine/settings/dev.py b/engine/settings/dev.py index ef43c7ff..d63b6f74 100644 --- a/engine/settings/dev.py +++ b/engine/settings/dev.py @@ -98,7 +98,6 @@ SWAGGER_SETTINGS = { } if TESTING: - FEATURE_EXTRA_MESSAGING_BACKENDS_ENABLED = True EXTRA_MESSAGING_BACKENDS = ["apps.base.tests.messaging_backend.TestOnlyBackend"] TELEGRAM_TOKEN = "0000000000:XXXXXXXXXXXXXXXXXXXXXXXXXXXX-XXXXXX" TWILIO_AUTH_TOKEN = "twilio_auth_token" diff --git a/grafana-plugin/CHANGELOG.md b/grafana-plugin/CHANGELOG.md index 8893332c..e48e4082 100644 --- a/grafana-plugin/CHANGELOG.md +++ b/grafana-plugin/CHANGELOG.md @@ -1,5 +1,14 @@ # Change Log +## 1.0.2 (2022-06-17) + +- Fix Grafana Alerting integration to handle API changes in Grafana 9 +- Improve public API endpoint for outgoing webhooks (/actions) by adding ability to create, update and delete + +## 1.0.0 (2022-06-14) + +- First Public Release + ## 0.0.71 (2022-06-06) -- Initial Release \ No newline at end of file +- Initial Commit Release \ No newline at end of file diff --git a/grafana-plugin/src/models/team/team.types.ts b/grafana-plugin/src/models/team/team.types.ts index 8c75b4b8..505052db 100644 --- a/grafana-plugin/src/models/team/team.types.ts +++ b/grafana-plugin/src/models/team/team.types.ts @@ -68,6 +68,5 @@ export interface Team { env_status: { twilio_configured: boolean; telegram_configured: boolean; - extra_messaging_backends_enabled: boolean; }; }