Implement WFJT prompting for limit & scm_branch

add feature to UI and awxkit

restructure some details of create_unified_job
  for workflows to allow use of char_prompts
  hidden field
avoid conflict with sliced jobs in char_prompts copy logic

update developer docs

update migration reference

bump migration
This commit is contained in:
AlanCoding 2019-07-23 16:01:42 -04:00
parent d3b413c125
commit e8581f6892
No known key found for this signature in database
GPG Key ID: FD2C3C012A72926B
19 changed files with 390 additions and 85 deletions

View File

@ -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

View File

@ -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):

View File

@ -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'),
),
]

View File

@ -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):

View File

@ -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

View File

@ -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):

View File

@ -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',)

View File

@ -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

View File

@ -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']

View File

@ -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)

View File

@ -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',

View File

@ -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,

View File

@ -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'),

View File

@ -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">

View File

@ -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,

View File

@ -25,6 +25,7 @@ class WorkflowJobTemplateNode(HasCreate, base.Base):
'diff_mode',
'extra_data',
'limit',
'scm_branch',
'job_tags',
'job_type',
'skip_tags',

View File

@ -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

View File

@ -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

View File

@ -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.