oncall-engine/engine/apps/schedules/models/custom_on_call_shift.py
Matias Bordese a536af95f4
Refresh final schedule after cached icals are dropped (#2004)
Make sure the final schedule is refreshed after dropping the cached ical
representations (sometimes the refresh final task was completed before
the cached ical files were refreshed).
2023-05-25 12:01:52 +00:00

791 lines
34 KiB
Python

import copy
import itertools
import logging
import random
import string
from calendar import monthrange
from uuid import uuid4
import pytz
from dateutil import relativedelta
from django.apps import apps
from django.conf import settings
from django.core.validators import MinLengthValidator
from django.db import models, transaction
from django.db.models import JSONField
from django.forms.models import model_to_dict
from django.utils import timezone
from django.utils.functional import cached_property
from icalendar.cal import Event
from recurring_ical_events import UnfoldableCalendar
from apps.schedules.tasks import (
drop_cached_ical_task,
schedule_notify_about_empty_shifts_in_schedule,
schedule_notify_about_gaps_in_schedule,
)
from apps.user_management.models import User
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
def generate_public_primary_key_for_custom_oncall_shift():
prefix = "O"
new_public_primary_key = generate_public_primary_key(prefix)
failure_counter = 0
while CustomOnCallShift.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="CustomOnCallShift"
)
failure_counter += 1
return new_public_primary_key
class CustomOnCallShift(models.Model):
(
FREQUENCY_DAILY,
FREQUENCY_WEEKLY,
FREQUENCY_MONTHLY,
FREQUENCY_HOURLY,
) = range(4)
FREQUENCY_CHOICES = (
(FREQUENCY_HOURLY, "Hourly"),
(FREQUENCY_DAILY, "Daily"),
(FREQUENCY_WEEKLY, "Weekly"),
(FREQUENCY_MONTHLY, "Monthly"),
)
PUBLIC_FREQUENCY_CHOICES_MAP = {
FREQUENCY_HOURLY: "hourly",
FREQUENCY_DAILY: "daily",
FREQUENCY_WEEKLY: "weekly",
FREQUENCY_MONTHLY: "monthly",
}
WEB_FREQUENCY_CHOICES_MAP = {
FREQUENCY_HOURLY: "hours",
FREQUENCY_DAILY: "days",
FREQUENCY_WEEKLY: "weeks",
FREQUENCY_MONTHLY: "months",
}
(
TYPE_SINGLE_EVENT,
TYPE_RECURRENT_EVENT,
TYPE_ROLLING_USERS_EVENT,
TYPE_OVERRIDE,
) = range(4)
TYPE_CHOICES = (
(TYPE_SINGLE_EVENT, "Single event"),
(TYPE_RECURRENT_EVENT, "Recurrent event"),
(TYPE_ROLLING_USERS_EVENT, "Rolling users"),
(TYPE_OVERRIDE, "Override"),
)
PUBLIC_TYPE_CHOICES_MAP = {
TYPE_SINGLE_EVENT: "single_event",
TYPE_RECURRENT_EVENT: "recurrent_event",
TYPE_ROLLING_USERS_EVENT: "rolling_users",
TYPE_OVERRIDE: "override",
}
WEB_TYPES = (
TYPE_ROLLING_USERS_EVENT,
TYPE_OVERRIDE,
)
(MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY) = range(7)
WEEKDAY_CHOICES = (
(MONDAY, "Monday"),
(TUESDAY, "Tuesday"),
(WEDNESDAY, "Wednesday"),
(THURSDAY, "Thursday"),
(FRIDAY, "Friday"),
(SATURDAY, "Saturday"),
(SUNDAY, "Sunday"),
)
ICAL_WEEKDAY_MAP = {
MONDAY: "MO",
TUESDAY: "TU",
WEDNESDAY: "WE",
THURSDAY: "TH",
FRIDAY: "FR",
SATURDAY: "SA",
SUNDAY: "SU",
}
ICAL_WEEKDAY_REVERSE_MAP = {v: k for k, v in ICAL_WEEKDAY_MAP.items()}
WEB_WEEKDAY_MAP = {
"MO": "Monday",
"TU": "Tuesday",
"WE": "Wednesday",
"TH": "Thursday",
"FR": "Friday",
"SA": "Saturday",
"SU": "Sunday",
}
(
SOURCE_WEB,
SOURCE_API,
SOURCE_SLACK,
SOURCE_TERRAFORM,
) = range(4)
SOURCE_CHOICES = (
(SOURCE_WEB, "web"),
(SOURCE_API, "api"),
(SOURCE_SLACK, "slack"),
(SOURCE_TERRAFORM, "terraform"),
)
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_custom_oncall_shift,
)
organization = models.ForeignKey(
"user_management.Organization",
on_delete=models.CASCADE,
related_name="custom_on_call_shifts",
)
team = models.ForeignKey(
"user_management.Team",
on_delete=models.SET_NULL,
related_name="custom_on_call_shifts",
null=True,
default=None,
)
schedule = models.ForeignKey(
"schedules.OnCallSchedule",
on_delete=models.CASCADE,
related_name="custom_shifts",
null=True,
default=None,
)
name = models.CharField(max_length=200)
title = models.CharField(max_length=200, null=True, default=None)
time_zone = models.CharField(max_length=100, null=True, default=None)
source = models.IntegerField(choices=SOURCE_CHOICES, default=SOURCE_API)
users = models.ManyToManyField("user_management.User") # users in single and recurrent events
rolling_users = JSONField(null=True, default=None) # [{user.pk: user.public_primary_key, ...},...]
start_rotation_from_user_index = models.PositiveIntegerField(null=True, default=None)
uuid = models.UUIDField(default=uuid4) # event uuid
type = models.IntegerField(choices=TYPE_CHOICES) # "rolling_users", "recurrent_event", "single_event", "override"
start = models.DateTimeField() # event start datetime
duration = models.DurationField() # duration in seconds
rotation_start = models.DateTimeField() # used for calculation users rotation and rotation start date
frequency = models.IntegerField(choices=FREQUENCY_CHOICES, null=True, default=None)
priority_level = models.IntegerField(default=0)
interval = models.IntegerField(default=None, null=True) # every n days/months - ical format
until = models.DateTimeField(default=None, null=True) # if set, when recurrence ends
# week_start in ical format
week_start = models.IntegerField(choices=WEEKDAY_CHOICES, default=SUNDAY) # for weekly events
by_day = JSONField(
default=None, null=True
) # [] BYDAY - (MO, TU); (1MO, -2TU) - for monthly and weekly freq - ical format
by_month = JSONField(default=None, null=True) # [] BYMONTH - what months (1, 2, 3, ...) - ical format
by_monthday = JSONField(default=None, null=True) # [] BYMONTHDAY - what days of month (1, 2, -3) - ical format
updated_shift = models.OneToOneField(
"schedules.CustomOnCallShift",
on_delete=models.SET_NULL,
default=None,
null=True,
related_name="parent_shift",
)
class Meta:
unique_together = ("name", "organization")
def delete(self, *args, **kwargs):
schedules_to_update = list(self.schedules.all())
if self.schedule:
schedules_to_update.append(self.schedule)
force = kwargs.pop("force", False)
# do soft delete for started shifts that were created for web schedule
if self.schedule and self.event_is_started and not force:
updated_until = timezone.now().replace(microsecond=0)
if self.until is not None and updated_until >= self.until:
# event is already finished
return
self.until = updated_until
update_fields = ["until"]
if self.type == self.TYPE_OVERRIDE:
# since it is a single-time event, update override duration
delta = self.until - self.start
if delta < self.duration:
self.duration = delta
update_fields += ["duration"]
self.save(update_fields=update_fields)
else:
super().delete(*args, **kwargs)
for schedule in schedules_to_update:
self.start_drop_ical_and_check_schedule_tasks(schedule)
@property
def repr_settings_for_client_side_logging(self) -> str:
"""
Example of execution:
name: Demo recurrent event, team: example, source: terraform, type: Recurrent event, users: Alex,
start: 2020-09-10T16:00:00+00:00, duration: 3:00:00, priority level: 0, frequency: Weekly, interval: 2,
week start: 6, by day: ['MO', 'WE', 'FR'], by month: None, by monthday: None
"""
if self.type == CustomOnCallShift.TYPE_ROLLING_USERS_EVENT:
users_verbal = "empty"
if self.rolling_users is not None:
users_verbal = ""
for users_dict in self.rolling_users:
users = self.organization.users.filter(public_primary_key__in=users_dict.values())
users_verbal += f"[{', '.join([user.username for user in users])}]"
users_verbal = f"rolling users: {users_verbal}"
else:
users = self.users.all()
users_verbal = f"{', '.join([user.username for user in users]) if users else 'empty'}"
result = (
f"name: {self.name}, team: {self.team.name if self.team else 'No team'},"
f"{f' time_zone: {self.time_zone},' if self.time_zone else ''} "
f"source: {self.get_source_display()}, type: {self.get_type_display()}, users: {users_verbal}, "
f"start: {self.start.isoformat()}, duration: {self.duration}, priority level: {self.priority_level}"
)
if self.type not in (CustomOnCallShift.TYPE_SINGLE_EVENT, CustomOnCallShift.TYPE_OVERRIDE):
result += (
f", frequency: {self.get_frequency_display()}, interval: {self.interval}, "
f"week start: {self.week_start}, by day: {self.by_day}, by month: {self.by_month}, "
f"by monthday: {self.by_monthday}, rotation start: {self.rotation_start.isoformat()}, "
f"until: {self.until.isoformat() if self.until else None}"
)
return result
@property
def event_is_started(self):
return bool(self.rotation_start <= timezone.now())
@property
def event_is_finished(self):
if self.frequency is not None:
is_finished = bool(self.until <= timezone.now()) if self.until else False
else:
is_finished = bool(self.start + self.duration <= timezone.now())
return is_finished
def _daily_by_day_to_ical(self, time_zone, start, users_queue):
"""Create ical weekly shifts to distribute user groups combining daily + by_day.
e.g.
by_day: [WED, FRI]
users_queue: [user_group_1, user_group_2, user_group_3]
will result in the following ical shift rules:
user_group_1, weekly WED interval 3
user_group_2, weekly FRI interval 3
user_group_3, weekly WED interval 3
user_group_1, weekly FRI interval 3
user_group_2, weekly WED interval 3
user_group_3, weekly FRI interval 3
"""
result = ""
# keep tracking of (users, day) combinations, and starting dates for each
combinations = []
starting_dates = []
# we may need to iterate several times over users until we get a seen combination
# use the group index as reference since user groups could repeat in the queue
cycle_user_groups = itertools.cycle(range(len(users_queue)))
orig_start = last_start = start
all_rotations_checked = False
# we need to go through each individual day
day_by_day_rrule = copy.deepcopy(self.event_ical_rules)
day_by_day_rrule["interval"] = 1
for user_group_id in cycle_user_groups:
for i in range(self.interval):
if not start: # means that rotation ends before next event starts
all_rotations_checked = True
break
last_start = start
day = CustomOnCallShift.ICAL_WEEKDAY_MAP[start.weekday()]
if (user_group_id, day, i) in combinations:
all_rotations_checked = True
break
starting_dates.append(start)
combinations.append((user_group_id, day, i))
# get next event date following the original rule
event_ical = self.generate_ical(start, 1, None, 1, time_zone, custom_rrule=day_by_day_rrule)
start = self.get_rotation_date(event_ical, get_next_date=True, interval=1)
if all_rotations_checked:
break
week_interval = 1
if orig_start and last_start:
# number of weeks used to cover all combinations
week_interval = ((last_start - orig_start).days // 7) or 1
counter = 1
for ((user_group_id, day, _), start) in zip(combinations, starting_dates):
users = users_queue[user_group_id]
for user_counter, user in enumerate(users, start=1):
# setup weekly events, for each user group/day combinations,
# setting the right interval and the corresponding day
custom_rrule = copy.deepcopy(self.event_ical_rules)
custom_rrule["freq"] = ["WEEKLY"]
custom_rrule["interval"] = [week_interval]
custom_rrule["byday"] = [day]
custom_event_ical = self.generate_ical(
start, user_counter, user, counter, time_zone, custom_rrule=custom_rrule
)
result += custom_event_ical
counter += 1
return result
def convert_to_ical(self, time_zone="UTC", allow_empty_users=False):
result = ""
# 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 in (CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, CustomOnCallShift.TYPE_OVERRIDE):
# generate initial iCal for counting rotation start date
event_ical = self.generate_ical(self.start)
rotations_created = 0
all_rotation_checked = False
users_queue = self.get_rolling_users()
if not users_queue and not allow_empty_users:
return result
if not users_queue and allow_empty_users:
users_queue = [[None]]
if self.frequency is None:
users_queue = users_queue[:1]
# Get the date of the current rotation
if self.start == self.rotation_start or self.frequency is None:
start = self.start
else:
start = self.get_rotation_date(event_ical)
# Make sure we respect the selected days if any when defining start date
if self.frequency is not None and self.by_day and start is not None:
start_day = CustomOnCallShift.ICAL_WEEKDAY_MAP[start.weekday()]
if start_day not in self.by_day:
expected_start_day = min(CustomOnCallShift.ICAL_WEEKDAY_REVERSE_MAP[d] for d in self.by_day)
delta = (expected_start_day - start.weekday()) % 7
start = start + timezone.timedelta(days=delta)
if self.frequency == CustomOnCallShift.FREQUENCY_DAILY and self.by_day:
result = self._daily_by_day_to_ical(time_zone, start, users_queue)
all_rotation_checked = True
while not all_rotation_checked:
for counter, users in enumerate(users_queue, start=1):
if not start: # means that rotation ends before next event starts
all_rotation_checked = True
break
elif (
self.source == CustomOnCallShift.SOURCE_WEB and start + self.duration > self.rotation_start
) or start >= self.rotation_start:
# event has already started, generate iCal for each user
for user_counter, user in enumerate(users, start=1):
event_ical = self.generate_ical(start, user_counter, user, counter, time_zone)
result += event_ical
rotations_created += 1
else: # generate default iCal to calculate the date for the next rotation
event_ical = self.generate_ical(start)
if rotations_created == len(users_queue): # means that we generated iCal for every user group
all_rotation_checked = True
break
# Use the flag 'get_next_date' to get the date of the next rotation
start = self.get_rotation_date(event_ical, get_next_date=True)
else:
for user_counter, user in enumerate(self.users.all(), start=1):
result += self.generate_ical(self.start, user_counter, user, time_zone=time_zone)
return result
def generate_ical(self, start, user_counter=0, user=None, counter=1, time_zone="UTC", custom_rrule=None):
event = Event()
event["uid"] = f"oncall-{self.uuid}-PK{self.public_primary_key}-U{user_counter}-E{counter}-S{self.source}"
if user:
event.add("summary", self.get_summary_with_user_for_ical(user))
event.add("dtstart", self.convert_dt_to_schedule_timezone(start, time_zone))
dtend = start + self.duration
if self.until:
dtend = min(dtend, self.until)
event.add("dtend", self.convert_dt_to_schedule_timezone(dtend, time_zone))
event.add("dtstamp", self.rotation_start)
if custom_rrule:
event.add("rrule", custom_rrule)
elif self.event_ical_rules:
event.add("rrule", self.event_ical_rules)
try:
event_in_ical = event.to_ical().decode("utf-8")
except ValueError as e:
logger.warning(f"Cannot convert event with pk {self.pk} to ical: {str(e)}")
event_in_ical = ""
return event_in_ical
def get_summary_with_user_for_ical(self, user: User) -> str:
summary = ""
if self.priority_level > 0:
summary += f"[L{self.priority_level}] "
summary += f"{user.username} "
return summary
def get_rotation_date(self, event_ical, get_next_date=False, interval=None):
"""Get date of the next event (for rolling_users shifts)"""
ONE_DAY = 1
ONE_HOUR = 1
def add_months(year, month, months_add):
"""
Utility method for month calculation. E.g. (2022, 12) + 1 month = (2023, 1)
"""
dt = timezone.datetime.min.replace(year=year, month=month) + relativedelta.relativedelta(months=months_add)
return dt.year, dt.month
current_event = Event.from_ical(event_ical)
# take shift interval, not event interval. For rolling_users shift it is not the same.
if interval is None:
interval = self.interval or 1
if "rrule" in current_event:
# when triggering shift previews, there could be no rrule information yet
# (e.g. initial empty weekly rotation has no rrule set)
current_event["rrule"]["INTERVAL"] = interval
current_event_start = current_event["DTSTART"].dt
next_event_start = current_event_start
# Calculate the minimum start date for the next event based on rotation frequency. We don't need to do this
# for the first rotation, because in this case the min start date will be the same as the current event date.
if get_next_date:
if self.frequency == CustomOnCallShift.FREQUENCY_HOURLY:
next_event_start = current_event_start + timezone.timedelta(hours=ONE_HOUR)
elif self.frequency == CustomOnCallShift.FREQUENCY_DAILY:
next_event_start = current_event_start + timezone.timedelta(days=ONE_DAY)
elif self.frequency == CustomOnCallShift.FREQUENCY_WEEKLY:
DAYS_IN_A_WEEK = 7
# count days before the next week starts
days_for_next_event = DAYS_IN_A_WEEK - current_event_start.weekday() + self.week_start
if days_for_next_event > DAYS_IN_A_WEEK:
days_for_next_event = days_for_next_event % DAYS_IN_A_WEEK
# count next event start date with respect to event interval
next_event_start = current_event_start + timezone.timedelta(
days=days_for_next_event + DAYS_IN_A_WEEK * (interval - 1)
)
elif self.frequency == CustomOnCallShift.FREQUENCY_MONTHLY:
DAYS_IN_A_MONTH = monthrange(current_event_start.year, current_event_start.month)[1]
# count days before the next month starts
days_for_next_event = DAYS_IN_A_MONTH - current_event_start.day + ONE_DAY
# count next event start date with respect to event interval
for i in range(1, interval):
year, month = add_months(current_event_start.year, current_event_start.month, i)
next_month_days = monthrange(year, month)[1]
days_for_next_event += next_month_days
next_event_start = current_event_start + timezone.timedelta(days=days_for_next_event)
end_date = None
# get the period for calculating the current rotation end date for long events with frequency weekly and monthly
if self.frequency == CustomOnCallShift.FREQUENCY_WEEKLY:
DAYS_IN_A_WEEK = 7
days_diff = 0
# get the last day of the week with respect to the week_start
if next_event_start.weekday() != self.week_start:
days_diff = DAYS_IN_A_WEEK + next_event_start.weekday() - self.week_start
days_diff %= DAYS_IN_A_WEEK
end_date = next_event_start + timezone.timedelta(days=DAYS_IN_A_WEEK - days_diff - ONE_DAY)
elif self.frequency == CustomOnCallShift.FREQUENCY_MONTHLY:
# get the last day of the month
current_day_number = next_event_start.day
number_of_days = monthrange(next_event_start.year, next_event_start.month)[1]
days_diff = number_of_days - current_day_number
end_date = next_event_start + timezone.timedelta(days=days_diff)
next_event = None
# repetitions generate the next event shift according with the recurrence rules
repetitions = UnfoldableCalendar(current_event).RepeatedEvent(
current_event, next_event_start.replace(microsecond=0)
)
for event in repetitions.__iter__():
if end_date: # end_date exists for long events with frequency weekly and monthly
if end_date >= event.start >= next_event_start:
if (
self.source == CustomOnCallShift.SOURCE_WEB and event.stop > self.rotation_start
) or event.start >= self.rotation_start:
next_event = event
break
elif end_date < event.start:
break
else:
if event.start >= next_event_start:
next_event = event
break
next_event_dt = next_event.start if next_event is not None else next_event_start
if self.until and next_event_dt > self.until:
return
return next_event_dt
def get_last_event_date(self, date):
"""Get start date of the last event before the chosen date"""
assert date >= self.start, "Chosen date should be later or equal to initial event start date"
event_ical = self.generate_ical(self.start)
initial_event = Event.from_ical(event_ical)
# take shift interval, not event interval. For rolling_users shift it is not the same.
interval = self.interval or 1
if "rrule" in initial_event:
# means that shift has frequency
initial_event["rrule"]["INTERVAL"] = interval
initial_event_start = initial_event["DTSTART"].dt
last_event = None
# repetitions generate the next event shift according with the recurrence rules
repetitions = UnfoldableCalendar(initial_event).RepeatedEvent(
initial_event, initial_event_start.replace(microsecond=0)
)
for event in repetitions.__iter__():
if event.start > date:
break
last_event = event
last_event_dt = last_event.start if last_event else initial_event_start
return last_event_dt
@cached_property
def event_ical_rules(self):
# e.g. {'freq': ['WEEKLY'], 'interval': [2], 'byday': ['MO', 'WE', 'FR'], 'wkst': ['SU']}
rules = {}
if self.frequency is not None:
rules["freq"] = [self.get_frequency_display().upper()]
if self.event_interval is not None:
rules["interval"] = [self.event_interval]
if self.by_day:
rules["byday"] = self.by_day
if self.by_month is not None:
rules["bymonth"] = self.by_month
if self.by_monthday is not None:
rules["bymonthday"] = self.by_monthday
if self.week_start is not None:
rules["wkst"] = CustomOnCallShift.ICAL_WEEKDAY_MAP[self.week_start]
if self.until is not None:
# RRULE UNTIL values must be specified in UTC when DTSTART is timezone-aware
rules["until"] = self.convert_dt_to_schedule_timezone(self.until, "UTC")
return rules
@cached_property
def event_interval(self):
if self.type == CustomOnCallShift.TYPE_ROLLING_USERS_EVENT:
if self.rolling_users:
if self.interval is not None:
return self.interval * len(self.rolling_users)
else:
return len(self.rolling_users)
return self.interval
def convert_dt_to_schedule_timezone(self, dt, time_zone):
start_naive = dt.replace(tzinfo=None)
if time_zone and time_zone.lower() == "etc/utc":
# dateutil rrule breaks if Etc/UTC is given
time_zone = "UTC"
return pytz.timezone(time_zone).localize(start_naive, is_dst=None)
def get_rolling_users(self):
User = apps.get_model("user_management", "User")
all_users_pks = set()
users_queue = []
if self.rolling_users is not None:
# get all users pks from rolling_users field
for users_dict in self.rolling_users:
all_users_pks.update(users_dict.keys())
users = User.objects.filter(pk__in=all_users_pks)
# generate users_queue list with user objects
if self.start_rotation_from_user_index is not None:
rolling_users = (
self.rolling_users[self.start_rotation_from_user_index :]
+ self.rolling_users[: self.start_rotation_from_user_index]
)
else:
rolling_users = self.rolling_users
for users_dict in rolling_users:
users_list = list(users.filter(pk__in=users_dict.keys()))
if users_list:
users_queue.append(users_list)
return users_queue
def add_rolling_users(self, rolling_users_list):
result = []
for users in rolling_users_list:
result.append({user.pk: user.public_primary_key for user in users})
self.rolling_users = result
self.save(update_fields=["rolling_users"])
def get_rotation_user_index(self, date):
START_ROTATION_INDEX = 0
result = START_ROTATION_INDEX
if not self.rolling_users or self.frequency is None:
return START_ROTATION_INDEX
# generate initial iCal for counting rotation start date
event_ical = self.generate_ical(self.start, user_counter=0)
# Get the date of the current rotation
if self.start == self.rotation_start:
start = self.start
else:
start = self.get_rotation_date(event_ical)
if not start or start >= date:
return START_ROTATION_INDEX
# count how many times the rotation was triggered before the selected date
while start or start < date:
start = self.get_rotation_date(event_ical, get_next_date=True)
if not start or start >= date:
break
event_ical = self.generate_ical(start, user_counter=0)
result += 1
result %= len(self.rolling_users)
return result
def start_drop_ical_and_check_schedule_tasks(self, schedule):
drop_cached_ical_task.apply_async((schedule.pk,))
schedule_notify_about_empty_shifts_in_schedule.apply_async((schedule.pk,))
schedule_notify_about_gaps_in_schedule.apply_async((schedule.pk,))
@cached_property
def last_updated_shift(self):
last_shift = self.updated_shift
if last_shift is not None:
while last_shift.updated_shift is not None:
last_shift = last_shift.updated_shift
return last_shift
def create_or_update_last_shift(self, data):
now = timezone.now().replace(microsecond=0)
# rotation start date cannot be earlier than now
data["rotation_start"] = max(data["rotation_start"], now)
# prepare dict with params of existing instance with last updates and remove unique and m2m fields from it
shift_to_update = self.last_updated_shift or self
instance_data = model_to_dict(shift_to_update)
fields_to_remove = ["id", "public_primary_key", "uuid", "users", "updated_shift", "name"]
for field in fields_to_remove:
instance_data.pop(field)
instance_data.update(data)
instance_data["schedule"] = self.schedule
instance_data["team"] = self.team
# set new event start date to keep rotation index
if instance_data["start"] == self.start:
instance_data["start"] = self.get_last_event_date(now)
# calculate rotation index to keep user rotation order
start_rotation_from_user_index = self.get_rotation_user_index(now) + (self.start_rotation_from_user_index or 0)
if start_rotation_from_user_index >= len(instance_data["rolling_users"]):
start_rotation_from_user_index = 0
instance_data["start_rotation_from_user_index"] = start_rotation_from_user_index
if self.last_updated_shift is None or self.last_updated_shift.event_is_started:
# create new shift
instance_data["name"] = CustomOnCallShift.generate_name(
self.schedule, instance_data["priority_level"], instance_data["type"]
)
with transaction.atomic():
shift = CustomOnCallShift(**instance_data)
shift.save()
shift_to_update.until = data["rotation_start"]
shift_to_update.updated_shift = shift
shift_to_update.save(update_fields=["until", "updated_shift"])
else:
shift = self.last_updated_shift
for key in instance_data:
setattr(shift, key, instance_data[key])
shift.save(update_fields=list(instance_data))
return shift
@staticmethod
def generate_name(schedule, priority_level, shift_type):
shift_type_name = "override" if shift_type == CustomOnCallShift.TYPE_OVERRIDE else "rotation"
name = f"{schedule.name}-{shift_type_name}-{priority_level}-"
name += "".join(random.choice(string.ascii_lowercase) for _ in range(5))
return name
# Insight logs
@property
def insight_logs_type_verbal(self):
return "oncall_shift"
@property
def insight_logs_verbal(self):
return self.name
@property
def insight_logs_serialized(self):
users_verbal = []
if self.type == CustomOnCallShift.TYPE_ROLLING_USERS_EVENT:
if self.rolling_users is not None:
for users_dict in self.rolling_users:
users = self.organization.users.filter(public_primary_key__in=users_dict.values())
users_verbal.extend([user.username for user in users])
else:
users = self.users.all()
users_verbal = [user.username for user in users]
result = {
"name": self.name,
"source": self.get_source_display(),
"type": self.get_type_display(),
"users": users_verbal,
"start": self.start.isoformat(),
"duration": self.duration.seconds,
"priority_level": self.priority_level,
}
if self.type not in (CustomOnCallShift.TYPE_SINGLE_EVENT, CustomOnCallShift.TYPE_OVERRIDE):
result["frequency"] = self.get_frequency_display()
result["interval"] = self.interval
result["week_start"] = self.week_start
result["by_day"] = self.by_day
result["by_month"] = self.by_month
result["by_monthday"] = self.by_monthday
result["rotation_start"] = self.rotation_start.isoformat()
if self.until:
result["until"] = self.until.isoformat()
if self.team:
result["team"] = self.team.name
result["team_id"] = self.team.public_primary_key
else:
result["team"] = "General"
if self.time_zone:
result["time_zone"] = self.time_zone
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"
if self.schedule:
result["schedule"] = self.schedule.insight_logs_verbal
result["schedule_id"] = self.schedule.public_primary_key
return result