Add support for web overrides to Terraform schedules (#1222)

Related to #828 

- Enable web UI for API/Terraform schedules to add overrides
- Refactor backend to add a flag toggling between web-based and
iCal-based overrides (these options are mutually exclusive)

Also updated read-only tooltips (related to #1483)
This commit is contained in:
Matias Bordese 2023-03-10 13:21:50 -03:00 committed by GitHub
parent b6615c087f
commit cebfec5ef9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 302 additions and 104 deletions

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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)

View file

@ -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()

View file

@ -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(

View file

@ -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

View file

@ -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),
),
]

View file

@ -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):

View file

@ -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)

View file

@ -104,7 +104,7 @@ class Rotations extends Component<RotationsProps, RotationsState> {
</div>
{disabled ? (
isTypeReadOnly ? (
<Tooltip content="Ical and API/Terraform schedules are read-only" placement="top">
<Tooltip content="Ical and API/Terraform rotations are read-only here" placement="top">
<div>
<Button variant="primary" icon="plus" disabled>
Add rotation

View file

@ -12,7 +12,7 @@ import Rotation from 'containers/Rotation/Rotation';
import ScheduleOverrideForm from 'containers/RotationForm/ScheduleOverrideForm';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { getOverrideColor, getOverridesFromStore } from 'models/schedule/schedule.helpers';
import { Schedule, ScheduleType, Shift, ShiftEvents } from 'models/schedule/schedule.types';
import { Schedule, Shift, ShiftEvents } from 'models/schedule/schedule.types';
import { Timezone } from 'models/timezone/timezone.types';
import { getStartOfDay } from 'pages/schedule/Schedule.helpers';
import { WithStoreProps } from 'state/types';
@ -73,8 +73,7 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
const schedule = store.scheduleStore.items[scheduleId];
const isTypeReadOnly =
schedule && (schedule?.type === ScheduleType.Ical || schedule?.type === ScheduleType.Calendar);
const isTypeReadOnly = !schedule?.enable_web_overrides;
return (
<>
@ -87,7 +86,7 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
</Text.Title>
</div>
{isTypeReadOnly ? (
<Tooltip content="Ical and API/Terraform schedules are read-only" placement="top">
<Tooltip content="You can set an override using the override calendar" placement="top">
<div>
<Button variant="primary" icon="plus" disabled>
Add override

View file

@ -131,6 +131,14 @@ export const calendarForm: { name: string; fields: FormItem[] } = {
type: FormItemType.Input,
validation: { required: true },
},
{
name: 'enable_web_overrides',
label: 'Enable web interface overrides ',
type: FormItemType.Switch,
description:
'Allow overrides to be created using the web UI. \n' +
'NOTE: when enabled, iCal URL overrides will be ignored.',
},
{
name: 'ical_url_overrides',
label: 'Overrides schedule iCal URL ',
@ -139,7 +147,8 @@ export const calendarForm: { name: string; fields: FormItem[] } = {
'You can use an override calendar to share with your team members. Users can add \n' +
'events to this calendar, and they will override existing events in the primary \n' +
'calendar. The iCal URL for your override calendar can be found in the calendar \n' +
'integration settings of your calendar service.',
'integration settings of your calendar service. \n' +
'NOTE: web overrides must be disabled to use iCal based overrides',
},
...commonFields,
],

View file

@ -5,6 +5,7 @@ export function prepareForEdit(item: Schedule) {
name: item.name,
ical_url_primary: item.ical_url_primary,
ical_url_overrides: item.ical_url_overrides,
enable_web_overrides: item.enable_web_overrides,
slack_channel_id: item.slack_channel?.id,
user_group: item.user_group?.id,
send_empty_shifts_report: item.send_empty_shifts_report,

View file

@ -35,6 +35,7 @@ export interface Schedule {
mention_oncall_start: boolean;
notify_empty_oncall: number;
number_of_escalation_chains: number;
enable_web_overrides: boolean;
}
export interface ScheduleEvent {

View file

@ -116,11 +116,15 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
const users = store.userStore.getSearchResult().results;
const schedule = scheduleStore.items[scheduleId];
const disabled =
const disabledRotationForm =
!isUserActionAllowed(UserActions.SchedulesWrite) ||
schedule?.type !== ScheduleType.API ||
shiftIdToShowRotationForm ||
shiftIdToShowOverridesForm;
!!shiftIdToShowRotationForm;
const disabledOverrideForm =
!isUserActionAllowed(UserActions.SchedulesWrite) ||
!schedule?.enable_web_overrides ||
!!shiftIdToShowOverridesForm;
return (
<PageErrorHandlingWrapper errorData={errorData} objectName="schedule" pageName="schedules">
@ -237,7 +241,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
currentTimezone={currentTimezone}
startMoment={startMoment}
onClick={this.handleShowForm}
disabled={disabled}
disabled={disabledRotationForm}
/>
<Rotations
scheduleId={scheduleId}
@ -248,7 +252,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
onDelete={this.handleDeleteRotation}
shiftIdToShowRotationForm={shiftIdToShowRotationForm}
onShowRotationForm={this.handleShowRotationForm}
disabled={disabled}
disabled={disabledRotationForm}
/>
<ScheduleOverrides
scheduleId={scheduleId}
@ -259,7 +263,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
onDelete={this.handleDeleteOverride}
shiftIdToShowRotationForm={shiftIdToShowOverridesForm}
onShowRotationForm={this.handleShowOverridesForm}
disabled={disabled}
disabled={disabledOverrideForm}
/>
</div>
</VerticalGroup>