diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index 0bd077a6..e1d98938 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -1,7 +1,11 @@ name: Integration Tests on: - - pull_request + pull_request: + # You can use the merge_group event to trigger your GitHub Actions workflow when + # a pull request is added to a merge queue + # https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue#triggering-merge-group-checks-with-github-actions + merge_group: # TODO: ideally we would be able to have one CI job which spins up the kind cluster and does the helm release # then we could have the UI and backend integration tests dependent on this job and not have to each diff --git a/CHANGELOG.md b/CHANGELOG.md index 87d694eb..5c0a5bfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Enable web overrides for Terraform-based schedules + ## v1.1.36 (2023-03-09) ### Fixed diff --git a/engine/apps/api/serializers/on_call_shifts.py b/engine/apps/api/serializers/on_call_shifts.py index 607dca30..cb1b1505 100644 --- a/engine/apps/api/serializers/on_call_shifts.py +++ b/engine/apps/api/serializers/on_call_shifts.py @@ -1,7 +1,7 @@ from django.utils import timezone from rest_framework import serializers -from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb +from apps.schedules.models import CustomOnCallShift, OnCallSchedule from apps.user_management.models import User from common.api_helpers.custom_fields import ( OrganizationFilteredPrimaryKeyRelatedField, @@ -19,7 +19,7 @@ class OnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer): required=True, choices=CustomOnCallShift.WEB_TYPES, ) - schedule = OrganizationFilteredPrimaryKeyRelatedField(queryset=OnCallScheduleWeb.objects) + schedule = OrganizationFilteredPrimaryKeyRelatedField(queryset=OnCallSchedule.objects) frequency = serializers.ChoiceField(required=False, choices=CustomOnCallShift.FREQUENCY_CHOICES, allow_null=True) shift_start = serializers.DateTimeField(source="start") shift_end = serializers.SerializerMethodField() diff --git a/engine/apps/api/serializers/schedule_base.py b/engine/apps/api/serializers/schedule_base.py index ffe6f6ac..1cb34913 100644 --- a/engine/apps/api/serializers/schedule_base.py +++ b/engine/apps/api/serializers/schedule_base.py @@ -17,6 +17,7 @@ class ScheduleBaseSerializer(EagerLoadingMixin, serializers.ModelSerializer): warnings = serializers.SerializerMethodField() on_call_now = serializers.SerializerMethodField() number_of_escalation_chains = serializers.SerializerMethodField() + enable_web_overrides = serializers.SerializerMethodField() class Meta: fields = [ @@ -33,6 +34,7 @@ class ScheduleBaseSerializer(EagerLoadingMixin, serializers.ModelSerializer): "mention_oncall_start", "mention_oncall_next", "number_of_escalation_chains", + "enable_web_overrides", ] SELECT_RELATED = ["organization"] @@ -75,6 +77,9 @@ class ScheduleBaseSerializer(EagerLoadingMixin, serializers.ModelSerializer): num = getattr(obj, "num_escalation_chains", 0) return num or 0 + def get_enable_web_overrides(self, obj): + return False + def validate(self, attrs): if "slack_channel_id" in attrs: slack_channel_id = attrs.pop("slack_channel_id", None) diff --git a/engine/apps/api/serializers/schedule_calendar.py b/engine/apps/api/serializers/schedule_calendar.py index 436685e0..4d97d60f 100644 --- a/engine/apps/api/serializers/schedule_calendar.py +++ b/engine/apps/api/serializers/schedule_calendar.py @@ -1,3 +1,5 @@ +from rest_framework import serializers + from apps.api.serializers.schedule_base import ScheduleBaseSerializer from apps.schedules.models import OnCallScheduleCalendar from apps.schedules.tasks import schedule_notify_about_empty_shifts_in_schedule, schedule_notify_about_gaps_in_schedule @@ -9,6 +11,7 @@ from common.timezones import TimeZoneField class ScheduleCalendarSerializer(ScheduleBaseSerializer): time_zone = TimeZoneField(required=False) + enable_web_overrides = serializers.BooleanField(required=False, allow_null=True) class Meta: model = OnCallScheduleCalendar @@ -41,13 +44,19 @@ class ScheduleCalendarCreateSerializer(ScheduleCalendarSerializer): def update(self, instance, validated_data): old_ical_url_overrides = instance.ical_url_overrides old_time_zone = instance.time_zone + old_enable_web_overrides = instance.enable_web_overrides updated_schedule = super().update(instance, validated_data) updated_ical_url_overrides = updated_schedule.ical_url_overrides updated_time_zone = updated_schedule.time_zone + updated_enable_web_overrides = updated_schedule.enable_web_overrides - if old_time_zone != updated_time_zone or old_ical_url_overrides != updated_ical_url_overrides: + if ( + old_time_zone != updated_time_zone + or old_ical_url_overrides != updated_ical_url_overrides + or old_enable_web_overrides != updated_enable_web_overrides + ): updated_schedule.drop_cached_ical() updated_schedule.check_empty_shifts_for_next_week() updated_schedule.check_gaps_for_next_week() diff --git a/engine/apps/api/serializers/schedule_web.py b/engine/apps/api/serializers/schedule_web.py index 86414e12..fbe85175 100644 --- a/engine/apps/api/serializers/schedule_web.py +++ b/engine/apps/api/serializers/schedule_web.py @@ -13,6 +13,9 @@ class ScheduleWebSerializer(ScheduleBaseSerializer): model = OnCallScheduleWeb fields = [*ScheduleBaseSerializer.Meta.fields, "slack_channel", "time_zone"] + def get_enable_web_overrides(self, obj): + return True + class ScheduleWebCreateSerializer(ScheduleWebSerializer): slack_channel_id = OrganizationFilteredPrimaryKeyRelatedField( diff --git a/engine/apps/api/tests/test_schedules.py b/engine/apps/api/tests/test_schedules.py index cddee07f..d03c9bd5 100644 --- a/engine/apps/api/tests/test_schedules.py +++ b/engine/apps/api/tests/test_schedules.py @@ -97,6 +97,7 @@ def test_get_list_schedules( "notify_empty_oncall": 0, "notify_oncall_shift_freq": 1, "number_of_escalation_chains": 0, + "enable_web_overrides": False, }, { "id": ical_schedule.public_primary_key, @@ -115,6 +116,7 @@ def test_get_list_schedules( "notify_empty_oncall": 0, "notify_oncall_shift_freq": 1, "number_of_escalation_chains": 0, + "enable_web_overrides": False, }, { "id": web_schedule.public_primary_key, @@ -132,6 +134,7 @@ def test_get_list_schedules( "notify_empty_oncall": 0, "notify_oncall_shift_freq": 1, "number_of_escalation_chains": 1, + "enable_web_overrides": True, }, ], } @@ -172,6 +175,7 @@ def test_get_list_schedules_pagination( "notify_empty_oncall": 0, "notify_oncall_shift_freq": 1, "number_of_escalation_chains": 0, + "enable_web_overrides": False, }, { "id": ical_schedule.public_primary_key, @@ -190,6 +194,7 @@ def test_get_list_schedules_pagination( "notify_empty_oncall": 0, "notify_oncall_shift_freq": 1, "number_of_escalation_chains": 0, + "enable_web_overrides": False, }, { "id": web_schedule.public_primary_key, @@ -207,6 +212,7 @@ def test_get_list_schedules_pagination( "notify_empty_oncall": 0, "notify_oncall_shift_freq": 1, "number_of_escalation_chains": 1, + "enable_web_overrides": True, }, ] @@ -278,6 +284,7 @@ def test_get_list_schedules_by_type( "notify_empty_oncall": 0, "notify_oncall_shift_freq": 1, "number_of_escalation_chains": 0, + "enable_web_overrides": False, }, { "id": ical_schedule.public_primary_key, @@ -296,6 +303,7 @@ def test_get_list_schedules_by_type( "notify_empty_oncall": 0, "notify_oncall_shift_freq": 1, "number_of_escalation_chains": 0, + "enable_web_overrides": False, }, { "id": web_schedule.public_primary_key, @@ -313,6 +321,7 @@ def test_get_list_schedules_by_type( "notify_empty_oncall": 0, "notify_oncall_shift_freq": 1, "number_of_escalation_chains": 1, + "enable_web_overrides": True, }, ] @@ -409,6 +418,7 @@ def test_get_detail_calendar_schedule(schedule_internal_api_setup, make_user_aut "notify_empty_oncall": 0, "notify_oncall_shift_freq": 1, "number_of_escalation_chains": 0, + "enable_web_overrides": False, } response = client.get(url, format="json", **make_user_auth_headers(user, token)) @@ -439,6 +449,7 @@ def test_get_detail_ical_schedule(schedule_internal_api_setup, make_user_auth_he "notify_empty_oncall": 0, "notify_oncall_shift_freq": 1, "number_of_escalation_chains": 0, + "enable_web_overrides": False, } response = client.get(url, format="json", **make_user_auth_headers(user, token)) @@ -478,6 +489,7 @@ def test_get_detail_web_schedule( "notify_empty_oncall": 0, "notify_oncall_shift_freq": 1, "number_of_escalation_chains": 1, + "enable_web_overrides": True, } response = client.get(url, format="json", **make_user_auth_headers(user, token)) @@ -505,6 +517,7 @@ def test_create_calendar_schedule(schedule_internal_api_setup, make_user_auth_he "mention_oncall_start": True, "notify_empty_oncall": 0, "notify_oncall_shift_freq": 1, + "enable_web_overrides": True, } response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) # modify initial data by adding id and None for optional fields @@ -544,6 +557,7 @@ def test_create_ical_schedule(schedule_internal_api_setup, make_user_auth_header schedule = OnCallSchedule.objects.get(public_primary_key=response.data["id"]) data["id"] = schedule.public_primary_key data["number_of_escalation_chains"] = 0 + data["enable_web_overrides"] = False assert response.status_code == status.HTTP_201_CREATED assert response.data == data @@ -573,6 +587,7 @@ def test_create_web_schedule(schedule_internal_api_setup, make_user_auth_headers schedule = OnCallSchedule.objects.get(public_primary_key=response.data["id"]) data["id"] = schedule.public_primary_key data["number_of_escalation_chains"] = 0 + data["enable_web_overrides"] = True assert response.status_code == status.HTTP_201_CREATED assert response.data == data diff --git a/engine/apps/schedules/migrations/0009_oncallschedulecalendar_enable_web_overrides.py b/engine/apps/schedules/migrations/0009_oncallschedulecalendar_enable_web_overrides.py new file mode 100644 index 00000000..fb3c9535 --- /dev/null +++ b/engine/apps/schedules/migrations/0009_oncallschedulecalendar_enable_web_overrides.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.18 on 2023-03-08 18:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('schedules', '0008_auto_20221201_0809'), + ] + + operations = [ + migrations.AddField( + model_name='oncallschedulecalendar', + name='enable_web_overrides', + field=models.BooleanField(default=False, null=True), + ), + ] diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index 1a06968f..4e01ef61 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -11,7 +11,6 @@ from django.db import models from django.db.utils import DatabaseError from django.utils import timezone from django.utils.functional import cached_property -from icalendar.cal import Calendar from polymorphic.managers import PolymorphicManager from polymorphic.models import PolymorphicModel from polymorphic.query import PolymorphicQuerySet @@ -403,6 +402,83 @@ class OnCallSchedule(PolymorphicModel): events = merged return events + def _generate_ical_file_from_shifts(self, qs, extra_shifts=None, allow_empty_users=False): + """Generate iCal events file from custom on-call shifts.""" + # default to empty string since it is not possible to have a no-events ical file + ical = "" + if qs.exists() or extra_shifts is not None: + if extra_shifts is None: + extra_shifts = [] + end_line = "END:VCALENDAR" + calendar = icalendar.Calendar() + calendar.add("prodid", "-//web schedule//oncall//") + calendar.add("version", "2.0") + calendar.add("method", "PUBLISH") + ical_file = calendar.to_ical().decode() + ical = ical_file.replace(end_line, "").strip() + ical = f"{ical}\r\n" + for event in itertools.chain(qs.all(), extra_shifts): + ical += event.convert_to_ical(self.time_zone, allow_empty_users=allow_empty_users) + ical += f"{end_line}\r\n" + return ical + + def preview_shift(self, custom_shift, user_tz, starting_date, days, updated_shift_pk=None): + """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 + + extra_shifts = [custom_shift] + if updated_shift_pk is not None: + try: + update_shift = qs.get(public_primary_key=updated_shift_pk) + except CustomOnCallShift.DoesNotExist: + pass + else: + if update_shift.event_is_started: + custom_shift.rotation_start = max( + custom_shift.rotation_start, timezone.now().replace(microsecond=0) + ) + custom_shift.start_rotation_from_user_index = update_shift.start_rotation_from_user_index + update_shift.until = custom_shift.rotation_start + extra_shifts.append(update_shift) + else: + # only reuse PK for preview when updating a rotation that won't be started after the update + custom_shift.public_primary_key = updated_shift_pk + qs = qs.exclude(public_primary_key=updated_shift_pk) + + ical_file = self._generate_ical_file_from_shifts(qs, extra_shifts=extra_shifts, allow_empty_users=True) + + 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) + # return preview events for affected shifts + updated_shift_pks = {s.public_primary_key for s in extra_shifts} + shift_events = [e for e in events if e["shift"]["pk"] in updated_shift_pks] + final_events = self._resolve_schedule(events) + + _invalidate_cache(self, ical_property) + setattr(self, ical_attr, original_value) + + return shift_events, final_events + # Insight logs @property def insight_logs_verbal(self): @@ -519,6 +595,8 @@ class OnCallScheduleCalendar(OnCallSchedule): time_zone = models.CharField(max_length=100, default="UTC") custom_on_call_shifts = models.ManyToManyField("schedules.CustomOnCallShift", related_name="schedules") + enable_web_overrides = models.BooleanField(default=False, null=True) + @cached_property def _ical_file_primary(self): """ @@ -534,14 +612,11 @@ class OnCallScheduleCalendar(OnCallSchedule): """ Download iCal file imported from calendar """ - cached_ical_file = self.cached_ical_file_overrides - if self.ical_url_overrides is not None and self.cached_ical_file_overrides is None: - self.cached_ical_file_overrides, self.ical_file_error_overrides = fetch_ical_file_or_get_error( - self.ical_url_overrides - ) - self.save(update_fields=["cached_ical_file_overrides", "ical_file_error_overrides"]) - cached_ical_file = self.cached_ical_file_overrides - return cached_ical_file + if self.cached_ical_file_overrides is not None: + return self.cached_ical_file_overrides + + self._refresh_overrides_ical_file() + return self.cached_ical_file_overrides def _refresh_primary_ical_file(self): self.prev_ical_file_primary = self.cached_ical_file_primary @@ -555,10 +630,16 @@ class OnCallScheduleCalendar(OnCallSchedule): def _refresh_overrides_ical_file(self): self.prev_ical_file_overrides = self.cached_ical_file_overrides - if self.ical_url_overrides is not None: + + if self.enable_web_overrides: + # web overrides + qs = self.custom_shifts.filter(type=CustomOnCallShift.TYPE_OVERRIDE) + self.cached_ical_file_overrides = self._generate_ical_file_from_shifts(qs) + elif self.ical_url_overrides is not None: self.cached_ical_file_overrides, self.ical_file_error_overrides = fetch_ical_file_or_get_error( self.ical_url_overrides, ) + self.save(update_fields=["cached_ical_file_overrides", "prev_ical_file_overrides", "ical_file_error_overrides"]) def _generate_ical_file_primary(self): @@ -569,7 +650,7 @@ class OnCallScheduleCalendar(OnCallSchedule): ical = "" if self.custom_on_call_shifts.exists(): end_line = "END:VCALENDAR" - calendar = Calendar() + calendar = icalendar.Calendar() calendar.add("prodid", "-//My calendar product//amixr//") calendar.add("version", "2.0") calendar.add("method", "PUBLISH") @@ -581,6 +662,12 @@ class OnCallScheduleCalendar(OnCallSchedule): ical += f"{end_line}\r\n" return ical + def preview_shift(self, custom_shift, user_tz, starting_date, days, updated_shift_pk=None): + """Return unsaved rotation and final schedule preview events.""" + if custom_shift.type != CustomOnCallShift.TYPE_OVERRIDE: + raise ValueError("Invalid shift type") + return super().preview_shift(custom_shift, user_tz, starting_date, days, updated_shift_pk=updated_shift_pk) + @property def insight_logs_type_verbal(self): return "calendar_schedule" @@ -595,26 +682,6 @@ class OnCallScheduleCalendar(OnCallSchedule): class OnCallScheduleWeb(OnCallSchedule): time_zone = models.CharField(max_length=100, default="UTC") - def _generate_ical_file_from_shifts(self, qs, extra_shifts=None, allow_empty_users=False): - """Generate iCal events file from custom on-call shifts.""" - # default to empty string since it is not possible to have a no-events ical file - ical = "" - 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//") - calendar.add("version", "2.0") - calendar.add("method", "PUBLISH") - ical_file = calendar.to_ical().decode() - ical = ical_file.replace(end_line, "").strip() - ical = f"{ical}\r\n" - for event in itertools.chain(qs.all(), extra_shifts): - ical += event.convert_to_ical(self.time_zone, allow_empty_users=allow_empty_users) - ical += f"{end_line}\r\n" - return ical - def _generate_ical_file_primary(self): qs = self.custom_shifts.exclude(type=CustomOnCallShift.TYPE_OVERRIDE) return self._generate_ical_file_from_shifts(qs) @@ -673,63 +740,6 @@ class OnCallScheduleWeb(OnCallSchedule): ) return users - def preview_shift(self, custom_shift, user_tz, starting_date, days, updated_shift_pk=None): - """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 - - extra_shifts = [custom_shift] - if updated_shift_pk is not None: - try: - update_shift = qs.get(public_primary_key=updated_shift_pk) - except CustomOnCallShift.DoesNotExist: - pass - else: - if update_shift.event_is_started: - custom_shift.rotation_start = max( - custom_shift.rotation_start, timezone.now().replace(microsecond=0) - ) - custom_shift.start_rotation_from_user_index = update_shift.start_rotation_from_user_index - update_shift.until = custom_shift.rotation_start - extra_shifts.append(update_shift) - else: - # only reuse PK for preview when updating a rotation that won't be started after the update - custom_shift.public_primary_key = updated_shift_pk - qs = qs.exclude(public_primary_key=updated_shift_pk) - - ical_file = self._generate_ical_file_from_shifts(qs, extra_shifts=extra_shifts, allow_empty_users=True) - - 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) - # return preview events for affected shifts - updated_shift_pks = {s.public_primary_key for s in extra_shifts} - shift_events = [e for e in events if e["shift"]["pk"] in updated_shift_pks] - final_events = self._resolve_schedule(events) - - _invalidate_cache(self, ical_property) - setattr(self, ical_attr, original_value) - - return shift_events, final_events - # Insight logs @property def insight_logs_type_verbal(self): diff --git a/engine/apps/schedules/tests/test_on_call_schedule.py b/engine/apps/schedules/tests/test_on_call_schedule.py index 208bf760..74418275 100644 --- a/engine/apps/schedules/tests/test_on_call_schedule.py +++ b/engine/apps/schedules/tests/test_on_call_schedule.py @@ -1,4 +1,5 @@ import datetime +from unittest.mock import patch import pytest import pytz @@ -997,3 +998,116 @@ def test_schedules_ical_shift_cache(make_organization, make_schedule): # after the refresh, cached value is updated # (not None means no need to refresh cached value) assert schedule.cached_ical_file_primary == "" + + +@pytest.mark.django_db +def test_api_schedule_use_overrides_from_url(make_organization, make_schedule, get_ical): + ical_file = get_ical("calendar_with_recurring_event.ics") + ical_data = ical_file.to_ical().decode("utf-8") + organization = make_organization() + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleCalendar, + ical_url_overrides="http://some-url", + ) + assert schedule.enable_web_overrides is False + + with patch("apps.schedules.models.on_call_schedule.fetch_ical_file_or_get_error") as mock_fetch_ical: + mock_fetch_ical.return_value = (ical_data, None) + schedule.refresh_ical_file() + + schedule.refresh_from_db() + assert schedule.cached_ical_file_overrides == ical_data + + +@pytest.mark.django_db +def test_api_schedule_use_overrides_from_db(make_organization, make_schedule, make_on_call_shift): + organization = make_organization() + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleCalendar, + ical_url_overrides=None, + enable_web_overrides=True, + ) + now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + override = make_on_call_shift( + organization=organization, + shift_type=CustomOnCallShift.TYPE_OVERRIDE, + priority_level=1, + start=now, + rotation_start=now, + duration=timezone.timedelta(minutes=30), + source=CustomOnCallShift.SOURCE_WEB, + schedule=schedule, + ) + + schedule.refresh_ical_file() + + ical_event = override.convert_to_ical() + assert ical_event in schedule.cached_ical_file_overrides + + +@pytest.mark.django_db +def test_api_schedule_ignores_overrides_from_url( + make_organization, make_user_for_organization, make_schedule, make_on_call_shift, get_ical +): + ical_file = get_ical("calendar_with_recurring_event.ics") + ical_data = ical_file.to_ical().decode("utf-8") + organization = make_organization() + user_1 = make_user_for_organization(organization) + user_2 = make_user_for_organization(organization) + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleCalendar, + ical_url_overrides="http://some-url", + enable_web_overrides=True, + ) + now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + override = make_on_call_shift( + organization=organization, + shift_type=CustomOnCallShift.TYPE_OVERRIDE, + priority_level=1, + start=now, + rotation_start=now, + duration=timezone.timedelta(minutes=30), + source=CustomOnCallShift.SOURCE_WEB, + schedule=schedule, + ) + override.add_rolling_users([[user_1, user_2]]) + + with patch("apps.schedules.models.on_call_schedule.fetch_ical_file_or_get_error") as mock_fetch_ical: + mock_fetch_ical.return_value = (ical_data, None) + schedule.refresh_ical_file() + + schedule.refresh_from_db() + + # events coming from ical file are not in the final ical file + for component in ical_file.walk(): + if component.name == "VEVENT": + assert component.to_ical().decode("utf-8") not in schedule.cached_ical_file_overrides + # only the event coming from the override shift + ical_event = override.convert_to_ical() + assert ical_event in schedule.cached_ical_file_overrides + + +@pytest.mark.django_db +def test_api_schedule_preview_requires_override(make_organization, make_schedule, make_on_call_shift): + organization = make_organization() + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleCalendar, + ) + now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + non_override_shift = make_on_call_shift( + organization=organization, + shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, + priority_level=1, + start=now, + rotation_start=now, + duration=timezone.timedelta(minutes=30), + source=CustomOnCallShift.SOURCE_WEB, + schedule=schedule, + ) + + with pytest.raises(ValueError): + schedule.preview_shift(non_override_shift, "UTC", now, 1) diff --git a/grafana-plugin/src/containers/Rotations/Rotations.tsx b/grafana-plugin/src/containers/Rotations/Rotations.tsx index 882fdebb..a70b649a 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.tsx +++ b/grafana-plugin/src/containers/Rotations/Rotations.tsx @@ -104,7 +104,7 @@ class Rotations extends Component { {disabled ? ( isTypeReadOnly ? ( - +
{isTypeReadOnly ? ( - +