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:
parent
b6615c087f
commit
cebfec5ef9
16 changed files with 302 additions and 104 deletions
6
.github/workflows/integration_tests.yml
vendored
6
.github/workflows/integration_tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue