Merge remote-tracking branch 'origin/matiasb/preview-schedule-shift' into new-schedules
This commit is contained in:
commit
daaf109794
13 changed files with 968 additions and 178 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
512
engine/apps/schedules/tests/test_on_call_schedule.py
Normal file
512
engine/apps/schedules/tests/test_on_call_schedule.py
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue