diff --git a/CHANGELOG.md b/CHANGELOG.md index c4a12e545f..dc5bc82866 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/engine/apps/mobile_app/migrations/0004_auto_20230425_1033.py b/engine/apps/mobile_app/migrations/0004_auto_20230425_1033.py new file mode 100644 index 0000000000..4e1cf9c811 --- /dev/null +++ b/engine/apps/mobile_app/migrations/0004_auto_20230425_1033.py @@ -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, + ), + ] diff --git a/engine/apps/mobile_app/models.py b/engine/apps/mobile_app/models.py index 92d0e7f83c..c1f32fbdfc 100644 --- a/engine/apps/mobile_app/models.py +++ b/engine/apps/mobile_app/models.py @@ -107,3 +107,23 @@ class VolumeType(models.TextChoices): # 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 + ) diff --git a/engine/apps/mobile_app/serializers.py b/engine/apps/mobile_app/serializers.py index 1338ecdc97..02e68abfb5 100644 --- a/engine/apps/mobile_app/serializers.py +++ b/engine/apps/mobile_app/serializers.py @@ -15,4 +15,6 @@ class Meta: "important_notification_volume_type", "important_notification_volume", "important_notification_override_dnd", + "info_notifications_enabled", + "going_oncall_notification_timing", ) diff --git a/engine/apps/mobile_app/tasks.py b/engine/apps/mobile_app/tasks.py index 972b34a27b..d0476c5849 100644 --- a/engine/apps/mobile_app/tasks.py +++ b/engine/apps/mobile_app/tasks.py @@ -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,69 +19,60 @@ 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) -@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 - from apps.base.models import UserNotificationPolicy, UserNotificationPolicyLogRecord +class MessageImportanceType(str, Enum): + NORMAL = "oncall.message" + CRITICAL = "oncall.critical_message" - try: - user = User.objects.get(pk=user_pk) - except User.DoesNotExist: - logger.warning(f"User {user_pk} does not exist") - return - try: - alert_group = AlertGroup.all_objects.get(pk=alert_group_pk) - except AlertGroup.DoesNotExist: - logger.warning(f"Alert group {alert_group_pk} does not exist") - return +class FCMMessageData(typing.TypedDict): + title: str + subtitle: typing.Optional[str] + body: typing.Optional[str] - try: - notification_policy = UserNotificationPolicy.objects.get(pk=notification_policy_pk) - except UserNotificationPolicy.DoesNotExist: - logger.warning(f"User notification policy {notification_policy_pk} does not exist") - return - def _create_error_log_record(): - """ - Utility method to create a UserNotificationPolicyLogRecord with error - """ - UserNotificationPolicyLogRecord.objects.create( - author=user, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - notification_policy=notification_policy, - alert_group=alert_group, - reason="Mobile push notification error", - notification_step=notification_policy.step, - notification_channel=notification_policy.notify_by, - ) +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) - device_to_notify = FCMDevice.objects.filter(user=user).first() + response = requests.post( + url, headers={"Authorization": live_settings.GRAFANA_CLOUD_ONCALL_TOKEN}, json=json.loads(str(message)) + ) + response.raise_for_status() + + return response - # create an error log in case user has no devices set up - if not device_to_notify: - _create_error_log_record() - 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};") +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(): - _create_error_log_record() + _error_cb() logger.error(f"Error while sending a mobile push notification: not connected to cloud") return @@ -85,7 +82,7 @@ def _create_error_log_record(): 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() + _error_cb() logger.error( f"Error while sending a mobile push notification: HTTP client error {e.response.status_code}" ) @@ -101,21 +98,56 @@ def _create_error_log_record(): raise response -def send_push_notification_to_fcm_relay(message): - """ - 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) +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 = {} - response = requests.post( - url, headers={"Authorization": live_settings.GRAFANA_CLOUD_ONCALL_TOKEN}, json=json.loads(str(message)) - ) - response.raise_for_status() + if apns_payload is not None: + apns_config_kwargs["payload"] = apns_payload - return response + 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_fcm_message(alert_group, user, registration_id, critical): +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 @@ -153,78 +185,253 @@ def _get_fcm_message(alert_group, user, registration_id, 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 - ), - }, - 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", + 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 ), - 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", - }, - ), + "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, ), - headers={ - # From the docs - # https://firebase.google.com/docs/cloud-messaging/concept-options#setting-the-priority-of-a-message - "apns-priority": "10", + 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 + from apps.base.models import UserNotificationPolicy, UserNotificationPolicyLogRecord + + try: + user = User.objects.get(pk=user_pk) + except User.DoesNotExist: + logger.warning(f"User {user_pk} does not exist") + return + + try: + alert_group = AlertGroup.all_objects.get(pk=alert_group_pk) + except AlertGroup.DoesNotExist: + logger.warning(f"Alert group {alert_group_pk} does not exist") + return + + try: + notification_policy = UserNotificationPolicy.objects.get(pk=notification_policy_pk) + except UserNotificationPolicy.DoesNotExist: + logger.warning(f"User notification policy {notification_policy_pk} does not exist") + return + + def _create_error_log_record(): + """ + Utility method to create a UserNotificationPolicyLogRecord with error + """ + UserNotificationPolicyLogRecord.objects.create( + author=user, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, + notification_policy=notification_policy, + alert_group=alert_group, + reason="Mobile push notification error", + notification_step=notification_policy.step, + notification_channel=notification_policy.notify_by, + ) + + device_to_notify = FCMDevice.objects.filter(user=user).first() + + # create an error log in case user has no devices set up + if not device_to_notify: + _create_error_log_record() + logger.error(f"Error while sending a mobile push notification: user {user_pk} has no device set up") + return + + 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 _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]: + """ + If the user should be set a "you're going oncall" push notification, return the number of seconds + until they will be going oncall. + + 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 + ) + + 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 _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 + + PUSH_NOTIFICATION_TRACKING_CACHE_KEY_TTL = 60 * 60 # 60 minutes + user_cache: typing.Dict[str, User] = {} + device_cache: typing.Dict[str, FCMDevice] = {} + + logger.info(f"Start calculate_going_oncall_push_notifications_for_schedule for schedule {schedule_pk}") + + 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 + + now = timezone.now() + schedule_final_events = schedule.final_events("UTC", now, days=7) + + 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"] + ] + + relevant_notifications_already_sent = cache.get_many(relevant_cache_keys) + + for schedule_event in schedule_final_events: + users = schedule_event["users"] + + 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}") + + 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,)) diff --git a/engine/apps/mobile_app/tests/test_fcm_relay.py b/engine/apps/mobile_app/tests/test_fcm_relay.py index 476346172c..bc2b3e582d 100644 --- a/engine/apps/mobile_app/tests/test_fcm_relay.py +++ b/engine/apps/mobile_app/tests/test_fcm_relay.py @@ -9,7 +9,7 @@ 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 diff --git a/engine/apps/mobile_app/tests/test_notify_user.py b/engine/apps/mobile_app/tests/test_notify_user.py index 9f76d2f76c..48e38a17fe 100644 --- a/engine/apps/mobile_app/tests/test_notify_user.py +++ b/engine/apps/mobile_app/tests/test_notify_user.py @@ -6,7 +6,7 @@ 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" diff --git a/engine/apps/mobile_app/tests/test_user_settings.py b/engine/apps/mobile_app/tests/test_user_settings.py index de14d9dfc8..b4dcc87c40 100644 --- a/engine/apps/mobile_app/tests/test_user_settings.py +++ b/engine/apps/mobile_app/tests/test_user_settings.py @@ -6,7 +6,7 @@ @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 diff --git a/engine/apps/mobile_app/tests/test_your_going_oncall_notification.py b/engine/apps/mobile_app/tests/test_your_going_oncall_notification.py new file mode 100644 index 0000000000..76b77b7ba1 --- /dev/null +++ b/engine/apps/mobile_app/tests/test_your_going_oncall_notification.py @@ -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, + ) diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index 03681a81a9..f69d836b9e 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -3,7 +3,7 @@ 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 @@ def filter_events( 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 @@ def get_balance_score_by_duration_map(dur_map: dict[str, datetime.timedelta]) -> "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 @@ def event_cmp_key(e): 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 @@ def insort_event(eventlist, e): 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 @@ def _merge_intervals(evs): # 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 @@ def _merge_intervals(evs): 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]] diff --git a/engine/settings/base.py b/engine/settings/base.py index 003a43719b..0ab59af265 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -474,6 +474,11 @@ class BrokerTypes: "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"]