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:
parent
1ac39c2879
commit
088414c4d3
15 changed files with 356 additions and 24 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
205
engine/apps/mobile_app/tests/test_fcm_endpoint.py
Normal file
205
engine/apps/mobile_app/tests/test_fcm_endpoint.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue