Merge branch '2035-arv-mount-tags-folders' into origin-2035-arv-mount-tags-folders
authorPeter Amstutz <peter.amstutz@curoverse.com>
Wed, 7 May 2014 15:39:55 +0000 (11:39 -0400)
committerPeter Amstutz <peter.amstutz@curoverse.com>
Wed, 7 May 2014 15:39:55 +0000 (11:39 -0400)
95 files changed:
apps/workbench/.gitignore
apps/workbench/Gemfile
apps/workbench/Gemfile.lock
apps/workbench/app/assets/javascripts/application.js
apps/workbench/app/assets/javascripts/editable.js
apps/workbench/app/assets/javascripts/folders.js [new file with mode: 0644]
apps/workbench/app/assets/javascripts/folders.js.coffee [new file with mode: 0644]
apps/workbench/app/assets/javascripts/selection.js
apps/workbench/app/assets/stylesheets/application.css.scss
apps/workbench/app/assets/stylesheets/cards.css.scss [new file with mode: 0644]
apps/workbench/app/assets/stylesheets/folders.css.scss [new file with mode: 0644]
apps/workbench/app/assets/stylesheets/sb-admin.css.scss [new file with mode: 0644]
apps/workbench/app/assets/stylesheets/selection.css
apps/workbench/app/controllers/actions_controller.rb
apps/workbench/app/controllers/api_client_authorizations_controller.rb
apps/workbench/app/controllers/application_controller.rb
apps/workbench/app/controllers/collections_controller.rb
apps/workbench/app/controllers/folders_controller.rb [new file with mode: 0644]
apps/workbench/app/controllers/groups_controller.rb
apps/workbench/app/helpers/application_helper.rb
apps/workbench/app/helpers/folders_helper.rb [new file with mode: 0644]
apps/workbench/app/helpers/pipeline_instances_helper.rb
apps/workbench/app/models/arvados_api_client.rb
apps/workbench/app/models/arvados_base.rb
apps/workbench/app/models/arvados_resource_list.rb
apps/workbench/app/models/collection.rb
apps/workbench/app/models/group.rb
apps/workbench/app/models/log.rb
apps/workbench/app/models/user.rb
apps/workbench/app/views/application/_content.html.erb
apps/workbench/app/views/application/_delete_object_button.html.erb
apps/workbench/app/views/application/_show_object_button.html.erb [new file with mode: 0644]
apps/workbench/app/views/application/_show_recent.html.erb
apps/workbench/app/views/application/index.html.erb
apps/workbench/app/views/folders/_show_my_folders.html.erb [new file with mode: 0644]
apps/workbench/app/views/folders/_show_shared_with_me.html.erb [new file with mode: 0644]
apps/workbench/app/views/folders/remove_item.js.erb [new file with mode: 0644]
apps/workbench/app/views/folders/show.html.erb [new file with mode: 0644]
apps/workbench/app/views/layouts/application.html.erb
apps/workbench/app/views/logs/show.html.erb [deleted file]
apps/workbench/config/routes.rb
apps/workbench/test/functional/folders_controller_test.rb [new file with mode: 0644]
apps/workbench/test/integration/folders_test.rb [new file with mode: 0644]
apps/workbench/test/integration/smoke_test.rb
apps/workbench/test/integration/users_test.rb
apps/workbench/test/integration/virtual_machines_test.rb
apps/workbench/test/integration_helper.rb
apps/workbench/test/test_helper.rb
apps/workbench/test/unit/arvados_resource_list_test.rb [new file with mode: 0644]
apps/workbench/test/unit/group_test.rb
apps/workbench/test/unit/helpers/folders_helper_test.rb [new file with mode: 0644]
apps/workbench/test/unit/user_test.rb
doc/api/index.html.textile.liquid
doc/api/methods/groups.html.textile.liquid
doc/api/methods/users.html.textile.liquid
doc/api/schema/Collection.html.textile.liquid
doc/install/client.html.textile.liquid
doc/sdk/python/sdk-python.html.textile.liquid
doc/sdk/ruby/index.html.textile.liquid
sdk/python/arvados/fuse/__init__.py
sdk/python/bin/arv-mount
sdk/python/requirements.txt
sdk/python/setup_fuse.py
sdk/python/test_mount.py
sdk/ruby/.gitignore
sdk/ruby/Gemfile.lock [deleted file]
services/api/.gitignore
services/api/Gemfile
services/api/Gemfile.lock
services/api/Rakefile
services/api/app/controllers/application_controller.rb
services/api/app/controllers/arvados/v1/groups_controller.rb
services/api/app/controllers/user_sessions_controller.rb
services/api/app/models/arvados_model.rb
services/api/app/models/group.rb
services/api/app/models/link.rb
services/api/app/models/node.rb
services/api/app/models/user.rb
services/api/config/routes.rb
services/api/db/migrate/20140501165548_add_unique_name_index_to_links.rb [new file with mode: 0644]
services/api/db/schema.rb
services/api/lib/current_api_client.rb
services/api/lib/load_param.rb
services/api/test/fixtures/api_clients.yml
services/api/test/fixtures/groups.yml
services/api/test/fixtures/links.yml
services/api/test/fixtures/repositories.yml
services/api/test/fixtures/specimens.yml
services/api/test/functional/arvados/v1/groups_controller_test.rb
services/api/test/functional/arvados/v1/links_controller_test.rb
services/api/test/functional/arvados/v1/users_controller_test.rb
services/api/test/integration/user_sessions_test.rb [new file with mode: 0644]
services/api/test/test_helper.rb
services/api/test/unit/group_test.rb
services/api/test/unit/link_test.rb

index afb317b169a9cd1a4f56ce03faa9c1c54720f12f..8502e958bac9c182c0c60eb054b8cb4b7c90723e 100644 (file)
@@ -28,3 +28,6 @@
 
 # This can be a symlink to ../../../doc/.site in dev setups
 /public/doc
+
+# SimpleCov reports
+/coverage
index ee43a895c713c3d995164f35e93a1ed78af659f9..bcbe3ef36fd40d85c02f1302383a9acef2942b81 100644 (file)
@@ -29,6 +29,11 @@ group :test do
   gem 'capybara'
   gem 'poltergeist'
   gem 'headless'
+  # Note: "require: false" here tells bunder not to automatically
+  # 'require' the packages during application startup. Installation is
+  # still mandatory.
+  gem 'simplecov', '~> 0.7.1', require: false
+  gem 'simplecov-rcov', require: false
 end
 
 gem 'jquery-rails'
index e1e2b819542d94a0305c4813cd1f14cba143b707..8e748326a1582871efbef261ed72d2288fe70a87 100644 (file)
@@ -149,6 +149,12 @@ GEM
       multi_json (~> 1.0)
       rubyzip (~> 1.0)
       websocket (~> 1.0.4)
+    simplecov (0.7.1)
+      multi_json (~> 1.0)
+      simplecov-html (~> 0.7.1)
+    simplecov-html (0.7.1)
+    simplecov-rcov (0.2.3)
+      simplecov (>= 0.4.1)
     sprockets (2.2.2)
       hike (~> 1.2)
       multi_json (~> 1.0)
@@ -200,6 +206,8 @@ DEPENDENCIES
   sass
   sass-rails (~> 3.2.0)
   selenium-webdriver
+  simplecov (~> 0.7.1)
+  simplecov-rcov
   sqlite3
   themes_for_rails
   therubyracer
index 189063bcffbcc8f1e9daf64bbb4a02b9dbb6a2db..d66cb9224f0f703dfe8484b83b142d483d9b8901 100644 (file)
@@ -29,7 +29,6 @@ jQuery(function($){
             'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content')
         }
     });
-    $('.editable').editable();
     $('[data-toggle=tooltip]').tooltip();
 
     $('.expand-collapse-row').on('click', function(event) {
index e6799bf78b40d4a3b3ef0cb4cbcb3c764db82d4d..8eea1693e8721efcf401c4c2c3c55bb0205180d1 100644 (file)
@@ -1,4 +1,4 @@
-$.fn.editable.defaults.ajaxOptions = {type: 'put', dataType: 'json'};
+$.fn.editable.defaults.ajaxOptions = {type: 'post', dataType: 'json'};
 $.fn.editable.defaults.send = 'always';
 
 // Default for editing is popup.  I experimented with inline which is a little
@@ -12,9 +12,20 @@ $.fn.editable.defaults.send = 'always';
 $.fn.editable.defaults.params = function (params) {
     var a = {};
     var key = params.pk.key;
-    a.id = params.pk.id;
-    a[key] = {};
+    a.id = $(this).attr('data-object-uuid') || params.pk.id;
+    a[key] = params.pk.defaults || {};
+    // Remove null values. Otherwise they get transmitted as empty
+    // strings in request params.
+    for (i in a[key]) {
+        if (a[key][i] == null)
+            delete a[key][i];
+    }
     a[key][params.name] = params.value;
+    if (!a.id) {
+        a['_method'] = 'post';
+    } else {
+        a['_method'] = 'put';
+    }
     return a;
 };
 
@@ -24,6 +35,44 @@ $.fn.editable.defaults.validate = function (value) {
     }
 }
 
+$(document).
+    on('ready ajax:complete', function() {
+        $('#editable-submit').click(function() {
+            console.log($(this));
+        });
+        $('.editable').
+            editable({
+                success: function(response, newValue) {
+                    // If we just created a new object, stash its UUID
+                    // so we edit it next time instead of creating
+                    // another new object.
+                    if (!$(this).attr('data-object-uuid') && response.uuid) {
+                        $(this).attr('data-object-uuid', response.uuid);
+                    }
+                    if (response.href) {
+                        $(this).editable('option', 'url', response.href);
+                    }
+                    return;
+                }
+            }).
+            on('hidden', function(e, reason) {
+                // After saving a new attribute, update the same
+                // information if it appears elsewhere on the page.
+                if (reason != 'save') return;
+                var html = $(this).html();
+                var uuid = $(this).attr('data-object-uuid');
+                var attr = $(this).attr('data-name');
+                var edited = this;
+                if (uuid && attr) {
+                    $("[data-object-uuid='" + uuid + "']" +
+                      "[data-name='" + attr + "']").each(function() {
+                          if (this != edited)
+                              $(this).html(html);
+                      });
+                }
+            });
+    });
+
 $.fn.editabletypes.text.defaults.tpl = '<input type="text" name="editable-text">'
 
 $.fn.editableform.buttons = '\
diff --git a/apps/workbench/app/assets/javascripts/folders.js b/apps/workbench/app/assets/javascripts/folders.js
new file mode 100644 (file)
index 0000000..10695cf
--- /dev/null
@@ -0,0 +1,12 @@
+$(document).
+    on('ready ajax:complete', function() {
+        $("[data-toggle='x-editable']").click(function(e) {
+            e.stopPropagation();
+            $($(this).attr('data-toggle-selector')).editable('toggle');
+        });
+    }).on('paste keyup change', 'input.search-folder-contents', function() {
+        var q = new RegExp($(this).val(), 'i');
+        $(this).closest('div.panel').find('tbody tr').each(function() {
+            $(this).toggle(!!$(this).text().match(q));
+        });
+    });
diff --git a/apps/workbench/app/assets/javascripts/folders.js.coffee b/apps/workbench/app/assets/javascripts/folders.js.coffee
new file mode 100644 (file)
index 0000000..7615679
--- /dev/null
@@ -0,0 +1,3 @@
+# Place all the behaviors and hooks related to the matching controller here.
+# All this logic will automatically be available in application.js.
+# You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/
index d70794dc0a58f41ea71b59b2258934766a824cab..f8dbed59c4f0a2f072189670c5be84bac5fecab6 100644 (file)
@@ -49,15 +49,21 @@ jQuery(function($){
     }
 
     var update_count = function(e) {
+        var html;
+        var this_object_uuid = $('#selection-form-content').
+            closest('form').
+            find('input[name=uuid]').val();
         var lst = get_selection_list();
         $("#persistent-selection-count").text(lst.length);
         if (lst.length > 0) {
-            $('#selection-form-content').html(
-                '<li><a href="#" id="clear_selections_button">Clear selections</a></li>'
-                    + '<li><input type="submit" name="combine_selected_files_into_collection" '
-                    + ' id="combine_selected_files_into_collection" '
-                    + ' value="Combine selected collections and files into a new collection" /></li>'
-                    + '<li class="notification"><table style="width: 100%"></table></li>');
+            html = '<li><a href="#" class="btn btn-xs btn-info" id="clear_selections_button"><i class="fa fa-fw fa-ban"></i> Clear selections</a></li>';
+            if (this_object_uuid.match('-j7d0g-'))
+                html += '<li><button class="btn btn-xs btn-info" type="submit" name="copy_selections_into_folder" id="copy_selections_into_folder"><i class="fa fa-fw fa-folder-open"></i> Copy selections into this folder</button></li>';
+            html += '<li><button class="btn btn-xs btn-info" type="submit" name="combine_selected_files_into_collection" '
+                + ' id="combine_selected_files_into_collection">'
+                + '<i class="fa fa-fw fa-archive"></i> Combine selected collections and files into a new collection</button></li>'
+                + '<li class="notification"><table style="width: 100%"></table></li>';
+            $('#selection-form-content').html(html);
 
             for (var i = 0; i < lst.length; i++) {
                 $('#selection-form-content > li > table').append("<tr>"
index 455e4c0a9fa6cb13553cd121b5c64f427c8fbc90..51c96d7fc87d1cf5be336b4bd4815460f471d48d 100644 (file)
@@ -40,10 +40,17 @@ table.table-justforlayout>tbody>tr>th{
 table.table-justforlayout {
     margin-bottom: 0;
 }
+.smaller-text {
+    font-size: .8em;
+}
 .deemphasize {
     font-size: .8em;
     color: #888;
 }
+.arvados-uuid {
+    font-size: .8em;
+    font-family: monospace;
+}
 table .data-size, .table .data-size {
     text-align: right;
 }
@@ -87,25 +94,6 @@ form.small-form-margin {
     text-decoration: none;
     text-shadow: 0 1px 0 #ffffff;
 }
-/*.navbar .nav .dropdown .dropdown-menu li a {
-    padding: 2px 20px;
-}*/
-
-ul.arvados-nav {
-    list-style: none;
-    padding-left: 0em;
-    margin-left: 0em;
-}
-
-ul.arvados-nav li ul {
-    list-style: none;
-    padding-left: 0;
-}
-
-ul.arvados-nav li ul li {
-    list-style: none;
-    padding-left: 1em;
-}
 
 .dax {
     max-width: 10%;
@@ -147,20 +135,6 @@ span.removable-tag-container {
 li.notification {
     padding: 10px;
 }
-.arvados-nav-container {
-    top: 70px; 
-    height: calc(100% - 70px); 
-    overflow: auto; 
-    z-index: 2;
-}
-
-.arvados-nav-active {
-    background: rgb(66, 139, 202);
-}
-
-.arvados-nav-active a, .arvados-nav-active a:hover {
-    color: white;
-}
 
 // See HeaderRowFixer in application.js
 table.table-fixed-header-row {
@@ -185,3 +159,35 @@ table.table-fixed-header-row tbody {
     overflow-y: auto;
 }
 
+.row-fill-height, .row-fill-height>div[class*='col-'] {
+    display: flex;
+}
+.row-fill-height>div[class*='col-']>div {
+    width: 100%;
+}
+
+/* Show editable popover above side-nav */
+.editable-popup.popover {
+    z-index:1055;
+}
+
+.navbar-nav.side-nav {
+    box-shadow: inset -1px 0 #e7e7e7;
+}
+.navbar-nav.side-nav > li:first-child {
+    margin-top: 5px; /* keep "hover" bg below top nav bottom border */
+}
+.navbar-nav.side-nav > li > a {
+    padding-top: 10px;
+    padding-bottom: 10px;
+}
+.navbar-nav.side-nav > li.dropdown > ul.dropdown-menu > li > a {
+    padding-top: 5px;
+    padding-bottom: 5px;
+}
+.navbar-nav.side-nav a.active,
+.navbar-nav.side-nav a:hover,
+.navbar-nav.side-nav a:focus {
+    border-right: 1px solid #ffffff;
+    background: #ffffff;
+}
diff --git a/apps/workbench/app/assets/stylesheets/cards.css.scss b/apps/workbench/app/assets/stylesheets/cards.css.scss
new file mode 100644 (file)
index 0000000..c9560ad
--- /dev/null
@@ -0,0 +1,85 @@
+.card {
+    padding-top: 20px;
+    margin: 10px 0 20px 0;
+    background-color: #ffffff;
+    border: 1px solid #d8d8d8;
+    border-top-width: 0;
+    border-bottom-width: 2px;
+    -webkit-border-radius: 3px;
+    -moz-border-radius: 3px;
+    border-radius: 3px;
+    -webkit-box-shadow: none;
+    -moz-box-shadow: none;
+    box-shadow: none;
+    -webkit-box-sizing: border-box;
+    -moz-box-sizing: border-box;
+    box-sizing: border-box;
+}
+.card.arvados-object {
+    position: relative;
+    display: inline-block;
+    width: 170px;
+    height: 175px;
+    padding-top: 0;
+    margin-left: 20px;
+    overflow: hidden;
+    vertical-align: top;
+}
+.card.arvados-object .card-top.green {
+    background-color: #53a93f;
+}
+.card.arvados-object .card-top.blue {
+    background-color: #427fed;
+}
+.card.arvados-object .card-top {
+    position: absolute;
+    top: 0;
+    left: 0;
+    display: inline-block;
+    width: 170px;
+    height: 25px;
+    background-color: #ffffff;
+}
+.card.arvados-object .card-info {
+    position: absolute;
+    top: 25px;
+    display: inline-block;
+    width: 100%;
+    height: 101px;
+    overflow: hidden;
+    background: #ffffff;
+    -webkit-box-sizing: border-box;
+    -moz-box-sizing: border-box;
+    box-sizing: border-box;
+}
+.card.arvados-object .card-info .title {
+    display: block;
+    margin: 8px 14px 0 14px;
+    overflow: hidden;
+    font-size: 16px;
+    font-weight: bold;
+    line-height: 18px;
+    color: #404040;
+}
+.card.arvados-object .card-info .desc {
+    display: block;
+    margin: 8px 14px 0 14px;
+    overflow: hidden;
+    font-size: 12px;
+    line-height: 16px;
+    color: #737373;
+    text-overflow: ellipsis;
+}
+.card.arvados-object .card-bottom {
+    position: absolute;
+    bottom: 0;
+    left: 0;
+    display: inline-block;
+    width: 100%;
+    padding: 10px 20px;
+    line-height: 29px;
+    text-align: center;
+    -webkit-box-sizing: border-box;
+    -moz-box-sizing: border-box;
+    box-sizing: border-box;
+}
diff --git a/apps/workbench/app/assets/stylesheets/folders.css.scss b/apps/workbench/app/assets/stylesheets/folders.css.scss
new file mode 100644 (file)
index 0000000..1dea791
--- /dev/null
@@ -0,0 +1,3 @@
+// Place all the styles related to the folders controller here.
+// They will automatically be included in application.css.
+// You can use Sass (SCSS) here: http://sass-lang.com/
diff --git a/apps/workbench/app/assets/stylesheets/sb-admin.css.scss b/apps/workbench/app/assets/stylesheets/sb-admin.css.scss
new file mode 100644 (file)
index 0000000..9bae214
--- /dev/null
@@ -0,0 +1,164 @@
+/* 
+Author: Start Bootstrap - http://startbootstrap.com
+'SB Admin' HTML Template by Start Bootstrap
+
+All Start Bootstrap themes are licensed under Apache 2.0. 
+For more info and more free Bootstrap 3 HTML themes, visit http://startbootstrap.com!
+*/
+
+/* ATTN: This is mobile first CSS - to update 786px and up screen width use the media query near the bottom of the document! */
+
+/* Global Styles */
+
+body {
+  margin-top: 50px;
+}
+
+#wrapper {
+  padding-left: 0;
+}
+
+#page-wrapper {
+  width: 100%;
+  padding: 5px 15px;
+}
+
+/* Nav Messages */
+
+.messages-dropdown .dropdown-menu .message-preview .avatar,
+.messages-dropdown .dropdown-menu .message-preview .name,
+.messages-dropdown .dropdown-menu .message-preview .message,
+.messages-dropdown .dropdown-menu .message-preview .time {
+  display: block;
+}
+
+.messages-dropdown .dropdown-menu .message-preview .avatar {
+  float: left;
+  margin-right: 15px;
+}
+
+.messages-dropdown .dropdown-menu .message-preview .name {
+  font-weight: bold;
+}
+
+.messages-dropdown .dropdown-menu .message-preview .message {
+  font-size: 12px;
+}
+
+.messages-dropdown .dropdown-menu .message-preview .time {
+  font-size: 12px;
+}
+
+
+/* Nav Announcements */
+
+.announcement-heading {
+  font-size: 50px;
+  margin: 0;
+}
+
+.announcement-text {
+  margin: 0;
+}
+
+/* Table Headers */
+
+table.tablesorter thead {
+  cursor: pointer;
+}
+
+table.tablesorter thead tr th:hover {
+  background-color: #f5f5f5;
+}
+
+/* Flot Chart Containers */
+
+.flot-chart {
+  display: block;
+  height: 400px;
+}
+
+.flot-chart-content {
+  width: 100%;
+  height: 100%;
+}
+
+/* Edit Below to Customize Widths > 768px */
+@media (min-width:768px) {
+
+  /* Wrappers */
+
+  #wrapper {
+        padding-left: 225px;
+  }
+
+  #page-wrapper {
+        padding: 15px 25px;
+  }
+
+  /* Side Nav */
+
+  .side-nav {
+        margin-left: -225px;
+        left: 225px;
+        width: 225px;
+        position: fixed;
+        top: 50px;
+        height: calc(100% - 50px);
+        border-radius: 0;
+        border: none;
+        background-color: #f8f8f8;
+        overflow-y: auto;
+        overflow-x: hidden; /* no left nav scroll bar */
+  }
+
+  /* Bootstrap Default Overrides - Customized Dropdowns for the Side Nav */
+
+  .side-nav>li.dropdown>ul.dropdown-menu {
+        position: relative;
+        min-width: 225px;
+        margin: 0;
+        padding: 0;
+        border: none;
+        border-radius: 0;
+        background-color: transparent;
+        box-shadow: none;
+        -webkit-box-shadow: none;
+  }
+
+  .side-nav>li.dropdown>ul.dropdown-menu>li>a {
+        color: #777777;
+        padding: 15px 15px 15px 25px;
+  }
+
+  .side-nav>li.dropdown>ul.dropdown-menu>li>a:hover,
+  .side-nav>li.dropdown>ul.dropdown-menu>li>a.active,
+  .side-nav>li.dropdown>ul.dropdown-menu>li>a:focus {
+        background-color: #ffffff;
+  }
+
+  .side-nav>li>a {
+        width: 225px;
+  }
+
+  .navbar-default .navbar-nav.side-nav>li>a:hover,
+  .navbar-default .navbar-nav.side-nav>li>a:focus {
+        background-color: #ffffff;
+  }
+
+  /* Nav Messages */
+
+  .messages-dropdown .dropdown-menu {
+        min-width: 300px;
+  }
+
+  .messages-dropdown .dropdown-menu li a {
+        white-space: normal;
+  }
+
+  .navbar-collapse {
+    padding-left: 15px !important;
+    padding-right: 15px !important;
+  }
+
+}
index 147d6fe93b45a741e5ce41c2336fb59509d13cfa..5e0da41b7a676b92b61d0b7433142956c631e0cd 100644 (file)
@@ -2,18 +2,8 @@
     width: 500px;
 }
 
-#selection-form-content > li > a, #selection-form-content > li > input {
-    display: block;
-    padding: 3px 20px;
-    clear: both;
-    font-weight: normal;
-    line-height: 1.42857;
-    color: rgb(51, 51, 51);
-    white-space: nowrap;    
-    border: none;
-    background: transparent;
-    width: 100%;
-    text-align: left;
+#selection-form-content > li > a, #selection-form-content > li > button {
+    margin: 3px 20px;
 }
 
 #selection-form-content li table tr {
@@ -22,8 +12,6 @@
     border-top: 1px solid rgb(221, 221, 221);
 }
 
-#selection-form-content a:hover, #selection-form-content a:focus, #selection-form-content input:hover, #selection-form-content input:focus, #selection-form-content tr:hover {
-    text-decoration: none;
-    color: rgb(38, 38, 38);
-    background-color: whitesmoke;
-}
\ No newline at end of file
+#selection-form-content li table tr:last-child {
+    border-bottom: 1px solid rgb(221, 221, 221);
+}
index 8a817f03cd78e52dc61ab7c57bff4dc12ff3fe8a..2dab6dd6a86e00f52f6f2302eaa817bea2c54ffb 100644 (file)
@@ -1,8 +1,39 @@
 class ActionsController < ApplicationController
 
-  skip_before_filter :find_object_by_uuid, only: :post
+  @@exposed_actions = {}
+  def self.expose_action method, &block
+    @@exposed_actions[method] = true
+    define_method method, block
+  end
+
+  def model_class
+    ArvadosBase::resource_class_for_uuid(params[:uuid])
+  end
+
+  def post
+    params.keys.collect(&:to_sym).each do |param|
+      if @@exposed_actions[param]
+        return self.send(param)
+      end
+    end
+    redirect_to :back
+  end
+
+  expose_action :copy_selections_into_folder do
+    already_named = Link.
+      filter([['tail_uuid','=',@object.uuid],
+              ['head_uuid','in',params["selection"]]]).
+      collect(&:head_uuid)
+    (params["selection"] - already_named).each do |s|
+      Link.create(tail_uuid: @object.uuid,
+                  head_uuid: s,
+                  link_class: 'name',
+                  name: "#{s} added #{Time.now}")
+    end
+    redirect_to @object
+  end
 
-  def combine_selected_files_into_collection
+  expose_action :combine_selected_files_into_collection do
     lst = []
     files = []
     params["selection"].each do |s|
@@ -87,11 +118,4 @@ class ActionsController < ApplicationController
     redirect_to controller: 'collections', action: :show, id: newc.uuid
   end
 
-  def post
-    if params["combine_selected_files_into_collection"]
-      combine_selected_files_into_collection
-    else
-      redirect_to :back
-    end
-  end
 end
index 24b4ae3185d4701c7152af5a8f582f2e97ecb424..8385b6b2d056b7b2b84f7f75f6194a329b417123 100644 (file)
@@ -7,7 +7,7 @@ class ApiClientAuthorizationsController < ApplicationController
     filtered = m.to_ary.reject do |x|
       x.api_client_id == 0 or (x.expires_at and x.expires_at < Time.now) rescue false
     end
-    ArvadosApiClient::patch_paging_vars(filtered, items_available, offset, limit)
+    ArvadosApiClient::patch_paging_vars(filtered, items_available, offset, limit, nil)
     @objects = ArvadosResourceList.new(ApiClientAuthorization)
     @objects.results= filtered
     super
index f9de62d60e3d3ebe4613f4f29c97726e28d60549..a3576bc83e153ad8f3daf62449b9cf9f240f787c 100644 (file)
@@ -12,6 +12,7 @@ class ApplicationController < ActionController::Base
   before_filter :check_user_notifications, except: ERROR_ACTIONS
   around_filter :using_reader_tokens, only: [:index, :show]
   before_filter :find_object_by_uuid, except: [:index] + ERROR_ACTIONS
+  before_filter :check_my_folders, :except => ERROR_ACTIONS
   theme :select_theme
 
   begin
@@ -76,7 +77,16 @@ class ApplicationController < ActionController::Base
       offset = 0
     end
 
-    @objects ||= model_class.limit(limit).offset(offset).all
+    if params[:filters]
+      filters = params[:filters]
+      if filters.is_a? String
+        filters = Oj.load filters
+      end
+    else
+      filters = []
+    end
+
+    @objects ||= model_class.filter(filters).limit(limit).offset(offset).all
     respond_to do |f|
       f.json { render json: @objects }
       f.html { render }
@@ -89,7 +99,7 @@ class ApplicationController < ActionController::Base
       return render_not_found("object not found")
     end
     respond_to do |f|
-      f.json { render json: @object }
+      f.json { render json: @object.attributes.merge(href: url_for(@object)) }
       f.html {
         if request.method == 'GET'
           render
@@ -134,16 +144,12 @@ class ApplicationController < ActionController::Base
   end
 
   def create
-    @object ||= model_class.new params[model_class.to_s.underscore.singularize]
+    @new_resource_attrs ||= params[model_class.to_s.underscore.singularize]
+    @new_resource_attrs ||= {}
+    @new_resource_attrs.reject! { |k,v| k.to_s == 'uuid' }
+    @object ||= model_class.new @new_resource_attrs
     @object.save!
-
-    respond_to do |f|
-      f.json { render json: @object }
-      f.html {
-        redirect_to(params[:return_to] || @object)
-      }
-      f.js { render }
-    end
+    show
   end
 
   def destroy
@@ -242,8 +248,14 @@ class ApplicationController < ActionController::Base
     if params[:id] and params[:id].match /\D/
       params[:uuid] = params.delete :id
     end
-    if params[:uuid].is_a? String
-      @object = model_class.find(params[:uuid])
+    if not model_class
+      @object = nil
+    elsif params[:uuid].is_a? String
+      if params[:uuid].empty?
+        @object = nil
+      else
+        @object = model_class.find(params[:uuid])
+      end
     else
       @object = model_class.where(uuid: params[:uuid]).first
     end
@@ -410,6 +422,15 @@ class ApplicationController < ActionController::Base
     }
   }
 
+  def check_my_folders
+    @my_top_level_folders = lambda do
+      @top_level_folders ||= Group.
+        filter([['group_class','=','folder'],
+                ['owner_uuid','=',current_user.uuid]]).
+        sort_by { |x| x.name || '' }
+    end
+  end
+
   def check_user_notifications
     @notification_count = 0
     @notifications = []
index a64ac11da32392a4b50ee3b99d3b9ddd9985e44b..4178e38361b7481903df9743535d6a2c23818b90 100644 (file)
@@ -159,7 +159,6 @@ class CollectionsController < ApplicationController
     end
 
     Collection.where(uuid: @object.uuid).each do |u|
-      puts request
       @prov_svg = ProvenanceHelper::create_provenance_graph(u.provenance, "provenance_svg",
                                                             {:request => request,
                                                               :direction => :bottom_up,
diff --git a/apps/workbench/app/controllers/folders_controller.rb b/apps/workbench/app/controllers/folders_controller.rb
new file mode 100644 (file)
index 0000000..86ee42b
--- /dev/null
@@ -0,0 +1,99 @@
+class FoldersController < ApplicationController
+  def model_class
+    Group
+  end
+
+  def index_pane_list
+    %w(My_folders Shared_with_me)
+  end
+
+  def remove_item
+    @removed_uuids = []
+    links = []
+    item = ArvadosBase.find params[:item_uuid]
+    if (item.class == Link and
+        item.link_class == 'name' and
+        item.tail_uuid = @object.uuid)
+      # Given uuid is a name link, linking an object to this
+      # folder. First follow the link to find the item we're removing,
+      # then delete the link.
+      links << item
+      item = ArvadosBase.find item.head_uuid
+    else
+      # Given uuid is an object. Delete all names.
+      links += Link.where(tail_uuid: @object.uuid,
+                          head_uuid: item.uuid,
+                          link_class: 'name')
+    end
+    links.each do |link|
+      @removed_uuids << link.uuid
+      link.destroy
+    end
+    if item.owner_uuid == @object.uuid
+      # Object is owned by this folder. Remove it from the folder by
+      # changing owner to the current user.
+      item.update_attributes owner_uuid: current_user
+      @removed_uuids << item.uuid
+    end
+  end
+
+  def index
+    @my_folders = []
+    @shared_with_me = []
+    @objects = Group.where(group_class: 'folder').order('name')
+    owner_of = {}
+    moretodo = true
+    while moretodo
+      moretodo = false
+      @objects.each do |folder|
+        if !owner_of[folder.uuid]
+          moretodo = true
+          owner_of[folder.uuid] = folder.owner_uuid
+        end
+        if owner_of[folder.owner_uuid]
+          if owner_of[folder.uuid] != owner_of[folder.owner_uuid]
+            owner_of[folder.uuid] = owner_of[folder.owner_uuid]
+            moretodo = true
+          end
+        end
+      end
+    end
+    @objects.each do |folder|
+      if owner_of[folder.uuid] == current_user.uuid
+        @my_folders << folder
+      else
+        @shared_with_me << folder
+      end
+    end
+  end
+
+  def show
+    @objects = @object.contents include_linked: true
+    @share_links = Link.filter([['head_uuid', '=', @object.uuid],
+                                ['link_class', '=', 'permission']])
+    @logs = Log.limit(10).filter([['object_uuid', '=', @object.uuid]])
+
+    @objects_and_names = []
+    @objects.each do |object|
+      if !(name_links = @objects.links_for(object, 'name')).empty?
+        name_links.each do |name_link|
+          @objects_and_names << [object, name_link]
+        end
+      else
+        @objects_and_names << [object,
+                               Link.new(tail_uuid: @object.uuid,
+                                        head_uuid: object.uuid,
+                                        link_class: "name",
+                                        name: "")]
+      end
+    end
+
+    super
+  end
+
+  def create
+    @new_resource_attrs = (params['folder'] || {}).merge(group_class: 'folder')
+    @new_resource_attrs[:name] ||= 'New folder'
+    super
+  end
+end
index b360b19aae0b93b0dbb5d3c325fb9c21458d4c6e..854496a56a27c2e73e4a2b6a68cbc8803a440bac 100644 (file)
@@ -1,8 +1,13 @@
 class GroupsController < ApplicationController
   def index
-    @groups = Group.all
+    @groups = Group.filter [['group_class', 'not in', ['folder']]]
     @group_uuids = @groups.collect &:uuid
     @links_from = Link.where link_class: 'permission', tail_uuid: @group_uuids
     @links_to = Link.where link_class: 'permission', head_uuid: @group_uuids
   end
+
+  def show
+    return redirect_to(folder_path(@object)) if @object.group_class == 'folder'
+    super
+  end
 end
index b17231336fbd9b51f3187000a7ad9c42073ae154..dbb05d6ad4ae1d0b9a027194c93d596f9b8007c8 100644 (file)
@@ -141,16 +141,29 @@ module ApplicationHelper
 
     attrvalue = attrvalue.to_json if attrvalue.is_a? Hash or attrvalue.is_a? Array
 
-    link_to attrvalue.to_s, '#', {
+    ajax_options = {
+      "data-pk" => {
+        id: object.uuid,
+        key: object.class.to_s.underscore
+      }
+    }
+    if object.uuid
+      ajax_options['data-url'] = url_for(action: "update", id: object.uuid, controller: object.class.to_s.pluralize.underscore)
+    else
+      ajax_options['data-url'] = url_for(action: "create", controller: object.class.to_s.pluralize.underscore)
+      ajax_options['data-pk'][:defaults] = object.attributes
+    end
+    ajax_options['data-pk'] = ajax_options['data-pk'].to_json
+
+    content_tag 'span', attrvalue.to_s, {
       "data-emptytext" => "none",
       "data-placement" => "bottom",
       "data-type" => input_type,
-      "data-url" => url_for(action: "update", id: object.uuid, controller: object.class.to_s.pluralize.underscore),
       "data-title" => "Update #{attr.gsub '_', ' '}",
       "data-name" => attr,
-      "data-pk" => "{id: \"#{object.uuid}\", key: \"#{object.class.to_s.underscore}\"}",
+      "data-object-uuid" => object.uuid,
       :class => "editable"
-    }.merge(htmloptions)
+    }.merge(htmloptions).merge(ajax_options)
   end
 
   def render_pipeline_component_attribute(object, attr, subattr, value_info, htmloptions={})
diff --git a/apps/workbench/app/helpers/folders_helper.rb b/apps/workbench/app/helpers/folders_helper.rb
new file mode 100644 (file)
index 0000000..d27e7b4
--- /dev/null
@@ -0,0 +1,2 @@
+module FoldersHelper
+end
index bb0ff74c3435d84891345e69a14c252b63d389c8..e3a2b62a4dfd228c68064b78df9037cf17083168 100644 (file)
@@ -30,7 +30,6 @@ module PipelineInstancesHelper
     i = -1
 
     object.components.each do |cname, c|
-      puts cname, c
       i += 1
       pj = {index: i, name: cname}
       pj[:job] = c[:job].is_a?(Hash) ? c[:job] : {}
index b6aeeca38119b4d79eb6f9c584d1c8f0096a765f..c7f7d3435e4a13459878a11fb98724061a968650 100644 (file)
@@ -90,7 +90,7 @@ class ArvadosApiClient
     resp
   end
 
-  def self.patch_paging_vars(ary, items_available, offset, limit)
+  def self.patch_paging_vars(ary, items_available, offset, limit, links=nil)
     if items_available
       (class << ary; self; end).class_eval { attr_accessor :items_available }
       ary.items_available = items_available
@@ -103,13 +103,21 @@ class ArvadosApiClient
       (class << ary; self; end).class_eval { attr_accessor :limit }
       ary.limit = limit
     end
+    if links
+      (class << ary; self; end).class_eval { attr_accessor :links }
+      ary.links = links
+    end
     ary
   end
 
   def unpack_api_response(j, kind=nil)
     if j.is_a? Hash and j[:items].is_a? Array and j[:kind].match(/(_list|List)$/)
       ary = j[:items].collect { |x| unpack_api_response x, x[:kind] }
-      self.class.patch_paging_vars(ary, j[:items_available], j[:offset], j[:limit])
+      links = ArvadosResourceList.new Link
+      links.results = (j[:links] || []).collect do |x|
+        unpack_api_response x, x[:kind]
+      end
+      self.class.patch_paging_vars(ary, j[:items_available], j[:offset], j[:limit], links)
     elsif j.is_a? Hash and (kind || j[:kind])
       oclass = self.kind_class(kind || j[:kind])
       if oclass
index 1cf0d1fe840458a3ba3f805917e71b49154c0bac..1a0da6424a828b0638aaf95305ffbcb5d34b8273 100644 (file)
@@ -25,17 +25,23 @@ class ArvadosBase < ActiveRecord::Base
     super(*args)
     @attribute_sortkey ||= {
       'id' => nil,
-      'uuid' => '000',
-      'owner_uuid' => '001',
-      'created_at' => '002',
-      'modified_at' => '003',
-      'modified_by_user_uuid' => '004',
-      'modified_by_client_uuid' => '005',
-      'name' => '050',
-      'tail_uuid' => '100',
-      'head_uuid' => '101',
-      'info' => 'zzz-000',
-      'updated_at' => 'zzz-999'
+      'name' => '000',
+      'owner_uuid' => '002',
+      'event_type' => '100',
+      'link_class' => '100',
+      'group_class' => '100',
+      'tail_uuid' => '101',
+      'head_uuid' => '102',
+      'object_uuid' => '102',
+      'summary' => '104',
+      'description' => '104',
+      'properties' => '150',
+      'info' => '150',
+      'created_at' => '200',
+      'modified_at' => '201',
+      'modified_by_user_uuid' => '202',
+      'modified_by_client_uuid' => '203',
+      'uuid' => '999',
     }
   end
 
@@ -79,6 +85,11 @@ class ArvadosBase < ActiveRecord::Base
       raise 'argument to find() must be a uuid string. Acceptable formats: warehouse locator or string with format xxxxx-xxxxx-xxxxxxxxxxxxxxx'
     end
 
+    if self == ArvadosBase
+      # Determine type from uuid and defer to the appropriate subclass.
+      return resource_class_for_uuid(uuid).find(uuid, opts)
+    end
+
     # Only do one lookup on the API side per {class, uuid, workbench
     # request} unless {cache: false} is given via opts.
     cache_key = "request_#{Thread.current.object_id}_#{self.to_s}_#{uuid}"
@@ -244,6 +255,10 @@ class ArvadosBase < ActiveRecord::Base
     }
   end
 
+  def class_for_display
+    self.class.to_s
+  end
+
   def self.creatable?
     current_user
   end
@@ -251,7 +266,8 @@ class ArvadosBase < ActiveRecord::Base
   def editable?
     (current_user and current_user.is_active and
      (current_user.is_admin or
-      current_user.uuid == self.owner_uuid))
+      current_user.uuid == self.owner_uuid or
+      new_record?))
   end
 
   def attribute_editable?(attr)
@@ -262,7 +278,9 @@ class ArvadosBase < ActiveRecord::Base
     elsif "uuid owner_uuid".index(attr.to_s) or current_user.is_admin
       current_user.is_admin
     else
-      current_user.uuid == self.owner_uuid or current_user.uuid == self.uuid
+      current_user.uuid == self.owner_uuid or
+        current_user.uuid == self.uuid or
+        new_record?
     end
   end
 
@@ -299,6 +317,10 @@ class ArvadosBase < ActiveRecord::Base
     (name if self.respond_to? :name) || uuid
   end
 
+  def content_summary
+    self.class_for_display
+  end
+
   def selection_label
     friendly_link_name
   end
index 16a59b173e680ed39fd6e9f58b7f2adf6fb857d6..3f74407c01429229bd3ecacf26da7238232dd706 100644 (file)
@@ -1,7 +1,7 @@
 class ArvadosResourceList
   include Enumerable
 
-  def initialize(resource_class)
+  def initialize resource_class=nil
     @resource_class = resource_class
   end
 
@@ -90,6 +90,12 @@ class ArvadosResourceList
     self
   end
 
+  def collect
+    results.collect do |m|
+      yield m
+    end
+  end
+
   def first
     results.first
   end
@@ -134,4 +140,38 @@ class ArvadosResourceList
     results.offset if results.respond_to? :offset
   end
 
+  def result_links
+    results.links if results.respond_to? :links
+  end
+
+  # Return links provided with API response that point to the
+  # specified object, and have the specified link_class. If link_class
+  # is false or omitted, return all links pointing to the specified
+  # object.
+  def links_for item_or_uuid, link_class=false
+    return [] if !result_links
+    unless @links_for_uuid
+      @links_for_uuid = {}
+      result_links.each do |link|
+        if link.respond_to? :head_uuid
+          @links_for_uuid[link.head_uuid] ||= []
+          @links_for_uuid[link.head_uuid] << link
+        end
+      end
+    end
+    if item_or_uuid.respond_to? :uuid
+      uuid = item_or_uuid.uuid
+    else
+      uuid = item_or_uuid
+    end
+    (@links_for_uuid[uuid] || []).select do |link|
+      link_class == false or link.link_class == link_class
+    end
+  end
+
+  # Note: this arbitrarily chooses one of (possibly) multiple names.
+  def name_for item_or_uuid
+    links_for(item_or_uuid, 'name').first.andand.name
+  end
+
 end
index 5460e9a6e01641192b2ee0c9c0c12d9773e293e3..a63bf90cb006d450f7dccf958ec06aa83fa67d24 100644 (file)
@@ -1,4 +1,5 @@
 class Collection < ArvadosBase
+  include ApplicationHelper
 
   MD5_EMPTY = 'd41d8cd98f00b204e9800998ecf8427e'
 
@@ -7,6 +8,10 @@ class Collection < ArvadosBase
     !!locator.to_s.match("^#{MD5_EMPTY}(\\+.*)?\$")
   end
 
+  def content_summary
+    human_readable_bytes_html(total_bytes) + " " + super
+  end
+
   def total_bytes
     if files
       tot = 0
index f53a6f40adccf6e79e3172c2dd7c0c1df1953812..dde6019e9ca4ed9a5d51978fe933fabee0208727 100644 (file)
@@ -1,6 +1,20 @@
 class Group < ArvadosBase
-  def self.owned_items
-    res = $arvados_api_client.api self, "/#{self.uuid}/owned_items", {}
-    $arvados_api_client.unpack_api_response(res)
+  def contents params={}
+    res = $arvados_api_client.api self.class, "/#{self.uuid}/contents", {
+      _method: 'GET'
+    }.merge(params)
+    ret = ArvadosResourceList.new
+    ret.results = $arvados_api_client.unpack_api_response(res)
+    ret
+  end
+
+  def class_for_display
+    group_class == 'folder' ? 'Folder' : super
+  end
+
+  def editable?
+    respond_to?(:writable_by) and
+      writable_by and
+      writable_by.index(current_user.uuid)
   end
 end
index c804bf7b7150a2df90796114d792f3970cbdae43..39d585bf90bb6f3aded909321a1e4a7652dec015 100644 (file)
@@ -1,3 +1,8 @@
 class Log < ArvadosBase
   attr_accessor :object
+  def self.creatable?
+    # Technically yes, but not worth offering: it will be empty, and
+    # you won't be able to edit it.
+    false
+  end
 end
index c03e317f54d51ba1f8663b8b63e674923b43164b..44d615b89fecf117dcc618e01627e1beb74e38f2 100644 (file)
@@ -17,11 +17,6 @@ class User < ArvadosBase
                              end
   end
 
-  def owned_items
-    res = $arvados_api_client.api self.class, "/#{self.uuid}/owned_items"
-    $arvados_api_client.unpack_api_response(res)
-  end
-
   def full_name
     (self.first_name || "") + " " + (self.last_name || "")
   end
index 53444a5c9c72defe283deff498996f1c8ffb7782..8a0624b7afc9a7523a716599964e6fbde8a8b8db 100644 (file)
@@ -4,7 +4,7 @@
 <% pane_list ||= %w(recent) %>
 <% panes = Hash[pane_list.map { |pane|
      [pane, render(partial: 'show_' + pane.downcase,
-                   locals: { comparable: comparable })]
+                   locals: { comparable: comparable, objects: @objects })]
    }.compact] %>
 
 <ul class="nav nav-tabs">
index 67a3d06df2e38abef90f051768bc1cfbef175f7c..69ed9dca74faabd39e0294e5c81b12ebb77b4dcc 100644 (file)
@@ -1,5 +1,5 @@
 <% if object.editable? %>
-  <%= link_to({action: 'destroy', id: object.uuid}, method: :delete, remote: true, data: {confirm: "You are about to delete #{object.class} #{object.uuid}.\n\nAre you sure?"}) do %>
+  <%= link_to({action: 'destroy', id: object.uuid}, method: :delete, remote: true, data: {confirm: "You are about to delete #{object.class_for_display.downcase} '#{object.friendly_link_name}' (#{object.uuid}).\n\nAre you sure?"}) do %>
     <i class="glyphicon glyphicon-trash"></i>
   <% end %>
 <% end %>
diff --git a/apps/workbench/app/views/application/_show_object_button.html.erb b/apps/workbench/app/views/application/_show_object_button.html.erb
new file mode 100644 (file)
index 0000000..81ca745
--- /dev/null
@@ -0,0 +1,3 @@
+<% htmloptions = {class: ''}.merge(htmloptions || {})
+   htmloptions[:class] += " btn-#{size}" rescue nil %>
+<%= link_to_if_arvados_object object, { link_text: raw('Show <i class="fa fa-fw fa-arrow-circle-right"></i>') }, { class: 'btn btn-default ' + htmloptions[:class] } %>
index 04387ffb3e9d8e45227f69e7ce85f2deadb14f35..c36f27ccc74ea35c55eadc877bb784d52f085003 100644 (file)
@@ -1,14 +1,14 @@
-<% if @objects.empty? %>
+<% if objects.empty? %>
 <br/>
 <p style="text-align: center">
-  No <%= controller.model_class.to_s.pluralize.underscore.gsub '_', ' ' %> to display.
+  No <%= controller.controller_name.humanize.downcase %> to display.
 </p>
 
 <% else %>
 
-<% attr_blacklist = ' created_at modified_at modified_by_user_uuid modified_by_client_uuid updated_at' %>
+<% attr_blacklist = ' created_at modified_at modified_by_user_uuid modified_by_client_uuid updated_at owner_uuid group_class' %>
 
-<%= render partial: "paging", locals: {results: @objects, object: @object} %>
+<%= render partial: "paging", locals: {results: objects, object: @object} %>
 
 <%= form_tag do |f| %>
 
@@ -16,7 +16,8 @@
   <thead>
     <tr>
       <th></th>
-      <% @objects.first.attributes_for_display.each do |attr, attrvalue| %>
+      <th></th>
+      <% objects.first.attributes_for_display.each do |attr, attrvalue| %>
       <% next if attr_blacklist.index(" "+attr) %>
       <th class="arv-attr-<%= attr %>">
         <%= controller.model_class.attribute_info[attr.to_sym].andand[:column_heading] or attr.sub /_uuid/, '' %>
   </thead>
       
   <tbody>
-    <% @objects.each do |object| %>
+    <% objects.each do |object| %>
     <tr data-object-uuid="<%= object.uuid %>">
       <td>
         <%= render :partial => "selection_checkbox", :locals => {:object => object} %>
       </td>
+      <td>
+        <%= render :partial => "show_object_button", :locals => {object: object, size: 'xs'} %>
+      </td>
 
       <% object.attributes_for_display.each do |attr, attrvalue| %>
       <% next if attr_blacklist.index(" "+attr) %>
       <td class="arv-object-<%= object.class.to_s %> arv-attr-<%= attr %>">
         <% if attr == 'uuid' %>
-        <%= link_to_if_arvados_object object %>
-        <%= link_to_if_arvados_object(object, { link_text: raw('<i class="icon-hand-right"></i>') }) %>
+        <span class="arvados-uuid"><%= attrvalue %></span>
         <% else %>
         <% if object.attribute_editable? attr %>
         <%= render_editable_attribute object, attr %>
@@ -65,6 +68,6 @@
 
 <% end %>
 
-<%= render partial: "paging", locals: {results: @objects, object: @object} %>
+<%= render partial: "paging", locals: {results: objects, object: @object} %>
 
 <% end %>
index 3f312405b509813bb88aa3e418c35a32c05ff75a..20af6485b088c67f4ad6ae612ffc63916ead8c4e 100644 (file)
@@ -1,5 +1,5 @@
 <% content_for :page_title do %>
-<%= controller.model_class.to_s.pluralize.underscore.capitalize.gsub('_', ' ') %>
+<%= controller.controller_name.humanize.capitalize %>
 <% end %>
 
 <% content_for :tab_line_buttons do %>
@@ -12,8 +12,8 @@
           'data-target' => '#user-setup-modal-window', return_to: request.url}  %>
       <div id="user-setup-modal-window" class="modal fade" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true"></div>
     <% else %>
-      <%= button_to "Add a new #{controller.model_class.to_s.underscore.gsub '_', ' '}",
-        { action: 'create', return_to: request.url },
+      <%= button_to "Add a new #{controller.controller_name.singularize.humanize.downcase}",
+        { action: 'create' },
         { class: 'btn btn-primary pull-right' } %>
     <% end %>
 
diff --git a/apps/workbench/app/views/folders/_show_my_folders.html.erb b/apps/workbench/app/views/folders/_show_my_folders.html.erb
new file mode 100644 (file)
index 0000000..b009acf
--- /dev/null
@@ -0,0 +1,2 @@
+<%= render(partial: 'show_recent',
+    locals: { comparable: comparable, objects: @my_folders }) %>
diff --git a/apps/workbench/app/views/folders/_show_shared_with_me.html.erb b/apps/workbench/app/views/folders/_show_shared_with_me.html.erb
new file mode 100644 (file)
index 0000000..6ccf983
--- /dev/null
@@ -0,0 +1,2 @@
+<%= render(partial: 'show_recent',
+    locals: { comparable: comparable, objects: @shared_with_me }) %>
diff --git a/apps/workbench/app/views/folders/remove_item.js.erb b/apps/workbench/app/views/folders/remove_item.js.erb
new file mode 100644 (file)
index 0000000..5444cbe
--- /dev/null
@@ -0,0 +1,5 @@
+<% @removed_uuids.each do |uuid| %>
+$('[data-object-uuid=<%= uuid %>]').hide('slow', function() {
+    $(this).remove();
+});
+<% end %>
diff --git a/apps/workbench/app/views/folders/show.html.erb b/apps/workbench/app/views/folders/show.html.erb
new file mode 100644 (file)
index 0000000..11bb52c
--- /dev/null
@@ -0,0 +1,193 @@
+<div class="row row-fill-height">
+  <div class="col-md-6">
+    <div class="panel panel-info">
+      <div class="panel-heading">
+       <h3 class="panel-title">
+         <%= render_editable_attribute @object, 'name', nil, {data: {emptytext: "New folder"}} %>
+       </h3>
+      </div>
+      <div class="panel-body">
+        <img src="/favicon.ico" class="pull-right" alt="" style="opacity: 0.3"/>
+       <%= render_editable_attribute @object, 'description', nil, { 'data-emptytext' => "Created: #{@object.created_at.to_s(:long)}", 'data-toggle' => 'manual', 'id' => "#{@object.uuid}-description" } %>
+        <% if @object.attribute_editable? 'description' %>
+        <div style="margin-top: 1em;">
+          <a href="#" class="btn btn-xs btn-default" data-toggle="x-editable" data-toggle-selector="#<%= @object.uuid %>-description"><i class="fa fa-fw fa-pencil"></i> Edit description</a>
+        </div>
+        <% end %>
+      </div>
+    </div>
+  </div>
+  <div class="col-md-3">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+       <h3 class="panel-title">
+         Activity
+       </h3>
+      </div>
+      <div class="panel-body smaller-text">
+        <!--
+       <input type="text" class="form-control" placeholder="Search"/>
+        -->
+       <div style="height:0.5em;"></div>
+        <% @logs[0..2].each do |log| %>
+       <p>
+         <%= time_ago_in_words(log.event_at) %> ago: <%= log.summary %>
+          <% if log.object_uuid %>
+          <%= link_to_if_arvados_object log.object_uuid, link_text: raw('<i class="fa fa-hand-o-right"></i>') %>
+          <% end %>
+       </p>
+        <% end %>
+        <% if @logs.any? %>
+       <%= link_to raw('Show all activity &nbsp; <i class="fa fa-fw fa-arrow-circle-right"></i>'),
+            logs_path(filters: [['object_uuid','=',@object.uuid]].to_json),
+            class: 'btn btn-xs btn-default' %>
+        <% else %>
+        <p>
+          Created: <%= @object.created_at.to_s(:long) %>
+        </p>
+        <p>
+          Last modified: <%= @object.modified_at.to_s(:long) %> by <%= link_to_if_arvados_object @object.modified_by_user_uuid, friendly_name: true %>
+        </p>
+        <% end %>
+      </div>
+    </div>
+  </div>
+  <div class="col-md-3">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+       <h3 class="panel-title">
+         Sharing and permissions
+       </h3>
+      </div>
+      <div class="panel-body">
+        <!--
+       <input type="text" class="form-control" placeholder="Search"/>
+        -->
+       <div style="height:0.5em;"></div>
+        <p>Owner: <%= link_to_if_arvados_object @object.owner_uuid, friendly_name: true %></p>
+        <% if @share_links.any? %>
+        <p>Shared with:
+          <% @share_links.andand.each do |link| %>
+          <br /><%= link_to_if_arvados_object link.tail_uuid, friendly_name: true %>
+          <% end %>
+        </p>
+        <% end %>
+      </div>
+    </div>
+  </div>
+</div>
+
+<% if @show_cards %>
+<!-- disable cards section until we have bookmarks -->
+<div class="row">
+  <% @objects[0..3].each do |object| %>
+  <div class="card arvados-object">
+    <div class="card-top blue">
+      <a href="#">
+        <img src="/favicon.ico" alt=""/>
+      </a>
+    </div>
+    <div class="card-info">
+      <span class="title"><%= @objects.name_for(object) || object.class_for_display %></span>
+      <div class="desc"><%= object.respond_to?(:description) ? object.description : object.uuid %></div>
+    </div>
+    <div class="card-bottom">
+      <%= render :partial => "show_object_button", :locals => {object: object, htmloptions: {class: 'btn-default btn-block'}} %>
+    </div>
+  </div>
+  <% end %>
+</div>
+<!-- end disabled cards section -->
+<% end %>
+
+<div class="row">
+  <div class="col-md-12">
+    <div class="panel panel-info">
+      <div class="panel-heading">
+        <div class="row">
+          <div class="col-md-6">
+            <h3 class="panel-title" style="vertical-align:middle;">
+              Contents
+            </h3>
+          </div>
+          <div class="col-md-6">
+            <div class="input-group input-group-sm pull-right">
+              <input type="text" class="form-control search-folder-contents" placeholder="Search folder contents"/>
+            </div>
+          </div>
+        </div>
+      </div>
+      <div class="panel-body">
+        <p>
+        </p>
+        <table class="table table-condensed arv-index">
+          <tbody>
+            <colgroup>
+              <col width="3%" />
+              <col width="8%" />
+              <col width="30%" />
+              <col width="15%" />
+              <col width="15%" />
+              <col width="20%" />
+              <col width="8%" />
+            </colgroup>
+            <% @objects_and_names.each do |object, name_link| %>
+              <tr data-object-uuid="<%= (name_link && name_link.uuid) || object.uuid %>">
+                <td>
+                  <%= render :partial => "selection_checkbox", :locals => {object: object} %>
+                </td>
+                <td>
+                  <%= render :partial => "show_object_button", :locals => {object: object, size: 'xs'} %>
+                </td>
+                <td>
+                  <%= render_editable_attribute name_link, 'name', nil, {data: {emptytext: "Unnamed #{object.class_for_display}"}} %>
+                </td>
+                <td>
+                  <%= object.content_summary %>
+                </td>
+                <td title="<%= object.modified_at %>">
+                  <span>
+                    <%= raw distance_of_time_in_words(object.modified_at, Time.now).sub('about ','~').sub(' ','&nbsp;') + '&nbsp;ago' rescue object.modified_at %>
+                  </span>
+                </td>
+                <td class="arvados-uuid">
+                  <%= object.uuid %>
+                </td>
+                <td>
+                  <% if @object.editable? %>
+                    <%= link_to({action: 'remove_item', id: @object.uuid, item_uuid: ((name_link && name_link.uuid) || object.uuid)}, method: :delete, remote: true, data: {confirm: "You are about to remove #{object.class_for_display} #{object.uuid} from this folder.\n\nAre you sure?"}, class: 'btn btn-xs btn-default') do %>
+                      Remove <i class="fa fa-fw fa-ban"></i>
+                    <% end %>
+                  <% end %>
+                </td>
+              </tr>
+            <% end %>
+          </tbody>
+          <thead>
+            <tr>
+              <th>
+              </th>
+              <th>
+              </th>
+              <th>
+                name
+              </th>
+              <th>
+                type
+              </th>
+              <th>
+                modified
+              </th>
+              <th>
+                uuid
+              </th>
+              <th>
+              </th>
+            </tr>
+          </thead>
+        </table>
+        <p></p>
+      </div>
+    </div>
+  </div>
+</div>
index 9da171e4006c62e8036ba52e7a64e2f90dca20c9..2b5ec88fc62bb71ce9919e777603e5e8b47cd596 100644 (file)
     padding-top: 70px; /* 70px to make the container go all the way to the bottom of the navbar */
     }
 
-    body > div.container-fluid > div.col-sm-9.col-sm-offset-3 {
-    overflow: auto;
-    }
-
     @media (max-width: 979px) { body { padding-top: 0; } }
 
     .navbar .nav li.nav-separator > span.glyphicon.glyphicon-arrow-right {
     padding-top: 1.25em;
     }
 
-    @media (min-width: 768px) {
-    .left-nav {
-    position: fixed;
-    }
-    }
     @media (max-width: 767px) {
     .breadcrumbs {
     display: none;
     }
     }
   </style>
+  <link href="//netdna.bootstrapcdn.com/font-awesome/4.0.3/css/font-awesome.css" rel="stylesheet">
 </head>
 <body>
-
-  <div class="navbar navbar-default navbar-fixed-top">
-    <div class="container-fluid">
+  <div id="wrapper">
+    <nav class="navbar navbar-default navbar-fixed-top" role="navigation">
       <div class="navbar-header">
-        <button type="button" class="navbar-toggle" data-toggle="collapse" data-target="#workbench-navbar.navbar-collapse">
+        <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
           <span class="sr-only">Toggle navigation</span>
           <span class="icon-bar"></span>
           <span class="icon-bar"></span>
         <a class="navbar-brand" href="/"><%= Rails.configuration.site_name rescue Rails.application.class.parent_name %></a>
       </div>
 
-      <div class="collapse navbar-collapse" id="workbench-navbar">
-      <ul class="nav navbar-nav navbar-left breadcrumbs">
-        <% if current_user %>
-        <% if content_for?(:breadcrumbs) %>
-          <%= yield(:breadcrumbs) %>
-        <% else %>
-          <li class="nav-separator"><span class="glyphicon glyphicon-arrow-right"></span></li>
-          <li>
-            <%= link_to(
-                        controller.model_class.to_s.pluralize.underscore.gsub('_', ' '),
-                        url_for({controller: params[:controller]})) %>
-          </li>
-          <% if params[:action] != 'index' %>
-            <li class="nav-separator">
-              <span class="glyphicon glyphicon-arrow-right"></span>
+      <div class="collapse navbar-collapse">
+        <% if current_user.andand.is_active %>
+          <ul class="nav navbar-nav side-nav">
+
+            <li class="<%= 'arvados-nav-active' if params[:action] == 'home' %>">
+              <a href="/"><i class="fa fa-lg fa-dashboard fa-fw"></i> Dashboard</a>
             </li>
-            <li>
-              <%= link_to_if_arvados_object @object %>
+
+            <li class="dropdown">
+              <a href="#" class="dropdown-toggle" data-toggle="dropdown"><i class="fa fa-lg fa-hand-o-up fa-fw"></i> Help <b class="caret"></b></a>
+              <ul class="dropdown-menu">
+                <li><%= link_to raw('<i class="fa fa-book fa-fw"></i> Tutorials and User guide'), "#{Rails.configuration.arvados_docsite}/user", target: "_blank" %></li>
+                <li><%= link_to raw('<i class="fa fa-book fa-fw"></i> API Reference'), "#{Rails.configuration.arvados_docsite}/api", target: "_blank" %></li>
+                <li><%= link_to raw('<i class="fa fa-book fa-fw"></i> SDK Reference'), "#{Rails.configuration.arvados_docsite}/sdk", target: "_blank" %></li>
+              </ul>
             </li>
-            <li style="padding: 14px 0 14px">
-              <%= form_tag do |f| %>
-                <%= render :partial => "selection_checkbox", :locals => {:object => @object} %>
-              <% end %>
+
+            <li class="dropdown">
+              <a href="#" class="dropdown-toggle" data-toggle="dropdown"><i class="fa fa-lg fa-folder-o fa-fw"></i> Folders <b class="caret"></b></a>
+              <ul class="dropdown-menu">
+                <li><%= link_to raw('<i class="fa fa-plus fa-fw"></i> Create new folder'), folders_path, method: :post %></li>
+                <% @my_top_level_folders.call[0..7].each do |folder| %>
+                <li><%= link_to raw('<i class="fa fa-folder-open fa-fw"></i> ') + folder.name, folder_path(folder) %></li>
+                <% end %>
+                <li><a href="/folders">
+                    <i class="fa fa-ellipsis-h fa-fw"></i> Show all folders
+                </a></li>
+              </ul>
             </li>
-          <% end %>
-        <% end %>
-        <% end %>
-      </ul>
-
-      <ul class="nav navbar-nav navbar-right">
-
-        <li>
-          <a><i class="rotating loading glyphicon glyphicon-refresh"></i></a>
-        </li>
-
-        <% if current_user %>
-        <!-- XXX placeholder for this when search is implemented
-        <li>
-          <form class="navbar-form" role="search">
-            <div class="input-group" style="width: 220px">
-              <input type="text" class="form-control" placeholder="search">
-              <span class="input-group-addon"><span class="glyphicon glyphicon-search"></span></span>
-            </div>
-          </form>
-        </li>
-        -->
-
-        <li class="dropdown notification-menu">
-          <a href="#" class="dropdown-toggle" data-toggle="dropdown" id="collections-menu">
-            <span class="glyphicon glyphicon-paperclip"></span>
-            <span class="badge" id="persistent-selection-count"></span>
-            <span class="caret"></span>
-          </a>
-            <ul class="dropdown-menu" role="menu" id="persistent-selection-list">
-              <%= form_tag '/actions' do %>
-              <div id="selection-form-content"></div>
-              <% end %>
-          </ul>
-        </li>
-
-        <% if current_user.is_active %>
-        <li class="dropdown notification-menu">
-          <a href="#" class="dropdown-toggle" data-toggle="dropdown" id="notifications-menu">
-            <span class="glyphicon glyphicon-envelope"></span>
-            <span class="badge badge-alert notification-count"><%= @notification_count %></span>
-            <span class="caret"></span>
-          </a>
-          <ul class="dropdown-menu" role="menu">
-            <% if (@notifications || []).length > 0 %>
-              <% @notifications.each_with_index do |n, i| %>
-                <% if i > 0 %><li class="divider"></li><% end %>
-                <li class="notification"><%= n.call(self) %></li>
-              <% end %>
-            <% else %>
-              <li class="notification empty">No notifications.</li>
+            <li><a href="/collections">
+                <i class="fa fa-lg fa-briefcase fa-fw"></i> Collections (data files)
+            </a></li>
+            <li><a href="/pipeline_instances">
+                <i class="fa fa-lg fa-tasks fa-fw"></i> Pipeline instances
+            </a></li>
+            <li><a href="/pipeline_templates">
+                <i class="fa fa-lg fa-gears fa-fw"></i> Pipeline templates
+            </a></li>
+            <li>&nbsp;</li>
+            <li><a href="/repositories">
+                <i class="fa fa-lg fa-code-fork fa-fw"></i> Repositories
+            </a></li>
+            <li><a href="/virtual_machines">
+                <i class="fa fa-lg fa-terminal fa-fw"></i> Virtual machines
+            </a></li>
+            <li><a href="/humans">
+                <i class="fa fa-lg fa-male fa-fw"></i> Humans
+            </a></li>
+            <li><a href="/specimens">
+                <i class="fa fa-lg fa-flask fa-fw"></i> Specimens
+            </a></li>
+            <li><a href="/traits">
+                <i class="fa fa-lg fa-clipboard fa-fw"></i> Traits
+            </a></li>
+            <li><a href="/links">
+                <i class="fa fa-lg fa-arrows-h fa-fw"></i> Links
+            </a></li>
+            <% if current_user.andand.is_admin %>
+              <li><a href="/users">
+                  <i class="fa fa-lg fa-user fa-fw"></i> Users
+              </a></li>
             <% end %>
+            <li><a href="/groups">
+                <i class="fa fa-lg fa-users fa-fw"></i> Groups
+            </a></li>
+            <li><a href="/nodes">
+                <i class="fa fa-lg fa-cogs fa-fw"></i> Compute nodes
+            </a></li>
+            <li><a href="/keep_disks">
+                <i class="fa fa-lg fa-hdd-o fa-fw"></i> Keep disks
+            </a></li>
           </ul>
-        </li>
         <% end %>
 
-        <li class="dropdown">
-          <a href="#" class="dropdown-toggle" data-toggle="dropdown" id="user-menu">
-            <span class="glyphicon glyphicon-user"></span><span class="caret"></span>
-          </a>
-          <ul class="dropdown-menu" role="menu">
-            <li role="presentation" class="dropdown-header"><%= current_user.email %></li>
-            <% if current_user.is_active %>
-            <li role="presentation" class="divider"></li>
-            <li role="presentation"><a href="/authorized_keys" role="menuitem">Manage ssh keys</a></li>
-            <li role="presentation"><a href="/api_client_authorizations" role="menuitem">Manage API tokens</a></li>
-            <li role="presentation" class="divider"></li>
+        <ul class="nav navbar-nav navbar-left breadcrumbs">
+          <% if current_user %>
+            <% if content_for?(:breadcrumbs) %>
+              <%= yield(:breadcrumbs) %>
+            <% else %>
+              <li class="nav-separator"><span class="glyphicon glyphicon-arrow-right"></span></li>
+              <li>
+                <%= link_to(
+                            controller.controller_name.humanize.downcase,
+                            url_for({controller: params[:controller]})) %>
+              </li>
+              <% if params[:action] != 'index' %>
+                <li class="nav-separator">
+                  <span class="glyphicon glyphicon-arrow-right"></span>
+                </li>
+                <li>
+                  <%= link_to_if_arvados_object @object, {friendly_name: true}, {data: {object_uuid: @object.andand.uuid, name: 'name'}} %>
+                </li>
+                <li style="padding: 14px 0 14px">
+                  <%= form_tag do |f| %>
+                    <%= render :partial => "selection_checkbox", :locals => {:object => @object} %>
+                  <% end %>
+                </li>
+              <% end %>
             <% end %>
-            <li role="presentation"><a href="<%= logout_path %>" role="menuitem">Log out</a></li>
-          </ul>
-        </li>
-       <% else -%>
-          <li><a href="<%= $arvados_api_client.arvados_login_url(return_to: root_url) %>">Log in</a></li>
-       <% end -%>
-      </ul>
-      </div><!-- /.navbar-collapse -->
-    </div><!-- /.container-fluid -->
-  </div>
+          <% end %>
+        </ul>
 
-  <div class="container-fluid">
-      <div class="col-sm-9 col-sm-offset-3">
-        <div id="content" class="body-content">
-          <%= yield %>
-        </div>
-      </div>
-      <div class="col-sm-3 left-nav">
-        <div class="arvados-nav-container">
-        <% if current_user.andand.is_active %>
-        <div class="well">
-        <ul class="arvados-nav">
-          <li class="<%= 'arvados-nav-active' if params[:action] == 'home' %>">
-            <a href="/">Dashboard</a>
+        <ul class="nav navbar-nav navbar-right">
+
+          <li>
+            <a><i class="rotating loading glyphicon glyphicon-refresh"></i></a>
           </li>
 
-          <% [['Data', [['collections', 'Collections (data files)'],
-                        ['humans'],
-                        ['traits'],
-                        ['specimens'],
-                        ['links']]],
-              ['Activity', [['pipeline_instances', 'Recent pipeline instances'],
-                            ['jobs', 'Recent jobs']]],
-              ['Compute', [['pipeline_templates'],
-                           ['repositories', 'Code repositories'],
-                           ['virtual_machines']]],
-              ['System', [['users'],
-                         ['groups'],
-                         ['nodes', 'Compute nodes'],
-                         ['keep_disks']]]].each do |j| %>
-            <li><%= j[0] %>
-              <ul>
-              <% j[1].each do |k| %>
-                <% unless k[0] == 'users' and !current_user.andand.is_admin %>
-                  <li class="<%= 'arvados-nav-active' if (params[:controller] == k[0] && params[:action] != 'home') %>">
-                    <a href="/<%= k[0] %>">
-                      <%= if k[1] then k[1] else k[0].capitalize.gsub('_', ' ') end %>
-                    </a>
-                  </li>
+          <% if current_user %>
+          <!-- XXX placeholder for this when search is implemented
+          <li>
+            <form class="navbar-form" role="search">
+              <div class="input-group" style="width: 220px">
+                <input type="text" class="form-control" placeholder="search">
+                <span class="input-group-addon"><span class="glyphicon glyphicon-search"></span></span>
+              </div>
+            </form>
+          </li>
+          -->
+
+          <li class="dropdown notification-menu">
+            <a href="#" class="dropdown-toggle" data-toggle="dropdown" id="collections-menu">
+              <span class="glyphicon glyphicon-paperclip"></span>
+              <span class="badge" id="persistent-selection-count"></span>
+              <span class="caret"></span>
+            </a>
+              <ul class="dropdown-menu" role="menu" id="persistent-selection-list">
+                <%= form_tag '/actions' do %>
+                <%= hidden_field_tag 'uuid', @object.andand.uuid %>
+                <div id="selection-form-content"></div>
                 <% end %>
+            </ul>
+          </li>
+
+          <% if current_user.is_active %>
+          <li class="dropdown notification-menu">
+            <a href="#" class="dropdown-toggle" data-toggle="dropdown" id="notifications-menu">
+              <span class="glyphicon glyphicon-envelope"></span>
+              <span class="badge badge-alert notification-count"><%= @notification_count %></span>
+              <span class="caret"></span>
+            </a>
+            <ul class="dropdown-menu" role="menu">
+              <% if (@notifications || []).length > 0 %>
+                <% @notifications.each_with_index do |n, i| %>
+                  <% if i > 0 %><li class="divider"></li><% end %>
+                  <li class="notification"><%= n.call(self) %></li>
+                <% end %>
+              <% else %>
+                <li class="notification empty">No notifications.</li>
               <% end %>
-              </ul>
-            </li>
+            </ul>
+          </li>
           <% end %>
 
-          <li>Help
-            <ul>
-              <li><%= link_to 'Tutorials and User guide', "#{Rails.configuration.arvados_docsite}/user", target: "_blank" %></li>
-              <li><%= link_to 'API Reference', "#{Rails.configuration.arvados_docsite}/api", target: "_blank" %></li>
-              <li><%= link_to 'SDK Reference', "#{Rails.configuration.arvados_docsite}/sdk", target: "_blank" %></li>
+          <li class="dropdown">
+            <a href="#" class="dropdown-toggle" data-toggle="dropdown" id="user-menu">
+              <span class="glyphicon glyphicon-user"></span><span class="caret"></span>
+            </a>
+            <ul class="dropdown-menu" role="menu">
+              <li role="presentation" class="dropdown-header"><%= current_user.email %></li>
+              <% if current_user.is_active %>
+              <li role="presentation" class="divider"></li>
+              <li role="presentation"><a href="/authorized_keys" role="menuitem"><i class="fa fa-key fa-fw"></i> Manage ssh keys</a></li>
+              <li role="presentation"><a href="/api_client_authorizations" role="menuitem"><i class="fa fa-ticket fa-fw"></i> Manage API tokens</a></li>
+              <li role="presentation" class="divider"></li>
+              <% end %>
+              <li role="presentation"><a href="<%= logout_path %>" role="menuitem"><i class="fa fa-sign-out fa-fw"></i> Log out</a></li>
             </ul>
           </li>
+          <% else %>
+            <li><a href="<%= $arvados_api_client.arvados_login_url(return_to: root_url) %>">Log in</a></li>
+          <% end %>
         </ul>
-        </div>
-        <% end %>
-      </div>
-        </div>
+      </div><!-- /.navbar-collapse -->
+    </nav>
+
+    <div id="page-wrapper">
+      <%= yield %>
+    </div>
   </div>
 
+</div>
+
   <%= yield :footer_html %>
   <%= piwik_tracking_tag %>
   <%= javascript_tag do %>
diff --git a/apps/workbench/app/views/logs/show.html.erb b/apps/workbench/app/views/logs/show.html.erb
deleted file mode 100644 (file)
index 9079085..0000000
+++ /dev/null
@@ -1 +0,0 @@
-<%= render :partial => 'application/arvados_object' %>
index cac3431667a80e0c9437747b8de0eb6cdc9d8c79..e8862c24267846a461708d9f43f1162df0e4dbfc 100644 (file)
@@ -44,6 +44,9 @@ ArvadosWorkbench::Application.routes.draw do
     post 'set_persistent', on: :member
   end
   get '/collections/:uuid/*file' => 'collections#show_file', :format => false
+  resources :folders do
+    match 'remove/:item_uuid', on: :member, via: :delete, action: :remove_item
+  end
 
   post 'actions' => 'actions#post'
   get 'websockets' => 'websocket#index'
diff --git a/apps/workbench/test/functional/folders_controller_test.rb b/apps/workbench/test/functional/folders_controller_test.rb
new file mode 100644 (file)
index 0000000..2e06cca
--- /dev/null
@@ -0,0 +1,7 @@
+require 'test_helper'
+
+class FoldersControllerTest < ActionController::TestCase
+  # test "the truth" do
+  #   assert true
+  # end
+end
diff --git a/apps/workbench/test/integration/folders_test.rb b/apps/workbench/test/integration/folders_test.rb
new file mode 100644 (file)
index 0000000..dc51b77
--- /dev/null
@@ -0,0 +1,48 @@
+require 'integration_helper'
+require 'selenium-webdriver'
+require 'headless'
+
+class FoldersTest < ActionDispatch::IntegrationTest
+
+  test 'Find a folder and edit its description' do
+    Capybara.current_driver = Capybara.javascript_driver
+    visit page_with_token 'active', '/'
+    find('nav a', text: 'Folders').click
+    find('.side-nav a,button', text: 'A Folder').
+      click
+    within('.panel', text: api_fixture('groups')['afolder']['name']) do
+      find('span', text: api_fixture('groups')['afolder']['name']).click
+      find('.glyphicon-ok').click
+      find('.btn', text: 'Edit description').click
+      find('.editable-input textarea').set('I just edited this.')
+      find('.editable-submit').click
+      wait_for_ajax
+    end
+    visit current_path
+    assert(find?('.panel', text: 'I just edited this.'),
+           "Description update did not survive page refresh")
+  end
+
+  test 'Add a new name, then edit it, without creating a duplicate' do
+    Capybara.current_driver = Capybara.javascript_driver
+    folder_uuid = api_fixture('groups')['afolder']['uuid']
+    specimen_uuid = api_fixture('specimens')['owned_by_afolder_with_no_name_link']['uuid']
+    visit page_with_token 'active', '/folders/' + folder_uuid
+    within('.panel tr', text: specimen_uuid) do
+      find(".editable[data-name='name']").click
+      find('.editable-input input').set('Now I have a name.')
+      find('.glyphicon-ok').click
+      find('.editable', text: 'Now I have a name.').click
+      find('.editable-input input').set('Now I have a new name.')
+      find('.glyphicon-ok').click
+      wait_for_ajax
+      find('.editable', text: 'Now I have a new name.')
+    end
+    visit current_path
+    within '.panel', text: 'Contents' do
+      find '.editable', text: 'Now I have a new name.'
+      page.assert_no_selector '.editable', text: 'Now I have a name.'
+    end
+  end
+
+end
index 700c8e62925779298bdfee4ab7376347fce02ca6..864224b98dfd42aaee396fd374724a8793f0b105 100644 (file)
@@ -23,7 +23,7 @@ class SmokeTest < ActionDispatch::IntegrationTest
     visit page_with_token('active_trustedclient', '/')
     assert_visit_success
     click_link 'user-menu'
-    urls = [all_links_in('.arvados-nav'),
+    urls = [all_links_in('nav'),
             all_links_in('.navbar', /^Manage /)].flatten
     seen_urls = ['/']
     while not (url = urls.shift).nil?
index df7d2453a7396ea568c6ffddbcdcaaf4a7ed3bd9..6df7ee3a612f89785559067c60de381ccb061299 100644 (file)
@@ -23,7 +23,9 @@ class UsersTest < ActionDispatch::IntegrationTest
       assert (text.include? 'true false'), 'Expected is_active'
     end
 
-    click_link 'zzzzz-tpzed-xurymjxw79nv3jz'
+    find('tr', text: 'zzzzz-tpzed-xurymjxw79nv3jz').
+      find('a,button', text: 'Show').
+      click
     assert page.has_text? 'Attributes'
     assert page.has_text? 'Metadata'
     assert page.has_text? 'Admin'
@@ -31,10 +33,10 @@ class UsersTest < ActionDispatch::IntegrationTest
     # go to the Attributes tab
     click_link 'Attributes'
     assert page.has_text? 'modified_by_user_uuid'
-    page.within(:xpath, '//a[@data-name="is_active"]') do
+    page.within(:xpath, '//span[@data-name="is_active"]') do
       assert_equal "true", text, "Expected user's is_active to be true"
     end
-    page.within(:xpath, '//a[@data-name="is_admin"]') do
+    page.within(:xpath, '//span[@data-name="is_admin"]') do
       assert_equal "false", text, "Expected user's is_admin to be false"
     end
 
@@ -63,26 +65,23 @@ class UsersTest < ActionDispatch::IntegrationTest
       click_button "Submit"
     end
 
-    sleep(0.1)
-
-    # verify that the new user showed up in the users page
-    assert page.has_text? 'foo@example.com'
-
-    new_user_uuid = nil
-    all("tr").each do |elem|
-      if elem.text.include? 'foo@example.com'
-        new_user_uuid = elem.text.split[0]
-        break
-      end
-    end
+    visit '/users'
 
+    # verify that the new user showed up in the users page and find
+    # the new user's UUID
+    new_user_uuid =
+      find('tr[data-object-uuid]', text: 'foo@example.com').
+      find('td', text: '-tpzed-').
+      text
     assert new_user_uuid, "Expected new user uuid not found"
 
     # go to the new user's page
-    click_link new_user_uuid
+    find('tr', text: new_user_uuid).
+      find('a,button', text: 'Show').
+      click
 
     assert page.has_text? 'modified_by_user_uuid'
-    page.within(:xpath, '//a[@data-name="is_active"]') do
+    page.within(:xpath, '//span[@data-name="is_active"]') do
       assert_equal "false", text, "Expected new user's is_active to be false"
     end
 
@@ -102,10 +101,10 @@ class UsersTest < ActionDispatch::IntegrationTest
 
     click_link 'Users'
 
-    assert page.has_link? 'zzzzz-tpzed-xurymjxw79nv3jz'
-
     # click on active user
-    click_link 'zzzzz-tpzed-xurymjxw79nv3jz'
+    find('tr', text: 'zzzzz-tpzed-xurymjxw79nv3jz').
+      find('a,button', text: 'Show').
+      click
 
     # Setup user
     click_link 'Admin'
@@ -161,15 +160,15 @@ class UsersTest < ActionDispatch::IntegrationTest
 
     click_link 'Users'
 
-    assert page.has_link? 'zzzzz-tpzed-xurymjxw79nv3jz'
-
     # click on active user
-    click_link 'zzzzz-tpzed-xurymjxw79nv3jz'
+    find('tr', text: 'zzzzz-tpzed-xurymjxw79nv3jz').
+      find('a,button', text: 'Show').
+      click
 
     # Verify that is_active is set
-    click_link 'Attributes'
+    find('a,button', text: 'Attributes').click
     assert page.has_text? 'modified_by_user_uuid'
-    page.within(:xpath, '//a[@data-name="is_active"]') do
+    page.within(:xpath, '//span[@data-name="is_active"]') do
       assert_equal "true", text, "Expected user's is_active to be true"
     end
 
@@ -185,7 +184,7 @@ class UsersTest < ActionDispatch::IntegrationTest
     # Should now be back in the Attributes tab for the user
     page.driver.browser.switch_to.alert.accept
     assert page.has_text? 'modified_by_user_uuid'
-    page.within(:xpath, '//a[@data-name="is_active"]') do
+    page.within(:xpath, '//span[@data-name="is_active"]') do
       assert_equal "false", text, "Expected user's is_active to be false after unsetup"
     end
 
index 541a7aaac2c1e045e9dc5b76efa35f8c877fe866..26da0d0b039efa78e03808074b84150a147b1731 100644 (file)
@@ -7,8 +7,8 @@ class VirtualMachinesTest < ActionDispatch::IntegrationTest
     click_link 'Virtual machines'
     assert page.has_text? 'testvm.shell'
     click_on 'Add a new virtual machine'
-    assert page.has_text? 'none'
-    click_link 'none'
+    find('tr', text: 'hostname').
+      find('span', text: 'none').click
     assert page.has_text? 'Update hostname'
     fill_in 'editable-text', with: 'testname'
     click_button 'editable-submit'
index 88aec2ca6948ef8512b3b5604efe4b315d107560..a8788ceb53f6de95cffe0332eadb6dd6d312e808 100644 (file)
@@ -4,10 +4,24 @@ require 'capybara/poltergeist'
 require 'uri'
 require 'yaml'
 
+module WaitForAjax
+  Capybara.default_wait_time = 5
+  def wait_for_ajax
+    Timeout.timeout(Capybara.default_wait_time) do
+      loop until finished_all_ajax_requests?
+    end
+  end
+
+  def finished_all_ajax_requests?
+    page.evaluate_script('jQuery.active').zero?
+  end
+end
+
 class ActionDispatch::IntegrationTest
   # Make the Capybara DSL available in all integration tests
   include Capybara::DSL
   include ApiFixtureLoader
+  include WaitForAjax
 
   @@API_AUTHS = self.api_fixture('api_client_authorizations')
 
@@ -22,4 +36,16 @@ class ActionDispatch::IntegrationTest
     q_string = URI.encode_www_form('api_token' => api_token)
     "#{path}#{sep}#{q_string}"
   end
+
+  # Find a page element, but return false instead of raising an
+  # exception if not found. Use this with assertions to explain that
+  # the error signifies a failed test rather than an unexpected error
+  # during a testing procedure.
+  def find? *args
+    begin
+      find *args
+    rescue Capybara::ElementNotFound
+      false
+    end
+  end
 end
index cd90d725c01bc0aafb84a2822559af014ad42d9e..05be43cb5fe9fe7daeb1a224238f5411bc0c8b40 100644 (file)
@@ -1,4 +1,24 @@
 ENV["RAILS_ENV"] = "test"
+unless ENV["NO_COVERAGE_TEST"]
+  begin
+    require 'simplecov'
+    require 'simplecov-rcov'
+    class SimpleCov::Formatter::MergedFormatter
+      def format(result)
+        SimpleCov::Formatter::HTMLFormatter.new.format(result)
+        SimpleCov::Formatter::RcovFormatter.new.format(result)
+      end
+    end
+    SimpleCov.formatter = SimpleCov::Formatter::MergedFormatter
+    SimpleCov.start do
+      add_filter '/test/'
+      add_filter 'initializers/secret_token'
+    end
+  rescue Exception => e
+    $stderr.puts "SimpleCov unavailable (#{e}). Proceeding without."
+  end
+end
+
 require File.expand_path('../../config/environment', __FILE__)
 require 'rails/test_help'
 
@@ -65,6 +85,7 @@ class ApiServerBackedTestRunner < MiniTest::Unit
   def _run(args=[])
     Capybara.javascript_driver = :poltergeist
     server_pid = Dir.chdir($ARV_API_SERVER_DIR) do |apidir|
+      ENV["NO_COVERAGE_TEST"] = "1"
       _system('bundle', 'exec', 'rake', 'db:test:load')
       _system('bundle', 'exec', 'rake', 'db:fixtures:load')
       _system('bundle', 'exec', 'rails', 'server', '-d')
diff --git a/apps/workbench/test/unit/arvados_resource_list_test.rb b/apps/workbench/test/unit/arvados_resource_list_test.rb
new file mode 100644 (file)
index 0000000..a5681d2
--- /dev/null
@@ -0,0 +1,55 @@
+require 'test_helper'
+
+class ResourceListTest < ActiveSupport::TestCase
+
+  test 'links_for on a resource list that does not return links' do
+    use_token :active
+    results = Specimen.all
+    assert_equal [], results.links_for(api_fixture('users')['active']['uuid'])
+  end
+
+  test 'links_for on non-empty resource list' do
+    use_token :active
+    results = Group.find(api_fixture('groups')['afolder']['uuid']).contents(include_linked: true)
+    assert_equal [], results.links_for(api_fixture('users')['active']['uuid'])
+    assert_equal [], results.links_for(api_fixture('jobs')['running_cancelled']['uuid'])
+    assert_equal [], results.links_for(api_fixture('jobs')['running']['uuid'], 'bogus-link-class')
+    assert_equal true, results.links_for(api_fixture('jobs')['running']['uuid'], 'name').any?
+  end
+
+  test 'links_for returns all link classes (simulated results)' do
+    folder_uuid = api_fixture('groups')['afolder']['uuid']
+    specimen_uuid = api_fixture('specimens')['in_afolder']['uuid']
+    api_response = {
+      kind: 'arvados#specimenList',
+      links: [{kind: 'arvados#link',
+                uuid: 'zzzzz-o0j2j-asdfasdfasdfas0',
+                tail_uuid: folder_uuid,
+                head_uuid: specimen_uuid,
+                link_class: 'name',
+                name: 'Alice'},
+              {kind: 'arvados#link',
+                uuid: 'zzzzz-o0j2j-asdfasdfasdfas1',
+                tail_uuid: folder_uuid,
+                head_uuid: specimen_uuid,
+                link_class: 'foo',
+                name: 'Bob'},
+              {kind: 'arvados#link',
+                uuid: 'zzzzz-o0j2j-asdfasdfasdfas2',
+                tail_uuid: folder_uuid,
+                head_uuid: specimen_uuid,
+                link_class: nil,
+                name: 'Clydesdale'}],
+      items: [{kind: 'arvados#specimen',
+                uuid: specimen_uuid}]
+    }
+    arl = ArvadosResourceList.new
+    arl.results = ArvadosApiClient.new.unpack_api_response(api_response)
+    assert_equal(['name', 'foo', nil],
+                 (arl.
+                  links_for(specimen_uuid).
+                  collect { |x| x.link_class }),
+                 "Expected links_for to return all link_classes")
+  end
+
+end
index 0821e1fa6a084b34f25229d2cdd5e42ae4b82274..4a7144f99ae098feeaed448d3d099222e2662048 100644 (file)
@@ -1,7 +1,28 @@
 require 'test_helper'
 
-class ProjectTest < ActiveSupport::TestCase
-  # test "the truth" do
-  #   assert true
-  # end
+class GroupTest < ActiveSupport::TestCase
+  test "get contents with names" do
+    use_token :active
+    oi = Group.
+      find(api_fixture('groups')['asubfolder']['uuid']).
+      contents(include_linked: true)
+    assert_operator(0, :<, oi.count,
+                    "Expected to find some items belonging to :active user")
+    assert_operator(0, :<, oi.items_available,
+                    "Expected contents response to have items_available > 0")
+    assert_operator(0, :<, oi.result_links.count,
+                    "Expected to receive name links with contents response")
+    oi_uuids = oi.collect { |i| i['uuid'] }
+
+    expect_uuid = api_fixture('specimens')['in_asubfolder']['uuid']
+    assert_includes(oi_uuids, expect_uuid,
+                    "Expected '#{expect_uuid}' in asubfolder's contents")
+
+    expect_uuid = api_fixture('specimens')['in_afolder_linked_from_asubfolder']['uuid']
+    expect_name = api_fixture('links')['specimen_is_in_two_folders']['name']
+    assert_includes(oi_uuids, expect_uuid,
+                    "Expected '#{expect_uuid}' in asubfolder's contents")
+    assert_equal(expect_name, oi.name_for(expect_uuid),
+                 "Expected name_for '#{expect_uuid}' to be '#{expect_name}'")
+  end
 end
diff --git a/apps/workbench/test/unit/helpers/folders_helper_test.rb b/apps/workbench/test/unit/helpers/folders_helper_test.rb
new file mode 100644 (file)
index 0000000..5b4c557
--- /dev/null
@@ -0,0 +1,4 @@
+require 'test_helper'
+
+class FoldersHelperTest < ActionView::TestCase
+end
index d82765135c4af855c11009bcdec91d88cc262ebb..20cde7e868c0459d553a81747e32b98746cd7f2e 100644 (file)
@@ -1,16 +1,4 @@
 require 'test_helper'
 
 class UserTest < ActiveSupport::TestCase
-  test "get owned_items" do
-    use_token :active
-    oi = User.find(api_fixture('users')['active']['uuid']).owned_items
-    assert_operator(0, :<, oi.count,
-                    "Expected to find some items belonging to :active user")
-    assert_operator(0, :<, oi.items_available,
-                    "Expected owned_items response to have items_available > 0")
-    oi_uuids = oi.collect { |i| i['uuid'] }
-    expect = api_fixture('specimens')['owned_by_active_user']['uuid']
-    assert_includes(oi_uuids, expect,
-                    "Expected active user's owned_items to include #{expect}")
-  end
 end
index 4b0df898f355b48f21be9d4a7b24a4392db549ec..81b2c1ce6a482418f8302fde597e0ff1bdce300b 100644 (file)
@@ -44,8 +44,6 @@ h3. Arvados Infrastructure
 
 These resources govern the Arvados infrastructure itself: Git repositories, Keep disks, active nodes, etc.
 
-* "CommitAncestor":schema/CommitAncestor.html
-* "Commit":schema/Commit.html
 * "KeepDisk":schema/KeepDisk.html
 * "Node":schema/Node.html
 * "Repository":schema/Repository.html
index 9394d7691e46a5d4065351be2518f29d54c1612e..478662eea80665a734260ae28f15e4eb3e10c738 100644 (file)
@@ -14,6 +14,20 @@ API endpoint base: @https://{{ site.arvados_api_host }}/arvados/v1/groups@
 Required arguments are displayed in %{background:#ccffcc}green%.
 
 
+h2. contents
+
+Retrieve a list of items which are associated with the given group by ownership (i.e., the group owns the item) or a "name" link (i.e., a "name" link referencing the item).
+
+Arguments:
+
+table(table table-bordered table-condensed).
+|_. Argument |_. Type |_. Description |_. Location |_. Example |
+{background:#ccffcc}.|uuid|string|The UUID of the group in question.|path||
+|include_linked|boolean|If false, results will only include items whose @owner_uuid@ attribute is the specified group. If true, results will additionally include items for which a "name" link exists.|path|{white-space:nowrap}. @false@ (default)
+@true@|
+
+If @include_linked@ is @true@, the @"links"@ field in the response will contain the "name" links referencing the objects in the @"items"@ field.
+
 h2. create
 
 Create a new Group.
@@ -56,18 +70,6 @@ table(table table-bordered table-condensed).
 |order|string|Order in which to return matching groups.|query||
 |filters|array|Conditions for filtering groups.|query||
 
-h2. owned_items
-
-Retrieve a list of items which are owned by the given group.
-
-Arguments:
-
-table(table table-bordered table-condensed).
-|_. Argument |_. Type |_. Description |_. Location |_. Example |
-{background:#ccffcc}.|uuid|string|The UUID of the group in question.|path||
-|include_linked|boolean|If true, results will also include items on which the given group has _can_manage_ permission, even if they are owned by different users/groups.|path|{white-space:nowrap}. @false@ (default)
-@true@|
-
 h2. show
 
 show groups
index 65bd6969d4bb140ada079db6bff9b8e33973a33c..59fa856b493d98d0c6c31fbc08f89b15d80a141b 100644 (file)
@@ -74,18 +74,6 @@ table(table table-bordered table-condensed).
 |order|string|Order in which to return matching users.|query||
 |filters|array|Conditions for filtering users.|query||
 
-h2. owned_items
-
-Retrieve a list of items which are owned by the given user.
-
-Arguments:
-
-table(table table-bordered table-condensed).
-|_. Argument |_. Type |_. Description |_. Location |_. Example |
-{background:#ccffcc}.|uuid|string|The UUID of the user in question.|path||
-|include_linked|boolean|If true, results will also include items on which the given user has _can_manage_ permission, even if they are owned by different users/groups.|path|{white-space:nowrap}. @false@ (default)
-@true@|
-
 h2. show
 
 show users
index 06f6bca0ab6b27d994bf4260232d661f9435f06c..69a8dc3366b658e81069b3805c06141513621960 100644 (file)
@@ -18,7 +18,7 @@ The @uuid@ and @manifest_text@ attributes must be provided when creating a Colle
 
 h3. Side effects of creating a Collection
 
-Referenced data can be protected from garbage collection. See the section about "resources" links on the "Links":Links.html page.
+Referenced data can be protected from garbage collection. See the section about "resources" links on the "Links":Link.html page.
 
 Data can be shared with other users via the Arvados permission model.
 
index 2c3b6eb48ec9ca1932bd163561a5459333aa44b1..c2fc96646794bf189a70ef562415509007b40414 100644 (file)
@@ -5,46 +5,5 @@ title: Install client libraries
 
 ...
 
-
-
-h3. Python
-
-{% include 'notebox_begin' %}
-The Python package includes the Python API client library module and the CLI utilities @arv-get@ and @arv-put@.
-{% include 'notebox_end' %}
-
-Get the arvados source tree.
-
-notextile. <pre><code>$ <span class="userinput">git clone https://github.com/curoverse/arvados.git</span></code></pre>
-
-Build and install the python package.
-
-<notextile>
-<pre><code>$ <span class="userinput">cd arvados/sdk/python</span>
-$ <span class="userinput">sudo python setup.py install</span>
-</code></pre>
-</notextile>
-
-Alternatively, build the package (without sudo) using @python setup.py bdist_egg@ and copy the @.egg@ package from @dist/@ to the target system.
-
-h3. Ruby
-
-{% include 'notebox_begin' %}
-The arvados package includes the Ruby client library module. The arvados-cli package includes the CLI utilities @arv@, @arv-run-pipeline-instance@, and @crunch-job@.
-{% include 'notebox_end' %}
-
-notextile. <pre><code>$ <span class="userinput">sudo gem install arvados arvados-cli</span></code></pre>
-
-h3. Perl
-
-{% include 'notebox_begin' %}
-The Perl client library includes the @Arvados.pm@ module and submodules.
-{% include 'notebox_end' %}
-
-<notextile>
-<pre><code>$ <span class="userinput">cd arvados/sdk/perl</span>
-$ <span class="userinput">perl Makefile.PL</span>
-$ <span class="userinput">sudo make install</span>
-</code></pre>
-</notextile>
+The "SDK Reference":{{site.baseurl}}/sdk/index.html page has installation instructions for each of the SDKs.
 
index 09af1a323899f8f812bf3ac1d5c17c4814a34258..d563d6e6ae13f31ccdf9cf2b5279b45e05aa9056 100644 (file)
@@ -16,6 +16,10 @@ If you are logged in to an Arvados VM, the Python SDK should be installed.
 
 To use the Python SDK elsewhere, you can either install the Python SDK via PyPI or build and install the package using the arvados source tree.
 
+{% include 'notebox_begin' %}
+The Python SDK requires Python 2.7
+{% include 'notebox_end' %}
+
 h4. Option 1: install with PyPI
 
 <notextile>
index 1a455b1417883d776aa478f72097dd53480a01b6..11dfcfb4343099f0afa05c529425345d3ae26c61 100644 (file)
@@ -31,7 +31,7 @@ h4. Option 2: build and install from source
 <notextile>
 <pre>
 $ <code class="userinput">git clone https://github.com/curoverse/arvados.git</code>
-$ <code class="userinput">cd arvados/sdk/cli</code>
+$ <code class="userinput">cd arvados/sdk/ruby</code>
 $ <code class="userinput">gem build arvados.gemspec</code>
 $ <code class="userinput">sudo gem install arvados-*.gem</code>
 </pre>
index cc35d584bd5332e2cda93bca76384ddbd35fd3d6..8b734f2433e69d48cb7a4897aeebf914833bba5a 100644 (file)
@@ -12,34 +12,22 @@ import threading
 import arvados
 import pprint
 import arvados.events
+import re
+import apiclient
+import json
 
 from time import time
 from llfuse import FUSEError
 
-class Directory(object):
-    '''Generic directory object, backed by a dict.
-    Consists of a set of entries with the key representing the filename
-    and the value referencing a File or Directory object.
-    '''
-
-    def __init__(self, parent_inode):
-        '''parent_inode is the integer inode number'''
-        self.inode = None
-        if not isinstance(parent_inode, int):
-            raise Exception("parent_inode should be an int")
-        self.parent_inode = parent_inode
-        self._entries = {}
+class FreshBase(object):
+    '''Base class for maintaining fresh/stale state to determine when to update.'''
+    def __init__(self):
         self._stale = True
         self._poll = False
         self._last_update = time()
         self._poll_time = 60
 
-    #  Overriden by subclasses to implement logic to update the entries dict
-    #  when the directory is stale
-    def update(self):
-        pass
-
-    # Mark the entries dict as stale
+    # Mark the value as stale
     def invalidate(self):
         self._stale = True
 
@@ -55,31 +43,132 @@ class Directory(object):
         self._stale = False
         self._last_update = time()
 
+
+class File(FreshBase):
+    '''Base for file objects.'''
+
+    def __init__(self, parent_inode):
+        super(File, self).__init__()
+        self.inode = None
+        self.parent_inode = parent_inode
+
+    def size(self):
+        return 0
+
+    def readfrom(self, off, size):
+        return ''
+
+
+class StreamReaderFile(File):
+    '''Wraps a StreamFileReader as a file.'''
+
+    def __init__(self, parent_inode, reader):
+        super(StreamReaderFile, self).__init__(parent_inode)
+        self.reader = reader
+
+    def size(self):
+        return self.reader.size()
+
+    def readfrom(self, off, size):
+        return self.reader.readfrom(off, size)
+
+    def stale(self):
+        return False
+
+
+class ObjectFile(File):
+    '''Wraps a dict as a serialized json object.'''
+
+    def __init__(self, parent_inode, contents):
+        super(ObjectFile, self).__init__(parent_inode)
+        self.contentsdict = contents
+        self.uuid = self.contentsdict['uuid']
+        self.contents = json.dumps(self.contentsdict, indent=4, sort_keys=True)
+
+    def size(self):
+        return len(self.contents)
+
+    def readfrom(self, off, size):
+        return self.contents[off:(off+size)]
+
+
+class Directory(FreshBase):
+    '''Generic directory object, backed by a dict.
+    Consists of a set of entries with the key representing the filename
+    and the value referencing a File or Directory object.
+    '''
+
+    def __init__(self, parent_inode):
+        super(Directory, self).__init__()
+
+        '''parent_inode is the integer inode number'''
+        self.inode = None
+        if not isinstance(parent_inode, int):
+            raise Exception("parent_inode should be an int")
+        self.parent_inode = parent_inode
+        self._entries = {}
+
+    #  Overriden by subclasses to implement logic to update the entries dict
+    #  when the directory is stale
+    def update(self):
+        pass
+
     # Only used when computing the size of the disk footprint of the directory
     # (stub)
     def size(self):
         return 0
 
-    def __getitem__(self, item):
+    def checkupdate(self):
         if self.stale():
-            self.update()
+            try:
+                self.update()
+            except apiclient.errors.HttpError as e:
+                print e
+
+    def __getitem__(self, item):
+        self.checkupdate()
         return self._entries[item]
 
     def items(self):
-        if self.stale():
-            self.update()
+        self.checkupdate()
         return self._entries.items()
 
     def __iter__(self):
-        if self.stale():
-            self.update()
+        self.checkupdate()
         return self._entries.iterkeys()
 
     def __contains__(self, k):
-        if self.stale():
-            self.update()
+        self.checkupdate()
         return k in self._entries
 
+    def merge(self, items, fn, same, new_entry):
+        '''Helper method for updating the contents of the directory.
+
+        items: array with new directory contents
+
+        fn: function to take an entry in 'items' and return the desired file or
+        directory name
+
+        same: function to compare an existing entry with an entry in the items
+        list to determine whether to keep the existing entry.
+
+        new_entry: function to create a new directory entry from array entry.
+        '''
+
+        oldentries = self._entries
+        self._entries = {}
+        for i in items:
+            n = fn(i)
+            if n in oldentries and same(oldentries[n], i):
+                self._entries[n] = oldentries[n]
+                del oldentries[n]
+            else:
+                self._entries[n] = self.inodes.add_entry(new_entry(i))
+        for n in oldentries:
+            llfuse.invalidate_entry(self.inode, str(n))
+            self.inodes.del_entry(oldentries[n])
+        self.fresh()
+
 
 class CollectionDirectory(Directory):
     '''Represents the root of a directory tree holding a collection.'''
@@ -89,6 +178,9 @@ class CollectionDirectory(Directory):
         self.inodes = inodes
         self.collection_locator = collection_locator
 
+    def same(self, i):
+        return i['uuid'] == self.collection_locator
+
     def update(self):
         collection = arvados.CollectionReader(arvados.Keep.get(self.collection_locator))
         for s in collection.all_streams():
@@ -99,9 +191,10 @@ class CollectionDirectory(Directory):
                         cwd._entries[part] = self.inodes.add_entry(Directory(cwd.inode))
                     cwd = cwd._entries[part]
             for k, v in s.files().items():
-                cwd._entries[k] = self.inodes.add_entry(File(cwd.inode, v))
+                cwd._entries[k] = self.inodes.add_entry(StreamReaderFile(cwd.inode, v))
         self.fresh()
 
+
 class MagicDirectory(Directory):
     '''A special directory that logically contains the set of all extant keep
     locators.  When a file is referenced by lookup(), it is tested to see if it
@@ -154,17 +247,11 @@ class TagsDirectory(Directory):
                 self._entries[a].invalidate()
 
     def update(self):
-        tags = self.api.links().list(filters=[['link_class', '=', 'tag']], select=['name'], distinct = 'name').execute()
-        oldentries = self._entries
-        self._entries = {}
-        for n in tags['items']:
-            n = n['name']
-            if n in oldentries:
-                self._entries[n] = oldentries[n]
-            else:
-                self._entries[n] = self.inodes.add_entry(TagDirectory(self.inode, self.inodes, self.api, n, poll=self._poll, poll_time=self._poll_time))
-        self.fresh()
-
+        tags = self.api.links().list(filters=[['link_class', '=', 'tag']], select=['name'], distinct = True).execute()
+        self.merge(tags['items'],
+                   lambda i: i['name'],
+                   lambda a, i: a.tag == i,
+                   lambda i: TagDirectory(self.inode, self.inodes, self.api, i['name'], poll=self._poll, poll_time=self._poll_time))
 
 class TagDirectory(Directory):
     '''A special directory that contains as subdirectories all collections visible
@@ -180,30 +267,94 @@ class TagDirectory(Directory):
         self._poll_time = poll_time
 
     def update(self):
-        collections = self.api.links().list(filters=[['link_class', '=', 'tag'],
+        taggedcollections = self.api.links().list(filters=[['link_class', '=', 'tag'],
                                                ['name', '=', self.tag],
                                                ['head_uuid', 'is_a', 'arvados#collection']],
                                       select=['head_uuid']).execute()
-        oldentries = self._entries
-        self._entries = {}
-        for c in collections['items']:
-            n = c['head_uuid']
-            if n in oldentries:
-                self._entries[n] = oldentries[n]
-            else:
-                self._entries[n] = self.inodes.add_entry(CollectionDirectory(self.inode, self.inodes, n))
-        self.fresh()
+        self.merge(taggedcollections['items'],
+                   lambda i: i['head_uuid'],
+                   lambda a, i: a.collection_locator == i['head_uuid'],
+                   lambda i: CollectionDirectory(self.inode, self.inodes, i['head_uuid']))
 
-class File(object):
-    '''Wraps a StreamFileReader for use by Directory.'''
 
-    def __init__(self, parent_inode, reader):
-        self.inode = None
-        self.parent_inode = parent_inode
-        self.reader = reader
+class GroupsDirectory(Directory):
+    '''A special directory that contains as subdirectories all groups visible to the user.'''
 
-    def size(self):
-        return self.reader.size()
+    def __init__(self, parent_inode, inodes, api, poll_time=60):
+        super(GroupsDirectory, self).__init__(parent_inode)
+        self.inodes = inodes
+        self.api = api
+        try:
+            arvados.events.subscribe(self.api, [], lambda ev: self.invalidate())
+        except:
+            self._poll = True
+            self._poll_time = poll_time
+
+    def invalidate(self):
+        with llfuse.lock:
+            super(GroupsDirectory, self).invalidate()
+            for a in self._entries:
+                self._entries[a].invalidate()
+
+    def update(self):
+        groups = self.api.groups().list().execute()
+        self.merge(groups['items'],
+                   lambda i: i['uuid'],
+                   lambda a, i: a.uuid == i['uuid'],
+                   lambda i: GroupDirectory(self.inode, self.inodes, self.api, i, poll=self._poll, poll_time=self._poll_time))
+
+
+class GroupDirectory(Directory):
+    '''A special directory that contains the contents of a group.'''
+
+    def __init__(self, parent_inode, inodes, api, uuid, poll=False, poll_time=60):
+        super(GroupDirectory, self).__init__(parent_inode)
+        self.inodes = inodes
+        self.api = api
+        self.uuid = uuid['uuid']
+        self._poll = poll
+        self._poll_time = poll_time
+
+    def invalidate(self):
+        with llfuse.lock:
+            super(GroupDirectory, self).invalidate()
+            for a in self._entries:
+                self._entries[a].invalidate()
+
+    def createDirectory(self, i):
+        if re.match(r'[0-9a-f]{32}\+\d+', i['uuid']):
+            return CollectionDirectory(self.inode, self.inodes, i['uuid'])
+        elif re.match(r'[a-z0-9]{5}-j7d0g-[a-z0-9]{15}', i['uuid']):
+            return GroupDirectory(self.parent_inode, self.inodes, self.api, i, self._poll, self._poll_time)
+        elif re.match(r'[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}', i['uuid']):
+            return ObjectFile(self.parent_inode, i)
+        return None
+
+    def update(self):
+        contents = self.api.groups().contents(uuid=self.uuid, include_linked=True).execute()
+        links = {}
+        for a in contents['links']:
+            links[a['head_uuid']] = a['name']
+
+        def choose_name(i):
+            if i['uuid'] in links:
+                return links[i['uuid']]
+            else:
+                return i['uuid']
+
+        def same(a, i):
+            if isinstance(a, CollectionDirectory):
+                return a.collection_locator == i['uuid']
+            elif isinstance(a, GroupDirectory):
+                return a.uuid == i['uuid']
+            elif isinstance(a, ObjectFile):
+                return a.uuid == i['uuid'] and not a.stale()
+            return False
+
+        self.merge(contents['items'],
+                   choose_name,
+                   same,
+                   self.createDirectory)
 
 
 class FileHandle(object):
@@ -244,6 +395,10 @@ class Inodes(object):
         self._counter += 1
         return entry
 
+    def del_entry(self, entry):
+        llfuse.invalidate_inode(entry.inode)
+        del self._entries[entry.inode]
+
 class Operations(llfuse.Operations):
     '''This is the main interface with llfuse.  The methods on this object are
     called by llfuse threads to service FUSE events to query and read from
@@ -277,6 +432,9 @@ class Operations(llfuse.Operations):
         return True
 
     def getattr(self, inode):
+        if inode not in self.inodes:
+            raise llfuse.FUSEError(errno.ENOENT)
+
         e = self.inodes[inode]
 
         entry = llfuse.EntryAttributes()
@@ -353,7 +511,7 @@ class Operations(llfuse.Operations):
 
         try:
             with llfuse.lock_released:
-                return handle.entry.reader.readfrom(off, size)
+                return handle.entry.readfrom(off, size)
         except:
             raise llfuse.FUSEError(errno.EIO)
 
@@ -394,7 +552,8 @@ class Operations(llfuse.Operations):
 
         e = off
         while e < len(handle.entry):
-            yield (handle.entry[e][0], self.getattr(handle.entry[e][1].inode), e+1)
+            if handle.entry[e][1].inode in self.inodes:
+                yield (handle.entry[e][0], self.getattr(handle.entry[e][1].inode), e+1)
             e += 1
 
     def releasedir(self, fh):
index 79b9148d7b2abbb6641d0fb720761d168b41b446..fc5491ff68ccb68c16745ee9032c41d96fe30c4f 100755 (executable)
@@ -4,6 +4,7 @@ from arvados.fuse import *
 import arvados
 import subprocess
 import argparse
+import daemon
 
 if __name__ == '__main__':
     # Handle command line parameters
@@ -22,7 +23,9 @@ with "--".
     parser.add_argument('--collection', type=str, help="""Mount only the specified collection at the mount point.""")
     parser.add_argument('--tags', action='store_true', help="""Mount as a virtual directory consisting of subdirectories representing tagged
 collections on the server.""")
+    parser.add_argument('--groups', action='store_true', help="""Mount as a virtual directory consisting of subdirectories representing groups on the server.""")
     parser.add_argument('--debug', action='store_true', help="""Debug mode""")
+    parser.add_argument('--foreground', action='store_true', help="""Run in foreground (default is to daemonize unless --exec specified)""", default=False)
     parser.add_argument('--exec', type=str, nargs=argparse.REMAINDER,
                         dest="exec_args", metavar=('command', 'args', '...', '--'),
                         help="""Mount, run a command, then unmount and exit""")
@@ -32,7 +35,10 @@ collections on the server.""")
     # Create the request handler
     operations = Operations(os.getuid(), os.getgid())
 
-    if args.tags:
+    if args.groups:
+        api = arvados.api('v1')
+        e = operations.inodes.add_entry(GroupsDirectory(llfuse.ROOT_INODE, operations.inodes, api))
+    elif args.tags:
         api = arvados.api('v1')
         e = operations.inodes.add_entry(TagsDirectory(llfuse.ROOT_INODE, operations.inodes, api))
     elif args.collection != None:
@@ -49,10 +55,10 @@ collections on the server.""")
     if args.debug:
         opts += ['debug']
 
-    # Initialize the fuse connection
-    llfuse.init(operations, args.mountpoint, opts)
-
     if args.exec_args:
+        # Initialize the fuse connection
+        llfuse.init(operations, args.mountpoint, opts)
+
         t = threading.Thread(None, lambda: llfuse.main())
         t.start()
 
@@ -72,4 +78,12 @@ collections on the server.""")
 
         exit(rc)
     else:
-        llfuse.main()
+        if args.foreground:
+            # Initialize the fuse connection
+            llfuse.init(operations, args.mountpoint, opts)
+            llfuse.main()
+        else:
+            with daemon.DaemonContext():
+                # Initialize the fuse connection
+                llfuse.init(operations, args.mountpoint, opts)
+                llfuse.main()
index 652e3ce854439c3397d1717e3996ae134ebd0620..a6a7591c8d11c8cdb77c5dffe9a34de55daa8355 100644 (file)
@@ -4,3 +4,5 @@ python-gflags==2.0
 urllib3==1.7.1
 llfuse==0.40
 ws4py==0.3.4
+PyYAML==3.11
+python-daemon==1.6
index 4c759c11e99b2d0f50cef5246a4e946a622b34f0..28aa89f7193c8dea62e73e5b875597bc2c2edb79 100644 (file)
@@ -15,6 +15,7 @@ setup(name='arvados-fuse-driver',
         ],
       install_requires=[
         'arvados-python-client',
-       'llfuse'
+       'llfuse',
+        'python-daemon'
         ],
       zip_safe=False)
index 9251f6976319ad0cba68262037c4b7ba5acebea9..a462799899b83ea878f7e59a548cc75f2a67c29c 100644 (file)
@@ -10,7 +10,7 @@ import shutil
 import subprocess
 import glob
 import run_test_server
-
+import json
 
 class MountTestBase(unittest.TestCase):
     def setUp(self):
@@ -236,7 +236,7 @@ class FuseTagsUpdateTestBase(MountTestBase):
         d3.sort()
         self.assertEqual(['fa7aeb5140e2848d39b416daeef4ffc5+45'], d3)
 
-        api.links().create(body={'link': {
+        l = api.links().create(body={'link': {
             'head_uuid': 'ea10d51bcf88862dbcc36eb292017dfd+45',
             'link_class': 'tag',
             'name': 'bar_tag'
@@ -248,6 +248,14 @@ class FuseTagsUpdateTestBase(MountTestBase):
         d4.sort()
         self.assertEqual(['ea10d51bcf88862dbcc36eb292017dfd+45', 'fa7aeb5140e2848d39b416daeef4ffc5+45'], d4)
 
+        api.links().delete(uuid=l['uuid']).execute()
+
+        time.sleep(1)
+
+        d5 = os.listdir(os.path.join(self.mounttmp, 'bar_tag'))
+        d5.sort()
+        self.assertEqual(['fa7aeb5140e2848d39b416daeef4ffc5+45'], d5)
+
 
 class FuseTagsUpdateTestWebsockets(FuseTagsUpdateTestBase):
     def setUp(self):
@@ -273,3 +281,51 @@ class FuseTagsUpdateTestPoll(FuseTagsUpdateTestBase):
     def tearDown(self):
         run_test_server.stop(False)
         super(FuseTagsUpdateTestPoll, self).tearDown()
+
+
+class FuseGroupsTest(MountTestBase):
+    def setUp(self):
+        super(FuseGroupsTest, self).setUp()
+        run_test_server.run()
+
+    def runTest(self):
+        run_test_server.authorize_with("admin")
+        api = arvados.api('v1')
+
+        operations = fuse.Operations(os.getuid(), os.getgid())
+        e = operations.inodes.add_entry(fuse.GroupsDirectory(llfuse.ROOT_INODE, operations.inodes, api))
+
+        llfuse.init(operations, self.mounttmp, [])
+        t = threading.Thread(None, lambda: llfuse.main())
+        t.start()
+
+        # wait until the driver is finished initializing
+        operations.initlock.wait()
+
+        d1 = os.listdir(self.mounttmp)
+        d1.sort()
+        self.assertIn('zzzzz-j7d0g-v955i6s2oi1cbso', d1)
+
+        d2 = os.listdir(os.path.join(self.mounttmp, 'zzzzz-j7d0g-v955i6s2oi1cbso'))
+        d2.sort()
+        self.assertEqual(["I'm a job in a folder",
+                          "I'm a template in a folder",
+                          "zzzzz-j58dm-5gid26432uujf79",
+                          "zzzzz-j58dm-7r18rnd5nzhg5yk",
+                          "zzzzz-j7d0g-axqo7eu9pwvna1x"
+                      ], d2)
+
+        d3 = os.listdir(os.path.join(self.mounttmp, 'zzzzz-j7d0g-v955i6s2oi1cbso', 'zzzzz-j7d0g-axqo7eu9pwvna1x'))
+        d3.sort()
+        self.assertEqual(["I'm in a subfolder, too",
+                          "zzzzz-j58dm-c40lddwcqqr1ffs",
+                          "zzzzz-o0j2j-ryhm1bn83ni03sn"
+                      ], d3)
+
+        with open(os.path.join(self.mounttmp, 'zzzzz-j7d0g-v955i6s2oi1cbso', "I'm a template in a folder")) as f:
+            j = json.load(f)
+            self.assertEqual("Two Part Pipeline Template", j['name'])
+
+    def tearDown(self):
+        run_test_server.stop()
+        super(FuseGroupsTest, self).tearDown()
index 53dc71ab230ad36d6433af3f4434d66fb9cf11c4..1a58eb08a2b5c4c7c9dc442b1227c94339703a31 100644 (file)
@@ -1 +1,2 @@
+Gemfile.lock
 arvados*gem
diff --git a/sdk/ruby/Gemfile.lock b/sdk/ruby/Gemfile.lock
deleted file mode 100644 (file)
index c71fea0..0000000
+++ /dev/null
@@ -1,58 +0,0 @@
-PATH
-  remote: .
-  specs:
-    arvados (0.1.20140228213600)
-      activesupport (>= 3.2.13)
-      andand
-      google-api-client (~> 0.6.3)
-      json (>= 1.7.7)
-
-GEM
-  remote: https://rubygems.org/
-  specs:
-    activesupport (3.2.17)
-      i18n (~> 0.6, >= 0.6.4)
-      multi_json (~> 1.0)
-    addressable (2.3.5)
-    andand (1.3.3)
-    autoparse (0.3.3)
-      addressable (>= 2.3.1)
-      extlib (>= 0.9.15)
-      multi_json (>= 1.0.0)
-    extlib (0.9.16)
-    faraday (0.8.9)
-      multipart-post (~> 1.2.0)
-    google-api-client (0.6.4)
-      addressable (>= 2.3.2)
-      autoparse (>= 0.3.3)
-      extlib (>= 0.9.15)
-      faraday (~> 0.8.4)
-      jwt (>= 0.1.5)
-      launchy (>= 2.1.1)
-      multi_json (>= 1.0.0)
-      signet (~> 0.4.5)
-      uuidtools (>= 2.1.0)
-    i18n (0.6.9)
-    json (1.8.1)
-    jwt (0.1.11)
-      multi_json (>= 1.5)
-    launchy (2.4.2)
-      addressable (~> 2.3)
-    minitest (5.2.2)
-    multi_json (1.8.4)
-    multipart-post (1.2.0)
-    rake (10.1.1)
-    signet (0.4.5)
-      addressable (>= 2.2.3)
-      faraday (~> 0.8.1)
-      jwt (>= 0.1.5)
-      multi_json (>= 1.0.0)
-    uuidtools (2.1.4)
-
-PLATFORMS
-  ruby
-
-DEPENDENCIES
-  arvados!
-  minitest (>= 5.0.0)
-  rake
index 1b76c6446b10fd1bad8e441d617654fe1e6b7ec3..c286717a7e174a4705ceb5b3657e9fe2e5d555ac 100644 (file)
@@ -22,3 +22,5 @@
 /Capfile*
 /config/deploy*
 
+# SimpleCov reports
+/coverage
index 3b715d3a82c3edd0bcc4c1a5e0303126dfb083ff..b0f85124a07b1010a940f19c9cbcc744fb8de485 100644 (file)
@@ -6,7 +6,11 @@ gem 'rails', '~> 3.2.0'
 # gem 'rails',     :git => 'git://github.com/rails/rails.git'
 
 group :test, :development do
-  gem 'sqlite3'
+  # Note: "require: false" here tells bunder not to automatically
+  # 'require' the packages during application startup. Installation is
+  # still mandatory.
+  gem 'simplecov', '~> 0.7.1', require: false
+  gem 'simplecov-rcov', require: false
 end
 
 # This might not be needed in :test and :development, but we load it
@@ -68,3 +72,6 @@ gem 'database_cleaner'
 gem 'themes_for_rails'
 
 gem 'arvados-cli', '>= 0.1.20140328152103'
+
+# pg_power lets us use partial indexes in schema.rb in Rails 3
+gem 'pg_power'
index d00e681cf739c90ff2d1ef869973c14fdf258362..d574644644aa1c94dfab216d982d78bc637d7b2c 100644 (file)
@@ -135,6 +135,9 @@ GEM
       rack
       rake (>= 0.8.1)
     pg (0.17.1)
+    pg_power (1.6.4)
+      pg
+      rails (~> 3.1)
     polyglot (0.3.4)
     rack (1.4.5)
     rack-cache (1.2)
@@ -175,12 +178,17 @@ GEM
       faraday (~> 0.8.1)
       jwt (>= 0.1.5)
       multi_json (>= 1.0.0)
+    simplecov (0.7.1)
+      multi_json (~> 1.0)
+      simplecov-html (~> 0.7.1)
+    simplecov-html (0.7.1)
+    simplecov-rcov (0.2.3)
+      simplecov (>= 0.4.1)
     sprockets (2.2.2)
       hike (~> 1.2)
       multi_json (~> 1.0)
       rack (~> 1.0)
       tilt (~> 1.1, != 1.3.0)
-    sqlite3 (1.3.9)
     test_after_commit (0.2.3)
     themes_for_rails (0.5.1)
       rails (>= 3.0.0)
@@ -218,11 +226,13 @@ DEPENDENCIES
   omniauth-oauth2 (= 1.1.1)
   passenger
   pg
+  pg_power
   rails (~> 3.2.0)
   redis
   rvm-capistrano
   sass-rails (>= 3.2.0)
-  sqlite3
+  simplecov (~> 0.7.1)
+  simplecov-rcov
   test_after_commit
   themes_for_rails
   therubyracer
index 17d5fe7202f6be2c4d0ba04ff9b10a1fead85c3a..223f5ca2168c5ab25d316ef97dd3eb6081fb1463 100644 (file)
@@ -4,4 +4,10 @@
 
 require File.expand_path('../config/application', __FILE__)
 
+begin
+  ok = PgPower
+rescue
+  abort "Hm, pg_power is missing. Make sure you use 'bundle exec rake ...'"
+end
+
 Server::Application.load_tasks
index 4d23b0bcbfddf07b47638979e3877682b1333087..9a54abe4d0fc6dd907ea362d4e7c547ef21fb88f 100644 (file)
@@ -34,9 +34,9 @@ class ApplicationController < ActionController::Base
   before_filter :catch_redirect_hint
   before_filter(:find_object_by_uuid,
                 except: [:index, :create] + ERROR_ACTIONS)
-  before_filter :load_limit_offset_order_params, only: [:index, :owned_items]
-  before_filter :load_where_param, only: [:index, :owned_items]
-  before_filter :load_filters_param, only: [:index, :owned_items]
+  before_filter :load_limit_offset_order_params, only: [:index, :contents]
+  before_filter :load_where_param, only: [:index, :contents]
+  before_filter :load_filters_param, only: [:index, :contents]
   before_filter :find_objects_for_index, :only => :index
   before_filter :reload_object_before_update, :only => :update
   before_filter(:render_404_if_no_object,
@@ -77,75 +77,6 @@ class ApplicationController < ActionController::Base
     show
   end
 
-  def self._owned_items_requires_parameters
-    _index_requires_parameters.
-      merge({
-              include_linked: {
-                type: 'boolean', required: false, default: false
-              },
-            })
-  end
-
-  def owned_items
-    all_objects = []
-    all_available = 0
-
-    # Trick apply_where_limit_order_params into applying suitable
-    # per-table values. *_all are the real ones we'll apply to the
-    # aggregate set.
-    limit_all = @limit
-    offset_all = @offset
-    @orders = []
-
-    ArvadosModel.descendants.
-      reject(&:abstract_class?).
-      sort_by(&:to_s).
-      each do |klass|
-      case klass.to_s
-        # We might expect klass==Link etc. here, but we would be
-        # disappointed: when Rails reloads model classes, we get two
-        # distinct classes called Link which do not equal each
-        # other. But we can still rely on klass.to_s to be "Link".
-      when 'ApiClientAuthorization'
-        # Do not want.
-      else
-        @objects = klass.readable_by(*@read_users)
-        cond_sql = "#{klass.table_name}.owner_uuid = ?"
-        cond_params = [@object.uuid]
-        if params[:include_linked]
-          @objects = @objects.
-            joins("LEFT JOIN links mng_links"\
-                  " ON mng_links.link_class=#{klass.sanitize 'permission'}"\
-                  "    AND mng_links.name=#{klass.sanitize 'can_manage'}"\
-                  "    AND mng_links.tail_uuid=#{klass.sanitize @object.uuid}"\
-                  "    AND mng_links.head_uuid=#{klass.table_name}.uuid")
-          cond_sql += " OR mng_links.uuid IS NOT NULL"
-        end
-        @objects = @objects.where(cond_sql, *cond_params).order(:uuid)
-        @limit = limit_all - all_objects.count
-        apply_where_limit_order_params
-        items_available = @objects.
-          except(:limit).except(:offset).
-          count(:id, distinct: true)
-        all_available += items_available
-        @offset = [@offset - items_available, 0].max
-
-        all_objects += @objects.to_a
-      end
-    end
-    @objects = all_objects || []
-    @object_list = {
-      :kind  => "arvados#objectList",
-      :etag => "",
-      :self_link => "",
-      :offset => offset_all,
-      :limit => limit_all,
-      :items_available => all_available,
-      :items => @objects.as_api_response(nil)
-    }
-    render json: @object_list
-  end
-
   def catch_redirect_hint
     if !current_user
       if params.has_key?('redirect_to') then
index f742fa9d7139410703fa307201a55a3b64d5477d..49f0b02c5fdd84d9a8605ba33b7d3f71fdee0f8d 100644 (file)
@@ -1,2 +1,70 @@
 class Arvados::V1::GroupsController < ApplicationController
+
+  def self._contents_requires_parameters
+    _index_requires_parameters.
+      merge({
+              include_linked: {
+                type: 'boolean', required: false, default: false
+              },
+            })
+  end
+
+  def contents
+    all_objects = []
+    all_available = 0
+
+    # Trick apply_where_limit_order_params into applying suitable
+    # per-table values. *_all are the real ones we'll apply to the
+    # aggregate set.
+    limit_all = @limit
+    offset_all = @offset
+    @orders = []
+
+    ArvadosModel.descendants.reject(&:abstract_class?).sort_by(&:to_s).
+      each do |klass|
+      case klass.to_s
+        # We might expect klass==Link etc. here, but we would be
+        # disappointed: when Rails reloads model classes, we get two
+        # distinct classes called Link which do not equal each
+        # other. But we can still rely on klass.to_s to be "Link".
+      when 'ApiClientAuthorization', 'UserAgreement', 'Link'
+        # Do not want.
+      else
+        @objects = klass.readable_by(*@read_users)
+        cond_sql = "#{klass.table_name}.owner_uuid = ?"
+        cond_params = [@object.uuid]
+        if params[:include_linked]
+          cond_sql += " OR #{klass.table_name}.uuid IN (SELECT head_uuid FROM links WHERE link_class=#{klass.sanitize 'name'} AND links.tail_uuid=#{klass.sanitize @object.uuid})"
+        end
+        @objects = @objects.where(cond_sql, *cond_params).order("#{klass.table_name}.uuid")
+        @limit = limit_all - all_objects.count
+        apply_where_limit_order_params
+        items_available = @objects.
+          except(:limit).except(:offset).
+          count(:id, distinct: true)
+        all_available += items_available
+        @offset = [@offset - items_available, 0].max
+
+        all_objects += @objects.to_a
+      end
+    end
+    @objects = all_objects || []
+    @links = Link.where('link_class=? and tail_uuid=?'\
+                        ' and head_uuid in (?)',
+                        'name',
+                        @object.uuid,
+                        @objects.collect(&:uuid))
+    @object_list = {
+      :kind  => "arvados#objectList",
+      :etag => "",
+      :self_link => "",
+      :links => @links.as_api_response(nil),
+      :offset => offset_all,
+      :limit => limit_all,
+      :items_available => all_available,
+      :items => @objects.as_api_response(nil)
+    }
+    render json: @object_list
+  end
+
 end
index 3d4b05af4a0db5dbf37057ec71adb2b6b69e9b61..0b80877bc25624e9b66a38f8c0c35c75b468cc0f 100644 (file)
@@ -9,7 +9,6 @@ class UserSessionsController < ApplicationController
   # omniauth callback method
   def create
     omniauth = env['omniauth.auth']
-    #logger.debug "+++ #{omniauth}"
 
     identity_url_ok = (omniauth['info']['identity_url'].length > 0) rescue false
     unless identity_url_ok
@@ -58,7 +57,7 @@ class UserSessionsController < ApplicationController
     # "unauthorized":
     Thread.current[:user] = user
 
-    user.save!
+    user.save or raise Exception.new(user.errors.messages)
 
     omniauth.delete('extra')
 
index 9dfca2d9414cf8d66dd0673d68052cd4137d3dec..006eb90e12fd550e2110906a1b1c42cc64e291e8 100644 (file)
@@ -9,8 +9,10 @@ class ArvadosModel < ActiveRecord::Base
   attr_protected :modified_by_client_uuid
   attr_protected :modified_at
   after_initialize :log_start_state
-  before_create :ensure_permission_to_create
-  before_update :ensure_permission_to_update
+  before_save :ensure_permission_to_save
+  before_save :ensure_owner_uuid_is_permitted
+  before_save :ensure_ownership_path_leads_to_user
+  before_destroy :ensure_owner_uuid_is_permitted
   before_destroy :ensure_permission_to_destroy
 
   before_create :update_modified_by_fields
@@ -62,6 +64,24 @@ class ArvadosModel < ActiveRecord::Base
     self.columns.select { |col| col.name == attr.to_s }.first
   end
 
+  # Return nil if current user is not allowed to see the list of
+  # writers. Otherwise, return a list of user_ and group_uuids with
+  # write permission. (If not returning nil, current_user is always in
+  # the list because can_manage permission is needed to see the list
+  # of writers.)
+  def writable_by
+    unless (owner_uuid == current_user.uuid or
+            current_user.is_admin or
+            current_user.groups_i_can(:manage).index(owner_uuid))
+      return nil
+    end
+    [owner_uuid, current_user.uuid] + permissions.collect do |p|
+      if ['can_write', 'can_manage'].index p.name
+        p.tail_uuid
+      end
+    end.compact.uniq
+  end
+
   # Return a query with read permissions restricted to the union of of the
   # permissions of the members of users_list, i.e. if something is readable by
   # any user in users_list, it will be readable in the query returned by this
@@ -90,9 +110,11 @@ class ArvadosModel < ActiveRecord::Base
       # A permission link exists ('write' and 'manage' implicitly include
       # 'read') from a member of users_list, or a group readable by users_list,
       # to this row, or to the owner of this row (see join() below).
+      permitted_uuids = "(SELECT head_uuid FROM links WHERE link_class='permission' AND tail_uuid IN (#{sanitized_uuid_list}))"
+
       sql_conds += ["#{table_name}.owner_uuid in (?)",
                     "#{table_name}.uuid in (?)",
-                    "permissions.head_uuid IS NOT NULL"]
+                    "#{table_name}.uuid IN #{permitted_uuids}"]
       sql_params += [uuid_list, user_uuids]
 
       if self == Link and users_list.any?
@@ -105,7 +127,7 @@ class ArvadosModel < ActiveRecord::Base
 
       if self == Log and users_list.any?
         # Link head points to the object described by this row
-        or_object_uuid = ", #{table_name}.object_uuid"
+        sql_conds += ["#{table_name}.object_uuid IN #{permitted_uuids}"]
 
         # This object described by this row is owned by this user, or owned by a group readable by this user
         sql_conds += ["#{table_name}.object_owner_uuid in (?)"]
@@ -118,15 +140,11 @@ class ArvadosModel < ActiveRecord::Base
       # user (the identity with authorization to read)
       #
       # Link class is 'permission' ('write' and 'manage' implicitly include 'read')
-
-      joins("LEFT JOIN links permissions ON permissions.head_uuid in (#{table_name}.owner_uuid, #{table_name}.uuid #{or_object_uuid}) AND permissions.tail_uuid in (#{sanitized_uuid_list}) AND permissions.link_class='permission'")
-        .where(sql_conds.join(' OR '), *sql_params).uniq
-
+      where(sql_conds.join(' OR '), *sql_params)
     else
       # At least one user is admin, so don't bother to apply any restrictions.
       self
     end
-
   end
 
   def logged_attributes
@@ -135,16 +153,71 @@ class ArvadosModel < ActiveRecord::Base
 
   protected
 
-  def ensure_permission_to_create
-    raise PermissionDeniedError unless permission_to_create
+  def ensure_ownership_path_leads_to_user
+    if new_record? or owner_uuid_changed?
+      uuid_in_path = {owner_uuid => true, uuid => true}
+      x = owner_uuid
+      while (owner_class = self.class.resource_class_for_uuid(x)) != User
+        begin
+          if x == uuid
+            # Test for cycles with the new version, not the DB contents
+            x = owner_uuid
+          elsif !owner_class.respond_to? :find_by_uuid
+            raise ActiveRecord::RecordNotFound.new
+          else
+            x = owner_class.find_by_uuid(x).owner_uuid
+          end
+        rescue ActiveRecord::RecordNotFound => e
+          errors.add :owner_uuid, "is not owned by any user: #{e}"
+          return false
+        end
+        if uuid_in_path[x]
+          if x == owner_uuid
+            errors.add :owner_uuid, "would create an ownership cycle"
+          else
+            errors.add :owner_uuid, "has an ownership cycle"
+          end
+          return false
+        end
+        uuid_in_path[x] = true
+      end
+    end
+    true
   end
 
-  def permission_to_create
-    current_user.andand.is_active
+  def ensure_owner_uuid_is_permitted
+    raise PermissionDeniedError if !current_user
+    self.owner_uuid ||= current_user.uuid
+    if self.owner_uuid_changed?
+      if current_user.uuid == self.owner_uuid or
+          current_user.can? write: self.owner_uuid
+        # current_user is, or has :write permission on, the new owner
+      else
+        logger.warn "User #{current_user.uuid} tried to change owner_uuid of #{self.class.to_s} #{self.uuid} to #{self.owner_uuid} but does not have permission to write to #{self.owner_uuid}"
+        raise PermissionDeniedError
+      end
+    end
+    if new_record?
+      return true
+    elsif current_user.uuid == self.owner_uuid_was or
+        current_user.uuid == self.uuid or
+        current_user.can? write: self.owner_uuid_was
+      # current user is, or has :write permission on, the previous owner
+      return true
+    else
+      logger.warn "User #{current_user.uuid} tried to modify #{self.class.to_s} #{self.uuid} but does not have permission to write #{self.owner_uuid_was}"
+      raise PermissionDeniedError
+    end
+  end
+
+  def ensure_permission_to_save
+    unless (new_record? ? permission_to_create : permission_to_update)
+      raise PermissionDeniedError
+    end
   end
 
-  def ensure_permission_to_update
-    raise PermissionDeniedError unless permission_to_update
+  def permission_to_create
+    current_user.andand.is_active
   end
 
   def permission_to_update
@@ -161,24 +234,7 @@ class ArvadosModel < ActiveRecord::Base
       logger.warn "User #{current_user.uuid} tried to change uuid of #{self.class.to_s} #{self.uuid_was} to #{self.uuid}"
       return false
     end
-    if self.owner_uuid_changed?
-      if current_user.uuid == self.owner_uuid or
-          current_user.can? write: self.owner_uuid
-        # current_user is, or has :write permission on, the new owner
-      else
-        logger.warn "User #{current_user.uuid} tried to change owner_uuid of #{self.class.to_s} #{self.uuid} to #{self.owner_uuid} but does not have permission to write to #{self.owner_uuid}"
-        return false
-      end
-    end
-    if current_user.uuid == self.owner_uuid_was or
-        current_user.uuid == self.uuid or
-        current_user.can? write: self.owner_uuid_was
-      # current user is, or has :write permission on, the previous owner
-      return true
-    else
-      logger.warn "User #{current_user.uuid} tried to modify #{self.class.to_s} #{self.uuid} but does not have permission to write #{self.owner_uuid_was}"
-      return false
-    end
+    return true
   end
 
   def ensure_permission_to_destroy
@@ -191,6 +247,7 @@ class ArvadosModel < ActiveRecord::Base
 
   def maybe_update_modified_by_fields
     update_modified_by_fields if self.changed? or self.new_record?
+    true
   end
 
   def update_modified_by_fields
@@ -199,6 +256,7 @@ class ArvadosModel < ActiveRecord::Base
     self.modified_at = Time.now
     self.modified_by_user_uuid = current_user ? current_user.uuid : nil
     self.modified_by_client_uuid = current_api_client ? current_api_client.uuid : nil
+    true
   end
 
   def ensure_serialized_attribute_type
index 7391df5dde1910d193d5cc24f4e6b8e63ae006d8..4d7f63005344019f2020ac75f59858cb635d4cb7 100644 (file)
@@ -7,5 +7,6 @@ class Group < ArvadosModel
     t.add :name
     t.add :group_class
     t.add :description
+    t.add :writable_by
   end
 end
index 787088d53eb54b13d1de44775b76a79b248672e2..8e83a15bab84b5e7bf9dbb6c3df01c6f3add56db 100644 (file)
@@ -9,6 +9,7 @@ class Link < ArvadosModel
   after_create :maybe_invalidate_permissions_cache
   after_destroy :maybe_invalidate_permissions_cache
   attr_accessor :head_kind, :tail_kind
+  validate :name_link_has_valid_name
 
   api_accessible :user, extend: :common do |t|
     t.add :tail_uuid
@@ -81,4 +82,14 @@ class Link < ArvadosModel
       User.invalidate_permissions_cache
     end
   end
+
+  def name_link_has_valid_name
+    if link_class == 'name'
+      unless name.is_a? String and !name.empty?
+        errors.add('name', 'must be a non-empty string')
+      end
+    else
+      true
+    end
+  end
 end
index b88d4a50d037a30a80e8fcec6d9269dd384ef1a2..21d249b625792b6ce0d38aeb49b076390426a749 100644 (file)
@@ -119,7 +119,7 @@ class Node < ArvadosModel
   end
 
   def start!(ping_url_method)
-    ensure_permission_to_update
+    ensure_permission_to_save
     ping_url = ping_url_method.call({ id: self.uuid, ping_secret: self.info[:ping_secret] })
     if (Rails.configuration.compute_node_ec2run_args and
         Rails.configuration.compute_node_ami)
index 81cae987a20d023f61d96a0ade93c9dd75d60c84..6bba194c24dac1f5460f5ce82fe90e87c202919c 100644 (file)
@@ -177,6 +177,10 @@ class User < ArvadosModel
 
   protected
 
+  def ensure_ownership_path_leads_to_user
+    true
+  end
+
   def permission_to_update
     # users must be able to update themselves (even if they are
     # inactive) in order to create sessions
index b229511de6a61ce5db09b90ddeaec8e79c1f7b54..46f0e7b819d2d58748c75545906d418700ba0627 100644 (file)
@@ -15,7 +15,7 @@ Server::Application.routes.draw do
         get 'used_by', on: :member
       end
       resources :groups do
-        get 'owned_items', on: :member
+        get 'contents', on: :member
       end
       resources :humans
       resources :job_tasks
@@ -50,7 +50,6 @@ Server::Application.routes.draw do
         post 'activate', on: :member
         post 'setup', on: :collection
         post 'unsetup', on: :member
-        get 'owned_items', on: :member
       end
       resources :virtual_machines do
         get 'logins', on: :member
diff --git a/services/api/db/migrate/20140501165548_add_unique_name_index_to_links.rb b/services/api/db/migrate/20140501165548_add_unique_name_index_to_links.rb
new file mode 100644 (file)
index 0000000..444265a
--- /dev/null
@@ -0,0 +1,13 @@
+class AddUniqueNameIndexToLinks < ActiveRecord::Migration
+  def change
+    # Make sure PgPower is here. Otherwise the "where" will be ignored
+    # and we'll end up with a far too restrictive unique
+    # constraint. (Rails4 should work without PgPower, but that isn't
+    # tested.)
+    if not PgPower then raise "No partial column support" end
+
+    add_index(:links, [:tail_uuid, :name], unique: true,
+              where: "link_class='name'",
+              name: 'links_tail_name_unique_if_link_class_name')
+  end
+end
index c4cef7c6fb36c5159016dcfdcb6c2ee66f1691ed..0613cd37da74d22bd6848e160ba7d3082860681d 100644 (file)
@@ -11,7 +11,9 @@
 #
 # It's strongly recommended to check this file into your version control system.
 
-ActiveRecord::Schema.define(:version => 20140423133559) do
+ActiveRecord::Schema.define(:version => 20140501165548) do
+
+
 
   create_table "api_client_authorizations", :force => true do |t|
     t.string   "api_token",                                           :null => false
@@ -252,6 +254,7 @@ ActiveRecord::Schema.define(:version => 20140423133559) do
   add_index "links", ["created_at"], :name => "index_links_on_created_at"
   add_index "links", ["head_uuid"], :name => "index_links_on_head_uuid"
   add_index "links", ["modified_at"], :name => "index_links_on_modified_at"
+  add_index "links", ["tail_uuid", "name"], :name => "links_tail_name_unique_if_link_class_name", :unique => true, :where => "((link_class)::text = 'name'::text)"
   add_index "links", ["tail_uuid"], :name => "index_links_on_tail_uuid"
   add_index "links", ["uuid"], :name => "index_links_on_uuid", :unique => true
 
@@ -423,4 +426,5 @@ ActiveRecord::Schema.define(:version => 20140423133559) do
   add_index "virtual_machines", ["hostname"], :name => "index_virtual_machines_on_hostname"
   add_index "virtual_machines", ["uuid"], :name => "index_virtual_machines_on_uuid", :unique => true
 
+
 end
index 8e7d7fca27e4f172ef92bf76ae16156e49827d52..f851c588c7445ef7d180bee1896b9f2e0bfc7d36 100644 (file)
@@ -44,7 +44,9 @@ module CurrentApiClient
   def system_user
     if not $system_user
       real_current_user = Thread.current[:user]
-      Thread.current[:user] = User.new(is_admin: true, is_active: true)
+      Thread.current[:user] = User.new(is_admin: true,
+                                       is_active: true,
+                                       uuid: system_user_uuid)
       $system_user = User.where('uuid=?', system_user_uuid).first
       if !$system_user
         $system_user = User.new(uuid: system_user_uuid,
index 7acf014ec6aaed42ecf91ac9501a8a852b7ba8bc..70387fe9165e34595d9cae9db9c90adee23433ee 100644 (file)
@@ -105,7 +105,7 @@ module LoadParam
     when String
       begin
         @select = Oj.load params[:select]
-        raise unless @select.is_a? Array
+        raise unless @select.is_a? Array or @select.nil?
       rescue
         raise ArgumentError.new("Could not parse \"select\" param as an array")
       end
index beb061cff79a1245cf292c7a25841e39964eac95..79bddf01cee1e64d483cf21e57fda4ae391e442e 100644 (file)
@@ -2,12 +2,14 @@
 
 trusted_workbench:
   uuid: zzzzz-ozdt8-teyxzyd8qllg11h
+  owner_uuid: zzzzz-tpzed-000000000000000
   name: Official Workbench
   url_prefix: https://official-workbench.local/
   is_trusted: true
 
 untrusted:
   uuid: zzzzz-ozdt8-obw7foaks3qjyej
+  owner_uuid: zzzzz-tpzed-000000000000000
   name: Untrusted
   url_prefix: https://untrusted.local/
   is_trusted: false
index ce04ece9f4b493bc070c506e9310ba1e0e66e92b..96db93c35e1e1c812910654b78f98665ef833499 100644 (file)
@@ -65,5 +65,25 @@ asubfolder:
   modified_at: 2014-04-21 15:37:48 -0400
   updated_at: 2014-04-21 15:37:48 -0400
   name: A Subfolder
-  description: Test folder belonging to active user's first test folder
+  description: "Test folder belonging to active user's first test folder"
   group_class: folder
+
+bad_group_has_ownership_cycle_a:
+  uuid: zzzzz-j7d0g-cx2al9cqkmsf1hs
+  owner_uuid: zzzzz-j7d0g-0077nzts8c178lw
+  modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
+  modified_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
+  created_at: 2014-05-03 18:50:08 -0400
+  modified_at: 2014-05-03 18:50:08 -0400
+  updated_at: 2014-05-03 18:50:08 -0400
+  name: Owned by bad group b
+
+bad_group_has_ownership_cycle_b:
+  uuid: zzzzz-j7d0g-0077nzts8c178lw
+  owner_uuid: zzzzz-j7d0g-cx2al9cqkmsf1hs
+  modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
+  modified_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
+  created_at: 2014-05-03 18:50:08 -0400
+  modified_at: 2014-05-03 18:50:08 -0400
+  updated_at: 2014-05-03 18:50:08 -0400
+  name: Owned by bad group a
index 7d27f17637e1cf56f77a727598b31b55bba582ce..1385467e7e9bbee4183673a079631a00adff6a16 100644 (file)
@@ -233,7 +233,7 @@ foo_repository_readable_by_spectator:
   tail_uuid: zzzzz-tpzed-l1s2piq4t4mps8r
   link_class: permission
   name: can_read
-  head_uuid: zzzzz-2x53u-382brsig8rp3666
+  head_uuid: zzzzz-s0uqq-382brsig8rp3666
   properties: {}
 
 foo_repository_writable_by_active:
@@ -306,16 +306,44 @@ test_timestamps:
 
 specimen_is_in_two_folders:
   uuid: zzzzz-o0j2j-ryhm1bn83ni03sn
-  owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
+  owner_uuid: zzzzz-j7d0g-axqo7eu9pwvna1x
   created_at: 2014-04-21 15:37:48 -0400
   modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
   modified_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   modified_at: 2014-04-21 15:37:48 -0400
   updated_at: 2014-04-21 15:37:48 -0400
   tail_uuid: zzzzz-j7d0g-axqo7eu9pwvna1x
-  head_uuid: zzzzz-2x53u-5gid26432uujf79
-  link_class: permission
-  name: can_manage
+  head_uuid: zzzzz-j58dm-5gid26432uujf79
+  link_class: name
+  name: "I'm in a subfolder, too"
+  properties: {}
+
+template_name_in_afolder:
+  uuid: zzzzz-o0j2j-4kpwf3d6rwkeqhl
+  owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
+  created_at: 2014-04-29 16:47:26 -0400
+  modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
+  modified_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
+  modified_at: 2014-04-29 16:47:26 -0400
+  updated_at: 2014-04-29 16:47:26 -0400
+  tail_uuid: zzzzz-j7d0g-v955i6s2oi1cbso
+  head_uuid: zzzzz-p5p6p-aox0k0ofxrystgw
+  link_class: name
+  name: "I'm a template in a folder"
+  properties: {}
+
+job_name_in_afolder:
+  uuid: zzzzz-o0j2j-1kt6dppqcxbl1yt
+  owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
+  created_at: 2014-04-29 16:47:26 -0400
+  modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
+  modified_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
+  modified_at: 2014-04-29 16:47:26 -0400
+  updated_at: 2014-04-29 16:47:26 -0400
+  tail_uuid: zzzzz-j7d0g-v955i6s2oi1cbso
+  head_uuid: zzzzz-8i9sb-pshmckwoma9plh7
+  link_class: name
+  name: "I'm a job in a folder"
   properties: {}
 
 foo_collection_tag:
@@ -331,3 +359,17 @@ foo_collection_tag:
   link_class: tag
   name: foo_tag
   properties: {}
+
+active_user_can_manage_bad_group_cx2al9cqkmsf1hs:
+  uuid: zzzzz-o0j2j-ezv55ahzc9lvjwe
+  owner_uuid: zzzzz-tpzed-000000000000000
+  created_at: 2014-05-03 18:50:08 -0400
+  modified_by_client_uuid: zzzzz-ozdt8-brczlopd8u8d0jr
+  modified_by_user_uuid: zzzzz-tpzed-000000000000000
+  modified_at: 2014-05-03 18:50:08 -0400
+  updated_at: 2014-05-03 18:50:08 -0400
+  tail_uuid: zzzzz-tpzed-xurymjxw79nv3jz
+  link_class: permission
+  name: can_manage
+  head_uuid: zzzzz-j7d0g-cx2al9cqkmsf1hs
+  properties: {}
index 62a153d898f41b5b60b07dd34739c98f22bdfd61..13222401eff7107e5816262d63217263c122b779 100644 (file)
@@ -1,9 +1,9 @@
 foo:
-  uuid: zzzzz-2x53u-382brsig8rp3666
+  uuid: zzzzz-s0uqq-382brsig8rp3666
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz # active user
   name: foo
 
 repository2:
-  uuid: zzzzz-2x53u-382brsig8rp3667
+  uuid: zzzzz-s0uqq-382brsig8rp3667
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz # active user
   name: foo2
index 6ad7cfe295b058bfb6ce63c808bd580ac48ebb07..c48bff71470606c31f8aaf4f0f10bd60c6d1f71f 100644 (file)
@@ -1,23 +1,41 @@
 owned_by_active_user:
-  uuid: zzzzz-2x53u-3zx463qyo0k4xrn
+  uuid: zzzzz-j58dm-3zx463qyo0k4xrn
   owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
+  created_at: 2014-04-21 15:37:48 -0400
+  modified_at: 2014-04-21 15:37:48 -0400
 
 owned_by_private_group:
-  uuid: zzzzz-2x53u-5m3qwg45g3nlpu6
+  uuid: zzzzz-j58dm-5m3qwg45g3nlpu6
   owner_uuid: zzzzz-j7d0g-rew6elm53kancon
+  created_at: 2014-04-21 15:37:48 -0400
+  modified_at: 2014-04-21 15:37:48 -0400
 
 owned_by_spectator:
-  uuid: zzzzz-2x53u-3b0xxwzlbzxq5yr
+  uuid: zzzzz-j58dm-3b0xxwzlbzxq5yr
   owner_uuid: zzzzz-tpzed-l1s2piq4t4mps8r
+  created_at: 2014-04-21 15:37:48 -0400
+  modified_at: 2014-04-21 15:37:48 -0400
 
 in_afolder:
-  uuid: zzzzz-2x53u-7r18rnd5nzhg5yk
+  uuid: zzzzz-j58dm-7r18rnd5nzhg5yk
   owner_uuid: zzzzz-j7d0g-v955i6s2oi1cbso
+  created_at: 2014-04-21 15:37:48 -0400
+  modified_at: 2014-04-21 15:37:48 -0400
 
 in_asubfolder:
-  uuid: zzzzz-2x53u-c40lddwcqqr1ffs
+  uuid: zzzzz-j58dm-c40lddwcqqr1ffs
   owner_uuid: zzzzz-j7d0g-axqo7eu9pwvna1x
+  created_at: 2014-04-21 15:37:48 -0400
+  modified_at: 2014-04-21 15:37:48 -0400
 
 in_afolder_linked_from_asubfolder:
-  uuid: zzzzz-2x53u-5gid26432uujf79
+  uuid: zzzzz-j58dm-5gid26432uujf79
   owner_uuid: zzzzz-j7d0g-v955i6s2oi1cbso
+  created_at: 2014-04-21 15:37:48 -0400
+  modified_at: 2014-04-21 15:37:48 -0400
+
+owned_by_afolder_with_no_name_link:
+  uuid: zzzzz-j58dm-ypsjlol9dofwijz
+  owner_uuid: zzzzz-j7d0g-v955i6s2oi1cbso
+  created_at: 2014-05-05 04:11:52 -0400
+  modified_at: 2014-05-05 04:11:52 -0400
index 74110ae9895206fc081e88efbdfe3cdb78844330..f8f9eaeb0d5224d2cbb95f6179fbe835dde0a0ac 100644 (file)
@@ -56,18 +56,22 @@ class Arvados::V1::GroupsControllerTest < ActionController::TestCase
 
   test 'get group-owned objects' do
     authorize_with :active
-    get :owned_items, {
+    get :contents, {
       id: groups(:afolder).uuid,
       format: :json,
+      include_linked: true,
     }
     assert_response :success
     assert_operator 2, :<=, json_response['items_available']
     assert_operator 2, :<=, json_response['items'].count
+    kinds = json_response['items'].collect { |i| i['kind'] }.uniq
+    expect_kinds = %w'arvados#group arvados#specimen arvados#pipelineTemplate arvados#job'
+    assert_equal expect_kinds, (expect_kinds & kinds)
   end
 
   test 'get group-owned objects with limit' do
     authorize_with :active
-    get :owned_items, {
+    get :contents, {
       id: groups(:afolder).uuid,
       limit: 1,
       format: :json,
@@ -79,7 +83,7 @@ class Arvados::V1::GroupsControllerTest < ActionController::TestCase
 
   test 'get group-owned objects with limit and offset' do
     authorize_with :active
-    get :owned_items, {
+    get :contents, {
       id: groups(:afolder).uuid,
       limit: 1,
       offset: 12345,
@@ -92,7 +96,7 @@ class Arvados::V1::GroupsControllerTest < ActionController::TestCase
 
   test 'get group-owned objects with additional filter matching nothing' do
     authorize_with :active
-    get :owned_items, {
+    get :contents, {
       id: groups(:afolder).uuid,
       filters: [['uuid', 'in', ['foo_not_a_uuid','bar_not_a_uuid']]],
       format: :json,
@@ -105,7 +109,7 @@ class Arvados::V1::GroupsControllerTest < ActionController::TestCase
   test 'get group-owned objects without include_linked' do
     unexpected_uuid = specimens(:in_afolder_linked_from_asubfolder).uuid
     authorize_with :active
-    get :owned_items, {
+    get :contents, {
       id: groups(:asubfolder).uuid,
       format: :json,
     }
@@ -117,7 +121,7 @@ class Arvados::V1::GroupsControllerTest < ActionController::TestCase
   test 'get group-owned objects with include_linked' do
     expected_uuid = specimens(:in_afolder_linked_from_asubfolder).uuid
     authorize_with :active
-    get :owned_items, {
+    get :contents, {
       id: groups(:asubfolder).uuid,
       include_linked: true,
       format: :json,
@@ -125,5 +129,118 @@ class Arvados::V1::GroupsControllerTest < ActionController::TestCase
     assert_response :success
     uuids = json_response['items'].collect { |i| i['uuid'] }
     assert_includes uuids, expected_uuid, "Did not get #{expected_uuid}"
+
+    expected_name = links(:specimen_is_in_two_folders).name
+    found_specimen_name = false
+    assert(json_response['links'].any?,
+           "Expected a non-empty array of links in response")
+    json_response['links'].each do |link|
+      if link['head_uuid'] == expected_uuid
+        if link['name'] == expected_name
+          found_specimen_name = true
+        end
+      end
+    end
+    assert(found_specimen_name,
+           "Expected to find name '#{expected_name}' in response")
+  end
+
+  [false, true].each do |inc_ind|
+    test "get all pages of group-owned #{'and -linked ' if inc_ind}objects" do
+      authorize_with :active
+      limit = 5
+      offset = 0
+      items_available = nil
+      uuid_received = {}
+      owner_received = {}
+      while true
+        # Behaving badly here, using the same controller multiple
+        # times within a test.
+        @json_response = nil
+        get :contents, {
+          id: groups(:afolder).uuid,
+          include_linked: inc_ind,
+          limit: limit,
+          offset: offset,
+          format: :json,
+        }
+        assert_response :success
+        assert_operator(0, :<, json_response['items'].count,
+                        "items_available=#{items_available} but received 0 "\
+                        "items with offset=#{offset}")
+        items_available ||= json_response['items_available']
+        assert_equal(items_available, json_response['items_available'],
+                     "items_available changed between page #{offset/limit} "\
+                     "and page #{1+offset/limit}")
+        json_response['items'].each do |item|
+          uuid = item['uuid']
+          assert_equal(nil, uuid_received[uuid],
+                       "Received '#{uuid}' again on page #{1+offset/limit}")
+          uuid_received[uuid] = true
+          owner_received[item['owner_uuid']] = true
+          offset += 1
+          if not inc_ind
+            assert_equal groups(:afolder).uuid, item['owner_uuid']
+          end
+        end
+        break if offset >= items_available
+      end
+      if inc_ind
+        assert_operator 0, :<, (json_response.keys - [users(:active).uuid]).count,
+        "Set include_linked=true but did not receive any non-owned items"
+      end
+    end
+  end
+
+  %w(offset limit).each do |arg|
+    ['foo', '', '1234five', '0x10', '-8'].each do |val|
+      test "Raise error on bogus #{arg} parameter #{val.inspect}" do
+        authorize_with :active
+        get :contents, {
+          :id => groups(:afolder).uuid,
+          :format => :json,
+          arg => val,
+        }
+        assert_response 422
+      end
+    end
+  end
+
+  test 'get writable_by list for owned group' do
+    authorize_with :active
+    get :show, {
+      id: groups(:afolder).uuid,
+      format: :json
+    }
+    assert_response :success
+    assert_not_nil(json_response['writable_by'],
+                   "Should receive uuid list in 'writable_by' field")
+    assert_includes(json_response['writable_by'], users(:active).uuid,
+                    "owner should be included in writable_by list")
+  end
+
+  test 'no writable_by list for group with read-only access' do
+    authorize_with :rominiadmin
+    get :show, {
+      id: groups(:testusergroup_admins).uuid,
+      format: :json
+    }
+    assert_response :success
+    assert_nil(json_response['writable_by'],
+               "Should not receive uuid list in 'writable_by' field")
+  end
+
+  test 'get writable_by list by admin user' do
+    authorize_with :admin
+    get :show, {
+      id: groups(:testusergroup_admins).uuid,
+      format: :json
+    }
+    assert_response :success
+    assert_not_nil(json_response['writable_by'],
+                   "Should receive uuid list in 'writable_by' field")
+    assert_includes(json_response['writable_by'],
+                    users(:admin).uuid,
+                    "Current user should be included in 'writable_by' field")
   end
 end
index 804e1bdfdb1260abe075ceecba6710d84e03050a..dfce78b13f7f79c4f5927bbbda749882758071c6 100644 (file)
@@ -270,4 +270,17 @@ class Arvados::V1::LinksControllerTest < ActionController::TestCase
     assert_response :success
   end
 
+  test "refuse duplicate name" do
+    the_name = links(:job_name_in_afolder).name
+    the_folder = links(:job_name_in_afolder).tail_uuid
+    authorize_with :active
+    post :create, link: {
+      tail_uuid: the_folder,
+      head_uuid: specimens(:owned_by_active_user).uuid,
+      link_class: 'name',
+      name: the_name,
+      properties: {this_s: "a duplicate name"}
+    }
+    assert_response 422
+  end
 end
index e1ebbb21f76fa3732ed6c680f6768dec79a18cbb..1fefcb6c681c265ae73eeb987b5b29946d17ff97 100644 (file)
@@ -908,80 +908,4 @@ class Arvados::V1::UsersControllerTest < ActionController::TestCase
                                tail_uuid: system_group_uuid,
                                head_uuid: user_uuid).count
   end
-
-  test 'get user-owned objects' do
-    authorize_with :active
-    get :owned_items, {
-      id: users(:active).uuid,
-      limit: 500,
-      format: :json,
-    }
-    assert_response :success
-    assert_operator 2, :<=, json_response['items_available']
-    assert_operator 2, :<=, json_response['items'].count
-    kinds = json_response['items'].collect { |i| i['kind'] }.uniq
-    expect_kinds = %w'arvados#group arvados#specimen arvados#pipelineTemplate arvados#job'
-    assert_equal expect_kinds, (expect_kinds & kinds)
-  end
-
-  [false, true].each do |inc_ind|
-    test "get all pages of user-owned #{'and -linked ' if inc_ind}objects" do
-      authorize_with :active
-      limit = 5
-      offset = 0
-      items_available = nil
-      uuid_received = {}
-      owner_received = {}
-      while true
-        # Behaving badly here, using the same controller multiple
-        # times within a test.
-        @json_response = nil
-        get :owned_items, {
-          id: users(:active).uuid,
-          include_linked: inc_ind,
-          limit: limit,
-          offset: offset,
-          format: :json,
-        }
-        assert_response :success
-        assert_operator(0, :<, json_response['items'].count,
-                        "items_available=#{items_available} but received 0 "\
-                        "items with offset=#{offset}")
-        items_available ||= json_response['items_available']
-        assert_equal(items_available, json_response['items_available'],
-                     "items_available changed between page #{offset/limit} "\
-                     "and page #{1+offset/limit}")
-        json_response['items'].each do |item|
-          uuid = item['uuid']
-          assert_equal(nil, uuid_received[uuid],
-                       "Received '#{uuid}' again on page #{1+offset/limit}")
-          uuid_received[uuid] = true
-          owner_received[item['owner_uuid']] = true
-          offset += 1
-          if not inc_ind
-            assert_equal users(:active).uuid, item['owner_uuid']
-          end
-        end
-        break if offset >= items_available
-      end
-      if inc_ind
-        assert_operator 0, :<, (json_response.keys - [users(:active).uuid]).count,
-        "Set include_linked=true but did not receive any non-owned items"
-      end
-    end
-  end
-
-  %w(offset limit).each do |arg|
-    ['foo', '', '1234five', '0x10', '-8'].each do |val|
-      test "Raise error on bogus #{arg} parameter #{val.inspect}" do
-        authorize_with :active
-        get :owned_items, {
-          :id => users(:active).uuid,
-          :format => :json,
-          arg => val,
-        }
-        assert_response 422
-      end
-    end
-  end
 end
diff --git a/services/api/test/integration/user_sessions_test.rb b/services/api/test/integration/user_sessions_test.rb
new file mode 100644 (file)
index 0000000..321a5ac
--- /dev/null
@@ -0,0 +1,28 @@
+require 'test_helper'
+
+class UserSessionsApiTest < ActionDispatch::IntegrationTest
+  test 'create new user during omniauth callback' do
+    mock = {
+      'provider' => 'josh_id',
+      'uid' => 'https://edward.example.com',
+      'info' => {
+        'identity_url' => 'https://edward.example.com',
+        'name' => 'Edward Example',
+        'first_name' => 'Edward',
+        'last_name' => 'Example',
+        'email' => 'edward@example.com',
+      },
+    }
+    client_url = 'https://wb.example.com'
+    post('/auth/josh_id/callback',
+         {return_to: client_url},
+         {'omniauth.auth' => mock})
+    assert_response :redirect, 'Did not redirect to client with token'
+    assert_equal(0, @response.redirect_url.index(client_url),
+                 'Redirected to wrong address after succesful login: was ' +
+                 @response.redirect_url + ', expected ' + client_url + '[...]')
+    assert_not_nil(@response.redirect_url.index('api_token='),
+                   'Expected api_token in query string of redirect url ' +
+                   @response.redirect_url)
+  end
+end
index e1738c3aa1fd690e041f0d58ae88f81be54350e2..47c6b613c2b85ba7f1f96fa52402fcb8bf3ab7e8 100644 (file)
@@ -1,4 +1,25 @@
 ENV["RAILS_ENV"] = "test"
+unless ENV["NO_COVERAGE_TEST"]
+  begin
+    require 'simplecov'
+    require 'simplecov-rcov'
+    class SimpleCov::Formatter::MergedFormatter
+      def format(result)
+        SimpleCov::Formatter::HTMLFormatter.new.format(result)
+        SimpleCov::Formatter::RcovFormatter.new.format(result)
+      end
+    end
+    SimpleCov.formatter = SimpleCov::Formatter::MergedFormatter
+    SimpleCov.start do
+      add_filter '/test/'
+      add_filter 'initializers/secret_token'
+      add_filter 'initializers/omniauth'
+    end
+  rescue Exception => e
+    $stderr.puts "SimpleCov unavailable (#{e}). Proceeding without."
+  end
+end
+
 require File.expand_path('../../config/environment', __FILE__)
 require 'rails/test_help'
 
index 778eb0c58112da09ed327f989964963b1fbde577..597af62ec83aa82baf96548837e74bd3c8372603 100644 (file)
@@ -1,7 +1,62 @@
 require 'test_helper'
 
 class GroupTest < ActiveSupport::TestCase
-  # test "the truth" do
-  #   assert true
-  # end
+
+  test "cannot set owner_uuid to object with existing ownership cycle" do
+    set_user_from_auth :active_trustedclient
+
+    # First make sure we have lots of permission on the bad group by
+    # renaming it to "{current name} is mine all mine"
+    g = groups(:bad_group_has_ownership_cycle_b)
+    g.name += " is mine all mine"
+    assert g.save, "active user should be able to modify group #{g.uuid}"
+
+    # Use the group as the owner of a new object
+    s = Specimen.
+      create(owner_uuid: groups(:bad_group_has_ownership_cycle_b).uuid)
+    assert s.valid?, "ownership should pass validation"
+    assert_equal false, s.save, "should not save object with #{g.uuid} as owner"
+
+    # Use the group as the new owner of an existing object
+    s = specimens(:in_afolder)
+    s.owner_uuid = groups(:bad_group_has_ownership_cycle_b).uuid
+    assert s.valid?, "ownership should pass validation"
+    assert_equal false, s.save, "should not save object with #{g.uuid} as owner"
+  end
+
+  test "cannot create a new ownership cycle" do
+    set_user_from_auth :active_trustedclient
+
+    g_foo = Group.create(name: "foo")
+    g_foo.save!
+
+    g_bar = Group.create(name: "bar")
+    g_bar.save!
+
+    g_foo.owner_uuid = g_bar.uuid
+    assert g_foo.save, lambda { g_foo.errors.messages }
+    g_bar.owner_uuid = g_foo.uuid
+    assert g_bar.valid?, "ownership cycle should not prevent validation"
+    assert_equal false, g_bar.save, "should not create an ownership loop"
+    assert g_bar.errors.messages[:owner_uuid].join(" ").match(/ownership cycle/)
+  end
+
+  test "cannot create a single-object ownership cycle" do
+    set_user_from_auth :active_trustedclient
+
+    g_foo = Group.create(name: "foo")
+    assert g_foo.save
+
+    # Ensure I have permission to manage this group even when its owner changes
+    perm_link = Link.create(tail_uuid: users(:active).uuid,
+                            head_uuid: g_foo.uuid,
+                            link_class: 'permission',
+                            name: 'can_manage')
+    assert perm_link.save
+
+    g_foo.owner_uuid = g_foo.uuid
+    assert_equal false, g_foo.save, "should not create an ownership loop"
+    assert g_foo.errors.messages[:owner_uuid].join(" ").match(/ownership cycle/)
+  end
+
 end
index 944bfced3d30423ff2b83e0d6cf7c134ce1f8e88..72d6017ce7ac9c6beac74fe67579c4a35034290e 100644 (file)
@@ -1,7 +1,48 @@
 require 'test_helper'
 
 class LinkTest < ActiveSupport::TestCase
-  # test "the truth" do
-  #   assert true
-  # end
+  fixtures :all
+
+  setup do
+    Thread.current[:user] = users(:active)
+  end
+
+  test 'name links with the same tail_uuid must be unique' do
+    a = Link.create!(tail_uuid: groups(:afolder).uuid,
+                     head_uuid: specimens(:owned_by_active_user).uuid,
+                     link_class: 'name',
+                     name: 'foo')
+    assert a.valid?, a.errors.to_s
+    assert_raises ActiveRecord::RecordNotUnique do
+      b = Link.create!(tail_uuid: groups(:afolder).uuid,
+                       head_uuid: specimens(:owned_by_active_user).uuid,
+                       link_class: 'name',
+                       name: 'foo')
+    end
+  end
+
+  test 'name links with different tail_uuid need not be unique' do
+    a = Link.create!(tail_uuid: groups(:afolder).uuid,
+                     head_uuid: specimens(:owned_by_active_user).uuid,
+                     link_class: 'name',
+                     name: 'foo')
+    assert a.valid?, a.errors.to_s
+    b = Link.create!(tail_uuid: groups(:asubfolder).uuid,
+                     head_uuid: specimens(:owned_by_active_user).uuid,
+                     link_class: 'name',
+                     name: 'foo')
+    assert b.valid?, b.errors.to_s
+    assert_not_equal(a.uuid, b.uuid,
+                     "created two links and got the same uuid back.")
+  end
+
+  [nil, '', false].each do |name|
+    test "name links cannot have name=#{name.inspect}" do
+      a = Link.create(tail_uuid: groups(:afolder).uuid,
+                      head_uuid: specimens(:owned_by_active_user).uuid,
+                      link_class: 'name',
+                      name: name)
+      assert a.invalid?, "invalid name was accepted as valid?"
+    end
+  end
 end