Merge branch 'master' into 3354-render-textile
[arvados.git] / apps / workbench / app / controllers / application_controller.rb
index 3e458bf33c17cff8a8ac9318392c865c669df98f..d2f4ed44b240990b5c611c34d0103a478924c7f4 100644 (file)
@@ -1,5 +1,6 @@
 class ApplicationController < ActionController::Base
   include ArvadosApiClientHelper
+  include ApplicationHelper
 
   respond_to :html, :json, :js
   protect_from_forgery
@@ -7,24 +8,27 @@ class ApplicationController < ActionController::Base
   ERROR_ACTIONS = [:render_error, :render_not_found]
 
   around_filter :thread_clear
-  around_filter :thread_with_mandatory_api_token, except: ERROR_ACTIONS
-  around_filter :thread_with_optional_api_token
+  around_filter :set_thread_api_token
+  # Methods that don't require login should
+  #   skip_around_filter :require_thread_api_token
+  around_filter :require_thread_api_token, except: ERROR_ACTIONS
+  before_filter :accept_uuid_as_id_param, except: ERROR_ACTIONS
   before_filter :check_user_agreements, except: ERROR_ACTIONS
+  before_filter :check_user_profile, except: ERROR_ACTIONS
   before_filter :check_user_notifications, except: ERROR_ACTIONS
-  before_filter :find_object_by_uuid, except: [:index] + ERROR_ACTIONS
+  before_filter :load_filters_and_paging_params, except: ERROR_ACTIONS
+  before_filter :find_object_by_uuid, except: [:index, :choose] + ERROR_ACTIONS
   theme :select_theme
 
   begin
-    rescue_from Exception,
-    :with => :render_exception
-    rescue_from ActiveRecord::RecordNotFound,
-    :with => :render_not_found
-    rescue_from ActionController::RoutingError,
-    :with => :render_not_found
-    rescue_from ActionController::UnknownController,
-    :with => :render_not_found
-    rescue_from ::AbstractController::ActionNotFound,
-    :with => :render_not_found
+    rescue_from(ActiveRecord::RecordNotFound,
+                ActionController::RoutingError,
+                ActionController::UnknownController,
+                AbstractController::ActionNotFound,
+                with: :render_not_found)
+    rescue_from(Exception,
+                ActionController::UrlGenerationError,
+                with: :render_exception)
   end
 
   def unprocessable(message=nil)
@@ -34,36 +38,70 @@ class ApplicationController < ActionController::Base
     render_error status: 422
   end
 
-  def render_error(opts)
-    opts = {status: 500}.merge opts
+  def render_error(opts={})
+    opts[:status] ||= 500
     respond_to do |f|
       # json must come before html here, so it gets used as the
       # default format when js is requested by the client. This lets
       # ajax:error callback parse the response correctly, even though
       # the browser can't.
       f.json { render opts.merge(json: {success: false, errors: @errors}) }
-      f.html { render opts.merge(controller: 'application', action: 'error') }
+      f.html { render({action: 'error'}.merge(opts)) }
     end
   end
 
   def render_exception(e)
     logger.error e.inspect
     logger.error e.backtrace.collect { |x| x + "\n" }.join('') if e.backtrace
-    if @object.andand.errors.andand.full_messages.andand.any?
+    err_opts = {status: 422}
+    if e.is_a?(ArvadosApiClient::ApiError)
+      err_opts.merge!(action: 'api_error', locals: {api_error: e})
+      @errors = e.api_response[:errors]
+    elsif @object.andand.errors.andand.full_messages.andand.any?
       @errors = @object.errors.full_messages
     else
       @errors = [e.to_s]
     end
-    self.render_error status: 422
+    # Make user information available on the error page, falling back to the
+    # session cache if the API server is unavailable.
+    begin
+      load_api_token(session[:arvados_api_token])
+    rescue ArvadosApiClient::ApiError
+      unless session[:user].nil?
+        begin
+          Thread.current[:user] = User.new(session[:user])
+        rescue ArvadosApiClient::ApiError
+          # This can happen if User's columns are unavailable.  Nothing to do.
+        end
+      end
+    end
+    # Preload projects trees for the template.  If that's not doable, set empty
+    # trees so error page rendering can proceed.  (It's easier to rescue the
+    # exception here than in a template.)
+    unless current_user.nil?
+      begin
+        build_project_trees
+      rescue ArvadosApiClient::ApiError
+        # Fall back to the default-setting code later.
+      end
+    end
+    @my_project_tree ||= []
+    @shared_project_tree ||= []
+    render_error(err_opts)
   end
 
   def render_not_found(e=ActionController::RoutingError.new("Path not found"))
     logger.error e.inspect
     @errors = ["Path not found"]
-    self.render_error status: 404
+    set_thread_api_token do
+      self.render_error(action: '404', status: 404)
+    end
   end
 
-  def index
+  def load_filters_and_paging_params
+    @order = params[:order] || 'created_at desc'
+    @order = [@order] unless @order.is_a? Array
+
     @limit ||= 200
     if params[:limit]
       @limit = params[:limit].to_i
@@ -79,27 +117,102 @@ class ApplicationController < ActionController::Base
       filters = params[:filters]
       if filters.is_a? String
         filters = Oj.load filters
+      elsif filters.is_a? Array
+        filters = filters.collect do |filter|
+          if filter.is_a? String
+            # Accept filters[]=["foo","=","bar"]
+            Oj.load filter
+          else
+            # Accept filters=[["foo","=","bar"]]
+            filter
+          end
+        end
       end
       @filters += filters
     end
+  end
 
+  def find_objects_for_index
     @objects ||= model_class
-    @objects = @objects.filter(@filters).limit(@limit).offset(@offset).all
+    @objects = @objects.filter(@filters).limit(@limit).offset(@offset)
+  end
+
+  def render_index
     respond_to do |f|
       f.json { render json: @objects }
-      f.html { render }
+      f.html {
+        if params[:tab_pane]
+          render_pane params[:tab_pane]
+        else
+          render
+        end
+      }
       f.js { render }
     end
   end
 
+  helper_method :render_pane
+  def render_pane tab_pane, opts={}
+    render_opts = {
+      partial: 'show_' + tab_pane.downcase,
+      locals: {
+        comparable: self.respond_to?(:compare),
+        objects: @objects,
+        tab_pane: tab_pane
+      }.merge(opts[:locals] || {})
+    }
+    if opts[:to_string]
+      render_to_string render_opts
+    else
+      render render_opts
+    end
+  end
+
+  def index
+    find_objects_for_index if !@objects
+    render_index
+  end
+
+  helper_method :next_page_offset
+  def next_page_offset objects=nil
+    if !objects
+      objects = @objects
+    end
+    if objects.respond_to?(:result_offset) and
+        objects.respond_to?(:result_limit) and
+        objects.respond_to?(:items_available)
+      next_offset = objects.result_offset + objects.result_limit
+      if next_offset < objects.items_available
+        next_offset
+      else
+        nil
+      end
+    end
+  end
+
+  helper_method :next_page_href
+  def next_page_href with_params={}
+    if next_page_offset
+      url_for with_params.merge(offset: next_page_offset)
+    end
+  end
+
   def show
     if !@object
       return render_not_found("object not found")
     end
     respond_to do |f|
-      f.json { render json: @object.attributes.merge(href: url_for(@object)) }
+      f.json do
+        extra_attrs = { href: url_for(action: :show, id: @object) }
+        @object.textile_attributes.each do |textile_attr|
+          extra_attrs.merge!({ "#{textile_attr}Textile" => view_context.render_content_from_database(@object.attributes[textile_attr]) })
+        end
+        render json: @object.attributes.merge(extra_attrs)
+      end
       f.html {
-        if request.method == 'GET'
+        if params['tab_pane']
+          render_pane params['tab_pane']
+        elsif request.method.in? ['GET', 'HEAD']
           render
         else
           redirect_to params[:return_to] || @object
@@ -109,6 +222,25 @@ class ApplicationController < ActionController::Base
     end
   end
 
+  def choose
+    params[:limit] ||= 40
+    find_objects_for_index if !@objects
+    respond_to do |f|
+      if params[:partial]
+        f.json {
+          render json: {
+            content: render_to_string(partial: "choose_rows.html",
+                                      formats: [:html]),
+            next_page_href: next_page_href(partial: params[:partial])
+          }
+        }
+      end
+      f.js {
+        render partial: 'choose', locals: {multiple: params[:multiple]}
+      }
+    end
+  end
+
   def render_content
     if !@object
       return render_not_found("object not found")
@@ -120,7 +252,7 @@ class ApplicationController < ActionController::Base
   end
 
   def update
-    @updates ||= params[@object.class.to_s.underscore.singularize.to_sym]
+    @updates ||= params[@object.resource_param_name.to_sym]
     @updates.keys.each do |attr|
       if @object.send(attr).is_a? Hash
         if @updates[attr].is_a? String
@@ -145,7 +277,34 @@ class ApplicationController < ActionController::Base
     @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 ||= model_class.new @new_resource_attrs, params["options"]
+    if @object.save
+      respond_to do |f|
+        f.json { render json: @object.attributes.merge(href: url_for(action: :show, id: @object)) }
+        f.html {
+          redirect_to @object
+        }
+        f.js { render }
+      end
+    else
+      self.render_error status: 422
+    end
+  end
+
+  # Clone the given object, merging any attribute values supplied as
+  # with a create action.
+  def copy
+    @new_resource_attrs ||= params[model_class.to_s.underscore.singularize]
+    @new_resource_attrs ||= {}
+    @object = @object.dup
+    @object.update_attributes @new_resource_attrs
+    if not @new_resource_attrs[:name] and @object.respond_to? :name
+      if @object.name and @object.name != ''
+        @object.name = "Copy of #{@object.name}"
+      else
+        @object.name = ""
+      end
+    end
     @object.save!
     show
   end
@@ -165,12 +324,7 @@ class ApplicationController < ActionController::Base
   end
 
   def current_user
-    if Thread.current[:arvados_api_token]
-      Thread.current[:user] ||= User.current
-    else
-      logger.error "No API token in Thread"
-      return nil
-    end
+    Thread.current[:user]
   end
 
   def model_class
@@ -188,16 +342,20 @@ class ApplicationController < ActionController::Base
   end
 
   def show_pane_list
-    %w(Attributes Metadata JSON API)
+    %w(Attributes Advanced)
   end
 
   protected
 
+  def strip_token_from_path(path)
+    path.sub(/([\?&;])api_token=[^&;]*[&;]?/, '\1')
+  end
+
   def redirect_to_login
     respond_to do |f|
       f.html {
-        if request.method == 'GET'
-          redirect_to arvados_api_client.arvados_login_url(return_to: request.url)
+        if request.method.in? ['GET', 'HEAD']
+          redirect_to arvados_api_client.arvados_login_url(return_to: strip_token_from_path(request.url))
         else
           flash[:error] = "Either you are not logged in, or your session has timed out. I can't automatically log you in and re-attempt this request."
           redirect_to :back
@@ -216,8 +374,7 @@ class ApplicationController < ActionController::Base
     [:arvados_api_token, :user].each do |key|
       start_values[key] = Thread.current[key]
     end
-    Thread.current[:arvados_api_token] = api_token
-    Thread.current[:user] = nil
+    load_api_token(api_token)
     begin
       yield
     ensure
@@ -225,108 +382,136 @@ class ApplicationController < ActionController::Base
     end
   end
 
-  def find_object_by_uuid
+
+  def accept_uuid_as_id_param
     if params[:id] and params[:id].match /\D/
       params[:uuid] = params.delete :id
     end
-    if not model_class
-      @object = nil
-    elsif params[:uuid].is_a? String
-      if params[:uuid].empty?
+  end
+
+  def find_object_by_uuid
+    begin
+      if not model_class
+        @object = nil
+      elsif not params[:uuid].is_a?(String)
+        @object = model_class.where(uuid: params[:uuid]).first
+      elsif params[:uuid].empty?
         @object = nil
+      elsif (model_class != Link and
+             resource_class_for_uuid(params[:uuid]) == Link)
+        @name_link = Link.find(params[:uuid])
+        @object = model_class.find(@name_link.head_uuid)
       else
         @object = model_class.find(params[:uuid])
       end
-    else
-      @object = model_class.where(uuid: params[:uuid]).first
+    rescue ArvadosApiClient::NotFoundException, RuntimeError => error
+      if error.is_a?(RuntimeError) and (error.message !~ /^argument to find\(/)
+        raise
+      end
+      render_not_found(error)
+      return false
     end
   end
 
   def thread_clear
-    Thread.current[:arvados_api_token] = nil
-    Thread.current[:user] = nil
+    load_api_token(nil)
     Rails.cache.delete_matched(/^request_#{Thread.current.object_id}_/)
     yield
     Rails.cache.delete_matched(/^request_#{Thread.current.object_id}_/)
   end
 
-  def thread_with_api_token(login_optional = false)
+  # Set up the thread with the given API token and associated user object.
+  def load_api_token(new_token)
+    Thread.current[:arvados_api_token] = new_token
+    if new_token.nil?
+      Thread.current[:user] = nil
+    else
+      Thread.current[:user] = User.current
+    end
+  end
+
+  # If there's a valid api_token parameter, set up the session with that
+  # user's information.  Return true if the method redirects the request
+  # (usually a post-login redirect); false otherwise.
+  def setup_user_session
+    return false unless params[:api_token]
+    Thread.current[:arvados_api_token] = params[:api_token]
     begin
-      try_redirect_to_login = true
-      if params[:api_token]
-        try_redirect_to_login = false
-        Thread.current[:arvados_api_token] = params[:api_token]
-        # Before copying the token into session[], do a simple API
-        # call to verify its authenticity.
-        if verify_api_token
-          session[:arvados_api_token] = params[:api_token]
-          if !request.format.json? and request.method == 'GET'
-            # Repeat this request with api_token in the (new) session
-            # cookie instead of the query string.  This prevents API
-            # tokens from appearing in (and being inadvisedly copied
-            # and pasted from) browser Location bars.
-            redirect_to request.fullpath.sub(%r{([&\?]api_token=)[^&\?]*}, '')
-          else
-            yield
-          end
-        else
-          @errors = ['Invalid API token']
-          self.render_error status: 401
-        end
-      elsif session[:arvados_api_token]
-        # In this case, the token must have already verified at some
-        # point, but it might have been revoked since.  We'll try
-        # using it, and catch the exception if it doesn't work.
-        try_redirect_to_login = false
-        Thread.current[:arvados_api_token] = session[:arvados_api_token]
-        begin
-          yield
-        rescue ArvadosApiClient::NotLoggedInException
-          try_redirect_to_login = true
-        end
+      user = User.current
+    rescue ArvadosApiClient::NotLoggedInException
+      false  # We may redirect to login, or not, based on the current action.
+    else
+      session[:arvados_api_token] = params[:api_token]
+      # If we later have trouble contacting the API server, we still want
+      # to be able to render basic user information in the UI--see
+      # render_exception above.  We store that in the session here.  This is
+      # not intended to be used as a general-purpose cache.  See #2891.
+      session[:user] = {
+        uuid: user.uuid,
+        email: user.email,
+        first_name: user.first_name,
+        last_name: user.last_name,
+        is_active: user.is_active,
+        is_admin: user.is_admin,
+        prefs: user.prefs
+      }
+
+      if !request.format.json? and request.method.in? ['GET', 'HEAD']
+        # Repeat this request with api_token in the (new) session
+        # cookie instead of the query string.  This prevents API
+        # tokens from appearing in (and being inadvisedly copied
+        # and pasted from) browser Location bars.
+        redirect_to strip_token_from_path(request.fullpath)
+        true
       else
-        logger.debug "No token received, session is #{session.inspect}"
-      end
-      if try_redirect_to_login
-        unless login_optional
-          redirect_to_login
-        else
-          # login is optional for this route so go on to the regular controller
-          Thread.current[:arvados_api_token] = nil
-          yield
-        end
+        false
       end
     ensure
-      # Remove token in case this Thread is used for anything else.
       Thread.current[:arvados_api_token] = nil
     end
   end
 
-  def thread_with_mandatory_api_token
-    thread_with_api_token do
-      yield
+  # Save the session API token in thread-local storage, and yield.
+  # This method also takes care of session setup if the request
+  # provides a valid api_token parameter.
+  # If a token is unavailable or expired, the block is still run, with
+  # a nil token.
+  def set_thread_api_token
+    if Thread.current[:arvados_api_token]
+      yield   # An API token has already been found - pass it through.
+      return
+    elsif setup_user_session
+      return  # A new session was set up and received a response.
     end
-  end
 
-  # This runs after thread_with_mandatory_api_token in the filter chain.
-  def thread_with_optional_api_token
-    if Thread.current[:arvados_api_token]
-      # We are already inside thread_with_mandatory_api_token.
+    begin
+      load_api_token(session[:arvados_api_token])
       yield
-    else
-      # We skipped thread_with_mandatory_api_token. Use the optional version.
-      thread_with_api_token(true) do
+    rescue ArvadosApiClient::NotLoggedInException
+      # If we got this error with a token, it must've expired.
+      # Retry the request without a token.
+      unless Thread.current[:arvados_api_token].nil?
+        load_api_token(nil)
         yield
       end
+    ensure
+      # Remove token in case this Thread is used for anything else.
+      load_api_token(nil)
     end
   end
 
-  def verify_api_token
-    begin
-      Link.where(uuid: 'just-verifying-my-api-token')
-      true
-    rescue ArvadosApiClient::NotLoggedInException
-      false
+  # Redirect to login/welcome if client provided expired API token (or none at all)
+  def require_thread_api_token
+    if Thread.current[:arvados_api_token]
+      yield
+    elsif session[:arvados_api_token]
+      # Expired session. Clear it before refreshing login so that,
+      # if this login procedure fails, we end up showing the "please
+      # log in" page instead of getting stuck in a redirect loop.
+      session.delete :arvados_api_token
+      redirect_to_login
+    else
+      redirect_to welcome_users_path(return_to: request.fullpath)
     end
   end
 
@@ -337,16 +522,22 @@ class ApplicationController < ActionController::Base
     end
   end
 
+  helper_method :unsigned_user_agreements
+  def unsigned_user_agreements
+    @signed_ua_uuids ||= UserAgreement.signatures.map &:head_uuid
+    @unsigned_user_agreements ||= UserAgreement.all.map do |ua|
+      if not @signed_ua_uuids.index ua.uuid
+        Collection.find(ua.uuid)
+      end
+    end.compact
+  end
+
   def check_user_agreements
-    if current_user && !current_user.is_active && current_user.is_invited
-      signatures = UserAgreement.signatures
-      @signed_ua_uuids = UserAgreement.signatures.map &:head_uuid
-      @required_user_agreements = UserAgreement.all.map do |ua|
-        if not @signed_ua_uuids.index ua.uuid
-          Collection.find(ua.uuid)
-        end
-      end.compact
-      if @required_user_agreements.empty?
+    if current_user && !current_user.is_active
+      if not current_user.is_invited
+        return redirect_to inactive_users_path(return_to: request.fullpath)
+      end
+      if unsigned_user_agreements.empty?
         # No agreements to sign. Perhaps we just need to ask?
         current_user.activate
         if !current_user.is_active
@@ -355,12 +546,47 @@ class ApplicationController < ActionController::Base
         end
       end
       if !current_user.is_active
-        render 'user_agreements/index'
+        redirect_to user_agreements_path(return_to: request.fullpath)
       end
     end
     true
   end
 
+  def check_user_profile
+    if request.method.downcase != 'get' || params[:partial] ||
+       params[:tab_pane] || params[:action_method] ||
+       params[:action] == 'setup_popup'
+      return true
+    end
+
+    if missing_required_profile?
+      redirect_to profile_user_path(current_user.uuid, return_to: request.fullpath)
+    end
+    true
+  end
+
+  helper_method :missing_required_profile?
+  def missing_required_profile?
+    missing_required = false
+
+    profile_config = Rails.configuration.user_profile_form_fields
+    if current_user && profile_config
+      current_user_profile = current_user.prefs[:profile]
+      profile_config.kind_of?(Array) && profile_config.andand.each do |entry|
+        if entry['required']
+          if !current_user_profile ||
+             !current_user_profile[entry['key'].to_sym] ||
+             current_user_profile[entry['key'].to_sym].empty?
+            missing_required = true
+            break
+          end
+        end
+      end
+    end
+
+    missing_required
+  end
+
   def select_theme
     return Rails.configuration.arvados_theme
   end
@@ -376,15 +602,6 @@ class ApplicationController < ActionController::Base
     }
   }
 
-  #@@notification_tests.push lambda { |controller, current_user|
-  #  Job.limit(1).where(created_by: current_user.uuid).each do
-  #    return nil
-  #  end
-  #  return lambda { |view|
-  #    view.render partial: 'notifications/jobs_notification'
-  #  }
-  #}
-
   @@notification_tests.push lambda { |controller, current_user|
     Collection.limit(1).where(created_by: current_user.uuid).each do
       return nil
@@ -404,10 +621,12 @@ class ApplicationController < ActionController::Base
   }
 
   def check_user_notifications
+    return if params['tab_pane']
+
     @notification_count = 0
     @notifications = []
 
-    if current_user
+    if current_user.andand.is_active
       @showallalerts = false
       @@notification_tests.each do |t|
         a = t.call(self, current_user)
@@ -423,14 +642,20 @@ class ApplicationController < ActionController::Base
     end
   end
 
-  helper_method :my_folders
-  def my_folders
-    return @my_folders if @my_folders
-    @my_folders = []
+  helper_method :all_projects
+  def all_projects
+    @all_projects ||= Group.
+      filter([['group_class','=','project']]).order('name')
+  end
+
+  helper_method :my_projects
+  def my_projects
+    return @my_projects if @my_projects
+    @my_projects = []
     root_of = {}
-    Group.filter([['group_class','=','folder']]).each do |g|
+    all_projects.each do |g|
       root_of[g.uuid] = g.owner_uuid
-      @my_folders << g
+      @my_projects << g
     end
     done = false
     while not done
@@ -444,70 +669,193 @@ class ApplicationController < ActionController::Base
         end
       end
     end
-    @my_folders = @my_folders.select do |g|
+    @my_projects = @my_projects.select do |g|
       root_of[g.uuid] == current_user.uuid
     end
   end
 
+  helper_method :projects_shared_with_me
+  def projects_shared_with_me
+    my_project_uuids = my_projects.collect &:uuid
+    all_projects.reject { |x| x.uuid.in? my_project_uuids }
+  end
+
+  helper_method :recent_jobs_and_pipelines
+  def recent_jobs_and_pipelines
+    (Job.limit(10) |
+     PipelineInstance.limit(10)).
+      sort_by do |x|
+      (x.finished_at || x.started_at rescue nil) || x.modified_at || x.created_at
+    end.reverse
+  end
+
+  helper_method :my_project_tree
+  def my_project_tree
+    build_project_trees
+    @my_project_tree
+  end
+
+  helper_method :shared_project_tree
+  def shared_project_tree
+    build_project_trees
+    @shared_project_tree
+  end
+
+  def build_project_trees
+    return if @my_project_tree and @shared_project_tree
+    parent_of = {current_user.uuid => 'me'}
+    all_projects.each do |ob|
+      parent_of[ob.uuid] = ob.owner_uuid
+    end
+    children_of = {false => [], 'me' => [current_user]}
+    all_projects.each do |ob|
+      if ob.owner_uuid != current_user.uuid and
+          not parent_of.has_key? ob.owner_uuid
+        parent_of[ob.uuid] = false
+      end
+      children_of[parent_of[ob.uuid]] ||= []
+      children_of[parent_of[ob.uuid]] << ob
+    end
+    buildtree = lambda do |children_of, root_uuid=false|
+      tree = {}
+      children_of[root_uuid].andand.each do |ob|
+        tree[ob] = buildtree.call(children_of, ob.uuid)
+      end
+      tree
+    end
+    sorted_paths = lambda do |tree, depth=0|
+      paths = []
+      tree.keys.sort_by { |ob|
+        ob.is_a?(String) ? ob : ob.friendly_link_name
+      }.each do |ob|
+        paths << {object: ob, depth: depth}
+        paths += sorted_paths.call tree[ob], depth+1
+      end
+      paths
+    end
+    @my_project_tree =
+      sorted_paths.call buildtree.call(children_of, 'me')
+    @shared_project_tree =
+      sorted_paths.call({'Shared with me' =>
+                          buildtree.call(children_of, false)})
+  end
+
+  helper_method :get_object
+  def get_object uuid
+    if @get_object.nil? and @objects
+      @get_object = @objects.each_with_object({}) do |object, h|
+        h[object.uuid] = object
+      end
+    end
+    @get_object ||= {}
+    @get_object[uuid]
+  end
+
+  helper_method :project_breadcrumbs
+  def project_breadcrumbs
+    crumbs = []
+    current = @name_link || @object
+    while current
+      if current.is_a?(Group) and current.group_class == 'project'
+        crumbs.prepend current
+      end
+      if current.is_a? Link
+        current = Group.find?(current.tail_uuid)
+      else
+        current = Group.find?(current.owner_uuid)
+      end
+    end
+    crumbs
+  end
+
+  helper_method :current_project_uuid
+  def current_project_uuid
+    if @object.is_a? Group and @object.group_class == 'project'
+      @object.uuid
+    elsif @name_link.andand.tail_uuid
+      @name_link.tail_uuid
+    elsif @object and resource_class_for_uuid(@object.owner_uuid) == Group
+      @object.owner_uuid
+    else
+      nil
+    end
+  end
+
   # helper method to get links for given object or uuid
   helper_method :links_for_object
   def links_for_object object_or_uuid
-    uuid = object_or_uuid.is_a?(String) ? object_or_uuid : object_or_uuid.uuid
+    raise ArgumentError, 'No input argument' unless object_or_uuid
     preload_links_for_objects([object_or_uuid])
-    @all_links_for[uuid]
+    uuid = object_or_uuid.is_a?(String) ? object_or_uuid : object_or_uuid.uuid
+    @all_links_for[uuid] ||= []
   end
 
   # helper method to preload links for given objects and uuids
   helper_method :preload_links_for_objects
   def preload_links_for_objects objects_and_uuids
-    uuids = objects_and_uuids.collect { |x| x.is_a?(String) ? x : x.uuid }
     @all_links_for ||= {}
+
+    raise ArgumentError, 'Argument is not an array' unless objects_and_uuids.is_a? Array
+    return @all_links_for if objects_and_uuids.empty?
+
+    uuids = objects_and_uuids.collect { |x| x.is_a?(String) ? x : x.uuid }
+
+    # if already preloaded for all of these uuids, return
     if not uuids.select { |x| @all_links_for[x].nil? }.any?
-      # already preloaded for all of these uuids
-      return
+      return @all_links_for
     end
+
     uuids.each do |x|
       @all_links_for[x] = []
     end
+
     # TODO: make sure we get every page of results from API server
-    Link.filter([['head_uuid','in',uuids]]).each do |link|
+    Link.filter([['head_uuid', 'in', uuids]]).each do |link|
       @all_links_for[link.head_uuid] << link
     end
+    @all_links_for
   end
 
   # helper method to get a certain number of objects of a specific type
   # this can be used to replace any uses of: "dataclass.limit(n)"
-  helper_method :get_objects_of_type
-  def get_objects_of_type dataclass, size
-    # if the objects_map_for has a value for this dataclass, and the size used
-    # to retrieve those objects is greater than equal to size, return it
-    size_key = "#{dataclass}_size"
-    if @objects_map_for && @objects_map_for[dataclass] && @objects_map_for[size_key] &&
-        (@objects_map_for[size_key] >= size)
-      return @objects_map_for[dataclass] 
-    end
-
-    @objects_map_for = {}
-    @objects_map_for[dataclass] = dataclass.limit(size)
-    @objects_map_for[size_key] = size
+  helper_method :get_n_objects_of_class
+  def get_n_objects_of_class dataclass, size
+    @objects_map_for ||= {}
+
+    raise ArgumentError, 'Argument is not a data class' unless dataclass.is_a? Class and dataclass < ArvadosBase
+    raise ArgumentError, 'Argument is not a valid limit size' unless (size && size>0)
 
-    return @objects_map_for[dataclass]
+    # if the objects_map_for has a value for this dataclass, and the
+    # size used to retrieve those objects is equal, return it
+    size_key = "#{dataclass.name}_size"
+    if @objects_map_for[dataclass.name] && @objects_map_for[size_key] &&
+        (@objects_map_for[size_key] == size)
+      return @objects_map_for[dataclass.name]
+    end
+
+    @objects_map_for[size_key] = size
+    @objects_map_for[dataclass.name] = dataclass.limit(size)
   end
 
   # helper method to get collections for the given uuid
   helper_method :collections_for_object
   def collections_for_object uuid
+    raise ArgumentError, 'No input argument' unless uuid
     preload_collections_for_objects([uuid])
-    @all_collections_for[uuid]
+    @all_collections_for[uuid] ||= []
   end
 
   # helper method to preload collections for the given uuids
   helper_method :preload_collections_for_objects
   def preload_collections_for_objects uuids
     @all_collections_for ||= {}
+
+    raise ArgumentError, 'Argument is not an array' unless uuids.is_a? Array
+    return @all_collections_for if uuids.empty?
+
+    # if already preloaded for all of these uuids, return
     if not uuids.select { |x| @all_collections_for[x].nil? }.any?
-      # already preloaded for all of these uuids
-      return
+      return @all_collections_for
     end
 
     uuids.each do |x|
@@ -518,30 +866,46 @@ class ApplicationController < ActionController::Base
     Collection.where(uuid: uuids).each do |collection|
       @all_collections_for[collection.uuid] << collection
     end
+    @all_collections_for
   end
 
   # helper method to get log collections for the given log
   helper_method :log_collections_for_object
   def log_collections_for_object log
+    raise ArgumentError, 'No input argument' unless log
+
+    preload_log_collections_for_objects([log])
+
+    uuid = log
     fixup = /([a-f0-9]{32}\+\d+)(\+?.*)/.match(log)
-    uuid = fixup[1]
-    preload_log_collections_for_objects([uuid])
-    @all_log_collections_for[uuid]
+    if fixup && fixup.size>1
+      uuid = fixup[1]
+    end
+
+    @all_log_collections_for[uuid] ||= []
   end
 
   # helper method to preload collections for the given uuids
   helper_method :preload_log_collections_for_objects
   def preload_log_collections_for_objects logs
+    @all_log_collections_for ||= {}
+
+    raise ArgumentError, 'Argument is not an array' unless logs.is_a? Array
+    return @all_log_collections_for if logs.empty?
+
     uuids = []
     logs.each do |log|
       fixup = /([a-f0-9]{32}\+\d+)(\+?.*)/.match(log)
-      uuids << fixup[1]
+      if fixup && fixup.size>1
+        uuids << fixup[1]
+      else
+        uuids << log
+      end
     end
 
-    @all_log_collections_for ||= {}
+    # if already preloaded for all of these uuids, return
     if not uuids.select { |x| @all_log_collections_for[x].nil? }.any?
-      # already preloaded for all of these uuids
-      return
+      return @all_log_collections_for
     end
 
     uuids.each do |x|
@@ -552,6 +916,39 @@ class ApplicationController < ActionController::Base
     Collection.where(uuid: uuids).each do |collection|
       @all_log_collections_for[collection.uuid] << collection
     end
+    @all_log_collections_for
+  end
+
+  # helper method to get object of a given dataclass and uuid
+  helper_method :object_for_dataclass
+  def object_for_dataclass dataclass, uuid
+    raise ArgumentError, 'No input argument dataclass' unless (dataclass && uuid)
+    preload_objects_for_dataclass(dataclass, [uuid])
+    @objects_for[uuid]
+  end
+
+  # helper method to preload objects for given dataclass and uuids
+  helper_method :preload_objects_for_dataclass
+  def preload_objects_for_dataclass dataclass, uuids
+    @objects_for ||= {}
+
+    raise ArgumentError, 'Argument is not a data class' unless dataclass.is_a? Class
+    raise ArgumentError, 'Argument is not an array' unless uuids.is_a? Array
+
+    return @objects_for if uuids.empty?
+
+    # if already preloaded for all of these uuids, return
+    if not uuids.select { |x| @objects_for[x].nil? }.any?
+      return @objects_for
+    end
+
+    dataclass.where(uuid: uuids).each do |obj|
+      @objects_for[obj.uuid] = obj
+    end
+    @objects_for
   end
 
+  def wiselinks_layout
+    'body'
+  end
 end