From 1ac39c28793522eb34f8c6775d318c683032537f Mon Sep 17 00:00:00 2001 From: Dominik Broj Date: Wed, 13 Dec 2023 09:12:19 +0100 Subject: [PATCH 1/3] add missing borders (#3560) # What this PR does add missing borders ## Which issue(s) this PR fixes https://github.com/grafana/oncall/issues/3463 ## 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) --- .../src/pages/integration/Integration.module.scss | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/grafana-plugin/src/pages/integration/Integration.module.scss b/grafana-plugin/src/pages/integration/Integration.module.scss index 14a17809..1777d34a 100644 --- a/grafana-plugin/src/pages/integration/Integration.module.scss +++ b/grafana-plugin/src/pages/integration/Integration.module.scss @@ -112,10 +112,17 @@ $LARGE-MARGIN: 24px; } } +:global(.theme-light) { + .input { + border: var(--border-weak); + } +} + .input { flex-grow: 1; max-width: calc(100% - 80px); } + .input-with-toggler { max-width: calc(100% - 134px); } @@ -213,6 +220,7 @@ $LARGE-MARGIN: 24px; .inline-switch { height: 34px; + border: var(--border-weak); } .contactpoints { From 088414c4d3b37f2a80331430d395a67df048f7d0 Mon Sep 17 00:00:00 2001 From: Yulya Artyukhina Date: Wed, 13 Dec 2023 10:00:18 +0100 Subject: [PATCH 2/3] 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) --- CHANGELOG.md | 6 + engine/apps/mobile_app/demo_push.py | 5 +- engine/apps/mobile_app/serializers.py | 37 ++++ .../tasks/going_oncall_notification.py | 10 +- .../apps/mobile_app/tasks/new_alert_group.py | 9 +- .../tasks/new_shift_swap_request.py | 10 +- .../tasks/test_going_oncall_notification.py | 18 +- .../tasks/test_new_shift_swap_request.py | 7 +- .../apps/mobile_app/tests/test_demo_push.py | 7 +- .../mobile_app/tests/test_fcm_endpoint.py | 205 ++++++++++++++++++ .../tests/test_mobile_app_auth_token.py | 1 + engine/apps/mobile_app/tests/test_utils.py | 11 + engine/apps/mobile_app/utils.py | 5 + engine/apps/mobile_app/views.py | 48 +++- .../apps/user_management/tests/factories.py | 1 + 15 files changed, 356 insertions(+), 24 deletions(-) create mode 100644 engine/apps/mobile_app/tests/test_fcm_endpoint.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 61de9a7a..7219936c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/engine/apps/mobile_app/demo_push.py b/engine/apps/mobile_app/demo_push.py index 551a9bf8..19daca5b 100644 --- a/engine/apps/mobile_app/demo_push.py +++ b/engine/apps/mobile_app/demo_push.py @@ -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 diff --git a/engine/apps/mobile_app/serializers.py b/engine/apps/mobile_app/serializers.py index 8a321aff..92afc56f 100644 --- a/engine/apps/mobile_app/serializers.py +++ b/engine/apps/mobile_app/serializers.py @@ -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 diff --git a/engine/apps/mobile_app/tasks/going_oncall_notification.py b/engine/apps/mobile_app/tasks/going_oncall_notification.py index 9d58a707..61265d2c 100644 --- a/engine/apps/mobile_app/tasks/going_oncall_notification.py +++ b/engine/apps/mobile_app/tasks/going_oncall_notification.py @@ -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 ), diff --git a/engine/apps/mobile_app/tasks/new_alert_group.py b/engine/apps/mobile_app/tasks/new_alert_group.py index e359328b..869f4c98 100644 --- a/engine/apps/mobile_app/tasks/new_alert_group.py +++ b/engine/apps/mobile_app/tasks/new_alert_group.py @@ -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, diff --git a/engine/apps/mobile_app/tasks/new_shift_swap_request.py b/engine/apps/mobile_app/tasks/new_shift_swap_request.py index f7f4e736..c3750e9f 100644 --- a/engine/apps/mobile_app/tasks/new_shift_swap_request.py +++ b/engine/apps/mobile_app/tasks/new_shift_swap_request.py @@ -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 diff --git a/engine/apps/mobile_app/tests/tasks/test_going_oncall_notification.py b/engine/apps/mobile_app/tests/tasks/test_going_oncall_notification.py index c4f6d799..67b51628 100644 --- a/engine/apps/mobile_app/tests/tasks/test_going_oncall_notification.py +++ b/engine/apps/mobile_app/tests/tasks/test_going_oncall_notification.py @@ -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), diff --git a/engine/apps/mobile_app/tests/tasks/test_new_shift_swap_request.py b/engine/apps/mobile_app/tests/tasks/test_new_shift_swap_request.py index 2baed054..888a653f 100644 --- a/engine/apps/mobile_app/tests/tasks/test_new_shift_swap_request.py +++ b/engine/apps/mobile_app/tests/tasks/test_new_shift_swap_request.py @@ -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"] diff --git a/engine/apps/mobile_app/tests/test_demo_push.py b/engine/apps/mobile_app/tests/test_demo_push.py index abf5f6eb..769691f7 100644 --- a/engine/apps/mobile_app/tests/test_demo_push.py +++ b/engine/apps/mobile_app/tests/test_demo_push.py @@ -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) diff --git a/engine/apps/mobile_app/tests/test_fcm_endpoint.py b/engine/apps/mobile_app/tests/test_fcm_endpoint.py new file mode 100644 index 00000000..211e5124 --- /dev/null +++ b/engine/apps/mobile_app/tests/test_fcm_endpoint.py @@ -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() diff --git a/engine/apps/mobile_app/tests/test_mobile_app_auth_token.py b/engine/apps/mobile_app/tests/test_mobile_app_auth_token.py index af92a8bd..2f5f2f4d 100644 --- a/engine/apps/mobile_app/tests/test_mobile_app_auth_token.py +++ b/engine/apps/mobile_app/tests/test_mobile_app_auth_token.py @@ -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 diff --git a/engine/apps/mobile_app/tests/test_utils.py b/engine/apps/mobile_app/tests/test_utils.py index facdebf8..50c13b9f 100644 --- a/engine/apps/mobile_app/tests/test_utils.py +++ b/engine/apps/mobile_app/tests/test_utils.py @@ -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 diff --git a/engine/apps/mobile_app/utils.py b/engine/apps/mobile_app/utils.py index 69092bdb..4a3991d7 100644 --- a/engine/apps/mobile_app/utils.py +++ b/engine/apps/mobile_app/utils.py @@ -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}" diff --git a/engine/apps/mobile_app/views.py b/engine/apps/mobile_app/views.py index 6deea716..b0ba7328 100644 --- a/engine/apps/mobile_app/views.py +++ b/engine/apps/mobile_app/views.py @@ -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): diff --git a/engine/apps/user_management/tests/factories.py b/engine/apps/user_management/tests/factories.py index b876c06e..ccfbb858 100644 --- a/engine/apps/user_management/tests/factories.py +++ b/engine/apps/user_management/tests/factories.py @@ -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 From 6c1ac8aa7337797545444ffc97f7794383536601 Mon Sep 17 00:00:00 2001 From: Yulya Artyukhina Date: Thu, 14 Dec 2023 13:19:55 +0100 Subject: [PATCH 3/3] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7219936c..6500ab98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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 +## v1.3.79 (2023-12-14) ### Added