From 84d7f6c13fba75e30653ac5c094fa0ca72e7a1d8 Mon Sep 17 00:00:00 2001 From: Michael Abashian Date: Wed, 9 Mar 2016 14:24:14 -0500 Subject: [PATCH 01/41] Moved job template routes, controllers, and views out into its own module. --- awx/ui/client/src/app.js | 72 +- awx/ui/client/src/controllers/JobTemplates.js | 1273 ----------------- .../add/inventory-job-templates-add.route.js | 19 + .../add/job-templates-add.controller.js | 452 ++++++ .../add/job-templates-add.partial.html | 5 + .../add/job-templates-add.route.js | 23 + awx/ui/client/src/job-templates/add/main.js | 17 + .../inventory-job-templates-edit.route.js | 22 + .../edit/job-templates-edit.controller.js | 596 ++++++++ .../edit/job-templates-edit.partial.html | 5 + .../edit/job-templates-edit.route.js | 22 + awx/ui/client/src/job-templates/edit/main.js | 17 + .../list/job-templates-list.controller.js | 241 ++++ .../list/job-templates-list.partial.html} | 1 - .../list/job-templates-list.route.js | 26 + awx/ui/client/src/job-templates/list/main.js | 15 + awx/ui/client/src/job-templates/main.js | 8 +- .../survey-maker/surveys/show.factory.js | 47 +- 18 files changed, 1490 insertions(+), 1371 deletions(-) delete mode 100644 awx/ui/client/src/controllers/JobTemplates.js create mode 100644 awx/ui/client/src/job-templates/add/inventory-job-templates-add.route.js create mode 100644 awx/ui/client/src/job-templates/add/job-templates-add.controller.js create mode 100644 awx/ui/client/src/job-templates/add/job-templates-add.partial.html create mode 100644 awx/ui/client/src/job-templates/add/job-templates-add.route.js create mode 100644 awx/ui/client/src/job-templates/add/main.js create mode 100644 awx/ui/client/src/job-templates/edit/inventory-job-templates-edit.route.js create mode 100644 awx/ui/client/src/job-templates/edit/job-templates-edit.controller.js create mode 100644 awx/ui/client/src/job-templates/edit/job-templates-edit.partial.html create mode 100644 awx/ui/client/src/job-templates/edit/job-templates-edit.route.js create mode 100644 awx/ui/client/src/job-templates/edit/main.js create mode 100644 awx/ui/client/src/job-templates/list/job-templates-list.controller.js rename awx/ui/client/src/{partials/job_templates.html => job-templates/list/job-templates-list.partial.html} (95%) create mode 100644 awx/ui/client/src/job-templates/list/job-templates-list.route.js create mode 100644 awx/ui/client/src/job-templates/list/main.js diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index a23e803de2..cbf50b22b6 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -47,7 +47,7 @@ import login from './login/main'; import activityStream from './activity-stream/main'; import standardOut from './standard-out/main'; import lookUpHelper from './lookup/main'; -import {JobTemplatesList, JobTemplatesAdd, JobTemplatesEdit} from './controllers/JobTemplates'; +import JobTemplates from './job-templates/main'; import {ScheduleEditController} from './controllers/Schedules'; import {ProjectsList, ProjectsAdd, ProjectsEdit} from './controllers/Projects'; import {OrganizationsList, OrganizationsAdd, OrganizationsEdit} from './controllers/Organizations'; @@ -65,7 +65,6 @@ import './shared/directives'; import './shared/filters'; import './shared/InventoryTree'; import './shared/Socket'; -import './job-templates/main'; import './shared/features/main'; import './login/authenticationServices/pendo/ng-pendo'; import footer from './footer/main'; @@ -101,6 +100,7 @@ var tower = angular.module('Tower', [ jobDetail.name, notifications.name, standardOut.name, + JobTemplates.name, 'templates', 'Utilities', 'OrganizationFormDefinition', @@ -297,52 +297,6 @@ var tower = angular.module('Tower', [ } }). - state('jobTemplates', { - url: '/job_templates', - templateUrl: urlPrefix + 'partials/job_templates.html', - controller: JobTemplatesList, - data: { - activityStream: true, - activityStreamTarget: 'job_template' - }, - ncyBreadcrumb: { - label: "JOB TEMPLATES" - }, - resolve: { - features: ['FeaturesService', function(FeaturesService) { - return FeaturesService.get(); - }] - } - }). - - state('jobTemplates.add', { - url: '/add', - templateUrl: urlPrefix + 'partials/job_templates.html', - controller: JobTemplatesAdd, - ncyBreadcrumb: { - parent: "jobTemplates", - label: "CREATE JOB TEMPLATE" - }, - resolve: { - features: ['FeaturesService', function(FeaturesService) { - return FeaturesService.get(); - }] - } - }). - - state('jobTemplates.edit', { - url: '/:template_id', - templateUrl: urlPrefix + 'partials/job_templates.html', - controller: JobTemplatesEdit, - data: { - activityStreamId: 'template_id' - }, - resolve: { - features: ['FeaturesService', function(FeaturesService) { - return FeaturesService.get(); - }] - } - }). state('projects', { url: '/projects', templateUrl: urlPrefix + 'partials/projects.html', @@ -458,28 +412,6 @@ var tower = angular.module('Tower', [ } }). - state('inventoryJobTemplateAdd', { - url: '/inventories/:inventory_id/job_templates/add', - templateUrl: urlPrefix + 'partials/job_templates.html', - controller: JobTemplatesAdd, - resolve: { - features: ['FeaturesService', function(FeaturesService) { - return FeaturesService.get(); - }] - } - }). - - state('inventoryJobTemplateEdit', { - url: '/inventories/:inventory_id/job_templates/:template_id', - templateUrl: urlPrefix + 'partials/job_templates.html', - controller: JobTemplatesEdit, - resolve: { - features: ['FeaturesService', function(FeaturesService) { - return FeaturesService.get(); - }] - } - }). - state('inventoryManage', { url: '/inventories/:inventory_id/manage?groups', templateUrl: urlPrefix + 'partials/inventory-manage.html', diff --git a/awx/ui/client/src/controllers/JobTemplates.js b/awx/ui/client/src/controllers/JobTemplates.js deleted file mode 100644 index b01dc04a48..0000000000 --- a/awx/ui/client/src/controllers/JobTemplates.js +++ /dev/null @@ -1,1273 +0,0 @@ -/************************************************* - * Copyright (c) 2015 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - -/** - * @ngdoc function - * @name controllers.function:JobTemplate - * @description This controller's for the Job Template page -*/ - - -export function JobTemplatesList($scope, $rootScope, $location, $log, - $stateParams, Rest, Alert, JobTemplateList, GenerateList, Prompt, - SearchInit, PaginateInit, ReturnToCaller, ClearScope, ProcessErrors, - GetBasePath, JobTemplateForm, CredentialList, LookUpInit, PlaybookRun, - Wait, CreateDialog, $compile, $state) { - - ClearScope(); - - var list = JobTemplateList, - defaultUrl = GetBasePath('job_templates'), - view = GenerateList, - base = $location.path().replace(/^\//, '').split('/')[0], - mode = (base === 'job_templates') ? 'edit' : 'select'; - - view.inject(list, { mode: mode, scope: $scope }); - $rootScope.flashMessage = null; - - if ($scope.removePostRefresh) { - $scope.removePostRefresh(); - } - $scope.removePostRefresh = $scope.$on('PostRefresh', function () { - // Cleanup after a delete - Wait('stop'); - $('#prompt-modal').modal('hide'); - }); - - SearchInit({ - scope: $scope, - set: 'job_templates', - list: list, - url: defaultUrl - }); - PaginateInit({ - scope: $scope, - list: list, - url: defaultUrl - }); - - // Called from Inventories tab, host failed events link: - if ($stateParams.name) { - $scope[list.iterator + 'SearchField'] = 'name'; - $scope[list.iterator + 'SearchValue'] = $stateParams.name; - $scope[list.iterator + 'SearchFieldLabel'] = list.fields.name.label; - } - - $scope.search(list.iterator); - - $scope.addJobTemplate = function () { - $state.transitionTo('jobTemplates.add'); - }; - - $scope.editJobTemplate = function (id) { - $state.transitionTo('jobTemplates.edit', {template_id: id}); - }; - - $scope.deleteJobTemplate = function (id, name) { - var action = function () { - $('#prompt-modal').modal('hide'); - Wait('start'); - var url = defaultUrl + id + '/'; - Rest.setUrl(url); - Rest.destroy() - .success(function () { - $scope.search(list.iterator); - }) - .error(function (data) { - Wait('stop'); - ProcessErrors($scope, data, status, null, { hdr: 'Error!', - msg: 'Call to ' + url + ' failed. DELETE returned status: ' + status }); - }); - }; - - Prompt({ - hdr: 'Delete', - body: '
Are you sure you want to delete the job template below?
' + name + '
', - action: action, - actionText: 'DELETE' - }); - }; - - $scope.copyJobTemplate = function(id, name){ - var element, - buttons = [{ - "label": "Cancel", - "onClick": function() { - $(this).dialog('close'); - }, - "icon": "fa-times", - "class": "btn btn-default", - "id": "copy-close-button" - },{ - "label": "Copy", - "onClick": function() { - copyAction(); - // setTimeout(function(){ - // scope.$apply(function(){ - // if(mode==='survey-taker'){ - // scope.$emit('SurveyTakerCompleted'); - // } else{ - // scope.saveSurvey(); - // } - // }); - // }); - }, - "icon": "fa-copy", - "class": "btn btn-primary", - "id": "job-copy-button" - }], - copyAction = function () { - // retrieve the copy of the job template object from the api, then overwrite the name and throw away the id - Wait('start'); - var url = defaultUrl + id + '/'; - Rest.setUrl(url); - Rest.get() - .success(function (data) { - data.name = $scope.new_copy_name; - delete data.id; - $scope.$emit('GoToCopy', data); - }) - .error(function (data) { - Wait('stop'); - ProcessErrors($scope, data, status, null, { hdr: 'Error!', - msg: 'Call to ' + url + ' failed. DELETE returned status: ' + status }); - }); - }; - - - CreateDialog({ - id: 'copy-job-modal', - title: "Copy", - scope: $scope, - buttons: buttons, - width: 500, - height: 300, - minWidth: 200, - callback: 'CopyDialogReady' - }); - - $('#job_name').text(name); - $('#copy-job-modal').show(); - - - if ($scope.removeCopyDialogReady) { - $scope.removeCopyDialogReady(); - } - $scope.removeCopyDialogReady = $scope.$on('CopyDialogReady', function() { - //clear any old remaining text - $scope.new_copy_name = "" ; - $scope.copy_form.$setPristine(); - $('#copy-job-modal').dialog('open'); - $('#job-copy-button').attr('ng-disabled', "!copy_form.$valid"); - element = angular.element(document.getElementById('job-copy-button')); - $compile(element)($scope); - - }); - - if ($scope.removeGoToCopy) { - $scope.removeGoToCopy(); - } - $scope.removeGoToCopy = $scope.$on('GoToCopy', function(e, data) { - var url = defaultUrl, - old_survey_url = (data.related.survey_spec) ? data.related.survey_spec : "" ; - Rest.setUrl(url); - Rest.post(data) - .success(function (data) { - if(data.survey_enabled===true){ - $scope.$emit("CopySurvey", data, old_survey_url); - } - else { - $('#copy-job-modal').dialog('close'); - Wait('stop'); - $location.path($location.path() + '/' + data.id); - } - - }) - .error(function (data) { - Wait('stop'); - ProcessErrors($scope, data, status, null, { hdr: 'Error!', - msg: 'Call to ' + url + ' failed. DELETE returned status: ' + status }); - }); - }); - - if ($scope.removeCopySurvey) { - $scope.removeCopySurvey(); - } - $scope.removeCopySurvey = $scope.$on('CopySurvey', function(e, new_data, old_url) { - // var url = data.related.survey_spec; - Rest.setUrl(old_url); - Rest.get() - .success(function (survey_data) { - - Rest.setUrl(new_data.related.survey_spec); - Rest.post(survey_data) - .success(function () { - $('#copy-job-modal').dialog('close'); - Wait('stop'); - $location.path($location.path() + '/' + new_data.id); - }) - .error(function (data) { - Wait('stop'); - ProcessErrors($scope, data, status, null, { hdr: 'Error!', - msg: 'Call to ' + new_data.related.survey_spec + ' failed. DELETE returned status: ' + status }); - }); - - - }) - .error(function (data) { - Wait('stop'); - ProcessErrors($scope, data, status, null, { hdr: 'Error!', - msg: 'Call to ' + old_url + ' failed. DELETE returned status: ' + status }); - }); - - }); - - }; - - $scope.submitJob = function (id) { - PlaybookRun({ scope: $scope, id: id }); - }; - - $scope.scheduleJob = function (id) { - $state.go('jobTemplateSchedules', {id: id}); - }; -} - -JobTemplatesList.$inject = ['$scope', '$rootScope', '$location', '$log', - '$stateParams', 'Rest', 'Alert', 'JobTemplateList', 'generateList', - 'Prompt', 'SearchInit', 'PaginateInit', 'ReturnToCaller', 'ClearScope', - 'ProcessErrors', 'GetBasePath', 'JobTemplateForm', 'CredentialList', - 'LookUpInit', 'PlaybookRun', 'Wait', 'CreateDialog' , '$compile', - '$state' -]; - -export function JobTemplatesAdd(Refresh, $filter, $scope, $rootScope, $compile, - $location, $log, $stateParams, JobTemplateForm, GenerateForm, Rest, Alert, - ProcessErrors, ReturnToCaller, ClearScope, GetBasePath, InventoryList, - CredentialList, ProjectList, LookUpInit, md5Setup, ParseTypeChange, Wait, - Empty, ToJSON, CallbackHelpInit, SurveyControllerInit, Prompt, GetChoices, - $state, CreateSelect2) { - - ClearScope(); - - // Inject dynamic view - var defaultUrl = GetBasePath('job_templates'), - form = JobTemplateForm(), - generator = GenerateForm, - master = {}, - CloudCredentialList = {}, - selectPlaybook, checkSCMStatus, - callback, - base = $location.path().replace(/^\//, '').split('/')[0], - context = (base === 'job_templates') ? 'job_template' : 'inv'; - - CallbackHelpInit({ scope: $scope }); - $scope.can_edit = true; - generator.inject(form, { mode: 'add', related: false, scope: $scope }); - - callback = function() { - // Make sure the form controller knows there was a change - $scope[form.name + '_form'].$setDirty(); - }; - $scope.mode = "add"; - $scope.parseType = 'yaml'; - ParseTypeChange({ scope: $scope, field_id: 'job_templates_variables', onChange: callback }); - - $scope.playbook_options = []; - $scope.allow_callbacks = false; - - generator.reset(); - - md5Setup({ - scope: $scope, - master: master, - check_field: 'allow_callbacks', - default_val: false - }); - - LookUpInit({ - scope: $scope, - form: form, - current_item: ($stateParams.inventory_id !== undefined) ? $stateParams.inventory_id : null, - list: InventoryList, - field: 'inventory', - input_type: "radio" - }); - - - // Clone the CredentialList object for use with cloud_credential. Cloning - // and changing properties to avoid collision. - jQuery.extend(true, CloudCredentialList, CredentialList); - CloudCredentialList.name = 'cloudcredentials'; - CloudCredentialList.iterator = 'cloudcredential'; - - SurveyControllerInit({ - scope: $scope, - parent_scope: $scope - }); - - if ($scope.removeLookUpInitialize) { - $scope.removeLookUpInitialize(); - } - $scope.removeLookUpInitialize = $scope.$on('lookUpInitialize', function () { - LookUpInit({ - url: GetBasePath('credentials') + '?cloud=true', - scope: $scope, - form: form, - current_item: null, - list: CloudCredentialList, - field: 'cloud_credential', - hdr: 'Select Cloud Credential', - input_type: 'radio' - }); - - LookUpInit({ - url: GetBasePath('credentials') + '?kind=ssh', - scope: $scope, - form: form, - current_item: null, - list: CredentialList, - field: 'credential', - hdr: 'Select Machine Credential', - input_type: "radio" - }); - }); - - var selectCount = 0; - - if ($scope.removeChoicesReady) { - $scope.removeChoicesReady(); - } - $scope.removeChoicesReady = $scope.$on('choicesReadyVerbosity', function () { - selectCount++; - if (selectCount === 2) { - var verbosity; - // this sets the default options for the selects as specified by the controller. - for (verbosity in $scope.verbosity_options) { - if ($scope.verbosity_options[verbosity].isDefault) { - $scope.verbosity = $scope.verbosity_options[verbosity]; - } - } - $scope.job_type = $scope.job_type_options[$scope.job_type_field.default]; - - // if you're getting to the form from the scan job section on inventories, - // set the job type select to be scan - if ($stateParams.inventory_id) { - // This means that the job template form was accessed via inventory prop's - // This also means the job is a scan job. - $scope.job_type.value = 'scan'; - $scope.jobTypeChange(); - $scope.inventory = $stateParams.inventory_id; - Rest.setUrl(GetBasePath('inventory') + $stateParams.inventory_id + '/'); - Rest.get() - .success(function (data) { - $scope.inventory_name = data.name; - }) - .error(function (data, status) { - ProcessErrors($scope, data, status, form, { hdr: 'Error!', - msg: 'Failed to lookup inventory: ' + data.id + '. GET returned status: ' + status }); - }); - } - CreateSelect2({ - element:'#job_templates_job_type', - multiple: false - }); - - CreateSelect2({ - element:'#playbook-select', - multiple: false - }); - - CreateSelect2({ - element:'#job_templates_verbosity', - multiple: false - }); - - $scope.$emit('lookUpInitialize'); - } - }); - - // setup verbosity options select - GetChoices({ - scope: $scope, - url: defaultUrl, - field: 'verbosity', - variable: 'verbosity_options', - callback: 'choicesReadyVerbosity' - }); - - // setup job type options select - GetChoices({ - scope: $scope, - url: defaultUrl, - field: 'job_type', - variable: 'job_type_options', - callback: 'choicesReadyVerbosity' - }); - - // Update playbook select whenever project value changes - selectPlaybook = function (oldValue, newValue) { - var url; - if($scope.job_type.value === 'scan' && $scope.project_name === "Default"){ - $scope.playbook_options = ['Default']; - $scope.playbook = 'Default'; - Wait('stop'); - } - else if (oldValue !== newValue) { - if ($scope.project) { - Wait('start'); - url = GetBasePath('projects') + $scope.project + '/playbooks/'; - Rest.setUrl(url); - Rest.get() - .success(function (data) { - var i, opts = []; - for (i = 0; i < data.length; i++) { - opts.push(data[i]); - } - $scope.playbook_options = opts; - Wait('stop'); - }) - .error(function (data, status) { - ProcessErrors($scope, data, status, form, { hdr: 'Error!', - msg: 'Failed to get playbook list for ' + url + '. GET returned status: ' + status }); - }); - } - } - }; - - $scope.jobTypeChange = function(){ - if($scope.job_type){ - if($scope.job_type.value === 'scan'){ - $scope.toggleScanInfo(); - } - else if($scope.project_name === "Default"){ - $scope.project_name = null; - $scope.playbook_options = []; - // $scope.playbook = 'null'; - $scope.job_templates_form.playbook.$setPristine(); - } - } - }; - - $scope.toggleScanInfo = function() { - $scope.project_name = 'Default'; - if($scope.project === null){ - selectPlaybook(); - } - else { - $scope.project = null; - } - }; - - // Detect and alert user to potential SCM status issues - checkSCMStatus = function (oldValue, newValue) { - if (oldValue !== newValue && !Empty($scope.project)) { - Rest.setUrl(GetBasePath('projects') + $scope.project + '/'); - Rest.get() - .success(function (data) { - var msg; - switch (data.status) { - case 'failed': - msg = "The selected project has a failed status. Review the project's SCM settings" + - " and run an update before adding it to a template."; - break; - case 'never updated': - msg = 'The selected project has a never updated status. You will need to run a successful' + - ' update in order to selected a playbook. Without a valid playbook you will not be able ' + - ' to save this template.'; - break; - case 'missing': - msg = 'The selected project has a status of missing. Please check the server and make sure ' + - ' the directory exists and file permissions are set correctly.'; - break; - } - if (msg) { - Alert('Warning', msg, 'alert-info', null, null, null, null, true); - } - }) - .error(function (data, status) { - ProcessErrors($scope, data, status, form, { hdr: 'Error!', - msg: 'Failed to get project ' + $scope.project + '. GET returned status: ' + status }); - }); - } - }; - - - // $scope.selectPlaybookUnregister = $scope.$watch('project_name', function (newval, oldval) { - // selectPlaybook(oldval, newval); - // checkSCMStatus(oldval, newval); - // }); - - // Register a watcher on project_name - if ($scope.selectPlaybookUnregister) { - $scope.selectPlaybookUnregister(); - } - $scope.selectPlaybookUnregister = $scope.$watch('project', function (newValue, oldValue) { - if (newValue !== oldValue) { - selectPlaybook(oldValue, newValue); - checkSCMStatus(); - } - }); - - LookUpInit({ - scope: $scope, - form: form, - current_item: null, - list: ProjectList, - field: 'project', - input_type: "radio", - autopopulateLookup: (context === 'inv') ? false : true - }); - - if ($scope.removeSurveySaved) { - $scope.rmoveSurveySaved(); - } - $scope.removeSurveySaved = $scope.$on('SurveySaved', function() { - Wait('stop'); - $scope.survey_exists = true; - $scope.invalid_survey = false; - $('#job_templates_survey_enabled_chbox').attr('checked', true); - $('#job_templates_delete_survey_btn').show(); - $('#job_templates_edit_survey_btn').show(); - $('#job_templates_create_survey_btn').hide(); - - }); - - - function saveCompleted() { - setTimeout(function() { - $scope.$apply(function() { - var base = $location.path().replace(/^\//, '').split('/')[0]; - if (base === 'job_templates') { - ReturnToCaller(); - } - else { - ReturnToCaller(1); - } - }); - }, 500); - } - - if ($scope.removeTemplateSaveSuccess) { - $scope.removeTemplateSaveSuccess(); - } - $scope.removeTemplateSaveSuccess = $scope.$on('templateSaveSuccess', function(e, data) { - Wait('stop'); - if (data.related && data.related.callback) { - Alert('Callback URL', '

Host callbacks are enabled for this template. The callback URL is:

'+ - '

' + $scope.callback_server_path + data.related.callback + '

'+ - '

The host configuration key is: ' + $filter('sanitize')(data.host_config_key) + '

', 'alert-info', saveCompleted, null, null, null, true); - } - else { - saveCompleted(); - } - }); - - // Save - $scope.formSave = function () { - $scope.invalid_survey = false; - if ($scope.removeGatherFormFields) { - $scope.removeGatherFormFields(); - } - $scope.removeGatherFormFields = $scope.$on('GatherFormFields', function(e, data) { - generator.clearApiErrors(); - Wait('start'); - data = {}; - var fld; - try { - for (fld in form.fields) { - if (form.fields[fld].type === 'select' && fld !== 'playbook') { - data[fld] = $scope[fld].value; - } else { - if (fld !== 'variables') { - data[fld] = $scope[fld]; - } - } - } - data.extra_vars = ToJSON($scope.parseType, $scope.variables, true); - if(data.job_type === 'scan' && $scope.default_scan === true){ - data.project = ""; - data.playbook = ""; - } - Rest.setUrl(defaultUrl); - Rest.post(data) - .success(function(data) { - $scope.$emit('templateSaveSuccess', data); - - $scope.addedItem = data.id; - - Refresh({ - scope: $scope, - set: 'job_templates', - iterator: 'job_template', - url: $scope.current_url - }); - - if(data.survey_enabled===true){ - //once the job template information is saved we submit the survey info to the correct endpoint - var url = data.url+ 'survey_spec/'; - Rest.setUrl(url); - Rest.post({ name: $scope.survey_name, description: $scope.survey_description, spec: $scope.survey_questions }) - .success(function () { - Wait('stop'); - - }) - .error(function (data, status) { - ProcessErrors($scope, data, status, form, { hdr: 'Error!', - msg: 'Failed to add new survey. Post returned status: ' + status }); - }); - } - - - }) - .error(function (data, status) { - ProcessErrors($scope, data, status, form, { hdr: 'Error!', - msg: 'Failed to add new job template. POST returned status: ' + status - }); - }); - - } catch (err) { - Wait('stop'); - Alert("Error", "Error parsing extra variables. Parser returned: " + err); - } - }); - - - if ($scope.removePromptForSurvey) { - $scope.removePromptForSurvey(); - } - $scope.removePromptForSurvey = $scope.$on('PromptForSurvey', function() { - var action = function () { - // $scope.$emit("GatherFormFields"); - Wait('start'); - $('#prompt-modal').modal('hide'); - $scope.addSurvey(); - - }; - Prompt({ - hdr: 'Incomplete Survey', - body: '
Do you want to create a survey before proceeding?
', - action: action - }); - }); - - // users can't save a survey with a scan job - if($scope.job_type.value === "scan" && $scope.survey_enabled === true){ - $scope.survey_enabled = false; - } - if($scope.survey_enabled === true && $scope.survey_exists!==true){ - // $scope.$emit("PromptForSurvey"); - - // The original design for this was a pop up that would prompt the user if they wanted to create a - // survey, because they had enabled one but not created it yet. We switched this for now so that - // an error message would be displayed by the survey buttons that tells the user to add a survey or disabled - // surveys. - $scope.invalid_survey = true; - return; - } else { - $scope.$emit("GatherFormFields"); - } - - - }; - - $scope.formCancel = function () { - $state.transitionTo('jobTemplates'); - }; -} - -JobTemplatesAdd.$inject = ['Refresh', '$filter', '$scope', '$rootScope', '$compile', - '$location', '$log', '$stateParams', 'JobTemplateForm', 'GenerateForm', - 'Rest', 'Alert', 'ProcessErrors', 'ReturnToCaller', 'ClearScope', - 'GetBasePath', 'InventoryList', 'CredentialList', 'ProjectList', - 'LookUpInit', 'md5Setup', 'ParseTypeChange', 'Wait', 'Empty', 'ToJSON', - 'CallbackHelpInit', 'initSurvey', 'Prompt', 'GetChoices', '$state', - 'CreateSelect2' -]; - - -export function JobTemplatesEdit($filter, $scope, $rootScope, $compile, - $location, $log, $stateParams, JobTemplateForm, GenerateForm, Rest, Alert, - ProcessErrors, RelatedSearchInit, RelatedPaginateInit, ReturnToCaller, - ClearScope, InventoryList, CredentialList, ProjectList, LookUpInit, - GetBasePath, md5Setup, ParseTypeChange, JobStatusToolTip, FormatDate, Wait, - Empty, Prompt, ParseVariableString, ToJSON, SchedulesControllerInit, - JobsControllerInit, JobsListUpdate, GetChoices, SchedulesListInit, - SchedulesList, CallbackHelpInit, PlaybookRun, SurveyControllerInit, $state, - CreateSelect2){ - - ClearScope(); - - var defaultUrl = GetBasePath('job_templates'), - generator = GenerateForm, - form = JobTemplateForm(), - base = $location.path().replace(/^\//, '').split('/')[0], - master = {}, - id = $stateParams.template_id, - relatedSets = {}, - checkSCMStatus, getPlaybooks, callback, - choicesCount = 0; - - - CallbackHelpInit({ scope: $scope }); - - SchedulesList.well = false; - generator.inject(form, { mode: 'edit', related: true, scope: $scope }); - $scope.mode = 'edit'; - $scope.parseType = 'yaml'; - $scope.showJobType = false; - - SurveyControllerInit({ - scope: $scope, - parent_scope: $scope, - id: id - }); - - callback = function() { - // Make sure the form controller knows there was a change - $scope[form.name + '_form'].$setDirty(); - }; - - $scope.playbook_options = null; - $scope.playbook = null; - generator.reset(); - - getPlaybooks = function (project) { - var url; - if($scope.job_type.value === 'scan' && $scope.project_name === "Default"){ - $scope.playbook_options = ['Default']; - $scope.playbook = 'Default'; - Wait('stop'); - } - else if (!Empty(project)) { - url = GetBasePath('projects') + project + '/playbooks/'; - Wait('start'); - Rest.setUrl(url); - Rest.get() - .success(function (data) { - var i; - $scope.playbook_options = []; - for (i = 0; i < data.length; i++) { - $scope.playbook_options.push(data[i]); - if (data[i] === $scope.playbook) { - $scope.job_templates_form.playbook.$setValidity('required', true); - } - } - if ($scope.playbook) { - $scope.$emit('jobTemplateLoadFinished'); - } else { - Wait('stop'); - } - }) - .error(function () { - Wait('stop'); - Alert('Missing Playbooks', 'Unable to retrieve the list of playbooks for this project. Choose a different ' + - ' project or make the playbooks available on the file system.', 'alert-info'); - }); - } - else { - Wait('stop'); - } - }; - - $scope.jobTypeChange = function(){ - if($scope.job_type){ - if($scope.job_type.value === 'scan'){ - $scope.toggleScanInfo(); - } - else if($scope.project_name === "Default"){ - $scope.project_name = null; - $scope.playbook_options = []; - // $scope.playbook = 'null'; - $scope.job_templates_form.playbook.$setPristine(); - } - - } - }; - - $scope.toggleScanInfo = function() { - $scope.project_name = 'Default'; - if($scope.project === null){ - getPlaybooks(); - } - else { - $scope.project = null; - } - }; - - // Detect and alert user to potential SCM status issues - checkSCMStatus = function () { - if (!Empty($scope.project)) { - Wait('start'); - Rest.setUrl(GetBasePath('projects') + $scope.project + '/'); - Rest.get() - .success(function (data) { - var msg; - switch (data.status) { - case 'failed': - msg = "The selected project has a failed status. Review the project's SCM settings" + - " and run an update before adding it to a template."; - break; - case 'never updated': - msg = 'The selected project has a never updated status. You will need to run a successful' + - ' update in order to selected a playbook. Without a valid playbook you will not be able ' + - ' to save this template.'; - break; - case 'missing': - msg = 'The selected project has a status of missing. Please check the server and make sure ' + - ' the directory exists and file permissions are set correctly.'; - break; - } - Wait('stop'); - if (msg) { - Alert('Warning', msg, 'alert-info', null, null, null, null, true); - } - }) - .error(function (data, status) { - ProcessErrors($scope, data, status, form, { hdr: 'Error!', msg: 'Failed to get project ' + $scope.project + - '. GET returned status: ' + status }); - }); - } - }; - - if ($scope.removerelatedschedules) { - $scope.removerelatedschedules(); - } - $scope.removerelatedschedules = $scope.$on('relatedschedules', function() { - SchedulesListInit({ - scope: $scope, - list: SchedulesList, - choices: null, - related: true - }); - }); - - // Register a watcher on project_name. Refresh the playbook list on change. - if ($scope.watchProjectUnregister) { - $scope.watchProjectUnregister(); - } - $scope.watchProjectUnregister = $scope.$watch('project', function (newValue, oldValue) { - if (newValue !== oldValue) { - getPlaybooks($scope.project); - checkSCMStatus(); - } - }); - - - - // Turn off 'Wait' after both cloud credential and playbook list come back - if ($scope.removeJobTemplateLoadFinished) { - $scope.removeJobTemplateLoadFinished(); - } - $scope.removeJobTemplateLoadFinished = $scope.$on('jobTemplateLoadFinished', function () { - CreateSelect2({ - element:'#job_templates_job_type', - multiple: false - }); - - CreateSelect2({ - element:'#playbook-select', - multiple: false - }); - - CreateSelect2({ - element:'#job_templates_verbosity', - multiple: false - }); - - for (var set in relatedSets) { - $scope.search(relatedSets[set].iterator); - } - SchedulesControllerInit({ - scope: $scope, - parent_scope: $scope, - iterator: 'schedule' - }); - - }); - - // Set the status/badge for each related job - if ($scope.removeRelatedCompletedJobs) { - $scope.removeRelatedCompletedJobs(); - } - $scope.removeRelatedCompletedJobs = $scope.$on('relatedcompleted_jobs', function () { - JobsControllerInit({ - scope: $scope, - parent_scope: $scope, - iterator: form.related.completed_jobs.iterator - }); - JobsListUpdate({ - scope: $scope, - parent_scope: $scope, - list: form.related.completed_jobs - }); - }); - - if ($scope.cloudCredentialReadyRemove) { - $scope.cloudCredentialReadyRemove(); - } - $scope.cloudCredentialReadyRemove = $scope.$on('cloudCredentialReady', function (e, name) { - var CloudCredentialList = {}; - $scope.cloud_credential_name = name; - master.cloud_credential_name = name; - // Clone the CredentialList object for use with cloud_credential. Cloning - // and changing properties to avoid collision. - jQuery.extend(true, CloudCredentialList, CredentialList); - CloudCredentialList.name = 'cloudcredentials'; - CloudCredentialList.iterator = 'cloudcredential'; - LookUpInit({ - url: GetBasePath('credentials') + '?cloud=true', - scope: $scope, - form: form, - current_item: $scope.cloud_credential, - list: CloudCredentialList, - field: 'cloud_credential', - hdr: 'Select Cloud Credential', - input_type: "radio" - }); - $scope.$emit('jobTemplateLoadFinished'); - }); - - - // Retrieve each related set and populate the playbook list - if ($scope.jobTemplateLoadedRemove) { - $scope.jobTemplateLoadedRemove(); - } - $scope.jobTemplateLoadedRemove = $scope.$on('jobTemplateLoaded', function (e, related_cloud_credential, masterObject, relatedSets) { - var dft, set; - master = masterObject; - getPlaybooks($scope.project); - - for (set in relatedSets) { - $scope.search(relatedSets[set].iterator); - } - - dft = ($scope.host_config_key === "" || $scope.host_config_key === null) ? false : true; - md5Setup({ - scope: $scope, - master: master, - check_field: 'allow_callbacks', - default_val: dft - }); - - ParseTypeChange({ scope: $scope, field_id: 'job_templates_variables', onChange: callback }); - - if (related_cloud_credential) { - Rest.setUrl(related_cloud_credential); - Rest.get() - .success(function (data) { - $scope.$emit('cloudCredentialReady', data.name); - }) - .error(function (data, status) { - ProcessErrors($scope, data, status, null, {hdr: 'Error!', - msg: 'Failed to related cloud credential. GET returned status: ' + status }); - }); - } else { - // No existing cloud credential - $scope.$emit('cloudCredentialReady', null); - } - }); - - Wait('start'); - - if ($scope.removeEnableSurvey) { - $scope.removeEnableSurvey(); - } - $scope.removeEnableSurvey = $scope.$on('EnableSurvey', function(fld) { - - $('#job_templates_survey_enabled_chbox').attr('checked', $scope[fld]); - Rest.setUrl(defaultUrl + id+ '/survey_spec/'); - Rest.get() - .success(function (data) { - if(!data || !data.name){ - $('#job_templates_delete_survey_btn').hide(); - $('#job_templates_edit_survey_btn').hide(); - $('#job_templates_create_survey_btn').show(); - } - else { - $scope.survey_exists = true; - $('#job_templates_delete_survey_btn').show(); - $('#job_templates_edit_survey_btn').show(); - $('#job_templates_create_survey_btn').hide(); - } - }) - .error(function (data, status) { - ProcessErrors($scope, data, status, form, { - hdr: 'Error!', - msg: 'Failed to retrieve job template: ' + $stateParams.template_id + '. GET status: ' + status - }); - }); - }); - - if ($scope.removeSurveySaved) { - $scope.rmoveSurveySaved(); - } - $scope.removeSurveySaved = $scope.$on('SurveySaved', function() { - Wait('stop'); - $scope.survey_exists = true; - $scope.invalid_survey = false; - $('#job_templates_survey_enabled_chbox').attr('checked', true); - $('#job_templates_delete_survey_btn').show(); - $('#job_templates_edit_survey_btn').show(); - $('#job_templates_create_survey_btn').hide(); - - }); - - if ($scope.removeLoadJobs) { - $scope.rmoveLoadJobs(); - } - $scope.removeLoadJobs = $scope.$on('LoadJobs', function() { - $scope.fillJobTemplate(); - }); - - if ($scope.removeChoicesReady) { - $scope.removeChoicesReady(); - } - $scope.removeChoicesReady = $scope.$on('choicesReady', function() { - choicesCount++; - if (choicesCount === 4) { - $scope.$emit('LoadJobs'); - } - }); - - GetChoices({ - scope: $scope, - url: GetBasePath('unified_jobs'), - field: 'status', - variable: 'status_choices', - callback: 'choicesReady' - }); - - GetChoices({ - scope: $scope, - url: GetBasePath('unified_jobs'), - field: 'type', - variable: 'type_choices', - callback: 'choicesReady' - }); - - // setup verbosity options lookup - GetChoices({ - scope: $scope, - url: defaultUrl, - field: 'verbosity', - variable: 'verbosity_options', - callback: 'choicesReady' - }); - - // setup job type options lookup - GetChoices({ - scope: $scope, - url: defaultUrl, - field: 'job_type', - variable: 'job_type_options', - callback: 'choicesReady' - }); - - function saveCompleted() { - setTimeout(function() { - $scope.$apply(function() { - var base = $location.path().replace(/^\//, '').split('/')[0]; - if (base === 'job_templates') { - ReturnToCaller(); - } - else { - ReturnToCaller(1); - } - }); - }, 500); - } - - if ($scope.removeTemplateSaveSuccess) { - $scope.removeTemplateSaveSuccess(); - } - $scope.removeTemplateSaveSuccess = $scope.$on('templateSaveSuccess', function(e, data) { - Wait('stop'); - if ($scope.allow_callbacks && ($scope.host_config_key !== master.host_config_key || $scope.callback_url !== master.callback_url)) { - if (data.related && data.related.callback) { - Alert('Callback URL', '

Host callbacks are enabled for this template. The callback URL is:

'+ - '

' + $scope.callback_server_path + data.related.callback + '

'+ - '

The host configuration key is: ' + $filter('sanitize')(data.host_config_key) + '

', 'alert-info', saveCompleted, null, null, null, true); - } - else { - saveCompleted(); - } - } - else { - saveCompleted(); - } - }); - - - - // Save changes to the parent - $scope.formSave = function () { - $scope.invalid_survey = false; - if ($scope.removeGatherFormFields) { - $scope.removeGatherFormFields(); - } - $scope.removeGatherFormFields = $scope.$on('GatherFormFields', function(e, data) { - generator.clearApiErrors(); - Wait('start'); - data = {}; - var fld; - try { - // Make sure we have valid variable data - data.extra_vars = ToJSON($scope.parseType, $scope.variables, true); - if(data.extra_vars === undefined ){ - throw 'undefined variables'; - } - for (fld in form.fields) { - if (form.fields[fld].type === 'select' && fld !== 'playbook') { - data[fld] = $scope[fld].value; - } else { - if (fld !== 'variables' && fld !== 'callback_url') { - data[fld] = $scope[fld]; - } - } - } - Rest.setUrl(defaultUrl + id + '/'); - Rest.put(data) - .success(function (data) { - $scope.$emit('templateSaveSuccess', data); - }) - .error(function (data, status) { - ProcessErrors($scope, data, status, form, { hdr: 'Error!', - msg: 'Failed to update job template. PUT returned status: ' + status }); - }); - - } catch (err) { - Wait('stop'); - Alert("Error", "Error parsing extra variables. Parser returned: " + err); - } - }); - - - if ($scope.removePromptForSurvey) { - $scope.removePromptForSurvey(); - } - $scope.removePromptForSurvey = $scope.$on('PromptForSurvey', function() { - var action = function () { - // $scope.$emit("GatherFormFields"); - Wait('start'); - $('#prompt-modal').modal('hide'); - $scope.addSurvey(); - - }; - Prompt({ - hdr: 'Incomplete Survey', - body: '
Do you want to create a survey before proceeding?
', - action: action - }); - }); - - // users can't save a survey with a scan job - if($scope.job_type.value === "scan" && $scope.survey_enabled === true){ - $scope.survey_enabled = false; - } - if($scope.survey_enabled === true && $scope.survey_exists!==true){ - // $scope.$emit("PromptForSurvey"); - - // The original design for this was a pop up that would prompt the user if they wanted to create a - // survey, because they had enabled one but not created it yet. We switched this for now so that - // an error message would be displayed by the survey buttons that tells the user to add a survey or disabled - // surveys. - $scope.invalid_survey = true; - return; - } else { - $scope.$emit("GatherFormFields"); - } - - }; - - $scope.formCancel = function () { - $state.transitionTo('jobTemplates'); - }; - - // Related set: Add button - $scope.add = function (set) { - $rootScope.flashMessage = null; - $location.path('/' + base + '/' + $stateParams.template_id + '/' + set); - }; - - // Related set: Edit button - $scope.edit = function (set, id) { - $rootScope.flashMessage = null; - $location.path('/' + set + '/' + id); - }; - - // Launch a job using the selected template - $scope.launch = function() { - - if ($scope.removePromptForSurvey) { - $scope.removePromptForSurvey(); - } - $scope.removePromptForSurvey = $scope.$on('PromptForSurvey', function() { - var action = function () { - // $scope.$emit("GatherFormFields"); - Wait('start'); - $('#prompt-modal').modal('hide'); - $scope.addSurvey(); - - }; - Prompt({ - hdr: 'Incomplete Survey', - body: '
Do you want to create a survey before proceeding?
', - action: action - }); - }); - if($scope.survey_enabled === true && $scope.survey_exists!==true){ - $scope.$emit("PromptForSurvey"); - } - else { - - PlaybookRun({ - scope: $scope, - id: id - }); - } - }; - - // handler for 'Enable Survey' button - $scope.surveyEnabled = function(){ - Rest.setUrl(defaultUrl + id+ '/'); - Rest.patch({"survey_enabled": $scope.survey_enabled}) - .success(function (data) { - - if(Empty(data.summary_fields.survey)){ - $('#job_templates_delete_survey_btn').hide(); - $('#job_templates_edit_survey_btn').hide(); - $('#job_templates_create_survey_btn').show(); - } - else{ - $scope.survey_exists = true; - $('#job_templates_delete_survey_btn').show(); - $('#job_templates_edit_survey_btn').show(); - $('#job_templates_create_survey_btn').hide(); - } - }) - .error(function (data, status) { - ProcessErrors($scope, data, status, form, { - hdr: 'Error!', - msg: 'Failed to retrieve save survey_enabled: ' + $stateParams.template_id + '. GET status: ' + status - }); - }); - }; - - -} - -JobTemplatesEdit.$inject = ['$filter', '$scope', '$rootScope', '$compile', - '$location', '$log', '$stateParams', 'JobTemplateForm', 'GenerateForm', - 'Rest', 'Alert', 'ProcessErrors', 'RelatedSearchInit', - 'RelatedPaginateInit','ReturnToCaller', 'ClearScope', 'InventoryList', - 'CredentialList', 'ProjectList', 'LookUpInit', 'GetBasePath', 'md5Setup', - 'ParseTypeChange', 'JobStatusToolTip', 'FormatDate', 'Wait', - 'Empty', 'Prompt', 'ParseVariableString', 'ToJSON', - 'SchedulesControllerInit', 'JobsControllerInit', 'JobsListUpdate', - 'GetChoices', 'SchedulesListInit', 'SchedulesList', 'CallbackHelpInit', - 'PlaybookRun' , 'initSurvey', '$state', 'CreateSelect2' -]; diff --git a/awx/ui/client/src/job-templates/add/inventory-job-templates-add.route.js b/awx/ui/client/src/job-templates/add/inventory-job-templates-add.route.js new file mode 100644 index 0000000000..c273fcbbf3 --- /dev/null +++ b/awx/ui/client/src/job-templates/add/inventory-job-templates-add.route.js @@ -0,0 +1,19 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import {templateUrl} from '../../shared/template-url/template-url.factory'; + +export default { + name: 'inventoryJobTemplateAdd', + url: '/inventories/:inventory_id/job_templates/add', + templateUrl: templateUrl('job-templates/add/job-templates-add'), + controller: 'JobTemplatesAdd', + resolve: { + features: ['FeaturesService', function(FeaturesService) { + return FeaturesService.get(); + }] + } +}; diff --git a/awx/ui/client/src/job-templates/add/job-templates-add.controller.js b/awx/ui/client/src/job-templates/add/job-templates-add.controller.js new file mode 100644 index 0000000000..1c7000d399 --- /dev/null +++ b/awx/ui/client/src/job-templates/add/job-templates-add.controller.js @@ -0,0 +1,452 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + + export default + [ 'Refresh', '$filter', '$scope', '$rootScope', '$compile', + '$location', '$log', '$stateParams', 'JobTemplateForm', 'GenerateForm', + 'Rest', 'Alert', 'ProcessErrors', 'ReturnToCaller', 'ClearScope', + 'GetBasePath', 'InventoryList', 'CredentialList', 'ProjectList', + 'LookUpInit', 'md5Setup', 'ParseTypeChange', 'Wait', 'Empty', 'ToJSON', + 'CallbackHelpInit', 'initSurvey', 'Prompt', 'GetChoices', '$state', + 'CreateSelect2', + function( + Refresh, $filter, $scope, $rootScope, $compile, + $location, $log, $stateParams, JobTemplateForm, GenerateForm, Rest, Alert, + ProcessErrors, ReturnToCaller, ClearScope, GetBasePath, InventoryList, + CredentialList, ProjectList, LookUpInit, md5Setup, ParseTypeChange, Wait, + Empty, ToJSON, CallbackHelpInit, SurveyControllerInit, Prompt, GetChoices, + $state, CreateSelect2 + ) { + + ClearScope(); + + // Inject dynamic view + var defaultUrl = GetBasePath('job_templates'), + form = JobTemplateForm(), + generator = GenerateForm, + master = {}, + CloudCredentialList = {}, + selectPlaybook, checkSCMStatus, + callback, + base = $location.path().replace(/^\//, '').split('/')[0], + context = (base === 'job_templates') ? 'job_template' : 'inv'; + + CallbackHelpInit({ scope: $scope }); + $scope.can_edit = true; + generator.inject(form, { mode: 'add', related: false, scope: $scope }); + + callback = function() { + // Make sure the form controller knows there was a change + $scope[form.name + '_form'].$setDirty(); + }; + $scope.mode = "add"; + $scope.parseType = 'yaml'; + ParseTypeChange({ scope: $scope, field_id: 'job_templates_variables', onChange: callback }); + + $scope.playbook_options = []; + $scope.allow_callbacks = false; + + generator.reset(); + + md5Setup({ + scope: $scope, + master: master, + check_field: 'allow_callbacks', + default_val: false + }); + + LookUpInit({ + scope: $scope, + form: form, + current_item: ($stateParams.inventory_id !== undefined) ? $stateParams.inventory_id : null, + list: InventoryList, + field: 'inventory', + input_type: "radio" + }); + + + // Clone the CredentialList object for use with cloud_credential. Cloning + // and changing properties to avoid collision. + jQuery.extend(true, CloudCredentialList, CredentialList); + CloudCredentialList.name = 'cloudcredentials'; + CloudCredentialList.iterator = 'cloudcredential'; + + SurveyControllerInit({ + scope: $scope, + parent_scope: $scope + }); + + if ($scope.removeLookUpInitialize) { + $scope.removeLookUpInitialize(); + } + $scope.removeLookUpInitialize = $scope.$on('lookUpInitialize', function () { + LookUpInit({ + url: GetBasePath('credentials') + '?cloud=true', + scope: $scope, + form: form, + current_item: null, + list: CloudCredentialList, + field: 'cloud_credential', + hdr: 'Select Cloud Credential', + input_type: 'radio' + }); + + LookUpInit({ + url: GetBasePath('credentials') + '?kind=ssh', + scope: $scope, + form: form, + current_item: null, + list: CredentialList, + field: 'credential', + hdr: 'Select Machine Credential', + input_type: "radio" + }); + }); + + var selectCount = 0; + + if ($scope.removeChoicesReady) { + $scope.removeChoicesReady(); + } + $scope.removeChoicesReady = $scope.$on('choicesReadyVerbosity', function () { + selectCount++; + if (selectCount === 2) { + var verbosity; + // this sets the default options for the selects as specified by the controller. + for (verbosity in $scope.verbosity_options) { + if ($scope.verbosity_options[verbosity].isDefault) { + $scope.verbosity = $scope.verbosity_options[verbosity]; + } + } + $scope.job_type = $scope.job_type_options[$scope.job_type_field.default]; + + // if you're getting to the form from the scan job section on inventories, + // set the job type select to be scan + if ($stateParams.inventory_id) { + // This means that the job template form was accessed via inventory prop's + // This also means the job is a scan job. + $scope.job_type.value = 'scan'; + $scope.jobTypeChange(); + $scope.inventory = $stateParams.inventory_id; + Rest.setUrl(GetBasePath('inventory') + $stateParams.inventory_id + '/'); + Rest.get() + .success(function (data) { + $scope.inventory_name = data.name; + }) + .error(function (data, status) { + ProcessErrors($scope, data, status, form, { hdr: 'Error!', + msg: 'Failed to lookup inventory: ' + data.id + '. GET returned status: ' + status }); + }); + } + CreateSelect2({ + element:'#job_templates_job_type', + multiple: false + }); + + CreateSelect2({ + element:'#playbook-select', + multiple: false + }); + + CreateSelect2({ + element:'#job_templates_verbosity', + multiple: false + }); + + $scope.$emit('lookUpInitialize'); + } + }); + + // setup verbosity options select + GetChoices({ + scope: $scope, + url: defaultUrl, + field: 'verbosity', + variable: 'verbosity_options', + callback: 'choicesReadyVerbosity' + }); + + // setup job type options select + GetChoices({ + scope: $scope, + url: defaultUrl, + field: 'job_type', + variable: 'job_type_options', + callback: 'choicesReadyVerbosity' + }); + + // Update playbook select whenever project value changes + selectPlaybook = function (oldValue, newValue) { + var url; + if($scope.job_type.value === 'scan' && $scope.project_name === "Default"){ + $scope.playbook_options = ['Default']; + $scope.playbook = 'Default'; + Wait('stop'); + } + else if (oldValue !== newValue) { + if ($scope.project) { + Wait('start'); + url = GetBasePath('projects') + $scope.project + '/playbooks/'; + Rest.setUrl(url); + Rest.get() + .success(function (data) { + var i, opts = []; + for (i = 0; i < data.length; i++) { + opts.push(data[i]); + } + $scope.playbook_options = opts; + Wait('stop'); + }) + .error(function (data, status) { + ProcessErrors($scope, data, status, form, { hdr: 'Error!', + msg: 'Failed to get playbook list for ' + url + '. GET returned status: ' + status }); + }); + } + } + }; + + $scope.jobTypeChange = function(){ + if($scope.job_type){ + if($scope.job_type.value === 'scan'){ + $scope.toggleScanInfo(); + } + else if($scope.project_name === "Default"){ + $scope.project_name = null; + $scope.playbook_options = []; + // $scope.playbook = 'null'; + $scope.job_templates_form.playbook.$setPristine(); + } + } + }; + + $scope.toggleScanInfo = function() { + $scope.project_name = 'Default'; + if($scope.project === null){ + selectPlaybook(); + } + else { + $scope.project = null; + } + }; + + // Detect and alert user to potential SCM status issues + checkSCMStatus = function (oldValue, newValue) { + if (oldValue !== newValue && !Empty($scope.project)) { + Rest.setUrl(GetBasePath('projects') + $scope.project + '/'); + Rest.get() + .success(function (data) { + var msg; + switch (data.status) { + case 'failed': + msg = "The selected project has a failed status. Review the project's SCM settings" + + " and run an update before adding it to a template."; + break; + case 'never updated': + msg = 'The selected project has a never updated status. You will need to run a successful' + + ' update in order to selected a playbook. Without a valid playbook you will not be able ' + + ' to save this template.'; + break; + case 'missing': + msg = 'The selected project has a status of missing. Please check the server and make sure ' + + ' the directory exists and file permissions are set correctly.'; + break; + } + if (msg) { + Alert('Warning', msg, 'alert-info', null, null, null, null, true); + } + }) + .error(function (data, status) { + ProcessErrors($scope, data, status, form, { hdr: 'Error!', + msg: 'Failed to get project ' + $scope.project + '. GET returned status: ' + status }); + }); + } + }; + + + // $scope.selectPlaybookUnregister = $scope.$watch('project_name', function (newval, oldval) { + // selectPlaybook(oldval, newval); + // checkSCMStatus(oldval, newval); + // }); + + // Register a watcher on project_name + if ($scope.selectPlaybookUnregister) { + $scope.selectPlaybookUnregister(); + } + $scope.selectPlaybookUnregister = $scope.$watch('project', function (newValue, oldValue) { + if (newValue !== oldValue) { + selectPlaybook(oldValue, newValue); + checkSCMStatus(); + } + }); + + LookUpInit({ + scope: $scope, + form: form, + current_item: null, + list: ProjectList, + field: 'project', + input_type: "radio", + autopopulateLookup: (context === 'inv') ? false : true + }); + + if ($scope.removeSurveySaved) { + $scope.rmoveSurveySaved(); + } + $scope.removeSurveySaved = $scope.$on('SurveySaved', function() { + Wait('stop'); + $scope.survey_exists = true; + $scope.invalid_survey = false; + $('#job_templates_survey_enabled_chbox').attr('checked', true); + $('#job_templates_delete_survey_btn').show(); + $('#job_templates_edit_survey_btn').show(); + $('#job_templates_create_survey_btn').hide(); + + }); + + + function saveCompleted() { + setTimeout(function() { + $scope.$apply(function() { + var base = $location.path().replace(/^\//, '').split('/')[0]; + if (base === 'job_templates') { + ReturnToCaller(); + } + else { + ReturnToCaller(1); + } + }); + }, 500); + } + + if ($scope.removeTemplateSaveSuccess) { + $scope.removeTemplateSaveSuccess(); + } + $scope.removeTemplateSaveSuccess = $scope.$on('templateSaveSuccess', function(e, data) { + Wait('stop'); + if (data.related && data.related.callback) { + Alert('Callback URL', '

Host callbacks are enabled for this template. The callback URL is:

'+ + '

' + $scope.callback_server_path + data.related.callback + '

'+ + '

The host configuration key is: ' + $filter('sanitize')(data.host_config_key) + '

', 'alert-info', saveCompleted, null, null, null, true); + } + else { + saveCompleted(); + } + }); + + // Save + $scope.formSave = function () { + $scope.invalid_survey = false; + if ($scope.removeGatherFormFields) { + $scope.removeGatherFormFields(); + } + $scope.removeGatherFormFields = $scope.$on('GatherFormFields', function(e, data) { + generator.clearApiErrors(); + Wait('start'); + data = {}; + var fld; + try { + for (fld in form.fields) { + if (form.fields[fld].type === 'select' && fld !== 'playbook') { + data[fld] = $scope[fld].value; + } else { + if (fld !== 'variables') { + data[fld] = $scope[fld]; + } + } + } + data.extra_vars = ToJSON($scope.parseType, $scope.variables, true); + if(data.job_type === 'scan' && $scope.default_scan === true){ + data.project = ""; + data.playbook = ""; + } + Rest.setUrl(defaultUrl); + Rest.post(data) + .success(function(data) { + $scope.$emit('templateSaveSuccess', data); + + $scope.addedItem = data.id; + + Refresh({ + scope: $scope, + set: 'job_templates', + iterator: 'job_template', + url: $scope.current_url + }); + + if(data.survey_enabled===true){ + //once the job template information is saved we submit the survey info to the correct endpoint + var url = data.url+ 'survey_spec/'; + Rest.setUrl(url); + Rest.post({ name: $scope.survey_name, description: $scope.survey_description, spec: $scope.survey_questions }) + .success(function () { + Wait('stop'); + + }) + .error(function (data, status) { + ProcessErrors($scope, data, status, form, { hdr: 'Error!', + msg: 'Failed to add new survey. Post returned status: ' + status }); + }); + } + + + }) + .error(function (data, status) { + ProcessErrors($scope, data, status, form, { hdr: 'Error!', + msg: 'Failed to add new job template. POST returned status: ' + status + }); + }); + + } catch (err) { + Wait('stop'); + Alert("Error", "Error parsing extra variables. Parser returned: " + err); + } + }); + + + if ($scope.removePromptForSurvey) { + $scope.removePromptForSurvey(); + } + $scope.removePromptForSurvey = $scope.$on('PromptForSurvey', function() { + var action = function () { + // $scope.$emit("GatherFormFields"); + Wait('start'); + $('#prompt-modal').modal('hide'); + $scope.addSurvey(); + + }; + Prompt({ + hdr: 'Incomplete Survey', + body: '
Do you want to create a survey before proceeding?
', + action: action + }); + }); + + // users can't save a survey with a scan job + if($scope.job_type.value === "scan" && $scope.survey_enabled === true){ + $scope.survey_enabled = false; + } + if($scope.survey_enabled === true && $scope.survey_exists!==true){ + // $scope.$emit("PromptForSurvey"); + + // The original design for this was a pop up that would prompt the user if they wanted to create a + // survey, because they had enabled one but not created it yet. We switched this for now so that + // an error message would be displayed by the survey buttons that tells the user to add a survey or disabled + // surveys. + $scope.invalid_survey = true; + return; + } else { + $scope.$emit("GatherFormFields"); + } + + + }; + + $scope.formCancel = function () { + $state.transitionTo('jobTemplates'); + }; + } + + ]; diff --git a/awx/ui/client/src/job-templates/add/job-templates-add.partial.html b/awx/ui/client/src/job-templates/add/job-templates-add.partial.html new file mode 100644 index 0000000000..6c3956cfb8 --- /dev/null +++ b/awx/ui/client/src/job-templates/add/job-templates-add.partial.html @@ -0,0 +1,5 @@ +
+
+
+
+
diff --git a/awx/ui/client/src/job-templates/add/job-templates-add.route.js b/awx/ui/client/src/job-templates/add/job-templates-add.route.js new file mode 100644 index 0000000000..7d79f00763 --- /dev/null +++ b/awx/ui/client/src/job-templates/add/job-templates-add.route.js @@ -0,0 +1,23 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import {templateUrl} from '../../shared/template-url/template-url.factory'; + +export default { + name: 'jobTemplates.add', + url: '/add', + templateUrl: templateUrl('job-templates/add/job-templates-add'), + controller: 'JobTemplatesAdd', + ncyBreadcrumb: { + parent: "jobTemplates", + label: "CREATE JOB TEMPLATE" + }, + resolve: { + features: ['FeaturesService', function(FeaturesService) { + return FeaturesService.get(); + }] + } +}; diff --git a/awx/ui/client/src/job-templates/add/main.js b/awx/ui/client/src/job-templates/add/main.js new file mode 100644 index 0000000000..b618873933 --- /dev/null +++ b/awx/ui/client/src/job-templates/add/main.js @@ -0,0 +1,17 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import jobTemplateAddRoute from './job-templates-add.route'; +import inventoryJobTemplateAddRoute from './inventory-job-templates-add.route'; +import controller from './job-templates-add.controller'; + +export default + angular.module('jobTemplatesAdd', []) + .controller('JobTemplatesAdd', controller) + .run(['$stateExtender', function($stateExtender) { + $stateExtender.addState(jobTemplateAddRoute); + $stateExtender.addState(inventoryJobTemplateAddRoute); + }]); diff --git a/awx/ui/client/src/job-templates/edit/inventory-job-templates-edit.route.js b/awx/ui/client/src/job-templates/edit/inventory-job-templates-edit.route.js new file mode 100644 index 0000000000..6b0d476302 --- /dev/null +++ b/awx/ui/client/src/job-templates/edit/inventory-job-templates-edit.route.js @@ -0,0 +1,22 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import {templateUrl} from '../../shared/template-url/template-url.factory'; + +export default { + name: 'inventoryJobTemplateEdit', + url: '/inventories/:inventory_id/job_templates/:template_id', + templateUrl: templateUrl('job-templates/edit/job-templates-edit'), + controller: 'JobTemplatesEdit', + data: { + activityStreamId: 'template_id' + }, + resolve: { + features: ['FeaturesService', function(FeaturesService) { + return FeaturesService.get(); + }] + } +}; diff --git a/awx/ui/client/src/job-templates/edit/job-templates-edit.controller.js b/awx/ui/client/src/job-templates/edit/job-templates-edit.controller.js new file mode 100644 index 0000000000..786358d52d --- /dev/null +++ b/awx/ui/client/src/job-templates/edit/job-templates-edit.controller.js @@ -0,0 +1,596 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +/** + * @ngdoc function + * @name controllers.function:JobTemplatesEdit + * @description This controller's for Job Template Edit +*/ + +export default + [ '$filter', '$scope', '$rootScope', '$compile', + '$location', '$log', '$stateParams', 'JobTemplateForm', 'GenerateForm', + 'Rest', 'Alert', 'ProcessErrors', 'RelatedSearchInit', + 'RelatedPaginateInit','ReturnToCaller', 'ClearScope', 'InventoryList', + 'CredentialList', 'ProjectList', 'LookUpInit', 'GetBasePath', 'md5Setup', + 'ParseTypeChange', 'JobStatusToolTip', 'FormatDate', 'Wait', + 'Empty', 'Prompt', 'ParseVariableString', 'ToJSON', + 'SchedulesControllerInit', 'JobsControllerInit', 'JobsListUpdate', + 'GetChoices', 'SchedulesListInit', 'SchedulesList', 'CallbackHelpInit', + 'PlaybookRun' , 'initSurvey', '$state', 'CreateSelect2', + function( + $filter, $scope, $rootScope, $compile, + $location, $log, $stateParams, JobTemplateForm, GenerateForm, Rest, Alert, + ProcessErrors, RelatedSearchInit, RelatedPaginateInit, ReturnToCaller, + ClearScope, InventoryList, CredentialList, ProjectList, LookUpInit, + GetBasePath, md5Setup, ParseTypeChange, JobStatusToolTip, FormatDate, Wait, + Empty, Prompt, ParseVariableString, ToJSON, SchedulesControllerInit, + JobsControllerInit, JobsListUpdate, GetChoices, SchedulesListInit, + SchedulesList, CallbackHelpInit, PlaybookRun, SurveyControllerInit, $state, + CreateSelect2 + ) { + + ClearScope(); + + var defaultUrl = GetBasePath('job_templates'), + generator = GenerateForm, + form = JobTemplateForm(), + base = $location.path().replace(/^\//, '').split('/')[0], + master = {}, + id = $stateParams.template_id, + relatedSets = {}, + checkSCMStatus, getPlaybooks, callback, + choicesCount = 0; + + + CallbackHelpInit({ scope: $scope }); + + SchedulesList.well = false; + generator.inject(form, { mode: 'edit', related: true, scope: $scope }); + $scope.mode = 'edit'; + $scope.parseType = 'yaml'; + $scope.showJobType = false; + + SurveyControllerInit({ + scope: $scope, + parent_scope: $scope, + id: id + }); + + callback = function() { + // Make sure the form controller knows there was a change + $scope[form.name + '_form'].$setDirty(); + }; + + $scope.playbook_options = null; + $scope.playbook = null; + generator.reset(); + + getPlaybooks = function (project) { + var url; + if($scope.job_type.value === 'scan' && $scope.project_name === "Default"){ + $scope.playbook_options = ['Default']; + $scope.playbook = 'Default'; + Wait('stop'); + } + else if (!Empty(project)) { + url = GetBasePath('projects') + project + '/playbooks/'; + Wait('start'); + Rest.setUrl(url); + Rest.get() + .success(function (data) { + var i; + $scope.playbook_options = []; + for (i = 0; i < data.length; i++) { + $scope.playbook_options.push(data[i]); + if (data[i] === $scope.playbook) { + $scope.job_templates_form.playbook.$setValidity('required', true); + } + } + if ($scope.playbook) { + $scope.$emit('jobTemplateLoadFinished'); + } else { + Wait('stop'); + } + }) + .error(function () { + Wait('stop'); + Alert('Missing Playbooks', 'Unable to retrieve the list of playbooks for this project. Choose a different ' + + ' project or make the playbooks available on the file system.', 'alert-info'); + }); + } + else { + Wait('stop'); + } + }; + + $scope.jobTypeChange = function(){ + if($scope.job_type){ + if($scope.job_type.value === 'scan'){ + $scope.toggleScanInfo(); + } + else if($scope.project_name === "Default"){ + $scope.project_name = null; + $scope.playbook_options = []; + // $scope.playbook = 'null'; + $scope.job_templates_form.playbook.$setPristine(); + } + + } + }; + + $scope.toggleScanInfo = function() { + $scope.project_name = 'Default'; + if($scope.project === null){ + getPlaybooks(); + } + else { + $scope.project = null; + } + }; + + // Detect and alert user to potential SCM status issues + checkSCMStatus = function () { + if (!Empty($scope.project)) { + Wait('start'); + Rest.setUrl(GetBasePath('projects') + $scope.project + '/'); + Rest.get() + .success(function (data) { + var msg; + switch (data.status) { + case 'failed': + msg = "The selected project has a failed status. Review the project's SCM settings" + + " and run an update before adding it to a template."; + break; + case 'never updated': + msg = 'The selected project has a never updated status. You will need to run a successful' + + ' update in order to selected a playbook. Without a valid playbook you will not be able ' + + ' to save this template.'; + break; + case 'missing': + msg = 'The selected project has a status of missing. Please check the server and make sure ' + + ' the directory exists and file permissions are set correctly.'; + break; + } + Wait('stop'); + if (msg) { + Alert('Warning', msg, 'alert-info', null, null, null, null, true); + } + }) + .error(function (data, status) { + ProcessErrors($scope, data, status, form, { hdr: 'Error!', msg: 'Failed to get project ' + $scope.project + + '. GET returned status: ' + status }); + }); + } + }; + + if ($scope.removerelatedschedules) { + $scope.removerelatedschedules(); + } + $scope.removerelatedschedules = $scope.$on('relatedschedules', function() { + SchedulesListInit({ + scope: $scope, + list: SchedulesList, + choices: null, + related: true + }); + }); + + // Register a watcher on project_name. Refresh the playbook list on change. + if ($scope.watchProjectUnregister) { + $scope.watchProjectUnregister(); + } + $scope.watchProjectUnregister = $scope.$watch('project', function (newValue, oldValue) { + if (newValue !== oldValue) { + getPlaybooks($scope.project); + checkSCMStatus(); + } + }); + + + + // Turn off 'Wait' after both cloud credential and playbook list come back + if ($scope.removeJobTemplateLoadFinished) { + $scope.removeJobTemplateLoadFinished(); + } + $scope.removeJobTemplateLoadFinished = $scope.$on('jobTemplateLoadFinished', function () { + CreateSelect2({ + element:'#job_templates_job_type', + multiple: false + }); + + CreateSelect2({ + element:'#playbook-select', + multiple: false + }); + + CreateSelect2({ + element:'#job_templates_verbosity', + multiple: false + }); + + for (var set in relatedSets) { + $scope.search(relatedSets[set].iterator); + } + SchedulesControllerInit({ + scope: $scope, + parent_scope: $scope, + iterator: 'schedule' + }); + + }); + + // Set the status/badge for each related job + if ($scope.removeRelatedCompletedJobs) { + $scope.removeRelatedCompletedJobs(); + } + $scope.removeRelatedCompletedJobs = $scope.$on('relatedcompleted_jobs', function () { + JobsControllerInit({ + scope: $scope, + parent_scope: $scope, + iterator: form.related.completed_jobs.iterator + }); + JobsListUpdate({ + scope: $scope, + parent_scope: $scope, + list: form.related.completed_jobs + }); + }); + + if ($scope.cloudCredentialReadyRemove) { + $scope.cloudCredentialReadyRemove(); + } + $scope.cloudCredentialReadyRemove = $scope.$on('cloudCredentialReady', function (e, name) { + var CloudCredentialList = {}; + $scope.cloud_credential_name = name; + master.cloud_credential_name = name; + // Clone the CredentialList object for use with cloud_credential. Cloning + // and changing properties to avoid collision. + jQuery.extend(true, CloudCredentialList, CredentialList); + CloudCredentialList.name = 'cloudcredentials'; + CloudCredentialList.iterator = 'cloudcredential'; + LookUpInit({ + url: GetBasePath('credentials') + '?cloud=true', + scope: $scope, + form: form, + current_item: $scope.cloud_credential, + list: CloudCredentialList, + field: 'cloud_credential', + hdr: 'Select Cloud Credential', + input_type: "radio" + }); + $scope.$emit('jobTemplateLoadFinished'); + }); + + + // Retrieve each related set and populate the playbook list + if ($scope.jobTemplateLoadedRemove) { + $scope.jobTemplateLoadedRemove(); + } + $scope.jobTemplateLoadedRemove = $scope.$on('jobTemplateLoaded', function (e, related_cloud_credential, masterObject, relatedSets) { + var dft, set; + master = masterObject; + getPlaybooks($scope.project); + + for (set in relatedSets) { + $scope.search(relatedSets[set].iterator); + } + + dft = ($scope.host_config_key === "" || $scope.host_config_key === null) ? false : true; + md5Setup({ + scope: $scope, + master: master, + check_field: 'allow_callbacks', + default_val: dft + }); + + ParseTypeChange({ scope: $scope, field_id: 'job_templates_variables', onChange: callback }); + + if (related_cloud_credential) { + Rest.setUrl(related_cloud_credential); + Rest.get() + .success(function (data) { + $scope.$emit('cloudCredentialReady', data.name); + }) + .error(function (data, status) { + ProcessErrors($scope, data, status, null, {hdr: 'Error!', + msg: 'Failed to related cloud credential. GET returned status: ' + status }); + }); + } else { + // No existing cloud credential + $scope.$emit('cloudCredentialReady', null); + } + }); + + Wait('start'); + + if ($scope.removeEnableSurvey) { + $scope.removeEnableSurvey(); + } + $scope.removeEnableSurvey = $scope.$on('EnableSurvey', function(fld) { + + $('#job_templates_survey_enabled_chbox').attr('checked', $scope[fld]); + Rest.setUrl(defaultUrl + id+ '/survey_spec/'); + Rest.get() + .success(function (data) { + if(!data || !data.name){ + $('#job_templates_delete_survey_btn').hide(); + $('#job_templates_edit_survey_btn').hide(); + $('#job_templates_create_survey_btn').show(); + } + else { + $scope.survey_exists = true; + $('#job_templates_delete_survey_btn').show(); + $('#job_templates_edit_survey_btn').show(); + $('#job_templates_create_survey_btn').hide(); + } + }) + .error(function (data, status) { + ProcessErrors($scope, data, status, form, { + hdr: 'Error!', + msg: 'Failed to retrieve job template: ' + $stateParams.template_id + '. GET status: ' + status + }); + }); + }); + + if ($scope.removeSurveySaved) { + $scope.rmoveSurveySaved(); + } + $scope.removeSurveySaved = $scope.$on('SurveySaved', function() { + Wait('stop'); + $scope.survey_exists = true; + $scope.invalid_survey = false; + $('#job_templates_survey_enabled_chbox').attr('checked', true); + $('#job_templates_delete_survey_btn').show(); + $('#job_templates_edit_survey_btn').show(); + $('#job_templates_create_survey_btn').hide(); + + }); + + if ($scope.removeLoadJobs) { + $scope.rmoveLoadJobs(); + } + $scope.removeLoadJobs = $scope.$on('LoadJobs', function() { + $scope.fillJobTemplate(); + }); + + if ($scope.removeChoicesReady) { + $scope.removeChoicesReady(); + } + $scope.removeChoicesReady = $scope.$on('choicesReady', function() { + choicesCount++; + if (choicesCount === 4) { + $scope.$emit('LoadJobs'); + } + }); + + GetChoices({ + scope: $scope, + url: GetBasePath('unified_jobs'), + field: 'status', + variable: 'status_choices', + callback: 'choicesReady' + }); + + GetChoices({ + scope: $scope, + url: GetBasePath('unified_jobs'), + field: 'type', + variable: 'type_choices', + callback: 'choicesReady' + }); + + // setup verbosity options lookup + GetChoices({ + scope: $scope, + url: defaultUrl, + field: 'verbosity', + variable: 'verbosity_options', + callback: 'choicesReady' + }); + + // setup job type options lookup + GetChoices({ + scope: $scope, + url: defaultUrl, + field: 'job_type', + variable: 'job_type_options', + callback: 'choicesReady' + }); + + function saveCompleted() { + setTimeout(function() { + $scope.$apply(function() { + var base = $location.path().replace(/^\//, '').split('/')[0]; + if (base === 'job_templates') { + ReturnToCaller(); + } + else { + ReturnToCaller(1); + } + }); + }, 500); + } + + if ($scope.removeTemplateSaveSuccess) { + $scope.removeTemplateSaveSuccess(); + } + $scope.removeTemplateSaveSuccess = $scope.$on('templateSaveSuccess', function(e, data) { + Wait('stop'); + if ($scope.allow_callbacks && ($scope.host_config_key !== master.host_config_key || $scope.callback_url !== master.callback_url)) { + if (data.related && data.related.callback) { + Alert('Callback URL', '

Host callbacks are enabled for this template. The callback URL is:

'+ + '

' + $scope.callback_server_path + data.related.callback + '

'+ + '

The host configuration key is: ' + $filter('sanitize')(data.host_config_key) + '

', 'alert-info', saveCompleted, null, null, null, true); + } + else { + saveCompleted(); + } + } + else { + saveCompleted(); + } + }); + + + + // Save changes to the parent + $scope.formSave = function () { + $scope.invalid_survey = false; + if ($scope.removeGatherFormFields) { + $scope.removeGatherFormFields(); + } + $scope.removeGatherFormFields = $scope.$on('GatherFormFields', function(e, data) { + generator.clearApiErrors(); + Wait('start'); + data = {}; + var fld; + try { + // Make sure we have valid variable data + data.extra_vars = ToJSON($scope.parseType, $scope.variables, true); + if(data.extra_vars === undefined ){ + throw 'undefined variables'; + } + for (fld in form.fields) { + if (form.fields[fld].type === 'select' && fld !== 'playbook') { + data[fld] = $scope[fld].value; + } else { + if (fld !== 'variables' && fld !== 'callback_url') { + data[fld] = $scope[fld]; + } + } + } + Rest.setUrl(defaultUrl + id + '/'); + Rest.put(data) + .success(function (data) { + $scope.$emit('templateSaveSuccess', data); + }) + .error(function (data, status) { + ProcessErrors($scope, data, status, form, { hdr: 'Error!', + msg: 'Failed to update job template. PUT returned status: ' + status }); + }); + + } catch (err) { + Wait('stop'); + Alert("Error", "Error parsing extra variables. Parser returned: " + err); + } + }); + + + if ($scope.removePromptForSurvey) { + $scope.removePromptForSurvey(); + } + $scope.removePromptForSurvey = $scope.$on('PromptForSurvey', function() { + var action = function () { + // $scope.$emit("GatherFormFields"); + Wait('start'); + $('#prompt-modal').modal('hide'); + $scope.addSurvey(); + + }; + Prompt({ + hdr: 'Incomplete Survey', + body: '
Do you want to create a survey before proceeding?
', + action: action + }); + }); + + // users can't save a survey with a scan job + if($scope.job_type.value === "scan" && $scope.survey_enabled === true){ + $scope.survey_enabled = false; + } + if($scope.survey_enabled === true && $scope.survey_exists!==true){ + // $scope.$emit("PromptForSurvey"); + + // The original design for this was a pop up that would prompt the user if they wanted to create a + // survey, because they had enabled one but not created it yet. We switched this for now so that + // an error message would be displayed by the survey buttons that tells the user to add a survey or disabled + // surveys. + $scope.invalid_survey = true; + return; + } else { + $scope.$emit("GatherFormFields"); + } + + }; + + $scope.formCancel = function () { + $state.transitionTo('jobTemplates'); + }; + + // Related set: Add button + $scope.add = function (set) { + $rootScope.flashMessage = null; + $location.path('/' + base + '/' + $stateParams.template_id + '/' + set); + }; + + // Related set: Edit button + $scope.edit = function (set, id) { + $rootScope.flashMessage = null; + $location.path('/' + set + '/' + id); + }; + + // Launch a job using the selected template + $scope.launch = function() { + + if ($scope.removePromptForSurvey) { + $scope.removePromptForSurvey(); + } + $scope.removePromptForSurvey = $scope.$on('PromptForSurvey', function() { + var action = function () { + // $scope.$emit("GatherFormFields"); + Wait('start'); + $('#prompt-modal').modal('hide'); + $scope.addSurvey(); + + }; + Prompt({ + hdr: 'Incomplete Survey', + body: '
Do you want to create a survey before proceeding?
', + action: action + }); + }); + if($scope.survey_enabled === true && $scope.survey_exists!==true){ + $scope.$emit("PromptForSurvey"); + } + else { + + PlaybookRun({ + scope: $scope, + id: id + }); + } + }; + + // handler for 'Enable Survey' button + $scope.surveyEnabled = function(){ + Rest.setUrl(defaultUrl + id+ '/'); + Rest.patch({"survey_enabled": $scope.survey_enabled}) + .success(function (data) { + + if(Empty(data.summary_fields.survey)){ + $('#job_templates_delete_survey_btn').hide(); + $('#job_templates_edit_survey_btn').hide(); + $('#job_templates_create_survey_btn').show(); + } + else{ + $scope.survey_exists = true; + $('#job_templates_delete_survey_btn').show(); + $('#job_templates_edit_survey_btn').show(); + $('#job_templates_create_survey_btn').hide(); + } + }) + .error(function (data, status) { + ProcessErrors($scope, data, status, form, { + hdr: 'Error!', + msg: 'Failed to retrieve save survey_enabled: ' + $stateParams.template_id + '. GET status: ' + status + }); + }); + }; + + + } + ]; diff --git a/awx/ui/client/src/job-templates/edit/job-templates-edit.partial.html b/awx/ui/client/src/job-templates/edit/job-templates-edit.partial.html new file mode 100644 index 0000000000..cb55a8a700 --- /dev/null +++ b/awx/ui/client/src/job-templates/edit/job-templates-edit.partial.html @@ -0,0 +1,5 @@ +
+
+
+
+
diff --git a/awx/ui/client/src/job-templates/edit/job-templates-edit.route.js b/awx/ui/client/src/job-templates/edit/job-templates-edit.route.js new file mode 100644 index 0000000000..2a4ab960e4 --- /dev/null +++ b/awx/ui/client/src/job-templates/edit/job-templates-edit.route.js @@ -0,0 +1,22 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import {templateUrl} from '../../shared/template-url/template-url.factory'; + +export default { + name: 'jobTemplates.edit', + url: '/:template_id', + templateUrl: templateUrl('job-templates/edit/job-templates-edit'), + controller: 'JobTemplatesEdit', + data: { + activityStreamId: 'template_id' + }, + resolve: { + features: ['FeaturesService', function(FeaturesService) { + return FeaturesService.get(); + }] + } +}; diff --git a/awx/ui/client/src/job-templates/edit/main.js b/awx/ui/client/src/job-templates/edit/main.js new file mode 100644 index 0000000000..b7a48404ec --- /dev/null +++ b/awx/ui/client/src/job-templates/edit/main.js @@ -0,0 +1,17 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import jobTemplateEditRoute from './job-templates-edit.route'; +import inventoryJobTemplateEditRoute from './inventory-job-templates-edit.route'; +import controller from './job-templates-edit.controller'; + +export default + angular.module('jobTemplatesEdit', []) + .controller('JobTemplatesEdit', controller) + .run(['$stateExtender', function($stateExtender) { + $stateExtender.addState(jobTemplateEditRoute); + $stateExtender.addState(inventoryJobTemplateEditRoute); + }]); diff --git a/awx/ui/client/src/job-templates/list/job-templates-list.controller.js b/awx/ui/client/src/job-templates/list/job-templates-list.controller.js new file mode 100644 index 0000000000..c23258017f --- /dev/null +++ b/awx/ui/client/src/job-templates/list/job-templates-list.controller.js @@ -0,0 +1,241 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +export default + [ '$scope', '$rootScope', '$location', '$log', + '$stateParams', 'Rest', 'Alert', 'JobTemplateList', 'generateList', + 'Prompt', 'SearchInit', 'PaginateInit', 'ReturnToCaller', 'ClearScope', + 'ProcessErrors', 'GetBasePath', 'JobTemplateForm', 'CredentialList', + 'LookUpInit', 'PlaybookRun', 'Wait', 'CreateDialog' , '$compile', + '$state', + + function( + $scope, $rootScope, $location, $log, + $stateParams, Rest, Alert, JobTemplateList, GenerateList, Prompt, + SearchInit, PaginateInit, ReturnToCaller, ClearScope, ProcessErrors, + GetBasePath, JobTemplateForm, CredentialList, LookUpInit, PlaybookRun, + Wait, CreateDialog, $compile, $state + ) { + + ClearScope(); + + var list = JobTemplateList, + defaultUrl = GetBasePath('job_templates'), + view = GenerateList, + base = $location.path().replace(/^\//, '').split('/')[0], + mode = (base === 'job_templates') ? 'edit' : 'select'; + + view.inject(list, { mode: mode, scope: $scope }); + $rootScope.flashMessage = null; + + if ($scope.removePostRefresh) { + $scope.removePostRefresh(); + } + $scope.removePostRefresh = $scope.$on('PostRefresh', function () { + // Cleanup after a delete + Wait('stop'); + $('#prompt-modal').modal('hide'); + }); + + SearchInit({ + scope: $scope, + set: 'job_templates', + list: list, + url: defaultUrl + }); + PaginateInit({ + scope: $scope, + list: list, + url: defaultUrl + }); + + // Called from Inventories tab, host failed events link: + if ($stateParams.name) { + $scope[list.iterator + 'SearchField'] = 'name'; + $scope[list.iterator + 'SearchValue'] = $stateParams.name; + $scope[list.iterator + 'SearchFieldLabel'] = list.fields.name.label; + } + + $scope.search(list.iterator); + + $scope.addJobTemplate = function () { + $state.transitionTo('jobTemplates.add'); + }; + + $scope.editJobTemplate = function (id) { + $state.transitionTo('jobTemplates.edit', {template_id: id}); + }; + + $scope.deleteJobTemplate = function (id, name) { + var action = function () { + $('#prompt-modal').modal('hide'); + Wait('start'); + var url = defaultUrl + id + '/'; + Rest.setUrl(url); + Rest.destroy() + .success(function () { + $scope.search(list.iterator); + }) + .error(function (data) { + Wait('stop'); + ProcessErrors($scope, data, status, null, { hdr: 'Error!', + msg: 'Call to ' + url + ' failed. DELETE returned status: ' + status }); + }); + }; + + Prompt({ + hdr: 'Delete', + body: '
Are you sure you want to delete the job template below?
' + name + '
', + action: action, + actionText: 'DELETE' + }); + }; + + $scope.copyJobTemplate = function(id, name){ + var element, + buttons = [{ + "label": "Cancel", + "onClick": function() { + $(this).dialog('close'); + }, + "icon": "fa-times", + "class": "btn btn-default", + "id": "copy-close-button" + },{ + "label": "Copy", + "onClick": function() { + copyAction(); + // setTimeout(function(){ + // scope.$apply(function(){ + // if(mode==='survey-taker'){ + // scope.$emit('SurveyTakerCompleted'); + // } else{ + // scope.saveSurvey(); + // } + // }); + // }); + }, + "icon": "fa-copy", + "class": "btn btn-primary", + "id": "job-copy-button" + }], + copyAction = function () { + // retrieve the copy of the job template object from the api, then overwrite the name and throw away the id + Wait('start'); + var url = defaultUrl + id + '/'; + Rest.setUrl(url); + Rest.get() + .success(function (data) { + data.name = $scope.new_copy_name; + delete data.id; + $scope.$emit('GoToCopy', data); + }) + .error(function (data) { + Wait('stop'); + ProcessErrors($scope, data, status, null, { hdr: 'Error!', + msg: 'Call to ' + url + ' failed. DELETE returned status: ' + status }); + }); + }; + + + CreateDialog({ + id: 'copy-job-modal', + title: "Copy", + scope: $scope, + buttons: buttons, + width: 500, + height: 300, + minWidth: 200, + callback: 'CopyDialogReady' + }); + + $('#job_name').text(name); + $('#copy-job-modal').show(); + + + if ($scope.removeCopyDialogReady) { + $scope.removeCopyDialogReady(); + } + $scope.removeCopyDialogReady = $scope.$on('CopyDialogReady', function() { + //clear any old remaining text + $scope.new_copy_name = "" ; + $scope.copy_form.$setPristine(); + $('#copy-job-modal').dialog('open'); + $('#job-copy-button').attr('ng-disabled', "!copy_form.$valid"); + element = angular.element(document.getElementById('job-copy-button')); + $compile(element)($scope); + + }); + + if ($scope.removeGoToCopy) { + $scope.removeGoToCopy(); + } + $scope.removeGoToCopy = $scope.$on('GoToCopy', function(e, data) { + var url = defaultUrl, + old_survey_url = (data.related.survey_spec) ? data.related.survey_spec : "" ; + Rest.setUrl(url); + Rest.post(data) + .success(function (data) { + if(data.survey_enabled===true){ + $scope.$emit("CopySurvey", data, old_survey_url); + } + else { + $('#copy-job-modal').dialog('close'); + Wait('stop'); + $location.path($location.path() + '/' + data.id); + } + + }) + .error(function (data) { + Wait('stop'); + ProcessErrors($scope, data, status, null, { hdr: 'Error!', + msg: 'Call to ' + url + ' failed. DELETE returned status: ' + status }); + }); + }); + + if ($scope.removeCopySurvey) { + $scope.removeCopySurvey(); + } + $scope.removeCopySurvey = $scope.$on('CopySurvey', function(e, new_data, old_url) { + // var url = data.related.survey_spec; + Rest.setUrl(old_url); + Rest.get() + .success(function (survey_data) { + + Rest.setUrl(new_data.related.survey_spec); + Rest.post(survey_data) + .success(function () { + $('#copy-job-modal').dialog('close'); + Wait('stop'); + $location.path($location.path() + '/' + new_data.id); + }) + .error(function (data) { + Wait('stop'); + ProcessErrors($scope, data, status, null, { hdr: 'Error!', + msg: 'Call to ' + new_data.related.survey_spec + ' failed. DELETE returned status: ' + status }); + }); + + + }) + .error(function (data) { + Wait('stop'); + ProcessErrors($scope, data, status, null, { hdr: 'Error!', + msg: 'Call to ' + old_url + ' failed. DELETE returned status: ' + status }); + }); + + }); + + }; + + $scope.submitJob = function (id) { + PlaybookRun({ scope: $scope, id: id }); + }; + + $scope.scheduleJob = function (id) { + $state.go('jobTemplateSchedules', {id: id}); + }; + } + ]; diff --git a/awx/ui/client/src/partials/job_templates.html b/awx/ui/client/src/job-templates/list/job-templates-list.partial.html similarity index 95% rename from awx/ui/client/src/partials/job_templates.html rename to awx/ui/client/src/job-templates/list/job-templates-list.partial.html index 9aa018d9bc..4fcfdeb6e9 100644 --- a/awx/ui/client/src/partials/job_templates.html +++ b/awx/ui/client/src/job-templates/list/job-templates-list.partial.html @@ -3,7 +3,6 @@
-
diff --git a/awx/ui/client/src/job-templates/main.js b/awx/ui/client/src/job-templates/main.js index bba8038410..4bbf4c1125 100644 --- a/awx/ui/client/src/job-templates/main.js +++ b/awx/ui/client/src/job-templates/main.js @@ -10,7 +10,10 @@ import surveyMaker from './survey-maker/main'; import jobTemplatesList from './list/main'; import jobTemplatesAdd from './add/main'; import jobTemplatesEdit from './edit/main'; +import jobTemplatesCopy from './copy/main'; export default - angular.module('jobTemplates', [surveyMaker.name, jobTemplatesList.name, jobTemplatesAdd.name, jobTemplatesEdit.name]) + angular.module('jobTemplates', + [surveyMaker.name, jobTemplatesList.name, jobTemplatesAdd.name, + jobTemplatesEdit.name, jobTemplatesCopy.name]) .service('deleteJobTemplate', deleteJobTemplate); diff --git a/awx/ui/client/src/lists/JobTemplates.js b/awx/ui/client/src/lists/JobTemplates.js index 55ed708df2..d7331d62d5 100644 --- a/awx/ui/client/src/lists/JobTemplates.js +++ b/awx/ui/client/src/lists/JobTemplates.js @@ -71,7 +71,7 @@ export default }, copy: { label: 'Copy', - ngClick: "copyJobTemplate(job_template.id, job_template.name)", + 'ui-sref': 'jobTemplates.copy({id: job_template.id})', "class": 'btn-danger btn-xs', awToolTip: 'Copy template', dataPlacement: 'top', diff --git a/awx/ui/client/src/lists/ScanJobs.js b/awx/ui/client/src/lists/ScanJobs.js index 457a7d2d1e..93eaa9486c 100644 --- a/awx/ui/client/src/lists/ScanJobs.js +++ b/awx/ui/client/src/lists/ScanJobs.js @@ -58,8 +58,7 @@ export default }, copy: { label: 'Copy', - ngClick: "copyJobTemplate(job_template.id, job_template.name)", - "class": 'btn-danger btn-xs', + 'ui-sref': 'jobTemplates.copy({id: job_template.id})', "class": 'btn-danger btn-xs', awToolTip: 'Copy template', dataPlacement: 'top', ngHide: 'job_template.summary_fields.can_copy===false' diff --git a/awx/ui/client/tests/job-templates/delete-job-template.service-test.js b/awx/ui/client/tests/job-templates/delete-job-template.service-test.js index f301995c57..014d46651f 100644 --- a/awx/ui/client/tests/job-templates/delete-job-template.service-test.js +++ b/awx/ui/client/tests/job-templates/delete-job-template.service-test.js @@ -1,26 +1,44 @@ import '../support/node'; -import jobTemplates from 'job-templates/main'; -import {describeModule} from '../support/describe-module'; +import jobTemplatesModule from 'job-templates/main'; +import RestStub from '../support/rest-stub'; -describeModule(jobTemplates.name) - .testService('deleteJobTemplate', function(test, restStub) { +//import RestStub from '../support/rest-stub'; - var service; +describe('jobTemplates.service', function(){ + var $httpBackend, jobTemplates, service, Rest, $q, $stateExtender; - test.withService(function(_service) { - service = _service; - }); + before('instantiate RestStub', function(){ + Rest = new RestStub(); + }); - it('deletes the job template', function() { - var result = {}; + beforeEach('instantiate the jobTemplates module', function(){ + angular.mock.module(jobTemplatesModule.name); + }); - var actual = service(); + beforeEach('mock dependencies', angular.mock.module(['$provide', function(_$provide_){ + var $provide = _$provide_; + $provide.value('GetBasePath', angular.noop); + $provide.value('$stateExtender', {addState: angular.noop}); + $provide.value('Rest', Rest); + }])); - restStub.succeedOn('destroy', result); - restStub.flush(); + beforeEach('put $q into the scope', window.inject(['$q', function($q){ + Rest.$q = $q; + }])) - expect(actual).to.eventually.equal(result); + beforeEach('inject real dependencies', inject(function($injector){ + $httpBackend = $injector.get('$httpBackend'); + service = $injector.get('deleteJobTemplate'); + })); + describe('deleteJobTemplate', function(){ + it('deletes a job template', function() { + var result = {}; + var actual = service.deleteJobTemplate(1); + + $httpBackend.when('DELETE', 'url').respond(200) + expect(actual).to.eventually.equal(result); }); }); +}); diff --git a/config/awx-munin.conf b/config/awx-munin.conf index 90c479f77a..833a6f36bf 100644 --- a/config/awx-munin.conf +++ b/config/awx-munin.conf @@ -1,17 +1,12 @@ - -Alias /munin /var/cache/munin/www - +Alias /munin /var/www/html/munin/ + Order Allow,Deny Allow from all - Options FollowSymLinks AuthUserFile /var/lib/awx/.munin_htpasswd AuthName "Munin" AuthType Basic require valid-user - - ExpiresActive On - ExpiresDefault M310 - +ScriptAlias /munin-cgi/munin-cgi-graph /var/www/cgi-bin/munin-cgi-graph \ No newline at end of file From eab223d229c30bd4d1b2bb62ce6ece19e22bdb68 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Fri, 11 Mar 2016 15:11:08 -0500 Subject: [PATCH 14/41] Make sure we are covering system jobs and template on notifications --- awx/api/serializers.py | 5 ++++ awx/api/urls.py | 4 +++ awx/api/views.py | 27 +++++++++++++++++++ .../management/commands/run_task_system.py | 2 ++ awx/main/models/jobs.py | 7 +++++ awx/main/signals.py | 4 ++- awx/main/tasks.py | 10 +++++++ 7 files changed, 58 insertions(+), 1 deletion(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index f8ab5c4d73..9aed65dbf4 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1852,6 +1852,10 @@ class SystemJobTemplateSerializer(UnifiedJobTemplateSerializer): jobs = reverse('api:system_job_template_jobs_list', args=(obj.pk,)), schedules = reverse('api:system_job_template_schedules_list', args=(obj.pk,)), launch = reverse('api:system_job_template_launch', args=(obj.pk,)), + notifiers_any = reverse('api:system_job_template_notifiers_any_list', args=(obj.pk,)), + notifiers_success = reverse('api:system_job_template_notifiers_success_list', args=(obj.pk,)), + notifiers_error = reverse('api:system_job_template_notifiers_error_list', args=(obj.pk,)), + )) return res @@ -1866,6 +1870,7 @@ class SystemJobSerializer(UnifiedJobSerializer): if obj.system_job_template and obj.system_job_template.active: res['system_job_template'] = reverse('api:system_job_template_detail', args=(obj.system_job_template.pk,)) + res['notifications'] = reverse('api:system_job_notifications_list', args=(obj.pk,)) if obj.can_cancel or True: res['cancel'] = reverse('api:system_job_cancel', args=(obj.pk,)) return res diff --git a/awx/api/urls.py b/awx/api/urls.py index ed36f4e057..394ff3639e 100644 --- a/awx/api/urls.py +++ b/awx/api/urls.py @@ -218,12 +218,16 @@ system_job_template_urls = patterns('awx.api.views', url(r'^(?P[0-9]+)/launch/$', 'system_job_template_launch'), url(r'^(?P[0-9]+)/jobs/$', 'system_job_template_jobs_list'), url(r'^(?P[0-9]+)/schedules/$', 'system_job_template_schedules_list'), + url(r'^(?P[0-9]+)/notifiers_any/$', 'system_job_template_notifiers_any_list'), + url(r'^(?P[0-9]+)/notifiers_error/$', 'system_job_template_notifiers_error_list'), + url(r'^(?P[0-9]+)/notifiers_success/$', 'system_job_template_notifiers_success_list'), ) system_job_urls = patterns('awx.api.views', url(r'^$', 'system_job_list'), url(r'^(?P[0-9]+)/$', 'system_job_detail'), url(r'^(?P[0-9]+)/cancel/$', 'system_job_cancel'), + url(r'^(?P[0-9]+)/notifications/$', 'system_job_notifications_list'), ) notifier_urls = patterns('awx.api.views', diff --git a/awx/api/views.py b/awx/api/views.py index 9b330805aa..b203015770 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -2223,6 +2223,27 @@ class SystemJobTemplateJobsList(SubListAPIView): relationship = 'jobs' parent_key = 'system_job_template' +class SystemJobTemplateNotifiersAnyList(SubListCreateAttachDetachAPIView): + + model = Notifier + serializer_class = NotifierSerializer + parent_model = SystemJobTemplate + relationship = 'notifiers_any' + +class SystemJobTemplateNotifiersErrorList(SubListCreateAttachDetachAPIView): + + model = Notifier + serializer_class = NotifierSerializer + parent_model = SystemJobTemplate + relationship = 'notifiers_error' + +class SystemJobTemplateNotifiersSuccessList(SubListCreateAttachDetachAPIView): + + model = Notifier + serializer_class = NotifierSerializer + parent_model = SystemJobTemplate + relationship = 'notifiers_success' + class JobList(ListCreateAPIView): model = Job @@ -2903,6 +2924,12 @@ class SystemJobCancel(RetrieveAPIView): else: return self.http_method_not_allowed(request, *args, **kwargs) +class SystemJobNotificationsList(SubListAPIView): + + model = Notification + serializer_class = NotificationSerializer + parent_model = SystemJob + relationship = 'notifications' class UnifiedJobTemplateList(ListAPIView): diff --git a/awx/main/management/commands/run_task_system.py b/awx/main/management/commands/run_task_system.py index 5b5dd3bff0..9e933a0507 100644 --- a/awx/main/management/commands/run_task_system.py +++ b/awx/main/management/commands/run_task_system.py @@ -108,6 +108,8 @@ class SimpleDAG(object): return "inventory_update" elif type(obj) == ProjectUpdate: return "project_update" + elif type(obj) == SystemJob: + return "system_job" return "unknown" def get_dependencies(self, obj): diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 9c1ba1c50e..e8a13d7737 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -1065,6 +1065,13 @@ class SystemJobTemplate(UnifiedJobTemplate, SystemJobOptions): def cache_timeout_blocked(self): return False + @property + def notifiers(self): + base_notifiers = Notifier.objects.filter(active=True) + error_notifiers = list(base_notifiers.filter(unifiedjobtemplate_notifiers_for_errors__in=[self])) + success_notifiers = list(base_notifiers.filter(unifiedjobtemplate_notifiers_for_success__in=[self])) + any_notifiers = list(base_notifiers.filter(unifiedjobtemplate_notifiers_for_any__in=[self])) + return dict(error=list(error_notifiers), success=list(success_notifiers), any=list(any_notifiers)) class SystemJob(UnifiedJob, SystemJobOptions): diff --git a/awx/main/signals.py b/awx/main/signals.py index 29c5c7d016..5a633ee0f6 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -393,9 +393,11 @@ def activity_stream_associate(sender, instance, **kwargs): obj2_id = entity_acted obj2_actual = obj2.objects.get(id=obj2_id) object2 = camelcase_to_underscore(obj2.__name__) - # Skip recording any inventory source changes here. + # Skip recording any inventory source, or system job template changes here. if isinstance(obj1, InventorySource) or isinstance(obj2_actual, InventorySource): continue + if isinstance(obj1, SystemJobTemplate) or isinstance(obj2_actual, SystemJobTemplate): + continue activity_entry = ActivityStream( operation=action, object1=object1, diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 1ad8524240..ec34886632 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -201,6 +201,11 @@ def handle_work_success(self, result, task_actual): instance_name = instance.module_name notifiers = [] # TODO: Ad-hoc commands need to notify someone friendly_name = "AdHoc Command" + elif task_actual['type'] == 'system_job': + instance = SystemJob.objects.get(id=task_actual['id']) + instance_name = instance.system_job_template.name + notifiers = instance.system_job_template.notifiers + friendly_name = "System Job" else: return notification_body = instance.notification_data() @@ -244,6 +249,11 @@ def handle_work_error(self, task_id, subtasks=None): instance_name = instance.module_name notifiers = [] friendly_name = "AdHoc Command" + elif task_actual['type'] == 'system_job': + instance = SystemJob.objects.get(id=task_actual['id']) + instance_name = instance.system_job_template.name + notifiers = instance.system_job_template.notifiers + friendly_name = "System Job" else: # Unknown task type break From ff1c41e8dcbe243c92e9014b591fbe155c5c3e7f Mon Sep 17 00:00:00 2001 From: Leigh Johnson Date: Fri, 11 Mar 2016 15:33:16 -0500 Subject: [PATCH 15/41] revert unintended change to awx-munin.conf --- config/awx-munin.conf | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/config/awx-munin.conf b/config/awx-munin.conf index 833a6f36bf..5cfffe573e 100644 --- a/config/awx-munin.conf +++ b/config/awx-munin.conf @@ -1,12 +1,17 @@ -Alias /munin /var/www/html/munin/ - + +Alias /munin /var/cache/munin/www + Order Allow,Deny Allow from all + Options FollowSymLinks AuthUserFile /var/lib/awx/.munin_htpasswd AuthName "Munin" AuthType Basic require valid-user - -ScriptAlias /munin-cgi/munin-cgi-graph /var/www/cgi-bin/munin-cgi-graph \ No newline at end of file + + ExpiresActive On + ExpiresDefault M310 + + \ No newline at end of file From 765dcd33185224f9dad0db87c7b3588592642c13 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Fri, 11 Mar 2016 08:47:33 -0500 Subject: [PATCH 16/41] Added queries that calculate counts for organization resources --- awx/api/serializers.py | 8 ++ awx/api/views.py | 78 +++++++++++++++++++ .../api/test_organization_counts.py | 54 +++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 awx/main/tests/functional/api/test_organization_counts.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 9aed65dbf4..743ca1c054 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -798,6 +798,14 @@ class OrganizationSerializer(BaseSerializer): )) return res + def get_summary_fields(self, obj): + summary_dict = super(OrganizationSerializer, self).get_summary_fields(obj) + counts_dict = self.context.get('counts', None) + if counts_dict is not None and summary_dict is not None: + print 'counts_dict: ' + str(counts_dict) + summary_dict['counts'] = counts_dict[obj.id] + return summary_dict + class ProjectOptionsSerializer(BaseSerializer): diff --git a/awx/api/views.py b/awx/api/views.py index b203015770..09a14f5381 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -596,6 +596,15 @@ class OrganizationList(ListCreateAPIView): model = Organization serializer_class = OrganizationSerializer + # @paginated + # def get(self, *args, **kwargs): + # # self.paginated_params = {'limit': limit, 'offset': offset, 'ordering': ordering} + # limit = kwargs.pop('limit') + # offset = kwargs.pop('offset') + # ordering = kwargs.pop('ordering') + # # qs[offset:offset + limit] + # return (super(OrganizationList, self).get(*args, **kwargs), 5, None) + def create(self, request, *args, **kwargs): """Create a new organzation. @@ -614,6 +623,75 @@ class OrganizationList(ListCreateAPIView): # Okay, create the organization as usual. return super(OrganizationList, self).create(request, *args, **kwargs) + def get_serializer_context(self, *args, **kwargs): + full_context = super(OrganizationList, self).get_serializer_context(*args, **kwargs) + + if self.request is None: + return full_context + + db_results = {} + org_qs = self.request.user.get_queryset(self.model) + org_id_list = org_qs.values('id') + if len(org_id_list) == 0: + return full_context + + # Produce counts of Foreign Key relationships + db_results['inventories'] = self.request.user.get_queryset(Inventory)\ + .values('organization').annotate(Count('organization')).order_by('organization') + + db_results['teams'] = self.request.user.get_queryset(Team)\ + .values('organization').annotate(Count('organization')).order_by('organization') + + JT_reference = 'inventory__organization' + db_JT_results = self.request.user.get_queryset(JobTemplate)\ + .values(JT_reference).annotate(Count(JT_reference)).\ + order_by(JT_reference) + + # Produce counts of m2m relationships + project_qs = self.request.user.get_queryset(Project) + db_results['projects'] = Organization.projects.through.objects\ + .filter( + project_id__in=project_qs.values_list('pk', flat=True), + organization_id__in=org_qs.values_list('pk', flat=True))\ + .values('organization')\ + .annotate(Count('organization')).order_by('organization') + + # TODO: When RBAC branch merges, change these to role relation + user_qs = self.request.user.get_queryset(User) + db_results['users'] = Organization.users.through.objects\ + .filter( + user_id__in=user_qs.values_list('pk', flat=True), + organization_id__in=org_qs.values_list('pk', flat=True))\ + .values('organization')\ + .annotate(Count('organization')).order_by('organization') + + db_results['admins'] = Organization.admins.through.objects\ + .filter( + user_id__in=user_qs.values_list('pk', flat=True), + organization_id__in=org_qs.values_list('pk', flat=True))\ + .values('organization')\ + .annotate(Count('organization')).order_by('organization') + + count_context = {} + for org in org_id_list: + org_id = org['id'] + count_context[org_id] = {'inventories': 0, 'teams': 0, 'users': 0, + 'job_templates': 0, 'admins': 0, + 'projects': 0} + + for res in db_results: + for entry in db_results[res]: + org_id = entry['organization'] + count_context[org_id][res] = entry['organization__count'] + + for entry in db_JT_results: + org_id = entry[JT_reference] + count_context[org_id]['job_templates'] = entry['%s__count' % JT_reference] + + full_context['counts'] = count_context + + return full_context + class OrganizationDetail(RetrieveUpdateDestroyAPIView): model = Organization diff --git a/awx/main/tests/functional/api/test_organization_counts.py b/awx/main/tests/functional/api/test_organization_counts.py new file mode 100644 index 0000000000..6a31bf1151 --- /dev/null +++ b/awx/main/tests/functional/api/test_organization_counts.py @@ -0,0 +1,54 @@ +import pytest + +from django.core.urlresolvers import reverse + +@pytest.fixture +def resourced_organization(organization, project, user): + admin_user = user('test-admin', True) + member_user = user('org-member') + + # Associate one resource of every type with the organization + organization.users.add(member_user) + organization.admins.add(admin_user) + organization.projects.add(project) + organization.teams.create(name='org-team') + inventory = organization.inventories.create(name="associated-inv") + inventory.jobtemplates.create(name="test-jt", + description="test-job-template-desc", + project=project, + playbook="test_playbook.yml") + + return organization + +@pytest.mark.django_db +def test_org_counts_admin(resourced_organization, user, get): + # Check that all types of resources are counted by a superuser + external_admin = user('admin', True) + response = get(reverse('api:organization_list', args=[]), external_admin) + counts = response.data['results'][0]['summary_fields']['counts'] + + assert counts == { + 'users': 1, + 'admins': 1, + 'job_templates': 1, + 'projects': 1, + 'inventories': 1, + 'teams': 1 + } + +@pytest.mark.django_db +def test_org_counts_member(resourced_organization, get): + # Check that a non-admin user can only see the full project and + # user count, consistent with the RBAC rules + member_user = resourced_organization.users.get(username='org-member') + response = get(reverse('api:organization_list', args=[]), member_user) + counts = response.data['results'][0]['summary_fields']['counts'] + + assert counts == { + 'users': 1, # User can see themselves + 'admins': 0, + 'job_templates': 0, + 'projects': 1, # Projects are shared with all the organization + 'inventories': 0, + 'teams': 0 + } From 9f25a48936413c072c0b97d6048abd3eb9952b90 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Sun, 13 Mar 2016 11:45:08 -0400 Subject: [PATCH 17/41] fix for POST scenario --- awx/api/views.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/awx/api/views.py b/awx/api/views.py index 09a14f5381..7d7e5b34ad 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -596,15 +596,6 @@ class OrganizationList(ListCreateAPIView): model = Organization serializer_class = OrganizationSerializer - # @paginated - # def get(self, *args, **kwargs): - # # self.paginated_params = {'limit': limit, 'offset': offset, 'ordering': ordering} - # limit = kwargs.pop('limit') - # offset = kwargs.pop('offset') - # ordering = kwargs.pop('ordering') - # # qs[offset:offset + limit] - # return (super(OrganizationList, self).get(*args, **kwargs), 5, None) - def create(self, request, *args, **kwargs): """Create a new organzation. @@ -673,11 +664,16 @@ class OrganizationList(ListCreateAPIView): .annotate(Count('organization')).order_by('organization') count_context = {} + zeroed_dict = {'inventories': 0, 'teams': 0, 'users': 0, + 'job_templates': 0, 'admins': 0, 'projects': 0} for org in org_id_list: org_id = org['id'] - count_context[org_id] = {'inventories': 0, 'teams': 0, 'users': 0, - 'job_templates': 0, 'admins': 0, - 'projects': 0} + count_context[org_id] = zeroed_dict.copy() + if self.request.method == 'POST': + org_id = max([int(k) for k in count_context.keys()]) + 1 + # org_id = instance = self.get_object().id + # self.request.data['id'] + count_context[org_id] = zeroed_dict for res in db_results: for entry in db_results[res]: From 39c956335243832688db842558108bd8f984418b Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Sun, 13 Mar 2016 20:40:21 -0400 Subject: [PATCH 18/41] test and fix for POST to empty list scenaro and JT count fix --- awx/api/serializers.py | 8 ++- awx/api/views.py | 19 +++---- .../api/test_organization_counts.py | 51 +++++++++++++++++++ 3 files changed, 67 insertions(+), 11 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 743ca1c054..514075bc29 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -802,8 +802,12 @@ class OrganizationSerializer(BaseSerializer): summary_dict = super(OrganizationSerializer, self).get_summary_fields(obj) counts_dict = self.context.get('counts', None) if counts_dict is not None and summary_dict is not None: - print 'counts_dict: ' + str(counts_dict) - summary_dict['counts'] = counts_dict[obj.id] + if obj.id not in counts_dict: + summary_dict['counts'] = { + 'inventories': 0, 'teams': 0, 'users': 0, + 'job_templates': 0, 'admins': 0, 'projects': 0} + else: + summary_dict['counts'] = counts_dict[obj.id] return summary_dict diff --git a/awx/api/views.py b/awx/api/views.py index 7d7e5b34ad..9bf2acbf40 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -624,17 +624,23 @@ class OrganizationList(ListCreateAPIView): org_qs = self.request.user.get_queryset(self.model) org_id_list = org_qs.values('id') if len(org_id_list) == 0: + if self.request.method == 'POST': + full_context['counts'] = {} return full_context # Produce counts of Foreign Key relationships - db_results['inventories'] = self.request.user.get_queryset(Inventory)\ + inv_qs = self.request.user.get_queryset(Inventory) + db_results['inventories'] = inv_qs\ .values('organization').annotate(Count('organization')).order_by('organization') db_results['teams'] = self.request.user.get_queryset(Team)\ .values('organization').annotate(Count('organization')).order_by('organization') JT_reference = 'inventory__organization' + # Extra filter is applied on the inventory, because this catches + # the case of deleted (and purged) inventory db_JT_results = self.request.user.get_queryset(JobTemplate)\ + .filter(inventory_id__in=inv_qs.values_list('pk', flat=True))\ .values(JT_reference).annotate(Count(JT_reference)).\ order_by(JT_reference) @@ -664,16 +670,11 @@ class OrganizationList(ListCreateAPIView): .annotate(Count('organization')).order_by('organization') count_context = {} - zeroed_dict = {'inventories': 0, 'teams': 0, 'users': 0, - 'job_templates': 0, 'admins': 0, 'projects': 0} for org in org_id_list: org_id = org['id'] - count_context[org_id] = zeroed_dict.copy() - if self.request.method == 'POST': - org_id = max([int(k) for k in count_context.keys()]) + 1 - # org_id = instance = self.get_object().id - # self.request.data['id'] - count_context[org_id] = zeroed_dict + count_context[org_id] = { + 'inventories': 0, 'teams': 0, 'users': 0, 'job_templates': 0, + 'admins': 0, 'projects': 0} for res in db_results: for entry in db_results[res]: diff --git a/awx/main/tests/functional/api/test_organization_counts.py b/awx/main/tests/functional/api/test_organization_counts.py index 6a31bf1151..694eb24468 100644 --- a/awx/main/tests/functional/api/test_organization_counts.py +++ b/awx/main/tests/functional/api/test_organization_counts.py @@ -52,3 +52,54 @@ def test_org_counts_member(resourced_organization, get): 'inventories': 0, 'teams': 0 } + +@pytest.mark.django_db +def test_new_org_zero_counts(user, post): + # Check that a POST to the organization list endpoint returns + # correct counts, including the new record + org_list_url = reverse('api:organization_list', args=[]) + post_response = post(url=org_list_url, data={'name': 'test organization', + 'description': ''}, user=user('admin', True)) + new_org_list = post_response.render().data + counts_dict = new_org_list['summary_fields']['counts'] + + assert counts_dict == { + 'users': 0, + 'admins': 0, + 'job_templates': 0, + 'projects': 0, + 'inventories': 0, + 'teams': 0 + } + +@pytest.mark.django_db +def test_two_organizations(resourced_organization, organizations, user, get): + # Check correct results for two organizations are returned + external_admin = user('admin', True) + organization_zero = organizations(1)[0] + response = get(reverse('api:organization_list', args=[]), external_admin) + org_id_full = resourced_organization.id + org_id_zero = organization_zero.id + print ' ids: ' + str(org_id_full) + " : " + str(org_id_zero) + print ' counts_dict: ' + str(response.data['results']) + counts = {} + for i in range(2): + org_id = response.data['results'][i]['id'] + counts[org_id] = response.data['results'][i]['summary_fields']['counts'] + + assert counts[org_id_full] == { + 'users': 1, + 'admins': 1, + 'job_templates': 1, + 'projects': 1, + 'inventories': 1, + 'teams': 1 + } + assert counts[org_id_zero] == { + 'users': 0, + 'admins': 0, + 'job_templates': 0, + 'projects': 0, + 'inventories': 0, + 'teams': 0 + } From 6996ea22b00019eba7be4a61c150032b7549377c Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Sun, 13 Mar 2016 22:39:52 -0400 Subject: [PATCH 19/41] style tweaks, add one more assertion --- awx/api/views.py | 4 ++-- awx/main/tests/functional/api/test_organization_counts.py | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/awx/api/views.py b/awx/api/views.py index 9bf2acbf40..ebdb57098d 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -641,8 +641,8 @@ class OrganizationList(ListCreateAPIView): # the case of deleted (and purged) inventory db_JT_results = self.request.user.get_queryset(JobTemplate)\ .filter(inventory_id__in=inv_qs.values_list('pk', flat=True))\ - .values(JT_reference).annotate(Count(JT_reference)).\ - order_by(JT_reference) + .values(JT_reference).annotate(Count(JT_reference))\ + .order_by(JT_reference) # Produce counts of m2m relationships project_qs = self.request.user.get_queryset(Project) diff --git a/awx/main/tests/functional/api/test_organization_counts.py b/awx/main/tests/functional/api/test_organization_counts.py index 694eb24468..56fdb8215e 100644 --- a/awx/main/tests/functional/api/test_organization_counts.py +++ b/awx/main/tests/functional/api/test_organization_counts.py @@ -63,6 +63,7 @@ def test_new_org_zero_counts(user, post): new_org_list = post_response.render().data counts_dict = new_org_list['summary_fields']['counts'] + assert post_response.status_code == 201 assert counts_dict == { 'users': 0, 'admins': 0, @@ -80,8 +81,6 @@ def test_two_organizations(resourced_organization, organizations, user, get): response = get(reverse('api:organization_list', args=[]), external_admin) org_id_full = resourced_organization.id org_id_zero = organization_zero.id - print ' ids: ' + str(org_id_full) + " : " + str(org_id_zero) - print ' counts_dict: ' + str(response.data['results']) counts = {} for i in range(2): org_id = response.data['results'][i]['id'] From 938772544206d24c7c5b23f4e4f74c433ba76db0 Mon Sep 17 00:00:00 2001 From: Ken Hoes Date: Mon, 14 Mar 2016 09:26:43 -0400 Subject: [PATCH 20/41] Lookup modal feedback from demo prep --- awx/ui/client/legacy-styles/jquery-ui-overrides.less | 3 +++ awx/ui/client/src/lookup/lookup.factory.js | 1 + 2 files changed, 4 insertions(+) diff --git a/awx/ui/client/legacy-styles/jquery-ui-overrides.less b/awx/ui/client/legacy-styles/jquery-ui-overrides.less index c40440bf8a..2027e218e9 100644 --- a/awx/ui/client/legacy-styles/jquery-ui-overrides.less +++ b/awx/ui/client/legacy-styles/jquery-ui-overrides.less @@ -31,6 +31,9 @@ table.ui-datepicker-calendar { opacity: .7; text-shadow: 0 1px 0 @white; } + .ui-dialog-content { + overflow: hidden; + } .ui-widget-header { border-radius: 0; border: none; diff --git a/awx/ui/client/src/lookup/lookup.factory.js b/awx/ui/client/src/lookup/lookup.factory.js index e52dfb4876..cf8507a941 100644 --- a/awx/ui/client/src/lookup/lookup.factory.js +++ b/awx/ui/client/src/lookup/lookup.factory.js @@ -175,6 +175,7 @@ export default ['Rest', 'ProcessErrors', 'generateList', width: 600, height: (instructions) ? 625 : 450, minWidth: 500, + resizable: false, title: hdr, id: 'LookupModal-dialog', onClose: function() { From 36182109e248f2073d5c81e6a1fc7f532448285e Mon Sep 17 00:00:00 2001 From: kensible Date: Mon, 14 Mar 2016 10:27:25 -0400 Subject: [PATCH 21/41] Revert "Lookup modal feedback from demo prep" --- awx/ui/client/legacy-styles/jquery-ui-overrides.less | 3 --- awx/ui/client/src/lookup/lookup.factory.js | 1 - 2 files changed, 4 deletions(-) diff --git a/awx/ui/client/legacy-styles/jquery-ui-overrides.less b/awx/ui/client/legacy-styles/jquery-ui-overrides.less index 2027e218e9..c40440bf8a 100644 --- a/awx/ui/client/legacy-styles/jquery-ui-overrides.less +++ b/awx/ui/client/legacy-styles/jquery-ui-overrides.less @@ -31,9 +31,6 @@ table.ui-datepicker-calendar { opacity: .7; text-shadow: 0 1px 0 @white; } - .ui-dialog-content { - overflow: hidden; - } .ui-widget-header { border-radius: 0; border: none; diff --git a/awx/ui/client/src/lookup/lookup.factory.js b/awx/ui/client/src/lookup/lookup.factory.js index cf8507a941..e52dfb4876 100644 --- a/awx/ui/client/src/lookup/lookup.factory.js +++ b/awx/ui/client/src/lookup/lookup.factory.js @@ -175,7 +175,6 @@ export default ['Rest', 'ProcessErrors', 'generateList', width: 600, height: (instructions) ? 625 : 450, minWidth: 500, - resizable: false, title: hdr, id: 'LookupModal-dialog', onClose: function() { From 5fdab74ae75c2af7851a5ace864ec8dc0e090835 Mon Sep 17 00:00:00 2001 From: Ken Hoes Date: Mon, 14 Mar 2016 11:36:29 -0400 Subject: [PATCH 22/41] Increase specificity and keep y-scrollbar for long name edge cases. --- awx/ui/client/legacy-styles/jquery-ui-overrides.less | 3 --- awx/ui/client/src/lookup/lookup.block.less | 4 ++++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/awx/ui/client/legacy-styles/jquery-ui-overrides.less b/awx/ui/client/legacy-styles/jquery-ui-overrides.less index 2027e218e9..c40440bf8a 100644 --- a/awx/ui/client/legacy-styles/jquery-ui-overrides.less +++ b/awx/ui/client/legacy-styles/jquery-ui-overrides.less @@ -31,9 +31,6 @@ table.ui-datepicker-calendar { opacity: .7; text-shadow: 0 1px 0 @white; } - .ui-dialog-content { - overflow: hidden; - } .ui-widget-header { border-radius: 0; border: none; diff --git a/awx/ui/client/src/lookup/lookup.block.less b/awx/ui/client/src/lookup/lookup.block.less index 9f2dea25a9..25c05ef803 100644 --- a/awx/ui/client/src/lookup/lookup.block.less +++ b/awx/ui/client/src/lookup/lookup.block.less @@ -19,4 +19,8 @@ .List-tableCell { color: @default-interface-txt; } + + &.ui-dialog-content { + overflow-x: hidden; + } } From b43f7e8c7f88d09164faf524d65b6cf5bba21958 Mon Sep 17 00:00:00 2001 From: Ken Hoes Date: Mon, 14 Mar 2016 11:42:44 -0400 Subject: [PATCH 23/41] Lookup modal not-resizable --- awx/ui/client/src/lookup/lookup.factory.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/client/src/lookup/lookup.factory.js b/awx/ui/client/src/lookup/lookup.factory.js index cf8507a941..ef4cfafba6 100644 --- a/awx/ui/client/src/lookup/lookup.factory.js +++ b/awx/ui/client/src/lookup/lookup.factory.js @@ -175,9 +175,9 @@ export default ['Rest', 'ProcessErrors', 'generateList', width: 600, height: (instructions) ? 625 : 450, minWidth: 500, - resizable: false, title: hdr, id: 'LookupModal-dialog', + resizable: false, onClose: function() { setTimeout(function() { scope.$apply(function() { From 001eb1f69f546fe83e5e84a22cde5576f4409558 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Mon, 14 Mar 2016 11:56:51 -0400 Subject: [PATCH 24/41] Handle a mongo OperationFailure This seems to happenw hen the database is up, allows us to connect but we don't have permission to access the objects (or they don't exist yet). --- awx/main/migrations/_system_tracking.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/awx/main/migrations/_system_tracking.py b/awx/main/migrations/_system_tracking.py index e5be20f4ef..936786609b 100644 --- a/awx/main/migrations/_system_tracking.py +++ b/awx/main/migrations/_system_tracking.py @@ -1,6 +1,7 @@ from awx.fact.models import FactVersion from mongoengine.connection import ConnectionError +from pymongo.errors import OperationFailure from django.conf import settings def drop_system_tracking_db(): @@ -11,6 +12,9 @@ def drop_system_tracking_db(): # TODO: Log this. Not a deal-breaker. Just let the user know they # may need to manually drop/delete the database. pass + except OperationFailure: + # TODO: This means the database was up but something happened when we tried to query it + return pass def migrate_facts(apps, schema_editor): Fact = apps.get_model('main', "Fact") @@ -22,6 +26,9 @@ def migrate_facts(apps, schema_editor): # TODO: Let the user know about the error. Likely this is # a new install and we just don't need to do this return (0, 0) + except OperationFailure: + # TODO: This means the database was up but something happened when we tried to query it + return (0, 0) migrated_count = 0 not_migrated_count = 0 From 7e818319b55b97e4c30fe4b608984b971e2e9523 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Mon, 14 Mar 2016 13:53:34 -0400 Subject: [PATCH 25/41] Pass, don't return pass when checking mongo connectivity --- awx/main/migrations/_system_tracking.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/main/migrations/_system_tracking.py b/awx/main/migrations/_system_tracking.py index 936786609b..71206d253e 100644 --- a/awx/main/migrations/_system_tracking.py +++ b/awx/main/migrations/_system_tracking.py @@ -14,7 +14,7 @@ def drop_system_tracking_db(): pass except OperationFailure: # TODO: This means the database was up but something happened when we tried to query it - return pass + pass def migrate_facts(apps, schema_editor): Fact = apps.get_model('main', "Fact") From af9882af97790c920bf3602b8f766f835c42d067 Mon Sep 17 00:00:00 2001 From: Leigh Johnson Date: Mon, 14 Mar 2016 21:16:44 -0400 Subject: [PATCH 26/41] Yoink REST calls from Job Details controller into modular service, resolves #1239 --- .../host-event.route.js} | 0 .../src/job-detail/job-detail.controller.js | 97 ++++++-------- .../src/job-detail/job-detail.partial.html | 2 +- .../src/job-detail/job-detail.service.js | 118 ++++++++++++++++++ awx/ui/client/src/job-detail/main.js | 2 + 5 files changed, 160 insertions(+), 59 deletions(-) rename awx/ui/client/src/job-detail/{job-detail.factory.js => host-event/host-event.route.js} (100%) create mode 100644 awx/ui/client/src/job-detail/job-detail.service.js diff --git a/awx/ui/client/src/job-detail/job-detail.factory.js b/awx/ui/client/src/job-detail/host-event/host-event.route.js similarity index 100% rename from awx/ui/client/src/job-detail/job-detail.factory.js rename to awx/ui/client/src/job-detail/host-event/host-event.route.js diff --git a/awx/ui/client/src/job-detail/job-detail.controller.js b/awx/ui/client/src/job-detail/job-detail.controller.js index 447cec8d8c..e36dbb13de 100644 --- a/awx/ui/client/src/job-detail/job-detail.controller.js +++ b/awx/ui/client/src/job-detail/job-detail.controller.js @@ -1,5 +1,5 @@ /************************************************* - * Copyright (c) 2015 Ansible, Inc. + * Copyright (c) 2016 Ansible, Inc. * * All Rights Reserved *************************************************/ @@ -12,23 +12,22 @@ export default [ '$location', '$rootScope', '$filter', '$scope', '$compile', - '$stateParams', '$log', 'ClearScope', 'GetBasePath', 'Wait', 'Rest', + '$stateParams', '$log', 'ClearScope', 'GetBasePath', 'Wait', 'ProcessErrors', 'SelectPlay', 'SelectTask', 'Socket', 'GetElapsed', 'DrawGraph', 'LoadHostSummary', 'ReloadHostSummaryList', - 'JobIsFinished', 'SetTaskStyles', 'DigestEvent', 'UpdateDOM', - 'EventViewer', 'DeleteJob', 'PlaybookRun', 'HostEventsViewer', + 'JobIsFinished', 'SetTaskStyles', 'DigestEvent', 'UpdateDOM', 'DeleteJob', 'PlaybookRun', 'HostEventsViewer', 'LoadPlays', 'LoadTasks', 'LoadHosts', 'HostsEdit', 'ParseVariableString', 'GetChoices', 'fieldChoices', 'fieldLabels', - 'EditSchedule', 'ParseTypeChange', + 'EditSchedule', 'ParseTypeChange', 'JobDetailService', 'EventViewer', function( $location, $rootScope, $filter, $scope, $compile, $stateParams, - $log, ClearScope, GetBasePath, Wait, Rest, ProcessErrors, + $log, ClearScope, GetBasePath, Wait, ProcessErrors, SelectPlay, SelectTask, Socket, GetElapsed, DrawGraph, LoadHostSummary, ReloadHostSummaryList, JobIsFinished, - SetTaskStyles, DigestEvent, UpdateDOM, EventViewer, DeleteJob, + SetTaskStyles, DigestEvent, UpdateDOM, DeleteJob, PlaybookRun, HostEventsViewer, LoadPlays, LoadTasks, LoadHosts, HostsEdit, ParseVariableString, GetChoices, fieldChoices, - fieldLabels, EditSchedule, ParseTypeChange + fieldLabels, EditSchedule, ParseTypeChange, JobDetailService, EventViewer ) { ClearScope(); @@ -283,15 +282,15 @@ export default scope.removeInitialLoadComplete(); } scope.removeInitialLoadComplete = scope.$on('InitialLoadComplete', function() { - var url; Wait('stop'); if (JobIsFinished(scope)) { scope.liveEventProcessing = false; // signal that event processing is over and endless scroll scope.pauseLiveEvents = false; // should be enabled - url = scope.job.related.job_events + '?event=playbook_on_stats'; - Rest.setUrl(url); - Rest.get() + var params = { + event: 'playbook_on_stats' + }; + JobDetailService.getRelatedJobEvents(scope.job.id, params) .success(function(data) { if (data.results.length > 0) { LoadHostSummary({ @@ -327,11 +326,11 @@ export default } scope.removeHostSummaries = scope.$on('LoadHostSummaries', function() { if(scope.job){ - var url = scope.job.related.job_host_summaries + '?'; - url += '&page_size=' + scope.hostSummariesMaxRows + '&order=host_name'; - - Rest.setUrl(url); - Rest.get() + var params = { + page_size: scope.hostSummariesMaxRows, + order: 'host_name' + }; + JobDetailService.getJobHostSummaries(scope.job.id, params) .success(function(data) { scope.next_host_summaries = data.next; if (data.results.length > 0) { @@ -357,10 +356,6 @@ export default }; }); scope.$emit('InitialLoadComplete'); - }) - .error(function(data, status) { - ProcessErrors(scope, data, status, null, { hdr: 'Error!', - msg: 'Call to ' + url + '. GET returned: ' + status }); }); } @@ -373,17 +368,17 @@ export default if (scope.activeTask) { var play = scope.jobData.plays[scope.activePlay], - task, // = play.tasks[scope.activeTask], - url; + task; if(play){ task = play.tasks[scope.activeTask]; } if (play && task) { - url = scope.job.related.job_events + '?parent=' + task.id + '&'; - url += 'event__startswith=runner&page_size=' + scope.hostResultsMaxRows + '&order=host_name,counter'; - - Rest.setUrl(url); - Rest.get() + var params = { + parent: task.id, + event__startswith: 'runner', + page_size: scope.hostResultsMaxRows + }; + JobDetailService.getRelatedJobEvents(scope.job.id, params) .success(function(data) { var idx, event, status, status_text, item, msg; if (data.results.length > 0) { @@ -450,10 +445,6 @@ export default } } scope.$emit('LoadHostSummaries'); - }) - .error(function(data, status) { - ProcessErrors(scope, data, status, null, { hdr: 'Error!', - msg: 'Call to ' + url + '. GET returned: ' + status }); }); } else { scope.$emit('LoadHostSummaries'); @@ -468,14 +459,15 @@ export default } scope.removeLoadTasks = scope.$on('LoadTasks', function() { if (scope.activePlay) { - var play = scope.jobData.plays[scope.activePlay], url; + var play = scope.jobData.plays[scope.activePlay]; if (play) { - url = scope.job.url + 'job_tasks/?event_id=' + play.id; - url += '&page_size=' + scope.tasksMaxRows + '&order=id'; - - Rest.setUrl(url); - Rest.get() + var params = { + event_id: play.id, + page_size: scope.tasksMaxRows, + order: 'id' + } + JobDetailService.getJobTasks(scope.job.id, params) .success(function(data) { scope.next_tasks = data.next; if (data.results.length > 0) { @@ -585,12 +577,10 @@ export default scope.host_summary.failed = 0; scope.host_summary.total = 0; scope.jobData.plays = {}; - - var url = scope.job.url + 'job_plays/?order_by=id'; - url += '&page_size=' + scope.playsMaxRows + '&order_by=id'; - - Rest.setUrl(url); - Rest.get() + var params = { + order_by: 'id' + }; + JobDetailService.getJobPlays(scope.job.id, params) .success( function(data) { scope.next_plays = data.next; if (data.results.length > 0) { @@ -681,10 +671,6 @@ export default scope.jobData.plays[scope.activePlay].playActiveClass = 'JobDetail-tableRow--selected'; } scope.$emit('LoadTasks', events_url); - }) - .error( function(data, status) { - ProcessErrors(scope, data, status, null, { hdr: 'Error!', - msg: 'Call to ' + url + '. GET returned: ' + status }); }); }); @@ -702,8 +688,7 @@ export default scope.LoadHostSummaries = true; // Load the job record - Rest.setUrl(GetBasePath('jobs') + job_id + '/'); - Rest.get() + JobDetailService.getJob(job_id) .success(function(data) { var i; scope.job = data; @@ -1177,8 +1162,7 @@ export default if (((!scope.liveEventProcessing) || (scope.liveEventProcessing && scope.pauseLiveEvents)) && scope.next_plays) { $('#playsMoreRows').fadeIn(); scope.playsLoading = true; - Rest.setUrl(scope.next_plays); - Rest.get() + JobDetailService.getNextPage(scope.next_plays) .success( function(data) { scope.next_plays = data.next; data.results.forEach(function(event, idx) { @@ -1243,8 +1227,7 @@ export default if (((!scope.liveEventProcessing) || (scope.liveEventProcessing && scope.pauseLiveEvents)) && scope.next_tasks) { $('#tasksMoreRows').fadeIn(); scope.tasksLoading = true; - Rest.setUrl(scope.next_tasks); - Rest.get() + JobDetailService.getNextPage(scope.next_tasks) .success(function(data) { scope.next_tasks = data.next; data.results.forEach(function(event, idx) { @@ -1315,8 +1298,7 @@ export default if (((!scope.liveEventProcessing) || (scope.liveEventProcessing && scope.pauseLiveEvents)) && scope.next_host_results) { $('#hostResultsMoreRows').fadeIn(); scope.hostResultsLoading = true; - Rest.setUrl(scope.next_host_results); - Rest.get() + JobDetailService.getNextPage(scope.next_host_results) .success(function(data) { scope.next_host_results = data.next; data.results.forEach(function(row) { @@ -1387,8 +1369,7 @@ export default // check for more hosts when user scrolls to bottom of host summaries list... if (((!scope.liveEventProcessing) || (scope.liveEventProcessing && scope.pauseLiveEvents)) && scope.next_host_summaries) { scope.hostSummariesLoading = true; - Rest.setUrl(scope.next_host_summaries); - Rest.get() + JobDetailService.getNextPage(scope.next_host_summaries) .success(function(data) { scope.next_host_summaries = data.next; data.results.forEach(function(row) { diff --git a/awx/ui/client/src/job-detail/job-detail.partial.html b/awx/ui/client/src/job-detail/job-detail.partial.html index 8bbbc4aaaa..3ff7262d1c 100644 --- a/awx/ui/client/src/job-detail/job-detail.partial.html +++ b/awx/ui/client/src/job-detail/job-detail.partial.html @@ -344,7 +344,7 @@ - + diff --git a/awx/ui/client/src/job-detail/job-detail.service.js b/awx/ui/client/src/job-detail/job-detail.service.js new file mode 100644 index 0000000000..8597fff9f1 --- /dev/null +++ b/awx/ui/client/src/job-detail/job-detail.service.js @@ -0,0 +1,118 @@ +export default + ['$rootScope', 'Rest', 'GetBasePath', 'ProcessErrors', function($rootScope, Rest, GetBasePath, ProcessErrors){ + return { + + /* + For ES6 + it might be useful to set some default params here, e.g. + getJobHostSummaries: function(id, page_size=200, order='host_name'){} + without ES6, we'd have to supply defaults like this: + this.page_size = params.page_size ? params.page_size : 200; + */ + + // GET events related to a job run + // e.g. + // ?event=playbook_on_stats + // ?parent=206&event__startswith=runner&page_size=200&order=host_name,counter + getRelatedJobEvents: function(id, params){ + var url = GetBasePath('jobs'); + url = url + id + '/job_events/?'; + Object.keys(params).forEach(function(key, index) { + // the API is tolerant of extra ampersands + // ?&event=playbook_on_start == ?event=playbook_on_stats + url = url + '&' + key + '=' + params[key]; + }); + Rest.setUrl(url); + return Rest.get() + .success(function(data){ + return data + }) + .error(function(data, status) { + ProcessErrors($rootScope, data, status, null, { hdr: 'Error!', + msg: 'Call to ' + url + '. GET returned: ' + status }); + }); + }, + // GET job host summaries related to a job run + // e.g. ?page_size=200&order=host_name + getJobHostSummaries: function(id, params){ + var url = GetBasePath('jobs'); + url = url + id + '/job_host_summaries/?' + Object.keys(params).forEach(function(key, index) { + // the API is tolerant of extra ampersands + url = url + '&' + key + '=' + params[key]; + }); + Rest.setUrl(url); + return Rest.get() + .success(function(data){ + return data + }) + .error(function(data, status) { + ProcessErrors($rootScope, data, status, null, { hdr: 'Error!', + msg: 'Call to ' + url + '. GET returned: ' + status }); + }); + }, + // GET job plays related to a job run + // e.g. ?page_size=200 + getJobPlays: function(id, params){ + var url = GetBasePath('jobs'); + url = url + id + '/job_plays/?'; + Object.keys(params).forEach(function(key, index) { + // the API is tolerant of extra ampersands + url = url + '&' + key + '=' + params[key]; + }); + Rest.setUrl(url); + return Rest.get() + .success(function(data){ + return data + }) + .error(function(data, status) { + ProcessErrors($rootScope, data, status, null, { hdr: 'Error!', + msg: 'Call to ' + url + '. GET returned: ' + status }); + }); + }, + getJobTasks: function(id, params){ + var url = GetBasePath('jobs'); + url = url + id + '/job_tasks/?'; + Object.keys(params).forEach(function(key, index) { + // the API is tolerant of extra ampersands + url = url + '&' + key + '=' + params[key]; + }); + Rest.setUrl(url); + return Rest.get() + .success(function(data){ + return data + }) + .error(function(data, status) { + ProcessErrors($rootScope, data, status, null, { hdr: 'Error!', + msg: 'Call to ' + url + '. GET returned: ' + status }); + }); + }, + getJob: function(id){ + var url = GetBasePath('jobs'); + url = url + id; + Rest.setUrl(url); + return Rest.get() + .success(function(data){ + return data + }) + .error(function(data, status) { + ProcessErrors($rootScope, data, status, null, { hdr: 'Error!', + msg: 'Call to ' + url + '. GET returned: ' + status }); + }); + }, + // GET next set of paginated results + // expects 'next' param returned by the API e.g. + // "/api/v1/jobs/51/job_plays/?order_by=id&page=2&page_size=1" + getNextPage: function(url){ + return Rest.get() + .success(function(data){ + return data + }) + .error(function(data, status) { + ProcessErrors($rootScope, data, status, null, { hdr: 'Error!', + msg: 'Call to ' + url + '. GET returned: ' + status }); + }); + } + } + } + ]; \ No newline at end of file diff --git a/awx/ui/client/src/job-detail/main.js b/awx/ui/client/src/job-detail/main.js index 42e9cae45c..d985a310e6 100644 --- a/awx/ui/client/src/job-detail/main.js +++ b/awx/ui/client/src/job-detail/main.js @@ -6,10 +6,12 @@ import route from './job-detail.route'; import controller from './job-detail.controller'; +import service from './job-detail.service'; export default angular.module('jobDetail', []) .controller('JobDetailController', controller) + .service('JobDetailService', service) .run(['$stateExtender', function($stateExtender) { $stateExtender.addState(route); }]); From da39f1269a4e97f1bf03806d1264ca791ec2f4db Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Tue, 15 Mar 2016 14:26:50 -0400 Subject: [PATCH 27/41] org counts code restructing to better prepare for RBAC merge --- awx/api/serializers.py | 6 +- awx/api/views.py | 41 +++++----- .../api/test_organization_counts.py | 77 +++++++++++++++---- 3 files changed, 86 insertions(+), 38 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 514075bc29..6ca73cf6a0 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -800,14 +800,14 @@ class OrganizationSerializer(BaseSerializer): def get_summary_fields(self, obj): summary_dict = super(OrganizationSerializer, self).get_summary_fields(obj) - counts_dict = self.context.get('counts', None) + counts_dict = self.context.get('related_field_counts', None) if counts_dict is not None and summary_dict is not None: if obj.id not in counts_dict: - summary_dict['counts'] = { + summary_dict['related_field_counts'] = { 'inventories': 0, 'teams': 0, 'users': 0, 'job_templates': 0, 'admins': 0, 'projects': 0} else: - summary_dict['counts'] = counts_dict[obj.id] + summary_dict['related_field_counts'] = counts_dict[obj.id] return summary_dict diff --git a/awx/api/views.py b/awx/api/views.py index ebdb57098d..b50ba0497c 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -625,47 +625,43 @@ class OrganizationList(ListCreateAPIView): org_id_list = org_qs.values('id') if len(org_id_list) == 0: if self.request.method == 'POST': - full_context['counts'] = {} + full_context['related_field_counts'] = {} return full_context - # Produce counts of Foreign Key relationships inv_qs = self.request.user.get_queryset(Inventory) + project_qs = self.request.user.get_queryset(Project) + user_qs = self.request.user.get_queryset(User) + + # Produce counts of Foreign Key relationships db_results['inventories'] = inv_qs\ .values('organization').annotate(Count('organization')).order_by('organization') db_results['teams'] = self.request.user.get_queryset(Team)\ .values('organization').annotate(Count('organization')).order_by('organization') + # TODO: When RBAC branch merges, change this to project relationship JT_reference = 'inventory__organization' # Extra filter is applied on the inventory, because this catches # the case of deleted (and purged) inventory - db_JT_results = self.request.user.get_queryset(JobTemplate)\ - .filter(inventory_id__in=inv_qs.values_list('pk', flat=True))\ + db_results['job_templates'] = self.request.user.get_queryset(JobTemplate)\ + .filter(inventory__in=inv_qs)\ .values(JT_reference).annotate(Count(JT_reference))\ .order_by(JT_reference) # Produce counts of m2m relationships - project_qs = self.request.user.get_queryset(Project) db_results['projects'] = Organization.projects.through.objects\ - .filter( - project_id__in=project_qs.values_list('pk', flat=True), - organization_id__in=org_qs.values_list('pk', flat=True))\ + .filter(project__in=project_qs, organization__in=org_qs)\ .values('organization')\ .annotate(Count('organization')).order_by('organization') # TODO: When RBAC branch merges, change these to role relation - user_qs = self.request.user.get_queryset(User) db_results['users'] = Organization.users.through.objects\ - .filter( - user_id__in=user_qs.values_list('pk', flat=True), - organization_id__in=org_qs.values_list('pk', flat=True))\ + .filter(user__in=user_qs, organization__in=org_qs)\ .values('organization')\ .annotate(Count('organization')).order_by('organization') db_results['admins'] = Organization.admins.through.objects\ - .filter( - user_id__in=user_qs.values_list('pk', flat=True), - organization_id__in=org_qs.values_list('pk', flat=True))\ + .filter(user__in=user_qs, organization__in=org_qs)\ .values('organization')\ .annotate(Count('organization')).order_by('organization') @@ -677,15 +673,16 @@ class OrganizationList(ListCreateAPIView): 'admins': 0, 'projects': 0} for res in db_results: + if res == 'job_templates': + org_reference = JT_reference + else: + org_reference = 'organization' for entry in db_results[res]: - org_id = entry['organization'] - count_context[org_id][res] = entry['organization__count'] + org_id = entry[org_reference] + if org_id in count_context: + count_context[org_id][res] = entry['%s__count' % org_reference] - for entry in db_JT_results: - org_id = entry[JT_reference] - count_context[org_id]['job_templates'] = entry['%s__count' % JT_reference] - - full_context['counts'] = count_context + full_context['related_field_counts'] = count_context return full_context diff --git a/awx/main/tests/functional/api/test_organization_counts.py b/awx/main/tests/functional/api/test_organization_counts.py index 56fdb8215e..de629dbcf4 100644 --- a/awx/main/tests/functional/api/test_organization_counts.py +++ b/awx/main/tests/functional/api/test_organization_counts.py @@ -3,7 +3,7 @@ import pytest from django.core.urlresolvers import reverse @pytest.fixture -def resourced_organization(organization, project, user): +def resourced_organization(organization, project, team, inventory, user): admin_user = user('test-admin', True) member_user = user('org-member') @@ -11,12 +11,12 @@ def resourced_organization(organization, project, user): organization.users.add(member_user) organization.admins.add(admin_user) organization.projects.add(project) - organization.teams.create(name='org-team') - inventory = organization.inventories.create(name="associated-inv") - inventory.jobtemplates.create(name="test-jt", - description="test-job-template-desc", - project=project, - playbook="test_playbook.yml") + # organization.teams.create(name='org-team') + # inventory = organization.inventories.create(name="associated-inv") + project.jobtemplates.create(name="test-jt", + description="test-job-template-desc", + inventory=inventory, + playbook="test_playbook.yml") return organization @@ -25,8 +25,9 @@ def test_org_counts_admin(resourced_organization, user, get): # Check that all types of resources are counted by a superuser external_admin = user('admin', True) response = get(reverse('api:organization_list', args=[]), external_admin) - counts = response.data['results'][0]['summary_fields']['counts'] + assert response.status_code == 200 + counts = response.data['results'][0]['summary_fields']['related_field_counts'] assert counts == { 'users': 1, 'admins': 1, @@ -42,7 +43,9 @@ def test_org_counts_member(resourced_organization, get): # user count, consistent with the RBAC rules member_user = resourced_organization.users.get(username='org-member') response = get(reverse('api:organization_list', args=[]), member_user) - counts = response.data['results'][0]['summary_fields']['counts'] + assert response.status_code == 200 + + counts = response.data['results'][0]['summary_fields']['related_field_counts'] assert counts == { 'users': 1, # User can see themselves @@ -60,10 +63,10 @@ def test_new_org_zero_counts(user, post): org_list_url = reverse('api:organization_list', args=[]) post_response = post(url=org_list_url, data={'name': 'test organization', 'description': ''}, user=user('admin', True)) - new_org_list = post_response.render().data - counts_dict = new_org_list['summary_fields']['counts'] - assert post_response.status_code == 201 + + new_org_list = post_response.render().data + counts_dict = new_org_list['summary_fields']['related_field_counts'] assert counts_dict == { 'users': 0, 'admins': 0, @@ -79,12 +82,14 @@ def test_two_organizations(resourced_organization, organizations, user, get): external_admin = user('admin', True) organization_zero = organizations(1)[0] response = get(reverse('api:organization_list', args=[]), external_admin) + assert response.status_code == 200 + org_id_full = resourced_organization.id org_id_zero = organization_zero.id counts = {} for i in range(2): org_id = response.data['results'][i]['id'] - counts[org_id] = response.data['results'][i]['summary_fields']['counts'] + counts[org_id] = response.data['results'][i]['summary_fields']['related_field_counts'] assert counts[org_id_full] == { 'users': 1, @@ -102,3 +107,49 @@ def test_two_organizations(resourced_organization, organizations, user, get): 'inventories': 0, 'teams': 0 } + +@pytest.mark.django_db +def test_overlapping_project(resourced_organization, organizations, user, get): + # Check correct results for two organizations are returned + external_admin = user('admin', True) + organization2 = organizations(1)[0] + the_project = resourced_organization.projects.all()[0] + organization2.projects.add(the_project) + organization2.projects.create(name="second-project", + description="test-proj-desc", + scm_type="git", + scm_url="https://github.com/jlaska/ansible-playbooks") + inventory = organization2.inventories.create(name="second-inventory") + organization2.projects.get(name="second-project").jobtemplates.create( + name="second-job-template", + inventory=inventory, + playbook="hello.yml" + ) + + response = get(reverse('api:organization_list', args=[]), external_admin) + assert response.status_code == 200 + + org_id_full = resourced_organization.id + org_id2 = organization2.id + counts = {} + for i in range(2): + org_id = response.data['results'][i]['id'] + counts[org_id] = response.data['results'][i]['summary_fields']['related_field_counts'] + + assert counts[org_id_full] == { + 'users': 1, + 'admins': 1, + 'job_templates': 1, + 'projects': 1, + 'inventories': 1, + 'teams': 1 + } + assert counts[org_id2] == { + 'users': 0, + 'admins': 0, + 'job_templates': 2, + 'projects': 2, + 'inventories': 1, + 'teams': 0 + } + assert False From 85a9e14ced14e23c27af0349234606103147a096 Mon Sep 17 00:00:00 2001 From: Ken Hoes Date: Tue, 15 Mar 2016 14:55:12 -0400 Subject: [PATCH 28/41] Split up and modularized organizations --- awx/ui/client/src/app.js | 112 ++--- .../client/src/controllers/Organizations.js | 382 ------------------ awx/ui/client/src/organizations/add/main.js | 14 + .../add/organizations-add.controller.js | 66 +++ .../add/organizations-add.partial.html | 4 + .../add/organizations-add.route.js | 24 ++ awx/ui/client/src/organizations/edit/main.js | 15 + .../edit/organizations-edit.controller.js | 150 +++++++ .../edit/organizations-edit.route.js | 29 ++ awx/ui/client/src/organizations/list/main.js | 14 + .../list/organizations-list.controller.js | 182 +++++++++ .../list/organizations-list.partial.html | 62 +++ .../list/organizations-list.route.js | 31 ++ awx/ui/client/src/organizations/main.js | 16 + 14 files changed, 665 insertions(+), 436 deletions(-) delete mode 100644 awx/ui/client/src/controllers/Organizations.js create mode 100644 awx/ui/client/src/organizations/add/main.js create mode 100644 awx/ui/client/src/organizations/add/organizations-add.controller.js create mode 100644 awx/ui/client/src/organizations/add/organizations-add.partial.html create mode 100644 awx/ui/client/src/organizations/add/organizations-add.route.js create mode 100644 awx/ui/client/src/organizations/edit/main.js create mode 100644 awx/ui/client/src/organizations/edit/organizations-edit.controller.js create mode 100644 awx/ui/client/src/organizations/edit/organizations-edit.route.js create mode 100644 awx/ui/client/src/organizations/list/main.js create mode 100644 awx/ui/client/src/organizations/list/organizations-list.controller.js create mode 100644 awx/ui/client/src/organizations/list/organizations-list.partial.html create mode 100644 awx/ui/client/src/organizations/list/organizations-list.route.js create mode 100644 awx/ui/client/src/organizations/main.js diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index cbf50b22b6..a64b8c9d62 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -27,6 +27,7 @@ import {JobsListController} from './controllers/Jobs'; import {PortalController} from './controllers/Portal'; import systemTracking from './system-tracking/main'; import inventoryScripts from './inventory-scripts/main'; +import organizations from './organizations/main'; import permissions from './permissions/main'; import managementJobs from './management-jobs/main'; import jobDetail from './job-detail/main'; @@ -50,7 +51,9 @@ import lookUpHelper from './lookup/main'; import JobTemplates from './job-templates/main'; import {ScheduleEditController} from './controllers/Schedules'; import {ProjectsList, ProjectsAdd, ProjectsEdit} from './controllers/Projects'; -import {OrganizationsList, OrganizationsAdd, OrganizationsEdit} from './controllers/Organizations'; +import OrganizationsList from './organizations/list/organizations-list.controller'; +import OrganizationsAdd from './organizations/add/organizations-add.controller'; +import OrganizationsEdit from './organizations/edit/organizations-edit.controller'; import {InventoriesList, InventoriesAdd, InventoriesEdit, InventoriesManage} from './controllers/Inventories'; import {AdminsList} from './controllers/Admins'; import {UsersList, UsersAdd, UsersEdit} from './controllers/Users'; @@ -85,6 +88,7 @@ var tower = angular.module('Tower', [ browserData.name, systemTracking.name, inventoryScripts.name, + organizations.name, permissions.name, managementJobs.name, setupMenu.name, @@ -428,60 +432,60 @@ var tower = angular.module('Tower', [ } }). - state('organizations', { - url: '/organizations', - templateUrl: urlPrefix + 'partials/organizations.html', - controller: OrganizationsList, - data: { - activityStream: true, - activityStreamTarget: 'organization' - }, - ncyBreadcrumb: { - parent: function($scope) { - $scope.$parent.$emit("ReloadOrgListView"); - return "setup"; - }, - label: "ORGANIZATIONS" - }, - resolve: { - features: ['FeaturesService', function(FeaturesService) { - return FeaturesService.get(); - }] - } - }). + // state('organizations', { + // url: '/organizations', + // templateUrl: urlPrefix + 'partials/organizations.html', + // controller: OrganizationsList, + // data: { + // activityStream: true, + // activityStreamTarget: 'organization' + // }, + // ncyBreadcrumb: { + // parent: function($scope) { + // $scope.$parent.$emit("ReloadOrgListView"); + // return "setup"; + // }, + // label: "ORGANIZATIONS" + // }, + // resolve: { + // features: ['FeaturesService', function(FeaturesService) { + // return FeaturesService.get(); + // }] + // } + // }). - state('organizations.add', { - url: '/add', - templateUrl: urlPrefix + 'partials/organizations.crud.html', - controller: OrganizationsAdd, - ncyBreadcrumb: { - parent: "organizations", - label: "CREATE ORGANIZATION" - }, - resolve: { - features: ['FeaturesService', function(FeaturesService) { - return FeaturesService.get(); - }] - } - }). - - state('organizations.edit', { - url: '/:organization_id', - templateUrl: urlPrefix + 'partials/organizations.crud.html', - controller: OrganizationsEdit, - data: { - activityStreamId: 'organization_id' - }, - ncyBreadcrumb: { - parent: "organizations", - label: "{{name}}" - }, - resolve: { - features: ['FeaturesService', function(FeaturesService) { - return FeaturesService.get(); - }] - } - }). + // state('organizations.add', { + // url: '/add', + // templateUrl: urlPrefix + 'partials/organizations.crud.html', + // controller: OrganizationsAdd, + // ncyBreadcrumb: { + // parent: "organizations", + // label: "CREATE ORGANIZATION" + // }, + // resolve: { + // features: ['FeaturesService', function(FeaturesService) { + // return FeaturesService.get(); + // }] + // } + // }). + // + // state('organizations.edit', { + // url: '/:organization_id', + // templateUrl: urlPrefix + 'partials/organizations.crud.html', + // controller: OrganizationsEdit, + // data: { + // activityStreamId: 'organization_id' + // }, + // ncyBreadcrumb: { + // parent: "organizations", + // label: "{{name}}" + // }, + // resolve: { + // features: ['FeaturesService', function(FeaturesService) { + // return FeaturesService.get(); + // }] + // } + // }). state('organizationAdmins', { url: '/organizations/:organization_id/admins', diff --git a/awx/ui/client/src/controllers/Organizations.js b/awx/ui/client/src/controllers/Organizations.js deleted file mode 100644 index fb1ebecfa1..0000000000 --- a/awx/ui/client/src/controllers/Organizations.js +++ /dev/null @@ -1,382 +0,0 @@ -/************************************************* - * Copyright (c) 2015 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - -/** - * @ngdoc function - * @name controllers.function:Organizations - * @description This controller's for the Organizations page -*/ - - -export function OrganizationsList($stateParams, $scope, $rootScope, $location, - $log, $compile, Rest, PaginateWidget, PaginateInit, SearchInit, OrganizationList, Alert, Prompt, ClearScope, ProcessErrors, GetBasePath, Wait, - $state) { - - ClearScope(); - - var defaultUrl = GetBasePath('organizations'), - list = OrganizationList, - pageSize = $scope.orgCount; - - PaginateInit({ - scope: $scope, - list: list, - url: defaultUrl, - pageSize: pageSize, - }); - SearchInit({ - scope: $scope, - list: list, - url: defaultUrl, - }); - - $scope.search(list.iterator); - - $scope.PaginateWidget = PaginateWidget({ - iterator: list.iterator, - set: 'organizations' - }); - - var paginationContainer = $('#pagination-container'); - paginationContainer.html($scope.PaginateWidget); - $compile(paginationContainer.contents())($scope) - - var parseCardData = function (cards) { - return cards.map(function (card) { - var val = {}; - val.name = card.name; - val.id = card.id; - if (card.id + "" === cards.activeCard) { - val.isActiveCard = true; - } - val.description = card.description || undefined; - val.links = []; - val.links.push({ - href: card.related.users, - name: "USERS" - }); - val.links.push({ - href: card.related.teams, - name: "TEAMS" - }); - val.links.push({ - href: card.related.inventories, - name: "INVENTORIES" - }); - val.links.push({ - href: card.related.projects, - name: "PROJECTS" - }); - val.links.push({ - href: card.related.job_templates, - name: "JOB TEMPLATES" - }); - val.links.push({ - href: card.related.admins, - name: "ADMINS" - }); - return val; - }); - }; - - var getOrganization = function (id) { - Rest.setUrl(defaultUrl); - Rest.get() - .success(function (data) { - data.results.activeCard = id; - $scope.orgCount = data.count; - $scope.orgCards = parseCardData(data.results); - Wait("stop"); - }) - .error(function (data, status) { - ProcessErrors($scope, data, status, null, { hdr: 'Error!', - msg: 'Call to ' + defaultUrl + ' failed. DELETE returned status: ' + status }); - }); - }; - - $scope.$on("ReloadOrgListView", function() { - if ($state.$current.self.name === "organizations") { - delete $scope.activeCard; - if ($scope.orgCards) { - $scope.orgCards = $scope.orgCards.map(function (card) { - delete card.isActiveCard; - return card; - }); - } - $scope.hideListHeader = false; - } - }); - - $scope.$on("ReloadOrganzationCards", function(e, id) { - $scope.activeCard = id; - getOrganization(id); - }); - - $scope.$on("HideOrgListHeader", function() { - $scope.hideListHeader = true; - }); - - $scope.$on("ShowOrgListHeader", function() { - $scope.hideListHeader = false; - }); - - getOrganization(); - - $rootScope.flashMessage = null; - - if ($scope.removePostRefresh) { - $scope.removePostRefresh(); - } - $scope.removePostRefresh = $scope.$on('PostRefresh', function () { - // Cleanup after a delete - Wait('stop'); - $('#prompt-modal').modal('hide'); - }); - - $scope.addOrganization = function () { - $state.transitionTo('organizations.add'); - }; - - $scope.editOrganization = function (id) { - $scope.activeCard = id; - $state.transitionTo('organizations.edit', {organization_id: id}); - }; - - $scope.deleteOrganization = function (id, name) { - - var action = function () { - $('#prompt-modal').modal('hide'); - Wait('start'); - var url = defaultUrl + id + '/'; - Rest.setUrl(url); - Rest.destroy() - .success(function () { - if ($state.current.name !== "organzations") { - $state.transitionTo("organizations"); - } - $scope.$emit("ReloadOrganzationCards"); - }) - .error(function (data, status) { - ProcessErrors($scope, data, status, null, { hdr: 'Error!', - msg: 'Call to ' + url + ' failed. DELETE returned status: ' + status }); - }); - }; - - Prompt({ - hdr: 'Delete', - body: '
Are you sure you want to delete the organization below?
' + name + '
', - action: action, - actionText: 'DELETE' - }); - }; -} - -OrganizationsList.$inject = ['$stateParams', '$scope', '$rootScope', - '$location', '$log', '$compile', 'Rest', 'PaginateWidget', 'PaginateInit', 'SearchInit', 'OrganizationList', 'Alert', 'Prompt', 'ClearScope', - 'ProcessErrors', 'GetBasePath', 'Wait', - '$state' -]; - - -export function OrganizationsAdd($scope, $rootScope, $compile, $location, $log, - $stateParams, OrganizationForm, GenerateForm, Rest, Alert, ProcessErrors, - ClearScope, GetBasePath, ReturnToCaller, Wait, $state) { - - ClearScope(); - - // Inject dynamic view - var generator = GenerateForm, - form = OrganizationForm, - base = $location.path().replace(/^\//, '').split('/')[0]; - - generator.inject(form, { mode: 'add', related: false, scope: $scope}); - generator.reset(); - - $scope.$emit("HideOrgListHeader"); - - // Save - $scope.formSave = function () { - generator.clearApiErrors(); - Wait('start'); - var url = GetBasePath(base); - url += (base !== 'organizations') ? $stateParams.project_id + '/organizations/' : ''; - Rest.setUrl(url); - Rest.post({ name: $scope.name, description: $scope.description }) - .success(function (data) { - Wait('stop'); - $scope.$emit("ReloadOrganzationCards", data.id); - if (base === 'organizations') { - $rootScope.flashMessage = "New organization successfully created!"; - $location.path('/organizations/' + data.id); - } else { - ReturnToCaller(1); - } - }) - .error(function (data, status) { - ProcessErrors($scope, data, status, form, { hdr: 'Error!', - msg: 'Failed to add new organization. Post returned status: ' + status }); - }); - }; - - $scope.formCancel = function () { - $scope.$emit("ReloadOrganzationCards"); - $scope.$emit("ShowOrgListHeader"); - $state.transitionTo('organizations'); - }; -} - -OrganizationsAdd.$inject = ['$scope', '$rootScope', '$compile', '$location', - '$log', '$stateParams', 'OrganizationForm', 'GenerateForm', 'Rest', 'Alert', - 'ProcessErrors', 'ClearScope', 'GetBasePath', 'ReturnToCaller', 'Wait', - '$state' -]; - - -export function OrganizationsEdit($scope, $rootScope, $compile, $location, $log, - $stateParams, OrganizationForm, GenerateForm, Rest, Alert, ProcessErrors, - RelatedSearchInit, RelatedPaginateInit, Prompt, ClearScope, GetBasePath, - Wait, $state) { - - ClearScope(); - - // Inject dynamic view - var form = OrganizationForm, - generator = GenerateForm, - defaultUrl = GetBasePath('organizations'), - base = $location.path().replace(/^\//, '').split('/')[0], - master = {}, - id = $stateParams.organization_id, - relatedSets = {}; - - $scope.$emit("HideOrgListHeader"); - - $scope.$emit("ReloadOrganzationCards", id); - - $scope.organization_id = id; - - generator.inject(form, { mode: 'edit', related: true, scope: $scope}); - generator.reset(); - - // After the Organization is loaded, retrieve each related set - if ($scope.organizationLoadedRemove) { - $scope.organizationLoadedRemove(); - } - $scope.organizationLoadedRemove = $scope.$on('organizationLoaded', function () { - for (var set in relatedSets) { - $scope.search(relatedSets[set].iterator); - } - Wait('stop'); - }); - - // Retrieve detail record and prepopulate the form - Wait('start'); - Rest.setUrl(defaultUrl + id + '/'); - Rest.get() - .success(function (data) { - var fld, related, set; - $scope.organization_name = data.name; - for (fld in form.fields) { - if (data[fld]) { - $scope[fld] = data[fld]; - master[fld] = data[fld]; - } - } - related = data.related; - for (set in form.related) { - if (related[set]) { - relatedSets[set] = { - url: related[set], - iterator: form.related[set].iterator - }; - } - } - // Initialize related search functions. Doing it here to make sure relatedSets object is populated. - RelatedSearchInit({ scope: $scope, form: form, relatedSets: relatedSets }); - RelatedPaginateInit({ scope: $scope, relatedSets: relatedSets }); - $scope.$emit('organizationLoaded'); - }) - .error(function (data, status) { - ProcessErrors($scope, data, status, form, { hdr: 'Error!', - msg: 'Failed to retrieve organization: ' + $stateParams.id + '. GET status: ' + status }); - }); - - - // Save changes to the parent - $scope.formSave = function () { - var fld, params = {}; - generator.clearApiErrors(); - Wait('start'); - for (fld in form.fields) { - params[fld] = $scope[fld]; - } - Rest.setUrl(defaultUrl + id + '/'); - Rest.put(params) - .success(function (data) { - Wait('stop'); - $scope.organization_name = $scope.name; - master = params; - $rootScope.flashMessage = "Your changes were successfully saved!"; - $scope.$emit("ReloadOrganzationCards", data.id); - }) - .error(function (data, status) { - ProcessErrors($scope, data, status, OrganizationForm, { hdr: 'Error!', - msg: 'Failed to update organization: ' + id + '. PUT status: ' + status }); - }); - }; - - $scope.formCancel = function () { - $scope.$emit("ReloadOrganzationCards"); - $scope.$emit("ShowOrgListHeader"); - $state.transitionTo('organizations'); - }; - - // Related set: Add button - $scope.add = function (set) { - $rootScope.flashMessage = null; - $location.path('/' + base + '/' + $stateParams.organization_id + '/' + set); - }; - - // Related set: Edit button - $scope.edit = function (set, id) { - $rootScope.flashMessage = null; - $location.path('/' + set + '/' + id); - }; - - // Related set: Delete button - $scope['delete'] = function (set, itm_id, name, title) { - $rootScope.flashMessage = null; - - var action = function () { - Wait('start'); - var url = defaultUrl + $stateParams.organization_id + '/' + set + '/'; - Rest.setUrl(url); - Rest.post({ id: itm_id, disassociate: 1 }) - .success(function () { - $('#prompt-modal').modal('hide'); - $scope.search(form.related[set].iterator); - }) - .error(function (data, status) { - $('#prompt-modal').modal('hide'); - ProcessErrors($scope, data, status, null, { hdr: 'Error!', - msg: 'Call to ' + url + ' failed. POST returned status: ' + status }); - }); - }; - - Prompt({ - hdr: 'Delete', - body: '
Are you sure you want to remove the ' + title + ' below from ' + $scope.name + '?
' + name + '
', - action: action, - actionText: 'DELETE' - }); - - }; -} - -OrganizationsEdit.$inject = ['$scope', '$rootScope', '$compile', '$location', - '$log', '$stateParams', 'OrganizationForm', 'GenerateForm', 'Rest', 'Alert', - 'ProcessErrors', 'RelatedSearchInit', 'RelatedPaginateInit', 'Prompt', - 'ClearScope', 'GetBasePath', 'Wait', '$state' -]; \ No newline at end of file diff --git a/awx/ui/client/src/organizations/add/main.js b/awx/ui/client/src/organizations/add/main.js new file mode 100644 index 0000000000..27b8406e0b --- /dev/null +++ b/awx/ui/client/src/organizations/add/main.js @@ -0,0 +1,14 @@ +/************************************************* + * Copyright (c) 2015 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import route from './organizations-add.route'; +import controller from './organizations-add.controller'; + +export default + angular.module('organizationsAdd', []) + .run(['$stateExtender', function($stateExtender) { + $stateExtender.addState(route); + }]); diff --git a/awx/ui/client/src/organizations/add/organizations-add.controller.js b/awx/ui/client/src/organizations/add/organizations-add.controller.js new file mode 100644 index 0000000000..00c7ad9579 --- /dev/null +++ b/awx/ui/client/src/organizations/add/organizations-add.controller.js @@ -0,0 +1,66 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +export default ['$scope', '$rootScope', '$compile', '$location', + '$log', '$stateParams', 'OrganizationForm', 'GenerateForm', 'Rest', 'Alert', + 'ProcessErrors', 'ClearScope', 'GetBasePath', 'ReturnToCaller', 'Wait', + '$state', + function($scope, $rootScope, $compile, $location, $log, + $stateParams, OrganizationForm, GenerateForm, Rest, Alert, ProcessErrors, + ClearScope, GetBasePath, ReturnToCaller, Wait, $state) { + + ClearScope(); + + // Inject dynamic view + var generator = GenerateForm, + form = OrganizationForm, + base = $location.path().replace(/^\//, '').split('/')[0]; + + generator.inject(form, { + mode: 'add', + related: false, + scope: $scope + }); + generator.reset(); + + $scope.$emit("HideOrgListHeader"); + + // Save + $scope.formSave = function() { + generator.clearApiErrors(); + Wait('start'); + var url = GetBasePath(base); + url += (base !== 'organizations') ? $stateParams.project_id + '/organizations/' : ''; + Rest.setUrl(url); + Rest.post({ + name: $scope.name, + description: $scope.description + }) + .success(function(data) { + Wait('stop'); + $scope.$emit("ReloadOrganzationCards", data.id); + if (base === 'organizations') { + $rootScope.flashMessage = "New organization successfully created!"; + $location.path('/organizations/' + data.id); + } else { + ReturnToCaller(1); + } + }) + .error(function(data, status) { + ProcessErrors($scope, data, status, form, { + hdr: 'Error!', + msg: 'Failed to add new organization. Post returned status: ' + status + }); + }); + }; + + $scope.formCancel = function() { + $scope.$emit("ReloadOrganzationCards"); + $scope.$emit("ShowOrgListHeader"); + $state.transitionTo('organizations'); + }; + } +] diff --git a/awx/ui/client/src/organizations/add/organizations-add.partial.html b/awx/ui/client/src/organizations/add/organizations-add.partial.html new file mode 100644 index 0000000000..5db1583d13 --- /dev/null +++ b/awx/ui/client/src/organizations/add/organizations-add.partial.html @@ -0,0 +1,4 @@ +
+
+
+
diff --git a/awx/ui/client/src/organizations/add/organizations-add.route.js b/awx/ui/client/src/organizations/add/organizations-add.route.js new file mode 100644 index 0000000000..9deab323b7 --- /dev/null +++ b/awx/ui/client/src/organizations/add/organizations-add.route.js @@ -0,0 +1,24 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import {templateUrl} from '../../shared/template-url/template-url.factory'; +import OrganizationsAdd from './organizations-add.controller'; + +export default { + name: 'organizations.add', + route: '/add', + templateUrl: templateUrl('organizations/add/organizations-add'), + controller: OrganizationsAdd, + ncyBreadcrumb: { + parent: "organizations", + label: "CREATE ORGANIZATION" + }, + resolve: { + features: ['FeaturesService', function(FeaturesService) { + return FeaturesService.get(); + }] + } +}; diff --git a/awx/ui/client/src/organizations/edit/main.js b/awx/ui/client/src/organizations/edit/main.js new file mode 100644 index 0000000000..8f2a825df9 --- /dev/null +++ b/awx/ui/client/src/organizations/edit/main.js @@ -0,0 +1,15 @@ +/************************************************* + * Copyright (c) 2015 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import route from './organizations-edit.route'; +import controller from './organizations-edit.controller'; + +export default + angular.module('organizationsEdit', []) + .controller('organizationsEditController', controller) + .run(['$stateExtender', function($stateExtender) { + $stateExtender.addState(route); + }]); diff --git a/awx/ui/client/src/organizations/edit/organizations-edit.controller.js b/awx/ui/client/src/organizations/edit/organizations-edit.controller.js new file mode 100644 index 0000000000..f9178a1fb1 --- /dev/null +++ b/awx/ui/client/src/organizations/edit/organizations-edit.controller.js @@ -0,0 +1,150 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +export default ['$scope', '$rootScope', '$compile', '$location', + '$log', '$stateParams', 'OrganizationForm', 'GenerateForm', 'Rest', 'Alert', + 'ProcessErrors', 'RelatedSearchInit', 'RelatedPaginateInit', 'Prompt', + 'ClearScope', 'GetBasePath', 'Wait', '$state', + function($scope, $rootScope, $compile, $location, $log, + $stateParams, OrganizationForm, GenerateForm, Rest, Alert, ProcessErrors, + RelatedSearchInit, RelatedPaginateInit, Prompt, ClearScope, GetBasePath, + Wait, $state) { + + ClearScope(); + + // Inject dynamic view + var form = OrganizationForm, + generator = GenerateForm, + defaultUrl = GetBasePath('organizations'), + base = $location.path().replace(/^\//, '').split('/')[0], + master = {}, + id = $stateParams.organization_id, + relatedSets = {}; + + $scope.$emit("HideOrgListHeader"); + + $scope.$emit("ReloadOrganzationCards", id); + + $scope.organization_id = id; + + generator.inject(form, { mode: 'edit', related: true, scope: $scope}); + generator.reset(); + + // After the Organization is loaded, retrieve each related set + if ($scope.organizationLoadedRemove) { + $scope.organizationLoadedRemove(); + } + $scope.organizationLoadedRemove = $scope.$on('organizationLoaded', function () { + for (var set in relatedSets) { + $scope.search(relatedSets[set].iterator); + } + Wait('stop'); + }); + + // Retrieve detail record and prepopulate the form + Wait('start'); + Rest.setUrl(defaultUrl + id + '/'); + Rest.get() + .success(function (data) { + var fld, related, set; + $scope.organization_name = data.name; + for (fld in form.fields) { + if (data[fld]) { + $scope[fld] = data[fld]; + master[fld] = data[fld]; + } + } + related = data.related; + for (set in form.related) { + if (related[set]) { + relatedSets[set] = { + url: related[set], + iterator: form.related[set].iterator + }; + } + } + // Initialize related search functions. Doing it here to make sure relatedSets object is populated. + RelatedSearchInit({ scope: $scope, form: form, relatedSets: relatedSets }); + RelatedPaginateInit({ scope: $scope, relatedSets: relatedSets }); + $scope.$emit('organizationLoaded'); + }) + .error(function (data, status) { + ProcessErrors($scope, data, status, form, { hdr: 'Error!', + msg: 'Failed to retrieve organization: ' + $stateParams.id + '. GET status: ' + status }); + }); + + + // Save changes to the parent + $scope.formSave = function () { + var fld, params = {}; + generator.clearApiErrors(); + Wait('start'); + for (fld in form.fields) { + params[fld] = $scope[fld]; + } + Rest.setUrl(defaultUrl + id + '/'); + Rest.put(params) + .success(function (data) { + Wait('stop'); + $scope.organization_name = $scope.name; + master = params; + $rootScope.flashMessage = "Your changes were successfully saved!"; + $scope.$emit("ReloadOrganzationCards", data.id); + }) + .error(function (data, status) { + ProcessErrors($scope, data, status, OrganizationForm, { hdr: 'Error!', + msg: 'Failed to update organization: ' + id + '. PUT status: ' + status }); + }); + }; + + $scope.formCancel = function () { + $scope.$emit("ReloadOrganzationCards"); + $scope.$emit("ShowOrgListHeader"); + $state.transitionTo('organizations'); + }; + + // Related set: Add button + $scope.add = function (set) { + $rootScope.flashMessage = null; + $location.path('/' + base + '/' + $stateParams.organization_id + '/' + set); + }; + + // Related set: Edit button + $scope.edit = function (set, id) { + $rootScope.flashMessage = null; + $location.path('/' + set + '/' + id); + }; + + // Related set: Delete button + $scope['delete'] = function (set, itm_id, name, title) { + $rootScope.flashMessage = null; + + var action = function () { + Wait('start'); + var url = defaultUrl + $stateParams.organization_id + '/' + set + '/'; + Rest.setUrl(url); + Rest.post({ id: itm_id, disassociate: 1 }) + .success(function () { + $('#prompt-modal').modal('hide'); + $scope.search(form.related[set].iterator); + }) + .error(function (data, status) { + $('#prompt-modal').modal('hide'); + ProcessErrors($scope, data, status, null, { hdr: 'Error!', + msg: 'Call to ' + url + ' failed. POST returned status: ' + status }); + }); + }; + + Prompt({ + hdr: 'Delete', + body: '
Are you sure you want to remove the ' + title + ' below from ' + $scope.name + '?
' + name + '
', + action: action, + actionText: 'DELETE' + }); + + }; +} +] diff --git a/awx/ui/client/src/organizations/edit/organizations-edit.route.js b/awx/ui/client/src/organizations/edit/organizations-edit.route.js new file mode 100644 index 0000000000..ad546e71cc --- /dev/null +++ b/awx/ui/client/src/organizations/edit/organizations-edit.route.js @@ -0,0 +1,29 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import { + templateUrl +} from '../../shared/template-url/template-url.factory'; +import OrganizationsEdit from './organizations-edit.controller'; + +export default { + name: 'organizations.edit', + route: '/:organization_id', + templateUrl: templateUrl('organizations/add/organizations-add'), + controller: OrganizationsEdit, + data: { + activityStreamId: 'organization_id' + }, + ncyBreadcrumb: { + parent: "organizations", + label: "{{name}}" + }, + resolve: { + features: ['FeaturesService', function(FeaturesService) { + return FeaturesService.get(); + }] + } +}; diff --git a/awx/ui/client/src/organizations/list/main.js b/awx/ui/client/src/organizations/list/main.js new file mode 100644 index 0000000000..0250a5f5f8 --- /dev/null +++ b/awx/ui/client/src/organizations/list/main.js @@ -0,0 +1,14 @@ +/************************************************* + * Copyright (c) 2015 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import route from './organizations-list.route'; +import controller from './organizations-list.controller'; + +export default + angular.module('organizationsList', []) + .run(['$stateExtender', function($stateExtender) { + $stateExtender.addState(route); + }]); diff --git a/awx/ui/client/src/organizations/list/organizations-list.controller.js b/awx/ui/client/src/organizations/list/organizations-list.controller.js new file mode 100644 index 0000000000..2a21b03ffb --- /dev/null +++ b/awx/ui/client/src/organizations/list/organizations-list.controller.js @@ -0,0 +1,182 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +export default ['$stateParams', '$scope', '$rootScope', '$location', + '$log', '$compile', 'Rest', 'PaginateWidget', 'PaginateInit', + 'SearchInit', 'OrganizationList', 'Alert', 'Prompt', 'ClearScope', + 'ProcessErrors', 'GetBasePath', 'Wait', + '$state', + function($stateParams, $scope, $rootScope, $location, + $log, $compile, Rest, PaginateWidget, PaginateInit, + SearchInit, OrganizationList, Alert, Prompt, ClearScope, + ProcessErrors, GetBasePath, Wait, + $state) { + + ClearScope(); + + var defaultUrl = GetBasePath('organizations'), + list = OrganizationList, + pageSize = $scope.orgCount; + + PaginateInit({ + scope: $scope, + list: list, + url: defaultUrl, + pageSize: pageSize, + }); + SearchInit({ + scope: $scope, + list: list, + url: defaultUrl, + }); + + $scope.search(list.iterator); + + $scope.PaginateWidget = PaginateWidget({ + iterator: list.iterator, + set: 'organizations' + }); + + var paginationContainer = $('#pagination-container'); + paginationContainer.html($scope.PaginateWidget); + $compile(paginationContainer.contents())($scope) + + var parseCardData = function(cards) { + return cards.map(function(card) { + var val = {}; + val.name = card.name; + val.id = card.id; + if (card.id + "" === cards.activeCard) { + val.isActiveCard = true; + } + val.description = card.description || undefined; + val.links = []; + val.links.push({ + href: card.related.users, + name: "USERS" + }); + val.links.push({ + href: card.related.teams, + name: "TEAMS" + }); + val.links.push({ + href: card.related.inventories, + name: "INVENTORIES" + }); + val.links.push({ + href: card.related.projects, + name: "PROJECTS" + }); + val.links.push({ + href: card.related.job_templates, + name: "JOB TEMPLATES" + }); + val.links.push({ + href: card.related.admins, + name: "ADMINS" + }); + return val; + }); + }; + + var getOrganization = function(id) { + Rest.setUrl(defaultUrl); + Rest.get() + .success(function(data) { + data.results.activeCard = id; + $scope.orgCount = data.count; + $scope.orgCards = parseCardData(data.results); + Wait("stop"); + }) + .error(function(data, status) { + ProcessErrors($scope, data, status, null, { + hdr: 'Error!', + msg: 'Call to ' + defaultUrl + ' failed. DELETE returned status: ' + status + }); + }); + }; + + $scope.$on("ReloadOrgListView", function() { + if ($state.$current.self.name === "organizations") { + delete $scope.activeCard; + if ($scope.orgCards) { + $scope.orgCards = $scope.orgCards.map(function(card) { + delete card.isActiveCard; + return card; + }); + } + $scope.hideListHeader = false; + } + }); + + $scope.$on("ReloadOrganzationCards", function(e, id) { + $scope.activeCard = id; + getOrganization(id); + }); + + $scope.$on("HideOrgListHeader", function() { + $scope.hideListHeader = true; + }); + + $scope.$on("ShowOrgListHeader", function() { + $scope.hideListHeader = false; + }); + + getOrganization(); + + $rootScope.flashMessage = null; + + if ($scope.removePostRefresh) { + $scope.removePostRefresh(); + } + $scope.removePostRefresh = $scope.$on('PostRefresh', function() { + // Cleanup after a delete + Wait('stop'); + $('#prompt-modal').modal('hide'); + }); + + $scope.addOrganization = function() { + $state.transitionTo('organizations.add'); + }; + + $scope.editOrganization = function(id) { + $scope.activeCard = id; + $state.transitionTo('organizations.edit', { + organization_id: id + }); + }; + + $scope.deleteOrganization = function(id, name) { + + var action = function() { + $('#prompt-modal').modal('hide'); + Wait('start'); + var url = defaultUrl + id + '/'; + Rest.setUrl(url); + Rest.destroy() + .success(function() { + if ($state.current.name !== "organzations") { + $state.transitionTo("organizations"); + } + $scope.$emit("ReloadOrganzationCards"); + }) + .error(function(data, status) { + ProcessErrors($scope, data, status, null, { + hdr: 'Error!', + msg: 'Call to ' + url + ' failed. DELETE returned status: ' + status + }); + }); + }; + + Prompt({ + hdr: 'Delete', + body: '
Are you sure you want to delete the organization below?
' + name + '
', + action: action, + actionText: 'DELETE' + }); + }; + } +] diff --git a/awx/ui/client/src/organizations/list/organizations-list.partial.html b/awx/ui/client/src/organizations/list/organizations-list.partial.html new file mode 100644 index 0000000000..b6d531897a --- /dev/null +++ b/awx/ui/client/src/organizations/list/organizations-list.partial.html @@ -0,0 +1,62 @@ +
+
+
+
+
+
+ organizations +
+ + {{ orgCount }} + +
+
+ +
+
+
+
+
+
+

{{ card.name }}

+
+ + +
+
+

{{ card.description || "Place organization description here" }}

+ +
+
+
+
+
diff --git a/awx/ui/client/src/organizations/list/organizations-list.route.js b/awx/ui/client/src/organizations/list/organizations-list.route.js new file mode 100644 index 0000000000..a97b939c01 --- /dev/null +++ b/awx/ui/client/src/organizations/list/organizations-list.route.js @@ -0,0 +1,31 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import {templateUrl} from '../../shared/template-url/template-url.factory'; +import OrganizationsList from './organizations-list.controller'; + +export default { + name: 'organizations', + route: '/organizations', + templateUrl: templateUrl('organizations/list/organizations-list'), + controller: OrganizationsList, + data: { + activityStream: true, + activityStreamTarget: 'organization' + }, + ncyBreadcrumb: { + parent: function($scope) { + $scope.$parent.$emit("ReloadOrgListView"); + return "setup"; + }, + label: "ORGANIZATIONS" + }, + resolve: { + features: ['FeaturesService', function(FeaturesService) { + return FeaturesService.get(); + }] + } +}; diff --git a/awx/ui/client/src/organizations/main.js b/awx/ui/client/src/organizations/main.js new file mode 100644 index 0000000000..b846961f8d --- /dev/null +++ b/awx/ui/client/src/organizations/main.js @@ -0,0 +1,16 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import organizationsList from './list/main'; +import organizationsAdd from './add/main'; +import organizationsEdit from './edit/main'; + +export default +angular.module('organizations', [ + organizationsList.name, + organizationsAdd.name, + organizationsEdit.name, +]); From 52cd4f5ef94a303ef8b839300165f233a77c4822 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Tue, 15 Mar 2016 15:06:00 -0400 Subject: [PATCH 29/41] reduce test to only check project inventory connection --- .../api/test_organization_counts.py | 53 ++++++++----------- 1 file changed, 21 insertions(+), 32 deletions(-) diff --git a/awx/main/tests/functional/api/test_organization_counts.py b/awx/main/tests/functional/api/test_organization_counts.py index de629dbcf4..8d881fe8a0 100644 --- a/awx/main/tests/functional/api/test_organization_counts.py +++ b/awx/main/tests/functional/api/test_organization_counts.py @@ -109,47 +109,36 @@ def test_two_organizations(resourced_organization, organizations, user, get): } @pytest.mark.django_db -def test_overlapping_project(resourced_organization, organizations, user, get): - # Check correct results for two organizations are returned +def test_JT_associated_with_project(organizations, project, user, get): + # Check that adding a project to an organization gets the project's JT + # included in the organization's JT count external_admin = user('admin', True) - organization2 = organizations(1)[0] - the_project = resourced_organization.projects.all()[0] - organization2.projects.add(the_project) - organization2.projects.create(name="second-project", - description="test-proj-desc", - scm_type="git", - scm_url="https://github.com/jlaska/ansible-playbooks") - inventory = organization2.inventories.create(name="second-inventory") - organization2.projects.get(name="second-project").jobtemplates.create( - name="second-job-template", - inventory=inventory, - playbook="hello.yml" - ) + two_orgs = organizations(2) + organization = two_orgs[0] + other_org = two_orgs[1] + + unrelated_inv = other_org.inventories.create(name='not-in-organization') + project.jobtemplates.create(name="test-jt", + description="test-job-template-desc", + inventory=unrelated_inv, + playbook="test_playbook.yml") + organization.projects.add(project) response = get(reverse('api:organization_list', args=[]), external_admin) assert response.status_code == 200 - org_id_full = resourced_organization.id - org_id2 = organization2.id + org_id = organization.id counts = {} for i in range(2): - org_id = response.data['results'][i]['id'] - counts[org_id] = response.data['results'][i]['summary_fields']['related_field_counts'] + working_id = response.data['results'][i]['id'] + counts[working_id] = response.data['results'][i]['summary_fields']['related_field_counts'] - assert counts[org_id_full] == { - 'users': 1, - 'admins': 1, - 'job_templates': 1, - 'projects': 1, - 'inventories': 1, - 'teams': 1 - } - assert counts[org_id2] == { + assert counts[org_id] == { 'users': 0, 'admins': 0, - 'job_templates': 2, - 'projects': 2, - 'inventories': 1, + 'job_templates': 1, + 'projects': 1, + 'inventories': 0, 'teams': 0 } - assert False + From 63b01bb04b98b8a7bd00590fdea0686b561bafc8 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Wed, 16 Mar 2016 13:06:54 -0400 Subject: [PATCH 30/41] Fix up the docker-refresh Makefile target This allows you to cleanup the images without requiring a rebuild necessarily --- Makefile | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index a6461e8e66..df28fdb381 100644 --- a/Makefile +++ b/Makefile @@ -802,13 +802,15 @@ docker-compose-test: cd tools && docker-compose run --rm --service-ports tower /bin/bash MACHINE?=default -docker-refresh: +docker-clean: rm -f awx/lib/.deps_built + rm -f awx/lib/site-packages eval $$(docker-machine env $(MACHINE)) docker stop $$(docker ps -a -q) - docker rm $$(docker ps -f name=tools_tower -a -q) - docker rmi tools_tower - docker-compose -f tools/docker-compose.yml up + -docker rm $$(docker ps -f name=tools_tower -a -q) + -docker rmi tools_tower + +docker-refresh: docker-clean docker-compose mongo-debug-ui: docker run -it --rm --name mongo-express --link tools_mongo_1:mongo -e ME_CONFIG_OPTIONS_EDITORTHEME=ambiance -e ME_CONFIG_BASICAUTH_USERNAME=admin -e ME_CONFIG_BASICAUTH_PASSWORD=password -p 8081:8081 knickers/mongo-express From 45f95bf2b257f92fa0afd3ae1251a03b91e30726 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Wed, 16 Mar 2016 13:08:50 -0400 Subject: [PATCH 31/41] Disallow related elements to be treated as choices DRF will try to resolve potential candidates into the OPTIONS endpoint. This is mainly to support their POST field in the browseable API. We don't need this and it can yield some expensive queries so we bypass generating choices for any RelatedField fields --- awx/api/metadata.py | 39 +++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/awx/api/metadata.py b/awx/api/metadata.py index 01f8fe306e..3ec4c6d4d1 100644 --- a/awx/api/metadata.py +++ b/awx/api/metadata.py @@ -1,6 +1,8 @@ # Copyright (c) 2016 Ansible, Inc. # All Rights Reserved. +from collections import OrderedDict + # Django from django.core.exceptions import PermissionDenied from django.http import Http404 @@ -10,6 +12,7 @@ from django.utils.encoding import force_text from rest_framework import exceptions from rest_framework import metadata from rest_framework import serializers +from rest_framework.relations import RelatedField from rest_framework.request import clone_request # Ansible Tower @@ -37,9 +40,20 @@ class Metadata(metadata.SimpleMetadata): return field_info def get_field_info(self, field): - field_info = super(Metadata, self).get_field_info(field) - if hasattr(field, 'choices') and field.choices: - field_info = self._render_read_only_choices(field, field_info) + field_info = OrderedDict() + field_info['type'] = self.label_lookup[field] + field_info['required'] = getattr(field, 'required', False) + + text_attrs = [ + 'read_only', 'label', 'help_text', + 'min_length', 'max_length', + 'min_value', 'max_value' + ] + + for attr in text_attrs: + value = getattr(field, attr, None) + if value is not None and value != '': + field_info[attr] = force_text(value, strings_only=True) # Indicate if a field has a default value. # FIXME: Still isn't showing all default values? @@ -48,21 +62,18 @@ class Metadata(metadata.SimpleMetadata): except serializers.SkipField: pass + if getattr(field, 'child', None): + field_info['child'] = self.get_field_info(field.child) + elif getattr(field, 'fields', None): + field_info['children'] = self.get_serializer_info(field) + + if hasattr(field, 'choices') and not isinstance(field, RelatedField): + field_info['choices'] = [(choice_value, choice_name) for choice_value, choice_name in field.choices.items()] + # Indicate if a field is write-only. if getattr(field, 'write_only', False): field_info['write_only'] = True - # Update choices to be a list of 2-tuples instead of list of dicts with - # value/display_name. - if 'choices' in field_info: - choices = [] - for choice in field_info['choices']: - if isinstance(choice, dict): - choices.append((choice.get('value'), choice.get('display_name'))) - else: - choices.append(choice) - field_info['choices'] = choices - # Special handling of inventory source_region choices that vary based on # selected inventory source. if field.field_name == 'source_regions': From 526a6ec7dd25b3b2aece7a89a6f75371e27ff3d0 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Wed, 16 Mar 2016 13:12:13 -0400 Subject: [PATCH 32/41] Remove unneeded fetch for r/o fields --- awx/api/metadata.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/awx/api/metadata.py b/awx/api/metadata.py index 3ec4c6d4d1..6fccdb887d 100644 --- a/awx/api/metadata.py +++ b/awx/api/metadata.py @@ -21,24 +21,6 @@ from awx.main.models import InventorySource, Notifier class Metadata(metadata.SimpleMetadata): - # DRF 3.3 doesn't render choices for read-only fields - # - # We want to render choices for read-only fields - # - # Note: This works in conjuction with logic in serializers.py that sets - # field property editable=True before calling DRF build_standard_field() - # Note: Consider expanding this rendering for more than just choices fields - def _render_read_only_choices(self, field, field_info): - if field_info.get('read_only') and hasattr(field, 'choices'): - field_info['choices'] = [ - { - 'value': choice_value, - 'display_name': force_text(choice_name, strings_only=True) - } - for choice_value, choice_name in field.choices.items() - ] - return field_info - def get_field_info(self, field): field_info = OrderedDict() field_info['type'] = self.label_lookup[field] From 8ca3a6b2bfd5d130af947e9820a745ffede6fcea Mon Sep 17 00:00:00 2001 From: Ken Hoes Date: Wed, 16 Mar 2016 13:23:37 -0400 Subject: [PATCH 33/41] Added counts to organizations listing --- .../list/organizations-list.controller.js | 18 ++++++++++++------ .../list/organizations-list.partial.html | 2 +- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/awx/ui/client/src/organizations/list/organizations-list.controller.js b/awx/ui/client/src/organizations/list/organizations-list.controller.js index 2a21b03ffb..7316f145b9 100644 --- a/awx/ui/client/src/organizations/list/organizations-list.controller.js +++ b/awx/ui/client/src/organizations/list/organizations-list.controller.js @@ -56,27 +56,33 @@ export default ['$stateParams', '$scope', '$rootScope', '$location', val.links = []; val.links.push({ href: card.related.users, - name: "USERS" + name: "USERS", + count: card.summary_fields.related_field_counts.users }); val.links.push({ href: card.related.teams, - name: "TEAMS" + name: "TEAMS", + count: card.summary_fields.related_field_counts.teams }); val.links.push({ href: card.related.inventories, - name: "INVENTORIES" + name: "INVENTORIES", + count: card.summary_fields.related_field_counts.inventories }); val.links.push({ href: card.related.projects, - name: "PROJECTS" + name: "PROJECTS", + count: card.summary_fields.related_field_counts.projects }); val.links.push({ href: card.related.job_templates, - name: "JOB TEMPLATES" + name: "JOB TEMPLATES", + count: card.summary_fields.related_field_counts.job_templates }); val.links.push({ href: card.related.admins, - name: "ADMINS" + name: "ADMINS", + count: card.summary_fields.related_field_counts.admins }); return val; }); diff --git a/awx/ui/client/src/organizations/list/organizations-list.partial.html b/awx/ui/client/src/organizations/list/organizations-list.partial.html index b6d531897a..73bfa908e9 100644 --- a/awx/ui/client/src/organizations/list/organizations-list.partial.html +++ b/awx/ui/client/src/organizations/list/organizations-list.partial.html @@ -47,7 +47,7 @@
{{ result.name }}{{ result.name }}{{ result.name }}{{ result.name }} {{ result.item }} {{ result.msg }}