mirror of
https://github.com/ansible/awx.git
synced 2026-04-14 14:39:26 -02:30
support TZID= in schedule rrules
this commit allows schedule `rrule` strings to include local timezone information via TZID=NNNNN; occurrences are _generated_ in the local time specific by the user (or UTC, if e.g., DTSTART:YYYYMMDDTHHMMSSZ) while Schedule.next_run, Schedule.dtstart, and Schedule.dtend will be stored in the UTC equivalent (i.e., the scheduler will still do math on "what to run next" based on UTC datetimes). in addition to this change, there is now a new API endpoint, `/api/v2/schedules/preview/`, which takes an rrule and shows the next 10 occurrences in local and UTC time. see: https://github.com/ansible/ansible-tower/issues/823 related: https://github.com/dateutil/dateutil/issues/614
This commit is contained in:
@@ -9,7 +9,6 @@ import re
|
||||
import six
|
||||
import urllib
|
||||
from collections import OrderedDict
|
||||
from dateutil import rrule
|
||||
|
||||
# Django
|
||||
from django.conf import settings
|
||||
@@ -3873,7 +3872,67 @@ class LabelSerializer(BaseSerializer):
|
||||
return res
|
||||
|
||||
|
||||
class ScheduleSerializer(LaunchConfigurationBaseSerializer):
|
||||
class SchedulePreviewSerializer(BaseSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Schedule
|
||||
fields = ('rrule',)
|
||||
|
||||
# We reject rrules if:
|
||||
# - DTSTART is not include
|
||||
# - INTERVAL is not included
|
||||
# - SECONDLY is used
|
||||
# - TZID is used
|
||||
# - BYDAY prefixed with a number (MO is good but not 20MO)
|
||||
# - BYYEARDAY
|
||||
# - BYWEEKNO
|
||||
# - Multiple DTSTART or RRULE elements
|
||||
# - COUNT > 999
|
||||
def validate_rrule(self, value):
|
||||
rrule_value = value
|
||||
multi_by_month_day = ".*?BYMONTHDAY[\:\=][0-9]+,-*[0-9]+"
|
||||
multi_by_month = ".*?BYMONTH[\:\=][0-9]+,[0-9]+"
|
||||
by_day_with_numeric_prefix = ".*?BYDAY[\:\=][0-9]+[a-zA-Z]{2}"
|
||||
match_count = re.match(".*?(COUNT\=[0-9]+)", rrule_value)
|
||||
match_multiple_dtstart = re.findall(".*?(DTSTART(;[^:]+)?\:[0-9]+T[0-9]+Z?)", rrule_value)
|
||||
match_native_dtstart = re.findall(".*?(DTSTART:[0-9]+T[0-9]+) ", rrule_value)
|
||||
match_multiple_rrule = re.findall(".*?(RRULE\:)", rrule_value)
|
||||
if not len(match_multiple_dtstart):
|
||||
raise serializers.ValidationError(_('Valid DTSTART required in rrule. Value should start with: DTSTART:YYYYMMDDTHHMMSSZ'))
|
||||
if len(match_native_dtstart):
|
||||
raise serializers.ValidationError(_('DTSTART cannot be a naive datetime. Specify ;TZINFO= or YYYYMMDDTHHMMSSZZ.'))
|
||||
if len(match_multiple_dtstart) > 1:
|
||||
raise serializers.ValidationError(_('Multiple DTSTART is not supported.'))
|
||||
if not len(match_multiple_rrule):
|
||||
raise serializers.ValidationError(_('RRULE required in rrule.'))
|
||||
if len(match_multiple_rrule) > 1:
|
||||
raise serializers.ValidationError(_('Multiple RRULE is not supported.'))
|
||||
if 'interval' not in rrule_value.lower():
|
||||
raise serializers.ValidationError(_('INTERVAL required in rrule.'))
|
||||
if 'secondly' in rrule_value.lower():
|
||||
raise serializers.ValidationError(_('SECONDLY is not supported.'))
|
||||
if re.match(multi_by_month_day, rrule_value):
|
||||
raise serializers.ValidationError(_('Multiple BYMONTHDAYs not supported.'))
|
||||
if re.match(multi_by_month, rrule_value):
|
||||
raise serializers.ValidationError(_('Multiple BYMONTHs not supported.'))
|
||||
if re.match(by_day_with_numeric_prefix, rrule_value):
|
||||
raise serializers.ValidationError(_("BYDAY with numeric prefix not supported."))
|
||||
if 'byyearday' in rrule_value.lower():
|
||||
raise serializers.ValidationError(_("BYYEARDAY not supported."))
|
||||
if 'byweekno' in rrule_value.lower():
|
||||
raise serializers.ValidationError(_("BYWEEKNO not supported."))
|
||||
if match_count:
|
||||
count_val = match_count.groups()[0].strip().split("=")
|
||||
if int(count_val[1]) > 999:
|
||||
raise serializers.ValidationError(_("COUNT > 999 is unsupported."))
|
||||
try:
|
||||
Schedule.rrulestr(rrule_value)
|
||||
except Exception:
|
||||
raise serializers.ValidationError(_("rrule parsing failed validation."))
|
||||
return value
|
||||
|
||||
|
||||
class ScheduleSerializer(LaunchConfigurationBaseSerializer, SchedulePreviewSerializer):
|
||||
show_capabilities = ['edit', 'delete']
|
||||
|
||||
class Meta:
|
||||
@@ -3900,58 +3959,6 @@ class ScheduleSerializer(LaunchConfigurationBaseSerializer):
|
||||
'Schedule its source project `{}` instead.').format(value.source_project.name)))
|
||||
return value
|
||||
|
||||
# We reject rrules if:
|
||||
# - DTSTART is not include
|
||||
# - INTERVAL is not included
|
||||
# - SECONDLY is used
|
||||
# - TZID is used
|
||||
# - BYDAY prefixed with a number (MO is good but not 20MO)
|
||||
# - BYYEARDAY
|
||||
# - BYWEEKNO
|
||||
# - Multiple DTSTART or RRULE elements
|
||||
# - COUNT > 999
|
||||
def validate_rrule(self, value):
|
||||
rrule_value = value
|
||||
multi_by_month_day = ".*?BYMONTHDAY[\:\=][0-9]+,-*[0-9]+"
|
||||
multi_by_month = ".*?BYMONTH[\:\=][0-9]+,[0-9]+"
|
||||
by_day_with_numeric_prefix = ".*?BYDAY[\:\=][0-9]+[a-zA-Z]{2}"
|
||||
match_count = re.match(".*?(COUNT\=[0-9]+)", rrule_value)
|
||||
match_multiple_dtstart = re.findall(".*?(DTSTART\:[0-9]+T[0-9]+Z)", rrule_value)
|
||||
match_multiple_rrule = re.findall(".*?(RRULE\:)", rrule_value)
|
||||
if not len(match_multiple_dtstart):
|
||||
raise serializers.ValidationError(_('DTSTART required in rrule. Value should match: DTSTART:YYYYMMDDTHHMMSSZ'))
|
||||
if len(match_multiple_dtstart) > 1:
|
||||
raise serializers.ValidationError(_('Multiple DTSTART is not supported.'))
|
||||
if not len(match_multiple_rrule):
|
||||
raise serializers.ValidationError(_('RRULE require in rrule.'))
|
||||
if len(match_multiple_rrule) > 1:
|
||||
raise serializers.ValidationError(_('Multiple RRULE is not supported.'))
|
||||
if 'interval' not in rrule_value.lower():
|
||||
raise serializers.ValidationError(_('INTERVAL required in rrule.'))
|
||||
if 'tzid' in rrule_value.lower():
|
||||
raise serializers.ValidationError(_('TZID is not supported.'))
|
||||
if 'secondly' in rrule_value.lower():
|
||||
raise serializers.ValidationError(_('SECONDLY is not supported.'))
|
||||
if re.match(multi_by_month_day, rrule_value):
|
||||
raise serializers.ValidationError(_('Multiple BYMONTHDAYs not supported.'))
|
||||
if re.match(multi_by_month, rrule_value):
|
||||
raise serializers.ValidationError(_('Multiple BYMONTHs not supported.'))
|
||||
if re.match(by_day_with_numeric_prefix, rrule_value):
|
||||
raise serializers.ValidationError(_("BYDAY with numeric prefix not supported."))
|
||||
if 'byyearday' in rrule_value.lower():
|
||||
raise serializers.ValidationError(_("BYYEARDAY not supported."))
|
||||
if 'byweekno' in rrule_value.lower():
|
||||
raise serializers.ValidationError(_("BYWEEKNO not supported."))
|
||||
if match_count:
|
||||
count_val = match_count.groups()[0].strip().split("=")
|
||||
if int(count_val[1]) > 999:
|
||||
raise serializers.ValidationError(_("COUNT > 999 is unsupported."))
|
||||
try:
|
||||
rrule.rrulestr(rrule_value)
|
||||
except Exception:
|
||||
raise serializers.ValidationError(_("rrule parsing failed validation."))
|
||||
return value
|
||||
|
||||
|
||||
class InstanceSerializer(BaseSerializer):
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ from awx.api.views import (
|
||||
JobExtraCredentialsList,
|
||||
JobTemplateCredentialsList,
|
||||
JobTemplateExtraCredentialsList,
|
||||
SchedulePreview,
|
||||
ScheduleZoneInfo,
|
||||
)
|
||||
|
||||
from .organization import urls as organization_urls
|
||||
@@ -113,6 +115,8 @@ v2_urls = [
|
||||
url(r'^jobs/(?P<pk>[0-9]+)/credentials/$', JobCredentialsList.as_view(), name='job_credentials_list'),
|
||||
url(r'^job_templates/(?P<pk>[0-9]+)/extra_credentials/$', JobTemplateExtraCredentialsList.as_view(), name='job_template_extra_credentials_list'),
|
||||
url(r'^job_templates/(?P<pk>[0-9]+)/credentials/$', JobTemplateCredentialsList.as_view(), name='job_template_credentials_list'),
|
||||
url(r'^schedules/preview/$', SchedulePreview.as_view(), name='schedule_rrule'),
|
||||
url(r'^schedules/zoneinfo/$', ScheduleZoneInfo.as_view(), name='schedule_zoneinfo'),
|
||||
]
|
||||
|
||||
app_name = 'api'
|
||||
|
||||
@@ -53,6 +53,7 @@ import ansiconv
|
||||
# Python Social Auth
|
||||
from social_core.backends.utils import load_backends
|
||||
|
||||
import pytz
|
||||
from wsgiref.util import FileWrapper
|
||||
|
||||
# AWX
|
||||
@@ -608,6 +609,31 @@ class ScheduleDetail(RetrieveUpdateDestroyAPIView):
|
||||
new_in_148 = True
|
||||
|
||||
|
||||
class SchedulePreview(GenericAPIView):
|
||||
|
||||
model = Schedule
|
||||
view_name = _('Schedule Recurrence Rule Preview')
|
||||
serializer_class = SchedulePreviewSerializer
|
||||
new_in_api_v2 = True
|
||||
|
||||
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))
|
||||
return Response({
|
||||
'local': schedule,
|
||||
'utc': [s.astimezone(pytz.utc) for s in schedule]
|
||||
})
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class ScheduleZoneInfo(APIView):
|
||||
|
||||
def get(self, request):
|
||||
from dateutil.zoneinfo import get_zonefile_instance
|
||||
return Response(sorted(get_zonefile_instance().zones.keys()))
|
||||
|
||||
|
||||
class LaunchConfigCredentialsBase(SubListAttachDetachAPIView):
|
||||
|
||||
model = Credential
|
||||
|
||||
@@ -5,11 +5,12 @@ import re
|
||||
import logging
|
||||
import datetime
|
||||
import dateutil.rrule
|
||||
from dateutil.tz import gettz
|
||||
|
||||
# Django
|
||||
from django.db import models
|
||||
from django.db.models.query import QuerySet
|
||||
from django.utils.timezone import now, make_aware, get_default_timezone
|
||||
from django.utils.timezone import now, make_aware
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
# AWX
|
||||
@@ -19,6 +20,9 @@ from awx.main.models.jobs import LaunchTimeConfig
|
||||
from awx.main.utils import ignore_inventory_computed_fields
|
||||
from awx.main.consumers import emit_channel_notification
|
||||
|
||||
import pytz
|
||||
import six
|
||||
|
||||
|
||||
logger = logging.getLogger('awx.main.models.schedule')
|
||||
|
||||
@@ -54,6 +58,10 @@ class ScheduleManager(ScheduleFilterMethods, models.Manager):
|
||||
|
||||
class Schedule(CommonModel, LaunchTimeConfig):
|
||||
|
||||
TZID_REGEX = re.compile(
|
||||
"^(DTSTART;TZID=(?P<tzid>[^:]+)(?P<stamp>\:[0-9]+T[0-9]+))(?P<rrule> .*)$"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
app_label = 'main'
|
||||
ordering = ['-next_run']
|
||||
@@ -92,6 +100,74 @@ class Schedule(CommonModel, LaunchTimeConfig):
|
||||
help_text=_("The next time that the scheduled action will run.")
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def rrulestr(cls, rrule, **kwargs):
|
||||
"""
|
||||
Apply our own custom rrule parsing logic. This applies some extensions
|
||||
and limitations to parsing that are specific to our supported
|
||||
implementation. Namely:
|
||||
|
||||
* python-dateutil doesn't _natively_ support `DTSTART;TZID=`; this
|
||||
function parses out the TZID= component and uses it to produce the
|
||||
`tzinfos` keyword argument to `dateutil.rrule.rrulestr()`. In this
|
||||
way, we translate:
|
||||
|
||||
DTSTART;TZID=America/New_York:20180601T120000 RRULE:FREQ=DAILY;INTERVAL=1
|
||||
|
||||
...into...
|
||||
|
||||
DTSTART:20180601T120000TZID RRULE:FREQ=DAILY;INTERVAL=1
|
||||
|
||||
...and we pass a hint about the local timezone to dateutil's parser:
|
||||
`dateutil.rrule.rrulestr(rrule, {
|
||||
'tzinfos': {
|
||||
'TZID': dateutil.tz.gettz('America/New_York')
|
||||
}
|
||||
})`
|
||||
|
||||
it's possible that we can remove the custom code that performs this
|
||||
parsing if TZID= gains support in upstream dateutil:
|
||||
https://github.com/dateutil/dateutil/pull/615
|
||||
|
||||
* RFC5545 specifies that: if the "DTSTART" property is specified as
|
||||
a date with local time (in our case, TZID=), then the UNTIL rule part
|
||||
MUST also be treated as a date with local time. If the "DTSTART"
|
||||
property is specified as a date with UTC time (with a Z suffix),
|
||||
then the UNTIL rule part MUST be specified as a date with UTC time.
|
||||
|
||||
this function provides additional parsing to translate RRULES like this:
|
||||
|
||||
DTSTART;TZID=America/New_York:20180601T120000 RRULE:FREQ=DAILY;INTERVAL=1;UNTIL=20180602T170000
|
||||
|
||||
...into this (under the hood):
|
||||
|
||||
DTSTART;TZID=America/New_York:20180601T120000 RRULE:FREQ=DAILY;INTERVAL=1;UNTIL=20180602T210000Z
|
||||
"""
|
||||
source_rrule = rrule
|
||||
kwargs['tzinfos'] = {}
|
||||
match = cls.TZID_REGEX.match(rrule)
|
||||
if match is not None:
|
||||
rrule = cls.TZID_REGEX.sub("DTSTART\g<stamp>TZI\g<rrule>", rrule)
|
||||
timezone = gettz(match.group('tzid'))
|
||||
if 'until' in rrule.lower():
|
||||
# if DTSTART;TZID= is used, coerce "naive" UNTIL values
|
||||
# to the proper UTC date
|
||||
match_until = re.match(".*?(?P<until>UNTIL\=[0-9]+T[0-9]+)(?P<utcflag>Z?)", rrule)
|
||||
until_date = match_until.group('until').split("=")[1]
|
||||
if len(match_until.group('utcflag')):
|
||||
raise ValueError(six.text_type(
|
||||
_('invalid rrule `{}` includes TZINFO= stanza and UTC-based UNTIL clause').format(source_rrule)
|
||||
)) # noqa
|
||||
localized = make_aware(
|
||||
datetime.datetime.strptime(until_date, "%Y%m%dT%H%M%S"),
|
||||
timezone
|
||||
)
|
||||
utc = localized.astimezone(pytz.utc).strftime('%Y%m%dT%H%M%SZ')
|
||||
rrule = rrule.replace(match_until.group('until'), 'UNTIL={}'.format(utc))
|
||||
kwargs['tzinfos']['TZI'] = timezone
|
||||
x = dateutil.rrule.rrulestr(rrule, **kwargs)
|
||||
return x
|
||||
|
||||
def __unicode__(self):
|
||||
return u'%s_t%s_%s_%s' % (self.name, self.unified_job_template.id, self.id, self.next_run)
|
||||
|
||||
@@ -107,21 +183,22 @@ class Schedule(CommonModel, LaunchTimeConfig):
|
||||
return job_kwargs
|
||||
|
||||
def update_computed_fields(self):
|
||||
future_rs = dateutil.rrule.rrulestr(self.rrule, forceset=True)
|
||||
future_rs = Schedule.rrulestr(self.rrule, forceset=True)
|
||||
next_run_actual = future_rs.after(now())
|
||||
if next_run_actual is not None:
|
||||
next_run_actual = next_run_actual.astimezone(pytz.utc)
|
||||
|
||||
self.next_run = next_run_actual
|
||||
try:
|
||||
self.dtstart = future_rs[0]
|
||||
self.dtstart = future_rs[0].astimezone(pytz.utc)
|
||||
except IndexError:
|
||||
self.dtstart = None
|
||||
self.dtend = None
|
||||
if 'until' in self.rrule.lower():
|
||||
match_until = re.match(".*?(UNTIL\=[0-9]+T[0-9]+Z)", self.rrule)
|
||||
until_date = match_until.groups()[0].split("=")[1]
|
||||
self.dtend = make_aware(datetime.datetime.strptime(until_date, "%Y%m%dT%H%M%SZ"), get_default_timezone())
|
||||
if 'count' in self.rrule.lower():
|
||||
self.dtend = future_rs[-1]
|
||||
if 'until' in self.rrule.lower() or 'count' in self.rrule.lower():
|
||||
try:
|
||||
self.dtend = future_rs[-1].astimezone(pytz.utc)
|
||||
except IndexError:
|
||||
self.dtend = None
|
||||
emit_channel_notification('schedules-changed', dict(id=self.id, group_name='schedules'))
|
||||
with ignore_inventory_computed_fields():
|
||||
self.unified_job_template.update_computed_fields()
|
||||
|
||||
@@ -8,6 +8,17 @@ from awx.main.models import JobTemplate
|
||||
RRULE_EXAMPLE = 'DTSTART:20151117T050000Z RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1'
|
||||
|
||||
|
||||
def get_rrule(tz=None):
|
||||
parts = ['DTSTART']
|
||||
if tz:
|
||||
parts.append(';TZID={}'.format(tz))
|
||||
parts.append(':20300308T050000')
|
||||
if tz is None:
|
||||
parts.append('Z')
|
||||
parts.append(' RRULE:FREQ=DAILY;INTERVAL=1;COUNT=5')
|
||||
return ''.join(parts)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_non_job_extra_vars_prohibited(post, project, admin_user):
|
||||
url = reverse('api:project_schedules_list', kwargs={'pk': project.id})
|
||||
@@ -32,3 +43,118 @@ def test_valid_survey_answer(post, admin_user, project, inventory, survey_spec_f
|
||||
url = reverse('api:job_template_schedules_list', kwargs={'pk': job_template.id})
|
||||
post(url, {'name': 'test sch', 'rrule': RRULE_EXAMPLE, 'extra_data': '{"var1": 54}'},
|
||||
admin_user, expect=201)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize('rrule, error', [
|
||||
("", "This field may not be blank"),
|
||||
("DTSTART:NONSENSE", "Valid DTSTART required in rrule"),
|
||||
("DTSTART:20300308T050000Z DTSTART:20310308T050000", "Multiple DTSTART is not supported"),
|
||||
("DTSTART:20300308T050000Z", "RRULE required in rrule"),
|
||||
("DTSTART:20300308T050000Z RRULE:NONSENSE", "INTERVAL required in rrule"),
|
||||
("DTSTART:20300308T050000Z RRULE:FREQ=SECONDLY;INTERVAL=5;COUNT=6", "SECONDLY is not supported"),
|
||||
("DTSTART:20300308T050000Z RRULE:FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=3,4", "Multiple BYMONTHDAYs not supported"), # noqa
|
||||
("DTSTART:20300308T050000Z RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=1,2", "Multiple BYMONTHs not supported"), # noqa
|
||||
("DTSTART:20300308T050000Z RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=5MO", "BYDAY with numeric prefix not supported"), # noqa
|
||||
("DTSTART:20300308T050000Z RRULE:FREQ=YEARLY;INTERVAL=1;BYYEARDAY=100", "BYYEARDAY not supported"), # noqa
|
||||
("DTSTART:20300308T050000Z RRULE:FREQ=YEARLY;INTERVAL=1;BYWEEKNO=20", "BYWEEKNO not supported"),
|
||||
("DTSTART:20300308T050000Z RRULE:FREQ=DAILY;INTERVAL=1;COUNT=2000", "COUNT > 999 is unsupported"), # noqa
|
||||
("DTSTART:20300308T050000Z RRULE:FREQ=REGULARLY;INTERVAL=1", "rrule parsing failed validation"), # noqa
|
||||
("DTSTART;TZID=America/New_York:30200308T050000Z RRULE:FREQ=DAILY;INTERVAL=1", "rrule parsing failed validation"),
|
||||
("DTSTART:20300308T050000 RRULE:FREQ=DAILY;INTERVAL=1", "DTSTART cannot be a naive datetime"),
|
||||
])
|
||||
def test_invalid_rrules(post, admin_user, project, inventory, rrule, error):
|
||||
job_template = JobTemplate.objects.create(
|
||||
name='test-jt',
|
||||
project=project,
|
||||
playbook='helloworld.yml',
|
||||
inventory=inventory
|
||||
)
|
||||
url = reverse('api:job_template_schedules_list', kwargs={'pk': job_template.id})
|
||||
resp = post(url, {
|
||||
'name': 'Some Schedule',
|
||||
'rrule': rrule,
|
||||
}, admin_user, expect=400)
|
||||
assert error in resp.content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_utc_preview(post, admin_user):
|
||||
url = reverse('api:schedule_rrule')
|
||||
r = post(url, {'rrule': get_rrule()}, admin_user, expect=200)
|
||||
assert r.data['utc'] == r.data['local']
|
||||
assert map(str, r.data['utc']) == [
|
||||
'2030-03-08 05:00:00+00:00',
|
||||
'2030-03-09 05:00:00+00:00',
|
||||
'2030-03-10 05:00:00+00:00',
|
||||
'2030-03-11 05:00:00+00:00',
|
||||
'2030-03-12 05:00:00+00:00',
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_nyc_with_dst(post, admin_user):
|
||||
url = reverse('api:schedule_rrule')
|
||||
r = post(url, {'rrule': get_rrule('America/New_York')}, admin_user, expect=200)
|
||||
|
||||
# March 10, 2030 is when DST takes effect in NYC
|
||||
assert map(str, r.data['local']) == [
|
||||
'2030-03-08 05:00:00-05:00',
|
||||
'2030-03-09 05:00:00-05:00',
|
||||
'2030-03-10 05:00:00-04:00',
|
||||
'2030-03-11 05:00:00-04:00',
|
||||
'2030-03-12 05:00:00-04:00',
|
||||
]
|
||||
assert map(str, r.data['utc']) == [
|
||||
'2030-03-08 10:00:00+00:00',
|
||||
'2030-03-09 10:00:00+00:00',
|
||||
'2030-03-10 09:00:00+00:00',
|
||||
'2030-03-11 09:00:00+00:00',
|
||||
'2030-03-12 09:00:00+00:00',
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_phoenix_without_dst(post, admin_user):
|
||||
# The state of Arizona (aside from a few Native American territories) does
|
||||
# not observe DST
|
||||
url = reverse('api:schedule_rrule')
|
||||
r = post(url, {'rrule': get_rrule('America/Phoenix')}, admin_user, expect=200)
|
||||
|
||||
# March 10, 2030 is when DST takes effect in NYC
|
||||
assert map(str, r.data['local']) == [
|
||||
'2030-03-08 05:00:00-07:00',
|
||||
'2030-03-09 05:00:00-07:00',
|
||||
'2030-03-10 05:00:00-07:00',
|
||||
'2030-03-11 05:00:00-07:00',
|
||||
'2030-03-12 05:00:00-07:00',
|
||||
]
|
||||
assert map(str, r.data['utc']) == [
|
||||
'2030-03-08 12:00:00+00:00',
|
||||
'2030-03-09 12:00:00+00:00',
|
||||
'2030-03-10 12:00:00+00:00',
|
||||
'2030-03-11 12:00:00+00:00',
|
||||
'2030-03-12 12:00:00+00:00',
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_interval_by_local_day(post, admin_user):
|
||||
url = reverse('api:schedule_rrule')
|
||||
rrule = 'DTSTART;TZID=America/New_York:20300112T210000 RRULE:FREQ=MONTHLY;INTERVAL=1;BYDAY=SA;BYSETPOS=1;COUNT=4'
|
||||
r = post(url, {'rrule': rrule}, admin_user, expect=200)
|
||||
|
||||
# March 10, 2030 is when DST takes effect in NYC
|
||||
assert map(str, r.data['local']) == [
|
||||
'2030-02-02 21:00:00-05:00',
|
||||
'2030-03-02 21:00:00-05:00',
|
||||
'2030-04-06 21:00:00-04:00',
|
||||
'2030-05-04 21:00:00-04:00',
|
||||
]
|
||||
|
||||
assert map(str, r.data['utc']) == [
|
||||
'2030-02-03 02:00:00+00:00',
|
||||
'2030-03-03 02:00:00+00:00',
|
||||
'2030-04-07 01:00:00+00:00',
|
||||
'2030-05-05 01:00:00+00:00',
|
||||
]
|
||||
|
||||
168
awx/main/tests/functional/models/test_schedule.py
Normal file
168
awx/main/tests/functional/models/test_schedule.py
Normal file
@@ -0,0 +1,168 @@
|
||||
from awx.main.models import JobTemplate, Schedule
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def job_template(inventory, project):
|
||||
# need related resources set for these tests
|
||||
return JobTemplate.objects.create(
|
||||
name='test-job_template',
|
||||
inventory=inventory,
|
||||
project=project
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_repeats_forever(job_template):
|
||||
s = Schedule(
|
||||
name='Some Schedule',
|
||||
rrule='DTSTART:20300112T210000Z RRULE:FREQ=DAILY;INTERVAL=1',
|
||||
unified_job_template=job_template
|
||||
)
|
||||
s.save()
|
||||
assert str(s.next_run) == str(s.dtstart) == '2030-01-12 21:00:00+00:00'
|
||||
assert s.dtend is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_no_recurrence_utc(job_template):
|
||||
s = Schedule(
|
||||
name='Some Schedule',
|
||||
rrule='DTSTART:20300112T210000Z RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1',
|
||||
unified_job_template=job_template
|
||||
)
|
||||
s.save()
|
||||
assert str(s.next_run) == str(s.dtstart) == str(s.dtend) == '2030-01-12 21:00:00+00:00'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_no_recurrence_est(job_template):
|
||||
s = Schedule(
|
||||
name='Some Schedule',
|
||||
rrule='DTSTART;TZID=America/New_York:20300112T210000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1',
|
||||
unified_job_template=job_template
|
||||
)
|
||||
s.save()
|
||||
assert str(s.next_run) == str(s.dtstart) == str(s.dtend) == '2030-01-13 02:00:00+00:00'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_next_run_utc(job_template):
|
||||
s = Schedule(
|
||||
name='Some Schedule',
|
||||
rrule='DTSTART:20300112T210000Z RRULE:FREQ=MONTHLY;INTERVAL=1;BYDAY=SA;BYSETPOS=1;COUNT=4',
|
||||
unified_job_template=job_template
|
||||
)
|
||||
s.save()
|
||||
assert str(s.next_run) == '2030-02-02 21:00:00+00:00'
|
||||
assert str(s.next_run) == str(s.dtstart)
|
||||
assert str(s.dtend) == '2030-05-04 21:00:00+00:00'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_next_run_est(job_template):
|
||||
s = Schedule(
|
||||
name='Some Schedule',
|
||||
rrule='DTSTART;TZID=America/New_York:20300112T210000 RRULE:FREQ=MONTHLY;INTERVAL=1;BYDAY=SA;BYSETPOS=1;COUNT=4',
|
||||
unified_job_template=job_template
|
||||
)
|
||||
s.save()
|
||||
|
||||
assert str(s.next_run) == '2030-02-03 02:00:00+00:00'
|
||||
assert str(s.next_run) == str(s.dtstart)
|
||||
|
||||
# March 10, 2030 is when DST takes effect in NYC
|
||||
assert str(s.dtend) == '2030-05-05 01:00:00+00:00'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_year_boundary(job_template):
|
||||
rrule = 'DTSTART;TZID=America/New_York:20301231T230000 RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=12;BYMONTHDAY=31;COUNT=4' # noqa
|
||||
s = Schedule(
|
||||
name='Some Schedule',
|
||||
rrule=rrule,
|
||||
unified_job_template=job_template
|
||||
)
|
||||
s.save()
|
||||
|
||||
assert str(s.next_run) == '2031-01-01 04:00:00+00:00' # UTC = +5 EST
|
||||
assert str(s.next_run) == str(s.dtstart)
|
||||
assert str(s.dtend) == '2034-01-01 04:00:00+00:00' # UTC = +5 EST
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_leap_year_day(job_template):
|
||||
rrule = 'DTSTART;TZID=America/New_York:20320229T050000 RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=02;BYMONTHDAY=29;COUNT=2' # noqa
|
||||
s = Schedule(
|
||||
name='Some Schedule',
|
||||
rrule=rrule,
|
||||
unified_job_template=job_template
|
||||
)
|
||||
s.save()
|
||||
|
||||
assert str(s.next_run) == '2032-02-29 10:00:00+00:00' # UTC = +5 EST
|
||||
assert str(s.next_run) == str(s.dtstart)
|
||||
assert str(s.dtend) == '2036-02-29 10:00:00+00:00' # UTC = +5 EST
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize('until, dtend', [
|
||||
['20180602T170000Z', '2018-06-02 12:00:00+00:00'],
|
||||
['20180602T000000Z', '2018-06-01 12:00:00+00:00'],
|
||||
])
|
||||
def test_utc_until(job_template, until, dtend):
|
||||
rrule = 'DTSTART:20180601T120000Z RRULE:FREQ=DAILY;INTERVAL=1;UNTIL={}'.format(until)
|
||||
s = Schedule(
|
||||
name='Some Schedule',
|
||||
rrule=rrule,
|
||||
unified_job_template=job_template
|
||||
)
|
||||
s.save()
|
||||
|
||||
assert str(s.next_run) == '2018-06-01 12:00:00+00:00'
|
||||
assert str(s.next_run) == str(s.dtstart)
|
||||
assert str(s.dtend) == dtend
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize('until, dtend', [
|
||||
['20180602T170000', '2018-06-02 16:00:00+00:00'],
|
||||
['20180602T000000', '2018-06-01 16:00:00+00:00'],
|
||||
])
|
||||
def test_tzinfo_until(job_template, until, dtend):
|
||||
rrule = 'DTSTART;TZID=America/New_York:20180601T120000 RRULE:FREQ=DAILY;INTERVAL=1;UNTIL={}'.format(until) # noqa
|
||||
s = Schedule(
|
||||
name='Some Schedule',
|
||||
rrule=rrule,
|
||||
unified_job_template=job_template
|
||||
)
|
||||
s.save()
|
||||
|
||||
assert str(s.next_run) == '2018-06-01 16:00:00+00:00' # UTC = +4 EST
|
||||
assert str(s.next_run) == str(s.dtstart)
|
||||
assert str(s.dtend) == dtend
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_mismatched_until_timezone(job_template):
|
||||
rrule = 'DTSTART;TZID=America/New_York:20180601T120000 RRULE:FREQ=DAILY;INTERVAL=1;UNTIL=20180602T000000' + 'Z' # noqa the Z isn't allowed, because we have a TZID=America/New_York
|
||||
s = Schedule(
|
||||
name='Some Schedule',
|
||||
rrule=rrule,
|
||||
unified_job_template=job_template
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
s.save()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_utc_until_in_the_past(job_template):
|
||||
rrule = 'DTSTART:20180601T120000Z RRULE:FREQ=DAILY;INTERVAL=1;UNTIL=20150101T100000Z'
|
||||
s = Schedule(
|
||||
name='Some Schedule',
|
||||
rrule=rrule,
|
||||
unified_job_template=job_template
|
||||
)
|
||||
s.save()
|
||||
|
||||
assert s.next_run is s.dtstart is s.dtend is None
|
||||
Reference in New Issue
Block a user