]> source.dussan.org Git - redmine.git/commitdiff
Displays thumbnails of attached images of the issue view (#1006).
authorJean-Philippe Lang <jp_lang@yahoo.fr>
Sat, 7 Jul 2012 13:48:07 +0000 (13:48 +0000)
committerJean-Philippe Lang <jp_lang@yahoo.fr>
Sat, 7 Jul 2012 13:48:07 +0000 (13:48 +0000)
This behaviour can be turned on/off in Settings -> Display (off by default). Thumbnail size can be configured there too.

git-svn-id: svn+ssh://rubyforge.org/var/svn/redmine/trunk@9933 e93f8b46-1217-0410-a6f0-8f06a7374b81

20 files changed:
app/controllers/attachments_controller.rb
app/helpers/application_helper.rb
app/helpers/attachments_helper.rb
app/models/attachment.rb
app/views/attachments/_links.html.erb
app/views/issues/show.html.erb
app/views/settings/_display.html.erb
config/configuration.yml.example
config/locales/en.yml
config/locales/fr.yml
config/routes.rb
config/settings.yml
lib/redmine/thumbnail.rb [new file with mode: 0644]
lib/redmine/utils.rb
public/stylesheets/application.css
test/functional/attachments_controller_test.rb
test/functional/issues_controller_test.rb
test/integration/routing/attachments_test.rb
test/test_helper.rb
test/unit/attachment_test.rb

index a52024d14ae10aea55380734b94e2d2f672ba850..4ef929896d12e80b1410f6fdd2d99a6492831596 100644 (file)
@@ -17,7 +17,7 @@
 
 class AttachmentsController < ApplicationController
   before_filter :find_project, :except => :upload
-  before_filter :file_readable, :read_authorize, :only => [:show, :download]
+  before_filter :file_readable, :read_authorize, :only => [:show, :download, :thumbnail]
   before_filter :delete_authorize, :only => :destroy
   before_filter :authorize_global, :only => :upload
 
@@ -59,6 +59,18 @@ class AttachmentsController < ApplicationController
 
   end
 
+  def thumbnail
+    if @attachment.thumbnailable? && Setting.thumbnails_enabled? && thumbnail = @attachment.thumbnail
+      send_file thumbnail,
+        :filename => filename_for_content_disposition(@attachment.filename),
+        :type => detect_content_type(@attachment),
+        :disposition => 'inline'
+    else
+      # No thumbnail for the attachment or thumbnail could not be created
+      render :nothing => true, :status => 404
+    end
+  end
+
   def upload
     # Make sure that API users get used to set this content type
     # as it won't trigger Rails' automatic parsing of the request body for parameters
index eaf89da8a612bf73287036cbb15156f51c505064..9963408fb7027389b0469312ae7a1fb1ce9f6b03 100644 (file)
@@ -153,6 +153,12 @@ module ApplicationHelper
     end
   end
 
+  def thumbnail_tag(attachment)
+    link_to image_tag(url_for(:controller => 'attachments', :action => 'thumbnail', :id => attachment)),
+      {:controller => 'attachments', :action => 'show', :id => attachment, :filename => attachment.filename},
+      :title => attachment.filename
+  end
+
   def toggle_link(name, id, options={})
     onclick = "Element.toggle('#{id}'); "
     onclick << (options[:focus] ? "Form.Element.focus('#{options[:focus]}'); " : "this.blur(); ")
index 73cbaf01d1c008c348a944874bceb49ded80ca24..7b7bdf5da12664bdbbd7a19595303aeb881265e0 100644 (file)
@@ -21,12 +21,14 @@ module AttachmentsHelper
   # Displays view/delete links to the attachments of the given object
   # Options:
   #   :author -- author names are not displayed if set to false
+  #   :thumbails -- display thumbnails if enabled in settings
   def link_to_attachments(container, options = {})
-    options.assert_valid_keys(:author)
+    options.assert_valid_keys(:author, :thumbnails)
 
     if container.attachments.any?
       options = {:deletable => container.attachments_deletable?, :author => true}.merge(options)
-      render :partial => 'attachments/links', :locals => {:attachments => container.attachments, :options => options}
+      render :partial => 'attachments/links',
+        :locals => {:attachments => container.attachments, :options => options, :thumbnails => (options[:thumbnails] && Setting.thumbnails_enabled?)}
     end
   end
 
index b61db87e54409263719783bbcac11e2f6e6cd893..1fd0a5b82e640e8759c150c6ba0946e184246619 100644 (file)
@@ -46,6 +46,9 @@ class Attachment < ActiveRecord::Base
   cattr_accessor :storage_path
   @@storage_path = Redmine::Configuration['attachments_storage_path'] || File.join(Rails.root, "files")
 
+  cattr_accessor :thumbnails_storage_path
+  @@thumbnails_storage_path = File.join(Rails.root, "tmp", "thumbnails")
+
   before_save :files_to_final_location
   after_destroy :delete_from_disk
 
@@ -150,7 +153,35 @@ class Attachment < ActiveRecord::Base
   end
 
   def image?
-    self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i
+    !!(self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i)
+  end
+
+  def thumbnailable?
+    image?
+  end
+
+  # Returns the full path the attachment thumbnail, or nil
+  # if the thumbnail cannot be generated.
+  def thumbnail
+    if thumbnailable? && readable?
+      size = Setting.thumbnails_size.to_i
+      size = 100 unless size > 0
+      target = File.join(self.class.thumbnails_storage_path, "#{id}_#{digest}_#{size}.thumb")
+
+      begin
+        Redmine::Thumbnail.generate(self.diskfile, target, size)
+      rescue => e
+        logger.error "An error occured while generating thumbnail for #{disk_filename} to #{target}\nException was: #{e.message}" if logger
+        return nil
+      end
+    end
+  end
+
+  # Deletes all thumbnails
+  def self.clear_thumbnails
+    Dir.glob(File.join(thumbnails_storage_path, "*.thumb")).each do |file|
+      File.delete file
+    end
   end
 
   def is_text?
index 779702fb7ff255cc0150f008ea36aa6ff3e6a0e5..f77cfb1ebc87e64bffae950ee4eef5ebbcbcb59f 100644 (file)
   <% end %>
   </p>
 <% end %>
+<% if defined?(thumbnails) && thumbnails %>
+  <% images = attachments.select(&:thumbnailable?) %>
+  <% if images.any? %>
+  <div class="thumbnails">
+    <% images.each do |attachment| %>
+      <div><%= thumbnail_tag(attachment) %></div>
+    <% end %>
+  </div>
+  <% end %>
+<% end %>
 </div>
index 4f53592b0957b6dfd5f3baaaf7ca9c13e9aeccfe..5a60b032680f0f2496d8477f3106cda64402c3b6 100644 (file)
@@ -81,7 +81,7 @@ end %>
   <%= textilizable @issue, :description, :attachments => @issue.attachments %>
   </div>
 <% end %>
-<%= link_to_attachments @issue %>
+<%= link_to_attachments @issue, :thumbnails => true %>
 <% end -%>
 
 <%= call_hook(:view_issues_show_description_bottom, :issue => @issue) %>
index 2eb0e2560d690e3992aeb45459f088f5ebd9be8d..1ae5a351a6080239e6d415bfdc4ce7a1ec455826 100644 (file)
 <p><%= setting_check_box :gravatar_enabled %></p>
 
 <p><%= setting_select :gravatar_default, [["Wavatars", 'wavatar'], ["Identicons", 'identicon'], ["Monster ids", 'monsterid'], ["Retro", 'retro'], ["Mystery man", 'mm']], :blank => :label_none %></p>
+
+<p><%= setting_check_box :thumbnails_enabled %></p>
+
+<p><%= setting_text_field :thumbnails_size, :size => 6 %></p>
 </div>
 
 <%= submit_tag l(:button_save) %>
index 2224cd130c1cd250dbb4db42d83600c150a00533..35f1fa4b4c2f6035dcc06daa2a8e3447d0fd589a 100644 (file)
@@ -163,6 +163,10 @@ default:
   # same secret token on each machine.
   #secret_token: 'change it to a long random string'
 
+  # Absolute path (e.g. /usr/bin/convert, c:/im/convert.exe) to
+  # the ImageMagick's `convert` binary. Used to generate attachment thumbnails.
+  #imagemagick_convert_command:
+
 # specific configuration options for production environment
 # that overrides the default ones
 production:
index 4609cfba3f6d673e1c1d6e251c0865c6a4d8fdfc..ef4466c524070adb6771bf2c7b5c7893dc68f620 100644 (file)
@@ -394,6 +394,8 @@ en:
   setting_unsubscribe: Allow users to delete their own account
   setting_session_lifetime: Session maximum lifetime
   setting_session_timeout: Session inactivity timeout
+  setting_thumbnails_enabled: Display attachment thumbnails
+  setting_thumbnails_size: Thumbnails size (in pixels)
 
   permission_add_project: Create project
   permission_add_subprojects: Create subprojects
index ca06fac06b1ffa0ae9eb975d386b95551292ec97..7a68b72c046ccdd2e8a75bb15a43411373c659f0 100644 (file)
@@ -390,6 +390,8 @@ fr:
   setting_unsubscribe: Permettre aux utilisateurs de supprimer leur propre compte
   setting_session_lifetime: Durée de vie maximale des sessions
   setting_session_timeout: Durée maximale d'inactivité
+  setting_thumbnails_enabled: Afficher les vignettes des images
+  setting_thumbnails_size: Taille des vignettes (en pixels)
 
   permission_add_project: Créer un projet
   permission_add_subprojects: Créer des sous-projets
index 1ecaba8af71659267fe6438e60f9a0b859289f8e..b4c792b9939ac181d6c1f1fd822ef4635fcffcc1 100644 (file)
@@ -264,6 +264,7 @@ RedmineApp::Application.routes.draw do
   match 'attachments/:id/:filename', :controller => 'attachments', :action => 'show', :id => /\d+/, :filename => /.*/, :via => :get
   match 'attachments/download/:id/:filename', :controller => 'attachments', :action => 'download', :id => /\d+/, :filename => /.*/, :via => :get
   match 'attachments/download/:id', :controller => 'attachments', :action => 'download', :id => /\d+/, :via => :get
+  match 'attachments/thumbnail/:id', :controller => 'attachments', :action => 'thumbnail', :id => /\d+/, :via => :get
   resources :attachments, :only => [:show, :destroy]
 
   resources :groups do
index edb3baa24085a8453b1178fa761c163d3cb4a990..01906609fb8933f0bf2a6bd1efef14d517dd35b5 100644 (file)
@@ -212,3 +212,8 @@ default_notification_option:
   default: 'only_my_events'
 emails_header:
   default: ''
+thumbnails_enabled:
+  default: 0
+thumbnails_size:
+  format: int
+  default: 100
diff --git a/lib/redmine/thumbnail.rb b/lib/redmine/thumbnail.rb
new file mode 100644 (file)
index 0000000..2fa33a1
--- /dev/null
@@ -0,0 +1,46 @@
+# Redmine - project management software
+# Copyright (C) 2006-2012  Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+require 'fileutils'
+
+module Redmine
+  module Thumbnail
+    extend Redmine::Utils::Shell
+
+    # Generates a thumbnail for the source image to target
+    def self.generate(source, target, size)
+      unless File.exists?(target)
+        directory = File.dirname(target)
+        unless File.exists?(directory)
+          FileUtils.mkdir_p directory
+        end
+        bin = Redmine::Configuration['imagemagick_convert_command'] || 'convert'
+        size_option = "#{size}x#{size}>"
+        cmd = "#{shell_quote bin} #{shell_quote source} -thumbnail #{shell_quote size_option} #{shell_quote target}"
+        unless system(cmd)
+          logger.error("Creating thumbnail failed (#{$?}):\nCommand: #{cmd}")
+          return nil
+        end
+      end
+      target
+    end
+
+    def self.logger
+      Rails.logger
+    end
+  end
+end
index 3e44b63596b4c29733e4b34cc17ad4c2693f8398..cfdb4d15d76a9b270692860b20b67310932c04a3 100644 (file)
@@ -41,5 +41,15 @@ module Redmine
         SecureRandom.hex(n)
       end
     end
+
+    module Shell
+      def shell_quote(str)
+        if Redmine::Platform.mswin?
+          '"' + str.gsub(/"/, '\\"') + '"'
+        else
+          "'" + str.gsub(/'/, "'\"'\"'") + "'"
+        end
+      end
+    end
   end
 end
index d7aab6eddb06baa42a688f2ca6c1422b864de61f..57f2f24b616ddcc4fabb1066c317aed283f11eae 100644 (file)
@@ -516,6 +516,10 @@ div.attachments p { margin:4px 0 2px 0; }
 div.attachments img { vertical-align: middle; }
 div.attachments span.author { font-size: 0.9em; color: #888; }
 
+div.thumbnails {margin-top:0.6em;}
+div.thumbnails div {background:#fff;border:2px solid #ddd;display:inline-block;margin-right:2px;}
+div.thumbnails img {margin: 3px;}
+
 p.other-formats { text-align: right; font-size:0.9em; color: #666; }
 .other-formats span + span:before { content: "| "; }
 
index 487ec58092f59411cad540a8a7097a011bb180e4..949ed39799c4a558ecc907527e9ae683bdd6aa40 100644 (file)
@@ -252,12 +252,58 @@ class AttachmentsControllerTest < ActionController::TestCase
     set_tmp_attachments_directory
   end
 
-  def test_anonymous_on_private_private
+  def test_download_should_be_denied_without_permission
     get :download, :id => 7
     assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Fattachments%2Fdownload%2F7'
     set_tmp_attachments_directory
   end
 
+  if convert_installed?
+    def test_thumbnail
+      Attachment.clear_thumbnails
+      @request.session[:user_id] = 2
+      with_settings :thumbnails_enabled => '1' do
+        get :thumbnail, :id => 16
+        assert_response :success
+        assert_equal 'image/png', response.content_type
+      end
+    end
+
+    def test_thumbnail_should_return_404_for_non_image_attachment
+      @request.session[:user_id] = 2
+      with_settings :thumbnails_enabled => '1' do
+        get :thumbnail, :id => 15
+        assert_response 404
+      end
+    end
+
+    def test_thumbnail_should_return_404_if_thumbnails_not_enabled
+      @request.session[:user_id] = 2
+      with_settings :thumbnails_enabled => '0' do
+        get :thumbnail, :id => 16
+        assert_response 404
+      end
+    end
+
+    def test_thumbnail_should_return_404_if_thumbnail_generation_failed
+      Attachment.any_instance.stubs(:thumbnail).returns(nil)
+      @request.session[:user_id] = 2
+      with_settings :thumbnails_enabled => '1' do
+        get :thumbnail, :id => 16
+        assert_response 404
+      end
+    end
+
+    def test_thumbnail_should_be_denied_without_permission
+      with_settings :thumbnails_enabled => '1' do
+        get :thumbnail, :id => 16
+        assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Fattachments%2Fthumbnail%2F16'
+      end
+    end
+  else
+    puts '(ImageMagick convert not available)'
+  end
+
   def test_destroy_issue_attachment
     set_tmp_attachments_directory
     issue = Issue.find(3)
index d5c2565a162ddb627da181cc8543289bfa67ee1d..7e9e3a39842aa475ab959c12d629edf90f648bfa 100644 (file)
@@ -1155,7 +1155,33 @@ class IssuesControllerTest < ActionController::TestCase
       end
     end
   end
-  
+
+  def test_show_with_thumbnails_enabled_should_display_thumbnails
+    @request.session[:user_id] = 2
+
+    with_settings :thumbnails_enabled => '1' do
+      get :show, :id => 14
+      assert_response :success
+    end
+
+    assert_select 'div.thumbnails' do
+      assert_select 'a[href=/attachments/16/testfile.png]' do
+        assert_select 'img[src=/attachments/thumbnail/16]'
+      end
+    end
+  end
+
+  def test_show_with_thumbnails_disabled_should_not_display_thumbnails
+    @request.session[:user_id] = 2
+
+    with_settings :thumbnails_enabled => '0' do
+      get :show, :id => 14
+      assert_response :success
+    end
+
+    assert_select 'div.thumbnails', 0
+  end
+
   def test_show_with_multi_custom_field
     field = CustomField.find(1)
     field.update_attribute :multiple, true
index 84ccbbb67e2d482cda210ae50bbff69b44a6a602..ba4bb2c3606d9b2545da699f818122cc4b53bb05 100644 (file)
@@ -45,6 +45,10 @@ class RoutingAttachmentsTest < ActionController::IntegrationTest
            { :controller => 'attachments', :action => 'download', :id => '1',
              :filename => 'filename.ext' }
          )
+    assert_routing(
+           { :method => 'get', :path => "/attachments/thumbnail/1" },
+           { :controller => 'attachments', :action => 'thumbnail', :id => '1' }
+         )
     assert_routing(
            { :method => 'delete', :path => "/attachments/1" },
            { :controller => 'attachments', :action => 'destroy', :id => '1' }
index 4485f75c97920457a59779c7afbfc49d443c0e01..6f5ab98a43838b510f19dd973b852d1901b93264 100644 (file)
@@ -128,6 +128,13 @@ class ActiveSupport::TestCase
     return nil
   end
 
+  def self.convert_installed?
+    bin = Redmine::Configuration['imagemagick_convert_command'] || 'convert'
+    system("#{bin} -version")
+  rescue
+    false
+  end
+
   # Returns the path to the test +vendor+ repository
   def self.repository_path(vendor)
     Rails.root.join("tmp/test/#{vendor.downcase}_repository").to_s
index 9d78077fd91f919efe120ec9dd98dad2706c60ba..cd301dc9384f19e2459a0004dc23b816a5c96da1 100644 (file)
@@ -214,4 +214,28 @@ class AttachmentTest < ActiveSupport::TestCase
 
     set_tmp_attachments_directory
   end
+
+  def test_thumbnailable_should_be_true_for_images
+    assert_equal true, Attachment.new(:filename => 'test.jpg').thumbnailable?
+  end
+
+  def test_thumbnailable_should_be_true_for_non_images
+    assert_equal false, Attachment.new(:filename => 'test.txt').thumbnailable?
+  end
+
+  if convert_installed?
+    def test_thumbnail_should_generate_the_thumbnail
+      set_fixtures_attachments_directory
+      attachment = Attachment.find(16)
+      Attachment.clear_thumbnails
+
+      assert_difference "Dir.glob(File.join(Attachment.thumbnails_storage_path, '*.thumb')).size" do
+        thumbnail = attachment.thumbnail
+        assert_equal "16_8e0294de2441577c529f170b6fb8f638_100.thumb", File.basename(thumbnail)
+        assert File.exists?(thumbnail)
+      end
+    end
+  else
+    puts '(ImageMagick convert not available)'
+  end
 end