diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 66c06c3ce6..3ebbe55ecb 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 @@ class WorkflowJobTemplateSerializer(JobTemplateMixin, LabelsListMixin, UnifiedJo 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 psuedo fields + if mock_obj.char_prompts: + attrs['char_prompts'] = mock_obj.char_prompts + + return attrs + class WorkflowJobTemplateWithSpecSerializer(WorkflowJobTemplateSerializer): ''' @@ -3356,13 +3375,15 @@ class WorkflowJobTemplateWithSpecSerializer(WorkflowJobTemplateSerializer): 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) @@ -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 @@ class WorkflowJobLaunchSerializer(BaseSerializer): 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 9302249e67..d77ec92b91 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -3111,6 +3111,17 @@ class WorkflowJobTemplateCopy(CopyAPIView): 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 @@ class WorkflowJobTemplateLaunch(RetrieveAPIView): 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/0085_v360_WFJT_prompts.py b/awx/main/migrations/0085_v360_WFJT_prompts.py new file mode 100644 index 0000000000..7df324e9fa --- /dev/null +++ b/awx/main/migrations/0085_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', '0084_v360_token_description'), + ] + + 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 8e5f1733ee..7618b36eb3 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -1501,7 +1501,7 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions, CustomVirtualE @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 4986d6f717..058ef87515 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -39,7 +39,7 @@ from awx.main.models.notifications import ( NotificationTemplate, JobNotificationMixin, ) -from awx.main.utils import parse_yaml_or_json, getattr_dne +from awx.main.utils import parse_yaml_or_json, getattr_dne, NullablePromptPsuedoField from awx.main.fields import ImplicitRoleField, JSONField, AskForField from awx.main.models.mixins import ( ResourceMixin, @@ -271,7 +271,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour @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 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana 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 LaunchTimeConfigBase(BaseModel): 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 @@ -918,7 +900,7 @@ class LaunchTimeConfigBase(BaseModel): ''' Hides fields marked as passwords in survey. ''' - if self.survey_passwords: + 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: @@ -931,6 +913,15 @@ class LaunchTimeConfigBase(BaseModel): 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, NullablePromptPsuedoField(field_name)) + + class LaunchTimeConfig(LaunchTimeConfigBase): ''' Common model for all objects that save details of a saved launch config @@ -964,15 +955,6 @@ class LaunchTimeConfig(LaunchTimeConfigBase): self.extra_data = 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)) - - class JobLaunchConfig(LaunchTimeConfig): ''' Historical record of user launch-time overrides for a job diff --git a/awx/main/models/projects.py b/awx/main/models/projects.py index afd61e8faa..b7b52dcf6b 100644 --- a/awx/main/models/projects.py +++ b/awx/main/models/projects.py @@ -329,7 +329,7 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn @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 10df35e561..b2312ab63d 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -19,7 +19,7 @@ from awx.main.models.notifications import ( 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 @@ class WorkflowJobNode(WorkflowNodeBase): 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 @@ class WorkflowJobNode(WorkflowNodeBase): return data -class WorkflowJobOptions(BaseModel): +class WorkflowJobOptions(LaunchTimeConfigBase): class Meta: abstract = True @@ -318,10 +321,11 @@ class WorkflowJobOptions(BaseModel): @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,19 +376,18 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl on_delete=models.SET_NULL, related_name='workflows', ) - inventory = models.ForeignKey( - 'Inventory', - related_name='%(class)ss', - 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.'), - ) ask_inventory_on_launch = AskForField( blank=True, default=False, ) + ask_limit_on_launch = AskForField( + blank=True, + default=False, + ) + ask_scm_branch_on_launch = AskForField( + blank=True, + default=False, + ) admin_role = ImplicitRoleField(parent_role=[ 'singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, 'organization.workflow_admin_role' @@ -515,7 +518,7 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl 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/tests/functional/models/test_workflow.py b/awx/main/tests/functional/models/test_workflow.py index cc4c42ecfe..f4daf7d578 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 @@ class TestWorkflowJobTemplate: 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 @@ class TestWorkflowJobTemplate: 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 f904cf3b95..f101168a8b 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 @@ import pytest 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 @@ class TestWorkflowJobInheritNodesMixin(): 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 @@ class TestWorkflowJobNodeJobKWARGS: 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 cf3a511e28..6b76faa6f4 100644 --- a/awx/main/utils/common.py +++ b/awx/main/utils/common.py @@ -19,9 +19,14 @@ from functools import reduce, wraps 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 @@ __all__ = ['get_object_or_400', 'camelcase_to_underscore', 'underscore_to_camelc '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', + 'NullablePromptPsuedoField', '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 NullablePromptPsuedoField: + """ + Interface for psuedo-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 coppied 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/src/templates/workflows.form.js b/awx/ui/client/src/templates/workflows.form.js index 776e75da49..839abfd265 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._("Select a limit for the workflow. This limit is applied to all job template nodes that prompt for a limit.") + "
", + 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/edit-workflow/workflow-edit.controller.js b/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js index 53039f6402..5a2ef48adb 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 13856a430c..871fa87a66 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 61ca637641..635b69c60f 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 @@ + +