1
0
mirror of https://github.com/ansible/awx.git synced 2024-11-02 01:21:21 +03:00

Prevent user from selecting an invalid JT when adding/editing a wfjt node

This commit is contained in:
mabashian 2018-04-18 18:12:48 -04:00
parent cbe3bc3f2a
commit a918539e23
8 changed files with 66 additions and 229 deletions

View File

@ -89,6 +89,10 @@ function TemplatesStrings (BaseString) {
ns.warnings = { ns.warnings = {
WORKFLOW_RESTRICTED_COPY: t.s('You do not have access to all resources used by this workflow. Resources that you don\'t have access to will not be copied and will result in an incomplete workflow.') WORKFLOW_RESTRICTED_COPY: t.s('You do not have access to all resources used by this workflow. Resources that you don\'t have access to will not be copied and will result in an incomplete workflow.')
}; };
ns.workflows = {
INVALID_JOB_TEMPLATE: t.s('This Job Template is missing a default inventory or project. This must be addressed in the Job Template form before this node can be saved.')
};
} }
TemplatesStrings.$inject = ['BaseStringService']; TemplatesStrings.$inject = ['BaseStringService'];

View File

@ -18,7 +18,6 @@ import workflowService from './workflows/workflow.service';
import WorkflowForm from './workflows.form'; import WorkflowForm from './workflows.form';
import InventorySourcesList from './inventory-sources.list'; import InventorySourcesList from './inventory-sources.list';
import TemplateList from './templates.list'; import TemplateList from './templates.list';
import TemplatesStrings from './templates.strings';
import listRoute from '~features/templates/routes/templatesList.route.js'; import listRoute from '~features/templates/routes/templatesList.route.js';
import templateCompletedJobsRoute from '~features/jobs/routes/templateCompletedJobs.route.js'; import templateCompletedJobsRoute from '~features/jobs/routes/templateCompletedJobs.route.js';
@ -32,7 +31,6 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p
// TODO: currently being kept arround for rbac selection, templates within projects and orgs, etc. // TODO: currently being kept arround for rbac selection, templates within projects and orgs, etc.
.factory('TemplateList', TemplateList) .factory('TemplateList', TemplateList)
.value('InventorySourcesList', InventorySourcesList) .value('InventorySourcesList', InventorySourcesList)
.service('TemplatesStrings', TemplatesStrings)
.config(['$stateProvider', 'stateDefinitionsProvider', '$stateExtenderProvider', .config(['$stateProvider', 'stateDefinitionsProvider', '$stateExtenderProvider',
function($stateProvider, stateDefinitionsProvider, $stateExtenderProvider) { function($stateProvider, stateDefinitionsProvider, $stateExtenderProvider) {
let stateTree, addJobTemplate, editJobTemplate, addWorkflow, editWorkflow, let stateTree, addJobTemplate, editJobTemplate, addWorkflow, editWorkflow,

View File

@ -1,7 +0,0 @@
function TemplatesStrings (BaseString) {
BaseString.call(this, 'templates');
}
TemplatesStrings.$inject = ['BaseStringService'];
export default TemplatesStrings;

View File

@ -1,11 +1,9 @@
import workflowMaker from './workflow-maker.directive'; import workflowMaker from './workflow-maker.directive';
import WorkflowMakerController from './workflow-maker.controller'; import WorkflowMakerController from './workflow-maker.controller';
import WorkflowMakerForm from './workflow-maker.form';
export default export default
angular.module('templates.workflowMaker', []) angular.module('templates.workflowMaker', [])
// In order to test this controller I had to expose it at the module level // In order to test this controller I had to expose it at the module level
// like so. Is this correct? Is there a better pattern for doing this? // like so. Is this correct? Is there a better pattern for doing this?
.controller('WorkflowMakerController', WorkflowMakerController) .controller('WorkflowMakerController', WorkflowMakerController)
.factory('WorkflowMakerForm', WorkflowMakerForm)
.directive('workflowMaker', workflowMaker); .directive('workflowMaker', workflowMaker);

View File

@ -275,6 +275,10 @@
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
} }
.WorkflowMaker-invalidJobTemplateWarning {
margin-bottom: 5px;
color: @default-err;
}
.Key-list { .Key-list {
margin: 0; margin: 0;

View File

@ -5,15 +5,16 @@
*************************************************/ *************************************************/
export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService', export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
'$state', 'ProcessErrors', 'CreateSelect2', 'WorkflowMakerForm', '$q', 'JobTemplateModel', '$state', 'ProcessErrors', 'CreateSelect2', '$q', 'JobTemplateModel',
'Empty', 'PromptService', 'Rest', 'Empty', 'PromptService', 'Rest', 'TemplatesStrings',
function($scope, WorkflowService, GetBasePath, TemplatesService, $state, function($scope, WorkflowService, GetBasePath, TemplatesService,
ProcessErrors, CreateSelect2, WorkflowMakerForm, $q, JobTemplate, $state, ProcessErrors, CreateSelect2, $q, JobTemplate,
Empty, PromptService, Rest) { Empty, PromptService, Rest, TemplatesStrings) {
let form = WorkflowMakerForm();
let promptWatcher, surveyQuestionWatcher; let promptWatcher, surveyQuestionWatcher;
$scope.strings = TemplatesStrings;
$scope.workflowMakerFormConfig = { $scope.workflowMakerFormConfig = {
nodeMode: "idle", nodeMode: "idle",
activeTab: "jobs", activeTab: "jobs",
@ -184,7 +185,7 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
params.node.isNew = false; params.node.isNew = false;
continueRecursing(data.data.id); continueRecursing(data.data.id);
}, function(error) { }, function(error) {
ProcessErrors($scope, error.data, error.status, form, { ProcessErrors($scope, error.data, error.status, null, {
hdr: 'Error!', hdr: 'Error!',
msg: 'Failed to add workflow node. ' + msg: 'Failed to add workflow node. ' +
'POST returned status: ' + 'POST returned status: ' +
@ -403,7 +404,11 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
$q.all(associatePromises.concat(credentialPromises)) $q.all(associatePromises.concat(credentialPromises))
.then(function() { .then(function() {
$scope.closeDialog(); $scope.closeDialog();
}).catch(({data, status}) => {
ProcessErrors($scope, data, status, null);
}); });
}).catch(({data, status}) => {
ProcessErrors($scope, data, status, null);
}); });
}; };
@ -552,6 +557,8 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
} }
$scope.promptData = null; $scope.promptData = null;
$scope.selectedTemplateInvalid = false;
$scope.showPromptButton = false;
// Reset the edgeConflict flag // Reset the edgeConflict flag
resetEdgeConflict(); resetEdgeConflict();
@ -647,6 +654,12 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
prompts.credentials.value = workflowNodeCredentials.concat(defaultCredsWithoutOverrides); prompts.credentials.value = workflowNodeCredentials.concat(defaultCredsWithoutOverrides);
if ((!$scope.nodeBeingEdited.unifiedJobTemplate.inventory && !launchConf.ask_inventory_on_launch) || !$scope.nodeBeingEdited.unifiedJobTemplate.project) {
$scope.selectedTemplateInvalid = true;
} else {
$scope.selectedTemplateInvalid = false;
}
if (!launchConf.survey_enabled && if (!launchConf.survey_enabled &&
!launchConf.ask_inventory_on_launch && !launchConf.ask_inventory_on_launch &&
!launchConf.ask_credential_on_launch && !launchConf.ask_credential_on_launch &&
@ -658,7 +671,6 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
!launchConf.ask_diff_mode_on_launch && !launchConf.ask_diff_mode_on_launch &&
!launchConf.survey_enabled && !launchConf.survey_enabled &&
!launchConf.credential_needed_to_start && !launchConf.credential_needed_to_start &&
!launchConf.inventory_needed_to_start &&
launchConf.passwords_needed_to_start.length === 0 && launchConf.passwords_needed_to_start.length === 0 &&
launchConf.variables_needed_to_start.length === 0) { launchConf.variables_needed_to_start.length === 0) {
$scope.showPromptButton = false; $scope.showPromptButton = false;
@ -794,7 +806,7 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
$scope.nodeBeingEdited.unifiedJobTemplate = _.clone(data.data.results[0]); $scope.nodeBeingEdited.unifiedJobTemplate = _.clone(data.data.results[0]);
finishConfiguringEdit(); finishConfiguringEdit();
}, function(error) { }, function(error) {
ProcessErrors($scope, error.data, error.status, form, { ProcessErrors($scope, error.data, error.status, null, {
hdr: 'Error!', hdr: 'Error!',
msg: 'Failed to get unified job template. GET returned ' + msg: 'Failed to get unified job template. GET returned ' +
'status: ' + error.status 'status: ' + error.status
@ -946,8 +958,6 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
$scope.templateManuallySelected = function(selectedTemplate) { $scope.templateManuallySelected = function(selectedTemplate) {
$scope.selectedTemplate = angular.copy(selectedTemplate);
if (selectedTemplate.type === "job_template") { if (selectedTemplate.type === "job_template") {
let jobTemplate = new JobTemplate(); let jobTemplate = new JobTemplate();
@ -955,6 +965,14 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
.then((responses) => { .then((responses) => {
let launchConf = responses[1].data; let launchConf = responses[1].data;
if ((!selectedTemplate.inventory && !launchConf.ask_inventory_on_launch) || !selectedTemplate.project) {
$scope.selectedTemplateInvalid = true;
} else {
$scope.selectedTemplateInvalid = false;
}
$scope.selectedTemplate = angular.copy(selectedTemplate);
if (!launchConf.survey_enabled && if (!launchConf.survey_enabled &&
!launchConf.ask_inventory_on_launch && !launchConf.ask_inventory_on_launch &&
!launchConf.ask_credential_on_launch && !launchConf.ask_credential_on_launch &&
@ -966,7 +984,6 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
!launchConf.ask_diff_mode_on_launch && !launchConf.ask_diff_mode_on_launch &&
!launchConf.survey_enabled && !launchConf.survey_enabled &&
!launchConf.credential_needed_to_start && !launchConf.credential_needed_to_start &&
!launchConf.inventory_needed_to_start &&
launchConf.passwords_needed_to_start.length === 0 && launchConf.passwords_needed_to_start.length === 0 &&
launchConf.variables_needed_to_start.length === 0) { launchConf.variables_needed_to_start.length === 0) {
$scope.showPromptButton = false; $scope.showPromptButton = false;
@ -1028,6 +1045,8 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
}); });
} else { } else {
// TODO - clear out prompt data? // TODO - clear out prompt data?
$scope.selectedTemplate = angular.copy(selectedTemplate);
$scope.selectedTemplateInvalid = false;
$scope.showPromptButton = false; $scope.showPromptButton = false;
} }
}; };
@ -1114,7 +1133,7 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
buildTreeFromNodes(); buildTreeFromNodes();
} }
}, function(error){ }, function(error){
ProcessErrors($scope, error.data, error.status, form, { ProcessErrors($scope, error.data, error.status, null, {
hdr: 'Error!', hdr: 'Error!',
msg: 'Failed to get workflow job template nodes. GET returned ' + msg: 'Failed to get workflow job template nodes. GET returned ' +
'status: ' + error.status 'status: ' + error.status

View File

@ -1,183 +0,0 @@
/*************************************************
* Copyright (c) 2015 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
/**
* @ngdoc function
* @name forms.function:JobTemplate
* @description This form is for adding/editing a Job Template
*/
export default ['NotificationsList', 'i18n', '$rootScope', function(NotificationsList, i18n, $rootScope) {
return function() {
var WorkflowMakerFormObject = {
addTitle: '',
editTitle: '',
name: 'workflow_maker',
basePath: 'job_templates',
tabs: false,
cancelButton: false,
showHeader: false,
fields: {
edgeType: {
label: i18n._('Type'),
type: 'radio_group',
ngShow: 'selectedTemplate && edgeFlags.showTypeOptions',
ngDisabled: '!(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)',
options: [
{
label: i18n._('On Success'),
value: 'success',
ngShow: '!edgeFlags.typeRestriction || edgeFlags.typeRestriction === "successFailure"'
},
{
label: i18n._('On Failure'),
value: 'failure',
ngShow: '!edgeFlags.typeRestriction || edgeFlags.typeRestriction === "successFailure"'
},
{
label: i18n._('Always'),
value: 'always',
ngShow: '!edgeFlags.typeRestriction || edgeFlags.typeRestriction === "always"'
}
],
awRequiredWhen: {
reqExpression: 'edgeFlags.showTypeOptions'
}
},
credential: {
label: i18n._('Credential'),
type: 'lookup',
sourceModel: 'credential',
sourceField: 'name',
ngClick: 'lookUpCredential()',
requiredErrorMsg: i18n._("Please select a Credential."),
class: 'Form-formGroup--fullWidth',
awPopOver: "<p>" + i18n._("Select the credential you want the job to use when accessing the remote hosts. Choose the credential containing " +
" the username and SSH key or password that Ansible will need to log into the remote hosts.") + "</p>",
dataTitle: i18n._('Credential'),
dataPlacement: 'right',
dataContainer: "body",
ngShow: "selectedTemplate.ask_credential_on_launch",
ngDisabled: '!(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)',
awRequiredWhen: {
reqExpression: 'selectedTemplate && selectedTemplate.ask_credential_on_launch'
}
},
inventory: {
label: i18n._('Inventory'),
type: 'lookup',
sourceModel: 'inventory',
sourceField: 'name',
list: 'OrganizationList',
basePath: 'organization',
ngClick: 'lookUpInventory()',
requiredErrorMsg: i18n._("Please select an Inventory."),
class: 'Form-formGroup--fullWidth',
awPopOver: "<p>" + i18n._("Select the inventory containing the hosts you want this job to manage.") + "</p>",
dataTitle: i18n._('Inventory'),
dataPlacement: 'right',
dataContainer: "body",
ngShow: "selectedTemplate.ask_inventory_on_launch",
ngDisabled: '!(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)',
awRequiredWhen: {
reqExpression: 'selectedTemplate && selectedTemplate.ask_inventory_on_launch'
}
},
job_type: {
label: i18n._('Job Type'),
type: 'select',
ngOptions: 'type.label for type in job_type_options track by type.value',
"default": 0,
class: 'Form-formGroup--fullWidth',
awPopOver: "<p>" + i18n.sprintf(i18n._("When this template is submitted as a job, setting the type to %s will execute the playbook, running tasks " +
" on the selected hosts."), "<em>run</em>") + "</p> <p>" +
i18n.sprintf(i18n._("Setting the type to %s will not execute the playbook. Instead, %s will check playbook " +
" syntax, test environment setup and report problems."), "<em>check</em>", "<code>ansible</code>") + "</p> <p>" +
i18n.sprintf(i18n._("Setting the type to %s will execute the playbook and store any " +
" scanned facts for use with " + $rootScope.BRAND_NAME + "'s System Tracking feature."), "<em>scan</em>") + "</p>",
dataTitle: i18n._('Job Type'),
dataPlacement: 'right',
dataContainer: "body",
ngShow: "selectedTemplate.ask_job_type_on_launch",
ngDisabled: '!(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)',
awRequiredWhen: {
reqExpression: 'selectedTemplate && selectedTemplate.ask_job_type_on_launch'
}
},
limit: {
label: i18n._('Limit'),
type: 'text',
class: 'Form-formGroup--fullWidth',
awPopOver: "<p>" + i18n.sprintf(i18n._("Provide a host pattern to further constrain the list of hosts that will be managed or affected by the playbook. " +
"Multiple patterns can be separated by %s %s or %s"), "&#59;", "&#58;", "&#44;") + "</p><p>" +
i18n.sprintf(i18n._("For more information and examples see " +
"%sthe Patterns topic at docs.ansible.com%s."), "<a href=\"http://docs.ansible.com/intro_patterns.html\" target=\"_blank\">", "</a>") + "</p>",
dataTitle: i18n._('Limit'),
dataPlacement: 'right',
dataContainer: "body",
ngShow: "selectedTemplate.ask_limit_on_launch",
ngDisabled: '!(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)'
},
job_tags: {
label: i18n._('Job Tags'),
type: 'textarea',
rows: 5,
'elementClass': 'Form-textInput',
class: 'Form-formGroup--fullWidth',
awPopOver: "<p>" + i18n._("Provide a comma separated list of tags.") + "</p>\n" +
"<p>" + i18n._("Tags are useful when you have a large playbook, and you want to run a specific part of a play or task.") + "</p>" +
"<p>" + i18n._("Consult the Ansible documentation for further details on the usage of tags.") + "</p>",
dataTitle: i18n._("Job Tags"),
dataPlacement: "right",
dataContainer: "body",
ngShow: "selectedTemplate.ask_tags_on_launch",
ngDisabled: '!(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)'
},
skip_tags: {
label: i18n._('Skip Tags'),
type: 'textarea',
rows: 5,
'elementClass': 'Form-textInput',
class: 'Form-formGroup--fullWidth',
awPopOver: "<p>" + i18n._("Provide a comma separated list of tags.") + "</p>\n" +
"<p>" + i18n._("Skip tags are useful when you have a large playbook, and you want to skip specific parts of a play or task.") + "</p>" +
"<p>" + i18n._("Consult the Ansible documentation for further details on the usage of tags.") + "</p>",
dataTitle: i18n._("Skip Tags"),
dataPlacement: "right",
dataContainer: "body",
ngShow: "selectedTemplate.ask_skip_tags_on_launch",
ngDisabled: '!(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)'
}
},
buttons: {
cancel: {
ngClick: 'cancelNodeForm()',
ngShow: '(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)'
},
close: {
ngClick: 'cancelNodeForm()',
ngShow: '!(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)'
},
select: {
ngClick: 'saveNodeForm()',
ngDisabled: "workflow_maker_form.$invalid || !selectedTemplate",
ngShow: '(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)'
}
}
};
var itm;
for (itm in WorkflowMakerFormObject.related) {
if (WorkflowMakerFormObject.related[itm].include === "NotificationsList") {
WorkflowMakerFormObject.related[itm] = NotificationsList;
WorkflowMakerFormObject.related[itm].generateList = true; // tell form generator to call list generator and inject a list
}
}
return WorkflowMakerFormObject;
};
}];

View File

@ -93,31 +93,35 @@
<div id="workflow-project-sync-list" ui-view="projectSyncList" ng-show="workflowMakerFormConfig.activeTab === 'project_sync'"></div> <div id="workflow-project-sync-list" ui-view="projectSyncList" ng-show="workflowMakerFormConfig.activeTab === 'project_sync'"></div>
<div id="workflow-inventory-sync-list" ui-view="inventorySyncList" ng-show="workflowMakerFormConfig.activeTab === 'inventory_sync'"></div> <div id="workflow-inventory-sync-list" ui-view="inventorySyncList" ng-show="workflowMakerFormConfig.activeTab === 'inventory_sync'"></div>
</div> </div>
<div ng-show="selectedTemplate"> <div ng-if="selectedTemplate && selectedTemplateInvalid">
<div class="form-group Form-formGroup Form-formGroup--singleColumn"> <div class="WorkflowMaker-invalidJobTemplateWarning">
<label for="verbosity" class="Form-inputLabelContainer"> <span class="fa fa-warning"></span>
<span class="Form-requiredAsterisk">*</span> <span>{{:: strings.get('workflows.INVALID_JOB_TEMPLATE') }}</span>
<span class="Form-inputLabel">RUN</span>
</label>
<div>
<select
id="workflow_node_edge"
ng-options="v as v.label for v in edgeTypeOptions track by v.value"
ng-model="edgeType"
class="form-control Form-dropDown"
name="edgeType"
tabindex="-1"
aria-hidden="true">
</select>
</div>
</div> </div>
<div class="buttons Form-buttons" id="workflow_maker_controls"> </div>
<button type="button" class="btn btn-sm Form-primaryButton Form-primaryButton--noMargin" id="workflow_maker_prompt_btn" ng-show="showPromptButton" ng-click="openPromptModal()"> Prompt</button> <div class="form-group Form-formGroup Form-formGroup--singleColumn" ng-show="selectedTemplate && !selectedTemplateInvalid">
<button type="button" class="btn btn-sm Form-cancelButton" id="workflow_maker_cancel_btn" ng-show="(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)" ng-click="cancelNodeForm()"> Cancel</button> <label for="verbosity" class="Form-inputLabelContainer">
<button type="button" class="btn btn-sm Form-cancelButton" id="workflow_maker_close_btn" ng-show="!(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)" ng-click="cancelNodeForm()"> Close</button> <span class="Form-requiredAsterisk">*</span>
<button type="button" class="btn btn-sm Form-saveButton" id="workflow_maker_select_btn" ng-show="(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)" ng-click="confirmNodeForm()" ng-disabled="workflow_maker_form.$invalid || !selectedTemplate || promptModalMissingReqFields" disabled="disabled"> Select</button> <span class="Form-inputLabel">RUN</span>
</label>
<div>
<select
id="workflow_node_edge"
ng-options="v as v.label for v in edgeTypeOptions track by v.value"
ng-model="edgeType"
class="form-control Form-dropDown"
name="edgeType"
tabindex="-1"
aria-hidden="true">
</select>
</div> </div>
</div> </div>
<div class="buttons Form-buttons" id="workflow_maker_controls">
<button type="button" class="btn btn-sm Form-primaryButton Form-primaryButton--noMargin" id="workflow_maker_prompt_btn" ng-show="showPromptButton" ng-click="openPromptModal()"> Prompt</button>
<button type="button" class="btn btn-sm Form-cancelButton" id="workflow_maker_cancel_btn" ng-show="(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)" ng-click="cancelNodeForm()"> Cancel</button>
<button type="button" class="btn btn-sm Form-cancelButton" id="workflow_maker_close_btn" ng-show="!(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)" ng-click="cancelNodeForm()"> Close</button>
<button type="button" class="btn btn-sm Form-saveButton" id="workflow_maker_select_btn" ng-show="(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate) && !selectedTemplateInvalid" ng-click="confirmNodeForm()" ng-disabled="!selectedTemplate || promptModalMissingReqFields"> Select</button>
</div>
</div> </div>
</div> </div>
</div> </div>