diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index d05cc0f4..31269432 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -276,18 +276,23 @@ class OnCallSchedule(PolymorphicModel): if not events: return [] - def apply_sorting(eventlist): - """Sort events keeping the events priority criteria.""" - eventlist.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 event_cmp_key(e): + """Sorting key criteria for events.""" + return ( + -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"], ) - # sort schedule events by (type desc, priority desc, start timestamp asc) - apply_sorting(events) + def insort_event(eventlist, e): + """Insert event keeping ordering criteria into already sorted event list.""" + idx = 0 + for i in eventlist: + if event_cmp_key(e) > event_cmp_key(i): + idx += 1 + else: + break + eventlist.insert(idx, e) def _merge_intervals(evs): """Keep track of scheduled intervals.""" @@ -303,24 +308,25 @@ class OnCallSchedule(PolymorphicModel): result.append(interval) return result + # sort schedule events by (type desc, priority desc, start timestamp asc) + events.sort(key=event_cmp_key) + # 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 + resolved = [] + pending = events current_interval_idx = 0 # current scheduled interval being checked - current_priority = pending[0]["priority_level"] # current priority level being resolved + current_priority = None # current priority level being resolved - while current_event_idx < len(pending): - ev = pending[current_event_idx] + while pending: + ev = pending.pop(0) + + if ev["calendar_type"] == OnCallSchedule.TYPE_ICAL_OVERRIDES: + # include overrides from start + resolved.append(ev) + continue if ev["priority_level"] != current_priority: # update scheduled intervals on priority change @@ -333,11 +339,11 @@ class OnCallSchedule(PolymorphicModel): 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 @@ -351,13 +357,14 @@ class OnCallSchedule(PolymorphicModel): ev["start"] = intervals[current_interval_idx][1] # reorder pending events after updating current event start date # (ie. insert the event where it should be to keep the order criteria) - apply_sorting(pending) - else: - # done, go to next event - current_event_idx += 1 + # TODO: switch to bisect insert on python 3.10 (or consider heapq) + insort_event(pending, ev) + # done, go to next event + 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 + continue + elif ( ev["start"] >= intervals[current_interval_idx][0] and ev["start"] < intervals[current_interval_idx][1] @@ -366,11 +373,16 @@ class OnCallSchedule(PolymorphicModel): # 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 + # unresolved, re-add to pending + # TODO: switch to bisect insert on python 3.10 (or consider heapq) + insort_event(pending, ev) + 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 + # unresolved, re-add to pending + # TODO: switch to bisect insert on python 3.10 (or consider heapq) + insort_event(pending, ev) resolved.sort(key=lambda e: (e["start"], e["shift"]["pk"])) return resolved diff --git a/engine/apps/schedules/tests/test_on_call_schedule.py b/engine/apps/schedules/tests/test_on_call_schedule.py index 9fae6517..48e1e244 100644 --- a/engine/apps/schedules/tests/test_on_call_schedule.py +++ b/engine/apps/schedules/tests/test_on_call_schedule.py @@ -389,6 +389,74 @@ def test_final_schedule_splitting_events( assert returned_events == expected_events +@pytest.mark.django_db +def test_final_schedule_splitting_same_time_events( + make_organization, make_user_for_organization, make_on_call_shift, make_schedule +): + 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) + + user_a, user_b, user_c = (make_user_for_organization(organization, username=i) for i in "ABC") + + shifts = ( + # user, priority, start time (h), duration (hs) + (user_a, 1, 10, 10), # r1-1: 10-20 / A + (user_b, 1, 10, 10), # r1-2: 10-20 / B + (user_c, 2, 10, 3), # r2-1: 10-13 / C + ) + for user, priority, start_h, duration in shifts: + data = { + "start": start_date + timezone.timedelta(hours=start_h), + "rotation_start": start_date + timezone.timedelta(hours=start_h), + "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_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[user]]) + + returned_events = schedule.final_events("UTC", start_date, days=1) + + expected = ( + # start (h), duration (H), user, priority + (10, 3, "C", 2), # 10-13 C + (13, 7, "A", 1), # 13-20 A + (13, 7, "B", 1), # 13-20 B + ) + expected_events = [ + { + "end": start_date + timezone.timedelta(hours=start + duration), + "priority_level": priority, + "start": start_date + timezone.timedelta(hours=start), + "user": user, + } + for start, duration, user, priority in expected + ] + returned_events = [ + { + "end": e["end"], + "priority_level": e["priority_level"], + "start": e["start"], + "user": e["users"][0]["display_name"] if e["users"] else None, + } + for e in sorted( + returned_events, key=lambda e: (e["start"], e["users"][0]["display_name"] if e["users"] else None) + ) + if not e["is_gap"] + ] + assert returned_events == expected_events + + @pytest.mark.django_db def test_preview_shift(make_organization, make_user_for_organization, make_schedule, make_on_call_shift): organization = make_organization()