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