Merge pull request #268 from grafana/matiasb-resolve-web-schedule
Update schedule filter_events to resolve final schedule shifts
This commit is contained in:
commit
7516183a9a
4 changed files with 345 additions and 10 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue