Merge branch '5105-ajax-redirect' closes #5105
authorTom Clegg <tom@curoverse.com>
Tue, 10 Mar 2015 19:46:26 +0000 (15:46 -0400)
committerTom Clegg <tom@curoverse.com>
Tue, 10 Mar 2015 19:46:26 +0000 (15:46 -0400)
16 files changed:
apps/workbench/app/assets/stylesheets/application.css.scss
apps/workbench/app/controllers/actions_controller.rb
apps/workbench/app/controllers/application_controller.rb
apps/workbench/app/controllers/projects_controller.rb
apps/workbench/app/helpers/application_helper.rb
apps/workbench/app/views/application/_name_and_description.html.erb
apps/workbench/app/views/collections/_show_files.html.erb
apps/workbench/app/views/projects/_show_description.html.erb [new file with mode: 0644]
apps/workbench/app/views/projects/show.html.erb
apps/workbench/test/integration/anonymous_access_test.rb
apps/workbench/test/integration/collections_test.rb
apps/workbench/test/integration/pipeline_instances_test.rb
apps/workbench/test/integration/projects_test.rb
services/api/db/migrate/20150303210106_fix_collection_portable_data_hash_with_hinted_manifest.rb [new file with mode: 0644]
services/api/db/structure.sql
services/api/test/fixtures/collections.yml

index 9bc93e32bd49574ff998e60980c6417cb98d4770..710bc9227cc790ae17c1c1d070486be8fe2e1118 100644 (file)
@@ -277,6 +277,12 @@ span.editable-textile {
   display: none;
 }
 
+/* Hide Angular content until Angular is ready */
 [ng\:cloak], [ng-cloak], .ng-cloak {
     display: none !important;
 }
+
+/* tabs */
+ul.nav.nav-tabs {
+    font-size: 90%
+}
index 59dcbb92bb9c57db69fc15277233a0072fd73dac..84b62492b9208e529107af01ba54096b9c454f62 100644 (file)
@@ -46,10 +46,10 @@ class ActionsController < ApplicationController
   def move_or_copy action
     uuids_to_add = params["selection"]
     uuids_to_add = [ uuids_to_add ] unless uuids_to_add.is_a? Array
-    uuids_to_add.
+    resource_classes = uuids_to_add.
       collect { |x| ArvadosBase::resource_class_for_uuid(x) }.
-      uniq.
-      each do |resource_class|
+      uniq
+    resource_classes.each do |resource_class|
       resource_class.filter([['uuid','in',uuids_to_add]]).each do |src|
         if resource_class == Collection and not Collection.attribute_info.include?(:name)
           dst = Link.new(owner_uuid: @object.uuid,
@@ -87,7 +87,17 @@ class ActionsController < ApplicationController
         end
       end
     end
-    redirect_to @object
+    if (resource_classes == [Collection] and
+        @object.is_a? Group and
+        @object.group_class == 'project')
+      # In the common case where only collections are copied/moved
+      # into a project, it's polite to land on the collections tab on
+      # the destination project.
+      redirect_to project_url(@object.uuid, anchor: 'Data_collections')
+    else
+      # Otherwise just land on the default (Description) tab.
+      redirect_to @object
+    end
   end
 
   def arv_normalize mt, *opts
index e0786418ec736aa9e3bb790dd77409c11781ca18..1b59c574b774a897372a7f771dc4cdeda5f45df2 100644 (file)
@@ -267,6 +267,17 @@ class ApplicationController < ActionController::Base
     end
   end
 
+  def redirect_to uri, *args
+    if request.xhr?
+      if not uri.is_a? String
+        uri = polymorphic_url(uri)
+      end
+      render json: {href: uri}
+    else
+      super
+    end
+  end
+
   def choose
     params[:limit] ||= 40
     respond_to do |f|
index 8c2f72e6689a40127cb66c2dbefbe7ffe60c50c0..3302771814eb3bc217f72c2e0aa5768e932dc65e 100644 (file)
@@ -43,6 +43,9 @@ class ProjectsController < ApplicationController
   # It also seems to me that something like these could be used to configure the contents of the panes.
   def show_pane_list
     pane_list = []
+    if @object.uuid != current_user.andand.uuid
+      pane_list << 'Description'
+    end
     pane_list <<
       {
         :name => 'Data_collections',
index ef2830cd7e3fcc667719e0b442019e87de83715b..d02d058e3aef2601ba5398247e313d8de083feea 100644 (file)
@@ -200,7 +200,9 @@ module ApplicationHelper
       "id" => span_id,
       :class => "editable #{is_textile?( object, attr ) ? 'editable-textile' : ''}"
     }.merge(htmloptions).merge(ajax_options)
-    edit_button = raw('<a href="#" class="btn btn-xs btn-default btn-nodecorate" data-toggle="x-editable tooltip" data-toggle-selector="#' + span_id + '" data-placement="top" title="' + (htmloptions[:tiptitle] || 'edit') + '"><i class="fa fa-fw fa-pencil"></i></a>')
+    edit_tiptitle = 'edit'
+    edit_tiptitle = 'Warning: do not use hyphens in the repository name as they will be stripped' if (object.class.to_s == 'Repository' and attr == 'name')
+    edit_button = raw('<a href="#" class="btn btn-xs btn-default btn-nodecorate" data-toggle="x-editable tooltip" data-toggle-selector="#' + span_id + '" data-placement="top" title="' + (htmloptions[:tiptitle] || edit_tiptitle) + '"><i class="fa fa-fw fa-pencil"></i></a>')
     if htmloptions[:btnplacement] == :left
       edit_button + ' ' + span_tag
     else
index 0144a4dd8184c886956197c17269b0982cd20cdc..68a201f19542d71b1fa78e662b3ddcea4b0603fe 100644 (file)
@@ -9,4 +9,3 @@
     <%= render_editable_attribute @object, 'description', nil, { 'data-emptytext' => "(No description provided)", 'data-toggle' => 'manual' } %>
   </div>
 <% end %>
-
index 383ec64c0ab2697b781660644790f35a0e7a7524..02ac2eeb7dbc8a3974ad89a49a7a88d36cf61747 100644 (file)
@@ -83,7 +83,7 @@ function unselect_all_files() {
           </div>
 
           <div class="collection_files_name">
-            <% if !defined? no_checkboxes or !no_checkboxes %>
+            <% if (!defined? no_checkboxes or !no_checkboxes) and current_user %>
             <%= check_box_tag 'uuids[]', "#{@object.uuid}/#{file_path}", false, {
                   :class => "persistent-selection",
                   :friendly_type => "File",
diff --git a/apps/workbench/app/views/projects/_show_description.html.erb b/apps/workbench/app/views/projects/_show_description.html.erb
new file mode 100644 (file)
index 0000000..7260940
--- /dev/null
@@ -0,0 +1,5 @@
+<% if @object.respond_to? :description %>
+  <div class="arv-description-as-subtitle">
+    <%= render_editable_attribute @object, 'description', nil, { 'data-emptytext' => "(No description provided)", 'data-toggle' => 'manual' } %>
+  </div>
+<% end %>
index 5dd1017c8d4724844bdd802a3328dd79cd9f6494..2a85da83214303fed625725dea1a334067691ee2 100644 (file)
@@ -1,7 +1,11 @@
-<% if @object.uuid != current_user.andand.uuid # Not the "Home" project %>
 <% content_for :content_top do %>
-  <%= render partial: 'name_and_description' %>
-<% end %>
+  <h2>
+    <% if @object.uuid == current_user.andand.uuid %>
+      Home
+    <% else %>
+      <%= render_editable_attribute @object, 'name', nil, { 'data-emptytext' => "New project" } %>
+    <% end %>
+  </h2>
 <% end %>
 
 <% content_for :tab_line_buttons do %>
index 6508879099d8252c5ca2d9561cb961661174acb4..b51730bd953958e4713408edc447c427098eaa0d 100644 (file)
@@ -55,6 +55,7 @@ class AnonymousAccessTest < ActionDispatch::IntegrationTest
   test "selection actions when anonymous user accesses shared project" do
     visit PUBLIC_PROJECT
 
+    assert_selector 'a', text: 'Description'
     assert_selector 'a', text: 'Data collections'
     assert_selector 'a', text: 'Jobs and pipelines'
     assert_selector 'a', text: 'Pipeline templates'
@@ -63,6 +64,7 @@ class AnonymousAccessTest < ActionDispatch::IntegrationTest
     assert_no_selector 'a', text: 'Other objects'
     assert_no_selector 'button', text: 'Add data'
 
+    click_link 'Data collections'
     click_button 'Selection'
     within('.selection-action-container') do
       assert_selector 'li', text: 'Compare selected'
@@ -75,6 +77,7 @@ class AnonymousAccessTest < ActionDispatch::IntegrationTest
 
   test "anonymous user accesses data collections tab in shared project" do
     visit PUBLIC_PROJECT
+    click_link 'Data collections'
     collection = api_fixture('collections')['user_agreement_in_anonymously_accessible_project']
     assert_text 'GNU General Public License'
 
@@ -92,6 +95,7 @@ class AnonymousAccessTest < ActionDispatch::IntegrationTest
     assert_no_selector 'button', 'Selection'
 
     within '#collection_files tr,li', text: 'GNU_General_Public_License,_version_3.pdf' do
+      assert page.has_no_selector?('[value*="GNU_General_Public_License"]')
       find 'a[title~=View]'
       find 'a[title~=Download]'
     end
@@ -114,6 +118,7 @@ class AnonymousAccessTest < ActionDispatch::IntegrationTest
   ].each do |type|
     test "anonymous user accesses jobs and pipelines tab in shared project and clicks on #{type}" do
       visit PUBLIC_PROJECT
+      click_link 'Data collections'
       assert_text 'GNU General Public License'
 
       click_link 'Jobs and pipelines'
@@ -155,6 +160,7 @@ class AnonymousAccessTest < ActionDispatch::IntegrationTest
 
   test "anonymous user accesses pipeline templates tab in shared project" do
     visit PUBLIC_PROJECT
+    click_link 'Data collections'
     assert_text 'GNU General Public License'
 
     assert_selector 'a', text: 'Pipeline templates'
index 4338d19ea1fa5a61f6244dadb674e5feb6e35c97..0a026f107d1f4a2d38f46f1cefdfa00058660394 100644 (file)
@@ -24,10 +24,9 @@ class CollectionsTest < ActionDispatch::IntegrationTest
     click_link 'Copy to project...'
     find('.selectable', text: project_name).click
     find('.modal-footer a,button', text: 'Copy').click
-    wait_for_ajax
-    # It should navigate to the project after copying...
-    assert(page.has_text?(project_name))
-    assert(page.has_text?("Copy of #{collection_name}"))
+    # Should navigate to the Data collections tab of the project after copying
+    assert_text project_name
+    assert_text "Copy of #{collection_name}"
   end
 
   test "Collection page renders name" do
index f8d57961999077898b95440e608bd1f6a8762a7e..f2916741cb28fbc0cdee25ed85093a47a0b45f9e 100644 (file)
@@ -5,6 +5,51 @@ class PipelineInstancesTest < ActionDispatch::IntegrationTest
     need_javascript
   end
 
+  def parse_browser_timestamp t
+    # Timestamps are displayed in the browser's time zone (which can
+    # differ from ours) and they come from toLocaleTimeString (which
+    # means they don't necessarily tell us which time zone they're
+    # using). In order to make sense of them, we need to ask the
+    # browser to parse them and generate a timestamp that can be
+    # parsed reliably.
+    #
+    # Note: Even with all this help, phantomjs seem to behave badly
+    # when parsing timestamps on the other side of a DST transition.
+    # See skipped tests below.
+    if /(\d+:\d+ [AP]M) (\d+\/\d+\/\d+)/ =~ t
+      # Currently dates.js renders timestamps as
+      # '{t.toLocaleTimeString()} {t.toLocaleDateString()}' which even
+      # browsers can't make sense of. First we need to flip it around
+      # so it looks like what toLocaleString() would have made.
+      t = $~[2] + ', ' + $~[1]
+    end
+    DateTime.parse(page.evaluate_script "new Date('#{t}').toUTCString()").to_time
+  end
+
+  if false
+    # No need to test (or mention) these all the time. If they start
+    # working (without need_selenium) then some real tests might not
+    # need_selenium any more.
+
+    test 'phantomjs DST' do
+      skip '^^'
+      t0s = '3/8/2015, 01:59 AM'
+      t1s = '3/8/2015, 03:01 AM'
+      t0 = parse_browser_timestamp t0s
+      t1 = parse_browser_timestamp t1s
+      assert_equal 120, t1-t0, "'#{t0s}' to '#{t1s}' was reported as #{t1-t0} seconds, should be 120"
+    end
+
+    test 'phantomjs DST 2' do
+      skip '^^'
+      t0s = '2015-03-08T10:43:00Z'
+      t1s = '2015-03-09T03:43:00Z'
+      t0 = parse_browser_timestamp page.evaluate_script("new Date('#{t0s}').toLocaleString()")
+      t1 = parse_browser_timestamp page.evaluate_script("new Date('#{t1s}').toLocaleString()")
+      assert_equal 17*3600, t1-t0, "'#{t0s}' to '#{t1s}' was reported as #{t1-t0} seconds, should be #{17*3600} (17 hours)"
+    end
+  end
+
   test 'Create and run a pipeline' do
     visit page_with_token('active_trustedclient', '/pipeline_templates')
     within('tr', text: 'Two Part Pipeline Template') do
@@ -417,6 +462,7 @@ class PipelineInstancesTest < ActionDispatch::IntegrationTest
     ['active', 'zzzzz-d1hrv-runningpipeline', nil], # state = running
   ].each do |user, uuid, run_time|
     test "pipeline start and finish time display for #{uuid}" do
+      need_selenium 'to parse timestamps correctly across DST boundaries'
       visit page_with_token(user, "/pipeline_instances/#{uuid}")
 
       assert page.has_text? 'This pipeline started at'
@@ -432,12 +478,11 @@ class PipelineInstancesTest < ActionDispatch::IntegrationTest
       start_at = match[1]
       assert_not_nil(start_at, 'Did not find start_at time')
 
-      # start and finished time display is of the format '2:20 PM 10/20/2014'
-      start_time = DateTime.strptime(start_at, '%H:%M %p %m/%d/%Y').to_time
+      start_time = parse_browser_timestamp start_at
       if run_time
         finished_at = match[3]
         assert_not_nil(finished_at, 'Did not find finished_at time')
-        finished_time = DateTime.strptime(finished_at, '%H:%M %p %m/%d/%Y').to_time
+        finished_time = parse_browser_timestamp finished_at
         assert_equal(run_time, finished_time-start_time,
           "Time difference did not match for start_at #{start_at}, finished_at #{finished_at}, ran_for #{match[2]}")
       else
index 88972e50a73225391a17b723e12f77140914b1db..9ee328c5369b0ff7bd36dc5361ab18b7a653538d 100644 (file)
@@ -11,6 +11,7 @@ class ProjectsTest < ActionDispatch::IntegrationTest
   test 'Check collection count for A Project in the tab pane titles' do
     project_uuid = api_fixture('groups')['aproject']['uuid']
     visit page_with_token 'active', '/projects/' + project_uuid
+    click_link 'Data collections'
     wait_for_ajax
     collection_count = page.all("[data-pk*='collection']").count
     assert_selector '#Data_collections-tab span', text: "(#{collection_count})"
@@ -256,6 +257,7 @@ class ProjectsTest < ActionDispatch::IntegrationTest
         visit page_with_token 'active', '/'
         find("#projects-menu").click
         find(".dropdown-menu a", text: dest['name']).click
+        click_link 'Data collections'
         assert page.has_text?(my_collection['name']), 'Collection not found in dest project after copy'
 
       when 'Move'
@@ -263,6 +265,7 @@ class ProjectsTest < ActionDispatch::IntegrationTest
         visit page_with_token 'active', '/'
         find("#projects-menu").click
         find(".dropdown-menu a", text: dest['name']).click
+        click_link 'Data collections'
         assert page.has_text?(my_collection['name']), 'Collection not found in dest project after move'
 
       when 'Remove'
@@ -275,6 +278,7 @@ class ProjectsTest < ActionDispatch::IntegrationTest
     visit page_with_token 'active', '/'
     find("#projects-menu").click
     find(".dropdown-menu a", text: src['name']).click
+    click_link 'Data collections'
     assert page.has_text?(item['name']), 'Collection not found in src project'
 
     within('tr', text: item['name']) do
@@ -313,6 +317,7 @@ class ProjectsTest < ActionDispatch::IntegrationTest
     find("#projects-menu").click
     find(".dropdown-menu a", text: my_project['name']).click
 
+    click_link 'Data collections'
     click_button 'Selection'
     within('.selection-action-container') do
       assert_selector 'li.disabled', text: 'Create new collection with selected collections'
@@ -326,6 +331,7 @@ class ProjectsTest < ActionDispatch::IntegrationTest
     visit page_with_token 'active', '/'
     find("#projects-menu").click
     find(".dropdown-menu a", text: my_project['name']).click
+    click_link 'Data collections'
     assert page.has_text?(my_collection['name']), 'Collection not found in project'
 
     within('tr', text: my_collection['name']) do
@@ -459,11 +465,13 @@ class ProjectsTest < ActionDispatch::IntegrationTest
     end
   end
 
-  # "Move selected" and "Remove selected" options should not be available when current user cannot write to the project
+  # "Move selected" and "Remove selected" options should not be
+  # available when current user cannot write to the project
   test "move selected and remove selected actions not available when current user cannot write to project" do
     my_project = api_fixture('groups')['anonymously_accessible_project']
     visit page_with_token 'active', "/projects/#{my_project['uuid']}"
 
+    click_link 'Data collections'
     click_button 'Selection'
     within('.selection-action-container') do
       assert_selector 'li', text: 'Create new collection with selected collections'
@@ -485,6 +493,7 @@ class ProjectsTest < ActionDispatch::IntegrationTest
       visit page_with_token user, '/'
       find("#projects-menu").click
       find(".dropdown-menu a", text: my_project['name']).click
+      click_link 'Data collections'
       assert page.has_text?(my_collection['name']), 'Collection not found in project'
 
       within('tr', text: my_collection['name']) do
@@ -707,9 +716,9 @@ class ProjectsTest < ActionDispatch::IntegrationTest
     # As of 2014-12-19, the first tab of project#show uses infinite scrolling.
     # Make sure that it loads data even if we visit another tab directly.
     need_selenium 'to land on specified tab using {url}#Advanced'
-    project = api_fixture("groups", "aproject")
+    user = api_fixture("users", "active")
     visit(page_with_token("active_trustedclient",
-                          "/projects/#{project['uuid']}#Advanced"))
+                          "/projects/#{user['uuid']}#Advanced"))
     assert_text("API response")
     find("#page-wrapper .nav-tabs :first-child a").click
     assert_text("Collection modified at")
diff --git a/services/api/db/migrate/20150303210106_fix_collection_portable_data_hash_with_hinted_manifest.rb b/services/api/db/migrate/20150303210106_fix_collection_portable_data_hash_with_hinted_manifest.rb
new file mode 100644 (file)
index 0000000..7f65450
--- /dev/null
@@ -0,0 +1,112 @@
+require 'has_uuid'
+require 'kind_and_etag'
+
+class FixCollectionPortableDataHashWithHintedManifest < ActiveRecord::Migration
+  include CurrentApiClient
+
+  class ArvadosModel < ActiveRecord::Base
+    self.abstract_class = true
+    extend HasUuid::ClassMethods
+    include CurrentApiClient
+    include KindAndEtag
+    before_create do |record|
+      record.uuid ||= record.class.generate_uuid
+      record.owner_uuid ||= system_user_uuid
+    end
+    serialize :properties, Hash
+
+    def self.to_s
+      # Clean up the name of the stub model class so we generate correct UUIDs.
+      super.sub("FixCollectionPortableDataHashWithHintedManifest::", "")
+    end
+  end
+
+  class Collection < ArvadosModel
+  end
+
+  class Log < ArvadosModel
+    def self.log_for(thing, age="old")
+      { "#{age}_etag" => thing.etag,
+        "#{age}_attributes" => thing.attributes,
+      }
+    end
+
+    def self.log_create(thing)
+      new_log("create", thing, log_for(thing, "new"))
+    end
+
+    def self.log_update(thing, start_state)
+      new_log("update", thing, start_state.merge(log_for(thing, "new")))
+    end
+
+    def self.log_destroy(thing)
+      new_log("destroy", thing, log_for(thing, "old"))
+    end
+
+    private
+
+    def self.new_log(event_type, thing, properties)
+      create!(event_type: event_type,
+              event_at: Time.now,
+              object_uuid: thing.uuid,
+              object_owner_uuid: thing.owner_uuid,
+              properties: properties)
+    end
+  end
+
+  def each_bad_collection
+    Collection.find_each do |coll|
+      next unless (coll.manifest_text =~ /\+[A-Z]/)
+      stripped_manifest = coll.manifest_text.
+        gsub(/( [0-9a-f]{32}(\+\d+)?)(\+\S+)/, '\1')
+      stripped_pdh = sprintf("%s+%i",
+                             Digest::MD5.hexdigest(stripped_manifest),
+                             stripped_manifest.bytesize)
+      yield [coll, stripped_pdh] if (coll.portable_data_hash != stripped_pdh)
+    end
+  end
+
+  def up
+    Collection.reset_column_information
+    Log.reset_column_information
+    copied_attr_names =
+      [:owner_uuid, :created_at, :modified_by_client_uuid, :manifest_text,
+       :modified_by_user_uuid, :modified_at, :updated_at, :name,
+       :description, :portable_data_hash, :replication_desired,
+       :replication_confirmed, :replication_confirmed_at, :expires_at]
+    new_expiry = Date.new(2038, 1, 31)
+
+    each_bad_collection do |coll, stripped_pdh|
+      # Create a copy of the collection including bad portable data hash,
+      # with an expiration.  This makes it possible to resolve the bad
+      # portable data hash, but the expiration can hide the Collection
+      # from more user-friendly interfaces like Workbench.
+      start_log = Log.log_for(coll)
+      attributes = Hash[copied_attr_names.map { |key| [key, coll.send(key)] }]
+      attributes[:expires_at] ||= new_expiry
+      attributes[:properties] = (coll.properties.dup rescue {})
+      attributes[:properties]["migrated_from"] ||= coll.uuid
+      coll_copy = Collection.create!(attributes)
+      Log.log_create(coll_copy)
+      coll.update_attributes(portable_data_hash: stripped_pdh)
+      Log.log_update(coll, start_log)
+    end
+  end
+
+  def down
+    Collection.reset_column_information
+    Log.reset_column_information
+    each_bad_collection do |coll, stripped_pdh|
+      if ((src_uuid = coll.properties["migrated_from"]) and
+          (src_coll = Collection.where(uuid: src_uuid).first) and
+          (src_coll.portable_data_hash == stripped_pdh))
+        start_log = Log.log_for(src_coll)
+        src_coll.portable_data_hash = coll.portable_data_hash
+        src_coll.save!
+        Log.log_update(src_coll, start_log)
+        coll.destroy or raise Exception.new("failed to destroy old collection")
+        Log.log_destroy(coll)
+      end
+    end
+  end
+end
index afc03510d58e030f66f5026ec1ecf056a16b9313..007d05ebb6fc74852f9c5297f65de2ae73a3a94b 100644 (file)
@@ -2360,4 +2360,6 @@ INSERT INTO schema_migrations (version) VALUES ('20150206210804');
 
 INSERT INTO schema_migrations (version) VALUES ('20150206230342');
 
-INSERT INTO schema_migrations (version) VALUES ('20150216193428');
\ No newline at end of file
+INSERT INTO schema_migrations (version) VALUES ('20150216193428');
+
+INSERT INTO schema_migrations (version) VALUES ('20150303210106');
\ No newline at end of file
index 220122533bea050ef23cb70cef5ba9f5454ef89b..d8c4c44a8149e9a0a8175ffd0b9ded77d40fcff1 100644 (file)
@@ -434,7 +434,7 @@ replication_desired_2_confirmed_2:
   created_at: 2015-02-07 00:19:28.596506247 Z
   modified_by_user_uuid: zzzzz-tpzed-xurymjxw79nv3jz
   modified_at: 2015-02-07 00:19:28.596338465 Z
-  portable_data_hash: fa7aeb5140e2848d39b416daeef4ffc5+45
+  portable_data_hash: ec53808e4c23e6aeebea24d998ae5346+88
   replication_desired: 2
   replication_confirmed_at: 2015-02-07 00:24:52.983381227 Z
   replication_confirmed: 2