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 0000000000..0f9946cfbf --- /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 08d2c1f295..92d0e7f83c 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 @@ def create_auth_token(cls, user: User, organization: Organization) -> Tuple["Mob 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 0000000000..1338ecdc97 --- /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 8b18f7e519..dec51193d6 100644 --- a/engine/apps/mobile_app/tasks.py +++ b/engine/apps/mobile_app/tasks.py @@ -67,6 +67,58 @@ def _create_error_log_record(): logger.error(f"Error while sending a mobile push notification: user {user_pk} has no device set up") return + 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 + from apps.oss_installation.models import CloudConnector + + if not CloudConnector.objects.exists(): + _create_error_log_record() + logger.error(f"Error while sending a mobile push notification: not connected to cloud") + return + + try: + response = send_push_notification_to_fcm_relay(message) + logger.debug(f"FCM relay response: {response}") + except HTTPError as e: + if status.HTTP_400_BAD_REQUEST <= e.response.status_code < status.HTTP_500_INTERNAL_SERVER_ERROR: + # do not retry on HTTP client errors (4xx errors) + _create_error_log_record() + logger.error( + f"Error while sending a mobile push notification: HTTP client error {e.response.status_code}" + ) + return + else: + raise + else: + # https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream + response = device_to_notify.send_message(message) + logger.debug(f"FCM response: {response}") + + if isinstance(response, FirebaseError): + raise response + + +def send_push_notification_to_fcm_relay(message): + """ + Send push notification to FCM relay on cloud instance: apps.mobile_app.fcm_relay.FCMRelayView + """ + url = create_engine_url("mobile_app/v1/fcm_relay", override_base=settings.GRAFANA_CLOUD_ONCALL_API_URL) + + response = requests.post( + url, headers={"Authorization": live_settings.GRAFANA_CLOUD_ONCALL_TOKEN}, json=json.loads(str(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() @@ -87,8 +139,18 @@ def _create_error_log_record(): alert_body = f"Status: {status_verbose}, alerts: {alerts_count_str}" - message = Message( - token=device_to_notify.registration_id, + 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 @@ -103,6 +165,25 @@ def _create_error_log_record(): "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( @@ -111,9 +192,10 @@ def _create_error_log_record(): 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, + # 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", @@ -122,49 +204,3 @@ def _create_error_log_record(): ), ), ) - - logger.debug(f"Sending push notification with message: {message}; thread-id: {thread_id};") - - if settings.IS_OPEN_SOURCE: - # FCM relay uses cloud connection to send push notifications - from apps.oss_installation.models import CloudConnector - - if not CloudConnector.objects.exists(): - _create_error_log_record() - logger.error(f"Error while sending a mobile push notification: not connected to cloud") - return - - try: - response = send_push_notification_to_fcm_relay(message) - logger.debug(f"FCM relay response: {response}") - except HTTPError as e: - if status.HTTP_400_BAD_REQUEST <= e.response.status_code < status.HTTP_500_INTERNAL_SERVER_ERROR: - # do not retry on HTTP client errors (4xx errors) - _create_error_log_record() - logger.error( - f"Error while sending a mobile push notification: HTTP client error {e.response.status_code}" - ) - return - else: - raise - else: - # https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream - response = device_to_notify.send_message(message) - logger.debug(f"FCM response: {response}") - - if isinstance(response, FirebaseError): - raise response - - -def send_push_notification_to_fcm_relay(message): - """ - Send push notification to FCM relay on cloud instance: apps.mobile_app.fcm_relay.FCMRelayView - """ - url = create_engine_url("mobile_app/v1/fcm_relay", override_base=settings.GRAFANA_CLOUD_ONCALL_API_URL) - - response = requests.post( - url, headers={"Authorization": live_settings.GRAFANA_CLOUD_ONCALL_TOKEN}, json=json.loads(str(message)) - ) - response.raise_for_status() - - return response diff --git a/engine/apps/mobile_app/tests/test_notify_user.py b/engine/apps/mobile_app/tests/test_notify_user.py index 8b64eda303..8e3d548758 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 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 0000000000..de14d9dfc8 --- /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 2f0433d911..5d4898e92c 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 @@ 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 1625744098..035b68fabe 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 @@ def delete(self, request): 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 a79e18b0df..de2ad0a3b5 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -55,7 +55,7 @@ ) 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(user, organization): 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):