"You're Going OnCall" mobile app push notification (#1814)

# What this PR does

https://www.loom.com/share/c5deb35309604cfdab6176c44de7b15e

## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [ ] 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:
Joey Orlando 2023-05-04 12:59:57 -04:00 committed by GitHub
parent 311e5209f1
commit 620f69e409
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 762 additions and 164 deletions

View file

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Added
- Add a new mobile app push notification which notifies users when they are going on call by @joeyorlando ([#1814](https://github.com/grafana/oncall/pull/1814))
### Changed
- Improve ical comparison when checking for imported ical updates ([1870](https://github.com/grafana/oncall/pull/1870))

View file

@ -0,0 +1,39 @@
# Generated by Django 3.2.18 on 2023-04-25 10:33
from django.db import migrations, models
from django_add_default_value import AddDefaultValue
class Migration(migrations.Migration):
dependencies = [
('mobile_app', '0003_mobileappusersettings'),
]
operations = [
migrations.AddField(
model_name='mobileappusersettings',
name='info_notifications_enabled',
field=models.BooleanField(default=True),
),
# migrations.AddField enforces the default value on the app level, which leads to the issues during release
# adding same default value on the database level
AddDefaultValue(
model_name='mobileappusersettings',
name='info_notifications_enabled',
value=True,
),
migrations.AddField(
model_name='mobileappusersettings',
name='going_oncall_notification_timing',
field=models.IntegerField(choices=[(43200, 'twelve hours before'), (86400, 'one day before'), (604800, 'one week before')], default=43200),
),
# migrations.AddField enforces the default value on the app level, which leads to the issues during release
# adding same default value on the database level
AddDefaultValue(
model_name='mobileappusersettings',
name='going_oncall_notification_timing',
value=43200,
),
]

View file

@ -107,3 +107,23 @@ class MobileAppUserSettings(models.Model):
# 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)
# this is used for non escalation related push notifications such as the
# "You're going OnCall soon" push notification
info_notifications_enabled = models.BooleanField(default=True)
# these choices + the below column are used to calculate when to send the "You're Going OnCall soon"
# push notification
# ONE_HOUR, TWELVE_HOURS, ONE_DAY, ONE_WEEK = range(4)
TWELVE_HOURS_IN_SECONDS = 12 * 60 * 60
ONE_DAY_IN_SECONDS = TWELVE_HOURS_IN_SECONDS * 2
ONE_WEEK_IN_SECONDS = ONE_DAY_IN_SECONDS * 7
NOTIFICATION_TIMING_CHOICES = (
(TWELVE_HOURS_IN_SECONDS, "twelve hours before"),
(ONE_DAY_IN_SECONDS, "one day before"),
(ONE_WEEK_IN_SECONDS, "one week before"),
)
going_oncall_notification_timing = models.IntegerField(
choices=NOTIFICATION_TIMING_CHOICES, default=TWELVE_HOURS_IN_SECONDS
)

View file

@ -15,4 +15,6 @@ class MobileAppUserSettingsSerializer(serializers.ModelSerializer):
"important_notification_volume_type",
"important_notification_volume",
"important_notification_override_dnd",
"info_notifications_enabled",
"going_oncall_notification_timing",
)

View file

@ -1,9 +1,15 @@
import json
import logging
import math
import typing
from enum import Enum
import humanize
import requests
from celery.utils.log import get_task_logger
from django.conf import settings
from django.core.cache import cache
from django.utils import timezone
from fcm_django.models import FCMDevice
from firebase_admin.exceptions import FirebaseError
from firebase_admin.messaging import AndroidConfig, APNSConfig, APNSPayload, Aps, ApsAlert, CriticalSound, Message
@ -13,15 +19,231 @@ from rest_framework import status
from apps.alerts.models import AlertGroup
from apps.base.utils import live_settings
from apps.mobile_app.alert_rendering import get_push_notification_message
from apps.schedules.models.on_call_schedule import OnCallSchedule, ScheduleEvent
from apps.user_management.models import User
from common.api_helpers.utils import create_engine_url
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
if typing.TYPE_CHECKING:
from apps.mobile_app.models import MobileAppUserSettings
MAX_RETRIES = 1 if settings.DEBUG else 10
logger = get_task_logger(__name__)
logger.setLevel(logging.DEBUG)
class MessageImportanceType(str, Enum):
NORMAL = "oncall.message"
CRITICAL = "oncall.critical_message"
class FCMMessageData(typing.TypedDict):
title: str
subtitle: typing.Optional[str]
body: typing.Optional[str]
def send_push_notification_to_fcm_relay(message: Message) -> requests.Response:
"""
Send push notification to FCM relay on cloud instance: apps.mobile_app.fcm_relay.FCMRelayView
"""
url = create_engine_url("mobile_app/v1/fcm_relay", override_base=settings.GRAFANA_CLOUD_ONCALL_API_URL)
response = requests.post(
url, headers={"Authorization": live_settings.GRAFANA_CLOUD_ONCALL_TOKEN}, json=json.loads(str(message))
)
response.raise_for_status()
return response
def _send_push_notification(
device_to_notify: FCMDevice, message: Message, error_cb: typing.Optional[typing.Callable[..., None]] = None
) -> None:
logger.debug(f"Sending push notification with message: {message}")
def _error_cb():
if error_cb:
error_cb()
if settings.IS_OPEN_SOURCE:
# FCM relay uses cloud connection to send push notifications
from apps.oss_installation.models import CloudConnector
if not CloudConnector.objects.exists():
_error_cb()
logger.error(f"Error while sending a mobile push notification: not connected to cloud")
return
try:
response = send_push_notification_to_fcm_relay(message)
logger.debug(f"FCM relay response: {response}")
except HTTPError as e:
if status.HTTP_400_BAD_REQUEST <= e.response.status_code < status.HTTP_500_INTERNAL_SERVER_ERROR:
# do not retry on HTTP client errors (4xx errors)
_error_cb()
logger.error(
f"Error while sending a mobile push notification: HTTP client error {e.response.status_code}"
)
return
else:
raise
else:
# https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream
response = device_to_notify.send_message(message)
logger.debug(f"FCM response: {response}")
if isinstance(response, FirebaseError):
raise response
def _construct_fcm_message(
device_to_notify: FCMDevice,
thread_id: str,
data: FCMMessageData,
apns_payload: typing.Optional[APNSPayload] = None,
critical_message_type: bool = False,
) -> Message:
apns_config_kwargs = {}
if apns_payload is not None:
apns_config_kwargs["payload"] = apns_payload
return 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
**data,
"type": MessageImportanceType.CRITICAL if critical_message_type else MessageImportanceType.NORMAL,
"thread_id": thread_id,
},
android=AndroidConfig(
# from the docs
# https://firebase.google.com/docs/cloud-messaging/concept-options#setting-the-priority-of-a-message
#
# Normal priority.
# Normal priority messages are delivered immediately when the app is in the foreground.
# For backgrounded apps, delivery may be delayed. For less time-sensitive messages, such as notifications
# of new email, keeping your UI in sync, or syncing app data in the background, choose normal delivery
# priority.
#
# High priority.
# FCM attempts to deliver high priority messages immediately even if the device is in Doze mode.
# High priority messages are for time-sensitive, user visible content.
priority="high",
),
apns=APNSConfig(
**apns_config_kwargs,
headers={
# From the docs
# https://firebase.google.com/docs/cloud-messaging/concept-options#setting-the-priority-of-a-message
"apns-priority": "10",
},
),
)
def _get_alert_group_escalation_fcm_message(
alert_group: AlertGroup, user: User, device_to_notify: FCMDevice, critical: bool
) -> Message:
# 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)
# critical defines the type of notification.
# we use overrideDND to establish if the notification should sound even if DND is on
overrideDND = critical and mobile_app_user_settings.important_notification_override_dnd
# 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
fcm_message_data: FCMMessageData = {
"title": alert_title,
"subtitle": alert_subtitle,
"body": alert_body,
"orgId": alert_group.channel.organization.public_primary_key,
"orgName": alert_group.channel.organization.stack_slug,
"alertGroupId": alert_group.public_primary_key,
# alert_group.status is an int so it must be casted...
"status": str(alert_group.status),
# 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_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=overrideDND,
name=apns_sound_name,
volume=apns_volume,
),
custom_data={
"interruption-level": "critical" if overrideDND else "time-sensitive",
},
),
)
return _construct_fcm_message(device_to_notify, thread_id, fcm_message_data, apns_payload, critical)
def _get_youre_going_oncall_fcm_message(
user: User, schedule: OnCallSchedule, device_to_notify: FCMDevice, seconds_until_going_oncall: int
) -> Message:
thread_id = f"{schedule.public_primary_key}:{user.public_primary_key}:going-oncall"
data: FCMMessageData = {
"title": f"You are going on call in {humanize.naturaldelta(seconds_until_going_oncall)} for schedule {schedule.name}",
}
return _construct_fcm_message(device_to_notify, thread_id, data)
@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True, max_retries=MAX_RETRIES)
def notify_user_async(user_pk, alert_group_pk, notification_policy_pk, critical):
# avoid circular import
@ -67,164 +289,149 @@ 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
message = _get_fcm_message(alert_group, user, device_to_notify.registration_id, critical)
logger.debug(f"Sending push notification with message: {message};")
if settings.IS_OPEN_SOURCE:
# FCM relay uses cloud connection to send push notifications
from apps.oss_installation.models import CloudConnector
if not CloudConnector.objects.exists():
_create_error_log_record()
logger.error(f"Error while sending a mobile push notification: not connected to cloud")
return
try:
response = send_push_notification_to_fcm_relay(message)
logger.debug(f"FCM relay response: {response}")
except HTTPError as e:
if status.HTTP_400_BAD_REQUEST <= e.response.status_code < status.HTTP_500_INTERNAL_SERVER_ERROR:
# do not retry on HTTP client errors (4xx errors)
_create_error_log_record()
logger.error(
f"Error while sending a mobile push notification: HTTP client error {e.response.status_code}"
)
return
else:
raise
else:
# https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream
response = device_to_notify.send_message(message)
logger.debug(f"FCM response: {response}")
if isinstance(response, FirebaseError):
raise response
message = _get_alert_group_escalation_fcm_message(alert_group, user, device_to_notify, critical)
_send_push_notification(device_to_notify, message, _create_error_log_record)
def send_push_notification_to_fcm_relay(message):
def _shift_starts_within_range(
timing_window_lower: int, timing_window_upper: int, seconds_until_shift_starts: int
) -> bool:
return timing_window_lower <= seconds_until_shift_starts <= timing_window_upper
def should_we_send_going_oncall_push_notification(
now: timezone.datetime, user_settings: "MobileAppUserSettings", schedule_event: ScheduleEvent
) -> typing.Optional[int]:
"""
Send push notification to FCM relay on cloud instance: apps.mobile_app.fcm_relay.FCMRelayView
"""
url = create_engine_url("mobile_app/v1/fcm_relay", override_base=settings.GRAFANA_CLOUD_ONCALL_API_URL)
If the user should be set a "you're going oncall" push notification, return the number of seconds
until they will be going oncall.
response = requests.post(
url, headers={"Authorization": live_settings.GRAFANA_CLOUD_ONCALL_TOKEN}, json=json.loads(str(message))
If no notification should be sent, return None.
Currently we will send notifications for the following scenarios:
- schedule is starting in user's "configured notification timing preference" +/- a 4 minute buffer
- schedule is starting within the next fifteen minutes
"""
NOTIFICATION_TIMING_BUFFER = 7 * 60 # 7 minutes in seconds
FIFTEEN_MINUTES_IN_SECONDS = 15 * 60
# this _should_ always be positive since final_events is returning only events in the future
seconds_until_shift_starts = math.floor((schedule_event["start"] - now).total_seconds())
user_wants_to_receive_info_notifications = user_settings.info_notifications_enabled
# int representing num of seconds before the shift starts that the user wants to be notified
user_notification_timing_preference = user_settings.going_oncall_notification_timing
if not user_wants_to_receive_info_notifications:
logger.info("not sending going oncall push notification because info_notifications_enabled is false")
return
# 14 minute window where the notification could be sent (7 mins before or 7 mins after)
timing_window_lower = user_notification_timing_preference - NOTIFICATION_TIMING_BUFFER
timing_window_upper = user_notification_timing_preference + NOTIFICATION_TIMING_BUFFER
shift_starts_within_users_notification_timing_preference = _shift_starts_within_range(
timing_window_lower, timing_window_upper, seconds_until_shift_starts
)
shift_starts_within_fifteen_minutes = _shift_starts_within_range(
0, FIFTEEN_MINUTES_IN_SECONDS, seconds_until_shift_starts
)
response.raise_for_status()
return response
timing_logging_msg = (
f"seconds_until_shift_starts: {seconds_until_shift_starts}\n"
f"user_notification_timing_preference: {user_notification_timing_preference}\n"
f"timing_window_lower: {timing_window_lower}\n"
f"timing_window_upper: {timing_window_upper}\n"
f"shift_starts_within_users_notification_timing_preference: {shift_starts_within_users_notification_timing_preference}\n"
f"shift_starts_within_fifteen_minutes: {shift_starts_within_fifteen_minutes}"
)
if shift_starts_within_users_notification_timing_preference or shift_starts_within_fifteen_minutes:
logger.info(f"timing is right to send going oncall push notification\n{timing_logging_msg}")
return seconds_until_shift_starts
logger.info(f"timing is not right to send going oncall push notification\n{timing_logging_msg}")
def _get_fcm_message(alert_group, user, registration_id, critical):
def _generate_going_oncall_push_notification_cache_key(user_pk: str, schedule_event: ScheduleEvent) -> str:
return f"going_oncall_push_notification:{user_pk}:{schedule_event['shift']['pk']}"
@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True, max_retries=MAX_RETRIES)
def conditionally_send_going_oncall_push_notifications_for_schedule(schedule_pk) -> None:
# 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()
PUSH_NOTIFICATION_TRACKING_CACHE_KEY_TTL = 60 * 60 # 60 minutes
user_cache: typing.Dict[str, User] = {}
device_cache: typing.Dict[str, FCMDevice] = {}
alert_title = "New Critical Alert" if critical else "New Alert"
alert_subtitle = get_push_notification_message(alert_group)
logger.info(f"Start calculate_going_oncall_push_notifications_for_schedule for schedule {schedule_pk}")
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()
try:
schedule: OnCallSchedule = OnCallSchedule.objects.get(pk=schedule_pk)
except OnCallSchedule.DoesNotExist:
logger.info(f"Tried to notify user about going on-call for non-existing schedule {schedule_pk}")
return
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}+"
now = timezone.now()
schedule_final_events = schedule.final_events("UTC", now, days=7)
alert_body = f"Status: {status_verbose}, alerts: {alerts_count_str}"
relevant_cache_keys = [
_generate_going_oncall_push_notification_cache_key(user["pk"], schedule_event)
for schedule_event in schedule_final_events
for user in schedule_event["users"]
]
mobile_app_user_settings, _ = MobileAppUserSettings.objects.get_or_create(user=user)
relevant_notifications_already_sent = cache.get_many(relevant_cache_keys)
# critical defines the type of notification.
# we use overrideDND to establish if the notification should sound even if DND is on
overrideDND = critical and mobile_app_user_settings.important_notification_override_dnd
for schedule_event in schedule_final_events:
users = schedule_event["users"]
# 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
for user in users:
user_pk = user["pk"]
logger.info(f"Evaluating if we should send push notification for schedule {schedule_pk} for user {user_pk}")
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
),
},
android=AndroidConfig(
# from the docs
# https://firebase.google.com/docs/cloud-messaging/concept-options#setting-the-priority-of-a-message
#
# Normal priority.
# Normal priority messages are delivered immediately when the app is in the foreground.
# For backgrounded apps, delivery may be delayed. For less time-sensitive messages, such as notifications
# of new email, keeping your UI in sync, or syncing app data in the background, choose normal delivery
# priority.
#
# High priority.
# FCM attempts to deliver high priority messages immediately even if the device is in Doze mode.
# High priority messages are for time-sensitive, user visible content.
priority="high",
),
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=overrideDND,
name=apns_sound_name,
volume=apns_volume,
),
custom_data={
"interruption-level": "critical" if overrideDND else "time-sensitive",
},
),
),
headers={
# From the docs
# https://firebase.google.com/docs/cloud-messaging/concept-options#setting-the-priority-of-a-message
"apns-priority": "10",
},
),
)
user = user_cache.get(user_pk, None)
if user is None:
try:
user = User.objects.get(public_primary_key=user_pk)
user_cache[user_pk] = user
except User.DoesNotExist:
logger.warning(f"User {user_pk} does not exist")
continue
device_to_notify = device_cache.get(user_pk, None)
if device_to_notify is None:
device_to_notify = FCMDevice.objects.filter(user=user).first()
if not device_to_notify:
logger.info(f"User {user_pk} has no device set up")
continue
else:
device_cache[user_pk] = device_to_notify
mobile_app_user_settings, _ = MobileAppUserSettings.objects.get_or_create(user=user)
cache_key = _generate_going_oncall_push_notification_cache_key(user_pk, schedule_event)
already_sent_this_push_notification = cache_key in relevant_notifications_already_sent
if (
should_we_send_going_oncall_push_notification(now, mobile_app_user_settings, schedule_event)
and not already_sent_this_push_notification
):
message = _get_youre_going_oncall_fcm_message(
user, schedule, device_to_notify, mobile_app_user_settings.going_oncall_notification_timing
)
_send_push_notification(device_to_notify, message)
cache.set(cache_key, True, PUSH_NOTIFICATION_TRACKING_CACHE_KEY_TTL)
else:
logger.info(
f"Skipping sending going oncall push notification for user {user_pk} and shift {schedule_event['shift']['pk']}. "
f"Already sent: {already_sent_this_push_notification}"
)
@shared_dedicated_queue_retry_task()
def conditionally_send_going_oncall_push_notifications_for_all_schedules() -> None:
for schedule in OnCallSchedule.objects.all():
conditionally_send_going_oncall_push_notifications_for_schedule.apply_async((schedule.pk,))

View file

@ -9,7 +9,7 @@ from rest_framework import status
from rest_framework.test import APIClient
from apps.mobile_app.fcm_relay import FCMRelayThrottler, _get_message_from_request_data, fcm_relay_async
from apps.mobile_app.tasks import _get_fcm_message
from apps.mobile_app.tasks import _get_alert_group_escalation_fcm_message
@pytest.mark.django_db
@ -118,7 +118,7 @@ def test_fcm_relay_serialize_deserialize(
make_alert(alert_group=alert_group, raw_request_data={})
# Imitate sending a message to the FCM relay endpoint
original_message = _get_fcm_message(alert_group, user, device.registration_id, critical=False)
original_message = _get_alert_group_escalation_fcm_message(alert_group, user, device, critical=False)
request_data = json.loads(str(original_message))
# Imitate receiving a message from the FCM relay endpoint

View file

@ -6,7 +6,7 @@ from firebase_admin.exceptions import FirebaseError
from apps.base.models import UserNotificationPolicy, UserNotificationPolicyLogRecord
from apps.mobile_app.models import MobileAppUserSettings
from apps.mobile_app.tasks import _get_fcm_message, notify_user_async
from apps.mobile_app.tasks import _get_alert_group_escalation_fcm_message, notify_user_async
from apps.oss_installation.models import CloudConnector
MOBILE_APP_BACKEND_ID = 5
@ -223,7 +223,7 @@ def test_fcm_message_user_settings(
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)
message = _get_alert_group_escalation_fcm_message(alert_group, user, device, critical=False)
# Check user settings are passed to FCM message
assert message.data["default_notification_sound_name"] == "default_sound.mp3"
@ -253,7 +253,7 @@ def test_fcm_message_user_settings_critical(
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)
message = _get_alert_group_escalation_fcm_message(alert_group, user, device, critical=True)
# Check user settings are passed to FCM message
assert message.data["default_notification_sound_name"] == "default_sound.mp3"
@ -286,7 +286,7 @@ def test_fcm_message_user_settings_critical_override_dnd_disabled(
# 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)
message = _get_alert_group_escalation_fcm_message(alert_group, user, device, critical=True)
# Check user settings are passed to FCM message
assert message.data["important_notification_override_dnd"] == "false"

View file

@ -6,7 +6,7 @@ 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()
_, _, auth_token = make_organization_and_user_with_mobile_app_auth_token()
client = APIClient()
url = reverse("mobile_app:user_settings")
@ -24,12 +24,25 @@ def test_user_settings_get(make_organization_and_user_with_mobile_app_auth_token
"important_notification_volume_type": "constant",
"important_notification_volume": 0.8,
"important_notification_override_dnd": True,
"info_notifications_enabled": True,
"going_oncall_notification_timing": 43200,
}
@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()
@pytest.mark.parametrize(
"going_oncall_notification_timing,expected_status_code",
[
(43200, status.HTTP_200_OK),
(86400, status.HTTP_200_OK),
(604800, status.HTTP_200_OK),
(500, status.HTTP_400_BAD_REQUEST),
],
)
def test_user_settings_put(
make_organization_and_user_with_mobile_app_auth_token, going_oncall_notification_timing, expected_status_code
):
_, _, auth_token = make_organization_and_user_with_mobile_app_auth_token()
client = APIClient()
url = reverse("mobile_app:user_settings")
@ -42,10 +55,13 @@ def test_user_settings_put(make_organization_and_user_with_mobile_app_auth_token
"important_notification_volume_type": "intensifying",
"important_notification_volume": 1,
"important_notification_override_dnd": False,
"info_notifications_enabled": False,
"going_oncall_notification_timing": going_oncall_notification_timing,
}
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=auth_token)
assert response.status_code == status.HTTP_200_OK
assert response.status_code == expected_status_code
# Check the values are updated correctly
assert response.json() == data
if expected_status_code == status.HTTP_200_OK:
# Check the values are updated correctly
assert response.json() == data

View file

@ -0,0 +1,277 @@
import typing
from unittest import mock
import pytest
from django.core.cache import cache
from django.utils import timezone
from fcm_django.models import FCMDevice
from apps.mobile_app import tasks
from apps.mobile_app.models import MobileAppUserSettings
from apps.schedules.models import OnCallScheduleCalendar, OnCallScheduleICal, OnCallScheduleWeb
from apps.schedules.models.on_call_schedule import ScheduleEvent
ONE_HOUR_IN_SECONDS = 60 * 60
ONCALL_TIMING_PREFERENCE = ONE_HOUR_IN_SECONDS * 12
class ScheduleEventUser(typing.TypedDict):
pk: str
@pytest.fixture(autouse=True)
def clear_cache():
cache.clear()
def _create_schedule_event(
start_time: timezone.datetime, shift_pk: str, users: typing.List[ScheduleEventUser]
) -> ScheduleEvent:
return {"start": start_time, "shift": {"pk": shift_pk}, "users": users}
@pytest.mark.parametrize(
"timing_window_lower,timing_window_upper,seconds_until_shift_starts,expected",
[
(0, 15 * 60, 0, True),
(0, 15 * 60, 1, True),
(0, 15 * 60, (15 * 60) - 1, True),
(0, 15 * 60, 15 * 60, True),
],
)
@pytest.mark.django_db
def test_shift_starts_within_range(timing_window_lower, timing_window_upper, seconds_until_shift_starts, expected):
assert (
tasks._shift_starts_within_range(timing_window_lower, timing_window_upper, seconds_until_shift_starts)
== expected
)
@pytest.mark.parametrize(
"info_notifications_enabled,now,going_oncall_notification_timing,schedule_start,expected",
[
# shift starts in 1h8m, user timing preference is 1h - don't send
(
True,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
timezone.datetime(2022, 5, 2, 13, 13, 0),
None,
),
# shift starts in 1h7m, user timing preference is 1h - send only if info_notifications_enabled is true
(
True,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
timezone.datetime(2022, 5, 2, 13, 12, 0),
67 * 60,
),
(
False,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
timezone.datetime(2022, 5, 2, 13, 12, 0),
None,
),
# shift starts in 53m, user timing preference is 1h - send only if info_notifications_enabled is true
(
True,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
timezone.datetime(2022, 5, 2, 12, 58, 0),
53 * 60,
),
(
False,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
timezone.datetime(2022, 5, 2, 12, 58, 0),
None,
),
# shift starts in 52m, user timing preference is 1h - don't send
(
True,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
timezone.datetime(2022, 5, 2, 12, 57, 0),
None,
),
# shift starts in 16m, don't send
(
True,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
timezone.datetime(2022, 5, 2, 12, 21, 0),
None,
),
# shift starts in 15m - send only if info_notifications_enabled is true
(
True,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
timezone.datetime(2022, 5, 2, 12, 20, 0),
15 * 60,
),
(
False,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
timezone.datetime(2022, 5, 2, 12, 20, 0),
None,
),
# shift starts in 0secs - send only if info_notifications_enabled is true
(
True,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
timezone.datetime(2022, 5, 2, 12, 5, 0),
0,
),
(
False,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
timezone.datetime(2022, 5, 2, 12, 5, 0),
None,
),
# shift started 5secs ago - don't send
(
True,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
timezone.datetime(2022, 5, 2, 12, 4, 55),
None,
),
],
)
@pytest.mark.django_db
def test_should_we_send_going_oncall_push_notification(
make_organization_and_user,
info_notifications_enabled,
now,
going_oncall_notification_timing,
schedule_start,
expected,
):
_, user = make_organization_and_user()
user_mobile_settings = MobileAppUserSettings.objects.create(
user=user,
info_notifications_enabled=info_notifications_enabled,
going_oncall_notification_timing=going_oncall_notification_timing,
)
assert (
tasks.should_we_send_going_oncall_push_notification(
now, user_mobile_settings, _create_schedule_event(schedule_start, "12345", [])
)
== expected
)
def test_generate_going_oncall_push_notification_cache_key() -> None:
user_pk = "adfad"
schedule_event = {"shift": {"pk": "dfdfdf"}}
assert (
tasks._generate_going_oncall_push_notification_cache_key(user_pk, schedule_event)
== f"going_oncall_push_notification:{user_pk}:{schedule_event['shift']['pk']}"
)
@mock.patch("apps.mobile_app.tasks._send_push_notification")
@pytest.mark.django_db
def test_conditionally_send_going_oncall_push_notifications_for_schedule_schedule_not_found(
mocked_send_push_notification,
):
tasks.conditionally_send_going_oncall_push_notifications_for_schedule(12345)
mocked_send_push_notification.assert_not_called()
@mock.patch("apps.mobile_app.tasks.OnCallSchedule.final_events")
@mock.patch("apps.mobile_app.tasks._send_push_notification")
@mock.patch("apps.mobile_app.tasks.should_we_send_going_oncall_push_notification")
@mock.patch("apps.mobile_app.tasks._get_youre_going_oncall_fcm_message")
@pytest.mark.django_db
def test_conditionally_send_going_oncall_push_notifications_for_schedule(
mock_get_youre_going_oncall_fcm_message,
mock_should_we_send_going_oncall_push_notification,
mock_send_push_notification,
mock_oncall_schedule_final_events,
make_organization_and_user,
make_schedule,
):
organization, user = make_organization_and_user()
shift_pk = "mncvmnvc"
user_pk = user.public_primary_key
mock_fcm_message = {"foo": "bar"}
final_events = [
_create_schedule_event(
timezone.now(),
shift_pk,
[
{
"pk": user_pk,
},
],
),
]
mock_get_youre_going_oncall_fcm_message.return_value = mock_fcm_message
mock_should_we_send_going_oncall_push_notification.return_value = True
mock_oncall_schedule_final_events.return_value = final_events
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
cache_key = f"going_oncall_push_notification:{user_pk}:{shift_pk}"
assert cache.get(cache_key) is None
# no device available
tasks.conditionally_send_going_oncall_push_notifications_for_schedule(schedule.pk)
mock_send_push_notification.assert_not_called()
# device available
device = FCMDevice.objects.create(user=user, registration_id="test_device_id")
MobileAppUserSettings.objects.create(user=user, going_oncall_notification_timing=ONCALL_TIMING_PREFERENCE)
tasks.conditionally_send_going_oncall_push_notifications_for_schedule(schedule.pk)
mock_get_youre_going_oncall_fcm_message.assert_called_once_with(user, schedule, device, ONCALL_TIMING_PREFERENCE)
mock_send_push_notification.assert_called_once_with(device, mock_fcm_message)
assert cache.get(cache_key) is True
# we shouldn't double send the same push notification for the same user/shift
tasks.conditionally_send_going_oncall_push_notifications_for_schedule(schedule.pk)
assert mock_send_push_notification.call_count == 1
# if the cache key expires we will resend the push notification for the same user/shift
# (in reality we're setting a timeout on the cache key, here we will just delete it to simulate this)
cache.delete(cache_key)
tasks.conditionally_send_going_oncall_push_notifications_for_schedule(schedule.pk)
assert mock_send_push_notification.call_count == 2
assert cache.get(cache_key) is True
@mock.patch("apps.mobile_app.tasks.conditionally_send_going_oncall_push_notifications_for_schedule")
@pytest.mark.django_db
def test_conditionally_send_going_oncall_push_notifications_for_all_schedules(
mocked_conditionally_send_going_oncall_push_notifications_for_schedule,
make_organization_and_user,
make_schedule,
):
organization, _ = make_organization_and_user()
schedule1 = make_schedule(organization, schedule_class=OnCallScheduleCalendar)
schedule2 = make_schedule(organization, schedule_class=OnCallScheduleICal)
schedule3 = make_schedule(organization, schedule_class=OnCallScheduleWeb)
tasks.conditionally_send_going_oncall_push_notifications_for_all_schedules()
mocked_conditionally_send_going_oncall_push_notifications_for_schedule.apply_async.assert_has_calls(
[
mock.call((schedule1.pk,)),
mock.call((schedule2.pk,)),
mock.call((schedule3.pk,)),
],
any_order=True,
)

View file

@ -3,7 +3,7 @@ import itertools
import re
from collections import defaultdict
from enum import Enum
from typing import Iterable, Optional, TypedDict
from typing import Iterable, List, Optional, Tuple, TypedDict, Union
import icalendar
import pytz
@ -72,6 +72,34 @@ class QualityReport(TypedDict):
overloaded_users: list[QualityReportOverloadedUser]
class ScheduleEventUser(TypedDict):
display_name: str
pk: str
class ScheduleEventShift(TypedDict):
pk: str
class ScheduleEvent(TypedDict):
all_day: bool
start: datetime.datetime
end: datetime.datetime
users: List[ScheduleEventUser]
missing_users: List[str]
priority_level: Union[int, None]
source: Union[str, None]
calendar_type: Union[int, None]
is_empty: bool
is_gap: bool
is_override: bool
shift: ScheduleEventShift
ScheduleEvents = List[ScheduleEvent]
ScheduleEventIntervals = List[List[datetime.datetime]]
def generate_public_primary_key_for_oncall_schedule_channel():
prefix = "S"
new_public_primary_key = generate_public_primary_key(prefix)
@ -261,7 +289,7 @@ class OnCallSchedule(PolymorphicModel):
with_gap=False,
filter_by=None,
all_day_datetime=False,
):
) -> ScheduleEvents:
"""Return filtered events from schedule."""
shifts = (
list_of_oncall_shifts_from_ical(
@ -518,15 +546,15 @@ class OnCallSchedule(PolymorphicModel):
"overloaded_users": overloaded_users,
}
def _resolve_schedule(self, events):
def _resolve_schedule(self, events: ScheduleEvents) -> ScheduleEvents:
"""Calculate final schedule shifts considering rotations and overrides."""
if not events:
return []
def event_start_cmp_key(e):
def event_start_cmp_key(e: ScheduleEvent) -> datetime.datetime:
return e["start"]
def event_cmp_key(e):
def event_cmp_key(e: ScheduleEvent) -> Tuple[int, int, datetime.datetime]:
"""Sorting key criteria for events."""
start = event_start_cmp_key(e)
return (
@ -535,7 +563,7 @@ class OnCallSchedule(PolymorphicModel):
start,
)
def insort_event(eventlist, e):
def insort_event(eventlist: ScheduleEvents, e: ScheduleEvent) -> None:
"""Insert event keeping ordering criteria into already sorted event list."""
idx = 0
for i in eventlist:
@ -545,7 +573,7 @@ class OnCallSchedule(PolymorphicModel):
break
eventlist.insert(idx, e)
def _merge_intervals(evs):
def _merge_intervals(evs: ScheduleEvents) -> ScheduleEventIntervals:
"""Keep track of scheduled intervals."""
if not evs:
return []
@ -567,8 +595,8 @@ class OnCallSchedule(PolymorphicModel):
# split the event, or fix start/end timestamps accordingly
intervals = []
resolved = []
pending = events
resolved: ScheduleEvents = []
pending: ScheduleEvents = events
current_interval_idx = 0 # current scheduled interval being checked
current_type = OnCallSchedule.TYPE_ICAL_OVERRIDES # current calendar type
current_priority = None # current priority level being resolved
@ -643,7 +671,7 @@ class OnCallSchedule(PolymorphicModel):
resolved.sort(key=lambda e: (event_start_cmp_key(e), e["shift"]["pk"] or ""))
return resolved
def _merge_events(self, events):
def _merge_events(self, events: ScheduleEvents) -> ScheduleEvents:
"""Merge user groups same-shift events."""
if events:
merged = [events[0]]

View file

@ -474,6 +474,11 @@ CELERY_BEAT_SCHEDULE = {
"schedule": 60 * 10,
"args": (),
},
"conditionally_send_going_oncall_push_notifications_for_all_schedules": {
"task": "apps.mobile_app.tasks.conditionally_send_going_oncall_push_notifications_for_all_schedules",
"schedule": 10 * 60,
"args": (),
},
}
INTERNAL_IPS = ["127.0.0.1"]