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:
Vadim Stepanov 2023-08-02 12:26:45 +01:00 committed by GitHub
parent 2bc5c28777
commit c855258018
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 735 additions and 3 deletions

View file

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

View file

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

View file

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

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

View file

@ -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),
),
]

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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]

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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"},