Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mobile app settings backend #1571

Merged
merged 14 commits into from
Mar 22, 2023
Merged
30 changes: 30 additions & 0 deletions engine/apps/mobile_app/migrations/0003_mobileappusersettings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 3.2.18 on 2023-03-17 17:58

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', 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.6, validators=[django.core.validators.MinValueValidator(0.0), django.core.validators.MaxValueValidator(1.0)])),
('default_notification_volume_override', models.BooleanField(default=False)),
('critical_notification_sound_name', models.CharField(default='default', max_length=100)),
('critical_notification_volume_type', models.CharField(choices=[('constant', 'Constant'), ('intensifying', 'Intensifying')], default='constant', max_length=50)),
('critical_notification_volume', models.FloatField(default=0.6, validators=[django.core.validators.MinValueValidator(0.0), django.core.validators.MaxValueValidator(1.0)])),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='user_management.user')),
],
),
]
31 changes: 31 additions & 0 deletions engine/apps/mobile_app/models.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -68,3 +69,33 @@ def create_auth_token(cls, user: User, organization: Organization) -> Tuple["Mob
organization=organization,
)
return instance, token_string


class MobileAppUserSettings(models.Model):
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") # TODO: check sound name
vstpme marked this conversation as resolved.
Show resolved Hide resolved
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.6
)
default_notification_volume_override = models.BooleanField(default=False)

# Push notification settings for critical notifications
critical_notification_sound_name = models.CharField(max_length=100, default="default") # TODO: check sound name
vstpme marked this conversation as resolved.
Show resolved Hide resolved
critical_notification_volume_type = models.CharField(
max_length=50, choices=VolumeType.choices, default=VolumeType.CONSTANT
)
critical_notification_volume = models.FloatField(
validators=[validators.MinValueValidator(0.0), validators.MaxValueValidator(1.0)], default=0.6
)
17 changes: 17 additions & 0 deletions engine/apps/mobile_app/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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",
"critical_notification_sound_name",
"critical_notification_volume_type",
"critical_notification_volume",
)
126 changes: 77 additions & 49 deletions engine/apps/mobile_app/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
vstpme marked this conversation as resolved.
Show resolved Hide resolved
# 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()

Expand All @@ -87,8 +139,18 @@ def _create_error_log_record():

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.critical_notification_volume if critical else None
vstpme marked this conversation as resolved.
Show resolved Hide resolved
apns_sound_name = (
mobile_app_user_settings.critical_notification_sound_name
if critical
else mobile_app_user_settings.default_notification_sound_name
)

message = Message(
vstpme marked this conversation as resolved.
Show resolved Hide resolved
token=device_to_notify.registration_id,
token=registration_id,
data={
# from the docs..
# A dictionary of data fields (optional). All keys and values in the dictionary must be strings
Expand All @@ -103,6 +165,16 @@ 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,
"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
),
"critical_notification_sound_name": mobile_app_user_settings.critical_notification_sound_name,
"critical_notification_volume_type": mobile_app_user_settings.critical_notification_volume_type,
"critical_notification_volume": str(mobile_app_user_settings.critical_notification_volume),
},
apns=APNSConfig(
payload=APNSPayload(
Expand All @@ -111,9 +183,9 @@ 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,
critical=critical,
name=apns_sound_name,
volume=apns_volume,
),
custom_data={
"interruption-level": "critical" if critical else "time-sensitive",
Expand All @@ -123,48 +195,4 @@ 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
return message
vstpme marked this conversation as resolved.
Show resolved Hide resolved
60 changes: 59 additions & 1 deletion engine/apps/mobile_app/tests/test_notify_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
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.tasks import _get_fcm_message, notify_user_async
from apps.oss_installation.models import CloudConnector

MOBILE_APP_BACKEND_ID = 5
Expand Down Expand Up @@ -209,3 +209,61 @@ 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"
assert message.data["default_notification_volume_type"] == "constant"
assert message.data["default_notification_volume_override"] == "false"
assert message.data["default_notification_volume"] == "0.6"
assert message.data["critical_notification_sound_name"] == "default"
assert message.data["critical_notification_volume_type"] == "constant"
assert message.data["critical_notification_volume"] == "0.6"

# 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"
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"
assert message.data["default_notification_volume_type"] == "constant"
assert message.data["default_notification_volume_override"] == "false"
assert message.data["default_notification_volume"] == "0.6"
assert message.data["critical_notification_sound_name"] == "default"
assert message.data["critical_notification_volume_type"] == "constant"
assert message.data["critical_notification_volume"] == "0.6"

# 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"
assert apns_sound.volume == 0.6
49 changes: 49 additions & 0 deletions engine/apps/mobile_app/tests/test_user_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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",
"default_notification_volume_type": "constant",
"default_notification_volume": 0.6,
"default_notification_volume_override": False,
"critical_notification_sound_name": "default",
"critical_notification_volume_type": "constant",
"critical_notification_volume": 0.6,
}


@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,
"critical_notification_sound_name": "test_critical",
"critical_notification_volume_type": "intensifying",
"critical_notification_volume": 1,
}

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
3 changes: 2 additions & 1 deletion engine/apps/mobile_app/urls.py
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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 += [
Expand Down
Loading