oncall-engine/engine/apps/schedules/models/on_call_schedule.py
Vadim Stepanov a936c8c7fe
Improve schedule quality feature (#1602)
# What this PR does

Before:

<img width="281" alt="Screenshot 2023-03-23 at 16 56 42"
src="https://user-images.githubusercontent.com/20116910/227279464-c883ec05-a964-4360-bda2-3443409ca90a.png">

After:

<img width="338" alt="Screenshot 2023-03-23 at 16 57 41"
src="https://user-images.githubusercontent.com/20116910/227279476-468bffba-922a-45ea-b400-5f34d6bf0534.png">


- Add scores for overloaded users, e.g. `(+25% avg)` which means the
user is scheduled to be on-call 25% more than average for given
schedule.
- Add score for gaps, e.g. `Schedule has gaps (29% not covered)` which
means 29% of time no one is scheduled to be on-call.
- Make things easier to understand when there are gaps in the schedule,
add `(see overloaded users)` text.
- Consider events for next 52 weeks (~1 year) instead of 90 days (~3
months), so the quality report is more accurate. Also treat any balance
quality >95% as perfectly balanced. These two changes (period change and
adding 95% threshold) should help eliminate false positives for _most_
schedules.
- Modify backend & frontend so the backend returns all necessary user
information to render without using the user store.
- Move quality report generation to `OnCallSchedule` model, add more
tests.

## Which issue(s) this PR fixes
Related to https://github.com/grafana/oncall/issues/1552

## Checklist

- [x] Tests updated
- [x] `CHANGELOG.md` updated
(public docs will be added in a separate PR)
2023-03-27 11:03:16 +00:00

899 lines
37 KiB
Python

import datetime
import functools
import itertools
from collections import defaultdict
from enum import Enum
from typing import Iterable, Optional, TypedDict
import icalendar
import pytz
from django.apps import apps
from django.conf import settings
from django.core.validators import MinLengthValidator
from django.db import models
from django.db.utils import DatabaseError
from django.utils import timezone
from django.utils.functional import cached_property
from polymorphic.managers import PolymorphicManager
from polymorphic.models import PolymorphicModel
from polymorphic.query import PolymorphicQuerySet
from apps.schedules.ical_utils import (
fetch_ical_file_or_get_error,
get_oncall_users_for_multiple_schedules,
list_of_empty_shifts_in_schedule,
list_of_gaps_in_schedule,
list_of_oncall_shifts_from_ical,
)
from apps.schedules.models import CustomOnCallShift
from apps.user_management.models import User
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
# Utility classes for schedule quality report
class QualityReportCommentType(str, Enum):
INFO = "info"
WARNING = "warning"
class QualityReportComment(TypedDict):
type: QualityReportCommentType
text: str
class QualityReportOverloadedUser(TypedDict):
id: str
username: str
score: int
class QualityReport(TypedDict):
total_score: int
comments: list[QualityReportComment]
overloaded_users: list[QualityReportOverloadedUser]
def generate_public_primary_key_for_oncall_schedule_channel():
prefix = "S"
new_public_primary_key = generate_public_primary_key(prefix)
failure_counter = 0
while OnCallSchedule.objects.filter(public_primary_key=new_public_primary_key).exists():
new_public_primary_key = increase_public_primary_key_length(
failure_counter=failure_counter, prefix=prefix, model_name="OnCallSchedule"
)
failure_counter += 1
return new_public_primary_key
class OnCallScheduleQuerySet(PolymorphicQuerySet):
def get_oncall_users(self, events_datetime=None):
return get_oncall_users_for_multiple_schedules(self, events_datetime)
class OnCallSchedule(PolymorphicModel):
objects = PolymorphicManager.from_queryset(OnCallScheduleQuerySet)()
# type of calendars in schedule
TYPE_ICAL_PRIMARY, TYPE_ICAL_OVERRIDES, TYPE_CALENDAR = range(
3
) # todo: discuss do we need the third type (this types used for frontend)
PRIMARY, OVERRIDES = range(2)
CALENDAR_TYPE_VERBAL = {PRIMARY: "primary", OVERRIDES: "overrides"}
public_primary_key = models.CharField(
max_length=20,
validators=[MinLengthValidator(settings.PUBLIC_PRIMARY_KEY_MIN_LENGTH + 1)],
unique=True,
default=generate_public_primary_key_for_oncall_schedule_channel,
)
cached_ical_file_primary = models.TextField(null=True, default=None)
prev_ical_file_primary = models.TextField(null=True, default=None)
cached_ical_file_overrides = models.TextField(null=True, default=None)
prev_ical_file_overrides = models.TextField(null=True, default=None)
organization = models.ForeignKey(
"user_management.Organization", on_delete=models.CASCADE, related_name="oncall_schedules"
)
team = models.ForeignKey(
"user_management.Team",
on_delete=models.SET_NULL,
related_name="oncall_schedules",
null=True,
default=None,
)
name = models.CharField(max_length=200)
channel = models.CharField(max_length=100, null=True, default=None)
# Slack user group to be updated when on-call users change for this schedule
user_group = models.ForeignKey(
to="slack.SlackUserGroup", null=True, on_delete=models.SET_NULL, related_name="oncall_schedules"
)
# schedule reminder related fields
class NotifyOnCallShiftFreq(models.IntegerChoices):
NEVER = 0, "Never"
EACH_SHIFT = 1, "Each shift"
class NotifyEmptyOnCall(models.IntegerChoices):
ALL = 0, "Notify all people in the channel"
PREV = 1, "Mention person from the previous slot"
NO_ONE = 2, "Inform about empty slot"
current_shifts = models.TextField(null=False, default="{}")
# Used to not drop current_shifts to use them when "Mention person from the previous slot"
empty_oncall = models.BooleanField(default=True)
notify_oncall_shift_freq = models.IntegerField(
null=False,
choices=NotifyOnCallShiftFreq.choices,
default=NotifyOnCallShiftFreq.EACH_SHIFT,
)
mention_oncall_start = models.BooleanField(null=False, default=True)
mention_oncall_next = models.BooleanField(null=False, default=False)
notify_empty_oncall = models.IntegerField(
null=False, choices=NotifyEmptyOnCall.choices, default=NotifyEmptyOnCall.ALL
)
# Gaps-checker related fields
has_gaps = models.BooleanField(default=False)
gaps_report_sent_at = models.DateField(null=True, default=None)
# empty shifts checker related fields
has_empty_shifts = models.BooleanField(default=False)
empty_shifts_report_sent_at = models.DateField(null=True, default=None)
class Meta:
unique_together = ("name", "organization")
def get_icalendars(self):
"""Returns list of calendars. Primary calendar should always be the first"""
calendar_primary = None
calendar_overrides = None
# if self._ical_file_(primary|overrides) is None -> no cache, will trigger a refresh
# if self._ical_file_(primary|overrides) == "" -> cached value for an empty schedule
if self._ical_file_primary:
calendar_primary = icalendar.Calendar.from_ical(self._ical_file_primary)
if self._ical_file_overrides:
calendar_overrides = icalendar.Calendar.from_ical(self._ical_file_overrides)
return calendar_primary, calendar_overrides
def get_prev_and_current_ical_files(self):
"""Returns list of tuples with prev and current iCal files for each calendar"""
return [
(self.prev_ical_file_primary, self.cached_ical_file_primary),
(self.prev_ical_file_overrides, self.cached_ical_file_overrides),
]
def check_gaps_for_next_week(self):
today = timezone.now().date()
gaps = list_of_gaps_in_schedule(self, today, today + timezone.timedelta(days=7))
has_gaps = len(gaps) != 0
self.has_gaps = has_gaps
self.save(update_fields=["has_gaps"])
return has_gaps
def check_empty_shifts_for_next_week(self):
today = timezone.now().date()
empty_shifts = list_of_empty_shifts_in_schedule(self, today, today + timezone.timedelta(days=7))
has_empty_shifts = len(empty_shifts) != 0
self.has_empty_shifts = has_empty_shifts
self.save(update_fields=["has_empty_shifts"])
return has_empty_shifts
def drop_cached_ical(self):
self._drop_primary_ical_file()
self._drop_overrides_ical_file()
def refresh_ical_file(self):
self._refresh_primary_ical_file()
self._refresh_overrides_ical_file()
def _ical_file_primary(self):
raise NotImplementedError
def _ical_file_overrides(self):
raise NotImplementedError
def _refresh_primary_ical_file(self):
raise NotImplementedError
def _refresh_overrides_ical_file(self):
raise NotImplementedError
def _drop_primary_ical_file(self):
self.prev_ical_file_primary = self.cached_ical_file_primary
self.cached_ical_file_primary = None
self.save(update_fields=["cached_ical_file_primary", "prev_ical_file_primary"])
def _drop_overrides_ical_file(self):
self.prev_ical_file_overrides = self.cached_ical_file_overrides
self.cached_ical_file_overrides = None
self.save(update_fields=["cached_ical_file_overrides", "prev_ical_file_overrides"])
def related_users(self):
"""Return public primary keys for all users referenced in the schedule."""
return set()
def filter_events(
self,
user_timezone,
starting_date,
days,
with_empty=False,
with_gap=False,
filter_by=None,
all_day_datetime=False,
):
"""Return filtered events from schedule."""
shifts = (
list_of_oncall_shifts_from_ical(
self, starting_date, user_timezone, with_empty, with_gap, days=days, filter_by=filter_by
)
or []
)
events = []
for shift in shifts:
start = shift["start"]
all_day = type(start) == datetime.date
# fix confusing end date for all-day event
end = shift["end"] - timezone.timedelta(days=1) if all_day else shift["end"]
if all_day and all_day_datetime:
start = datetime.datetime.combine(start, datetime.datetime.min.time(), tzinfo=pytz.UTC)
end = datetime.datetime.combine(end, datetime.datetime.max.time(), tzinfo=pytz.UTC)
is_gap = shift.get("is_gap", False)
shift_json = {
"all_day": all_day,
"start": start,
"end": 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)
# combine multiple-users same-shift events into one
events = self._merge_events(events)
return events
def final_events(self, user_tz, starting_date, days):
"""Return schedule final events, after resolving shifts and overrides."""
events = self.filter_events(
user_tz, starting_date, days=days, with_empty=True, with_gap=True, all_day_datetime=True
)
events = self._resolve_schedule(events)
return events
def quality_report(self, date: Optional[timezone.datetime], days: Optional[int]) -> QualityReport:
"""
Return schedule quality report to be used by the web UI.
TODO: Add scores on "inside working hours" and "balance outside working hours" when
TODO: working hours editor is implemented in the web UI.
"""
# get events to consider for calculation
if date is None:
today = datetime.datetime.now(tz=datetime.timezone.utc)
date = today - datetime.timedelta(days=7 - today.weekday()) # start of next week in UTC
if days is None:
days = 52 * 7 # consider next 52 weeks (~1 year)
events = self.final_events(user_tz="UTC", starting_date=date, days=days)
# an event is “good” if it's not a gap and not empty
good_events = [event for event in events if not event["is_gap"] and not event["is_empty"]]
if not good_events:
return {
"total_score": 0,
"comments": [{"type": QualityReportCommentType.WARNING, "text": "Schedule is empty"}],
"overloaded_users": [],
}
def event_duration(ev: dict) -> datetime.timedelta:
return ev["end"] - ev["start"]
def timedelta_sum(deltas: Iterable[datetime.timedelta]) -> datetime.timedelta:
return sum(deltas, start=datetime.timedelta())
def score_to_percent(value: float) -> int:
return round(value * 100)
def get_duration_map(evs: list[dict]) -> dict[str, datetime.timedelta]:
"""Return a map of user PKs to total duration of events they are in."""
result = defaultdict(datetime.timedelta)
for ev in evs:
for user in ev["users"]:
user_pk = user["pk"]
result[user_pk] += event_duration(ev)
return result
def get_balance_score_by_duration_map(dur_map: dict[str, datetime.timedelta]) -> float:
"""
Return a score between 0 and 1, based on how balanced the durations are in the duration map.
The formula is taken from https://github.com/grafana/oncall/issues/118#issuecomment-1161787854.
"""
if len(dur_map) <= 1:
return 1
result = 0
for key_1, key_2 in itertools.combinations(dur_map, 2):
duration_1 = dur_map[key_1]
duration_2 = dur_map[key_2]
result += min(duration_1, duration_2) / max(duration_1, duration_2)
number_of_pairs = len(dur_map) * (len(dur_map) - 1) // 2
return result / number_of_pairs
# calculate good event score
good_events_duration = timedelta_sum(event_duration(event) for event in good_events)
good_event_score = min(good_events_duration / datetime.timedelta(days=days), 1)
good_event_score = score_to_percent(good_event_score)
# calculate balance score
duration_map = get_duration_map(good_events)
balance_score = get_balance_score_by_duration_map(duration_map)
balance_score = score_to_percent(balance_score)
# calculate overloaded users
if balance_score >= 95: # tolerate minor imbalance
balance_score = 100
overloaded_users = []
else:
average_duration = timedelta_sum(duration_map.values()) / len(duration_map)
overloaded_user_pks = [user_pk for user_pk, duration in duration_map.items() if duration > average_duration]
usernames = {
u.public_primary_key: u.username
for u in User.objects.filter(public_primary_key__in=overloaded_user_pks).only(
"public_primary_key", "username"
)
}
overloaded_users = []
for user_pk in overloaded_user_pks:
score = score_to_percent(duration_map[user_pk] / average_duration) - 100
username = usernames.get(user_pk) or "unknown" # fallback to "unknown" if user is not found
overloaded_users.append({"id": user_pk, "username": username, "score": score})
# show most overloaded users first
overloaded_users.sort(key=lambda u: (-u["score"], u["username"]))
# generate comments regarding gaps
comments = []
if good_event_score == 100:
comments.append({"type": QualityReportCommentType.INFO, "text": "Schedule has no gaps"})
else:
not_covered = 100 - good_event_score
comments.append(
{"type": QualityReportCommentType.WARNING, "text": f"Schedule has gaps ({not_covered}% not covered)"}
)
# generate comments regarding balance
if balance_score == 100:
comments.append({"type": QualityReportCommentType.INFO, "text": "Schedule is perfectly balanced"})
else:
comments.append(
{"type": QualityReportCommentType.WARNING, "text": "Schedule has balance issues (see overloaded users)"}
)
# calculate total score (weighted sum of good event score and balance score)
total_score = round((good_event_score + balance_score) / 2)
return {
"total_score": total_score,
"comments": comments,
"overloaded_users": overloaded_users,
}
def _resolve_schedule(self, events):
"""Calculate final schedule shifts considering rotations and overrides."""
if not events:
return []
def event_start_cmp_key(e):
return e["start"]
def event_cmp_key(e):
"""Sorting key criteria for events."""
start = event_start_cmp_key(e)
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,
start,
)
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."""
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
# 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
intervals = []
resolved = []
pending = events
current_interval_idx = 0 # current scheduled interval being checked
current_type = OnCallSchedule.TYPE_ICAL_OVERRIDES # current calendar type
current_priority = None # current priority level being resolved
while pending:
ev = pending.pop(0)
if ev["is_empty"]:
# exclude events without active users
continue
# api/terraform shifts could be missing a priority; assume None means 0
priority = ev["priority_level"] or 0
if priority != current_priority or current_type != ev["calendar_type"]:
# update scheduled intervals on priority change
# and start from the beginning for the new priority level
# also for calendar event type (overrides first, then apply regular shifts)
resolved.sort(key=event_start_cmp_key)
intervals = _merge_intervals(resolved)
current_interval_idx = 0
current_priority = priority
current_type = ev["calendar_type"]
if current_interval_idx >= len(intervals):
# event outside scheduled intervals, add to resolved
resolved.append(ev)
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)
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]
# reorder pending events after updating current event start date
# (ie. insert the event where it should be to keep the order criteria)
# 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)
continue
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]
# 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: (event_start_cmp_key(e), e["shift"]["pk"] or ""))
return resolved
def _merge_events(self, events):
"""Merge user groups same-shift events."""
if events:
merged = [events[0]]
current = merged[0]
for next_event in events[1:]:
if (
current["start"] == next_event["start"]
and current["shift"]["pk"] is not None
and current["shift"]["pk"] == next_event["shift"]["pk"]
):
current["users"] += [u for u in next_event["users"] if u not in current["users"]]
current["missing_users"] += [
u for u in next_event["missing_users"] if u not in current["missing_users"]
]
else:
merged.append(next_event)
current = next_event
events = merged
return events
def _generate_ical_file_from_shifts(self, qs, extra_shifts=None, allow_empty_users=False):
"""Generate iCal events file from custom on-call shifts."""
# default to empty string since it is not possible to have a no-events ical file
ical = ""
if qs.exists() or extra_shifts is not None:
if extra_shifts is None:
extra_shifts = []
end_line = "END:VCALENDAR"
calendar = icalendar.Calendar()
calendar.add("prodid", "-//web schedule//oncall//")
calendar.add("version", "2.0")
calendar.add("method", "PUBLISH")
ical_file = calendar.to_ical().decode()
ical = ical_file.replace(end_line, "").strip()
ical = f"{ical}\r\n"
for event in itertools.chain(qs.all(), extra_shifts):
ical += event.convert_to_ical(self.time_zone, allow_empty_users=allow_empty_users)
ical += f"{end_line}\r\n"
return ical
def preview_shift(self, custom_shift, user_tz, starting_date, days, updated_shift_pk=None):
"""Return unsaved rotation and final schedule preview events."""
if custom_shift.type == CustomOnCallShift.TYPE_OVERRIDE:
qs = self.custom_shifts.filter(type=CustomOnCallShift.TYPE_OVERRIDE)
ical_attr = "cached_ical_file_overrides"
ical_property = "_ical_file_overrides"
elif custom_shift.type == CustomOnCallShift.TYPE_ROLLING_USERS_EVENT:
qs = self.custom_shifts.exclude(type=CustomOnCallShift.TYPE_OVERRIDE)
ical_attr = "cached_ical_file_primary"
ical_property = "_ical_file_primary"
else:
raise ValueError("Invalid shift type")
def _invalidate_cache(schedule, prop_name):
"""Invalidate cached property cache"""
try:
delattr(schedule, prop_name)
except AttributeError:
pass
extra_shifts = [custom_shift]
if updated_shift_pk is not None:
try:
update_shift = qs.get(public_primary_key=updated_shift_pk)
except CustomOnCallShift.DoesNotExist:
pass
else:
if update_shift.event_is_started:
custom_shift.rotation_start = max(
custom_shift.rotation_start, timezone.now().replace(microsecond=0)
)
custom_shift.start_rotation_from_user_index = update_shift.start_rotation_from_user_index
update_shift.until = custom_shift.rotation_start
extra_shifts.append(update_shift)
else:
# only reuse PK for preview when updating a rotation that won't be started after the update
custom_shift.public_primary_key = updated_shift_pk
qs = qs.exclude(public_primary_key=updated_shift_pk)
ical_file = self._generate_ical_file_from_shifts(qs, extra_shifts=extra_shifts, allow_empty_users=True)
original_value = getattr(self, ical_attr)
_invalidate_cache(self, ical_property)
setattr(self, ical_attr, ical_file)
# filter events using a temporal overriden calendar including the not-yet-saved shift
events = self.filter_events(user_tz, starting_date, days=days, with_empty=True, with_gap=True)
# return preview events for affected shifts
updated_shift_pks = {s.public_primary_key for s in extra_shifts}
shift_events = [e for e in events if e["shift"]["pk"] in updated_shift_pks]
final_events = self._resolve_schedule(events)
_invalidate_cache(self, ical_property)
setattr(self, ical_attr, original_value)
return shift_events, final_events
# Insight logs
@property
def insight_logs_verbal(self):
return self.name
@property
def insight_logs_serialized(self):
result = {
"name": self.name,
}
if self.team:
result["team"] = self.team.name
result["team_id"] = self.team.public_primary_key
else:
result["team"] = "General"
if self.organization.slack_team_identity:
if self.channel:
SlackChannel = apps.get_model("slack", "SlackChannel")
sti = self.organization.slack_team_identity
slack_channel = SlackChannel.objects.filter(slack_team_identity=sti, slack_id=self.channel).first()
if slack_channel:
result["slack_channel"] = slack_channel.name
if self.user_group is not None:
result["user_group"] = self.user_group.handle
result["notification_frequency"] = self.get_notify_oncall_shift_freq_display()
result["current_shift_notification"] = self.mention_oncall_start
result["next_shift_notification"] = self.mention_oncall_next
result["notify_empty_oncall"] = self.get_notify_empty_oncall_display
return result
@property
def insight_logs_metadata(self):
result = {}
if self.team:
result["team"] = self.team.name
result["team_id"] = self.team.public_primary_key
else:
result["team"] = "General"
return result
class OnCallScheduleICal(OnCallSchedule):
# For the ical schedule both primary and overrides icals are imported via ical url
ical_url_primary = models.CharField(max_length=500, null=True, default=None)
ical_file_error_primary = models.CharField(max_length=200, null=True, default=None)
ical_url_overrides = models.CharField(max_length=500, null=True, default=None)
ical_file_error_overrides = models.CharField(max_length=200, null=True, default=None)
@cached_property
def _ical_file_primary(self):
"""
Download iCal file imported from calendar
"""
cached_ical_file = self.cached_ical_file_primary
if self.ical_url_primary is not None and self.cached_ical_file_primary is None:
self.cached_ical_file_primary, self.ical_file_error_primary = fetch_ical_file_or_get_error(
self.ical_url_primary
)
self.save(update_fields=["cached_ical_file_primary", "ical_file_error_primary"])
cached_ical_file = self.cached_ical_file_primary
return cached_ical_file
@cached_property
def _ical_file_overrides(self):
"""
Download iCal file imported from calendar
"""
cached_ical_file = self.cached_ical_file_overrides
if self.ical_url_overrides is not None and self.cached_ical_file_overrides is None:
self.cached_ical_file_overrides, self.ical_file_error_overrides = fetch_ical_file_or_get_error(
self.ical_url_overrides
)
self.save(update_fields=["cached_ical_file_overrides", "ical_file_error_overrides"])
cached_ical_file = self.cached_ical_file_overrides
return cached_ical_file
def _refresh_primary_ical_file(self):
self.prev_ical_file_primary = self.cached_ical_file_primary
if self.ical_url_primary is not None:
self.cached_ical_file_primary, self.ical_file_error_primary = fetch_ical_file_or_get_error(
self.ical_url_primary,
)
self.save(update_fields=["cached_ical_file_primary", "prev_ical_file_primary", "ical_file_error_primary"])
def _refresh_overrides_ical_file(self):
self.prev_ical_file_overrides = self.cached_ical_file_overrides
if self.ical_url_overrides is not None:
self.cached_ical_file_overrides, self.ical_file_error_overrides = fetch_ical_file_or_get_error(
self.ical_url_overrides,
)
self.save(update_fields=["cached_ical_file_overrides", "prev_ical_file_overrides", "ical_file_error_overrides"])
# Insight logs
@property
def insight_logs_serialized(self):
res = super().insight_logs_serialized
res["primary_calendar_url"] = self.ical_url_primary
res["overrides_calendar_url"] = self.ical_url_overrides
return res
@property
def insight_logs_type_verbal(self):
return "ical_schedule"
class OnCallScheduleCalendar(OnCallSchedule):
# For the calendar schedule only overrides ical is imported via ical url.
ical_url_overrides = models.CharField(max_length=500, null=True, default=None)
ical_file_error_overrides = models.CharField(max_length=200, null=True, default=None)
# Primary ical is generated from custom_on_call_shifts.
time_zone = models.CharField(max_length=100, default="UTC")
custom_on_call_shifts = models.ManyToManyField("schedules.CustomOnCallShift", related_name="schedules")
enable_web_overrides = models.BooleanField(default=False, null=True)
@cached_property
def _ical_file_primary(self):
"""
Return cached ical file with iCal events from custom on-call shifts
"""
if self.cached_ical_file_primary is None:
self.cached_ical_file_primary = self._generate_ical_file_primary()
self.save(update_fields=["cached_ical_file_primary"])
return self.cached_ical_file_primary
@cached_property
def _ical_file_overrides(self):
"""
Download iCal file imported from calendar
"""
if self.cached_ical_file_overrides is not None:
return self.cached_ical_file_overrides
self._refresh_overrides_ical_file()
return self.cached_ical_file_overrides
def _refresh_primary_ical_file(self):
self.prev_ical_file_primary = self.cached_ical_file_primary
self.cached_ical_file_primary = self._generate_ical_file_primary()
self.save(
update_fields=[
"cached_ical_file_primary",
"prev_ical_file_primary",
]
)
def _refresh_overrides_ical_file(self):
self.prev_ical_file_overrides = self.cached_ical_file_overrides
if self.enable_web_overrides:
# web overrides
qs = self.custom_shifts.filter(type=CustomOnCallShift.TYPE_OVERRIDE)
self.cached_ical_file_overrides = self._generate_ical_file_from_shifts(qs)
elif self.ical_url_overrides is not None:
self.cached_ical_file_overrides, self.ical_file_error_overrides = fetch_ical_file_or_get_error(
self.ical_url_overrides,
)
self.save(update_fields=["cached_ical_file_overrides", "prev_ical_file_overrides", "ical_file_error_overrides"])
def _generate_ical_file_primary(self):
"""
Generate iCal events file from custom on-call shifts (created via API)
"""
# default to empty string since it is not possible to have a no-events ical file
ical = ""
if self.custom_on_call_shifts.exists():
end_line = "END:VCALENDAR"
calendar = icalendar.Calendar()
calendar.add("prodid", "-//My calendar product//amixr//")
calendar.add("version", "2.0")
calendar.add("method", "PUBLISH")
ical_file = calendar.to_ical().decode()
ical = ical_file.replace(end_line, "").strip()
ical = f"{ical}\r\n"
for event in self.custom_on_call_shifts.all():
ical += event.convert_to_ical(self.time_zone)
ical += f"{end_line}\r\n"
return ical
def preview_shift(self, custom_shift, user_tz, starting_date, days, updated_shift_pk=None):
"""Return unsaved rotation and final schedule preview events."""
if custom_shift.type != CustomOnCallShift.TYPE_OVERRIDE:
raise ValueError("Invalid shift type")
return super().preview_shift(custom_shift, user_tz, starting_date, days, updated_shift_pk=updated_shift_pk)
@property
def insight_logs_type_verbal(self):
return "calendar_schedule"
@property
def insight_logs_serialized(self):
res = super().insight_logs_serialized
res["overrides_calendar_url"] = self.ical_url_overrides
return res
class OnCallScheduleWeb(OnCallSchedule):
time_zone = models.CharField(max_length=100, default="UTC")
def _generate_ical_file_primary(self):
qs = self.custom_shifts.exclude(type=CustomOnCallShift.TYPE_OVERRIDE)
return self._generate_ical_file_from_shifts(qs)
def _generate_ical_file_overrides(self):
qs = self.custom_shifts.filter(type=CustomOnCallShift.TYPE_OVERRIDE)
return self._generate_ical_file_from_shifts(qs)
@cached_property
def _ical_file_primary(self):
"""Return cached ical file with iCal events from custom on-call shifts."""
if self.cached_ical_file_primary is None:
self.cached_ical_file_primary = self._generate_ical_file_primary()
try:
self.save(update_fields=["cached_ical_file_primary"])
except DatabaseError:
# schedule may have been deleted from db
return
return self.cached_ical_file_primary
def _refresh_primary_ical_file(self):
self.prev_ical_file_primary = self.cached_ical_file_primary
self.cached_ical_file_primary = self._generate_ical_file_primary()
self.save(update_fields=["cached_ical_file_primary", "prev_ical_file_primary"])
@cached_property
def _ical_file_overrides(self):
"""Return cached ical file with iCal events from custom on-call overrides shifts."""
if self.cached_ical_file_overrides is None:
self.cached_ical_file_overrides = self._generate_ical_file_overrides()
try:
self.save(update_fields=["cached_ical_file_overrides"])
except DatabaseError:
# schedule may have been deleted from db
return
return self.cached_ical_file_overrides
def _refresh_overrides_ical_file(self):
self.prev_ical_file_overrides = self.cached_ical_file_overrides
self.cached_ical_file_overrides = self._generate_ical_file_overrides()
self.save(update_fields=["cached_ical_file_overrides", "prev_ical_file_overrides"])
def related_users(self):
"""Return public primary keys for all users referenced in the schedule."""
rolling_users = self.custom_shifts.values_list("rolling_users", flat=True)
users = functools.reduce(
set.union,
(
set(g.values())
for rolling_groups in rolling_users
if rolling_groups is not None
for g in rolling_groups
if g is not None
),
set(),
)
return users
# Insight logs
@property
def insight_logs_type_verbal(self):
return "web_schedule"
@property
def insight_logs_serialized(self):
res = super().insight_logs_serialized
res["time_zone"] = self.time_zone
return res