From ddd98e0c3fb43e4469a41a6dacf6655d1436002e Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Fri, 28 Jul 2023 15:53:27 -0300 Subject: [PATCH] Apply shift swap requests to schedule events (#2677) Reflect swap requests details in schedule events. --- CHANGELOG.md | 4 + engine/apps/schedules/ical_utils.py | 58 +-- .../apps/schedules/models/on_call_schedule.py | 107 ++++- .../schedules/models/shift_swap_request.py | 8 +- .../tests/test_custom_on_call_shift.py | 5 +- .../schedules/tests/test_on_call_schedule.py | 411 ++++++++++++++++++ .../tests/test_shift_swap_request.py | 14 +- 7 files changed, 546 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54c9d785..c970cb98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix one of the latest migrations failing on SQLite by @vadimkerr ([#2680](https://github.com/grafana/oncall/pull/2680)) +### Added + +- Apply swap requests details to schedule events ([#2677](https://github.com/grafana/oncall/pull/2677)) + ## v1.3.18 (2023-07-28) ### Changed diff --git a/engine/apps/schedules/ical_utils.py b/engine/apps/schedules/ical_utils.py index 72285e71..0bedb986 100644 --- a/engine/apps/schedules/ical_utils.py +++ b/engine/apps/schedules/ical_utils.py @@ -61,8 +61,7 @@ IcalEvents = typing.List[IcalEvent] def users_in_ical( usernames_from_ical: typing.List[str], organization: "Organization", - users_to_filter: typing.Optional["UserQuerySet"] = None, -) -> typing.Sequence["User"]: +) -> "UserQuerySet": """ This method returns a sequence of `User` objects, filtered by users whose username, or case-insensitive e-mail, is present in `usernames_from_ical`. If `include_viewers` is set to `True`, users are further filtered down @@ -74,25 +73,11 @@ def users_in_ical( A list of usernames present in the ical feed organization : apps.user_management.models.organization.Organization The organization in question - include_viewers : bool - Whether or not the list should be further filtered to exclude users based on granted permissions - users_to_filter : typing.Optional[UserQuerySet] - Filter users without making SQL queries if users_to_filter arg is provided - users_to_filter is passed in `apps.schedules.ical_utils.get_oncall_users_for_multiple_schedules` """ from apps.user_management.models import User emails_from_ical = [username.lower() for username in usernames_from_ical] - if users_to_filter is not None: - return list( - { - user - for user in users_to_filter - if user.username in usernames_from_ical or user.email.lower() in emails_from_ical - } - ) - # users_found_in_ical = organization.users users_found_in_ical = organization.users.filter( **User.build_permissions_query(RBACPermission.Permissions.SCHEDULES_WRITE, organization) @@ -106,9 +91,7 @@ def users_in_ical( @timed_lru_cache(timeout=100) -def memoized_users_in_ical( - usernames_from_ical: typing.List[str], organization: "Organization" -) -> typing.Sequence["User"]: +def memoized_users_in_ical(usernames_from_ical: typing.List[str], organization: "Organization") -> UserQuerySet: # using in-memory cache instead of redis to avoid pickling python objects return users_in_ical(usernames_from_ical, organization) @@ -336,7 +319,6 @@ def list_of_empty_shifts_in_schedule( def list_users_to_notify_from_ical( schedule: "OnCallSchedule", events_datetime: typing.Optional[datetime.datetime] = None, - users_to_filter: typing.Optional["UserQuerySet"] = None, ) -> typing.Sequence["User"]: """ Retrieve on-call users for the current time @@ -346,7 +328,6 @@ def list_users_to_notify_from_ical( schedule, events_datetime, events_datetime, - users_to_filter=users_to_filter, ) @@ -354,23 +335,20 @@ def list_users_to_notify_from_ical_for_period( schedule: "OnCallSchedule", start_datetime: datetime.datetime, end_datetime: datetime.datetime, - users_to_filter=None, -) -> typing.Sequence["User"]: +) -> UserQuerySet: users_found_in_ical: typing.Sequence["User"] = [] events = schedule.final_events(start_datetime, end_datetime) usernames = [] for event in events: usernames += [u["email"] for u in event.get("users", [])] - users_found_in_ical = users_in_ical(usernames, schedule.organization, users_to_filter=users_to_filter) + users_found_in_ical = users_in_ical(usernames, schedule.organization) return users_found_in_ical def get_oncall_users_for_multiple_schedules( schedules: "OnCallScheduleQuerySet", events_datetime=None -) -> typing.Dict["OnCallSchedule", typing.List[User]]: - from apps.user_management.models import User - +) -> typing.Dict["OnCallSchedule", UserQuerySet]: if events_datetime is None: events_datetime = datetime.datetime.now(timezone.utc) @@ -378,35 +356,11 @@ def get_oncall_users_for_multiple_schedules( if not schedules.exists(): return {} - # Assume all schedules from the queryset belong to the same organization - organization = schedules[0].organization - - # Gather usernames from all events from all schedules - usernames = set() - for schedule in schedules.all(): - calendars = schedule.get_icalendars() - for calendar in calendars: - if calendar is None: - continue - events = ical_events.get_events_from_ical_between(calendar, events_datetime, events_datetime) - for event in events: - current_usernames, _ = get_usernames_from_ical_event(event) - usernames.update(current_usernames) - - # Fetch relevant users from the db - emails = [username.lower() for username in usernames] - users = organization.users.filter( - Q(**User.build_permissions_query(RBACPermission.Permissions.SCHEDULES_WRITE, organization)) - & (Q(username__in=usernames) | Q(email__lower__in=emails)) - ) - # Get on-call users oncall_users = {} for schedule in schedules.all(): # pass user list to list_users_to_notify_from_ical - schedule_oncall_users = list_users_to_notify_from_ical( - schedule, events_datetime=events_datetime, users_to_filter=users - ) + schedule_oncall_users = list_users_to_notify_from_ical(schedule, events_datetime=events_datetime) oncall_users.update({schedule.pk: schedule_oncall_users}) return oncall_users diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index ade4da18..f06d3996 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -1,3 +1,4 @@ +import copy import datetime import itertools import re @@ -92,6 +93,15 @@ class ScheduleEventUser(typing.TypedDict): avatar_full: str +class SwapRequest(typing.TypedDict): + pk: str + user: typing.Optional[ScheduleEventUser] + + +class MaybeSwappedScheduleEventUser(ScheduleEventUser): + swap_request: typing.Optional[SwapRequest] + + class ScheduleEventShift(typing.TypedDict): pk: str @@ -100,7 +110,7 @@ class ScheduleEvent(typing.TypedDict): all_day: bool start: datetime.datetime end: datetime.datetime - users: typing.List[ScheduleEventUser] + users: typing.List[MaybeSwappedScheduleEventUser] missing_users: typing.List[str] priority_level: typing.Optional[int] source: typing.Optional[str] @@ -379,7 +389,12 @@ class OnCallSchedule(PolymorphicModel): events.append(shift_json) # combine multiple-users same-shift events into one - return self._merge_events(events) + events = self._merge_events(events) + + # annotate events with swap request details swapping users as needed + events = self._apply_swap_requests(events, datetime_start, datetime_end) + + return events def final_events(self, datetime_start, datetime_end): """Return schedule final events, after resolving shifts and overrides.""" @@ -601,6 +616,94 @@ class OnCallSchedule(PolymorphicModel): "overloaded_users": overloaded_users, } + def _apply_swap_requests(self, events, datetime_start, datetime_end) -> ScheduleEvents: + """Apply swap requests details to schedule events.""" + # get swaps requests affecting this schedule / time range + swaps = self.shift_swap_requests.filter( # starting before but ongoing + swap_start__lt=datetime_start, swap_end__gte=datetime_start + ).union( + self.shift_swap_requests.filter( # starting after but before end + swap_start__gte=datetime_start, swap_start__lte=datetime_end + ) + ) + swaps = swaps.order_by("created_at") + + def _insert_event(index, event): + # add event, if any, to events list in the specified index + # return incremented index if the event was added + if event is None: + return index + events.insert(index, event) + return index + 1 + + # apply swaps sequentially + for swap in swaps: + i = 0 + while i < len(events): + event = events.pop(i) + + if event["start"] > swap.swap_end or event["end"] < swap.swap_start: + # event outside the swap period, keep as it is and continue + i = _insert_event(i, event) + continue + + users = set(u["pk"] for u in event["users"]) + if swap.beneficiary.public_primary_key in users: + # swap request affects current event + + split_before = None + if event["start"] < swap.swap_start: + # partially included start -> split + split_before = copy.deepcopy(event) + split_before["end"] = swap.swap_start + # update event to swap + event["start"] = swap.swap_start + + split_after = None + if event["end"] > swap.swap_end: + # partially included end -> split + split_after = copy.deepcopy(event) + split_after["start"] = swap.swap_end + # update event to swap + event["end"] = swap.swap_end + + # identify user to swap + user_to_swap = None + for u in event["users"]: + if u["pk"] == swap.beneficiary.public_primary_key: + user_to_swap = u + break + + # apply swap changes to event user + swap_details = {"pk": swap.public_primary_key} + if swap.benefactor: + # swap is taken, update user in shift + user_to_swap["pk"] = swap.benefactor.public_primary_key + user_to_swap["display_name"] = swap.benefactor.username + user_to_swap["email"] = swap.benefactor.email + user_to_swap["avatar_full"] = swap.benefactor.avatar_full_url + # add beneficiary user to details + swap_details["user"] = { + "display_name": swap.beneficiary.username, + "email": swap.beneficiary.email, + "pk": swap.beneficiary.public_primary_key, + "avatar_full": swap.beneficiary.avatar_full_url, + } + user_to_swap["swap_request"] = swap_details + + # update events list + # keep first split event in its original index + i = _insert_event(i, split_before) + # insert updated swap-related event + i = _insert_event(i, event) + # keep second split event after swap + i = _insert_event(i, split_after) + else: + # event for different user(s), keep as it is and continue + i = _insert_event(i, event) + + return events + def _resolve_schedule( self, events: ScheduleEvents, datetime_start: datetime.datetime, datetime_end: datetime.datetime ) -> ScheduleEvents: diff --git a/engine/apps/schedules/models/shift_swap_request.py b/engine/apps/schedules/models/shift_swap_request.py index f7ce7997..07ccd247 100644 --- a/engine/apps/schedules/models/shift_swap_request.py +++ b/engine/apps/schedules/models/shift_swap_request.py @@ -7,6 +7,7 @@ from django.db import models from django.utils import timezone from apps.schedules import exceptions +from apps.schedules.tasks import refresh_ical_final_schedule from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length if typing.TYPE_CHECKING: @@ -156,9 +157,13 @@ class ShiftSwapRequest(models.Model): def delete(self): self.deleted_at = timezone.now() self.save() + # make sure final schedule ical representation is updated + refresh_ical_final_schedule.apply_async((self.schedule.pk,)) def hard_delete(self): super().delete() + # make sure final schedule ical representation is updated + refresh_ical_final_schedule.apply_async((self.schedule.pk,)) def take(self, benefactor: "User") -> None: if benefactor == self.beneficiary: @@ -169,7 +174,8 @@ class ShiftSwapRequest(models.Model): self.benefactor = benefactor self.save() - # TODO: implement the actual override logic in https://github.com/grafana/oncall/issues/2590 + # make sure final schedule ical representation is updated + refresh_ical_final_schedule.apply_async((self.schedule.pk,)) # Insight logs @property diff --git a/engine/apps/schedules/tests/test_custom_on_call_shift.py b/engine/apps/schedules/tests/test_custom_on_call_shift.py index 4cd436f1..4f872bb7 100644 --- a/engine/apps/schedules/tests/test_custom_on_call_shift.py +++ b/engine/apps/schedules/tests/test_custom_on_call_shift.py @@ -1297,7 +1297,7 @@ def test_get_oncall_users_for_empty_schedule( schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar) schedules = OnCallSchedule.objects.filter(pk=schedule.pk) - assert schedules.get_oncall_users()[schedule.pk] == [] + assert list(schedules.get_oncall_users()[schedule.pk]) == [] @pytest.mark.django_db @@ -1412,7 +1412,8 @@ def test_get_oncall_users_for_multiple_schedules_emails_case_insensitive( schedules = OnCallSchedule.objects.filter(pk=schedule.pk) oncall_users = schedules.get_oncall_users(events_datetime=events_datetime) - assert oncall_users == {schedule.pk: [user]} + assert len(oncall_users) == 1 + assert list(oncall_users[schedule.pk]) == [user] @pytest.mark.django_db diff --git a/engine/apps/schedules/tests/test_on_call_schedule.py b/engine/apps/schedules/tests/test_on_call_schedule.py index 6b8e61ef..1e56761b 100644 --- a/engine/apps/schedules/tests/test_on_call_schedule.py +++ b/engine/apps/schedules/tests/test_on_call_schedule.py @@ -1837,3 +1837,414 @@ def test_event_until_non_utc(make_organization, make_schedule): # check this works without raising exception datetime_end = now + timezone.timedelta(days=7) schedule.final_events(now, datetime_end) + + +@pytest.mark.django_db +@pytest.mark.parametrize("swap_taken", [False, True]) +def test_swap_request_split_start( + make_organization, + make_user_for_organization, + make_schedule, + make_on_call_shift, + make_shift_swap_request, + swap_taken, +): + organization = make_organization() + user = make_user_for_organization(organization) + other_user = make_user_for_organization(organization) + + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start = today + timezone.timedelta(hours=12) + duration = timezone.timedelta(hours=3) + data = { + "start": start, + "rotation_start": start, + "duration": duration, + "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]]) + + tomorrow = today + timezone.timedelta(days=1) + # setup swap request + swap_request = make_shift_swap_request( + schedule, + user, + swap_start=tomorrow + timezone.timedelta(hours=13), + swap_end=tomorrow + timezone.timedelta(hours=18), + ) + if swap_taken: + swap_request.take(other_user) + + events = schedule.filter_events(today, today + timezone.timedelta(days=2)) + + expected = [ + # start, end, swap requested + (start, start + duration, False), # today shift unchanged + (start + timezone.timedelta(days=1), start + timezone.timedelta(days=1, hours=1), False), # first split + ( + start + timezone.timedelta(days=1, hours=1), + start + timezone.timedelta(days=1, hours=3), + True, + ), # second split + ] + returned = [(e["start"], e["end"], bool(e["users"][0].get("swap_request", False))) for e in events] + assert returned == expected + # check swap request details + assert events[2]["users"][0]["swap_request"]["pk"] == swap_request.public_primary_key + if swap_taken: + assert events[2]["users"][0]["pk"] == other_user.public_primary_key + assert events[2]["users"][0]["swap_request"]["user"]["pk"] == user.public_primary_key + else: + assert events[2]["users"][0]["pk"] == user.public_primary_key + + +@pytest.mark.django_db +@pytest.mark.parametrize("swap_taken", [False, True]) +def test_swap_request_split_end( + make_organization, + make_user_for_organization, + make_schedule, + make_on_call_shift, + make_shift_swap_request, + swap_taken, +): + organization = make_organization() + user = make_user_for_organization(organization) + other_user = make_user_for_organization(organization) + + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start = today + timezone.timedelta(hours=12) + duration = timezone.timedelta(hours=3) + data = { + "start": start, + "rotation_start": start, + "duration": duration, + "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]]) + + tomorrow = today + timezone.timedelta(days=1) + # setup swap request + swap_request = make_shift_swap_request( + schedule, + user, + swap_start=tomorrow + timezone.timedelta(hours=10), + swap_end=tomorrow + timezone.timedelta(hours=13), + ) + if swap_taken: + swap_request.take(other_user) + + events = schedule.filter_events(today, today + timezone.timedelta(days=2)) + + expected = [ + # start, end, swap requested + (start, start + duration, False), # today shift unchanged + (start + timezone.timedelta(days=1), start + timezone.timedelta(days=1, hours=1), True), # first split + ( + start + timezone.timedelta(days=1, hours=1), + start + timezone.timedelta(days=1, hours=3), + False, + ), # second split + ] + returned = [(e["start"], e["end"], bool(e["users"][0].get("swap_request", False))) for e in events] + assert returned == expected + # check swap request details + assert events[1]["users"][0]["swap_request"]["pk"] == swap_request.public_primary_key + if swap_taken: + assert events[1]["users"][0]["pk"] == other_user.public_primary_key + assert events[1]["users"][0]["swap_request"]["user"]["pk"] == user.public_primary_key + else: + assert events[1]["users"][0]["pk"] == user.public_primary_key + + +@pytest.mark.django_db +@pytest.mark.parametrize("swap_taken", [False, True]) +def test_swap_request_split_both( + make_organization, + make_user_for_organization, + make_schedule, + make_on_call_shift, + make_shift_swap_request, + swap_taken, +): + organization = make_organization() + user = make_user_for_organization(organization) + other_user = make_user_for_organization(organization) + + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start = today + timezone.timedelta(hours=12) + duration = timezone.timedelta(hours=3) + data = { + "start": start, + "rotation_start": start, + "duration": duration, + "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]]) + + tomorrow = today + timezone.timedelta(days=1) + # setup swap request + swap_request = make_shift_swap_request( + schedule, + user, + swap_start=tomorrow + timezone.timedelta(hours=13), + swap_end=tomorrow + timezone.timedelta(hours=14), + ) + if swap_taken: + swap_request.take(other_user) + + events = schedule.filter_events(today, today + timezone.timedelta(days=2)) + + expected = [ + # start, end, swap requested + (start, start + duration, False), # today shift unchanged + (start + timezone.timedelta(days=1), start + timezone.timedelta(days=1, hours=1), False), # first split + ( + start + timezone.timedelta(days=1, hours=1), + start + timezone.timedelta(days=1, hours=2), + True, + ), # second split + ( + start + timezone.timedelta(days=1, hours=2), + start + timezone.timedelta(days=1, hours=3), + False, + ), # third split + ] + returned = [(e["start"], e["end"], bool(e["users"][0].get("swap_request", False))) for e in events] + assert returned == expected + # check swap request details + assert events[2]["users"][0]["swap_request"]["pk"] == swap_request.public_primary_key + if swap_taken: + assert events[2]["users"][0]["pk"] == other_user.public_primary_key + assert events[2]["users"][0]["swap_request"]["user"]["pk"] == user.public_primary_key + else: + assert events[2]["users"][0]["pk"] == user.public_primary_key + + # check cached final schedule reflects swap + # force final schedule export to consider 2 days only + with patch("apps.schedules.models.on_call_schedule.EXPORT_WINDOW_DAYS_AFTER", 2): + with patch("apps.schedules.models.on_call_schedule.EXPORT_WINDOW_DAYS_BEFORE", 0): + schedule.refresh_ical_final_schedule() + assert schedule.cached_ical_final_schedule + expected_events = [ + # start, end, user + (start, start + duration, user.username), # today shift unchanged + (start + timezone.timedelta(days=1), start + timezone.timedelta(days=1, hours=1), user.username), # first split + ( + start + timezone.timedelta(days=1, hours=1), + start + timezone.timedelta(days=1, hours=2), + other_user.username if swap_taken else user.username, + ), # second split + ( + start + timezone.timedelta(days=1, hours=2), + start + timezone.timedelta(days=1, hours=3), + user.username, + ), # third split + ] + calendar = icalendar.Calendar.from_ical(schedule.cached_ical_final_schedule) + for component in calendar.walk(): + if component.name == ICAL_COMPONENT_VEVENT: + event = ( + component[ICAL_DATETIME_START].dt, + component[ICAL_DATETIME_END].dt, + component[ICAL_SUMMARY], + ) + assert event in expected_events + + +@pytest.mark.django_db +@pytest.mark.parametrize("swap_taken", [False, True]) +def test_swap_request_whole_shift( + make_organization, + make_user_for_organization, + make_schedule, + make_on_call_shift, + make_shift_swap_request, + swap_taken, +): + organization = make_organization() + user = make_user_for_organization(organization) + other_user = make_user_for_organization(organization) + + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start = today + timezone.timedelta(hours=12) + duration = timezone.timedelta(hours=3) + data = { + "start": start, + "rotation_start": start, + "duration": duration, + "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]]) + + tomorrow = today + timezone.timedelta(days=1) + # setup swap request + swap_request = make_shift_swap_request( + schedule, + user, + swap_start=tomorrow + timezone.timedelta(hours=12), + swap_end=tomorrow + timezone.timedelta(hours=15), + ) + if swap_taken: + swap_request.take(other_user) + + events = schedule.filter_events(today, today + timezone.timedelta(days=2)) + + expected = [ + # start, end, swap requested + (start, start + duration, False), # today shift unchanged + (start + timezone.timedelta(days=1), start + timezone.timedelta(days=1, hours=3), True), # no splits + ] + returned = [(e["start"], e["end"], bool(e["users"][0].get("swap_request", False))) for e in events] + assert returned == expected + # check swap request details + assert events[1]["users"][0]["swap_request"]["pk"] == swap_request.public_primary_key + if swap_taken: + assert events[1]["users"][0]["pk"] == other_user.public_primary_key + assert events[1]["users"][0]["swap_request"]["user"]["pk"] == user.public_primary_key + else: + assert events[1]["users"][0]["pk"] == user.public_primary_key + + +@pytest.mark.django_db +@pytest.mark.parametrize("swap_taken", [False, True]) +def test_swap_request_partial_replace( + make_organization, + make_user_for_organization, + make_schedule, + make_on_call_shift, + make_shift_swap_request, + swap_taken, +): + organization = make_organization() + user = make_user_for_organization(organization) + another_user = make_user_for_organization(organization) + other_user = make_user_for_organization(organization) + + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start = today + timezone.timedelta(hours=12) + duration = timezone.timedelta(hours=3) + data = { + "start": start, + "rotation_start": start, + "duration": duration, + "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, another_user]]) + + tomorrow = today + timezone.timedelta(days=1) + # setup swap request + swap_request = make_shift_swap_request( + schedule, + user, + swap_start=tomorrow + timezone.timedelta(hours=10), + swap_end=tomorrow + timezone.timedelta(hours=13), + ) + if swap_taken: + swap_request.take(other_user) + + events = schedule.filter_events(today, today + timezone.timedelta(days=2)) + + expected = [ + # start, end, swap requested + (start, start + duration, False), # today shift unchanged + (start + timezone.timedelta(days=1), start + timezone.timedelta(days=1, hours=1), True), # first split + ( + start + timezone.timedelta(days=1, hours=1), + start + timezone.timedelta(days=1, hours=3), + False, + ), # second split + ] + expected_user = user + if swap_taken: + expected_user = other_user + returned = [ + ( + e["start"], + e["end"], + bool([u for u in e["users"] if u["pk"] == expected_user.public_primary_key and u.get("swap_request")]), + ) + for e in events + ] + assert returned == expected + # check swap request details + user_pks = [u["pk"] for u in events[1]["users"]] + assert expected_user.public_primary_key in user_pks + if swap_taken: + for u in events[1]["users"]: + if u["pk"] == expected_user: + assert u["swap_request"]["pk"] == swap_request.public_primary_key + assert u["swap_request"]["user"]["pk"] == user.public_primary_key + + +@pytest.mark.django_db +def test_swap_request_no_changes( + make_organization, + make_user_for_organization, + make_schedule, + make_on_call_shift, + make_shift_swap_request, +): + organization = make_organization() + user = make_user_for_organization(organization) + other_user = make_user_for_organization(organization) + + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start = today + timezone.timedelta(hours=12) + duration = timezone.timedelta(hours=3) + data = { + "start": start, + "rotation_start": start, + "duration": duration, + "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]]) + + events_before = schedule.filter_events(today, today + timezone.timedelta(days=2)) + + # setup swap requests + tomorrow = today + timezone.timedelta(days=1) + make_shift_swap_request(schedule, other_user, swap_start=today, swap_end=tomorrow) + make_shift_swap_request(schedule, user, swap_start=today, swap_end=tomorrow, deleted_at=today) + make_shift_swap_request( + schedule, user, swap_start=today - timezone.timedelta(days=7), swap_end=tomorrow - timezone.timedelta(days=7) + ) + + events_after = schedule.filter_events(today, today + timezone.timedelta(days=2)) + assert events_before == events_after diff --git a/engine/apps/schedules/tests/test_shift_swap_request.py b/engine/apps/schedules/tests/test_shift_swap_request.py index 68c187ad..9c08dbed 100644 --- a/engine/apps/schedules/tests/test_shift_swap_request.py +++ b/engine/apps/schedules/tests/test_shift_swap_request.py @@ -1,4 +1,5 @@ import datetime +from unittest.mock import patch import pytest @@ -10,11 +11,15 @@ from apps.schedules.models import ShiftSwapRequest def test_soft_delete(shift_swap_request_setup): ssr, _, _ = shift_swap_request_setup() assert ssr.deleted_at is None - ssr.delete() + + with patch("apps.schedules.models.shift_swap_request.refresh_ical_final_schedule") as mock_refresh_final: + ssr.delete() ssr.refresh_from_db() assert ssr.deleted_at is not None + assert mock_refresh_final.apply_async.called_with((ssr.schedule.pk,)) + assert ShiftSwapRequest.objects.all().count() == 0 assert ShiftSwapRequest.objects_with_deleted.all().count() == 1 @@ -65,12 +70,13 @@ def test_take(shift_swap_request_setup) -> None: ssr, _, benefactor = shift_swap_request_setup() original_updated_at = ssr.updated_at - ssr.take(benefactor) + with patch("apps.schedules.models.shift_swap_request.refresh_ical_final_schedule") as mock_refresh_final: + ssr.take(benefactor) assert ssr.benefactor == benefactor assert ssr.updated_at != original_updated_at - - # TODO: + # final schedule refresh was triggered + assert mock_refresh_final.apply_async.called_with((ssr.schedule.pk,)) @pytest.mark.django_db