From 017d98efad6faf0d39daed45640c6a35a6ed8165 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Tue, 18 Apr 2023 14:07:11 -0300 Subject: [PATCH] Rework schedule ical export (#1783) Related to #1501. Behind a feature flag, will migrate existing exports to use the new ical export transparently. --- CHANGELOG.md | 6 + engine/apps/api/views/schedule.py | 1 + .../public_api/tests/test_schedule_export.py | 13 +- engine/apps/public_api/views/schedules.py | 7 +- engine/apps/schedules/constants.py | 7 + engine/apps/schedules/ical_utils.py | 48 +++- ...callschedule_cached_ical_final_schedule.py | 18 ++ .../schedules/models/custom_on_call_shift.py | 2 + .../apps/schedules/models/on_call_schedule.py | 73 +++++ engine/apps/schedules/tasks/__init__.py | 7 +- .../schedules/tasks/refresh_ical_files.py | 25 ++ .../schedules/tests/test_on_call_schedule.py | 256 ++++++++++++++++++ engine/settings/base.py | 5 + 13 files changed, 457 insertions(+), 11 deletions(-) create mode 100644 engine/apps/schedules/migrations/0011_oncallschedule_cached_ical_final_schedule.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 098385de..2eef5c06 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 + +### Changed + +- Rework ical schedule export to include final events; also improve changing shifts sync + ## v1.2.12 (2023-04-18) ### Changed diff --git a/engine/apps/api/views/schedule.py b/engine/apps/api/views/schedule.py index 9916c9d1..38564156 100644 --- a/engine/apps/api/views/schedule.py +++ b/engine/apps/api/views/schedule.py @@ -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() diff --git a/engine/apps/public_api/tests/test_schedule_export.py b/engine/apps/public_api/tests/test_schedule_export.py index f24b25a5..b457f281 100644 --- a/engine/apps/public_api/tests/test_schedule_export.py +++ b/engine/apps/public_api/tests/test_schedule_export.py @@ -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 diff --git a/engine/apps/public_api/views/schedules.py b/engine/apps/public_api/views/schedules.py index 9ae3752e..39d5d26d 100644 --- a/engine/apps/public_api/views/schedules.py +++ b/engine/apps/public_api/views/schedules.py @@ -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) diff --git a/engine/apps/schedules/constants.py b/engine/apps/schedules/constants.py index a2ec8adc..1cb07749 100644 --- a/engine/apps/schedules/constants.py +++ b/engine/apps/schedules/constants.py @@ -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 diff --git a/engine/apps/schedules/ical_utils.py b/engine/apps/schedules/ical_utils.py index 45032c98..bb1d98ff 100644 --- a/engine/apps/schedules/ical_utils.py +++ b/engine/apps/schedules/ical_utils.py @@ -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() diff --git a/engine/apps/schedules/migrations/0011_oncallschedule_cached_ical_final_schedule.py b/engine/apps/schedules/migrations/0011_oncallschedule_cached_ical_final_schedule.py new file mode 100644 index 00000000..fe5b8a88 --- /dev/null +++ b/engine/apps/schedules/migrations/0011_oncallschedule_cached_ical_final_schedule.py @@ -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), + ), + ] diff --git a/engine/apps/schedules/models/custom_on_call_shift.py b/engine/apps/schedules/models/custom_on_call_shift.py index 6003a5d0..d1905a22 100644 --- a/engine/apps/schedules/models/custom_on_call_shift.py +++ b/engine/apps/schedules/models/custom_on_call_shift.py @@ -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): diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index cf03c647..b21ca26d 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -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() diff --git a/engine/apps/schedules/tasks/__init__.py b/engine/apps/schedules/tasks/__init__.py index 9bc75ff5..a5db45aa 100644 --- a/engine/apps/schedules/tasks/__init__.py +++ b/engine/apps/schedules/tasks/__init__.py @@ -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, +) diff --git a/engine/apps/schedules/tasks/refresh_ical_files.py b/engine/apps/schedules/tasks/refresh_ical_files.py index 20204359..18b78936 100644 --- a/engine/apps/schedules/tasks/refresh_ical_files.py +++ b/engine/apps/schedules/tasks/refresh_ical_files.py @@ -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() diff --git a/engine/apps/schedules/tests/test_on_call_schedule.py b/engine/apps/schedules/tests/test_on_call_schedule.py index c45a10bc..ac0b4037 100644 --- a/engine/apps/schedules/tests/test_on_call_schedule.py +++ b/engine/apps/schedules/tests/test_on_call_schedule.py @@ -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 diff --git a/engine/settings/base.py b/engine/settings/base.py index 5640d6e5..dd366e97 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -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,