Merge pull request #2121 from grafana/dev

main to dev v1.2.40
This commit is contained in:
Vadim Stepanov 2023-06-07 14:28:37 +01:00 committed by GitHub
commit 7f1e10f77f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
88 changed files with 1338 additions and 501 deletions

View file

@ -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!

View file

@ -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)

View file

@ -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)

View file

@ -12,7 +12,6 @@
Developer-friendly incident response with brilliant Slack integration.
<!-- markdownlint-disable MD013 MD033 -->
<table>
<tbody>
<tr>
@ -21,7 +20,6 @@ Developer-friendly incident response with brilliant Slack integration.
</tr>
</tbody>
</table>
<!-- markdownlint-enable MD013 MD033 -->
- Collect and analyze alerts from multiple monitoring systems

View file

@ -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

View file

@ -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",

View file

@ -63,4 +63,4 @@ class AlertGroupTelegramRenderer(AlertGroupBaseRenderer):
if image_url is not None:
text = f"<a href='{image_url}'>&#8205;</a>" + text
return emojize(text, use_aliases=True)
return emojize(text, language="alias")

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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}],

18
engine/apps/api/errors.py Normal file
View file

@ -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

View file

@ -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")

View file

@ -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

View file

@ -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(

View file

@ -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,

View file

@ -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,

View file

@ -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")
)

View file

@ -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,
},
],
}

View file

@ -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",

View file

@ -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"

View file

@ -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<id>\w+)/?$", AlertDetailView.as_view(), name="alerts-detail"),
optional_slash_path("direct_paging", DirectPagingAPIView.as_view(), name="direct_paging"),
]

View file

@ -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)

View file

@ -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)

View file

@ -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")

View file

@ -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)

View file

@ -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):

View file

@ -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

View file

@ -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],

View file

@ -29,4 +29,4 @@ class SlackChannelView(PublicPrimaryKeyMixin, mixins.ListModelMixin, mixins.Retr
is_archived=False,
)
return queryset
return queryset.order_by("id")

View file

@ -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)

View file

@ -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(

View file

@ -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):

View file

@ -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)

View file

@ -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"

View file

@ -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)}"

View file

@ -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,
},

View file

@ -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")

View file

@ -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",

View file

@ -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

View file

@ -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",

View file

@ -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,

View file

@ -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()

View file

@ -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")

View file

@ -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"]

View file

@ -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:

View file

@ -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)

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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)

View file

@ -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 = {

View file

@ -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,

View file

@ -30,14 +30,14 @@ class SlackFormatter(SlackFormatter):
message = message.replace("<!here|@here>", "@here")
message = message.replace("<!everyone>", "@everyone")
message = message.replace("<!everyone|@everyone>", "@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

View file

@ -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()

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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:

View file

@ -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

View file

@ -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):

View file

@ -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"]

View file

@ -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):

View file

@ -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 = '<a href="https://amixr.io/">https://amixr.io/</a>'

View file

@ -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__}"

View file

@ -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 <a> 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

View file

@ -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

View file

@ -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);
}

View file

@ -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<HTMLElement>;
listWidth: number;
listBorder: number;
withBackground?: boolean;
className?: string;
}
const cx = cn.bind(styles);
const HamburgerMenu: React.FC<HamburgerMenuProps> = (props) => {
const ref = useRef<HTMLDivElement>();
const { openMenu, listBorder, listWidth, withBackground, className } = props;
return (
<div
ref={ref}
className={withBackground ? cx('hamburger-menu-withBackground') : cx('hamburger-menu', className)}
onClick={() => {
const boundingRect = ref.current.getBoundingClientRect();
openMenu({
pageX: boundingRect.right - listWidth + listBorder * 2,
pageY: boundingRect.top + boundingRect.height,
} as any);
}}
>
<Icon size="sm" name="ellipsis-v" />
</div>
);
};
export default HamburgerMenu;

View file

@ -16,5 +16,6 @@
&--collapsedBorder {
border-left: none;
padding-left: 0;
padding-right: 0;
}
}

View file

@ -2,6 +2,7 @@
display: flex;
flex-direction: row;
margin-bottom: 4px;
max-width: 100%;
&__content {
width: 100%;

View file

@ -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<CollapsedIntegrationRouteDisplayProps> = observer(
({ channelFilterId, alertReceiveChannelId, routeIndex, toggle }) => {
const { escalationChainStore, alertReceiveChannelStore } = useStore();
const store = useStore();
const { escalationChainStore, alertReceiveChannelStore, telegramChannelStore } = store;
const [routeIdForDeletion, setRouteIdForDeletion] = useState<ChannelFilter['id']>(undefined);
const [telegramInfo, setTelegramInfo] = useState<Array<{ id: string; channel_name: string }>>([]);
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<CollapsedIntegrationRouteDispla
content={
<div className={cx('spacing')}>
<VerticalGroup>
{IntegrationHelper.getChatOpsChannels(channelFilter).map((chatOpsChannel, key) => (
<HorizontalGroup key={key}>
<Text type="secondary">Publish to ChatOps</Text>
<Icon name={chatOpsChannel.icon} />
<Text type="primary" strong>
{chatOpsChannel.name}
</Text>
</HorizontalGroup>
))}
{IntegrationHelper.getChatOpsChannels(channelFilter, telegramInfo, store)
.filter((it) => it)
.map((chatOpsChannel, key) => (
<HorizontalGroup key={key}>
<Text type="secondary">Publish to ChatOps</Text>
<Icon name={chatOpsChannel.icon} />
<Text type="primary" strong>
{chatOpsChannel.name}
</Text>
</HorizontalGroup>
))}
<HorizontalGroup>
<Icon name="list-ui-alt" />

View file

@ -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);
}

View file

@ -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<ExpandedIntegrationRouteDisplayP
</IntegrationBlockItem>
)}
{routeIndex !== channelFiltersTotal.length - 1 && (
<IntegrationBlockItem>
<VerticalGroup>
<Text type="secondary">
If the Routing template evaluates to True, the alert will be grouped with the Grouping template
and proceed to the following steps
</Text>
</VerticalGroup>
</IntegrationBlockItem>
)}
<IntegrationBlockItem>
<VerticalGroup spacing="md">
<Text type="primary">Publish to ChatOps</Text>
@ -341,11 +334,38 @@ export const RouteButtonsDisplay: React.FC<RouteButtonsDisplayProps> = ({
)}
{!channelFilter.is_default && (
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<Tooltip placement="top" content={'Delete'}>
<Button variant={'secondary'} icon={'trash-alt'} size={'sm'} onClick={onDelete} />
</Tooltip>
</WithPermissionControlTooltip>
<WithContextMenu
renderMenuItems={() => (
<div className={cx('integrations-actionsList')}>
<CopyToClipboard text={channelFilter.id} onCopy={() => openNotification('Route ID is copied')}>
<div className={cx('integrations-actionItem')}>
<HorizontalGroup spacing={'xs'}>
<Icon name="copy" />
<Text type="primary">UID: {channelFilter.id}</Text>
</HorizontalGroup>
</div>
</CopyToClipboard>
<div className="thin-line-break" />
<WithPermissionControlTooltip key="delete" userAction={UserActions.IntegrationsWrite}>
<div className={cx('integrations-actionItem')} onClick={onDelete}>
<Text type="danger">
<HorizontalGroup spacing={'xs'}>
<Icon name="trash-alt" />
<span>Delete Route</span>
</HorizontalGroup>
</Text>
</div>
</WithPermissionControlTooltip>
</div>
)}
>
{({ openMenu }) => (
<HamburgerMenu openMenu={openMenu} listBorder={2} listWidth={200} className={cx('hamburgerMenu-small')} />
)}
</WithContextMenu>
)}
</HorizontalGroup>
);

View file

@ -161,7 +161,7 @@ export const form: { name: string; fields: FormItem[] } = {
},
{
name: 'authorization_header',
type: FormItemType.Input,
type: FormItemType.Password,
},
{
name: 'trigger_template',

View file

@ -18,7 +18,7 @@ interface TeamNameProps {
}
const TeamName = observer((props: TeamNameProps) => {
const { team, size } = props;
const { team, size = 'medium' } = props;
if (!team) {
return null;
}
@ -26,10 +26,10 @@ const TeamName = observer((props: TeamNameProps) => {
return <Badge text={team.name} color={'blue'} tooltip={'Resource is not assigned to any team (ex General team)'} />;
}
return (
<Text type="secondary" size={size ? size : 'medium'}>
<Text type="secondary" size={size}>
<Avatar size="small" src={team.avatar_url} className={cx('avatar')} />
<Tooltip placement="top" content={'Resource is assigned to ' + team.name}>
<span>{team.name}</span>
<Text type="primary">{team.name}</Text>
</Tooltip>
</Text>
);

View file

@ -173,7 +173,7 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => {
!isPhoneProviderConfigured;
const isPhoneDisabled = !!user.verified_phone_number;
const isCodeFieldDisabled = !isCodeSent || !isUserActionAllowed(action);
const isCodeFieldDisabled = (!isCodeSent && !isPhoneCallInitiated) || !isUserActionAllowed(action);
const showToggle = user.verified_phone_number && isCurrentUser;
if (showForgetScreen) {

View file

@ -29,7 +29,7 @@ export const pages: { [id: string]: PageDefinition } = [
icon: 'bell',
id: 'alert-groups',
hideFromBreadcrumbs: true,
text: 'Alert Groups',
text: 'Alert groups',
hideTitle: true,
path: getPath('alert-groups'),
action: UserActions.AlertGroupsRead,
@ -63,7 +63,7 @@ export const pages: { [id: string]: PageDefinition } = [
{
icon: 'list-ul',
id: 'escalations',
text: 'Escalation Chains',
text: 'Escalation chains',
hideFromBreadcrumbs: true,
path: getPath('escalations'),
action: UserActions.EscalationChainsRead,
@ -92,7 +92,7 @@ export const pages: { [id: string]: PageDefinition } = [
{
icon: 'link',
id: 'outgoing_webhooks',
text: 'Outgoing Webhooks',
text: 'Outgoing webhooks',
path: getPath('outgoing_webhooks'),
hideFromBreadcrumbs: true,
action: UserActions.OutgoingWebhooksRead,
@ -109,7 +109,7 @@ export const pages: { [id: string]: PageDefinition } = [
{
icon: 'link',
id: 'outgoing_webhooks_2',
text: 'Outgoing Webhooks 2',
text: 'Outgoing webhooks 2',
path: getPath('outgoing_webhooks_2'),
hideFromBreadcrumbs: true,
hideFromTabs: true,

View file

@ -8,6 +8,8 @@ import dayjs from 'dayjs';
import { MaintenanceMode } from 'models/alert_receive_channel/alert_receive_channel.types';
import { ChannelFilter } from 'models/channel_filter/channel_filter.types';
import { RootStore } from 'state';
import { AppFeature } from 'state/features';
import { MAX_CHARACTERS_COUNT, TEXTAREA_ROWS_COUNT } from './Integration2.config';
@ -70,14 +72,31 @@ const IntegrationHelper = {
return totalDiffString;
},
getChatOpsChannels(channelFilter: ChannelFilter): Array<{ name: string; icon: IconName }> {
getChatOpsChannels(
channelFilter: ChannelFilter,
telegramInfo: Array<{ id: string; channel_name: string }>,
store: RootStore
): Array<{ name: string; icon: IconName }> {
const channels: Array<{ name: string; icon: IconName }> = [];
if (channelFilter.notify_in_slack && channelFilter.slack_channel?.display_name) {
if (
store.hasFeature(AppFeature.Slack) &&
channelFilter.notify_in_slack &&
channelFilter.notify_in_slack &&
channelFilter.slack_channel?.display_name
) {
channels.push({ name: channelFilter.slack_channel.display_name, icon: 'slack' });
}
if (channelFilter.telegram_channel) {
channels.push({ name: channelFilter.telegram_channel, icon: 'telegram-alt' });
const matchingTelegram = telegramInfo?.find((t) => t.id === channelFilter.telegram_channel);
if (
store.hasFeature(AppFeature.Telegram) &&
channelFilter.telegram_channel &&
channelFilter.notify_in_telegram &&
matchingTelegram?.channel_name
) {
channels.push({ name: matchingTelegram.channel_name, icon: 'telegram-alt' });
}
return channels;

View file

@ -43,7 +43,7 @@ $LARGE-MARGIN: 24px;
&__actionsList {
display: flex;
flex-direction: column;
width: 160px;
width: 200px;
border-radius: 2px;
}
@ -85,22 +85,6 @@ $LARGE-MARGIN: 24px;
}
}
.hamburger-menu {
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: 32px;
width: 30px;
padding: 4px;
cursor: pointer;
color: var(--primary-text-color);
}
.loadingPlaceholder {
margin-bottom: 0;
margin-right: 4px;
@ -132,6 +116,7 @@ $LARGE-MARGIN: 24px;
.input {
flex-grow: 1;
max-width: calc(100% - 80px);
}
.how-to-connect__container {
@ -216,6 +201,7 @@ $LARGE-MARGIN: 24px;
&__item {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
}
}

View file

@ -1,4 +1,4 @@
import React, { useRef, useState } from 'react';
import React, { useState } from 'react';
import {
Button,
@ -23,6 +23,7 @@ import { RouteComponentProps, useHistory, withRouter } from 'react-router-dom';
import { debounce } from 'throttle-debounce';
import { TemplateForEdit, templateForEdit } from 'components/AlertTemplates/AlertTemplatesForm.config';
import HamburgerMenu from 'components/HamburgerMenu/HamburgerMenu';
import IntegrationCollapsibleTreeView, {
IntegrationCollapsibleItem,
} from 'components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView';
@ -83,7 +84,7 @@ interface Integration2State extends PageBaseState {
isAddingRoute: boolean;
}
const ACTIONS_LIST_WIDTH = 160;
const ACTIONS_LIST_WIDTH = 200;
const ACTIONS_LIST_BORDER = 2;
const NEW_ROUTE_DEFAULT = '{{ (payload.severity == "foo" and "bar" in payload.region) or True }}';
@ -135,6 +136,7 @@ class Integration2 extends React.Component<Integration2Props, Integration2State>
} = this.state;
const {
store: { alertReceiveChannelStore },
query: { p },
match: {
params: { id },
},
@ -164,7 +166,7 @@ class Integration2 extends React.Component<Integration2Props, Integration2State>
<div className={cx('root')}>
{isTemplateSettingsOpen && (
<Drawer
width="640px"
width="75%"
scrollableContent
title="Template Settings"
onClose={() => this.setState({ isTemplateSettingsOpen: false })}
@ -186,7 +188,7 @@ class Integration2 extends React.Component<Integration2Props, Integration2State>
)}
<div className={cx('integration__heading-container')}>
<PluginLink query={{ page: 'integrations_2' }}>
<PluginLink query={{ page: 'integrations_2', p }}>
<IconButton name="arrow-left" size="xxl" />
</PluginLink>
<h1 className={cx('integration__name')}>
@ -586,27 +588,6 @@ const DemoNotification: React.FC = () => {
);
};
const HamburgerMenu: React.FC<{ openMenu: React.MouseEventHandler<HTMLElement> }> = ({ openMenu }) => {
const ref = useRef<HTMLDivElement>();
return (
<div
ref={ref}
className={cx('hamburger-menu')}
onClick={() => {
const boundingRect = ref.current.getBoundingClientRect();
openMenu({
pageX: boundingRect.right - ACTIONS_LIST_WIDTH + ACTIONS_LIST_BORDER * 2,
pageY: boundingRect.top + boundingRect.height,
} as any);
}}
>
<Icon size="sm" name="ellipsis-v" />
</div>
);
};
interface IntegrationSendDemoPayloadModalProps {
isOpen: boolean;
alertReceiveChannel: AlertReceiveChannel;
@ -620,9 +601,9 @@ const IntegrationSendDemoPayloadModal: React.FC<IntegrationSendDemoPayloadModalP
}) => {
const store = useStore();
const { alertReceiveChannelStore } = store;
const [demoPayload, setDemoPayload] = useState<string>(
JSON.stringify(alertReceiveChannel.demo_alert_payload, null, '\t')
);
const stringifiedJson = JSON.stringify(alertReceiveChannel.demo_alert_payload, null, 2);
const initialDemoJSON = stringifiedJson.substring(1, stringifiedJson.length - 1);
const [demoPayload, setDemoPayload] = useState<string>(alertReceiveChannel.demo_alert_payload);
let onPayloadChangeDebounced = debounce(100, onPayloadChange);
return (
@ -631,7 +612,14 @@ const IntegrationSendDemoPayloadModal: React.FC<IntegrationSendDemoPayloadModalP
closeOnEscape
isOpen={isOpen}
onDismiss={onHideOrCancel}
title={`Send demo alert to ${alertReceiveChannel.verbal_name}`}
title={
<HorizontalGroup>
<Text.Title level={4}>
Send demo alert to {''}
<Emoji text={alertReceiveChannel.verbal_name} />
</Text.Title>
</HorizontalGroup>
}
>
<VerticalGroup>
<HorizontalGroup spacing={'xs'}>
@ -650,7 +638,7 @@ const IntegrationSendDemoPayloadModal: React.FC<IntegrationSendDemoPayloadModalP
<div className={cx('integration__payloadInput')}>
<MonacoEditor
value={JSON.stringify(alertReceiveChannel.demo_alert_payload, null, '\t')}
value={initialDemoJSON}
disabled={true}
height={`200px`}
useAutoCompleteList={false}
@ -839,6 +827,19 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({ alertReceiveCha
</WithPermissionControlTooltip>
)}
<CopyToClipboard
text={alertReceiveChannel.id}
onCopy={() => openNotification('Integration ID is copied')}
>
<div className={cx('integration__actionItem')}>
<HorizontalGroup spacing={'xs'}>
<Icon name="copy" />
<Text type="primary">UID: {alertReceiveChannel.id}</Text>
</HorizontalGroup>
</div>
</CopyToClipboard>
<div className="thin-line-break" />
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
@ -859,6 +860,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({ alertReceiveCha
confirmText: 'Delete',
});
}}
style={{ width: '100%' }}
>
<Text type="danger">
<HorizontalGroup spacing={'xs'}>
@ -872,7 +874,14 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({ alertReceiveCha
</div>
)}
>
{({ openMenu }) => <HamburgerMenu openMenu={openMenu} />}
{({ openMenu }) => (
<HamburgerMenu
openMenu={openMenu}
listBorder={ACTIONS_LIST_BORDER}
listWidth={ACTIONS_LIST_WIDTH}
withBackground
/>
)}
</WithContextMenu>
</div>
</>
@ -1042,7 +1051,7 @@ const IntegrationHeader: React.FC<IntegrationHeaderProps> = ({
</div>
<div className={cx('headerTop__item')}>
<Text type="secondary">Team:</Text>
<TeamName team={grafanaTeamStore.items[alertReceiveChannel.team]} size="small" />
<TeamName team={grafanaTeamStore.items[alertReceiveChannel.team]} />
</div>
<div className={cx('headerTop__item')}>
<Text type="secondary">Created by:</Text>

View file

@ -19,3 +19,29 @@
padding: 4px 10px;
width: 40px;
}
.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);
}
}

View file

@ -1,13 +1,15 @@
import React from 'react';
import { HorizontalGroup, Button, IconButton, VerticalGroup } from '@grafana/ui';
import { HorizontalGroup, Button, VerticalGroup, Icon, ConfirmModal } from '@grafana/ui';
import cn from 'classnames/bind';
import { debounce } from 'lodash-es';
import { observer } from 'mobx-react';
import CopyToClipboard from 'react-copy-to-clipboard';
import Emoji from 'react-emoji-render';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import GTable from 'components/GTable/GTable';
import HamburgerMenu from 'components/HamburgerMenu/HamburgerMenu';
import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo';
import { Filters } from 'components/IntegrationsFilters/IntegrationsFilters';
import { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
@ -18,7 +20,7 @@ import {
import PluginLink from 'components/PluginLink/PluginLink';
import Text from 'components/Text/Text';
import TooltipBadge from 'components/TooltipBadge/TooltipBadge';
import WithConfirm from 'components/WithConfirm/WithConfirm';
import { WithContextMenu } from 'components/WithContextMenu/WithContextMenu';
import IntegrationForm2 from 'containers/IntegrationForm/IntegrationForm2';
import RemoteFilters from 'containers/RemoteFilters/RemoteFilters';
import TeamName from 'containers/TeamName/TeamName';
@ -28,6 +30,7 @@ import { AlertReceiveChannel, MaintenanceMode } from 'models/alert_receive_chann
import IntegrationHelper from 'pages/integration_2/Integration2.helper';
import { PageProps, WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import { openNotification } from 'utils';
import LocationHelper from 'utils/LocationHelper';
import { UserActions } from 'utils/authorization';
@ -37,11 +40,23 @@ const cx = cn.bind(styles);
const FILTERS_DEBOUNCE_MS = 500;
const ITEMS_PER_PAGE = 15;
const MAX_LINE_LENGTH = 40;
const ACTIONS_LIST_WIDTH = 200;
const ACTIONS_LIST_BORDER = 2;
interface IntegrationsState extends PageBaseState {
integrationsFilters: Filters;
alertReceiveChannelId?: AlertReceiveChannel['id'] | 'new';
page: number;
confirmationModal: {
isOpen: boolean;
title: any;
dismissText: string;
confirmText: string;
body?: React.ReactNode;
description?: string;
confirmationText?: string;
onConfirm: () => void;
};
}
interface IntegrationsProps extends WithStoreProps, PageProps, RouteComponentProps<{ id: string }> {}
@ -52,6 +67,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
integrationsFilters: { searchTerm: '' },
errorData: initErrorDataState(),
page: 1,
confirmationModal: undefined,
};
async componentDidMount() {
@ -110,7 +126,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
render() {
const { store, query } = this.props;
const { alertReceiveChannelId, page } = this.state;
const { alertReceiveChannelId, page, confirmationModal } = this.state;
const { grafanaTeamStore, alertReceiveChannelStore, heartbeatStore } = store;
const { count, results } = alertReceiveChannelStore.getPaginatedSearchResult();
@ -215,6 +231,24 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
id={alertReceiveChannelId}
/>
)}
{confirmationModal && (
<ConfirmModal
isOpen={confirmationModal.isOpen}
title={confirmationModal.title}
confirmText={confirmationModal.confirmText}
dismissText="Cancel"
body={confirmationModal.body}
description={confirmationModal.description}
confirmationText={confirmationModal.confirmationText}
onConfirm={confirmationModal.onConfirm}
onDismiss={() =>
this.setState({
confirmationModal: undefined,
})
}
/>
)}
</>
);
}
@ -231,9 +265,13 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
);
}
renderName(item: AlertReceiveChannel) {
renderName = (item: AlertReceiveChannel) => {
const {
query: { p },
} = this.props;
return (
<PluginLink query={{ page: 'integrations_2', id: item.id }}>
<PluginLink query={{ page: 'integrations_2', id: item.id, p }}>
<Text type="link" size="medium">
<Emoji
className={cx('title')}
@ -246,7 +284,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
</Text>
</PluginLink>
);
}
};
renderDatasource(item: AlertReceiveChannel, alertReceiveChannelStore) {
const alertReceiveChannel = alertReceiveChannelStore.items[item.id];
@ -354,29 +392,66 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
renderButtons = (item: AlertReceiveChannel) => {
return (
<HorizontalGroup justify="flex-end">
<WithPermissionControlTooltip key="edit" userAction={UserActions.IntegrationsWrite}>
<IconButton tooltip="Settings" name="cog" onClick={() => this.onIntegrationEditClick(item.id)} />
</WithPermissionControlTooltip>
<WithPermissionControlTooltip key="edit" userAction={UserActions.IntegrationsWrite}>
<WithConfirm
description={
<Text>
<Emoji
className={cx('title')}
text={`Are you sure you want to delete ${item.verbal_name} integration?`}
/>
</Text>
}
>
<IconButton
tooltip="Delete"
name="trash-alt"
onClick={() => this.handleDeleteAlertReceiveChannel(item.id)}
/>
</WithConfirm>
</WithPermissionControlTooltip>
</HorizontalGroup>
<WithContextMenu
renderMenuItems={() => (
<div className={cx('integrations-actionsList')}>
<WithPermissionControlTooltip key="edit" userAction={UserActions.IntegrationsWrite}>
<div className={cx('integrations-actionItem')} onClick={() => this.onIntegrationEditClick(item.id)}>
<Text type="primary">Integration settings</Text>
</div>
</WithPermissionControlTooltip>
<CopyToClipboard text={item.id} onCopy={() => openNotification('Integration ID is copied')}>
<div className={cx('integrations-actionItem')}>
<HorizontalGroup spacing={'xs'}>
<Icon name="copy" />
<Text type="primary">UID: {item.id}</Text>
</HorizontalGroup>
</div>
</CopyToClipboard>
<div className="thin-line-break" />
<WithPermissionControlTooltip key="delete" userAction={UserActions.IntegrationsWrite}>
<>
<div className={cx('integrations-actionItem')}>
<div
onClick={() => {
this.setState({
confirmationModal: {
isOpen: true,
confirmText: 'Delete',
dismissText: 'Cancel',
onConfirm: () => this.handleDeleteAlertReceiveChannel(item.id),
title: 'Delete integration',
body: (
<Text type="primary">
Are you sure you want to delete <Emoji text={item.verbal_name} /> integration?
</Text>
),
},
});
}}
style={{ width: '100%' }}
>
<Text type="danger">
<HorizontalGroup spacing={'xs'}>
<Icon name="trash-alt" />
<span>Delete Integration</span>
</HorizontalGroup>
</Text>
</div>
</div>
</>
</WithPermissionControlTooltip>
</div>
)}
>
{({ openMenu }) => (
<HamburgerMenu openMenu={openMenu} listBorder={ACTIONS_LIST_BORDER} listWidth={ACTIONS_LIST_WIDTH} />
)}
</WithContextMenu>
);
};
@ -390,6 +465,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
const { alertReceiveChannelStore } = store;
alertReceiveChannelStore.deleteAlertReceiveChannel(alertReceiveChannelId).then(this.applyFilters);
this.setState({ confirmationModal: undefined });
};
handleIntegrationsFiltersChange = (integrationsFilters: Filters) => {

View file

@ -40,7 +40,7 @@
},
{
"type": "page",
"name": "Alert Groups",
"name": "Alert groups",
"path": "/a/grafana-oncall-app/alert-groups",
"role": "Viewer",
"action": "grafana-oncall-app.alert-groups:read",
@ -64,7 +64,7 @@
},
{
"type": "page",
"name": "Escalation Chains",
"name": "Escalation chains",
"path": "/a/grafana-oncall-app/escalations",
"role": "Viewer",
"action": "grafana-oncall-app.escalation-chains:read",
@ -80,7 +80,7 @@
},
{
"type": "page",
"name": "Outgoing Webhooks",
"name": "Outgoing webhooks",
"path": "/a/grafana-oncall-app/outgoing_webhooks",
"role": "Viewer",
"action": "grafana-oncall-app.outgoing-webhooks:read",
@ -88,7 +88,7 @@
},
{
"type": "page",
"name": "Outgoing Webhooks 2",
"name": "Outgoing webhooks 2",
"path": "/a/grafana-oncall-app/outgoing_webhooks_2",
"role": "Viewer",
"action": "grafana-oncall-app.outgoing-webhooks:read",