diff --git a/engine/apps/api/tests/test_schedules.py b/engine/apps/api/tests/test_schedules.py index 8cb0319d..7e49a987 100644 --- a/engine/apps/api/tests/test_schedules.py +++ b/engine/apps/api/tests/test_schedules.py @@ -469,13 +469,13 @@ def test_filter_events_calendar( "by_day": ["MO", "FR"], "schedule": schedule, } - on_call_shift = make_on_call_shift( organization=organization, shift_type=CustomOnCallShift.TYPE_RECURRENT_EVENT, **data ) on_call_shift.users.add(user) url = reverse("api-internal:schedule-filter-events", kwargs={"pk": schedule.public_primary_key}) + url += "?type=rotation" response = client.get(url, format="json", **make_user_auth_headers(user, token)) # current week events are expected @@ -525,6 +525,7 @@ def test_filter_events_calendar( @pytest.mark.django_db def test_filter_events_range_calendar( make_organization_and_user_with_plugin_token, + make_user_for_organization, make_user_auth_headers, make_schedule, make_on_call_shift, @@ -540,6 +541,9 @@ def test_filter_events_range_calendar( now = timezone.now().replace(microsecond=0) start_date = now - timezone.timedelta(days=7) + mon_start = now - timezone.timedelta(days=start_date.weekday()) + request_date = mon_start + timezone.timedelta(days=2) + data = { "start": start_date, "rotation_start": start_date, @@ -549,17 +553,27 @@ def test_filter_events_range_calendar( "by_day": ["MO", "FR"], "schedule": schedule, } - on_call_shift = make_on_call_shift( organization=organization, shift_type=CustomOnCallShift.TYPE_RECURRENT_EVENT, **data ) on_call_shift.users.add(user) - mon_start = now - timezone.timedelta(days=start_date.weekday()) - request_date = mon_start + timezone.timedelta(days=2) + # add override shift + override_start = request_date + timezone.timedelta(seconds=3600) + override_data = { + "start": override_start, + "rotation_start": override_start, + "duration": timezone.timedelta(seconds=3600), + "schedule": schedule, + } + override = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **override_data + ) + other_user = make_user_for_organization(organization) + override.users.add(other_user) url = reverse("api-internal:schedule-filter-events", kwargs={"pk": schedule.public_primary_key}) - url += "?date={}&days=3".format(request_date.strftime("%Y-%m-%d")) + url += "?date={}&days=3&type=rotation".format(request_date.strftime("%Y-%m-%d")) response = client.get(url, format="json", **make_user_auth_headers(user, token)) # only friday occurrence is expected @@ -590,6 +604,215 @@ def test_filter_events_range_calendar( assert response.data == expected_result +@pytest.mark.django_db +def test_filter_events_overrides( + 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(microsecond=0) + start_date = now - timezone.timedelta(days=7) + mon_start = now - timezone.timedelta(days=start_date.weekday()) + request_date = mon_start + timezone.timedelta(days=2) + + data = { + "start": start_date, + "rotation_start": start_date, + "duration": timezone.timedelta(seconds=7200), + "priority_level": 1, + "frequency": CustomOnCallShift.FREQUENCY_WEEKLY, + "by_day": ["MO", "FR"], + "schedule": schedule, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_RECURRENT_EVENT, **data + ) + on_call_shift.users.add(user) + + # add override shift + override_start = request_date + timezone.timedelta(seconds=3600) + override_data = { + "start": override_start, + "rotation_start": override_start, + "duration": timezone.timedelta(seconds=3600), + "schedule": schedule, + } + override = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **override_data + ) + other_user = make_user_for_organization(organization) + override.add_rolling_users([[other_user]]) + + url = reverse("api-internal:schedule-filter-events", kwargs={"pk": schedule.public_primary_key}) + url += "?date={}&days=3&type=override".format(request_date.strftime("%Y-%m-%d")) + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + + # only override occurrence is expected + expected_result = { + "id": schedule.public_primary_key, + "name": "test_web_schedule", + "type": 2, + "events": [ + { + "all_day": False, + "start": override_start, + "end": override_start + override.duration, + "users": [{"display_name": other_user.username, "pk": other_user.public_primary_key}], + "missing_users": [], + "priority_level": None, + "source": "api", + "calendar_type": OnCallSchedule.OVERRIDES, + "is_empty": False, + "is_gap": False, + "shift": { + "pk": override.public_primary_key, + }, + } + ], + } + assert response.status_code == status.HTTP_200_OK + assert response.data == expected_result + + +@pytest.mark.django_db +def test_filter_events_final_schedule( + 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, user_b, user_c, user_d, user_e = (make_user_for_organization(organization, username=i) for i in "ABCDE") + + shifts = ( + # user, priority, start time (h), duration (hs) + (user_a, 1, 10, 5), # r1-1: 10-15 / A + (user_b, 1, 11, 2), # r1-2: 11-13 / B + (user_a, 1, 16, 3), # r1-3: 16-19 / A + (user_a, 1, 21, 1), # r1-4: 21-22 / A + (user_b, 1, 22, 2), # r1-5: 22-00 / B + (user_c, 2, 12, 2), # r2-1: 12-14 / C + (user_d, 2, 14, 1), # r2-2: 14-15 / D + (user_d, 2, 17, 1), # r2-3: 17-18 / D + (user_d, 2, 20, 3), # r2-4: 20-23 / D + ) + for user, priority, start_h, duration in shifts: + data = { + "start": start_date + timezone.timedelta(hours=start_h), + "rotation_start": start_date, + "duration": timezone.timedelta(hours=duration), + "priority_level": priority, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + "schedule": schedule, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_RECURRENT_EVENT, **data + ) + on_call_shift.users.add(user) + + # override: 22-23 / E + override_data = { + "start": start_date + timezone.timedelta(hours=22), + "rotation_start": start_date, + "duration": timezone.timedelta(hours=1), + "schedule": schedule, + } + override = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **override_data + ) + override.add_rolling_users([[user_e]]) + + 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 + + expected = ( + # start (h), duration (H), user, priority, is_gap, is_override + (0, 10, None, None, True, False), # 0-10 gap + (10, 2, "A", 1, False, False), # 10-12 A + (11, 1, "B", 1, False, False), # 11-12 B + (12, 2, "C", 2, False, False), # 12-14 C + (14, 1, "D", 2, False, False), # 14-15 D + (15, 1, None, None, True, False), # 15-16 gap + (16, 1, "A", 1, False, False), # 16-17 A + (17, 1, "D", 2, False, False), # 17-18 D + (18, 1, "A", 1, False, False), # 18-19 A + (19, 1, None, None, True, False), # 19-20 gap + (20, 2, "D", 2, False, False), # 20-22 D + (22, 1, "E", None, False, True), # 22-23 E (override) + (23, 1, "B", 1, False, False), # 23-00 B + ) + expected_events = [ + { + "calendar_type": 1 if is_override else None if is_gap else 0, + "end": start_date + timezone.timedelta(hours=start + duration), + "is_gap": is_gap, + "priority_level": priority, + "start": start_date + timezone.timedelta(hours=start, milliseconds=1 if start == 0 else 0), + "user": user, + } + for start, duration, user, priority, is_gap, is_override in expected + ] + returned_events = [ + { + "calendar_type": e["calendar_type"], + "end": e["end"], + "is_gap": e["is_gap"], + "priority_level": e["priority_level"], + "start": e["start"], + "user": e["users"][0]["display_name"] if e["users"] else None, + } + for e in response.data["events"] + ] + assert returned_events == expected_events + + +@pytest.mark.django_db +def test_filter_events_invalid_type( + make_organization_and_user_with_plugin_token, + 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", + ) + + url = reverse("api-internal:schedule-filter-events", kwargs={"pk": schedule.public_primary_key}) + url += "?type=invalid" + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", diff --git a/engine/apps/api/views/schedule.py b/engine/apps/api/views/schedule.py index 1b587210..f7d25ab5 100644 --- a/engine/apps/api/views/schedule.py +++ b/engine/apps/api/views/schedule.py @@ -38,6 +38,10 @@ from common.api_helpers.mixins import ( ) from common.api_helpers.utils import create_engine_url +EVENTS_FILTER_BY_ROTATION = "rotation" +EVENTS_FILTER_BY_OVERRIDE = "override" +EVENTS_FILTER_BY_FINAL = "final" + class ScheduleView( PublicPrimaryKeyMixin, ShortSerializerMixin, CreateSerializerMixin, UpdateSerializerMixin, ModelViewSet @@ -257,8 +261,12 @@ class ScheduleView( @action(detail=True, methods=["get"]) def filter_events(self, request, pk): user_tz, date = self.get_request_timezone() - with_empty = self.request.query_params.get("with_empty", False) == "true" - with_gap = self.request.query_params.get("with_gap", False) == "true" + filter_by = self.request.query_params.get("type") + + valid_filters = (EVENTS_FILTER_BY_ROTATION, EVENTS_FILTER_BY_OVERRIDE, EVENTS_FILTER_BY_FINAL) + if filter_by is not None and filter_by not in valid_filters: + raise BadRequest(detail="Invalid type value") + resolve_schedule = filter_by is None or filter_by == EVENTS_FILTER_BY_FINAL starting_date = date if self.request.query_params.get("date") else None if starting_date is None: @@ -272,9 +280,16 @@ class ScheduleView( schedule = self.original_get_object() events = self._filter_events( - schedule, user_tz, starting_date, days=days, with_empty=with_empty, with_gap=with_gap + schedule, user_tz, starting_date, days=days, with_empty=True, with_gap=resolve_schedule ) + if filter_by == EVENTS_FILTER_BY_OVERRIDE: + events = [e for e in events if e["calendar_type"] == OnCallSchedule.OVERRIDES] + elif filter_by == EVENTS_FILTER_BY_ROTATION: + events = [e for e in events if e["calendar_type"] == OnCallSchedule.PRIMARY] + else: # resolve_schedule + events = self._resolve_schedule(events) + result = { "id": schedule.public_primary_key, "name": schedule.name, @@ -283,6 +298,103 @@ class ScheduleView( } return Response(result, status=status.HTTP_200_OK) + def _resolve_schedule(self, events): + """Calculate final schedule shifts considering rotations and overrides.""" + if not events: + return [] + + # sort schedule events by (type desc, priority desc, start timestamp asc) + events.sort( + key=lambda e: ( + -e["calendar_type"] if e["calendar_type"] else 0, # overrides: 1, shifts: 0, gaps: None + -e["priority_level"] if e["priority_level"] else 0, + e["start"], + ) + ) + + def _merge_intervals(evs): + """Keep track of scheduled intervals.""" + if not evs: + return [] + intervals = [[e["start"], e["end"]] for e in evs] + result = [intervals[0]] + for interval in intervals[1:]: + previous_interval = result[-1] + if previous_interval[0] <= interval[0] <= previous_interval[1]: + previous_interval[1] = max(previous_interval[1], interval[1]) + else: + result.append(interval) + return result + + # iterate over events, reserving schedule slots based on their priority + # if the expected slot was already scheduled for a higher priority event, + # split the event, or fix start/end timestamps accordingly + + # include overrides from start + resolved = [e for e in events if e["calendar_type"] == OnCallSchedule.TYPE_ICAL_OVERRIDES] + intervals = _merge_intervals(resolved) + + pending = events[len(resolved) :] + if not pending: + return resolved + + current_event_idx = 0 # current event to resolve + current_interval_idx = 0 # current scheduled interval being checked + current_priority = pending[0]["priority_level"] # current priority level being resolved + + while current_event_idx < len(pending): + ev = pending[current_event_idx] + + if ev["priority_level"] != current_priority: + # update scheduled intervals on priority change + # and start from the beginning for the new priority level + resolved.sort(key=lambda e: e["start"]) + intervals = _merge_intervals(resolved) + current_interval_idx = 0 + current_priority = ev["priority_level"] + + if current_interval_idx >= len(intervals): + # event outside scheduled intervals, add to resolved + resolved.append(ev) + current_event_idx += 1 + elif ev["start"] < intervals[current_interval_idx][0] and ev["end"] <= intervals[current_interval_idx][0]: + # event starts and ends outside an already scheduled interval, add to resolved + resolved.append(ev) + current_event_idx += 1 + elif ev["start"] < intervals[current_interval_idx][0] and ev["end"] > intervals[current_interval_idx][0]: + # event starts outside interval but overlaps with an already scheduled interval + # 1. add a split event copy to schedule the time before the already scheduled interval + to_add = ev.copy() + to_add["end"] = intervals[current_interval_idx][0] + resolved.append(to_add) + # 2. check if there is still time to be scheduled after the current scheduled interval ends + if ev["end"] > intervals[current_interval_idx][1]: + # event ends after current interval, update event start timestamp to match the interval end + # and process the updated event as any other event + ev["start"] = intervals[current_interval_idx][1] + else: + # done, go to next event + current_event_idx += 1 + elif ev["start"] >= intervals[current_interval_idx][0] and ev["end"] <= intervals[current_interval_idx][1]: + # event inside an already scheduled interval, ignore (go to next) + current_event_idx += 1 + elif ( + ev["start"] >= intervals[current_interval_idx][0] + and ev["start"] < intervals[current_interval_idx][1] + and ev["end"] > intervals[current_interval_idx][1] + ): + # event starts inside a scheduled interval but ends out of it + # update the event start timestamp to match the interval end + ev["start"] = intervals[current_interval_idx][1] + # move to next interval and process the updated event as any other event + current_interval_idx += 1 + elif ev["start"] >= intervals[current_interval_idx][1]: + # 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"]) + return resolved + @action(detail=False, methods=["get"]) def type_options(self, request): # TODO: check if it needed diff --git a/engine/apps/schedules/models/custom_on_call_shift.py b/engine/apps/schedules/models/custom_on_call_shift.py index 64d72ae8..c19d2250 100644 --- a/engine/apps/schedules/models/custom_on_call_shift.py +++ b/engine/apps/schedules/models/custom_on_call_shift.py @@ -246,7 +246,7 @@ class CustomOnCallShift(models.Model): # use shift time_zone if it exists, otherwise use schedule or default time_zone time_zone = self.time_zone if self.time_zone is not None else time_zone # rolling_users shift converts to several ical events - if self.type == CustomOnCallShift.TYPE_ROLLING_USERS_EVENT: + if self.type in (CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, CustomOnCallShift.TYPE_OVERRIDE): event_ical = None users_queue = self.get_rolling_users() for counter, users in enumerate(users_queue, start=1): 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 1782e483..70ec55b0 100644 --- a/engine/apps/schedules/tests/test_custom_on_call_shift.py +++ b/engine/apps/schedules/tests/test_custom_on_call_shift.py @@ -48,7 +48,7 @@ def test_get_on_call_users_from_web_schedule_override(make_organization_and_user } on_call_shift = make_on_call_shift(organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **data) - on_call_shift.users.add(user) + on_call_shift.add_rolling_users([[user]]) # user is on-call date = date + timezone.timedelta(minutes=5)