Test mobile push (#1933)

# What this PR does
Adds ability to send test push notification

---------

Co-authored-by: Vadim Stepanov <vadimkerr@gmail.com>
Co-authored-by: Rares Mardare <rares.mardare@grafana.com>
This commit is contained in:
Innokentii Konstantinov 2023-05-18 15:52:42 +08:00 committed by GitHub
parent 39770c2266
commit f51e6fff5e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 3130 additions and 2794 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,6 +14,11 @@
}
}
.notification-buttons {
width: 100%;
padding-top: 12px;
}
.icon {
margin-top: -6px;
margin-left: 4px;

View file

@ -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<NodeJS.Timeout>(undefined);
const [refreshTimeoutId, setRefreshTimeoutId] = useState<NodeJS.Timeout>(undefined);
const [isQRBlurry, setIsQRBlurry] = useState<boolean>(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."
>
<div className={cx('container')}>
<Block shadowed bordered withBackground className={cx('container__box')}>
<DownloadIcons />
</Block>
<Block shadowed bordered withBackground className={cx('container__box')}>
{content}
</Block>
</div>
<VerticalGroup>
<div className={cx('container')}>
<Block shadowed bordered withBackground className={cx('container__box')}>
<DownloadIcons />
</Block>
<Block shadowed bordered withBackground className={cx('container__box')}>
{content}
</Block>
</div>
{false && // temporary disable test notifications
mobileAppIsCurrentlyConnected &&
isCurrentUser && (
<div className={cx('notification-buttons')}>
<HorizontalGroup spacing={'md'} justify={'flex-end'}>
<Button
variant="secondary"
onClick={() => onSendTestNotification()}
disabled={isAttemptingTestNotification}
>
Send Test Push notification
</Button>
<Button
variant="destructive"
onClick={() => onSendTestNotification(true)}
disabled={isAttemptingTestNotification}
>
Send Critical Test Push notification
</Button>
</HorizontalGroup>
</div>
)}
</VerticalGroup>
</WithPermissionControlDisplay>
);
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);

View file

@ -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/', {});