diff --git a/engine/apps/api/tests/test_oncall_shift.py b/engine/apps/api/tests/test_oncall_shift.py index fe9f77cf..7d5ef169 100644 --- a/engine/apps/api/tests/test_oncall_shift.py +++ b/engine/apps/api/tests/test_oncall_shift.py @@ -1311,3 +1311,98 @@ def test_on_call_shift_preview( if not e["is_override"] and not e["is_gap"] ] assert returned_events == expected_events + + +@pytest.mark.django_db +def test_on_call_shift_preview_merge_events( + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_user_auth_headers, + make_schedule, +): + organization, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + + 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) + request_date = start_date + + user = make_user_for_organization(organization) + other_user = make_user_for_organization(organization) + + url = "{}?date={}&days={}".format( + reverse("api-internal:oncall_shifts-preview"), request_date.strftime("%Y-%m-%d"), 1 + ) + shift_start = (start_date + timezone.timedelta(hours=12)).strftime("%Y-%m-%dT%H:%M:%SZ") + shift_end = (start_date + timezone.timedelta(hours=13)).strftime("%Y-%m-%dT%H:%M:%SZ") + shift_data = { + "schedule": schedule.public_primary_key, + "type": CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, + "rotation_start": shift_start, + "shift_start": shift_start, + "shift_end": shift_end, + "rolling_users": [[user.public_primary_key, other_user.public_primary_key]], + "priority_level": 2, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + } + response = client.post(url, shift_data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + + # check rotation events + rotation_events = response.json()["rotation"] + expected_rotation_events = [ + { + "calendar_type": OnCallSchedule.TYPE_ICAL_PRIMARY, + "start": shift_start, + "end": shift_end, + "all_day": False, + "is_override": False, + "is_empty": False, + "is_gap": False, + "priority_level": 2, + "missing_users": [], + "source": "web", + } + ] + expected_users = sorted([user.username, other_user.username]) + returned_event = rotation_events[0] + # there isn't a saved shift, we don't care/know the temp pk + returned_event.pop("shift") + returned_users = sorted(u["display_name"] for u in returned_event.pop("users")) + assert sorted(returned_users) == expected_users + assert rotation_events == expected_rotation_events + + # check final schedule events + final_events = response.json()["final"] + expected = ( + # start (h), duration (H), users, priority + (12, 1, expected_users, 2), # 12-13 other_user + ) + expected_events = [ + { + "end": (start_date + timezone.timedelta(hours=start + duration)).strftime("%Y-%m-%dT%H:%M:%SZ"), + "priority_level": priority, + "start": (start_date + timezone.timedelta(hours=start, milliseconds=1 if start == 0 else 0)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ), + "users": users, + } + for start, duration, users, priority in expected + ] + returned_events = [ + { + "end": e["end"], + "priority_level": e["priority_level"], + "start": e["start"], + "users": sorted(u["display_name"] for u in e["users"]), + } + for e in final_events + if not e["is_override"] and not e["is_gap"] + ] + assert returned_events == expected_events diff --git a/engine/apps/api/views/schedule.py b/engine/apps/api/views/schedule.py index 8a066cee..e2c1ea51 100644 --- a/engine/apps/api/views/schedule.py +++ b/engine/apps/api/views/schedule.py @@ -240,9 +240,6 @@ class ScheduleView( else: # return final schedule events = schedule.final_events(user_tz, starting_date, days) - # combine multiple-users same-shift events into one - events = self._merge_events(events) - result = { "id": schedule.public_primary_key, "name": schedule.name, @@ -251,25 +248,6 @@ class ScheduleView( } return Response(result, status=status.HTTP_200_OK) - def _merge_events(self, events): - """Merge user groups same-shift events.""" - if events: - merged = [events[0]] - current = merged[0] - for next_event in events[1:]: - if ( - current["start"] == next_event["start"] - and current["shift"]["pk"] is not None - and current["shift"]["pk"] == next_event["shift"]["pk"] - ): - current["users"] += next_event["users"] - current["missing_users"] += next_event["missing_users"] - else: - merged.append(next_event) - current = next_event - events = merged - return events - @action(detail=True, methods=["get"]) def next_shifts_per_user(self, request, pk): """Return next shift for users in schedule.""" diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index 31269432..5841b198 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -263,6 +263,9 @@ class OnCallSchedule(PolymorphicModel): } events.append(shift_json) + # combine multiple-users same-shift events into one + events = self._merge_events(events) + return events def final_events(self, user_tz, starting_date, days): @@ -387,6 +390,25 @@ class OnCallSchedule(PolymorphicModel): resolved.sort(key=lambda e: (e["start"], e["shift"]["pk"])) return resolved + def _merge_events(self, events): + """Merge user groups same-shift events.""" + if events: + merged = [events[0]] + current = merged[0] + for next_event in events[1:]: + if ( + current["start"] == next_event["start"] + and current["shift"]["pk"] is not None + and current["shift"]["pk"] == next_event["shift"]["pk"] + ): + current["users"] += next_event["users"] + current["missing_users"] += next_event["missing_users"] + else: + merged.append(next_event) + current = next_event + events = merged + return events + class OnCallScheduleICal(OnCallSchedule): # For the ical schedule both primary and overrides icals are imported via ical url