diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 66c06c3ce6a2..c7a322489bd1 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3314,11 +3314,14 @@ class WorkflowJobTemplateSerializer(JobTemplateMixin, LabelsListMixin, UnifiedJo 'admin', 'execute', {'copy': 'organization.workflow_admin'} ] + limit = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None) + scm_branch = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None) class Meta: model = WorkflowJobTemplate fields = ('*', 'extra_vars', 'organization', 'survey_enabled', 'allow_simultaneous', - 'ask_variables_on_launch', 'inventory', 'ask_inventory_on_launch',) + 'ask_variables_on_launch', 'inventory', 'limit', 'scm_branch', + 'ask_inventory_on_launch', 'ask_scm_branch_on_launch', 'ask_limit_on_launch',) def get_related(self, obj): res = super(WorkflowJobTemplateSerializer, self).get_related(obj) @@ -3344,6 +3347,22 @@ def get_related(self, obj): def validate_extra_vars(self, value): return vars_validate_or_raise(value) + def validate(self, attrs): + attrs = super(WorkflowJobTemplateSerializer, self).validate(attrs) + + # process char_prompts, these are not direct fields on the model + mock_obj = self.Meta.model() + for field_name in ('scm_branch', 'limit'): + if field_name in attrs: + setattr(mock_obj, field_name, attrs[field_name]) + attrs.pop(field_name) + + # Model `.save` needs the container dict, not the pseudo fields + if mock_obj.char_prompts: + attrs['char_prompts'] = mock_obj.char_prompts + + return attrs + class WorkflowJobTemplateWithSpecSerializer(WorkflowJobTemplateSerializer): ''' @@ -3356,13 +3375,15 @@ class Meta: class WorkflowJobSerializer(LabelsListMixin, UnifiedJobSerializer): + limit = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None) + scm_branch = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None) class Meta: model = WorkflowJob fields = ('*', 'workflow_job_template', 'extra_vars', 'allow_simultaneous', 'job_template', 'is_sliced_job', '-execution_node', '-event_processing_finished', '-controller_node', - 'inventory',) + 'inventory', 'limit', 'scm_branch',) def get_related(self, obj): res = super(WorkflowJobSerializer, self).get_related(obj) @@ -3596,7 +3617,7 @@ def validate(self, attrs): if errors: raise serializers.ValidationError(errors) - # Model `.save` needs the container dict, not the psuedo fields + # Model `.save` needs the container dict, not the pseudo fields if mock_obj.char_prompts: attrs['char_prompts'] = mock_obj.char_prompts @@ -4180,12 +4201,16 @@ class WorkflowJobLaunchSerializer(BaseSerializer): queryset=Inventory.objects.all(), required=False, write_only=True ) + limit = serializers.CharField(required=False, write_only=True, allow_blank=True) + scm_branch = serializers.CharField(required=False, write_only=True, allow_blank=True) workflow_job_template_data = serializers.SerializerMethodField() class Meta: model = WorkflowJobTemplate - fields = ('ask_inventory_on_launch', 'can_start_without_user_input', 'defaults', 'extra_vars', - 'inventory', 'survey_enabled', 'variables_needed_to_start', + fields = ('ask_inventory_on_launch', 'ask_limit_on_launch', 'ask_scm_branch_on_launch', + 'can_start_without_user_input', 'defaults', 'extra_vars', + 'inventory', 'limit', 'scm_branch', + 'survey_enabled', 'variables_needed_to_start', 'node_templates_missing', 'node_prompts_rejected', 'workflow_job_template_data', 'survey_enabled', 'ask_variables_on_launch') read_only_fields = ('ask_inventory_on_launch', 'ask_variables_on_launch') @@ -4225,9 +4250,14 @@ def validate(self, attrs): WFJT_extra_vars = template.extra_vars WFJT_inventory = template.inventory + WFJT_limit = template.limit + WFJT_scm_branch = template.scm_branch super(WorkflowJobLaunchSerializer, self).validate(attrs) template.extra_vars = WFJT_extra_vars template.inventory = WFJT_inventory + template.limit = WFJT_limit + template.scm_branch = WFJT_scm_branch + return accepted diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 9302249e670a..d77ec92b9174 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -3111,6 +3111,17 @@ def get(self, request, *args, **kwargs): data.update(messages) return Response(data) + def _build_create_dict(self, obj): + """Special processing of fields managed by char_prompts + """ + r = super(WorkflowJobTemplateCopy, self)._build_create_dict(obj) + field_names = set(f.name for f in obj._meta.get_fields()) + for field_name, ask_field_name in obj.get_ask_mapping().items(): + if field_name in r and field_name not in field_names: + r.setdefault('char_prompts', {}) + r['char_prompts'][field_name] = r.pop(field_name) + return r + @staticmethod def deep_copy_permission_check_func(user, new_objs): for obj in new_objs: @@ -3139,7 +3150,6 @@ class WorkflowJobTemplateLabelList(JobTemplateLabelList): class WorkflowJobTemplateLaunch(RetrieveAPIView): - model = models.WorkflowJobTemplate obj_permission_type = 'start' serializer_class = serializers.WorkflowJobLaunchSerializer @@ -3156,10 +3166,15 @@ def update_raw_data(self, data): extra_vars.setdefault(v, u'') if extra_vars: data['extra_vars'] = extra_vars - if obj.ask_inventory_on_launch: - data['inventory'] = obj.inventory_id - else: - data.pop('inventory', None) + modified_ask_mapping = models.WorkflowJobTemplate.get_ask_mapping() + modified_ask_mapping.pop('extra_vars') + for field_name, ask_field_name in obj.get_ask_mapping().items(): + if not getattr(obj, ask_field_name): + data.pop(field_name, None) + elif field_name == 'inventory': + data[field_name] = getattrd(obj, "%s.%s" % (field_name, 'id'), None) + else: + data[field_name] = getattr(obj, field_name) return data def post(self, request, *args, **kwargs): diff --git a/awx/main/migrations/0090_v360_WFJT_prompts.py b/awx/main/migrations/0090_v360_WFJT_prompts.py new file mode 100644 index 000000000000..1fa317e71b6e --- /dev/null +++ b/awx/main/migrations/0090_v360_WFJT_prompts.py @@ -0,0 +1,59 @@ +# Generated by Django 2.2.2 on 2019-07-23 17:56 + +import awx.main.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0089_v360_new_job_event_types'), + ] + + operations = [ + migrations.AddField( + model_name='workflowjobtemplate', + name='ask_limit_on_launch', + field=awx.main.fields.AskForField(blank=True, default=False), + ), + migrations.AddField( + model_name='workflowjobtemplate', + name='ask_scm_branch_on_launch', + field=awx.main.fields.AskForField(blank=True, default=False), + ), + migrations.AddField( + model_name='workflowjobtemplate', + name='char_prompts', + field=awx.main.fields.JSONField(blank=True, default=dict), + ), + migrations.AlterField( + model_name='joblaunchconfig', + name='inventory', + field=models.ForeignKey(blank=True, default=None, help_text='Inventory applied as a prompt, assuming job template prompts for inventory', null=True, on_delete=models.deletion.SET_NULL, related_name='joblaunchconfigs', to='main.Inventory'), + ), + migrations.AlterField( + model_name='schedule', + name='inventory', + field=models.ForeignKey(blank=True, default=None, help_text='Inventory applied as a prompt, assuming job template prompts for inventory', null=True, on_delete=models.deletion.SET_NULL, related_name='schedules', to='main.Inventory'), + ), + migrations.AlterField( + model_name='workflowjob', + name='inventory', + field=models.ForeignKey(blank=True, default=None, help_text='Inventory applied as a prompt, assuming job template prompts for inventory', null=True, on_delete=models.deletion.SET_NULL, related_name='workflowjobs', to='main.Inventory'), + ), + migrations.AlterField( + model_name='workflowjobnode', + name='inventory', + field=models.ForeignKey(blank=True, default=None, help_text='Inventory applied as a prompt, assuming job template prompts for inventory', null=True, on_delete=models.deletion.SET_NULL, related_name='workflowjobnodes', to='main.Inventory'), + ), + migrations.AlterField( + model_name='workflowjobtemplate', + name='inventory', + field=models.ForeignKey(blank=True, default=None, help_text='Inventory applied as a prompt, assuming job template prompts for inventory', null=True, on_delete=models.deletion.SET_NULL, related_name='workflowjobtemplates', to='main.Inventory'), + ), + migrations.AlterField( + model_name='workflowjobtemplatenode', + name='inventory', + field=models.ForeignKey(blank=True, default=None, help_text='Inventory applied as a prompt, assuming job template prompts for inventory', null=True, on_delete=models.deletion.SET_NULL, related_name='workflowjobtemplatenodes', to='main.Inventory'), + ), + ] diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 8e5f1733ee68..7618b36eb3ef 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -1501,7 +1501,7 @@ def _get_unified_job_class(cls): @classmethod def _get_unified_job_field_names(cls): return set(f.name for f in InventorySourceOptions._meta.fields) | set( - ['name', 'description', 'schedule', 'credentials', 'inventory'] + ['name', 'description', 'credentials', 'inventory'] ) def save(self, *args, **kwargs): diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 4986d6f71784..a6395c495b12 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -39,7 +39,7 @@ NotificationTemplate, JobNotificationMixin, ) -from awx.main.utils import parse_yaml_or_json, getattr_dne +from awx.main.utils import parse_yaml_or_json, getattr_dne, NullablePromptPseudoField from awx.main.fields import ImplicitRoleField, JSONField, AskForField from awx.main.models.mixins import ( ResourceMixin, @@ -271,7 +271,7 @@ def _get_unified_job_class(cls): @classmethod def _get_unified_job_field_names(cls): return set(f.name for f in JobOptions._meta.fields) | set( - ['name', 'description', 'schedule', 'survey_passwords', 'labels', 'credentials', + ['name', 'description', 'survey_passwords', 'labels', 'credentials', 'job_slice_number', 'job_slice_count'] ) @@ -839,25 +839,6 @@ def finish_job_fact_cache(self, destination, modification_times): host.save() -# Add on aliases for the non-related-model fields -class NullablePromptPsuedoField(object): - """ - Interface for psuedo-property stored in `char_prompts` dict - Used in LaunchTimeConfig and submodels - """ - def __init__(self, field_name): - self.field_name = field_name - - def __get__(self, instance, type=None): - return instance.char_prompts.get(self.field_name, None) - - def __set__(self, instance, value): - if value in (None, {}): - instance.char_prompts.pop(self.field_name, None) - else: - instance.char_prompts[self.field_name] = value - - class LaunchTimeConfigBase(BaseModel): ''' Needed as separate class from LaunchTimeConfig because some models @@ -878,6 +859,7 @@ class Meta: null=True, default=None, on_delete=models.SET_NULL, + help_text=_('Inventory applied as a prompt, assuming job template prompts for inventory') ) # All standard fields are stored in this dictionary field # This is a solution to the nullable CharField problem, specific to prompting @@ -914,21 +896,14 @@ def prompts_dict(self, display=False): data[prompt_name] = prompt_val return data - def display_extra_vars(self): - ''' - Hides fields marked as passwords in survey. - ''' - if self.survey_passwords: - extra_vars = parse_yaml_or_json(self.extra_vars).copy() - for key, value in self.survey_passwords.items(): - if key in extra_vars: - extra_vars[key] = value - return extra_vars - else: - return self.extra_vars - def display_extra_data(self): - return self.display_extra_vars() +for field_name in JobTemplate.get_ask_mapping().keys(): + if field_name == 'extra_vars': + continue + try: + LaunchTimeConfigBase._meta.get_field(field_name) + except FieldDoesNotExist: + setattr(LaunchTimeConfigBase, field_name, NullablePromptPseudoField(field_name)) class LaunchTimeConfig(LaunchTimeConfigBase): @@ -963,14 +938,21 @@ def extra_vars(self): def extra_vars(self, extra_vars): self.extra_data = extra_vars + def display_extra_vars(self): + ''' + Hides fields marked as passwords in survey. + ''' + if hasattr(self, 'survey_passwords') and self.survey_passwords: + extra_vars = parse_yaml_or_json(self.extra_vars).copy() + for key, value in self.survey_passwords.items(): + if key in extra_vars: + extra_vars[key] = value + return extra_vars + else: + return self.extra_vars -for field_name in JobTemplate.get_ask_mapping().keys(): - if field_name == 'extra_vars': - continue - try: - LaunchTimeConfig._meta.get_field(field_name) - except FieldDoesNotExist: - setattr(LaunchTimeConfig, field_name, NullablePromptPsuedoField(field_name)) + def display_extra_data(self): + return self.display_extra_vars() class JobLaunchConfig(LaunchTimeConfig): diff --git a/awx/main/models/projects.py b/awx/main/models/projects.py index afd61e8faa10..b7b52dcf6bd8 100644 --- a/awx/main/models/projects.py +++ b/awx/main/models/projects.py @@ -329,7 +329,7 @@ def _get_unified_job_class(cls): @classmethod def _get_unified_job_field_names(cls): return set(f.name for f in ProjectOptions._meta.fields) | set( - ['name', 'description', 'schedule'] + ['name', 'description'] ) def save(self, *args, **kwargs): diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 10df35e561f6..b2312ab63df7 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -19,7 +19,7 @@ NotificationTemplate, JobNotificationMixin ) -from awx.main.models.base import BaseModel, CreatedModifiedModel, VarsDictProperty +from awx.main.models.base import CreatedModifiedModel, VarsDictProperty from awx.main.models.rbac import ( ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, ROLE_SINGLETON_SYSTEM_AUDITOR @@ -207,11 +207,14 @@ def get_absolute_url(self, request=None): def prompts_dict(self, *args, **kwargs): r = super(WorkflowJobNode, self).prompts_dict(*args, **kwargs) # Explanation - WFJT extra_vars still break pattern, so they are not - # put through prompts processing, but inventory is only accepted + # put through prompts processing, but inventory and others are only accepted # if JT prompts for it, so it goes through this mechanism - if self.workflow_job and self.workflow_job.inventory_id: - # workflow job inventory takes precedence - r['inventory'] = self.workflow_job.inventory + if self.workflow_job: + if self.workflow_job.inventory_id: + # workflow job inventory takes precedence + r['inventory'] = self.workflow_job.inventory + if self.workflow_job.char_prompts: + r.update(self.workflow_job.char_prompts) return r def get_job_kwargs(self): @@ -298,7 +301,7 @@ def get_job_kwargs(self): return data -class WorkflowJobOptions(BaseModel): +class WorkflowJobOptions(LaunchTimeConfigBase): class Meta: abstract = True @@ -318,10 +321,11 @@ def workflow_nodes(self): @classmethod def _get_unified_job_field_names(cls): - return set(f.name for f in WorkflowJobOptions._meta.fields) | set( - # NOTE: if other prompts are added to WFJT, put fields in WJOptions, remove inventory - ['name', 'description', 'schedule', 'survey_passwords', 'labels', 'inventory'] + r = set(f.name for f in WorkflowJobOptions._meta.fields) | set( + ['name', 'description', 'survey_passwords', 'labels', 'limit', 'scm_branch'] ) + r.remove('char_prompts') # needed due to copying launch config to launch config + return r def _create_workflow_nodes(self, old_node_list, user=None): node_links = {} @@ -372,16 +376,15 @@ class Meta: on_delete=models.SET_NULL, related_name='workflows', ) - inventory = models.ForeignKey( - 'Inventory', - related_name='%(class)ss', + ask_inventory_on_launch = AskForField( blank=True, - null=True, - default=None, - on_delete=models.SET_NULL, - help_text=_('Inventory applied to all job templates in workflow that prompt for inventory.'), + default=False, ) - ask_inventory_on_launch = AskForField( + ask_limit_on_launch = AskForField( + blank=True, + default=False, + ) + ask_scm_branch_on_launch = AskForField( blank=True, default=False, ) @@ -515,7 +518,7 @@ def _get_related_jobs(self): return WorkflowJob.objects.filter(workflow_job_template=self) -class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificationMixin, LaunchTimeConfigBase): +class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificationMixin): class Meta: app_label = 'main' ordering = ('id',) diff --git a/awx/main/tasks.py b/awx/main/tasks.py index cccf2b4fd784..a9308df43268 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -2235,7 +2235,7 @@ def build_args(self, inventory_update, private_data_dir, passwords): getattr(settings, '%s_INSTANCE_ID_VAR' % src.upper()),]) # Add arguments for the source inventory script args.append('--source') - args.append(self.psuedo_build_inventory(inventory_update, private_data_dir)) + args.append(self.pseudo_build_inventory(inventory_update, private_data_dir)) if src == 'custom': args.append("--custom") args.append('-v%d' % inventory_update.verbosity) @@ -2246,7 +2246,7 @@ def build_args(self, inventory_update, private_data_dir, passwords): def build_inventory(self, inventory_update, private_data_dir): return None # what runner expects in order to not deal with inventory - def psuedo_build_inventory(self, inventory_update, private_data_dir): + def pseudo_build_inventory(self, inventory_update, private_data_dir): """Inventory imports are ran through a management command we pass the inventory in args to that command, so this is not considered to be "Ansible" inventory (by runner) even though it is diff --git a/awx/main/tests/functional/models/test_workflow.py b/awx/main/tests/functional/models/test_workflow.py index cc4c42ecfe1e..f4daf7d57870 100644 --- a/awx/main/tests/functional/models/test_workflow.py +++ b/awx/main/tests/functional/models/test_workflow.py @@ -1,6 +1,8 @@ # Python import pytest +from unittest import mock +import json # AWX from awx.main.models.workflow import ( @@ -248,7 +250,6 @@ def test_topology_validator(self, wfjt): test_view = WorkflowJobTemplateNodeSuccessNodesList() nodes = wfjt.workflow_job_template_nodes.all() # test cycle validation - print(nodes[0].success_nodes.get(id=nodes[1].id).failure_nodes.get(id=nodes[2].id)) assert test_view.is_valid_relation(nodes[2], nodes[0]) == {'Error': 'Cycle detected.'} def test_always_success_failure_creation(self, wfjt, admin, get): @@ -270,6 +271,103 @@ def test_wfjt_unique_together_with_org(self, organization): wfjt2.validate_unique() +@pytest.mark.django_db +class TestWorkflowJobTemplatePrompts: + """These are tests for prompts that live on the workflow job template model + not the node, prompts apply for entire workflow + """ + @pytest.fixture + def wfjt_prompts(self): + return WorkflowJobTemplate.objects.create( + ask_inventory_on_launch=True, + ask_variables_on_launch=True, + ask_limit_on_launch=True, + ask_scm_branch_on_launch=True + ) + + @pytest.fixture + def prompts_data(self, inventory): + return dict( + inventory=inventory, + extra_vars={'foo': 'bar'}, + limit='webservers', + scm_branch='release-3.3' + ) + + def test_apply_workflow_job_prompts(self, workflow_job_template, wfjt_prompts, prompts_data, inventory): + # null or empty fields used + workflow_job = workflow_job_template.create_unified_job() + assert workflow_job.limit is None + assert workflow_job.inventory is None + assert workflow_job.scm_branch is None + + # fields from prompts used + workflow_job = workflow_job_template.create_unified_job(**prompts_data) + assert json.loads(workflow_job.extra_vars) == {'foo': 'bar'} + assert workflow_job.limit == 'webservers' + assert workflow_job.inventory == inventory + assert workflow_job.scm_branch == 'release-3.3' + + # non-null fields from WFJT used + workflow_job_template.inventory = inventory + workflow_job_template.limit = 'fooo' + workflow_job_template.scm_branch = 'bar' + workflow_job = workflow_job_template.create_unified_job() + assert workflow_job.limit == 'fooo' + assert workflow_job.inventory == inventory + assert workflow_job.scm_branch == 'bar' + + + @pytest.mark.django_db + def test_process_workflow_job_prompts(self, inventory, workflow_job_template, wfjt_prompts, prompts_data): + accepted, rejected, errors = workflow_job_template._accept_or_ignore_job_kwargs(**prompts_data) + assert accepted == {} + assert rejected == prompts_data + assert errors + accepted, rejected, errors = wfjt_prompts._accept_or_ignore_job_kwargs(**prompts_data) + assert accepted == prompts_data + assert rejected == {} + assert not errors + + + @pytest.mark.django_db + def test_set_all_the_prompts(self, post, organization, inventory, org_admin): + r = post( + url = reverse('api:workflow_job_template_list'), + data = dict( + name='My new workflow', + organization=organization.id, + inventory=inventory.id, + limit='foooo', + ask_limit_on_launch=True, + scm_branch='bar', + ask_scm_branch_on_launch=True + ), + user = org_admin, + expect = 201 + ) + wfjt = WorkflowJobTemplate.objects.get(id=r.data['id']) + assert wfjt.char_prompts == { + 'limit': 'foooo', 'scm_branch': 'bar' + } + assert wfjt.ask_scm_branch_on_launch is True + assert wfjt.ask_limit_on_launch is True + + launch_url = r.data['related']['launch'] + with mock.patch('awx.main.queue.CallbackQueueDispatcher.dispatch', lambda self, obj: None): + r = post( + url = launch_url, + data = dict( + scm_branch = 'prompt_branch', + limit = 'prompt_limit' + ), + user = org_admin, + expect=201 + ) + assert r.data['limit'] == 'prompt_limit' + assert r.data['scm_branch'] == 'prompt_branch' + + @pytest.mark.django_db def test_workflow_ancestors(organization): # Spawn order of templates grandparent -> parent -> child diff --git a/awx/main/tests/unit/models/test_workflow_unit.py b/awx/main/tests/unit/models/test_workflow_unit.py index f904cf3b957b..f101168a8ba4 100644 --- a/awx/main/tests/unit/models/test_workflow_unit.py +++ b/awx/main/tests/unit/models/test_workflow_unit.py @@ -3,7 +3,7 @@ from awx.main.models.jobs import JobTemplate from awx.main.models import Inventory, CredentialType, Credential, Project from awx.main.models.workflow import ( - WorkflowJobTemplate, WorkflowJobTemplateNode, WorkflowJobOptions, + WorkflowJobTemplate, WorkflowJobTemplateNode, WorkflowJob, WorkflowJobNode ) from unittest import mock @@ -33,11 +33,11 @@ def job_template_nodes(self, job_templates): def test__create_workflow_job_nodes(self, mocker, job_template_nodes): workflow_job_node_create = mocker.patch('awx.main.models.WorkflowJobTemplateNode.create_workflow_job_node') - mixin = WorkflowJobOptions() - mixin._create_workflow_nodes(job_template_nodes) + workflow_job = WorkflowJob() + workflow_job._create_workflow_nodes(job_template_nodes) for job_template_node in job_template_nodes: - workflow_job_node_create.assert_any_call(workflow_job=mixin) + workflow_job_node_create.assert_any_call(workflow_job=workflow_job) class TestMapWorkflowJobNodes(): @pytest.fixture @@ -236,4 +236,4 @@ def test_no_accepted_project_node_prompts(self, job_node_no_prompts, project_uni def test_get_ask_mapping_integrity(): - assert list(WorkflowJobTemplate.get_ask_mapping().keys()) == ['extra_vars', 'inventory'] + assert list(WorkflowJobTemplate.get_ask_mapping().keys()) == ['extra_vars', 'inventory', 'limit', 'scm_branch'] diff --git a/awx/main/utils/common.py b/awx/main/utils/common.py index cf3a511e28c6..d36dfa272bec 100644 --- a/awx/main/utils/common.py +++ b/awx/main/utils/common.py @@ -19,9 +19,14 @@ from decimal import Decimal # Django -from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ObjectDoesNotExist, FieldDoesNotExist from django.utils.translation import ugettext_lazy as _ +from django.utils.functional import cached_property from django.db.models.fields.related import ForeignObjectRel, ManyToManyField +from django.db.models.fields.related_descriptors import ( + ForwardManyToOneDescriptor, + ManyToManyDescriptor +) from django.db.models.query import QuerySet from django.db.models import Q @@ -42,7 +47,7 @@ 'get_current_apps', 'set_current_apps', 'extract_ansible_vars', 'get_search_fields', 'get_system_task_capacity', 'get_cpu_capacity', 'get_mem_capacity', 'wrap_args_with_proot', 'build_proot_temp_dir', 'check_proot_installed', 'model_to_dict', - 'model_instance_diff', 'parse_yaml_or_json', 'RequireDebugTrueOrTest', + 'NullablePromptPseudoField', 'model_instance_diff', 'parse_yaml_or_json', 'RequireDebugTrueOrTest', 'has_model_field_prefetched', 'set_environ', 'IllegalArgumentError', 'get_custom_venv_choices', 'get_external_account', 'task_manager_bulk_reschedule', 'schedule_task_manager', 'classproperty', 'create_temporary_fifo'] @@ -435,6 +440,39 @@ def model_to_dict(obj, serializer_mapping=None): return attr_d +class CharPromptDescriptor: + """Class used for identifying nullable launch config fields from class + ex. Schedule.limit + """ + def __init__(self, field): + self.field = field + + +class NullablePromptPseudoField: + """ + Interface for pseudo-property stored in `char_prompts` dict + Used in LaunchTimeConfig and submodels, defined here to avoid circular imports + """ + def __init__(self, field_name): + self.field_name = field_name + + @cached_property + def field_descriptor(self): + return CharPromptDescriptor(self) + + def __get__(self, instance, type=None): + if instance is None: + # for inspection on class itself + return self.field_descriptor + return instance.char_prompts.get(self.field_name, None) + + def __set__(self, instance, value): + if value in (None, {}): + instance.char_prompts.pop(self.field_name, None) + else: + instance.char_prompts[self.field_name] = value + + def copy_model_by_class(obj1, Class2, fields, kwargs): ''' Creates a new unsaved object of type Class2 using the fields from obj1 @@ -442,9 +480,10 @@ def copy_model_by_class(obj1, Class2, fields, kwargs): ''' create_kwargs = {} for field_name in fields: - # Foreign keys can be specified as field_name or field_name_id. - id_field_name = '%s_id' % field_name - if hasattr(obj1, id_field_name): + descriptor = getattr(Class2, field_name) + if isinstance(descriptor, ForwardManyToOneDescriptor): # ForeignKey + # Foreign keys can be specified as field_name or field_name_id. + id_field_name = '%s_id' % field_name if field_name in kwargs: value = kwargs[field_name] elif id_field_name in kwargs: @@ -454,15 +493,29 @@ def copy_model_by_class(obj1, Class2, fields, kwargs): if hasattr(value, 'id'): value = value.id create_kwargs[id_field_name] = value + elif isinstance(descriptor, CharPromptDescriptor): + # difficult case of copying one launch config to another launch config + new_val = None + if field_name in kwargs: + new_val = kwargs[field_name] + elif hasattr(obj1, 'char_prompts'): + if field_name in obj1.char_prompts: + new_val = obj1.char_prompts[field_name] + elif hasattr(obj1, field_name): + # extremely rare case where a template spawns a launch config - sliced jobs + new_val = getattr(obj1, field_name) + if new_val is not None: + create_kwargs.setdefault('char_prompts', {}) + create_kwargs['char_prompts'][field_name] = new_val + elif isinstance(descriptor, ManyToManyDescriptor): + continue # not copied in this method elif field_name in kwargs: if field_name == 'extra_vars' and isinstance(kwargs[field_name], dict): create_kwargs[field_name] = json.dumps(kwargs['extra_vars']) elif not isinstance(Class2._meta.get_field(field_name), (ForeignObjectRel, ManyToManyField)): create_kwargs[field_name] = kwargs[field_name] elif hasattr(obj1, field_name): - field_obj = obj1._meta.get_field(field_name) - if not isinstance(field_obj, ManyToManyField): - create_kwargs[field_name] = getattr(obj1, field_name) + create_kwargs[field_name] = getattr(obj1, field_name) # Apply class-specific extra processing for origination of unified jobs if hasattr(obj1, '_update_unified_job_kwargs') and obj1.__class__ != Class2: @@ -481,7 +534,10 @@ def copy_m2m_relationships(obj1, obj2, fields, kwargs=None): ''' for field_name in fields: if hasattr(obj1, field_name): - field_obj = obj1._meta.get_field(field_name) + try: + field_obj = obj1._meta.get_field(field_name) + except FieldDoesNotExist: + continue if isinstance(field_obj, ManyToManyField): # Many to Many can be specified as field_name src_field_value = getattr(obj1, field_name) diff --git a/awx/ui/client/lib/models/WorkflowJobTemplate.js b/awx/ui/client/lib/models/WorkflowJobTemplate.js index f171098eb833..06d9e9deccdc 100644 --- a/awx/ui/client/lib/models/WorkflowJobTemplate.js +++ b/awx/ui/client/lib/models/WorkflowJobTemplate.js @@ -58,8 +58,9 @@ function canLaunchWithoutPrompt () { launchData.can_start_without_user_input && !launchData.ask_inventory_on_launch && !launchData.ask_variables_on_launch && - !launchData.survey_enabled && + !launchData.ask_limit_on_launch && !launchData.ask_scm_branch_on_launch && + !launchData.survey_enabled && launchData.variables_needed_to_start.length === 0 ); } diff --git a/awx/ui/client/src/templates/workflows.form.js b/awx/ui/client/src/templates/workflows.form.js index 776e75da494c..9124233c7102 100644 --- a/awx/ui/client/src/templates/workflows.form.js +++ b/awx/ui/client/src/templates/workflows.form.js @@ -89,6 +89,34 @@ export default ['NotificationsList', 'i18n', function(NotificationsList, i18n) { }, ngDisabled: '!(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddOrEdit) || !canEditInventory', }, + limit: { + label: i18n._('Limit'), + type: 'text', + column: 1, + awPopOver: "
" + i18n._("Provide a host pattern to further constrain the list of hosts that will be managed or affected by the workflow. This limit is applied to all job template nodes that prompt for a limit. Refer to Ansible documentation for more information and examples on patterns.") + "
", + dataTitle: i18n._('Limit'), + dataPlacement: 'right', + dataContainer: "body", + subCheckbox: { + variable: 'ask_limit_on_launch', + text: i18n._('Prompt on launch') + }, + ngDisabled: '!(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddOrEdit) || !canEditInventory', + }, + scm_branch: { + label: i18n._('SCM Branch'), + type: 'text', + column: 1, + awPopOver: "" + i18n._("Select a branch for the workflow. This branch is applied to all job template nodes that prompt for a branch.") + "
", + dataTitle: i18n._('SCM Branch'), + dataPlacement: 'right', + dataContainer: "body", + subCheckbox: { + variable: 'ask_scm_branch_on_launch', + text: i18n._('Prompt on launch') + }, + ngDisabled: '!(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddOrEdit)', + }, labels: { label: i18n._('Labels'), type: 'select', diff --git a/awx/ui/client/src/templates/workflows/add-workflow/workflow-add.controller.js b/awx/ui/client/src/templates/workflows/add-workflow/workflow-add.controller.js index d46038d0f6d3..5dcb050dfbea 100644 --- a/awx/ui/client/src/templates/workflows/add-workflow/workflow-add.controller.js +++ b/awx/ui/client/src/templates/workflows/add-workflow/workflow-add.controller.js @@ -70,9 +70,11 @@ export default [ data[fld] = $scope[fld]; } } - + data.ask_inventory_on_launch = Boolean($scope.ask_inventory_on_launch); data.ask_variables_on_launch = Boolean($scope.ask_variables_on_launch); + data.ask_limit_on_launch = Boolean($scope.ask_limit_on_launch); + data.ask_scm_branch_on_launch = Boolean($scope.ask_scm_branch_on_launch); data.extra_vars = ToJSON($scope.parseType, $scope.variables, true); diff --git a/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js b/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js index 53039f640287..5a2ef48adb42 100644 --- a/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js +++ b/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js @@ -54,6 +54,8 @@ export default [ $scope.parseType = 'yaml'; $scope.includeWorkflowMaker = false; $scope.ask_inventory_on_launch = workflowJobTemplateData.ask_inventory_on_launch; + $scope.ask_limit_on_launch = workflowJobTemplateData.ask_limit_on_launch; + $scope.ask_scm_branch_on_launch = workflowJobTemplateData.ask_scm_branch_on_launch; $scope.ask_variables_on_launch = (workflowJobTemplateData.ask_variables_on_launch) ? true : false; if (Inventory){ @@ -91,6 +93,8 @@ export default [ } data.ask_inventory_on_launch = Boolean($scope.ask_inventory_on_launch); + data.ask_limit_on_launch = Boolean($scope.ask_limit_on_launch); + data.ask_scm_branch_on_launch = Boolean($scope.ask_scm_branch_on_launch); data.ask_variables_on_launch = Boolean($scope.ask_variables_on_launch); data.extra_vars = ToJSON($scope.parseType, diff --git a/awx/ui/client/src/workflow-results/workflow-results.controller.js b/awx/ui/client/src/workflow-results/workflow-results.controller.js index 13856a430ce8..871fa87a6632 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.controller.js +++ b/awx/ui/client/src/workflow-results/workflow-results.controller.js @@ -69,7 +69,9 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions', SLICE_TEMPLATE: i18n._('Slice Job Template'), JOB_EXPLANATION: i18n._('Explanation'), SOURCE_WORKFLOW_JOB: i18n._('Source Workflow'), - INVENTORY: i18n._('Inventory') + INVENTORY: i18n._('Inventory'), + LIMIT: i18n._('Inventory Limit'), + SCM_BRANCH: i18n._('SCM Branch') }, details: { HEADER: i18n._('DETAILS'), diff --git a/awx/ui/client/src/workflow-results/workflow-results.partial.html b/awx/ui/client/src/workflow-results/workflow-results.partial.html index 61ca63764155..635b69c60f9c 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.partial.html +++ b/awx/ui/client/src/workflow-results/workflow-results.partial.html @@ -140,6 +140,26 @@ + +