Merge branch 'dev'
This commit is contained in:
commit
bbb9384ea1
22 changed files with 558 additions and 129 deletions
|
|
@ -1,22 +1,32 @@
|
|||
from django.apps import apps
|
||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django_filters import rest_framework as filters
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import NotFound
|
||||
from rest_framework.filters import SearchFilter
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from apps.api.permissions import RBACPermission
|
||||
from apps.api.serializers.webhook import WebhookSerializer
|
||||
from apps.auth_token.auth import PluginAuthentication
|
||||
from apps.webhooks.models import Webhook
|
||||
from common.api_helpers.filters import ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, TeamModelMultipleChoiceFilter
|
||||
from common.api_helpers.mixins import PublicPrimaryKeyMixin, TeamFilteringMixin
|
||||
|
||||
|
||||
class WebhooksFilter(ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, filters.FilterSet):
|
||||
team = TeamModelMultipleChoiceFilter()
|
||||
|
||||
|
||||
class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet):
|
||||
authentication_classes = (PluginAuthentication,)
|
||||
permission_classes = (IsAuthenticated, RBACPermission)
|
||||
|
||||
rbac_permissions = {
|
||||
"metadata": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ],
|
||||
"filters": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ],
|
||||
"list": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ],
|
||||
"retrieve": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ],
|
||||
"create": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE],
|
||||
|
|
@ -28,6 +38,10 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet):
|
|||
model = Webhook
|
||||
serializer_class = WebhookSerializer
|
||||
|
||||
filter_backends = [SearchFilter, filters.DjangoFilterBackend]
|
||||
search_fields = ["public_primary_key", "name"]
|
||||
filterset_class = WebhooksFilter
|
||||
|
||||
def get_queryset(self, ignore_filtering_by_available_teams=False):
|
||||
queryset = Webhook.objects.filter(
|
||||
organization=self.request.auth.organization,
|
||||
|
|
@ -48,9 +62,8 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet):
|
|||
# use this method to get the object from the whole organization instead of the current team
|
||||
pk = self.kwargs["pk"]
|
||||
organization = self.request.auth.organization
|
||||
|
||||
try:
|
||||
obj = organization.webhooks.get(public_primary_key=pk)
|
||||
obj = organization.webhooks.filter(*self.available_teams_lookup_args).get(public_primary_key=pk)
|
||||
except ObjectDoesNotExist:
|
||||
raise NotFound
|
||||
|
||||
|
|
@ -83,3 +96,22 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet):
|
|||
)[0]
|
||||
if self.request.auth.organization.pk not in enabled_webhooks_2_orgs.json_value["org_ids"]:
|
||||
raise PermissionDenied("Webhooks 2 not enabled for organization. Permission denied.")
|
||||
|
||||
@action(methods=["get"], detail=False)
|
||||
def filters(self, request):
|
||||
filter_name = request.query_params.get("search", None)
|
||||
api_root = "/api/internal/v1/"
|
||||
|
||||
filter_options = [
|
||||
{
|
||||
"name": "team",
|
||||
"type": "team_select",
|
||||
"href": api_root + "teams/",
|
||||
"global": True,
|
||||
},
|
||||
]
|
||||
|
||||
if filter_name is not None:
|
||||
filter_options = list(filter(lambda f: filter_name in f["name"], filter_options))
|
||||
|
||||
return Response(filter_options)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
# Generated by Django 3.2.18 on 2023-03-21 15:53
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('user_management', '0009_organization_cluster_slug'),
|
||||
('mobile_app', '0002_alter_mobileappauthtoken_user'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='MobileAppUserSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('default_notification_sound_name', models.CharField(default='default_sound', max_length=100)),
|
||||
('default_notification_volume_type', models.CharField(choices=[('constant', 'Constant'), ('intensifying', 'Intensifying')], default='constant', max_length=50)),
|
||||
('default_notification_volume', models.FloatField(default=0.8, validators=[django.core.validators.MinValueValidator(0.0), django.core.validators.MaxValueValidator(1.0)])),
|
||||
('default_notification_volume_override', models.BooleanField(default=False)),
|
||||
('important_notification_sound_name', models.CharField(default='default_sound_important', max_length=100)),
|
||||
('important_notification_volume_type', models.CharField(choices=[('constant', 'Constant'), ('intensifying', 'Intensifying')], default='constant', max_length=50)),
|
||||
('important_notification_volume', models.FloatField(default=0.8, validators=[django.core.validators.MinValueValidator(0.0), django.core.validators.MaxValueValidator(1.0)])),
|
||||
('important_notification_override_dnd', models.BooleanField(default=True)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='user_management.user')),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
from typing import Tuple
|
||||
|
||||
from django.conf import settings
|
||||
from django.core import validators
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
|
@ -68,3 +69,41 @@ class MobileAppAuthToken(BaseAuthToken):
|
|||
organization=organization,
|
||||
)
|
||||
return instance, token_string
|
||||
|
||||
|
||||
class MobileAppUserSettings(models.Model):
|
||||
# Sound names are stored without extension, extension is added when sending push notifications
|
||||
IOS_SOUND_NAME_EXTENSION = ".aiff"
|
||||
ANDROID_SOUND_NAME_EXTENSION = ".mp3"
|
||||
|
||||
class VolumeType(models.TextChoices):
|
||||
CONSTANT = "constant"
|
||||
INTENSIFYING = "intensifying"
|
||||
|
||||
user = models.OneToOneField(to=User, null=False, on_delete=models.CASCADE)
|
||||
|
||||
# Push notification settings for default notifications
|
||||
default_notification_sound_name = models.CharField(max_length=100, default="default_sound")
|
||||
default_notification_volume_type = models.CharField(
|
||||
max_length=50, choices=VolumeType.choices, default=VolumeType.CONSTANT
|
||||
)
|
||||
|
||||
# APNS only allows to specify volume for critical notifications,
|
||||
# so "default_notification_volume" and "default_notification_volume_override" are only used on Android
|
||||
default_notification_volume = models.FloatField(
|
||||
validators=[validators.MinValueValidator(0.0), validators.MaxValueValidator(1.0)], default=0.8
|
||||
)
|
||||
default_notification_volume_override = models.BooleanField(default=False)
|
||||
|
||||
# Push notification settings for important notifications
|
||||
important_notification_sound_name = models.CharField(max_length=100, default="default_sound_important")
|
||||
important_notification_volume_type = models.CharField(
|
||||
max_length=50, choices=VolumeType.choices, default=VolumeType.CONSTANT
|
||||
)
|
||||
important_notification_volume = models.FloatField(
|
||||
validators=[validators.MinValueValidator(0.0), validators.MaxValueValidator(1.0)], default=0.8
|
||||
)
|
||||
|
||||
# For the "Mobile push important" step it's possible to make notifications non-critical
|
||||
# if "override DND" setting is disabled in the app
|
||||
important_notification_override_dnd = models.BooleanField(default=True)
|
||||
|
|
|
|||
18
engine/apps/mobile_app/serializers.py
Normal file
18
engine/apps/mobile_app/serializers.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from apps.mobile_app.models import MobileAppUserSettings
|
||||
|
||||
|
||||
class MobileAppUserSettingsSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = MobileAppUserSettings
|
||||
fields = (
|
||||
"default_notification_sound_name",
|
||||
"default_notification_volume_type",
|
||||
"default_notification_volume",
|
||||
"default_notification_volume_override",
|
||||
"important_notification_sound_name",
|
||||
"important_notification_volume_type",
|
||||
"important_notification_volume",
|
||||
"important_notification_override_dnd",
|
||||
)
|
||||
|
|
@ -67,63 +67,8 @@ def notify_user_async(user_pk, alert_group_pk, notification_policy_pk, critical)
|
|||
logger.error(f"Error while sending a mobile push notification: user {user_pk} has no device set up")
|
||||
return
|
||||
|
||||
thread_id = f"{alert_group.channel.organization.public_primary_key}:{alert_group.public_primary_key}"
|
||||
number_of_alerts = alert_group.alerts.count()
|
||||
|
||||
alert_title = "New Critical Alert" if critical else "New Alert"
|
||||
alert_subtitle = get_push_notification_message(alert_group)
|
||||
|
||||
status_verbose = "Firing" # TODO: we should probably de-duplicate this text
|
||||
if alert_group.resolved:
|
||||
status_verbose = alert_group.get_resolve_text()
|
||||
elif alert_group.acknowledged:
|
||||
status_verbose = alert_group.get_acknowledge_text()
|
||||
|
||||
if number_of_alerts <= 10:
|
||||
alerts_count_str = str(number_of_alerts)
|
||||
else:
|
||||
alert_count_rounded = (number_of_alerts // 10) * 10
|
||||
alerts_count_str = f"{alert_count_rounded}+"
|
||||
|
||||
alert_body = f"Status: {status_verbose}, alerts: {alerts_count_str}"
|
||||
|
||||
message = Message(
|
||||
token=device_to_notify.registration_id,
|
||||
data={
|
||||
# from the docs..
|
||||
# A dictionary of data fields (optional). All keys and values in the dictionary must be strings
|
||||
#
|
||||
# alert_group.status is an int so it must be casted...
|
||||
"orgId": alert_group.channel.organization.public_primary_key,
|
||||
"orgName": alert_group.channel.organization.stack_slug,
|
||||
"alertGroupId": alert_group.public_primary_key,
|
||||
"status": str(alert_group.status),
|
||||
"type": "oncall.critical_message" if critical else "oncall.message",
|
||||
"title": alert_title,
|
||||
"subtitle": alert_subtitle,
|
||||
"body": alert_body,
|
||||
"thread_id": thread_id,
|
||||
},
|
||||
apns=APNSConfig(
|
||||
payload=APNSPayload(
|
||||
aps=Aps(
|
||||
thread_id=thread_id,
|
||||
badge=number_of_alerts,
|
||||
alert=ApsAlert(title=alert_title, subtitle=alert_subtitle, body=alert_body),
|
||||
sound=CriticalSound(
|
||||
critical=1 if critical else 0,
|
||||
name="ambulance.aiff" if critical else "bingbong.aiff",
|
||||
volume=1,
|
||||
),
|
||||
custom_data={
|
||||
"interruption-level": "critical" if critical else "time-sensitive",
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
logger.debug(f"Sending push notification with message: {message}; thread-id: {thread_id};")
|
||||
message = _get_fcm_message(alert_group, user, device_to_notify.registration_id, critical)
|
||||
logger.debug(f"Sending push notification with message: {message};")
|
||||
|
||||
if settings.IS_OPEN_SOURCE:
|
||||
# FCM relay uses cloud connection to send push notifications
|
||||
|
|
@ -168,3 +113,94 @@ def send_push_notification_to_fcm_relay(message):
|
|||
response.raise_for_status()
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def _get_fcm_message(alert_group, user, registration_id, critical):
|
||||
# avoid circular import
|
||||
from apps.mobile_app.models import MobileAppUserSettings
|
||||
|
||||
thread_id = f"{alert_group.channel.organization.public_primary_key}:{alert_group.public_primary_key}"
|
||||
number_of_alerts = alert_group.alerts.count()
|
||||
|
||||
alert_title = "New Critical Alert" if critical else "New Alert"
|
||||
alert_subtitle = get_push_notification_message(alert_group)
|
||||
|
||||
status_verbose = "Firing" # TODO: we should probably de-duplicate this text
|
||||
if alert_group.resolved:
|
||||
status_verbose = alert_group.get_resolve_text()
|
||||
elif alert_group.acknowledged:
|
||||
status_verbose = alert_group.get_acknowledge_text()
|
||||
|
||||
if number_of_alerts <= 10:
|
||||
alerts_count_str = str(number_of_alerts)
|
||||
else:
|
||||
alert_count_rounded = (number_of_alerts // 10) * 10
|
||||
alerts_count_str = f"{alert_count_rounded}+"
|
||||
|
||||
alert_body = f"Status: {status_verbose}, alerts: {alerts_count_str}"
|
||||
|
||||
mobile_app_user_settings, _ = MobileAppUserSettings.objects.get_or_create(user=user)
|
||||
|
||||
# APNS only allows to specify volume for critical notifications
|
||||
apns_volume = mobile_app_user_settings.important_notification_volume if critical else None
|
||||
apns_sound_name = (
|
||||
mobile_app_user_settings.important_notification_sound_name
|
||||
if critical
|
||||
else mobile_app_user_settings.default_notification_sound_name
|
||||
) + MobileAppUserSettings.IOS_SOUND_NAME_EXTENSION # iOS app expects the filename to have an extension
|
||||
|
||||
return Message(
|
||||
token=registration_id,
|
||||
data={
|
||||
# from the docs..
|
||||
# A dictionary of data fields (optional). All keys and values in the dictionary must be strings
|
||||
#
|
||||
# alert_group.status is an int so it must be casted...
|
||||
"orgId": alert_group.channel.organization.public_primary_key,
|
||||
"orgName": alert_group.channel.organization.stack_slug,
|
||||
"alertGroupId": alert_group.public_primary_key,
|
||||
"status": str(alert_group.status),
|
||||
"type": "oncall.critical_message" if critical else "oncall.message",
|
||||
"title": alert_title,
|
||||
"subtitle": alert_subtitle,
|
||||
"body": alert_body,
|
||||
"thread_id": thread_id,
|
||||
# Pass user settings, so the Android app can use them to play the correct sound and volume
|
||||
"default_notification_sound_name": (
|
||||
mobile_app_user_settings.default_notification_sound_name
|
||||
+ MobileAppUserSettings.ANDROID_SOUND_NAME_EXTENSION
|
||||
),
|
||||
"default_notification_volume_type": mobile_app_user_settings.default_notification_volume_type,
|
||||
"default_notification_volume": str(mobile_app_user_settings.default_notification_volume),
|
||||
"default_notification_volume_override": json.dumps(
|
||||
mobile_app_user_settings.default_notification_volume_override
|
||||
),
|
||||
"important_notification_sound_name": (
|
||||
mobile_app_user_settings.important_notification_sound_name
|
||||
+ MobileAppUserSettings.ANDROID_SOUND_NAME_EXTENSION
|
||||
),
|
||||
"important_notification_volume_type": mobile_app_user_settings.important_notification_volume_type,
|
||||
"important_notification_volume": str(mobile_app_user_settings.important_notification_volume),
|
||||
"important_notification_override_dnd": json.dumps(
|
||||
mobile_app_user_settings.important_notification_override_dnd
|
||||
),
|
||||
},
|
||||
apns=APNSConfig(
|
||||
payload=APNSPayload(
|
||||
aps=Aps(
|
||||
thread_id=thread_id,
|
||||
badge=number_of_alerts,
|
||||
alert=ApsAlert(title=alert_title, subtitle=alert_subtitle, body=alert_body),
|
||||
sound=CriticalSound(
|
||||
# The notification shouldn't be critical if the user has disabled "override DND" setting
|
||||
critical=(critical and mobile_app_user_settings.important_notification_override_dnd),
|
||||
name=apns_sound_name,
|
||||
volume=apns_volume,
|
||||
),
|
||||
custom_data={
|
||||
"interruption-level": "critical" if critical else "time-sensitive",
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ from fcm_django.models import FCMDevice
|
|||
from firebase_admin.exceptions import FirebaseError
|
||||
|
||||
from apps.base.models import UserNotificationPolicy, UserNotificationPolicyLogRecord
|
||||
from apps.mobile_app.tasks import notify_user_async
|
||||
from apps.mobile_app.models import MobileAppUserSettings
|
||||
from apps.mobile_app.tasks import _get_fcm_message, notify_user_async
|
||||
from apps.oss_installation.models import CloudConnector
|
||||
|
||||
MOBILE_APP_BACKEND_ID = 5
|
||||
|
|
@ -209,3 +210,86 @@ def test_notify_user_retry(
|
|||
notification_policy_pk=notification_policy.pk,
|
||||
critical=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_fcm_message_user_settings(
|
||||
make_organization_and_user, make_alert_receive_channel, make_alert_group, make_alert
|
||||
):
|
||||
organization, user = make_organization_and_user()
|
||||
device = FCMDevice.objects.create(user=user, registration_id="test_device_id")
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(organization=organization)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
make_alert(alert_group=alert_group, raw_request_data={})
|
||||
|
||||
message = _get_fcm_message(alert_group, user, device.registration_id, critical=False)
|
||||
|
||||
# Check user settings are passed to FCM message
|
||||
assert message.data["default_notification_sound_name"] == "default_sound.mp3"
|
||||
assert message.data["default_notification_volume_type"] == "constant"
|
||||
assert message.data["default_notification_volume_override"] == "false"
|
||||
assert message.data["default_notification_volume"] == "0.8"
|
||||
assert message.data["important_notification_sound_name"] == "default_sound_important.mp3"
|
||||
assert message.data["important_notification_volume_type"] == "constant"
|
||||
assert message.data["important_notification_volume"] == "0.8"
|
||||
assert message.data["important_notification_override_dnd"] == "true"
|
||||
|
||||
# Check APNS notification sound is set correctly
|
||||
apns_sound = message.apns.payload.aps.sound
|
||||
assert apns_sound.critical is False
|
||||
assert apns_sound.name == "default_sound.aiff"
|
||||
assert apns_sound.volume is None # APNS doesn't allow to specify volume for non-critical notifications
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_fcm_message_user_settings_critical(
|
||||
make_organization_and_user, make_alert_receive_channel, make_alert_group, make_alert
|
||||
):
|
||||
organization, user = make_organization_and_user()
|
||||
device = FCMDevice.objects.create(user=user, registration_id="test_device_id")
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(organization=organization)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
make_alert(alert_group=alert_group, raw_request_data={})
|
||||
|
||||
message = _get_fcm_message(alert_group, user, device.registration_id, critical=True)
|
||||
|
||||
# Check user settings are passed to FCM message
|
||||
assert message.data["default_notification_sound_name"] == "default_sound.mp3"
|
||||
assert message.data["default_notification_volume_type"] == "constant"
|
||||
assert message.data["default_notification_volume_override"] == "false"
|
||||
assert message.data["default_notification_volume"] == "0.8"
|
||||
assert message.data["important_notification_sound_name"] == "default_sound_important.mp3"
|
||||
assert message.data["important_notification_volume_type"] == "constant"
|
||||
assert message.data["important_notification_volume"] == "0.8"
|
||||
assert message.data["important_notification_override_dnd"] == "true"
|
||||
|
||||
# Check APNS notification sound is set correctly
|
||||
apns_sound = message.apns.payload.aps.sound
|
||||
assert apns_sound.critical is True
|
||||
assert apns_sound.name == "default_sound_important.aiff"
|
||||
assert apns_sound.volume == 0.8
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_fcm_message_user_settings_critical_override_dnd_disabled(
|
||||
make_organization_and_user, make_alert_receive_channel, make_alert_group, make_alert
|
||||
):
|
||||
organization, user = make_organization_and_user()
|
||||
device = FCMDevice.objects.create(user=user, registration_id="test_device_id")
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(organization=organization)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
make_alert(alert_group=alert_group, raw_request_data={})
|
||||
|
||||
# Disable important notification override DND
|
||||
MobileAppUserSettings.objects.create(user=user, important_notification_override_dnd=False)
|
||||
message = _get_fcm_message(alert_group, user, device.registration_id, critical=True)
|
||||
|
||||
# Check user settings are passed to FCM message
|
||||
assert message.data["important_notification_override_dnd"] == "false"
|
||||
|
||||
# Check APNS notification sound is set correctly
|
||||
apns_sound = message.apns.payload.aps.sound
|
||||
assert apns_sound.critical is False
|
||||
|
|
|
|||
51
engine/apps/mobile_app/tests/test_user_settings.py
Normal file
51
engine/apps/mobile_app/tests/test_user_settings.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import pytest
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_settings_get(make_organization_and_user_with_mobile_app_auth_token):
|
||||
organization, user, auth_token = make_organization_and_user_with_mobile_app_auth_token()
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("mobile_app:user_settings")
|
||||
|
||||
response = client.get(url, HTTP_AUTHORIZATION=auth_token)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Check the default values are correct
|
||||
assert response.json() == {
|
||||
"default_notification_sound_name": "default_sound",
|
||||
"default_notification_volume_type": "constant",
|
||||
"default_notification_volume": 0.8,
|
||||
"default_notification_volume_override": False,
|
||||
"important_notification_sound_name": "default_sound_important",
|
||||
"important_notification_volume_type": "constant",
|
||||
"important_notification_volume": 0.8,
|
||||
"important_notification_override_dnd": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_settings_put(make_organization_and_user_with_mobile_app_auth_token):
|
||||
organization, user, auth_token = make_organization_and_user_with_mobile_app_auth_token()
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("mobile_app:user_settings")
|
||||
data = {
|
||||
"default_notification_sound_name": "test_default",
|
||||
"default_notification_volume_type": "intensifying",
|
||||
"default_notification_volume": 1,
|
||||
"default_notification_volume_override": True,
|
||||
"important_notification_sound_name": "test_important",
|
||||
"important_notification_volume_type": "intensifying",
|
||||
"important_notification_volume": 1,
|
||||
"important_notification_override_dnd": False,
|
||||
}
|
||||
|
||||
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=auth_token)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Check the values are updated correctly
|
||||
assert response.json() == data
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
from apps.mobile_app.fcm_relay import FCMRelayView
|
||||
from apps.mobile_app.views import FCMDeviceAuthorizedViewSet, MobileAppAuthTokenAPIView
|
||||
from apps.mobile_app.views import FCMDeviceAuthorizedViewSet, MobileAppAuthTokenAPIView, MobileAppUserSettingsAPIView
|
||||
from common.api_helpers.optional_slash_router import OptionalSlashRouter, optional_slash_path
|
||||
|
||||
app_name = "mobile_app"
|
||||
|
|
@ -10,6 +10,7 @@ router.register("fcm", FCMDeviceAuthorizedViewSet, basename="fcm")
|
|||
urlpatterns = [
|
||||
*router.urls,
|
||||
optional_slash_path("auth_token", MobileAppAuthTokenAPIView.as_view(), name="auth_token"),
|
||||
optional_slash_path("user_settings", MobileAppUserSettingsAPIView.as_view(), name="user_settings"),
|
||||
]
|
||||
|
||||
urlpatterns += [
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
from fcm_django.api.rest_framework import FCMDeviceAuthorizedViewSet as BaseFCMDeviceAuthorizedViewSet
|
||||
from rest_framework import status
|
||||
from rest_framework import generics, status
|
||||
from rest_framework.exceptions import NotFound
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication, MobileAppVerificationTokenAuthentication
|
||||
from apps.mobile_app.models import MobileAppAuthToken
|
||||
from apps.mobile_app.models import MobileAppAuthToken, MobileAppUserSettings
|
||||
from apps.mobile_app.serializers import MobileAppUserSettingsSerializer
|
||||
|
||||
|
||||
class FCMDeviceAuthorizedViewSet(BaseFCMDeviceAuthorizedViewSet):
|
||||
|
|
@ -50,3 +52,13 @@ class MobileAppAuthTokenAPIView(APIView):
|
|||
raise NotFound
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class MobileAppUserSettingsAPIView(generics.RetrieveUpdateAPIView):
|
||||
authentication_classes = (MobileAppAuthTokenAuthentication,)
|
||||
permission_classes = (IsAuthenticated,)
|
||||
serializer_class = MobileAppUserSettingsSerializer
|
||||
|
||||
def get_object(self):
|
||||
mobile_app_settings, _ = MobileAppUserSettings.objects.get_or_create(user=self.request.user)
|
||||
return mobile_app_settings
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ from apps.base.tests.factories import (
|
|||
)
|
||||
from apps.email.tests.factories import EmailMessageFactory
|
||||
from apps.heartbeat.tests.factories import IntegrationHeartBeatFactory
|
||||
from apps.mobile_app.models import MobileAppVerificationToken
|
||||
from apps.mobile_app.models import MobileAppAuthToken, MobileAppVerificationToken
|
||||
from apps.schedules.tests.factories import (
|
||||
CustomOnCallShiftFactory,
|
||||
OnCallScheduleCalendarFactory,
|
||||
|
|
@ -185,6 +185,14 @@ def make_mobile_app_verification_token_for_user():
|
|||
return _make_mobile_app_verification_token_for_user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_mobile_app_auth_token_for_user():
|
||||
def _make_mobile_app_auth_token_for_user(user, organization):
|
||||
return MobileAppAuthToken.create_auth_token(user, organization)
|
||||
|
||||
return _make_mobile_app_auth_token_for_user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_public_api_token():
|
||||
def _make_public_api_token(user, organization, name="test_api_token"):
|
||||
|
|
@ -685,6 +693,20 @@ def make_organization_and_user_with_mobile_app_verification_token(
|
|||
return _make_organization_and_user_with_mobile_app_verification_token
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def make_organization_and_user_with_mobile_app_auth_token(
|
||||
make_organization_and_user, make_mobile_app_auth_token_for_user
|
||||
):
|
||||
def _make_organization_and_user_with_mobile_app_auth_token(
|
||||
role: typing.Optional[LegacyAccessControlRole] = None,
|
||||
):
|
||||
organization, user = make_organization_and_user(role)
|
||||
_, token = make_mobile_app_auth_token_for_user(user, organization)
|
||||
return organization, user, token
|
||||
|
||||
return _make_organization_and_user_with_mobile_app_auth_token
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_send_user_notification_signal(monkeypatch):
|
||||
def mocked_send_signal(*args, **kwargs):
|
||||
|
|
|
|||
|
|
@ -29,11 +29,17 @@ const GList = <T extends WithId>(props: GListProps<T>) => {
|
|||
};
|
||||
}, []);
|
||||
|
||||
const selectedRef = useRef<HTMLDivElement>();
|
||||
const itemsRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScroll && selectedRef.current) {
|
||||
const selectedElement = selectedRef.current;
|
||||
if (autoScroll && selectedId) {
|
||||
const map = getMap();
|
||||
const selectedElement = map.get(selectedId);
|
||||
|
||||
if (!selectedElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const divToScroll = selectedElement.parentElement.parentElement;
|
||||
|
||||
const maxScroll = Math.max(0, selectedElement.parentElement.offsetHeight - divToScroll.offsetHeight);
|
||||
|
|
@ -49,7 +55,14 @@ const GList = <T extends WithId>(props: GListProps<T>) => {
|
|||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}, [autoScroll, selectedRef.current]);
|
||||
}, [selectedId, autoScroll]);
|
||||
|
||||
function getMap() {
|
||||
if (!itemsRef.current) {
|
||||
itemsRef.current = new Map();
|
||||
}
|
||||
return itemsRef.current;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
|
|
@ -57,8 +70,11 @@ const GList = <T extends WithId>(props: GListProps<T>) => {
|
|||
items.map((item) => (
|
||||
<div
|
||||
ref={(node) => {
|
||||
if (item.id === selectedId) {
|
||||
selectedRef.current = node;
|
||||
const map = getMap();
|
||||
if (node) {
|
||||
map.set(item.id, node);
|
||||
} else {
|
||||
map.delete(item.id);
|
||||
}
|
||||
}}
|
||||
key={item.id}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ const ManualAlertGroup: FC<ManualAlertGroupProps> = (props) => {
|
|||
const [userResponders, setUserResponders] = useState([]);
|
||||
const [scheduleResponders, setScheduleResponders] = useState([]);
|
||||
const { onHide, onCreate } = props;
|
||||
const data = {};
|
||||
const data = { team: store.userStore.currentUser?.current_team };
|
||||
|
||||
const handleFormSubmit = async (data) => {
|
||||
store.directPagingStore
|
||||
|
|
|
|||
|
|
@ -84,13 +84,13 @@ const AlertReceiveChannelCard = observer((props: AlertReceiveChannelCardProps) =
|
|||
</PluginLink>
|
||||
)}
|
||||
</HorizontalGroup>
|
||||
<HorizontalGroup>
|
||||
<HorizontalGroup spacing="xs">
|
||||
<IntegrationLogo scale={0.08} integration={integration} />
|
||||
<Text type="secondary" size="small">
|
||||
{integration?.display_name}
|
||||
</Text>
|
||||
</HorizontalGroup>
|
||||
<TeamName team={grafanaTeamStore.items[alertReceiveChannel.team]} />
|
||||
<TeamName team={grafanaTeamStore.items[alertReceiveChannel.team]} size="small" />
|
||||
</VerticalGroup>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ const EscalationChainCard = observer((props: AlertReceiveChannelCardProps) => {
|
|||
}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
<TeamName team={grafanaTeamStore.items[escalationChain.team]} />
|
||||
<TeamName team={grafanaTeamStore.items[escalationChain.team]} size="small" />
|
||||
</VerticalGroup>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,20 @@ export const form: { name: string; fields: FormItem[] } = {
|
|||
type: FormItemType.Input,
|
||||
validation: { required: true },
|
||||
},
|
||||
{
|
||||
name: 'team',
|
||||
label: 'Assign to team',
|
||||
description:
|
||||
'Assigning to the teams allows you to filter Outgoing Webhooks and configure their visibility. Go to OnCall -> Settings -> Team and Access Settings for more details',
|
||||
type: FormItemType.GSelect,
|
||||
extra: {
|
||||
modelName: 'grafanaTeamStore',
|
||||
displayField: 'name',
|
||||
valueField: 'id',
|
||||
showSearch: true,
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'trigger_type',
|
||||
label: 'Trigger type',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
.avatar {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
|
@ -1,12 +1,17 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Badge, Tooltip } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import Avatar from 'components/Avatar/Avatar';
|
||||
import Text from 'components/Text/Text';
|
||||
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
|
||||
|
||||
import styles from './TeamName.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface TeamNameProps {
|
||||
team: GrafanaTeam;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
|
|
@ -15,17 +20,17 @@ interface TeamNameProps {
|
|||
const TeamName = observer((props: TeamNameProps) => {
|
||||
const { team, size } = props;
|
||||
if (!team) {
|
||||
return <></>;
|
||||
return null;
|
||||
}
|
||||
if (team.id === 'null') {
|
||||
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'}>
|
||||
<Avatar size="small" src={team.avatar_url} />{' '}
|
||||
<Avatar size="small" src={team.avatar_url} className={cx('avatar')} />
|
||||
<Tooltip placement="top" content={'Resource is assigned to ' + team.name}>
|
||||
<span>{team.name}</span>
|
||||
</Tooltip>{' '}
|
||||
</Tooltip>
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { Badge, Button, Field, HorizontalGroup, Modal, RadioButtonGroup, Tooltip, VerticalGroup } from '@grafana/ui';
|
||||
import { Badge, Button, Field, HorizontalGroup, Modal, RadioButtonList, Tooltip, VerticalGroup } from '@grafana/ui';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import Avatar from 'components/Avatar/Avatar';
|
||||
|
|
@ -146,12 +146,14 @@ const TeamModal = ({ teamId, onHide }: TeamModalProps) => {
|
|||
const { grafanaTeamStore } = store;
|
||||
const team = grafanaTeamStore.items[teamId];
|
||||
|
||||
const [shareResourceToAll, setShareResourceToAll] = useState<boolean>(team.is_sharing_resources_to_all);
|
||||
const [shareResourceToAll, setShareResourceToAll] = useState<string>(
|
||||
String(Number(team.is_sharing_resources_to_all))
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
Promise.all([grafanaTeamStore.updateTeam(teamId, { is_sharing_resources_to_all: shareResourceToAll })]).then(
|
||||
onHide
|
||||
);
|
||||
Promise.all([
|
||||
grafanaTeamStore.updateTeam(teamId, { is_sharing_resources_to_all: Boolean(Number(shareResourceToAll)) }),
|
||||
]).then(onHide);
|
||||
}, [shareResourceToAll]);
|
||||
|
||||
return (
|
||||
|
|
@ -167,14 +169,17 @@ const TeamModal = ({ teamId, onHide }: TeamModalProps) => {
|
|||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
|
||||
<VerticalGroup>
|
||||
<Field label="Who can see the team name and access the team resources">
|
||||
<RadioButtonGroup
|
||||
options={[
|
||||
{ label: 'All Users', value: true },
|
||||
{ label: 'Team members and admins', value: false },
|
||||
]}
|
||||
value={shareResourceToAll}
|
||||
onChange={setShareResourceToAll}
|
||||
/>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<RadioButtonList
|
||||
name="shareResourceToAll"
|
||||
options={[
|
||||
{ label: 'All Users', value: '1' },
|
||||
{ label: 'Team members and admins', value: '0' },
|
||||
]}
|
||||
value={shareResourceToAll}
|
||||
onChange={setShareResourceToAll}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
</VerticalGroup>
|
||||
</WithPermissionControlTooltip>
|
||||
|
|
@ -184,7 +189,7 @@ const TeamModal = ({ teamId, onHide }: TeamModalProps) => {
|
|||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} variant="primary">
|
||||
Submit
|
||||
Save
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { action, observable } from 'mobx';
|
||||
|
||||
import BaseStore from 'models/base_store';
|
||||
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
|
||||
import { makeRequest } from 'network';
|
||||
import { RootStore } from 'state';
|
||||
|
||||
|
|
@ -52,9 +53,11 @@ export class OutgoingWebhook2Store extends BaseStore {
|
|||
}
|
||||
|
||||
@action
|
||||
async updateItems(query = '') {
|
||||
async updateItems(query: any = '') {
|
||||
const params = typeof query === 'string' ? { search: query } : query;
|
||||
|
||||
const results = await makeRequest(`${this.path}`, {
|
||||
params: { search: query },
|
||||
params,
|
||||
});
|
||||
|
||||
this.items = {
|
||||
|
|
@ -68,9 +71,11 @@ export class OutgoingWebhook2Store extends BaseStore {
|
|||
),
|
||||
};
|
||||
|
||||
const key = typeof query === 'string' ? query : '';
|
||||
|
||||
this.searchResult = {
|
||||
...this.searchResult,
|
||||
[query]: results.map((item: OutgoingWebhook2) => item.id),
|
||||
[key]: results.map((item: OutgoingWebhook) => item.id),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -69,8 +69,6 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
|
|||
|
||||
const { escalationChainStore } = store;
|
||||
|
||||
const searchResult = escalationChainStore.getSearchResult();
|
||||
|
||||
let selectedEscalationChain: EscalationChain['id'];
|
||||
if (id) {
|
||||
let escalationChain = await escalationChainStore
|
||||
|
|
@ -87,22 +85,25 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
|
|||
}
|
||||
}
|
||||
|
||||
if (!selectedEscalationChain) {
|
||||
selectedEscalationChain = searchResult[0]?.id;
|
||||
}
|
||||
|
||||
if (selectedEscalationChain) {
|
||||
this.enrichExtraEscalationChainsAndSelect(selectedEscalationChain);
|
||||
} else {
|
||||
this.setState({ selectedEscalationChain: undefined });
|
||||
}
|
||||
};
|
||||
|
||||
handleEsclalationSelect = (id: EscalationChain['id']) => {
|
||||
const { history } = this.props;
|
||||
|
||||
history.push(`${PLUGIN_ROOT}/escalations/${id}${window.location.search}`);
|
||||
};
|
||||
|
||||
setSelectedEscalationChain = async (escalationChainId: EscalationChain['id']) => {
|
||||
const { store, history } = this.props;
|
||||
const { store } = this.props;
|
||||
|
||||
const { escalationChainStore } = store;
|
||||
|
||||
this.setState({ selectedEscalationChain: escalationChainId }, () => {
|
||||
history.push(`${PLUGIN_ROOT}/escalations/${escalationChainId || ''}${window.location.search}`);
|
||||
if (escalationChainId) {
|
||||
escalationChainStore.updateEscalationChainDetails(escalationChainId);
|
||||
}
|
||||
|
|
@ -167,7 +168,7 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
|
|||
selectedId={selectedEscalationChain}
|
||||
items={data}
|
||||
itemKey="id"
|
||||
onSelect={this.setSelectedEscalationChain}
|
||||
onSelect={this.handleEsclalationSelect}
|
||||
>
|
||||
{(item) => <EscalationChainCard id={item.id} />}
|
||||
</GList>
|
||||
|
|
@ -234,8 +235,14 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
|
|||
}
|
||||
|
||||
handleFiltersChange = (filters: FiltersValues, isOnMount = false) => {
|
||||
const {
|
||||
match: {
|
||||
params: { id },
|
||||
},
|
||||
} = this.props;
|
||||
|
||||
this.setState({ escalationChainsFilters: filters, extraEscalationChains: undefined }, () => {
|
||||
if (isOnMount) {
|
||||
if (isOnMount && id) {
|
||||
this.applyFilters().then(this.parseQueryParams);
|
||||
} else {
|
||||
this.applyFilters().then(this.autoSelectEscalationChain);
|
||||
|
|
@ -244,14 +251,15 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
|
|||
};
|
||||
|
||||
autoSelectEscalationChain = () => {
|
||||
const { store } = this.props;
|
||||
const { store, history } = this.props;
|
||||
const { selectedEscalationChain } = this.state;
|
||||
const { escalationChainStore } = store;
|
||||
|
||||
const searchResult = escalationChainStore.getSearchResult();
|
||||
|
||||
if (!searchResult.find((escalationChain: EscalationChain) => escalationChain.id === selectedEscalationChain)) {
|
||||
this.setSelectedEscalationChain(searchResult[0]?.id);
|
||||
const id = searchResult[0]?.id;
|
||||
history.push(`${PLUGIN_ROOT}/escalations/${id || ''}${window.location.search}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -358,7 +366,11 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
|
|||
};
|
||||
|
||||
handleEscalationChainCreate = async (id: EscalationChain['id']) => {
|
||||
this.enrichExtraEscalationChainsAndSelect(id);
|
||||
const { history } = this.props;
|
||||
|
||||
await this.applyFilters();
|
||||
|
||||
history.push(`${PLUGIN_ROOT}/escalations/${id}${window.location.search}`);
|
||||
};
|
||||
|
||||
enrichExtraEscalationChainsAndSelect = async (id: EscalationChain['id']) => {
|
||||
|
|
@ -389,7 +401,7 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
|
|||
};
|
||||
|
||||
handleDeleteEscalationChain = () => {
|
||||
const { store } = this.props;
|
||||
const { store, history } = this.props;
|
||||
const { escalationChainStore } = store;
|
||||
const { selectedEscalationChain, extraEscalationChains } = this.state;
|
||||
|
||||
|
|
@ -413,7 +425,7 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
|
|||
|
||||
const newSelected = escalationChains[index - 1] || escalationChains[0];
|
||||
|
||||
this.setSelectedEscalationChain(newSelected?.id);
|
||||
history.push(`${PLUGIN_ROOT}/escalations/${newSelected?.id || ''}${window.location.search}`);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
|
|||
} = this.props;
|
||||
const { alertReceiveChannelStore } = store;
|
||||
|
||||
let selectedAlertReceiveChannel = store.selectedAlertReceiveChannel;
|
||||
let selectedAlertReceiveChannel = undefined;
|
||||
|
||||
if (id) {
|
||||
let alertReceiveChannel = await alertReceiveChannelStore
|
||||
|
|
@ -110,6 +110,8 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
|
|||
|
||||
if (selectedAlertReceiveChannel) {
|
||||
this.enrichAlertReceiveChannelsAndSelect(selectedAlertReceiveChannel);
|
||||
} else {
|
||||
store.selectedAlertReceiveChannel = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -309,28 +311,29 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
|
|||
delete alertReceiveChanneltoPoll[alertReceiveChannelId];
|
||||
}
|
||||
|
||||
alertReceiveChannelStore.deleteAlertReceiveChannel(alertReceiveChannelId).then(async () => {
|
||||
await this.applyFilters();
|
||||
alertReceiveChannelStore
|
||||
.deleteAlertReceiveChannel(alertReceiveChannelId)
|
||||
.then(this.applyFilters)
|
||||
.then(() => {
|
||||
if (alertReceiveChannelId === store.selectedAlertReceiveChannel) {
|
||||
if (extraAlertReceiveChannels) {
|
||||
const newExtraAlertReceiveChannels = extraAlertReceiveChannels.filter(
|
||||
(alertReceiveChannel) => alertReceiveChannel.id !== alertReceiveChannelId
|
||||
);
|
||||
|
||||
if (alertReceiveChannelId === store.selectedAlertReceiveChannel) {
|
||||
if (extraAlertReceiveChannels) {
|
||||
const newExtraAlertReceiveChannels = extraAlertReceiveChannels.filter(
|
||||
(alertReceiveChannel) => alertReceiveChannel.id !== alertReceiveChannelId
|
||||
this.setState({ extraAlertReceiveChannels: newExtraAlertReceiveChannels });
|
||||
}
|
||||
|
||||
const searchResult = alertReceiveChannelStore.getSearchResult();
|
||||
|
||||
const index = searchResult.findIndex(
|
||||
(alertReceiveChannel: AlertReceiveChannel) => alertReceiveChannel.id === store.selectedAlertReceiveChannel
|
||||
);
|
||||
const newSelected = searchResult[index - 1] || searchResult[0];
|
||||
|
||||
this.setState({ extraAlertReceiveChannels: newExtraAlertReceiveChannels });
|
||||
history.push(`${PLUGIN_ROOT}/integrations/${newSelected?.id || ''}${window.location.search}`);
|
||||
}
|
||||
|
||||
const searchResult = alertReceiveChannelStore.getSearchResult();
|
||||
|
||||
const index = searchResult.findIndex(
|
||||
(alertReceiveChannel: AlertReceiveChannel) => alertReceiveChannel.id === store.selectedAlertReceiveChannel
|
||||
);
|
||||
const newSelected = searchResult[index - 1] || searchResult[0];
|
||||
|
||||
history.push(`${PLUGIN_ROOT}/integrations/${newSelected?.id || ''}${window.location.search}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
applyFilters = () => {
|
||||
|
|
@ -363,7 +366,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
|
|||
},
|
||||
} = this.props;
|
||||
|
||||
this.setState({ integrationsFilters }, () => {
|
||||
this.setState({ integrationsFilters, extraAlertReceiveChannels: undefined }, () => {
|
||||
this.applyFilters().then(() => {
|
||||
if (isOnMount && id) {
|
||||
this.parseQueryParams();
|
||||
|
|
|
|||
|
|
@ -17,8 +17,12 @@ import Text from 'components/Text/Text';
|
|||
import WithConfirm from 'components/WithConfirm/WithConfirm';
|
||||
import OutgoingWebhook2Form from 'containers/OutgoingWebhook2Form/OutgoingWebhook2Form';
|
||||
import OutgoingWebhook2Status from 'containers/OutgoingWebhook2Status/OutgoingWebhook2Status';
|
||||
import RemoteFilters from 'containers/RemoteFilters/RemoteFilters';
|
||||
import TeamName from 'containers/TeamName/TeamName';
|
||||
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
|
||||
import { ActionDTO } from 'models/action';
|
||||
import { FiltersValues } from 'models/filters/filters.types';
|
||||
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
|
||||
import { OutgoingWebhook2 } from 'models/outgoing_webhook_2/outgoing_webhook_2.types';
|
||||
import { makeRequest } from 'network';
|
||||
import { PageProps, WithStoreProps } from 'state/types';
|
||||
|
|
@ -122,6 +126,11 @@ class OutgoingWebhooks2 extends React.Component<OutgoingWebhooks2Props, Outgoing
|
|||
title: 'Last run',
|
||||
dataIndex: 'last_run',
|
||||
},
|
||||
{
|
||||
width: '15%',
|
||||
title: 'Team',
|
||||
render: (item: OutgoingWebhook) => this.renderTeam(item, store.grafanaTeamStore.items),
|
||||
},
|
||||
{
|
||||
width: '20%',
|
||||
key: 'action',
|
||||
|
|
@ -139,6 +148,7 @@ class OutgoingWebhooks2 extends React.Component<OutgoingWebhooks2Props, Outgoing
|
|||
{() => (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
{this.renderOutgoingWebhooksFilters()}
|
||||
<GTable
|
||||
emptyText={webhooks ? 'No outgoing webhooks found' : 'Loading...'}
|
||||
title={() => (
|
||||
|
|
@ -191,6 +201,36 @@ class OutgoingWebhooks2 extends React.Component<OutgoingWebhooks2Props, Outgoing
|
|||
);
|
||||
}
|
||||
|
||||
renderOutgoingWebhooksFilters() {
|
||||
const { query, store } = this.props;
|
||||
return (
|
||||
<div className={cx('filters')}>
|
||||
<RemoteFilters
|
||||
query={query}
|
||||
page="webhooks"
|
||||
grafanaTeamStore={store.grafanaTeamStore}
|
||||
onChange={this.handleFiltersChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
handleFiltersChange = (filters: FiltersValues, isOnMount) => {
|
||||
const { store } = this.props;
|
||||
|
||||
const { outgoingWebhook2Store } = store;
|
||||
|
||||
outgoingWebhook2Store.updateItems(filters).then(() => {
|
||||
if (isOnMount) {
|
||||
this.parseQueryParams();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
renderTeam(record: OutgoingWebhook, teams: any) {
|
||||
return <TeamName team={teams[record.team]} />;
|
||||
}
|
||||
|
||||
renderActionButtons = (record: ActionDTO) => {
|
||||
return (
|
||||
<HorizontalGroup justify="flex-end">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue