##// END OF EJS Templates
Option to specify allowed extensions for a file custom field (#6719)....
Jean-Philippe Lang -
r15539:f94711ea8c99
parent child
Show More
@@ -1,413 +1,428
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require "digest/md5"
18 require "digest/md5"
19 require "fileutils"
19 require "fileutils"
20
20
21 class Attachment < ActiveRecord::Base
21 class Attachment < ActiveRecord::Base
22 include Redmine::SafeAttributes
22 include Redmine::SafeAttributes
23 belongs_to :container, :polymorphic => true
23 belongs_to :container, :polymorphic => true
24 belongs_to :author, :class_name => "User"
24 belongs_to :author, :class_name => "User"
25
25
26 validates_presence_of :filename, :author
26 validates_presence_of :filename, :author
27 validates_length_of :filename, :maximum => 255
27 validates_length_of :filename, :maximum => 255
28 validates_length_of :disk_filename, :maximum => 255
28 validates_length_of :disk_filename, :maximum => 255
29 validates_length_of :description, :maximum => 255
29 validates_length_of :description, :maximum => 255
30 validate :validate_max_file_size, :validate_file_extension
30 validate :validate_max_file_size, :validate_file_extension
31 attr_protected :id
31 attr_protected :id
32
32
33 acts_as_event :title => :filename,
33 acts_as_event :title => :filename,
34 :url => Proc.new {|o| {:controller => 'attachments', :action => 'download', :id => o.id, :filename => o.filename}}
34 :url => Proc.new {|o| {:controller => 'attachments', :action => 'download', :id => o.id, :filename => o.filename}}
35
35
36 acts_as_activity_provider :type => 'files',
36 acts_as_activity_provider :type => 'files',
37 :permission => :view_files,
37 :permission => :view_files,
38 :author_key => :author_id,
38 :author_key => :author_id,
39 :scope => select("#{Attachment.table_name}.*").
39 :scope => select("#{Attachment.table_name}.*").
40 joins("LEFT JOIN #{Version.table_name} ON #{Attachment.table_name}.container_type='Version' AND #{Version.table_name}.id = #{Attachment.table_name}.container_id " +
40 joins("LEFT JOIN #{Version.table_name} ON #{Attachment.table_name}.container_type='Version' AND #{Version.table_name}.id = #{Attachment.table_name}.container_id " +
41 "LEFT JOIN #{Project.table_name} ON #{Version.table_name}.project_id = #{Project.table_name}.id OR ( #{Attachment.table_name}.container_type='Project' AND #{Attachment.table_name}.container_id = #{Project.table_name}.id )")
41 "LEFT JOIN #{Project.table_name} ON #{Version.table_name}.project_id = #{Project.table_name}.id OR ( #{Attachment.table_name}.container_type='Project' AND #{Attachment.table_name}.container_id = #{Project.table_name}.id )")
42
42
43 acts_as_activity_provider :type => 'documents',
43 acts_as_activity_provider :type => 'documents',
44 :permission => :view_documents,
44 :permission => :view_documents,
45 :author_key => :author_id,
45 :author_key => :author_id,
46 :scope => select("#{Attachment.table_name}.*").
46 :scope => select("#{Attachment.table_name}.*").
47 joins("LEFT JOIN #{Document.table_name} ON #{Attachment.table_name}.container_type='Document' AND #{Document.table_name}.id = #{Attachment.table_name}.container_id " +
47 joins("LEFT JOIN #{Document.table_name} ON #{Attachment.table_name}.container_type='Document' AND #{Document.table_name}.id = #{Attachment.table_name}.container_id " +
48 "LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id")
48 "LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id")
49
49
50 cattr_accessor :storage_path
50 cattr_accessor :storage_path
51 @@storage_path = Redmine::Configuration['attachments_storage_path'] || File.join(Rails.root, "files")
51 @@storage_path = Redmine::Configuration['attachments_storage_path'] || File.join(Rails.root, "files")
52
52
53 cattr_accessor :thumbnails_storage_path
53 cattr_accessor :thumbnails_storage_path
54 @@thumbnails_storage_path = File.join(Rails.root, "tmp", "thumbnails")
54 @@thumbnails_storage_path = File.join(Rails.root, "tmp", "thumbnails")
55
55
56 before_create :files_to_final_location
56 before_create :files_to_final_location
57 after_rollback :delete_from_disk, :on => :create
57 after_rollback :delete_from_disk, :on => :create
58 after_commit :delete_from_disk, :on => :destroy
58 after_commit :delete_from_disk, :on => :destroy
59
59
60 safe_attributes 'filename', 'content_type', 'description'
60 safe_attributes 'filename', 'content_type', 'description'
61
61
62 # Returns an unsaved copy of the attachment
62 # Returns an unsaved copy of the attachment
63 def copy(attributes=nil)
63 def copy(attributes=nil)
64 copy = self.class.new
64 copy = self.class.new
65 copy.attributes = self.attributes.dup.except("id", "downloads")
65 copy.attributes = self.attributes.dup.except("id", "downloads")
66 copy.attributes = attributes if attributes
66 copy.attributes = attributes if attributes
67 copy
67 copy
68 end
68 end
69
69
70 def validate_max_file_size
70 def validate_max_file_size
71 if @temp_file && self.filesize > Setting.attachment_max_size.to_i.kilobytes
71 if @temp_file && self.filesize > Setting.attachment_max_size.to_i.kilobytes
72 errors.add(:base, l(:error_attachment_too_big, :max_size => Setting.attachment_max_size.to_i.kilobytes))
72 errors.add(:base, l(:error_attachment_too_big, :max_size => Setting.attachment_max_size.to_i.kilobytes))
73 end
73 end
74 end
74 end
75
75
76 def validate_file_extension
76 def validate_file_extension
77 if @temp_file
77 if @temp_file
78 extension = File.extname(filename)
78 extension = File.extname(filename)
79 unless self.class.valid_extension?(extension)
79 unless self.class.valid_extension?(extension)
80 errors.add(:base, l(:error_attachment_extension_not_allowed, :extension => extension))
80 errors.add(:base, l(:error_attachment_extension_not_allowed, :extension => extension))
81 end
81 end
82 end
82 end
83 end
83 end
84
84
85 def file=(incoming_file)
85 def file=(incoming_file)
86 unless incoming_file.nil?
86 unless incoming_file.nil?
87 @temp_file = incoming_file
87 @temp_file = incoming_file
88 if @temp_file.size > 0
88 if @temp_file.size > 0
89 if @temp_file.respond_to?(:original_filename)
89 if @temp_file.respond_to?(:original_filename)
90 self.filename = @temp_file.original_filename
90 self.filename = @temp_file.original_filename
91 self.filename.force_encoding("UTF-8")
91 self.filename.force_encoding("UTF-8")
92 end
92 end
93 if @temp_file.respond_to?(:content_type)
93 if @temp_file.respond_to?(:content_type)
94 self.content_type = @temp_file.content_type.to_s.chomp
94 self.content_type = @temp_file.content_type.to_s.chomp
95 end
95 end
96 self.filesize = @temp_file.size
96 self.filesize = @temp_file.size
97 end
97 end
98 end
98 end
99 end
99 end
100
100
101 def file
101 def file
102 nil
102 nil
103 end
103 end
104
104
105 def filename=(arg)
105 def filename=(arg)
106 write_attribute :filename, sanitize_filename(arg.to_s)
106 write_attribute :filename, sanitize_filename(arg.to_s)
107 filename
107 filename
108 end
108 end
109
109
110 # Copies the temporary file to its final location
110 # Copies the temporary file to its final location
111 # and computes its MD5 hash
111 # and computes its MD5 hash
112 def files_to_final_location
112 def files_to_final_location
113 if @temp_file && (@temp_file.size > 0)
113 if @temp_file && (@temp_file.size > 0)
114 self.disk_directory = target_directory
114 self.disk_directory = target_directory
115 self.disk_filename = Attachment.disk_filename(filename, disk_directory)
115 self.disk_filename = Attachment.disk_filename(filename, disk_directory)
116 logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)") if logger
116 logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)") if logger
117 path = File.dirname(diskfile)
117 path = File.dirname(diskfile)
118 unless File.directory?(path)
118 unless File.directory?(path)
119 FileUtils.mkdir_p(path)
119 FileUtils.mkdir_p(path)
120 end
120 end
121 md5 = Digest::MD5.new
121 md5 = Digest::MD5.new
122 File.open(diskfile, "wb") do |f|
122 File.open(diskfile, "wb") do |f|
123 if @temp_file.respond_to?(:read)
123 if @temp_file.respond_to?(:read)
124 buffer = ""
124 buffer = ""
125 while (buffer = @temp_file.read(8192))
125 while (buffer = @temp_file.read(8192))
126 f.write(buffer)
126 f.write(buffer)
127 md5.update(buffer)
127 md5.update(buffer)
128 end
128 end
129 else
129 else
130 f.write(@temp_file)
130 f.write(@temp_file)
131 md5.update(@temp_file)
131 md5.update(@temp_file)
132 end
132 end
133 end
133 end
134 self.digest = md5.hexdigest
134 self.digest = md5.hexdigest
135 end
135 end
136 @temp_file = nil
136 @temp_file = nil
137
137
138 if content_type.blank? && filename.present?
138 if content_type.blank? && filename.present?
139 self.content_type = Redmine::MimeType.of(filename)
139 self.content_type = Redmine::MimeType.of(filename)
140 end
140 end
141 # Don't save the content type if it's longer than the authorized length
141 # Don't save the content type if it's longer than the authorized length
142 if self.content_type && self.content_type.length > 255
142 if self.content_type && self.content_type.length > 255
143 self.content_type = nil
143 self.content_type = nil
144 end
144 end
145 end
145 end
146
146
147 # Deletes the file from the file system if it's not referenced by other attachments
147 # Deletes the file from the file system if it's not referenced by other attachments
148 def delete_from_disk
148 def delete_from_disk
149 if Attachment.where("disk_filename = ? AND id <> ?", disk_filename, id).empty?
149 if Attachment.where("disk_filename = ? AND id <> ?", disk_filename, id).empty?
150 delete_from_disk!
150 delete_from_disk!
151 end
151 end
152 end
152 end
153
153
154 # Returns file's location on disk
154 # Returns file's location on disk
155 def diskfile
155 def diskfile
156 File.join(self.class.storage_path, disk_directory.to_s, disk_filename.to_s)
156 File.join(self.class.storage_path, disk_directory.to_s, disk_filename.to_s)
157 end
157 end
158
158
159 def title
159 def title
160 title = filename.to_s
160 title = filename.to_s
161 if description.present?
161 if description.present?
162 title << " (#{description})"
162 title << " (#{description})"
163 end
163 end
164 title
164 title
165 end
165 end
166
166
167 def increment_download
167 def increment_download
168 increment!(:downloads)
168 increment!(:downloads)
169 end
169 end
170
170
171 def project
171 def project
172 container.try(:project)
172 container.try(:project)
173 end
173 end
174
174
175 def visible?(user=User.current)
175 def visible?(user=User.current)
176 if container_id
176 if container_id
177 container && container.attachments_visible?(user)
177 container && container.attachments_visible?(user)
178 else
178 else
179 author == user
179 author == user
180 end
180 end
181 end
181 end
182
182
183 def editable?(user=User.current)
183 def editable?(user=User.current)
184 if container_id
184 if container_id
185 container && container.attachments_editable?(user)
185 container && container.attachments_editable?(user)
186 else
186 else
187 author == user
187 author == user
188 end
188 end
189 end
189 end
190
190
191 def deletable?(user=User.current)
191 def deletable?(user=User.current)
192 if container_id
192 if container_id
193 container && container.attachments_deletable?(user)
193 container && container.attachments_deletable?(user)
194 else
194 else
195 author == user
195 author == user
196 end
196 end
197 end
197 end
198
198
199 def image?
199 def image?
200 !!(self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i)
200 !!(self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i)
201 end
201 end
202
202
203 def thumbnailable?
203 def thumbnailable?
204 image?
204 image?
205 end
205 end
206
206
207 # Returns the full path the attachment thumbnail, or nil
207 # Returns the full path the attachment thumbnail, or nil
208 # if the thumbnail cannot be generated.
208 # if the thumbnail cannot be generated.
209 def thumbnail(options={})
209 def thumbnail(options={})
210 if thumbnailable? && readable?
210 if thumbnailable? && readable?
211 size = options[:size].to_i
211 size = options[:size].to_i
212 if size > 0
212 if size > 0
213 # Limit the number of thumbnails per image
213 # Limit the number of thumbnails per image
214 size = (size / 50) * 50
214 size = (size / 50) * 50
215 # Maximum thumbnail size
215 # Maximum thumbnail size
216 size = 800 if size > 800
216 size = 800 if size > 800
217 else
217 else
218 size = Setting.thumbnails_size.to_i
218 size = Setting.thumbnails_size.to_i
219 end
219 end
220 size = 100 unless size > 0
220 size = 100 unless size > 0
221 target = File.join(self.class.thumbnails_storage_path, "#{id}_#{digest}_#{size}.thumb")
221 target = File.join(self.class.thumbnails_storage_path, "#{id}_#{digest}_#{size}.thumb")
222
222
223 begin
223 begin
224 Redmine::Thumbnail.generate(self.diskfile, target, size)
224 Redmine::Thumbnail.generate(self.diskfile, target, size)
225 rescue => e
225 rescue => e
226 logger.error "An error occured while generating thumbnail for #{disk_filename} to #{target}\nException was: #{e.message}" if logger
226 logger.error "An error occured while generating thumbnail for #{disk_filename} to #{target}\nException was: #{e.message}" if logger
227 return nil
227 return nil
228 end
228 end
229 end
229 end
230 end
230 end
231
231
232 # Deletes all thumbnails
232 # Deletes all thumbnails
233 def self.clear_thumbnails
233 def self.clear_thumbnails
234 Dir.glob(File.join(thumbnails_storage_path, "*.thumb")).each do |file|
234 Dir.glob(File.join(thumbnails_storage_path, "*.thumb")).each do |file|
235 File.delete file
235 File.delete file
236 end
236 end
237 end
237 end
238
238
239 def is_text?
239 def is_text?
240 Redmine::MimeType.is_type?('text', filename)
240 Redmine::MimeType.is_type?('text', filename)
241 end
241 end
242
242
243 def is_image?
243 def is_image?
244 Redmine::MimeType.is_type?('image', filename)
244 Redmine::MimeType.is_type?('image', filename)
245 end
245 end
246
246
247 def is_diff?
247 def is_diff?
248 self.filename =~ /\.(patch|diff)$/i
248 self.filename =~ /\.(patch|diff)$/i
249 end
249 end
250
250
251 def is_pdf?
251 def is_pdf?
252 Redmine::MimeType.of(filename) == "application/pdf"
252 Redmine::MimeType.of(filename) == "application/pdf"
253 end
253 end
254
254
255 # Returns true if the file is readable
255 # Returns true if the file is readable
256 def readable?
256 def readable?
257 File.readable?(diskfile)
257 File.readable?(diskfile)
258 end
258 end
259
259
260 # Returns the attachment token
260 # Returns the attachment token
261 def token
261 def token
262 "#{id}.#{digest}"
262 "#{id}.#{digest}"
263 end
263 end
264
264
265 # Finds an attachment that matches the given token and that has no container
265 # Finds an attachment that matches the given token and that has no container
266 def self.find_by_token(token)
266 def self.find_by_token(token)
267 if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/
267 if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/
268 attachment_id, attachment_digest = $1, $2
268 attachment_id, attachment_digest = $1, $2
269 attachment = Attachment.where(:id => attachment_id, :digest => attachment_digest).first
269 attachment = Attachment.where(:id => attachment_id, :digest => attachment_digest).first
270 if attachment && attachment.container.nil?
270 if attachment && attachment.container.nil?
271 attachment
271 attachment
272 end
272 end
273 end
273 end
274 end
274 end
275
275
276 # Bulk attaches a set of files to an object
276 # Bulk attaches a set of files to an object
277 #
277 #
278 # Returns a Hash of the results:
278 # Returns a Hash of the results:
279 # :files => array of the attached files
279 # :files => array of the attached files
280 # :unsaved => array of the files that could not be attached
280 # :unsaved => array of the files that could not be attached
281 def self.attach_files(obj, attachments)
281 def self.attach_files(obj, attachments)
282 result = obj.save_attachments(attachments, User.current)
282 result = obj.save_attachments(attachments, User.current)
283 obj.attach_saved_attachments
283 obj.attach_saved_attachments
284 result
284 result
285 end
285 end
286
286
287 # Updates the filename and description of a set of attachments
287 # Updates the filename and description of a set of attachments
288 # with the given hash of attributes. Returns true if all
288 # with the given hash of attributes. Returns true if all
289 # attachments were updated.
289 # attachments were updated.
290 #
290 #
291 # Example:
291 # Example:
292 # Attachment.update_attachments(attachments, {
292 # Attachment.update_attachments(attachments, {
293 # 4 => {:filename => 'foo'},
293 # 4 => {:filename => 'foo'},
294 # 7 => {:filename => 'bar', :description => 'file description'}
294 # 7 => {:filename => 'bar', :description => 'file description'}
295 # })
295 # })
296 #
296 #
297 def self.update_attachments(attachments, params)
297 def self.update_attachments(attachments, params)
298 params = params.transform_keys {|key| key.to_i}
298 params = params.transform_keys {|key| key.to_i}
299
299
300 saved = true
300 saved = true
301 transaction do
301 transaction do
302 attachments.each do |attachment|
302 attachments.each do |attachment|
303 if p = params[attachment.id]
303 if p = params[attachment.id]
304 attachment.filename = p[:filename] if p.key?(:filename)
304 attachment.filename = p[:filename] if p.key?(:filename)
305 attachment.description = p[:description] if p.key?(:description)
305 attachment.description = p[:description] if p.key?(:description)
306 saved &&= attachment.save
306 saved &&= attachment.save
307 end
307 end
308 end
308 end
309 unless saved
309 unless saved
310 raise ActiveRecord::Rollback
310 raise ActiveRecord::Rollback
311 end
311 end
312 end
312 end
313 saved
313 saved
314 end
314 end
315
315
316 def self.latest_attach(attachments, filename)
316 def self.latest_attach(attachments, filename)
317 attachments.sort_by(&:created_on).reverse.detect do |att|
317 attachments.sort_by(&:created_on).reverse.detect do |att|
318 filename.casecmp(att.filename) == 0
318 filename.casecmp(att.filename) == 0
319 end
319 end
320 end
320 end
321
321
322 def self.prune(age=1.day)
322 def self.prune(age=1.day)
323 Attachment.where("created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age).destroy_all
323 Attachment.where("created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age).destroy_all
324 end
324 end
325
325
326 # Moves an existing attachment to its target directory
326 # Moves an existing attachment to its target directory
327 def move_to_target_directory!
327 def move_to_target_directory!
328 return unless !new_record? & readable?
328 return unless !new_record? & readable?
329
329
330 src = diskfile
330 src = diskfile
331 self.disk_directory = target_directory
331 self.disk_directory = target_directory
332 dest = diskfile
332 dest = diskfile
333
333
334 return if src == dest
334 return if src == dest
335
335
336 if !FileUtils.mkdir_p(File.dirname(dest))
336 if !FileUtils.mkdir_p(File.dirname(dest))
337 logger.error "Could not create directory #{File.dirname(dest)}" if logger
337 logger.error "Could not create directory #{File.dirname(dest)}" if logger
338 return
338 return
339 end
339 end
340
340
341 if !FileUtils.mv(src, dest)
341 if !FileUtils.mv(src, dest)
342 logger.error "Could not move attachment from #{src} to #{dest}" if logger
342 logger.error "Could not move attachment from #{src} to #{dest}" if logger
343 return
343 return
344 end
344 end
345
345
346 update_column :disk_directory, disk_directory
346 update_column :disk_directory, disk_directory
347 end
347 end
348
348
349 # Moves existing attachments that are stored at the root of the files
349 # Moves existing attachments that are stored at the root of the files
350 # directory (ie. created before Redmine 2.3) to their target subdirectories
350 # directory (ie. created before Redmine 2.3) to their target subdirectories
351 def self.move_from_root_to_target_directory
351 def self.move_from_root_to_target_directory
352 Attachment.where("disk_directory IS NULL OR disk_directory = ''").find_each do |attachment|
352 Attachment.where("disk_directory IS NULL OR disk_directory = ''").find_each do |attachment|
353 attachment.move_to_target_directory!
353 attachment.move_to_target_directory!
354 end
354 end
355 end
355 end
356
356
357 # Returns true if the extension is allowed, otherwise false
357 # Returns true if the extension is allowed regarding allowed/denied
358 # extensions defined in application settings, otherwise false
358 def self.valid_extension?(extension)
359 def self.valid_extension?(extension)
359 extension = extension.downcase.sub(/\A\.+/, '')
360
361 denied, allowed = [:attachment_extensions_denied, :attachment_extensions_allowed].map do |setting|
360 denied, allowed = [:attachment_extensions_denied, :attachment_extensions_allowed].map do |setting|
362 Setting.send(setting).to_s.split(",").map {|s| s.strip.downcase.sub(/\A\.+/, '')}.reject(&:blank?)
361 Setting.send(setting)
363 end
362 end
364 if denied.present? && denied.include?(extension)
363 if denied.present? && extension_in?(extension, denied)
365 return false
364 return false
366 end
365 end
367 unless allowed.blank? || allowed.include?(extension)
366 if allowed.present? && !extension_in?(extension, allowed)
368 return false
367 return false
369 end
368 end
370 true
369 true
371 end
370 end
372
371
372 # Returns true if extension belongs to extensions list.
373 def self.extension_in?(extension, extensions)
374 extension = extension.downcase.sub(/\A\.+/, '')
375
376 unless extensions.is_a?(Array)
377 extensions = extensions.to_s.split(",").map(&:strip)
378 end
379 extensions = extensions.map {|s| s.downcase.sub(/\A\.+/, '')}.reject(&:blank?)
380 extensions.include?(extension)
381 end
382
383 # Returns true if attachment's extension belongs to extensions list.
384 def extension_in?(extensions)
385 self.class.extension_in?(File.extname(filename), extensions)
386 end
387
373 private
388 private
374
389
375 # Physically deletes the file from the file system
390 # Physically deletes the file from the file system
376 def delete_from_disk!
391 def delete_from_disk!
377 if disk_filename.present? && File.exist?(diskfile)
392 if disk_filename.present? && File.exist?(diskfile)
378 File.delete(diskfile)
393 File.delete(diskfile)
379 end
394 end
380 end
395 end
381
396
382 def sanitize_filename(value)
397 def sanitize_filename(value)
383 # get only the filename, not the whole path
398 # get only the filename, not the whole path
384 just_filename = value.gsub(/\A.*(\\|\/)/m, '')
399 just_filename = value.gsub(/\A.*(\\|\/)/m, '')
385
400
386 # Finally, replace invalid characters with underscore
401 # Finally, replace invalid characters with underscore
387 just_filename.gsub(/[\/\?\%\*\:\|\"\'<>\n\r]+/, '_')
402 just_filename.gsub(/[\/\?\%\*\:\|\"\'<>\n\r]+/, '_')
388 end
403 end
389
404
390 # Returns the subdirectory in which the attachment will be saved
405 # Returns the subdirectory in which the attachment will be saved
391 def target_directory
406 def target_directory
392 time = created_on || DateTime.now
407 time = created_on || DateTime.now
393 time.strftime("%Y/%m")
408 time.strftime("%Y/%m")
394 end
409 end
395
410
396 # Returns an ASCII or hashed filename that do not
411 # Returns an ASCII or hashed filename that do not
397 # exists yet in the given subdirectory
412 # exists yet in the given subdirectory
398 def self.disk_filename(filename, directory=nil)
413 def self.disk_filename(filename, directory=nil)
399 timestamp = DateTime.now.strftime("%y%m%d%H%M%S")
414 timestamp = DateTime.now.strftime("%y%m%d%H%M%S")
400 ascii = ''
415 ascii = ''
401 if filename =~ %r{^[a-zA-Z0-9_\.\-]*$}
416 if filename =~ %r{^[a-zA-Z0-9_\.\-]*$}
402 ascii = filename
417 ascii = filename
403 else
418 else
404 ascii = Digest::MD5.hexdigest(filename)
419 ascii = Digest::MD5.hexdigest(filename)
405 # keep the extension if any
420 # keep the extension if any
406 ascii << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$}
421 ascii << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$}
407 end
422 end
408 while File.exist?(File.join(storage_path, directory.to_s, "#{timestamp}_#{ascii}"))
423 while File.exist?(File.join(storage_path, directory.to_s, "#{timestamp}_#{ascii}"))
409 timestamp.succ!
424 timestamp.succ!
410 end
425 end
411 "#{timestamp}_#{ascii}"
426 "#{timestamp}_#{ascii}"
412 end
427 end
413 end
428 end
@@ -1,326 +1,327
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class CustomField < ActiveRecord::Base
18 class CustomField < ActiveRecord::Base
19 include Redmine::SafeAttributes
19 include Redmine::SafeAttributes
20 include Redmine::SubclassFactory
20 include Redmine::SubclassFactory
21
21
22 has_many :enumerations,
22 has_many :enumerations,
23 lambda { order(:position) },
23 lambda { order(:position) },
24 :class_name => 'CustomFieldEnumeration',
24 :class_name => 'CustomFieldEnumeration',
25 :dependent => :delete_all
25 :dependent => :delete_all
26 has_many :custom_values, :dependent => :delete_all
26 has_many :custom_values, :dependent => :delete_all
27 has_and_belongs_to_many :roles, :join_table => "#{table_name_prefix}custom_fields_roles#{table_name_suffix}", :foreign_key => "custom_field_id"
27 has_and_belongs_to_many :roles, :join_table => "#{table_name_prefix}custom_fields_roles#{table_name_suffix}", :foreign_key => "custom_field_id"
28 acts_as_positioned
28 acts_as_positioned
29 serialize :possible_values
29 serialize :possible_values
30 store :format_store
30 store :format_store
31
31
32 validates_presence_of :name, :field_format
32 validates_presence_of :name, :field_format
33 validates_uniqueness_of :name, :scope => :type
33 validates_uniqueness_of :name, :scope => :type
34 validates_length_of :name, :maximum => 30
34 validates_length_of :name, :maximum => 30
35 validates_inclusion_of :field_format, :in => Proc.new { Redmine::FieldFormat.available_formats }
35 validates_inclusion_of :field_format, :in => Proc.new { Redmine::FieldFormat.available_formats }
36 validate :validate_custom_field
36 validate :validate_custom_field
37 attr_protected :id
37 attr_protected :id
38
38
39 before_validation :set_searchable
39 before_validation :set_searchable
40 before_save do |field|
40 before_save do |field|
41 field.format.before_custom_field_save(field)
41 field.format.before_custom_field_save(field)
42 end
42 end
43 after_save :handle_multiplicity_change
43 after_save :handle_multiplicity_change
44 after_save do |field|
44 after_save do |field|
45 if field.visible_changed? && field.visible
45 if field.visible_changed? && field.visible
46 field.roles.clear
46 field.roles.clear
47 end
47 end
48 end
48 end
49
49
50 scope :sorted, lambda { order(:position) }
50 scope :sorted, lambda { order(:position) }
51 scope :visible, lambda {|*args|
51 scope :visible, lambda {|*args|
52 user = args.shift || User.current
52 user = args.shift || User.current
53 if user.admin?
53 if user.admin?
54 # nop
54 # nop
55 elsif user.memberships.any?
55 elsif user.memberships.any?
56 where("#{table_name}.visible = ? OR #{table_name}.id IN (SELECT DISTINCT cfr.custom_field_id FROM #{Member.table_name} m" +
56 where("#{table_name}.visible = ? OR #{table_name}.id IN (SELECT DISTINCT cfr.custom_field_id FROM #{Member.table_name} m" +
57 " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
57 " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
58 " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
58 " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
59 " WHERE m.user_id = ?)",
59 " WHERE m.user_id = ?)",
60 true, user.id)
60 true, user.id)
61 else
61 else
62 where(:visible => true)
62 where(:visible => true)
63 end
63 end
64 }
64 }
65 def visible_by?(project, user=User.current)
65 def visible_by?(project, user=User.current)
66 visible? || user.admin?
66 visible? || user.admin?
67 end
67 end
68
68
69 safe_attributes 'name',
69 safe_attributes 'name',
70 'field_format',
70 'field_format',
71 'possible_values',
71 'possible_values',
72 'regexp',
72 'regexp',
73 'min_lnegth',
73 'min_lnegth',
74 'max_length',
74 'max_length',
75 'is_required',
75 'is_required',
76 'is_for_all',
76 'is_for_all',
77 'is_filter',
77 'is_filter',
78 'position',
78 'position',
79 'searchable',
79 'searchable',
80 'default_value',
80 'default_value',
81 'editable',
81 'editable',
82 'visible',
82 'visible',
83 'multiple',
83 'multiple',
84 'description',
84 'description',
85 'role_ids',
85 'role_ids',
86 'url_pattern',
86 'url_pattern',
87 'text_formatting',
87 'text_formatting',
88 'edit_tag_style',
88 'edit_tag_style',
89 'user_role',
89 'user_role',
90 'version_status'
90 'version_status',
91 'extensions_allowed'
91
92
92 def format
93 def format
93 @format ||= Redmine::FieldFormat.find(field_format)
94 @format ||= Redmine::FieldFormat.find(field_format)
94 end
95 end
95
96
96 def field_format=(arg)
97 def field_format=(arg)
97 # cannot change format of a saved custom field
98 # cannot change format of a saved custom field
98 if new_record?
99 if new_record?
99 @format = nil
100 @format = nil
100 super
101 super
101 end
102 end
102 end
103 end
103
104
104 def set_searchable
105 def set_searchable
105 # make sure these fields are not searchable
106 # make sure these fields are not searchable
106 self.searchable = false unless format.class.searchable_supported
107 self.searchable = false unless format.class.searchable_supported
107 # make sure only these fields can have multiple values
108 # make sure only these fields can have multiple values
108 self.multiple = false unless format.class.multiple_supported
109 self.multiple = false unless format.class.multiple_supported
109 true
110 true
110 end
111 end
111
112
112 def validate_custom_field
113 def validate_custom_field
113 format.validate_custom_field(self).each do |attribute, message|
114 format.validate_custom_field(self).each do |attribute, message|
114 errors.add attribute, message
115 errors.add attribute, message
115 end
116 end
116
117
117 if regexp.present?
118 if regexp.present?
118 begin
119 begin
119 Regexp.new(regexp)
120 Regexp.new(regexp)
120 rescue
121 rescue
121 errors.add(:regexp, :invalid)
122 errors.add(:regexp, :invalid)
122 end
123 end
123 end
124 end
124
125
125 if default_value.present?
126 if default_value.present?
126 validate_field_value(default_value).each do |message|
127 validate_field_value(default_value).each do |message|
127 errors.add :default_value, message
128 errors.add :default_value, message
128 end
129 end
129 end
130 end
130 end
131 end
131
132
132 def possible_custom_value_options(custom_value)
133 def possible_custom_value_options(custom_value)
133 format.possible_custom_value_options(custom_value)
134 format.possible_custom_value_options(custom_value)
134 end
135 end
135
136
136 def possible_values_options(object=nil)
137 def possible_values_options(object=nil)
137 if object.is_a?(Array)
138 if object.is_a?(Array)
138 object.map {|o| format.possible_values_options(self, o)}.reduce(:&) || []
139 object.map {|o| format.possible_values_options(self, o)}.reduce(:&) || []
139 else
140 else
140 format.possible_values_options(self, object) || []
141 format.possible_values_options(self, object) || []
141 end
142 end
142 end
143 end
143
144
144 def possible_values
145 def possible_values
145 values = read_attribute(:possible_values)
146 values = read_attribute(:possible_values)
146 if values.is_a?(Array)
147 if values.is_a?(Array)
147 values.each do |value|
148 values.each do |value|
148 value.to_s.force_encoding('UTF-8')
149 value.to_s.force_encoding('UTF-8')
149 end
150 end
150 values
151 values
151 else
152 else
152 []
153 []
153 end
154 end
154 end
155 end
155
156
156 # Makes possible_values accept a multiline string
157 # Makes possible_values accept a multiline string
157 def possible_values=(arg)
158 def possible_values=(arg)
158 if arg.is_a?(Array)
159 if arg.is_a?(Array)
159 values = arg.compact.map {|a| a.to_s.strip}.reject(&:blank?)
160 values = arg.compact.map {|a| a.to_s.strip}.reject(&:blank?)
160 write_attribute(:possible_values, values)
161 write_attribute(:possible_values, values)
161 else
162 else
162 self.possible_values = arg.to_s.split(/[\n\r]+/)
163 self.possible_values = arg.to_s.split(/[\n\r]+/)
163 end
164 end
164 end
165 end
165
166
166 def set_custom_field_value(custom_field_value, value)
167 def set_custom_field_value(custom_field_value, value)
167 format.set_custom_field_value(self, custom_field_value, value)
168 format.set_custom_field_value(self, custom_field_value, value)
168 end
169 end
169
170
170 def cast_value(value)
171 def cast_value(value)
171 format.cast_value(self, value)
172 format.cast_value(self, value)
172 end
173 end
173
174
174 def value_from_keyword(keyword, customized)
175 def value_from_keyword(keyword, customized)
175 format.value_from_keyword(self, keyword, customized)
176 format.value_from_keyword(self, keyword, customized)
176 end
177 end
177
178
178 # Returns the options hash used to build a query filter for the field
179 # Returns the options hash used to build a query filter for the field
179 def query_filter_options(query)
180 def query_filter_options(query)
180 format.query_filter_options(self, query)
181 format.query_filter_options(self, query)
181 end
182 end
182
183
183 def totalable?
184 def totalable?
184 format.totalable_supported
185 format.totalable_supported
185 end
186 end
186
187
187 # Returns a ORDER BY clause that can used to sort customized
188 # Returns a ORDER BY clause that can used to sort customized
188 # objects by their value of the custom field.
189 # objects by their value of the custom field.
189 # Returns nil if the custom field can not be used for sorting.
190 # Returns nil if the custom field can not be used for sorting.
190 def order_statement
191 def order_statement
191 return nil if multiple?
192 return nil if multiple?
192 format.order_statement(self)
193 format.order_statement(self)
193 end
194 end
194
195
195 # Returns a GROUP BY clause that can used to group by custom value
196 # Returns a GROUP BY clause that can used to group by custom value
196 # Returns nil if the custom field can not be used for grouping.
197 # Returns nil if the custom field can not be used for grouping.
197 def group_statement
198 def group_statement
198 return nil if multiple?
199 return nil if multiple?
199 format.group_statement(self)
200 format.group_statement(self)
200 end
201 end
201
202
202 def join_for_order_statement
203 def join_for_order_statement
203 format.join_for_order_statement(self)
204 format.join_for_order_statement(self)
204 end
205 end
205
206
206 def visibility_by_project_condition(project_key=nil, user=User.current, id_column=nil)
207 def visibility_by_project_condition(project_key=nil, user=User.current, id_column=nil)
207 if visible? || user.admin?
208 if visible? || user.admin?
208 "1=1"
209 "1=1"
209 elsif user.anonymous?
210 elsif user.anonymous?
210 "1=0"
211 "1=0"
211 else
212 else
212 project_key ||= "#{self.class.customized_class.table_name}.project_id"
213 project_key ||= "#{self.class.customized_class.table_name}.project_id"
213 id_column ||= id
214 id_column ||= id
214 "#{project_key} IN (SELECT DISTINCT m.project_id FROM #{Member.table_name} m" +
215 "#{project_key} IN (SELECT DISTINCT m.project_id FROM #{Member.table_name} m" +
215 " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
216 " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
216 " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
217 " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
217 " WHERE m.user_id = #{user.id} AND cfr.custom_field_id = #{id_column})"
218 " WHERE m.user_id = #{user.id} AND cfr.custom_field_id = #{id_column})"
218 end
219 end
219 end
220 end
220
221
221 def self.visibility_condition
222 def self.visibility_condition
222 if user.admin?
223 if user.admin?
223 "1=1"
224 "1=1"
224 elsif user.anonymous?
225 elsif user.anonymous?
225 "#{table_name}.visible"
226 "#{table_name}.visible"
226 else
227 else
227 "#{project_key} IN (SELECT DISTINCT m.project_id FROM #{Member.table_name} m" +
228 "#{project_key} IN (SELECT DISTINCT m.project_id FROM #{Member.table_name} m" +
228 " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
229 " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
229 " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
230 " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
230 " WHERE m.user_id = #{user.id} AND cfr.custom_field_id = #{id})"
231 " WHERE m.user_id = #{user.id} AND cfr.custom_field_id = #{id})"
231 end
232 end
232 end
233 end
233
234
234 def <=>(field)
235 def <=>(field)
235 position <=> field.position
236 position <=> field.position
236 end
237 end
237
238
238 # Returns the class that values represent
239 # Returns the class that values represent
239 def value_class
240 def value_class
240 format.target_class if format.respond_to?(:target_class)
241 format.target_class if format.respond_to?(:target_class)
241 end
242 end
242
243
243 def self.customized_class
244 def self.customized_class
244 self.name =~ /^(.+)CustomField$/
245 self.name =~ /^(.+)CustomField$/
245 $1.constantize rescue nil
246 $1.constantize rescue nil
246 end
247 end
247
248
248 # to move in project_custom_field
249 # to move in project_custom_field
249 def self.for_all
250 def self.for_all
250 where(:is_for_all => true).order('position').to_a
251 where(:is_for_all => true).order('position').to_a
251 end
252 end
252
253
253 def type_name
254 def type_name
254 nil
255 nil
255 end
256 end
256
257
257 # Returns the error messages for the given value
258 # Returns the error messages for the given value
258 # or an empty array if value is a valid value for the custom field
259 # or an empty array if value is a valid value for the custom field
259 def validate_custom_value(custom_value)
260 def validate_custom_value(custom_value)
260 value = custom_value.value
261 value = custom_value.value
261 errs = format.validate_custom_value(custom_value)
262 errs = format.validate_custom_value(custom_value)
262
263
263 unless errs.any?
264 unless errs.any?
264 if value.is_a?(Array)
265 if value.is_a?(Array)
265 if !multiple?
266 if !multiple?
266 errs << ::I18n.t('activerecord.errors.messages.invalid')
267 errs << ::I18n.t('activerecord.errors.messages.invalid')
267 end
268 end
268 if is_required? && value.detect(&:present?).nil?
269 if is_required? && value.detect(&:present?).nil?
269 errs << ::I18n.t('activerecord.errors.messages.blank')
270 errs << ::I18n.t('activerecord.errors.messages.blank')
270 end
271 end
271 else
272 else
272 if is_required? && value.blank?
273 if is_required? && value.blank?
273 errs << ::I18n.t('activerecord.errors.messages.blank')
274 errs << ::I18n.t('activerecord.errors.messages.blank')
274 end
275 end
275 end
276 end
276 end
277 end
277
278
278 errs
279 errs
279 end
280 end
280
281
281 # Returns the error messages for the default custom field value
282 # Returns the error messages for the default custom field value
282 def validate_field_value(value)
283 def validate_field_value(value)
283 validate_custom_value(CustomFieldValue.new(:custom_field => self, :value => value))
284 validate_custom_value(CustomFieldValue.new(:custom_field => self, :value => value))
284 end
285 end
285
286
286 # Returns true if value is a valid value for the custom field
287 # Returns true if value is a valid value for the custom field
287 def valid_field_value?(value)
288 def valid_field_value?(value)
288 validate_field_value(value).empty?
289 validate_field_value(value).empty?
289 end
290 end
290
291
291 def after_save_custom_value(custom_value)
292 def after_save_custom_value(custom_value)
292 format.after_save_custom_value(self, custom_value)
293 format.after_save_custom_value(self, custom_value)
293 end
294 end
294
295
295 def format_in?(*args)
296 def format_in?(*args)
296 args.include?(field_format)
297 args.include?(field_format)
297 end
298 end
298
299
299 def self.human_attribute_name(attribute_key_name, *args)
300 def self.human_attribute_name(attribute_key_name, *args)
300 attr_name = attribute_key_name.to_s
301 attr_name = attribute_key_name.to_s
301 if attr_name == 'url_pattern'
302 if attr_name == 'url_pattern'
302 attr_name = "url"
303 attr_name = "url"
303 end
304 end
304 super(attr_name, *args)
305 super(attr_name, *args)
305 end
306 end
306
307
307 protected
308 protected
308
309
309 # Removes multiple values for the custom field after setting the multiple attribute to false
310 # Removes multiple values for the custom field after setting the multiple attribute to false
310 # We kepp the value with the highest id for each customized object
311 # We kepp the value with the highest id for each customized object
311 def handle_multiplicity_change
312 def handle_multiplicity_change
312 if !new_record? && multiple_was && !multiple
313 if !new_record? && multiple_was && !multiple
313 ids = custom_values.
314 ids = custom_values.
314 where("EXISTS(SELECT 1 FROM #{CustomValue.table_name} cve WHERE cve.custom_field_id = #{CustomValue.table_name}.custom_field_id" +
315 where("EXISTS(SELECT 1 FROM #{CustomValue.table_name} cve WHERE cve.custom_field_id = #{CustomValue.table_name}.custom_field_id" +
315 " AND cve.customized_type = #{CustomValue.table_name}.customized_type AND cve.customized_id = #{CustomValue.table_name}.customized_id" +
316 " AND cve.customized_type = #{CustomValue.table_name}.customized_type AND cve.customized_id = #{CustomValue.table_name}.customized_id" +
316 " AND cve.id > #{CustomValue.table_name}.id)").
317 " AND cve.id > #{CustomValue.table_name}.id)").
317 pluck(:id)
318 pluck(:id)
318
319
319 if ids.any?
320 if ids.any?
320 custom_values.where(:id => ids).delete_all
321 custom_values.where(:id => ids).delete_all
321 end
322 end
322 end
323 end
323 end
324 end
324 end
325 end
325
326
326 require_dependency 'redmine/field_format'
327 require_dependency 'redmine/field_format'
@@ -0,0 +1,4
1 <p>
2 <%= f.text_field :extensions_allowed, :size => 50, :label => :setting_attachment_extensions_allowed %>
3 <em class="info"><%= l(:text_comma_separated) %> <%= l(:label_example) %>: txt, png</em>
4 </p>
@@ -1,963 +1,974
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require 'uri'
18 require 'uri'
19
19
20 module Redmine
20 module Redmine
21 module FieldFormat
21 module FieldFormat
22 def self.add(name, klass)
22 def self.add(name, klass)
23 all[name.to_s] = klass.instance
23 all[name.to_s] = klass.instance
24 end
24 end
25
25
26 def self.delete(name)
26 def self.delete(name)
27 all.delete(name.to_s)
27 all.delete(name.to_s)
28 end
28 end
29
29
30 def self.all
30 def self.all
31 @formats ||= Hash.new(Base.instance)
31 @formats ||= Hash.new(Base.instance)
32 end
32 end
33
33
34 def self.available_formats
34 def self.available_formats
35 all.keys
35 all.keys
36 end
36 end
37
37
38 def self.find(name)
38 def self.find(name)
39 all[name.to_s]
39 all[name.to_s]
40 end
40 end
41
41
42 # Return an array of custom field formats which can be used in select_tag
42 # Return an array of custom field formats which can be used in select_tag
43 def self.as_select(class_name=nil)
43 def self.as_select(class_name=nil)
44 formats = all.values.select do |format|
44 formats = all.values.select do |format|
45 format.class.customized_class_names.nil? || format.class.customized_class_names.include?(class_name)
45 format.class.customized_class_names.nil? || format.class.customized_class_names.include?(class_name)
46 end
46 end
47 formats.map {|format| [::I18n.t(format.label), format.name] }.sort_by(&:first)
47 formats.map {|format| [::I18n.t(format.label), format.name] }.sort_by(&:first)
48 end
48 end
49
49
50 # Returns an array of formats that can be used for a custom field class
50 # Returns an array of formats that can be used for a custom field class
51 def self.formats_for_custom_field_class(klass=nil)
51 def self.formats_for_custom_field_class(klass=nil)
52 all.values.select do |format|
52 all.values.select do |format|
53 format.class.customized_class_names.nil? || format.class.customized_class_names.include?(klass.name)
53 format.class.customized_class_names.nil? || format.class.customized_class_names.include?(klass.name)
54 end
54 end
55 end
55 end
56
56
57 class Base
57 class Base
58 include Singleton
58 include Singleton
59 include Redmine::I18n
59 include Redmine::I18n
60 include Redmine::Helpers::URL
60 include Redmine::Helpers::URL
61 include ERB::Util
61 include ERB::Util
62
62
63 class_attribute :format_name
63 class_attribute :format_name
64 self.format_name = nil
64 self.format_name = nil
65
65
66 # Set this to true if the format supports multiple values
66 # Set this to true if the format supports multiple values
67 class_attribute :multiple_supported
67 class_attribute :multiple_supported
68 self.multiple_supported = false
68 self.multiple_supported = false
69
69
70 # Set this to true if the format supports filtering on custom values
70 # Set this to true if the format supports filtering on custom values
71 class_attribute :is_filter_supported
71 class_attribute :is_filter_supported
72 self.is_filter_supported = true
72 self.is_filter_supported = true
73
73
74 # Set this to true if the format supports textual search on custom values
74 # Set this to true if the format supports textual search on custom values
75 class_attribute :searchable_supported
75 class_attribute :searchable_supported
76 self.searchable_supported = false
76 self.searchable_supported = false
77
77
78 # Set this to true if field values can be summed up
78 # Set this to true if field values can be summed up
79 class_attribute :totalable_supported
79 class_attribute :totalable_supported
80 self.totalable_supported = false
80 self.totalable_supported = false
81
81
82 # Restricts the classes that the custom field can be added to
82 # Restricts the classes that the custom field can be added to
83 # Set to nil for no restrictions
83 # Set to nil for no restrictions
84 class_attribute :customized_class_names
84 class_attribute :customized_class_names
85 self.customized_class_names = nil
85 self.customized_class_names = nil
86
86
87 # Name of the partial for editing the custom field
87 # Name of the partial for editing the custom field
88 class_attribute :form_partial
88 class_attribute :form_partial
89 self.form_partial = nil
89 self.form_partial = nil
90
90
91 class_attribute :change_as_diff
91 class_attribute :change_as_diff
92 self.change_as_diff = false
92 self.change_as_diff = false
93
93
94 class_attribute :change_no_details
94 class_attribute :change_no_details
95 self.change_no_details = false
95 self.change_no_details = false
96
96
97 def self.add(name)
97 def self.add(name)
98 self.format_name = name
98 self.format_name = name
99 Redmine::FieldFormat.add(name, self)
99 Redmine::FieldFormat.add(name, self)
100 end
100 end
101 private_class_method :add
101 private_class_method :add
102
102
103 def self.field_attributes(*args)
103 def self.field_attributes(*args)
104 CustomField.store_accessor :format_store, *args
104 CustomField.store_accessor :format_store, *args
105 end
105 end
106
106
107 field_attributes :url_pattern
107 field_attributes :url_pattern
108
108
109 def name
109 def name
110 self.class.format_name
110 self.class.format_name
111 end
111 end
112
112
113 def label
113 def label
114 "label_#{name}"
114 "label_#{name}"
115 end
115 end
116
116
117 def set_custom_field_value(custom_field, custom_field_value, value)
117 def set_custom_field_value(custom_field, custom_field_value, value)
118 if value.is_a?(Array)
118 if value.is_a?(Array)
119 value = value.map(&:to_s).reject{|v| v==''}.uniq
119 value = value.map(&:to_s).reject{|v| v==''}.uniq
120 if value.empty?
120 if value.empty?
121 value << ''
121 value << ''
122 end
122 end
123 else
123 else
124 value = value.to_s
124 value = value.to_s
125 end
125 end
126
126
127 value
127 value
128 end
128 end
129
129
130 def cast_custom_value(custom_value)
130 def cast_custom_value(custom_value)
131 cast_value(custom_value.custom_field, custom_value.value, custom_value.customized)
131 cast_value(custom_value.custom_field, custom_value.value, custom_value.customized)
132 end
132 end
133
133
134 def cast_value(custom_field, value, customized=nil)
134 def cast_value(custom_field, value, customized=nil)
135 if value.blank?
135 if value.blank?
136 nil
136 nil
137 elsif value.is_a?(Array)
137 elsif value.is_a?(Array)
138 casted = value.map do |v|
138 casted = value.map do |v|
139 cast_single_value(custom_field, v, customized)
139 cast_single_value(custom_field, v, customized)
140 end
140 end
141 casted.compact.sort
141 casted.compact.sort
142 else
142 else
143 cast_single_value(custom_field, value, customized)
143 cast_single_value(custom_field, value, customized)
144 end
144 end
145 end
145 end
146
146
147 def cast_single_value(custom_field, value, customized=nil)
147 def cast_single_value(custom_field, value, customized=nil)
148 value.to_s
148 value.to_s
149 end
149 end
150
150
151 def target_class
151 def target_class
152 nil
152 nil
153 end
153 end
154
154
155 def possible_custom_value_options(custom_value)
155 def possible_custom_value_options(custom_value)
156 possible_values_options(custom_value.custom_field, custom_value.customized)
156 possible_values_options(custom_value.custom_field, custom_value.customized)
157 end
157 end
158
158
159 def possible_values_options(custom_field, object=nil)
159 def possible_values_options(custom_field, object=nil)
160 []
160 []
161 end
161 end
162
162
163 def value_from_keyword(custom_field, keyword, object)
163 def value_from_keyword(custom_field, keyword, object)
164 possible_values_options = possible_values_options(custom_field, object)
164 possible_values_options = possible_values_options(custom_field, object)
165 if possible_values_options.present?
165 if possible_values_options.present?
166 keyword = keyword.to_s
166 keyword = keyword.to_s
167 if v = possible_values_options.detect {|text, id| keyword.casecmp(text) == 0}
167 if v = possible_values_options.detect {|text, id| keyword.casecmp(text) == 0}
168 if v.is_a?(Array)
168 if v.is_a?(Array)
169 v.last
169 v.last
170 else
170 else
171 v
171 v
172 end
172 end
173 end
173 end
174 else
174 else
175 keyword
175 keyword
176 end
176 end
177 end
177 end
178
178
179 # Returns the validation errors for custom_field
179 # Returns the validation errors for custom_field
180 # Should return an empty array if custom_field is valid
180 # Should return an empty array if custom_field is valid
181 def validate_custom_field(custom_field)
181 def validate_custom_field(custom_field)
182 errors = []
182 errors = []
183 pattern = custom_field.url_pattern
183 pattern = custom_field.url_pattern
184 if pattern.present? && !uri_with_safe_scheme?(url_pattern_without_tokens(pattern))
184 if pattern.present? && !uri_with_safe_scheme?(url_pattern_without_tokens(pattern))
185 errors << [:url_pattern, :invalid]
185 errors << [:url_pattern, :invalid]
186 end
186 end
187 errors
187 errors
188 end
188 end
189
189
190 # Returns the validation error messages for custom_value
190 # Returns the validation error messages for custom_value
191 # Should return an empty array if custom_value is valid
191 # Should return an empty array if custom_value is valid
192 # custom_value is a CustomFieldValue.
192 # custom_value is a CustomFieldValue.
193 def validate_custom_value(custom_value)
193 def validate_custom_value(custom_value)
194 values = Array.wrap(custom_value.value).reject {|value| value.to_s == ''}
194 values = Array.wrap(custom_value.value).reject {|value| value.to_s == ''}
195 errors = values.map do |value|
195 errors = values.map do |value|
196 validate_single_value(custom_value.custom_field, value, custom_value.customized)
196 validate_single_value(custom_value.custom_field, value, custom_value.customized)
197 end
197 end
198 errors.flatten.uniq
198 errors.flatten.uniq
199 end
199 end
200
200
201 def validate_single_value(custom_field, value, customized=nil)
201 def validate_single_value(custom_field, value, customized=nil)
202 []
202 []
203 end
203 end
204
204
205 # CustomValue after_save callback
205 # CustomValue after_save callback
206 def after_save_custom_value(custom_field, custom_value)
206 def after_save_custom_value(custom_field, custom_value)
207 end
207 end
208
208
209 def formatted_custom_value(view, custom_value, html=false)
209 def formatted_custom_value(view, custom_value, html=false)
210 formatted_value(view, custom_value.custom_field, custom_value.value, custom_value.customized, html)
210 formatted_value(view, custom_value.custom_field, custom_value.value, custom_value.customized, html)
211 end
211 end
212
212
213 def formatted_value(view, custom_field, value, customized=nil, html=false)
213 def formatted_value(view, custom_field, value, customized=nil, html=false)
214 casted = cast_value(custom_field, value, customized)
214 casted = cast_value(custom_field, value, customized)
215 if html && custom_field.url_pattern.present?
215 if html && custom_field.url_pattern.present?
216 texts_and_urls = Array.wrap(casted).map do |single_value|
216 texts_and_urls = Array.wrap(casted).map do |single_value|
217 text = view.format_object(single_value, false).to_s
217 text = view.format_object(single_value, false).to_s
218 url = url_from_pattern(custom_field, single_value, customized)
218 url = url_from_pattern(custom_field, single_value, customized)
219 [text, url]
219 [text, url]
220 end
220 end
221 links = texts_and_urls.sort_by(&:first).map {|text, url| view.link_to_if uri_with_safe_scheme?(url), text, url}
221 links = texts_and_urls.sort_by(&:first).map {|text, url| view.link_to_if uri_with_safe_scheme?(url), text, url}
222 links.join(', ').html_safe
222 links.join(', ').html_safe
223 else
223 else
224 casted
224 casted
225 end
225 end
226 end
226 end
227
227
228 # Returns an URL generated with the custom field URL pattern
228 # Returns an URL generated with the custom field URL pattern
229 # and variables substitution:
229 # and variables substitution:
230 # %value% => the custom field value
230 # %value% => the custom field value
231 # %id% => id of the customized object
231 # %id% => id of the customized object
232 # %project_id% => id of the project of the customized object if defined
232 # %project_id% => id of the project of the customized object if defined
233 # %project_identifier% => identifier of the project of the customized object if defined
233 # %project_identifier% => identifier of the project of the customized object if defined
234 # %m1%, %m2%... => capture groups matches of the custom field regexp if defined
234 # %m1%, %m2%... => capture groups matches of the custom field regexp if defined
235 def url_from_pattern(custom_field, value, customized)
235 def url_from_pattern(custom_field, value, customized)
236 url = custom_field.url_pattern.to_s.dup
236 url = custom_field.url_pattern.to_s.dup
237 url.gsub!('%value%') {URI.encode value.to_s}
237 url.gsub!('%value%') {URI.encode value.to_s}
238 url.gsub!('%id%') {URI.encode customized.id.to_s}
238 url.gsub!('%id%') {URI.encode customized.id.to_s}
239 url.gsub!('%project_id%') {URI.encode (customized.respond_to?(:project) ? customized.project.try(:id) : nil).to_s}
239 url.gsub!('%project_id%') {URI.encode (customized.respond_to?(:project) ? customized.project.try(:id) : nil).to_s}
240 url.gsub!('%project_identifier%') {URI.encode (customized.respond_to?(:project) ? customized.project.try(:identifier) : nil).to_s}
240 url.gsub!('%project_identifier%') {URI.encode (customized.respond_to?(:project) ? customized.project.try(:identifier) : nil).to_s}
241 if custom_field.regexp.present?
241 if custom_field.regexp.present?
242 url.gsub!(%r{%m(\d+)%}) do
242 url.gsub!(%r{%m(\d+)%}) do
243 m = $1.to_i
243 m = $1.to_i
244 if matches ||= value.to_s.match(Regexp.new(custom_field.regexp))
244 if matches ||= value.to_s.match(Regexp.new(custom_field.regexp))
245 URI.encode matches[m].to_s
245 URI.encode matches[m].to_s
246 end
246 end
247 end
247 end
248 end
248 end
249 url
249 url
250 end
250 end
251 protected :url_from_pattern
251 protected :url_from_pattern
252
252
253 # Returns the URL pattern with substitution tokens removed,
253 # Returns the URL pattern with substitution tokens removed,
254 # for validation purpose
254 # for validation purpose
255 def url_pattern_without_tokens(url_pattern)
255 def url_pattern_without_tokens(url_pattern)
256 url_pattern.to_s.gsub(/%(value|id|project_id|project_identifier|m\d+)%/, '')
256 url_pattern.to_s.gsub(/%(value|id|project_id|project_identifier|m\d+)%/, '')
257 end
257 end
258 protected :url_pattern_without_tokens
258 protected :url_pattern_without_tokens
259
259
260 def edit_tag(view, tag_id, tag_name, custom_value, options={})
260 def edit_tag(view, tag_id, tag_name, custom_value, options={})
261 view.text_field_tag(tag_name, custom_value.value, options.merge(:id => tag_id))
261 view.text_field_tag(tag_name, custom_value.value, options.merge(:id => tag_id))
262 end
262 end
263
263
264 def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
264 def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
265 view.text_field_tag(tag_name, value, options.merge(:id => tag_id)) +
265 view.text_field_tag(tag_name, value, options.merge(:id => tag_id)) +
266 bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
266 bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
267 end
267 end
268
268
269 def bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
269 def bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
270 if custom_field.is_required?
270 if custom_field.is_required?
271 ''.html_safe
271 ''.html_safe
272 else
272 else
273 view.content_tag('label',
273 view.content_tag('label',
274 view.check_box_tag(tag_name, '__none__', (value == '__none__'), :id => nil, :data => {:disables => "##{tag_id}"}) + l(:button_clear),
274 view.check_box_tag(tag_name, '__none__', (value == '__none__'), :id => nil, :data => {:disables => "##{tag_id}"}) + l(:button_clear),
275 :class => 'inline'
275 :class => 'inline'
276 )
276 )
277 end
277 end
278 end
278 end
279 protected :bulk_clear_tag
279 protected :bulk_clear_tag
280
280
281 def query_filter_options(custom_field, query)
281 def query_filter_options(custom_field, query)
282 {:type => :string}
282 {:type => :string}
283 end
283 end
284
284
285 def before_custom_field_save(custom_field)
285 def before_custom_field_save(custom_field)
286 end
286 end
287
287
288 # Returns a ORDER BY clause that can used to sort customized
288 # Returns a ORDER BY clause that can used to sort customized
289 # objects by their value of the custom field.
289 # objects by their value of the custom field.
290 # Returns nil if the custom field can not be used for sorting.
290 # Returns nil if the custom field can not be used for sorting.
291 def order_statement(custom_field)
291 def order_statement(custom_field)
292 # COALESCE is here to make sure that blank and NULL values are sorted equally
292 # COALESCE is here to make sure that blank and NULL values are sorted equally
293 "COALESCE(#{join_alias custom_field}.value, '')"
293 "COALESCE(#{join_alias custom_field}.value, '')"
294 end
294 end
295
295
296 # Returns a GROUP BY clause that can used to group by custom value
296 # Returns a GROUP BY clause that can used to group by custom value
297 # Returns nil if the custom field can not be used for grouping.
297 # Returns nil if the custom field can not be used for grouping.
298 def group_statement(custom_field)
298 def group_statement(custom_field)
299 nil
299 nil
300 end
300 end
301
301
302 # Returns a JOIN clause that is added to the query when sorting by custom values
302 # Returns a JOIN clause that is added to the query when sorting by custom values
303 def join_for_order_statement(custom_field)
303 def join_for_order_statement(custom_field)
304 alias_name = join_alias(custom_field)
304 alias_name = join_alias(custom_field)
305
305
306 "LEFT OUTER JOIN #{CustomValue.table_name} #{alias_name}" +
306 "LEFT OUTER JOIN #{CustomValue.table_name} #{alias_name}" +
307 " ON #{alias_name}.customized_type = '#{custom_field.class.customized_class.base_class.name}'" +
307 " ON #{alias_name}.customized_type = '#{custom_field.class.customized_class.base_class.name}'" +
308 " AND #{alias_name}.customized_id = #{custom_field.class.customized_class.table_name}.id" +
308 " AND #{alias_name}.customized_id = #{custom_field.class.customized_class.table_name}.id" +
309 " AND #{alias_name}.custom_field_id = #{custom_field.id}" +
309 " AND #{alias_name}.custom_field_id = #{custom_field.id}" +
310 " AND (#{custom_field.visibility_by_project_condition})" +
310 " AND (#{custom_field.visibility_by_project_condition})" +
311 " AND #{alias_name}.value <> ''" +
311 " AND #{alias_name}.value <> ''" +
312 " AND #{alias_name}.id = (SELECT max(#{alias_name}_2.id) FROM #{CustomValue.table_name} #{alias_name}_2" +
312 " AND #{alias_name}.id = (SELECT max(#{alias_name}_2.id) FROM #{CustomValue.table_name} #{alias_name}_2" +
313 " WHERE #{alias_name}_2.customized_type = #{alias_name}.customized_type" +
313 " WHERE #{alias_name}_2.customized_type = #{alias_name}.customized_type" +
314 " AND #{alias_name}_2.customized_id = #{alias_name}.customized_id" +
314 " AND #{alias_name}_2.customized_id = #{alias_name}.customized_id" +
315 " AND #{alias_name}_2.custom_field_id = #{alias_name}.custom_field_id)"
315 " AND #{alias_name}_2.custom_field_id = #{alias_name}.custom_field_id)"
316 end
316 end
317
317
318 def join_alias(custom_field)
318 def join_alias(custom_field)
319 "cf_#{custom_field.id}"
319 "cf_#{custom_field.id}"
320 end
320 end
321 protected :join_alias
321 protected :join_alias
322 end
322 end
323
323
324 class Unbounded < Base
324 class Unbounded < Base
325 def validate_single_value(custom_field, value, customized=nil)
325 def validate_single_value(custom_field, value, customized=nil)
326 errs = super
326 errs = super
327 value = value.to_s
327 value = value.to_s
328 unless custom_field.regexp.blank? or value =~ Regexp.new(custom_field.regexp)
328 unless custom_field.regexp.blank? or value =~ Regexp.new(custom_field.regexp)
329 errs << ::I18n.t('activerecord.errors.messages.invalid')
329 errs << ::I18n.t('activerecord.errors.messages.invalid')
330 end
330 end
331 if custom_field.min_length && value.length < custom_field.min_length
331 if custom_field.min_length && value.length < custom_field.min_length
332 errs << ::I18n.t('activerecord.errors.messages.too_short', :count => custom_field.min_length)
332 errs << ::I18n.t('activerecord.errors.messages.too_short', :count => custom_field.min_length)
333 end
333 end
334 if custom_field.max_length && custom_field.max_length > 0 && value.length > custom_field.max_length
334 if custom_field.max_length && custom_field.max_length > 0 && value.length > custom_field.max_length
335 errs << ::I18n.t('activerecord.errors.messages.too_long', :count => custom_field.max_length)
335 errs << ::I18n.t('activerecord.errors.messages.too_long', :count => custom_field.max_length)
336 end
336 end
337 errs
337 errs
338 end
338 end
339 end
339 end
340
340
341 class StringFormat < Unbounded
341 class StringFormat < Unbounded
342 add 'string'
342 add 'string'
343 self.searchable_supported = true
343 self.searchable_supported = true
344 self.form_partial = 'custom_fields/formats/string'
344 self.form_partial = 'custom_fields/formats/string'
345 field_attributes :text_formatting
345 field_attributes :text_formatting
346
346
347 def formatted_value(view, custom_field, value, customized=nil, html=false)
347 def formatted_value(view, custom_field, value, customized=nil, html=false)
348 if html
348 if html
349 if custom_field.url_pattern.present?
349 if custom_field.url_pattern.present?
350 super
350 super
351 elsif custom_field.text_formatting == 'full'
351 elsif custom_field.text_formatting == 'full'
352 view.textilizable(value, :object => customized)
352 view.textilizable(value, :object => customized)
353 else
353 else
354 value.to_s
354 value.to_s
355 end
355 end
356 else
356 else
357 value.to_s
357 value.to_s
358 end
358 end
359 end
359 end
360 end
360 end
361
361
362 class TextFormat < Unbounded
362 class TextFormat < Unbounded
363 add 'text'
363 add 'text'
364 self.searchable_supported = true
364 self.searchable_supported = true
365 self.form_partial = 'custom_fields/formats/text'
365 self.form_partial = 'custom_fields/formats/text'
366 self.change_as_diff = true
366 self.change_as_diff = true
367
367
368 def formatted_value(view, custom_field, value, customized=nil, html=false)
368 def formatted_value(view, custom_field, value, customized=nil, html=false)
369 if html
369 if html
370 if value.present?
370 if value.present?
371 if custom_field.text_formatting == 'full'
371 if custom_field.text_formatting == 'full'
372 view.textilizable(value, :object => customized)
372 view.textilizable(value, :object => customized)
373 else
373 else
374 view.simple_format(html_escape(value))
374 view.simple_format(html_escape(value))
375 end
375 end
376 else
376 else
377 ''
377 ''
378 end
378 end
379 else
379 else
380 value.to_s
380 value.to_s
381 end
381 end
382 end
382 end
383
383
384 def edit_tag(view, tag_id, tag_name, custom_value, options={})
384 def edit_tag(view, tag_id, tag_name, custom_value, options={})
385 view.text_area_tag(tag_name, custom_value.value, options.merge(:id => tag_id, :rows => 3))
385 view.text_area_tag(tag_name, custom_value.value, options.merge(:id => tag_id, :rows => 3))
386 end
386 end
387
387
388 def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
388 def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
389 view.text_area_tag(tag_name, value, options.merge(:id => tag_id, :rows => 3)) +
389 view.text_area_tag(tag_name, value, options.merge(:id => tag_id, :rows => 3)) +
390 '<br />'.html_safe +
390 '<br />'.html_safe +
391 bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
391 bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
392 end
392 end
393
393
394 def query_filter_options(custom_field, query)
394 def query_filter_options(custom_field, query)
395 {:type => :text}
395 {:type => :text}
396 end
396 end
397 end
397 end
398
398
399 class LinkFormat < StringFormat
399 class LinkFormat < StringFormat
400 add 'link'
400 add 'link'
401 self.searchable_supported = false
401 self.searchable_supported = false
402 self.form_partial = 'custom_fields/formats/link'
402 self.form_partial = 'custom_fields/formats/link'
403
403
404 def formatted_value(view, custom_field, value, customized=nil, html=false)
404 def formatted_value(view, custom_field, value, customized=nil, html=false)
405 if html && value.present?
405 if html && value.present?
406 if custom_field.url_pattern.present?
406 if custom_field.url_pattern.present?
407 url = url_from_pattern(custom_field, value, customized)
407 url = url_from_pattern(custom_field, value, customized)
408 else
408 else
409 url = value.to_s
409 url = value.to_s
410 unless url =~ %r{\A[a-z]+://}i
410 unless url =~ %r{\A[a-z]+://}i
411 # no protocol found, use http by default
411 # no protocol found, use http by default
412 url = "http://" + url
412 url = "http://" + url
413 end
413 end
414 end
414 end
415 view.link_to value.to_s.truncate(40), url
415 view.link_to value.to_s.truncate(40), url
416 else
416 else
417 value.to_s
417 value.to_s
418 end
418 end
419 end
419 end
420 end
420 end
421
421
422 class Numeric < Unbounded
422 class Numeric < Unbounded
423 self.form_partial = 'custom_fields/formats/numeric'
423 self.form_partial = 'custom_fields/formats/numeric'
424 self.totalable_supported = true
424 self.totalable_supported = true
425
425
426 def order_statement(custom_field)
426 def order_statement(custom_field)
427 # Make the database cast values into numeric
427 # Make the database cast values into numeric
428 # Postgresql will raise an error if a value can not be casted!
428 # Postgresql will raise an error if a value can not be casted!
429 # CustomValue validations should ensure that it doesn't occur
429 # CustomValue validations should ensure that it doesn't occur
430 "CAST(CASE #{join_alias custom_field}.value WHEN '' THEN '0' ELSE #{join_alias custom_field}.value END AS decimal(30,3))"
430 "CAST(CASE #{join_alias custom_field}.value WHEN '' THEN '0' ELSE #{join_alias custom_field}.value END AS decimal(30,3))"
431 end
431 end
432
432
433 # Returns totals for the given scope
433 # Returns totals for the given scope
434 def total_for_scope(custom_field, scope)
434 def total_for_scope(custom_field, scope)
435 scope.joins(:custom_values).
435 scope.joins(:custom_values).
436 where(:custom_values => {:custom_field_id => custom_field.id}).
436 where(:custom_values => {:custom_field_id => custom_field.id}).
437 where.not(:custom_values => {:value => ''}).
437 where.not(:custom_values => {:value => ''}).
438 sum("CAST(#{CustomValue.table_name}.value AS decimal(30,3))")
438 sum("CAST(#{CustomValue.table_name}.value AS decimal(30,3))")
439 end
439 end
440
440
441 def cast_total_value(custom_field, value)
441 def cast_total_value(custom_field, value)
442 cast_single_value(custom_field, value)
442 cast_single_value(custom_field, value)
443 end
443 end
444 end
444 end
445
445
446 class IntFormat < Numeric
446 class IntFormat < Numeric
447 add 'int'
447 add 'int'
448
448
449 def label
449 def label
450 "label_integer"
450 "label_integer"
451 end
451 end
452
452
453 def cast_single_value(custom_field, value, customized=nil)
453 def cast_single_value(custom_field, value, customized=nil)
454 value.to_i
454 value.to_i
455 end
455 end
456
456
457 def validate_single_value(custom_field, value, customized=nil)
457 def validate_single_value(custom_field, value, customized=nil)
458 errs = super
458 errs = super
459 errs << ::I18n.t('activerecord.errors.messages.not_a_number') unless value.to_s =~ /^[+-]?\d+$/
459 errs << ::I18n.t('activerecord.errors.messages.not_a_number') unless value.to_s =~ /^[+-]?\d+$/
460 errs
460 errs
461 end
461 end
462
462
463 def query_filter_options(custom_field, query)
463 def query_filter_options(custom_field, query)
464 {:type => :integer}
464 {:type => :integer}
465 end
465 end
466
466
467 def group_statement(custom_field)
467 def group_statement(custom_field)
468 order_statement(custom_field)
468 order_statement(custom_field)
469 end
469 end
470 end
470 end
471
471
472 class FloatFormat < Numeric
472 class FloatFormat < Numeric
473 add 'float'
473 add 'float'
474
474
475 def cast_single_value(custom_field, value, customized=nil)
475 def cast_single_value(custom_field, value, customized=nil)
476 value.to_f
476 value.to_f
477 end
477 end
478
478
479 def cast_total_value(custom_field, value)
479 def cast_total_value(custom_field, value)
480 value.to_f.round(2)
480 value.to_f.round(2)
481 end
481 end
482
482
483 def validate_single_value(custom_field, value, customized=nil)
483 def validate_single_value(custom_field, value, customized=nil)
484 errs = super
484 errs = super
485 errs << ::I18n.t('activerecord.errors.messages.invalid') unless (Kernel.Float(value) rescue nil)
485 errs << ::I18n.t('activerecord.errors.messages.invalid') unless (Kernel.Float(value) rescue nil)
486 errs
486 errs
487 end
487 end
488
488
489 def query_filter_options(custom_field, query)
489 def query_filter_options(custom_field, query)
490 {:type => :float}
490 {:type => :float}
491 end
491 end
492 end
492 end
493
493
494 class DateFormat < Unbounded
494 class DateFormat < Unbounded
495 add 'date'
495 add 'date'
496 self.form_partial = 'custom_fields/formats/date'
496 self.form_partial = 'custom_fields/formats/date'
497
497
498 def cast_single_value(custom_field, value, customized=nil)
498 def cast_single_value(custom_field, value, customized=nil)
499 value.to_date rescue nil
499 value.to_date rescue nil
500 end
500 end
501
501
502 def validate_single_value(custom_field, value, customized=nil)
502 def validate_single_value(custom_field, value, customized=nil)
503 if value =~ /^\d{4}-\d{2}-\d{2}$/ && (value.to_date rescue false)
503 if value =~ /^\d{4}-\d{2}-\d{2}$/ && (value.to_date rescue false)
504 []
504 []
505 else
505 else
506 [::I18n.t('activerecord.errors.messages.not_a_date')]
506 [::I18n.t('activerecord.errors.messages.not_a_date')]
507 end
507 end
508 end
508 end
509
509
510 def edit_tag(view, tag_id, tag_name, custom_value, options={})
510 def edit_tag(view, tag_id, tag_name, custom_value, options={})
511 view.date_field_tag(tag_name, custom_value.value, options.merge(:id => tag_id, :size => 10)) +
511 view.date_field_tag(tag_name, custom_value.value, options.merge(:id => tag_id, :size => 10)) +
512 view.calendar_for(tag_id)
512 view.calendar_for(tag_id)
513 end
513 end
514
514
515 def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
515 def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
516 view.date_field_tag(tag_name, value, options.merge(:id => tag_id, :size => 10)) +
516 view.date_field_tag(tag_name, value, options.merge(:id => tag_id, :size => 10)) +
517 view.calendar_for(tag_id) +
517 view.calendar_for(tag_id) +
518 bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
518 bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
519 end
519 end
520
520
521 def query_filter_options(custom_field, query)
521 def query_filter_options(custom_field, query)
522 {:type => :date}
522 {:type => :date}
523 end
523 end
524
524
525 def group_statement(custom_field)
525 def group_statement(custom_field)
526 order_statement(custom_field)
526 order_statement(custom_field)
527 end
527 end
528 end
528 end
529
529
530 class List < Base
530 class List < Base
531 self.multiple_supported = true
531 self.multiple_supported = true
532 field_attributes :edit_tag_style
532 field_attributes :edit_tag_style
533
533
534 def edit_tag(view, tag_id, tag_name, custom_value, options={})
534 def edit_tag(view, tag_id, tag_name, custom_value, options={})
535 if custom_value.custom_field.edit_tag_style == 'check_box'
535 if custom_value.custom_field.edit_tag_style == 'check_box'
536 check_box_edit_tag(view, tag_id, tag_name, custom_value, options)
536 check_box_edit_tag(view, tag_id, tag_name, custom_value, options)
537 else
537 else
538 select_edit_tag(view, tag_id, tag_name, custom_value, options)
538 select_edit_tag(view, tag_id, tag_name, custom_value, options)
539 end
539 end
540 end
540 end
541
541
542 def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
542 def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
543 opts = []
543 opts = []
544 opts << [l(:label_no_change_option), ''] unless custom_field.multiple?
544 opts << [l(:label_no_change_option), ''] unless custom_field.multiple?
545 opts << [l(:label_none), '__none__'] unless custom_field.is_required?
545 opts << [l(:label_none), '__none__'] unless custom_field.is_required?
546 opts += possible_values_options(custom_field, objects)
546 opts += possible_values_options(custom_field, objects)
547 view.select_tag(tag_name, view.options_for_select(opts, value), options.merge(:multiple => custom_field.multiple?))
547 view.select_tag(tag_name, view.options_for_select(opts, value), options.merge(:multiple => custom_field.multiple?))
548 end
548 end
549
549
550 def query_filter_options(custom_field, query)
550 def query_filter_options(custom_field, query)
551 {:type => :list_optional, :values => query_filter_values(custom_field, query)}
551 {:type => :list_optional, :values => query_filter_values(custom_field, query)}
552 end
552 end
553
553
554 protected
554 protected
555
555
556 # Returns the values that are available in the field filter
556 # Returns the values that are available in the field filter
557 def query_filter_values(custom_field, query)
557 def query_filter_values(custom_field, query)
558 possible_values_options(custom_field, query.project)
558 possible_values_options(custom_field, query.project)
559 end
559 end
560
560
561 # Renders the edit tag as a select tag
561 # Renders the edit tag as a select tag
562 def select_edit_tag(view, tag_id, tag_name, custom_value, options={})
562 def select_edit_tag(view, tag_id, tag_name, custom_value, options={})
563 blank_option = ''.html_safe
563 blank_option = ''.html_safe
564 unless custom_value.custom_field.multiple?
564 unless custom_value.custom_field.multiple?
565 if custom_value.custom_field.is_required?
565 if custom_value.custom_field.is_required?
566 unless custom_value.custom_field.default_value.present?
566 unless custom_value.custom_field.default_value.present?
567 blank_option = view.content_tag('option', "--- #{l(:actionview_instancetag_blank_option)} ---", :value => '')
567 blank_option = view.content_tag('option', "--- #{l(:actionview_instancetag_blank_option)} ---", :value => '')
568 end
568 end
569 else
569 else
570 blank_option = view.content_tag('option', '&nbsp;'.html_safe, :value => '')
570 blank_option = view.content_tag('option', '&nbsp;'.html_safe, :value => '')
571 end
571 end
572 end
572 end
573 options_tags = blank_option + view.options_for_select(possible_custom_value_options(custom_value), custom_value.value)
573 options_tags = blank_option + view.options_for_select(possible_custom_value_options(custom_value), custom_value.value)
574 s = view.select_tag(tag_name, options_tags, options.merge(:id => tag_id, :multiple => custom_value.custom_field.multiple?))
574 s = view.select_tag(tag_name, options_tags, options.merge(:id => tag_id, :multiple => custom_value.custom_field.multiple?))
575 if custom_value.custom_field.multiple?
575 if custom_value.custom_field.multiple?
576 s << view.hidden_field_tag(tag_name, '')
576 s << view.hidden_field_tag(tag_name, '')
577 end
577 end
578 s
578 s
579 end
579 end
580
580
581 # Renders the edit tag as check box or radio tags
581 # Renders the edit tag as check box or radio tags
582 def check_box_edit_tag(view, tag_id, tag_name, custom_value, options={})
582 def check_box_edit_tag(view, tag_id, tag_name, custom_value, options={})
583 opts = []
583 opts = []
584 unless custom_value.custom_field.multiple? || custom_value.custom_field.is_required?
584 unless custom_value.custom_field.multiple? || custom_value.custom_field.is_required?
585 opts << ["(#{l(:label_none)})", '']
585 opts << ["(#{l(:label_none)})", '']
586 end
586 end
587 opts += possible_custom_value_options(custom_value)
587 opts += possible_custom_value_options(custom_value)
588 s = ''.html_safe
588 s = ''.html_safe
589 tag_method = custom_value.custom_field.multiple? ? :check_box_tag : :radio_button_tag
589 tag_method = custom_value.custom_field.multiple? ? :check_box_tag : :radio_button_tag
590 opts.each do |label, value|
590 opts.each do |label, value|
591 value ||= label
591 value ||= label
592 checked = (custom_value.value.is_a?(Array) && custom_value.value.include?(value)) || custom_value.value.to_s == value
592 checked = (custom_value.value.is_a?(Array) && custom_value.value.include?(value)) || custom_value.value.to_s == value
593 tag = view.send(tag_method, tag_name, value, checked, :id => tag_id)
593 tag = view.send(tag_method, tag_name, value, checked, :id => tag_id)
594 # set the id on the first tag only
594 # set the id on the first tag only
595 tag_id = nil
595 tag_id = nil
596 s << view.content_tag('label', tag + ' ' + label)
596 s << view.content_tag('label', tag + ' ' + label)
597 end
597 end
598 if custom_value.custom_field.multiple?
598 if custom_value.custom_field.multiple?
599 s << view.hidden_field_tag(tag_name, '')
599 s << view.hidden_field_tag(tag_name, '')
600 end
600 end
601 css = "#{options[:class]} check_box_group"
601 css = "#{options[:class]} check_box_group"
602 view.content_tag('span', s, options.merge(:class => css))
602 view.content_tag('span', s, options.merge(:class => css))
603 end
603 end
604 end
604 end
605
605
606 class ListFormat < List
606 class ListFormat < List
607 add 'list'
607 add 'list'
608 self.searchable_supported = true
608 self.searchable_supported = true
609 self.form_partial = 'custom_fields/formats/list'
609 self.form_partial = 'custom_fields/formats/list'
610
610
611 def possible_custom_value_options(custom_value)
611 def possible_custom_value_options(custom_value)
612 options = possible_values_options(custom_value.custom_field)
612 options = possible_values_options(custom_value.custom_field)
613 missing = [custom_value.value].flatten.reject(&:blank?) - options
613 missing = [custom_value.value].flatten.reject(&:blank?) - options
614 if missing.any?
614 if missing.any?
615 options += missing
615 options += missing
616 end
616 end
617 options
617 options
618 end
618 end
619
619
620 def possible_values_options(custom_field, object=nil)
620 def possible_values_options(custom_field, object=nil)
621 custom_field.possible_values
621 custom_field.possible_values
622 end
622 end
623
623
624 def validate_custom_field(custom_field)
624 def validate_custom_field(custom_field)
625 errors = []
625 errors = []
626 errors << [:possible_values, :blank] if custom_field.possible_values.blank?
626 errors << [:possible_values, :blank] if custom_field.possible_values.blank?
627 errors << [:possible_values, :invalid] unless custom_field.possible_values.is_a? Array
627 errors << [:possible_values, :invalid] unless custom_field.possible_values.is_a? Array
628 errors
628 errors
629 end
629 end
630
630
631 def validate_custom_value(custom_value)
631 def validate_custom_value(custom_value)
632 values = Array.wrap(custom_value.value).reject {|value| value.to_s == ''}
632 values = Array.wrap(custom_value.value).reject {|value| value.to_s == ''}
633 invalid_values = values - Array.wrap(custom_value.value_was) - custom_value.custom_field.possible_values
633 invalid_values = values - Array.wrap(custom_value.value_was) - custom_value.custom_field.possible_values
634 if invalid_values.any?
634 if invalid_values.any?
635 [::I18n.t('activerecord.errors.messages.inclusion')]
635 [::I18n.t('activerecord.errors.messages.inclusion')]
636 else
636 else
637 []
637 []
638 end
638 end
639 end
639 end
640
640
641 def group_statement(custom_field)
641 def group_statement(custom_field)
642 order_statement(custom_field)
642 order_statement(custom_field)
643 end
643 end
644 end
644 end
645
645
646 class BoolFormat < List
646 class BoolFormat < List
647 add 'bool'
647 add 'bool'
648 self.multiple_supported = false
648 self.multiple_supported = false
649 self.form_partial = 'custom_fields/formats/bool'
649 self.form_partial = 'custom_fields/formats/bool'
650
650
651 def label
651 def label
652 "label_boolean"
652 "label_boolean"
653 end
653 end
654
654
655 def cast_single_value(custom_field, value, customized=nil)
655 def cast_single_value(custom_field, value, customized=nil)
656 value == '1' ? true : false
656 value == '1' ? true : false
657 end
657 end
658
658
659 def possible_values_options(custom_field, object=nil)
659 def possible_values_options(custom_field, object=nil)
660 [[::I18n.t(:general_text_Yes), '1'], [::I18n.t(:general_text_No), '0']]
660 [[::I18n.t(:general_text_Yes), '1'], [::I18n.t(:general_text_No), '0']]
661 end
661 end
662
662
663 def group_statement(custom_field)
663 def group_statement(custom_field)
664 order_statement(custom_field)
664 order_statement(custom_field)
665 end
665 end
666
666
667 def edit_tag(view, tag_id, tag_name, custom_value, options={})
667 def edit_tag(view, tag_id, tag_name, custom_value, options={})
668 case custom_value.custom_field.edit_tag_style
668 case custom_value.custom_field.edit_tag_style
669 when 'check_box'
669 when 'check_box'
670 single_check_box_edit_tag(view, tag_id, tag_name, custom_value, options)
670 single_check_box_edit_tag(view, tag_id, tag_name, custom_value, options)
671 when 'radio'
671 when 'radio'
672 check_box_edit_tag(view, tag_id, tag_name, custom_value, options)
672 check_box_edit_tag(view, tag_id, tag_name, custom_value, options)
673 else
673 else
674 select_edit_tag(view, tag_id, tag_name, custom_value, options)
674 select_edit_tag(view, tag_id, tag_name, custom_value, options)
675 end
675 end
676 end
676 end
677
677
678 # Renders the edit tag as a simple check box
678 # Renders the edit tag as a simple check box
679 def single_check_box_edit_tag(view, tag_id, tag_name, custom_value, options={})
679 def single_check_box_edit_tag(view, tag_id, tag_name, custom_value, options={})
680 s = ''.html_safe
680 s = ''.html_safe
681 s << view.hidden_field_tag(tag_name, '0', :id => nil)
681 s << view.hidden_field_tag(tag_name, '0', :id => nil)
682 s << view.check_box_tag(tag_name, '1', custom_value.value.to_s == '1', :id => tag_id)
682 s << view.check_box_tag(tag_name, '1', custom_value.value.to_s == '1', :id => tag_id)
683 view.content_tag('span', s, options)
683 view.content_tag('span', s, options)
684 end
684 end
685 end
685 end
686
686
687 class RecordList < List
687 class RecordList < List
688 self.customized_class_names = %w(Issue TimeEntry Version Document Project)
688 self.customized_class_names = %w(Issue TimeEntry Version Document Project)
689
689
690 def cast_single_value(custom_field, value, customized=nil)
690 def cast_single_value(custom_field, value, customized=nil)
691 target_class.find_by_id(value.to_i) if value.present?
691 target_class.find_by_id(value.to_i) if value.present?
692 end
692 end
693
693
694 def target_class
694 def target_class
695 @target_class ||= self.class.name[/^(.*::)?(.+)Format$/, 2].constantize rescue nil
695 @target_class ||= self.class.name[/^(.*::)?(.+)Format$/, 2].constantize rescue nil
696 end
696 end
697
697
698 def reset_target_class
698 def reset_target_class
699 @target_class = nil
699 @target_class = nil
700 end
700 end
701
701
702 def possible_custom_value_options(custom_value)
702 def possible_custom_value_options(custom_value)
703 options = possible_values_options(custom_value.custom_field, custom_value.customized)
703 options = possible_values_options(custom_value.custom_field, custom_value.customized)
704 missing = [custom_value.value_was].flatten.reject(&:blank?) - options.map(&:last)
704 missing = [custom_value.value_was].flatten.reject(&:blank?) - options.map(&:last)
705 if missing.any?
705 if missing.any?
706 options += target_class.where(:id => missing.map(&:to_i)).map {|o| [o.to_s, o.id.to_s]}
706 options += target_class.where(:id => missing.map(&:to_i)).map {|o| [o.to_s, o.id.to_s]}
707 end
707 end
708 options
708 options
709 end
709 end
710
710
711 def order_statement(custom_field)
711 def order_statement(custom_field)
712 if target_class.respond_to?(:fields_for_order_statement)
712 if target_class.respond_to?(:fields_for_order_statement)
713 target_class.fields_for_order_statement(value_join_alias(custom_field))
713 target_class.fields_for_order_statement(value_join_alias(custom_field))
714 end
714 end
715 end
715 end
716
716
717 def group_statement(custom_field)
717 def group_statement(custom_field)
718 "COALESCE(#{join_alias custom_field}.value, '')"
718 "COALESCE(#{join_alias custom_field}.value, '')"
719 end
719 end
720
720
721 def join_for_order_statement(custom_field)
721 def join_for_order_statement(custom_field)
722 alias_name = join_alias(custom_field)
722 alias_name = join_alias(custom_field)
723
723
724 "LEFT OUTER JOIN #{CustomValue.table_name} #{alias_name}" +
724 "LEFT OUTER JOIN #{CustomValue.table_name} #{alias_name}" +
725 " ON #{alias_name}.customized_type = '#{custom_field.class.customized_class.base_class.name}'" +
725 " ON #{alias_name}.customized_type = '#{custom_field.class.customized_class.base_class.name}'" +
726 " AND #{alias_name}.customized_id = #{custom_field.class.customized_class.table_name}.id" +
726 " AND #{alias_name}.customized_id = #{custom_field.class.customized_class.table_name}.id" +
727 " AND #{alias_name}.custom_field_id = #{custom_field.id}" +
727 " AND #{alias_name}.custom_field_id = #{custom_field.id}" +
728 " AND (#{custom_field.visibility_by_project_condition})" +
728 " AND (#{custom_field.visibility_by_project_condition})" +
729 " AND #{alias_name}.value <> ''" +
729 " AND #{alias_name}.value <> ''" +
730 " AND #{alias_name}.id = (SELECT max(#{alias_name}_2.id) FROM #{CustomValue.table_name} #{alias_name}_2" +
730 " AND #{alias_name}.id = (SELECT max(#{alias_name}_2.id) FROM #{CustomValue.table_name} #{alias_name}_2" +
731 " WHERE #{alias_name}_2.customized_type = #{alias_name}.customized_type" +
731 " WHERE #{alias_name}_2.customized_type = #{alias_name}.customized_type" +
732 " AND #{alias_name}_2.customized_id = #{alias_name}.customized_id" +
732 " AND #{alias_name}_2.customized_id = #{alias_name}.customized_id" +
733 " AND #{alias_name}_2.custom_field_id = #{alias_name}.custom_field_id)" +
733 " AND #{alias_name}_2.custom_field_id = #{alias_name}.custom_field_id)" +
734 " LEFT OUTER JOIN #{target_class.table_name} #{value_join_alias custom_field}" +
734 " LEFT OUTER JOIN #{target_class.table_name} #{value_join_alias custom_field}" +
735 " ON CAST(CASE #{alias_name}.value WHEN '' THEN '0' ELSE #{alias_name}.value END AS decimal(30,0)) = #{value_join_alias custom_field}.id"
735 " ON CAST(CASE #{alias_name}.value WHEN '' THEN '0' ELSE #{alias_name}.value END AS decimal(30,0)) = #{value_join_alias custom_field}.id"
736 end
736 end
737
737
738 def value_join_alias(custom_field)
738 def value_join_alias(custom_field)
739 join_alias(custom_field) + "_" + custom_field.field_format
739 join_alias(custom_field) + "_" + custom_field.field_format
740 end
740 end
741 protected :value_join_alias
741 protected :value_join_alias
742 end
742 end
743
743
744 class EnumerationFormat < RecordList
744 class EnumerationFormat < RecordList
745 add 'enumeration'
745 add 'enumeration'
746 self.form_partial = 'custom_fields/formats/enumeration'
746 self.form_partial = 'custom_fields/formats/enumeration'
747
747
748 def label
748 def label
749 "label_field_format_enumeration"
749 "label_field_format_enumeration"
750 end
750 end
751
751
752 def target_class
752 def target_class
753 @target_class ||= CustomFieldEnumeration
753 @target_class ||= CustomFieldEnumeration
754 end
754 end
755
755
756 def possible_values_options(custom_field, object=nil)
756 def possible_values_options(custom_field, object=nil)
757 possible_values_records(custom_field, object).map {|u| [u.name, u.id.to_s]}
757 possible_values_records(custom_field, object).map {|u| [u.name, u.id.to_s]}
758 end
758 end
759
759
760 def possible_values_records(custom_field, object=nil)
760 def possible_values_records(custom_field, object=nil)
761 custom_field.enumerations.active
761 custom_field.enumerations.active
762 end
762 end
763
763
764 def value_from_keyword(custom_field, keyword, object)
764 def value_from_keyword(custom_field, keyword, object)
765 value = custom_field.enumerations.where("LOWER(name) LIKE LOWER(?)", keyword).first
765 value = custom_field.enumerations.where("LOWER(name) LIKE LOWER(?)", keyword).first
766 value ? value.id : nil
766 value ? value.id : nil
767 end
767 end
768 end
768 end
769
769
770 class UserFormat < RecordList
770 class UserFormat < RecordList
771 add 'user'
771 add 'user'
772 self.form_partial = 'custom_fields/formats/user'
772 self.form_partial = 'custom_fields/formats/user'
773 field_attributes :user_role
773 field_attributes :user_role
774
774
775 def possible_values_options(custom_field, object=nil)
775 def possible_values_options(custom_field, object=nil)
776 possible_values_records(custom_field, object).map {|u| [u.name, u.id.to_s]}
776 possible_values_records(custom_field, object).map {|u| [u.name, u.id.to_s]}
777 end
777 end
778
778
779 def possible_values_records(custom_field, object=nil)
779 def possible_values_records(custom_field, object=nil)
780 if object.is_a?(Array)
780 if object.is_a?(Array)
781 projects = object.map {|o| o.respond_to?(:project) ? o.project : nil}.compact.uniq
781 projects = object.map {|o| o.respond_to?(:project) ? o.project : nil}.compact.uniq
782 projects.map {|project| possible_values_records(custom_field, project)}.reduce(:&) || []
782 projects.map {|project| possible_values_records(custom_field, project)}.reduce(:&) || []
783 elsif object.respond_to?(:project) && object.project
783 elsif object.respond_to?(:project) && object.project
784 scope = object.project.users
784 scope = object.project.users
785 if custom_field.user_role.is_a?(Array)
785 if custom_field.user_role.is_a?(Array)
786 role_ids = custom_field.user_role.map(&:to_s).reject(&:blank?).map(&:to_i)
786 role_ids = custom_field.user_role.map(&:to_s).reject(&:blank?).map(&:to_i)
787 if role_ids.any?
787 if role_ids.any?
788 scope = scope.where("#{Member.table_name}.id IN (SELECT DISTINCT member_id FROM #{MemberRole.table_name} WHERE role_id IN (?))", role_ids)
788 scope = scope.where("#{Member.table_name}.id IN (SELECT DISTINCT member_id FROM #{MemberRole.table_name} WHERE role_id IN (?))", role_ids)
789 end
789 end
790 end
790 end
791 scope.sorted
791 scope.sorted
792 else
792 else
793 []
793 []
794 end
794 end
795 end
795 end
796
796
797 def value_from_keyword(custom_field, keyword, object)
797 def value_from_keyword(custom_field, keyword, object)
798 users = possible_values_records(custom_field, object).to_a
798 users = possible_values_records(custom_field, object).to_a
799 user = Principal.detect_by_keyword(users, keyword)
799 user = Principal.detect_by_keyword(users, keyword)
800 user ? user.id : nil
800 user ? user.id : nil
801 end
801 end
802
802
803 def before_custom_field_save(custom_field)
803 def before_custom_field_save(custom_field)
804 super
804 super
805 if custom_field.user_role.is_a?(Array)
805 if custom_field.user_role.is_a?(Array)
806 custom_field.user_role.map!(&:to_s).reject!(&:blank?)
806 custom_field.user_role.map!(&:to_s).reject!(&:blank?)
807 end
807 end
808 end
808 end
809 end
809 end
810
810
811 class VersionFormat < RecordList
811 class VersionFormat < RecordList
812 add 'version'
812 add 'version'
813 self.form_partial = 'custom_fields/formats/version'
813 self.form_partial = 'custom_fields/formats/version'
814 field_attributes :version_status
814 field_attributes :version_status
815
815
816 def possible_values_options(custom_field, object=nil)
816 def possible_values_options(custom_field, object=nil)
817 versions_options(custom_field, object)
817 versions_options(custom_field, object)
818 end
818 end
819
819
820 def before_custom_field_save(custom_field)
820 def before_custom_field_save(custom_field)
821 super
821 super
822 if custom_field.version_status.is_a?(Array)
822 if custom_field.version_status.is_a?(Array)
823 custom_field.version_status.map!(&:to_s).reject!(&:blank?)
823 custom_field.version_status.map!(&:to_s).reject!(&:blank?)
824 end
824 end
825 end
825 end
826
826
827 protected
827 protected
828
828
829 def query_filter_values(custom_field, query)
829 def query_filter_values(custom_field, query)
830 versions_options(custom_field, query.project, true)
830 versions_options(custom_field, query.project, true)
831 end
831 end
832
832
833 def versions_options(custom_field, object, all_statuses=false)
833 def versions_options(custom_field, object, all_statuses=false)
834 if object.is_a?(Array)
834 if object.is_a?(Array)
835 projects = object.map {|o| o.respond_to?(:project) ? o.project : nil}.compact.uniq
835 projects = object.map {|o| o.respond_to?(:project) ? o.project : nil}.compact.uniq
836 projects.map {|project| possible_values_options(custom_field, project)}.reduce(:&) || []
836 projects.map {|project| possible_values_options(custom_field, project)}.reduce(:&) || []
837 elsif object.respond_to?(:project) && object.project
837 elsif object.respond_to?(:project) && object.project
838 scope = object.project.shared_versions
838 scope = object.project.shared_versions
839 filtered_versions_options(custom_field, scope, all_statuses)
839 filtered_versions_options(custom_field, scope, all_statuses)
840 elsif object.nil?
840 elsif object.nil?
841 scope = ::Version.visible.where(:sharing => 'system')
841 scope = ::Version.visible.where(:sharing => 'system')
842 filtered_versions_options(custom_field, scope, all_statuses)
842 filtered_versions_options(custom_field, scope, all_statuses)
843 else
843 else
844 []
844 []
845 end
845 end
846 end
846 end
847
847
848 def filtered_versions_options(custom_field, scope, all_statuses=false)
848 def filtered_versions_options(custom_field, scope, all_statuses=false)
849 if !all_statuses && custom_field.version_status.is_a?(Array)
849 if !all_statuses && custom_field.version_status.is_a?(Array)
850 statuses = custom_field.version_status.map(&:to_s).reject(&:blank?)
850 statuses = custom_field.version_status.map(&:to_s).reject(&:blank?)
851 if statuses.any?
851 if statuses.any?
852 scope = scope.where(:status => statuses.map(&:to_s))
852 scope = scope.where(:status => statuses.map(&:to_s))
853 end
853 end
854 end
854 end
855 scope.sort.collect{|u| [u.to_s, u.id.to_s] }
855 scope.sort.collect{|u| [u.to_s, u.id.to_s] }
856 end
856 end
857 end
857 end
858
858
859 class AttachementFormat < Base
859 class AttachementFormat < Base
860 add 'attachment'
860 add 'attachment'
861 self.form_partial = 'custom_fields/formats/attachment'
861 self.form_partial = 'custom_fields/formats/attachment'
862 self.is_filter_supported = false
862 self.is_filter_supported = false
863 self.change_no_details = true
863 self.change_no_details = true
864 field_attributes :extensions_allowed
864
865
865 def set_custom_field_value(custom_field, custom_field_value, value)
866 def set_custom_field_value(custom_field, custom_field_value, value)
866 attachment_present = false
867 attachment_present = false
867
868
868 if value.is_a?(Hash)
869 if value.is_a?(Hash)
869 attachment_present = true
870 attachment_present = true
870 value = value.except(:blank)
871 value = value.except(:blank)
871
872
872 if value.values.any? && value.values.all? {|v| v.is_a?(Hash)}
873 if value.values.any? && value.values.all? {|v| v.is_a?(Hash)}
873 value = value.values.first
874 value = value.values.first
874 end
875 end
875
876
876 if value.key?(:id)
877 if value.key?(:id)
877 value = set_custom_field_value_by_id(custom_field, custom_field_value, value[:id])
878 value = set_custom_field_value_by_id(custom_field, custom_field_value, value[:id])
878 elsif value[:token].present?
879 elsif value[:token].present?
879 if attachment = Attachment.find_by_token(value[:token])
880 if attachment = Attachment.find_by_token(value[:token])
880 value = attachment.id.to_s
881 value = attachment.id.to_s
881 else
882 else
882 value = ''
883 value = ''
883 end
884 end
884 elsif value.key?(:file)
885 elsif value.key?(:file)
885 attachment = Attachment.new(:file => value[:file], :author => User.current)
886 attachment = Attachment.new(:file => value[:file], :author => User.current)
886 if attachment.save
887 if attachment.save
887 value = attachment.id.to_s
888 value = attachment.id.to_s
888 else
889 else
889 value = ''
890 value = ''
890 end
891 end
891 else
892 else
892 attachment_present = false
893 attachment_present = false
893 value = ''
894 value = ''
894 end
895 end
895 elsif value.is_a?(String)
896 elsif value.is_a?(String)
896 value = set_custom_field_value_by_id(custom_field, custom_field_value, value)
897 value = set_custom_field_value_by_id(custom_field, custom_field_value, value)
897 end
898 end
898 custom_field_value.instance_variable_set "@attachment_present", attachment_present
899 custom_field_value.instance_variable_set "@attachment_present", attachment_present
899
900
900 value
901 value
901 end
902 end
902
903
903 def set_custom_field_value_by_id(custom_field, custom_field_value, id)
904 def set_custom_field_value_by_id(custom_field, custom_field_value, id)
904 attachment = Attachment.find_by_id(id)
905 attachment = Attachment.find_by_id(id)
905 if attachment && attachment.container.is_a?(CustomValue) && attachment.container.customized == custom_field_value.customized
906 if attachment && attachment.container.is_a?(CustomValue) && attachment.container.customized == custom_field_value.customized
906 id.to_s
907 id.to_s
907 else
908 else
908 ''
909 ''
909 end
910 end
910 end
911 end
911 private :set_custom_field_value_by_id
912 private :set_custom_field_value_by_id
912
913
913 def cast_single_value(custom_field, value, customized=nil)
914 def cast_single_value(custom_field, value, customized=nil)
914 Attachment.find_by_id(value.to_i) if value.present? && value.respond_to?(:to_i)
915 Attachment.find_by_id(value.to_i) if value.present? && value.respond_to?(:to_i)
915 end
916 end
916
917
917 def validate_custom_value(custom_value)
918 def validate_custom_value(custom_value)
918 errors = []
919 errors = []
919
920
920 if custom_value.instance_variable_get("@attachment_present") && custom_value.value.blank?
921 if custom_value.value.blank?
921 errors << ::I18n.t('activerecord.errors.messages.invalid')
922 if custom_value.instance_variable_get("@attachment_present")
923 errors << ::I18n.t('activerecord.errors.messages.invalid')
924 end
925 else
926 if custom_value.value.present?
927 attachment = Attachment.where(:id => custom_value.value.to_s).first
928 extensions = custom_value.custom_field.extensions_allowed
929 if attachment && extensions.present? && !attachment.extension_in?(extensions)
930 errors << "#{::I18n.t('activerecord.errors.messages.invalid')} (#{l(:setting_attachment_extensions_allowed)}: #{extensions})"
931 end
932 end
922 end
933 end
923
934
924 errors.uniq
935 errors.uniq
925 end
936 end
926
937
927 def after_save_custom_value(custom_field, custom_value)
938 def after_save_custom_value(custom_field, custom_value)
928 if custom_value.value_changed?
939 if custom_value.value_changed?
929 if custom_value.value.present?
940 if custom_value.value.present?
930 attachment = Attachment.where(:id => custom_value.value.to_s).first
941 attachment = Attachment.where(:id => custom_value.value.to_s).first
931 if attachment
942 if attachment
932 attachment.container = custom_value
943 attachment.container = custom_value
933 attachment.save!
944 attachment.save!
934 end
945 end
935 end
946 end
936 if custom_value.value_was.present?
947 if custom_value.value_was.present?
937 attachment = Attachment.where(:id => custom_value.value_was.to_s).first
948 attachment = Attachment.where(:id => custom_value.value_was.to_s).first
938 if attachment
949 if attachment
939 attachment.destroy
950 attachment.destroy
940 end
951 end
941 end
952 end
942 end
953 end
943 end
954 end
944
955
945 def edit_tag(view, tag_id, tag_name, custom_value, options={})
956 def edit_tag(view, tag_id, tag_name, custom_value, options={})
946 attachment = nil
957 attachment = nil
947 if custom_value.value.present? #&& custom_value.value == custom_value.value_was
958 if custom_value.value.present? #&& custom_value.value == custom_value.value_was
948 attachment = Attachment.find_by_id(custom_value.value)
959 attachment = Attachment.find_by_id(custom_value.value)
949 end
960 end
950
961
951 view.hidden_field_tag("#{tag_name}[blank]", "") +
962 view.hidden_field_tag("#{tag_name}[blank]", "") +
952 view.render(:partial => 'attachments/form',
963 view.render(:partial => 'attachments/form',
953 :locals => {
964 :locals => {
954 :attachment_param => tag_name,
965 :attachment_param => tag_name,
955 :multiple => false,
966 :multiple => false,
956 :description => false,
967 :description => false,
957 :saved_attachments => [attachment].compact,
968 :saved_attachments => [attachment].compact,
958 :filedrop => false
969 :filedrop => false
959 })
970 })
960 end
971 end
961 end
972 end
962 end
973 end
963 end
974 end
@@ -1,156 +1,194
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require File.expand_path('../../../../../test_helper', __FILE__)
18 require File.expand_path('../../../../../test_helper', __FILE__)
19
19
20 class AttachmentFieldFormatTest < Redmine::IntegrationTest
20 class AttachmentFieldFormatTest < Redmine::IntegrationTest
21 fixtures :projects,
21 fixtures :projects,
22 :users, :email_addresses,
22 :users, :email_addresses,
23 :roles,
23 :roles,
24 :members,
24 :members,
25 :member_roles,
25 :member_roles,
26 :trackers,
26 :trackers,
27 :projects_trackers,
27 :projects_trackers,
28 :enabled_modules,
28 :enabled_modules,
29 :issue_statuses,
29 :issue_statuses,
30 :issues,
30 :issues,
31 :enumerations,
31 :enumerations,
32 :custom_fields,
32 :custom_fields,
33 :custom_values,
33 :custom_values,
34 :custom_fields_trackers,
34 :custom_fields_trackers,
35 :attachments
35 :attachments
36
36
37 def setup
37 def setup
38 set_tmp_attachments_directory
38 set_tmp_attachments_directory
39 @field = IssueCustomField.generate!(:name => "File", :field_format => "attachment")
39 @field = IssueCustomField.generate!(:name => "File", :field_format => "attachment")
40 log_user "jsmith", "jsmith"
40 log_user "jsmith", "jsmith"
41 end
41 end
42
42
43 def test_new_should_include_inputs
43 def test_new_should_include_inputs
44 get '/projects/ecookbook/issues/new'
44 get '/projects/ecookbook/issues/new'
45 assert_response :success
45 assert_response :success
46
46
47 assert_select '[name^=?]', "issue[custom_field_values][#{@field.id}]", 2
47 assert_select '[name^=?]', "issue[custom_field_values][#{@field.id}]", 2
48 assert_select 'input[name=?][type=hidden][value=""]', "issue[custom_field_values][#{@field.id}][blank]"
48 assert_select 'input[name=?][type=hidden][value=""]', "issue[custom_field_values][#{@field.id}][blank]"
49 end
49 end
50
50
51 def test_create_with_attachment
51 def test_create_with_attachment
52 issue = new_record(Issue) do
52 issue = new_record(Issue) do
53 assert_difference 'Attachment.count' do
53 assert_difference 'Attachment.count' do
54 post '/projects/ecookbook/issues', {
54 post '/projects/ecookbook/issues', {
55 :issue => {
55 :issue => {
56 :subject => "Subject",
56 :subject => "Subject",
57 :custom_field_values => {
57 :custom_field_values => {
58 @field.id => {
58 @field.id => {
59 'blank' => '',
59 'blank' => '',
60 '1' => {:file => uploaded_test_file("testfile.txt", "text/plain")}
60 '1' => {:file => uploaded_test_file("testfile.txt", "text/plain")}
61 }
61 }
62 }
62 }
63 }
63 }
64 }
64 }
65 assert_response 302
65 assert_response 302
66 end
66 end
67 end
67 end
68
68
69 custom_value = issue.custom_value_for(@field)
69 custom_value = issue.custom_value_for(@field)
70 assert custom_value
70 assert custom_value
71 assert custom_value.value.present?
71 assert custom_value.value.present?
72
72
73 attachment = Attachment.find_by_id(custom_value.value)
73 attachment = Attachment.find_by_id(custom_value.value)
74 assert attachment
74 assert attachment
75 assert_equal custom_value, attachment.container
75 assert_equal custom_value, attachment.container
76
76
77 follow_redirect!
77 follow_redirect!
78 assert_response :success
78 assert_response :success
79
79
80 # link to the attachment
80 # link to the attachment
81 link = css_select(".cf_#{@field.id} .value a")
81 link = css_select(".cf_#{@field.id} .value a")
82 assert_equal 1, link.size
82 assert_equal 1, link.size
83 assert_equal "testfile.txt", link.text
83 assert_equal "testfile.txt", link.text
84
84
85 # download the attachment
85 # download the attachment
86 get link.attr('href')
86 get link.attr('href')
87 assert_response :success
87 assert_response :success
88 assert_equal "text/plain", response.content_type
88 assert_equal "text/plain", response.content_type
89 end
89 end
90
90
91 def test_create_without_attachment
91 def test_create_without_attachment
92 issue = new_record(Issue) do
92 issue = new_record(Issue) do
93 assert_no_difference 'Attachment.count' do
93 assert_no_difference 'Attachment.count' do
94 post '/projects/ecookbook/issues', {
94 post '/projects/ecookbook/issues', {
95 :issue => {
95 :issue => {
96 :subject => "Subject",
96 :subject => "Subject",
97 :custom_field_values => {
97 :custom_field_values => {
98 @field.id => {:blank => ''}
98 @field.id => {:blank => ''}
99 }
99 }
100 }
100 }
101 }
101 }
102 assert_response 302
102 assert_response 302
103 end
103 end
104 end
104 end
105
105
106 custom_value = issue.custom_value_for(@field)
106 custom_value = issue.custom_value_for(@field)
107 assert custom_value
107 assert custom_value
108 assert custom_value.value.blank?
108 assert custom_value.value.blank?
109
109
110 follow_redirect!
110 follow_redirect!
111 assert_response :success
111 assert_response :success
112
112
113 # no links to the attachment
113 # no links to the attachment
114 assert_select ".cf_#{@field.id} .value a", 0
114 assert_select ".cf_#{@field.id} .value a", 0
115 end
115 end
116
116
117 def test_failure_on_create_should_preserve_attachment
117 def test_failure_on_create_should_preserve_attachment
118 attachment = new_record(Attachment) do
118 attachment = new_record(Attachment) do
119 assert_no_difference 'Issue.count' do
119 assert_no_difference 'Issue.count' do
120 post '/projects/ecookbook/issues', {
120 post '/projects/ecookbook/issues', {
121 :issue => {
121 :issue => {
122 :subject => "",
122 :subject => "",
123 :custom_field_values => {
123 :custom_field_values => {
124 @field.id => {:file => uploaded_test_file("testfile.txt", "text/plain")}
124 @field.id => {:file => uploaded_test_file("testfile.txt", "text/plain")}
125 }
125 }
126 }
126 }
127 }
127 }
128 assert_response :success
128 assert_response :success
129 assert_select_error /Subject cannot be blank/
129 assert_select_error /Subject cannot be blank/
130 end
130 end
131 end
131 end
132
132
133 assert_nil attachment.container_id
133 assert_nil attachment.container_id
134 assert_select 'input[name=?][value=?][type=hidden]', "issue[custom_field_values][#{@field.id}][p0][token]", attachment.token
134 assert_select 'input[name=?][value=?][type=hidden]', "issue[custom_field_values][#{@field.id}][p0][token]", attachment.token
135 assert_select 'input[name=?][value=?]', "issue[custom_field_values][#{@field.id}][p0][filename]", 'testfile.txt'
135 assert_select 'input[name=?][value=?]', "issue[custom_field_values][#{@field.id}][p0][filename]", 'testfile.txt'
136
136
137 issue = new_record(Issue) do
137 issue = new_record(Issue) do
138 assert_no_difference 'Attachment.count' do
138 assert_no_difference 'Attachment.count' do
139 post '/projects/ecookbook/issues', {
139 post '/projects/ecookbook/issues', {
140 :issue => {
140 :issue => {
141 :subject => "Subject",
141 :subject => "Subject",
142 :custom_field_values => {
142 :custom_field_values => {
143 @field.id => {:token => attachment.token}
143 @field.id => {:token => attachment.token}
144 }
144 }
145 }
145 }
146 }
146 }
147 assert_response 302
147 assert_response 302
148 end
148 end
149 end
149 end
150
150
151 custom_value = issue.custom_value_for(@field)
151 custom_value = issue.custom_value_for(@field)
152 assert custom_value
152 assert custom_value
153 assert_equal attachment.id.to_s, custom_value.value
153 assert_equal attachment.id.to_s, custom_value.value
154 assert_equal custom_value, attachment.reload.container
154 assert_equal custom_value, attachment.reload.container
155 end
155 end
156
157 def test_create_with_valid_extension
158 @field.extensions_allowed = "txt, log"
159 @field.save!
160
161 attachment = new_record(Attachment) do
162 assert_difference 'Issue.count' do
163 post '/projects/ecookbook/issues', {
164 :issue => {
165 :subject => "Blank",
166 :custom_field_values => {
167 @field.id => {:file => uploaded_test_file("testfile.txt", "text/plain")}
168 }
169 }
170 }
171 assert_response 302
172 end
173 end
174 end
175
176 def test_create_with_invalid_extension_should_fail
177 @field.extensions_allowed = "png, jpeg"
178 @field.save!
179
180 attachment = new_record(Attachment) do
181 assert_no_difference 'Issue.count' do
182 post '/projects/ecookbook/issues', {
183 :issue => {
184 :subject => "Blank",
185 :custom_field_values => {
186 @field.id => {:file => uploaded_test_file("testfile.txt", "text/plain")}
187 }
188 }
189 }
190 assert_response :success
191 end
192 end
193 end
156 end
194 end
General Comments 0
You need to be logged in to leave comments. Login now