diff --git a/engine/apps/api/views/webhooks.py b/engine/apps/api/views/webhooks.py index 1a013926..de492a3d 100644 --- a/engine/apps/api/views/webhooks.py +++ b/engine/apps/api/views/webhooks.py @@ -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) diff --git a/engine/apps/mobile_app/migrations/0003_mobileappusersettings.py b/engine/apps/mobile_app/migrations/0003_mobileappusersettings.py new file mode 100644 index 00000000..0f9946cf --- /dev/null +++ b/engine/apps/mobile_app/migrations/0003_mobileappusersettings.py @@ -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')), + ], + ), + ] diff --git a/engine/apps/mobile_app/models.py b/engine/apps/mobile_app/models.py index 08d2c1f2..92d0e7f8 100644 --- a/engine/apps/mobile_app/models.py +++ b/engine/apps/mobile_app/models.py @@ -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) diff --git a/engine/apps/mobile_app/serializers.py b/engine/apps/mobile_app/serializers.py new file mode 100644 index 00000000..1338ecdc --- /dev/null +++ b/engine/apps/mobile_app/serializers.py @@ -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", + ) diff --git a/engine/apps/mobile_app/tasks.py b/engine/apps/mobile_app/tasks.py index 8b18f7e5..dec51193 100644 --- a/engine/apps/mobile_app/tasks.py +++ b/engine/apps/mobile_app/tasks.py @@ -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", + }, + ), + ), + ), + ) diff --git a/engine/apps/mobile_app/tests/test_notify_user.py b/engine/apps/mobile_app/tests/test_notify_user.py index 8b64eda3..8e3d5487 100644 --- a/engine/apps/mobile_app/tests/test_notify_user.py +++ b/engine/apps/mobile_app/tests/test_notify_user.py @@ -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 diff --git a/engine/apps/mobile_app/tests/test_user_settings.py b/engine/apps/mobile_app/tests/test_user_settings.py new file mode 100644 index 00000000..de14d9df --- /dev/null +++ b/engine/apps/mobile_app/tests/test_user_settings.py @@ -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 diff --git a/engine/apps/mobile_app/urls.py b/engine/apps/mobile_app/urls.py index 2f0433d9..5d4898e9 100644 --- a/engine/apps/mobile_app/urls.py +++ b/engine/apps/mobile_app/urls.py @@ -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 += [ diff --git a/engine/apps/mobile_app/views.py b/engine/apps/mobile_app/views.py index 16257440..035b68fa 100644 --- a/engine/apps/mobile_app/views.py +++ b/engine/apps/mobile_app/views.py @@ -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 diff --git a/engine/conftest.py b/engine/conftest.py index a79e18b0..de2ad0a3 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -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): diff --git a/grafana-plugin/src/components/GList/GList.tsx b/grafana-plugin/src/components/GList/GList.tsx index d77817a0..26c3ccf9 100644 --- a/grafana-plugin/src/components/GList/GList.tsx +++ b/grafana-plugin/src/components/GList/GList.tsx @@ -29,11 +29,17 @@ const GList = (props: GListProps) => { }; }, []); - const selectedRef = useRef(); + 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 = (props: GListProps) => { behavior: 'smooth', }); } - }, [autoScroll, selectedRef.current]); + }, [selectedId, autoScroll]); + + function getMap() { + if (!itemsRef.current) { + itemsRef.current = new Map(); + } + return itemsRef.current; + } return (
@@ -57,8 +70,11 @@ const GList = (props: GListProps) => { items.map((item) => (
{ - 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} diff --git a/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.tsx b/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.tsx index 36be298f..2f7f58a5 100644 --- a/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.tsx +++ b/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.tsx @@ -27,7 +27,7 @@ const ManualAlertGroup: FC = (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 diff --git a/grafana-plugin/src/containers/AlertReceiveChannelCard/AlertReceiveChannelCard.tsx b/grafana-plugin/src/containers/AlertReceiveChannelCard/AlertReceiveChannelCard.tsx index c01955c4..71c1b2f1 100644 --- a/grafana-plugin/src/containers/AlertReceiveChannelCard/AlertReceiveChannelCard.tsx +++ b/grafana-plugin/src/containers/AlertReceiveChannelCard/AlertReceiveChannelCard.tsx @@ -84,13 +84,13 @@ const AlertReceiveChannelCard = observer((props: AlertReceiveChannelCardProps) = )} - + {integration?.display_name} - +
diff --git a/grafana-plugin/src/containers/EscalationChainCard/EscalationChainCard.tsx b/grafana-plugin/src/containers/EscalationChainCard/EscalationChainCard.tsx index ac293ef1..55f2a395 100644 --- a/grafana-plugin/src/containers/EscalationChainCard/EscalationChainCard.tsx +++ b/grafana-plugin/src/containers/EscalationChainCard/EscalationChainCard.tsx @@ -45,7 +45,7 @@ const EscalationChainCard = observer((props: AlertReceiveChannelCardProps) => { } /> - +
diff --git a/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.config.ts b/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.config.ts index e071333e..f40881a7 100644 --- a/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.config.ts +++ b/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.config.ts @@ -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', diff --git a/grafana-plugin/src/containers/TeamName/TeamName.module.css b/grafana-plugin/src/containers/TeamName/TeamName.module.css new file mode 100644 index 00000000..c053b2f5 --- /dev/null +++ b/grafana-plugin/src/containers/TeamName/TeamName.module.css @@ -0,0 +1,3 @@ +.avatar { + margin-right: 4px; +} diff --git a/grafana-plugin/src/containers/TeamName/TeamName.tsx b/grafana-plugin/src/containers/TeamName/TeamName.tsx index 4595abd8..77a1744c 100644 --- a/grafana-plugin/src/containers/TeamName/TeamName.tsx +++ b/grafana-plugin/src/containers/TeamName/TeamName.tsx @@ -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 ; } return ( - {' '} + {team.name} - {' '} + ); }); diff --git a/grafana-plugin/src/containers/TeamsList/TeamsList.tsx b/grafana-plugin/src/containers/TeamsList/TeamsList.tsx index 78bede89..a1eede11 100644 --- a/grafana-plugin/src/containers/TeamsList/TeamsList.tsx +++ b/grafana-plugin/src/containers/TeamsList/TeamsList.tsx @@ -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(team.is_sharing_resources_to_all); + const [shareResourceToAll, setShareResourceToAll] = useState( + 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) => { - +
+ +
@@ -184,7 +189,7 @@ const TeamModal = ({ teamId, onHide }: TeamModalProps) => { Cancel diff --git a/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.ts b/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.ts index e1538963..893f5f84 100644 --- a/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.ts +++ b/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.ts @@ -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), }; } diff --git a/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx b/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx index fe5ac804..dbcb04ed 100644 --- a/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx +++ b/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx @@ -69,8 +69,6 @@ class EscalationChainsPage extends React.Component { + 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 {(item) => } @@ -234,8 +235,14 @@ class EscalationChainsPage extends React.Component { + 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 { - 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 { - 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 { - 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 } = 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 if (selectedAlertReceiveChannel) { this.enrichAlertReceiveChannelsAndSelect(selectedAlertReceiveChannel); + } else { + store.selectedAlertReceiveChannel = undefined; } }; @@ -309,28 +311,29 @@ class Integrations extends React.Component 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 }, } = this.props; - this.setState({ integrationsFilters }, () => { + this.setState({ integrationsFilters, extraAlertReceiveChannels: undefined }, () => { this.applyFilters().then(() => { if (isOnMount && id) { this.parseQueryParams(); diff --git a/grafana-plugin/src/pages/outgoing_webhooks_2/OutgoingWebhooks2.tsx b/grafana-plugin/src/pages/outgoing_webhooks_2/OutgoingWebhooks2.tsx index 9adbb3ac..59f656e3 100644 --- a/grafana-plugin/src/pages/outgoing_webhooks_2/OutgoingWebhooks2.tsx +++ b/grafana-plugin/src/pages/outgoing_webhooks_2/OutgoingWebhooks2.tsx @@ -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 this.renderTeam(item, store.grafanaTeamStore.items), + }, { width: '20%', key: 'action', @@ -139,6 +148,7 @@ class OutgoingWebhooks2 extends React.Component ( <>
+ {this.renderOutgoingWebhooksFilters()} ( @@ -191,6 +201,36 @@ class OutgoingWebhooks2 extends React.Component + +
+ ); + } + + 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 ; + } + renderActionButtons = (record: ActionDTO) => { return (