Merge pull request #268 from grafana/matiasb-resolve-web-schedule

Update schedule filter_events to resolve final schedule shifts
This commit is contained in:
Matias Bordese 2022-07-22 14:20:20 -03:00 committed by GitHub
commit 7516183a9a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 345 additions and 10 deletions

View file

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

View file

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

View file

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

View file

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