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

allowing storing/updating time_zone in mobile app user settings table #2601

Merged
merged 3 commits into from
Jul 20, 2023
Merged
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Added

- Allow persisting mobile app's timezone, to allow for more accurate datetime related notifications by @joeyorlando
([#2601](https://github.com/grafana/oncall/pull/2601))

### Fixed

- Fix Slack direct paging issue when there are more than 100 schedules by @vadimkerr ([#2594](https://github.com/grafana/oncall/pull/2594))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Generated by Django 3.2.20 on 2023-07-20 14:53

from django.db import migrations, models
from django_add_default_value import AddDefaultValue


class Migration(migrations.Migration):

dependencies = [
('mobile_app', '0009_fcmdevice'),
]

operations = [
migrations.AddField(
model_name='mobileappusersettings',
name='time_zone',
field=models.CharField(default='UTC', max_length=100),
),
# 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='time_zone',
value='UTC',
Copy link
Contributor Author

Choose a reason for hiding this comment

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

we are already using "UTC" as the fallback value here if a value is not set in the user object.

),
]
1 change: 1 addition & 0 deletions engine/apps/mobile_app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,4 @@ class VolumeType(models.TextChoices):
)

locale = models.CharField(max_length=50, null=True)
time_zone = models.CharField(max_length=100, default="UTC")
4 changes: 4 additions & 0 deletions engine/apps/mobile_app/serializers.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
from rest_framework import serializers

from apps.mobile_app.models import MobileAppUserSettings
from common.timezones import TimeZoneField


class MobileAppUserSettingsSerializer(serializers.ModelSerializer):
time_zone = TimeZoneField(required=False, allow_null=False)

class Meta:
model = MobileAppUserSettings
fields = (
Expand All @@ -23,4 +26,5 @@ class Meta:
"info_notifications_enabled",
"going_oncall_notification_timing",
"locale",
"time_zone",
)
13 changes: 3 additions & 10 deletions engine/apps/mobile_app/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
from common.api_helpers.utils import create_engine_url
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
from common.l10n import format_localized_datetime, format_localized_time
from common.timezones import is_valid_timezone

if typing.TYPE_CHECKING:
from apps.mobile_app.models import FCMDevice, MobileAppUserSettings
Expand Down Expand Up @@ -235,7 +234,6 @@ def _get_youre_going_oncall_notification_subtitle(
schedule: OnCallSchedule,
schedule_event: ScheduleEvent,
mobile_app_user_settings: "MobileAppUserSettings",
user_timezone: typing.Optional[str],
) -> str:
shift_start = schedule_event["start"]
shift_end = schedule_event["end"]
Expand All @@ -244,16 +242,11 @@ def _get_youre_going_oncall_notification_subtitle(

def _format_datetime(dt: datetime.datetime) -> str:
"""
1. Convert the shift datetime to the user's configured timezone, if set, otherwise fallback to UTC
1. Convert the shift datetime to the user's mobile device's timezone
2. Display the timezone aware datetime as a formatted string that is based on the user's configured mobile
app locale, otherwise fallback to "en"
"""
if user_timezone is None or not is_valid_timezone(user_timezone):
user_tz = "UTC"
else:
user_tz = user_timezone

localized_dt = dt.astimezone(pytz.timezone(user_tz))
localized_dt = dt.astimezone(pytz.timezone(mobile_app_user_settings.time_zone))
Copy link
Contributor Author

Choose a reason for hiding this comment

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

main change of the PR

return dt_formatter_func(localized_dt, mobile_app_user_settings.locale)

formatted_shift = f"{_format_datetime(shift_start)} - {_format_datetime(shift_end)}"
Expand All @@ -277,7 +270,7 @@ def _get_youre_going_oncall_fcm_message(

notification_title = _get_youre_going_oncall_notification_title(seconds_until_going_oncall)
notification_subtitle = _get_youre_going_oncall_notification_subtitle(
schedule, schedule_event, mobile_app_user_settings, user.timezone
schedule, schedule_event, mobile_app_user_settings
)

data: FCMMessageData = {
Expand Down
24 changes: 24 additions & 0 deletions engine/apps/mobile_app/tests/test_user_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def test_user_settings_get(make_organization_and_user_with_mobile_app_auth_token
"info_notifications_enabled": False,
"going_oncall_notification_timing": 43200,
"locale": None,
"time_zone": "UTC",
}


Expand Down Expand Up @@ -69,6 +70,7 @@ def test_user_settings_put(
"info_notifications_enabled": True,
"going_oncall_notification_timing": going_oncall_notification_timing,
"locale": "ca_FR",
"time_zone": "Europe/Paris",
}

response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=auth_token)
Expand Down Expand Up @@ -103,6 +105,7 @@ def test_user_settings_patch(make_organization_and_user_with_mobile_app_auth_tok
"important_notification_volume_override": False,
"important_notification_override_dnd": False,
"info_notifications_enabled": True,
"time_zone": "Europe/Luxembourg",
}

response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=auth_token)
Expand All @@ -116,3 +119,24 @@ def test_user_settings_patch(make_organization_and_user_with_mobile_app_auth_tok
assert response.status_code == status.HTTP_200_OK
# all original settings should stay the same, only data set in PATCH call should get updated
assert response.json() == {**original_settings, **patch_data}


@pytest.mark.django_db
def test_user_settings_time_zone_must_be_valid(make_organization_and_user_with_mobile_app_auth_token):
_, _, auth_token = make_organization_and_user_with_mobile_app_auth_token()

valid_timezone = {"time_zone": "Europe/Luxembourg"}
invalid_timezone = {"time_zone": "asdflkjasdlkj"}
null_timezone = {"time_zone": None}

client = APIClient()
url = reverse("mobile_app:user_settings")

response = client.put(url, data=valid_timezone, format="json", HTTP_AUTHORIZATION=auth_token)
assert response.status_code == status.HTTP_200_OK

response = client.put(url, data=invalid_timezone, format="json", HTTP_AUTHORIZATION=auth_token)
assert response.status_code == status.HTTP_400_BAD_REQUEST

response = client.put(url, data=null_timezone, format="json", HTTP_AUTHORIZATION=auth_token)
assert response.status_code == status.HTTP_400_BAD_REQUEST
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,9 @@ def test_get_youre_going_oncall_notification_title(make_organization_and_user, m
# same day shift
##################
same_day_shift_title = tasks._get_youre_going_oncall_notification_title(seconds_until_going_oncall)
same_day_shift_subtitle = tasks._get_youre_going_oncall_notification_subtitle(
schedule, same_day_shift, maus, user.timezone
)
same_day_shift_subtitle = tasks._get_youre_going_oncall_notification_subtitle(schedule, same_day_shift, maus)
same_day_shift_no_locale_subtitle = tasks._get_youre_going_oncall_notification_subtitle(
schedule, same_day_shift, maus_no_locale, user2.timezone
schedule, same_day_shift, maus_no_locale
)

assert same_day_shift_title == f"Your on-call shift starts in {humanized_time_until_going_oncall}"
Expand All @@ -111,10 +109,10 @@ def test_get_youre_going_oncall_notification_title(make_organization_and_user, m
##################
multiple_day_shift_title = tasks._get_youre_going_oncall_notification_title(seconds_until_going_oncall)
multiple_day_shift_subtitle = tasks._get_youre_going_oncall_notification_subtitle(
schedule, multiple_day_shift, maus, user.timezone
schedule, multiple_day_shift, maus
)
multiple_day_shift_no_locale_subtitle = tasks._get_youre_going_oncall_notification_subtitle(
schedule, multiple_day_shift, maus_no_locale, user2.timezone
schedule, multiple_day_shift, maus_no_locale
)

assert multiple_day_shift_title == f"Your on-call shift starts in {humanized_time_until_going_oncall}"
Expand All @@ -128,9 +126,8 @@ def test_get_youre_going_oncall_notification_title(make_organization_and_user, m
@pytest.mark.parametrize(
"user_timezone,expected_shift_times",
[
(None, "9:00 AM - 5:00 PM"),
("UTC", "9:00 AM - 5:00 PM"),
("Europe/Amsterdam", "11:00 AM - 7:00 PM"),
("asdfasdfasdf", "9:00 AM - 5:00 PM"),
],
)
@pytest.mark.django_db
Expand All @@ -140,9 +137,9 @@ def test_get_youre_going_oncall_notification_subtitle(
schedule_name = "asdfasdfasdfasdf"

organization = make_organization()
user = make_user_for_organization(organization, _timezone=user_timezone)
user = make_user_for_organization(organization)
user_pk = user.public_primary_key
maus = MobileAppUserSettings.objects.create(user=user)
maus = MobileAppUserSettings.objects.create(user=user, time_zone=user_timezone)

schedule = make_schedule(organization, name=schedule_name, schedule_class=OnCallScheduleWeb)

Expand All @@ -161,7 +158,7 @@ def test_get_youre_going_oncall_notification_subtitle(
)

assert (
tasks._get_youre_going_oncall_notification_subtitle(schedule, shift, maus, user.timezone)
tasks._get_youre_going_oncall_notification_subtitle(schedule, shift, maus)
== f"{expected_shift_times}\nSchedule {schedule_name}"
)

Expand Down Expand Up @@ -198,7 +195,7 @@ def test_get_youre_going_oncall_fcm_message(

organization = make_organization()
user_tz = "Europe/Amsterdam"
user = make_user_for_organization(organization, _timezone=user_tz)
user = make_user_for_organization(organization)
user_pk = user.public_primary_key
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
notification_thread_id = f"{schedule.public_primary_key}:{user_pk}:going-oncall"
Expand All @@ -215,7 +212,7 @@ def test_get_youre_going_oncall_fcm_message(
)

device = FCMDevice.objects.create(user=user)
maus = MobileAppUserSettings.objects.create(user=user)
maus = MobileAppUserSettings.objects.create(user=user, time_zone=user_tz)

data = {
"title": mock_notification_title,
Expand Down Expand Up @@ -248,7 +245,7 @@ def test_get_youre_going_oncall_fcm_message(
)
mock_apns_payload.assert_called_once_with(aps=mock_aps.return_value)

mock_get_youre_going_oncall_notification_subtitle.assert_called_once_with(schedule, schedule_event, maus, user_tz)
mock_get_youre_going_oncall_notification_subtitle.assert_called_once_with(schedule, schedule_event, maus)
mock_get_youre_going_oncall_notification_title.assert_called_once_with(seconds_until_going_oncall)

mock_construct_fcm_message.assert_called_once_with(
Expand Down