Mobile app settings backend (#1571)

# What this PR does
Adds mobile app settings support to OnCall backend.

- Adds a new Django model `MobileAppUserSettings` to store push
notification settings
- Adds a new endpoint `/mobile_app/v1/user_settings` to fetch/update
settings from the mobile app

Some additional info on implementation: at first I wanted to extend the
messaging backend system to allow storing / retrieving per-user data and
implement mobile app settings based on those changes. After some thought
I decided not to extend the messaging backend system and have this as
functionality specific to the `mobile_app` Django app. Currently the
messaging backend system is used by the backend and plugin UI, but
mobile app settings are specific only to the mobile app and not
configurable in the plugin UI.

**tldr: wanted to extend messaging backend system, but decided not to do
that**

# Usage

## Get settings via API

`GET /mobile_app/v1/user_settings`
Example response:

```json
{
  "default_notification_sound_name": "default_sound",  # sound name without file extension
  "default_notification_volume_type": "constant",
  "default_notification_volume": 0.8,
  "default_notification_volume_override": false,
  "important_notification_sound_name": "default_sound_important",  # sound name without file extension
  "important_notification_volume_type": "constant",
  "important_notification_volume": 0.8,
  "important_notification_override_dnd": true
}
```

## Update settings via API
`PUT /mobile_app/v1/user_settings` - see example response above for
payload shape.

Note that sound names must be passed without file extension. When
sending push notifications, the backend will add `.mp3` to sound names
and pass it to push notification data for Android. For iOS, sound names
will be suffixed with `.aiff` to be used by APNS.


## Get settings from notification data for Android
All the settings from example response will be available in push
notification data (along with `orgId`, `alertGroupId`, `title`, etc.).
Fields `default_notification_volume`,
`default_notification_volume_override` and
`important_notification_volume` , `important_notification_override_dnd`
will be converted to strings due to FCM limitations.
Fields `default_notification_sound_name` and
`important_notification_sound_name` will be suffixed with `.mp3` in push
notification data.

## iOS limitations
While Android push notifications are handled purely on the mobile app
side, iOS notifications are sent via APNS which imposes some
limitations.
- Notification volume cannot be overridden for non-critical
notifications (so fields `default_notification_volume_override` and
`default_notification_volume` will be disregarded for iOS notifications)
- It's not possible to control volume type (i.e. "constant" vs
"intensifying") via APNS. A possible workaround is to have different
sound files for "constant" and "intensifying" and pass that as
`default_notification_sound_name` / `important_notification_sound_name`.

# Which issue(s) this PR fixes
Related to https://github.com/grafana/oncall-private/issues/1602

# Checklist

- [x] Tests updated
- [x] No changelog updates since the changes are not user-facing
This commit is contained in:
Vadim Stepanov 2023-03-22 14:47:18 +00:00 committed by GitHub
parent 137758be4f
commit ed5b5e153d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 356 additions and 62 deletions

View file

@ -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')),
],
),
]

View file

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

View file

@ -0,0 +1,18 @@
from rest_framework import serializers
from apps.mobile_app.models import MobileAppUserSettings
class MobileAppUserSettingsSerializer(serializers.ModelSerializer):
class Meta:
model = MobileAppUserSettings
fields = (
"default_notification_sound_name",
"default_notification_volume_type",
"default_notification_volume",
"default_notification_volume_override",
"important_notification_sound_name",
"important_notification_volume_type",
"important_notification_volume",
"important_notification_override_dnd",
)

View file

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

View file

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

View file

@ -0,0 +1,51 @@
import pytest
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
@pytest.mark.django_db
def test_user_settings_get(make_organization_and_user_with_mobile_app_auth_token):
organization, user, auth_token = make_organization_and_user_with_mobile_app_auth_token()
client = APIClient()
url = reverse("mobile_app:user_settings")
response = client.get(url, HTTP_AUTHORIZATION=auth_token)
assert response.status_code == status.HTTP_200_OK
# Check the default values are correct
assert response.json() == {
"default_notification_sound_name": "default_sound",
"default_notification_volume_type": "constant",
"default_notification_volume": 0.8,
"default_notification_volume_override": False,
"important_notification_sound_name": "default_sound_important",
"important_notification_volume_type": "constant",
"important_notification_volume": 0.8,
"important_notification_override_dnd": True,
}
@pytest.mark.django_db
def test_user_settings_put(make_organization_and_user_with_mobile_app_auth_token):
organization, user, auth_token = make_organization_and_user_with_mobile_app_auth_token()
client = APIClient()
url = reverse("mobile_app:user_settings")
data = {
"default_notification_sound_name": "test_default",
"default_notification_volume_type": "intensifying",
"default_notification_volume": 1,
"default_notification_volume_override": True,
"important_notification_sound_name": "test_important",
"important_notification_volume_type": "intensifying",
"important_notification_volume": 1,
"important_notification_override_dnd": False,
}
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=auth_token)
assert response.status_code == status.HTTP_200_OK
# Check the values are updated correctly
assert response.json() == data

View file

@ -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 += [

View file

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

View file

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