diff --git a/engine/apps/api/tests/test_schedules.py b/engine/apps/api/tests/test_schedules.py index 9560f2c6..f7117879 100644 --- a/engine/apps/api/tests/test_schedules.py +++ b/engine/apps/api/tests/test_schedules.py @@ -557,9 +557,9 @@ def test_filter_events_range_calendar( "schedule": schedule, } on_call_shift = make_on_call_shift( - organization=organization, shift_type=CustomOnCallShift.TYPE_RECURRENT_EVENT, **data + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data ) - on_call_shift.users.add(user) + on_call_shift.add_rolling_users([[user]]) # add override shift override_start = request_date + timezone.timedelta(seconds=3600) @@ -640,9 +640,9 @@ def test_filter_events_overrides( "schedule": schedule, } on_call_shift = make_on_call_shift( - organization=organization, shift_type=CustomOnCallShift.TYPE_RECURRENT_EVENT, **data + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data ) - on_call_shift.users.add(user) + on_call_shift.add_rolling_users([[user]]) # add override shift override_start = request_date + timezone.timedelta(seconds=3600) @@ -735,9 +735,9 @@ def test_filter_events_final_schedule( "schedule": schedule, } on_call_shift = make_on_call_shift( - organization=organization, shift_type=CustomOnCallShift.TYPE_RECURRENT_EVENT, **data + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data ) - on_call_shift.users.add(user) + on_call_shift.add_rolling_users([[user]]) # override: 22-23 / E override_data = { @@ -868,6 +868,97 @@ def test_next_shifts_per_user( assert returned_data == expected +@pytest.mark.django_db +def test_merging_same_shift_events( + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_user_auth_headers, + make_schedule, + make_on_call_shift, +): + 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_a = make_user_for_organization(organization) + user_b = make_user_for_organization(organization) + user_c = make_user_for_organization(organization, role=Role.VIEWER) + + data = { + "start": start_date + timezone.timedelta(hours=10), + "rotation_start": start_date, + "duration": timezone.timedelta(hours=2), + "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_a, user_c, user_b]]) + + expected_events = [ + { + "calendar_type": 0, + "end": start_date + timezone.timedelta(hours=12), + "is_gap": False, + "priority_level": 1, + "start": start_date + timezone.timedelta(hours=10), + "users": [user_a.username, user_b.username], + "missing_users": [user_c.username], + } + ] + + # final schedule + url = reverse("api-internal:schedule-filter-events", kwargs={"pk": schedule.public_primary_key}) + url += "?date={}&days=1".format(request_date.strftime("%Y-%m-%d")) + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + returned_events = [ + { + "calendar_type": e["calendar_type"], + "end": e["end"], + "is_gap": e["is_gap"], + "priority_level": e["priority_level"], + "start": e["start"], + "users": [u["display_name"] for u in e["users"]] if e["users"] else None, + "missing_users": e["missing_users"], + } + for e in response.data["events"] + if not e["is_gap"] + ] + assert returned_events == expected_events + + # rotations + url = reverse("api-internal:schedule-filter-events", kwargs={"pk": schedule.public_primary_key}) + url += "?date={}&days=1&type=rotation".format(request_date.strftime("%Y-%m-%d")) + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + + returned_events = [ + { + "calendar_type": e["calendar_type"], + "end": e["end"], + "is_gap": e["is_gap"], + "priority_level": e["priority_level"], + "start": e["start"], + "users": [u["display_name"] for u in e["users"]] if e["users"] else None, + "missing_users": e["missing_users"], + } + for e in response.data["events"] + if not e["is_gap"] + ] + assert returned_events == expected_events + + @pytest.mark.django_db def test_filter_events_invalid_type( make_organization_and_user_with_plugin_token, diff --git a/engine/apps/api/views/schedule.py b/engine/apps/api/views/schedule.py index 19b65010..5bec4ef1 100644 --- a/engine/apps/api/views/schedule.py +++ b/engine/apps/api/views/schedule.py @@ -240,6 +240,9 @@ 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, @@ -248,6 +251,25 @@ 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 70fdd01f..98d605f3 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -365,7 +365,7 @@ class OnCallSchedule(PolymorphicModel): # event starts after the current interval, move to next interval and go through it current_interval_idx += 1 - resolved.sort(key=lambda e: e["start"]) + resolved.sort(key=lambda e: (e["start"], e["shift"]["pk"])) return resolved