oncall-engine/engine/apps/schedules/models/on_call_schedule.py
Matias Bordese cebfec5ef9
Add support for web overrides to Terraform schedules (#1222)
Related to #828 

- Enable web UI for API/Terraform schedules to add overrides
- Refactor backend to add a flag toggling between web-based and
iCal-based overrides (these options are mutually exclusive)

Also updated read-only tooltips (related to #1483)
2023-03-10 16:21:50 +00:00

752 lines
31 KiB
Python

import datetime
import functools
import itertools
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 common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
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 _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