Explorar o código

[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 %!s(int64=13) %!d(string=hai) anos
pai
achega
e115cccb5e
Modificáronse 22 ficheiros con 552 adicións e 305 borrados
  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.