"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:
parent
311e5209f1
commit
620f69e409
11 changed files with 762 additions and 164 deletions
|
|
@ -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))
|
||||
|
|
|
|||
39
engine/apps/mobile_app/migrations/0004_auto_20230425_1033.py
Normal file
39
engine/apps/mobile_app/migrations/0004_auto_20230425_1033.py
Normal 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,
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue