diff --git a/awx/main/models/schedules.py b/awx/main/models/schedules.py index 051305803c..4fbe74048b 100644 --- a/awx/main/models/schedules.py +++ b/awx/main/models/schedules.py @@ -59,8 +59,13 @@ def _fast_forward_rrule(rrule, ref_dt=None): The operation ensures that the original occurrences (based on the original dtstart) will match the occurrences after changing the dtstart. + All datetime operations (subtracting dates and adding timedeltas) should be + in UTC to avoid DST issues. As such, the rrule dtstart is converted to UTC + then back to the original timezone at the end. + Returns a new rrule with a new dtstart ''' + if rrule._freq not in {dateutil.rrule.HOURLY, dateutil.rrule.MINUTELY}: return rrule @@ -70,7 +75,10 @@ def _fast_forward_rrule(rrule, ref_dt=None): if ref_dt is None: ref_dt = now() - if rrule._dtstart > ref_dt: + ref_dt = ref_dt.astimezone(datetime.timezone.utc) + + rrule_dtstart_utc = rrule._dtstart.astimezone(datetime.timezone.utc) + if rrule_dtstart_utc > ref_dt: return rrule interval = rrule._interval if rrule._interval else 1 @@ -84,15 +92,15 @@ def _fast_forward_rrule(rrule, ref_dt=None): if isinstance(interval, float) and not interval.is_integer(): return rrule - seconds_since_dtstart = (ref_dt - rrule._dtstart).total_seconds() + seconds_since_dtstart = (ref_dt - rrule_dtstart_utc).total_seconds() # it is important to fast forward by a number that is divisible by # interval. For example, if interval is 7 hours, we fast forward by 7, 14, 21, etc. hours. # Otherwise, the occurrences after the fast forward might not match the ones before. # x // y is integer division, lopping off any remainder, so that we get the outcome we want. interval_aligned_offset = datetime.timedelta(seconds=(seconds_since_dtstart // interval) * interval) - new_start = rrule._dtstart + interval_aligned_offset - new_rrule = rrule.replace(dtstart=new_start) + new_start = rrule_dtstart_utc + interval_aligned_offset + new_rrule = rrule.replace(dtstart=new_start.astimezone(rrule._dtstart.tzinfo)) return new_rrule diff --git a/awx/main/tests/unit/utils/test_schedule_fast_forward.py b/awx/main/tests/unit/utils/test_schedule_fast_forward.py index 075e485b07..be1bdae53e 100644 --- a/awx/main/tests/unit/utils/test_schedule_fast_forward.py +++ b/awx/main/tests/unit/utils/test_schedule_fast_forward.py @@ -43,6 +43,44 @@ def test_fast_forwarded_rrule_matches_original_occurrence(rrulestr): assert occurrences == orig_occurrences +@pytest.mark.parametrize( + 'ref_dt', + [ + pytest.param(datetime.datetime(2024, 12, 1, 0, 0, tzinfo=datetime.timezone.utc), id='ref-dt-out-of-dst'), + pytest.param(datetime.datetime(2024, 6, 1, 0, 0, tzinfo=datetime.timezone.utc), id='ref-dt-in-dst'), + ], +) +@pytest.mark.parametrize( + 'rrulestr', + [ + pytest.param('DTSTART;TZID=America/New_York:20240118T200000 RRULE:FREQ=MINUTELY;INTERVAL=10', id='rrule-out-of-dst'), + pytest.param('DTSTART;TZID=America/New_York:20240318T000000 RRULE:FREQ=MINUTELY;INTERVAL=10', id='rrule-in-dst'), + pytest.param( + 'DTSTART;TZID=Europe/Lisbon:20230703T005800 RRULE:INTERVAL=10;FREQ=MINUTELY;BYHOUR=9,10,11,12,13,14,15,16,17,18,19,20,21', id='rrule-in-dst-by-hour' + ), + ], +) +def test_fast_forward_across_dst(rrulestr, ref_dt): + ''' + Ensure fast forward works across daylight savings boundaries + "in dst" means between March and November + "out of dst" means between November and March the following year + + Assert that the resulting fast forwarded date is included in the original rrule + occurrence list + ''' + rruleset = Schedule.rrulestr(rrulestr, ref_dt=ref_dt) + + gen = rruleset.xafter(ref_dt, count=200) + occurrences = [i for i in gen] + + orig_rruleset = dateutil.rrule.rrulestr(rrulestr, forceset=True) + gen = orig_rruleset.xafter(ref_dt, count=200) + orig_occurrences = [i for i in gen] + + assert occurrences == orig_occurrences + + def test_fast_forward_rrule_hours(): ''' Generate an rrule for each hour of the day