Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 17 commits into from
May 4, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions engine/apps/mobile_app/fcm_relay.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def post(self, request):
try:
token = request.data["token"]
data = request.data["data"]
apns = request.data["apns"]
apns = request.data.get("apns") # optional
android = request.data.get("android") # optional
except KeyError:
return Response(status=status.HTTP_400_BAD_REQUEST)
Expand All @@ -53,7 +53,7 @@ def post(self, request):
@shared_dedicated_queue_retry_task(
autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else 5
)
def fcm_relay_async(token, data, apns, android=None):
def fcm_relay_async(token, data, apns=None, android=None):
message = _get_message_from_request_data(token, data, apns, android)

# https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream
Expand All @@ -68,9 +68,11 @@ def _get_message_from_request_data(token, data, apns, android):
"""
Create Message object from JSON payload from OSS instance.
"""

return Message(
token=token, data=data, apns=_deserialize_apns(apns), android=AndroidConfig(**android) if android else None
token=token,
data=data,
apns=_deserialize_apns(apns) if apns else None,
android=AndroidConfig(**android) if android else None,
)


Expand Down
39 changes: 39 additions & 0 deletions engine/apps/mobile_app/migrations/0004_auto_20230425_1033.py
Original file line number Diff line number Diff line change
@@ -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=[(0, 'twelve hours before')], default=0),
),
# 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=0,
),
]
11 changes: 11 additions & 0 deletions engine/apps/mobile_app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,14 @@ 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 = 0
NOTIFICATION_TIMING_CHOICES = ((TWELVE_HOURS, "twelve hours before"),)
going_oncall_notification_timing = models.IntegerField(choices=NOTIFICATION_TIMING_CHOICES, default=TWELVE_HOURS)
2 changes: 2 additions & 0 deletions engine/apps/mobile_app/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@ class Meta:
"important_notification_volume_type",
"important_notification_volume",
"important_notification_override_dnd",
"info_notifications_enabled",
"going_oncall_notification_timing",
)
92 changes: 92 additions & 0 deletions engine/apps/mobile_app/tasks.py
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this looks like a bigger diff than it actually is. I extracted the logic that is now in the following methods to avoid having to duplicate w/ the addition of this new push notification:

  • send_push_notification_to_fcm_relay
  • _send_push_notification
  • _construct_fcm_message

Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

import requests
from celery.utils.log import get_task_logger
from django.apps import apps
from django.conf import settings
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
Expand All @@ -22,6 +24,13 @@
logger.setLevel(logging.DEBUG)


# @shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True, max_retries=MAX_RETRIES)
def send_push_notification(user, device_registration_id, message) -> None:
# TODO: refactor notify_user_async and conditionally_send_going_oncall_push_notifications_for_schedule
# to deduplicate most of their logic to this method
pass


@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
Expand Down Expand Up @@ -228,3 +237,86 @@ def _get_fcm_message(alert_group, user, registration_id, critical):
},
),
)


@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:
logger.info(f"Start calculate_going_oncall_push_notifications_for_schedule for schedule {schedule_pk}")

OnCallSchedule = apps.get_model("schedules", "OnCallSchedule")

try:
schedule = 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

schedule_events = schedule.final_events("UTC", timezone.now(), days=5)

for schedule_event in schedule_events:
users = schedule_event["users"]

for user in users:
user_pk = user["pk"]

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

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

# TODO: determine if the timing for the notification is correct

message = Message(
token=device_to_notify.registration_id,
data={
# from the docs..
# A dictionary of data fields (optional). All keys and values in the dictionary must be strings
"type": "oncall.message",
"title": "You are going on call in X time for schedule foo", # TODO:
"subtitle": "foo bar baz", # TODO:
"body": "blah blah blah", # TODO:
"thread_id": f"{schedule.public_primary_key}:{user.public_primary_key}:going-oncall",
},
)

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():
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)
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


@shared_dedicated_queue_retry_task()
def conditionally_send_going_oncall_push_notifications_for_all_schedules() -> None:
OnCallSchedule = apps.get_model("schedules", "OnCallSchedule")
for schedule in OnCallSchedule.objects.all():
conditionally_send_going_oncall_push_notifications_for_schedule.apply_async((schedule.pk,))
8 changes: 6 additions & 2 deletions engine/apps/mobile_app/tests/test_user_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -24,12 +24,14 @@ 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": 0,
}


@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()
_, _, auth_token = make_organization_and_user_with_mobile_app_auth_token()

client = APIClient()
url = reverse("mobile_app:user_settings")
Expand All @@ -42,6 +44,8 @@ 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": 0,
}

response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=auth_token)
Expand Down
5 changes: 5 additions & 0 deletions engine/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,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"]
Expand Down