Before this change, a diff ical check (which happens with frequency with imported ical), particularly with overrides in an API/terraform schedule would trigger unexpected slack notifications because the prev vs current ical comparison will flag a diff, but when comparing current and previous shifts, `current_shifts` will have the shift in progress while the `prev_shifts` calculated from the overrides-only diff will most of the time be empty (unless you set/change an override at current time). Simplified the checks to always compare previous current shifts (ie. the ones in the schedule from the DB) vs the recalculated ones using the (refreshed) ical data from the schedule.
340 lines
15 KiB
Python
340 lines
15 KiB
Python
import datetime
|
|
import json
|
|
from copy import copy
|
|
|
|
from django.apps import apps
|
|
from django.utils import timezone
|
|
|
|
from apps.schedules.ical_events import ical_events
|
|
from apps.schedules.ical_utils import (
|
|
calculate_shift_diff,
|
|
event_start_end_all_day_with_respect_to_type,
|
|
get_icalendar_tz_or_utc,
|
|
get_usernames_from_ical_event,
|
|
memoized_users_in_ical,
|
|
)
|
|
from apps.slack.scenarios import scenario_step
|
|
from apps.slack.slack_client import SlackClientWithErrorHandling
|
|
from apps.slack.slack_client.exceptions import SlackAPIException, SlackAPITokenException
|
|
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
|
|
|
|
from .task_logger import task_logger
|
|
|
|
|
|
def get_current_shifts_from_ical(calendar, schedule, min_priority=0):
|
|
calendar_tz = get_icalendar_tz_or_utc(calendar)
|
|
now = timezone.datetime.now(timezone.utc)
|
|
events_from_ical_for_three_days = ical_events.get_events_from_ical_between(
|
|
calendar, now - timezone.timedelta(days=1), now + timezone.timedelta(days=1)
|
|
)
|
|
shifts = {}
|
|
current_users = {}
|
|
for event in events_from_ical_for_three_days:
|
|
usernames, priority = get_usernames_from_ical_event(event)
|
|
users = memoized_users_in_ical(tuple(usernames), schedule.organization)
|
|
if len(users) > 0:
|
|
event_start, event_end, all_day_event = event_start_end_all_day_with_respect_to_type(event, calendar_tz)
|
|
|
|
if event["UID"] in shifts:
|
|
existing_event = shifts[event["UID"]]
|
|
if existing_event["start"] < now < existing_event["end"]:
|
|
continue
|
|
shifts[event["UID"]] = {
|
|
"users": [u.pk for u in users],
|
|
"start": event_start,
|
|
"end": event_end,
|
|
"all_day": all_day_event,
|
|
"priority": priority + min_priority, # increase priority for overrides
|
|
"priority_increased_by": min_priority,
|
|
}
|
|
current_users[event["UID"]] = users
|
|
|
|
return shifts, current_users
|
|
|
|
|
|
def get_next_shifts_from_ical(calendar, schedule, min_priority=0, days_to_lookup=3):
|
|
calendar_tz = get_icalendar_tz_or_utc(calendar)
|
|
now = timezone.datetime.now(timezone.utc)
|
|
next_events_from_ical = ical_events.get_events_from_ical_between(
|
|
calendar, now - timezone.timedelta(days=1), now + timezone.timedelta(days=days_to_lookup)
|
|
)
|
|
shifts = {}
|
|
for event in next_events_from_ical:
|
|
usernames, priority = get_usernames_from_ical_event(event)
|
|
users = memoized_users_in_ical(tuple(usernames), schedule.organization)
|
|
if len(users) > 0:
|
|
event_start, event_end, all_day_event = event_start_end_all_day_with_respect_to_type(event, calendar_tz)
|
|
|
|
# next_shifts are not stored in db so we can use User objects directly
|
|
shifts[f"{event_start.timestamp()}_{event['UID']}"] = {
|
|
"users": users,
|
|
"start": event_start,
|
|
"end": event_end,
|
|
"all_day": all_day_event,
|
|
"priority": priority + min_priority, # increase priority for overrides
|
|
"priority_increased_by": min_priority,
|
|
}
|
|
|
|
return shifts
|
|
|
|
|
|
def recalculate_shifts_with_respect_to_priority(shifts, users=None):
|
|
flag = True
|
|
while flag:
|
|
splitted_shifts = {}
|
|
uids_to_pop = set()
|
|
splitted = False
|
|
flag = False
|
|
for outer_k, outer_shift in shifts.items():
|
|
if not splitted:
|
|
for inner_k, inner_shift in shifts.items():
|
|
if outer_k == inner_k:
|
|
continue
|
|
else:
|
|
if outer_shift.get("priority", 0) > inner_shift.get("priority", 0):
|
|
if outer_shift["start"] > inner_shift["start"] and outer_shift["end"] < inner_shift["end"]:
|
|
new_uid_r = f"{inner_k}-split-r"
|
|
new_uid_l = f"{inner_k}-split-l"
|
|
splitted_shift_left = copy(inner_shift)
|
|
splitted_shift_right = copy(inner_shift)
|
|
splitted_shift_left["end"] = outer_shift["start"]
|
|
splitted_shift_right["start"] = outer_shift["end"]
|
|
splitted_shift_left["all_day"] = False
|
|
splitted_shift_right["all_day"] = False
|
|
splitted_shifts[new_uid_l] = splitted_shift_left
|
|
splitted_shifts[new_uid_r] = splitted_shift_right
|
|
uids_to_pop.add(inner_k)
|
|
if users is not None:
|
|
users[new_uid_l] = users[inner_k]
|
|
users[new_uid_r] = users[inner_k]
|
|
|
|
splitted = True
|
|
flag = True
|
|
break
|
|
elif outer_shift["start"] <= inner_shift["start"] < outer_shift["end"] < inner_shift["end"]:
|
|
inner_shift["start"] = outer_shift["end"]
|
|
flag = True
|
|
elif outer_shift["end"] >= inner_shift["end"] > outer_shift["start"] > inner_shift["start"]:
|
|
inner_shift["end"] = outer_shift["start"]
|
|
flag = True
|
|
elif (
|
|
outer_shift["start"] <= inner_shift["start"]
|
|
and outer_shift["end"] >= inner_shift["end"]
|
|
):
|
|
uids_to_pop.add(inner_k)
|
|
flag = True
|
|
else:
|
|
flag = False
|
|
elif outer_shift.get("priority", 0) < inner_shift.get("priority", 0):
|
|
if inner_shift["start"] > outer_shift["start"] and inner_shift["end"] < outer_shift["end"]:
|
|
new_uid_r = f"{outer_k}-split-r"
|
|
new_uid_l = f"{outer_k}-split-l"
|
|
splitted_shift_left = copy(outer_shift)
|
|
splitted_shift_right = copy(outer_shift)
|
|
splitted_shift_left["all_day"] = False
|
|
splitted_shift_right["all_day"] = False
|
|
splitted_shift_left["end"] = inner_shift["start"]
|
|
splitted_shift_right["start"] = inner_shift["end"]
|
|
splitted_shifts[new_uid_l] = splitted_shift_left
|
|
splitted_shifts[new_uid_r] = splitted_shift_right
|
|
uids_to_pop.add(outer_k)
|
|
|
|
if users is not None:
|
|
users[new_uid_l] = users[outer_k]
|
|
users[new_uid_r] = users[outer_k]
|
|
|
|
splitted = True
|
|
flag = True
|
|
break
|
|
elif inner_shift["start"] <= outer_shift["start"] < inner_shift["end"] < outer_shift["end"]:
|
|
outer_shift["start"] = inner_shift["end"]
|
|
flag = True
|
|
elif inner_shift["end"] >= outer_shift["end"] > inner_shift["start"] > outer_shift["start"]:
|
|
outer_shift["end"] = inner_shift["start"]
|
|
flag = True
|
|
elif (
|
|
inner_shift["start"] <= outer_shift["start"]
|
|
and inner_shift["end"] >= outer_shift["end"]
|
|
):
|
|
uids_to_pop.add(outer_k)
|
|
flag = True
|
|
else:
|
|
flag = False
|
|
else:
|
|
flag = False
|
|
else:
|
|
break
|
|
|
|
shifts.update(splitted_shifts)
|
|
for uid in uids_to_pop:
|
|
shifts.pop(uid)
|
|
|
|
|
|
@shared_dedicated_queue_retry_task()
|
|
def notify_ical_schedule_shift(schedule_pk):
|
|
task_logger.info(f"Notify ical schedule shift {schedule_pk}")
|
|
OnCallSchedule = apps.get_model("schedules", "OnCallSchedule")
|
|
|
|
try:
|
|
schedule = OnCallSchedule.objects.get(
|
|
pk=schedule_pk, cached_ical_file_primary__isnull=False, channel__isnull=False
|
|
)
|
|
except OnCallSchedule.DoesNotExist:
|
|
task_logger.info(f"Trying to notify ical schedule shift for non-existing schedule {schedule_pk}")
|
|
return
|
|
|
|
if schedule.organization.slack_team_identity is None:
|
|
task_logger.info(f"Trying to notify ical schedule shift with no slack team identity {schedule_pk}")
|
|
return
|
|
|
|
MIN_DAYS_TO_LOOKUP_FOR_THE_END_OF_EVENT = 3
|
|
|
|
now = timezone.datetime.now(timezone.utc)
|
|
# get list of iCalendars from current iCal files. If there is more than one calendar, primary calendar will always
|
|
# be the first
|
|
current_calendars = schedule.get_icalendars()
|
|
|
|
current_shifts = {}
|
|
# expected current_shifts structure:
|
|
# {
|
|
# some uid: {
|
|
# "users": [users pks],
|
|
# "start": event start date,
|
|
# "end": event end date,
|
|
# "all_day": bool if event has all-day type,
|
|
# "priority": priority level,
|
|
# "priority_increased_by": min priority level of primary calendar, (for primary calendar event it is 0)
|
|
# },
|
|
# }
|
|
|
|
# Current_user dict exists because it's bad idea to serialize User objects.
|
|
# Instead users' pks are stored in db for calculation related to shift diff.
|
|
# When it is needed to pass shift's user (e.g. in def get_report_blocks_ical())
|
|
# we take users from current_users{} by shift uuid and replace users' pk
|
|
current_users = {}
|
|
|
|
overrides_priority = 0
|
|
for calendar in current_calendars:
|
|
if calendar is not None:
|
|
current_shifts_result, current_users_result = get_current_shifts_from_ical(
|
|
calendar,
|
|
schedule,
|
|
overrides_priority,
|
|
)
|
|
if overrides_priority == 0 and current_shifts_result:
|
|
overrides_priority = max([current_shifts_result[uid]["priority"] for uid in current_shifts_result]) + 1
|
|
current_shifts.update(current_shifts_result)
|
|
current_users.update(current_users_result)
|
|
|
|
recalculate_shifts_with_respect_to_priority(current_shifts, current_users)
|
|
|
|
# drop events that don't intersection with current time
|
|
drop = []
|
|
for uid, current_shift in current_shifts.items():
|
|
if not current_shift["start"] < now < current_shift["end"]:
|
|
drop.append(uid)
|
|
for item in drop:
|
|
current_shifts.pop(item)
|
|
|
|
# compare events from prev and current shifts
|
|
prev_shifts = json.loads(schedule.current_shifts) if not schedule.empty_oncall else {}
|
|
# convert datetimes which was dumped to str back to datetime to calculate shift diff correct
|
|
str_format = "%Y-%m-%d %X%z"
|
|
for prev_shift in prev_shifts.values():
|
|
prev_shift["start"] = datetime.datetime.strptime(prev_shift["start"], str_format)
|
|
prev_shift["end"] = datetime.datetime.strptime(prev_shift["end"], str_format)
|
|
|
|
shift_changed, diff_uids = calculate_shift_diff(current_shifts, prev_shifts)
|
|
|
|
if shift_changed:
|
|
task_logger.info(f"shifts_changed: {diff_uids}")
|
|
# Get only new/changed shifts to send a reminder message.
|
|
new_shifts = []
|
|
for uid in diff_uids:
|
|
# using copy to not to mutate original current_shifts dict which will be stored in db as current_shifts
|
|
new_shift = copy(current_shifts[uid])
|
|
# replace users' pk by objects to make reminder message from new shifts
|
|
new_shift["users"] = current_users[uid]
|
|
new_shifts.append(new_shift)
|
|
new_shifts = sorted(new_shifts, key=lambda shift: shift["start"])
|
|
|
|
if len(new_shifts) != 0:
|
|
days_to_lookup = (new_shifts[-1]["end"].date() - now.date()).days + 1
|
|
days_to_lookup = max([days_to_lookup, MIN_DAYS_TO_LOOKUP_FOR_THE_END_OF_EVENT])
|
|
else:
|
|
days_to_lookup = MIN_DAYS_TO_LOOKUP_FOR_THE_END_OF_EVENT
|
|
|
|
next_shifts = {}
|
|
next_overrides_priority = 0
|
|
|
|
for calendar in current_calendars:
|
|
if calendar is not None:
|
|
next_shifts_result = get_next_shifts_from_ical(
|
|
calendar,
|
|
schedule,
|
|
next_overrides_priority,
|
|
days_to_lookup=days_to_lookup,
|
|
)
|
|
if next_overrides_priority == 0 and next_shifts_result:
|
|
next_overrides_priority = (
|
|
max([next_shifts_result[uid]["priority"] for uid in next_shifts_result]) + 1
|
|
)
|
|
|
|
next_shifts.update(next_shifts_result)
|
|
|
|
recalculate_shifts_with_respect_to_priority(next_shifts)
|
|
|
|
# drop events that already started
|
|
drop = []
|
|
for uid, next_shift in next_shifts.items():
|
|
if now > next_shift["start"]:
|
|
drop.append(uid)
|
|
for item in drop:
|
|
next_shifts.pop(item)
|
|
|
|
next_shifts_from_ical = sorted(next_shifts.values(), key=lambda shift: shift["start"])
|
|
|
|
upcoming_shifts = []
|
|
# Add the earliest next_shift
|
|
if len(next_shifts_from_ical) > 0:
|
|
earliest_shift = next_shifts_from_ical[0]
|
|
upcoming_shifts.append(earliest_shift)
|
|
# Check if there are next shifts with the same start as the earliest
|
|
for shift in next_shifts_from_ical[1:]:
|
|
if shift["start"] == earliest_shift["start"]:
|
|
upcoming_shifts.append(shift)
|
|
|
|
empty_oncall = len(current_shifts) == 0
|
|
if empty_oncall:
|
|
schedule.empty_oncall = True
|
|
else:
|
|
schedule.empty_oncall = False
|
|
schedule.current_shifts = json.dumps(current_shifts, default=str)
|
|
|
|
schedule.save(update_fields=["current_shifts", "empty_oncall"])
|
|
|
|
if len(new_shifts) > 0 or empty_oncall:
|
|
task_logger.info(f"new_shifts: {new_shifts}")
|
|
slack_client = SlackClientWithErrorHandling(schedule.organization.slack_team_identity.bot_access_token)
|
|
step = scenario_step.ScenarioStep.get_step("schedules", "EditScheduleShiftNotifyStep")
|
|
report_blocks = step.get_report_blocks_ical(new_shifts, upcoming_shifts, schedule, empty_oncall)
|
|
|
|
if schedule.notify_oncall_shift_freq != OnCallSchedule.NotifyOnCallShiftFreq.NEVER:
|
|
try:
|
|
slack_client.api_call(
|
|
"chat.postMessage",
|
|
channel=schedule.channel,
|
|
blocks=report_blocks,
|
|
text=f"On-call shift for schedule {schedule.name} has changed",
|
|
)
|
|
except SlackAPITokenException:
|
|
pass
|
|
except SlackAPIException as e:
|
|
if e.response["error"] == "channel_not_found":
|
|
print(e)
|
|
elif e.response["error"] == "is_archived":
|
|
print(e)
|
|
elif e.response["error"] == "invalid_auth":
|
|
print(e)
|
|
else:
|
|
raise e
|