diff --git a/awx/api/views.py b/awx/api/views.py index 0674e74f09..4d43be1c3c 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -619,7 +619,19 @@ class SchedulePreview(GenericAPIView): def post(self, request): serializer = self.get_serializer(data=request.data) if serializer.is_valid(): - schedule = list(Schedule.rrulestr(serializer.validated_data['rrule']).xafter(now(), count=10)) + next_stamp = now() + schedule = [] + gen = Schedule.rrulestr(serializer.validated_data['rrule']).xafter(next_stamp, count=20) + + # loop across the entire generator and grab the first 10 events + for event in gen: + if len(schedule) >= 10: + break + if not dateutil.tz.datetime_exists(event): + # skip imaginary dates, like 2:30 on DST boundaries + continue + schedule.append(event) + return Response({ 'local': schedule, 'utc': [s.astimezone(pytz.utc) for s in schedule] diff --git a/awx/main/models/schedules.py b/awx/main/models/schedules.py index 19e45c3917..fcfc3ce88f 100644 --- a/awx/main/models/schedules.py +++ b/awx/main/models/schedules.py @@ -5,7 +5,7 @@ import re import logging import datetime import dateutil.rrule -from dateutil.tz import gettz +from dateutil.tz import gettz, datetime_exists # Django from django.db import models @@ -185,7 +185,11 @@ class Schedule(CommonModel, LaunchTimeConfig): def update_computed_fields(self): future_rs = Schedule.rrulestr(self.rrule, forceset=True) next_run_actual = future_rs.after(now()) + if next_run_actual is not None: + if not datetime_exists(next_run_actual): + # skip imaginary dates, like 2:30 on DST boundaries + next_run_actual = future_rs.after(next_run_actual) next_run_actual = next_run_actual.astimezone(pytz.utc) self.next_run = next_run_actual diff --git a/awx/main/tests/functional/api/test_schedules.py b/awx/main/tests/functional/api/test_schedules.py index b414241ce1..f63bf37c65 100644 --- a/awx/main/tests/functional/api/test_schedules.py +++ b/awx/main/tests/functional/api/test_schedules.py @@ -158,3 +158,118 @@ def test_interval_by_local_day(post, admin_user): '2030-04-07 01:00:00+00:00', '2030-05-05 01:00:00+00:00', ] + + +@pytest.mark.django_db +def test_weekday_timezone_boundary(post, admin_user): + url = reverse('api:schedule_rrule') + rrule = 'DTSTART;TZID=America/New_York:20300101T210000 RRULE:FREQ=WEEKLY;BYDAY=TU;INTERVAL=1;COUNT=3' + r = post(url, {'rrule': rrule}, admin_user, expect=200) + + assert map(str, r.data['local']) == [ + '2030-01-01 21:00:00-05:00', + '2030-01-08 21:00:00-05:00', + '2030-01-15 21:00:00-05:00', + ] + + assert map(str, r.data['utc']) == [ + '2030-01-02 02:00:00+00:00', + '2030-01-09 02:00:00+00:00', + '2030-01-16 02:00:00+00:00', + ] + + +@pytest.mark.django_db +def test_first_monthly_weekday_timezone_boundary(post, admin_user): + url = reverse('api:schedule_rrule') + rrule = 'DTSTART;TZID=America/New_York:20300101T210000 RRULE:FREQ=MONTHLY;BYDAY=SU;BYSETPOS=1;INTERVAL=1;COUNT=3' + r = post(url, {'rrule': rrule}, admin_user, expect=200) + + assert map(str, r.data['local']) == [ + '2030-01-06 21:00:00-05:00', + '2030-02-03 21:00:00-05:00', + '2030-03-03 21:00:00-05:00', + ] + + assert map(str, r.data['utc']) == [ + '2030-01-07 02:00:00+00:00', + '2030-02-04 02:00:00+00:00', + '2030-03-04 02:00:00+00:00', + ] + + +@pytest.mark.django_db +def test_annual_timezone_boundary(post, admin_user): + url = reverse('api:schedule_rrule') + rrule = 'DTSTART;TZID=America/New_York:20301231T230000 RRULE:FREQ=YEARLY;INTERVAL=1;COUNT=3' + r = post(url, {'rrule': rrule}, admin_user, expect=200) + + assert map(str, r.data['local']) == [ + '2030-12-31 23:00:00-05:00', + '2031-12-31 23:00:00-05:00', + '2032-12-31 23:00:00-05:00', + ] + + assert map(str, r.data['utc']) == [ + '2031-01-01 04:00:00+00:00', + '2032-01-01 04:00:00+00:00', + '2033-01-01 04:00:00+00:00', + ] + + +def test_dst_phantom_hour(post, admin_user): + # The DST period in the United States begins at 02:00 (2 am) local time, so + # the hour from 2:00:00 to 2:59:59 does not exist in the night of the + # switch. + + # Three Sundays, starting 2:30AM America/New_York, starting Mar 3, 2030, + # should _not_ include Mar 10, 2030 @ 2:30AM (because it doesn't exist) + url = reverse('api:schedule_rrule') + rrule = 'DTSTART;TZID=America/New_York:20300303T023000 RRULE:FREQ=WEEKLY;BYDAY=SU;INTERVAL=1;COUNT=3' + r = post(url, {'rrule': rrule}, admin_user, expect=200) + + assert map(str, r.data['local']) == [ + '2030-03-03 02:30:00-05:00', + '2030-03-17 02:30:00-04:00', # Skip 3/10 because 3/10 @ 2:30AM isn't a real date + ] + + assert map(str, r.data['utc']) == [ + '2030-03-03 07:30:00+00:00', + '2030-03-17 06:30:00+00:00', # Skip 3/10 because 3/10 @ 2:30AM isn't a real date + ] + + +@pytest.mark.django_db +def test_months_with_31_days(post, admin_user): + url = reverse('api:schedule_rrule') + rrule = 'DTSTART;TZID=America/New_York:20300101T000000 RRULE:FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=31;COUNT=7' + r = post(url, {'rrule': rrule}, admin_user, expect=200) + + # 30 days have September, April, June, and November... + assert map(str, r.data['local']) == [ + '2030-01-31 00:00:00-05:00', + '2030-03-31 00:00:00-04:00', + '2030-05-31 00:00:00-04:00', + '2030-07-31 00:00:00-04:00', + '2030-08-31 00:00:00-04:00', + '2030-10-31 00:00:00-04:00', + '2030-12-31 00:00:00-05:00', + ] + + +def test_dst_rollback_duplicates(post, admin_user): + # From Nov 2 -> Nov 3, 2030, daylight savings ends and we "roll back" an hour. + # Make sure we don't "double count" duplicate times in the "rolled back" + # hour. + + url = reverse('api:schedule_rrule') + rrule = 'DTSTART;TZID=America/New_York:20301102T233000 RRULE:FREQ=HOURLY;INTERVAL=1;COUNT=5' + r = post(url, {'rrule': rrule}, admin_user, expect=200) + + assert map(str, r.data['local']) == [ + '2030-11-02 23:30:00-04:00', + '2030-11-03 00:30:00-04:00', + '2030-11-03 01:30:00-04:00', + '2030-11-03 02:30:00-05:00', + '2030-11-03 03:30:00-05:00', + ] diff --git a/awx/main/tests/functional/models/test_schedule.py b/awx/main/tests/functional/models/test_schedule.py index 8e1df57ce2..2df0e479e3 100644 --- a/awx/main/tests/functional/models/test_schedule.py +++ b/awx/main/tests/functional/models/test_schedule.py @@ -1,5 +1,10 @@ -from awx.main.models import JobTemplate, Schedule +from datetime import datetime + +import mock import pytest +import pytz + +from awx.main.models import JobTemplate, Schedule @pytest.fixture @@ -166,3 +171,24 @@ def test_utc_until_in_the_past(job_template): s.save() assert s.next_run is s.dtstart is s.dtend is None + + +@pytest.mark.django_db +@mock.patch('awx.main.models.schedules.now', lambda: datetime(2030, 03, 05, tzinfo=pytz.utc)) +def test_dst_phantom_hour(job_template): + # The DST period in the United States begins at 02:00 (2 am) local time, so + # the hour from 2:00:00 to 2:59:59 does not exist in the night of the + # switch. + + # Three Sundays, starting 2:30AM America/New_York, starting Mar 3, 2030, + # (which doesn't exist) + rrule = 'DTSTART;TZID=America/New_York:20300303T023000 RRULE:FREQ=WEEKLY;BYDAY=SU;INTERVAL=1;COUNT=3' + s = Schedule( + name='Some Schedule', + rrule=rrule, + unified_job_template=job_template + ) + s.save() + + # 3/10/30 @ 2:30AM is skipped because it _doesn't exist_ + assert str(s.next_run) == '2030-03-17 06:30:00+00:00'