From f51e6fff5e1b6d2d07d79e4a2a83c3e0c8c4ab35 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Thu, 18 May 2023 15:52:42 +0800 Subject: [PATCH] Test mobile push (#1933) # What this PR does Adds ability to send test push notification --------- Co-authored-by: Vadim Stepanov Co-authored-by: Rares Mardare --- CHANGELOG.md | 1 + .../api/throttlers/test_call_throttler.py | 5 + engine/apps/api/views/features.py | 1 + engine/apps/api/views/user.py | 24 + engine/apps/mobile_app/demo_push.py | 86 + engine/apps/mobile_app/exceptions.py | 6 + .../apps/mobile_app/tests/test_demo_push.py | 95 + .../MobileAppConnection.module.scss | 5 + .../MobileAppConnection.tsx | 58 +- .../MobileAppConnection.test.tsx.snap | 5633 +++++++++-------- grafana-plugin/src/models/user/user.ts | 10 + 11 files changed, 3130 insertions(+), 2794 deletions(-) create mode 100644 engine/apps/mobile_app/demo_push.py create mode 100644 engine/apps/mobile_app/exceptions.py create mode 100644 engine/apps/mobile_app/tests/test_demo_push.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 28ad5925..3878f0b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add a way to set a maintenance mode message and display this in the web plugin UI by @joeyorlando ([#1917](https://github.com/grafana/oncall/pull/#1917)) +- Add "send test push" button ### Changed diff --git a/engine/apps/api/throttlers/test_call_throttler.py b/engine/apps/api/throttlers/test_call_throttler.py index 93517163..3941b609 100644 --- a/engine/apps/api/throttlers/test_call_throttler.py +++ b/engine/apps/api/throttlers/test_call_throttler.py @@ -4,3 +4,8 @@ from rest_framework.throttling import UserRateThrottle class TestCallThrottler(UserRateThrottle): scope = "make_test_call" rate = "5/m" + + +class TestPushThrottler(UserRateThrottle): + scope = "send_test_push" + rate = "10/m" diff --git a/engine/apps/api/views/features.py b/engine/apps/api/views/features.py index b0e41897..197e4ef5 100644 --- a/engine/apps/api/views/features.py +++ b/engine/apps/api/views/features.py @@ -14,6 +14,7 @@ FEATURE_GRAFANA_CLOUD_NOTIFICATIONS = "grafana_cloud_notifications" FEATURE_GRAFANA_CLOUD_CONNECTION = "grafana_cloud_connection" FEATURE_WEB_SCHEDULES = "web_schedules" FEATURE_WEBHOOKS2 = "webhooks2" +FEATURE_MOBILE_TEST_PUSH = "mobile_test_push" class FeaturesAPIView(APIView): diff --git a/engine/apps/api/views/user.py b/engine/apps/api/views/user.py index 35c29cca..0a8a64ed 100644 --- a/engine/apps/api/views/user.py +++ b/engine/apps/api/views/user.py @@ -33,12 +33,15 @@ from apps.api.throttlers import ( VerifyPhoneNumberThrottlerPerOrg, VerifyPhoneNumberThrottlerPerUser, ) +from apps.api.throttlers.test_call_throttler import TestPushThrottler from apps.auth_token.auth import PluginAuthentication from apps.auth_token.constants import SCHEDULE_EXPORT_TOKEN_NAME from apps.auth_token.models import UserScheduleExportAuthToken from apps.base.messaging import get_messaging_backend_from_id from apps.base.utils import live_settings from apps.mobile_app.auth import MobileAppAuthTokenAuthentication +from apps.mobile_app.demo_push import send_test_push +from apps.mobile_app.exceptions import DeviceNotSet from apps.schedules.models import OnCallSchedule from apps.telegram.client import TelegramClient from apps.telegram.models import TelegramVerificationCode @@ -156,6 +159,7 @@ class UserView( "unlink_telegram": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "unlink_backend": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "make_test_call": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "send_test_push": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "export_token": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "upcoming_shifts": [RBACPermission.Permissions.USER_SETTINGS_WRITE], } @@ -177,6 +181,7 @@ class UserView( "unlink_telegram", "unlink_backend", "make_test_call", + "send_test_push", "export_token", "upcoming_shifts", ], @@ -391,6 +396,25 @@ class UserView( return Response(status=status.HTTP_200_OK) + @action(detail=True, methods=["post"], throttle_classes=[TestPushThrottler]) + def send_test_push(self, request, pk): + user = self.get_object() + critical = request.query_params.get("critical", "false") == "true" + + try: + send_test_push(user, critical) + except DeviceNotSet: + return Response( + data="Mobile device not connected", + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception as e: + logger.info(f"UserView.send_test_push: Unable to send test push due to {e}") + return Response( + data="Something went wrong while sending a test push", status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + return Response(status=status.HTTP_200_OK) + @action(detail=True, methods=["get"]) def get_backend_verification_code(self, request, pk): backend_id = request.query_params.get("backend") diff --git a/engine/apps/mobile_app/demo_push.py b/engine/apps/mobile_app/demo_push.py new file mode 100644 index 00000000..96ace3f9 --- /dev/null +++ b/engine/apps/mobile_app/demo_push.py @@ -0,0 +1,86 @@ +import json +import random +import string + +from fcm_django.models import FCMDevice +from firebase_admin.messaging import APNSPayload, Aps, ApsAlert, CriticalSound, Message + +from apps.mobile_app.exceptions import DeviceNotSet +from apps.mobile_app.tasks import FCMMessageData, MessageType, _construct_fcm_message, _send_push_notification, logger +from apps.user_management.models import User + +TEST_PUSH_TITLE = "Hi, this is a test notification from Grafana OnCall" + + +def send_test_push(user, critical=False): + device_to_notify = FCMDevice.objects.filter(user=user).first() + if device_to_notify is None: + logger.info(f"send_test_push: fcm_device not found user_id={user.id}") + raise DeviceNotSet + message = _get_test_escalation_fcm_message(user, device_to_notify, critical) + _send_push_notification(device_to_notify, message) + + +def _get_test_escalation_fcm_message(user: User, device_to_notify: FCMDevice, critical: bool) -> Message: + # TODO: this method is copied from _get_alert_group_escalation_fcm_message + # to have same notification/sound/overrideDND logic. Ideally this logic should be abstracted, not repeated. + from apps.mobile_app.models import MobileAppUserSettings + + thread_id = f"{''.join(random.choices(string.digits, k=6))}:test_push" + + mobile_app_user_settings, _ = MobileAppUserSettings.objects.get_or_create(user=user) + # critical defines the type of notification. + # we use overrideDND to establish if the notification should sound even if DND is on + overrideDND = critical and mobile_app_user_settings.important_notification_override_dnd + + # 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 + + fcm_message_data: FCMMessageData = { + "title": TEST_PUSH_TITLE, + # 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_volume_override": json.dumps( + mobile_app_user_settings.important_notification_volume_override + ), + "important_notification_override_dnd": json.dumps(mobile_app_user_settings.important_notification_override_dnd), + } + + apns_payload = APNSPayload( + aps=Aps( + thread_id=thread_id, + alert=ApsAlert(title=TEST_PUSH_TITLE), + sound=CriticalSound( + # The notification shouldn't be critical if the user has disabled "override DND" setting + critical=overrideDND, + name=apns_sound_name, + volume=apns_volume, + ), + custom_data={ + "interruption-level": "critical" if overrideDND else "time-sensitive", + }, + ), + ) + + message_type = MessageType.CRITICAL if critical else MessageType.NORMAL + + return _construct_fcm_message(message_type, device_to_notify, thread_id, fcm_message_data, apns_payload) diff --git a/engine/apps/mobile_app/exceptions.py b/engine/apps/mobile_app/exceptions.py new file mode 100644 index 00000000..dc2be8d0 --- /dev/null +++ b/engine/apps/mobile_app/exceptions.py @@ -0,0 +1,6 @@ +class DeviceNotSet(Exception): + """ + Indicates that user has no connected fcm device. + Introduced only for test_push_notification handler. + We should have generic test notifications system across all messaging backends. + """ diff --git a/engine/apps/mobile_app/tests/test_demo_push.py b/engine/apps/mobile_app/tests/test_demo_push.py new file mode 100644 index 00000000..35843529 --- /dev/null +++ b/engine/apps/mobile_app/tests/test_demo_push.py @@ -0,0 +1,95 @@ +import pytest +from fcm_django.models import FCMDevice + +from apps.mobile_app.demo_push import TEST_PUSH_TITLE, _get_test_escalation_fcm_message +from apps.mobile_app.models import MobileAppUserSettings + + +@pytest.mark.django_db +def test_test_escalation_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") + + message = _get_test_escalation_fcm_message(user, device, 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_volume_override"] == "true" + 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 + + # Check expected test push content + assert message.apns.payload.aps.badge is None + assert message.apns.payload.aps.alert.title == TEST_PUSH_TITLE + assert message.data["title"] == TEST_PUSH_TITLE + + +@pytest.mark.django_db +def test_escalation_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") + + message = _get_test_escalation_fcm_message(user, device, 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_volume_override"] == "true" + 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 + assert message.apns.payload.aps.custom_data["interruption-level"] == "critical" + + # Check expected test push content + assert message.apns.payload.aps.badge is None + assert message.apns.payload.aps.alert.title == TEST_PUSH_TITLE + assert message.data["title"] == TEST_PUSH_TITLE + + +@pytest.mark.django_db +def test_escalation_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") + + # Disable important notification override DND + MobileAppUserSettings.objects.create(user=user, important_notification_override_dnd=False) + message = _get_test_escalation_fcm_message(user, device, 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 + assert message.apns.payload.aps.custom_data["interruption-level"] == "time-sensitive" + + # Check expected test push content + assert message.apns.payload.aps.badge is None + assert message.apns.payload.aps.alert.title == TEST_PUSH_TITLE + assert message.data["title"] == TEST_PUSH_TITLE diff --git a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.module.scss b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.module.scss index 94dbd47f..43853412 100644 --- a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.module.scss +++ b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.module.scss @@ -14,6 +14,11 @@ } } +.notification-buttons { + width: 100%; + padding-top: 12px; +} + .icon { margin-top: -6px; margin-left: 4px; diff --git a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx index 4fa269b4..1ea659cf 100644 --- a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx +++ b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { Button, Icon, LoadingPlaceholder, VerticalGroup } from '@grafana/ui'; +import { Button, HorizontalGroup, Icon, LoadingPlaceholder, VerticalGroup } from '@grafana/ui'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; @@ -12,6 +12,7 @@ import { WithPermissionControlDisplay } from 'containers/WithPermissionControl/W import { User } from 'models/user/user.types'; import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; +import { openErrorNotification, openNotification } from 'utils'; import { UserActions } from 'utils/authorization'; import styles from './MobileAppConnection.module.scss'; @@ -73,6 +74,8 @@ const MobileAppConnection = observer(({ userPk }: Props) => { const [userTimeoutId, setUserTimeoutId] = useState(undefined); const [refreshTimeoutId, setRefreshTimeoutId] = useState(undefined); const [isQRBlurry, setIsQRBlurry] = useState(false); + const [isAttemptingTestNotification, setIsAttemptingTestNotification] = useState(false); + const isCurrentUser = userStore.currentUserPk === userPk; const fetchQRCode = useCallback( async (showLoader = true) => { @@ -188,17 +191,54 @@ const MobileAppConnection = observer(({ userPk }: Props) => { userAction={UserActions.UserSettingsWrite} message="You do not have permission to perform this action. Ask an admin to upgrade your permissions." > -
- - - - - {content} - -
+ +
+ + + + + {content} + +
+ {false && // temporary disable test notifications + mobileAppIsCurrentlyConnected && + isCurrentUser && ( +
+ + + + +
+ )} +
); + async function onSendTestNotification(isCritical = false) { + setIsAttemptingTestNotification(true); + + try { + await userStore.sendTestPushNotification(userPk, isCritical); + openNotification('Notification was sent'); + } catch (ex) { + openErrorNotification('There was an error sending the notification'); + } finally { + setIsAttemptingTestNotification(false); + } + } + function getParsedQRCodeValue() { try { return JSON.parse(QRCodeValue); diff --git a/grafana-plugin/src/containers/MobileAppConnection/__snapshots__/MobileAppConnection.test.tsx.snap b/grafana-plugin/src/containers/MobileAppConnection/__snapshots__/MobileAppConnection.test.tsx.snap index bd9b8cdb..981e5e54 100644 --- a/grafana-plugin/src/containers/MobileAppConnection/__snapshots__/MobileAppConnection.test.tsx.snap +++ b/grafana-plugin/src/containers/MobileAppConnection/__snapshots__/MobileAppConnection.test.tsx.snap @@ -3,2339 +3,2348 @@ exports[`MobileAppConnection if we disconnect the app, it disconnects and fetches a new QR code 1`] = `
- - Download - -
-
- - The Grafana IRM app is available on both the App Store and Google Play Store. - -
- -
-
-
-
- - Sign In - -
-
- - Open Grafana IRM mobile application and scan this code to sync it with your account. - -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + Sign In + +
+
+ + Open Grafana IRM mobile application and scan this code to sync it with your account. + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -2348,109 +2357,118 @@ exports[`MobileAppConnection if we disconnect the app, it disconnects and fetche exports[`MobileAppConnection it shows a QR code if the app isn't already connected 1`] = `
- - Download - -
-
- - The Grafana IRM app is available on both the App Store and Google Play Store. - -
- -
-
-
-
- Loading... -
- +
+ Loading... + +
+ +
+
@@ -2461,109 +2479,118 @@ exports[`MobileAppConnection it shows a QR code if the app isn't already connect exports[`MobileAppConnection it shows a loading message if it is currently disconnecting 1`] = `
- - Download - -
-
- - The Grafana IRM app is available on both the App Store and Google Play Store. - -
- -
-
-
-
- Loading... -
- +
+ Loading... + +
+ +
+
@@ -2574,109 +2601,118 @@ exports[`MobileAppConnection it shows a loading message if it is currently disco exports[`MobileAppConnection it shows a loading message if it is currently fetching the QR code 1`] = `
- - Download - -
-
- - The Grafana IRM app is available on both the App Store and Google Play Store. - -
- -
-
-
-
- Loading... -
- +
+ Loading... + +
+ +
+
@@ -2687,155 +2723,164 @@ exports[`MobileAppConnection it shows a loading message if it is currently fetch exports[`MobileAppConnection it shows a message when the mobile app is already connected 1`] = `
- - Download - -
-
- - The Grafana IRM app is available on both the App Store and Google Play Store. - -
- -
-
-
-
- - App connected -
- - - -
-
-
-
- - You can sync one application to your account. To setup new device please disconnect app first. - -
-
- - +
+
+ + You can sync one application to your account. To setup new device please disconnect app first. + +
+
+
+ + +
+
@@ -2900,103 +2945,112 @@ exports[`MobileAppConnection it shows a warning when cloud is not connected 1`] exports[`MobileAppConnection it shows an error message if there was an error disconnecting the mobile app 1`] = `
- - Download - -
-
- - The Grafana IRM app is available on both the App Store and Google Play Store. - -
- +
+ + There was an error disconnecting your mobile app. Please try again. + +
-
- - There was an error disconnecting your mobile app. Please try again. - -
`; @@ -3004,103 +3058,112 @@ exports[`MobileAppConnection it shows an error message if there was an error dis exports[`MobileAppConnection it shows an error message if there was an error fetching the QR code 1`] = `
- - Download - -
-
- - The Grafana IRM app is available on both the App Store and Google Play Store. - -
- +
+ + There was an error fetching your QR code. Please try again. + +
-
- - There was an error fetching your QR code. Please try again. - -
`; diff --git a/grafana-plugin/src/models/user/user.ts b/grafana-plugin/src/models/user/user.ts index cbfbad22..25d8f750 100644 --- a/grafana-plugin/src/models/user/user.ts +++ b/grafana-plugin/src/models/user/user.ts @@ -347,6 +347,16 @@ export class UserStore extends BaseStore { this.notificationChoices = get(response, 'actions.POST', []); } + @action + async sendTestPushNotification(userId: User['pk'], isCritical: boolean) { + return await makeRequest(`/users/${userId}/send_test_push`, { + method: 'POST', + params: { + critical: isCritical, + }, + }); + } + @action async updateNotifyByOptions() { const response = await makeRequest('/notification_policies/notify_by_options/', {});