diff --git a/.github/workflows/triage-stale-pull-requests.yml b/.github/workflows/triage-stale-pull-requests.yml new file mode 100644 index 00000000..892a62c4 --- /dev/null +++ b/.github/workflows/triage-stale-pull-requests.yml @@ -0,0 +1,22 @@ +name: "Triage stale pull requests" +on: + schedule: + - cron: "30 1 * * *" + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v8 + with: + # docs - https://github.com/actions/stale + # don't mark issues as stale, only triage pull requests + days-before-issue-stale: -1 + days-before-issue-close: -1 + days-before-pr-stale: 30 + days-before-pr-close: 30 + ascending: true # start processing older pull requests first + stale-pr-message: > + This pull request has been automatically marked as stale because it has not had activity in the last 30 days. It will be closed in 30 days if no further activity occurs. Please feel free to give a status update now, ping for review, or re-open when it's ready. Thank you for your contributions! + close-pr-message: > + This pull request has been automatically closed because it has not had activity in the last 30 days. Please feel free to give a status update now, ping for review, or re-open when it's ready. Thank you for your contributions! diff --git a/CHANGELOG.md b/CHANGELOG.md index 56583ebf..ffaad58a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## v1.2.40 (2023-06-07) + +### Added + +- Allow mobile app to consume "internal" schedules API endpoints by @joeyorlando ([#2109](https://github.com/grafana/oncall/pull/2109)) +- Add inbound email address in integration API by @vadimkerr ([#2113](https://github.com/grafana/oncall/pull/2113)) + +### Changed + +- Make viewset actions more consistent by @vadimkerr ([#2120](https://github.com/grafana/oncall/pull/2120)) + +### Fixed + +- Fix + revert [#2057](https://github.com/grafana/oncall/pull/2057) which reverted a change which properly handles + `Organization.DoesNotExist` exceptions for Slack events by @joeyorlando ([#TBD](https://github.com/grafana/oncall/pull/TBD)) +- Fix Telegram ratelimit on live setting change by @vadimkerr and @alexintech ([#2100](https://github.com/grafana/oncall/pull/2100)) ## v1.2.39 (2023-06-06) diff --git a/Makefile b/Makefile index 40b2cafc..34d423c6 100644 --- a/Makefile +++ b/Makefile @@ -122,7 +122,9 @@ install-precommit-hook: install-pre-commit pre-commit install test: ## run backend tests - $(call run_engine_docker_command,pytest) +# always use settings.ci-test django settings file when running the tests +# if we use settings.dev it's very possible that some fail just based on the settings alone + $(call run_engine_docker_command,pytest --ds=settings.ci-test) start-celery-beat: ## start celery beat $(call run_engine_docker_command,celery -A engine beat -l info) diff --git a/README.md b/README.md index f995768b..3cc78af2 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,6 @@ Developer-friendly incident response with brilliant Slack integration. - @@ -21,7 +20,6 @@ Developer-friendly incident response with brilliant Slack integration.
- - Collect and analyze alerts from multiple monitoring systems diff --git a/docker-compose-developer.yml b/docker-compose-developer.yml index 080dd6fb..d840c3a3 100644 --- a/docker-compose-developer.yml +++ b/docker-compose-developer.yml @@ -10,8 +10,10 @@ x-oncall-build: &oncall-build-args x-oncall-volumes: &oncall-volumes - ./engine:/etc/app + # see all the fun answers/comments here on why we need to do this + # tldr; using /dev/null as a default leads to a lot of fun problems # https://stackoverflow.com/a/60456034 - - ${ENTERPRISE_ENGINE:-/dev/null}:/etc/app/extensions/engine_enterprise + - ${ENTERPRISE_ENGINE:-/dev/null}:${ENTERPRISE_ENGINE_VOLUME_MOUNT_DEST_DIR:-/tmp/empty:ro} - ${SQLITE_DB_FILE:-/dev/null}:/var/lib/oncall/oncall.db # this is mounted for testing purposes. Some of the authorization tests # reference this file diff --git a/docs/sources/oncall-api-reference/integrations.md b/docs/sources/oncall-api-reference/integrations.md index 00f4702c..e67ddf13 100644 --- a/docs/sources/oncall-api-reference/integrations.md +++ b/docs/sources/oncall-api-reference/integrations.md @@ -24,6 +24,7 @@ The above command returns JSON structured in the following way: "name": "Grafana :blush:", "team_id": null, "link": "{{API_URL}}/integrations/v1/grafana/mReAoNwDm0eMwKo1mTeTwYo/", + "inbound_email": null, "type": "grafana", "default_route": { "id": "RVBE4RKQSCGJ2", @@ -96,6 +97,7 @@ The above command returns JSON structured in the following way: "name": "Grafana :blush:", "team_id": null, "link": "{{API_URL}}/integrations/v1/grafana/mReAoNwDm0eMwKo1mTeTwYo/", + "inbound_email": null, "type": "grafana", "default_route": { "id": "RVBE4RKQSCGJ2", @@ -171,6 +173,7 @@ The above command returns JSON structured in the following way: "name": "Grafana :blush:", "team_id": null, "link": "{{API_URL}}/integrations/v1/grafana/mReAoNwDm0eMwKo1mTeTwYo/", + "inbound_email": null, "type": "grafana", "default_route": { "id": "RVBE4RKQSCGJ2", @@ -252,6 +255,7 @@ The above command returns JSON structured in the following way: "name": "Grafana :blush:", "team_id": null, "link": "{{API_URL}}/integrations/v1/grafana/mReAoNwDm0eMwKo1mTeTwYo/", + "inbound_email": null, "type": "grafana", "default_route": { "id": "RVBE4RKQSCGJ2", diff --git a/engine/apps/alerts/incident_appearance/renderers/telegram_renderer.py b/engine/apps/alerts/incident_appearance/renderers/telegram_renderer.py index 33d31b0a..b0572f1a 100644 --- a/engine/apps/alerts/incident_appearance/renderers/telegram_renderer.py +++ b/engine/apps/alerts/incident_appearance/renderers/telegram_renderer.py @@ -63,4 +63,4 @@ class AlertGroupTelegramRenderer(AlertGroupBaseRenderer): if image_url is not None: text = f"" + text - return emojize(text, use_aliases=True) + return emojize(text, language="alias") diff --git a/engine/apps/alerts/models/alert_receive_channel.py b/engine/apps/alerts/models/alert_receive_channel.py index 7c0d2791..50b7421d 100644 --- a/engine/apps/alerts/models/alert_receive_channel.py +++ b/engine/apps/alerts/models/alert_receive_channel.py @@ -203,7 +203,7 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject): ] def __str__(self): - short_name_with_emojis = emojize(self.short_name, use_aliases=True) + short_name_with_emojis = emojize(self.short_name, language="alias") return f"{self.pk}: {short_name_with_emojis}" def get_template_attribute(self, render_for, attr_name): @@ -271,7 +271,7 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject): @cached_property def emojized_verbal_name(self): - return emoji.emojize(self.verbal_name, use_aliases=True) + return emoji.emojize(self.verbal_name, language="alias") @property def new_incidents_web_link(self): @@ -398,6 +398,9 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject): @property def inbound_email(self): + if self.integration != AlertReceiveChannel.INTEGRATION_INBOUND_EMAIL: + return None + return f"{self.token}@{live_settings.INBOUND_EMAIL_DOMAIN}" @property diff --git a/engine/apps/alerts/signals.py b/engine/apps/alerts/signals.py index 30f54803..31abc93b 100644 --- a/engine/apps/alerts/signals.py +++ b/engine/apps/alerts/signals.py @@ -8,46 +8,24 @@ There are three entities which require sync between web, slack and telegram. AlertGroup, AlertGroup's logs and AlertGroup's resolution notes. """ # Signal to create alert group message in all connected integrations (Slack, Telegram) -alert_create_signal = django.dispatch.Signal( - providing_args=[ - "alert", - ] -) +alert_create_signal = django.dispatch.Signal() -alert_group_created_signal = django.dispatch.Signal( - providing_args=[ - "alert_group", - ] -) +alert_group_created_signal = django.dispatch.Signal() -alert_group_escalation_snapshot_built = django.dispatch.Signal( - providing_args=[ - "alert_group", - ] -) +alert_group_escalation_snapshot_built = django.dispatch.Signal() # Signal to rerender alert group in all connected integrations (Slack, Telegram) when its state is changed -alert_group_action_triggered_signal = django.dispatch.Signal( - providing_args=[ - "log_record", - "action_source", - ] -) +alert_group_action_triggered_signal = django.dispatch.Signal() # Signal to rerender alert group's log message in all connected integrations (Slack, Telegram) # when alert group state is changed -alert_group_update_log_report_signal = django.dispatch.Signal(providing_args=["alert_group"]) +alert_group_update_log_report_signal = django.dispatch.Signal() # Signal to rerender alert group's resolution note in all connected integrations (Slack) -alert_group_update_resolution_note_signal = django.dispatch.Signal( - providing_args=[ - "alert_group", - "resolution_note", - ] -) +alert_group_update_resolution_note_signal = django.dispatch.Signal() # Currently only writes error in Slack thread while notify user. Maybe it is worth to delete it? -user_notification_action_triggered_signal = django.dispatch.Signal(providing_args=["log_record"]) +user_notification_action_triggered_signal = django.dispatch.Signal() alert_create_signal.connect( AlertGroupSlackRepresentative.on_create_alert, diff --git a/engine/apps/alerts/tasks/check_escalation_finished.py b/engine/apps/alerts/tasks/check_escalation_finished.py index c382aa5f..f6684029 100644 --- a/engine/apps/alerts/tasks/check_escalation_finished.py +++ b/engine/apps/alerts/tasks/check_escalation_finished.py @@ -1,6 +1,6 @@ -import datetime import typing +import pytz import requests from celery import shared_task from django.apps import apps @@ -95,7 +95,7 @@ def audit_alert_group_escalation(alert_group: "AlertGroup") -> None: task_logger.info(f"{base_msg} passed the audit checks") -def get_auditable_alert_groups_started_at_range() -> typing.Tuple[datetime.datetime, datetime.datetime]: +def get_auditable_alert_groups_started_at_range() -> typing.Tuple[timezone.datetime, timezone.datetime]: """ NOTE: this started_at__range is a bit of a hack.. we wanted to avoid performing a migration on the alerts_alertgroup table to update @@ -110,7 +110,7 @@ def get_auditable_alert_groups_started_at_range() -> typing.Tuple[datetime.datet alert groups, whose integration did not have an escalation chain at the time the alert group was created we would raise errors """ - return (datetime.datetime(2023, 3, 25), timezone.now() - timezone.timedelta(days=2)) + return (timezone.datetime(2023, 3, 25, tzinfo=pytz.UTC), timezone.now() - timezone.timedelta(days=2)) # don't retry this task as the AlertGroup DB query is rather expensive diff --git a/engine/apps/alerts/tests/test_default_templates.py b/engine/apps/alerts/tests/test_default_templates.py index a1c81d9f..5e9e3b58 100644 --- a/engine/apps/alerts/tests/test_default_templates.py +++ b/engine/apps/alerts/tests/test_default_templates.py @@ -15,6 +15,9 @@ from config_integrations import grafana @pytest.mark.django_db +@pytest.mark.filterwarnings( + "ignore:The input looks more like a filename than markup. You may want to open this file and pass the filehandle into Beautiful Soup." +) @pytest.mark.parametrize( "integration, template_module", # Test only the integrations that have "tests" field in configuration diff --git a/engine/apps/alerts/tests/test_escalation_policy_snapshot.py b/engine/apps/alerts/tests/test_escalation_policy_snapshot.py index 01c41401..7ba39f00 100644 --- a/engine/apps/alerts/tests/test_escalation_policy_snapshot.py +++ b/engine/apps/alerts/tests/test_escalation_policy_snapshot.py @@ -170,7 +170,7 @@ def test_escalation_step_notify_on_call_schedule( schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar) # create on_call_shift with user to notify - start_date = timezone.datetime.now().replace(microsecond=0) + start_date = timezone.now().replace(microsecond=0) data = { "start": start_date, "rotation_start": start_date, @@ -218,7 +218,7 @@ def test_escalation_step_notify_on_call_schedule_viewer_user( schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar) # create on_call_shift with user to notify - start_date = timezone.datetime.now().replace(microsecond=0) + start_date = timezone.now().replace(microsecond=0) data = { "start": start_date, "rotation_start": start_date, diff --git a/engine/apps/alerts/tests/test_terraform_renderer.py b/engine/apps/alerts/tests/test_terraform_renderer.py index ca008db8..8f069baa 100644 --- a/engine/apps/alerts/tests/test_terraform_renderer.py +++ b/engine/apps/alerts/tests/test_terraform_renderer.py @@ -1,5 +1,5 @@ import pytest -from django.utils import dateparse, timezone +from django.utils import timezone from django.utils.text import slugify from apps.alerts.models import EscalationPolicy @@ -91,6 +91,8 @@ def test_render_terraform_file( name="test_calendar_schedule", ) + start = timezone.datetime.fromisoformat("2021-08-16T17:00:00Z") + shift = make_on_call_shift( organization=organization, name="test_shift", @@ -98,8 +100,8 @@ def test_render_terraform_file( frequency=CustomOnCallShift.FREQUENCY_WEEKLY, interval=1, week_start=CustomOnCallShift.MONDAY, - start=dateparse.parse_datetime("2021-08-16T17:00:00"), - rotation_start=dateparse.parse_datetime("2021-08-16T17:00:00"), + start=start, + rotation_start=start, duration=timezone.timedelta(seconds=3600), by_day=["MO", "SA"], rolling_users=[{user.pk: user.public_primary_key}], diff --git a/engine/apps/api/errors.py b/engine/apps/api/errors.py new file mode 100644 index 00000000..3c43686a --- /dev/null +++ b/engine/apps/api/errors.py @@ -0,0 +1,18 @@ +"""errors contains business-logic error codes for internal api. + +It's expected that error codes will use 1000-9999 codes range, where first two digits are for entity: +11xx - AlertGroup, 12xx - AlertReceiveChannel, etc. +10xx are saved for non-entity related errors. +""" +# TODO: this package is WIP. It requires validation of code ranges. +from enum import Enum, unique + + +@unique +class AlertGroupAPIError(Enum): + """ + Error codes for alert group. + Range is 1100-1199 + """ + + RESOLUTION_NOTE_REQUIRED = 1101 diff --git a/engine/apps/api/serializers/webhook.py b/engine/apps/api/serializers/webhook.py index 3d1cd32c..72d6a935 100644 --- a/engine/apps/api/serializers/webhook.py +++ b/engine/apps/api/serializers/webhook.py @@ -4,6 +4,7 @@ from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator from apps.webhooks.models import Webhook, WebhookResponse +from apps.webhooks.models.webhook import WEBHOOK_FIELD_PLACEHOLDER from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField from common.api_helpers.utils import CurrentOrganizationDefault, CurrentTeamDefault, CurrentUserDefault from common.jinja_templater import apply_jinja_template @@ -66,6 +67,21 @@ class WebhookSerializer(serializers.ModelSerializer): validators = [UniqueTogetherValidator(queryset=Webhook.objects.all(), fields=["name", "organization"])] + def to_representation(self, instance): + result = super().to_representation(instance) + if instance.password: + result["password"] = WEBHOOK_FIELD_PLACEHOLDER + if instance.authorization_header: + result["authorization_header"] = WEBHOOK_FIELD_PLACEHOLDER + return result + + def to_internal_value(self, data): + if data.get("password") == WEBHOOK_FIELD_PLACEHOLDER: + data["password"] = self.instance.password + if data.get("authorization_header") == WEBHOOK_FIELD_PLACEHOLDER: + data["authorization_header"] = self.instance.authorization_header + return super().to_internal_value(data) + def _validate_template_field(self, template): try: apply_jinja_template(template, alert_payload=defaultdict(str), alert_group_id="alert_group_1") diff --git a/engine/apps/api/tests/test_alert_group.py b/engine/apps/api/tests/test_alert_group.py index e8a8e2ac..ea815f28 100644 --- a/engine/apps/api/tests/test_alert_group.py +++ b/engine/apps/api/tests/test_alert_group.py @@ -9,6 +9,7 @@ from rest_framework.response import Response from rest_framework.test import APIClient from apps.alerts.models import AlertGroup, AlertGroupLogRecord, AlertReceiveChannel +from apps.api.errors import AlertGroupAPIError from apps.api.permissions import LegacyAccessControlRole from apps.base.models import UserNotificationPolicyLogRecord @@ -1805,3 +1806,42 @@ def test_direct_paging_integration_treated_as_deleted( response = client.get(url, format="json", **make_user_auth_headers(user, token)) assert response.json()["alert_receive_channel"]["deleted"] is True + + +@pytest.mark.django_db +def test_alert_group_resolve_resolution_note( + make_organization_and_user_with_plugin_token, + make_alert_receive_channel, + make_channel_filter, + make_alert_group, + make_alert, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + alert_receive_channel = make_alert_receive_channel(organization) + channel_filter = make_channel_filter(alert_receive_channel, is_default=True) + new_alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter) + make_alert(alert_group=new_alert_group, raw_request_data=alert_raw_request_data) + + organization.is_resolution_note_required = True + organization.save() + + client = APIClient() + url = reverse("api-internal:alertgroup-resolve", kwargs={"pk": new_alert_group.public_primary_key}) + + response = client.post(url, format="json", **make_user_auth_headers(user, token)) + # check that resolution note is required + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["code"] == AlertGroupAPIError.RESOLUTION_NOTE_REQUIRED.value + + with patch( + "apps.alerts.tasks.send_update_resolution_note_signal.send_update_resolution_note_signal.apply_async" + ) as mock_signal: + url = reverse("api-internal:alertgroup-resolve", kwargs={"pk": new_alert_group.public_primary_key}) + response = client.post( + url, format="json", data={"resolution_note": "hi"}, **make_user_auth_headers(user, token) + ) + assert response.status_code == status.HTTP_200_OK + + assert new_alert_group.has_resolution_notes + assert mock_signal.called diff --git a/engine/apps/api/tests/test_alert_receive_channel.py b/engine/apps/api/tests/test_alert_receive_channel.py index 3002c70b..e9eb88eb 100644 --- a/engine/apps/api/tests/test_alert_receive_channel.py +++ b/engine/apps/api/tests/test_alert_receive_channel.py @@ -802,9 +802,7 @@ def test_alert_receive_channel_send_demo_alert_not_enabled( make_alert_receive_channel, ): organization, user, token = make_organization_and_user_with_plugin_token() - alert_receive_channel = make_alert_receive_channel( - organization, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING - ) + alert_receive_channel = make_alert_receive_channel(organization, integration=AlertReceiveChannel.INTEGRATION_MANUAL) client = APIClient() url = reverse( diff --git a/engine/apps/api/tests/test_channel_filter.py b/engine/apps/api/tests/test_channel_filter.py index 8e9fb39e..baf845e3 100644 --- a/engine/apps/api/tests/test_channel_filter.py +++ b/engine/apps/api/tests/test_channel_filter.py @@ -314,6 +314,57 @@ def test_channel_filter_create_without_order( assert channel_filter.order == 0 +@pytest.mark.django_db +def test_move_to_position( + make_organization_and_user_with_plugin_token, + make_alert_receive_channel, + make_channel_filter, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + alert_receive_channel = make_alert_receive_channel(organization) + # create default channel filter + make_channel_filter(alert_receive_channel, is_default=True, order=0) + first_channel_filter = make_channel_filter(alert_receive_channel, filtering_term="a", is_default=False, order=1) + second_channel_filter = make_channel_filter(alert_receive_channel, filtering_term="b", is_default=False, order=2) + + client = APIClient() + url = reverse( + "api-internal:channel_filter-move-to-position", kwargs={"pk": first_channel_filter.public_primary_key} + ) + url += f"?position=2" + response = client.put(url, **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_200_OK + first_channel_filter.refresh_from_db() + second_channel_filter.refresh_from_db() + assert first_channel_filter.order == 2 + assert second_channel_filter.order == 1 + + +@pytest.mark.django_db +def test_move_to_position_cant_move_default( + make_organization_and_user_with_plugin_token, + make_alert_receive_channel, + make_channel_filter, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + alert_receive_channel = make_alert_receive_channel(organization) + # create default channel filter + default_channel_filter = make_channel_filter(alert_receive_channel, is_default=True, order=0) + make_channel_filter(alert_receive_channel, filtering_term="b", is_default=False, order=1) + + client = APIClient() + url = reverse( + "api-internal:channel_filter-move-to-position", kwargs={"pk": default_channel_filter.public_primary_key} + ) + url += f"?position=1" + response = client.put(url, **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db def test_channel_filter_update_with_order( make_organization_and_user_with_plugin_token, diff --git a/engine/apps/api/tests/test_features.py b/engine/apps/api/tests/test_features.py index 6f9192ee..9346abff 100644 --- a/engine/apps/api/tests/test_features.py +++ b/engine/apps/api/tests/test_features.py @@ -1,4 +1,5 @@ import pytest +from django.test import override_settings from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient @@ -65,6 +66,7 @@ def test_core_features_switch( @pytest.mark.django_db +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=True) def test_oss_features_enabled_in_oss_installation_by_default( make_organization_and_user_with_plugin_token, make_user_auth_headers, diff --git a/engine/apps/api/tests/test_live_settings.py b/engine/apps/api/tests/test_live_settings.py index 1cfecebd..dce86236 100644 --- a/engine/apps/api/tests/test_live_settings.py +++ b/engine/apps/api/tests/test_live_settings.py @@ -5,6 +5,8 @@ from django.urls import reverse from rest_framework.status import HTTP_200_OK from rest_framework.test import APIClient +from apps.base.models import LiveSetting + @pytest.mark.django_db def test_list_live_setting( @@ -98,3 +100,63 @@ def test_live_settings_update_not_trigger_unpopulate_slack_identities( assert not mocked_unpopulate_task.called assert response.status_code == HTTP_200_OK + + +@pytest.mark.django_db +def test_live_settings_update_validate_settings_once( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_live_setting, + settings, +): + """ + Check that settings are validated only once per update. + """ + + settings.FEATURE_LIVE_SETTINGS_ENABLED = True + + organization, user, token = make_organization_and_user_with_plugin_token() + LiveSetting.populate_settings_if_needed() + live_setting = LiveSetting.objects.get(name="EMAIL_HOST") # random setting + + client = APIClient() + url = reverse("api-internal:live_settings-detail", kwargs={"pk": live_setting.public_primary_key}) + data = {"id": live_setting.public_primary_key, "value": "TEST_UPDATED_VALUE", "name": "EMAIL_HOST"} + + with mock.patch.object(LiveSetting, "validate_settings") as mock_validate_settings: + response = client.put(url, data=data, format="json", **make_user_auth_headers(user, token)) + + assert response.status_code == HTTP_200_OK + mock_validate_settings.assert_called_once() + + +@pytest.mark.django_db +def test_live_settings_telegram_calls_set_webhook_once( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_live_setting, + settings, +): + """ + Check that when TELEGRAM_WEBHOOK_HOST live setting is updated, set_webhook method is called only once. + If set_webhook is called more than once in a short period of time, there will be a rate limit error. + """ + + settings.FEATURE_LIVE_SETTINGS_ENABLED = True + + organization, user, token = make_organization_and_user_with_plugin_token() + LiveSetting.populate_settings_if_needed() + live_setting = LiveSetting.objects.get(name="TELEGRAM_WEBHOOK_HOST") + + client = APIClient() + url = reverse("api-internal:live_settings-detail", kwargs={"pk": live_setting.public_primary_key}) + data = {"id": live_setting.public_primary_key, "value": "TEST_UPDATED_VALUE", "name": "TELEGRAM_WEBHOOK_HOST"} + + with mock.patch("telegram.Bot.get_webhook_info", return_value=mock.Mock(url="TEST_VALUE")): + with mock.patch("telegram.Bot.set_webhook") as mock_set_webhook: + response = client.put(url, data=data, format="json", **make_user_auth_headers(user, token)) + + assert response.status_code == HTTP_200_OK + mock_set_webhook.assert_called_once_with( + "TEST_UPDATED_VALUE/telegram/", allowed_updates=("message", "callback_query") + ) diff --git a/engine/apps/api/tests/test_user.py b/engine/apps/api/tests/test_user.py index f64cf916..18984b62 100644 --- a/engine/apps/api/tests/test_user.py +++ b/engine/apps/api/tests/test_user.py @@ -56,6 +56,7 @@ def test_update_user( assert response.json()["current_team"] == data["current_team"] +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=False) @pytest.mark.django_db def test_update_user_cant_change_email_and_username( make_organization, @@ -94,7 +95,7 @@ def test_update_user_cant_change_email_and_username( "user": admin.username, } }, - "cloud_connection_status": 0, + "cloud_connection_status": None, "permissions": DONT_USE_LEGACY_PERMISSION_MAPPING[admin.role], "notification_chain_verbal": {"default": "", "important": ""}, "slack_user_identity": None, @@ -106,6 +107,7 @@ def test_update_user_cant_change_email_and_username( assert response.json() == expected_response +@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=False) @pytest.mark.django_db def test_list_users( make_organization, @@ -150,7 +152,7 @@ def test_list_users( "slack_user_identity": None, "avatar": admin.avatar_url, "avatar_full": admin.avatar_full_url, - "cloud_connection_status": 0, + "cloud_connection_status": None, }, { "pk": editor.public_primary_key, @@ -176,7 +178,7 @@ def test_list_users( "slack_user_identity": None, "avatar": editor.avatar_url, "avatar_full": editor.avatar_full_url, - "cloud_connection_status": 0, + "cloud_connection_status": None, }, ], } diff --git a/engine/apps/api/tests/test_webhooks.py b/engine/apps/api/tests/test_webhooks.py index 31015481..f379425b 100644 --- a/engine/apps/api/tests/test_webhooks.py +++ b/engine/apps/api/tests/test_webhooks.py @@ -10,6 +10,7 @@ from rest_framework.test import APIClient from apps.api.permissions import LegacyAccessControlRole from apps.webhooks.models import Webhook +from apps.webhooks.models.webhook import WEBHOOK_FIELD_PLACEHOLDER TEST_URL = "https://some-url" @@ -44,8 +45,8 @@ def test_get_list_webhooks(webhook_internal_api_setup, make_user_auth_headers): "url": "https://github.com/", "data": '{"name": "{{ alert_payload }}"}', "username": "Chris Vanstras", - "password": "qwerty", - "authorization_header": "auth_token", + "password": WEBHOOK_FIELD_PLACEHOLDER, + "authorization_header": WEBHOOK_FIELD_PLACEHOLDER, "forward_all": False, "headers": None, "http_method": "POST", @@ -85,8 +86,8 @@ def test_get_detail_webhook(webhook_internal_api_setup, make_user_auth_headers): "url": "https://github.com/", "data": '{"name": "{{ alert_payload }}"}', "username": "Chris Vanstras", - "password": "qwerty", - "authorization_header": "auth_token", + "password": WEBHOOK_FIELD_PLACEHOLDER, + "authorization_header": WEBHOOK_FIELD_PLACEHOLDER, "forward_all": False, "headers": None, "http_method": "POST", diff --git a/engine/apps/api/throttlers/test_call_throttler.py b/engine/apps/api/throttlers/test_call_throttler.py index 3941b609..5bcb9d35 100644 --- a/engine/apps/api/throttlers/test_call_throttler.py +++ b/engine/apps/api/throttlers/test_call_throttler.py @@ -2,10 +2,24 @@ from rest_framework.throttling import UserRateThrottle class TestCallThrottler(UserRateThrottle): + """ + set a __test__ = False attribute in classes that pytest should ignore otherwise we end up getting the following: + PytestCollectionWarning: cannot collect test class 'TestCallThrottler' because it has a __init__ constructor + """ + + __test__ = False + scope = "make_test_call" rate = "5/m" class TestPushThrottler(UserRateThrottle): + """ + set a __test__ = False attribute in classes that pytest should ignore otherwise we end up getting the following: + PytestCollectionWarning: cannot collect test class 'TestPushThrottler' because it has a __init__ constructor + """ + + __test__ = False + scope = "send_test_push" rate = "10/m" diff --git a/engine/apps/api/urls.py b/engine/apps/api/urls.py index a5d528a5..02f20f62 100644 --- a/engine/apps/api/urls.py +++ b/engine/apps/api/urls.py @@ -38,7 +38,6 @@ from .views.slack_team_settings import ( from .views.subscription import SubscriptionView from .views.team import TeamViewSet from .views.telegram_channels import TelegramChannelViewSet -from .views.test_insight_logs import TestInsightLogsAPIView from .views.user import CurrentUserView, UserView from .views.user_group import UserGroupViewSet from .views.webhooks import WebhooksView @@ -106,7 +105,6 @@ urlpatterns = [ "preview_template_options", PreviewTemplateOptionsView.as_view(), name="preview_template_options" ), optional_slash_path("route_regex_debugger", RouteRegexDebuggerView.as_view(), name="route_regex_debugger"), - optional_slash_path("insight_logs_test", TestInsightLogsAPIView.as_view(), name="insight-logs-test"), re_path(r"^alerts/(?P\w+)/?$", AlertDetailView.as_view(), name="alerts-detail"), optional_slash_path("direct_paging", DirectPagingAPIView.as_view(), name="direct_paging"), ] diff --git a/engine/apps/api/views/alert_group.py b/engine/apps/api/views/alert_group.py index 4442e8dd..bcae5306 100644 --- a/engine/apps/api/views/alert_group.py +++ b/engine/apps/api/views/alert_group.py @@ -13,8 +13,10 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from apps.alerts.constants import ActionSource -from apps.alerts.models import Alert, AlertGroup, AlertReceiveChannel, EscalationChain +from apps.alerts.models import Alert, AlertGroup, AlertReceiveChannel, EscalationChain, ResolutionNote from apps.alerts.paging import unpage_user +from apps.alerts.tasks import send_update_resolution_note_signal +from apps.api.errors import AlertGroupAPIError from apps.api.permissions import RBACPermission from apps.api.serializers.alert_group import AlertGroupListSerializer, AlertGroupSerializer from apps.api.serializers.team import TeamSerializer @@ -456,11 +458,30 @@ class AlertGroupView( if alert_group.is_maintenance_incident: alert_group.stop_maintenance(self.request.user) else: - if organization.is_resolution_note_required and not alert_group.has_resolution_notes: - return Response( - data="Alert group without resolution note cannot be resolved due to organization settings.", - status=status.HTTP_400_BAD_REQUEST, + resolution_note_text = request.data.get("resolution_note") + if resolution_note_text: + rn = ResolutionNote.objects.create( + alert_group=alert_group, + author=self.request.user, + source=ResolutionNote.Source.WEB, + message_text=resolution_note_text[:3000], # trim text to fit in the db field ) + send_update_resolution_note_signal.apply_async( + kwargs={ + "alert_group_pk": alert_group.pk, + "resolution_note_pk": rn.pk, + } + ) + else: + # Check resolution note required setting only if resolution_note_text was not provided. + if organization.is_resolution_note_required and not alert_group.has_resolution_notes: + return Response( + data={ + "code": AlertGroupAPIError.RESOLUTION_NOTE_REQUIRED.value, + "detail": "Alert group without resolution note cannot be resolved due to organization settings", + }, + status=status.HTTP_400_BAD_REQUEST, + ) alert_group.resolve_by_user(self.request.user, action_source=ActionSource.WEB) return Response(AlertGroupSerializer(alert_group, context={"request": self.request}).data) diff --git a/engine/apps/api/views/alert_receive_channel.py b/engine/apps/api/views/alert_receive_channel.py index 5bb06fb6..eefec126 100644 --- a/engine/apps/api/views/alert_receive_channel.py +++ b/engine/apps/api/views/alert_receive_channel.py @@ -171,14 +171,14 @@ class AlertReceiveChannelView( @action(detail=True, methods=["post"], throttle_classes=[DemoAlertThrottler]) def send_demo_alert(self, request, pk): - alert_receive_channel = AlertReceiveChannel.objects.get(public_primary_key=pk) + instance = self.get_object() payload = request.data.get("demo_alert_payload", None) if payload is not None and not isinstance(payload, dict): raise BadRequest(detail="Payload for demo alert must be a valid json object") try: - alert_receive_channel.send_demo_alert(payload=payload) + instance.send_demo_alert(payload=payload) except UnableToSendDemoAlert as e: raise BadRequest(detail=str(e)) @@ -207,6 +207,8 @@ class AlertReceiveChannelView( @action(detail=True, methods=["put"]) def change_team(self, request, pk): + instance = self.get_object() + if "team_id" not in request.query_params: raise BadRequest(detail="team_id must be specified") @@ -214,8 +216,6 @@ class AlertReceiveChannelView( if team_id == "null": team_id = None - instance = self.get_object() - try: instance.change_team(team_id=team_id, user=self.request.user) except TeamCanNotBeChangedError as e: @@ -247,14 +247,16 @@ class AlertReceiveChannelView( # This method is required for PreviewTemplateMixin def get_alert_to_template(self, payload=None): + channel = self.get_object() + try: if payload is None: - return self.get_object().alert_groups.last().alerts.first() + return channel.alert_groups.last().alerts.first() else: if type(payload) != dict: raise PreviewTemplateException("Payload must be a valid json object") # Build Alert and AlertGroup objects to pass to templater without saving them to db - alert_group_to_template = AlertGroup(channel=self.get_object()) + alert_group_to_template = AlertGroup(channel=channel) return Alert(raw_request_data=payload, group=alert_group_to_template) except AttributeError: return None @@ -280,7 +282,7 @@ class AlertReceiveChannelView( @action(detail=True, methods=["post"]) def start_maintenance(self, request, pk): - instance = self.get_queryset(eager=False).get(public_primary_key=pk) + instance = self.get_object() mode = request.data.get("mode", None) duration = request.data.get("duration", None) @@ -310,7 +312,7 @@ class AlertReceiveChannelView( @action(detail=True, methods=["post"]) def stop_maintenance(self, request, pk): - instance = self.get_queryset(eager=False).get(public_primary_key=pk) + instance = self.get_object() user = request.user instance.force_disable_maintenance(user) return Response(status=status.HTTP_200_OK) diff --git a/engine/apps/api/views/channel_filter.py b/engine/apps/api/views/channel_filter.py index 2341c003..f00e195e 100644 --- a/engine/apps/api/views/channel_filter.py +++ b/engine/apps/api/views/channel_filter.py @@ -22,6 +22,7 @@ from common.api_helpers.mixins import ( TeamFilteringMixin, UpdateSerializerMixin, ) +from common.api_helpers.serializers import get_move_to_position_param from common.exceptions import UnableToSendDemoAlert from common.insight_log import EntityEvent, write_resource_insight_log @@ -110,36 +111,30 @@ class ChannelFilterView( @action(detail=True, methods=["put"]) def move_to_position(self, request, pk): - position = request.query_params.get("position", None) - if position is not None: - try: - instance = ChannelFilter.objects.get(public_primary_key=pk) - except ChannelFilter.DoesNotExist: - raise BadRequest(detail="Channel filter does not exist") - try: - if instance.is_default: - raise BadRequest(detail="Unable to change position for default filter") - prev_state = instance.insight_logs_serialized - instance.to(int(position)) - new_state = instance.insight_logs_serialized + instance = self.get_object() + position = get_move_to_position_param(request) - write_resource_insight_log( - instance=instance, - author=self.request.user, - event=EntityEvent.UPDATED, - prev_state=prev_state, - new_state=new_state, - ) - return Response(status=status.HTTP_200_OK) - except ValueError as e: - raise BadRequest(detail=f"{e}") - else: - raise BadRequest(detail="Position was not provided") + if instance.is_default: + raise BadRequest(detail="Unable to change position for default filter") + + prev_state = instance.insight_logs_serialized + instance.to(position) + new_state = instance.insight_logs_serialized + + write_resource_insight_log( + instance=instance, + author=self.request.user, + event=EntityEvent.UPDATED, + prev_state=prev_state, + new_state=new_state, + ) + + return Response(status=status.HTTP_200_OK) @action(detail=True, methods=["post"], throttle_classes=[DemoAlertThrottler]) def send_demo_alert(self, request, pk): """Deprecated action. May be used in the older version of the plugin.""" - instance = ChannelFilter.objects.get(public_primary_key=pk) + instance = self.get_object() try: instance.send_demo_alert() except UnableToSendDemoAlert as e: @@ -148,7 +143,7 @@ class ChannelFilterView( @action(detail=True, methods=["post"]) def convert_from_regex_to_jinja2(self, request, pk): - instance = self.get_queryset().get(public_primary_key=pk) + instance = self.get_object() if not instance.filtering_term_type == ChannelFilter.FILTERING_TERM_TYPE_REGEX: raise BadRequest(detail="Only regex filtering term type is supported") diff --git a/engine/apps/api/views/escalation_chain.py b/engine/apps/api/views/escalation_chain.py index 6be5a7bf..1a51c010 100644 --- a/engine/apps/api/views/escalation_chain.py +++ b/engine/apps/api/views/escalation_chain.py @@ -120,6 +120,8 @@ class EscalationChainViewSet( @action(methods=["post"], detail=True) def copy(self, request, pk): + obj = self.get_object() + name = request.data.get("name") team_id = request.data.get("team") if team_id == "null": @@ -131,7 +133,6 @@ class EscalationChainViewSet( if EscalationChain.objects.filter(organization=request.auth.organization, name=name).exists(): raise BadRequest(detail={"name": ["Escalation chain with this name already exists."]}) - obj = self.get_object() try: team = request.user.available_teams.get(public_primary_key=team_id) if team_id else None except Team.DoesNotExist: @@ -165,7 +166,7 @@ class EscalationChainViewSet( channel_filter["alert_receive_channel__public_primary_key"], { "id": channel_filter["alert_receive_channel__public_primary_key"], - "display_name": emojize(channel_filter["alert_receive_channel__verbal_name"], use_aliases=True), + "display_name": emojize(channel_filter["alert_receive_channel__verbal_name"], language="alias"), "channel_filters": [], }, )["channel_filters"].append(channel_filter_data) diff --git a/engine/apps/api/views/escalation_policy.py b/engine/apps/api/views/escalation_policy.py index 471cbc4f..fa330f23 100644 --- a/engine/apps/api/views/escalation_policy.py +++ b/engine/apps/api/views/escalation_policy.py @@ -15,13 +15,13 @@ from apps.api.serializers.escalation_policy import ( ) from apps.auth_token.auth import PluginAuthentication from apps.webhooks.utils import is_webhooks_enabled_for_organization -from common.api_helpers.exceptions import BadRequest from common.api_helpers.mixins import ( CreateSerializerMixin, PublicPrimaryKeyMixin, TeamFilteringMixin, UpdateSerializerMixin, ) +from common.api_helpers.serializers import get_move_to_position_param from common.insight_log import EntityEvent, write_resource_insight_log @@ -111,31 +111,22 @@ class EscalationPolicyView( @action(detail=True, methods=["put"]) def move_to_position(self, request, pk): - position = request.query_params.get("position", None) - if position is not None: - try: - instance = EscalationPolicy.objects.get(public_primary_key=pk) - except EscalationPolicy.DoesNotExist: - raise BadRequest(detail="Step does not exist") - try: - prev_state = instance.insight_logs_serialized - position = int(position) - instance.to(position) - new_state = instance.insight_logs_serialized + instance = self.get_object() + position = get_move_to_position_param(request) - write_resource_insight_log( - instance=instance, - author=self.request.user, - event=EntityEvent.UPDATED, - prev_state=prev_state, - new_state=new_state, - ) - return Response(status=status.HTTP_200_OK) - except ValueError as e: - raise BadRequest(detail=f"{e}") + prev_state = instance.insight_logs_serialized + instance.to(position) + new_state = instance.insight_logs_serialized - else: - raise BadRequest(detail="Position was not provided") + write_resource_insight_log( + instance=instance, + author=self.request.user, + event=EntityEvent.UPDATED, + prev_state=prev_state, + new_state=new_state, + ) + + return Response(status=status.HTTP_200_OK) @action(detail=False, methods=["get"]) def escalation_options(self, request): diff --git a/engine/apps/api/views/live_setting.py b/engine/apps/api/views/live_setting.py index 02ca047c..760e3f0d 100644 --- a/engine/apps/api/views/live_setting.py +++ b/engine/apps/api/views/live_setting.py @@ -70,9 +70,6 @@ class LiveSettingViewSet(PublicPrimaryKeyMixin, viewsets.ModelViewSet): self._reset_telegram_integration(old_token=old_value) register_telegram_webhook.delay() - if name == "TELEGRAM_WEBHOOK_HOST": - register_telegram_webhook.delay() - if name in ["SLACK_CLIENT_OAUTH_ID", "SLACK_CLIENT_OAUTH_SECRET"]: organization = self.request.auth.organization slack_team_identity = organization.slack_team_identity diff --git a/engine/apps/api/views/schedule.py b/engine/apps/api/views/schedule.py index afdd025b..c87e5b5d 100644 --- a/engine/apps/api/views/schedule.py +++ b/engine/apps/api/views/schedule.py @@ -27,6 +27,7 @@ from apps.api.serializers.user import ScheduleUserSerializer from apps.auth_token.auth import PluginAuthentication from apps.auth_token.constants import SCHEDULE_EXPORT_TOKEN_NAME from apps.auth_token.models import ScheduleExportAuthToken +from apps.mobile_app.auth import MobileAppAuthTokenAuthentication from apps.schedules.models import OnCallSchedule from apps.slack.models import SlackChannel from apps.slack.tasks import update_slack_user_group_for_schedules @@ -72,7 +73,10 @@ class ScheduleView( ModelViewSet, mixins.ListModelMixin, ): - authentication_classes = (PluginAuthentication,) + authentication_classes = ( + MobileAppAuthTokenAuthentication, + PluginAuthentication, + ) permission_classes = (IsAuthenticated, RBACPermission) rbac_permissions = { "metadata": [RBACPermission.Permissions.SCHEDULES_READ], diff --git a/engine/apps/api/views/slack_channel.py b/engine/apps/api/views/slack_channel.py index a5237e5e..59ba10f3 100644 --- a/engine/apps/api/views/slack_channel.py +++ b/engine/apps/api/views/slack_channel.py @@ -29,4 +29,4 @@ class SlackChannelView(PublicPrimaryKeyMixin, mixins.ListModelMixin, mixins.Retr is_archived=False, ) - return queryset + return queryset.order_by("id") diff --git a/engine/apps/api/views/test_insight_logs.py b/engine/apps/api/views/test_insight_logs.py deleted file mode 100644 index da6b4d17..00000000 --- a/engine/apps/api/views/test_insight_logs.py +++ /dev/null @@ -1,32 +0,0 @@ -import logging - -from django.apps import apps -from rest_framework.response import Response -from rest_framework.views import APIView - -from apps.auth_token.auth import PluginAuthentication - -insight_logger = logging.getLogger("insight_logger") - - -class TestInsightLogsAPIView(APIView): - """ - TestInsightLogsAPIView is used to test insight-logs infra setup. - It will be removed once proper insight-logs will be instrumented. - """ - - authentication_classes = (PluginAuthentication,) - - def post(self, request): - DynamicSetting = apps.get_model("base", "DynamicSetting") - org_id_to_enable_insight_logs, _ = DynamicSetting.objects.get_or_create( - name="org_id_to_enable_insight_logs", - defaults={"json_value": []}, - ) - org = self.request.user.organization - insight_logs_enabled = org.id in org_id_to_enable_insight_logs.json_value - if insight_logs_enabled: - message = request.data.get("message", "hello world") - insight_logger.info(f"tenant_id={self.request.user.organization.stack_id} message={message}") - return Response() - return Response(status=418) diff --git a/engine/apps/api/views/user.py b/engine/apps/api/views/user.py index d1c8092c..f8b8064d 100644 --- a/engine/apps/api/views/user.py +++ b/engine/apps/api/views/user.py @@ -479,12 +479,13 @@ class UserView( @action(detail=True, methods=["get"]) def get_backend_verification_code(self, request, pk): + user = self.get_object() + backend_id = request.query_params.get("backend") backend = get_messaging_backend_from_id(backend_id) if backend is None: return Response(status=status.HTTP_400_BAD_REQUEST) - user = self.get_object() code = backend.generate_user_verification_code(user) return Response(code) @@ -547,12 +548,13 @@ class UserView( @action(detail=True, methods=["post"]) def unlink_backend(self, request, pk): # TODO: insight logs support + user = self.get_object() + backend_id = request.query_params.get("backend") backend = get_messaging_backend_from_id(backend_id) if backend is None: return Response(status=status.HTTP_400_BAD_REQUEST) - user = self.get_object() try: backend.unlink_user(user) write_chatops_insight_log( diff --git a/engine/apps/api/views/user_notification_policy.py b/engine/apps/api/views/user_notification_policy.py index eb168b27..e05fc121 100644 --- a/engine/apps/api/views/user_notification_policy.py +++ b/engine/apps/api/views/user_notification_policy.py @@ -19,6 +19,7 @@ from apps.mobile_app.auth import MobileAppAuthTokenAuthentication from apps.user_management.models import User from common.api_helpers.exceptions import BadRequest from common.api_helpers.mixins import UpdateSerializerMixin +from common.api_helpers.serializers import get_move_to_position_param from common.exceptions import UserNotificationPolicyCouldNotBeDeleted from common.insight_log import EntityEvent, write_resource_insight_log @@ -139,16 +140,10 @@ class UserNotificationPolicyView(UpdateSerializerMixin, ModelViewSet): @action(detail=True, methods=["put"]) def move_to_position(self, request, pk): - position = request.query_params.get("position", None) - if position is not None: - step = self.get_object() - try: - step.to(int(position)) - return Response(status=status.HTTP_200_OK) - except ValueError as e: - raise BadRequest(detail=f"{e}") - else: - raise BadRequest(detail="Position was not provided") + instance = self.get_object() + position = get_move_to_position_param(request) + instance.to(position) + return Response(status=status.HTTP_200_OK) @action(detail=False, methods=["get"]) def delay_options(self, request): diff --git a/engine/apps/base/models/live_setting.py b/engine/apps/base/models/live_setting.py index e6d1e708..34600ddb 100644 --- a/engine/apps/base/models/live_setting.py +++ b/engine/apps/base/models/live_setting.py @@ -212,6 +212,7 @@ class LiveSetting(models.Model): def validate_settings(cls): settings_to_validate = cls.objects.all() for setting in settings_to_validate: + setting.error = LiveSettingValidator(live_setting=setting).get_error() setting.save(update_fields=["error"]) @staticmethod @@ -219,14 +220,9 @@ class LiveSetting(models.Model): return getattr(settings, setting_name) def save(self, *args, **kwargs): - """ - Save validates LiveSettings values and save them in database - """ if self.name not in self.AVAILABLE_NAMES: raise ValueError( f"Setting with name '{self.name}' is not in list of available names {self.AVAILABLE_NAMES}" ) - self.error = LiveSettingValidator(live_setting=self).get_error() - super().save(*args, **kwargs) diff --git a/engine/apps/base/tests/messaging_backend.py b/engine/apps/base/tests/messaging_backend.py index 48d62dc5..5b40168c 100644 --- a/engine/apps/base/tests/messaging_backend.py +++ b/engine/apps/base/tests/messaging_backend.py @@ -8,6 +8,13 @@ class TestOnlyTemplater(AlertWebTemplater): class TestOnlyBackend(BaseMessagingBackend): + """ + set a __test__ = False attribute in classes that pytest should ignore otherwise we end up getting the following: + PytestCollectionWarning: cannot collect test class 'TestOnlyBackend' because it has a __init__ constructor + """ + + __test__ = False + backend_id = "TESTONLY" label = "Test Only Backend" short_label = "Test" diff --git a/engine/apps/base/utils.py b/engine/apps/base/utils.py index 98f593bf..66f58e15 100644 --- a/engine/apps/base/utils.py +++ b/engine/apps/base/utils.py @@ -45,7 +45,7 @@ class LiveSettingValidator: def get_error(self): check_fn_name = f"_check_{self.live_setting.name.lower()}" - if self.live_setting.value is None and self.live_setting.name not in self.EMPTY_VALID_NAMES: + if self.live_setting.value in (None, "") and self.live_setting.name not in self.EMPTY_VALID_NAMES: return "Empty" # skip validation if there's no handler for it @@ -138,9 +138,11 @@ class LiveSettingValidator: @classmethod def _check_telegram_webhook_host(cls, telegram_webhook_host): try: + # avoid circular import + from apps.telegram.client import TelegramClient + url = create_engine_url("/telegram/", override_base=telegram_webhook_host) - bot = Bot(token=live_settings.TELEGRAM_TOKEN) - bot.set_webhook(url) + TelegramClient().register_webhook(url) except Exception as e: return f"Telegram error: {str(e)}" diff --git a/engine/apps/email/alert_rendering.py b/engine/apps/email/alert_rendering.py index f9036a85..c2897a88 100644 --- a/engine/apps/email/alert_rendering.py +++ b/engine/apps/email/alert_rendering.py @@ -43,7 +43,7 @@ def build_subject_and_message(alert_group, emails_left): "title": str_or_backup(templated_alert.title, title_fallback), "message": str_or_backup(message, ""), # not render message at all if smth goes wrong "organization": alert_group.channel.organization.org_title, - "integration": emojize(alert_group.channel.short_name, use_aliases=True), + "integration": emojize(alert_group.channel.short_name, language="alias"), "limit_notification": emails_left <= 20, "emails_left": emails_left, }, diff --git a/engine/apps/mobile_app/alert_rendering.py b/engine/apps/mobile_app/alert_rendering.py index 3b0c9538..ef5082db 100644 --- a/engine/apps/mobile_app/alert_rendering.py +++ b/engine/apps/mobile_app/alert_rendering.py @@ -35,4 +35,4 @@ def get_push_notification_subtitle(alert_group): + f"\n{alert_status}" ) - return emojize(subtitle, use_aliases=True) + return emojize(subtitle, language="alias") diff --git a/engine/apps/public_api/serializers/integrations.py b/engine/apps/public_api/serializers/integrations.py index dea69ef7..b9ee7427 100644 --- a/engine/apps/public_api/serializers/integrations.py +++ b/engine/apps/public_api/serializers/integrations.py @@ -76,6 +76,7 @@ class IntegrationSerializer(EagerLoadingMixin, serializers.ModelSerializer, Main name = serializers.CharField(required=False, source="verbal_name") team_id = TeamPrimaryKeyRelatedField(required=False, allow_null=True, source="team") link = serializers.ReadOnlyField(source="integration_url") + inbound_email = serializers.ReadOnlyField() type = IntegrationTypeField(source="integration") templates = serializers.DictField(required=False) default_route = serializers.DictField(required=False) @@ -93,6 +94,7 @@ class IntegrationSerializer(EagerLoadingMixin, serializers.ModelSerializer, Main "description_short", "team_id", "link", + "inbound_email", "type", "default_route", "templates", diff --git a/engine/apps/public_api/tests/test_integrations.py b/engine/apps/public_api/tests/test_integrations.py index 6bbea4ae..3a4a2db3 100644 --- a/engine/apps/public_api/tests/test_integrations.py +++ b/engine/apps/public_api/tests/test_integrations.py @@ -33,6 +33,7 @@ def test_get_list_integrations( "name": "grafana", "description_short": "Some description", "link": integration.integration_url, + "inbound_email": None, "type": "grafana", "default_route": { "escalation_chain_id": None, @@ -165,6 +166,7 @@ def test_update_integration_template( "name": "grafana", "description_short": None, "link": integration.integration_url, + "inbound_email": None, "type": "grafana", "default_route": { "escalation_chain_id": None, @@ -227,6 +229,7 @@ def test_update_integration_template_messaging_backend( "name": "grafana", "description_short": None, "link": integration.integration_url, + "inbound_email": None, "type": "grafana", "default_route": { "escalation_chain_id": None, @@ -305,6 +308,7 @@ def test_update_resolve_signal_template( "name": "grafana", "description_short": None, "link": integration.integration_url, + "inbound_email": None, "type": "grafana", "default_route": { "escalation_chain_id": None, @@ -415,6 +419,7 @@ def test_update_sms_template_with_empty_dict( "name": "grafana", "description_short": None, "link": integration.integration_url, + "inbound_email": None, "type": "grafana", "default_route": { "escalation_chain_id": None, @@ -477,6 +482,7 @@ def test_update_integration_name( "name": "grafana_updated", "description_short": None, "link": integration.integration_url, + "inbound_email": None, "type": "grafana", "default_route": { "escalation_chain_id": None, @@ -539,6 +545,7 @@ def test_update_integration_name_and_description_short( "name": "grafana_updated", "description_short": "Some description", "link": integration.integration_url, + "inbound_email": None, "type": "grafana", "default_route": { "escalation_chain_id": None, @@ -604,6 +611,7 @@ def test_set_default_template( "name": "grafana", "description_short": None, "link": integration.integration_url, + "inbound_email": None, "type": "grafana", "default_route": { "escalation_chain_id": None, @@ -672,6 +680,7 @@ def test_set_default_messaging_backend_template( "name": "grafana", "description_short": None, "link": integration.integration_url, + "inbound_email": None, "type": "grafana", "default_route": { "escalation_chain_id": None, @@ -734,3 +743,51 @@ def test_get_list_integrations_direct_paging_hidden( # Check no direct paging integrations in the response assert response.status_code == status.HTTP_200_OK assert response.json()["results"] == [] + + +@pytest.mark.django_db +def test_get_list_integrations_link_and_inbound_email( + make_organization_and_user_with_token, + make_alert_receive_channel, + make_channel_filter, + make_integration_heartbeat, + settings, +): + """ + Check that "link" and "inbound_email" fields are populated correctly for different integration types. + """ + + settings.BASE_URL = "https://test.com" + settings.INBOUND_EMAIL_DOMAIN = "test.com" + + organization, user, token = make_organization_and_user_with_token() + + for integration in AlertReceiveChannel._config: + make_alert_receive_channel(organization, integration=integration.slug, token="test123") + + client = APIClient() + url = reverse("api-public:integrations-list") + + response = client.get(url, HTTP_AUTHORIZATION=f"{token}") + assert response.status_code == status.HTTP_200_OK + + for integration in response.json()["results"]: + integration_type, integration_link, integration_inbound_email = ( + integration["type"], + integration["link"], + integration["inbound_email"], + ) + + if integration_type in [ + AlertReceiveChannel.INTEGRATION_MANUAL, + AlertReceiveChannel.INTEGRATION_SLACK_CHANNEL, + AlertReceiveChannel.INTEGRATION_MAINTENANCE, + ]: + assert integration_link is None + assert integration_inbound_email is None + elif integration_type == AlertReceiveChannel.INTEGRATION_INBOUND_EMAIL: + assert integration_link is None + assert integration_inbound_email == "test123@test.com" + else: + assert integration_link == f"https://test.com/integrations/v1/{integration_type}/test123/" + assert integration_inbound_email is None diff --git a/engine/apps/public_api/tests/test_on_call_shifts.py b/engine/apps/public_api/tests/test_on_call_shifts.py index a25fb68a..1a66fa09 100644 --- a/engine/apps/public_api/tests/test_on_call_shifts.py +++ b/engine/apps/public_api/tests/test_on_call_shifts.py @@ -1,5 +1,3 @@ -import datetime - import pytest from django.urls import reverse from django.utils import timezone @@ -13,7 +11,7 @@ invalid_field_data_1 = { } invalid_field_data_2 = { - "start": datetime.datetime.now(), + "start": timezone.now(), } invalid_field_data_3 = { @@ -55,7 +53,7 @@ def test_get_on_call_shift(make_organization_and_user_with_token, make_on_call_s organization, user, token = make_organization_and_user_with_token() client = APIClient() - start_date = timezone.datetime.now().replace(microsecond=0) + start_date = timezone.now().replace(microsecond=0) data = { "start": start_date, "rotation_start": start_date, @@ -96,11 +94,11 @@ def test_get_override_on_call_shift(make_organization_and_user_with_token, make_ schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) - start_date = timezone.datetime.now().replace(microsecond=0) + start_date = timezone.now().replace(microsecond=0) data = { "start": start_date, "rotation_start": start_date, - "duration": datetime.timedelta(seconds=7200), + "duration": timezone.timedelta(seconds=7200), "schedule": schedule, } on_call_shift = make_on_call_shift(organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **data) @@ -133,8 +131,8 @@ def test_create_on_call_shift(make_organization_and_user_with_token): url = reverse("api-public:on_call_shifts-list") - start = datetime.datetime.now() - until = start + datetime.timedelta(days=30) + start = timezone.now() + until = start + timezone.timedelta(days=30) data = { "team_id": None, "name": "test name", @@ -185,8 +183,8 @@ def test_create_on_call_shift_using_default_interval(make_organization_and_user_ url = reverse("api-public:on_call_shifts-list") - start = datetime.datetime.now() - until = start + datetime.timedelta(days=30) + start = timezone.now() + until = start + timezone.timedelta(days=30) data = { "team_id": None, "name": "test name", @@ -236,8 +234,8 @@ def test_create_on_call_shift_using_none_interval_fails(make_organization_and_us url = reverse("api-public:on_call_shifts-list") - start = datetime.datetime.now() - until = start + datetime.timedelta(days=30) + start = timezone.now() + until = start + timezone.timedelta(days=30) data = { "team_id": None, "name": "test name", @@ -267,7 +265,7 @@ def test_create_override_on_call_shift(make_organization_and_user_with_token): url = reverse("api-public:on_call_shifts-list") - start = datetime.datetime.now() + start = timezone.now() data = { "team_id": None, "name": "test name", @@ -304,8 +302,8 @@ def test_create_on_call_shift_invalid_time_zone(make_organization_and_user_with_ url = reverse("api-public:on_call_shifts-list") - start = datetime.datetime.now() - until = start + datetime.timedelta(days=30) + start = timezone.now() + until = start + timezone.timedelta(days=30) data = { "team_id": None, "name": "test name", @@ -334,11 +332,11 @@ def test_update_on_call_shift(make_organization_and_user_with_token, make_on_cal organization, user, token = make_organization_and_user_with_token() client = APIClient() - start_date = timezone.datetime.now().replace(microsecond=0) + start_date = timezone.now().replace(microsecond=0) data = { "start": start_date, "rotation_start": start_date, - "duration": datetime.timedelta(seconds=7200), + "duration": timezone.timedelta(seconds=7200), "frequency": CustomOnCallShift.FREQUENCY_WEEKLY, "interval": 2, "by_day": ["MO", "FR"], @@ -413,11 +411,11 @@ def test_update_on_call_shift_invalid_field(make_organization_and_user_with_toke organization, _, token = make_organization_and_user_with_token() client = APIClient() - start_date = timezone.datetime.now().replace(microsecond=0) + start_date = timezone.now().replace(microsecond=0) data = { "start": start_date, "rotation_start": start_date, - "duration": datetime.timedelta(seconds=7200), + "duration": timezone.timedelta(seconds=7200), "frequency": CustomOnCallShift.FREQUENCY_WEEKLY, "interval": 2, "by_day": ["MO", "FR"], @@ -439,11 +437,11 @@ def test_delete_on_call_shift(make_organization_and_user_with_token, make_on_cal organization, _, token = make_organization_and_user_with_token() client = APIClient() - start_date = timezone.datetime.now().replace(microsecond=0) + start_date = timezone.now().replace(microsecond=0) data = { "start": start_date, "rotation_start": start_date, - "duration": datetime.timedelta(seconds=7200), + "duration": timezone.timedelta(seconds=7200), } on_call_shift = make_on_call_shift( organization=organization, shift_type=CustomOnCallShift.TYPE_SINGLE_EVENT, **data @@ -466,13 +464,14 @@ def test_create_web_override(make_organization_and_user_with_token, make_on_call url = reverse("api-public:on_call_shifts-list") - start = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) + start = timezone.now().replace(microsecond=0) + start_str = start.strftime("%Y-%m-%dT%H:%M:%S") data = { "team_id": None, "name": "test web override", "type": "override", "source": 0, - "start": start.strftime("%Y-%m-%dT%H:%M:%S"), + "start": start_str, "duration": 3600, "users": [user.public_primary_key], "time_zone": "UTC", @@ -485,8 +484,8 @@ def test_create_web_override(make_organization_and_user_with_token, make_on_call "team_id": None, "name": "test web override", "type": "override", - "start": start.strftime("%Y-%m-%dT%H:%M:%S"), - "rotation_start": start.strftime("%Y-%m-%dT%H:%M:%S"), + "start": start_str, + "rotation_start": start_str, "duration": 3600, "users": [user.public_primary_key], "time_zone": "UTC", diff --git a/engine/apps/public_api/tests/test_schedules.py b/engine/apps/public_api/tests/test_schedules.py index 1e5a2ba5..59af6d0b 100644 --- a/engine/apps/public_api/tests/test_schedules.py +++ b/engine/apps/public_api/tests/test_schedules.py @@ -2,6 +2,7 @@ import collections from unittest.mock import patch import pytest +import pytz from django.urls import reverse from django.utils import timezone from rest_framework import status @@ -102,7 +103,7 @@ def test_create_calendar_schedule_with_shifts(make_organization_and_user_with_to team.users.add(user) client = APIClient() - start_date = timezone.datetime.now().replace(microsecond=0) + start_date = timezone.now().replace(microsecond=0) data = { "team": team, "start": start_date, @@ -348,7 +349,7 @@ def test_update_calendar_schedule_with_custom_event( schedule_class=OnCallScheduleCalendar, channel=slack_channel_id, ) - start_date = timezone.datetime.now().replace(microsecond=0) + start_date = timezone.now().replace(microsecond=0) data = { "start": start_date, "rotation_start": start_date, @@ -402,7 +403,7 @@ def test_update_calendar_schedule_invalid_override( organization, schedule_class=OnCallScheduleCalendar, ) - start_date = timezone.datetime.now().replace(microsecond=0) + start_date = timezone.now().replace(microsecond=0) data = { "start": start_date, "rotation_start": start_date, @@ -428,7 +429,7 @@ def test_update_schedule_invalid_timezone(make_organization_and_user_with_token, client = APIClient() schedule = make_schedule(organization, schedule_class=ScheduleClass) - start_date = timezone.datetime.now().replace(microsecond=0) + start_date = timezone.now().replace(microsecond=0) data = { "start": start_date, "rotation_start": start_date, @@ -451,14 +452,14 @@ def test_update_web_schedule_with_override( make_on_call_shift, ): - organization, user, token = make_organization_and_user_with_token() + organization, _, token = make_organization_and_user_with_token() client = APIClient() schedule = make_schedule( organization, schedule_class=OnCallScheduleWeb, ) - start_date = timezone.datetime.now().replace(microsecond=0) + start_date = timezone.now().replace(microsecond=0) data = { "start": start_date, "rotation_start": start_date, @@ -867,7 +868,7 @@ def test_oncall_shifts_export( user2_public_primary_key = user2.public_primary_key schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) - start_date = timezone.datetime(2023, 1, 1, 9, 0, 0) + start_date = timezone.datetime(2023, 1, 1, 9, 0, 0, tzinfo=pytz.UTC) make_on_call_shift( organization=organization, schedule=schedule, diff --git a/engine/apps/public_api/views/action.py b/engine/apps/public_api/views/action.py index 1d1ecdb8..eb8af372 100644 --- a/engine/apps/public_api/views/action.py +++ b/engine/apps/public_api/views/action.py @@ -32,7 +32,7 @@ class ActionView(RateLimitHeadersMixin, PublicPrimaryKeyMixin, UpdateSerializerM if action_name: queryset = queryset.filter(name=action_name) - return queryset + return queryset.order_by("id") def perform_create(self, serializer): serializer.save() diff --git a/engine/apps/public_api/views/alerts.py b/engine/apps/public_api/views/alerts.py index b4be1c73..bfa12157 100644 --- a/engine/apps/public_api/views/alerts.py +++ b/engine/apps/public_api/views/alerts.py @@ -46,4 +46,4 @@ class AlertView(RateLimitHeadersMixin, mixins.ListModelMixin, GenericViewSet): queryset = self.serializer_class.setup_eager_loading(queryset) - return queryset + return queryset.order_by("id") diff --git a/engine/apps/public_api/views/escalation_chains.py b/engine/apps/public_api/views/escalation_chains.py index 017ca470..88cf24ba 100644 --- a/engine/apps/public_api/views/escalation_chains.py +++ b/engine/apps/public_api/views/escalation_chains.py @@ -34,7 +34,7 @@ class EscalationChainView(RateLimitHeadersMixin, ModelViewSet): if name is not None: queryset = queryset.filter(name=name) - return queryset + return queryset.order_by("id") def get_object(self): public_primary_key = self.kwargs["pk"] diff --git a/engine/apps/public_api/views/maintaiable_object_mixin.py b/engine/apps/public_api/views/maintaiable_object_mixin.py index b99985f8..ff9d7cd5 100644 --- a/engine/apps/public_api/views/maintaiable_object_mixin.py +++ b/engine/apps/public_api/views/maintaiable_object_mixin.py @@ -15,6 +15,8 @@ class MaintainableObjectMixin(viewsets.ViewSet): @action(detail=True, methods=["post"]) def maintenance_start(self, request, pk) -> Response: + instance = self.get_object() + mode = str(request.data.get("mode", None)).lower() duration = request.data.get("duration", None) @@ -31,7 +33,6 @@ class MaintainableObjectMixin(viewsets.ViewSet): except (ValueError, TypeError): raise BadRequest(detail={"duration": ["Invalid duration"]}) - instance = self.get_object() try: instance.start_maintenance(mode, duration, request.user) except MaintenanceCouldNotBeStartedError as e: diff --git a/engine/apps/public_api/views/schedules.py b/engine/apps/public_api/views/schedules.py index ab3aec8e..6ba33f55 100644 --- a/engine/apps/public_api/views/schedules.py +++ b/engine/apps/public_api/views/schedules.py @@ -41,6 +41,9 @@ class OnCallScheduleChannelView(RateLimitHeadersMixin, UpdateSerializerMixin, Mo filter_backends = (filters.DjangoFilterBackend,) filterset_class = ByTeamFilter + # self.get_object() is not used in export action because ScheduleExportAuthentication is used + extra_actions_ignore_no_get_object = ["export"] + def get_queryset(self): name = self.request.query_params.get("name", None) diff --git a/engine/apps/public_api/views/slack_channels.py b/engine/apps/public_api/views/slack_channels.py index 41be9616..1f363596 100644 --- a/engine/apps/public_api/views/slack_channels.py +++ b/engine/apps/public_api/views/slack_channels.py @@ -30,4 +30,4 @@ class SlackChannelView(RateLimitHeadersMixin, mixins.ListModelMixin, GenericView if channel_name: queryset = queryset.filter(name=channel_name) - return queryset + return queryset.order_by("id") diff --git a/engine/apps/public_api/views/teams.py b/engine/apps/public_api/views/teams.py index 40eb0e87..96cea48d 100644 --- a/engine/apps/public_api/views/teams.py +++ b/engine/apps/public_api/views/teams.py @@ -27,4 +27,4 @@ class TeamView(PublicPrimaryKeyMixin, RetrieveModelMixin, ListModelMixin, viewse queryset = self.request.auth.organization.teams.all() if name: queryset = queryset.filter(name=name) - return queryset + return queryset.order_by("id") diff --git a/engine/apps/public_api/views/user_groups.py b/engine/apps/public_api/views/user_groups.py index 2859199d..3db86954 100644 --- a/engine/apps/public_api/views/user_groups.py +++ b/engine/apps/public_api/views/user_groups.py @@ -26,4 +26,4 @@ class UserGroupView(RateLimitHeadersMixin, mixins.ListModelMixin, GenericViewSet ).distinct() if slack_handle: queryset = queryset.filter(handle=slack_handle) - return queryset + return queryset.order_by("id") diff --git a/engine/apps/public_api/views/users.py b/engine/apps/public_api/views/users.py index bba5484e..930d1c85 100644 --- a/engine/apps/public_api/views/users.py +++ b/engine/apps/public_api/views/users.py @@ -48,6 +48,9 @@ class UserView(RateLimitHeadersMixin, ShortSerializerMixin, ReadOnlyModelViewSet throttle_classes = [UserThrottle] + # self.get_object() is not used in export action because UserScheduleExportAuthentication is used + extra_actions_ignore_no_get_object = ["schedule_export"] + def get_queryset(self): if is_request_from_terraform(self.request): sync_users_on_tf_request(self.request.auth.organization) diff --git a/engine/apps/schedules/tests/test_custom_on_call_shift.py b/engine/apps/schedules/tests/test_custom_on_call_shift.py index 77a7f46d..74208240 100644 --- a/engine/apps/schedules/tests/test_custom_on_call_shift.py +++ b/engine/apps/schedules/tests/test_custom_on_call_shift.py @@ -1,6 +1,7 @@ from calendar import monthrange import pytest +import pytz from django.utils import timezone from apps.schedules.ical_utils import list_users_to_notify_from_ical @@ -12,7 +13,7 @@ def test_get_on_call_users_from_single_event(make_organization_and_user, make_on organization, user = make_organization_and_user() schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar) - date = timezone.now().replace(tzinfo=None, microsecond=0) + date = timezone.now().replace(microsecond=0) data = { "priority_level": 1, @@ -96,7 +97,7 @@ def test_get_on_call_users_from_recurrent_event(make_organization_and_user, make organization, user = make_organization_and_user() schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar) - date = timezone.now().replace(tzinfo=None, microsecond=0) + date = timezone.now().replace(microsecond=0) data = { "priority_level": 1, @@ -575,7 +576,7 @@ def test_rolling_users_event_with_interval_monthly( user_2 = make_user_for_organization(organization) schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar) - start_date = timezone.datetime(year=2022, month=10, day=1, hour=10, minute=30) + start_date = timezone.datetime(year=2022, month=10, day=1, hour=10, minute=30, tzinfo=pytz.UTC) days_for_next_month_1 = monthrange(2022, 10)[1] days_for_next_month_2 = monthrange(2022, 11)[1] + days_for_next_month_1 days_for_next_month_3 = monthrange(2022, 12)[1] + days_for_next_month_2 @@ -939,7 +940,7 @@ def test_rolling_users_with_diff_start_and_rotation_start_monthly( user_3 = make_user_for_organization(organization) schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) - start_date = timezone.datetime(year=2022, month=12, day=1, hour=10, minute=30) + start_date = timezone.datetime(year=2022, month=12, day=1, hour=10, minute=30, tzinfo=pytz.UTC) days_in_curr_month = monthrange(2022, 12)[1] days_in_next_month = monthrange(2023, 1)[1] @@ -995,7 +996,7 @@ def test_rolling_users_with_diff_start_and_rotation_start_monthly_by_monthday( user_3 = make_user_for_organization(organization) schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) - start_date = timezone.datetime(year=2022, month=12, day=1, hour=10, minute=30) + start_date = timezone.datetime(year=2022, month=12, day=1, hour=10, minute=30, tzinfo=pytz.UTC) days_in_curr_month = monthrange(2022, 12)[1] days_in_next_month = monthrange(2023, 1)[1] @@ -1314,7 +1315,7 @@ def test_get_oncall_users_for_multiple_schedules( schedule_1 = make_schedule(organization, schedule_class=OnCallScheduleCalendar) schedule_2 = make_schedule(organization, schedule_class=OnCallScheduleCalendar) - now = timezone.now().replace(tzinfo=None, microsecond=0) + now = timezone.now().replace(microsecond=0) on_call_shift_1 = make_on_call_shift( organization=organization, @@ -1417,7 +1418,7 @@ def test_get_oncall_users_for_multiple_schedules_emails_case_insensitive( def test_shift_convert_to_ical(make_organization_and_user, make_on_call_shift): organization, user = make_organization_and_user() - date = timezone.now().replace(tzinfo=None, microsecond=0) + date = timezone.now().replace(microsecond=0) until = date + timezone.timedelta(days=30) data = { diff --git a/engine/apps/schedules/tests/test_ical_utils.py b/engine/apps/schedules/tests/test_ical_utils.py index 0f395238..822ac6ad 100644 --- a/engine/apps/schedules/tests/test_ical_utils.py +++ b/engine/apps/schedules/tests/test_ical_utils.py @@ -105,7 +105,7 @@ def test_list_users_to_notify_from_ical_viewers_inclusion( viewer = make_user_for_organization(organization, role=LegacyAccessControlRole.VIEWER) schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar) - date = timezone.now().replace(tzinfo=None, microsecond=0) + date = timezone.now().replace(microsecond=0) data = { "priority_level": 1, "start": date, @@ -139,7 +139,7 @@ def test_list_users_to_notify_from_ical_until_terminated_event( other_user = make_user_for_organization(organization) schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) - date = timezone.now().replace(tzinfo=None, microsecond=0) + date = timezone.now().replace(microsecond=0) data = { "start": date, diff --git a/engine/apps/slack/slack_formatter.py b/engine/apps/slack/slack_formatter.py index 488d77f7..706e4ee8 100644 --- a/engine/apps/slack/slack_formatter.py +++ b/engine/apps/slack/slack_formatter.py @@ -30,14 +30,14 @@ class SlackFormatter(SlackFormatter): message = message.replace("", "@here") message = message.replace("", "@everyone") message = message.replace("", "@everyone") - message = self._slack_to_accepted_emoji(message) + message = self.slack_to_accepted_emoji(message) # Handle mentions of users, channels and bots (e.g "<@U0BM1CGQY|calvinchanubc> has joined the channel") message = self._MENTION_PAT.sub(self._sub_annotated_mention, message) # Handle links message = self._LINK_PAT.sub(self._sub_hyperlink, message) # Introduce unicode emoji - message = emoji.emojize(message, use_aliases=True) + message = emoji.emojize(message, language="alias") return message diff --git a/engine/apps/slack/tests/test_interactive_api_endpoint.py b/engine/apps/slack/tests/test_interactive_api_endpoint.py new file mode 100644 index 00000000..5f857a7d --- /dev/null +++ b/engine/apps/slack/tests/test_interactive_api_endpoint.py @@ -0,0 +1,134 @@ +import json +from unittest.mock import call, patch + +import pytest +from django.conf import settings +from rest_framework import status +from rest_framework.test import APIClient + +from apps.slack.scenarios.scenario_step import PAYLOAD_TYPE_BLOCK_ACTIONS + +EVENT_TRIGGER_ID = "5333959822612.4122782784722.4734ff484b2ac4d36a185bb242ee9932" +WARNING_TEXT = ( + "OnCall is not able to process this action because one of the following scenarios: \n" + "1. The Slack chatops integration was disconnected from the instance that the Alert Group belongs " + "to, BUT the Slack workspace is still connected to another instance as well. In this case, simply log " + "in to the OnCall web interface and re-install the Slack Integration with this workspace again.\n" + "2. (Less likely) The Grafana instance belonging to this Alert Group was deleted. In this case the Alert Group is orphaned and cannot be acted upon." +) + +SLACK_TEAM_ID = "T043LP0P2M8" +SLACK_ACCESS_TOKEN = "asdfasdf" +SLACK_BOT_ACCESS_TOKEN = "cmncvmnvcnm" +SLACK_BOT_USER_ID = "mncvnmvcmnvcmncv,,cx," + +SLACK_USER_ID = "iurtiurituritu" + + +def _make_request(payload): + return APIClient().post( + "/slack/interactive_api_endpoint/", + format="json", + data=payload, + **{ + "HTTP_X_SLACK_SIGNATURE": "asdfasdf", + "HTTP_X_SLACK_REQUEST_TIMESTAMP": "xxcxcvx", + }, + ) + + +@pytest.fixture +def slack_team_identity(make_slack_team_identity): + return make_slack_team_identity( + slack_id=SLACK_TEAM_ID, + detected_token_revoked=None, + access_token=SLACK_ACCESS_TOKEN, + bot_access_token=SLACK_BOT_ACCESS_TOKEN, + bot_user_id=SLACK_BOT_USER_ID, + ) + + +@patch("apps.slack.views.SlackEventApiEndpointView.verify_signature", return_value=True) +@patch("apps.slack.views.SlackEventApiEndpointView._open_warning_window_if_needed") +@pytest.mark.django_db +def test_organization_not_found_scenario_properly_handled( + mock_open_warning_window_if_needed, + _mock_verify_signature, + make_organization, + make_slack_user_identity, + slack_team_identity, +): + # SCENARIO 1 + # two orgs connected to same slack workspace, the one belonging to the alert group/slack message + # is no longer connected to the slack workspace, but another org still is + make_slack_user_identity(slack_team_identity=slack_team_identity, slack_id=SLACK_USER_ID) + + make_organization(slack_team_identity=slack_team_identity) + org2 = make_organization() + event_payload_actions = [ + { + "value": json.dumps({"organization_id": org2.id}), + } + ] + + event_payload = { + "type": PAYLOAD_TYPE_BLOCK_ACTIONS, + "trigger_id": EVENT_TRIGGER_ID, + "user": { + "id": SLACK_USER_ID, + }, + "team": { + "id": SLACK_TEAM_ID, + }, + "actions": event_payload_actions, + } + + response = _make_request(event_payload) + assert response.status_code == status.HTTP_200_OK + + # SCENARIO 2 + # the org that was associated w/ the alert group, has since been deleted + # and the slack message is now orphaned + org2.hard_delete() + + response = _make_request(event_payload) + assert response.status_code == status.HTTP_200_OK + + mock_call = call(event_payload, slack_team_identity, WARNING_TEXT) + mock_open_warning_window_if_needed.assert_has_calls([mock_call, mock_call]) + + +@patch("apps.slack.views.SlackEventApiEndpointView.verify_signature", return_value=True) +@patch("apps.slack.views.SlackEventApiEndpointView._open_warning_window_if_needed") +@pytest.mark.django_db +def test_organization_not_found_scenario_doesnt_break_slash_commands( + mock_open_warning_window_if_needed, + _mock_verify_signature, + make_organization, + make_slack_user_identity, + slack_team_identity, +): + + make_organization(slack_team_identity=slack_team_identity) + make_slack_user_identity(slack_team_identity=slack_team_identity, slack_id=SLACK_USER_ID) + + response = _make_request( + { + "token": "axvnc,mvc,mv,mcvmnxcmnxc", + "team_id": SLACK_TEAM_ID, + "team_domain": "testingtest-nim4013", + "channel_id": "C043HQ70QMB", + "channel_name": "testy-testing", + "user_id": "U043HQ3VABF", + "user_name": "bob.smith", + "command": settings.SLACK_DIRECT_PAGING_SLASH_COMMAND, + "text": "potato", + "api_app_id": "A0909234092340293402934234234234234234", + "is_enterprise_install": "false", + "response_url": "https://hooks.slack.com/commands/cvcv/cvcv/cvcv", + "trigger_id": "asdfasdf.4122782784722.cvcv", + } + ) + + assert response.status_code == status.HTTP_200_OK + mock_open_warning_window_if_needed.assert_not_called() diff --git a/engine/apps/slack/tests/test_scenario_steps/test_alert_group_actions.py b/engine/apps/slack/tests/test_scenario_steps/test_alert_group_actions.py index 4d01d37d..38f9b012 100644 --- a/engine/apps/slack/tests/test_scenario_steps/test_alert_group_actions.py +++ b/engine/apps/slack/tests/test_scenario_steps/test_alert_group_actions.py @@ -9,6 +9,13 @@ from apps.slack.scenarios.step_mixins import AlertGroupActionsMixin class TestScenario(AlertGroupActionsMixin, ScenarioStep): + """ + set a __test__ = False attribute in classes that pytest should ignore otherwise we end up getting the following: + PytestCollectionWarning: cannot collect test class 'TestScenario' because it has a __init__ constructor + """ + + __test__ = False + pass diff --git a/engine/apps/slack/views.py b/engine/apps/slack/views.py index 6c0a4d16..89c3da8a 100644 --- a/engine/apps/slack/views.py +++ b/engine/apps/slack/views.py @@ -55,6 +55,7 @@ from apps.slack.scenarios.slack_usergroup import STEPS_ROUTING as SLACK_USERGROU from apps.slack.slack_client import SlackClientWithErrorHandling from apps.slack.slack_client.exceptions import SlackAPIException, SlackAPITokenException from apps.slack.tasks import clean_slack_integration_leftovers, unpopulate_slack_user_identities +from apps.user_management.models import Organization from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log from common.oncall_gateway import delete_slack_connector @@ -161,8 +162,29 @@ class SlackEventApiEndpointView(APIView): ) payload["amixr_slack_retries"] = request.META["HTTP_X_SLACK_RETRY_NUM"] + payload_type = payload.get("type") + payload_type_is_block_actions = payload_type == PAYLOAD_TYPE_BLOCK_ACTIONS + payload_command = payload.get("command") + payload_callback_id = payload.get("callback_id") + payload_actions = payload.get("actions", []) + payload_user = payload.get("user") + payload_user_id = payload.get("user_id") + + payload_event = payload.get("event", {}) + payload_event_type = payload_event.get("type") + payload_event_subtype = payload_event.get("subtype") + payload_event_user = payload_event.get("user") + payload_event_bot_id = payload_event.get("bot_id") + payload_event_channel_type = payload_event.get("channel_type") + + payload_event_message = payload_event.get("message", {}) + payload_event_message_user = payload_event_message.get("user") + + payload_event_previous_message = payload_event.get("previous_message", {}) + payload_event_previous_message_user = payload_event_previous_message.get("user") + # Initial url verification - if "type" in payload and payload["type"] == "url_verification": + if payload_type == "url_verification": logger.critical("URL verification from Slack side. That's suspicious.") return Response(payload["challenge"]) @@ -211,42 +233,38 @@ class SlackEventApiEndpointView(APIView): # Linking user identity slack_user_identity = None - if "event" in payload and payload["event"] is not None: - if ("user" in payload["event"]) and slack_team_identity and (payload["event"]["user"] is not None): - if "id" in payload["event"]["user"]: - slack_user_id = payload["event"]["user"]["id"] - elif type(payload["event"]["user"]) is str: - slack_user_id = payload["event"]["user"] + if payload_event: + if payload_event_user and slack_team_identity: + if "id" in payload_event_user: + slack_user_id = payload_event_user["id"] + elif type(payload_event_user) is str: + slack_user_id = payload_event_user else: raise Exception("Failed Linking user identity") elif ( - ("bot_id" in payload["event"]) + payload_event_bot_id and slack_team_identity - and ( - payload["event"]["bot_id"] is not None - and "channel_type" in payload["event"] - and payload["event"]["channel_type"] == EVENT_TYPE_MESSAGE_CHANNEL - ) + and payload_event_channel_type == EVENT_TYPE_MESSAGE_CHANNEL ): - response = sc.api_call("bots.info", bot=payload["event"]["bot_id"]) + response = sc.api_call("bots.info", bot=payload_event_bot_id) bot_user_id = response.get("bot", {}).get("user_id", "") # Don't react on own bot's messages. if bot_user_id == slack_team_identity.bot_user_id: return Response(status=200) - elif "user" in payload["event"].get("message", {}): - slack_user_id = payload["event"]["message"]["user"] + elif payload_event_message_user: + slack_user_id = payload_event_message_user # event subtype 'message_deleted' - elif "user" in payload["event"].get("previous_message", {}): - slack_user_id = payload["event"]["previous_message"]["user"] + elif payload_event_previous_message_user: + slack_user_id = payload_event_previous_message_user - if "user" in payload: - slack_user_id = payload["user"]["id"] + if payload_user: + slack_user_id = payload_user["id"] - elif "user_id" in payload: - slack_user_id = payload["user_id"] + elif payload_user_id: + slack_user_id = payload_user_id if slack_user_id is not None and slack_user_id != slack_team_identity.bot_user_id: slack_user_identity = SlackUserIdentity.objects.filter( @@ -259,18 +277,16 @@ class SlackEventApiEndpointView(APIView): logger.info("SlackUserIdentity detected: " + str(slack_user_identity)) if not slack_user_identity: - if "type" in payload and payload["type"] == PAYLOAD_TYPE_EVENT_CALLBACK: - if payload["event"]["type"] in [ + if payload_type == PAYLOAD_TYPE_EVENT_CALLBACK: + if payload_event_type in [ EVENT_TYPE_SUBTEAM_CREATED, EVENT_TYPE_SUBTEAM_UPDATED, EVENT_TYPE_SUBTEAM_MEMBERS_CHANGED, ]: logger.info("Slack event without user slack_id.") - elif payload["event"]["type"] in (EVENT_TYPE_USER_CHANGE, EVENT_TYPE_USER_PROFILE_CHANGED): + elif payload_event_type in (EVENT_TYPE_USER_CHANGE, EVENT_TYPE_USER_PROFILE_CHANGED): logger.info( - "Event {}. Dropping request because it does not have SlackUserIdentity.".format( - payload["event"]["type"] - ) + f"Event {payload_event_type}. Dropping request because it does not have SlackUserIdentity." ) return Response() else: @@ -285,6 +301,19 @@ class SlackEventApiEndpointView(APIView): # Open pop-up to inform user why OnCall bot doesn't work if any action was triggered self._open_warning_window_if_needed(payload, slack_team_identity, warning_text) return Response(status=200) + elif organization is None and payload_type_is_block_actions: + # see this GitHub issue for more context on how this situation can arise + # https://github.com/grafana/oncall-private/issues/1836 + warning_text = ( + "OnCall is not able to process this action because one of the following scenarios: \n" + "1. The Slack chatops integration was disconnected from the instance that the Alert Group belongs " + "to, BUT the Slack workspace is still connected to another instance as well. In this case, simply log " + "in to the OnCall web interface and re-install the Slack Integration with this workspace again.\n" + "2. (Less likely) The Grafana instance belonging to this Alert Group was deleted. In this case the Alert Group is orphaned and cannot be acted upon." + ) + # Open pop-up to inform user why OnCall bot doesn't work if any action was triggered + self._open_warning_window_if_needed(payload, slack_team_identity, warning_text) + return Response(status=200) elif not slack_user_identity.users.exists(): # Means that slack_user_identity doesn't have any connected user # Open pop-up to inform user why OnCall bot doesn't work if any action was triggered @@ -292,48 +321,53 @@ class SlackEventApiEndpointView(APIView): return Response(status=200) # Capture cases when we expect stateful message from user - if not step_was_found and "type" in payload and payload["type"] == PAYLOAD_TYPE_EVENT_CALLBACK: + if payload_type == PAYLOAD_TYPE_EVENT_CALLBACK: + event_type = payload_event_type + # Message event is from channel if ( - payload["event"]["type"] == EVENT_TYPE_MESSAGE - and payload["event"]["channel_type"] == EVENT_TYPE_MESSAGE_CHANNEL + event_type == EVENT_TYPE_MESSAGE + and payload_event_channel_type == EVENT_TYPE_MESSAGE_CHANNEL and ( - "subtype" not in payload["event"] - or payload["event"]["subtype"] == EVENT_SUBTYPE_BOT_MESSAGE - or payload["event"]["subtype"] == EVENT_SUBTYPE_MESSAGE_CHANGED - or payload["event"]["subtype"] == EVENT_SUBTYPE_FILE_SHARE - or payload["event"]["subtype"] == EVENT_SUBTYPE_MESSAGE_DELETED + not payload_event_subtype + or payload_event_subtype + in [ + EVENT_SUBTYPE_BOT_MESSAGE, + EVENT_SUBTYPE_MESSAGE_CHANGED, + EVENT_SUBTYPE_FILE_SHARE, + EVENT_SUBTYPE_MESSAGE_DELETED, + ] ) ): for route in SCENARIOS_ROUTES: - if ( - "message_channel_type" in route - and payload["event"]["channel_type"] == route["message_channel_type"] - ): + if payload_event_channel_type == route.get("message_channel_type"): Step = route["step"] logger.info("Routing to {}".format(Step)) step = Step(slack_team_identity, organization, user) step.process_scenario(slack_user_identity, slack_team_identity, payload) step_was_found = True # We don't do anything on app mention, but we doesn't want to unsubscribe from this event yet. - if payload["event"]["type"] == EVENT_TYPE_APP_MENTION: + if event_type == EVENT_TYPE_APP_MENTION: logger.info(f"Received event of type {EVENT_TYPE_APP_MENTION} from slack. Skipping.") return Response(status=200) + # Routing to Steps based on routing rules if not step_was_found: for route in SCENARIOS_ROUTES: + route_payload_type = route["payload_type"] + # Slash commands have to "type" - if "command" in payload and route["payload_type"] == PAYLOAD_TYPE_SLASH_COMMAND: - if payload["command"] in route["command_name"]: + if payload_command and route_payload_type == PAYLOAD_TYPE_SLASH_COMMAND: + if payload_command in route["command_name"]: Step = route["step"] logger.info("Routing to {}".format(Step)) step = Step(slack_team_identity, organization, user) step.process_scenario(slack_user_identity, slack_team_identity, payload) step_was_found = True - if "type" in payload and payload["type"] == route["payload_type"]: - if payload["type"] == PAYLOAD_TYPE_EVENT_CALLBACK: - if payload["event"]["type"] == route["event_type"]: + if payload_type == route_payload_type: + if payload_type == PAYLOAD_TYPE_EVENT_CALLBACK: + if payload_event_type == route["event_type"]: # event_name is used for stateful if "event_name" not in route: Step = route["step"] @@ -342,8 +376,8 @@ class SlackEventApiEndpointView(APIView): step.process_scenario(slack_user_identity, slack_team_identity, payload) step_was_found = True - if payload["type"] == PAYLOAD_TYPE_INTERACTIVE_MESSAGE: - for action in payload["actions"]: + if payload_type == PAYLOAD_TYPE_INTERACTIVE_MESSAGE: + for action in payload_actions: if action["type"] == route["action_type"]: # Action name may also contain action arguments. # So only beginning is used for routing. @@ -356,8 +390,8 @@ class SlackEventApiEndpointView(APIView): return result step_was_found = True - if payload["type"] == PAYLOAD_TYPE_BLOCK_ACTIONS: - for action in payload["actions"]: + if payload_type_is_block_actions: + for action in payload_actions: if action["type"] == route["block_action_type"]: if action["action_id"].startswith(route["block_action_id"]): Step = route["step"] @@ -366,8 +400,8 @@ class SlackEventApiEndpointView(APIView): step.process_scenario(slack_user_identity, slack_team_identity, payload) step_was_found = True - if payload["type"] == PAYLOAD_TYPE_DIALOG_SUBMISSION: - if payload["callback_id"] == route["dialog_callback_id"]: + if payload_type == PAYLOAD_TYPE_DIALOG_SUBMISSION: + if payload_callback_id == route["dialog_callback_id"]: Step = route["step"] logger.info("Routing to {}".format(Step)) step = Step(slack_team_identity, organization, user) @@ -376,7 +410,7 @@ class SlackEventApiEndpointView(APIView): return result step_was_found = True - if payload["type"] == PAYLOAD_TYPE_VIEW_SUBMISSION: + if payload_type == PAYLOAD_TYPE_VIEW_SUBMISSION: if payload["view"]["callback_id"].startswith(route["view_callback_id"]): Step = route["step"] logger.info("Routing to {}".format(Step)) @@ -386,8 +420,8 @@ class SlackEventApiEndpointView(APIView): return result step_was_found = True - if payload["type"] == PAYLOAD_TYPE_MESSAGE_ACTION: - if payload["callback_id"] in route["message_action_callback_id"]: + if payload_type == PAYLOAD_TYPE_MESSAGE_ACTION: + if payload_callback_id in route["message_action_callback_id"]: Step = route["step"] logger.info("Routing to {}".format(Step)) step = Step(slack_team_identity, organization, user) @@ -420,76 +454,93 @@ class SlackEventApiEndpointView(APIView): channel_id = None organization = None - # view submission or actions in view - if "view" in payload: - organization_id = None - private_metadata = payload["view"].get("private_metadata") - # steps with private_metadata in which we know organization before open view - if private_metadata and "organization_id" in private_metadata: - organization_id = json.loads(private_metadata).get("organization_id") - # steps with organization selection in view (e.g. slash commands) - elif SELECT_ORGANIZATION_AND_ROUTE_BLOCK_ID in payload["view"].get("state", {}).get("values", {}): - payload_values = payload["view"]["state"]["values"] - selected_value = payload_values[SELECT_ORGANIZATION_AND_ROUTE_BLOCK_ID][ - SELECT_ORGANIZATION_AND_ROUTE_BLOCK_ID - ]["selected_option"]["value"] - organization_id = int(selected_value.split("-")[0]) - if organization_id: - organization = slack_team_identity.organizations.get(pk=organization_id) - return organization - # buttons and actions - elif payload.get("type") in [ - PAYLOAD_TYPE_BLOCK_ACTIONS, - PAYLOAD_TYPE_INTERACTIVE_MESSAGE, - PAYLOAD_TYPE_MESSAGE_ACTION, - ]: - # for cases when we put organization_id into action value (e.g. public suggestion) - if ( - payload.get("actions") - and payload["actions"][0].get("value", {}) - and "organization_id" in payload["actions"][0]["value"] - ): - organization_id = int(json.loads(payload["actions"][0]["value"])["organization_id"]) - organization = slack_team_identity.organizations.get(pk=organization_id) - return organization + payload_type = payload.get("type") + payload_actions = payload.get("actions", []) + payload_message = payload.get("message", {}) + payload_message_ts = payload.get("message_ts") - channel_id = payload["channel"]["id"] - if "message" in payload: - message_ts = payload["message"].get("thread_ts") or payload["message"]["ts"] - # for interactive message - elif "message_ts" in payload: - message_ts = payload["message_ts"] - else: - return - # events - elif payload.get("type") == PAYLOAD_TYPE_EVENT_CALLBACK: - if "channel" in payload["event"]: # events without channel: user_change, events with subteam, etc. - channel_id = payload["event"]["channel"] + payload_view = payload.get("view", {}) + payload_view_state = payload_view.get("state", {}) + payload_view_state_values = payload_view_state.get("values", {}) - if "message" in payload["event"]: - message_ts = payload["event"]["message"].get("thread_ts") or payload["event"]["message"]["ts"] - elif "thread_ts" in payload["event"]: - message_ts = payload["event"]["thread_ts"] - else: - return - - if not (message_ts and channel_id): - return + payload_event = payload.get("event", {}) + payload_event_channel = payload_event.get("channel") + payload_event_message = payload_event.get("message", {}) + payload_event_thread_ts = payload_event.get("thread_ts") try: - slack_message = SlackMessage.objects.get( - slack_id=message_ts, - _slack_team_identity=slack_team_identity, - channel_id=channel_id, - ) - except SlackMessage.DoesNotExist: - pass - else: - alert_group = slack_message.get_alert_group() - if alert_group: - organization = alert_group.channel.organization - return organization - return organization + # view submission or actions in view + if payload_view: + organization_id = None + private_metadata = payload_view.get("private_metadata", {}) + # steps with private_metadata in which we know organization before open view + if "organization_id" in private_metadata: + organization_id = json.loads(private_metadata).get("organization_id") + # steps with organization selection in view (e.g. slash commands) + elif SELECT_ORGANIZATION_AND_ROUTE_BLOCK_ID in payload_view_state_values: + selected_value = payload_view_state_values[SELECT_ORGANIZATION_AND_ROUTE_BLOCK_ID][ + SELECT_ORGANIZATION_AND_ROUTE_BLOCK_ID + ]["selected_option"]["value"] + organization_id = int(selected_value.split("-")[0]) + if organization_id: + organization = slack_team_identity.organizations.get(pk=organization_id) + return organization + # buttons and actions + elif payload_type in [ + PAYLOAD_TYPE_BLOCK_ACTIONS, + PAYLOAD_TYPE_INTERACTIVE_MESSAGE, + PAYLOAD_TYPE_MESSAGE_ACTION, + ]: + # for cases when we put organization_id into action value (e.g. public suggestion) + if payload_actions: + payload_action_value = payload_actions[0].get("value", {}) + + if "organization_id" in payload_action_value: + organization_id = int(json.loads(payload_action_value)["organization_id"]) + organization = slack_team_identity.organizations.get(pk=organization_id) + return organization + + channel_id = payload["channel"]["id"] + if payload_message: + message_ts = payload_message.get("thread_ts") or payload_message["ts"] + # for interactive message + elif payload_message_ts: + message_ts = payload_message_ts + else: + return + # events + elif payload_type == PAYLOAD_TYPE_EVENT_CALLBACK: + if payload_event_channel: # events without channel: user_change, events with subteam, etc. + channel_id = payload_event_channel + + if payload_event_message: + message_ts = payload_event_message.get("thread_ts") or payload_event_message["ts"] + elif payload_event_thread_ts: + message_ts = payload_event_thread_ts + else: + return + + if not (message_ts and channel_id): + return + + try: + slack_message = SlackMessage.objects.get( + slack_id=message_ts, + _slack_team_identity=slack_team_identity, + channel_id=channel_id, + ) + except SlackMessage.DoesNotExist: + pass + else: + alert_group = slack_message.get_alert_group() + if alert_group: + organization = alert_group.channel.organization + return organization + return organization + except Organization.DoesNotExist: + # see this GitHub issue for more context on how this situation can arise + # https://github.com/grafana/oncall-private/issues/1836 + return None def _open_warning_window_if_needed(self, payload, slack_team_identity, warning_text) -> None: if payload.get("trigger_id") is not None: diff --git a/engine/apps/telegram/client.py b/engine/apps/telegram/client.py index 86e2de22..b5733bca 100644 --- a/engine/apps/telegram/client.py +++ b/engine/apps/telegram/client.py @@ -39,6 +39,7 @@ class TelegramClient: def register_webhook(self, webhook_url: Optional[str] = None) -> None: webhook_url = webhook_url or create_engine_url("/telegram/", override_base=live_settings.TELEGRAM_WEBHOOK_HOST) + # avoid unnecessary set_webhook calls to make sure Telegram rate limits are not exceeded webhook_info = self.api_client.get_webhook_info() if webhook_info.url == webhook_url: return diff --git a/engine/apps/twilioapp/tests/test_phone_calls.py b/engine/apps/twilioapp/tests/test_phone_calls.py index 4fa2aaed..c66977e1 100644 --- a/engine/apps/twilioapp/tests/test_phone_calls.py +++ b/engine/apps/twilioapp/tests/test_phone_calls.py @@ -170,7 +170,7 @@ def test_resolve_by_phone(mock_has_permission, mock_get_gather_url, make_twilio_ ) content = response.content.decode("utf-8") - content = BeautifulSoup(content, features="html.parser").findAll(text=True) + content = BeautifulSoup(content, features="xml").findAll(string=True) assert response.status_code == 200 assert "You have pressed digit 2" in content @@ -236,7 +236,7 @@ def test_wrong_pressed_digit(mock_has_permission, mock_get_gather_url, make_twil ) content = response.content.decode("utf-8") - content = BeautifulSoup(content, features="html.parser").findAll(text=True) + content = BeautifulSoup(content, features="xml").findAll(string=True) assert response.status_code == 200 assert "Wrong digit" in content diff --git a/engine/apps/webhooks/models/webhook.py b/engine/apps/webhooks/models/webhook.py index 50233336..97036576 100644 --- a/engine/apps/webhooks/models/webhook.py +++ b/engine/apps/webhooks/models/webhook.py @@ -23,6 +23,8 @@ from common.jinja_templater import apply_jinja_template from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length +WEBHOOK_FIELD_PLACEHOLDER = "****************" + def generate_public_primary_key_for_webhook(): prefix = "WH" diff --git a/engine/apps/webhooks/tasks/trigger_webhook.py b/engine/apps/webhooks/tasks/trigger_webhook.py index fab0df1a..c0e9e566 100644 --- a/engine/apps/webhooks/tasks/trigger_webhook.py +++ b/engine/apps/webhooks/tasks/trigger_webhook.py @@ -11,6 +11,7 @@ from apps.alerts.models import AlertGroup, AlertGroupLogRecord, EscalationPolicy from apps.base.models import UserNotificationPolicyLogRecord from apps.user_management.models import User from apps.webhooks.models import Webhook, WebhookResponse +from apps.webhooks.models.webhook import WEBHOOK_FIELD_PLACEHOLDER from apps.webhooks.utils import ( InvalidWebhookData, InvalidWebhookHeaders, @@ -94,6 +95,12 @@ def _build_payload(webhook, alert_group, user): return data +def mask_authorization_header(headers): + if "Authorization" in headers: + headers["Authorization"] = WEBHOOK_FIELD_PLACEHOLDER + return headers + + def make_request(webhook, alert_group, data): status = { "url": None, @@ -115,7 +122,8 @@ def make_request(webhook, alert_group, data): if triggered: status["url"] = webhook.build_url(data) request_kwargs = webhook.build_request_kwargs(data, raise_data_errors=True) - status["request_headers"] = json.dumps(request_kwargs.get("headers", {})) + headers = mask_authorization_header(request_kwargs.get("headers", {})) + status["request_headers"] = json.dumps(headers) if "json" in request_kwargs: status["request_data"] = json.dumps(request_kwargs["json"]) else: diff --git a/engine/apps/webhooks/tests/factories.py b/engine/apps/webhooks/tests/factories.py index b456d41a..6e6a5da1 100644 --- a/engine/apps/webhooks/tests/factories.py +++ b/engine/apps/webhooks/tests/factories.py @@ -1,4 +1,5 @@ import factory +import pytz from apps.webhooks.models import Webhook, WebhookResponse from common.utils import UniqueFaker @@ -14,7 +15,7 @@ class CustomWebhookFactory(factory.DjangoModelFactory): class WebhookResponseFactory(factory.DjangoModelFactory): - timestamp = factory.Faker("date_time") + timestamp = factory.Faker("date_time", tzinfo=pytz.UTC) class Meta: model = WebhookResponse diff --git a/engine/common/api_helpers/mixins.py b/engine/common/api_helpers/mixins.py index cba754db..78055985 100644 --- a/engine/common/api_helpers/mixins.py +++ b/engine/common/api_helpers/mixins.py @@ -300,6 +300,13 @@ class PreviewTemplateMixin: template_name = request.data.get("template_name", None) payload = request.data.get("payload", None) + try: + alert_to_template = self.get_alert_to_template(payload=payload) + if alert_to_template is None: + raise BadRequest(detail="Alert to preview does not exist") + except PreviewTemplateException as e: + raise BadRequest(detail=str(e)) + if template_body is None or template_name is None: response = {"preview": None} return Response(response, status=status.HTTP_200_OK) @@ -315,13 +322,6 @@ class PreviewTemplateMixin: if notification_channel not in NOTIFICATION_CHANNEL_OPTIONS: raise BadRequest(detail={"notification_channel": "Unknown notification_channel"}) - try: - alert_to_template = self.get_alert_to_template(payload=payload) - if alert_to_template is None: - raise BadRequest(detail="Alert to preview does not exist") - except PreviewTemplateException as e: - raise BadRequest(detail=str(e)) - if attr_name in APPEARANCE_TEMPLATE_NAMES: class PreviewTemplateLoader(TemplateLoader): diff --git a/engine/common/api_helpers/serializers.py b/engine/common/api_helpers/serializers.py new file mode 100644 index 00000000..db8f8f7b --- /dev/null +++ b/engine/common/api_helpers/serializers.py @@ -0,0 +1,17 @@ +from rest_framework import serializers +from rest_framework.request import Request + + +def get_move_to_position_param(request: Request): + """ + Get "position" parameter from query params + validate it. + Used by actions on ordered models (e.g. move_to_position). + """ + + class MoveToPositionQueryParamsSerializer(serializers.Serializer): + position = serializers.IntegerField() + + serializer = MoveToPositionQueryParamsSerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + + return serializer.validated_data["position"] diff --git a/engine/common/api_helpers/utils.py b/engine/common/api_helpers/utils.py index d81942bd..da57b13d 100644 --- a/engine/common/api_helpers/utils.py +++ b/engine/common/api_helpers/utils.py @@ -23,10 +23,10 @@ class CurrentOrganizationDefault: Example: organization = serializers.HiddenField(default=CurrentOrganizationDefault()) """ - def set_context(self, serializer_field): - self.organization = serializer_field.context["request"].auth.organization + requires_context = True - def __call__(self): + def __call__(self, serializer_field): + self.organization = serializer_field.context["request"].auth.organization return self.organization def __repr__(self): @@ -38,10 +38,10 @@ class CurrentTeamDefault: Utility class to get the current team right from the serializer field. """ - def set_context(self, serializer_field): - self.team = serializer_field.context["request"].user.current_team + requires_context = True - def __call__(self): + def __call__(self, serializer_field): + self.team = serializer_field.context["request"].user.current_team return self.team def __repr__(self): @@ -81,10 +81,10 @@ class CurrentUserDefault: Utility class to get the current user right from the serializer field. """ - def set_context(self, serializer_field): - self.user = serializer_field.context["request"].user + requires_context = True - def __call__(self): + def __call__(self, serializer_field): + self.user = serializer_field.context["request"].user return self.user def __repr__(self): diff --git a/engine/common/tests/test_urlize.py b/engine/common/tests/test_urlize.py index e239638d..8d9a5920 100644 --- a/engine/common/tests/test_urlize.py +++ b/engine/common/tests/test_urlize.py @@ -1,3 +1,5 @@ +import pytest + from common.utils import urlize_with_respect_to_a @@ -13,6 +15,10 @@ def test_urlize_will_not_mutate_text_with_link_in_a(): assert urlize_with_respect_to_a(original) == expected +@pytest.mark.filterwarnings( + "ignore:The input looks more like a URL than markup. You may want to use an HTTP client like requests to get the " + "document behind the URL, and feed that document to Beautiful Soup." +) def test_urlize_will_wrap_link(): original = "https://amixr.io/" expected = 'https://amixr.io/' diff --git a/engine/common/tests/test_viewset_actions.py b/engine/common/tests/test_viewset_actions.py new file mode 100644 index 00000000..52a5a503 --- /dev/null +++ b/engine/common/tests/test_viewset_actions.py @@ -0,0 +1,81 @@ +from unittest.mock import patch + +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.exceptions import NotFound +from rest_framework.test import APIClient + +from apps.api.urls import router as internal_api_router +from apps.public_api.urls import router as public_api_router + + +@pytest.mark.parametrize( + "basename,viewset_class,action", + [ + # Collect all detail actions from all viewsets registered in internal API router + (basename, viewset_class, action) + for _, viewset_class, basename in internal_api_router.registry + for action in viewset_class.get_extra_actions() + if action.detail + ], +) +@pytest.mark.django_db +def test_internal_api_detail_actions_get_object( + make_organization_and_user_with_plugin_token, make_user_auth_headers, basename, viewset_class, action +): + organization, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + + url = reverse(f"api-internal:{basename}-{action.url_name}", kwargs={"pk": "NONEXISTENT"}) + + with patch.object(viewset_class, "get_object", side_effect=NotFound) as mock_get_object: + method = list(action.mapping.keys())[0] # get the first allowed method + response = client.generic(path=url, method=method, **make_user_auth_headers(user, token)) + + """ + If you see this errors in tests, make sure to call self.get_object() in action method that's added / changed. + Call to self.get_object() must come before any additional checks. For example, call to self.get_object() must come + before checking for request data that may result in 400 Bad Request (i.e. check for 404 must come before check for 400). + This is required to ensure all detail actions are safe, consistent with each other and easily testable. + """ + assert response.status_code == status.HTTP_404_NOT_FOUND, "check for 404 must come before any additional checks" + assert ( + mock_get_object.call_count == 1 + ), f"self.get_object() must be called in {viewset_class.__class__.__name__}.{action.__name__}" + + +@pytest.mark.parametrize( + "basename,viewset_class,action", + [ + # Collect all detail actions from all viewsets registered in public API router + (basename, viewset_class, action) + for _, viewset_class, basename in public_api_router.registry + for action in viewset_class.get_extra_actions() + if action.detail and action.url_path not in getattr(viewset_class, "extra_actions_ignore_no_get_object", []) + ], +) +@pytest.mark.django_db +def test_public_api_detail_actions_get_object(make_organization_and_user_with_token, basename, viewset_class, action): + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + url = reverse(f"api-public:{basename}-{action.url_name}", kwargs={"pk": "NONEXISTENT"}) + + with patch.object(viewset_class, "get_object", side_effect=NotFound) as mock_get_object: + method = list(action.mapping.keys())[0] # get the first allowed method + response = client.generic(path=url, method=method, HTTP_AUTHORIZATION=token) + + """ + If you see this errors in tests, make sure to call self.get_object() in action method that's added / changed. + Call to self.get_object() must come before any additional checks. For example, call to self.get_object() must come + before checking for request data that may result in 400 Bad Request (i.e. check for 404 must come before check for 400). + This is required to ensure all detail actions are safe, consistent with each other and easily testable. + In rare cases when self.get_object() is not needed (e.g. because object is identified by authentication class), + pass "extra_actions_ignore_no_get_object" to viewset class. Actions listed in extra_actions_ignore_no_get_object + will be ignored by this test. + """ + assert response.status_code == status.HTTP_404_NOT_FOUND, "check for 404 must come before any additional checks" + assert ( + mock_get_object.call_count == 1 + ), f"self.get_object() must be called in {viewset_class.__class__.__name__}.{action.__name__}" diff --git a/engine/common/utils.py b/engine/common/utils.py index 4dc313e5..07517463 100644 --- a/engine/common/utils.py +++ b/engine/common/utils.py @@ -150,7 +150,7 @@ def str_or_backup(string, backup): def clean_html(text): - text = "".join(BeautifulSoup(text, features="html.parser").find_all(text=True)) + text = "".join(BeautifulSoup(text, features="html.parser").find_all(string=True)) return text @@ -202,7 +202,7 @@ def urlize_with_respect_to_a(html): Wrap links into tag if not already """ soup = BeautifulSoup(html, features="html.parser") - textNodes = soup.find_all(text=True) + textNodes = soup.find_all(string=True) for textNode in textNodes: if textNode.parent and getattr(textNode.parent, "name") == "a": continue diff --git a/engine/requirements.txt b/engine/requirements.txt index 75a1eb8c..71a18dee 100644 --- a/engine/requirements.txt +++ b/engine/requirements.txt @@ -10,7 +10,7 @@ redis==3.4.1 humanize==0.5.1 uwsgi==2.0.21 django-cors-headers==3.7.0 -django-debug-toolbar==3.2.1 +django-debug-toolbar==4.1 django-sns-view==0.1.2 python-telegram-bot==13.13 django-silk==5.0.3 @@ -20,11 +20,11 @@ django-ratelimit==2.0.0 django-filter==2.4.0 icalendar==4.0.7 recurring-ical-events==0.1.16b0 -slack-export-viewer==1.0.0 +slack-export-viewer==1.1.4 beautifulsoup4==4.12.2 -social-auth-app-django==3.1.0 +social-auth-app-django==5.0.0 cryptography==38.0.4 # version 39.0.0 introduced an issue - https://stackoverflow.com/a/75053968/3902555 -pytest==7.1.3 +pytest==7.3.1 pytest-django==4.5.2 pytest_factoryboy==2.5.1 factory-boy<3.0 @@ -38,7 +38,7 @@ django-mirage-field==1.3.0 django-mysql==4.6.0 PyMySQL==1.0.2 psycopg2==2.9.3 -emoji==1.7.0 +emoji==2.4.0 regex==2021.11.2 psutil==5.9.4 django-migration-linter==4.1.0 @@ -56,3 +56,4 @@ pymdown-extensions==10.0 requests==2.31.0 urllib3==1.26.15 prometheus_client==0.16.0 +lxml==4.9.2 diff --git a/grafana-plugin/src/components/HamburgerMenu/HamburgerMenu.module.css b/grafana-plugin/src/components/HamburgerMenu/HamburgerMenu.module.css new file mode 100644 index 00000000..0a753b7d --- /dev/null +++ b/grafana-plugin/src/components/HamburgerMenu/HamburgerMenu.module.css @@ -0,0 +1,19 @@ +.hamburger-menu { + cursor: pointer; + color: var(--primary-text-color); +} + +.hamburger-menu-withBackground { + display: inline-flex; + flex-direction: column; + align-items: center; + vertical-align: middle; + justify-content: center; + background-color: rgba(204, 204, 220, 0.16); + border: 1px solid transparent; + height: 32px; + width: 30px; + padding: 4px; + cursor: pointer; + color: var(--primary-text-color); +} diff --git a/grafana-plugin/src/components/HamburgerMenu/HamburgerMenu.tsx b/grafana-plugin/src/components/HamburgerMenu/HamburgerMenu.tsx new file mode 100644 index 00000000..7b7c1b48 --- /dev/null +++ b/grafana-plugin/src/components/HamburgerMenu/HamburgerMenu.tsx @@ -0,0 +1,39 @@ +import React, { useRef } from 'react'; + +import { Icon } from '@grafana/ui'; +import cn from 'classnames/bind'; + +import styles from './HamburgerMenu.module.css'; + +interface HamburgerMenuProps { + openMenu: React.MouseEventHandler; + listWidth: number; + listBorder: number; + withBackground?: boolean; + className?: string; +} + +const cx = cn.bind(styles); + +const HamburgerMenu: React.FC = (props) => { + const ref = useRef(); + const { openMenu, listBorder, listWidth, withBackground, className } = props; + return ( +
{ + const boundingRect = ref.current.getBoundingClientRect(); + + openMenu({ + pageX: boundingRect.right - listWidth + listBorder * 2, + pageY: boundingRect.top + boundingRect.height, + } as any); + }} + > + +
+ ); +}; + +export default HamburgerMenu; diff --git a/grafana-plugin/src/components/Integrations/IntegrationBlock.module.scss b/grafana-plugin/src/components/Integrations/IntegrationBlock.module.scss index fb569479..dc6e9221 100644 --- a/grafana-plugin/src/components/Integrations/IntegrationBlock.module.scss +++ b/grafana-plugin/src/components/Integrations/IntegrationBlock.module.scss @@ -16,5 +16,6 @@ &--collapsedBorder { border-left: none; padding-left: 0; + padding-right: 0; } } diff --git a/grafana-plugin/src/components/Integrations/IntegrationBlockItem.module.scss b/grafana-plugin/src/components/Integrations/IntegrationBlockItem.module.scss index df38ca40..04f5c8c7 100644 --- a/grafana-plugin/src/components/Integrations/IntegrationBlockItem.module.scss +++ b/grafana-plugin/src/components/Integrations/IntegrationBlockItem.module.scss @@ -2,6 +2,7 @@ display: flex; flex-direction: row; margin-bottom: 4px; + max-width: 100%; &__content { width: 100%; diff --git a/grafana-plugin/src/containers/IntegrationContainers/CollapsedIntegrationRouteDisplay/CollapsedIntegrationRouteDisplay.tsx b/grafana-plugin/src/containers/IntegrationContainers/CollapsedIntegrationRouteDisplay/CollapsedIntegrationRouteDisplay.tsx index 8a3504ac..d4f1db8b 100644 --- a/grafana-plugin/src/containers/IntegrationContainers/CollapsedIntegrationRouteDisplay/CollapsedIntegrationRouteDisplay.tsx +++ b/grafana-plugin/src/containers/IntegrationContainers/CollapsedIntegrationRouteDisplay/CollapsedIntegrationRouteDisplay.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { ConfirmModal, HorizontalGroup, Icon, VerticalGroup } from '@grafana/ui'; import cn from 'classnames/bind'; @@ -26,8 +26,17 @@ interface CollapsedIntegrationRouteDisplayProps { const CollapsedIntegrationRouteDisplay: React.FC = observer( ({ channelFilterId, alertReceiveChannelId, routeIndex, toggle }) => { - const { escalationChainStore, alertReceiveChannelStore } = useStore(); + const store = useStore(); + const { escalationChainStore, alertReceiveChannelStore, telegramChannelStore } = store; const [routeIdForDeletion, setRouteIdForDeletion] = useState(undefined); + const [telegramInfo, setTelegramInfo] = useState>([]); + + useEffect(() => { + (async function () { + const telegram = await telegramChannelStore.getAll(); + setTelegramInfo(telegram); + })(); + }, [channelFilterId]); const channelFilter = alertReceiveChannelStore.channelFilters[channelFilterId]; if (!channelFilter) { @@ -84,15 +93,17 @@ const CollapsedIntegrationRouteDisplay: React.FC - {IntegrationHelper.getChatOpsChannels(channelFilter).map((chatOpsChannel, key) => ( - - Publish to ChatOps - - - {chatOpsChannel.name} - - - ))} + {IntegrationHelper.getChatOpsChannels(channelFilter, telegramInfo, store) + .filter((it) => it) + .map((chatOpsChannel, key) => ( + + Publish to ChatOps + + + {chatOpsChannel.name} + + + ))} diff --git a/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.module.scss b/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.module.scss index c6ac32bf..72c5715b 100644 --- a/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.module.scss +++ b/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.module.scss @@ -6,3 +6,45 @@ width: 700px; } } + +.integrations-actionsList { + display: flex; + flex-direction: column; + width: 200px; + border-radius: 2px; +} + +.integrations-actionItem { + padding: 8px; + display: flex; + align-items: center; + flex-direction: row; + flex-shrink: 0; + white-space: nowrap; + border-left: 2px solid transparent; + cursor: pointer; + min-width: 84px; + display: flex; + gap: 8px; + flex-direction: row; + + &:hover { + background: var(--gray-9); + } +} + +.hamburgerMenu-small { + display: inline-flex; + flex-direction: column; + align-items: center; + vertical-align: middle; + justify-content: center; + background-color: rgba(204, 204, 220, 0.16); + color: var(--secondary-background); + border: 1px solid transparent; + height: 24px; + width: 22px; + padding: 4px; + cursor: pointer; + color: var(--primary-text-color); +} diff --git a/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.tsx b/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.tsx index cf24abe8..d57242dc 100644 --- a/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.tsx +++ b/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.tsx @@ -14,13 +14,16 @@ import { } from '@grafana/ui'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; +import CopyToClipboard from 'react-copy-to-clipboard'; +import HamburgerMenu from 'components/HamburgerMenu/HamburgerMenu'; import IntegrationBlock from 'components/Integrations/IntegrationBlock'; import IntegrationBlockItem from 'components/Integrations/IntegrationBlockItem'; import MonacoEditor from 'components/MonacoEditor/MonacoEditor'; import PluginLink from 'components/PluginLink/PluginLink'; import Text from 'components/Text/Text'; import TooltipBadge from 'components/TooltipBadge/TooltipBadge'; +import { WithContextMenu } from 'components/WithContextMenu/WithContextMenu'; import { ChatOpsConnectors } from 'containers/AlertRules/parts'; import EscalationChainSteps from 'containers/EscalationChainSteps/EscalationChainSteps'; import styles from 'containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.module.scss'; @@ -33,6 +36,7 @@ import { EscalationChain } from 'models/escalation_chain/escalation_chain.types' import { MONACO_INPUT_HEIGHT_SMALL, MONACO_OPTIONS } from 'pages/integration_2/Integration2.config'; import IntegrationHelper from 'pages/integration_2/Integration2.helper'; import { useStore } from 'state/useStore'; +import { openNotification } from 'utils'; import { UserActions } from 'utils/authorization'; const cx = cn.bind(styles); @@ -164,17 +168,6 @@ const ExpandedIntegrationRouteDisplay: React.FC )} - {routeIndex !== channelFiltersTotal.length - 1 && ( - - - - If the Routing template evaluates to True, the alert will be grouped with the Grouping template - and proceed to the following steps - - - - )} - Publish to ChatOps @@ -341,11 +334,38 @@ export const RouteButtonsDisplay: React.FC = ({ )} {!channelFilter.is_default && ( - - -