# What this PR does Remove [`apps.get_model`](https://docs.djangoproject.com/en/3.2/ref/applications/#django.apps.apps.get_model) invocations and use inline `import` statements in places where models are imported within functions/methods to avoid circular imports. I believe `import` statements are more appropriate for most use cases as they allow for better static code analysis & formatting, and solve the issue of circular imports without being unnecessarily dynamic as `apps.get_model`. With `import` statements, it's possible to: - Jump to model definitions in most IDEs - Automatically sort inline imports with `isort` - Find import errors faster/easier (most IDEs highlight broken imports) - Have more consistency across regular & inline imports when importing models This PR also adds a flake8 rule to ban imports of `django.apps.apps`, so it's harder to use `apps.get_model` by mistake (it's possible to ignore this rule by using `# noqa: I251`). The rule is not enforced on directories with migration files, because `apps.get_model` is often used to get a historical state of a model, which is useful when writing migrations ([see this SO answer for more details](https://stackoverflow.com/a/37769213)). So `apps.get_model` is considered OK in migrations (even necessary in some cases). ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required)
342 lines
16 KiB
Python
342 lines
16 KiB
Python
import datetime
|
|
import json
|
|
from copy import copy
|
|
|
|
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 = datetime.datetime.now(timezone.utc)
|
|
events_from_ical_for_three_days = ical_events.get_events_from_ical_between(
|
|
calendar, now - datetime.timedelta(days=1), now + datetime.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 = datetime.datetime.now(timezone.utc)
|
|
next_events_from_ical = ical_events.get_events_from_ical_between(
|
|
calendar, now - datetime.timedelta(days=1), now + datetime.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}")
|
|
from apps.schedules.models import 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
|
|
elif schedule.organization.deleted_at:
|
|
task_logger.info(f"Trying to notify ical schedule shift from deleted organization {schedule_pk}")
|
|
return
|
|
|
|
MIN_DAYS_TO_LOOKUP_FOR_THE_END_OF_EVENT = 3
|
|
|
|
now = datetime.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
|