From fbe2391b86fb3d41498d699f61ea3f7a25de3600 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Wed, 2 May 2018 15:23:57 -0400 Subject: [PATCH 01/11] provide the timezone so that the UI doesn't have to this will also ensure that UI doesn't use a different front end library that will yield different results than the underlying Python code --- awx/api/serializers.py | 7 ++++- awx/api/views.py | 6 +--- awx/main/models/schedules.py | 31 ++++++++++++++++++- .../tests/functional/models/test_schedule.py | 15 +++++++++ 4 files changed, 52 insertions(+), 7 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 64b709d056..b1e62b8613 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4511,9 +4511,14 @@ class SchedulePreviewSerializer(BaseSerializer): class ScheduleSerializer(LaunchConfigurationBaseSerializer, SchedulePreviewSerializer): show_capabilities = ['edit', 'delete'] + timezone = serializers.SerializerMethodField() + class Meta: model = Schedule - fields = ('*', 'unified_job_template', 'enabled', 'dtstart', 'dtend', 'rrule', 'next_run',) + fields = ('*', 'unified_job_template', 'enabled', 'dtstart', 'dtend', 'rrule', 'next_run', 'timezone',) + + def get_timezone(self, obj): + return obj.timezone def get_related(self, obj): res = super(ScheduleSerializer, self).get_related(obj) diff --git a/awx/api/views.py b/awx/api/views.py index 5e080ccea9..f1098ffce0 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -745,11 +745,7 @@ class ScheduleZoneInfo(APIView): swagger_topic = 'System Configuration' def get(self, request): - from dateutil.zoneinfo import get_zonefile_instance - return Response([ - {'name': zone} - for zone in sorted(get_zonefile_instance().zones) - ]) + return Response(Schedule.get_zoneinfo()) class LaunchConfigCredentialsBase(SubListAttachDetachAPIView): diff --git a/awx/main/models/schedules.py b/awx/main/models/schedules.py index 71efa702c6..af75b8e8a3 100644 --- a/awx/main/models/schedules.py +++ b/awx/main/models/schedules.py @@ -4,7 +4,9 @@ import logging import datetime import dateutil.rrule -from dateutil.tz import datetime_exists +from operator import itemgetter +import dateutil.parser +from dateutil.tz import datetime_exists, tzutc # Django from django.db import models @@ -94,6 +96,33 @@ class Schedule(CommonModel, LaunchTimeConfig): help_text=_("The next time that the scheduled action will run.") ) + @classmethod + def get_zoneinfo(self): + from dateutil.zoneinfo import get_zonefile_instance + return [ + {'name': zone} + for zone in sorted(get_zonefile_instance().zones) + ] + + @property + def timezone(self): + utc = tzutc() + _rrule = dateutil.rrule.rrulestr( + self.rrule, + tzinfos={x: utc for x in dateutil.parser.parserinfo().UTCZONE} + ) + tzinfo = _rrule._dtstart.tzinfo + if tzinfo == utc: + return 'UTC' + fname = tzinfo._filename + all_zones = map(itemgetter('name'), Schedule.get_zoneinfo()) + all_zones.sort(key = lambda x: -len(x)) + for zone in all_zones: + if fname.endswith(zone): + return zone + logger.warn('Could not detect valid zoneinfo for {}'.format(self.rrule)) + return '' + @classmethod def rrulestr(cls, rrule, **kwargs): """ diff --git a/awx/main/tests/functional/models/test_schedule.py b/awx/main/tests/functional/models/test_schedule.py index 101afa8b99..cb3dcd34ed 100644 --- a/awx/main/tests/functional/models/test_schedule.py +++ b/awx/main/tests/functional/models/test_schedule.py @@ -203,3 +203,18 @@ def test_beginning_of_time(job_template): ) with pytest.raises(ValueError): s.save() + + +@pytest.mark.django_db +@pytest.mark.parametrize('rrule, tz', [ + ['DTSTART:20300112T210000Z RRULE:FREQ=DAILY;INTERVAL=1', 'UTC'], + ['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, + unified_job_template=job_template + ) + assert s.timezone == tz From 441e5cc9c2521b16e8879d7544844fdfe17e56db Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Wed, 2 May 2018 16:24:55 -0400 Subject: [PATCH 02/11] allow naive UNTILs to be specified for schedule rrules --- awx/main/models/schedules.py | 56 ++++++++++++++++++- .../tests/functional/models/test_schedule.py | 26 +++------ 2 files changed, 61 insertions(+), 21 deletions(-) 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, From 33d4c97156d85a0a11fdaa9edbbdf4632aa06e0b Mon Sep 17 00:00:00 2001 From: Jared Tabor Date: Wed, 2 May 2018 16:59:14 -0700 Subject: [PATCH 03/11] Sets the timezone to the api/v2/schedule/:id -> data.timezone and moving the data calls from the controller to the route resolve --- .../src/management-jobs/scheduler/main.js | 5 +- .../src/scheduler/editSchedule.resolve.js | 39 ++ awx/ui/client/src/scheduler/main.js | 10 +- .../src/scheduler/schedulerEdit.controller.js | 631 +++++++++--------- 4 files changed, 363 insertions(+), 322 deletions(-) create mode 100644 awx/ui/client/src/scheduler/editSchedule.resolve.js diff --git a/awx/ui/client/src/management-jobs/scheduler/main.js b/awx/ui/client/src/management-jobs/scheduler/main.js index 4c1cb50ce5..0fff48415f 100644 --- a/awx/ui/client/src/management-jobs/scheduler/main.js +++ b/awx/ui/client/src/management-jobs/scheduler/main.js @@ -10,6 +10,8 @@ import controller from '../../scheduler/schedulerList.controller'; import addController from '../../scheduler/schedulerAdd.controller'; import editController from '../../scheduler/schedulerEdit.controller'; import { N_ } from '../../i18n'; +import editScheduleResolve from '../../scheduler/editSchedule.resolve'; + export default angular.module('managementJobScheduler', []) @@ -99,6 +101,7 @@ angular.module('managementJobScheduler', []) templateUrl: templateUrl('management-jobs/scheduler/schedulerForm'), controller: 'managementJobEditController' } - } + }, + resolve: editScheduleResolve() }); }]); diff --git a/awx/ui/client/src/scheduler/editSchedule.resolve.js b/awx/ui/client/src/scheduler/editSchedule.resolve.js new file mode 100644 index 0000000000..8c20f833b6 --- /dev/null +++ b/awx/ui/client/src/scheduler/editSchedule.resolve.js @@ -0,0 +1,39 @@ +function editScheduleResolve () { + const resolve = { + scheduleResolve: ['Rest', '$stateParams', 'GetBasePath', 'ProcessErrors', + (Rest, $stateParams, GetBasePath, ProcessErrors) => { + var path = `${GetBasePath('schedules')}${parseInt($stateParams.schedule_id)}/`; + // const path = GetBasePath('schedules') + parseInt($stateParams.schedule_id) + '/'); + Rest.setUrl(path); + return Rest.get() + .then(function(data) { + return (data.data); + }).catch(function(response) { + ProcessErrors(null, response.data, response.status, null, { + hdr: 'Error!', + msg: 'Failed to get schedule info. GET returned status: ' + + response.status + }); + }); + } + ], + timezonesResolve: ['Rest', '$stateParams', 'GetBasePath', 'ProcessErrors', + (Rest, $stateParams, GetBasePath, ProcessErrors) => { + var path = `${GetBasePath('schedules')}/zoneinfo`; + Rest.setUrl(path); + return Rest.get() + .then(function(data) { + return (data.data); + }).catch(function(response) { + ProcessErrors(null, response.data, response.status, null, { + hdr: 'Error!', + msg: 'Failed to get zoneinfo. GET returned status: ' + + response.status + }); + }); + } + ] + }; + return resolve; +} +export default editScheduleResolve; diff --git a/awx/ui/client/src/scheduler/main.js b/awx/ui/client/src/scheduler/main.js index 93357de409..b20b77b349 100644 --- a/awx/ui/client/src/scheduler/main.js +++ b/awx/ui/client/src/scheduler/main.js @@ -16,6 +16,7 @@ import SchedulePost from './factories/schedule-post.factory'; import ToggleSchedule from './factories/toggle-schedule.factory'; import SchedulesList from './schedules.list'; import ScheduledJobsList from './scheduled-jobs.list'; +import editScheduleResolve from './editSchedule.resolve'; export default angular.module('scheduler', []) @@ -121,7 +122,8 @@ export default ncyBreadcrumb: { parent: 'jobTemplateSchedules', label: '{{schedule_obj.name}}' - } + }, + resolve: editScheduleResolve() }); // workflows @@ -212,7 +214,8 @@ export default ncyBreadcrumb: { parent: 'workflowJobTemplateSchedules', label: '{{schedule_obj.name}}' - } + }, + resolve: editScheduleResolve() }); // projects $stateExtender.addState({ @@ -301,7 +304,8 @@ export default controller: 'schedulerEditController', templateUrl: templateUrl("scheduler/schedulerForm"), } - } + }, + resolve: editScheduleResolve() }); // upcoming scheduled jobs $stateExtender.addState({ diff --git a/awx/ui/client/src/scheduler/schedulerEdit.controller.js b/awx/ui/client/src/scheduler/schedulerEdit.controller.js index 339b46740c..f421c61a8e 100644 --- a/awx/ui/client/src/scheduler/schedulerEdit.controller.js +++ b/awx/ui/client/src/scheduler/schedulerEdit.controller.js @@ -1,11 +1,11 @@ export default ['$filter', '$state', '$stateParams', 'Wait', '$scope', 'moment', '$rootScope', '$http', 'CreateSelect2', 'ParseTypeChange', 'ParentObject', 'ProcessErrors', 'Rest', 'GetBasePath', 'SchedulerInit', 'SchedulePost', 'JobTemplateModel', '$q', 'Empty', 'PromptService', 'RRuleToAPI', -'WorkflowJobTemplateModel', 'TemplatesStrings', +'WorkflowJobTemplateModel', 'TemplatesStrings', 'scheduleResolve', 'timezonesResolve', function($filter, $state, $stateParams, Wait, $scope, moment, $rootScope, $http, CreateSelect2, ParseTypeChange, ParentObject, ProcessErrors, Rest, GetBasePath, SchedulerInit, SchedulePost, JobTemplate, $q, Empty, PromptService, RRuleToAPI, - WorkflowJobTemplate, TemplatesStrings + WorkflowJobTemplate, TemplatesStrings, scheduleResolve, timezonesResolve ) { let schedule, scheduler, scheduleCredentials = []; @@ -118,337 +118,332 @@ function($filter, $state, $stateParams, Wait, $scope, moment, Wait('start'); - // Get the existing record - Rest.setUrl(GetBasePath('schedules') + parseInt($stateParams.schedule_id) + '/'); - Rest.get() - .then(({data}) => { - schedule = data; - try { - schedule.extra_data = JSON.parse(schedule.extra_data); - } catch(e) { - // do nothing - } + //sets the timezone dropdown to the timezone specified by the API + function setTimezone () { + $scope.schedulerTimeZone = _.find($scope.timeZones, function(x) { + return x.name === scheduleResolve.timezone; + }); + } - $scope.extraVars = (data.extra_data === '' || _.isEmpty(data.extra_data)) ? '---' : '---\n' + jsyaml.safeDump(data.extra_data); + function init() { + schedule = scheduleResolve; - if (_.has(schedule, 'summary_fields.unified_job_template.unified_job_type') && - schedule.summary_fields.unified_job_template.unified_job_type === 'system_job'){ - $scope.cleanupJob = true; - } + try { + schedule.extra_data = JSON.parse(schedule.extra_data); + } catch(e) { + // do nothing + } - $scope.schedule_obj = data; + $scope.extraVars = (scheduleResolve.extra_data === '' || _.isEmpty(scheduleResolve.extra_data)) ? '---' : '---\n' + jsyaml.safeDump(scheduleResolve.extra_data); - $('#form-container').empty(); - scheduler = SchedulerInit({ scope: $scope, requireFutureStartTime: false }); + if (_.has(schedule, 'summary_fields.unified_job_template.unified_job_type') && + schedule.summary_fields.unified_job_template.unified_job_type === 'system_job'){ + $scope.cleanupJob = true; + } - $http.get('/api/v2/schedules/zoneinfo/').then(({data}) => { - scheduler.scope.timeZones = data; - scheduler.scope.schedulerTimeZone = _.find(data, function(x) { - let tz = $scope.schedule_obj.rrule.match(/TZID=\s*(.*?)\s*:/); - if (_.has(tz, '1')) { - return x.name === tz[1]; - } else { - return false; - } + $scope.schedule_obj = scheduleResolve; - }); - }); - scheduler.inject('form-container', false); - scheduler.injectDetail('occurrences', false); + $('#form-container').empty(); + scheduler = SchedulerInit({ scope: $scope, requireFutureStartTime: false }); - if (!/DTSTART/.test(schedule.rrule)) { - schedule.rrule += ";DTSTART=" + schedule.dtstart.replace(/\.\d+Z$/,'Z'); - } - schedule.rrule = schedule.rrule.replace(/ RRULE:/,';'); - schedule.rrule = schedule.rrule.replace(/DTSTART:/,'DTSTART='); - $scope.$on("htmlDetailReady", function() { - scheduler.setRRule(schedule.rrule); - scheduler.setName(schedule.name); - $scope.hideForm = false; + scheduler.scope.timeZones = timezonesResolve; + setTimezone(); - $scope.$watchGroup(["schedulerName", - "schedulerStartDt", - "schedulerStartHour", - "schedulerStartMinute", - "schedulerStartSecond", - "schedulerTimeZone", - "schedulerFrequency", - "schedulerInterval", - "monthlyRepeatOption", - "monthDay", - "monthlyOccurrence", - "monthlyWeekDay", - "yearlyRepeatOption", - "yearlyMonth", - "yearlyMonthDay", - "yearlyOccurrence", - "yearlyWeekDay", - "yearlyOtherMonth", - "schedulerEnd", - "schedulerOccurrenceCount", - "schedulerEndDt" - ], function() { - $rootScope.$broadcast("loadSchedulerDetailPane"); - }, true); + scheduler.inject('form-container', false); + scheduler.injectDetail('occurrences', false); - $scope.$watch("weekDays", function() { - $rootScope.$broadcast("loadSchedulerDetailPane"); - }, true); - - $rootScope.$broadcast("loadSchedulerDetailPane"); - Wait('stop'); - }); - - $scope.showRRuleDetail = false; + if (!/DTSTART/.test(schedule.rrule)) { + schedule.rrule += ";DTSTART=" + schedule.dtstart.replace(/\.\d+Z$/,'Z'); + } + schedule.rrule = schedule.rrule.replace(/ RRULE:/,';'); + schedule.rrule = schedule.rrule.replace(/DTSTART:/,'DTSTART='); + $scope.$on("htmlDetailReady", function() { scheduler.setRRule(schedule.rrule); scheduler.setName(schedule.name); + setTimezone(); + $scope.hideForm = false; - if ($scope.cleanupJob){ - $scope.schedulerPurgeDays = Number(schedule.extra_data.days); - } + $scope.$watchGroup(["schedulerName", + "schedulerStartDt", + "schedulerStartHour", + "schedulerStartMinute", + "schedulerStartSecond", + "schedulerTimeZone", + "schedulerFrequency", + "schedulerInterval", + "monthlyRepeatOption", + "monthDay", + "monthlyOccurrence", + "monthlyWeekDay", + "yearlyRepeatOption", + "yearlyMonth", + "yearlyMonthDay", + "yearlyOccurrence", + "yearlyWeekDay", + "yearlyOtherMonth", + "schedulerEnd", + "schedulerOccurrenceCount", + "schedulerEndDt" + ], function() { + $rootScope.$broadcast("loadSchedulerDetailPane"); + }, true); - if ($state.current.name === 'jobTemplateSchedules.edit'){ + $scope.$watch("weekDays", function() { + $rootScope.$broadcast("loadSchedulerDetailPane"); + }, true); - let jobTemplate = new JobTemplate(); - - Rest.setUrl(data.related.credentials); - - $q.all([jobTemplate.optionsLaunch(ParentObject.id), jobTemplate.getLaunch(ParentObject.id), Rest.get()]) - .then((responses) => { - let launchOptions = responses[0].data, - launchConf = responses[1].data; - - scheduleCredentials = responses[2].data.results; - - let watchForPromptChanges = () => { - let promptValuesToWatch = [ - 'promptData.prompts.inventory.value', - 'promptData.prompts.jobType.value', - 'promptData.prompts.verbosity.value', - 'missingSurveyValue' - ]; - - $scope.$watchGroup(promptValuesToWatch, function() { - let missingPromptValue = false; - if ($scope.missingSurveyValue) { - missingPromptValue = true; - } else if (!$scope.promptData.prompts.inventory.value || !$scope.promptData.prompts.inventory.value.id) { - missingPromptValue = true; - } - $scope.promptModalMissingReqFields = missingPromptValue; - }); - }; - - let prompts = PromptService.processPromptValues({ - launchConf: responses[1].data, - launchOptions: responses[0].data, - currentValues: data - }); - - let defaultCredsWithoutOverrides = []; - - const credentialHasScheduleOverride = (templateDefaultCred) => { - let credentialHasOverride = false; - scheduleCredentials.forEach((scheduleCred) => { - if (templateDefaultCred.credential_type === scheduleCred.credential_type) { - if ( - (!templateDefaultCred.vault_id && !scheduleCred.inputs.vault_id) || - (templateDefaultCred.vault_id && scheduleCred.inputs.vault_id && templateDefaultCred.vault_id === scheduleCred.inputs.vault_id) - ) { - credentialHasOverride = true; - } - } - }); - - return credentialHasOverride; - }; - - if (_.has(launchConf, 'defaults.credentials')) { - launchConf.defaults.credentials.forEach((defaultCred) => { - if (!credentialHasScheduleOverride(defaultCred)) { - defaultCredsWithoutOverrides.push(defaultCred); - } - }); - } - - prompts.credentials.value = defaultCredsWithoutOverrides.concat(scheduleCredentials); - - if (!launchConf.ask_variables_on_launch) { - $scope.noVars = true; - } - - if (!launchConf.survey_enabled && - !launchConf.ask_inventory_on_launch && - !launchConf.ask_credential_on_launch && - !launchConf.ask_verbosity_on_launch && - !launchConf.ask_job_type_on_launch && - !launchConf.ask_limit_on_launch && - !launchConf.ask_tags_on_launch && - !launchConf.ask_skip_tags_on_launch && - !launchConf.ask_diff_mode_on_launch && - !launchConf.survey_enabled && - !launchConf.credential_needed_to_start && - !launchConf.inventory_needed_to_start && - launchConf.passwords_needed_to_start.length === 0 && - launchConf.variables_needed_to_start.length === 0) { - $scope.showPromptButton = false; - } else { - $scope.showPromptButton = true; - - // Ignore the fact that variables might be promptable on launch - // Promptable variables will happen in the schedule form - launchConf.ignore_ask_variables = true; - - if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory') && !_.has(data, 'summary_fields.inventory')) { - $scope.promptModalMissingReqFields = true; - } - - if (responses[1].data.survey_enabled) { - // go out and get the survey questions - jobTemplate.getSurveyQuestions(ParentObject.id) - .then((surveyQuestionRes) => { - - let processed = PromptService.processSurveyQuestions({ - surveyQuestions: surveyQuestionRes.data.spec, - extra_data: _.cloneDeep(data.extra_data) - }); - - $scope.missingSurveyValue = processed.missingSurveyValue; - - $scope.extraVars = (processed.extra_data === '' || _.isEmpty(processed.extra_data)) ? '---' : '---\n' + jsyaml.safeDump(processed.extra_data); - - ParseTypeChange({ - scope: $scope, - variable: 'extraVars', - parse_variable: 'parseType', - field_id: 'SchedulerForm-extraVars', - readOnly: !$scope.schedule_obj.summary_fields.user_capabilities.edit - }); - - $scope.promptData = { - launchConf: launchConf, - launchOptions: launchOptions, - prompts: prompts, - surveyQuestions: surveyQuestionRes.data.spec, - template: ParentObject.id - }; - - $scope.$watch('promptData.surveyQuestions', () => { - let missingSurveyValue = false; - _.each($scope.promptData.surveyQuestions, (question) => { - if (question.required && (Empty(question.model) || question.model === [])) { - missingSurveyValue = true; - } - }); - $scope.missingSurveyValue = missingSurveyValue; - }, true); - - watchForPromptChanges(); - }); - } else { - $scope.promptData = { - launchConf: launchConf, - launchOptions: launchOptions, - prompts: prompts, - template: ParentObject.id - }; - watchForPromptChanges(); - } - } - }); - } else if ($state.current.name === 'workflowJobTemplateSchedules.edit') { - let workflowJobTemplate = new WorkflowJobTemplate(); - - $q.all([workflowJobTemplate.optionsLaunch(ParentObject.id), workflowJobTemplate.getLaunch(ParentObject.id)]) - .then((responses) => { - let launchOptions = responses[0].data, - launchConf = responses[1].data; - - let watchForPromptChanges = () => { - $scope.$watch('missingSurveyValue', function() { - $scope.promptModalMissingReqFields = $scope.missingSurveyValue ? true : false; - }); - }; - - let prompts = PromptService.processPromptValues({ - launchConf: responses[1].data, - launchOptions: responses[0].data, - currentValues: data - }); - - if(!launchConf.survey_enabled) { - $scope.showPromptButton = false; - } else { - $scope.showPromptButton = true; - - if(responses[1].data.survey_enabled) { - // go out and get the survey questions - workflowJobTemplate.getSurveyQuestions(ParentObject.id) - .then((surveyQuestionRes) => { - - let processed = PromptService.processSurveyQuestions({ - surveyQuestions: surveyQuestionRes.data.spec, - extra_data: _.cloneDeep(data.extra_data) - }); - - $scope.missingSurveyValue = processed.missingSurveyValue; - - $scope.promptData = { - launchConf: launchConf, - launchOptions: launchOptions, - prompts: prompts, - surveyQuestions: surveyQuestionRes.data.spec, - template: ParentObject.id - }; - - $scope.$watch('promptData.surveyQuestions', () => { - let missingSurveyValue = false; - _.each($scope.promptData.surveyQuestions, (question) => { - if(question.required && (Empty(question.model) || question.model === [])) { - missingSurveyValue = true; - } - }); - $scope.missingSurveyValue = missingSurveyValue; - }, true); - - watchForPromptChanges(); - }); - } - else { - $scope.promptData = { - launchConf: launchConf, - launchOptions: launchOptions, - prompts: prompts, - template: ParentObject.id - }; - watchForPromptChanges(); - } - } - }); - } - - // extra_data field is not manifested in the UI when scheduling a Management Job - if ($state.current.name !== 'managementJobsList.schedule.add' && $state.current.name !== 'managementJobsList.schedule.edit'){ - if ($state.current.name === 'projectSchedules.edit' || - $state.current.name === 'inventories.edit.inventory_sources.edit.schedules.edit' || - $state.current.name === 'workflowJobTemplateSchedules.add' - ){ - $scope.noVars = true; - } else { - ParseTypeChange({ - scope: $scope, - variable: 'extraVars', - parse_variable: 'parseType', - field_id: 'SchedulerForm-extraVars', - readOnly: !$scope.schedule_obj.summary_fields.user_capabilities.edit - }); - } - } - }) - .catch(({data, status}) => { - ProcessErrors($scope, data, status, null, { hdr: 'Error!', - msg: 'Failed to retrieve schedule ' + parseInt($stateParams.schedule_id) + ' GET returned: ' + status }); + $rootScope.$broadcast("loadSchedulerDetailPane"); + Wait('stop'); }); + $scope.showRRuleDetail = false; + scheduler.setRRule(schedule.rrule); + scheduler.setName(schedule.name); + scheduler.scope.timeZones = timezonesResolve; + scheduler.scope.schedulerTimeZone = scheduleResolve.timezone; + if ($scope.cleanupJob){ + $scope.schedulerPurgeDays = Number(schedule.extra_data.days); + } + + if ($state.current.name === 'jobTemplateSchedules.edit'){ + + let jobTemplate = new JobTemplate(); + + Rest.setUrl(scheduleResolve.related.credentials); + + $q.all([jobTemplate.optionsLaunch(ParentObject.id), jobTemplate.getLaunch(ParentObject.id), Rest.get()]) + .then((responses) => { + let launchOptions = responses[0].data, + launchConf = responses[1].data; + + scheduleCredentials = responses[2].data.results; + + let watchForPromptChanges = () => { + let promptValuesToWatch = [ + 'promptData.prompts.inventory.value', + 'promptData.prompts.jobType.value', + 'promptData.prompts.verbosity.value', + 'missingSurveyValue' + ]; + + $scope.$watchGroup(promptValuesToWatch, function() { + let missingPromptValue = false; + if ($scope.missingSurveyValue) { + missingPromptValue = true; + } else if (!$scope.promptData.prompts.inventory.value || !$scope.promptData.prompts.inventory.value.id) { + missingPromptValue = true; + } + $scope.promptModalMissingReqFields = missingPromptValue; + }); + }; + + let prompts = PromptService.processPromptValues({ + launchConf: responses[1].data, + launchOptions: responses[0].data, + currentValues: scheduleResolve + }); + + let defaultCredsWithoutOverrides = []; + + const credentialHasScheduleOverride = (templateDefaultCred) => { + let credentialHasOverride = false; + scheduleCredentials.forEach((scheduleCred) => { + if (templateDefaultCred.credential_type === scheduleCred.credential_type) { + if ( + (!templateDefaultCred.vault_id && !scheduleCred.inputs.vault_id) || + (templateDefaultCred.vault_id && scheduleCred.inputs.vault_id && templateDefaultCred.vault_id === scheduleCred.inputs.vault_id) + ) { + credentialHasOverride = true; + } + } + }); + + return credentialHasOverride; + }; + + if (_.has(launchConf, 'defaults.credentials')) { + launchConf.defaults.credentials.forEach((defaultCred) => { + if (!credentialHasScheduleOverride(defaultCred)) { + defaultCredsWithoutOverrides.push(defaultCred); + } + }); + } + + prompts.credentials.value = defaultCredsWithoutOverrides.concat(scheduleCredentials); + + if (!launchConf.ask_variables_on_launch) { + $scope.noVars = true; + } + + if (!launchConf.survey_enabled && + !launchConf.ask_inventory_on_launch && + !launchConf.ask_credential_on_launch && + !launchConf.ask_verbosity_on_launch && + !launchConf.ask_job_type_on_launch && + !launchConf.ask_limit_on_launch && + !launchConf.ask_tags_on_launch && + !launchConf.ask_skip_tags_on_launch && + !launchConf.ask_diff_mode_on_launch && + !launchConf.survey_enabled && + !launchConf.credential_needed_to_start && + !launchConf.inventory_needed_to_start && + launchConf.passwords_needed_to_start.length === 0 && + launchConf.variables_needed_to_start.length === 0) { + $scope.showPromptButton = false; + } else { + $scope.showPromptButton = true; + + // Ignore the fact that variables might be promptable on launch + // Promptable variables will happen in the schedule form + launchConf.ignore_ask_variables = true; + + if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory') && !_.has(scheduleResolve, 'summary_fields.inventory')) { + $scope.promptModalMissingReqFields = true; + } + + if (responses[1].data.survey_enabled) { + // go out and get the survey questions + jobTemplate.getSurveyQuestions(ParentObject.id) + .then((surveyQuestionRes) => { + + let processed = PromptService.processSurveyQuestions({ + surveyQuestions: surveyQuestionRes.data.spec, + extra_data: _.cloneDeep(scheduleResolve.extra_data) + }); + + $scope.missingSurveyValue = processed.missingSurveyValue; + + $scope.extraVars = (processed.extra_data === '' || _.isEmpty(processed.extra_data)) ? '---' : '---\n' + jsyaml.safeDump(processed.extra_data); + + ParseTypeChange({ + scope: $scope, + variable: 'extraVars', + parse_variable: 'parseType', + field_id: 'SchedulerForm-extraVars', + readOnly: !$scope.schedule_obj.summary_fields.user_capabilities.edit + }); + + $scope.promptData = { + launchConf: launchConf, + launchOptions: launchOptions, + prompts: prompts, + surveyQuestions: surveyQuestionRes.data.spec, + template: ParentObject.id + }; + + $scope.$watch('promptData.surveyQuestions', () => { + let missingSurveyValue = false; + _.each($scope.promptData.surveyQuestions, (question) => { + if (question.required && (Empty(question.model) || question.model === [])) { + missingSurveyValue = true; + } + }); + $scope.missingSurveyValue = missingSurveyValue; + }, true); + + watchForPromptChanges(); + }); + } else { + $scope.promptData = { + launchConf: launchConf, + launchOptions: launchOptions, + prompts: prompts, + template: ParentObject.id + }; + watchForPromptChanges(); + } + } + }); + } else if ($state.current.name === 'workflowJobTemplateSchedules.edit') { + let workflowJobTemplate = new WorkflowJobTemplate(); + + $q.all([workflowJobTemplate.optionsLaunch(ParentObject.id), workflowJobTemplate.getLaunch(ParentObject.id)]) + .then((responses) => { + let launchOptions = responses[0].data, + launchConf = responses[1].data; + + let watchForPromptChanges = () => { + $scope.$watch('missingSurveyValue', function() { + $scope.promptModalMissingReqFields = $scope.missingSurveyValue ? true : false; + }); + }; + + let prompts = PromptService.processPromptValues({ + launchConf: responses[1].data, + launchOptions: responses[0].data, + currentValues: scheduleResolve + }); + + if(!launchConf.survey_enabled) { + $scope.showPromptButton = false; + } else { + $scope.showPromptButton = true; + + if(responses[1].data.survey_enabled) { + // go out and get the survey questions + workflowJobTemplate.getSurveyQuestions(ParentObject.id) + .then((surveyQuestionRes) => { + + let processed = PromptService.processSurveyQuestions({ + surveyQuestions: surveyQuestionRes.data.spec, + extra_data: _.cloneDeep(scheduleResolve.extra_data) + }); + + $scope.missingSurveyValue = processed.missingSurveyValue; + + $scope.promptData = { + launchConf: launchConf, + launchOptions: launchOptions, + prompts: prompts, + surveyQuestions: surveyQuestionRes.data.spec, + template: ParentObject.id + }; + + $scope.$watch('promptData.surveyQuestions', () => { + let missingSurveyValue = false; + _.each($scope.promptData.surveyQuestions, (question) => { + if(question.required && (Empty(question.model) || question.model === [])) { + missingSurveyValue = true; + } + }); + $scope.missingSurveyValue = missingSurveyValue; + }, true); + + watchForPromptChanges(); + }); + } + else { + $scope.promptData = { + launchConf: launchConf, + launchOptions: launchOptions, + prompts: prompts, + template: ParentObject.id + }; + watchForPromptChanges(); + } + } + }); + } + + // extra_data field is not manifested in the UI when scheduling a Management Job + if ($state.current.name !== 'managementJobsList.schedule.add' && $state.current.name !== 'managementJobsList.schedule.edit'){ + if ($state.current.name === 'projectSchedules.edit' || + $state.current.name === 'inventories.edit.inventory_sources.edit.schedules.edit' || + $state.current.name === 'workflowJobTemplateSchedules.add' + ){ + $scope.noVars = true; + } else { + ParseTypeChange({ + scope: $scope, + variable: 'extraVars', + parse_variable: 'parseType', + field_id: 'SchedulerForm-extraVars', + readOnly: !$scope.schedule_obj.summary_fields.user_capabilities.edit + }); + } + } + } + init(); + callSelect2(); }]; From 876431c5294559d7305c103cd95ad44afc359a29 Mon Sep 17 00:00:00 2001 From: Jared Tabor Date: Mon, 7 May 2018 14:41:22 -0700 Subject: [PATCH 04/11] Removes the 'Z' from the UNTIL before sending the rrule to the API --- .../factories/r-rule-to-api.factory.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/awx/ui/client/src/scheduler/factories/r-rule-to-api.factory.js b/awx/ui/client/src/scheduler/factories/r-rule-to-api.factory.js index 7698ed9ea5..9c86205559 100644 --- a/awx/ui/client/src/scheduler/factories/r-rule-to-api.factory.js +++ b/awx/ui/client/src/scheduler/factories/r-rule-to-api.factory.js @@ -1,5 +1,21 @@ export default function RRuleToAPI() { + + // This function removes the 'Z' from the UNTIL portion of the + // rrule. The API will default to using the timezone that is + // specified in the TZID as the locale for the UNTIL. + function parseOutZ (rrule) { + let until = rrule.split('UNTIL='); + if(_.has(until, '1')){ + rrule = until[0]; + until = until[1].replace('Z', ''); + return `${rrule}UNTIL=${until}`; + } else { + return rrule; + } + } + + return function(rrule, scope) { let localTime = scope.schedulerLocalTime; let timeZone = scope.schedulerTimeZone.name; @@ -7,6 +23,8 @@ export default let response = rrule.replace(/(^.*(?=DTSTART))(DTSTART.*?)(=.*?;)(.*$)/, (str, p1, p2, p3, p4) => { return p2 + ';TZID=' + timeZone + ':' + localTime + ' ' + 'RRULE:' + p4; }); + + response = parseOutZ(response); return response; }; } From 00ae91dace48f5feda2979c30b42f7c084fd350a Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Tue, 8 May 2018 08:21:42 -0400 Subject: [PATCH 05/11] slight cleanup and refactor of Schedule.timezone property --- awx/api/views.py | 6 +++++- awx/main/models/schedules.py | 35 +++++++++++++++-------------------- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/awx/api/views.py b/awx/api/views.py index f1098ffce0..3304c2ca43 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -745,7 +745,11 @@ class ScheduleZoneInfo(APIView): swagger_topic = 'System Configuration' def get(self, request): - return Response(Schedule.get_zoneinfo()) + zones = [ + {'name': zone} + for zone in Schedule.get_zoneinfo() + ] + return Response(zones) class LaunchConfigCredentialsBase(SubListAttachDetachAPIView): diff --git a/awx/main/models/schedules.py b/awx/main/models/schedules.py index 565ad2de12..787e69da0b 100644 --- a/awx/main/models/schedules.py +++ b/awx/main/models/schedules.py @@ -6,9 +6,9 @@ import logging import re import dateutil.rrule -from operator import itemgetter import dateutil.parser from dateutil.tz import datetime_exists, tzutc +from dateutil.zoneinfo import get_zonefile_instance # Django from django.db import models @@ -100,28 +100,22 @@ class Schedule(CommonModel, LaunchTimeConfig): @classmethod def get_zoneinfo(self): - from dateutil.zoneinfo import get_zonefile_instance - return [ - {'name': zone} - for zone in sorted(get_zonefile_instance().zones) - ] + return sorted(get_zonefile_instance().zones) @property def timezone(self): utc = tzutc() - _rrule = dateutil.rrule.rrulestr( - self.rrule, - tzinfos={x: utc for x in dateutil.parser.parserinfo().UTCZONE} - ) - tzinfo = _rrule._dtstart.tzinfo - if tzinfo == utc: - return 'UTC' - fname = tzinfo._filename - all_zones = map(itemgetter('name'), Schedule.get_zoneinfo()) + all_zones = Schedule.get_zoneinfo() all_zones.sort(key = lambda x: -len(x)) - for zone in all_zones: - if fname.endswith(zone): - return zone + for r in Schedule.rrulestr(self.rrule)._rrule: + if r._dtstart: + tzinfo = r._dtstart.tzinfo + if tzinfo is utc: + return 'UTC' + fname = tzinfo._filename + for zone in all_zones: + if fname.endswith(zone): + return zone logger.warn('Could not detect valid zoneinfo for {}'.format(self.rrule)) return '' @@ -130,6 +124,7 @@ class Schedule(CommonModel, LaunchTimeConfig): """ Apply our own custom rrule parsing requirements """ + tzinfos = {x: tzutc() for x in dateutil.parser.parserinfo().UTCZONE} kwargs['forceset'] = True # @@ -163,7 +158,7 @@ class Schedule(CommonModel, LaunchTimeConfig): # 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} + tzinfos=tzinfos )._dtstart.tzinfo # Make a datetime object with tzinfo= @@ -181,7 +176,7 @@ class Schedule(CommonModel, LaunchTimeConfig): # 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) + x = dateutil.rrule.rrulestr(rrule, tzinfos=tzinfos, **kwargs) for r in x._rrule: if r._dtstart and r._dtstart.tzinfo is None: From 056933e33ec1b5add4318251f2988b5c3815b789 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Wed, 9 May 2018 15:00:48 -0400 Subject: [PATCH 06/11] refactor naive UNTIL= coercion --- awx/main/models/schedules.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/awx/main/models/schedules.py b/awx/main/models/schedules.py index 787e69da0b..dabf7fba10 100644 --- a/awx/main/models/schedules.py +++ b/awx/main/models/schedules.py @@ -31,6 +31,9 @@ logger = logging.getLogger('awx.main.models.schedule') __all__ = ['Schedule'] +UTC_TIMEZONES = {x: tzutc() for x in dateutil.parser.parserinfo().UTCZONE} + + class ScheduleFilterMethods(object): def enabled(self, enabled=True): @@ -120,13 +123,7 @@ class Schedule(CommonModel, LaunchTimeConfig): return '' @classmethod - def rrulestr(cls, rrule, **kwargs): - """ - Apply our own custom rrule parsing requirements - """ - tzinfos = {x: tzutc() for x in dateutil.parser.parserinfo().UTCZONE} - kwargs['forceset'] = True - + def coerce_naive_until(cls, rrule): # # RFC5545 specifies that the UNTIL rule part MUST ALWAYS be a date # with UTC time. This is extra work for API implementers because @@ -158,7 +155,7 @@ class Schedule(CommonModel, LaunchTimeConfig): # local_tz = tzfile('/usr/share/zoneinfo/America/New_York') local_tz = dateutil.rrule.rrulestr( rrule.replace(naive_until, naive_until + 'Z'), - tzinfos=tzinfos + tzinfos=UTC_TIMEZONES )._dtstart.tzinfo # Make a datetime object with tzinfo= @@ -175,8 +172,16 @@ class Schedule(CommonModel, LaunchTimeConfig): # 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) + return rrule - x = dateutil.rrule.rrulestr(rrule, tzinfos=tzinfos, **kwargs) + @classmethod + def rrulestr(cls, rrule, **kwargs): + """ + Apply our own custom rrule parsing requirements + """ + rrule = Schedule.coerce_naive_until(rrule) + kwargs['forceset'] = True + x = dateutil.rrule.rrulestr(rrule, tzinfos=UTC_TIMEZONES, **kwargs) for r in x._rrule: if r._dtstart and r._dtstart.tzinfo is None: @@ -234,4 +239,5 @@ class Schedule(CommonModel, LaunchTimeConfig): def save(self, *args, **kwargs): self.update_computed_fields() + self.rrule = Schedule.coerce_naive_until(self.rrule) super(Schedule, self).save(*args, **kwargs) From c52eb0f327315ca3a412a94ad28f7b3973ec7056 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Wed, 9 May 2018 15:11:02 -0400 Subject: [PATCH 07/11] provide a naive UNTIL= datestamp for schedules for UI convenience --- awx/api/serializers.py | 7 +- awx/main/models/schedules.py | 11 +++ .../tests/functional/models/test_schedule.py | 68 +++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index b1e62b8613..8e8b9930c5 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4512,14 +4512,19 @@ class ScheduleSerializer(LaunchConfigurationBaseSerializer, SchedulePreviewSeria show_capabilities = ['edit', 'delete'] timezone = serializers.SerializerMethodField() + until = serializers.SerializerMethodField() class Meta: model = Schedule - fields = ('*', 'unified_job_template', 'enabled', 'dtstart', 'dtend', 'rrule', 'next_run', 'timezone',) + fields = ('*', 'unified_job_template', 'enabled', 'dtstart', 'dtend', 'rrule', 'next_run', 'timezone', + 'until') def get_timezone(self, obj): return obj.timezone + def get_until(self, obj): + return obj.until + def get_related(self, obj): res = super(ScheduleSerializer, self).get_related(obj) res.update(dict( diff --git a/awx/main/models/schedules.py b/awx/main/models/schedules.py index dabf7fba10..13048dcf7d 100644 --- a/awx/main/models/schedules.py +++ b/awx/main/models/schedules.py @@ -122,6 +122,17 @@ class Schedule(CommonModel, LaunchTimeConfig): logger.warn('Could not detect valid zoneinfo for {}'.format(self.rrule)) return '' + @property + def until(self): + # The UNTIL= datestamp (if any) coerced from UTC to the local naive time + # of the DTSTART + for r in Schedule.rrulestr(self.rrule)._rrule: + if r._until: + local_until = r._until.astimezone(r._dtstart.tzinfo) + naive_until = local_until.replace(tzinfo=None) + return naive_until.isoformat() + return '' + @classmethod def coerce_naive_until(cls, rrule): # diff --git a/awx/main/tests/functional/models/test_schedule.py b/awx/main/tests/functional/models/test_schedule.py index e89dbf017d..d18e848d97 100644 --- a/awx/main/tests/functional/models/test_schedule.py +++ b/awx/main/tests/functional/models/test_schedule.py @@ -206,3 +206,71 @@ def test_timezone_property(job_template, rrule, tz): unified_job_template=job_template ) assert s.timezone == tz + + +@pytest.mark.django_db +def test_utc_until_property(job_template): + rrule = 'DTSTART:20380601T120000Z RRULE:FREQ=HOURLY;INTERVAL=1;UNTIL=20380601T170000Z' + s = Schedule( + name='Some Schedule', + rrule=rrule, + unified_job_template=job_template + ) + s.save() + + assert s.rrule.endswith('20380601T170000Z') + assert s.until == '2038-06-01T17:00:00' + + +@pytest.mark.django_db +def test_localized_until_property(job_template): + rrule = 'DTSTART;TZID=America/New_York:20380601T120000 RRULE:FREQ=HOURLY;INTERVAL=1;UNTIL=20380601T220000Z' + s = Schedule( + name='Some Schedule', + rrule=rrule, + unified_job_template=job_template + ) + s.save() + + assert s.rrule.endswith('20380601T220000Z') + assert s.until == '2038-06-01T17:00:00' + + +@pytest.mark.django_db +def test_utc_naive_coercion(job_template): + rrule = 'DTSTART:20380601T120000Z RRULE:FREQ=HOURLY;INTERVAL=1;UNTIL=20380601T170000' + s = Schedule( + name='Some Schedule', + rrule=rrule, + unified_job_template=job_template + ) + s.save() + + assert s.rrule.endswith('20380601T170000Z') + assert s.until == '2038-06-01T17:00:00' + + +@pytest.mark.django_db +def test_est_naive_coercion(job_template): + rrule = 'DTSTART;TZID=America/New_York:20380601T120000 RRULE:FREQ=HOURLY;INTERVAL=1;UNTIL=20380601T170000' + s = Schedule( + name='Some Schedule', + rrule=rrule, + unified_job_template=job_template + ) + s.save() + + assert s.rrule.endswith('20380601T220000Z') # 5PM EDT = 10PM UTC + assert s.until == '2038-06-01T17:00:00' + + +@pytest.mark.django_db +def test_empty_until_property(job_template): + rrule = 'DTSTART;TZID=America/New_York:20380601T120000 RRULE:FREQ=HOURLY;INTERVAL=1' + s = Schedule( + name='Some Schedule', + rrule=rrule, + unified_job_template=job_template + ) + s.save() + assert s.until == '' From 507db4e3b6cf9a0e159b04f51559ba795f1e1d1e Mon Sep 17 00:00:00 2001 From: Jared Tabor Date: Wed, 9 May 2018 14:25:40 -0700 Subject: [PATCH 08/11] sets the UNTIL time to use the "until" that is explicitly state on the schedules endpoint GET response --- .../src/scheduler/schedulerEdit.controller.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/awx/ui/client/src/scheduler/schedulerEdit.controller.js b/awx/ui/client/src/scheduler/schedulerEdit.controller.js index f421c61a8e..37bbf2ab6c 100644 --- a/awx/ui/client/src/scheduler/schedulerEdit.controller.js +++ b/awx/ui/client/src/scheduler/schedulerEdit.controller.js @@ -125,6 +125,21 @@ function($filter, $state, $stateParams, Wait, $scope, moment, }); } + function setUntil (scheduler) { + let { until } = scheduleResolve; + if(until !== ''){ + const date = moment(until); + const endDt = moment.parseZone(date).format("MM/DD/YYYY"); + const endHour = date.format('HH'); + const endMinute = date.format('mm'); + const endSecond = date.format('ss'); + scheduler.scope.schedulerEndDt = endDt; + scheduler.scope.schedulerEndHour = endHour; + scheduler.scope.schedulerEndMinute = endMinute; + scheduler.scope.schedulerEndSecond = endSecond; + } + } + function init() { schedule = scheduleResolve; @@ -161,6 +176,7 @@ function($filter, $state, $stateParams, Wait, $scope, moment, scheduler.setRRule(schedule.rrule); scheduler.setName(schedule.name); setTimezone(); + setUntil(scheduler); $scope.hideForm = false; $scope.$watchGroup(["schedulerName", From 04b8349895a71fa69070a2a61cf2ff8aaa8c4cdf Mon Sep 17 00:00:00 2001 From: Jared Tabor Date: Wed, 9 May 2018 16:15:02 -0700 Subject: [PATCH 09/11] fixes issues with select dropdowns and adds a search box for the timezone select dropdown --- .../src/scheduler/schedulerAdd.controller.js | 18 +++++++++++++++--- .../src/scheduler/schedulerEdit.controller.js | 5 +++++ .../src/scheduler/schedulerForm.partial.html | 2 +- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/awx/ui/client/src/scheduler/schedulerAdd.controller.js b/awx/ui/client/src/scheduler/schedulerAdd.controller.js index 49e143fa1a..70cf675aba 100644 --- a/awx/ui/client/src/scheduler/schedulerAdd.controller.js +++ b/awx/ui/client/src/scheduler/schedulerAdd.controller.js @@ -408,8 +408,20 @@ export default ['$filter', '$state', '$stateParams', '$http', 'Wait', } }); - CreateSelect2({ - element: '.MakeSelect2', - multiple: false + var callSelect2 = function() { + CreateSelect2({ + element: '.MakeSelect2', + multiple: false + }); + $("#schedulerTimeZone").select2({ + width:'100%', + containerCssClass: 'Form-dropDown', + placeholder: 'SEARCH' + }); + }; + + $scope.$on("updateSchedulerSelects", function() { + callSelect2(); }); + callSelect2(); }]; diff --git a/awx/ui/client/src/scheduler/schedulerEdit.controller.js b/awx/ui/client/src/scheduler/schedulerEdit.controller.js index 37bbf2ab6c..91f7df0e61 100644 --- a/awx/ui/client/src/scheduler/schedulerEdit.controller.js +++ b/awx/ui/client/src/scheduler/schedulerEdit.controller.js @@ -87,6 +87,11 @@ function($filter, $state, $stateParams, Wait, $scope, moment, element: '.MakeSelect2', multiple: false }); + $("#schedulerTimeZone").select2({ + width:'100%', + containerCssClass: 'Form-dropDown', + placeholder: 'SEARCH' + }); }; $scope.$on("updateSchedulerSelects", function() { diff --git a/awx/ui/client/src/scheduler/schedulerForm.partial.html b/awx/ui/client/src/scheduler/schedulerForm.partial.html index 9313db7f71..38245c3a12 100644 --- a/awx/ui/client/src/scheduler/schedulerForm.partial.html +++ b/awx/ui/client/src/scheduler/schedulerForm.partial.html @@ -122,7 +122,6 @@ +