mirror of
https://github.com/ansible/awx.git
synced 2026-08-01 02:19:55 -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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user