diff --git a/CHANGELOG.md b/CHANGELOG.md index 59d6c8e008..82ae90cade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/engine/apps/mobile_app/models.py b/engine/apps/mobile_app/models.py index 89c6703e07..a4a79abd23 100644 --- a/engine/apps/mobile_app/models.py +++ b/engine/apps/mobile_app/models.py @@ -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) diff --git a/engine/apps/mobile_app/tasks.py b/engine/apps/mobile_app/tasks.py index 41b36a3108..a4cd988961 100644 --- a/engine/apps/mobile_app/tasks.py +++ b/engine/apps/mobile_app/tasks.py @@ -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 @@ -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) diff --git a/engine/apps/mobile_app/tests/test_shift_swap_request.py b/engine/apps/mobile_app/tests/test_shift_swap_request.py new file mode 100644 index 0000000000..46d8b485a2 --- /dev/null +++ b/engine/apps/mobile_app/tests/test_shift_swap_request.py @@ -0,0 +1,350 @@ +from unittest.mock import PropertyMock, patch + +import pytest +from django.core.cache import cache +from django.utils import timezone +from firebase_admin.messaging import Message + +from apps.mobile_app.models import FCMDevice, MobileAppUserSettings +from apps.mobile_app.tasks import ( + SSR_EARLIEST_NOTIFICATION_OFFSET, + SSR_NOTIFICATION_WINDOW, + MessageType, + _get_shift_swap_requests_to_notify, + _has_user_been_notified_for_shift_swap_request, + _mark_shift_swap_request_notified_for_user, + _should_notify_user_about_shift_swap_request, + notify_shift_swap_request, + notify_shift_swap_requests, + notify_user_about_shift_swap_request, +) +from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb, ShiftSwapRequest +from apps.user_management.models import User +from apps.user_management.models.user import default_working_hours + +MICROSECOND = timezone.timedelta(microseconds=1) + + +def test_window_more_than_24_hours(): + """ + SSR_NOTIFICATION_WINDOW must be more than one week, otherwise it's not possible to guarantee that the + notification will be sent according to users' working hours. For example, if user only works on Fridays 10am-2pm, + and a shift swap request is created on Friday 3pm, we must wait for a whole week to send the notification. + """ + assert SSR_NOTIFICATION_WINDOW >= timezone.timedelta(weeks=1) + + +@pytest.mark.django_db +def test_get_shift_swap_requests_to_notify_starts_soon( + make_organization, make_user, make_schedule, make_shift_swap_request +): + organization = make_organization() + user = make_user(organization=organization) + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + + now = timezone.now() + swap_start = now + timezone.timedelta(days=10) + swap_end = swap_start + timezone.timedelta(days=1) + + shift_swap_request = make_shift_swap_request( + schedule, user, swap_start=swap_start, swap_end=swap_end, created_at=now + ) + + assert _get_shift_swap_requests_to_notify(now - MICROSECOND) == [] + assert _get_shift_swap_requests_to_notify(now) == [shift_swap_request] + assert _get_shift_swap_requests_to_notify(now + SSR_NOTIFICATION_WINDOW) == [shift_swap_request] + assert _get_shift_swap_requests_to_notify(now + SSR_NOTIFICATION_WINDOW + MICROSECOND) == [] + + +@pytest.mark.django_db +def test_get_shift_swap_requests_to_notify_starts_very_soon( + make_organization, make_user, make_schedule, make_shift_swap_request +): + organization = make_organization() + user = make_user(organization=organization) + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + + now = timezone.now() + swap_start = now + timezone.timedelta(minutes=1) + swap_end = swap_start + timezone.timedelta(minutes=10) + + shift_swap_request = make_shift_swap_request( + schedule, user, swap_start=swap_start, swap_end=swap_end, created_at=now + ) + + assert _get_shift_swap_requests_to_notify(now - MICROSECOND) == [] + assert _get_shift_swap_requests_to_notify(now) == [shift_swap_request] + assert _get_shift_swap_requests_to_notify(now + timezone.timedelta(minutes=1)) == [] + + +@pytest.mark.django_db +def test_get_shift_swap_requests_to_notify_starts_not_soon( + make_organization, make_user, make_schedule, make_shift_swap_request +): + organization = make_organization() + user = make_user(organization=organization) + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + + now = timezone.now() + swap_start = now + timezone.timedelta(days=100) + swap_end = swap_start + timezone.timedelta(days=1) + + shift_swap_request = make_shift_swap_request( + schedule, user, swap_start=swap_start, swap_end=swap_end, created_at=now + ) + + assert _get_shift_swap_requests_to_notify(now) == [] + assert _get_shift_swap_requests_to_notify(swap_start - SSR_EARLIEST_NOTIFICATION_OFFSET - MICROSECOND) == [] + assert _get_shift_swap_requests_to_notify(swap_start - SSR_EARLIEST_NOTIFICATION_OFFSET) == [shift_swap_request] + assert _get_shift_swap_requests_to_notify( + swap_start - SSR_EARLIEST_NOTIFICATION_OFFSET + SSR_NOTIFICATION_WINDOW + ) == [shift_swap_request] + assert ( + _get_shift_swap_requests_to_notify( + swap_start - SSR_EARLIEST_NOTIFICATION_OFFSET + SSR_NOTIFICATION_WINDOW + MICROSECOND + ) + == [] + ) + + +@pytest.mark.django_db +def test_notify_shift_swap_requests(make_organization, make_user, make_schedule, make_shift_swap_request, settings): + settings.FEATURE_SHIFT_SWAPS_ENABLED = True + + organization = make_organization() + user = make_user(organization=organization) + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + + now = timezone.now() + swap_start = now + timezone.timedelta(days=100) + swap_end = swap_start + timezone.timedelta(days=1) + + shift_swap_request = make_shift_swap_request( + schedule, user, swap_start=swap_start, swap_end=swap_end, created_at=now + ) + + with patch.object(notify_shift_swap_request, "delay") as mock_notify_shift_swap_request: + with patch( + "apps.mobile_app.tasks._get_shift_swap_requests_to_notify", + return_value=ShiftSwapRequest.objects.filter(pk=shift_swap_request.pk), + ) as mock_get_shift_swap_requests_to_notify: + notify_shift_swap_requests() + + mock_get_shift_swap_requests_to_notify.assert_called_once() + mock_notify_shift_swap_request.assert_called_once_with(shift_swap_request.pk) + + +@pytest.mark.django_db +def test_notify_shift_swap_requests_feature_flag_disabled( + make_organization, make_user, make_schedule, make_shift_swap_request, settings +): + settings.FEATURE_SHIFT_SWAPS_ENABLED = False + with patch("apps.mobile_app.tasks._get_shift_swap_requests_to_notify") as mock_get_shift_swap_requests_to_notify: + notify_shift_swap_requests() + + mock_get_shift_swap_requests_to_notify.assert_not_called() + + +@pytest.mark.django_db +def test_notify_shift_swap_request(make_organization, make_user, make_schedule, make_shift_swap_request, settings): + organization = make_organization() + user = make_user(organization=organization) + other_user = make_user(organization=organization) + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + + now = timezone.now() + swap_start = now + timezone.timedelta(days=100) + swap_end = swap_start + timezone.timedelta(days=1) + + shift_swap_request = make_shift_swap_request( + schedule, user, swap_start=swap_start, swap_end=swap_end, created_at=now + ) + + with patch.object(notify_user_about_shift_swap_request, "delay") as mock_notify_user_about_shift_swap_request: + with patch("apps.mobile_app.tasks._should_notify_user_about_shift_swap_request", return_value=True): + with patch.object( + ShiftSwapRequest, + "possible_benefactors", + new_callable=PropertyMock(return_value=User.objects.filter(pk=other_user.pk)), + ): + notify_shift_swap_request(shift_swap_request.pk) + + mock_notify_user_about_shift_swap_request.assert_called_once_with(shift_swap_request.pk, other_user.pk) + + +@pytest.mark.django_db +def test_notify_shift_swap_request_should_not_notify_user( + make_organization, make_user, make_schedule, make_shift_swap_request, settings +): + organization = make_organization() + user = make_user(organization=organization) + other_user = make_user(organization=organization) + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + + now = timezone.now() + swap_start = now + timezone.timedelta(days=100) + swap_end = swap_start + timezone.timedelta(days=1) + + shift_swap_request = make_shift_swap_request( + schedule, user, swap_start=swap_start, swap_end=swap_end, created_at=now + ) + + with patch.object(notify_user_about_shift_swap_request, "delay") as mock_notify_user_about_shift_swap_request: + with patch("apps.mobile_app.tasks._should_notify_user_about_shift_swap_request", return_value=False): + with patch.object( + ShiftSwapRequest, + "possible_benefactors", + new_callable=PropertyMock(return_value=User.objects.filter(pk=other_user.pk)), + ): + notify_shift_swap_request(shift_swap_request.pk) + + mock_notify_user_about_shift_swap_request.assert_not_called() + + +@pytest.mark.django_db +def test_notify_shift_swap_request_success( + make_organization, make_user, make_schedule, make_on_call_shift, make_shift_swap_request, settings +): + organization = make_organization() + beneficiary = make_user(organization=organization) + + # Set up the benefactor + benefactor = make_user( + organization=organization, + working_hours={day: [{"start": "00:00:00", "end": "23:59:59"}] for day in default_working_hours().keys()}, + ) + MobileAppUserSettings.objects.create(user=benefactor, info_notifications_enabled=True) + cache.clear() + + # Create schedule with the beneficiary and the benefactor in it + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + now = timezone.now() + for user in [benefactor, beneficiary]: + data = { + "start": now - timezone.timedelta(days=1), + "rotation_start": now - timezone.timedelta(days=1), + "duration": timezone.timedelta(hours=1), + "priority_level": 1, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + "schedule": schedule, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[user]]) + + schedule.refresh_ical_file() + schedule.refresh_from_db() + + swap_start = now + timezone.timedelta(days=100) + swap_end = swap_start + timezone.timedelta(days=1) + + shift_swap_request = make_shift_swap_request( + schedule, beneficiary, swap_start=swap_start, swap_end=swap_end, created_at=now + ) + + with patch.object(notify_user_about_shift_swap_request, "delay") as mock_notify_user_about_shift_swap_request: + notify_shift_swap_request(shift_swap_request.pk) + + mock_notify_user_about_shift_swap_request.assert_called_once_with(shift_swap_request.pk, benefactor.pk) + + +@pytest.mark.django_db +def test_notify_user_about_shift_swap_request( + make_organization, make_user, make_schedule, make_shift_swap_request, settings +): + settings.FEATURE_SHIFT_SWAPS_ENABLED = True + + organization = make_organization() + beneficiary = make_user(organization=organization, name="John Doe", username="john.doe") + benefactor = make_user(organization=organization) + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb, name="Test Schedule") + + device_to_notify = FCMDevice.objects.create(user=benefactor, registration_id="test_device_id") + MobileAppUserSettings.objects.create(user=benefactor, info_notifications_enabled=True) + + now = timezone.datetime(2023, 8, 1, 19, 38, tzinfo=timezone.utc) + swap_start = now + timezone.timedelta(days=100) + swap_end = swap_start + timezone.timedelta(days=1) + + shift_swap_request = make_shift_swap_request( + schedule, beneficiary, swap_start=swap_start, swap_end=swap_end, created_at=now + ) + + with patch("apps.mobile_app.tasks._send_push_notification") as mock_send_push_notification: + notify_user_about_shift_swap_request(shift_swap_request.pk, benefactor.pk) + + mock_send_push_notification.assert_called_once() + assert mock_send_push_notification.call_args.args[0] == device_to_notify + + message: Message = mock_send_push_notification.call_args.args[1] + assert message.data["type"] == MessageType.INFO + assert message.data["title"] == "New shift swap request" + assert message.data["subtitle"] == "John Doe, Test Schedule" + assert ( + message.data["route"] + == f"/schedules/{schedule.public_primary_key}/ssrs/{shift_swap_request.public_primary_key}" + ) + assert message.apns.payload.aps.sound.critical is False + + +@pytest.mark.django_db +def test_should_notify_user(make_organization, make_user, make_schedule, make_shift_swap_request): + organization = make_organization() + beneficiary = make_user(organization=organization) + benefactor = make_user(organization=organization) + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + + now = timezone.now() + swap_start = now + timezone.timedelta(days=100) + swap_end = swap_start + timezone.timedelta(days=1) + + shift_swap_request = make_shift_swap_request( + schedule, beneficiary, swap_start=swap_start, swap_end=swap_end, created_at=now + ) + + assert not MobileAppUserSettings.objects.exists() + assert _should_notify_user_about_shift_swap_request(shift_swap_request, benefactor, now) is False + + mobile_app_settings = MobileAppUserSettings.objects.create(user=benefactor, info_notifications_enabled=False) + assert _should_notify_user_about_shift_swap_request(shift_swap_request, benefactor, now) is False + + mobile_app_settings.info_notifications_enabled = True + mobile_app_settings.save(update_fields=["info_notifications_enabled"]) + + with patch.object(benefactor, "is_in_working_hours", return_value=True): + with patch("apps.mobile_app.tasks._has_user_been_notified_for_shift_swap_request", return_value=True): + assert _should_notify_user_about_shift_swap_request(shift_swap_request, benefactor, now) is False + + with patch.object(benefactor, "is_in_working_hours", return_value=False): + with patch("apps.mobile_app.tasks._has_user_been_notified_for_shift_swap_request", return_value=False): + assert _should_notify_user_about_shift_swap_request(shift_swap_request, benefactor, now) is False + + with patch.object(benefactor, "is_in_working_hours", return_value=True): + with patch("apps.mobile_app.tasks._has_user_been_notified_for_shift_swap_request", return_value=False): + assert _should_notify_user_about_shift_swap_request(shift_swap_request, benefactor, now) is True + + +@pytest.mark.django_db +def test_mark_notified(make_organization, make_user, make_schedule, make_shift_swap_request): + organization = make_organization() + beneficiary = make_user(organization=organization) + benefactor = make_user(organization=organization) + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + + now = timezone.now() + swap_start = now + timezone.timedelta(days=100) + swap_end = swap_start + timezone.timedelta(days=1) + + shift_swap_request = make_shift_swap_request( + schedule, beneficiary, swap_start=swap_start, swap_end=swap_end, created_at=now + ) + + cache.clear() + assert _has_user_been_notified_for_shift_swap_request(shift_swap_request, benefactor) is False + _mark_shift_swap_request_notified_for_user(shift_swap_request, benefactor) + assert _has_user_been_notified_for_shift_swap_request(shift_swap_request, benefactor) is True + + with patch.object(cache, "set") as mock_cache_set: + _mark_shift_swap_request_notified_for_user(shift_swap_request, benefactor) + assert mock_cache_set.call_args.kwargs["timeout"] == SSR_NOTIFICATION_WINDOW.total_seconds() diff --git a/engine/apps/schedules/migrations/0016_alter_shiftswaprequest_created_at.py b/engine/apps/schedules/migrations/0016_alter_shiftswaprequest_created_at.py new file mode 100644 index 0000000000..e8565d866b --- /dev/null +++ b/engine/apps/schedules/migrations/0016_alter_shiftswaprequest_created_at.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.20 on 2023-08-01 18:16 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('schedules', '0015_shiftswaprequest_slack_message'), + ] + + operations = [ + migrations.AlterField( + model_name='shiftswaprequest', + name='created_at', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + ] diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index 7a70a8e429..a8fc2a493b 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -57,7 +57,7 @@ RE_ICAL_SEARCH_USERNAME = r"SUMMARY:(\[L[0-9]+\] )?{}" -RE_ICAL_FETCH_USERNAME = re.compile(r"SUMMARY:(?:\[L[0-9]+\] )?(\w+)") +RE_ICAL_FETCH_USERNAME = re.compile(r"SUMMARY:(?:\[L[0-9]+\] )?([^\s]+)") # Utility classes for schedule quality report diff --git a/engine/apps/schedules/models/shift_swap_request.py b/engine/apps/schedules/models/shift_swap_request.py index 3c8189c00c..ebf4881174 100644 --- a/engine/apps/schedules/models/shift_swap_request.py +++ b/engine/apps/schedules/models/shift_swap_request.py @@ -4,6 +4,7 @@ from django.conf import settings from django.core.validators import MinLengthValidator from django.db import models +from django.db.models import QuerySet from django.utils import timezone from apps.schedules import exceptions @@ -60,7 +61,7 @@ class ShiftSwapRequest(models.Model): default=generate_public_primary_key_for_shift_swap_request, ) - created_at = models.DateTimeField(auto_now_add=True) + created_at = models.DateTimeField(default=timezone.now) updated_at = models.DateTimeField(auto_now=True) deleted_at = models.DateTimeField(null=True) @@ -128,6 +129,10 @@ def is_taken(self) -> bool: def is_past_due(self) -> bool: return timezone.now() > self.swap_start + @property + def is_open(self) -> bool: + return not any((self.is_deleted, self.is_taken, self.is_past_due)) + @property def status(self) -> str: if self.is_deleted: @@ -150,6 +155,10 @@ def slack_channel_id(self) -> str | None: def organization(self) -> "Organization": return self.schedule.organization + @property + def possible_benefactors(self) -> QuerySet["User"]: + return self.schedule.related_users().exclude(pk=self.beneficiary_id) + @property def web_link(self) -> str: # TODO: finish this once we know the proper URL we'll need diff --git a/engine/apps/schedules/tests/test_on_call_schedule.py b/engine/apps/schedules/tests/test_on_call_schedule.py index 744fbea352..aa478fe22a 100644 --- a/engine/apps/schedules/tests/test_on_call_schedule.py +++ b/engine/apps/schedules/tests/test_on_call_schedule.py @@ -1158,6 +1158,49 @@ def test_schedule_related_users(make_organization, make_user_for_organization, m assert set(users) == set([user_a, user_d, user_e]) +@pytest.mark.django_db +def test_schedule_related_users_usernames( + make_organization, make_user_for_organization, make_on_call_shift, make_schedule +): + """ + Check different usernames, including those with special characters and uppercase letters + """ + organization = make_organization() + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + name="test_web_schedule", + ) + + now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start_date = now - timezone.timedelta(days=7) + + # Check different usernames, including those with special characters and uppercase letters + usernames = ["test", "test.test", "test.test@test.test", "TEST.TEST@TEST.TEST"] + users = [make_user_for_organization(organization, username=u) for u in usernames] + # clear users pks <-> organization cache (persisting between tests) + memoized_users_in_ical.cache_clear() + + for user in users: + data = { + "start": start_date, + "rotation_start": start_date, + "duration": timezone.timedelta(hours=1), + "priority_level": 1, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + "schedule": schedule, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[user]]) + + schedule.refresh_ical_file() + schedule.refresh_from_db() + + assert set(schedule.related_users()) == set(users) + + @pytest.mark.django_db(transaction=True) def test_filter_events_none_cache_unchanged( make_organization, make_user_for_organization, make_schedule, make_on_call_shift diff --git a/engine/apps/schedules/tests/test_shift_swap_request.py b/engine/apps/schedules/tests/test_shift_swap_request.py index 05016fe19d..9f3f2cc9e3 100644 --- a/engine/apps/schedules/tests/test_shift_swap_request.py +++ b/engine/apps/schedules/tests/test_shift_swap_request.py @@ -7,6 +7,7 @@ from apps.schedules import exceptions from apps.schedules.models import CustomOnCallShift, ShiftSwapRequest +from apps.user_management.models import User ROTATION_START = datetime.datetime(2150, 8, 29, 0, 0, 0, 0, tzinfo=pytz.UTC) @@ -154,3 +155,12 @@ def test_related_shifts(shift_swap_request_setup, make_on_call_shift) -> None: ] returned_events = [(e["start"], e["end"], e["users"][0]["pk"], e["users"][0]["swap_request"]["pk"]) for e in events] assert returned_events == expected + + +@pytest.mark.django_db +def test_possible_benefactors(shift_swap_request_setup) -> None: + ssr, beneficiary, benefactor = shift_swap_request_setup() + + with patch.object(ssr.schedule, "related_users") as mock_related_users: + mock_related_users.return_value = User.objects.filter(pk__in=[beneficiary.pk, benefactor.pk]) + assert list(ssr.possible_benefactors) == [benefactor] diff --git a/engine/apps/user_management/models/user.py b/engine/apps/user_management/models/user.py index e6579a9f11..f92571e8f4 100644 --- a/engine/apps/user_management/models/user.py +++ b/engine/apps/user_management/models/user.py @@ -1,8 +1,10 @@ +import datetime import json import logging import typing from urllib.parse import urljoin +import pytz from django.conf import settings from django.core.validators import MinLengthValidator from django.db import models @@ -283,6 +285,42 @@ def timezone(self) -> typing.Optional[str]: def timezone(self, value): self._timezone = value + def is_in_working_hours(self, dt: datetime.datetime, tz: typing.Optional[str] = None) -> bool: + assert dt.tzinfo == pytz.utc, "dt must be in UTC" + + # Default to user's timezone + if not tz: + tz = self.timezone + + # If user has no timezone set, any time is considered non-working hours + if not tz: + return False + + # Convert to user's timezone and get day name (e.g. monday) + dt = dt.astimezone(pytz.timezone(tz)) + day_name = dt.date().strftime("%A").lower() + + # If no working hours for the day, return False + if day_name not in self.working_hours or not self.working_hours[day_name]: + return False + + # Extract start and end time for the day from working hours + day_start_time_str = self.working_hours[day_name][0]["start"] + day_start_time = datetime.time.fromisoformat(day_start_time_str) + + day_end_time_str = self.working_hours[day_name][0]["end"] + day_end_time = datetime.time.fromisoformat(day_end_time_str) + + # Calculate day start and end datetime + day_start = dt.replace( + hour=day_start_time.hour, minute=day_start_time.minute, second=day_start_time.second, microsecond=0 + ) + day_end = dt.replace( + hour=day_end_time.hour, minute=day_end_time.minute, second=day_end_time.second, microsecond=0 + ) + + return day_start <= dt <= day_end + def short(self): return { "username": self.username, diff --git a/engine/apps/user_management/tests/test_user.py b/engine/apps/user_management/tests/test_user.py index 6928d48979..5fd8baffe8 100644 --- a/engine/apps/user_management/tests/test_user.py +++ b/engine/apps/user_management/tests/test_user.py @@ -1,4 +1,5 @@ import pytest +from django.utils import timezone from apps.api.permissions import LegacyAccessControlRole from apps.user_management.models import User @@ -28,3 +29,68 @@ def test_lower_email_filter(make_organization, make_user_for_organization): assert User.objects.get(email__lower="testinguser@test.com") == user assert User.objects.filter(email__lower__in=["testinguser@test.com"]).get() == user + + +@pytest.mark.django_db +def test_is_in_working_hours(make_organization, make_user_for_organization): + organization = make_organization() + user = make_user_for_organization(organization, _timezone="Europe/London") + + _7_59_utc = timezone.datetime(2023, 8, 1, 7, 59, 59, tzinfo=timezone.utc) + _8_utc = timezone.datetime(2023, 8, 1, 8, 0, 0, tzinfo=timezone.utc) + _17_utc = timezone.datetime(2023, 8, 1, 16, 0, 0, tzinfo=timezone.utc) + _17_01_utc = timezone.datetime(2023, 8, 1, 16, 0, 1, tzinfo=timezone.utc) + + assert user.is_in_working_hours(_7_59_utc) is False + assert user.is_in_working_hours(_8_utc) is True + assert user.is_in_working_hours(_17_utc) is True + assert user.is_in_working_hours(_17_01_utc) is False + + +@pytest.mark.django_db +def test_is_in_working_hours_next_day(make_organization, make_user_for_organization): + organization = make_organization() + user = make_user_for_organization( + organization, + working_hours={ + "tuesday": [{"start": "17:00:00", "end": "18:00:00"}], + "wednesday": [{"start": "01:00:00", "end": "02:00:00"}], + }, + ) + + _8_59_utc = timezone.datetime(2023, 8, 1, 8, 59, 59, tzinfo=timezone.utc) # 4:59pm on Tuesday in Singapore + _9_utc = timezone.datetime(2023, 8, 1, 9, 0, 0, tzinfo=timezone.utc) # 5pm on Tuesday in Singapore + _10_utc = timezone.datetime(2023, 8, 1, 10, 0, 0, tzinfo=timezone.utc) # 6pm on Tuesday in Singapore + _10_01_utc = timezone.datetime(2023, 8, 1, 10, 0, 1, tzinfo=timezone.utc) # 6:01pm on Tuesday in Singapore + + _16_59_utc = timezone.datetime(2023, 8, 1, 16, 59, 0, tzinfo=timezone.utc) # 00:59am on Wednesday in Singapore + _17_utc = timezone.datetime(2023, 8, 1, 17, 0, 0, tzinfo=timezone.utc) # 1am on Wednesday in Singapore + _18_utc = timezone.datetime(2023, 8, 1, 18, 0, 0, tzinfo=timezone.utc) # 2am on Wednesday in Singapore + _18_01_utc = timezone.datetime(2023, 8, 1, 18, 0, 1, tzinfo=timezone.utc) # 2:01am on Wednesday in Singapore + + tz = "Asia/Singapore" + assert user.is_in_working_hours(_8_59_utc, tz=tz) is False + assert user.is_in_working_hours(_9_utc, tz=tz) is True + assert user.is_in_working_hours(_10_utc, tz=tz) is True + assert user.is_in_working_hours(_10_01_utc, tz=tz) is False + assert user.is_in_working_hours(_16_59_utc, tz=tz) is False + assert user.is_in_working_hours(_17_utc, tz=tz) is True + assert user.is_in_working_hours(_18_utc, tz=tz) is True + assert user.is_in_working_hours(_18_01_utc, tz=tz) is False + + +@pytest.mark.django_db +def test_is_in_working_hours_no_timezone(make_organization, make_user_for_organization): + organization = make_organization() + user = make_user_for_organization(organization, _timezone=None) + + assert user.is_in_working_hours(timezone.now()) is False + + +@pytest.mark.django_db +def test_is_in_working_hours_weekend(make_organization, make_user_for_organization): + organization = make_organization() + user = make_user_for_organization(organization, working_hours={"saturday": []}, _timezone=None) + + on_saturday = timezone.datetime(2023, 8, 5, 12, 0, 0, tzinfo=timezone.utc) + assert user.is_in_working_hours(on_saturday, "UTC") is False diff --git a/engine/settings/base.py b/engine/settings/base.py index 89f61d437e..8b881d2c25 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -480,6 +480,10 @@ class BrokerTypes: "task": "apps.mobile_app.tasks.conditionally_send_going_oncall_push_notifications_for_all_schedules", "schedule": 10 * 60, }, + "notify_shift_swap_requests": { + "task": "apps.mobile_app.tasks.notify_shift_swap_requests", + "schedule": 10 * 60, + }, "save_organizations_ids_in_cache": { "task": "apps.metrics_exporter.tasks.save_organizations_ids_in_cache", "schedule": 60 * 30, diff --git a/engine/settings/prod_without_db.py b/engine/settings/prod_without_db.py index 64c5896f73..f8d4342656 100644 --- a/engine/settings/prod_without_db.py +++ b/engine/settings/prod_without_db.py @@ -56,6 +56,9 @@ def on_uwsgi_worker_exit(): "apps.metrics_exporter.tasks.start_calculate_and_cache_metrics": {"queue": "default"}, "apps.metrics_exporter.tasks.start_recalculation_for_new_metric": {"queue": "default"}, "apps.metrics_exporter.tasks.save_organizations_ids_in_cache": {"queue": "default"}, + "apps.mobile_app.tasks.notify_shift_swap_requests": {"queue": "default"}, + "apps.mobile_app.tasks.notify_shift_swap_request": {"queue": "default"}, + "apps.mobile_app.tasks.notify_user_about_shift_swap_request": {"queue": "default"}, "apps.schedules.tasks.refresh_ical_files.refresh_ical_file": {"queue": "default"}, "apps.schedules.tasks.refresh_ical_files.start_refresh_ical_files": {"queue": "default"}, "apps.schedules.tasks.notify_about_gaps_in_schedule.check_empty_shifts_in_schedule": {"queue": "default"},