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:
parent
39770c2266
commit
f51e6fff5e
11 changed files with 3130 additions and 2794 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
86
engine/apps/mobile_app/demo_push.py
Normal file
86
engine/apps/mobile_app/demo_push.py
Normal 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)
|
||||
6
engine/apps/mobile_app/exceptions.py
Normal file
6
engine/apps/mobile_app/exceptions.py
Normal 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.
|
||||
"""
|
||||
95
engine/apps/mobile_app/tests/test_demo_push.py
Normal file
95
engine/apps/mobile_app/tests/test_demo_push.py
Normal 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
|
||||
|
|
@ -14,6 +14,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
.notification-buttons {
|
||||
width: 100%;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-top: -6px;
|
||||
margin-left: 4px;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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/', {});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue