Merge remote-tracking branch 'origin/matiasb/preview-schedule-shift' into new-schedules

This commit is contained in:
Maxim 2022-08-15 16:58:27 +03:00
commit daaf109794
13 changed files with 968 additions and 178 deletions

View file

@ -1,5 +1,8 @@
# Change Log
## v1.0.15 (2022-08-03)
- Bug fixes
## v1.0.13 (2022-07-27)
- Optimize alert group list view
- Fix a bug related to Twilio setup

View file

@ -79,10 +79,10 @@ lt --port 8080 -s pretty-turkey-83 --print-requests
type: message
callback_id: incident_create
description: Creates a new OnCall incident
- name: Add to postmortem
- name: Add to resolution note
type: message
callback_id: add_postmortem
description: Add this message to postmortem
callback_id: add_resolution_note
description: Add this message to resolution note
slash_commands:
- command: /oncall
url: <ONCALL_ENGINE_PUBLIC_URL>/slack/interactive_api_endpoint/

View file

@ -7,7 +7,7 @@ from rest_framework import status
from rest_framework.response import Response
from rest_framework.test import APIClient
from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb
from apps.schedules.models import CustomOnCallShift, OnCallSchedule, OnCallScheduleWeb
from common.constants.role import Role
@ -1140,3 +1140,174 @@ def test_on_call_shift_days_options_permissions(
response = client.get(url, format="json", **make_user_auth_headers(user, token))
assert response.status_code == expected_status
@pytest.mark.django_db
@pytest.mark.parametrize(
"role,expected_status",
[
(Role.ADMIN, status.HTTP_200_OK),
(Role.EDITOR, status.HTTP_403_FORBIDDEN),
(Role.VIEWER, status.HTTP_403_FORBIDDEN),
],
)
def test_on_call_shift_preview_permissions(
make_organization_and_user_with_plugin_token,
make_schedule,
make_user_auth_headers,
role,
expected_status,
):
organization, user, token = make_organization_and_user_with_plugin_token(role)
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
start_date = timezone.now()
client = APIClient()
shift_start = (start_date + timezone.timedelta(hours=12)).strftime("%Y-%m-%dT%H:%M:%SZ")
shift_end = (start_date + timezone.timedelta(hours=13)).strftime("%Y-%m-%dT%H:%M:%SZ")
shift_data = {
"schedule": schedule.public_primary_key,
"type": CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
"rotation_start": shift_start,
"shift_start": shift_start,
"shift_end": shift_end,
"rolling_users": [[user.public_primary_key]],
"priority_level": 2,
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
}
url = reverse("api-internal:oncall_shifts-preview")
response = client.post(url, shift_data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == expected_status
@pytest.mark.django_db
def test_on_call_shift_preview_missing_data(
make_organization_and_user_with_plugin_token,
make_schedule,
make_user_auth_headers,
):
organization, user, token = make_organization_and_user_with_plugin_token()
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
client = APIClient()
shift_data = {
"schedule": schedule.public_primary_key,
"type": CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
"rolling_users": [[user.public_primary_key]],
"priority_level": 2,
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
}
url = reverse("api-internal:oncall_shifts-preview")
response = client.post(url, shift_data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
def test_on_call_shift_preview(
make_organization_and_user_with_plugin_token,
make_user_for_organization,
make_user_auth_headers,
make_schedule,
make_on_call_shift,
):
organization, user, token = make_organization_and_user_with_plugin_token()
client = APIClient()
schedule = make_schedule(
organization,
schedule_class=OnCallScheduleWeb,
name="test_web_schedule",
)
now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
start_date = now - timezone.timedelta(days=7)
request_date = start_date
user = make_user_for_organization(organization)
other_user = make_user_for_organization(organization)
data = {
"start": start_date + timezone.timedelta(hours=9),
"rotation_start": start_date + timezone.timedelta(hours=9),
"duration": timezone.timedelta(hours=9),
"priority_level": 1,
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
"schedule": schedule,
}
on_call_shift = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
)
on_call_shift.add_rolling_users([[user]])
url = "{}?date={}&days={}".format(
reverse("api-internal:oncall_shifts-preview"), request_date.strftime("%Y-%m-%d"), 1
)
shift_start = (start_date + timezone.timedelta(hours=12)).strftime("%Y-%m-%dT%H:%M:%SZ")
shift_end = (start_date + timezone.timedelta(hours=13)).strftime("%Y-%m-%dT%H:%M:%SZ")
shift_data = {
"schedule": schedule.public_primary_key,
"type": CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
"rotation_start": shift_start,
"shift_start": shift_start,
"shift_end": shift_end,
"rolling_users": [[other_user.public_primary_key]],
"priority_level": 2,
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
}
response = client.post(url, shift_data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
# check rotation events
rotation_events = response.json()["rotation"]
expected_rotation_events = [
{
"calendar_type": OnCallSchedule.TYPE_ICAL_PRIMARY,
"start": shift_start,
"end": shift_end,
"all_day": False,
"is_override": False,
"is_empty": False,
"is_gap": False,
"priority_level": 2,
"missing_users": [],
"users": [{"display_name": other_user.username, "pk": other_user.public_primary_key}],
"source": "web",
}
]
# there isn't a saved shift, we don't care/know the temp pk
_ = [r.pop("shift") for r in rotation_events]
assert rotation_events == expected_rotation_events
# check final schedule events
final_events = response.json()["final"]
expected = (
# start (h), duration (H), user, priority
(9, 3, user.username, 1), # 9-12 user
(12, 1, other_user.username, 2), # 12-13 other_user
(13, 5, user.username, 1), # 13-18 C
)
expected_events = [
{
"end": (start_date + timezone.timedelta(hours=start + duration)).strftime("%Y-%m-%dT%H:%M:%SZ"),
"priority_level": priority,
"start": (start_date + timezone.timedelta(hours=start, milliseconds=1 if start == 0 else 0)).strftime(
"%Y-%m-%dT%H:%M:%SZ"
),
"user": user,
}
for start, duration, user, priority in expected
]
returned_events = [
{
"end": e["end"],
"priority_level": e["priority_level"],
"start": e["start"],
"user": e["users"][0]["display_name"] if e["users"] else None,
}
for e in final_events
if not e["is_override"] and not e["is_gap"]
]
assert returned_events == expected_events

View file

@ -10,7 +10,6 @@ from apps.api.permissions import IsAdmin
from apps.api.serializers.live_setting import LiveSettingSerializer
from apps.auth_token.auth import PluginAuthentication
from apps.base.models import LiveSetting
from apps.oss_installation.models import CloudConnector
from apps.oss_installation.tasks import sync_users_with_cloud
from apps.slack.tasks import unpopulate_slack_user_identities
from apps.telegram.client import TelegramClient
@ -73,6 +72,8 @@ class LiveSettingViewSet(PublicPrimaryKeyMixin, viewsets.ModelViewSet):
unpopulate_slack_user_identities.delay(organization_pk=organization.pk, force=True)
if instance.name == "GRAFANA_CLOUD_ONCALL_TOKEN":
from apps.oss_installation.models import CloudConnector
CloudConnector.remove_sync()
sync_users = self.request.query_params.get("sync_users", "true") == "true"

View file

@ -1,5 +1,6 @@
from django.db.models import Q
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
@ -12,6 +13,7 @@ from apps.schedules.models import CustomOnCallShift
from apps.user_management.organization_log_creator import OrganizationLogType, create_organization_log
from common.api_helpers.mixins import PublicPrimaryKeyMixin, UpdateSerializerMixin
from common.api_helpers.paginators import FiftyPageSizePaginator
from common.api_helpers.utils import get_date_range_from_request
class OnCallShiftView(PublicPrimaryKeyMixin, UpdateSerializerMixin, ModelViewSet):
@ -19,7 +21,7 @@ class OnCallShiftView(PublicPrimaryKeyMixin, UpdateSerializerMixin, ModelViewSet
permission_classes = (IsAuthenticated, ActionPermission)
action_permissions = {
IsAdmin: MODIFY_ACTIONS,
IsAdmin: (*MODIFY_ACTIONS, "preview"),
AnyRole: (*READ_ACTIONS, "details", "frequency_options", "days_options"),
}
@ -77,6 +79,26 @@ class OnCallShiftView(PublicPrimaryKeyMixin, UpdateSerializerMixin, ModelViewSet
create_organization_log(organization, user, OrganizationLogType.TYPE_ON_CALL_SHIFT_DELETED, description)
instance.delete()
@action(detail=False, methods=["post"])
def preview(self, request):
user_tz, starting_date, days = get_date_range_from_request(self.request)
serializer = self.get_serializer(data=request.data)
if not serializer.is_valid():
return Response(data=serializer.errors, status=status.HTTP_400_BAD_REQUEST)
validated_data = serializer._correct_validated_data(
serializer.validated_data["type"], serializer.validated_data
)
shift = CustomOnCallShift(**validated_data)
schedule = shift.schedule
shift_events, final_events = schedule.preview_shift(shift, user_tz, starting_date, days)
data = {
"rotation": shift_events,
"final": final_events,
}
return Response(data=data, status=status.HTTP_200_OK)
@action(detail=False, methods=["get"])
def frequency_options(self, request):
return Response(

View file

@ -1,5 +1,3 @@
import datetime
import pytz
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import OuterRef, Subquery
@ -24,7 +22,6 @@ from apps.api.serializers.schedule_polymorphic import (
from apps.auth_token.auth import PluginAuthentication
from apps.auth_token.constants import SCHEDULE_EXPORT_TOKEN_NAME
from apps.auth_token.models import ScheduleExportAuthToken
from apps.schedules.ical_utils import list_of_oncall_shifts_from_ical
from apps.schedules.models import OnCallSchedule
from apps.slack.models import SlackChannel
from apps.slack.tasks import update_slack_user_group_for_schedules
@ -36,7 +33,7 @@ from common.api_helpers.mixins import (
ShortSerializerMixin,
UpdateSerializerMixin,
)
from common.api_helpers.utils import create_engine_url
from common.api_helpers.utils import create_engine_url, get_date_range_from_request
EVENTS_FILTER_BY_ROTATION = "rotation"
EVENTS_FILTER_BY_OVERRIDE = "override"
@ -195,43 +192,6 @@ class ScheduleView(
return user_tz, date
def _filter_events(self, schedule, user_timezone, starting_date, days, with_empty, with_gap):
shifts = (
list_of_oncall_shifts_from_ical(schedule, starting_date, user_timezone, with_empty, with_gap, days=days)
or []
)
events = []
# for start, end, users, priority_level, source in shifts:
for shift in shifts:
all_day = type(shift["start"]) == datetime.date
is_gap = shift.get("is_gap", False)
shift_json = {
"all_day": all_day,
"start": shift["start"],
# fix confusing end date for all-day event
"end": shift["end"] - timezone.timedelta(days=1) if all_day else shift["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)
return events
@action(detail=True, methods=["get"])
def events(self, request, pk):
user_tz, date = self.get_request_timezone()
@ -239,7 +199,7 @@ class ScheduleView(
with_gap = self.request.query_params.get("with_gap", False) == "true"
schedule = self.original_get_object()
events = self._filter_events(schedule, user_tz, date, days=1, with_empty=with_empty, with_gap=with_gap)
events = schedule.filter_events(user_tz, date, days=1, with_empty=with_empty, with_gap=with_gap)
slack_channel = (
{
@ -262,35 +222,23 @@ class ScheduleView(
@action(detail=True, methods=["get"])
def filter_events(self, request, pk):
user_tz, date = self.get_request_timezone()
filter_by = self.request.query_params.get("type")
user_tz, starting_date, days = get_date_range_from_request(self.request)
filter_by = self.request.query_params.get("type")
valid_filters = (EVENTS_FILTER_BY_ROTATION, EVENTS_FILTER_BY_OVERRIDE, EVENTS_FILTER_BY_FINAL)
if filter_by is not None and filter_by not in valid_filters:
raise BadRequest(detail="Invalid type value")
resolve_schedule = filter_by is None or filter_by == EVENTS_FILTER_BY_FINAL
starting_date = date if self.request.query_params.get("date") else None
if starting_date is None:
# default to current week start
starting_date = date - datetime.timedelta(days=date.weekday())
try:
days = int(self.request.query_params.get("days", 7)) # fallback to a week
except ValueError:
raise BadRequest(detail="Invalid days format")
schedule = self.original_get_object()
events = self._filter_events(
schedule, user_tz, starting_date, days=days, with_empty=True, with_gap=resolve_schedule
)
if filter_by == EVENTS_FILTER_BY_OVERRIDE:
events = [e for e in events if e["calendar_type"] == OnCallSchedule.OVERRIDES]
elif filter_by == EVENTS_FILTER_BY_ROTATION:
events = [e for e in events if e["calendar_type"] == OnCallSchedule.PRIMARY]
else: # resolve_schedule
events = self._resolve_schedule(events)
if filter_by is not None:
filter_by = OnCallSchedule.PRIMARY if filter_by == EVENTS_FILTER_BY_ROTATION else OnCallSchedule.OVERRIDES
events = schedule.filter_events(
user_tz, starting_date, days=days, with_empty=True, with_gap=resolve_schedule, filter_by=filter_by
)
else: # return final schedule
events = schedule.final_events(user_tz, starting_date, days)
result = {
"id": schedule.public_primary_key,
@ -300,103 +248,6 @@ class ScheduleView(
}
return Response(result, status=status.HTTP_200_OK)
def _resolve_schedule(self, events):
"""Calculate final schedule shifts considering rotations and overrides."""
if not events:
return []
# sort schedule events by (type desc, priority desc, start timestamp asc)
events.sort(
key=lambda e: (
-e["calendar_type"] if e["calendar_type"] else 0, # overrides: 1, shifts: 0, gaps: None
-e["priority_level"] if e["priority_level"] else 0,
e["start"],
)
)
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
# 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
# include overrides from start
resolved = [e for e in events if e["calendar_type"] == OnCallSchedule.TYPE_ICAL_OVERRIDES]
intervals = _merge_intervals(resolved)
pending = events[len(resolved) :]
if not pending:
return resolved
current_event_idx = 0 # current event to resolve
current_interval_idx = 0 # current scheduled interval being checked
current_priority = pending[0]["priority_level"] # current priority level being resolved
while current_event_idx < len(pending):
ev = pending[current_event_idx]
if ev["priority_level"] != current_priority:
# update scheduled intervals on priority change
# and start from the beginning for the new priority level
resolved.sort(key=lambda e: e["start"])
intervals = _merge_intervals(resolved)
current_interval_idx = 0
current_priority = ev["priority_level"]
if current_interval_idx >= len(intervals):
# event outside scheduled intervals, add to resolved
resolved.append(ev)
current_event_idx += 1
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)
current_event_idx += 1
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]
else:
# done, go to next event
current_event_idx += 1
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)
current_event_idx += 1
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]
# move to next interval and process the updated event as any other event
current_interval_idx += 1
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
resolved.sort(key=lambda e: e["start"])
return resolved
@action(detail=True, methods=["get"])
def next_shifts_per_user(self, request, pk):
"""Return next shift for users in schedule."""
@ -404,8 +255,7 @@ class ScheduleView(
now = timezone.now()
starting_date = now.date()
schedule = self.original_get_object()
shift_events = self._filter_events(schedule, user_tz, starting_date, days=30, with_empty=False, with_gap=False)
events = self._resolve_schedule(shift_events)
events = schedule.final_events(user_tz, starting_date, days=30)
users = {}
for e in events:

View file

@ -83,7 +83,13 @@ logger.setLevel(logging.DEBUG)
# used for display schedule events on web
def list_of_oncall_shifts_from_ical(
schedule, date, user_timezone="UTC", with_empty_shifts=False, with_gaps=False, days=1
schedule,
date,
user_timezone="UTC",
with_empty_shifts=False,
with_gaps=False,
days=1,
filter_by=None,
):
"""
Parse the ical file and return list of events with users
@ -122,6 +128,9 @@ def list_of_oncall_shifts_from_ical(
else:
calendar_type = OnCallSchedule.OVERRIDES
if filter_by is not None and filter_by != calendar_type:
continue
tmp_result_datetime, tmp_result_date = get_shifts_dict(
calendar, calendar_type, schedule, datetime_start, datetime_end, date, with_empty_shifts
)

View file

@ -1,3 +1,6 @@
import datetime
import itertools
import icalendar
from django.apps import apps
from django.conf import settings
@ -14,6 +17,7 @@ from apps.schedules.ical_utils import (
fetch_ical_file_or_get_error,
list_of_empty_shifts_in_schedule,
list_of_gaps_in_schedule,
list_of_oncall_shifts_from_ical,
list_users_to_notify_from_ical,
)
from apps.schedules.models import CustomOnCallShift
@ -222,6 +226,148 @@ class OnCallSchedule(PolymorphicModel):
self.cached_ical_file_overrides = None
self.save(update_fields=["cached_ical_file_overrides", "prev_ical_file_overrides"])
def filter_events(self, user_timezone, starting_date, days, with_empty=False, with_gap=False, filter_by=None):
"""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:
all_day = type(shift["start"]) == datetime.date
is_gap = shift.get("is_gap", False)
shift_json = {
"all_day": all_day,
"start": shift["start"],
# fix confusing end date for all-day event
"end": shift["end"] - timezone.timedelta(days=1) if all_day else shift["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)
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)
events = self._resolve_schedule(events)
return events
def _resolve_schedule(self, events):
"""Calculate final schedule shifts considering rotations and overrides."""
if not events:
return []
# sort schedule events by (type desc, priority desc, start timestamp asc)
events.sort(
key=lambda e: (
-e["calendar_type"] if e["calendar_type"] else 0, # overrides: 1, shifts: 0, gaps: None
-e["priority_level"] if e["priority_level"] else 0,
e["start"],
)
)
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
# 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
# include overrides from start
resolved = [e for e in events if e["calendar_type"] == OnCallSchedule.TYPE_ICAL_OVERRIDES]
intervals = _merge_intervals(resolved)
pending = events[len(resolved) :]
if not pending:
return resolved
current_event_idx = 0 # current event to resolve
current_interval_idx = 0 # current scheduled interval being checked
current_priority = pending[0]["priority_level"] # current priority level being resolved
while current_event_idx < len(pending):
ev = pending[current_event_idx]
if ev["priority_level"] != current_priority:
# update scheduled intervals on priority change
# and start from the beginning for the new priority level
resolved.sort(key=lambda e: e["start"])
intervals = _merge_intervals(resolved)
current_interval_idx = 0
current_priority = ev["priority_level"]
if current_interval_idx >= len(intervals):
# event outside scheduled intervals, add to resolved
resolved.append(ev)
current_event_idx += 1
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)
current_event_idx += 1
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]
else:
# done, go to next event
current_event_idx += 1
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)
current_event_idx += 1
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]
# move to next interval and process the updated event as any other event
current_interval_idx += 1
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
resolved.sort(key=lambda e: e["start"])
return resolved
class OnCallScheduleICal(OnCallSchedule):
# For the ical schedule both primary and overrides icals are imported via ical url
@ -364,10 +510,12 @@ class OnCallScheduleCalendar(OnCallSchedule):
class OnCallScheduleWeb(OnCallSchedule):
time_zone = models.CharField(max_length=100, default="UTC")
def _generate_ical_file_from_shifts(self, qs):
def _generate_ical_file_from_shifts(self, qs, extra_shifts=None):
"""Generate iCal events file from custom on-call shifts."""
ical = None
if qs.exists():
if qs.exists() or extra_shifts is not None:
if extra_shifts is None:
extra_shifts = []
end_line = "END:VCALENDAR"
calendar = Calendar()
calendar.add("prodid", "-//web schedule//oncall//")
@ -376,7 +524,7 @@ class OnCallScheduleWeb(OnCallSchedule):
ical_file = calendar.to_ical().decode()
ical = ical_file.replace(end_line, "").strip()
ical = f"{ical}\r\n"
for event in qs.all():
for event in itertools.chain(qs.all(), extra_shifts):
ical += event.convert_to_ical(self.time_zone)
ical += f"{end_line}\r\n"
return ical
@ -414,3 +562,39 @@ class OnCallScheduleWeb(OnCallSchedule):
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 preview_shift(self, custom_shift, user_tz, starting_date, days):
"""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
ical_file = self._generate_ical_file_from_shifts(qs, extra_shifts=[custom_shift])
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)
shift_events = [e for e in events if e["shift"]["pk"] == custom_shift.public_primary_key]
final_events = self._resolve_schedule(events)
_invalidate_cache(self, ical_property)
setattr(self, ical_attr, original_value)
return shift_events, final_events

View file

@ -0,0 +1,512 @@
import pytest
from django.utils import timezone
from apps.schedules.models import CustomOnCallShift, OnCallSchedule, OnCallScheduleWeb
from common.constants.role import Role
@pytest.mark.django_db
def test_filter_events(make_organization, make_user_for_organization, make_schedule, make_on_call_shift):
organization = make_organization()
schedule = make_schedule(
organization,
schedule_class=OnCallScheduleWeb,
name="test_web_schedule",
)
user = make_user_for_organization(organization)
viewer = make_user_for_organization(organization, role=Role.VIEWER)
now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
start_date = now - timezone.timedelta(days=7)
data = {
"start": start_date + timezone.timedelta(days=1, hours=10),
"rotation_start": start_date + timezone.timedelta(days=1, hours=10),
"duration": timezone.timedelta(hours=4),
"priority_level": 1,
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
"schedule": schedule,
}
on_call_shift = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
)
on_call_shift.add_rolling_users([[user]])
# add empty shift
data = {
"start": start_date + timezone.timedelta(days=1, hours=20),
"rotation_start": start_date + timezone.timedelta(days=1, hours=20),
"duration": timezone.timedelta(hours=2),
"priority_level": 1,
"frequency": CustomOnCallShift.FREQUENCY_WEEKLY,
"schedule": schedule,
}
empty_shift = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
)
empty_shift.add_rolling_users([[viewer]])
# override: 22-23
override_data = {
"start": start_date + timezone.timedelta(hours=22),
"rotation_start": start_date + timezone.timedelta(hours=22),
"duration": timezone.timedelta(hours=1),
"schedule": schedule,
}
override = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **override_data
)
override.add_rolling_users([[user]])
# filter primary non-empty shifts only
events = schedule.filter_events("UTC", start_date, days=3, filter_by=OnCallSchedule.TYPE_ICAL_PRIMARY)
expected = [
{
"calendar_type": OnCallSchedule.TYPE_ICAL_PRIMARY,
"start": on_call_shift.start + timezone.timedelta(days=i),
"end": on_call_shift.start + timezone.timedelta(days=i) + on_call_shift.duration,
"all_day": False,
"is_override": False,
"is_empty": False,
"is_gap": False,
"priority_level": on_call_shift.priority_level,
"missing_users": [],
"users": [{"display_name": user.username, "pk": user.public_primary_key}],
"shift": {"pk": on_call_shift.public_primary_key},
"source": "api",
}
for i in range(2)
]
assert events == expected
# filter overrides only
events = schedule.filter_events("UTC", start_date, days=3, filter_by=OnCallSchedule.TYPE_ICAL_OVERRIDES)
expected_override = [
{
"calendar_type": OnCallSchedule.TYPE_ICAL_OVERRIDES,
"start": override.start,
"end": override.start + override.duration,
"all_day": False,
"is_override": True,
"is_empty": False,
"is_gap": False,
"priority_level": None,
"missing_users": [],
"users": [{"display_name": user.username, "pk": user.public_primary_key}],
"shift": {"pk": override.public_primary_key},
"source": "api",
}
]
assert events == expected_override
# no type filter
events = schedule.filter_events("UTC", start_date, days=3)
assert events == expected_override + expected
@pytest.mark.django_db
def test_filter_events_include_gaps(make_organization, make_user_for_organization, make_schedule, make_on_call_shift):
organization = make_organization()
schedule = make_schedule(
organization,
schedule_class=OnCallScheduleWeb,
name="test_web_schedule",
)
user = make_user_for_organization(organization)
now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
start_date = now - timezone.timedelta(days=7)
data = {
"start": start_date + timezone.timedelta(hours=10),
"rotation_start": start_date + timezone.timedelta(days=1, hours=10),
"duration": timezone.timedelta(hours=8),
"priority_level": 1,
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
"schedule": schedule,
}
on_call_shift = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
)
on_call_shift.add_rolling_users([[user]])
events = schedule.filter_events(
"UTC", start_date, days=1, filter_by=OnCallSchedule.TYPE_ICAL_PRIMARY, with_gap=True
)
expected = [
{
"calendar_type": None,
"start": start_date + timezone.timedelta(milliseconds=1),
"end": on_call_shift.start,
"all_day": False,
"is_override": False,
"is_empty": False,
"is_gap": True,
"priority_level": None,
"missing_users": [],
"users": [],
"shift": {"pk": None},
"source": None,
},
{
"calendar_type": OnCallSchedule.TYPE_ICAL_PRIMARY,
"start": on_call_shift.start,
"end": on_call_shift.start + on_call_shift.duration,
"all_day": False,
"is_override": False,
"is_empty": False,
"is_gap": False,
"priority_level": on_call_shift.priority_level,
"missing_users": [],
"users": [{"display_name": user.username, "pk": user.public_primary_key}],
"shift": {"pk": on_call_shift.public_primary_key},
"source": "api",
},
{
"calendar_type": None,
"start": on_call_shift.start + on_call_shift.duration,
"end": on_call_shift.start + timezone.timedelta(hours=13, minutes=59, seconds=59, milliseconds=1),
"all_day": False,
"is_override": False,
"is_empty": False,
"is_gap": True,
"priority_level": None,
"missing_users": [],
"users": [],
"shift": {"pk": None},
"source": None,
},
]
assert events == expected
@pytest.mark.django_db
def test_filter_events_include_empty(make_organization, make_user_for_organization, make_schedule, make_on_call_shift):
organization = make_organization()
schedule = make_schedule(
organization,
schedule_class=OnCallScheduleWeb,
name="test_web_schedule",
)
user = make_user_for_organization(organization, role=Role.VIEWER)
now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
start_date = now - timezone.timedelta(days=7)
data = {
"start": start_date + timezone.timedelta(hours=10),
"rotation_start": start_date + timezone.timedelta(days=1, hours=10),
"duration": timezone.timedelta(hours=8),
"priority_level": 1,
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
"schedule": schedule,
}
on_call_shift = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
)
on_call_shift.add_rolling_users([[user]])
events = schedule.filter_events(
"UTC", start_date, days=1, filter_by=OnCallSchedule.TYPE_ICAL_PRIMARY, with_empty=True
)
expected = [
{
"calendar_type": OnCallSchedule.TYPE_ICAL_PRIMARY,
"start": on_call_shift.start,
"end": on_call_shift.start + on_call_shift.duration,
"all_day": False,
"is_override": False,
"is_empty": True,
"is_gap": False,
"priority_level": on_call_shift.priority_level,
"missing_users": [user.username],
"users": [],
"shift": {"pk": on_call_shift.public_primary_key},
"source": "api",
}
]
assert events == expected
@pytest.mark.django_db
def test_final_schedule_events(make_organization, make_user_for_organization, make_on_call_shift, make_schedule):
organization = make_organization()
schedule = make_schedule(
organization,
schedule_class=OnCallScheduleWeb,
name="test_web_schedule",
)
now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
start_date = now - timezone.timedelta(days=7)
user_a, user_b, user_c, user_d, user_e = (make_user_for_organization(organization, username=i) for i in "ABCDE")
shifts = (
# user, priority, start time (h), duration (hs)
(user_a, 1, 10, 5), # r1-1: 10-15 / A
(user_b, 1, 11, 2), # r1-2: 11-13 / B
(user_a, 1, 16, 3), # r1-3: 16-19 / A
(user_a, 1, 21, 1), # r1-4: 21-22 / A
(user_b, 1, 22, 2), # r1-5: 22-00 / B
(user_c, 2, 12, 2), # r2-1: 12-14 / C
(user_d, 2, 14, 1), # r2-2: 14-15 / D
(user_d, 2, 17, 1), # r2-3: 17-18 / D
(user_d, 2, 20, 3), # r2-4: 20-23 / D
)
for user, priority, start_h, duration in shifts:
data = {
"start": start_date + timezone.timedelta(hours=start_h),
"rotation_start": start_date + timezone.timedelta(hours=start_h),
"duration": timezone.timedelta(hours=duration),
"priority_level": priority,
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
"schedule": schedule,
}
on_call_shift = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_RECURRENT_EVENT, **data
)
on_call_shift.users.add(user)
# override: 22-23 / E
override_data = {
"start": start_date + timezone.timedelta(hours=22),
"rotation_start": start_date + timezone.timedelta(hours=22),
"duration": timezone.timedelta(hours=1),
"schedule": schedule,
}
override = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **override_data
)
override.add_rolling_users([[user_e]])
returned_events = schedule.final_events("UTC", start_date, days=1)
expected = (
# start (h), duration (H), user, priority, is_gap, is_override
(0, 10, None, None, True, False), # 0-10 gap
(10, 2, "A", 1, False, False), # 10-12 A
(11, 1, "B", 1, False, False), # 11-12 B
(12, 2, "C", 2, False, False), # 12-14 C
(14, 1, "D", 2, False, False), # 14-15 D
(15, 1, None, None, True, False), # 15-16 gap
(16, 1, "A", 1, False, False), # 16-17 A
(17, 1, "D", 2, False, False), # 17-18 D
(18, 1, "A", 1, False, False), # 18-19 A
(19, 1, None, None, True, False), # 19-20 gap
(20, 2, "D", 2, False, False), # 20-22 D
(22, 1, "E", None, False, True), # 22-23 E (override)
(23, 1, "B", 1, False, False), # 23-00 B
)
expected_events = [
{
"calendar_type": 1 if is_override else None if is_gap else 0,
"end": start_date + timezone.timedelta(hours=start + duration),
"is_gap": is_gap,
"is_override": is_override,
"priority_level": priority,
"start": start_date + timezone.timedelta(hours=start, milliseconds=1 if start == 0 else 0),
"user": user,
}
for start, duration, user, priority, is_gap, is_override in expected
]
returned_events = [
{
"calendar_type": e["calendar_type"],
"end": e["end"],
"is_gap": e["is_gap"],
"is_override": e["is_override"],
"priority_level": e["priority_level"],
"start": e["start"],
"user": e["users"][0]["display_name"] if e["users"] else None,
}
for e in returned_events
]
assert returned_events == expected_events
@pytest.mark.django_db
def test_preview_shift(make_organization, make_user_for_organization, make_schedule, make_on_call_shift):
organization = make_organization()
schedule = make_schedule(
organization,
schedule_class=OnCallScheduleWeb,
name="test_web_schedule",
)
user = make_user_for_organization(organization)
other_user = make_user_for_organization(organization)
now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
start_date = now - timezone.timedelta(days=7)
data = {
"start": start_date + timezone.timedelta(hours=9),
"rotation_start": start_date + timezone.timedelta(hours=9),
"duration": timezone.timedelta(hours=9),
"priority_level": 1,
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
"schedule": schedule,
}
on_call_shift = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
)
on_call_shift.add_rolling_users([[user]])
schedule_primary_ical = schedule._ical_file_primary
# proposed shift
new_shift = CustomOnCallShift(
type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
organization=organization,
schedule=schedule,
name="testing",
start=start_date + timezone.timedelta(hours=12),
rotation_start=start_date + timezone.timedelta(hours=12),
duration=timezone.timedelta(seconds=3600),
frequency=CustomOnCallShift.FREQUENCY_DAILY,
priority_level=2,
rolling_users=[{other_user.pk: other_user.public_primary_key}],
)
rotation_events, final_events = schedule.preview_shift(new_shift, "UTC", start_date, days=1)
# check rotation events
expected_rotation_events = [
{
"calendar_type": OnCallSchedule.TYPE_ICAL_PRIMARY,
"start": new_shift.start,
"end": new_shift.start + new_shift.duration,
"all_day": False,
"is_override": False,
"is_empty": False,
"is_gap": False,
"priority_level": new_shift.priority_level,
"missing_users": [],
"users": [{"display_name": other_user.username, "pk": other_user.public_primary_key}],
"shift": {"pk": new_shift.public_primary_key},
"source": "api",
}
]
assert rotation_events == expected_rotation_events
# check final schedule events
expected = (
# start (h), duration (H), user, priority
(9, 3, user.username, 1), # 9-12 user
(12, 1, other_user.username, 2), # 12-13 other_user
(13, 5, user.username, 1), # 13-18 C
)
expected_events = [
{
"end": start_date + timezone.timedelta(hours=start + duration),
"priority_level": priority,
"start": start_date + timezone.timedelta(hours=start, milliseconds=1 if start == 0 else 0),
"user": user,
}
for start, duration, user, priority in expected
]
returned_events = [
{
"end": e["end"],
"priority_level": e["priority_level"],
"start": e["start"],
"user": e["users"][0]["display_name"] if e["users"] else None,
}
for e in final_events
if not e["is_override"] and not e["is_gap"]
]
assert returned_events == expected_events
# final ical schedule didn't change
assert schedule._ical_file_primary == schedule_primary_ical
@pytest.mark.django_db
def test_preview_override_shift(make_organization, make_user_for_organization, make_schedule, make_on_call_shift):
organization = make_organization()
schedule = make_schedule(
organization,
schedule_class=OnCallScheduleWeb,
name="test_web_schedule",
)
user = make_user_for_organization(organization)
other_user = make_user_for_organization(organization)
now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
start_date = now - timezone.timedelta(days=7)
data = {
"start": start_date + timezone.timedelta(hours=9),
"rotation_start": start_date + timezone.timedelta(hours=9),
"duration": timezone.timedelta(hours=9),
"priority_level": 1,
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
"schedule": schedule,
}
on_call_shift = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
)
on_call_shift.add_rolling_users([[user]])
schedule_overrides_ical = schedule._ical_file_overrides
# proposed override
new_shift = CustomOnCallShift(
type=CustomOnCallShift.TYPE_OVERRIDE,
organization=organization,
schedule=schedule,
name="testing",
start=start_date + timezone.timedelta(hours=12),
rotation_start=start_date + timezone.timedelta(hours=12),
duration=timezone.timedelta(seconds=3600),
rolling_users=[{other_user.pk: other_user.public_primary_key}],
)
rotation_events, final_events = schedule.preview_shift(new_shift, "UTC", start_date, days=1)
# check rotation events
expected_rotation_events = [
{
"calendar_type": OnCallSchedule.TYPE_ICAL_OVERRIDES,
"start": new_shift.start,
"end": new_shift.start + new_shift.duration,
"all_day": False,
"is_override": True,
"is_empty": False,
"is_gap": False,
"priority_level": None,
"missing_users": [],
"users": [{"display_name": other_user.username, "pk": other_user.public_primary_key}],
"shift": {"pk": new_shift.public_primary_key},
"source": "api",
}
]
assert rotation_events == expected_rotation_events
# check final schedule events
expected = (
# start (h), duration (H), user, priority, is_override
(9, 3, user.username, 1, False), # 9-12 user
(12, 1, other_user.username, None, True), # 12-13 other_user
(13, 5, user.username, 1, False), # 13-18 C
)
expected_events = [
{
"end": start_date + timezone.timedelta(hours=start + duration),
"priority_level": priority,
"start": start_date + timezone.timedelta(hours=start, milliseconds=1 if start == 0 else 0),
"user": user,
"is_override": is_override,
}
for start, duration, user, priority, is_override in expected
]
returned_events = [
{
"end": e["end"],
"priority_level": e["priority_level"],
"start": e["start"],
"user": e["users"][0]["display_name"] if e["users"] else None,
"is_override": e["is_override"],
}
for e in final_events
if not e["is_gap"]
]
assert returned_events == expected_events
# final ical schedule didn't change
assert schedule._ical_file_overrides == schedule_overrides_ical

View file

@ -133,8 +133,6 @@ class AddToResolutionNoteStep(CheckAlertIsUnarchivedMixin, scenario_step.Scenari
).save()
else:
resolution_note.recreate()
alert_group.drop_cached_after_resolve_report_json()
alert_group.schedule_cache_for_web()
try:
self._slack_client.api_call(
"reactions.add",
@ -704,7 +702,7 @@ class AddRemoveThreadMessageStep(UpdateResolutionNoteStep, scenario_step.Scenari
# Show error message
resolution_note_data = json.loads(payload["actions"][0]["value"])
resolution_note_data["resolution_note_window_action"] = "edit_update_error"
return ResolutionNoteModalStep(slack_team_identity).process_scenario(
return ResolutionNoteModalStep(slack_team_identity, self.organization, self.user).process_scenario(
slack_user_identity,
slack_team_identity,
payload,

View file

@ -54,7 +54,7 @@ class SlackOAuth2V2(SlackOAuth2):
ACCESS_TOKEN_URL = "https://slack.com/api/oauth.v2.access"
AUTH_TOKEN_NAME = SLACK_AUTH_TOKEN_NAME
# Remove redirect state because we loose session during redirects
# Remove redirect state because we lose session during redirects
REDIRECT_STATE = False
STATE_PARAMETER = False

View file

@ -1,10 +1,15 @@
import datetime
from urllib.parse import urljoin
import pytz
import requests
from django.conf import settings
from django.utils import dateparse, timezone
from icalendar import Calendar
from rest_framework import serializers
from common.api_helpers.exceptions import BadRequest
class CurrentOrganizationDefault:
"""
@ -71,3 +76,38 @@ def create_engine_url(path, override_base=None):
base += "/"
trimmed_path = path.lstrip("/")
return urljoin(base, trimmed_path)
def get_date_range_from_request(request):
"""Extract timezone, starting date and number of days params from request.
Used mainly for schedules and shifts API.
"""
user_tz = request.query_params.get("user_tz", "UTC")
try:
pytz.timezone(user_tz)
except pytz.exceptions.UnknownTimeZoneError:
raise BadRequest(detail="Invalid tz format")
date = timezone.now().date()
date_param = request.query_params.get("date")
if date_param is not None:
try:
date = dateparse.parse_date(date_param)
except ValueError:
raise BadRequest(detail="Invalid date format")
else:
if date is None:
raise BadRequest(detail="Invalid date format")
starting_date = date if request.query_params.get("date") else None
if starting_date is None:
# default to current week start
starting_date = date - datetime.timedelta(days=date.weekday())
try:
days = int(request.query_params.get("days", 7)) # fallback to a week
except ValueError:
raise BadRequest(detail="Invalid days format")
return user_tz, starting_date, days

View file

@ -8,13 +8,13 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 1.0.2
version: 1.0.3
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "v1.0.3"
appVersion: "v1.0.13"
dependencies:
- name: cert-manager
version: v1.8.0