]> source.dussan.org Git - redmine.git/commitdiff
Edit attachments after upload (#1326).
authorJean-Philippe Lang <jp_lang@yahoo.fr>
Sat, 29 Nov 2014 13:41:53 +0000 (13:41 +0000)
committerJean-Philippe Lang <jp_lang@yahoo.fr>
Sat, 29 Nov 2014 13:41:53 +0000 (13:41 +0000)
git-svn-id: http://svn.redmine.org/redmine/trunk@13665 e93f8b46-1217-0410-a6f0-8f06a7374b81

13 files changed:
app/controllers/attachments_controller.rb
app/helpers/attachments_helper.rb
app/models/attachment.rb
app/models/news.rb
app/models/project.rb
app/models/version.rb
app/views/attachments/_links.html.erb
app/views/attachments/edit.html.erb [new file with mode: 0644]
config/routes.rb
lib/plugins/acts_as_attachable/lib/acts_as_attachable.rb
test/functional/attachments_controller_test.rb
test/integration/routing/attachments_test.rb
test/unit/attachment_test.rb

index b7f856a1a25233ad48c272ac604a356cf18a3cac..924e9a18633d82edf64c072f9f5b0bd24d8be01c 100644 (file)
@@ -16,7 +16,8 @@
 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 
 class AttachmentsController < ApplicationController
-  before_filter :find_project, :except => :upload
+  before_filter :find_attachment, :only => [:show, :download, :thumbnail, :destroy]
+  before_filter :find_editable_attachments, :only => [:edit, :update]
   before_filter :file_readable, :read_authorize, :only => [:show, :download, :thumbnail]
   before_filter :delete_authorize, :only => :destroy
   before_filter :authorize_global, :only => :upload
@@ -99,6 +100,19 @@ class AttachmentsController < ApplicationController
     end
   end
 
+  def edit
+  end
+
+  def update
+    if params[:attachments].is_a?(Hash)
+      if Attachment.update_attachments(@attachments, params[:attachments])
+        redirect_back_or_default home_path
+        return
+      end
+    end
+    render :action => 'edit'
+  end
+
   def destroy
     if @attachment.container.respond_to?(:init_journal)
       @attachment.container.init_journal(User.current)
@@ -116,8 +130,9 @@ class AttachmentsController < ApplicationController
     end
   end
 
-private
-  def find_project
+  private
+
+  def find_attachment
     @attachment = Attachment.find(params[:id])
     # Show 404 if the filename in the url is wrong
     raise ActiveRecord::RecordNotFound if params[:filename] && params[:filename] != @attachment.filename
@@ -126,6 +141,27 @@ private
     render_404
   end
 
+  def find_editable_attachments
+    klass = params[:object_type].to_s.singularize.classify.constantize rescue nil
+    unless klass && klass.reflect_on_association(:attachments)
+      render_404
+      return
+    end
+
+    @container = klass.find(params[:object_id])
+    if @container.respond_to?(:visible?) && !@container.visible?
+      render_403
+      return
+    end
+    @attachments = @container.attachments.select(&:editable?)
+    if @container.respond_to?(:project)
+      @project = @container.project
+    end
+    render_404 if @attachments.empty?
+  rescue ActiveRecord::RecordNotFound
+    render_404
+  end
+
   # Checks that the file exists and is readable
   def file_readable
     if @attachment.readable?
index d86fe0dbe56790458b20be96e95586084d682086..387ed99d768da73b84fc182cd6c4285b0a5bbf8c 100644 (file)
 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 
 module AttachmentsHelper
+
+  def container_attachments_edit_path(container)
+    object_attachments_edit_path container.class.name.underscore.pluralize, container.id
+  end
+
+  def container_attachments_path(container)
+    object_attachments_path container.class.name.underscore.pluralize, container.id
+  end
+
   # Displays view/delete links to the attachments of the given object
   # Options:
   #   :author -- author names are not displayed if set to false
@@ -28,7 +37,12 @@ module AttachmentsHelper
     if container.attachments.any?
       options = {:deletable => container.attachments_deletable?, :author => true}.merge(options)
       render :partial => 'attachments/links',
-        :locals => {:attachments => container.attachments, :options => options, :thumbnails => (options[:thumbnails] && Setting.thumbnails_enabled?)}
+        :locals => {
+          :container => container,
+          :attachments => container.attachments,
+          :options => options,
+          :thumbnails => (options[:thumbnails] && Setting.thumbnails_enabled?)
+        }
     end
   end
 
index da82088f2e05c9277cdd533dcdfbd2624deb92b1..42690912ac8567c4567018ada4e053e434ab34b8 100644 (file)
@@ -166,6 +166,14 @@ class Attachment < ActiveRecord::Base
     end
   end
 
+  def editable?(user=User.current)
+    if container_id
+      container && container.attachments_editable?(user)
+    else
+      author == user
+    end
+  end
+
   def deletable?(user=User.current)
     if container_id
       container && container.attachments_deletable?(user)
@@ -254,6 +262,35 @@ class Attachment < ActiveRecord::Base
     result
   end
 
+  # Updates the filename and description of a set of attachments
+  # with the given hash of attributes. Returns true if all
+  # attachments were updated.
+  #
+  # Example:
+  #   Attachment.update_attachments(attachments, {
+  #     4 => {:filename => 'foo'},
+  #     7 => {:filename => 'bar', :description => 'file description'}
+  #   })
+  #
+  def self.update_attachments(attachments, params)
+    params = params.transform_keys {|key| key.to_i}
+
+    saved = true
+    transaction do
+      attachments.each do |attachment|
+        if p = params[attachment.id]
+          attachment.filename = p[:filename] if p.key?(:filename)
+          attachment.description = p[:description] if p.key?(:description)
+          saved &&= attachment.save
+        end
+      end
+      unless saved
+        raise ActiveRecord::Rollback
+      end
+    end
+    saved
+  end
+
   def self.latest_attach(attachments, filename)
     attachments.sort_by(&:created_on).reverse.detect do |att|
       att.filename.downcase == filename.downcase
index 19a55bda4bd927c77f0910446d5856ed17c9a8a8..6ade4f4f1438cc9172071565605bf8563f2fb2c5 100644 (file)
@@ -26,7 +26,8 @@ class News < ActiveRecord::Base
   validates_length_of :summary, :maximum => 255
   attr_protected :id
 
-  acts_as_attachable :delete_permission => :manage_news
+  acts_as_attachable :edit_permission => :manage_news,
+                     :delete_permission => :manage_news
   acts_as_searchable :columns => ['title', 'summary', "#{table_name}.description"],
                      :scope => preload(:project)
   acts_as_event :url => Proc.new {|o| {:controller => 'news', :action => 'show', :id => o.id}}
index 1a3534217b55365a23e4f9e5634718d5be99dd8f..c21f6ef7e6cfd07a232fac8ebf788942134541b4 100644 (file)
@@ -60,6 +60,7 @@ class Project < ActiveRecord::Base
 
   acts_as_nested_set :dependent => :destroy
   acts_as_attachable :view_permission => :view_files,
+                     :edit_permission => :manage_files,
                      :delete_permission => :manage_files
 
   acts_as_customizable
index c8ac6ca0ef9b92588e5631b2e6067511618c4678..e97512c0c3b7f8a12215f68334ba9ffb799f9d14 100644 (file)
@@ -22,6 +22,7 @@ class Version < ActiveRecord::Base
   has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id', :dependent => :nullify
   acts_as_customizable
   acts_as_attachable :view_permission => :view_files,
+                     :edit_permission => :manage_files,
                      :delete_permission => :manage_files
 
   VERSION_STATUSES = %w(open locked closed)
index d2ab22ced5c2205bec103fe5e55a77d995ad7b87..a8dbd35845616c281039b0b6fc019ca2091174a5 100644 (file)
@@ -1,4 +1,7 @@
 <div class="attachments">
+<div class="contextual">
+  <%= link_to image_tag('edit.png'), container_attachments_edit_path(container) if attachments.any?(&:editable?) %>
+</div>
 <% for attachment in attachments %>
 <p><%= link_to_attachment attachment, :class => 'icon icon-attachment', :download => true -%>
   <% if attachment.is_text? %>
diff --git a/app/views/attachments/edit.html.erb b/app/views/attachments/edit.html.erb
new file mode 100644 (file)
index 0000000..065488c
--- /dev/null
@@ -0,0 +1,30 @@
+<h2><%= l(:label_attachment_plural) %></h2> 
+
+<%= error_messages_for *@attachments %>
+
+<%= form_tag(container_attachments_path(@container), :method => 'patch') do %>
+  <%= back_url_hidden_field_tag %>
+  <div class="box attachments">
+  <table>
+  <% @attachments.each do |attachment| %>
+    <tr>
+      <td colspan="2">
+        <span class="icon icon-attachment"><%= attachment.filename_was %></span>
+        <span class="size">(<%= number_to_human_size attachment.filesize %>)</span>
+        <span class="author"><%= h(attachment.author) %>, <%= format_time(attachment.created_on) %></span>
+      </td>
+    </tr>
+    <tr id="attachment-<%= attachment.id %>">
+      <td><%= text_field_tag "attachments[#{attachment.id}][filename]", attachment.filename, :size => 40 %></td>
+      <td>
+        <%= text_field_tag "attachments[#{attachment.id}][description]", attachment.description, :size => 80, :placeholder => l(:label_optional_description) %>
+      </td>
+    </tr>
+  <% end %>
+  </table>
+  </div>
+  <p>
+    <%= submit_tag l(:button_save) %>
+    <%= link_to l(:button_cancel), back_url if back_url.present? %>
+  </p>
+<% end %>
index 1b57a6ecb5a0c3e72290076e703e24e8b485f4d8..6a3bc023caf3844a198fb479333e2a91e9fff5e8 100644 (file)
@@ -266,6 +266,8 @@ Rails.application.routes.draw do
   get 'attachments/download/:id', :to => 'attachments#download', :id => /\d+/
   get 'attachments/thumbnail/:id(/:size)', :to => 'attachments#thumbnail', :id => /\d+/, :size => /\d+/, :as => 'thumbnail'
   resources :attachments, :only => [:show, :destroy]
+  get 'attachments/:object_type/:object_id/edit', :to => 'attachments#edit', :as => :object_attachments_edit
+  patch 'attachments/:object_type/:object_id', :to => 'attachments#update', :as => :object_attachments
 
   resources :groups do
     resources :memberships, :controller => 'principal_memberships'
index e65c68c6f1b447fdf4fa8be566d2f02ea3849226..e86b83eb36e44d4bd92db2f9de7e1a6b92bc0428 100644 (file)
@@ -27,6 +27,7 @@ module Redmine
           cattr_accessor :attachable_options
           self.attachable_options = {}
           attachable_options[:view_permission] = options.delete(:view_permission) || "view_#{self.name.pluralize.underscore}".to_sym
+          attachable_options[:edit_permission] = options.delete(:edit_permission) || "edit_#{self.name.pluralize.underscore}".to_sym
           attachable_options[:delete_permission] = options.delete(:delete_permission) || "edit_#{self.name.pluralize.underscore}".to_sym
 
           has_many :attachments, lambda {order("#{Attachment.table_name}.created_on ASC, #{Attachment.table_name}.id ASC")},
@@ -46,6 +47,11 @@ module Redmine
             user.allowed_to?(self.class.attachable_options[:view_permission], self.project)
         end
 
+        def attachments_editable?(user=User.current)
+          (respond_to?(:visible?) ? visible?(user) : true) &&
+            user.allowed_to?(self.class.attachable_options[:edit_permission], self.project)
+        end
+
         def attachments_deletable?(user=User.current)
           (respond_to?(:visible?) ? visible?(user) : true) &&
             user.allowed_to?(self.class.attachable_options[:delete_permission], self.project)
index 19e4d0c09e1c594f60a6e359b8fec0bb9c1a6797..c1b27c64e764104e4904d5520a9064136225f144 100644 (file)
@@ -327,6 +327,64 @@ class AttachmentsControllerTest < ActionController::TestCase
     puts '(ImageMagick convert not available)'
   end
 
+  def test_edit
+    @request.session[:user_id] = 2
+    get :edit, :object_type => 'issues', :object_id => '3'
+    assert_response :success
+    assert_template 'edit'
+
+    container = Issue.find(3)
+    assert_equal container, assigns(:container)
+    assert_equal container.attachments.size, assigns(:attachments).size
+
+    assert_select 'form[action=?]', '/attachments/issues/3' do
+      assert_select 'tr#attachment-4' do
+        assert_select 'input[name=?][value=?]', 'attachments[4][filename]', 'source.rb'
+        assert_select 'input[name=?][value=?]', 'attachments[4][description]', 'This is a Ruby source file'
+      end
+    end
+  end
+
+  def test_edit_invalid_container_class_should_return_404
+    get :edit, :object_type => 'nuggets', :object_id => '3'
+    assert_response 404
+  end
+
+  def test_edit_for_object_that_is_not_visible_should_return_403
+    get :edit, :object_type => 'issues', :object_id => '4'
+    assert_response 403
+  end
+
+  def test_update
+    @request.session[:user_id] = 2
+    patch :update, :object_type => 'issues', :object_id => '3', :attachments => {
+        '1' => {:filename => 'newname.text', :description => ''},
+        '4' => {:filename => 'newname.rb', :description => 'Renamed'},
+      }
+
+    assert_response 302
+    attachment = Attachment.find(4)
+    assert_equal 'newname.rb', attachment.filename
+    assert_equal 'Renamed', attachment.description
+  end
+
+  def test_update_with_failure
+    @request.session[:user_id] = 2
+    patch :update, :object_type => 'issues', :object_id => '3', :attachments => {
+        '1' => {:filename => '', :description => ''},
+        '4' => {:filename => 'newname.rb', :description => 'Renamed'},
+      }
+
+    assert_response :success
+    assert_template 'edit'
+    assert_select_error /file #{ESCAPED_CANT} be blank/i
+
+    # The other attachment should not be updated
+    attachment = Attachment.find(4)
+    assert_equal 'source.rb', attachment.filename
+    assert_equal 'This is a Ruby source file', attachment.description
+  end
+
   def test_destroy_issue_attachment
     set_tmp_attachments_directory
     issue = Issue.find(3)
index d6d278001bde8dfee9ce42b8ca0dc3b2071481a6..9727f9ee3fd3467e37213ff92f12e59d79b1ca09 100644 (file)
@@ -29,5 +29,8 @@ class RoutingAttachmentsTest < Redmine::RoutingTest
     should_route 'GET /attachments/thumbnail/1/200' => 'attachments#thumbnail', :id => '1', :size => '200'
 
     should_route 'DELETE /attachments/1' => 'attachments#destroy', :id => '1'
+
+    should_route 'GET /attachments/issues/1/edit' => 'attachments#edit', :object_type => 'issues', :object_id => '1'
+    should_route 'PATCH /attachments/issues/1' => 'attachments#update', :object_type => 'issues', :object_id => '1'
   end
 end
index 6a077fcc2b2fe7572d959f3763f6d50bb595385d..29c433f7b5c17bd7de906cd5ad40997bc4eacaf6 100644 (file)
@@ -250,6 +250,45 @@ class AttachmentTest < ActiveSupport::TestCase
     assert_equal 'text/plain', attachment.content_type
   end
 
+  def test_update_attachments
+    attachments = Attachment.where(:id => [2, 3]).to_a
+
+    assert Attachment.update_attachments(attachments, {
+      '2' => {:filename => 'newname.txt', :description => 'New description'},
+      3 => {:filename => 'othername.txt'}
+    })
+
+    attachment = Attachment.find(2)
+    assert_equal 'newname.txt', attachment.filename
+    assert_equal 'New description', attachment.description
+
+    attachment = Attachment.find(3)
+    assert_equal 'othername.txt', attachment.filename
+  end
+
+  def test_update_attachments_with_failure
+    attachments = Attachment.where(:id => [2, 3]).to_a
+
+    assert !Attachment.update_attachments(attachments, {
+      '2' => {:filename => '', :description => 'New description'},
+      3 => {:filename => 'othername.txt'}
+    })
+
+    attachment = Attachment.find(3)
+    assert_equal 'logo.gif', attachment.filename
+  end
+
+  def test_update_attachments_should_sanitize_filename
+    attachments = Attachment.where(:id => 2).to_a
+
+    assert Attachment.update_attachments(attachments, {
+      2 => {:filename => 'newname?.txt'},
+    })
+
+    attachment = Attachment.find(2)
+    assert_equal 'newname_.txt', attachment.filename
+  end
+
   def test_latest_attach
     set_fixtures_attachments_directory
     a1 = Attachment.find(16)