diff --git a/awx/main/models/schedules.py b/awx/main/models/schedules.py index af75b8e8a3..565ad2de12 100644 --- a/awx/main/models/schedules.py +++ b/awx/main/models/schedules.py @@ -1,8 +1,10 @@ # Copyright (c) 2015 Ansible, Inc. # All Rights Reserved. -import logging import datetime +import logging +import re + import dateutil.rrule from operator import itemgetter import dateutil.parser @@ -11,7 +13,7 @@ from dateutil.tz import datetime_exists, tzutc # Django from django.db import models from django.db.models.query import QuerySet -from django.utils.timezone import now +from django.utils.timezone import now, make_aware from django.utils.translation import ugettext_lazy as _ # AWX @@ -129,6 +131,56 @@ class Schedule(CommonModel, LaunchTimeConfig): Apply our own custom rrule parsing requirements """ kwargs['forceset'] = True + + # + # RFC5545 specifies that the UNTIL rule part MUST ALWAYS be a date + # with UTC time. This is extra work for API implementers because + # it requires them to perform DTSTART local -> UTC datetime coercion on + # POST and UTC -> DTSTART local coercion on GET. + # + # This block of code is a departure from the RFC. If you send an + # rrule like this to the API (without a Z on the UNTIL): + # + # DTSTART;TZID=America/New_York:20180502T150000 RRULE:FREQ=HOURLY;INTERVAL=1;UNTIL=20180502T180000 + # + # ...we'll assume that the naive UNTIL is intended to match the DTSTART + # timezone (America/New_York), and so we'll coerce to UTC _for you_ + # automatically. + # + if 'until=' in rrule.lower(): + # if DTSTART;TZID= is used, coerce "naive" UNTIL values + # to the proper UTC date + match_until = re.match(".*?UNTIL\=(?P[0-9]+T[0-9]+)(?PZ?)", rrule) + if not len(match_until.group('utcflag')): + # rrule = DTSTART;TZID=America/New_York:20200601T120000 RRULE:...;UNTIL=20200601T170000 + + # Find the UNTIL=N part of the string + # naive_until = 20200601T170000 + naive_until = match_until.group('until') + + # What is the DTSTART timezone for: + # DTSTART;TZID=America/New_York:20200601T120000 RRULE:...;UNTIL=20200601T170000Z + # local_tz = tzfile('/usr/share/zoneinfo/America/New_York') + local_tz = dateutil.rrule.rrulestr( + rrule.replace(naive_until, naive_until + 'Z'), + tzinfos={x: tzutc() for x in dateutil.parser.parserinfo().UTCZONE} + )._dtstart.tzinfo + + # Make a datetime object with tzinfo= + # localized_until = datetime.datetime(2020, 6, 1, 17, 0, tzinfo=tzfile('/usr/share/zoneinfo/America/New_York')) + localized_until = make_aware( + datetime.datetime.strptime(naive_until, "%Y%m%dT%H%M%S"), + local_tz + ) + + # Coerce the datetime to UTC and format it as a string w/ Zulu format + # utc_until = 20200601T220000Z + utc_until = localized_until.astimezone(pytz.utc).strftime('%Y%m%dT%H%M%SZ') + + # rrule was: DTSTART;TZID=America/New_York:20200601T120000 RRULE:...;UNTIL=20200601T170000 + # rrule is now: DTSTART;TZID=America/New_York:20200601T120000 RRULE:...;UNTIL=20200601T220000Z + rrule = rrule.replace(naive_until, utc_until) + x = dateutil.rrule.rrulestr(rrule, **kwargs) for r in x._rrule: diff --git a/awx/main/tests/functional/models/test_schedule.py b/awx/main/tests/functional/models/test_schedule.py index cb3dcd34ed..e89dbf017d 100644 --- a/awx/main/tests/functional/models/test_schedule.py +++ b/awx/main/tests/functional/models/test_schedule.py @@ -1,5 +1,6 @@ from datetime import datetime +from django.utils.timezone import now import mock import pytest import pytz @@ -131,31 +132,19 @@ def test_utc_until(job_template, until, dtend): @pytest.mark.django_db @pytest.mark.parametrize('dtstart, until', [ - ['20180601T120000Z', '20180602T170000'], - ['TZID=America/New_York:20180601T120000', '20180602T170000'], + ['DTSTART:20380601T120000Z', '20380601T170000'], # noon UTC to 5PM UTC + ['DTSTART;TZID=America/New_York:20380601T120000', '20380601T170000'], # noon EST to 5PM EST ]) def test_tzinfo_naive_until(job_template, dtstart, until): - rrule = 'DTSTART;{} RRULE:FREQ=DAILY;INTERVAL=1;UNTIL={}'.format(dtstart, until) # noqa + rrule = '{} RRULE:FREQ=HOURLY;INTERVAL=1;UNTIL={}'.format(dtstart, until) # noqa s = Schedule( name='Some Schedule', rrule=rrule, unified_job_template=job_template ) - with pytest.raises(ValueError): - s.save() - - -@pytest.mark.django_db -def test_until_must_be_utc(job_template): - rrule = 'DTSTART;TZID=America/New_York:20180601T120000 RRULE:FREQ=DAILY;INTERVAL=1;UNTIL=20180602T000000' # noqa the Z is required - s = Schedule( - name='Some Schedule', - rrule=rrule, - unified_job_template=job_template - ) - with pytest.raises(ValueError) as e: - s.save() - assert 'RRULE UNTIL values must be specified in UTC' in str(e) + s.save() + gen = Schedule.rrulestr(s.rrule).xafter(now(), count=20) + assert len(list(gen)) == 6 # noon, 1PM, 2, 3, 4, 5PM @pytest.mark.django_db @@ -211,7 +200,6 @@ def test_beginning_of_time(job_template): ['DTSTART;TZID=America/New_York:20300112T210000 RRULE:FREQ=DAILY;INTERVAL=1', 'America/New_York'] ]) def test_timezone_property(job_template, rrule, tz): - # ensure that really large generators don't have performance issues s = Schedule( name='Some Schedule', rrule=rrule,