Skip to content

Commit

Permalink
Add mobile app push notifications for shift swap requests (#2717)
Browse files Browse the repository at this point in the history
# What this PR does

Adds mobile app push notifications for shift swap requests.

## Which issue(s) this PR fixes

#2630

## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)
  • Loading branch information
vstpme authored Aug 2, 2023
1 parent 2bc5c28 commit c855258
Show file tree
Hide file tree
Showing 13 changed files with 735 additions and 3 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Added

- Add mobile app push notifications for shift swap requests by @vadimkerr ([#2717](https://github.com/grafana/oncall/pull/2717))

### Changed

- Skip past due swap requests when calculating events ([2718](https://github.com/grafana/oncall/pull/2718))
Expand Down
2 changes: 1 addition & 1 deletion engine/apps/mobile_app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ class VolumeType(models.TextChoices):

# Push notification settings for info notifications
# this is used for non escalation related push notifications such as the
# "You're going OnCall soon" push notification
# "You're going OnCall soon" and "You have a new shift swap request" push notifications
info_notifications_enabled = models.BooleanField(default=False)

info_notification_sound_name = models.CharField(max_length=100, default="default_sound", null=True)
Expand Down
186 changes: 186 additions & 0 deletions engine/apps/mobile_app/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from apps.alerts.models import AlertGroup
from apps.base.utils import live_settings
from apps.mobile_app.alert_rendering import get_push_notification_subtitle
from apps.schedules.models import ShiftSwapRequest
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
Expand Down Expand Up @@ -499,3 +500,188 @@ def conditionally_send_going_oncall_push_notifications_for_schedule(schedule_pk)
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,))


# TODO: break down tasks.py into multiple files

# Don't send notifications for shift swap requests that start more than 4 weeks in the future
SSR_EARLIEST_NOTIFICATION_OFFSET = datetime.timedelta(weeks=4)

# Once it's time to send out notifications, send them over the course of a week.
# This is because users can be in multiple timezones / have different working hours configured,
# so we can't just send all notifications at once, but need to wait for the users to be in their working hours.
# Once a notification is sent to a user, they won't be notified again for the same shift swap request for a week.
# After a week, the shift swap request won't be in the notification window anymore (see _get_shift_swap_requests_to_notify).
SSR_NOTIFICATION_WINDOW = datetime.timedelta(weeks=1)


@shared_dedicated_queue_retry_task()
def notify_shift_swap_requests() -> None:
"""
A periodic task that notifies users about shift swap requests.
"""

if not settings.FEATURE_SHIFT_SWAPS_ENABLED:
return

for shift_swap_request in _get_shift_swap_requests_to_notify(timezone.now()):
notify_shift_swap_request.delay(shift_swap_request.pk)


def _get_shift_swap_requests_to_notify(now: datetime.datetime) -> list[ShiftSwapRequest]:
"""
Returns shifts swap requests that are open and are in the notification window.
This method can return the same shift swap request multiple times while it's in the notification window,
but users are only notified once per shift swap request (see _mark_shift_swap_request_notified_for_user).
"""

shift_swap_requests_in_notification_window = []
for shift_swap_request in ShiftSwapRequest.objects.filter(benefactor__isnull=True, swap_start__gt=now):
notification_window_start = max(
shift_swap_request.created_at, shift_swap_request.swap_start - SSR_EARLIEST_NOTIFICATION_OFFSET
)
notification_window_end = min(notification_window_start + SSR_NOTIFICATION_WINDOW, shift_swap_request.swap_end)

if notification_window_start <= now <= notification_window_end:
shift_swap_requests_in_notification_window.append(shift_swap_request)

return shift_swap_requests_in_notification_window


@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True, max_retries=MAX_RETRIES)
def notify_shift_swap_request(shift_swap_request_pk: int) -> None:
"""
Notify relevant users for an individual shift swap request.
"""
try:
shift_swap_request = ShiftSwapRequest.objects.get(pk=shift_swap_request_pk)
except ShiftSwapRequest.DoesNotExist:
logger.info(f"ShiftSwapRequest {shift_swap_request_pk} does not exist")
return

now = timezone.now()
for user in shift_swap_request.possible_benefactors:
if _should_notify_user_about_shift_swap_request(shift_swap_request, user, now):
notify_user_about_shift_swap_request.delay(shift_swap_request.pk, user.pk)
_mark_shift_swap_request_notified_for_user(shift_swap_request, user)


@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True, max_retries=MAX_RETRIES)
def notify_user_about_shift_swap_request(shift_swap_request_pk: int, user_pk: int) -> None:
"""
Send a push notification about a shift swap request to an individual user.
"""
# avoid circular import
from apps.mobile_app.models import FCMDevice, MobileAppUserSettings

try:
shift_swap_request = ShiftSwapRequest.objects.get(pk=shift_swap_request_pk)
except ShiftSwapRequest.DoesNotExist:
logger.info(f"ShiftSwapRequest {shift_swap_request_pk} does not exist")
return

try:
user = User.objects.get(pk=user_pk)
except User.DoesNotExist:
logger.info(f"User {user_pk} does not exist")
return

device_to_notify = FCMDevice.get_active_device_for_user(user)
if not device_to_notify:
logger.info(f"FCMDevice does not exist for user {user_pk}")
return

try:
mobile_app_user_settings = MobileAppUserSettings.objects.get(user=user)
except MobileAppUserSettings.DoesNotExist:
logger.info(f"MobileAppUserSettings does not exist for user {user_pk}")
return

if not mobile_app_user_settings.info_notifications_enabled:
logger.info(f"Info notifications are not enabled for user {user_pk}")
return

if not shift_swap_request.is_open:
logger.info(f"Shift swap request {shift_swap_request_pk} is not open anymore")
return

message = _shift_swap_request_fcm_message(shift_swap_request, user, device_to_notify, mobile_app_user_settings)
_send_push_notification(device_to_notify, message)


def _should_notify_user_about_shift_swap_request(
shift_swap_request: ShiftSwapRequest, user: User, now: datetime.datetime
) -> bool:
# avoid circular import
from apps.mobile_app.models import MobileAppUserSettings

try:
mobile_app_user_settings = MobileAppUserSettings.objects.get(user=user)
except MobileAppUserSettings.DoesNotExist:
return False # don't notify if the app is not configured

return (
mobile_app_user_settings.info_notifications_enabled # info notifications must be enabled
and user.is_in_working_hours(now, mobile_app_user_settings.time_zone) # user must be in working hours
and not _has_user_been_notified_for_shift_swap_request(shift_swap_request, user) # don't notify twice
)


def _mark_shift_swap_request_notified_for_user(shift_swap_request: ShiftSwapRequest, user: User) -> None:
key = _shift_swap_request_cache_key(shift_swap_request, user)
cache.set(key, True, timeout=SSR_NOTIFICATION_WINDOW.total_seconds())


def _has_user_been_notified_for_shift_swap_request(shift_swap_request: ShiftSwapRequest, user: User) -> bool:
key = _shift_swap_request_cache_key(shift_swap_request, user)
return cache.get(key) is True


def _shift_swap_request_cache_key(shift_swap_request: ShiftSwapRequest, user: User) -> str:
return f"ssr_push:{shift_swap_request.pk}:{user.pk}"


def _shift_swap_request_fcm_message(
shift_swap_request: ShiftSwapRequest,
user: User,
device_to_notify: "FCMDevice",
mobile_app_user_settings: "MobileAppUserSettings",
) -> Message:
from apps.mobile_app.models import MobileAppUserSettings

thread_id = f"{shift_swap_request.public_primary_key}:{user.public_primary_key}:ssr"
notification_title = "New shift swap request"
beneficiary_name = shift_swap_request.beneficiary.name or shift_swap_request.beneficiary.username
notification_subtitle = f"{beneficiary_name}, {shift_swap_request.schedule.name}"

# The mobile app will use this route to open the shift swap request
route = f"/schedules/{shift_swap_request.schedule.public_primary_key}/ssrs/{shift_swap_request.public_primary_key}"

data: FCMMessageData = {
"title": notification_title,
"subtitle": notification_subtitle,
"route": route,
"info_notification_sound_name": (
mobile_app_user_settings.info_notification_sound_name + MobileAppUserSettings.ANDROID_SOUND_NAME_EXTENSION
),
"info_notification_volume_type": mobile_app_user_settings.info_notification_volume_type,
"info_notification_volume": str(mobile_app_user_settings.info_notification_volume),
"info_notification_volume_override": json.dumps(mobile_app_user_settings.info_notification_volume_override),
}

apns_payload = APNSPayload(
aps=Aps(
thread_id=thread_id,
alert=ApsAlert(title=notification_title, subtitle=notification_subtitle),
sound=CriticalSound(
critical=False,
name=mobile_app_user_settings.info_notification_sound_name
+ MobileAppUserSettings.IOS_SOUND_NAME_EXTENSION,
),
custom_data={
"interruption-level": "time-sensitive",
},
),
)

return _construct_fcm_message(MessageType.INFO, device_to_notify, thread_id, data, apns_payload)
Loading

0 comments on commit c855258

Please sign in to comment.