Rework schedule ical export (#1783)
Related to #1501. Behind a feature flag, will migrate existing exports to use the new ical export transparently.
This commit is contained in:
parent
6cff9729d8
commit
017d98efad
13 changed files with 457 additions and 11 deletions
|
|
@ -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
|
||||
|
||||
### Changed
|
||||
|
||||
- Rework ical schedule export to include final events; also improve changing shifts sync
|
||||
|
||||
## v1.2.12 (2023-04-18)
|
||||
|
||||
### Changed
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@ class ScheduleView(
|
|||
# avoid requesting large text fields which are not used when listing schedules
|
||||
"prev_ical_file_primary",
|
||||
"prev_ical_file_overrides",
|
||||
"cached_ical_final_schedule",
|
||||
)
|
||||
if not ignore_filtering_by_available_teams:
|
||||
queryset = queryset.filter(*self.available_teams_lookup_args).distinct()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from rest_framework import status
|
|||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.auth_token.models import ScheduleExportAuthToken, UserScheduleExportAuthToken
|
||||
from apps.schedules.constants import ICAL_COMPONENT_VEVENT, ICAL_SUMMARY
|
||||
from apps.schedules.models import OnCallScheduleICal
|
||||
|
||||
ICAL_DATA = """
|
||||
|
|
@ -48,9 +49,13 @@ END:VCALENDAR
|
|||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_export_calendar(make_organization_and_user_with_token, make_schedule):
|
||||
def test_export_calendar(make_organization_and_user_with_token, make_user_for_organization, make_schedule):
|
||||
|
||||
organization, user, _ = make_organization_and_user_with_token()
|
||||
usernames = {"amixr", "justin.hunthrop@grafana.com"}
|
||||
# setup users for shifts
|
||||
for u in usernames:
|
||||
make_user_for_organization(organization, username=u)
|
||||
|
||||
schedule = make_schedule(
|
||||
organization,
|
||||
|
|
@ -75,7 +80,11 @@ def test_export_calendar(make_organization_and_user_with_token, make_schedule):
|
|||
cal = Calendar.from_ical(response.data)
|
||||
|
||||
assert type(cal) == Calendar
|
||||
assert len(cal.subcomponents) == 2
|
||||
# check there are events
|
||||
assert len(cal.subcomponents) > 0
|
||||
for component in cal.walk():
|
||||
if component.name == ICAL_COMPONENT_VEVENT:
|
||||
assert component[ICAL_SUMMARY] in usernames
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
|
|||
|
|
@ -38,7 +38,12 @@ class OnCallScheduleChannelView(RateLimitHeadersMixin, UpdateSerializerMixin, Mo
|
|||
def get_queryset(self):
|
||||
name = self.request.query_params.get("name", None)
|
||||
|
||||
queryset = OnCallSchedule.objects.filter(organization=self.request.auth.organization)
|
||||
queryset = OnCallSchedule.objects.filter(organization=self.request.auth.organization).defer(
|
||||
# avoid requesting large text fields which are not used when listing schedules
|
||||
"prev_ical_file_primary",
|
||||
"prev_ical_file_overrides",
|
||||
"cached_ical_final_schedule",
|
||||
)
|
||||
|
||||
if name is not None:
|
||||
queryset = queryset.filter(name=name)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,13 @@ ICAL_ATTENDEE = "ATTENDEE"
|
|||
ICAL_UID = "UID"
|
||||
ICAL_RRULE = "RRULE"
|
||||
ICAL_UNTIL = "UNTIL"
|
||||
ICAL_LAST_MODIFIED = "LAST-MODIFIED"
|
||||
ICAL_STATUS = "STATUS"
|
||||
ICAL_STATUS_CANCELLED = "CANCELLED"
|
||||
ICAL_COMPONENT_VEVENT = "VEVENT"
|
||||
RE_PRIORITY = re.compile(r"^\[L(\d+)\]")
|
||||
RE_EVENT_UID_V1 = re.compile(r"amixr-([\w\d-]+)-U(\d+)-E(\d+)-S(\d+)")
|
||||
RE_EVENT_UID_V2 = re.compile(r"oncall-([\w\d-]+)-PK([\w\d]+)-U(\d+)-E(\d+)-S(\d+)")
|
||||
|
||||
EXPORT_WINDOW_DAYS_AFTER = 180
|
||||
EXPORT_WINDOW_DAYS_BEFORE = 15
|
||||
|
|
|
|||
|
|
@ -601,6 +601,7 @@ def create_base_icalendar(name: str) -> Calendar:
|
|||
cal.add("calscale", "GREGORIAN")
|
||||
cal.add("x-wr-calname", name)
|
||||
cal.add("x-wr-timezone", "UTC")
|
||||
cal.add("version", "2.0")
|
||||
cal.add("prodid", "//Grafana Labs//Grafana On-Call//")
|
||||
|
||||
return cal
|
||||
|
|
@ -614,7 +615,7 @@ def get_events_from_calendars(ical_obj: Calendar, calendars: tuple) -> None:
|
|||
ical_obj.add_component(component)
|
||||
|
||||
|
||||
def get_user_events_from_calendars(ical_obj: Calendar, calendars: tuple, user: User) -> None:
|
||||
def get_user_events_from_calendars(ical_obj: Calendar, calendars: tuple, user: User, name: str = None) -> None:
|
||||
for calendar in calendars:
|
||||
if calendar:
|
||||
for component in calendar.walk():
|
||||
|
|
@ -622,14 +623,41 @@ def get_user_events_from_calendars(ical_obj: Calendar, calendars: tuple, user: U
|
|||
event_user = get_usernames_from_ical_event(component)
|
||||
event_user_value = event_user[0][0]
|
||||
if event_user_value == user.username or event_user_value.lower() == user.email.lower():
|
||||
if name:
|
||||
component["SUMMARY"] = "{}: {}".format(name, component["SUMMARY"])
|
||||
ical_obj.add_component(component)
|
||||
|
||||
|
||||
def _is_final_export_enabled(schedule: OnCallSchedule) -> bool:
|
||||
DynamicSetting = apps.get_model("base", "DynamicSetting")
|
||||
enabled_final_export = DynamicSetting.objects.get_or_create(
|
||||
name="enabled_final_schedule_export",
|
||||
defaults={
|
||||
"json_value": {
|
||||
"schedule_ids": [],
|
||||
}
|
||||
},
|
||||
)[0]
|
||||
return schedule.public_primary_key in enabled_final_export.json_value["schedule_ids"]
|
||||
|
||||
|
||||
def _get_ical_data_final_schedule(schedule: OnCallSchedule) -> str:
|
||||
ical_data = schedule.cached_ical_final_schedule
|
||||
if ical_data is None:
|
||||
schedule.refresh_ical_final_schedule()
|
||||
ical_data = schedule.cached_ical_final_schedule
|
||||
return ical_data
|
||||
|
||||
|
||||
def ical_export_from_schedule(schedule: OnCallSchedule) -> bytes:
|
||||
calendars = schedule.get_icalendars()
|
||||
ical_obj = create_base_icalendar(schedule.name)
|
||||
get_events_from_calendars(ical_obj, calendars)
|
||||
return ical_obj.to_ical()
|
||||
if _is_final_export_enabled(schedule):
|
||||
ical_data = _get_ical_data_final_schedule(schedule)
|
||||
return ical_data.encode()
|
||||
else:
|
||||
calendars = schedule.get_icalendars()
|
||||
ical_obj = create_base_icalendar(schedule.name)
|
||||
get_events_from_calendars(ical_obj, calendars)
|
||||
return ical_obj.to_ical()
|
||||
|
||||
|
||||
def user_ical_export(user: User, schedules: list[OnCallSchedule]) -> bytes:
|
||||
|
|
@ -637,8 +665,14 @@ def user_ical_export(user: User, schedules: list[OnCallSchedule]) -> bytes:
|
|||
ical_obj = create_base_icalendar(schedule_name)
|
||||
|
||||
for schedule in schedules:
|
||||
calendars = schedule.get_icalendars()
|
||||
get_user_events_from_calendars(ical_obj, calendars, user)
|
||||
if _is_final_export_enabled(schedule):
|
||||
name = schedule.name
|
||||
ical_data = _get_ical_data_final_schedule(schedule)
|
||||
calendars = [Calendar.from_ical(ical_data)]
|
||||
else:
|
||||
name = None
|
||||
calendars = schedule.get_icalendars()
|
||||
get_user_events_from_calendars(ical_obj, calendars, user, name=name)
|
||||
|
||||
return ical_obj.to_ical()
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.2.18 on 2023-04-11 19:03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('schedules', '0010_fix_polymorphic_delete_related'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='oncallschedule',
|
||||
name='cached_ical_final_schedule',
|
||||
field=models.TextField(default=None, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -21,6 +21,7 @@ from recurring_ical_events import UnfoldableCalendar
|
|||
|
||||
from apps.schedules.tasks import (
|
||||
drop_cached_ical_task,
|
||||
refresh_ical_final_schedule,
|
||||
schedule_notify_about_empty_shifts_in_schedule,
|
||||
schedule_notify_about_gaps_in_schedule,
|
||||
)
|
||||
|
|
@ -670,6 +671,7 @@ class CustomOnCallShift(models.Model):
|
|||
drop_cached_ical_task.apply_async((schedule.pk,))
|
||||
schedule_notify_about_empty_shifts_in_schedule.apply_async((schedule.pk,))
|
||||
schedule_notify_about_gaps_in_schedule.apply_async((schedule.pk,))
|
||||
refresh_ical_final_schedule.apply_async((schedule.pk,))
|
||||
|
||||
@cached_property
|
||||
def last_updated_shift(self):
|
||||
|
|
|
|||
|
|
@ -19,7 +19,21 @@ from polymorphic.managers import PolymorphicManager
|
|||
from polymorphic.models import PolymorphicModel
|
||||
from polymorphic.query import PolymorphicQuerySet
|
||||
|
||||
from apps.schedules.constants import (
|
||||
EXPORT_WINDOW_DAYS_AFTER,
|
||||
EXPORT_WINDOW_DAYS_BEFORE,
|
||||
ICAL_COMPONENT_VEVENT,
|
||||
ICAL_DATETIME_END,
|
||||
ICAL_DATETIME_STAMP,
|
||||
ICAL_DATETIME_START,
|
||||
ICAL_LAST_MODIFIED,
|
||||
ICAL_STATUS,
|
||||
ICAL_STATUS_CANCELLED,
|
||||
ICAL_SUMMARY,
|
||||
ICAL_UID,
|
||||
)
|
||||
from apps.schedules.ical_utils import (
|
||||
create_base_icalendar,
|
||||
fetch_ical_file_or_get_error,
|
||||
get_oncall_users_for_multiple_schedules,
|
||||
list_of_empty_shifts_in_schedule,
|
||||
|
|
@ -107,6 +121,8 @@ class OnCallSchedule(PolymorphicModel):
|
|||
cached_ical_file_overrides = models.TextField(null=True, default=None)
|
||||
prev_ical_file_overrides = models.TextField(null=True, default=None)
|
||||
|
||||
cached_ical_final_schedule = models.TextField(null=True, default=None)
|
||||
|
||||
organization = models.ForeignKey(
|
||||
"user_management.Organization", on_delete=NON_POLYMORPHIC_CASCADE, related_name="oncall_schedules"
|
||||
)
|
||||
|
|
@ -295,6 +311,63 @@ class OnCallSchedule(PolymorphicModel):
|
|||
events = self._resolve_schedule(events)
|
||||
return events
|
||||
|
||||
def refresh_ical_final_schedule(self):
|
||||
# TODO: check flag?
|
||||
tz = "UTC"
|
||||
now = timezone.now()
|
||||
# window to consider: from now, -15 days + 6 months
|
||||
delta = EXPORT_WINDOW_DAYS_BEFORE
|
||||
starting_datetime = now - timezone.timedelta(days=delta)
|
||||
starting_date = starting_datetime.date()
|
||||
days = EXPORT_WINDOW_DAYS_AFTER + delta
|
||||
|
||||
# setup calendar with final schedule shift events
|
||||
calendar = create_base_icalendar(self.name)
|
||||
events = self.final_events(tz, starting_date, days)
|
||||
updated_ids = set()
|
||||
for e in events:
|
||||
for u in e["users"]:
|
||||
event = icalendar.Event()
|
||||
event.add(ICAL_SUMMARY, u["display_name"])
|
||||
event.add(ICAL_DATETIME_START, e["start"])
|
||||
event.add(ICAL_DATETIME_END, e["end"])
|
||||
event.add(ICAL_DATETIME_STAMP, now)
|
||||
event.add(ICAL_LAST_MODIFIED, now)
|
||||
event_uid = "{}-{}-{}".format(e["shift"]["pk"], e["start"].strftime("%Y%m%d%H%S"), u["pk"])
|
||||
event[ICAL_UID] = event_uid
|
||||
calendar.add_component(event)
|
||||
updated_ids.add(event_uid)
|
||||
|
||||
# check previously cached final schedule for potentially cancelled events
|
||||
if self.cached_ical_final_schedule:
|
||||
previous = icalendar.Calendar.from_ical(self.cached_ical_final_schedule)
|
||||
for component in previous.walk():
|
||||
if component.name == ICAL_COMPONENT_VEVENT and component[ICAL_UID] not in updated_ids:
|
||||
# check if event was ended or cancelled, update ical
|
||||
dtend = component.get(ICAL_DATETIME_END)
|
||||
if dtend and dtend.dt < starting_datetime:
|
||||
# event ended before window start
|
||||
continue
|
||||
is_cancelled = component.get(ICAL_STATUS)
|
||||
last_modified = component.get(ICAL_LAST_MODIFIED)
|
||||
if is_cancelled and last_modified and last_modified.dt < starting_datetime:
|
||||
# drop already ended events older than the window we consider
|
||||
continue
|
||||
elif is_cancelled and not last_modified:
|
||||
# set last_modified if it was missing (e.g. from previous export ical implementation)
|
||||
component[ICAL_LAST_MODIFIED] = icalendar.vDatetime(now).to_ical()
|
||||
elif not is_cancelled:
|
||||
# set the event as cancelled
|
||||
component[ICAL_DATETIME_END] = component[ICAL_DATETIME_START]
|
||||
component[ICAL_STATUS] = ICAL_STATUS_CANCELLED
|
||||
component[ICAL_LAST_MODIFIED] = icalendar.vDatetime(now).to_ical()
|
||||
# include just cancelled events as well as those that were cancelled during the time window
|
||||
calendar.add_component(component)
|
||||
|
||||
ical_data = calendar.to_ical().decode()
|
||||
self.cached_ical_final_schedule = ical_data
|
||||
self.save(update_fields=["cached_ical_final_schedule"])
|
||||
|
||||
def upcoming_shift_for_user(self, user, days=7):
|
||||
user_tz = user.timezone or "UTC"
|
||||
now = timezone.now()
|
||||
|
|
|
|||
|
|
@ -13,4 +13,9 @@ from .notify_about_gaps_in_schedule import ( # noqa: F401
|
|||
start_check_gaps_in_schedule,
|
||||
start_notify_about_gaps_in_schedule,
|
||||
)
|
||||
from .refresh_ical_files import refresh_ical_file, start_refresh_ical_files # noqa: F401
|
||||
from .refresh_ical_files import ( # noqa: F401
|
||||
refresh_ical_file,
|
||||
refresh_ical_final_schedule,
|
||||
start_refresh_ical_files,
|
||||
start_refresh_ical_final_schedules,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,17 @@ def start_refresh_ical_files():
|
|||
start_update_slack_user_group_for_schedules.apply_async(countdown=30)
|
||||
|
||||
|
||||
@shared_dedicated_queue_retry_task()
|
||||
def start_refresh_ical_final_schedules():
|
||||
OnCallSchedule = apps.get_model("schedules", "OnCallSchedule")
|
||||
|
||||
task_logger.info("Start refresh ical final schedules")
|
||||
|
||||
schedules = OnCallSchedule.objects.all()
|
||||
for schedule in schedules:
|
||||
refresh_ical_final_schedule.apply_async((schedule.pk,))
|
||||
|
||||
|
||||
@shared_dedicated_queue_retry_task()
|
||||
def refresh_ical_file(schedule_pk):
|
||||
OnCallSchedule = apps.get_model("schedules", "OnCallSchedule")
|
||||
|
|
@ -74,3 +85,17 @@ def refresh_ical_file(schedule_pk):
|
|||
if run_task:
|
||||
notify_about_empty_shifts_in_schedule.apply_async((schedule_pk,))
|
||||
notify_about_gaps_in_schedule.apply_async((schedule_pk,))
|
||||
|
||||
|
||||
@shared_dedicated_queue_retry_task()
|
||||
def refresh_ical_final_schedule(schedule_pk):
|
||||
OnCallSchedule = apps.get_model("schedules", "OnCallSchedule")
|
||||
task_logger.info(f"Refresh ical final schedule {schedule_pk}")
|
||||
|
||||
try:
|
||||
schedule = OnCallSchedule.objects.get(pk=schedule_pk)
|
||||
except OnCallSchedule.DoesNotExist:
|
||||
task_logger.info(f"Tried to refresh final schedule for non-existing schedule {schedule_pk}")
|
||||
return
|
||||
|
||||
schedule.refresh_ical_final_schedule()
|
||||
|
|
|
|||
|
|
@ -1,11 +1,22 @@
|
|||
import datetime
|
||||
import textwrap
|
||||
from unittest.mock import patch
|
||||
|
||||
import icalendar
|
||||
import pytest
|
||||
import pytz
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.api.permissions import LegacyAccessControlRole
|
||||
from apps.schedules.constants import (
|
||||
ICAL_COMPONENT_VEVENT,
|
||||
ICAL_DATETIME_END,
|
||||
ICAL_DATETIME_START,
|
||||
ICAL_LAST_MODIFIED,
|
||||
ICAL_STATUS,
|
||||
ICAL_STATUS_CANCELLED,
|
||||
ICAL_SUMMARY,
|
||||
)
|
||||
from apps.schedules.ical_utils import memoized_users_in_ical
|
||||
from apps.schedules.models import (
|
||||
CustomOnCallShift,
|
||||
|
|
@ -1311,3 +1322,248 @@ def test_upcoming_shift_for_user(
|
|||
current_shift, upcoming_shift = schedule.upcoming_shift_for_user(other_user)
|
||||
assert current_shift is None
|
||||
assert upcoming_shift is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refresh_ical_final_schedule_ok(
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
make_schedule,
|
||||
make_on_call_shift,
|
||||
):
|
||||
organization = make_organization()
|
||||
u1 = make_user_for_organization(organization)
|
||||
u2 = make_user_for_organization(organization)
|
||||
|
||||
today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
|
||||
shifts = (
|
||||
# user, priority, start time (h), duration (seconds)
|
||||
(u1, 1, 0, (12 * 60 * 60) - 1), # r1-1: 0-11:59:59
|
||||
(u2, 1, 12, (12 * 60 * 60) - 1), # r1-1: 12-23:59:59
|
||||
)
|
||||
for user, priority, start_h, duration in shifts:
|
||||
data = {
|
||||
"start": today + timezone.timedelta(hours=start_h),
|
||||
"rotation_start": today + timezone.timedelta(hours=start_h),
|
||||
"duration": timezone.timedelta(seconds=duration),
|
||||
"priority_level": priority,
|
||||
"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]])
|
||||
|
||||
override_data = {
|
||||
"start": today + timezone.timedelta(hours=22),
|
||||
"rotation_start": today + 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([[u1]])
|
||||
schedule.refresh_ical_file()
|
||||
|
||||
expected_events = {
|
||||
# user, start, end
|
||||
(u1.username, today, today + timezone.timedelta(seconds=(12 * 60 * 60) - 1)),
|
||||
(u2.username, today + timezone.timedelta(hours=12), today + timezone.timedelta(hours=22)),
|
||||
(u1.username, today + timezone.timedelta(hours=22), today + timezone.timedelta(hours=23)),
|
||||
(u2.username, today + timezone.timedelta(hours=23), today + timezone.timedelta(seconds=(24 * 60 * 60) - 1)),
|
||||
}
|
||||
|
||||
for i in range(2):
|
||||
# running multiple times keeps the same events in place
|
||||
with patch("apps.schedules.models.on_call_schedule.EXPORT_WINDOW_DAYS_AFTER", 1):
|
||||
with patch("apps.schedules.models.on_call_schedule.EXPORT_WINDOW_DAYS_BEFORE", 0):
|
||||
schedule.refresh_ical_final_schedule()
|
||||
|
||||
assert schedule.cached_ical_final_schedule
|
||||
calendar = icalendar.Calendar.from_ical(schedule.cached_ical_final_schedule)
|
||||
for component in calendar.walk():
|
||||
if component.name == ICAL_COMPONENT_VEVENT:
|
||||
event = (component[ICAL_SUMMARY], component[ICAL_DATETIME_START].dt, component[ICAL_DATETIME_END].dt)
|
||||
assert event in expected_events
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refresh_ical_final_schedule_cancel_deleted_events(
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
make_schedule,
|
||||
make_on_call_shift,
|
||||
):
|
||||
organization = make_organization()
|
||||
u1 = make_user_for_organization(organization)
|
||||
u2 = make_user_for_organization(organization)
|
||||
|
||||
today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
|
||||
shifts = (
|
||||
# user, priority, start time (h), duration (seconds)
|
||||
(u1, 1, 0, (24 * 60 * 60) - 1), # r1-1: 0-23:59:59
|
||||
)
|
||||
for user, priority, start_h, duration in shifts:
|
||||
data = {
|
||||
"start": today + timezone.timedelta(hours=start_h),
|
||||
"rotation_start": today + timezone.timedelta(hours=start_h),
|
||||
"duration": timezone.timedelta(seconds=duration),
|
||||
"priority_level": priority,
|
||||
"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]])
|
||||
|
||||
override_data = {
|
||||
"start": today + timezone.timedelta(hours=22),
|
||||
"rotation_start": today + 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([[u2]])
|
||||
|
||||
# refresh ical files
|
||||
schedule.refresh_ical_file()
|
||||
with patch("apps.schedules.models.on_call_schedule.EXPORT_WINDOW_DAYS_AFTER", 1):
|
||||
with patch("apps.schedules.models.on_call_schedule.EXPORT_WINDOW_DAYS_BEFORE", 0):
|
||||
schedule.refresh_ical_final_schedule()
|
||||
|
||||
# delete override, re-check the final refresh
|
||||
override.delete()
|
||||
|
||||
# reload instance to avoid cached properties issue
|
||||
schedule = OnCallScheduleWeb.objects.get(id=schedule.id)
|
||||
schedule.refresh_ical_file()
|
||||
|
||||
with patch("apps.schedules.models.on_call_schedule.EXPORT_WINDOW_DAYS_AFTER", 1):
|
||||
with patch("apps.schedules.models.on_call_schedule.EXPORT_WINDOW_DAYS_BEFORE", 0):
|
||||
schedule.refresh_ical_final_schedule()
|
||||
|
||||
# check for deleted override
|
||||
calendar = icalendar.Calendar.from_ical(schedule.cached_ical_final_schedule)
|
||||
for component in calendar.walk():
|
||||
if component.name == ICAL_COMPONENT_VEVENT and component[ICAL_SUMMARY] == u2.username:
|
||||
# check event is cancelled
|
||||
assert component[ICAL_DATETIME_START].dt == component[ICAL_DATETIME_END].dt
|
||||
assert component[ICAL_LAST_MODIFIED]
|
||||
assert component[ICAL_STATUS] == ICAL_STATUS_CANCELLED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refresh_ical_final_schedule_cancelled_not_updated(
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
make_schedule,
|
||||
):
|
||||
organization = make_organization()
|
||||
u1 = make_user_for_organization(organization)
|
||||
u2 = make_user_for_organization(organization)
|
||||
last_week = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) - timezone.timedelta(days=7)
|
||||
last_week_timestamp = last_week.strftime("%Y%m%dT%H%M%S")
|
||||
cached_ical_final_schedule = textwrap.dedent(
|
||||
"""
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID://Grafana Labs//Grafana On-Call//
|
||||
CALSCALE:GREGORIAN
|
||||
X-WR-CALNAME:Cup cut.
|
||||
X-WR-TIMEZONE:UTC
|
||||
BEGIN:VEVENT
|
||||
SUMMARY:{}
|
||||
DTSTART;VALUE=DATE-TIME:20220414T000000Z
|
||||
DTEND;VALUE=DATE-TIME:20220414T000000Z
|
||||
DTSTAMP;VALUE=DATE-TIME:20220414T190951Z
|
||||
UID:O231U3VXVIYRX-202304140000-U5FWIHEASEWS2
|
||||
LAST-MODIFIED;VALUE=DATE-TIME:20220414T190951Z
|
||||
STATUS:CANCELLED
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
SUMMARY:{}
|
||||
DTSTART;VALUE=DATE-TIME:{}Z
|
||||
DTEND;VALUE=DATE-TIME:{}Z
|
||||
DTSTAMP;VALUE=DATE-TIME:20230414T190951Z
|
||||
UID:OBPQ1TI99E4DG-202304141200-U2G6RZQM3S3I9
|
||||
LAST-MODIFIED;VALUE=DATE-TIME:{}Z
|
||||
STATUS:CANCELLED
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
""".format(
|
||||
u1.username, u2.username, last_week_timestamp, last_week_timestamp, last_week_timestamp
|
||||
)
|
||||
)
|
||||
|
||||
schedule = make_schedule(
|
||||
organization,
|
||||
schedule_class=OnCallScheduleWeb,
|
||||
cached_ical_final_schedule=cached_ical_final_schedule,
|
||||
)
|
||||
|
||||
schedule.refresh_ical_final_schedule()
|
||||
|
||||
# check old event is dropped, recent one is kept unchanged
|
||||
event_count = 0
|
||||
calendar = icalendar.Calendar.from_ical(schedule.cached_ical_final_schedule)
|
||||
for component in calendar.walk():
|
||||
if component.name == ICAL_COMPONENT_VEVENT:
|
||||
event_count += 1
|
||||
if component[ICAL_SUMMARY] == u2.username:
|
||||
# check event is unchanged
|
||||
assert component[ICAL_DATETIME_START].dt == last_week
|
||||
assert component[ICAL_DATETIME_END].dt == last_week
|
||||
assert component[ICAL_LAST_MODIFIED].dt == last_week
|
||||
assert component[ICAL_STATUS] == ICAL_STATUS_CANCELLED
|
||||
assert event_count == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_refresh_ical_final_schedule_event_in_the_past(
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
make_schedule,
|
||||
):
|
||||
organization = make_organization()
|
||||
u1 = make_user_for_organization(organization)
|
||||
cached_ical_final_schedule = textwrap.dedent(
|
||||
"""
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID://Grafana Labs//Grafana On-Call//
|
||||
CALSCALE:GREGORIAN
|
||||
X-WR-CALNAME:Cup cut.
|
||||
X-WR-TIMEZONE:UTC
|
||||
BEGIN:VEVENT
|
||||
SUMMARY:{}
|
||||
DTSTART;VALUE=DATE-TIME:20220414T000000Z
|
||||
DTEND;VALUE=DATE-TIME:20220414T000000Z
|
||||
DTSTAMP;VALUE=DATE-TIME:20220414T190951Z
|
||||
UID:O231U3VXVIYRX-202304140000-U5FWIHEASEWS2
|
||||
LAST-MODIFIED;VALUE=DATE-TIME:20220414T190951Z
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
""".format(
|
||||
u1.username
|
||||
)
|
||||
)
|
||||
|
||||
schedule = make_schedule(
|
||||
organization,
|
||||
schedule_class=OnCallScheduleWeb,
|
||||
cached_ical_final_schedule=cached_ical_final_schedule,
|
||||
)
|
||||
|
||||
schedule.refresh_ical_final_schedule()
|
||||
|
||||
# check old event is dropped, recent one is kept unchanged
|
||||
calendar = icalendar.Calendar.from_ical(schedule.cached_ical_final_schedule)
|
||||
events = [component for component in calendar.walk() if component.name == ICAL_COMPONENT_VEVENT]
|
||||
assert len(events) == 0
|
||||
|
|
|
|||
|
|
@ -414,6 +414,11 @@ CELERY_BEAT_SCHEDULE = {
|
|||
"schedule": getenv_integer("ALERT_GROUP_ESCALATION_AUDITOR_CELERY_TASK_HEARTBEAT_INTERVAL", 13 * 60),
|
||||
"args": (),
|
||||
},
|
||||
"start_refresh_ical_final_schedules": {
|
||||
"task": "apps.schedules.tasks.refresh_ical_files.start_refresh_ical_final_schedules",
|
||||
"schedule": crontab(minute=15, hour=0),
|
||||
"args": (),
|
||||
},
|
||||
"start_refresh_ical_files": {
|
||||
"task": "apps.schedules.tasks.refresh_ical_files.start_refresh_ical_files",
|
||||
"schedule": 10 * 60,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue