From 960a6857d876e1d1bdf87363413d0236a5d37a86 Mon Sep 17 00:00:00 2001 From: Ildar Iskhakov Date: Wed, 3 Aug 2022 13:55:00 +0300 Subject: [PATCH 01/10] Update Chart.yaml --- helm/oncall/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/helm/oncall/Chart.yaml b/helm/oncall/Chart.yaml index 2d138cf1..1b853761 100644 --- a/helm/oncall/Chart.yaml +++ b/helm/oncall/Chart.yaml @@ -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 From 36716bd60ca0d43a5d21349719c43b3daf4423e1 Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Wed, 3 Aug 2022 11:57:44 +0100 Subject: [PATCH 02/10] Update CHANGELOG.md --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d3ada7d..ea06447b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Change Log +## v1.0.14 (2022-08-03) +- Bug fixes + ## v1.0.13 (2022-07-27) - Optimize alert group list view - Fix a bug related to Twilio setup From d409b839b3a0c27fb4228c76f34c4603540381e5 Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Wed, 3 Aug 2022 12:43:28 +0100 Subject: [PATCH 03/10] Fix circular import for CloudConnector (#329) --- engine/apps/api/views/live_setting.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/engine/apps/api/views/live_setting.py b/engine/apps/api/views/live_setting.py index e74d1f7f..d2c77ab0 100644 --- a/engine/apps/api/views/live_setting.py +++ b/engine/apps/api/views/live_setting.py @@ -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" From 352c0a8370cd3893ef7afbb87f4950eba00040f6 Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Wed, 3 Aug 2022 12:54:36 +0100 Subject: [PATCH 04/10] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea06447b..fce73ea1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Change Log -## v1.0.14 (2022-08-03) +## v1.0.15 (2022-08-03) - Bug fixes ## v1.0.13 (2022-07-27) From d036864d9a7080cea6bcb9fc4199e71c8cfdeaff Mon Sep 17 00:00:00 2001 From: Ildar Iskhakov Date: Thu, 4 Aug 2022 12:27:08 +0300 Subject: [PATCH 05/10] Fix resolution notes bug --- engine/apps/slack/scenarios/resolution_note.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engine/apps/slack/scenarios/resolution_note.py b/engine/apps/slack/scenarios/resolution_note.py index f6c78305..ab4488e9 100644 --- a/engine/apps/slack/scenarios/resolution_note.py +++ b/engine/apps/slack/scenarios/resolution_note.py @@ -704,7 +704,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, From 4e16767eaa33b9821af5e4b33943e05e852a09d9 Mon Sep 17 00:00:00 2001 From: Ildar Iskhakov Date: Thu, 4 Aug 2022 13:37:15 +0300 Subject: [PATCH 06/10] Cleanup unused code --- engine/apps/slack/scenarios/resolution_note.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/engine/apps/slack/scenarios/resolution_note.py b/engine/apps/slack/scenarios/resolution_note.py index ab4488e9..cec692c6 100644 --- a/engine/apps/slack/scenarios/resolution_note.py +++ b/engine/apps/slack/scenarios/resolution_note.py @@ -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", From 20e476b68eceb7dcc36627e3e1fe18d79d297ceb Mon Sep 17 00:00:00 2001 From: Ildar Iskhakov Date: Thu, 4 Aug 2022 13:41:59 +0300 Subject: [PATCH 07/10] Fix docs --- docs/sources/open-source.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/sources/open-source.md b/docs/sources/open-source.md index e6032929..c485e810 100644 --- a/docs/sources/open-source.md +++ b/docs/sources/open-source.md @@ -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: /slack/interactive_api_endpoint/ From 4a35d2522adea60cebca92392120867fe41b5da0 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Thu, 4 Aug 2022 17:00:09 -0300 Subject: [PATCH 08/10] Move schedule helpers (filter events, final) to model --- engine/apps/api/views/schedule.py | 156 +-------- engine/apps/schedules/ical_utils.py | 11 +- .../apps/schedules/models/on_call_schedule.py | 145 ++++++++ .../schedules/tests/test_on_call_schedule.py | 322 ++++++++++++++++++ 4 files changed, 486 insertions(+), 148 deletions(-) create mode 100644 engine/apps/schedules/tests/test_on_call_schedule.py diff --git a/engine/apps/api/views/schedule.py b/engine/apps/api/views/schedule.py index 25c35f10..4af90abf 100644 --- a/engine/apps/api/views/schedule.py +++ b/engine/apps/api/views/schedule.py @@ -24,7 +24,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 @@ -195,43 +194,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 +201,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 = ( { @@ -281,16 +243,14 @@ class ScheduleView( 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 +260,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 +267,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: diff --git a/engine/apps/schedules/ical_utils.py b/engine/apps/schedules/ical_utils.py index a22ddee4..93092cc3 100644 --- a/engine/apps/schedules/ical_utils.py +++ b/engine/apps/schedules/ical_utils.py @@ -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 ) diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index 05493789..1589757b 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -1,3 +1,5 @@ +import datetime + import icalendar from django.apps import apps from django.conf import settings @@ -14,6 +16,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 +225,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 diff --git a/engine/apps/schedules/tests/test_on_call_schedule.py b/engine/apps/schedules/tests/test_on_call_schedule.py new file mode 100644 index 00000000..11f4be13 --- /dev/null +++ b/engine/apps/schedules/tests/test_on_call_schedule.py @@ -0,0 +1,322 @@ +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 From 07b41fdfa88405dd6269eecc3f654485ab3d063e Mon Sep 17 00:00:00 2001 From: Andrew Grangaard Date: Tue, 9 Aug 2022 05:56:07 -0700 Subject: [PATCH 09/10] Fix spelling of "lose" (#346) --- engine/apps/social_auth/backends.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engine/apps/social_auth/backends.py b/engine/apps/social_auth/backends.py index 753e814a..c16d56af 100644 --- a/engine/apps/social_auth/backends.py +++ b/engine/apps/social_auth/backends.py @@ -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 From bd0334f8d3144dc876913debe12ede5e708475a1 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Mon, 15 Aug 2022 10:24:22 -0300 Subject: [PATCH 10/10] Add shift preview endpoint for web schedule --- engine/apps/api/tests/test_oncall_shift.py | 173 +++++++++++++++- engine/apps/api/views/on_call_shifts.py | 24 ++- engine/apps/api/views/schedule.py | 18 +- .../apps/schedules/models/on_call_schedule.py | 45 ++++- .../schedules/tests/test_on_call_schedule.py | 190 ++++++++++++++++++ engine/common/api_helpers/utils.py | 40 ++++ 6 files changed, 470 insertions(+), 20 deletions(-) diff --git a/engine/apps/api/tests/test_oncall_shift.py b/engine/apps/api/tests/test_oncall_shift.py index a40fbd46..fe9f77cf 100644 --- a/engine/apps/api/tests/test_oncall_shift.py +++ b/engine/apps/api/tests/test_oncall_shift.py @@ -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 diff --git a/engine/apps/api/views/on_call_shifts.py b/engine/apps/api/views/on_call_shifts.py index a12e5c0b..ad9fe688 100644 --- a/engine/apps/api/views/on_call_shifts.py +++ b/engine/apps/api/views/on_call_shifts.py @@ -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( diff --git a/engine/apps/api/views/schedule.py b/engine/apps/api/views/schedule.py index 4af90abf..19b65010 100644 --- a/engine/apps/api/views/schedule.py +++ b/engine/apps/api/views/schedule.py @@ -1,5 +1,3 @@ -import datetime - import pytz from django.core.exceptions import ObjectDoesNotExist from django.db.models import OuterRef, Subquery @@ -35,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" @@ -224,24 +222,14 @@ 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() if filter_by is not None: diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index 1589757b..70fdd01f 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -1,4 +1,5 @@ import datetime +import itertools import icalendar from django.apps import apps @@ -509,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//") @@ -521,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 @@ -559,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 diff --git a/engine/apps/schedules/tests/test_on_call_schedule.py b/engine/apps/schedules/tests/test_on_call_schedule.py index 11f4be13..3752e1f2 100644 --- a/engine/apps/schedules/tests/test_on_call_schedule.py +++ b/engine/apps/schedules/tests/test_on_call_schedule.py @@ -320,3 +320,193 @@ def test_final_schedule_events(make_organization, make_user_for_organization, ma 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 diff --git a/engine/common/api_helpers/utils.py b/engine/common/api_helpers/utils.py index 7ecd5d47..5ccc93b1 100644 --- a/engine/common/api_helpers/utils.py +++ b/engine/common/api_helpers/utils.py @@ -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