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