diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cc744d0..bcaa718d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update notification text for "You're going on call" push notifications to include information about the shift start and end times by @joeyorlando ([#2131](https://github.com/grafana/oncall/pull/2131)) +### Fixed + +- Handle non-UTC UNTIL datetime value when repeating ical events [#2241](https://github.com/grafana/oncall/pull/2241) + ## v1.2.44 (2023-06-14) ### Added diff --git a/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py b/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py index 37bc56b0..873a399f 100644 --- a/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py +++ b/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py @@ -1,4 +1,5 @@ import datetime +import re import typing from collections import defaultdict @@ -30,6 +31,8 @@ class AmixrUnfoldableCalendar(UnfoldableCalendar): """ class RepeatedEvent(UnfoldableCalendar.RepeatedEvent): + RE_DATETIME_VALUE = re.compile(r"\d+T\d+") + class Repetition(UnfoldableCalendar.RepeatedEvent.Repetition): """ A repetition of an event. Overridden version of @@ -40,6 +43,26 @@ class AmixrUnfoldableCalendar(UnfoldableCalendar): ATTRIBUTES_TO_DELETE_ON_COPY = ["RDATE", "EXDATE"] + def create_rule_with_start(self, rule_string, start): + """Override to handle issue with non-UTC UNTIL value including time information.""" + try: + return super().create_rule_with_start(rule_string, start) + except ValueError: + # string: FREQ=WEEKLY;UNTIL=20191023T100000;BYDAY=TH;WKST=SU + # ValueError: RRULE UNTIL values must be specified in UTC when DTSTART is timezone-aware + # https://stackoverflow.com/a/49991809 + rule_list = rule_string.split(";UNTIL=") + assert len(rule_list) == 2 + date_end_index = rule_list[1].find(";") + if date_end_index == -1: + date_end_index = len(rule_list[1]) + until_string = rule_list[1][:date_end_index] + if self.RE_DATETIME_VALUE.match(until_string): + rule_string = rule_list[0] + rule_list[1][date_end_index:] + ";UNTIL=" + until_string + "Z" + return super().create_rule_with_start(rule_string, self.start) + # otherwise, keep raising + raise + def between(self, start, stop): """Return events at a time between start (inclusive) and end (inclusive)""" span_start = self.to_datetime(start) diff --git a/engine/apps/schedules/tests/test_on_call_schedule.py b/engine/apps/schedules/tests/test_on_call_schedule.py index 9e76c662..cf696be8 100644 --- a/engine/apps/schedules/tests/test_on_call_schedule.py +++ b/engine/apps/schedules/tests/test_on_call_schedule.py @@ -1744,3 +1744,39 @@ def test_refresh_ical_final_schedule_all_day_date_event( 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 + + +@pytest.mark.django_db +def test_event_until_non_utc(make_organization, make_schedule): + organization = make_organization() + cached_ical_primary_schedule = textwrap.dedent( + """ + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:testing + CALSCALE:GREGORIAN + BEGIN:VEVENT + CREATED:20220316T121102Z + LAST-MODIFIED:20230127T151619Z + DTSTAMP:20230127T151619Z + UID:something + SUMMARY:testing + RRULE:FREQ=WEEKLY;UNTIL=20221231T010101 + DTSTART;TZID=Europe/Madrid:20220309T130000 + DTEND;TZID=Europe/Madrid:20220309T133000 + SEQUENCE:4 + END:VEVENT + END:VCALENDAR + """ + ) + + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleICal, + cached_ical_file_primary=cached_ical_primary_schedule, + ) + + now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + + # check this works without raising exception + schedule.final_events("UTC", now, days=7)