From ed5b5e153d289d355fbd529b39bf5cb66cafdebd Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Wed, 22 Mar 2023 14:47:18 +0000 Subject: [PATCH] Mobile app settings backend (#1571) # What this PR does Adds mobile app settings support to OnCall backend. - Adds a new Django model `MobileAppUserSettings` to store push notification settings - Adds a new endpoint `/mobile_app/v1/user_settings` to fetch/update settings from the mobile app Some additional info on implementation: at first I wanted to extend the messaging backend system to allow storing / retrieving per-user data and implement mobile app settings based on those changes. After some thought I decided not to extend the messaging backend system and have this as functionality specific to the `mobile_app` Django app. Currently the messaging backend system is used by the backend and plugin UI, but mobile app settings are specific only to the mobile app and not configurable in the plugin UI. **tldr: wanted to extend messaging backend system, but decided not to do that** # Usage ## Get settings via API `GET /mobile_app/v1/user_settings` Example response: ```json { "default_notification_sound_name": "default_sound", # sound name without file extension "default_notification_volume_type": "constant", "default_notification_volume": 0.8, "default_notification_volume_override": false, "important_notification_sound_name": "default_sound_important", # sound name without file extension "important_notification_volume_type": "constant", "important_notification_volume": 0.8, "important_notification_override_dnd": true } ``` ## Update settings via API `PUT /mobile_app/v1/user_settings` - see example response above for payload shape. Note that sound names must be passed without file extension. When sending push notifications, the backend will add `.mp3` to sound names and pass it to push notification data for Android. For iOS, sound names will be suffixed with `.aiff` to be used by APNS. ## Get settings from notification data for Android All the settings from example response will be available in push notification data (along with `orgId`, `alertGroupId`, `title`, etc.). Fields `default_notification_volume`, `default_notification_volume_override` and `important_notification_volume` , `important_notification_override_dnd` will be converted to strings due to FCM limitations. Fields `default_notification_sound_name` and `important_notification_sound_name` will be suffixed with `.mp3` in push notification data. ## iOS limitations While Android push notifications are handled purely on the mobile app side, iOS notifications are sent via APNS which imposes some limitations. - Notification volume cannot be overridden for non-critical notifications (so fields `default_notification_volume_override` and `default_notification_volume` will be disregarded for iOS notifications) - It's not possible to control volume type (i.e. "constant" vs "intensifying") via APNS. A possible workaround is to have different sound files for "constant" and "intensifying" and pass that as `default_notification_sound_name` / `important_notification_sound_name`. # Which issue(s) this PR fixes Related to https://github.com/grafana/oncall-private/issues/1602 # Checklist - [x] Tests updated - [x] No changelog updates since the changes are not user-facing --- .../migrations/0003_mobileappusersettings.py | 31 ++++ engine/apps/mobile_app/models.py | 39 +++++ engine/apps/mobile_app/serializers.py | 18 +++ engine/apps/mobile_app/tasks.py | 150 +++++++++++------- .../apps/mobile_app/tests/test_notify_user.py | 86 +++++++++- .../mobile_app/tests/test_user_settings.py | 51 ++++++ engine/apps/mobile_app/urls.py | 3 +- engine/apps/mobile_app/views.py | 16 +- engine/conftest.py | 24 ++- 9 files changed, 356 insertions(+), 62 deletions(-) create mode 100644 engine/apps/mobile_app/migrations/0003_mobileappusersettings.py create mode 100644 engine/apps/mobile_app/serializers.py create mode 100644 engine/apps/mobile_app/tests/test_user_settings.py diff --git a/engine/apps/mobile_app/migrations/0003_mobileappusersettings.py b/engine/apps/mobile_app/migrations/0003_mobileappusersettings.py new file mode 100644 index 00000000..0f9946cf --- /dev/null +++ b/engine/apps/mobile_app/migrations/0003_mobileappusersettings.py @@ -0,0 +1,31 @@ +# Generated by Django 3.2.18 on 2023-03-21 15:53 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0009_organization_cluster_slug'), + ('mobile_app', '0002_alter_mobileappauthtoken_user'), + ] + + operations = [ + migrations.CreateModel( + name='MobileAppUserSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('default_notification_sound_name', models.CharField(default='default_sound', max_length=100)), + ('default_notification_volume_type', models.CharField(choices=[('constant', 'Constant'), ('intensifying', 'Intensifying')], default='constant', max_length=50)), + ('default_notification_volume', models.FloatField(default=0.8, validators=[django.core.validators.MinValueValidator(0.0), django.core.validators.MaxValueValidator(1.0)])), + ('default_notification_volume_override', models.BooleanField(default=False)), + ('important_notification_sound_name', models.CharField(default='default_sound_important', max_length=100)), + ('important_notification_volume_type', models.CharField(choices=[('constant', 'Constant'), ('intensifying', 'Intensifying')], default='constant', max_length=50)), + ('important_notification_volume', models.FloatField(default=0.8, validators=[django.core.validators.MinValueValidator(0.0), django.core.validators.MaxValueValidator(1.0)])), + ('important_notification_override_dnd', models.BooleanField(default=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='user_management.user')), + ], + ), + ] diff --git a/engine/apps/mobile_app/models.py b/engine/apps/mobile_app/models.py index 08d2c1f2..92d0e7f8 100644 --- a/engine/apps/mobile_app/models.py +++ b/engine/apps/mobile_app/models.py @@ -1,6 +1,7 @@ from typing import Tuple from django.conf import settings +from django.core import validators from django.db import models from django.utils import timezone @@ -68,3 +69,41 @@ class MobileAppAuthToken(BaseAuthToken): organization=organization, ) return instance, token_string + + +class MobileAppUserSettings(models.Model): + # Sound names are stored without extension, extension is added when sending push notifications + IOS_SOUND_NAME_EXTENSION = ".aiff" + ANDROID_SOUND_NAME_EXTENSION = ".mp3" + + class VolumeType(models.TextChoices): + CONSTANT = "constant" + INTENSIFYING = "intensifying" + + user = models.OneToOneField(to=User, null=False, on_delete=models.CASCADE) + + # Push notification settings for default notifications + default_notification_sound_name = models.CharField(max_length=100, default="default_sound") + default_notification_volume_type = models.CharField( + max_length=50, choices=VolumeType.choices, default=VolumeType.CONSTANT + ) + + # APNS only allows to specify volume for critical notifications, + # so "default_notification_volume" and "default_notification_volume_override" are only used on Android + default_notification_volume = models.FloatField( + validators=[validators.MinValueValidator(0.0), validators.MaxValueValidator(1.0)], default=0.8 + ) + default_notification_volume_override = models.BooleanField(default=False) + + # Push notification settings for important notifications + important_notification_sound_name = models.CharField(max_length=100, default="default_sound_important") + important_notification_volume_type = models.CharField( + max_length=50, choices=VolumeType.choices, default=VolumeType.CONSTANT + ) + important_notification_volume = models.FloatField( + validators=[validators.MinValueValidator(0.0), validators.MaxValueValidator(1.0)], default=0.8 + ) + + # For the "Mobile push important" step it's possible to make notifications non-critical + # if "override DND" setting is disabled in the app + important_notification_override_dnd = models.BooleanField(default=True) diff --git a/engine/apps/mobile_app/serializers.py b/engine/apps/mobile_app/serializers.py new file mode 100644 index 00000000..1338ecdc --- /dev/null +++ b/engine/apps/mobile_app/serializers.py @@ -0,0 +1,18 @@ +from rest_framework import serializers + +from apps.mobile_app.models import MobileAppUserSettings + + +class MobileAppUserSettingsSerializer(serializers.ModelSerializer): + class Meta: + model = MobileAppUserSettings + fields = ( + "default_notification_sound_name", + "default_notification_volume_type", + "default_notification_volume", + "default_notification_volume_override", + "important_notification_sound_name", + "important_notification_volume_type", + "important_notification_volume", + "important_notification_override_dnd", + ) diff --git a/engine/apps/mobile_app/tasks.py b/engine/apps/mobile_app/tasks.py index 8b18f7e5..dec51193 100644 --- a/engine/apps/mobile_app/tasks.py +++ b/engine/apps/mobile_app/tasks.py @@ -67,63 +67,8 @@ def notify_user_async(user_pk, alert_group_pk, notification_policy_pk, critical) logger.error(f"Error while sending a mobile push notification: user {user_pk} has no device set up") return - thread_id = f"{alert_group.channel.organization.public_primary_key}:{alert_group.public_primary_key}" - number_of_alerts = alert_group.alerts.count() - - alert_title = "New Critical Alert" if critical else "New Alert" - alert_subtitle = get_push_notification_message(alert_group) - - status_verbose = "Firing" # TODO: we should probably de-duplicate this text - if alert_group.resolved: - status_verbose = alert_group.get_resolve_text() - elif alert_group.acknowledged: - status_verbose = alert_group.get_acknowledge_text() - - if number_of_alerts <= 10: - alerts_count_str = str(number_of_alerts) - else: - alert_count_rounded = (number_of_alerts // 10) * 10 - alerts_count_str = f"{alert_count_rounded}+" - - alert_body = f"Status: {status_verbose}, alerts: {alerts_count_str}" - - message = Message( - token=device_to_notify.registration_id, - data={ - # from the docs.. - # A dictionary of data fields (optional). All keys and values in the dictionary must be strings - # - # alert_group.status is an int so it must be casted... - "orgId": alert_group.channel.organization.public_primary_key, - "orgName": alert_group.channel.organization.stack_slug, - "alertGroupId": alert_group.public_primary_key, - "status": str(alert_group.status), - "type": "oncall.critical_message" if critical else "oncall.message", - "title": alert_title, - "subtitle": alert_subtitle, - "body": alert_body, - "thread_id": thread_id, - }, - apns=APNSConfig( - payload=APNSPayload( - aps=Aps( - thread_id=thread_id, - 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, - ), - custom_data={ - "interruption-level": "critical" if critical else "time-sensitive", - }, - ), - ), - ), - ) - - logger.debug(f"Sending push notification with message: {message}; thread-id: {thread_id};") + 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 @@ -168,3 +113,94 @@ def send_push_notification_to_fcm_relay(message): response.raise_for_status() return response + + +def _get_fcm_message(alert_group, user, registration_id, critical): + # avoid circular import + from apps.mobile_app.models import MobileAppUserSettings + + thread_id = f"{alert_group.channel.organization.public_primary_key}:{alert_group.public_primary_key}" + number_of_alerts = alert_group.alerts.count() + + alert_title = "New Critical Alert" if critical else "New Alert" + alert_subtitle = get_push_notification_message(alert_group) + + status_verbose = "Firing" # TODO: we should probably de-duplicate this text + if alert_group.resolved: + status_verbose = alert_group.get_resolve_text() + elif alert_group.acknowledged: + status_verbose = alert_group.get_acknowledge_text() + + if number_of_alerts <= 10: + alerts_count_str = str(number_of_alerts) + else: + alert_count_rounded = (number_of_alerts // 10) * 10 + alerts_count_str = f"{alert_count_rounded}+" + + 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.important_notification_volume if critical else None + apns_sound_name = ( + mobile_app_user_settings.important_notification_sound_name + if critical + else mobile_app_user_settings.default_notification_sound_name + ) + MobileAppUserSettings.IOS_SOUND_NAME_EXTENSION # iOS app expects the filename to have an extension + + return Message( + token=registration_id, + data={ + # from the docs.. + # A dictionary of data fields (optional). All keys and values in the dictionary must be strings + # + # alert_group.status is an int so it must be casted... + "orgId": alert_group.channel.organization.public_primary_key, + "orgName": alert_group.channel.organization.stack_slug, + "alertGroupId": alert_group.public_primary_key, + "status": str(alert_group.status), + "type": "oncall.critical_message" if critical else "oncall.message", + "title": alert_title, + "subtitle": alert_subtitle, + "body": alert_body, + "thread_id": thread_id, + # Pass user settings, so the Android app can use them to play the correct sound and volume + "default_notification_sound_name": ( + mobile_app_user_settings.default_notification_sound_name + + MobileAppUserSettings.ANDROID_SOUND_NAME_EXTENSION + ), + "default_notification_volume_type": mobile_app_user_settings.default_notification_volume_type, + "default_notification_volume": str(mobile_app_user_settings.default_notification_volume), + "default_notification_volume_override": json.dumps( + mobile_app_user_settings.default_notification_volume_override + ), + "important_notification_sound_name": ( + mobile_app_user_settings.important_notification_sound_name + + MobileAppUserSettings.ANDROID_SOUND_NAME_EXTENSION + ), + "important_notification_volume_type": mobile_app_user_settings.important_notification_volume_type, + "important_notification_volume": str(mobile_app_user_settings.important_notification_volume), + "important_notification_override_dnd": json.dumps( + mobile_app_user_settings.important_notification_override_dnd + ), + }, + apns=APNSConfig( + payload=APNSPayload( + aps=Aps( + thread_id=thread_id, + badge=number_of_alerts, + alert=ApsAlert(title=alert_title, subtitle=alert_subtitle, body=alert_body), + sound=CriticalSound( + # The notification shouldn't be critical if the user has disabled "override DND" setting + critical=(critical and mobile_app_user_settings.important_notification_override_dnd), + name=apns_sound_name, + volume=apns_volume, + ), + custom_data={ + "interruption-level": "critical" if critical else "time-sensitive", + }, + ), + ), + ), + ) diff --git a/engine/apps/mobile_app/tests/test_notify_user.py b/engine/apps/mobile_app/tests/test_notify_user.py index 8b64eda3..8e3d5487 100644 --- a/engine/apps/mobile_app/tests/test_notify_user.py +++ b/engine/apps/mobile_app/tests/test_notify_user.py @@ -5,7 +5,8 @@ from fcm_django.models import FCMDevice from firebase_admin.exceptions import FirebaseError from apps.base.models import UserNotificationPolicy, UserNotificationPolicyLogRecord -from apps.mobile_app.tasks import notify_user_async +from apps.mobile_app.models import MobileAppUserSettings +from apps.mobile_app.tasks import _get_fcm_message, notify_user_async from apps.oss_installation.models import CloudConnector MOBILE_APP_BACKEND_ID = 5 @@ -209,3 +210,86 @@ def test_notify_user_retry( notification_policy_pk=notification_policy.pk, critical=False, ) + + +@pytest.mark.django_db +def test_fcm_message_user_settings( + make_organization_and_user, make_alert_receive_channel, make_alert_group, make_alert +): + organization, user = make_organization_and_user() + device = FCMDevice.objects.create(user=user, registration_id="test_device_id") + + alert_receive_channel = make_alert_receive_channel(organization=organization) + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group=alert_group, raw_request_data={}) + + message = _get_fcm_message(alert_group, user, device.registration_id, critical=False) + + # Check user settings are passed to FCM message + assert message.data["default_notification_sound_name"] == "default_sound.mp3" + assert message.data["default_notification_volume_type"] == "constant" + assert message.data["default_notification_volume_override"] == "false" + assert message.data["default_notification_volume"] == "0.8" + assert message.data["important_notification_sound_name"] == "default_sound_important.mp3" + assert message.data["important_notification_volume_type"] == "constant" + assert message.data["important_notification_volume"] == "0.8" + assert message.data["important_notification_override_dnd"] == "true" + + # Check APNS notification sound is set correctly + apns_sound = message.apns.payload.aps.sound + assert apns_sound.critical is False + assert apns_sound.name == "default_sound.aiff" + assert apns_sound.volume is None # APNS doesn't allow to specify volume for non-critical notifications + + +@pytest.mark.django_db +def test_fcm_message_user_settings_critical( + make_organization_and_user, make_alert_receive_channel, make_alert_group, make_alert +): + organization, user = make_organization_and_user() + device = FCMDevice.objects.create(user=user, registration_id="test_device_id") + + alert_receive_channel = make_alert_receive_channel(organization=organization) + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group=alert_group, raw_request_data={}) + + message = _get_fcm_message(alert_group, user, device.registration_id, critical=True) + + # Check user settings are passed to FCM message + assert message.data["default_notification_sound_name"] == "default_sound.mp3" + assert message.data["default_notification_volume_type"] == "constant" + assert message.data["default_notification_volume_override"] == "false" + assert message.data["default_notification_volume"] == "0.8" + assert message.data["important_notification_sound_name"] == "default_sound_important.mp3" + assert message.data["important_notification_volume_type"] == "constant" + assert message.data["important_notification_volume"] == "0.8" + assert message.data["important_notification_override_dnd"] == "true" + + # Check APNS notification sound is set correctly + apns_sound = message.apns.payload.aps.sound + assert apns_sound.critical is True + assert apns_sound.name == "default_sound_important.aiff" + assert apns_sound.volume == 0.8 + + +@pytest.mark.django_db +def test_fcm_message_user_settings_critical_override_dnd_disabled( + make_organization_and_user, make_alert_receive_channel, make_alert_group, make_alert +): + organization, user = make_organization_and_user() + device = FCMDevice.objects.create(user=user, registration_id="test_device_id") + + alert_receive_channel = make_alert_receive_channel(organization=organization) + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group=alert_group, raw_request_data={}) + + # Disable important notification override DND + MobileAppUserSettings.objects.create(user=user, important_notification_override_dnd=False) + message = _get_fcm_message(alert_group, user, device.registration_id, critical=True) + + # Check user settings are passed to FCM message + assert message.data["important_notification_override_dnd"] == "false" + + # Check APNS notification sound is set correctly + apns_sound = message.apns.payload.aps.sound + assert apns_sound.critical is False diff --git a/engine/apps/mobile_app/tests/test_user_settings.py b/engine/apps/mobile_app/tests/test_user_settings.py new file mode 100644 index 00000000..de14d9df --- /dev/null +++ b/engine/apps/mobile_app/tests/test_user_settings.py @@ -0,0 +1,51 @@ +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + + +@pytest.mark.django_db +def test_user_settings_get(make_organization_and_user_with_mobile_app_auth_token): + organization, user, auth_token = make_organization_and_user_with_mobile_app_auth_token() + + client = APIClient() + url = reverse("mobile_app:user_settings") + + response = client.get(url, HTTP_AUTHORIZATION=auth_token) + assert response.status_code == status.HTTP_200_OK + + # Check the default values are correct + assert response.json() == { + "default_notification_sound_name": "default_sound", + "default_notification_volume_type": "constant", + "default_notification_volume": 0.8, + "default_notification_volume_override": False, + "important_notification_sound_name": "default_sound_important", + "important_notification_volume_type": "constant", + "important_notification_volume": 0.8, + "important_notification_override_dnd": True, + } + + +@pytest.mark.django_db +def test_user_settings_put(make_organization_and_user_with_mobile_app_auth_token): + organization, user, auth_token = make_organization_and_user_with_mobile_app_auth_token() + + client = APIClient() + url = reverse("mobile_app:user_settings") + data = { + "default_notification_sound_name": "test_default", + "default_notification_volume_type": "intensifying", + "default_notification_volume": 1, + "default_notification_volume_override": True, + "important_notification_sound_name": "test_important", + "important_notification_volume_type": "intensifying", + "important_notification_volume": 1, + "important_notification_override_dnd": False, + } + + response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=auth_token) + assert response.status_code == status.HTTP_200_OK + + # Check the values are updated correctly + assert response.json() == data diff --git a/engine/apps/mobile_app/urls.py b/engine/apps/mobile_app/urls.py index 2f0433d9..5d4898e9 100644 --- a/engine/apps/mobile_app/urls.py +++ b/engine/apps/mobile_app/urls.py @@ -1,5 +1,5 @@ from apps.mobile_app.fcm_relay import FCMRelayView -from apps.mobile_app.views import FCMDeviceAuthorizedViewSet, MobileAppAuthTokenAPIView +from apps.mobile_app.views import FCMDeviceAuthorizedViewSet, MobileAppAuthTokenAPIView, MobileAppUserSettingsAPIView from common.api_helpers.optional_slash_router import OptionalSlashRouter, optional_slash_path app_name = "mobile_app" @@ -10,6 +10,7 @@ router.register("fcm", FCMDeviceAuthorizedViewSet, basename="fcm") urlpatterns = [ *router.urls, optional_slash_path("auth_token", MobileAppAuthTokenAPIView.as_view(), name="auth_token"), + optional_slash_path("user_settings", MobileAppUserSettingsAPIView.as_view(), name="user_settings"), ] urlpatterns += [ diff --git a/engine/apps/mobile_app/views.py b/engine/apps/mobile_app/views.py index 16257440..035b68fa 100644 --- a/engine/apps/mobile_app/views.py +++ b/engine/apps/mobile_app/views.py @@ -1,11 +1,13 @@ from fcm_django.api.rest_framework import FCMDeviceAuthorizedViewSet as BaseFCMDeviceAuthorizedViewSet -from rest_framework import status +from rest_framework import generics, status from rest_framework.exceptions import NotFound +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from apps.mobile_app.auth import MobileAppAuthTokenAuthentication, MobileAppVerificationTokenAuthentication -from apps.mobile_app.models import MobileAppAuthToken +from apps.mobile_app.models import MobileAppAuthToken, MobileAppUserSettings +from apps.mobile_app.serializers import MobileAppUserSettingsSerializer class FCMDeviceAuthorizedViewSet(BaseFCMDeviceAuthorizedViewSet): @@ -50,3 +52,13 @@ class MobileAppAuthTokenAPIView(APIView): raise NotFound return Response(status=status.HTTP_204_NO_CONTENT) + + +class MobileAppUserSettingsAPIView(generics.RetrieveUpdateAPIView): + authentication_classes = (MobileAppAuthTokenAuthentication,) + permission_classes = (IsAuthenticated,) + serializer_class = MobileAppUserSettingsSerializer + + def get_object(self): + mobile_app_settings, _ = MobileAppUserSettings.objects.get_or_create(user=self.request.user) + return mobile_app_settings diff --git a/engine/conftest.py b/engine/conftest.py index a79e18b0..de2ad0a3 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -55,7 +55,7 @@ from apps.base.tests.factories import ( ) from apps.email.tests.factories import EmailMessageFactory from apps.heartbeat.tests.factories import IntegrationHeartBeatFactory -from apps.mobile_app.models import MobileAppVerificationToken +from apps.mobile_app.models import MobileAppAuthToken, MobileAppVerificationToken from apps.schedules.tests.factories import ( CustomOnCallShiftFactory, OnCallScheduleCalendarFactory, @@ -185,6 +185,14 @@ def make_mobile_app_verification_token_for_user(): return _make_mobile_app_verification_token_for_user +@pytest.fixture +def make_mobile_app_auth_token_for_user(): + def _make_mobile_app_auth_token_for_user(user, organization): + return MobileAppAuthToken.create_auth_token(user, organization) + + return _make_mobile_app_auth_token_for_user + + @pytest.fixture def make_public_api_token(): def _make_public_api_token(user, organization, name="test_api_token"): @@ -685,6 +693,20 @@ def make_organization_and_user_with_mobile_app_verification_token( return _make_organization_and_user_with_mobile_app_verification_token +@pytest.fixture() +def make_organization_and_user_with_mobile_app_auth_token( + make_organization_and_user, make_mobile_app_auth_token_for_user +): + def _make_organization_and_user_with_mobile_app_auth_token( + role: typing.Optional[LegacyAccessControlRole] = None, + ): + organization, user = make_organization_and_user(role) + _, token = make_mobile_app_auth_token_for_user(user, organization) + return organization, user, token + + return _make_organization_and_user_with_mobile_app_auth_token + + @pytest.fixture() def mock_send_user_notification_signal(monkeypatch): def mocked_send_signal(*args, **kwargs):