17119: Merge branch 'master' into 17119-add-filter-groups
authorWard Vandewege <ward@curii.com>
Tue, 30 Mar 2021 20:40:52 +0000 (16:40 -0400)
committerWard Vandewege <ward@curii.com>
Tue, 30 Mar 2021 20:40:52 +0000 (16:40 -0400)
Arvados-DCO-1.1-Signed-off-by: Ward Vandewege <ward@curii.com>

33 files changed:
apps/workbench/app/assets/javascripts/components/search.js
apps/workbench/app/controllers/actions_controller.rb
apps/workbench/app/controllers/application_controller.rb
apps/workbench/app/controllers/groups_controller.rb
apps/workbench/app/helpers/application_helper.rb
apps/workbench/app/models/group.rb
apps/workbench/app/views/application/_projects_tree_menu.html.erb
apps/workbench/app/views/projects/_choose.html.erb
apps/workbench/app/views/projects/show.html.erb
apps/workbench/test/controllers/projects_controller_test.rb
doc/_config.yml
doc/api/methods.html.textile.liquid
doc/api/methods/groups.html.textile.liquid
doc/api/permission-model.html.textile.liquid
doc/api/projects.html.textile.liquid [new file with mode: 0644]
lib/controller/federation/conn.go
lib/controller/localdb/conn.go
lib/controller/router/response.go
lib/controller/router/router.go
lib/controller/rpc/conn.go
sdk/go/arvados/api.go
sdk/go/arvados/fs_project_test.go
sdk/go/arvados/fs_site_test.go
sdk/go/arvadostest/api.go
services/api/app/controllers/arvados/v1/groups_controller.rb
services/api/app/models/group.rb
services/api/lib/fix_roles_projects.rb
services/api/test/fixtures/groups.yml
services/api/test/functional/arvados/v1/query_test.rb
services/api/test/integration/groups_test.rb
services/api/test/unit/group_test.rb
services/fuse/arvados_fuse/fusedir.py
services/fuse/tests/test_mount.py

index fc6308678002a20969d4a44cbadf834731e067dd..83ed1a68d4995d568c65d78f384039c4b1deada6 100644 (file)
@@ -127,6 +127,12 @@ window.Search = {
                             filters: [['group_class', '=', 'project']],
                             description: 'project',
                         },
+                        {
+                            wb_path: 'projects',
+                            api_path: 'arvados/v1/groups',
+                            filters: [['group_class', '=', 'filter']],
+                            description: 'project',
+                        },
                         {
                             wb_path: 'collections',
                             api_path: 'arvados/v1/collections',
index 885f539363730514a7f5dccbb1df49fb1ab2b8e0..b0b7a0b64de19135ff084fac61ad44fc2c32d835 100644 (file)
@@ -34,7 +34,7 @@ class ActionsController < ApplicationController
         @object.link_class == 'name' and
         ArvadosBase::resource_class_for_uuid(@object.head_uuid) == Collection
       redirect_to collection_path(id: @object.uuid)
-    elsif @object.is_a?(Group) and @object.group_class == 'project'
+    elsif @object.is_a?(Group) and (@object.group_class == 'project' or @object.group_class == 'filter')
       redirect_to project_path(id: @object.uuid)
     elsif @object
       redirect_to @object
index 6d139cd5fdb207ad872ec700225f9ae7b75b9047..04055f84852ed4628aaf5a9d32ea79d72c59907f 100644 (file)
@@ -95,7 +95,7 @@ class ApplicationController < ActionController::Base
     # exception here than in a template.)
     unless current_user.nil?
       begin
-        my_starred_projects current_user
+        my_starred_projects current_user, 'project'
         build_my_wanted_projects_tree current_user
       rescue ArvadosApiClient::ApiError
         # Fall back to the default-setting code later.
@@ -239,7 +239,7 @@ class ApplicationController < ActionController::Base
     if objects.respond_to?(:result_offset) and
         objects.respond_to?(:result_limit)
       next_offset = objects.result_offset + objects.result_limit
-      if objects.respond_to?(:items_available) and (next_offset < objects.items_available)
+      if objects.respond_to?(:items_available) and (objects.items_available != nil) and (next_offset < objects.items_available)
         next_offset
       elsif @objects.results.size > 0 and (params[:count] == 'none' or
            (params[:controller] == 'search' and params[:action] == 'choose'))
@@ -824,7 +824,7 @@ class ApplicationController < ActionController::Base
   helper_method :all_projects
   def all_projects
     @all_projects ||= Group.
-      filter([['group_class','=','project']]).order('name')
+      filter([['group_class','IN',['project','filter']]]).order('name')
   end
 
   helper_method :my_projects
@@ -925,13 +925,17 @@ class ApplicationController < ActionController::Base
   end
 
   helper_method :my_starred_projects
-  def my_starred_projects user
+  def my_starred_projects user, group_class
     return if defined?(@starred_projects) && @starred_projects
     links = Link.filter([['owner_uuid', 'in', ["#{Rails.configuration.ClusterID}-j7d0g-publicfavorites", user.uuid]],
                          ['link_class', '=', 'star'],
                          ['head_uuid', 'is_a', 'arvados#group']]).with_count("none").select(%w(head_uuid))
     uuids = links.collect { |x| x.head_uuid }
-    starred_projects = Group.filter([['uuid', 'in', uuids]]).order('name').with_count("none")
+    if group_class == ""
+      starred_projects = Group.filter([['uuid', 'in', uuids]]).order('name').with_count("none")
+    else
+      starred_projects = Group.filter([['uuid', 'in', uuids],['group_class', '=', group_class]]).order('name').with_count("none")
+    end
     @starred_projects = starred_projects.results
   end
 
@@ -949,7 +953,7 @@ class ApplicationController < ActionController::Base
     @too_many_projects = false
     @reached_level_limit = false
     while from_top.size <= page_size*2
-      current_level = Group.filter([['group_class','=','project'],
+      current_level = Group.filter([['group_class','IN',['project','filter']],
                                     ['owner_uuid', 'in', uuids]])
                       .order('name').limit(page_size*2)
       break if current_level.results.size == 0
index 5da55be0b5d69a10e5519ac710822ba297aca1ab..6abd2ff11d1b6a655be0dacd6e10a1dc398a9ddb 100644 (file)
@@ -4,7 +4,7 @@
 
 class GroupsController < ApplicationController
   def index
-    @groups = Group.filter [['group_class', '!=', 'project']]
+    @groups = Group.filter [['group_class', '!=', 'project'], ['group_class', '!=', 'filter']]
     @group_uuids = @groups.collect &:uuid
     @links_from = Link.where(link_class: 'permission', tail_uuid: @group_uuids).with_count("none")
     @links_to = Link.where(link_class: 'permission', head_uuid: @group_uuids).with_count("none")
@@ -12,7 +12,7 @@ class GroupsController < ApplicationController
   end
 
   def show
-    if @object.group_class == 'project'
+    if @object.group_class == 'project' or @object.group_class == 'filter'
       redirect_to(project_path(@object))
     else
       super
index 786716eb337d1a735fb0b82ee1058a571d2f7e18..f22ab50166591cb0875f32bfdba368af42e91fc3 100644 (file)
@@ -176,7 +176,7 @@ module ApplicationHelper
         raw(link_name)
       else
         controller_class = resource_class.to_s.tableize
-        if controller_class.eql?('groups') and object.andand.group_class.eql?('project')
+        if controller_class.eql?('groups') and (object.andand.group_class.eql?('project') or object.andand.group_class.eql?('filter'))
           controller_class = 'projects'
         end
         (link_to raw(link_name), { controller: controller_class, action: 'show', id: ((opts[:name_link].andand.uuid) || link_uuid) }, style_opts) + raw(tags)
index 08b13bf34b74611d2847918ffebeb8d65c19fb66..ea3da2db5ee828702d98931906aad8caf2946ee3 100644 (file)
@@ -20,6 +20,13 @@ class Group < ArvadosBase
     ret
   end
 
+  def editable?
+    if group_class == 'filter'
+      return false
+    end
+    super
+  end
+
   def contents params={}
     res = arvados_api_client.api self.class, "/#{self.uuid}/contents", {
       _method: 'GET'
@@ -30,7 +37,7 @@ class Group < ArvadosBase
   end
 
   def class_for_display
-    group_class == 'project' ? 'Project' : super
+    (group_class == 'project' or group_class == 'filter') ? 'Project' : super
   end
 
   def textile_attributes
index 08d3b81110e46cd460722550cca64e203ae54566..805d5279cc43f9e768394b3d5a9ebbc1f68f29f5 100644 (file)
@@ -2,7 +2,7 @@
 
 SPDX-License-Identifier: AGPL-3.0 %>
 
-<% starred_projects = my_starred_projects current_user%>
+<% starred_projects = my_starred_projects current_user, '' %>
 <% if starred_projects.andand.any? %>
   <li role="presentation" class="dropdown-header">
     My favorite projects
index 8e5695e6d873fdaecaaccdb71a713b1bcc737fa0..633a9ba33fea4f93606a2a6f9ac5a3e2ed2d0828 100644 (file)
@@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0 %>
 
       <div class="modal-body">
         <div class="selectable-container" style="height: 15em; overflow-y: scroll">
-          <% starred_projects = my_starred_projects current_user%>
+          <% starred_projects = my_starred_projects current_user, 'project' %>
           <% if starred_projects.andand.any? %>
             <% writable_projects = starred_projects.select(&:editable?) %>
             <% writable_projects.each do |projectnode| %>
index 6066335a1516914f0d4410c6e34e42f2053773e1..60f2d23407e4e211799f37ac42c66e762e48b49f 100644 (file)
@@ -11,6 +11,9 @@ SPDX-License-Identifier: AGPL-3.0 %>
       <%= render_editable_attribute @object, 'name', nil, { 'data-emptytext' => "New project" } %>
     <% end %>
   </h2>
+  <% if @object.class == Group and @object.group_class == 'filter' %>
+    This is a filter group.
+  <% end %>
 <% end %>
 
 <%
index 27d7dedc91d070bd4da02a1f80ab87c4c9a9563e..2d379f86400c298d369f95663ac301ec7fa5c583 100644 (file)
@@ -523,12 +523,12 @@ EOT
       use_token user
       ctrl = ProjectsController.new
       current_user = User.find(api_fixture('users')[user]['uuid'])
-      my_starred_project = ctrl.send :my_starred_projects, current_user
+      my_starred_project = ctrl.send :my_starred_projects, current_user, ''
       assert_equal(size, my_starred_project.andand.size)
 
       ctrl2 = ProjectsController.new
       current_user = User.find(api_fixture('users')[user]['uuid'])
-      my_starred_project = ctrl2.send :my_starred_projects, current_user
+      my_starred_project = ctrl2.send :my_starred_projects, current_user, ''
       assert_equal(size, my_starred_project.andand.size)
     end
   end
@@ -542,7 +542,7 @@ EOT
     use_token :project_viewer
     current_user = User.find(api_fixture('users')['project_viewer']['uuid'])
     ctrl = ProjectsController.new
-    my_starred_project = ctrl.send :my_starred_projects, current_user
+    my_starred_project = ctrl.send :my_starred_projects, current_user, ''
     assert_equal(0, my_starred_project.andand.size)
 
     # share it again
@@ -560,7 +560,7 @@ EOT
     # verify that the project is again included in starred projects
     use_token :project_viewer
     ctrl = ProjectsController.new
-    my_starred_project = ctrl.send :my_starred_projects, current_user
+    my_starred_project = ctrl.send :my_starred_projects, current_user, ''
     assert_equal(1, my_starred_project.andand.size)
   end
 end
index 191016ec43ca4917f7eb02dba8c0897df311732e..c249e56283604193f5436ca5c55b4912f0c55d06 100644 (file)
@@ -129,6 +129,7 @@ navbar:
       - api/keep-webdav.html.textile.liquid
       - api/keep-s3.html.textile.liquid
       - api/keep-web-urls.html.textile.liquid
+      - api/projects.html.textile.liquid
       - api/methods/collections.html.textile.liquid
       - api/methods/repositories.html.textile.liquid
     - Container engine:
index d6c34f4d3f5984633ea24de000303c6275f8a42e..c6e4ba00a74d8f1dcc440dfd83c125c17c9d6c1b 100644 (file)
@@ -107,8 +107,6 @@ table(table table-bordered table-condensed).
 |@is_a@|string|Arvados object type|@["head_uuid","is_a","arvados#collection"]@|
 |@exists@|string|Test if a subproperty is present.|@["properties","exists","my_subproperty"]@|
 
-Note:
-
 h4(#substringsearchfilter). Filtering using substring search
 
 Resources can also be filtered by searching for a substring in attributes of type @string@, @array of strings@, @text@, and @hash@, which are indexed in the database specifically for search. To use substring search, the filter must:
index f85e621db45d5d66e127279934b2a503bd7e673a..e4b8594dd195b7e7c4240d9628cc83065572f0f5 100644 (file)
@@ -25,8 +25,9 @@ Each Group has, in addition to the "Common resource fields":{{site.baseurl}}/api
 table(table table-bordered table-condensed).
 |_. Attribute|_. Type|_. Description|_. Example|
 |name|string|||
-|group_class|string|Type of group. This does not affect behavior, but determines how the group is presented in the user interface. For example, @project@ indicates that the group should be displayed by Workbench and arv-mount as a project for organizing and naming objects.|@"project"@
-null|
+|group_class|string|Type of group. @project@ and @filter@ indicate that the group should be displayed by Workbench and arv-mount as a project for organizing and naming objects. @role@ is used as part of the "permission system":{{site.baseurl}}/api/permission-model.html. |@"filter"@
+@"project"@
+@"role"@|
 |description|text|||
 |properties|hash|User-defined metadata, may be used in queries using "subproperty filters":{{site.baseurl}}/api/methods.html#subpropertyfilters ||
 |writable_by|array|List of UUID strings identifying Users and other Groups that have write permission for this Group.  Only users who are allowed to administer the Group will receive a full list.  Other users will receive a partial list that includes the Group's owner_uuid and (if applicable) their own user UUID.||
@@ -34,6 +35,51 @@ null|
 |delete_at|datetime|If @delete_at@ is non-null and in the past, the group and all objects directly or indirectly owned by the group may be permanently deleted.||
 |is_trashed|datetime|True if @trash_at@ is in the past, false if not.||
 
+@filter@ groups are virtual groups; they can not own other objects. Filter groups have a special @properties@ field named @filters@, which must be an array of filter conditions. See "list method filters":{{site.baseurl}}/api/methods.html#filters for details on the syntax of valid filters, but keep in mind that the attributes must include the object type (@collections@, @container_requests@, @groups@, @workflows@), separated with a dot from the field to be filtered on.
+
+Filters are applied with an implied *and* between them, but each filter only applies to the object type specified. The results are subject to the usual access controls - they are a subset of all objects the user can see. Here is an example:
+
+<pre>
+ "properties":{
+  "filters":[
+   [
+    "groups.name",
+    "like",
+    "Public%"
+   ]
+  ]
+ },
+</pre>
+
+This @filter@ group will return all groups (projects) that have a name starting with the word @Public@ and are visible to the user issuing the query. Because groups can contain many types of object, it will also return all objects of other types that the user can see.
+
+The 'is_a' filter operator is of particular interest to limit the @filter@ group 'content' to the desired object(s). When the 'is_a' operator is used, the attribute must be 'uuid'. The operand may be a string or an array which means objects of either type will match the filter. This example will return all groups (projects) that have a name starting with the word @Public@, as well as all collections that are in the project with uuid @zzzzz-j7d0g-0123456789abcde@.
+
+<pre>
+ "properties":{
+  "filters":[
+   [
+    "groups.name",
+    "like",
+    "Public%"
+   ],
+   [
+    "collections.owner_uuid",
+    "=",
+    "zzzzz-j7d0g-0123456789abcde"
+   ],
+   [
+    "uuid",
+    "is_a",
+    [
+     "arvados#group",
+     "arvados#collection"
+    ]
+   ]
+  ]
+ },
+ </pre>
+
 h2. Methods
 
 See "Common resource methods":{{site.baseurl}}/api/methods.html for more information about @create@, @delete@, @get@, @list@, and @update@.
index 54c4a3331650a62dcde39ebda5d7d4bdfb774a4d..82e8128c6e80445aed6f96409aa0165cd06c74b4 100644 (file)
@@ -26,7 +26,7 @@ There are four levels of permission: *none*, *can_read*, *can_write*, and *can_m
 
 h2. Ownership
 
-All Arvados objects have an @owner_uuid@ field. Valid uuid types for @owner_uuid@ are "User" and "Group".  For Group, the @group_class@ must be a "project".
+All Arvados objects have an @owner_uuid@ field. Valid uuid types for @owner_uuid@ are "User" and "Group".  In the case of a Group, the @group_class@ must be "project".
 
 The User or Group specified by @owner_uuid@ has *can_manage* permission on the object.  This permission is one way: an object that is owned does not get any special permissions on the User or Group that owns it.
 
@@ -63,9 +63,15 @@ h2. Projects and Roles
 A "project" is a subtype of Group that is displayed as a "Project" in Workbench, and as a directory by @arv-mount@.
 * A project can own things (appear in @owner_uuid@)
 * A project can be owned by a user or another project.
-* The name of a project is unique only among projects with the same owner_uuid.
+* The name of a project is unique only among projects and filters with the same owner_uuid.
 * Projects can be targets (@head_uuid@) of permission links, but not origins (@tail_uuid@).  Putting a project in a @tail_uuid@ field is an error.
 
+A "filter" is a subtype of Group that is displayed as a "Project" in Workbench, and as a directory by @arv-mount@. See "the groups API documentation":/api/methods/groups.html for more information.
+* A filter group cannot own things (cannot appear in @owner_uuid@).  Putting a filter group in an @owner_uuid@ field is an error.
+* A filter group can be owned by a user or a project.
+* The name of a filter is unique only among projects and filters with the same owner_uuid.
+* Filters can be targets (@head_uuid@) of permission links, but not origins (@tail_uuid@).  Putting a filter in a @tail_uuid@ field is an error.
+
 A "role" is a subtype of Group that is treated in Workbench as a group of users who have permissions in common (typically an organizational group).
 * A role cannot own things (cannot appear in @owner_uuid@).  Putting a role in an @owner_uuid@ field is an error.
 * All roles are owned by the system user.
diff --git a/doc/api/projects.html.textile.liquid b/doc/api/projects.html.textile.liquid
new file mode 100644 (file)
index 0000000..b1c74fe
--- /dev/null
@@ -0,0 +1,69 @@
+---
+layout: default
+navsection: api
+title: "Projects and filter groups"
+...
+{% comment %}
+Copyright (C) The Arvados Authors. All rights reserved.
+
+SPDX-License-Identifier: CC-BY-SA-3.0
+{% endcomment %}
+
+Arvados @projects@ are used to organize objects. Projects can contain @collections@, @container requests@, @workflows@, etc. Projects can also contain other projects. An object is part of a project if the @owner_uuid@ of the object is set to the uuid of the project.
+
+Projects are implemented as a subtype of the Arvados @group@ object type, with @group_class@ set to the value "project". More information is available in the "groups API reference":/api/methods/groups.html.
+
+Projects can be manipulated via Workbench, the cli tools, the SDKs, and the Arvados APIs.
+
+h2. The home project
+
+Each user has a @home project@, which is implemented differently. This is a virtual project that is comprised of all objects owned by the user, in other words, all objects with the @owner_uuid@ set to the @uuid@ of the user. The home project is accessible via Workbench, which makes it easy view its contents and to move objects from and to the home project. The home project is also accessible via FUSE, WebDAV and the S3 interface.
+
+The same thing can be done via the APIs. To put something in a user's home project via the cli or SDKs, one would set the @owner_uuid@ of the object to the user's @uuid@. This also implies that this user now has full ownership and control over that object.
+
+The contents of the home project can be accessed with the @group contents@ API, e.g. via the cli with this command:
+<pre>arv group contents --uuid zzzzz-tpzed-123456789012345</pre>
+In this command, `zzzzz-tpzed-123456789012345` is a @user@ uuid, which is unusual because we are using it as the argument to a @groups@ API. The @group contents@ API is normally used with a @group@ uuid.
+
+Because the home project is a virtual project, other operations via the @groups@ API are not supported.
+
+h2. Filter groups
+
+Filter groups are another type of virtual project. They are implemented as an Arvados @group@ object with @group_class@ set to the value "filter".
+
+Filter groups define one or more filters which are applied to all objects that the current user can see, and returned as the contents of the @group@. Filter groups are described in more detail in the "groups API reference":{{site.baseurl}}/api/methods/groups.html, and the rules for creating valid filters are the same as for "list method filters":{{site.baseurl}}/api/methods.html#filters.
+
+Filter groups are accessible (read-only) via Workbench and the Arvados FUSE mount, WebDAV and S3 interface. Filter groups must currently be defined via the API, SDK or cli, there is no Workbench support yet.
+
+As an example, create a filter group with the @arv@ cli:
+
+<notextile>
+<pre><code>~$ <span class="userinput"> FILTER_GROUP_UUID=`arv -s group create --group '{
+    "group_class":"filter",
+    "name":"my filter group",
+    "properties":{
+      "filters":
+        [
+          ["collections.name","ilike","%test%"],
+          ["uuid","is_a","arvados#collection"]
+        ]
+      }
+    }'`
+</code>
+</pre>
+</notextile>
+This filter group will contain all collections visible to the current user whose name matches the word @test@ (case insensitive).
+
+To see how this works via the keep FUSE mount, create a few matching (and non-matching) collections:
+
+<notextile>
+<pre><code>~$ <span class="userinput">arv collection create --collection '{"name":"empty test collection 1"}'</span>
+~$ <span class="userinput">arv collection create --collection '{"name":"another empty collection"}'</span>
+~$ <span class="userinput">arv collection create --collection '{"name":"empty Test collection 2"}'</span>
+~$ <span class="userinput">mkdir -p keep</span>
+~$ <span class="userinput">arv-mount keep</span>
+~$ <span class="userinput">ls keep/by_id/$FILTER_GROUP_UUID/ -C1</span>
+'empty test collection 1'
+'empty Test collection 2'</code>
+</pre>
+</notextile>
index b2e928b82ed2761cacd173af9b2a936b7fd939a6..6029056b25ba1482059ab605ff192a4986f1c03f 100644 (file)
@@ -433,6 +433,10 @@ func (conn *Conn) GroupDelete(ctx context.Context, options arvados.DeleteOptions
        return conn.chooseBackend(options.UUID).GroupDelete(ctx, options)
 }
 
+func (conn *Conn) GroupTrash(ctx context.Context, options arvados.DeleteOptions) (arvados.Group, error) {
+       return conn.chooseBackend(options.UUID).GroupTrash(ctx, options)
+}
+
 func (conn *Conn) GroupUntrash(ctx context.Context, options arvados.UntrashOptions) (arvados.Group, error) {
        return conn.chooseBackend(options.UUID).GroupUntrash(ctx, options)
 }
index d197675f8dc6e774d10427b11121a8f27e2c4823..04f85cb5a9f54c2cd2286e33645fc4a62cb400ca 100644 (file)
@@ -6,6 +6,8 @@ package localdb
 
 import (
        "context"
+       "fmt"
+       "strings"
 
        "git.arvados.org/arvados.git/lib/controller/railsproxy"
        "git.arvados.org/arvados.git/lib/controller/rpc"
@@ -45,3 +47,51 @@ func (conn *Conn) Login(ctx context.Context, opts arvados.LoginOptions) (arvados
 func (conn *Conn) UserAuthenticate(ctx context.Context, opts arvados.UserAuthenticateOptions) (arvados.APIClientAuthorization, error) {
        return conn.loginController.UserAuthenticate(ctx, opts)
 }
+
+func (conn *Conn) GroupContents(ctx context.Context, options arvados.GroupContentsOptions) (arvados.ObjectList, error) {
+       // The requested UUID can be a user (virtual home project), which we just pass on to
+       // the API server.
+       if strings.Index(options.UUID, "-j7d0g-") != 5 {
+               return conn.railsProxy.GroupContents(ctx, options)
+       }
+
+       var resp arvados.ObjectList
+
+       // Get the group object
+       respGroup, err := conn.GroupGet(ctx, arvados.GetOptions{UUID: options.UUID})
+       if err != nil {
+               return resp, err
+       }
+
+       // If the group has groupClass 'filter', apply the filters before getting the contents.
+       if respGroup.GroupClass == "filter" {
+               if filters, ok := respGroup.Properties["filters"].([]interface{}); ok {
+                       for _, f := range filters {
+                               // f is supposed to be a []string
+                               tmp, ok2 := f.([]interface{})
+                               if !ok2 || len(tmp) < 3 {
+                                       return resp, fmt.Errorf("filter unparsable: %T, %+v, original field: %T, %+v\n", tmp, tmp, f, f)
+                               }
+                               var filter arvados.Filter
+                               if attr, ok2 := tmp[0].(string); ok2 {
+                                       filter.Attr = attr
+                               } else {
+                                       return resp, fmt.Errorf("filter unparsable: attribute must be string: %T, %+v, filter: %T, %+v\n", tmp[0], tmp[0], f, f)
+                               }
+                               if operator, ok2 := tmp[1].(string); ok2 {
+                                       filter.Operator = operator
+                               } else {
+                                       return resp, fmt.Errorf("filter unparsable: operator must be string: %T, %+v, filter: %T, %+v\n", tmp[1], tmp[1], f, f)
+                               }
+                               filter.Operand = tmp[2]
+                               options.Filters = append(options.Filters, filter)
+                       }
+               } else {
+                       return resp, fmt.Errorf("filter unparsable: not an array\n")
+               }
+               // Use the generic /groups/contents endpoint for filter groups
+               options.UUID = ""
+       }
+
+       return conn.railsProxy.GroupContents(ctx, options)
+}
index 6e933fc00ed291483eb93279497c48f2d4365618..03cdcf18d27e4fcf3df814ab3c652c3479456165 100644 (file)
@@ -31,6 +31,9 @@ func (rtr *router) responseOptions(opts interface{}) (responseOptions, error) {
        case *arvados.ListOptions:
                rOpts.Select = opts.Select
                rOpts.Count = opts.Count
+       case *arvados.GroupContentsOptions:
+               rOpts.Select = opts.Select
+               rOpts.Count = opts.Count
        }
        return rOpts, nil
 }
index d7096ab9f457a92c7e07e3a7fb70710bb7bbb6c5..a313ebc8bed94c5b5b7e32b6c086644b4faae77f 100644 (file)
@@ -284,6 +284,13 @@ func (rtr *router) addRoutes() {
                                return rtr.backend.GroupDelete(ctx, *opts.(*arvados.DeleteOptions))
                        },
                },
+               {
+                       arvados.EndpointGroupTrash,
+                       func() interface{} { return &arvados.DeleteOptions{} },
+                       func(ctx context.Context, opts interface{}) (interface{}, error) {
+                               return rtr.backend.GroupTrash(ctx, *opts.(*arvados.DeleteOptions))
+                       },
+               },
                {
                        arvados.EndpointGroupUntrash,
                        func() interface{} { return &arvados.UntrashOptions{} },
index b3713d938b6d0dc39f088badf95c1adb44b90699..61d20de78a824e869677e15f8c9937b69e9e4121 100644 (file)
@@ -465,6 +465,13 @@ func (conn *Conn) GroupDelete(ctx context.Context, options arvados.DeleteOptions
        return resp, err
 }
 
+func (conn *Conn) GroupTrash(ctx context.Context, options arvados.DeleteOptions) (arvados.Group, error) {
+       ep := arvados.EndpointGroupTrash
+       var resp arvados.Group
+       err := conn.requestAndDecode(ctx, &resp, ep, nil, options)
+       return resp, err
+}
+
 func (conn *Conn) GroupUntrash(ctx context.Context, options arvados.UntrashOptions) (arvados.Group, error) {
        ep := arvados.EndpointGroupUntrash
        var resp arvados.Group
index 694b61d69ec0aa54cc0562c796459f99aae5f9d9..bfae393f861ce9bc168519cf49581b711c8efb82 100644 (file)
@@ -59,6 +59,7 @@ var (
        EndpointGroupContentsUUIDInPath       = APIEndpoint{"GET", "arvados/v1/groups/{uuid}/contents", ""} // Alternative HTTP route; client-side code should always use EndpointGroupContents instead
        EndpointGroupShared                   = APIEndpoint{"GET", "arvados/v1/groups/shared", ""}
        EndpointGroupDelete                   = APIEndpoint{"DELETE", "arvados/v1/groups/{uuid}", ""}
+       EndpointGroupTrash                    = APIEndpoint{"POST", "arvados/v1/groups/{uuid}/trash", ""}
        EndpointGroupUntrash                  = APIEndpoint{"POST", "arvados/v1/groups/{uuid}/untrash", ""}
        EndpointUserActivate                  = APIEndpoint{"POST", "arvados/v1/users/{uuid}/activate", ""}
        EndpointUserCreate                    = APIEndpoint{"POST", "arvados/v1/users", "user"}
@@ -141,8 +142,11 @@ type GroupContentsOptions struct {
        Limit              int64    `json:"limit"`
        Offset             int64    `json:"offset"`
        Order              []string `json:"order"`
+       Distinct           bool     `json:"distinct"`
+       Count              string   `json:"count"`
        Include            string   `json:"include"`
        Recursive          bool     `json:"recursive"`
+       IncludeTrash       bool     `json:"include_trash"`
        IncludeOldVersions bool     `json:"include_old_versions"`
        ExcludeHomeProject bool     `json:"exclude_home_project"`
 }
@@ -233,6 +237,7 @@ type API interface {
        GroupContents(ctx context.Context, options GroupContentsOptions) (ObjectList, error)
        GroupShared(ctx context.Context, options ListOptions) (GroupList, error)
        GroupDelete(ctx context.Context, options DeleteOptions) (Group, error)
+       GroupTrash(ctx context.Context, options DeleteOptions) (Group, error)
        GroupUntrash(ctx context.Context, options UntrashOptions) (Group, error)
        SpecimenCreate(ctx context.Context, options CreateOptions) (Specimen, error)
        SpecimenUpdate(ctx context.Context, options UpdateOptions) (Specimen, error)
index 86facd681e5aa336ed6c73252ecc9c3936c9502e..0564e2fae61a2c85e68d677b0344572014e184e8 100644 (file)
@@ -39,6 +39,42 @@ func (sc *spyingClient) RequestAndDecode(dst interface{}, method, path string, b
        return sc.Client.RequestAndDecode(dst, method, path, body, params)
 }
 
+func (s *SiteFSSuite) TestFilterGroup(c *check.C) {
+       // Make sure that a collection and group that match the filter are present,
+       // and that a group that does not match the filter is not present.
+       s.fs.MountProject("fg", fixtureThisFilterGroupUUID)
+
+       _, err := s.fs.OpenFile("/fg/baz_file", 0, 0)
+       c.Assert(err, check.IsNil)
+
+       _, err = s.fs.OpenFile("/fg/A Subproject", 0, 0)
+       c.Assert(err, check.IsNil)
+
+       _, err = s.fs.OpenFile("/fg/A Project", 0, 0)
+       c.Assert(err, check.Not(check.IsNil))
+
+       // An empty filter means everything that is visible should be returned.
+       s.fs.MountProject("fg2", fixtureAFilterGroupTwoUUID)
+
+       _, err = s.fs.OpenFile("/fg2/baz_file", 0, 0)
+       c.Assert(err, check.IsNil)
+
+       _, err = s.fs.OpenFile("/fg2/A Subproject", 0, 0)
+       c.Assert(err, check.IsNil)
+
+       _, err = s.fs.OpenFile("/fg2/A Project", 0, 0)
+       c.Assert(err, check.IsNil)
+
+       // An 'is_a' 'arvados#collection' filter means only collections should be returned.
+       s.fs.MountProject("fg3", fixtureAFilterGroupThreeUUID)
+
+       _, err = s.fs.OpenFile("/fg3/baz_file", 0, 0)
+       c.Assert(err, check.IsNil)
+
+       _, err = s.fs.OpenFile("/fg3/A Subproject", 0, 0)
+       c.Assert(err, check.Not(check.IsNil))
+}
+
 func (s *SiteFSSuite) TestCurrentUserHome(c *check.C) {
        s.fs.MountProject("home", "")
        s.testHomeProject(c, "/home")
index 778b12015a6f3964be7db301f30cd8ca5db1a971..b1c627f89c9e43c198dbd7a2a59f059c3cb01372 100644 (file)
@@ -18,6 +18,9 @@ const (
        // package].
        fixtureActiveToken             = "3kg6k6lzmp9kj5cpkcoxie963cmvjahbt2fod9zru30k1jqdmi"
        fixtureAProjectUUID            = "zzzzz-j7d0g-v955i6s2oi1cbso"
+       fixtureThisFilterGroupUUID     = "zzzzz-j7d0g-thisfiltergroup"
+       fixtureAFilterGroupTwoUUID     = "zzzzz-j7d0g-afiltergrouptwo"
+       fixtureAFilterGroupThreeUUID   = "zzzzz-j7d0g-filtergroupthre"
        fixtureFooAndBarFilesInDirUUID = "zzzzz-4zz18-foonbarfilesdir"
        fixtureFooCollectionName       = "zzzzz-4zz18-fy296fx3hot09f7 added sometime"
        fixtureFooCollectionPDH        = "1f4b0bc7583c2a7f9102c395f4ffc5e3+45"
index d9708e3b1aacd307185deee5644023d5791a8ff1..f255aeb2d085a9dbb4bc2fd2494daccf3f9d2605 100644 (file)
@@ -157,6 +157,10 @@ func (as *APIStub) GroupDelete(ctx context.Context, options arvados.DeleteOption
        as.appendCall(ctx, as.GroupDelete, options)
        return arvados.Group{}, as.Error
 }
+func (as *APIStub) GroupTrash(ctx context.Context, options arvados.DeleteOptions) (arvados.Group, error) {
+       as.appendCall(ctx, as.GroupTrash, options)
+       return arvados.Group{}, as.Error
+}
 func (as *APIStub) GroupUntrash(ctx context.Context, options arvados.UntrashOptions) (arvados.Group, error) {
        as.appendCall(ctx, as.GroupUntrash, options)
        return arvados.Group{}, as.Error
index 394b5603b7918e745140942af58ef3bfc393a8cd..aef956fb303bd981a11d513521dacb49c9814fbf 100644 (file)
@@ -127,9 +127,11 @@ class Arvados::V1::GroupsController < ApplicationController
       :self_link => "",
       :offset => @offset,
       :limit => @limit,
-      :items_available => @items_available,
       :items => @objects.as_api_response(nil)
     }
+    if params[:count] != 'none'
+      list[:items_available] = @items_available
+    end
     if @extra_included
       list[:included] = @extra_included.as_api_response(nil, {select: @select})
     end
@@ -244,8 +246,6 @@ class Arvados::V1::GroupsController < ApplicationController
 
     seen_last_class = false
     klasses.each do |klass|
-      @offset = 0 if seen_last_class  # reset offset for the new next type being processed
-
       # if current klass is same as params['last_object_class'], mark that fact
       seen_last_class = true if((params['count'].andand.==('none')) and
                                 (params['last_object_class'].nil? or
@@ -273,7 +273,7 @@ class Arvados::V1::GroupsController < ApplicationController
       if klass == Collection
         @select = klass.selectable_attributes - ["manifest_text", "unsigned_manifest_text"]
       elsif klass == Group
-        where_conds = where_conds.merge(group_class: "project")
+        where_conds = where_conds.merge(group_class: ["project","filter"])
       end
 
       @filters = request_filters.map do |col, op, val|
@@ -294,12 +294,24 @@ class Arvados::V1::GroupsController < ApplicationController
       if params['exclude_home_project']
         @objects = exclude_home @objects, klass
       end
+      if params['count'] == 'none'
+        # The call to object_list below will not populate :items_available in
+        # its response, because count is disabled.  Save @objects length (does
+        # not require another db query) so that @offset (if set) is handled
+        # correctly.
+        countless_items_available = @objects.length
+      end
 
       klass_limit = limit_all - all_objects.count
       @limit = klass_limit
       apply_where_limit_order_params klass
       klass_object_list = object_list(model_class: klass)
-      klass_items_available = klass_object_list[:items_available] || 0
+      if params['count'] != 'none'
+        klass_items_available = klass_object_list[:items_available] || 0
+      else
+        # klass_object_list[:items_available] is not populated
+        klass_items_available = countless_items_available
+      end
       @items_available += klass_items_available
       @offset = [@offset - klass_items_available, 0].max
       all_objects += klass_object_list[:items]
index 870e0d0c456ddd21e777351afea6106cdd6b7325..fd2f5f18c2ac8018b152307042e82d587f8290a7 100644 (file)
@@ -18,6 +18,7 @@ class Group < ArvadosModel
 
   validate :ensure_filesystem_compatible_name
   validate :check_group_class
+  validate :check_filter_group_filters
   before_create :assign_name
   after_create :after_ownership_change
   after_create :update_trash
@@ -56,6 +57,41 @@ class Group < ArvadosModel
     end
   end
 
+  def check_filter_group_filters
+    if group_class == 'filter'
+      if !self.properties.key?("filters")
+        errors.add :properties, "filters property missing, it must be an array of arrays, each with 3 elements"
+        return
+      end
+      if !self.properties["filters"].is_a?(Array)
+        errors.add :properties, "filters property must be an array of arrays, each with 3 elements"
+        return
+      end
+      self.properties["filters"].each do |filter|
+        if !filter.is_a?(Array)
+          errors.add :properties, "filters property must be an array of arrays, each with 3 elements"
+          return
+        end
+        if filter.length() != 3
+          errors.add :properties, "filters property must be an array of arrays, each with 3 elements"
+          return
+        end
+        if !filter[0].include?(".") and filter[0].downcase != "uuid"
+          errors.add :properties, "filter attribute must be 'uuid' or contain a dot (e.g. groups.name)"
+          return
+        end
+        if (filter[0].downcase != "uuid" and filter[1].downcase == "is_a")
+          errors.add :properties, "when filter operator is 'is_a', attribute must be 'uuid'"
+          return
+        end
+        if ! ["=","<","<=",">",">=","!=","like","ilike","in","not in","is_a"].include?(filter[1].downcase)
+          errors.add :properties, "filter operator is not valid (must be =,<,<=,>,>=,!=,like,ilike,in,not in,is_a)"
+          return
+        end
+      end
+    end
+  end
+
   def update_trash
     if saved_change_to_trash_at? or saved_change_to_owner_uuid?
       # The group was added or removed from the trash.
index 5bb013c9add7a1f241d4779768cef462ac9956b2..79fea459018b604a8b20515591067320f1a540d2 100644 (file)
@@ -13,8 +13,8 @@ def fix_roles_projects
     # shouldn't be anything to do at all.
     act_as_system_user do
       ActiveRecord::Base.transaction do
-        Group.where("group_class != 'project' or group_class is null").each do |g|
-          # 1) any group not group_class != project becomes a 'role' (both empty and invalid groups)
+        Group.where("(group_class != 'project' and group_class != 'filter') or group_class is null").each do |g|
+          # 1) any group not group_class != project and != filter becomes a 'role' (both empty and invalid groups)
           old_owner = g.owner_uuid
           g.owner_uuid = system_user_uuid
           g.group_class = 'role'
index 31a72f17208090a9b210996b4c34379e95116aa1..48925a27027a7cbd96dba6bd5bfa5eeb67757928 100644 (file)
@@ -107,6 +107,45 @@ asubproject:
   description: "Test project belonging to active user's first test project"
   group_class: project
 
+afiltergroup:
+  uuid: zzzzz-j7d0g-thisfiltergroup
+  owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
+  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
+  name: This filter group
+  group_class: filter
+  properties:
+    filters: [[ "collections.name", "like", "baz%" ], [ "groups.name", "=", "A Subproject" ]]
+
+afiltergroup2:
+  uuid: zzzzz-j7d0g-afiltergrouptwo
+  owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
+  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
+  name: A filter group without filters
+  group_class: filter
+  properties:
+    filters: []
+
+afiltergroup3:
+  uuid: zzzzz-j7d0g-filtergroupthre
+  owner_uuid: zzzzz-tpzed-xurymjxw79nv3jz
+  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
+  name: A filter group with an is_a collection filter
+  group_class: filter
+  properties:
+    filters: [["uuid", "is_a", "arvados#collection"]]
+
 future_project_viewing_group:
   uuid: zzzzz-j7d0g-futrprojviewgrp
   owner_uuid: zzzzz-tpzed-000000000000000
index dfa3b7fe778f4c56c897fd4d60e9816e579751ae..9bba418578e89c6dd36009ba2e3278f700d33eb3 100644 (file)
@@ -80,6 +80,16 @@ class Arvados::V1::QueryTest < ActionController::TestCase
     refute(json_response.has_key?('items_available'))
   end
 
+  test 'do not count items_available if count=none for group contents endpoint' do
+    @controller = Arvados::V1::GroupsController.new
+    authorize_with :active
+    get :contents, params: {
+      count: 'none',
+    }
+    assert_response(:success)
+    refute(json_response.has_key?('items_available'))
+  end
+
   [{}, {count: nil}, {count: ''}, {count: 'exact'}].each do |params|
     test "count items_available if params=#{params.inspect}" do
       @controller = Arvados::V1::LinksController.new
index 7021761278d72143c277b06622504eae3c593334..aa67166f7e613a7b71f1ce8b798cf3b23b060e4a 100644 (file)
@@ -177,6 +177,26 @@ class GroupsTest < ActionDispatch::IntegrationTest
     end
     assert_equal true, found_projects.include?(groups(:starred_and_shared_active_user_project).uuid)
   end
+
+  test 'count none works with offset' do
+    first_results = nil
+    (0..10).each do |offset|
+      get "/arvados/v1/groups/contents", params: {
+        id: groups(:aproject).uuid,
+        offset: offset,
+        format: :json,
+        order: :uuid,
+        count: :none,
+      }, headers: auth(:active)
+      assert_response :success
+      assert_nil json_response['items_available']
+      if first_results.nil?
+        first_results = json_response['items']
+      else
+        assert_equal first_results[offset]['uuid'], json_response['items'][0]['uuid']
+      end
+    end
+  end
 end
 
 class NonTransactionalGroupsTest < ActionDispatch::IntegrationTest
index d7a33a4515d615a19928525198c3acc61c7d9d1a..017916f48bee5fafd278800a143236eb7c6b609a 100644 (file)
@@ -278,7 +278,7 @@ class GroupTest < ActiveSupport::TestCase
       Rails.configuration.Collections.ForwardSlashNameSubstitution = subst
       proj = Group.create group_class: "project"
       role = Group.create group_class: "role"
-      filt = Group.create group_class: "filter"
+      filt = Group.create group_class: "filter", properties: {"filters":[]}
       [[nil, true],
        ["", true],
        [".", false],
@@ -292,9 +292,9 @@ class GroupTest < ActiveSupport::TestCase
         role.name = name
         assert_equal true, role.valid?
         proj.name = name
-        assert_equal valid, proj.valid?, "#{name.inspect} should be #{valid ? "valid" : "invalid"}"
+        assert_equal valid, proj.valid?, "project: #{name.inspect} should be #{valid ? "valid" : "invalid"}"
         filt.name = name
-        assert_equal valid, filt.valid?, "#{name.inspect} should be #{valid ? "valid" : "invalid"}"
+        assert_equal valid, filt.valid?, "filter: #{name.inspect} should be #{valid ? "valid" : "invalid"}"
       end
     end
   end
index 1fab2e0fb89d22d4362dd56d017e55519ead6f09..e8da789fa5fbcff8682cbc0fca251bdf9a7c23a8 100644 (file)
@@ -683,7 +683,7 @@ and the directory will appear if it exists.
 
             if group_uuid_pattern.match(k):
                 project = self.api.groups().list(
-                    filters=[['group_class', '=', 'project'], ["uuid", "=", k]]).execute(num_retries=self.num_retries)
+                    filters=[['group_class', 'in', ['project','filter']], ["uuid", "=", k]]).execute(num_retries=self.num_retries)
                 if project[u'items_available'] == 0:
                     return False
                 e = self.inodes.add_entry(ProjectDirectory(
@@ -811,7 +811,7 @@ class ProjectDirectory(Directory):
     """A special directory that contains the contents of a project."""
 
     def __init__(self, parent_inode, inodes, api, num_retries, project_object,
-                 poll=False, poll_time=60):
+                 poll=True, poll_time=3):
         super(ProjectDirectory, self).__init__(parent_inode, inodes, api.config)
         self.api = api
         self.num_retries = num_retries
@@ -899,7 +899,7 @@ class ProjectDirectory(Directory):
                                                  self.num_retries,
                                                  uuid=self.project_uuid,
                                                  filters=[["uuid", "is_a", "arvados#group"],
-                                                          ["group_class", "=", "project"]])
+                                                          ["groups.group_class", "in", ["project","filter"]]])
                 contents.extend(arvados.util.list_all(self.api.groups().contents,
                                                       self.num_retries,
                                                       uuid=self.project_uuid,
@@ -934,7 +934,7 @@ class ProjectDirectory(Directory):
             else:
                 namefilter = ["name", "in", [k, k2]]
             contents = self.api.groups().list(filters=[["owner_uuid", "=", self.project_uuid],
-                                                       ["group_class", "=", "project"],
+                                                       ["group_class", "in", ["project","filter"]],
                                                        namefilter],
                                               limit=2).execute(num_retries=self.num_retries)["items"]
             if not contents:
@@ -1103,7 +1103,7 @@ class SharedDirectory(Directory):
                 if 'httpMethod' in methods.get('shared', {}):
                     page = []
                     while True:
-                        resp = self.api.groups().shared(filters=[['group_class', '=', 'project']]+page,
+                        resp = self.api.groups().shared(filters=[['group_class', 'in', ['project','filter']]]+page,
                                                         order="uuid",
                                                         limit=10000,
                                                         count="none",
@@ -1120,7 +1120,7 @@ class SharedDirectory(Directory):
                 else:
                     all_projects = arvados.util.list_all(
                         self.api.groups().list, self.num_retries,
-                        filters=[['group_class','=','project']],
+                        filters=[['group_class','in',['project','filter']]],
                         select=["uuid", "owner_uuid"])
                     for ob in all_projects:
                         objects[ob['uuid']] = ob
index b2816ac16f4c1893d279c72c86e61f05f1cc1740..54316bb9a987cd5a2a771da901cf9d6db9da8c51 100644 (file)
@@ -129,7 +129,9 @@ class FuseMagicTest(MountTestBase):
 
         self.test_project = run_test_server.fixture('groups')['aproject']['uuid']
         self.non_project_group = run_test_server.fixture('groups')['public_role']['uuid']
+        self.filter_group = run_test_server.fixture('groups')['afiltergroup']['uuid']
         self.collection_in_test_project = run_test_server.fixture('collections')['foo_collection_in_aproject']['name']
+        self.collection_in_filter_group = run_test_server.fixture('collections')['baz_file']['name']
 
         cw = arvados.CollectionWriter()
 
@@ -157,6 +159,11 @@ class FuseMagicTest(MountTestBase):
                       llfuse.listdir(os.path.join(self.mounttmp, self.test_project)))
         self.assertIn(self.collection_in_test_project,
                       llfuse.listdir(os.path.join(self.mounttmp, 'by_id', self.test_project)))
+        self.assertIn(self.collection_in_filter_group,
+                      llfuse.listdir(os.path.join(self.mounttmp, self.filter_group)))
+        self.assertIn(self.collection_in_filter_group,
+                      llfuse.listdir(os.path.join(self.mounttmp, 'by_id', self.filter_group)))
+
 
         mount_ls = llfuse.listdir(self.mounttmp)
         self.assertIn('README', mount_ls)
@@ -166,6 +173,8 @@ class FuseMagicTest(MountTestBase):
         self.assertIn(self.test_project, mount_ls)
         self.assertIn(self.test_project,
                       llfuse.listdir(os.path.join(self.mounttmp, 'by_id')))
+        self.assertIn(self.filter_group,
+                      llfuse.listdir(os.path.join(self.mounttmp, 'by_id')))
 
         with self.assertRaises(OSError):
             llfuse.listdir(os.path.join(self.mounttmp, 'by_id', self.non_project_group))