diff options
19 files changed, 389 insertions, 84 deletions
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,16 +376,15 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl 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 @@ 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): @@ -271,6 +272,103 @@ class TestWorkflowJobTemplate: @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 # create child WFJT and workflow job 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: "<p>" + i18n._("Select a limit for the workflow. This limit is applied to all job template nodes that prompt for a limit.") + "</p>", + 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: "<p>" + i18n._("Select a branch for the workflow. This branch is applied to all job template nodes that prompt for a branch.") + "</p>", + 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 @@ </div> </div> + <!-- LIMIT --> + <div class="WorkflowResults-resultRow" ng-show="workflow.limit"> + <label class="WorkflowResults-resultRowLabel"> + {{ strings.labels.LIMIT }} + </label> + <div class="WorkflowResults-resultRowText"> + {{ workflow.limit }} + </div> + </div> + + <!-- BRANCH --> + <div class="WorkflowResults-resultRow" ng-show="workflow.scm_branch"> + <label class="WorkflowResults-resultRowLabel"> + {{ strings.labels.SCM_BRANCH }} + </label> + <div class="WorkflowResults-resultRowText"> + {{ workflow.scm_branch }} + </div> + </div> + <!-- TEMPLATE DETAIL --> <div class="WorkflowResults-resultRow" ng-show="workflow.summary_fields.workflow_job_template.name"> diff --git a/awx/ui/test/spec/workflows/workflow-add.controller-test.js b/awx/ui/test/spec/workflows/workflow-add.controller-test.js index 543e223467..46cf24f730 100644 --- a/awx/ui/test/spec/workflows/workflow-add.controller-test.js +++ b/awx/ui/test/spec/workflows/workflow-add.controller-test.js @@ -144,6 +144,8 @@ describe('Controller: WorkflowAdd', () => { description: "This is a test description", organization: undefined, inventory: undefined, + limit: undefined, + scm_branch: undefined, labels: undefined, variables: undefined, allow_simultaneous: undefined, diff --git a/awxkit/awxkit/api/pages/workflow_job_template_nodes.py b/awxkit/awxkit/api/pages/workflow_job_template_nodes.py index 94928abe16..4a36968f54 100644 --- a/awxkit/awxkit/api/pages/workflow_job_template_nodes.py +++ b/awxkit/awxkit/api/pages/workflow_job_template_nodes.py @@ -25,6 +25,7 @@ class WorkflowJobTemplateNode(HasCreate, base.Base): 'diff_mode', 'extra_data', 'limit', + 'scm_branch', 'job_tags', 'job_type', 'skip_tags', diff --git a/awxkit/awxkit/api/pages/workflow_job_templates.py b/awxkit/awxkit/api/pages/workflow_job_templates.py index 4bfaba19a8..4bbee6778a 100644 --- a/awxkit/awxkit/api/pages/workflow_job_templates.py +++ b/awxkit/awxkit/api/pages/workflow_job_templates.py @@ -48,8 +48,9 @@ class WorkflowJobTemplate(HasCopy, HasCreate, HasNotifications, HasSurvey, Unifi if kwargs.get('inventory'): payload.inventory = kwargs.get('inventory').id - if kwargs.get('ask_inventory_on_launch'): - payload.ask_inventory_on_launch = kwargs.get('ask_inventory_on_launch') + for field_name in ('ask_inventory_on_launch', 'limit', 'scm_branch', 'ask_scm_branch_on_launch'): + if field_name in kwargs: + setattr(payload, field_name, kwargs.get(field_name)) return payload diff --git a/docs/prompting.md b/docs/prompting.md index ebb9c89d8f..926788dcea 100644 --- a/docs/prompting.md +++ b/docs/prompting.md @@ -59,7 +59,7 @@ actions in the API. - POST to `/api/v2/job_templates/N/launch/` - can accept all prompt-able fields - POST to `/api/v2/workflow_job_templates/N/launch/` - - can accept extra_vars and inventory + - can accept certain fields, see `workflow.md` - POST to `/api/v2/system_job_templates/N/launch/` - can accept certain fields, with no user configuration @@ -174,7 +174,7 @@ job. If a user creates a node that would do this, a 400 response will be returne Workflow JTs are different than other cases, because they do not have a template directly linked, so their prompts are a form of action-at-a-distance. -When the node's prompts are gathered, any prompts from the workflow job +When the node's prompts are gathered to spawn its job, any prompts from the workflow job will take precedence over the node's value. As a special exception, `extra_vars` from a workflow will not obey JT survey @@ -182,8 +182,7 @@ and prompting rules, both both historical and ease-of-understanding reasons. This behavior may change in the future. Other than that exception, JT prompting rules are still adhered to when -a job is spawned, although so far this only applies to the workflow job's -`inventory` field. +a job is spawned. #### Job Relaunch and Re-scheduling diff --git a/docs/workflow.md b/docs/workflow.md index 3ccfb7d367..e01acf2e76 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -8,7 +8,7 @@ A workflow has an associated tree-graph that is composed of multiple nodes. Each ### Workflow Create-Read-Update-Delete (CRUD) Like other job resources, workflow jobs are created from workflow job templates. The API exposes common fields similar to job templates, including labels, schedules, notification templates, extra variables and survey specifications. Other than that, in the API, the related workflow graph nodes can be gotten to via the related workflow_nodes field. -The CRUD operations against a workflow job template and its corresponding workflow jobs are almost identical to those of normal job templates and related jobs. However, from an RBAC perspective, CRUD on workflow job templates/jobs are limited to super users. +The CRUD operations against a workflow job template and its corresponding workflow jobs are almost identical to those of normal job templates and related jobs. By default, organization administrators have full control over all workflow job templates under the same organization, and they share these abilities with users who have the `workflow_admin_role` in that organization. Permissions can be further delegated to other users via the workflow job template roles. @@ -20,7 +20,12 @@ Workflow job template nodes are listed and created under endpoint `/workflow_job #### Workflow Launch Configuration Workflow job templates can contain launch configuration items. So far, these only include -`extra_vars` and `inventory`, and the `extra_vars` may have specifications via + - `extra_vars` + - `inventory` + - `limit` + - `scm_branch` + +The `extra_vars` field may have specifications via a survey, in the same way that job templates work. Workflow nodes may also contain the launch-time configuration for the job it will spawn. |