Merge remote-tracking branch 'origin/matiasb/fix-final-schedule-event-splitting' into new-schedules

This commit is contained in:
Maxim 2022-08-17 16:25:39 +03:00
commit 622bdb383f
2 changed files with 111 additions and 31 deletions

View file

@ -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

View file

@ -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()