Add mobile app push notifications for shift swap requests (#2717)
# What this PR does Adds mobile app push notifications for shift swap requests. ## Which issue(s) this PR fixes https://github.com/grafana/oncall/issues/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)
This commit is contained in:
parent
2bc5c28777
commit
c855258018
13 changed files with 735 additions and 3 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ class MobileAppUserSettings(models.Model):
|
|||
|
||||
# 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)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from rest_framework import status
|
|||
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)
|
||||
|
|
|
|||
350
engine/apps/mobile_app/tests/test_shift_swap_request.py
Normal file
350
engine/apps/mobile_app/tests/test_shift_swap_request.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -57,7 +57,7 @@ if typing.TYPE_CHECKING:
|
|||
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import typing
|
|||
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 @@ class ShiftSwapRequest(models.Model):
|
|||
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 @@ class ShiftSwapRequest(models.Model):
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from django.utils import timezone
|
|||
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -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 @@ class User(models.Model):
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -480,6 +480,10 @@ CELERY_BEAT_SCHEDULE = {
|
|||
"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,
|
||||
|
|
|
|||
|
|
@ -56,6 +56,9 @@ CELERY_TASK_ROUTES = {
|
|||
"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"},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue