Explorar el Código

HUE-1185 [jobsub] Trash for designs

- Update trash manager to use a different set of idioms for retrieving
trashed and available workflows.
- Datatables and KO fixes.
- Reduce number of RPC calls made by templating system.
- Test cases for jobsub views.
- Re-enable jobsub JS test cases.
Abraham Elmahrek hace 12 años
padre
commit
28f84b5

+ 161 - 64
apps/jobsub/src/jobsub/templates/designs.mako

@@ -46,13 +46,29 @@ ${ commonheader(_('Job Designer'), "jobsub", user, "60px") | n,unicode }
     <%def name="actions()">
       <button id="home" class="btn" title="${_('Home')}"><i class="icon-share"></i> ${_('Home')}</button>
       &nbsp;
-      <button id="submit-design" class="btn" title="${_('Submit')}" data-bind="enable: selectedDesignObjects().length == 1"><i class="icon-play"></i> ${_('Submit')}</button>
-      <button id="edit-design" class="btn" title="${_('Edit')}" data-bind="enable: selectedDesignObjects().length == 1 && window.location.hash.substring(0,12) != '#edit-design'"><i class="icon-pencil"></i> ${_('Edit')}</button>
-      <button id="delete-designs" class="btn" title="${_('Delete')}" data-bind="enable: selectedDesignObjects().length > 0"><i class="icon-trash"></i> ${_('Delete')}</button>
-      <button id="copy-designs" class="btn" title="${_('Copy')}" data-bind="enable: selectedDesignObjects().length > 0"><i class="icon-retweet"></i> ${_('Copy')}</button>
+      <!-- ko ifnot: inTrash -->
+        <button id="submit-design" class="btn" title="${_('Submit')}" data-bind="enable: selectedDesignObjects().length == 1"><i class="icon-play"></i> ${_('Submit')}</button>
+        <button id="edit-design" class="btn" title="${_('Edit')}" data-bind="enable: selectedDesignObjects().length == 1 && window.location.hash.substring(0,12) != '#edit-design'"><i class="icon-pencil"></i> ${_('Edit')}</button>
+        <button id="copy-designs" class="btn" title="${_('Copy')}" data-bind="enable: selectedDesignObjects().length > 0"><i class="icon-retweet"></i> ${_('Copy')}</button>
+        <div id="delete-dropdown" class="btn-group" style="display: inline">
+          <button id="delete-btn" class="btn toolbarBtn dropdown-toggle" title="${_('Delete')}" data-toggle="dropdown" data-bind="enable: selectedDesignObjects().length > 0">
+            <i class="icon-remove"></i> ${_('Delete')}
+            <span class="caret"></span>
+          </button>
+          <ul class="dropdown-menu" style="top: auto">
+            <li><a href="javascript:void(0);" id="trash-designs" title="${_('Move to Trash')}"><i class="icon-trash"></i> ${_('Move to Trash')}</a></li>
+            <li><a href="javascript:void(0);" id="destroy-designs" title="${_('Delete forever')}"><i class="icon-bolt"></i> ${_('Delete forever')}</a></li>
+          </ul>
+        </div>
+      <!-- /ko -->
+      <!-- ko if: inTrash -->
+        <button id="restore-designs" class="btn" title="${_('Restore')}" data-bind="enable: selectedDesignObjects().length > 0"><i class="icon-cloud-upload"></i> ${_('Restore')}</button>
+        <button id="destroy-designs" class="btn" title="${_('Delete forever')}" data-bind="enable: selectedDesignObjects().length > 0"><i class="icon-bolt"></i> ${_('Delete forever')}</button>
+      <!-- /ko -->
     </%def>
 
     <%def name="creation()">
+      <!-- ko ifnot: inTrash -->
         <div id="new-action-dropdown" class="btn-group" style="display: inline">
           <a href="#" class="btn new-action-link dropdown-toggle" title="${_('New Action')}" data-toggle="dropdown">
             <i class="icon-plus-sign"></i> ${_('New Action')}
@@ -94,6 +110,12 @@ ${ commonheader(_('Job Designer'), "jobsub", user, "60px") | n,unicode }
             </li>
           </ul>
         </div>
+        &nbsp;&nbsp;
+        <a href="#trashed-designs" class="btn"><i class="icon-trash"></i> ${ _('Trash') }</a>
+      <!-- /ko -->
+      <!-- ko if: inTrash -->
+        <button type="button" id="purge-trashed-designs" class="btn" title="${ _('Delete all the designs') }"><i class="icon-fire"></i> ${ _('Empty') }</button>
+      <!-- /ko -->
     </%def>
   </%actionbar:render>
 
@@ -112,6 +134,7 @@ ${ commonheader(_('Job Designer'), "jobsub", user, "60px") | n,unicode }
           <th>${_('Type')}</th>
           <th>${_('Status')}</th>
           <th>${_('Last modified')}</th>
+          <th class="hide">${_('Trashed')}</th>
         </tr>
       </thead>
       <tbody id="designs" data-bind="template: {name: 'designTemplate', foreach: designs}">
@@ -140,20 +163,60 @@ ${ commonheader(_('Job Designer'), "jobsub", user, "60px") | n,unicode }
       <!-- /ko -->
     </td>
     <td data-bind="click: function(data, event) { window.location = '#edit-design/' + $index() }, text: new Date(last_modified() * 1000).format('%B %d, %Y %I:%M %p'), attr: { 'data-sort-value': last_modified() }"></td>
+    <td data-bind="visible: false, text: is_trashed"></td>
   </tr>
 </script>
 
 <div id="submitWf" class="modal hide fade"></div>
 
-<div id="deleteWf" class="modal hide fade">
-  <form id="deleteWfForm" action="#" method="POST" style="margin:0">
+<div id="trashWf" class="modal hide fade">
+  <form id="trashWfForm" action="#" method="POST" style="margin:0">
     <div class="modal-header">
       <a href="#" class="close" data-dismiss="modal">&times;</a>
-      <h3 id="deleteWfMessage">${_('Delete the selected designs?')}</h3>
+      <h3 id="trashWfMessage">${_('Move the selected designs to trash?')}</h3>
     </div>
     <div class="modal-footer">
       <a href="#" class="btn" data-dismiss="modal">${_('No')}</a>
-      <input type="submit" class="btn btn-danger" value="${_('Yes')}" data-dismiss="modal" data-bind="click: deleteDesigns" />
+      <input type="submit" class="btn btn-danger" value="${_('Yes')}" data-dismiss="modal" data-bind="click: trashDesigns" />
+    </div>
+  </form>
+</div>
+
+<div id="destroyWf" class="modal hide fade">
+  <form id="destroyWfForm" action="#" method="POST" style="margin:0">
+    <div class="modal-header">
+      <a href="#" class="close" data-dismiss="modal">&times;</a>
+      <h3 id="destroyWfMessage">${_('Delete the selected designs?')}</h3>
+    </div>
+    <div class="modal-footer">
+      <a href="#" class="btn" data-dismiss="modal">${_('No')}</a>
+      <input type="submit" class="btn btn-danger" value="${_('Yes')}" data-dismiss="modal" data-bind="click: destroyDesigns" />
+    </div>
+  </form>
+</div>
+
+<div id="purgeWf" class="modal hide fade">
+  <form id="purgeWfForm" action="#" method="POST" style="margin:0">
+    <div class="modal-header">
+      <a href="#" class="close" data-dismiss="modal">&times;</a>
+      <h3 id="purgeWfMessage">${_('Delete all trashed designs?')}</h3>
+    </div>
+    <div class="modal-footer">
+      <a href="#" class="btn" data-dismiss="modal">${_('No')}</a>
+      <input type="submit" class="btn btn-danger" value="${_('Yes')}" data-dismiss="modal" data-bind="click: destroyAllTrashedDesigns" />
+    </div>
+  </form>
+</div>
+
+<div id="restoreWf" class="modal hide fade">
+  <form id="restoreWfForm" action="#" method="POST" style="margin:0">
+    <div class="modal-header">
+      <a href="#" class="close" data-dismiss="modal">&times;</a>
+      <h3 id="restoreWfMessage">${_('Restore the selected designs?')}</h3>
+    </div>
+    <div class="modal-footer">
+      <a href="#" class="btn" data-dismiss="modal">${_('No')}</a>
+      <input type="submit" class="btn btn-danger" value="${_('Yes')}" data-dismiss="modal" data-bind="click: restoreDesigns" />
     </div>
   </form>
 </div>
@@ -172,57 +235,74 @@ ${ commonheader(_('Job Designer'), "jobsub", user, "60px") | n,unicode }
 </div>
 
 <script type="text/javascript" charset="utf-8">
-var AUTOCOMPLETE_PROPERTIES;
-
-$(document).bind('initialize.designs', function() {
-  var designTable, viewModel;
-
-  $("#filterInput").keyup(function() {
-      if (designTable != null){
-          designTable.fnFilter($(this).val());
-      }
-  });
-
-  designTable = $('#designTable').dataTable( {
-    "sPaginationType": "bootstrap",
-    "bLengthChange": false,
-    "sDom": "<'row'r>t<'row'<'span8'i><''p>>",
-    "bDestroy": true,
-    "aoColumns": [
-      { "bSortable": false },
-      null,
-      null,
-      null,
-      null,
-      null,
-      { "sSortDataType": "dom-sort-value", "sType": "numeric" }
-    ],
-    "aaSorting": [[ 5, "desc" ]],
-    "fnPreDrawCallback": function( oSettings ) {
-      if (designs.allSelected()) {
-        designs.selectAll();
-      }
-    },
-    "oLanguage": {
-      "sEmptyTable":     "${_('No data available')}",
-      "sInfo":           "${_('Showing _START_ to _END_ of _TOTAL_ entries')}",
-      "sInfoEmpty":      "${_('Showing 0 to 0 of 0 entries')}",
-      "sInfoFiltered":   "${_('(filtered from _MAX_ total entries)')}",
-      "sZeroRecords":    "${_('No matching records')}",
-      "oPaginate": {
-        "sFirst":    "${_('First')}",
-        "sLast":     "${_('Last')}",
-        "sNext":     "${_('Next')}",
-        "sPrevious": "${_('Previous')}"
-      }
+//// Binding
+var designs = new Designs();
+ko.applyBindings(designs);
+
+// Design table and other variables.
+var designTableOptions = {
+  "sPaginationType": "bootstrap",
+  "bLengthChange": false,
+  "sDom": "<'row'r>t<'row'<'span8'i><''p>>",
+  "bDestroy": true,
+  "aoColumnsDefs": [
+    { "bSortable": false, "aTargets": [ 0 ] },
+    { "sSortDataType": "dom-sort-value", "sType": "numeric", "aTargets": [6] },
+  ],
+  "aaSorting": [[ 5, "desc" ]],
+  "fnPreDrawCallback": function( oSettings ) {
+    if (designs.allSelected()) {
+      designs.selectAll();
     }
-  });
-
+  },
+  "oLanguage": {
+    "sEmptyTable":     "${_('No data available')}",
+    "sInfo":           "${_('Showing _START_ to _END_ of _TOTAL_ entries')}",
+    "sInfoEmpty":      "${_('Showing 0 to 0 of 0 entries')}",
+    "sInfoFiltered":   "${_('(filtered from _MAX_ total entries)')}",
+    "sZeroRecords":    "${_('No matching records')}",
+    "oPaginate": {
+      "sFirst":    "${_('First')}",
+      "sLast":     "${_('Last')}",
+      "sNext":     "${_('Next')}",
+      "sPrevious": "${_('Previous')}"
+    }
+  }
+};
+var designTable = $('#designTable').dataTable( designTableOptions );
+
+function redraw() {
+  designTable.fnDestroy();
+  designTable = $('#designTable').dataTable( designTableOptions );
+  designTable.fnFilter(designs.inTrash().toString(), 7);
+}
+
+function reload() {
+  // Reload with datatables and knockout doesn't work without this.
+  // Remove datatables, reload, then reinitialize datatables.
   $(document).one('load.designs', function() {
     designTable.fnDestroy();
   });
+  designs.load();
+  $(document).one('loaded.designs', function() {
+    designTable = $('#designTable').dataTable( designTableOptions );
+    designTable.fnFilter(designs.inTrash().toString(), 7);
+  });
+}
+
+$(document).bind('initialized.designs', function() {
+  $("#filterInput").keyup(function() {
+    if (designTable != null){
+      designTable.fnFilter($(this).val());
+    }
+  });
 });
-designs.load();
+$(document).bind('saved.design', reload);
+$(document).bind('deleted.design', reload);
+$(document).bind('cloned.design', reload);
+$(document).bind('restored.design', reload);
+
+reload();
 
 /**
  * Using Mustache templating system: http://mustache.github.com/
@@ -232,9 +312,6 @@ designs.load();
  * Routie is used to provide hash routing: http://projects.jga.me/routie/.
  */
 $(document).ready(function() {
-  //// Binding
-  ko.applyBindings(designs);
-
   //// Routes
   // Context matches up with jobsub.templates.js and various templates defined there.
   // If there is an update to any of the templates,
@@ -250,7 +327,7 @@ $(document).ready(function() {
     },
     cancel: {
       name: "${_('Cancel')}",
-      func: "function(data, event) {$root.closeDesign.call($parent, {}); designs.load();}"
+      func: "function(data, event) {$root.closeDesign.call($parent, {}); reload();}"
     },
     name: {
       name: "${ _('Name') }",
@@ -672,7 +749,7 @@ $(document).ready(function() {
        * Create template by calling `getActionTemplate`.
        */
       // Show section only after we've finished the new design process.
-      $(document).one('new.design', function() {
+      $(document).one('created.design', function() {
         showSection('design');
       });
 
@@ -696,7 +773,7 @@ $(document).ready(function() {
       }
 
       // Show section only after we've finished the edit design process.
-      $(document).one('edit.design', function() {
+      $(document).one('edited.design', function() {
         if (designObject.design().editable()) {
           $('#design input').removeAttr('disabled');
           $('#design textarea').removeAttr('disabled');
@@ -716,8 +793,17 @@ $(document).ready(function() {
       designs.select(index);
       designs.editDesign();
     },
+    'trashed-designs': function() {
+      $('#home').removeAttr('disabled');
+      designs.inTrash(true);
+      showSection('list-designs');
+      redraw();
+    },
     'list-designs': function() {
+      $('#home').removeAttr('disabled');
+      designs.inTrash(false);
       showSection('list-designs');
+      redraw();
     }
   });
   routie('list-designs');
@@ -733,19 +819,30 @@ $(document).ready(function() {
       }
     );
   });
-  $('#edit-design').click(function() {
+  $('body').on('click', '#edit-design', function() {
     routie('edit-design/' + designs.selectedIndex());
   });
-  $('#delete-designs').click(function() {
-    $('#deleteWf').modal('show');
+  $('body').on('click', '#trash-designs', function() {
+    $('#trashWf').modal('show');
+  });
+  $('body').on('click', '#destroy-designs', function() {
+    $('#destroyWf').modal('show');
   });
-  $('#copy-designs').click(function() {
+  $('body').on('click', '#purge-trashed-designs', function() {
+    $('#purgeWf').modal('show');
+  });
+  $('body').on('click', '#restore-designs', function() {
+    $('#restoreWf').modal('show');
+  });
+  $('body').on('click', '#copy-designs', function() {
     designs.cloneDesigns();
   });
   $('#home').click(function() {
     routie('list-designs');
   });
+
   // load the autocomplete properties
+  var AUTOCOMPLETE_PROPERTIES;
   $.getJSON("${ url('oozie:autocomplete_properties') }", function (properties) {
     AUTOCOMPLETE_PROPERTIES = properties;
   });

+ 11 - 4
apps/jobsub/src/jobsub/templates/jasmine.mako

@@ -1,15 +1,22 @@
-<%namespace name="listDesigns" file="list_designs.mako" />
+<%namespace name="listDesigns" file="designs.mako" />
 <%inherit file="common_jasmine.mako"/>
 
 <%block name="specs">
-    <script src="/static/ext/js/moment.min.js"></script>
-    <script src="static/js/jobsub.ko.js"></script>
+    <script src="/static/ext/js/mustache.js" type="text/javascript" charset="utf-8"></script>
+    <script src="/static/ext/js/routie-0.3.0.min.js" type="text/javascript" charset="utf-8"></script>
+    <script src="/static/ext/js/knockout-2.1.0.js" type="text/javascript" charset="utf-8"></script>
+    <script src="/static/ext/js/knockout.mapping-2.3.2.js" type="text/javascript" charset="utf-8"></script>
+    <script src="/static/ext/js/moment.min.js" type="text/javascript" charset="utf-8"></script>
+    <script src="/oozie/static/js/workflow.models.js" type="text/javascript" charset="utf-8"></script>
+    <script src="/oozie/static/js/workflow.node-fields.js" type="text/javascript" charset="utf-8"></script>
+    <script src="/jobsub/static/js/jobsub.templates.js" type="text/javascript" charset="utf-8"></script>
+    <script src="/jobsub/static/js/jobsub.ko.js" type="text/javascript" charset="utf-8"></script>
+    <script src="/jobsub/static/js/jobsub.js" type="text/javascript" charset="utf-8"></script>
     <script src="static/jasmine/jobsubSpec.js"></script>
 </%block>
 
 
 <%block name="fixtures">
   <div style="display:none">
-    ${listDesigns.layout()}
   </div>
 </%block>

+ 33 - 4
apps/jobsub/src/jobsub/tests.py

@@ -111,19 +111,48 @@ class TestJobsubWithHadoop(OozieServerProvider):
     assert_equal(response.status_code, 200)
 
   def test_delete_design(self):
-    assert_equal(1, Workflow.objects.count())
+    # Trash
+    assert_equal(1, Workflow.objects.available().count())
     response = self.client.post(reverse('jobsub.views.delete_design',
       kwargs={'design_id': self.design.id}),
       follow=True,
       HTTP_X_REQUESTED_WITH='XMLHttpRequest')
     assert_equal(response.status_code, 200)
-    assert_equal(0, Workflow.objects.count())
+    assert_equal(0, Workflow.objects.available().count())
+    assert_equal(1, Workflow.objects.trashed().count())
+
+    # Destroy
+    response = self.client.post(reverse('jobsub.views.delete_design',
+      kwargs={'design_id': self.design.id}) + '?skip_trash',
+      follow=True,
+      HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+    assert_equal(response.status_code, 200)
+    assert_equal(0, Workflow.objects.available().count())
+    assert_equal(0, Workflow.objects.trashed().count())
 
   def test_clone_design(self):
-    assert_equal(1, Workflow.objects.count())
+    assert_equal(1, Workflow.objects.available().count())
     response = self.client.post(reverse('jobsub.views.clone_design',
       kwargs={'design_id': self.design.id}),
       follow=True,
       HTTP_X_REQUESTED_WITH='XMLHttpRequest')
     assert_equal(response.status_code, 200)
-    assert_equal(2, Workflow.objects.count())
+    assert_equal(2, Workflow.objects.available().count())
+
+  def test_restore_design(self):
+    assert_equal(1, Workflow.objects.available().count())
+    response = self.client.post(reverse('jobsub.views.delete_design',
+      kwargs={'design_id': self.design.id}),
+      follow=True,
+      HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+    assert_equal(response.status_code, 200)
+    assert_equal(0, Workflow.objects.available().count())
+    assert_equal(1, Workflow.objects.trashed().count())
+
+    response = self.client.post(reverse('jobsub.views.restore_design',
+      kwargs={'design_id': self.design.id}),
+      follow=True,
+      HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+    assert_equal(response.status_code, 200)
+    assert_equal(1, Workflow.objects.available().count())
+    assert_equal(0, Workflow.objects.trashed().count())

+ 2 - 1
apps/jobsub/src/jobsub/urls.py

@@ -30,7 +30,8 @@ urlpatterns = patterns(
   url(r'^designs/(?P<design_id>\d+)/save$', 'save_design'),
   url(r'^designs/(?P<design_id>\d+)/clone$', 'clone_design'),
   url(r'^designs/(?P<design_id>\d+)/delete$', 'delete_design'),
+  url(r'^designs/(?P<design_id>\d+)/restore$', 'restore_design'),
 
   # Jasmine - Skip until rewritten
-  # url(r'^jasmine', 'views.jasmine'),
+  url(r'^jasmine', 'jasmine'),
 )

+ 55 - 12
apps/jobsub/src/jobsub/views.py

@@ -52,21 +52,21 @@ from jobsub.management.commands import jobsub_setup
 LOG = logging.getLogger(__name__)
 
 
-def list_designs(request):
-  '''
-  List all workflow designs. Result sorted by last modification time.
-  Query params:
+def _list_designs(request, owner, name, order_by='-last_modified'):
+  """
+  Fetch all workflow designs.
+  parameters:
     owner       - Substring filter by owner field
     name        - Substring filter by design name field
-  '''
+    order_by    - Order by string in django ORM format
+    is_trashed  - Boolean filter for trash or available
+  """
   data = Workflow.objects.filter(managed=False)
-  owner = request.GET.get('owner', '')
-  name = request.GET.get('name', '')
   if owner:
       data = data.filter(owner__username__icontains=owner)
   if name:
       data = data.filter(name__icontains=name)
-  data = data.order_by('-last_modified')
+  data = data.order_by(order_by)
 
   designs = []
   for design in data:
@@ -79,12 +79,29 @@ def list_designs(request):
         'node_type': design.start.get_child('to').node_type,
         'last_modified': py_time.mktime(design.last_modified.timetuple()),
         'editable': design.owner.id == request.user.id,
-        'is_shared': design.is_shared
+        'is_shared': design.is_shared,
+        'is_trashed': design.is_trashed
       }
       designs.append(ko_design)
 
+  return designs
+
+
+def list_designs(request):
+  '''
+  List all workflow designs. Result sorted by last modification time.
+  Query params:
+    owner       - Substring filter by owner field
+    name        - Substring filter by design name field
+  '''
+  data = Workflow.objects.filter(managed=False)
+  owner = request.GET.get('owner', '')
+  name = request.GET.get('name', '')
+
   if request.is_ajax():
-    return render_json(designs, js_safe=True)
+    return render_json({
+      'designs': _list_designs(request, owner, name)
+    }, js_safe=True)
   else:
     return render("designs.mako", request, {
       'currentuser': request.user,
@@ -111,15 +128,41 @@ def delete_design(request, design_id):
   if request.method != 'POST':
     raise StructuredException(code="METHOD_NOT_ALLOWED_ERROR", message=_('Must be POST request.'), error_code=405)
 
+  skip_trash = 'skip_trash' in request.GET
+
+  try:
+    workflow = _get_design(design_id)
+    _check_permission(request, workflow.owner.username,
+                      _("Access denied: delete workflow %(id)s.") % {'id': design_id},
+                      allow_root=True)
+    if skip_trash:
+      Workflow.objects.destroy(workflow, request.fs)
+    else:
+      workflow.delete(skip_trash=False)
+
+  except Workflow.DoesNotExist:
+    if skip_trash:
+      LOG.error("Trying to delete non-existent workflow (id %s)" % design_id)
+    else:
+      LOG.error("Trying to trash non-existent workflow (id %s)" % design_id)
+    raise StructuredException(code="NOT_FOUND", message=_('Could not find design.'), error_code=404)
+
+  return render_json({})
+
+
+def restore_design(request, design_id):
+  if request.method != 'POST':
+    raise StructuredException(code="METHOD_NOT_ALLOWED_ERROR", message=_('Must be POST request.'), error_code=405)
+
   try:
     workflow = _get_design(design_id)
     _check_permission(request, workflow.owner.username,
                       _("Access denied: delete workflow %(id)s.") % {'id': design_id},
                       allow_root=True)
-    Workflow.objects.destroy(workflow, request.fs)
+    workflow.restore()
 
   except Workflow.DoesNotExist:
-    LOG.error("Trying to delete non-existent workflow (id %s)" % (design_id,))
+    LOG.error("Trying to restore non-existent workflow (id %s)" % (design_id,))
     raise StructuredException(code="NOT_FOUND", message=_('Could not find design.'), error_code=404)
 
   return render_json({})

+ 26 - 73
apps/jobsub/static/jasmine/jobsubSpec.js

@@ -1,58 +1,44 @@
 
-describe("JobSubModel", function(){
+describe("Designs", function(){
   var viewModel;
 
-  function getSampleDesign(id, canSubmit, canDelete){
+  function getSampleDesign(id, name, node_type, is_shared, is_trashed, is_editable){
     return {
-      "url_params":"/fake/params/url/"+id,
-      "description":"[Sample] Jasmine Job "+id,
-      "last_modified":1350993390 + id,
-      "can_delete":canDelete,
-      "owner":"jasmine",
-      "url_edit":"/fake/edit/url/"+id,
-      "url_submit":"/fake/submit/url/"+id,
-      "id":id,
-      "can_submit":canSubmit,
-      "name":"jasmine_job_"+id,
-      "url_clone":"/fake/clone/url/"+id,
-      "url_delete":"/fake/delete/url/"+id,
-      "type":"mapreduce"
-    }
+      "is_shared": is_shared, 
+      "node_type": node_type, 
+      "last_modified": 1366678126.0, 
+      "name": name, 
+      "owner": "hdfs", 
+      "editable": is_editable, 
+      "id": id, 
+      "is_trashed": is_trashed, 
+      "description": ""
+    };
   }
 
 
-  var sampleDesigns = [
-    getSampleDesign(1, true, true),
-    getSampleDesign(2, false, true),
-    getSampleDesign(3, false, false)
+  var models = [
+    getSampleDesign(1, 'test1', 'mapreduce', true, false, true),
+    getSampleDesign(2, 'test2', 'java', true, false, true),
+    getSampleDesign(3, 'test3', 'shell', true, false, true),
   ];
 
   beforeEach(function(){
-    viewModel = new JobSubModel(sampleDesigns);
+    viewModel = new Designs({models: models});
     ko.applyBindings(viewModel);
   });
 
-  it("should render the design table on applyBindings", function(){
-    expect($("#designs tr").length).toEqual(viewModel.designs().length);
-  });
-
   describe("When clicking select all", function(){
 
-    it("should select first all designs and deselect them all", function(){
-      viewModel.selectAll();
-      expect(viewModel.selectedDesigns().length).toEqual(viewModel.designs().length);
-      viewModel.selectAll();
-      expect(viewModel.selectedDesigns().length).toEqual(0);
-    });
-
-    it("should add a css class to the 'Select All' checkbox", function(){
+    it("should select all designs and deselect them all", function(){
       viewModel.selectAll();
-      expect($("#selectAll").hasClass("icon-ok")).toBeTruthy();
-    });
-
-    it("should disable the action buttons", function(){
-      viewModel.selectAll();
-      expect($(".btn:enabled").length).toEqual(0);
+      expect(viewModel.selectedDesignObjects().length).toEqual(viewModel.designs().length);
+      viewModel.deselectAll();
+      expect(viewModel.selectedDesignObjects().length).toEqual(0);
+      viewModel.toggleSelectAll();
+      expect(viewModel.selectedDesignObjects().length).toEqual(viewModel.designs().length);
+      viewModel.toggleSelectAll();
+      expect(viewModel.selectedDesignObjects().length).toEqual(0);
     });
 
   });
@@ -61,40 +47,7 @@ describe("JobSubModel", function(){
 
     it("should return a selectedDesign", function(){
       viewModel.designs()[0].selected(true);
-      expect(viewModel.selectedDesign().name).toEqual(viewModel.designs()[0].name);
-    });
-
-    it("should always enable the clone button", function(){
-      viewModel.designs()[0].selected(true);
-      expect($(".btn:enabled:contains('Clone')").length).toEqual(1);
-      viewModel.designs()[0].selected(false);
-      expect($(".btn:enabled:contains('Clone')").length).toEqual(0);
-      viewModel.designs()[1].selected(true);
-      expect($(".btn:enabled:contains('Clone')").length).toEqual(1);
-    });
-
-    it("should enable the submit button if canSubmit is true", function(){
-      viewModel.designs()[0].selected(true);
-      expect($(".btn:enabled:contains('Submit')").length).toEqual(1);
-      viewModel.designs()[0].selected(false);
-      viewModel.designs()[1].selected(true);
-      expect($(".btn:enabled:contains('Submit')").length).toEqual(0);
-    });
-
-    it("should enable the edit button if canSubmit is true", function(){
-      viewModel.designs()[0].selected(true);
-      expect($(".btn:enabled:contains('Edit')").length).toEqual(1);
-      viewModel.designs()[0].selected(false);
-      viewModel.designs()[1].selected(true);
-      expect($(".btn:enabled:contains('Edit')").length).toEqual(0);
-    });
-
-    it("should enable the delete button if canDelete is true", function(){
-      viewModel.designs()[1].selected(true);
-      expect($(".btn:enabled:contains('Delete')").length).toEqual(1);
-      viewModel.designs()[1].selected(false);
-      viewModel.designs()[2].selected(true);
-      expect($(".btn:enabled:contains('Delete')").length).toEqual(0);
+      expect(viewModel.selectedDesign().name).toEqual(viewModel.designs()[0].design().name);
     });
 
   });

+ 1 - 4
apps/jobsub/static/js/jobsub.js

@@ -219,7 +219,4 @@ $.each(events, function(index, event) {
 });
 
 $(document).bind('error.design', addFileBrowseButton);
-$(document).bind('save.design', function() {designs.load();});
-$(document).bind('delete.design', function() {designs.load();});
-$(document).bind('clone.design', function() {designs.load();});
-$(document).bind('load.designs', function() { routie('list-designs'); });
+$(document).bind('loaded.designs', function() { routie('list-designs'); });

+ 95 - 40
apps/jobsub/static/js/jobsub.ko.js

@@ -21,8 +21,10 @@ var Design = (function($, ko, NodeFields) {
   var module = function(options) {
     var self = this;
 
-    self.options = {};
-    self.model = {};
+    self.options = options || {
+      model: {}
+    };
+    self.model = self.options.model;
 
     self.initialize(options);
   };
@@ -34,6 +36,8 @@ var Design = (function($, ko, NodeFields) {
     initialize: function(options) {
       var self = this;
 
+      $(document).trigger('initialize.design', [options, self]);
+
       self.options = $.extend(self.options, options);
       self.model = options.model;
 
@@ -97,6 +101,23 @@ var Design = (function($, ko, NodeFields) {
         }
       }, MAPPING_OPTIONS), self);
 
+      if ('files' in self) {
+        var files = self.files();
+        self.files = ko.observableArray([]);
+
+        // ['file', ...] => [{'name': 'file', 'dummy': ''}, ...].
+        $.each(files, function(index, filename) {
+          var prop = { name: ko.observable(filename), dummy: ko.observable("") };
+          prop.name.subscribe(function(value) {
+            self.files.valueHasMutated();
+          });
+          prop.dummy.subscribe(function(value) {
+            self.files.valueHasMutated();
+          });
+          self.files.push(prop);
+        });
+      }
+
       // hack on '<key>ErrorClass' and '<key>Condition'.
       $.each(self.__ko_mapping__, function(key, enabled) {
         if (ko.isObservable(self[key])) {
@@ -123,24 +144,7 @@ var Design = (function($, ko, NodeFields) {
         self.editable = ko.observable(false);
       }
 
-      if ('files' in self) {
-        var files = self.files();
-        self.files = ko.observableArray([]);
-
-        // ['file', ...] => [{'name': 'file', 'dummy': ''}, ...].
-        $.each(files, function(index, filename) {
-          var prop = { name: ko.observable(filename), dummy: ko.observable("") };
-          prop.name.subscribe(function(value) {
-            self.files.valueHasMutated();
-          });
-          prop.dummy.subscribe(function(value) {
-            self.files.valueHasMutated();
-          });
-          self.files.push(prop);
-        });
-      }
-
-      $(document).trigger('initialize.design', [options, self]);
+      $(document).trigger('initialized.design', [options, self]);
     },
     request: function(url, options) {
       var self = this;
@@ -159,8 +163,10 @@ var Design = (function($, ko, NodeFields) {
       var self = this;
       var options = $.extend({
         success: function(data) {
-          self.is_dirty(false);
           $(document).trigger('load.design', [options, data, self]);
+          self.is_dirty(false);
+          self.initialize({model: data});
+          $(document).trigger('loaded.design', [options, data, self]);
         }
       }, options);
       this.request('/jobsub/designs/' + self.id(), options);
@@ -169,6 +175,7 @@ var Design = (function($, ko, NodeFields) {
       // First try to save, then update error list if fail.
       // Response should be json object. IE: {data: {errors: {files: ['example', ...], ... }}}
       var self = this;
+      $(document).trigger('save.design', [options]);
       var model_dict = {};
       $.each(ko.mapping.toJS(self), function(key, value) {
         if (key != 'errors') {
@@ -198,30 +205,47 @@ var Design = (function($, ko, NodeFields) {
           }
         },
         success: function(data) {
-          $(document).trigger('save.design', [options, data]);
+          $(document).trigger('saved.design', [options, data]);
         }
       }, options);
       self.request((self.new()) ? '/jobsub/designs/'+self.node_type()+'/new' : '/jobsub/designs/'+self.id()+'/save', options);
     },
     clone: function(options) {
       var self = this;
+      $(document).trigger('clone.design', [options]);
       var options = $.extend({
         type: 'POST',
         success: function(data) {
-          $(document).trigger('clone.design', [options, data]);
+          $(document).trigger('cloned.design', [options, data]);
         }
       }, options);
       this.request('/jobsub/designs/' + self.id() + '/clone', options);
     },
-    delete: function(options) {
+    delete: function(skip_trash, options) {
+      var self = this;
+      $(document).trigger('delete.design', [options]);
+      var options = $.extend({
+        type: 'POST',
+        success: function(data) {
+          $(document).trigger('deleted.design', [options, data]);
+        }
+      }, options);
+      if (skip_trash) {
+        this.request('/jobsub/designs/' + self.id() + '/delete?skip_trash=true', options);
+      } else {
+        this.request('/jobsub/designs/' + self.id() + '/delete', options);
+      }
+    },
+    restore: function(options) {
       var self = this;
+      $(document).trigger('restore.design', [options]);
       var options = $.extend({
         type: 'POST',
         success: function(data) {
-          $(document).trigger('delete.design', [options, data]);
+          $(document).trigger('restored.design', [options, data]);
         }
       }, options);
-      this.request('/jobsub/designs/' + self.id() + '/delete', options);
+      this.request('/jobsub/designs/' + self.id() + '/restore', options);
     },
 
     // More node field methods
@@ -255,12 +279,21 @@ var Designs = (function($, ko, NodeModelChooser) {
     var self = this;
 
     self.options = options || {
-      models: []
+      models: [],
     };
 
     self.temporary = ko.observable();
-
+    self.inTrash = ko.observable(false);
     self.designs = ko.observableArray([]);
+    self.trashedDesignObjects = ko.computed(function() {
+      var selected = [];
+      $.each(self.designs(), function(index, designObject) {
+        if (designObject.design().is_trashed()) {
+          selected.push(designObject);
+        }
+      });
+      return selected;
+    });
     self.selectedDesignObjects = ko.computed(function() {
       var selected = [];
       $.each(self.designs(), function(index, designObject) {
@@ -299,11 +332,12 @@ var Designs = (function($, ko, NodeModelChooser) {
   $.extend(module.prototype, {
     initialize: function(options) {
       var self = this;
+      $(document).trigger('initialize.designs', [options, self]);
 
       self.options = $.extend(self.options, options);
 
       self.designs.removeAll();
-      self.createDesigns(self.options.models);
+      self.designs(self.createDesigns(self.options.models));
       self.temporary({
         design: ko.observable(null),
         selected: ko.observable(false),
@@ -311,7 +345,7 @@ var Designs = (function($, ko, NodeModelChooser) {
       })
       self.deselectAll();
 
-      $(document).trigger('initialize.designs', [options, self]);
+      $(document).trigger('initialized.designs', [options, self]);
     },
     load: function(options) {
       // Fetch designs from backend.
@@ -321,8 +355,10 @@ var Designs = (function($, ko, NodeModelChooser) {
         dataType: 'json',
         type: 'GET',
         success: function(data) {
+          // data = { designs: designs: [ {'is_trashed': ..., ...}, ... ] }
           $(document).trigger('load.designs', [options, data]);
-          self.initialize({models: data});
+          self.initialize({models: data.designs});
+          $(document).trigger('loaded.designs', [options, data]);
         },
         error: $.noop
       }, options || {});
@@ -352,13 +388,15 @@ var Designs = (function($, ko, NodeModelChooser) {
     },
     createDesigns: function(models) {
       var self = this;
+      var designs = [];
       $.each(models, function(index, model) {
-        self.designs.push({
+        designs.push({
           design: ko.observable(self.createDesign(model)),
           selected: ko.observable(false),
           template: ko.observable(model.node_type)
         });
       });
+      return designs;
     },
     toggleSelect: function(index) {
       var self = this;
@@ -392,6 +430,7 @@ var Designs = (function($, ko, NodeModelChooser) {
     //// Design delegation
     newDesign: function(node_type) {
       var self = this;
+      $(document).trigger('create.design', [design]);
       var design = self.createDesign({
         id: null,
         node_type: node_type,
@@ -403,7 +442,7 @@ var Designs = (function($, ko, NodeModelChooser) {
       self.temporary().design(design);
       // Do not do any thing with any other design.
       self.deselectAll();
-      $(document).trigger('new.design', [design]);
+      $(document).trigger('created.design', [design]);
     },
     saveDesign: function(data, event) {
       var self = this;
@@ -415,28 +454,46 @@ var Designs = (function($, ko, NodeModelChooser) {
         designObject.design().clone();
       });
     },
-    deleteDesigns: function() {
+    trashDesigns: function() {
       var self = this;
       $.each(self.selectedDesignObjects(), function(index, designObject) {
         designObject.design().delete();
       });
     },
+    destroyDesigns: function() {
+      var self = this;
+      $.each(self.selectedDesignObjects(), function(index, designObject) {
+        designObject.design().delete(true);
+      });
+    },
+    destroyAllTrashedDesigns: function() {
+      var self = this;
+      $.each(self.trashedDesignObjects(), function(index, designObject) {
+        designObject.design().delete(true);
+      });
+    },
+    restoreDesigns: function() {
+      var self = this;
+      $.each(self.selectedDesignObjects(), function(index, designObject) {
+        designObject.design().restore(true);
+      });
+    },
     editDesign: function(index) {
       var self = this;
+      $(document).trigger('edit.design', [design]);
       if (self.selectedDesignObject()) {
         var design = self.selectedDesignObject().design();
         if (design.is_dirty()) {
-          $(document).one('load.design', function(e, options, data, design) {
-            design.initialize({model: data});
+          $(document).one('loaded.design', function(e, options, data, design) {
             self.temporary().design(design);
             self.temporary().template(self.selectedDesignObject().template());
-            $(document).trigger('edit.design', [design]);
+            $(document).trigger('edited.design', [design]);
           });
           design.load();
         } else {
           self.temporary().design(design);
           self.temporary().template(self.selectedDesignObject().template());
-          $(document).trigger('edit.design', [design]);
+          $(document).trigger('edited.design', [design]);
         }
       }
     },
@@ -449,5 +506,3 @@ var Designs = (function($, ko, NodeModelChooser) {
 
   return module;
 })($, ko, nodeModelChooser);
-
-var designs = new Designs({models: []});

+ 26 - 7
apps/jobsub/static/js/jobsub.templates.js

@@ -57,28 +57,47 @@ var Templates = (function($, ko) {
         arguments_envvars: 'static/templates/widgets/params.html',
         params_arguments: 'static/templates/widgets/params.html',
         capture_output: 'static/templates/widgets/checkbox.html'
-      }
+      },
     }, options);
 
     self.initialize(options);
   };
 
+  function invertDictionary(dict) {
+    var inverse = {};
+    $.each(dict, function(key, value) {
+      if (value in inverse) {
+        inverse[value].push(key);
+      } else {
+        inverse[value] = [key];
+      }
+    });
+    return inverse;
+  }
+
   $.extend(module.prototype, {
     initialize: function(options) {
       var self = this;
 
+      var reverse_partials = invertDictionary(options.partials);
+      var reverse_actions = invertDictionary(options.actions);
+
       self.partials = {};
-      $.each(options.partials, function(widget_id, url) {
+      $.each(reverse_partials, function(url, widget_ids) {
         $.get(url, function(data) {
-          self.partials[widget_id] = data;
-        })
+          $.each(widget_ids, function(index, widget_id) {
+            self.partials[widget_id] = data;
+          });
+        });
       });
 
       self.actions = {};
-      $.each(options.actions, function(action_id, url) {
+      $.each(reverse_actions, function(url, widget_ids) {
         $.get(url, function(data) {
-          self.actions[action_id] = data;
-        })
+          $.each(widget_ids, function(index, widget_id) {
+            self.actions[widget_id] = data;
+          });
+        });
       });
     },
     getActionTemplate: function(id, context) {

+ 2 - 2
apps/oozie/src/oozie/forms.py

@@ -271,7 +271,7 @@ class SubWorkflowForm(forms.ModelForm):
     user = kwargs.pop('user')
     workflow = kwargs.pop('workflow')
     super(SubWorkflowForm, self).__init__(*args, **kwargs)
-    choices=((wf.id, wf) for wf in Workflow.objects.filter(owner=user).exclude(id=workflow.id))
+    choices=((wf.id, wf) for wf in Workflow.objects.available().filter(owner=user).exclude(id=workflow.id))
     self.fields['sub_workflow'] = forms.ChoiceField(choices=choices, widget=forms.RadioSelect(attrs={'class':'radio'}))
 
   class Meta:
@@ -348,7 +348,7 @@ class CoordinatorForm(forms.ModelForm):
     user = kwargs['user']
     del kwargs['user']
     super(CoordinatorForm, self).__init__(*args, **kwargs)
-    qs = Workflow.objects.filter(Q(is_shared=True) | Q(owner=user))
+    qs = Workflow.objects.available().filter(Q(is_shared=True) | Q(owner=user))
     workflows = []
     for workflow in qs:
       if workflow.is_accessible(user):

+ 1 - 1
apps/oozie/src/oozie/import_workflow.py

@@ -503,7 +503,7 @@ def _resolve_subworkflow_from_deployment_dir(fs, workflow, app_path):
   except Exception, e:
     raise RuntimeError(_("Could not find workflow at path %s") % app_path)
 
-  for subworkflow in Workflow.objects.all():
+  for subworkflow in Workflow.objects.available():
     if subworkflow.deployment_dir == app_path:
       if workflow.owner.id != subworkflow.owner.id:
         raise RuntimeError(_("Subworkflow is not owned by %s") % workflow.owner)

+ 1 - 1
apps/oozie/src/oozie/models.py

@@ -61,7 +61,7 @@ class TrashManager(models.Manager):
   def trashed(self):
     return super(TrashManager, self).get_query_set().filter(is_trashed=True)
 
-  def get_query_set(self):
+  def available(self):
     return super(TrashManager, self).get_query_set().filter(is_trashed=False)
 
 

+ 1 - 1
apps/oozie/src/oozie/templates/editor/list_trashed_workflows.mako

@@ -45,7 +45,7 @@ ${ layout.menubar(section='workflows') }
     </%def>
 
     <%def name="creation()">
-      <button type="button" id="purge-btn" class="btn" title="${ _('Restore all the workflows') }">
+      <button type="button" id="purge-btn" class="btn" title="${ _('Delete all the workflows') }">
         <i class="icon-fire"></i> ${ _('Empty') }
       </button>
     </%def>

+ 33 - 32
apps/oozie/src/oozie/tests.py

@@ -184,9 +184,6 @@ class OozieMockBase(object):
     oozie_api.OozieApi = MockOozieApi
     oozie_api._api_cache = None
 
-    History.objects.all().delete()
-    Coordinator.objects.all().delete()
-
     self.c = make_logged_in_client(is_superuser=False)
     grant_access("test", "test", "oozie")
     self.user = User.objects.get(username='test')
@@ -198,6 +195,10 @@ class OozieMockBase(object):
     Workflow.objects.check_workspace = Workflow.objects.original_check_workspace
     oozie_api._api_cache = None
 
+    History.objects.all().delete()
+    Coordinator.objects.all().delete()
+    Bundle.objects.all().delete()
+
 
   def setup_simple_workflow(self):
     """ Creates a linear workflow """
@@ -985,13 +986,13 @@ class TestEditor(OozieMockBase):
 
   def test_clone_coordinator(self):
     coord = create_coordinator(self.wf, self.c)
-    coordinator_count = Coordinator.objects.count()
+    coordinator_count = Coordinator.objects.available().count()
 
     response = self.c.post(reverse('oozie:clone_coordinator', args=[coord.id]), {}, follow=True)
 
     coord2 = Coordinator.objects.latest('id')
     assert_not_equal(coord.id, coord2.id)
-    assert_equal(coordinator_count + 1, Coordinator.objects.count(), response)
+    assert_equal(coordinator_count + 1, Coordinator.objects.available().count(), response)
 
     assert_equal(coord.dataset_set.count(), coord2.dataset_set.count())
     assert_equal(coord.datainput_set.count(), coord2.datainput_set.count())
@@ -1014,7 +1015,7 @@ class TestEditor(OozieMockBase):
 
     # Bulk delete
     response = self.c.post(reverse('oozie:delete_coordinator'), {'job_selection': [coord.id, coord2.id]}, follow=True)
-    assert_equal(coordinator_count - 1, Coordinator.objects.count(), response)
+    assert_equal(coordinator_count - 1, Coordinator.objects.available().count(), response)
 
 
   def test_coordinator_workflow_access_permissions(self):
@@ -1294,11 +1295,11 @@ class TestEditor(OozieMockBase):
 
   def test_trash_workflow(self):
     previous_trashed = len(Workflow.objects.trashed())
-    previous_available = len(Workflow.objects.all())
+    previous_available = len(Workflow.objects.available())
     response = self.c.post(reverse('oozie:delete_workflow'), {'job_selection': [self.wf.id]}, follow=True)
     assert_equal(200, response.status_code, response)
     assert_equal(previous_trashed + 1, len(Workflow.objects.trashed()))
-    assert_equal(previous_available - 1, len(Workflow.objects.all()))
+    assert_equal(previous_available - 1, len(Workflow.objects.available()))
 
 
 class TestEditorBundle(OozieMockBase):
@@ -1314,13 +1315,13 @@ class TestEditorBundle(OozieMockBase):
 
   def test_clone_bundle(self):
     bundle = create_bundle(self.c)
-    bundle_count = Bundle.objects.count()
+    bundle_count = Bundle.objects.available().count()
 
     response = self.c.post(reverse('oozie:clone_bundle', args=[bundle.id]), {}, follow=True)
 
     bundle2 = Bundle.objects.latest('id')
     assert_not_equal(bundle.id, bundle2.id)
-    assert_equal(bundle_count + 1, Bundle.objects.count(), response)
+    assert_equal(bundle_count + 1, Bundle.objects.available().count(), response)
 
     assert_equal(bundle.coordinators.count(), bundle.coordinators.all().count())
 
@@ -1335,16 +1336,16 @@ class TestEditorBundle(OozieMockBase):
 
     # Bulk delete
     response = self.c.post(reverse('oozie:delete_bundle'), {'job_selection': [bundle.id, bundle2.id]}, follow=True)
-    assert_equal(bundle_count - 1, Bundle.objects.count(), response)
+    assert_equal(bundle_count - 1, Bundle.objects.available().count(), response)
 
 
   def test_delete_bundle(self):
     bundle = create_bundle(self.c)
-    bundle_count = Bundle.objects.count()
+    bundle_count = Bundle.objects.available().count()
 
     response = self.c.post(reverse('oozie:delete_bundle'), {'job_selection': [bundle.id]}, follow=True)
 
-    assert_equal(bundle_count - 1, Bundle.objects.count(), response)
+    assert_equal(bundle_count - 1, Bundle.objects.available().count(), response)
 
 
   def test_bundle_gen_xml(self):
@@ -2137,11 +2138,11 @@ class TestEditorWithOozie(OozieBase):
 
 
   def test_clone_workflow(self):
-    workflow_count = Workflow.objects.count()
+    workflow_count = Workflow.objects.available().count()
 
     response = self.c.post(reverse('oozie:clone_workflow', args=[self.wf.id]), {}, follow=True)
 
-    assert_equal(workflow_count + 1, Workflow.objects.count(), response)
+    assert_equal(workflow_count + 1, Workflow.objects.available().count(), response)
 
     wf2 = Workflow.objects.latest('id')
     assert_not_equal(self.wf.id, wf2.id)
@@ -2156,11 +2157,11 @@ class TestEditorWithOozie(OozieBase):
 
     # Bulk delete
     response = self.c.post(reverse('oozie:delete_workflow'), {'job_selection': [self.wf.id, wf2.id]}, follow=True)
-    assert_equal(workflow_count - 1, Workflow.objects.count(), response)
+    assert_equal(workflow_count - 1, Workflow.objects.available().count(), response)
 
 
   def test_import_workflow(self):
-    workflow_count = Workflow.objects.count()
+    workflow_count = Workflow.objects.available().count()
 
     # Create
     filename = os.path.abspath(os.path.dirname(__file__) + "/test_data/0.4/test-mapreduce.xml")
@@ -2176,7 +2177,7 @@ class TestEditorWithOozie(OozieBase):
       'description': ['']
     }, follow=True)
     fh.close()
-    assert_equal(workflow_count + 1, Workflow.objects.count(), response)
+    assert_equal(workflow_count + 1, Workflow.objects.available().count(), response)
 
   def test_delete_workflow(self):
     previous_trashed = len(Workflow.objects.trashed())
@@ -2639,20 +2640,20 @@ class TestDashboard(OozieMockBase):
 
 
   def test_good_workflow_status_graph(self):
-    workflow_count = Workflow.objects.count()
+    workflow_count = Workflow.objects.available().count()
 
     response = self.c.get(reverse('oozie:list_oozie_workflow', args=[MockOozieApi.WORKFLOW_IDS[0]]), {})
 
     assert_true(response.context['workflow_graph'])
-    assert_equal(Workflow.objects.count(), workflow_count)
+    assert_equal(Workflow.objects.available().count(), workflow_count)
 
   def test_bad_workflow_status_graph(self):
-    workflow_count = Workflow.objects.count()
+    workflow_count = Workflow.objects.available().count()
 
     response = self.c.get(reverse('oozie:list_oozie_workflow', args=[MockOozieApi.WORKFLOW_IDS[1]]), {})
 
     assert_true(response.context['workflow_graph'] is None)
-    assert_equal(Workflow.objects.count(), workflow_count)
+    assert_equal(Workflow.objects.available().count(), workflow_count)
 
 
 class TestUtils(OozieMockBase):
@@ -2784,13 +2785,13 @@ def create_workflow(client, workflow_dict=WORKFLOW_DICT):
   Node.objects.filter(workflow__name=name).delete()
   Workflow.objects.filter(name=name).delete()
 
-  workflow_count = Workflow.objects.count()
+  workflow_count = Workflow.objects.available().count()
   response = client.get(reverse('oozie:create_workflow'))
-  assert_equal(workflow_count, Workflow.objects.count(), response)
+  assert_equal(workflow_count, Workflow.objects.available().count(), response)
 
   response = client.post(reverse('oozie:create_workflow'), workflow_dict, follow=True)
   assert_equal(200, response.status_code)
-  assert_equal(workflow_count + 1, Workflow.objects.count(), response)
+  assert_equal(workflow_count + 1, Workflow.objects.available().count(), response)
 
   wf = Workflow.objects.get(name=name)
   assert_not_equal('', wf.deployment_dir)
@@ -2800,27 +2801,27 @@ def create_workflow(client, workflow_dict=WORKFLOW_DICT):
 
 
 def create_coordinator(workflow, client):
-  coord_count = Coordinator.objects.count()
+  coord_count = Coordinator.objects.available().count()
   response = client.get(reverse('oozie:create_coordinator'))
-  assert_equal(coord_count, Coordinator.objects.count(), response)
+  assert_equal(coord_count, Coordinator.objects.available().count(), response)
 
   post = COORDINATOR_DICT.copy()
   post['workflow'] = workflow.id
   response = client.post(reverse('oozie:create_coordinator'), post)
-  assert_equal(coord_count + 1, Coordinator.objects.count(), response)
+  assert_equal(coord_count + 1, Coordinator.objects.available().count(), response)
 
   return Coordinator.objects.get(name='MyCoord')
 
 
 def create_bundle(client):
-  if not Bundle.objects.filter(name='MyBundle').exists():
-    bundle_count = Bundle.objects.count()
+  if not Bundle.objects.available().filter(name='MyBundle').exists():
+    bundle_count = Bundle.objects.available().count()
     response = client.get(reverse('oozie:create_bundle'))
-    assert_equal(bundle_count, Bundle.objects.count(), response)
+    assert_equal(bundle_count, Bundle.objects.available().count(), response)
 
     post = BUNDLE_DICT.copy()
     response = client.post(reverse('oozie:create_bundle'), post)
-    assert_equal(bundle_count + 1, Bundle.objects.count(), response)
+    assert_equal(bundle_count + 1, Bundle.objects.available().count(), response)
 
   return Bundle.objects.get(name='MyBundle')
 

+ 4 - 4
apps/oozie/src/oozie/views/editor.py

@@ -56,7 +56,7 @@ LOG = logging.getLogger(__name__)
 
 
 def list_workflows(request):
-  data = Workflow.objects.filter(managed=True)
+  data = Workflow.objects.available().filter(managed=True)
 
   if not SHARE_JOBS.get() and not request.user.is_superuser:
     data = data.filter(owner=request.user)
@@ -88,7 +88,7 @@ def list_trashed_workflows(request):
 
 
 def list_coordinators(request, workflow_id=None):
-  data = Coordinator.objects
+  data = Coordinator.objects.available()
   if workflow_id is not None:
     data = data.filter(workflow__id=workflow_id)
 
@@ -124,7 +124,7 @@ def list_trashed_coordinators(request, workflow_id=None):
 
 
 def list_bundles(request):
-  data = Bundle.objects
+  data = Bundle.objects.available()
 
   if not SHARE_JOBS.get() and not request.user.is_superuser:
     data = data.filter(owner=request.user)
@@ -342,7 +342,7 @@ def _submit_workflow(user, fs, workflow, mapping):
 
 @check_job_access_permission()
 def schedule_workflow(request, workflow):
-  if Coordinator.objects.filter(workflow=workflow).exists():
+  if Coordinator.objects.available().filter(workflow=workflow).exists():
     request.info(_('You already have some coordinators for this workflow. Submit one or create a new one.'))
     return list_coordinators(request, workflow_id=workflow.id)
   else:

+ 1 - 1
apps/oozie/static/js/workflow.node-fields.js

@@ -28,7 +28,7 @@ var NodeFields = {
   },
   addFile: function(data, event) {
     var self = this;
-    var prop = { name: ko.observable("") };
+    var prop = { name: ko.observable(""), dummy: ko.observable("") };
     prop.name.subscribe(function(value) {
       self.files.valueHasMutated();
     });