Add multi-stack support for mobile app (#3500)

# What this PR does
Allow creating multiple mobile devices with same `registration_id` for
different users (multi-stack support)

## Which issue(s) this PR fixes
https://github.com/grafana/oncall/issues/3452

## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)
This commit is contained in:
Yulya Artyukhina 2023-12-13 10:00:18 +01:00 committed by GitHub
parent 1ac39c2879
commit 088414c4d3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 356 additions and 24 deletions

View file

@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Added
- Add backend for multi-stack support for mobile-app @Ferril ([#3500](https://github.com/grafana/oncall/pull/3500))
## v1.3.78 (2023-12-12)
### Changed

View file

@ -8,7 +8,7 @@ from firebase_admin.messaging import APNSPayload, Aps, ApsAlert, CriticalSound,
from apps.mobile_app.exceptions import DeviceNotSet
from apps.mobile_app.types import FCMMessageData, MessageType, Platform
from apps.mobile_app.utils import construct_fcm_message, send_push_notification
from apps.mobile_app.utils import add_stack_slug_to_message_title, construct_fcm_message, send_push_notification
from apps.user_management.models import User
if typing.TYPE_CHECKING:
@ -47,7 +47,8 @@ def _get_test_escalation_fcm_message(user: User, device_to_notify: "FCMDevice",
apns_sound_name = mobile_app_user_settings.get_notification_sound_name(message_type, Platform.IOS)
fcm_message_data: FCMMessageData = {
"title": get_test_push_title(critical),
"title": add_stack_slug_to_message_title(get_test_push_title(critical), user.organization),
"orgName": user.organization.stack_slug,
# 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.get_notification_sound_name(
MessageType.DEFAULT, Platform.ANDROID

View file

@ -1,6 +1,8 @@
import typing
from fcm_django.api.rest_framework import FCMDeviceSerializer as BaseFCMDeviceSerializer
from rest_framework import serializers
from rest_framework.serializers import ValidationError
from apps.mobile_app.models import MobileAppUserSettings
from common.api_helpers.custom_fields import TimeZoneField
@ -43,3 +45,38 @@ class MobileAppUserSettingsSerializer(serializers.ModelSerializer):
if option not in notification_timing_options:
raise serializers.ValidationError(detail="invalid timing options")
return going_oncall_notification_timing
class FCMDeviceSerializer(BaseFCMDeviceSerializer):
def validate(self, attrs):
"""
Overrides `validate` method from BaseFCMDeviceSerializer to allow different users have same device
`registration_id` (multi-stack support).
Removed deactivating devices with the same `registration_id` during validation.
"""
devices = None
request_method = None
request = self.context["request"]
if self.initial_data.get("registration_id", None):
request_method = "update" if self.instance else "create"
else:
if request.method in ["PUT", "PATCH"]:
request_method = "update"
elif request.method == "POST":
request_method = "create"
Device = self.Meta.model
# unique together with registration_id and user
user = request.user
registration_id = attrs.get("registration_id")
if request_method == "update":
if registration_id:
devices = Device.objects.filter(registration_id=registration_id, user=user).exclude(id=self.instance.id)
elif request_method == "create":
devices = Device.objects.filter(user=user, registration_id=registration_id)
if devices:
raise ValidationError({"registration_id": "This field must be unique per us."})
return attrs

View file

@ -12,7 +12,12 @@ from django.utils import timezone
from firebase_admin.messaging import APNSPayload, Aps, ApsAlert, CriticalSound, Message
from apps.mobile_app.types import FCMMessageData, MessageType, Platform
from apps.mobile_app.utils import MAX_RETRIES, construct_fcm_message, send_push_notification
from apps.mobile_app.utils import (
MAX_RETRIES,
add_stack_slug_to_message_title,
construct_fcm_message,
send_push_notification,
)
from apps.schedules.models.on_call_schedule import OnCallSchedule, ScheduleEvent
from apps.user_management.models import User
from common.cache import ensure_cache_key_allocates_to_the_same_hash_slot
@ -72,8 +77,9 @@ def _get_fcm_message(
notification_subtitle = _get_notification_subtitle(schedule, schedule_event, mobile_app_user_settings)
data: FCMMessageData = {
"title": notification_title,
"title": add_stack_slug_to_message_title(notification_title, user.organization),
"subtitle": notification_subtitle,
"orgName": user.organization.stack_slug,
"info_notification_sound_name": mobile_app_user_settings.get_notification_sound_name(
MessageType.INFO, Platform.ANDROID
),

View file

@ -8,7 +8,12 @@ from firebase_admin.messaging import APNSPayload, Aps, ApsAlert, CriticalSound,
from apps.alerts.models import AlertGroup
from apps.mobile_app.alert_rendering import get_push_notification_subtitle
from apps.mobile_app.types import FCMMessageData, MessageType, Platform
from apps.mobile_app.utils import MAX_RETRIES, construct_fcm_message, send_push_notification
from apps.mobile_app.utils import (
MAX_RETRIES,
add_stack_slug_to_message_title,
construct_fcm_message,
send_push_notification,
)
from apps.user_management.models import User
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
@ -41,7 +46,7 @@ def _get_fcm_message(alert_group: AlertGroup, user: User, device_to_notify: "FCM
apns_sound_name = mobile_app_user_settings.get_notification_sound_name(message_type, Platform.IOS)
fcm_message_data: FCMMessageData = {
"title": alert_title,
"title": add_stack_slug_to_message_title(alert_title, alert_group.channel.organization),
"subtitle": alert_subtitle,
"orgId": alert_group.channel.organization.public_primary_key,
"orgName": alert_group.channel.organization.stack_slug,

View file

@ -10,7 +10,12 @@ from django.utils import timezone
from firebase_admin.messaging import APNSPayload, Aps, ApsAlert, CriticalSound, Message
from apps.mobile_app.types import FCMMessageData, MessageType, Platform
from apps.mobile_app.utils import MAX_RETRIES, construct_fcm_message, send_push_notification
from apps.mobile_app.utils import (
MAX_RETRIES,
add_stack_slug_to_message_title,
construct_fcm_message,
send_push_notification,
)
from apps.schedules.models import ShiftSwapRequest
from apps.user_management.models import User
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
@ -116,8 +121,9 @@ def _get_fcm_message(
route = f"/schedules/{shift_swap_request.schedule.public_primary_key}/ssrs/{shift_swap_request.public_primary_key}"
data: FCMMessageData = {
"title": notification_title,
"title": add_stack_slug_to_message_title(notification_title, user.organization),
"subtitle": notification_subtitle,
"orgName": user.organization.stack_slug,
"route": route,
"info_notification_sound_name": mobile_app_user_settings.get_notification_sound_name(
MessageType.INFO, Platform.ANDROID

View file

@ -18,6 +18,7 @@ from apps.mobile_app.tasks.going_oncall_notification import (
conditionally_send_going_oncall_push_notifications_for_schedule,
)
from apps.mobile_app.types import MessageType, Platform
from apps.mobile_app.utils import add_stack_slug_to_message_title
from apps.schedules.models import OnCallScheduleCalendar, OnCallScheduleICal, OnCallScheduleWeb
from apps.schedules.models.on_call_schedule import ScheduleEvent
@ -182,6 +183,13 @@ def test_get_fcm_message(
make_user_for_organization,
make_schedule,
):
organization = make_organization()
user_tz = "Europe/Amsterdam"
user = make_user_for_organization(organization)
user_pk = user.public_primary_key
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
notification_thread_id = f"{schedule.public_primary_key}:{user_pk}:going-oncall"
mock_fcm_message = "mncvmnvcmnvcnmvcmncvmn"
mock_notification_title = "asdfasdf"
mock_notification_subtitle = "9:06\u202fAM - 9:06\u202fAM\nSchedule XYZ"
@ -192,13 +200,6 @@ def test_get_fcm_message(
mock_get_notification_title.return_value = mock_notification_title
mock_get_notification_subtitle.return_value = mock_notification_subtitle
organization = make_organization()
user_tz = "Europe/Amsterdam"
user = make_user_for_organization(organization)
user_pk = user.public_primary_key
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
notification_thread_id = f"{schedule.public_primary_key}:{user_pk}:going-oncall"
schedule_event = _create_schedule_event(
timezone.now(),
timezone.now(),
@ -214,8 +215,9 @@ def test_get_fcm_message(
maus = MobileAppUserSettings.objects.create(user=user, time_zone=user_tz)
data = {
"title": mock_notification_title,
"title": add_stack_slug_to_message_title(mock_notification_title, organization),
"subtitle": mock_notification_subtitle,
"orgName": organization.stack_slug,
"info_notification_sound_name": maus.get_notification_sound_name(MessageType.INFO, Platform.ANDROID),
"info_notification_volume_type": maus.info_notification_volume_type,
"info_notification_volume": str(maus.info_notification_volume),

View file

@ -19,6 +19,7 @@ from apps.mobile_app.tasks.new_shift_swap_request import (
notify_shift_swap_requests,
notify_user_about_shift_swap_request,
)
from apps.mobile_app.utils import add_stack_slug_to_message_title
from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb, ShiftSwapRequest
from apps.user_management.models import User
from apps.user_management.models.user import default_working_hours
@ -268,7 +269,7 @@ def test_notify_user_about_shift_swap_request(
message: Message = mock_send_push_notification.call_args.args[1]
assert message.data["type"] == "oncall.info"
assert message.data["title"] == "New shift swap request"
assert message.data["title"] == add_stack_slug_to_message_title("New shift swap request", organization)
assert message.data["subtitle"] == "John Doe, Test Schedule"
assert (
message.data["route"]
@ -467,7 +468,9 @@ def test_notify_beneficiary_about_taken_shift_swap_request(
message: Message = mock_send_push_notification.call_args.args[1]
assert message.data["type"] == "oncall.info"
assert message.data["title"] == "Your shift swap request has been taken"
assert message.data["title"] == add_stack_slug_to_message_title(
"Your shift swap request has been taken", organization
)
assert message.data["subtitle"] == schedule_name
assert (
message.data["route"]

View file

@ -2,6 +2,7 @@ import pytest
from apps.mobile_app.demo_push import _get_test_escalation_fcm_message, get_test_push_title
from apps.mobile_app.models import FCMDevice, MobileAppUserSettings
from apps.mobile_app.utils import add_stack_slug_to_message_title
@pytest.mark.django_db
@ -33,7 +34,7 @@ def test_test_escalation_fcm_message_user_settings(
# Check expected test push content
assert message.apns.payload.aps.badge is None
assert message.apns.payload.aps.alert.title == get_test_push_title(critical=False)
assert message.data["title"] == get_test_push_title(critical=False)
assert message.data["title"] == add_stack_slug_to_message_title(get_test_push_title(critical=False), organization)
assert message.data["type"] == "oncall.message"
@ -67,7 +68,7 @@ def test_escalation_fcm_message_user_settings_critical(
# Check expected test push content
assert message.apns.payload.aps.badge is None
assert message.apns.payload.aps.alert.title == get_test_push_title(critical=True)
assert message.data["title"] == get_test_push_title(critical=True)
assert message.data["title"] == add_stack_slug_to_message_title(get_test_push_title(critical=True), organization)
assert message.data["type"] == "oncall.critical_message"
@ -93,4 +94,4 @@ def test_escalation_fcm_message_user_settings_critical_override_dnd_disabled(
# Check expected test push content
assert message.apns.payload.aps.badge is None
assert message.apns.payload.aps.alert.title == get_test_push_title(critical=True)
assert message.data["title"] == get_test_push_title(critical=True)
assert message.data["title"] == add_stack_slug_to_message_title(get_test_push_title(critical=True), organization)

View file

@ -0,0 +1,205 @@
import pytest
from django.urls import reverse
from fcm_django.models import DeviceType
from rest_framework import status
from rest_framework.test import APIClient
from apps.mobile_app.models import FCMDevice
@pytest.mark.django_db
def test_create_update_fcm_device(make_organization_and_user_with_mobile_app_auth_token):
organization, user, verification_token = make_organization_and_user_with_mobile_app_auth_token()
registration_id = "test_registration_id"
client = APIClient()
url = reverse("mobile_app:fcm-list")
# create new device
data = {
"registration_id": registration_id,
"type": DeviceType.ANDROID,
"name": "Test",
}
assert FCMDevice.objects.filter(registration_id=data["registration_id"]).count() == 0
response = client.post(url, data=data, HTTP_AUTHORIZATION=verification_token)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["registration_id"] == data["registration_id"]
assert response.json()["type"] == data["type"]
assert response.json()["name"] == data["name"]
assert response.json()["active"] is True
devices = FCMDevice.objects.filter(registration_id=data["registration_id"])
assert devices.count() == 1
device = devices.first()
assert device.user == user
# update using post and registration_id in data
data["name"] = "Renamed"
response = client.post(url, data=data, HTTP_AUTHORIZATION=verification_token)
assert response.status_code == status.HTTP_200_OK
assert response.json()["registration_id"] == data["registration_id"]
assert response.json()["type"] == data["type"]
assert response.json()["name"] == data["name"]
assert response.json()["active"] is True
assert FCMDevice.objects.filter(registration_id=data["registration_id"]).count() == 1
device.refresh_from_db()
assert device.user == user
# update using put
data["name"] = "Renamed2"
data["active"] = False
response = client.put(url + f"/{registration_id}", data=data, HTTP_AUTHORIZATION=verification_token)
assert response.status_code == status.HTTP_200_OK
assert response.json()["registration_id"] == data["registration_id"]
assert response.json()["type"] == data["type"]
assert response.json()["name"] == data["name"]
assert response.json()["active"] is False
assert FCMDevice.objects.filter(registration_id=data["registration_id"]).count() == 1
device.refresh_from_db()
assert device.user == user
@pytest.mark.django_db
def test_fcm_device_multiple_users(
make_organization_and_user_with_mobile_app_auth_token,
make_organization_and_user,
make_mobile_app_auth_token_for_user,
):
_, user_1, verification_token_1 = make_organization_and_user_with_mobile_app_auth_token()
organization_2, user_2 = make_organization_and_user()
_, verification_token_2 = make_mobile_app_auth_token_for_user(user_2, organization_2)
registration_id = "test_registration_id"
client = APIClient()
url = reverse("mobile_app:fcm-list")
# create new device
data = {
"registration_id": registration_id,
"type": DeviceType.ANDROID,
"name": "Test",
}
assert FCMDevice.objects.filter(registration_id=data["registration_id"]).count() == 0
# create device for user_1
response = client.post(url, data=data, HTTP_AUTHORIZATION=verification_token_1)
assert response.status_code == status.HTTP_201_CREATED
devices = FCMDevice.objects.filter(registration_id=data["registration_id"])
assert devices.count() == 1
device_1 = devices.filter(user=user_1).first()
assert device_1 is not None
# create device for user_2
response = client.post(url, data=data, HTTP_AUTHORIZATION=verification_token_2)
assert response.status_code == status.HTTP_201_CREATED
devices = FCMDevice.objects.filter(registration_id=data["registration_id"])
assert devices.count() == 2
device_2 = devices.filter(user=user_2).first()
assert device_2 is not None
# Check that the both devices are active and device_1 was not changed
device_1.refresh_from_db()
assert device_1.active is True
assert device_2.active is True
assert device_1.user == user_1
# update device_1 using post and registration_id in data
data_to_update = data.copy()
data_to_update["name"] = "Renamed"
response = client.post(url, data=data_to_update, HTTP_AUTHORIZATION=verification_token_1)
assert response.status_code == status.HTTP_200_OK
# Check that device_2 was not changed
device_2.refresh_from_db()
assert device_2.active is True
assert device_2.name == data["name"]
assert device_2.user == user_2
# update device_2 using put
data_to_update["name"] = "Renamed2"
data_to_update["active"] = False
response = client.put(url + f"/{registration_id}", data=data_to_update, HTTP_AUTHORIZATION=verification_token_2)
assert response.status_code == status.HTTP_200_OK
assert response.json()["name"] == data_to_update["name"]
assert response.json()["active"] is False
device_2.refresh_from_db()
assert device_2.active is False
assert device_2.name == data_to_update["name"]
assert device_2.user == user_2
# Check that device_1 was not changed
device_1.refresh_from_db()
assert device_1.active is True
assert device_1.name != data_to_update["name"]
assert device_1.user == user_1
# Delete device_1
response = client.delete(url + f"/{registration_id}", HTTP_AUTHORIZATION=verification_token_1)
assert response.status_code == status.HTTP_204_NO_CONTENT
with pytest.raises(FCMDevice.DoesNotExist):
device_1.refresh_from_db()
# Check that device_2 was not changed
device_2.refresh_from_db()
assert device_2.active is False
assert device_2.name == data_to_update["name"]
assert device_2.user == user_2
@pytest.mark.django_db
def test_fcm_device_owner(
make_organization_and_user_with_mobile_app_auth_token,
make_organization_and_user,
make_mobile_app_auth_token_for_user,
):
_, user_1, verification_token_1 = make_organization_and_user_with_mobile_app_auth_token()
organization_2, user_2 = make_organization_and_user()
_, verification_token_2 = make_mobile_app_auth_token_for_user(user_2, organization_2)
registration_id = "test_registration_id"
client = APIClient()
url = reverse("mobile_app:fcm-list")
device = FCMDevice.objects.create(registration_id=registration_id, user=user_2)
response = client.get(url, HTTP_AUTHORIZATION=verification_token_1)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) == 0
response = client.get(url, HTTP_AUTHORIZATION=verification_token_2)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) == 1
response = client.get(url + f"/{registration_id}", HTTP_AUTHORIZATION=verification_token_1)
assert response.status_code == status.HTTP_404_NOT_FOUND
response = client.get(url + f"/{registration_id}", HTTP_AUTHORIZATION=verification_token_2)
assert response.status_code == status.HTTP_200_OK
assert response.json()["registration_id"] == registration_id
response = client.delete(url + f"/{registration_id}", HTTP_AUTHORIZATION=verification_token_1)
assert response.status_code == status.HTTP_404_NOT_FOUND
device.refresh_from_db()
response = client.delete(url + f"/{registration_id}", HTTP_AUTHORIZATION=verification_token_2)
assert response.status_code == status.HTTP_204_NO_CONTENT
with pytest.raises(FCMDevice.DoesNotExist):
device.refresh_from_db()

View file

@ -41,6 +41,7 @@ def test_mobile_app_auth_token(
assert response.data["organization_id"] == organization.id
assert response.data["created_at"] == original_auth_token_created_at
assert response.data["revoked_at"] is None
assert response.data["stack_slug"] == organization.stack_slug
# can only ever have one mobile app auth token.. old one gets deleted if we try
# creating a new one

View file

@ -6,6 +6,7 @@ from requests import HTTPError
from apps.mobile_app import utils
from apps.mobile_app.models import FCMDevice
from apps.mobile_app.utils import add_stack_slug_to_message_title
from apps.oss_installation.models import CloudConnector
MOBILE_APP_BACKEND_ID = 5
@ -159,3 +160,13 @@ def test_send_push_notification_oss_fcm_relay_returns_server_error(
mock_error_cb.assert_not_called()
mock_send_push_notification_to_fcm_relay.assert_called_once_with(mock_message)
@pytest.mark.django_db
def test_add_stack_slug_to_message_title(make_organization):
test_stack_slug = "my-org"
organization = make_organization(stack_slug=test_stack_slug)
some_message_title = "Test title"
expected_result = "[my-org] Test title"
result = add_stack_slug_to_message_title(some_message_title, organization)
assert result == expected_result

View file

@ -15,6 +15,7 @@ from common.api_helpers.utils import create_engine_url
if typing.TYPE_CHECKING:
from apps.mobile_app.models import FCMDevice
from apps.user_management.models import Organization
MAX_RETRIES = 1 if settings.DEBUG else 10
@ -135,3 +136,7 @@ def construct_fcm_message(
},
),
)
def add_stack_slug_to_message_title(title: str, organization: "Organization") -> str:
return f"[{organization.stack_slug}] {title}"

View file

@ -4,6 +4,7 @@ import typing
import jwt
import requests
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.utils import timezone
from fcm_django.api.rest_framework import FCMDeviceAuthorizedViewSet as BaseFCMDeviceAuthorizedViewSet
from rest_framework import mixins, status, viewsets
@ -13,8 +14,8 @@ 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, MobileAppUserSettings
from apps.mobile_app.serializers import MobileAppUserSettingsSerializer
from apps.mobile_app.models import FCMDevice, MobileAppAuthToken, MobileAppUserSettings
from apps.mobile_app.serializers import FCMDeviceSerializer, MobileAppUserSettingsSerializer
if typing.TYPE_CHECKING:
from apps.user_management.models import Organization, User
@ -26,6 +27,41 @@ logger.setLevel(logging.DEBUG)
class FCMDeviceAuthorizedViewSet(BaseFCMDeviceAuthorizedViewSet):
authentication_classes = (MobileAppAuthTokenAuthentication,)
serializer_class = FCMDeviceSerializer
model = FCMDevice
def create(self, request, *args, **kwargs):
"""Overrides `create` from BaseFCMDeviceAuthorizedViewSet to add filtering by user on getting instance"""
serializer = None
is_update = False
if settings.FCM_DJANGO_SETTINGS["UPDATE_ON_DUPLICATE_REG_ID"] and "registration_id" in request.data:
instance = self.model.objects.filter(
registration_id=request.data["registration_id"], user=self.request.user
).first()
if instance:
serializer = self.get_serializer(instance, data=request.data)
is_update = True
if not serializer:
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
if is_update:
self.perform_update(serializer)
return Response(serializer.data)
else:
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def get_object(self):
"""Overrides original method to add filtering by user"""
try:
obj = self.model.objects.get(registration_id=self.kwargs["registration_id"], user=self.request.user)
except ObjectDoesNotExist:
raise NotFound
# May raise a permission denied
self.check_object_permissions(self.request, obj)
return obj
class MobileAppAuthTokenAPIView(APIView):
@ -43,6 +79,7 @@ class MobileAppAuthTokenAPIView(APIView):
"organization_id": token.organization_id,
"created_at": token.created_at,
"revoked_at": token.revoked_at,
"stack_slug": self.request.auth.organization.stack_slug,
}
return Response(response, status=status.HTTP_200_OK)
@ -55,7 +92,12 @@ class MobileAppAuthTokenAPIView(APIView):
pass
instance, token = MobileAppAuthToken.create_auth_token(self.request.user, self.request.user.organization)
data = {"id": instance.pk, "token": token, "created_at": instance.created_at}
data = {
"id": instance.pk,
"token": token,
"created_at": instance.created_at,
"stack_slug": self.request.auth.organization.stack_slug,
}
return Response(data, status=status.HTTP_201_CREATED)
def delete(self, request):

View file

@ -8,6 +8,7 @@ class OrganizationFactory(factory.DjangoModelFactory):
org_title = factory.Faker("word")
stack_id = UniqueFaker("pyint")
org_id = UniqueFaker("pyint")
stack_slug = factory.Faker("word")
class Meta:
model = Organization