Browse Source

[oozie] Add decision end node

Added an end to the decision node to graphically handle decision nodes better.
Import workflow adds decision end automatically.
abec 13 years ago
parent
commit
e115ccc
22 changed files with 552 additions and 305 deletions
  1. 69 20
      apps/oozie/src/oozie/import_workflow.py
  2. 303 0
      apps/oozie/src/oozie/migrations/0014_auto__add_decisionend.py
  3. 51 30
      apps/oozie/src/oozie/models.py
  4. 23 7
      apps/oozie/src/oozie/templates/editor/edit_workflow.mako
  5. 1 1
      apps/oozie/src/oozie/templates/editor/gen/workflow-decision.xml.mako
  6. 2 2
      apps/oozie/src/oozie/templates/editor/gen/workflow-distcp.xml.mako
  7. 2 2
      apps/oozie/src/oozie/templates/editor/gen/workflow-email.xml.mako
  8. 2 2
      apps/oozie/src/oozie/templates/editor/gen/workflow-fs.xml.mako
  9. 2 2
      apps/oozie/src/oozie/templates/editor/gen/workflow-generic.xml.mako
  10. 2 2
      apps/oozie/src/oozie/templates/editor/gen/workflow-hive.xml.mako
  11. 2 2
      apps/oozie/src/oozie/templates/editor/gen/workflow-java.xml.mako
  12. 1 1
      apps/oozie/src/oozie/templates/editor/gen/workflow-join.xml.mako
  13. 2 2
      apps/oozie/src/oozie/templates/editor/gen/workflow-mapreduce.xml.mako
  14. 2 2
      apps/oozie/src/oozie/templates/editor/gen/workflow-pig.xml.mako
  15. 2 2
      apps/oozie/src/oozie/templates/editor/gen/workflow-shell.xml.mako
  16. 2 2
      apps/oozie/src/oozie/templates/editor/gen/workflow-sqoop.xml.mako
  17. 2 2
      apps/oozie/src/oozie/templates/editor/gen/workflow-ssh.xml.mako
  18. 1 1
      apps/oozie/src/oozie/templates/editor/gen/workflow-start.xml.mako
  19. 2 2
      apps/oozie/src/oozie/templates/editor/gen/workflow-streaming.xml.mako
  20. 2 2
      apps/oozie/src/oozie/templates/editor/gen/workflow-subworkflow.xml.mako
  21. 3 3
      apps/oozie/src/oozie/tests.py
  22. 74 216
      apps/oozie/static/js/workflow.js

+ 69 - 20
apps/oozie/src/oozie/import_workflow.py

@@ -41,9 +41,12 @@ import re
 from lxml import etree
 
 from django.core import serializers
+from django.utils.translation import ugettext as _
+
+from desktop.lib.exceptions_renderable import PopupException
 
 from conf import DEFINITION_XSLT_DIR
-from models import Node, Link, Start, End, Decision, Fork, Join
+from models import Node, Link, Start, End, Decision, DecisionEnd, Fork, Join
 
 LOG = logging.getLogger(__name__)
 
@@ -164,12 +167,19 @@ def _resolve_decision_relationships(workflow):
   """
   Requires proper workflow structure.
   Decision must come before a any random ends.
-  Ends for decisions should be at the highest decision.
+  DecisionEnd nodes are added to the end of the decision DAG.
+  A 'related' link is created to associate the DecisionEnd to the Decision.
   IE:      D
          D   N
        N   N
            N
-  The decision at the top should have the end, not the nested decision.
+        equals
+           D
+         D   N
+       N   N
+         E
+           E
+           N
 
   Performs a breadth first search to understand branching.
   Call helper for every new decision found.
@@ -188,37 +198,76 @@ def _resolve_decision_relationships(workflow):
       decision = find_decision(child) or decision
     return decision
 
+  def insert_end(node, decision_end):
+    """Insert DecisionEnd between node and node parents"""
+    parent_links = node.get_parent_links().exclude(name='default')
+
+    # Nodes that will be seen will always have one node
+    # Otherwise, we should fail.
+    for parent_link in parent_links:
+      parent = parent_link.parent
+      if parent.node_type != Decision.node_type:
+        links = Link.objects.filter(parent=parent).exclude(name__in=['related', 'kill', 'error'])
+        if len(links) != 1:
+          raise PopupException(_('Cannot import workflows that have decision DAG leaf nodes with multiple children or no children.'))
+        link = links[0]
+        link.child = decision_end
+        link.save()
+
+    # Create link between DecisionEnd and terminal node.
+    link = Link(name='to', parent=decision_end, child=node)
+    link.save()
+
   def helper(decision):
     visit = deque(decision.get_children())
     branch_count = len(visit)
 
+    # Create decision end if it does not exist.
+    if not Link.objects.filter(parent=decision, name='related').exists():
+      end = DecisionEnd(workflow=workflow, node_type=DecisionEnd.node_type)
+      end.save()
+      link = Link(name='related', parent=decision, child=end)
+      link.save()
+
     # Find end
     while visit:
-      node = visit.popleft()
+      node = visit.popleft().get_full_node()
       parents = node.get_parents()
 
-      # An end found...
-      # IF it covers all branches, then it is a true end.
-      # ELSE it is a false end and belongs to a higher decision.
-      if len(parents) > 1:
-        if len(parents) == branch_count:
-          link = Link(name='related', parent=decision, child=node)
-          link.save()
+      # An end is found...
+      # IF it covers all branches, then it is an end that perfectly matches this decision.
+      # ELSE it is an end for a higher decision as well.
+      # The unhandled case is multiple ends for a single decision that converge on a single end.
+      # This is not handled in Hue.
+      if isinstance(node, Decision):
+        end, end_parent_count = helper(node)
+        branch_count -= 1
+
+        if end_parent_count < branch_count:
+          # In case we hit a workflow that has 2 decision or more nodes where the ends
+          # do not cover the entire decision DAG.
+          raise PopupException(_('Cannot import workflows that have decisions paths with multiple terminal nodes that converge on a single terminal node.'))
 
         else:
-          return node, branch_count
+          insert_end(end, decision.get_child_end())
+          if end_parent_count != branch_count:
+            return end, end_parent_count - branch_count
 
-      elif isinstance(node, Decision):
-        inner_branch_count, end = helper(node)
-        branch_count = branch_count + inner_branch_count - 1
+        visit.extend(end.get_children())
+
+      elif not isinstance(node, Join) and not isinstance(node, DecisionEnd) and len(parents) > 1:
+        if len(parents) < branch_count:
+          raise PopupException(_('Cannot import workflows that have decisions paths with multiple terminal nodes that converge on a single terminal node.'))
 
-        if len(end.get_parents()) == branch_count:
-          link = Link(name='related', parent=decision, child=end)
-          link.save()
         else:
-          return node, branch_count
+          insert_end(node, decision.get_child_end())
+          if len(parents) != branch_count:
+            return node, len(parents) - branch_count
+
+        visit.extend(node.get_children())
 
-      visit.extend(node.get_children())
+      else:
+        visit.extend(node.get_children())
 
   decision = find_decision(workflow.start.get_full_node())
   if decision is not None:

+ 303 - 0
apps/oozie/src/oozie/migrations/0014_auto__add_decisionend.py

@@ -0,0 +1,303 @@
+# encoding: utf-8
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+class Migration(SchemaMigration):
+    
+    def forwards(self, orm):
+        
+        # Adding model 'DecisionEnd'
+        db.create_table('oozie_decisionend', (
+            ('node_ptr', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['oozie.Node'], unique=True, primary_key=True)),
+        ))
+        db.send_create_signal('oozie', ['DecisionEnd'])
+    
+    
+    def backwards(self, orm):
+        
+        # Deleting model 'DecisionEnd'
+        db.delete_table('oozie_decisionend')
+    
+    
+    models = {
+        'auth.group': {
+            'Meta': {'object_name': 'Group'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+            'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+        },
+        'auth.permission': {
+            'Meta': {'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+            'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+        },
+        'auth.user': {
+            'Meta': {'object_name': 'User'},
+            'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'blank': 'True'}),
+            'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}),
+            'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}),
+            'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+            'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+            'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+        },
+        'contenttypes.contenttype': {
+            'Meta': {'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+            'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+        },
+        'oozie.coordinator': {
+            'Meta': {'object_name': 'Coordinator', '_ormbases': ['oozie.Job']},
+            'concurrency': ('django.db.models.fields.PositiveSmallIntegerField', [], {'null': 'True', 'blank': 'True'}),
+            'end': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2012, 12, 29, 18, 47, 41, 463666)'}),
+            'execution': ('django.db.models.fields.CharField', [], {'max_length': '10', 'null': 'True', 'blank': 'True'}),
+            'frequency_number': ('django.db.models.fields.SmallIntegerField', [], {'default': '1'}),
+            'frequency_unit': ('django.db.models.fields.CharField', [], {'default': "'days'", 'max_length': '20'}),
+            'job_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Job']", 'unique': 'True', 'primary_key': 'True'}),
+            'start': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2012, 12, 26, 18, 47, 41, 463616)'}),
+            'throttle': ('django.db.models.fields.PositiveSmallIntegerField', [], {'null': 'True', 'blank': 'True'}),
+            'timeout': ('django.db.models.fields.SmallIntegerField', [], {'null': 'True', 'blank': 'True'}),
+            'timezone': ('django.db.models.fields.CharField', [], {'default': "'America/Los_Angeles'", 'max_length': '24'}),
+            'workflow': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['oozie.Workflow']", 'null': 'True'})
+        },
+        'oozie.datainput': {
+            'Meta': {'object_name': 'DataInput'},
+            'coordinator': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['oozie.Coordinator']"}),
+            'dataset': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Dataset']", 'unique': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '40'})
+        },
+        'oozie.dataoutput': {
+            'Meta': {'object_name': 'DataOutput'},
+            'coordinator': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['oozie.Coordinator']"}),
+            'dataset': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Dataset']", 'unique': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '40'})
+        },
+        'oozie.dataset': {
+            'Meta': {'object_name': 'Dataset'},
+            'coordinator': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['oozie.Coordinator']"}),
+            'description': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}),
+            'done_flag': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '64', 'blank': 'True'}),
+            'frequency_number': ('django.db.models.fields.SmallIntegerField', [], {'default': '1'}),
+            'frequency_unit': ('django.db.models.fields.CharField', [], {'default': "'days'", 'max_length': '20'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
+            'start': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2012, 12, 26, 18, 47, 41, 464516)'}),
+            'timezone': ('django.db.models.fields.CharField', [], {'default': "'America/Los_Angeles'", 'max_length': '24'}),
+            'uri': ('django.db.models.fields.CharField', [], {'default': "'/data/${YEAR}${MONTH}${DAY}'", 'max_length': '1024'})
+        },
+        'oozie.decision': {
+            'Meta': {'object_name': 'Decision'},
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'})
+        },
+        'oozie.decisionend': {
+            'Meta': {'object_name': 'DecisionEnd'},
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'})
+        },
+        'oozie.distcp': {
+            'Meta': {'object_name': 'DistCp'},
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_xml': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'params': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'prepares': ('django.db.models.fields.TextField', [], {'default': "'[]'"})
+        },
+        'oozie.email': {
+            'Meta': {'object_name': 'Email'},
+            'body': ('django.db.models.fields.TextField', [], {'default': "''"}),
+            'cc': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'subject': ('django.db.models.fields.TextField', [], {'default': "''"}),
+            'to': ('django.db.models.fields.TextField', [], {'default': "''"})
+        },
+        'oozie.end': {
+            'Meta': {'object_name': 'End'},
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'})
+        },
+        'oozie.fork': {
+            'Meta': {'object_name': 'Fork'},
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'})
+        },
+        'oozie.fs': {
+            'Meta': {'object_name': 'Fs'},
+            'chmods': ('django.db.models.fields.TextField', [], {'default': "'[]'", 'blank': 'True'}),
+            'deletes': ('django.db.models.fields.TextField', [], {'default': "'[]'", 'blank': 'True'}),
+            'mkdirs': ('django.db.models.fields.TextField', [], {'default': "'[]'", 'blank': 'True'}),
+            'moves': ('django.db.models.fields.TextField', [], {'default': "'[]'", 'blank': 'True'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'touchzs': ('django.db.models.fields.TextField', [], {'default': "'[]'", 'blank': 'True'})
+        },
+        'oozie.generic': {
+            'Meta': {'object_name': 'Generic'},
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'xml': ('django.db.models.fields.TextField', [], {'default': "''"})
+        },
+        'oozie.history': {
+            'Meta': {'object_name': 'History'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'job': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['oozie.Job']"}),
+            'oozie_job_id': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+            'properties': ('django.db.models.fields.TextField', [], {}),
+            'submission_date': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
+            'submitter': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
+        },
+        'oozie.hive': {
+            'Meta': {'object_name': 'Hive'},
+            'archives': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'files': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': '\'[{"name":"oozie.hive.defaults","value":"hive-site.xml"}]\''}),
+            'job_xml': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'params': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'prepares': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'script_path': ('django.db.models.fields.CharField', [], {'max_length': '256'})
+        },
+        'oozie.java': {
+            'Meta': {'object_name': 'Java'},
+            'archives': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'args': ('django.db.models.fields.CharField', [], {'max_length': '4096', 'blank': 'True'}),
+            'files': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'jar_path': ('django.db.models.fields.CharField', [], {'max_length': '512'}),
+            'java_opts': ('django.db.models.fields.CharField', [], {'max_length': '256', 'blank': 'True'}),
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_xml': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}),
+            'main_class': ('django.db.models.fields.CharField', [], {'max_length': '256'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'prepares': ('django.db.models.fields.TextField', [], {'default': "'[]'"})
+        },
+        'oozie.job': {
+            'Meta': {'object_name': 'Job'},
+            'deployment_dir': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
+            'description': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_shared': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'db_index': 'True', 'blank': 'True'}),
+            'last_modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
+            'owner': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
+            'parameters': ('django.db.models.fields.TextField', [], {'default': '\'[{"name":"oozie.use.system.libpath","value":"true"}]\''}),
+            'schema_version': ('django.db.models.fields.CharField', [], {'max_length': '128'})
+        },
+        'oozie.join': {
+            'Meta': {'object_name': 'Join'},
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'})
+        },
+        'oozie.kill': {
+            'Meta': {'object_name': 'Kill'},
+            'message': ('django.db.models.fields.CharField', [], {'default': "'Action failed, error message[${wf:errorMessage(wf:lastErrorNode())}]'", 'max_length': '256'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'})
+        },
+        'oozie.link': {
+            'Meta': {'object_name': 'Link'},
+            'child': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'parent_node'", 'to': "orm['oozie.Node']"}),
+            'comment': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
+            'parent': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'child_node'", 'to': "orm['oozie.Node']"})
+        },
+        'oozie.mapreduce': {
+            'Meta': {'object_name': 'Mapreduce'},
+            'archives': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'files': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'jar_path': ('django.db.models.fields.CharField', [], {'max_length': '512'}),
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_xml': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True'}),
+            'prepares': ('django.db.models.fields.TextField', [], {'default': "'[]'"})
+        },
+        'oozie.node': {
+            'Meta': {'object_name': 'Node'},
+            'children': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'parents'", 'symmetrical': 'False', 'through': "orm['oozie.Link']", 'to': "orm['oozie.Node']"}),
+            'description': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
+            'node_type': ('django.db.models.fields.CharField', [], {'max_length': '64'}),
+            'workflow': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['oozie.Workflow']"})
+        },
+        'oozie.pig': {
+            'Meta': {'object_name': 'Pig'},
+            'archives': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'files': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_xml': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'params': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'prepares': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'script_path': ('django.db.models.fields.CharField', [], {'max_length': '256'})
+        },
+        'oozie.shell': {
+            'Meta': {'object_name': 'Shell'},
+            'archives': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'capture_output': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}),
+            'command': ('django.db.models.fields.CharField', [], {'max_length': '256'}),
+            'files': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_xml': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'params': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'prepares': ('django.db.models.fields.TextField', [], {'default': "'[]'"})
+        },
+        'oozie.sqoop': {
+            'Meta': {'object_name': 'Sqoop'},
+            'archives': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'files': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_xml': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'params': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'prepares': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'script_path': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'})
+        },
+        'oozie.ssh': {
+            'Meta': {'object_name': 'Ssh'},
+            'capture_output': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}),
+            'command': ('django.db.models.fields.CharField', [], {'max_length': '256'}),
+            'host': ('django.db.models.fields.CharField', [], {'max_length': '256'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'params': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'user': ('django.db.models.fields.CharField', [], {'max_length': '64'})
+        },
+        'oozie.start': {
+            'Meta': {'object_name': 'Start'},
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True'})
+        },
+        'oozie.streaming': {
+            'Meta': {'object_name': 'Streaming'},
+            'archives': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'files': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'mapper': ('django.db.models.fields.CharField', [], {'max_length': '512'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'reducer': ('django.db.models.fields.CharField', [], {'max_length': '512'})
+        },
+        'oozie.subworkflow': {
+            'Meta': {'object_name': 'SubWorkflow'},
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'propagate_configuration': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'blank': 'True'}),
+            'sub_workflow': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['oozie.Workflow']"})
+        },
+        'oozie.workflow': {
+            'Meta': {'object_name': 'Workflow', '_ormbases': ['oozie.Job']},
+            'end': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'end_workflow'", 'null': 'True', 'to': "orm['oozie.End']"}),
+            'is_single': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}),
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Job']", 'unique': 'True', 'primary_key': 'True'}),
+            'job_xml': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}),
+            'start': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'start_workflow'", 'null': 'True', 'to': "orm['oozie.Start']"})
+        }
+    }
+    
+    complete_apps = ['oozie']

+ 51 - 30
apps/oozie/src/oozie/models.py

@@ -366,7 +366,7 @@ class Workflow(Job):
     return self.get_hierarchy_rec(node=node) + [[Kill.objects.get(name='kill', workflow=node.workflow)],
                                            [End.objects.get(name='end', workflow=node.workflow)]]
 
-  def get_hierarchy_rec(self, node=None, skip_parents_check=False):
+  def get_hierarchy_rec(self, node=None):
     if node is None:
       node = self.start
       if node.id is None:
@@ -375,24 +375,21 @@ class Workflow(Job):
     node = node.get_full_node()
     parents = node.get_parents()
 
-    if len(parents) > 1 and not skip_parents_check:
-      return []
-
     if isinstance(node, End):
       return [] # Not returning the end node
     elif isinstance(node, Decision):
       children = node.get_children('start')
-      end = node.get_child_end_or_none()
-      if end:
-        return [[node, [self.get_hierarchy_rec(node=child) for child in children]] + self.get_hierarchy_rec(node=end, skip_parents_check=True)]
-      else:
-        return [[node, [self.get_hierarchy_rec(node=child) for child in children]]]
+      return [[node] + [[self.get_hierarchy_rec(node=child) for child in children],
+                        node.get_child_end()]] + self.get_hierarchy_rec(node.get_child_end().get_child('to'))
+    elif isinstance(node, DecisionEnd):
+      return []
     elif isinstance(node, Fork):
       children = node.get_children('start')
       return [[node] + [[self.get_hierarchy_rec(node=child) for child in children],
                         node.get_child_join()]] + self.get_hierarchy_rec(node.get_child_join().get_child('to'))
     elif isinstance(node, Join):
       return []
+
     else:
       child = Link.objects.filter(parent=node).exclude(name__in=['related', 'kill', 'error'])[0].child
       return [node] + self.get_hierarchy_rec(child)
@@ -412,7 +409,7 @@ class Workflow(Job):
 
 class Link(models.Model):
   # Links to exclude when using get_children_link(), get_parent_links() in the API
-  META_LINKS = ('related')
+  META_LINKS = ('related', 'default')
 
   parent = models.ForeignKey('Node', related_name='child_node')
   child = models.ForeignKey('Node', related_name='parent_node', verbose_name='')
@@ -483,10 +480,12 @@ class Node(models.Model):
       node = self.kill
     elif self.node_type == Fork.node_type:
       node = self.fork
-    elif self.node_type == Decision.node_type:
-      node = self.decision
     elif self.node_type == Join.node_type:
       node = self.join
+    elif self.node_type == Decision.node_type:
+      node = self.decision
+    elif self.node_type == DecisionEnd.node_type:
+      node = self.decisionend
     else:
       raise Exception(_('Unknown Node type: %s. Was it set at its creation?'), (self.node_type,))
 
@@ -523,8 +522,16 @@ class Node(models.Model):
     return self.get_link(name)
 
   def get_child(self, name=None):
+    """Includes DecisionEnd nodes"""
     return self.get_link(name).child.get_full_node()
 
+  def get_oozie_child(self, name=None):
+    """Resolves DecisionEnd nodes"""
+    child = self.get_link(name).child.get_full_node()
+    if child and child.node_type == DecisionEnd.node_type:
+      child = child.get_oozie_child('to')
+    return child
+
   def get_children(self, name=None):
     if name is not None:
       return [link.child for link in Link.objects.exclude(name__in=Link.META_LINKS).filter(parent=self, name=name)]
@@ -1046,25 +1053,29 @@ class Fork(ControlFlow):
     join.delete()
 
 
+class Join(ControlFlow):
+  node_type = 'join'
+
+  def is_visible(self):
+    return True
+
+  def get_parent_fork(self):
+    return self.get_parent_link('related').parent.get_full_node()
+
+  def get_parent_actions(self):
+    return [link.parent for link in self.get_parent_links()]
+
+
 class Decision(ControlFlow):
   """
-  Essentially a fork where the end is not a join, but another node.
-  If two decisions share an end, the decision with the higher level takes the end
-  and the lower level decision will not have an end.
-  IE:     D
-        D   N
-          E
-    The first 'D' will be assigned the end 'E'.
-    The second 'D' will not have an end.
-  This enables easier interpretation of visual hierarchy.
+  Essentially a fork where only one of the paths of execution are chosen.
+  Graphically, this is represented the same way as a fork.
+  The DecisionEnd node is not represented in Oozie, only in Hue.
   """
   node_type = 'decision'
 
-  def get_child_end_or_none(self):
-    try:
-      return Link.objects.get(parent=self, name='related').child.get_full_node()
-    except Link.DoesNotExist:
-      return None
+  def get_child_end(self):
+    return Link.objects.get(parent=self, name='related').child.get_full_node()
 
   def is_visible(self):
     return True
@@ -1074,18 +1085,27 @@ class Decision(ControlFlow):
     self.save()
 
 
-class Join(ControlFlow):
-  node_type = 'join'
+class DecisionEnd(ControlFlow):
+  """
+  Defines the end of a join.
+  This node exists purely in the Hue application to provide a smooth transition
+  from Decision to Endself.
+
+  NOTE: NOT AN OOZIE NODE
+  """
+  node_type = 'decisionend'
 
   def is_visible(self):
-    return True
+    return False
 
-  def get_parent_fork(self):
+  def get_parent_decision(self):
     return self.get_parent_link('related').parent.get_full_node()
 
   def get_parent_actions(self):
     return [link.parent for link in self.get_parent_links()]
 
+  def to_xml(self):
+    return ''
 
 
 FREQUENCY_UNITS = (('minutes', _('Minutes')),
@@ -1445,6 +1465,7 @@ NODE_TYPES.update({
   Fork.node_type: Fork,
   Join.node_type: Join,
   Decision.node_type: Decision,
+  DecisionEnd.node_type: DecisionEnd,
   Start.node_type: Start,
   End.node_type: End,
 })

+ 23 - 7
apps/oozie/src/oozie/templates/editor/edit_workflow.mako

@@ -453,6 +453,26 @@ ${ controls.decision_form(link_form, default_link_form, 'decision', True) }
   </div>
 </script>
 
+<script type="text/html" id="joinTemplate">
+  <div class="node node-join row-fluid">
+    <div class="action span12">
+      <div class="row-fluid">
+        <div class="span10">
+          <span class="label label-info" data-bind="text: (name()) ? name() : node_type() + '-' + id()"></span>
+        </div>
+      </div>
+
+      <div class="row-fluid">
+        <div class="span10">
+          <span data-bind="text: node_type()"></span>
+        </div>
+      </div>
+    </div>
+
+  </div>
+  <div class="row-fluid" data-bind="template: { name: 'linkTemplate', foreach: links() }"></div>
+</script>
+
 <script type="text/html" id="decisionTemplate">
   <div class="node node-decision row-fluid">
     <div class="action span12">
@@ -479,19 +499,15 @@ ${ controls.decision_form(link_form, default_link_form, 'decision', True) }
         </div>
       </div>
     </div>
-
-    <div class="row-fluid node-decision-end">
-      <div class="node-decision-end">&nbsp;</div>
-    </div>
   </div>
 </script>
 
-<script type="text/html" id="joinTemplate">
-  <div class="node node-join row-fluid">
+<script type="text/html" id="decisionEndTemplate">
+  <div class="node node-decisionend row-fluid">
     <div class="action span12">
       <div class="row-fluid">
         <div class="span10">
-          <span class="label label-info" data-bind="text: (name()) ? name() : node_type() + '-' + id()"></span>
+          <span class="label label-info" data-bind="text: 'end-' + id()"></span>
         </div>
       </div>
 

+ 1 - 1
apps/oozie/src/oozie/templates/editor/gen/workflow-decision.xml.mako

@@ -21,6 +21,6 @@
               ${ link.comment }
             </case>
         % endfor
-            <default to="${ node.get_child('default') }"/>
+            <default to="${ node.get_oozie_child('default') }"/>
         </switch>
     </decision>

+ 2 - 2
apps/oozie/src/oozie/templates/editor/gen/workflow-distcp.xml.mako

@@ -31,6 +31,6 @@
               <arg>${ param['value'] }</arg>
             % endfor
         </distcp>
-        <ok to="${ node.get_child('ok') }"/>
-        <error to="${ node.get_child('error') }"/>
+        <ok to="${ node.get_oozie_child('ok') }"/>
+        <error to="${ node.get_oozie_child('error') }"/>
     </action>

+ 2 - 2
apps/oozie/src/oozie/templates/editor/gen/workflow-email.xml.mako

@@ -25,6 +25,6 @@
             <subject>${ node.subject }</subject>
             <body>${ node.body }</body>
         </email>
-        <ok to="${ node.get_child('ok') }"/>
-        <error to="${ node.get_child('error') }"/>
+        <ok to="${ node.get_oozie_child('ok') }"/>
+        <error to="${ node.get_oozie_child('error') }"/>
     </action>

+ 2 - 2
apps/oozie/src/oozie/templates/editor/gen/workflow-fs.xml.mako

@@ -48,6 +48,6 @@
               <touchz path='${ smart_path(param['name']) }'/>
             % endfor
         </fs>
-        <ok to="${ node.get_child('ok') }"/>
-        <error to="${ node.get_child('error') }"/>
+        <ok to="${ node.get_oozie_child('ok') }"/>
+        <error to="${ node.get_oozie_child('error') }"/>
     </action>

+ 2 - 2
apps/oozie/src/oozie/templates/editor/gen/workflow-generic.xml.mako

@@ -16,6 +16,6 @@
 
     <action name="${ node }">
         ${ node.xml }
-        <ok to="${ node.get_child('ok') }"/>
-        <error to="${ node.get_child('error') }"/>
+        <ok to="${ node.get_oozie_child('ok') }"/>
+        <error to="${ node.get_oozie_child('error') }"/>
     </action>

+ 2 - 2
apps/oozie/src/oozie/templates/editor/gen/workflow-hive.xml.mako

@@ -35,6 +35,6 @@
 
             ${ common.distributed_cache(node.get_files(), node.get_archives()) }
         </hive>
-        <ok to="${ node.get_child('ok') }"/>
-        <error to="${ node.get_child('error') }"/>
+        <ok to="${ node.get_oozie_child('ok') }"/>
+        <error to="${ node.get_oozie_child('error') }"/>
     </action>

+ 2 - 2
apps/oozie/src/oozie/templates/editor/gen/workflow-java.xml.mako

@@ -38,6 +38,6 @@
 
             ${ common.distributed_cache(node.get_files(), node.get_archives()) }
         </java>
-        <ok to="${ node.get_child('ok') }"/>
-        <error to="${ node.get_child('error') }"/>
+        <ok to="${ node.get_oozie_child('ok') }"/>
+        <error to="${ node.get_oozie_child('error') }"/>
     </action>

+ 1 - 1
apps/oozie/src/oozie/templates/editor/gen/workflow-join.xml.mako

@@ -14,4 +14,4 @@
 ## See the License for the specific language governing permissions and
 ## limitations under the License.
 
-    <join name="${ node }" to="${ node.get_child('to') }"/>
+    <join name="${ node }" to="${ node.get_oozie_child('to') }"/>

+ 2 - 2
apps/oozie/src/oozie/templates/editor/gen/workflow-mapreduce.xml.mako

@@ -29,6 +29,6 @@
 
             ${ common.distributed_cache(node.get_files(), node.get_archives()) }
         </map-reduce>
-        <ok to="${ node.get_child('ok') }"/>
-        <error to="${ node.get_child('error') }"/>
+        <ok to="${ node.get_oozie_child('ok') }"/>
+        <error to="${ node.get_oozie_child('error') }"/>
     </action>

+ 2 - 2
apps/oozie/src/oozie/templates/editor/gen/workflow-pig.xml.mako

@@ -35,6 +35,6 @@
 
             ${ common.distributed_cache(node.get_files(), node.get_archives()) }
         </pig>
-        <ok to="${ node.get_child('ok') }"/>
-        <error to="${ node.get_child('error') }"/>
+        <ok to="${ node.get_oozie_child('ok') }"/>
+        <error to="${ node.get_oozie_child('error') }"/>
     </action>

+ 2 - 2
apps/oozie/src/oozie/templates/editor/gen/workflow-shell.xml.mako

@@ -39,6 +39,6 @@
               <capture-output/>
             % endif
         </shell>
-        <ok to="${ node.get_child('ok') }"/>
-        <error to="${ node.get_child('error') }"/>
+        <ok to="${ node.get_oozie_child('ok') }"/>
+        <error to="${ node.get_oozie_child('error') }"/>
     </action>

+ 2 - 2
apps/oozie/src/oozie/templates/editor/gen/workflow-sqoop.xml.mako

@@ -37,6 +37,6 @@
 
             ${ common.distributed_cache(node.get_files(), node.get_archives()) }
         </sqoop>
-        <ok to="${ node.get_child('ok') }"/>
-        <error to="${ node.get_child('error') }"/>
+        <ok to="${ node.get_oozie_child('ok') }"/>
+        <error to="${ node.get_oozie_child('error') }"/>
     </action>

+ 2 - 2
apps/oozie/src/oozie/templates/editor/gen/workflow-ssh.xml.mako

@@ -29,6 +29,6 @@
               <capture-output/>
             % endif
         </ssh>
-        <ok to="${ node.get_child('ok') }"/>
-        <error to="${ node.get_child('error') }"/>
+        <ok to="${ node.get_oozie_child('ok') }"/>
+        <error to="${ node.get_oozie_child('error') }"/>
     </action>

+ 1 - 1
apps/oozie/src/oozie/templates/editor/gen/workflow-start.xml.mako

@@ -14,4 +14,4 @@
 ## See the License for the specific language governing permissions and
 ## limitations under the License.
 
-    <start to="${ node.get_child('to') }"/>
+    <start to="${ node.get_oozie_child('to') }"/>

+ 2 - 2
apps/oozie/src/oozie/templates/editor/gen/workflow-streaming.xml.mako

@@ -30,6 +30,6 @@
             % endif
             ${ common.distributed_cache(node.get_files(), node.get_archives()) }
         </map-reduce>
-        <ok to="${ node.get_child('ok') }"/>
-        <error to="${ node.get_child('error') }"/>
+        <ok to="${ node.get_oozie_child('ok') }"/>
+        <error to="${ node.get_oozie_child('error') }"/>
     </action>

+ 2 - 2
apps/oozie/src/oozie/templates/editor/gen/workflow-subworkflow.xml.mako

@@ -26,6 +26,6 @@
 
             ${ common.configuration(node.get_properties()) }
         </sub-workflow>
-        <ok to="${ node.get_child('ok') }"/>
-        <error to="${ node.get_child('error') }"/>
+        <ok to="${ node.get_oozie_child('ok') }"/>
+        <error to="${ node.get_oozie_child('error') }"/>
     </action>

+ 3 - 3
apps/oozie/src/oozie/tests.py

@@ -1024,12 +1024,12 @@ class TestEditor(OozieMockBase):
     import_workflow(workflow, f.read(), schema_version=0.4)
     f.close()
     workflow.save()
-    assert_equal(11, len(Node.objects.filter(workflow=workflow)))
-    assert_equal(19, len(Link.objects.filter(parent__workflow=workflow)))
+    assert_equal(12, len(Node.objects.filter(workflow=workflow)))
+    assert_equal(20, len(Link.objects.filter(parent__workflow=workflow)))
     assert_equal(1, len(Link.objects.filter(parent__workflow=workflow, parent__node_type='decision', comment='${1 gt 2}', name='start')))
     assert_equal(1, len(Link.objects.filter(parent__workflow=workflow, parent__node_type='decision', comment='', name='start')))
     assert_equal(1, len(Link.objects.filter(parent__workflow=workflow, parent__node_type='decision', name='default')))
-    assert_equal(1, len(Link.objects.filter(parent__workflow=workflow, parent__node_type='decision', child__node_type='end', name='related')))
+    assert_equal(1, len(Link.objects.filter(parent__workflow=workflow, parent__node_type='decision', child__node_type='decisionend', name='related')))
     workflow.delete()
 
 

+ 74 - 216
apps/oozie/static/js/workflow.js

@@ -528,6 +528,7 @@ var IdGeneratorTable = {
   fork: new IdGenerator({prefix: 'fork'}),
   decision: new IdGenerator({prefix: 'decision'}),
   join: new IdGenerator({prefix: 'join'}),
+  decisionend: new IdGenerator({prefix: 'decisionend'}),
   kill: new IdGenerator({prefix: 'kill'})
 };
 
@@ -544,8 +545,9 @@ var NodeModule = function($, IdGeneratorTable) {
       case 'fork':
         return (child.node_type() == 'join') ? 'related' : 'start';
       case 'decision':
-        return 'start';
+        return (child.node_type() == 'decisionend') ? 'related' : 'start';
       case 'join':
+      case 'decisionend':
         return 'to';
       default:
         return 'ok';
@@ -607,12 +609,16 @@ var NodeModule = function($, IdGeneratorTable) {
       self.view_template = ko.observable('forkTemplate');
     break;
 
+    case 'join':
+      self.view_template = ko.observable('joinTemplate');
+    break;
+
     case 'decision':
       self.view_template = ko.observable('decisionTemplate');
     break;
 
-    case 'join':
-      self.view_template = ko.observable('joinTemplate');
+    case 'decisionend':
+      self.view_template = ko.observable('decisionEndTemplate');
     break;
 
     default:
@@ -1383,7 +1389,7 @@ $.extend(ForkNode.prototype, Node.prototype, {
    * Append a node to the current fork
    * Also adds join node to node.
    * When adding the join node, append will remove all the children from the join!
-   * We need to make sure the join remembers its children.
+   * We need to make sure the join remembers its children since append will replace them.
    * NOTE: Cannot append a fork! Use addChild or replaceChild instead!
    */
   append: function(node) {
@@ -1449,17 +1455,28 @@ $.extend(ForkNode.prototype, Node.prototype, {
    */
   convertToDecision: function() {
     var self = this;
+
     var join = self.join();
     var end = null;
     var child = join.findChildren()[0];
 
-    if (child.findParents().length == 1) {
-      end = child;
-    }
-
-    // Attaches child of join to parents of join
-    join.detach();
+    // Replace join with decision end
+    var decision_end_model = new NodeModel({
+      id: IdGeneratorTable['decisionend'].nextId(),
+      node_type: 'decisionend',
+      workflow: self.workflow(),
+      child_links: join.model.child_links
+    });
+    var decision_end_node = new Node(self._workflow, decision_end_model, self.registry);
+    $.each(decision_end_model.child_links, function(index, link) {
+      link.parent = decision_end_model.id;
+    });
+    var parents = join.findParents();
+    $.each(parents, function(index, parent) {
+      parent.replaceChild(join, decision_end_node);
+    });
 
+    // Replace fork with decision node
     var decision_model = new DecisionModel({
       id: IdGeneratorTable['decision'].nextId(),
       name: self.name(),
@@ -1468,7 +1485,6 @@ $.extend(ForkNode.prototype, Node.prototype, {
       workflow: self.workflow(),
       child_links: self.model.child_links
     });
-
     var default_link = {
       parent: decision_model.id,
       child: self._workflow.end(),
@@ -1482,22 +1498,22 @@ $.extend(ForkNode.prototype, Node.prototype, {
       link.parent = decision_model.id;
     });
 
-    var decision_node = new DecisionNode(self._workflow, decision_model, self.registry);
-    if (end) {
-      decision_node.addEnd(end);
-    }
     var parents = self.findParents();
+    var decision_node = new DecisionNode(self._workflow, decision_model, self.registry);
     decision_node.removeChild(join);
-
-    join.erase();
-    self.erase();
-
-    self.registry.add(decision_node.id(), decision_node);
+    decision_node.addChild(decision_end_node);
 
     $.each(parents, function(index, parent) {
       parent.replaceChild(self, decision_node);
     });
 
+    // Get rid of fork and join in registry
+    join.erase();
+    self.erase();
+
+    // Add decision and decision end to registry
+    self.registry.add(decision_node.id(), decision_node);
+    self.registry.add(decision_end_node.id(), decision_end_node);
   }
 });
 
@@ -1508,55 +1524,6 @@ $.extend(DecisionNode.prototype, ForkNode.prototype, {
     var registry = registry;
 
     var end = null;
-
-    $.each(self.child_links(), function(index, link) {
-      if (link.name() == 'related') {
-        $(registry).bind('registry:add', function(e) {
-          var end = self.end();
-          if (end) {
-            self.removeEnd();
-            self.addEnd(end);
-            $(registry).unbind(e);
-          }
-        });
-      }
-    });
-  },
-
-  // Only a single end allowed for now!
-  // Finds end for branch
-  findEnd: function( ) {
-    var self = this;
-
-    var end = self.end();
-
-    if (!end) {
-      return self._findEnd( self, 0 );
-    }
-
-    return end;
-  },
-
-  _findEnd: function( node, count ) {
-    var self = this;
-
-    var end = null;
-
-    if (node.findParents().length > 1 && --count == 0) {
-      return node;
-    }
-
-    if (node.node_type() == 'decision' || node.node_type() == 'fork') {
-      count++;
-    }
-
-    $.each(node.findChildren(), function(index, node) {
-      if (end == null) {
-        end = self._findEnd(node, count);
-      }
-    });
-
-    return end;
   },
 
   end: function() {
@@ -1573,79 +1540,6 @@ $.extend(DecisionNode.prototype, ForkNode.prototype, {
     return end;
   },
 
-  addEnd: function(node) {
-    var self = this;
-
-    self.child_links.push({
-      parent: ko.observable(self.id()),
-      child: ko.observable(node.id()),
-      name: ko.observable('related'),
-      comment: ko.observable(''),
-    });
-
-    $(node).one('detached', function(e) {
-      var end = self.end();
-
-      if (end.links().length == 1) {
-        self.removeEnd();
-        var new_end = self.findEnd();
-        self.addEnd(new_end);
-      } else {
-        // Should never hit this case.
-        self.removeEnd();
-      }
-    });
-  },
-
-  removeEnd: function() {
-    var self = this;
-
-    var spliceIndex = -1;
-
-    $.each(self.child_links(), function(index, link) {
-      if (link.name() == 'related') {
-        spliceIndex = index;
-      }
-    });
-
-    if (spliceIndex > -1) {
-      var end = registry.get(self.child_links()[spliceIndex].child());
-
-      self.child_links.splice(spliceIndex, 1);
-      return true;
-    }
-
-    return false;
-  },
-
-  isChild: function(node) {
-    var self = this;
-
-    return self._isChild(node, self, 0);
-  },
-
-  _isChild: function( test_child, node, count ) {
-    var self = this;
-
-    var result = node.id() == test_child.id();
-
-    if (node.findParents().length > 1 && --count == 0) {
-      return result;
-    }
-
-    if (node.node_type() == 'decision' || node.node_type() == 'fork') {
-      count++;
-    }
-
-    $.each(node.findChildren(), function(index, node) {
-      if (!result) {
-        result = self._isChild(test_child, node, count);
-      }
-    });
-
-    return result;
-  },
-
   /**
    * Append a node to the current decision
    * Also appends end node to node.
@@ -1654,7 +1548,7 @@ $.extend(DecisionNode.prototype, ForkNode.prototype, {
   append: function(node) {
     var self = this;
 
-    var end = self.findEnd();
+    var end = self.end();
 
     if (end.id() == node.id()) {
       return false;
@@ -1675,7 +1569,7 @@ $.extend(DecisionNode.prototype, ForkNode.prototype, {
     var self = this;
 
     var ret = true;
-    var end = self.findEnd();
+    var end = self.end();
 
     if (end && end.id() == replacement.id()) {
       ret = self.removeChild(child);
@@ -1685,7 +1579,9 @@ $.extend(DecisionNode.prototype, ForkNode.prototype, {
 
     if (self.links().length < 2) {
       self.detach();
+      end.detach();
       self.erase();
+      end.erase();
     }
 
     return ret;
@@ -2019,8 +1915,9 @@ var WorkflowModule = function($, NodeModelChooser, Node, ForkNode, DecisionNode,
         case 'end':
         case 'kill':
         case 'fork':
-        case 'decision':
         case 'join':
+        case 'decision':
+        case 'decisionend':
           return control(node, collection);
         default:
           return normal(node, collection);
@@ -2044,6 +1941,7 @@ var WorkflowModule = function($, NodeModelChooser, Node, ForkNode, DecisionNode,
           case 'end':
           case 'kill':
           case 'join':
+          case 'decisionend':
             return normal(node, collection, false, true);
 
           case 'fork':
@@ -2075,11 +1973,7 @@ var WorkflowModule = function($, NodeModelChooser, Node, ForkNode, DecisionNode,
             });
 
             // Add end
-            if (node.end() && end.id() == node.end().id()) {
-              return methodChooser(end, collection, true, true);
-            } else {
-              return end;
-            }
+            return methodChooser(node.end(), collection, true, true);
 
           default:
             // Should never get here.
@@ -2187,49 +2081,40 @@ var WorkflowModule = function($, NodeModelChooser, Node, ForkNode, DecisionNode,
         // This will make it so that we can drop and drop to the top of a node list within a fork.
         var newParent = self.registry.get(droppable.parent());
 
-        if (newParent.id() != draggable.id()) {
-          if (newParent.isChild(draggable)) {
-            if (draggable.findParents().length > 1) {
-              // End of decision tree is being dragged to the bottom of a branch
-              draggable.detach();
-              newParent.append(draggable);
-              workflow.is_dirty( true );
-              self.rebuild();
-            }
-          } else {
-            switch(newParent.node_type()) {
-            case 'fork':
-            case 'decision':
-              draggable.detach();
-
-              var child = self.registry.get(droppable.child());
-              newParent.replaceChild(child, draggable);
-              draggable.addChild(child);
-            break;
-
-            case 'join':
-              // Join may disappear when we detach...
-              // Remember its children and append to child.
-              var parents = newParent.findParents();
-              draggable.detach();
-
-              if (newParent.findParents().length < 2) {
-                $.each(parents, function(index, parent) {
-                  parent.append(draggable);
-                });
-              } else {
-                newParent.append(draggable);
-              }
-            break;
+        if (newParent.id() != draggable.id() && !newParent.isChild(draggable)) {
+          switch(newParent.node_type()) {
+          case 'fork':
+          case 'decision':
+            draggable.detach();
 
-            default:
-              draggable.detach();
+            var child = self.registry.get(droppable.child());
+            newParent.replaceChild(child, draggable);
+            draggable.addChild(child);
+          break;
+
+          case 'join':
+          case 'decisionend':
+            // Join and decisionend may disappear when we detach...
+            // Remember its children and append to child.
+            var parents = newParent.findParents();
+            draggable.detach();
+
+            if (newParent.findParents().length < 2) {
+              $.each(parents, function(index, parent) {
+                parent.append(draggable);
+              });
+            } else {
               newParent.append(draggable);
-            break;
             }
-            workflow.is_dirty( true );
-            self.rebuild();
+          break;
+
+          default:
+            draggable.detach();
+            newParent.append(draggable);
+          break;
           }
+          workflow.is_dirty( true );
+          self.rebuild();
         }
 
         // Prevent bubbling events
@@ -2272,33 +2157,6 @@ var WorkflowModule = function($, NodeModelChooser, Node, ForkNode, DecisionNode,
         return false;
       });
 
-      // Drop on decision end link
-      self.el.on('drop', '.node-decision-end', function(e, ui) {
-        // draggable should be a node.
-        // droppable should be a decision end link... which should give us the decision node.
-        var draggable = ko.contextFor(ui.draggable[0]).$data;
-        var droppable = ko.contextFor(this).$data;
-        var end = droppable.findEnd(droppable);
-
-        if (end.id() != draggable.id()) {
-          draggable.detach();
-
-          $.each(end.findParents(), function(index, parent) {
-            if (droppable.isChild(parent)) {
-              parent.append(draggable);
-            }
-          });
-
-          droppable.removeEnd();
-          droppable.addEnd(draggable);
-
-          self.rebuild();
-        }
-
-        // Prevent bubbling events
-        return false;
-      });
-
       // Drop on action
       self.el.on('drop', '.node-action', function(e, ui) {
         // draggable should be a node.