Repository: ansible/tower-cli
Pull request #253 (2017-03-02 14:52:39)

Add the ability to manage workflows
We have basic CRUD options, but workflows nodes have this tricky property that we can only truly address them by their pk value, and nothing else is unique.

To cope with this, I have introduced the workflow `schema` command to obtain both name-based referencing and idempotence. The following is a idempotent schema creation.

```
$ tower-cli workflow schema sch_test @wf_schema.yml
- job_template: 58
  success_nodes:
  - project: 40
```


Patch:
diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md
new file mode 100644
index 0000000..6fe5c71
--- /dev/null
+++ b/docs/WORKFLOWS.md
@@ -0,0 +1,187 @@
+These docs show how to populate an example workflow in Tower
+and how to automate the creation or copying of workflows.
+
+## Normal CRUD
+
+Workflows and workflow nodes can be managed as normal tower-cli resources.
+
+### Workflow Creation
+
+Create an empty workflow:
+```
+tower-cli workflow create --name="workflow1" --organization="Default" --description="example description"
+```
+
+Check the existing workflows with the standard list or get commands.
+```
+tower-cli workflow list --description-on
+```
+
+### Node Creation
+
+Create nodes inside the workflow
+These all become "root" nodes and will spawn jobs on workflow launch
+without any dependencies on other nodes.
+These commands create 2 root nodes.
+```
+tower-cli node create -W workflow1 --job-template="Hello World"
+tower-cli node create -W workflow1 --job-template="Hello World"
+```
+
+List the nodes inside of the workflow
+```
+tower-cli node list -W workflow1
+```
+
+### Node Association
+
+From the list command, you can find the ids of nodes you want to link
+`assocate_success_node` will cause the 2nd node to run on success of the
+first node. The following command causes node 2 to run on the event of
+successful completion of node 1.
+```
+tower-cli node assocate_success_node 1 2
+```
+
+This operation is only possible when node 2 is a root node.
+See the Tower documentation for the limitations on the types of node
+connections allowed.
+
+Auto-creation of the success node, only knowing the parent node id:
+```
+tower-cli node assocate_success_node 8 --job-template="Hello world"
+```
+
+Corresponding disassociate commands are also available. Disassociating
+a node from another node will make it a root node.
+
+## Node Network Bulk Management
+
+To print out a YAML representation of the nodes of a workflow, you can
+use the following command. JSON format is also allowed.
+
+```
+tower-cli workflow schema workflow1
+```
+
+Here, "workflow1" is the name of the workflow.
+
+### Creating a Schema Definition
+
+To bulk-create a workflow node network, use the workflow schema command.
+The schema is JSON or YAML content, and can be passed in the CLI
+argument, or pointed to a file. The schema is passed as a second positional
+argument, where the first argument references the workflow.
+
+```
+tower-cli workflow schema workflow2 @schema.yml
+```
+
+The schema can be provided without using a file:
+
+```
+tower-cli workflow schema workflow2 '[{"job_template": "Hello world"}]'
+```
+
+The Schema definition defines the hierarchy structure that connects the nodes.
+Names, as well as other valid parameters for node creation, are acceptable
+inside of the node's entry inside the schema definition.
+
+Links must be declared as a list under a key that starts with "success",
+"failure", or "always". The following is an example of a valid schema
+definition.
+
+Example `schema.yml` file:
+
+```yaml
+- job_template: Hello world
+  failure:
+  - inventory_source: AWS servers (AWS servers - 42)
+  success:
+  - project: Ansible Examples
+    always:
+    - job_template: Echo variable
+      success:
+      - job_template: Scan localhost
+
+```
+
+This style may be useful to apply in a script to create a workflow
+network with a tower-cli command after the constituent resources like
+the job templates and projects were created by preceding
+tower-cli commands.
+
+### Differences with Machine Formatted Schemas
+
+After writing a workflow schema, you may notice differences
+in how tower-cli subsequently echos the schema definition back to you.
+The following is what tower-cli might return after
+writing the above example.
+
+```yaml
+- failure_nodes:
+  - inventory_source: 42
+  job_template: 45
+  success_nodes:
+  - always_nodes:
+    - job_template: 55
+      success_nodes:
+      - job_template: 44
+    project: 40
+
+```
+
+Note that the root node data starts with "failure_nodes", instead
+of the name of the job template. This will not impact functionality, and
+manually changing the order will not impact functionality either.
+
+Although this format is harder to read, it does the same thing
+as the previous schema. The ability to both echo and create schemas can
+be used to copy the contents of one workflow to another.
+
+As an example, consider 2 workflows. The first has a name "workflow1", and
+has its node network populated. The second is named "workflow2" and is empty.
+The following commands will copy the structure from the first to the second.
+
+```bash
+tower-cli workflow schema workflow1 > schema.yml
+tower-cli workflow schema workflow2 @schema.yml
+```
+
+### Idempotence
+
+The workflow schema feature populates the workflow node network based on the
+hierarchy structure. Before creating each node, it attempts to find an
+existing node with the specified properties in that location in the
+tree, and will not create a new node if it exists.
+
+Thus, running the command multiple times should not change the workflow
+structure. To continue with the previous example, subsequent
+invocations of:
+
+```bash
+tower-cli workflow schema workflow2 @schema.yml
+tower-cli workflow schema workflow2 @schema.yml
+```
+
+should not change the network of workflow2.
+
+The schema command can not delete nodes, so running multiple commands with
+different schemas will result in creating multiple branches and/or
+sub-branches.
+
+## Launching Workflow Jobs
+
+Use the workflow_job resource to launch workflow jobs.
+This supports the use of extra_vars, which can contain answers to
+survey questions.
+A `--wait` flag is available to poll the server until workflow job
+reaches a completed status. Here is an example of using those features:
+
+```
+tower-cli workflow_job launch -W "echo Hello World" -e a=1 --wait
+```
+
+The `--wait` function will display temporary placeholder text.
+Tower-CLI does not yet offer any streaming updates of events within the
+workflow job.
diff --git a/tower_cli/models/base.py b/tower_cli/models/base.py
index d6b515d..24ecc6e 100644
--- a/tower_cli/models/base.py
+++ b/tower_cli/models/base.py
@@ -591,8 +591,10 @@ class ResourceMethods(BaseResource):
         # Sanity check: Are we missing required values?
         # If we don't have a primary key, then all required values must be
         # set, and if they're not, it's an error.
-        required_fields = [i.key or i.name for i in self.fields if i.required]
-        missing_fields = [i for i in required_fields if i not in kwargs]
+        missing_fields = []
+        for i in self.fields:
+            if i.key not in kwargs and i.name not in kwargs and i.required:
+                missing_fields.append(i.key or i.name)
         if missing_fields and not pk:
             raise exc.BadRequest('Missing required fields: %s' %
                                  ', '.join(missing_fields).replace('_', '-'))
@@ -799,6 +801,10 @@ class ResourceMethods(BaseResource):
             if field_name in kwargs:
                 read_params[field_name] = kwargs[field_name]
 
+        # Special case of resources that only only addressable by id
+        if 'id' in self.identity and len(self.identity) == 1:
+            return {}
+
         # Sanity check: Do we have any parameters?
         # If not, then there's no way for us to do this read.
         if not read_params:
diff --git a/tower_cli/resources/node.py b/tower_cli/resources/node.py
new file mode 100644
index 0000000..aab05a1
--- /dev/null
+++ b/tower_cli/resources/node.py
@@ -0,0 +1,159 @@
+# Copyright 2016, Ansible by Red Hat
+# Alan Rominger <arominge@redhat.com>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import absolute_import, unicode_literals
+
+from tower_cli import models, resources
+from tower_cli.utils import types
+from tower_cli.utils.resource_decorators import unified_job_template_options
+from tower_cli.utils import exceptions, debug
+from tower_cli.api import client
+
+import click
+
+
+NODE_STANDARD_FIELDS = [
+    'unified_job_template', 'inventory', 'credential', 'job_type',
+    'job_tags', 'skip_tags', 'limit'
+]
+JOB_TYPES = {
+    'job': 'job_template',
+    'project_update': 'project',
+    'inventory_update': 'inventory_source'
+}
+
+
+class Resource(models.Resource):
+    cli_help = 'Manage nodes inside of a workflow job template.'
+    endpoint = '/workflow_job_template_nodes/'
+    identity = ('id',)
+
+    workflow_job_template = models.Field(
+        key='-W', type=types.Related('workflow'))
+    unified_job_template = models.Field(required=False)
+    inventory = models.Field(
+        type=types.Related('inventory'), required=False, display=False)
+    credential = models.Field(
+        type=types.Related('credential'), required=False, display=False)
+    job_type = models.Field(required=False, display=False)
+    job_tags = models.Field(required=False, display=False)
+    skip_tags = models.Field(required=False, display=False)
+    limit = models.Field(required=False, display=False)
+
+    def __new__(cls, *args, **kwargs):
+        for attr in ['create', 'modify', 'list']:
+            setattr(cls, attr,
+                    unified_job_template_options(getattr(cls, attr)))
+        return super(Resource, cls).__new__(cls, *args, **kwargs)
+
+    @staticmethod
+    def _forward_rel_name(rel):
+        return '{0}_nodes'.format(rel)
+
+    @staticmethod
+    def _reverse_rel_name(rel):
+        return 'workflowjobtemplatenodes_{0}'.format(rel)
+
+    def _parent_filter(self, parent, relationship, **kwargs):
+        """
+        Returns filtering parameters to limit a search to the children
+        of a particular node by a particular relationship.
+        """
+        if parent is None or relationship is None:
+            return {}
+        parent_filter_kwargs = {}
+        query_params = ((self._reverse_rel_name(relationship), parent),)
+        parent_filter_kwargs['query'] = query_params
+        if kwargs.get('workflow_job_template', None) is None:
+            parent_data = self.read(pk=parent)['results'][0]
+            parent_filter_kwargs['workflow_job_template'] = parent_data[
+                'workflow_job_template']
+        return parent_filter_kwargs
+
+    @unified_job_template_options
+    def _get_or_create_child(self, parent, relationship, **kwargs):
+        ujt_pk = kwargs.get('unified_job_template', None)
+        if ujt_pk is None:
+            raise exceptions.BadRequest(
+                'A child node must be specified by one of the options '
+                'unified-job-template, job-template, project, or '
+                'inventory-source')
+        kwargs.update(self._parent_filter(parent, relationship, **kwargs))
+        response = self.read(
+            fail_on_no_results=False, fail_on_multiple_results=False, **kwargs)
+        if len(response['results']) == 0:
+            debug.log('Creating new workflow node.', header='details')
+            return client.post(self.endpoint, data=kwargs).json()
+        else:
+            return response['results'][0]
+
+    def _assoc_or_create(self, relationship, parent, child, **kwargs):
+        if child is None:
+            child_data = self._get_or_create_child(
+                parent, relationship, **kwargs)
+            return child_data
+        return self._assoc(self._forward_rel_name(relationship),
+                           parent, child)
+
+    @resources.command
+    @unified_job_template_options
+    @click.argument('parent', type=types.Related('node'))
+    @click.argument('child', type=types.Related('node'), required=False)
+    def associate_success_node(self, parent, child=None, **kwargs):
+        """Add a node to run on success."""
+        return self._assoc_or_create('success', parent, child, **kwargs)
+
+    @resources.command(use_fields_as_options=False)
+    @click.argument('parent', type=types.Related('node'))
+    @click.argument('child', type=types.Related('node'))
+    def disassociate_success_node(self, parent, child):
+        """Remove success node.
+        The resulatant 2 nodes will both become root nodes."""
+        return self._disassoc(
+            self._forward_rel_name('success'), parent, child)
+
+    @resources.command
+    @unified_job_template_options
+    @click.argument('parent', type=types.Related('node'))
+    @click.argument('child', type=types.Related('node'), required=False)
+    def associate_failure_node(self, parent, child=None, **kwargs):
+        """Add a node to run on failure."""
+        return self._assoc_or_create('failure', parent, child, **kwargs)
+
+    @resources.command(use_fields_as_options=False)
+    @click.argument('parent', type=types.Related('node'))
+    @click.argument('child', type=types.Related('node'))
+    def disassociate_failure_node(self, parent, child):
+        """Remove a failure node link.
+        The resulatant 2 nodes will both become root nodes."""
+        return self._disassoc(
+            self._forward_rel_name('failure'), parent, child)
+
+    @resources.command
+    @unified_job_template_options
+    @click.argument('parent', type=types.Related('node'))
+    @click.argument('child', type=types.Related('node'), required=False)
+    def associate_always_node(self, parent, child=None, **kwargs):
+        """Add a node to always run after the parent is finished."""
+        return self._assoc_or_create('always', parent, child, **kwargs)
+
+    @resources.command(use_fields_as_options=False)
+    @click.argument('parent', type=types.Related('node'))
+    @click.argument('child', type=types.Related('node'))
+    def disassociate_always_node(self, parent, child):
+        """Add a node to always run after the parent is finished.
+        The resulatant 2 nodes will both become root nodes."""
+        return self._disassoc(
+            self._forward_rel_name('always'), parent, child)
diff --git a/tower_cli/resources/role.py b/tower_cli/resources/role.py
index 7889209..82cb93c 100644
--- a/tower_cli/resources/role.py
+++ b/tower_cli/resources/role.py
@@ -33,7 +33,7 @@ RESOURCE_FIELDS = [
     'organization', 'project']
 
 ROLE_TYPES = [
-    'admin', 'read', 'member', 'execute', 'adhoc', 'update',
+    'admin', 'read', 'member', 'owner', 'execute', 'adhoc', 'update',
     'use', 'auditor']
 
 
diff --git a/tower_cli/resources/workflow.py b/tower_cli/resources/workflow.py
new file mode 100644
index 0000000..d51da8e
--- /dev/null
+++ b/tower_cli/resources/workflow.py
@@ -0,0 +1,179 @@
+# Copyright 2016, Ansible by Red Hat
+# Alan Rominger <arominge@redhat.com>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import absolute_import, unicode_literals
+
+from tower_cli import models, resources, get_resource
+from tower_cli.utils import types
+from tower_cli.utils.parser import string_to_dict
+from tower_cli.utils.exceptions import BadRequest
+from tower_cli.conf import settings
+from tower_cli.resources.node import NODE_STANDARD_FIELDS, JOB_TYPES
+
+import click
+
+
+class Resource(models.Resource):
+    cli_help = 'Manage workflow job templates.'
+    endpoint = '/workflow_job_templates/'
+
+    name = models.Field(unique=True)
+    description = models.Field(required=False, display=False)
+    extra_vars = models.Field(
+        type=types.Variables(), required=False, display=False,
+        help_text='Extra playbook variables to pass to jobs run inside this '
+                  'workflow, use "@" to get from file.')
+    organization = models.Field(type=types.Related('organization'),
+                                required=False)
+
+    @staticmethod
+    def _workflow_node_structure(node_results):
+        '''
+        Takes the list results from the API in `node_results` and
+        translates this data into a dictionary organized in a
+        human-readable heirarchial structure
+        '''
+        # Build list address translation, and create backlink lists
+        node_list_pos = {}
+        for i, node_result in enumerate(node_results):
+            for rel in ['success', 'failure', 'always']:
+                node_result['{0}_backlinks'.format(rel)] = []
+            node_list_pos[node_result['id']] = i
+
+        # Populate backlink lists
+        for node_result in node_results:
+            for rel in ['success', 'failure', 'always']:
+                for sub_node_id in node_result['{0}_nodes'.format(rel)]:
+                    j = node_list_pos[sub_node_id]
+                    node_results[j]['{0}_backlinks'.format(rel)].append(
+                        node_result['id'])
+
+        # Find the root nodes
+        root_nodes = []
+        for node_result in node_results:
+            is_root = True
+            for rel in ['success', 'failure', 'always']:
+                if node_result['{0}_backlinks'.format(rel)] != []:
+                    is_root = False
+                    break
+            if is_root:
+                root_nodes.append(node_result['id'])
+
+        # Create network dictionary recursively from root nodes
+        def branch_schema(node_id):
+            i = node_list_pos[node_id]
+            node_dict = node_results[i]
+            ret_dict = {}
+            for fd in NODE_STANDARD_FIELDS:
+                val = node_dict.get(fd, None)
+                if val is not None:
+                    if fd == 'unified_job_template':
+                        job_type = node_dict['summary_fields'][
+                            'unified_job_template']['unified_job_type']
+                        ujt_key = JOB_TYPES[job_type]
+                        ret_dict[ujt_key] = val
+                    else:
+                        ret_dict[fd] = val
+                for rel in ['success', 'failure', 'always']:
+                    sub_node_id_list = node_dict['{0}_nodes'.format(rel)]
+                    if len(sub_node_id_list) == 0:
+                        continue
+                    relationship_name = '{0}_nodes'.format(rel)
+                    ret_dict[relationship_name] = []
+                    for sub_node_id in sub_node_id_list:
+                        ret_dict[relationship_name].append(
+                            branch_schema(sub_node_id))
+            return ret_dict
+
+        schema_dict = []
+        for root_node_id in root_nodes:
+            schema_dict.append(branch_schema(root_node_id))
+        return schema_dict
+
+    def _get_schema(self, wfjt_id):
+        """
+        Returns a dictionary that represents the node network of the
+        workflow job template
+        """
+        node_res = get_resource('node')
+        node_results = node_res.list(workflow_job_template=wfjt_id,
+                                     all_pages=True)['results']
+        return self._workflow_node_structure(node_results)
+
+    @resources.command(use_fields_as_options=False)
+    @click.argument('wfjt', type=types.Related('workflow'))
+    @click.argument('node_network', type=types.Variables(), required=False)
+    def schema(self, wfjt, node_network=None):
+        """
+        Convert YAML/JSON content into workflow node objects if
+        node_network param is given.
+        If not, print a YAML representation of the node network.
+        """
+        if node_network is None:
+            if settings.format == 'human':
+                settings.format = 'yaml'
+            return self._get_schema(wfjt)
+
+        node_res = get_resource('node')
+
+        def create_node_recursive(node_branch, parent, relationship):
+            # Create node with data specified by top-level keys
+            create_data = {}
+            FK_FIELDS = JOB_TYPES.values() + ['inventory', 'credential']
+            for fd in NODE_STANDARD_FIELDS + JOB_TYPES.values():
+                if fd in node_branch:
+                    if (
+                            fd in FK_FIELDS and
+                            not isinstance(node_branch[fd], int)):
+                        # Node's template was given by name, do lookup
+                        ujt_res = get_resource(fd)
+                        ujt_data = ujt_res.get(name=node_branch[fd])
+                        create_data[fd] = ujt_data['id']
+                    else:
+                        create_data[fd] = node_branch[fd]
+            create_data['workflow_job_template'] = wfjt
+            node_data = node_res._get_or_create_child(
+                parent, relationship, **create_data)
+            # Repeat the process for all sub-branches
+            for fd in node_branch:
+                for rel in ['success', 'failure', 'always']:
+                    if fd.startswith(rel):
+                        sub_branch_list = node_branch[fd]
+                        if not isinstance(sub_branch_list, list):
+                            raise BadRequest(
+                                'Sublists in spec must be lists.'
+                                'Encountered in {0} at {1}'.format(
+                                    fd, sub_branch_list))
+                        for sub_branch in sub_branch_list:
+                            sub_node_data = create_node_recursive(
+                                sub_branch, parent=node_data['id'],
+                                relationship=rel)
+                            node_res._assoc(
+                                node_res._forward_rel_name(rel),
+                                node_data['id'], sub_node_data['id'])
+                        break
+            return node_data
+
+        if hasattr(node_network, 'read'):
+            node_network = node_network.read()
+        node_network = string_to_dict(
+            node_network, allow_kv=False, require_dict=False)
+
+        for base_node in node_network:
+            create_node_recursive(base_node, parent=None, relationship=None)
+
+        if settings.format == 'human':
+            settings.format = 'yaml'
+        return self._get_schema(wfjt)
diff --git a/tower_cli/resources/workflow_job.py b/tower_cli/resources/workflow_job.py
new file mode 100644
index 0000000..2f7618b
--- /dev/null
+++ b/tower_cli/resources/workflow_job.py
@@ -0,0 +1,70 @@
+# Copyright 2017 Ansible by Red Hat
+# Alan Rominger <arominge@redhat.com>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import click
+
+from tower_cli import models, resources
+from tower_cli.api import client
+from tower_cli.utils import debug, types, parser
+
+
+class Resource(models.ExeResource):
+    cli_help = 'Launch or monitor workflow jobs.'
+    endpoint = '/workflow_jobs/'
+
+    workflow_job_template = models.Field(
+        key='-W',
+        type=types.Related('workflow'),
+        display=True
+    )
+    extra_vars = models.Field(
+        type=types.Variables(), required=False, display=False,
+        multiple=True
+    )
+    created = models.Field(required=False, display=True)
+    status = models.Field(required=False, display=True)
+
+    @resources.command(
+        use_fields_as_options=('workflow_job_template', 'extra_vars')
+    )
+    @click.option('--monitor', is_flag=True, default=False,
+                  help='If sent, immediately calls monitor on the newly '
+                       'launched workflow job rather than exiting.')
+    @click.option('--timeout', required=False, type=int,
+                  help='If provided with --monitor, this command (not the job)'
+                       ' will time out after the given number of seconds. '
+                       'Does nothing if --monitor is not sent.')
+    def launch(self, workflow_job_template=None, monitor=False, timeout=None,
+               extra_vars=None, **kwargs):
+        """Launch a new workflow job based on a workflow job template.
+
+        Creates a new workflow job in Ansible Tower, starts it, and
+        returns back an ID in order for its status to be monitored.
+        """
+        if extra_vars is not None:
+            kwargs['extra_vars'] = parser.process_extra_vars(extra_vars)
+
+        debug.log('Launching the workflow job.', header='details')
+        self._pop_none(kwargs)
+        post_response = client.post('workflow_job_templates/{}/launch/'.format(
+            workflow_job_template), data=kwargs).json()
+
+        workflow_job_id = post_response['id']
+        post_response['changed'] = True
+
+        if monitor:
+            return self.monitor(workflow_job_id, timeout=timeout)
+
+        return post_response
diff --git a/tower_cli/utils/parser.py b/tower_cli/utils/parser.py
index 609a7d2..6ba853b 100644
--- a/tower_cli/utils/parser.py
+++ b/tower_cli/utils/parser.py
@@ -41,20 +41,13 @@ def parse_kv(var_string):
         fix_encoding_26 = True
 
     # Also hedge against Click library giving non-string type
-    is_unicode = False
     if fix_encoding_26 or not isinstance(var_string, str):
-        if isinstance(var_string, six.text_type):
-            var_string = var_string.encode('UTF-8')
-            is_unicode = True
-        else:
-            var_string = str(var_string)
+        var_string = str(var_string)
 
     # Use shlex library to split string by quotes, whitespace, etc.
     for token in shlex.split(var_string):
 
         # Second part of fix to avoid passing shlex unicode in py2.6
-        if (is_unicode):
-            token = token.decode('UTF-8')
         if fix_encoding_26:
             token = six.text_type(token)
         # Look for key=value pattern, if not, process as raw parameter
@@ -75,7 +68,7 @@ def parse_kv(var_string):
     return return_dict
 
 
-def string_to_dict(var_string, allow_kv=True):
+def string_to_dict(var_string, allow_kv=True, require_dict=True):
     """Returns a dictionary given a string with yaml or json syntax.
     If data is not present in a key: value format, then it return
     an empty dictionary.
@@ -91,7 +84,8 @@ def string_to_dict(var_string, allow_kv=True):
     try:
         # Accept all JSON and YAML
         return_dict = yaml.load(var_string)
-        assert type(return_dict) is dict
+        if require_dict:
+            assert type(return_dict) is dict
     except (AttributeError, yaml.YAMLError, AssertionError):
         # if these fail, parse by key=value syntax
         try:
@@ -148,7 +142,7 @@ def process_extra_vars(extra_vars_list, force_json=True):
                       header='decison', nl=2)
     if extra_vars == {}:
         return ""
-    return json.dumps(extra_vars, ensure_ascii=False)
+    return json.dumps(extra_vars)
 
 
 def ordered_dump(data, Dumper=yaml.Dumper, **kws):
diff --git a/tower_cli/utils/resource_decorators.py b/tower_cli/utils/resource_decorators.py
new file mode 100644
index 0000000..27a7d8c
--- /dev/null
+++ b/tower_cli/utils/resource_decorators.py
@@ -0,0 +1,54 @@
+# Copyright 2017 Ansible by Red Hat
+# Alan Rominger <arominge@redhat.com>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import functools
+from tower_cli.utils import types
+
+import click
+
+
+def unified_job_template_options(method):
+    """
+    Adds the decorators for all types of unified job templates,
+    and if the non-unified type is specified, converts it into the
+    unified_job_template kwarg.
+    """
+    jt_dec = click.option(
+        '--job-template', type=types.Related('job_template'),
+        help='Use this job template as unified_job_template field')
+    prj_dec = click.option(
+        '--project', type=types.Related('project'),
+        help='Use this project as unified_job_template field')
+    inv_src_dec = click.option(
+        '--inventory-source', type=types.Related('inventory_source'),
+        help='Use this inventory source as unified_job_template field')
+
+    def ujt_translation(_method):
+        def _ujt_translation(*args, **kwargs):
+            for fd in ['job_template', 'project', 'inventory_source']:
+                if fd in kwargs and kwargs[fd] is not None:
+                    kwargs['unified_job_template'] = kwargs.pop(fd)
+            return _method(*args, **kwargs)
+        return functools.wraps(_method)(_ujt_translation)
+
+    return ujt_translation(
+        inv_src_dec(
+            prj_dec(
+                jt_dec(
+                    method
+                )
+            )
+        )
+    )


Test patch:
diff --git a/tests/test_resources_workflow.py b/tests/test_resources_workflow.py
new file mode 100644
index 0000000..df92417
--- /dev/null
+++ b/tests/test_resources_workflow.py
@@ -0,0 +1,96 @@
+# Copyright 2017 Ansible by Red Hat
+# Alan Rominger <arominge@redhat.com>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import tower_cli
+from tower_cli.resources.workflow import Resource
+
+from tests.compat import unittest, mock
+
+
+EXAMPLE_NODE_LIST = [
+    {
+        'id': 1,
+        'success_nodes': [],
+        'failure_nodes': [],
+        'always_nodes': [2],
+        'unified_job_template': 48,
+        'summary_fields': {
+            'unified_job_template': {
+                'unified_job_type': 'job'
+            }
+        }
+    },
+    {
+        'id': 2,
+        'success_nodes': [],
+        'failure_nodes': [],
+        'always_nodes': [],
+        'unified_job_template': 98,
+        'summary_fields': {
+            'unified_job_template': {
+                'unified_job_type': 'project_update'
+            }
+        }
+    }
+]
+
+
+class SchemaTests(unittest.TestCase):
+    """
+    Tests for the schema file in the tower_cli utils
+    """
+
+    def test_expected_output(self):
+        """
+        Test that the expected structure is returned from the method
+        to reorganize workflow nodes in a heirarchy
+        """
+        output = Resource._workflow_node_structure(EXAMPLE_NODE_LIST)
+        self.assertEqual(
+            output,
+            [
+                {
+                    'job_template': 48,
+                    'always_nodes': [
+                        {
+                            'project': 98
+                        }
+                    ]
+                }
+            ]
+        )
+
+
+class NodeModelTests(unittest.TestCase):
+    """
+    Tests for workflow nodes
+    """
+
+    def setUp(self):
+        self.res = tower_cli.get_resource('node')
+
+    def test_translation_into_UJT(self):
+        """
+        Test application of additional decorator in __getattribute__
+        """
+        with mock.patch('tower_cli.models.base.ResourceMethods.write') as mck:
+            mck.return_value = {'id': 589}
+            mck.__name__ = 'create'
+            self.res.create(workflow_job_template=1,
+                            job_template=5)
+            mck.assert_called_once_with(
+                create_on_missing=True, fail_on_found=False,
+                force_on_exists=False, unified_job_template=5,
+                workflow_job_template=1)
diff --git a/tests/test_utils_parser.py b/tests/test_utils_parser.py
index f838465..44c079a 100644
--- a/tests/test_utils_parser.py
+++ b/tests/test_utils_parser.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
 # Copyright 2015, Ansible, Inc.
 # Alan Rominger <arominger@ansible.com>
 #
@@ -138,13 +137,6 @@ class ParserTests(unittest.TestCase):
         return_str = parser.process_extra_vars([""], force_json=False)
         self.assertEqual(return_str, "")
 
-    def test_handling_unicode(self):
-        """Verify that unicode strings are correctly parsed and
-        converted to desired python objects"""
-        input_unicode = u"the_user_name='äöü ÄÖÜ'"
-        return_dict = parser.string_to_dict(input_unicode)
-        self.assertEqual(return_dict, {u'the_user_name': u'äöü ÄÖÜ'})
-
 
 class TestSplitter_Gen(unittest.TestCase):
     """Set of strings paired with expected output is ran agains the parsing