"Going oncall" notification settings (#3187)

# What this PR does

## Which issue(s) this PR fixes

## 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)

---------

Co-authored-by: Joey Orlando <joey.orlando@grafana.com>
This commit is contained in:
Yulya Artyukhina 2023-10-30 14:44:18 +01:00 committed by GitHub
parent 6a78ee6983
commit 76a0643bc5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 146 additions and 59 deletions

View file

@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Simplify Direct Paging workflow. Now when using Direct Paging you either simply specify a team, or one or more users
to page by @joeyorlando ([#3128](https://github.com/grafana/oncall/pull/3128))
- Enable timing options for mobile push notifications, allow multi-select by @Ferril ([#3187](https://github.com/grafana/oncall/pull/3187))
### Fixed

View file

@ -0,0 +1,30 @@
# Generated by Django 3.2.20 on 2023-10-30 09:25
import apps.mobile_app.models
import django_migration_linter as linter
from django.db import migrations, models
from apps.mobile_app.models import default_notification_timing_options
def set_going_oncall_notification_timing_to_default(apps, schema_editor):
MobileAppUserSettings = apps.get_model("mobile_app", "MobileAppUserSettings")
default = default_notification_timing_options()
MobileAppUserSettings.objects.all().update(going_oncall_notification_timing=default)
class Migration(migrations.Migration):
dependencies = [
('mobile_app', '0010_mobileappusersettings_time_zone'),
]
operations = [
linter.IgnoreMigration(),
migrations.AlterField(
model_name='mobileappusersettings',
name='going_oncall_notification_timing',
field=models.JSONField(default=apps.mobile_app.models.default_notification_timing_options),
),
migrations.RunPython(set_going_oncall_notification_timing_to_default, migrations.RunPython.noop),
]

View file

@ -4,6 +4,7 @@ import typing
from django.core import validators
from django.db import models
from django.db.models import JSONField
from django.utils import timezone
from fcm_django.models import FCMDevice as BaseFCMDevice
@ -21,6 +22,10 @@ def get_expire_date():
return timezone.now() + timezone.timedelta(seconds=MOBILE_APP_AUTH_VERIFICATION_TOKEN_TIMEOUT_SECONDS)
def default_notification_timing_options():
return [MobileAppUserSettings.FIFTEEN_MINUTES_IN_SECONDS]
class ActiveFCMDeviceQuerySet(models.QuerySet):
def filter(self, *args, **kwargs):
return super().filter(*args, **kwargs, active=True)
@ -159,19 +164,20 @@ class MobileAppUserSettings(models.Model):
# 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)
FIFTEEN_MINUTES_IN_SECONDS = 15 * 60
ONE_HOUR_IN_SECONDS = 60 * 60
SIX_HOURS_IN_SECONDS = 6 * 60 * 60
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 = (
(FIFTEEN_MINUTES_IN_SECONDS, "fifteen minutes before"),
(ONE_HOUR_IN_SECONDS, "one hour before"),
(SIX_HOURS_IN_SECONDS, "six hours before"),
(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
)
going_oncall_notification_timing = JSONField(default=default_notification_timing_options)
locale = models.CharField(max_length=50, null=True)
time_zone = models.CharField(max_length=100, default="UTC")

View file

@ -1,3 +1,5 @@
import typing
from rest_framework import serializers
from apps.mobile_app.models import MobileAppUserSettings
@ -6,6 +8,7 @@ from common.api_helpers.custom_fields import TimeZoneField
class MobileAppUserSettingsSerializer(serializers.ModelSerializer):
time_zone = TimeZoneField(required=False, allow_null=False)
going_oncall_notification_timing = serializers.ListField(required=False, allow_null=False)
class Meta:
model = MobileAppUserSettings
@ -28,3 +31,15 @@ class MobileAppUserSettingsSerializer(serializers.ModelSerializer):
"locale",
"time_zone",
)
def validate_going_oncall_notification_timing(
self, going_oncall_notification_timing: typing.Optional[typing.List[int]]
) -> typing.Optional[typing.List[int]]:
if going_oncall_notification_timing is not None:
if len(going_oncall_notification_timing) == 0:
raise serializers.ValidationError(detail="invalid timing options")
notification_timing_options = [opt[0] for opt in MobileAppUserSettings.NOTIFICATION_TIMING_CHOICES]
for option in going_oncall_notification_timing:
if option not in notification_timing_options:
raise serializers.ValidationError(detail="invalid timing options")
return going_oncall_notification_timing

View file

@ -121,7 +121,6 @@ def _should_we_send_push_notification(
an `int` which represents the # of seconds until the oncall shift starts.
"""
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())
@ -134,32 +133,33 @@ def _should_we_send_push_notification(
logger.info("not sending going oncall push notification because info_notifications_enabled is false")
return None
# 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
for timing_preference in user_notification_timing_preference:
# 14 minute window where the notification could be sent (7 mins before or 7 mins after)
timing_window_lower = timing_preference - NOTIFICATION_TIMING_BUFFER
timing_window_upper = 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
)
shift_starts_within_users_notification_timing_preference = _shift_starts_within_range(
timing_window_lower, timing_window_upper, seconds_until_shift_starts
)
timing_logging_msg = (
if shift_starts_within_users_notification_timing_preference:
logger.info(
f"timing is right to send going oncall push notification\n"
f"seconds_until_shift_starts: {seconds_until_shift_starts}\n"
f"user_notification_timing_preference: {user_notification_timing_preference}\n"
f"current timing_preference: {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"
)
return seconds_until_shift_starts
logger.info(
f"timing is not right to send going oncall push notification\n"
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}"
f"shift_starts_within_users_notification_timing_preference: False\n"
)
# Temporary remove `shift_starts_within_users_notification_timing_preference` from condition to send notification only 15 minutes before the shift starts
# TODO: Return it once mobile app ready and default value is changed (https://github.com/grafana/oncall/issues/1999)
if 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}")
return None

View file

@ -21,6 +21,7 @@ from apps.mobile_app.types import MessageType, Platform
from apps.schedules.models import OnCallScheduleCalendar, OnCallScheduleICal, OnCallScheduleWeb
from apps.schedules.models.on_call_schedule import ScheduleEvent
FIFTEEN_MINUTES_IN_SECONDS = 15 * 60
ONE_HOUR_IN_SECONDS = 60 * 60
ONCALL_TIMING_PREFERENCE = ONE_HOUR_IN_SECONDS * 12
@ -254,7 +255,7 @@ def test_get_fcm_message(
(
True,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
[ONE_HOUR_IN_SECONDS],
timezone.datetime(2022, 5, 2, 13, 13, 0),
None,
),
@ -262,14 +263,14 @@ def test_get_fcm_message(
(
True,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
[ONE_HOUR_IN_SECONDS],
timezone.datetime(2022, 5, 2, 13, 12, 0),
None,
67 * 60,
),
(
False,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
[ONE_HOUR_IN_SECONDS],
timezone.datetime(2022, 5, 2, 13, 12, 0),
None,
),
@ -277,14 +278,14 @@ def test_get_fcm_message(
(
True,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
[ONE_HOUR_IN_SECONDS],
timezone.datetime(2022, 5, 2, 12, 58, 0),
None,
53 * 60,
),
(
False,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
[ONE_HOUR_IN_SECONDS],
timezone.datetime(2022, 5, 2, 12, 58, 0),
None,
),
@ -292,7 +293,7 @@ def test_get_fcm_message(
(
True,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
[ONE_HOUR_IN_SECONDS],
timezone.datetime(2022, 5, 2, 12, 57, 0),
None,
),
@ -300,37 +301,30 @@ def test_get_fcm_message(
(
True,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
[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
# shift starts in 15m, user timing preference is 1h and 15m - send only if info_notifications_enabled is true
(
True,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
[ONE_HOUR_IN_SECONDS, FIFTEEN_MINUTES_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,
[ONE_HOUR_IN_SECONDS, FIFTEEN_MINUTES_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,
),
# shift starts in 0secs - don't send
(
False,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
[ONE_HOUR_IN_SECONDS],
timezone.datetime(2022, 5, 2, 12, 5, 0),
None,
),
@ -338,7 +332,7 @@ def test_get_fcm_message(
(
True,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
[ONE_HOUR_IN_SECONDS],
timezone.datetime(2022, 5, 2, 12, 4, 55),
None,
),

View file

@ -33,20 +33,42 @@ def test_user_settings_get(make_organization_and_user_with_mobile_app_auth_token
"important_notification_volume_override": True,
"important_notification_override_dnd": True,
"info_notifications_enabled": False,
"going_oncall_notification_timing": 43200,
"going_oncall_notification_timing": [900],
"locale": None,
"time_zone": "UTC",
}
@pytest.mark.django_db
def test_user_settings_get_notification_timing_options(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:notification_timing_options")
choices = [
{"value": item[0], "display_name": item[1]} for item in MobileAppUserSettings.NOTIFICATION_TIMING_CHOICES
]
response = client.get(url, HTTP_AUTHORIZATION=auth_token)
assert response.status_code == status.HTTP_200_OK
# Check the default values are correct
assert response.json() == choices
@pytest.mark.django_db
@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),
([MobileAppUserSettings.FIFTEEN_MINUTES_IN_SECONDS], status.HTTP_200_OK),
([MobileAppUserSettings.ONE_HOUR_IN_SECONDS], status.HTTP_200_OK),
([MobileAppUserSettings.SIX_HOURS_IN_SECONDS], status.HTTP_200_OK),
([MobileAppUserSettings.TWELVE_HOURS_IN_SECONDS], status.HTTP_200_OK),
([MobileAppUserSettings.ONE_DAY_IN_SECONDS], status.HTTP_200_OK),
([MobileAppUserSettings.ONE_DAY_IN_SECONDS, MobileAppUserSettings.ONE_HOUR_IN_SECONDS], status.HTTP_200_OK),
([123], status.HTTP_400_BAD_REQUEST),
([], status.HTTP_400_BAD_REQUEST),
],
)
def test_user_settings_put(

View file

@ -1,5 +1,5 @@
from apps.mobile_app.fcm_relay import FCMRelayView
from apps.mobile_app.views import FCMDeviceAuthorizedViewSet, MobileAppAuthTokenAPIView, MobileAppUserSettingsAPIView
from apps.mobile_app.views import FCMDeviceAuthorizedViewSet, MobileAppAuthTokenAPIView, MobileAppUserSettingsViewSet
from common.api_helpers.optional_slash_router import OptionalSlashRouter, optional_slash_path
app_name = "mobile_app"
@ -10,7 +10,16 @@ router.register("fcm", FCMDeviceAuthorizedViewSet, basename="fcm")
urlpatterns = [
*router.urls,
optional_slash_path("auth_token", MobileAppAuthTokenAPIView.as_view(), name="auth_token"),
optional_slash_path("user_settings", MobileAppUserSettingsAPIView.as_view(), name="user_settings"),
optional_slash_path(
"user_settings/notification_timing_options",
MobileAppUserSettingsViewSet.as_view({"get": "notification_timing_options"}),
name="notification_timing_options",
),
optional_slash_path(
"user_settings",
MobileAppUserSettingsViewSet.as_view({"get": "retrieve", "put": "update", "patch": "partial_update"}),
name="user_settings",
),
]
urlpatterns += [

View file

@ -1,5 +1,5 @@
from fcm_django.api.rest_framework import FCMDeviceAuthorizedViewSet as BaseFCMDeviceAuthorizedViewSet
from rest_framework import generics, status
from rest_framework import mixins, status, viewsets
from rest_framework.exceptions import NotFound
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
@ -54,7 +54,11 @@ class MobileAppAuthTokenAPIView(APIView):
return Response(status=status.HTTP_204_NO_CONTENT)
class MobileAppUserSettingsAPIView(generics.RetrieveUpdateAPIView):
class MobileAppUserSettingsViewSet(
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
authentication_classes = (MobileAppAuthTokenAuthentication,)
permission_classes = (IsAuthenticated,)
serializer_class = MobileAppUserSettingsSerializer
@ -62,3 +66,9 @@ class MobileAppUserSettingsAPIView(generics.RetrieveUpdateAPIView):
def get_object(self):
mobile_app_settings, _ = MobileAppUserSettings.objects.get_or_create(user=self.request.user)
return mobile_app_settings
def notification_timing_options(self, request):
choices = [
{"value": item[0], "display_name": item[1]} for item in MobileAppUserSettings.NOTIFICATION_TIMING_CHOICES
]
return Response(choices)