Skip to content

Commit

Permalink
Merge pull request #353 from grafana/dev
Browse files Browse the repository at this point in the history
Merge dev to main
  • Loading branch information
matiasb authored Aug 10, 2022
2 parents 3d9542c + 83d5ccb commit 89f68d4
Show file tree
Hide file tree
Showing 8 changed files with 494 additions and 155 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Change Log

## v1.0.19 (2022-08-10)
- Bug fixes

## v1.0.15 (2022-08-03)
- Bug fixes

Expand Down
6 changes: 3 additions & 3 deletions docs/sources/open-source.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,10 @@ lt --port 8080 -s pretty-turkey-83 --print-requests
type: message
callback_id: incident_create
description: Creates a new OnCall incident
- name: Add to postmortem
- name: Add to resolution note
type: message
callback_id: add_postmortem
description: Add this message to postmortem
callback_id: add_resolution_note
description: Add this message to resolution note
slash_commands:
- command: /oncall
url: <ONCALL_ENGINE_PUBLIC_URL>/slack/interactive_api_endpoint/
Expand Down
156 changes: 9 additions & 147 deletions engine/apps/api/views/schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
from apps.auth_token.auth import PluginAuthentication
from apps.auth_token.constants import SCHEDULE_EXPORT_TOKEN_NAME
from apps.auth_token.models import ScheduleExportAuthToken
from apps.schedules.ical_utils import list_of_oncall_shifts_from_ical
from apps.schedules.models import OnCallSchedule
from apps.slack.models import SlackChannel
from apps.slack.tasks import update_slack_user_group_for_schedules
Expand Down Expand Up @@ -195,51 +194,14 @@ def get_request_timezone(self):

return user_tz, date

def _filter_events(self, schedule, user_timezone, starting_date, days, with_empty, with_gap):
shifts = (
list_of_oncall_shifts_from_ical(schedule, starting_date, user_timezone, with_empty, with_gap, days=days)
or []
)
events = []
# for start, end, users, priority_level, source in shifts:
for shift in shifts:
all_day = type(shift["start"]) == datetime.date
is_gap = shift.get("is_gap", False)
shift_json = {
"all_day": all_day,
"start": shift["start"],
# fix confusing end date for all-day event
"end": shift["end"] - timezone.timedelta(days=1) if all_day else shift["end"],
"users": [
{
"display_name": user.username,
"pk": user.public_primary_key,
}
for user in shift["users"]
],
"missing_users": shift["missing_users"],
"priority_level": shift["priority"] if shift["priority"] != 0 else None,
"source": shift["source"],
"calendar_type": shift["calendar_type"],
"is_empty": len(shift["users"]) == 0 and not is_gap,
"is_gap": is_gap,
"is_override": shift["calendar_type"] == OnCallSchedule.TYPE_ICAL_OVERRIDES,
"shift": {
"pk": shift["shift_pk"],
},
}
events.append(shift_json)

return events

@action(detail=True, methods=["get"])
def 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"

schedule = self.original_get_object()
events = self._filter_events(schedule, user_tz, date, days=1, with_empty=with_empty, with_gap=with_gap)
events = schedule.filter_events(user_tz, date, days=1, with_empty=with_empty, with_gap=with_gap)

slack_channel = (
{
Expand Down Expand Up @@ -281,16 +243,14 @@ def filter_events(self, request, pk):
raise BadRequest(detail="Invalid days format")

schedule = self.original_get_object()
events = self._filter_events(
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)
if filter_by is not None:
filter_by = OnCallSchedule.PRIMARY if filter_by == EVENTS_FILTER_BY_ROTATION else OnCallSchedule.OVERRIDES
events = schedule.filter_events(
user_tz, starting_date, days=days, with_empty=True, with_gap=resolve_schedule, filter_by=filter_by
)
else: # return final schedule
events = schedule.final_events(user_tz, starting_date, days)

result = {
"id": schedule.public_primary_key,
Expand All @@ -300,112 +260,14 @@ def filter_events(self, request, pk):
}
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=True, methods=["get"])
def next_shifts_per_user(self, request, pk):
"""Return next shift for users in schedule."""
user_tz, _ = self.get_request_timezone()
now = timezone.now()
starting_date = now.date()
schedule = self.original_get_object()
shift_events = self._filter_events(schedule, user_tz, starting_date, days=30, with_empty=False, with_gap=False)
events = self._resolve_schedule(shift_events)
events = schedule.final_events(user_tz, starting_date, days=30)

users = {}
for e in events:
Expand Down
11 changes: 10 additions & 1 deletion engine/apps/schedules/ical_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,13 @@ def memoized_users_in_ical(usernames_from_ical, organization):

# used for display schedule events on web
def list_of_oncall_shifts_from_ical(
schedule, date, user_timezone="UTC", with_empty_shifts=False, with_gaps=False, days=1
schedule,
date,
user_timezone="UTC",
with_empty_shifts=False,
with_gaps=False,
days=1,
filter_by=None,
):
"""
Parse the ical file and return list of events with users
Expand Down Expand Up @@ -122,6 +128,9 @@ def list_of_oncall_shifts_from_ical(
else:
calendar_type = OnCallSchedule.OVERRIDES

if filter_by is not None and filter_by != calendar_type:
continue

tmp_result_datetime, tmp_result_date = get_shifts_dict(
calendar, calendar_type, schedule, datetime_start, datetime_end, date, with_empty_shifts
)
Expand Down
Loading

0 comments on commit 89f68d4

Please sign in to comment.