commit
7f1e10f77f
88 changed files with 1338 additions and 501 deletions
22
.github/workflows/triage-stale-pull-requests.yml
vendored
Normal file
22
.github/workflows/triage-stale-pull-requests.yml
vendored
Normal 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!
|
||||
17
CHANGELOG.md
17
CHANGELOG.md
|
|
@ -5,7 +5,22 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## Unreleased
|
||||
## v1.2.40 (2023-06-07)
|
||||
|
||||
### Added
|
||||
|
||||
- Allow mobile app to consume "internal" schedules API endpoints by @joeyorlando ([#2109](https://github.com/grafana/oncall/pull/2109))
|
||||
- Add inbound email address in integration API by @vadimkerr ([#2113](https://github.com/grafana/oncall/pull/2113))
|
||||
|
||||
### Changed
|
||||
|
||||
- Make viewset actions more consistent by @vadimkerr ([#2120](https://github.com/grafana/oncall/pull/2120))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix + revert [#2057](https://github.com/grafana/oncall/pull/2057) which reverted a change which properly handles
|
||||
`Organization.DoesNotExist` exceptions for Slack events by @joeyorlando ([#TBD](https://github.com/grafana/oncall/pull/TBD))
|
||||
- Fix Telegram ratelimit on live setting change by @vadimkerr and @alexintech ([#2100](https://github.com/grafana/oncall/pull/2100))
|
||||
|
||||
## v1.2.39 (2023-06-06)
|
||||
|
||||
|
|
|
|||
4
Makefile
4
Makefile
|
|
@ -122,7 +122,9 @@ install-precommit-hook: install-pre-commit
|
|||
pre-commit install
|
||||
|
||||
test: ## run backend tests
|
||||
$(call run_engine_docker_command,pytest)
|
||||
# always use settings.ci-test django settings file when running the tests
|
||||
# if we use settings.dev it's very possible that some fail just based on the settings alone
|
||||
$(call run_engine_docker_command,pytest --ds=settings.ci-test)
|
||||
|
||||
start-celery-beat: ## start celery beat
|
||||
$(call run_engine_docker_command,celery -A engine beat -l info)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -63,4 +63,4 @@ class AlertGroupTelegramRenderer(AlertGroupBaseRenderer):
|
|||
if image_url is not None:
|
||||
text = f"<a href='{image_url}'>‍</a>" + text
|
||||
|
||||
return emojize(text, use_aliases=True)
|
||||
return emojize(text, language="alias")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
18
engine/apps/api/errors.py
Normal 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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -29,4 +29,4 @@ class SlackChannelView(PublicPrimaryKeyMixin, mixins.ListModelMixin, mixins.Retr
|
|||
is_archived=False,
|
||||
)
|
||||
|
||||
return queryset
|
||||
return queryset.order_by("id")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
134
engine/apps/slack/tests/test_interactive_api_endpoint.py
Normal file
134
engine/apps/slack/tests/test_interactive_api_endpoint.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
17
engine/common/api_helpers/serializers.py
Normal file
17
engine/common/api_helpers/serializers.py
Normal 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"]
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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>'
|
||||
|
|
|
|||
81
engine/common/tests/test_viewset_actions.py
Normal file
81
engine/common/tests/test_viewset_actions.py
Normal 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__}"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -16,5 +16,6 @@
|
|||
&--collapsedBorder {
|
||||
border-left: none;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-bottom: 4px;
|
||||
max-width: 100%;
|
||||
|
||||
&__content {
|
||||
width: 100%;
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@ export const form: { name: string; fields: FormItem[] } = {
|
|||
},
|
||||
{
|
||||
name: 'authorization_header',
|
||||
type: FormItemType.Input,
|
||||
type: FormItemType.Password,
|
||||
},
|
||||
{
|
||||
name: 'trigger_template',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue