##// END OF EJS Templates
Merged r15318 and r15319 (#22342)....
Jean-Philippe Lang -
r14947:3d4d6c31f3b2
parent child
Show More

The requested changes are too big and content was truncated. Show full diff

@@ -1,1701 +1,1702
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 Issue < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20 include Redmine::Utils::DateCalculation
21 21 include Redmine::I18n
22 22 before_save :set_parent_id
23 23 include Redmine::NestedSet::IssueNestedSet
24 24
25 25 belongs_to :project
26 26 belongs_to :tracker
27 27 belongs_to :status, :class_name => 'IssueStatus'
28 28 belongs_to :author, :class_name => 'User'
29 29 belongs_to :assigned_to, :class_name => 'Principal'
30 30 belongs_to :fixed_version, :class_name => 'Version'
31 31 belongs_to :priority, :class_name => 'IssuePriority'
32 32 belongs_to :category, :class_name => 'IssueCategory'
33 33
34 34 has_many :journals, :as => :journalized, :dependent => :destroy, :inverse_of => :journalized
35 35 has_many :visible_journals,
36 36 lambda {where(["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false])},
37 37 :class_name => 'Journal',
38 38 :as => :journalized
39 39
40 40 has_many :time_entries, :dependent => :destroy
41 41 has_and_belongs_to_many :changesets, lambda {order("#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC")}
42 42
43 43 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
44 44 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
45 45
46 46 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
47 47 acts_as_customizable
48 48 acts_as_watchable
49 49 acts_as_searchable :columns => ['subject', "#{table_name}.description"],
50 50 :preload => [:project, :status, :tracker],
51 51 :scope => lambda {|options| options[:open_issues] ? self.open : self.all}
52 52
53 53 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
54 54 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
55 55 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
56 56
57 57 acts_as_activity_provider :scope => preload(:project, :author, :tracker),
58 58 :author_key => :author_id
59 59
60 60 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
61 61
62 62 attr_reader :current_journal
63 63 delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
64 64
65 65 validates_presence_of :subject, :project, :tracker
66 66 validates_presence_of :priority, :if => Proc.new {|issue| issue.new_record? || issue.priority_id_changed?}
67 67 validates_presence_of :status, :if => Proc.new {|issue| issue.new_record? || issue.status_id_changed?}
68 68 validates_presence_of :author, :if => Proc.new {|issue| issue.new_record? || issue.author_id_changed?}
69 69
70 70 validates_length_of :subject, :maximum => 255
71 71 validates_inclusion_of :done_ratio, :in => 0..100
72 72 validates :estimated_hours, :numericality => {:greater_than_or_equal_to => 0, :allow_nil => true, :message => :invalid}
73 73 validates :start_date, :date => true
74 74 validates :due_date, :date => true
75 75 validate :validate_issue, :validate_required_fields
76 76 attr_protected :id
77 77
78 78 scope :visible, lambda {|*args|
79 79 joins(:project).
80 80 where(Issue.visible_condition(args.shift || User.current, *args))
81 81 }
82 82
83 83 scope :open, lambda {|*args|
84 84 is_closed = args.size > 0 ? !args.first : false
85 85 joins(:status).
86 86 where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
87 87 }
88 88
89 89 scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
90 90 scope :on_active_project, lambda {
91 91 joins(:project).
92 92 where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
93 93 }
94 94 scope :fixed_version, lambda {|versions|
95 95 ids = [versions].flatten.compact.map {|v| v.is_a?(Version) ? v.id : v}
96 96 ids.any? ? where(:fixed_version_id => ids) : where('1=0')
97 97 }
98 98 scope :assigned_to, lambda {|arg|
99 99 arg = Array(arg).uniq
100 100 ids = arg.map {|p| p.is_a?(Principal) ? p.id : p}
101 101 ids += arg.select {|p| p.is_a?(User)}.map(&:group_ids).flatten.uniq
102 102 ids.compact!
103 103 ids.any? ? where(:assigned_to_id => ids) : none
104 104 }
105 105
106 106 before_validation :clear_disabled_fields
107 107 before_create :default_assign
108 108 before_save :close_duplicates, :update_done_ratio_from_issue_status,
109 109 :force_updated_on_change, :update_closed_on, :set_assigned_to_was
110 110 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
111 111 after_save :reschedule_following_issues, :update_nested_set_attributes,
112 112 :update_parent_attributes, :create_journal
113 113 # Should be after_create but would be called before previous after_save callbacks
114 114 after_save :after_create_from_copy
115 115 after_destroy :update_parent_attributes
116 116 after_create :send_notification
117 117 # Keep it at the end of after_save callbacks
118 118 after_save :clear_assigned_to_was
119 119
120 120 # Returns a SQL conditions string used to find all issues visible by the specified user
121 121 def self.visible_condition(user, options={})
122 122 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
123 123 if user.id && user.logged?
124 124 case role.issues_visibility
125 125 when 'all'
126 126 nil
127 127 when 'default'
128 128 user_ids = [user.id] + user.groups.map(&:id).compact
129 129 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
130 130 when 'own'
131 131 user_ids = [user.id] + user.groups.map(&:id).compact
132 132 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
133 133 else
134 134 '1=0'
135 135 end
136 136 else
137 137 "(#{table_name}.is_private = #{connection.quoted_false})"
138 138 end
139 139 end
140 140 end
141 141
142 142 # Returns true if usr or current user is allowed to view the issue
143 143 def visible?(usr=nil)
144 144 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
145 145 if user.logged?
146 146 case role.issues_visibility
147 147 when 'all'
148 148 true
149 149 when 'default'
150 150 !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
151 151 when 'own'
152 152 self.author == user || user.is_or_belongs_to?(assigned_to)
153 153 else
154 154 false
155 155 end
156 156 else
157 157 !self.is_private?
158 158 end
159 159 end
160 160 end
161 161
162 162 # Returns true if user or current user is allowed to edit or add a note to the issue
163 163 def editable?(user=User.current)
164 164 attributes_editable?(user) || user.allowed_to?(:add_issue_notes, project)
165 165 end
166 166
167 167 # Returns true if user or current user is allowed to edit the issue
168 168 def attributes_editable?(user=User.current)
169 169 user.allowed_to?(:edit_issues, project)
170 170 end
171 171
172 172 def initialize(attributes=nil, *args)
173 173 super
174 174 if new_record?
175 175 # set default values for new records only
176 176 self.priority ||= IssuePriority.default
177 177 self.watcher_user_ids = []
178 178 end
179 179 end
180 180
181 181 def create_or_update
182 182 super
183 183 ensure
184 184 @status_was = nil
185 185 end
186 186 private :create_or_update
187 187
188 188 # AR#Persistence#destroy would raise and RecordNotFound exception
189 189 # if the issue was already deleted or updated (non matching lock_version).
190 190 # This is a problem when bulk deleting issues or deleting a project
191 191 # (because an issue may already be deleted if its parent was deleted
192 192 # first).
193 193 # The issue is reloaded by the nested_set before being deleted so
194 194 # the lock_version condition should not be an issue but we handle it.
195 195 def destroy
196 196 super
197 197 rescue ActiveRecord::StaleObjectError, ActiveRecord::RecordNotFound
198 198 # Stale or already deleted
199 199 begin
200 200 reload
201 201 rescue ActiveRecord::RecordNotFound
202 202 # The issue was actually already deleted
203 203 @destroyed = true
204 204 return freeze
205 205 end
206 206 # The issue was stale, retry to destroy
207 207 super
208 208 end
209 209
210 210 alias :base_reload :reload
211 211 def reload(*args)
212 212 @workflow_rule_by_attribute = nil
213 213 @assignable_versions = nil
214 214 @relations = nil
215 215 @spent_hours = nil
216 216 @total_spent_hours = nil
217 217 @total_estimated_hours = nil
218 218 base_reload(*args)
219 219 end
220 220
221 221 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
222 222 def available_custom_fields
223 223 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields) : []
224 224 end
225 225
226 226 def visible_custom_field_values(user=nil)
227 227 user_real = user || User.current
228 228 custom_field_values.select do |value|
229 229 value.custom_field.visible_by?(project, user_real)
230 230 end
231 231 end
232 232
233 233 # Copies attributes from another issue, arg can be an id or an Issue
234 234 def copy_from(arg, options={})
235 235 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
236 236 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
237 237 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
238 238 self.status = issue.status
239 239 self.author = User.current
240 240 unless options[:attachments] == false
241 241 self.attachments = issue.attachments.map do |attachement|
242 242 attachement.copy(:container => self)
243 243 end
244 244 end
245 245 @copied_from = issue
246 246 @copy_options = options
247 247 self
248 248 end
249 249
250 250 # Returns an unsaved copy of the issue
251 251 def copy(attributes=nil, copy_options={})
252 252 copy = self.class.new.copy_from(self, copy_options)
253 253 copy.attributes = attributes if attributes
254 254 copy
255 255 end
256 256
257 257 # Returns true if the issue is a copy
258 258 def copy?
259 259 @copied_from.present?
260 260 end
261 261
262 262 def status_id=(status_id)
263 263 if status_id.to_s != self.status_id.to_s
264 264 self.status = (status_id.present? ? IssueStatus.find_by_id(status_id) : nil)
265 265 end
266 266 self.status_id
267 267 end
268 268
269 269 # Sets the status.
270 270 def status=(status)
271 271 if status != self.status
272 272 @workflow_rule_by_attribute = nil
273 273 end
274 274 association(:status).writer(status)
275 275 end
276 276
277 277 def priority_id=(pid)
278 278 self.priority = nil
279 279 write_attribute(:priority_id, pid)
280 280 end
281 281
282 282 def category_id=(cid)
283 283 self.category = nil
284 284 write_attribute(:category_id, cid)
285 285 end
286 286
287 287 def fixed_version_id=(vid)
288 288 self.fixed_version = nil
289 289 write_attribute(:fixed_version_id, vid)
290 290 end
291 291
292 292 def tracker_id=(tracker_id)
293 293 if tracker_id.to_s != self.tracker_id.to_s
294 294 self.tracker = (tracker_id.present? ? Tracker.find_by_id(tracker_id) : nil)
295 295 end
296 296 self.tracker_id
297 297 end
298 298
299 299 # Sets the tracker.
300 300 # This will set the status to the default status of the new tracker if:
301 301 # * the status was the default for the previous tracker
302 302 # * or if the status was not part of the new tracker statuses
303 303 # * or the status was nil
304 304 def tracker=(tracker)
305 if tracker != self.tracker
306 if status == default_status
305 tracker_was = self.tracker
306 association(:tracker).writer(tracker)
307 if tracker != tracker_was
308 if status == tracker_was.try(:default_status)
307 309 self.status = nil
308 310 elsif status && tracker && !tracker.issue_status_ids.include?(status.id)
309 311 self.status = nil
310 312 end
311 @custom_field_values = nil
313 reassign_custom_field_values
312 314 @workflow_rule_by_attribute = nil
313 315 end
314 association(:tracker).writer(tracker)
315 316 self.status ||= default_status
316 317 self.tracker
317 318 end
318 319
319 320 def project_id=(project_id)
320 321 if project_id.to_s != self.project_id.to_s
321 322 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
322 323 end
323 324 self.project_id
324 325 end
325 326
326 327 # Sets the project.
327 328 # Unless keep_tracker argument is set to true, this will change the tracker
328 329 # to the first tracker of the new project if the previous tracker is not part
329 330 # of the new project trackers.
330 331 # This will:
331 332 # * clear the fixed_version is it's no longer valid for the new project.
332 333 # * clear the parent issue if it's no longer valid for the new project.
333 334 # * set the category to the category with the same name in the new
334 335 # project if it exists, or clear it if it doesn't.
335 336 # * for new issue, set the fixed_version to the project default version
336 337 # if it's a valid fixed_version.
337 338 def project=(project, keep_tracker=false)
338 339 project_was = self.project
339 340 association(:project).writer(project)
340 341 if project_was && project && project_was != project
341 342 @assignable_versions = nil
342 343
343 344 unless keep_tracker || project.trackers.include?(tracker)
344 345 self.tracker = project.trackers.first
345 346 end
346 347 # Reassign to the category with same name if any
347 348 if category
348 349 self.category = project.issue_categories.find_by_name(category.name)
349 350 end
350 351 # Keep the fixed_version if it's still valid in the new_project
351 352 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
352 353 self.fixed_version = nil
353 354 end
354 355 # Clear the parent task if it's no longer valid
355 356 unless valid_parent_project?
356 357 self.parent_issue_id = nil
357 358 end
358 @custom_field_values = nil
359 reassign_custom_field_values
359 360 @workflow_rule_by_attribute = nil
360 361 end
361 362 # Set fixed_version to the project default version if it's valid
362 363 if new_record? && fixed_version.nil? && project && project.default_version_id?
363 364 if project.shared_versions.open.exists?(project.default_version_id)
364 365 self.fixed_version_id = project.default_version_id
365 366 end
366 367 end
367 368 self.project
368 369 end
369 370
370 371 def description=(arg)
371 372 if arg.is_a?(String)
372 373 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
373 374 end
374 375 write_attribute(:description, arg)
375 376 end
376 377
377 378 # Overrides assign_attributes so that project and tracker get assigned first
378 379 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
379 380 return if new_attributes.nil?
380 381 attrs = new_attributes.dup
381 382 attrs.stringify_keys!
382 383
383 384 %w(project project_id tracker tracker_id).each do |attr|
384 385 if attrs.has_key?(attr)
385 386 send "#{attr}=", attrs.delete(attr)
386 387 end
387 388 end
388 389 send :assign_attributes_without_project_and_tracker_first, attrs, *args
389 390 end
390 391 # Do not redefine alias chain on reload (see #4838)
391 392 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
392 393
393 394 def attributes=(new_attributes)
394 395 assign_attributes new_attributes
395 396 end
396 397
397 398 def estimated_hours=(h)
398 399 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
399 400 end
400 401
401 402 safe_attributes 'project_id',
402 403 'tracker_id',
403 404 'status_id',
404 405 'category_id',
405 406 'assigned_to_id',
406 407 'priority_id',
407 408 'fixed_version_id',
408 409 'subject',
409 410 'description',
410 411 'start_date',
411 412 'due_date',
412 413 'done_ratio',
413 414 'estimated_hours',
414 415 'custom_field_values',
415 416 'custom_fields',
416 417 'lock_version',
417 418 'notes',
418 419 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
419 420
420 421 safe_attributes 'notes',
421 422 :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
422 423
423 424 safe_attributes 'private_notes',
424 425 :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
425 426
426 427 safe_attributes 'watcher_user_ids',
427 428 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
428 429
429 430 safe_attributes 'is_private',
430 431 :if => lambda {|issue, user|
431 432 user.allowed_to?(:set_issues_private, issue.project) ||
432 433 (issue.author_id == user.id && user.allowed_to?(:set_own_issues_private, issue.project))
433 434 }
434 435
435 436 safe_attributes 'parent_issue_id',
436 437 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
437 438 user.allowed_to?(:manage_subtasks, issue.project)}
438 439
439 440 def safe_attribute_names(user=nil)
440 441 names = super
441 442 names -= disabled_core_fields
442 443 names -= read_only_attribute_names(user)
443 444 if new_record?
444 445 # Make sure that project_id can always be set for new issues
445 446 names |= %w(project_id)
446 447 end
447 448 if dates_derived?
448 449 names -= %w(start_date due_date)
449 450 end
450 451 if priority_derived?
451 452 names -= %w(priority_id)
452 453 end
453 454 if done_ratio_derived?
454 455 names -= %w(done_ratio)
455 456 end
456 457 names
457 458 end
458 459
459 460 # Safely sets attributes
460 461 # Should be called from controllers instead of #attributes=
461 462 # attr_accessible is too rough because we still want things like
462 463 # Issue.new(:project => foo) to work
463 464 def safe_attributes=(attrs, user=User.current)
464 465 return unless attrs.is_a?(Hash)
465 466
466 467 attrs = attrs.deep_dup
467 468
468 469 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
469 470 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
470 471 if allowed_target_projects(user).where(:id => p.to_i).exists?
471 472 self.project_id = p
472 473 end
473 474
474 475 if project_id_changed? && attrs['category_id'].to_s == category_id_was.to_s
475 476 # Discard submitted category on previous project
476 477 attrs.delete('category_id')
477 478 end
478 479 end
479 480
480 481 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
481 482 self.tracker_id = t
482 483 end
483 484 if project
484 485 # Set the default tracker to accept custom field values
485 486 # even if tracker is not specified
486 487 self.tracker ||= project.trackers.first
487 488 end
488 489
489 490 statuses_allowed = new_statuses_allowed_to(user)
490 491 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
491 492 if statuses_allowed.collect(&:id).include?(s.to_i)
492 493 self.status_id = s
493 494 end
494 495 end
495 496 if new_record? && !statuses_allowed.include?(status)
496 497 self.status = statuses_allowed.first || default_status
497 498 end
498 499 if (u = attrs.delete('assigned_to_id')) && safe_attribute?('assigned_to_id')
499 500 if u.blank?
500 501 self.assigned_to_id = nil
501 502 else
502 503 u = u.to_i
503 504 if assignable_users.any?{|assignable_user| assignable_user.id == u}
504 505 self.assigned_to_id = u
505 506 end
506 507 end
507 508 end
508 509
509 510
510 511 attrs = delete_unsafe_attributes(attrs, user)
511 512 return if attrs.empty?
512 513
513 514 if attrs['parent_issue_id'].present?
514 515 s = attrs['parent_issue_id'].to_s
515 516 unless (m = s.match(%r{\A#?(\d+)\z})) && (m[1] == parent_id.to_s || Issue.visible(user).exists?(m[1]))
516 517 @invalid_parent_issue_id = attrs.delete('parent_issue_id')
517 518 end
518 519 end
519 520
520 521 if attrs['custom_field_values'].present?
521 522 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
522 523 attrs['custom_field_values'].select! {|k, v| editable_custom_field_ids.include?(k.to_s)}
523 524 end
524 525
525 526 if attrs['custom_fields'].present?
526 527 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
527 528 attrs['custom_fields'].select! {|c| editable_custom_field_ids.include?(c['id'].to_s)}
528 529 end
529 530
530 531 # mass-assignment security bypass
531 532 assign_attributes attrs, :without_protection => true
532 533 end
533 534
534 535 def disabled_core_fields
535 536 tracker ? tracker.disabled_core_fields : []
536 537 end
537 538
538 539 # Returns the custom_field_values that can be edited by the given user
539 540 def editable_custom_field_values(user=nil)
540 541 visible_custom_field_values(user).reject do |value|
541 542 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
542 543 end
543 544 end
544 545
545 546 # Returns the custom fields that can be edited by the given user
546 547 def editable_custom_fields(user=nil)
547 548 editable_custom_field_values(user).map(&:custom_field).uniq
548 549 end
549 550
550 551 # Returns the names of attributes that are read-only for user or the current user
551 552 # For users with multiple roles, the read-only fields are the intersection of
552 553 # read-only fields of each role
553 554 # The result is an array of strings where sustom fields are represented with their ids
554 555 #
555 556 # Examples:
556 557 # issue.read_only_attribute_names # => ['due_date', '2']
557 558 # issue.read_only_attribute_names(user) # => []
558 559 def read_only_attribute_names(user=nil)
559 560 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
560 561 end
561 562
562 563 # Returns the names of required attributes for user or the current user
563 564 # For users with multiple roles, the required fields are the intersection of
564 565 # required fields of each role
565 566 # The result is an array of strings where sustom fields are represented with their ids
566 567 #
567 568 # Examples:
568 569 # issue.required_attribute_names # => ['due_date', '2']
569 570 # issue.required_attribute_names(user) # => []
570 571 def required_attribute_names(user=nil)
571 572 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
572 573 end
573 574
574 575 # Returns true if the attribute is required for user
575 576 def required_attribute?(name, user=nil)
576 577 required_attribute_names(user).include?(name.to_s)
577 578 end
578 579
579 580 # Returns a hash of the workflow rule by attribute for the given user
580 581 #
581 582 # Examples:
582 583 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
583 584 def workflow_rule_by_attribute(user=nil)
584 585 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
585 586
586 587 user_real = user || User.current
587 588 roles = user_real.admin ? Role.all.to_a : user_real.roles_for_project(project)
588 589 roles = roles.select(&:consider_workflow?)
589 590 return {} if roles.empty?
590 591
591 592 result = {}
592 593 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).to_a
593 594 if workflow_permissions.any?
594 595 workflow_rules = workflow_permissions.inject({}) do |h, wp|
595 596 h[wp.field_name] ||= {}
596 597 h[wp.field_name][wp.role_id] = wp.rule
597 598 h
598 599 end
599 600 fields_with_roles = {}
600 601 IssueCustomField.where(:visible => false).joins(:roles).pluck(:id, "role_id").each do |field_id, role_id|
601 602 fields_with_roles[field_id] ||= []
602 603 fields_with_roles[field_id] << role_id
603 604 end
604 605 roles.each do |role|
605 606 fields_with_roles.each do |field_id, role_ids|
606 607 unless role_ids.include?(role.id)
607 608 field_name = field_id.to_s
608 609 workflow_rules[field_name] ||= {}
609 610 workflow_rules[field_name][role.id] = 'readonly'
610 611 end
611 612 end
612 613 end
613 614 workflow_rules.each do |attr, rules|
614 615 next if rules.size < roles.size
615 616 uniq_rules = rules.values.uniq
616 617 if uniq_rules.size == 1
617 618 result[attr] = uniq_rules.first
618 619 else
619 620 result[attr] = 'required'
620 621 end
621 622 end
622 623 end
623 624 @workflow_rule_by_attribute = result if user.nil?
624 625 result
625 626 end
626 627 private :workflow_rule_by_attribute
627 628
628 629 def done_ratio
629 630 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
630 631 status.default_done_ratio
631 632 else
632 633 read_attribute(:done_ratio)
633 634 end
634 635 end
635 636
636 637 def self.use_status_for_done_ratio?
637 638 Setting.issue_done_ratio == 'issue_status'
638 639 end
639 640
640 641 def self.use_field_for_done_ratio?
641 642 Setting.issue_done_ratio == 'issue_field'
642 643 end
643 644
644 645 def validate_issue
645 646 if due_date && start_date && (start_date_changed? || due_date_changed?) && due_date < start_date
646 647 errors.add :due_date, :greater_than_start_date
647 648 end
648 649
649 650 if start_date && start_date_changed? && soonest_start && start_date < soonest_start
650 651 errors.add :start_date, :earlier_than_minimum_start_date, :date => format_date(soonest_start)
651 652 end
652 653
653 654 if fixed_version
654 655 if !assignable_versions.include?(fixed_version)
655 656 errors.add :fixed_version_id, :inclusion
656 657 elsif reopening? && fixed_version.closed?
657 658 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
658 659 end
659 660 end
660 661
661 662 # Checks that the issue can not be added/moved to a disabled tracker
662 663 if project && (tracker_id_changed? || project_id_changed?)
663 664 unless project.trackers.include?(tracker)
664 665 errors.add :tracker_id, :inclusion
665 666 end
666 667 end
667 668
668 669 # Checks parent issue assignment
669 670 if @invalid_parent_issue_id.present?
670 671 errors.add :parent_issue_id, :invalid
671 672 elsif @parent_issue
672 673 if !valid_parent_project?(@parent_issue)
673 674 errors.add :parent_issue_id, :invalid
674 675 elsif (@parent_issue != parent) && (all_dependent_issues.include?(@parent_issue) || @parent_issue.all_dependent_issues.include?(self))
675 676 errors.add :parent_issue_id, :invalid
676 677 elsif !new_record?
677 678 # moving an existing issue
678 679 if move_possible?(@parent_issue)
679 680 # move accepted
680 681 else
681 682 errors.add :parent_issue_id, :invalid
682 683 end
683 684 end
684 685 end
685 686 end
686 687
687 688 # Validates the issue against additional workflow requirements
688 689 def validate_required_fields
689 690 user = new_record? ? author : current_journal.try(:user)
690 691
691 692 required_attribute_names(user).each do |attribute|
692 693 if attribute =~ /^\d+$/
693 694 attribute = attribute.to_i
694 695 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
695 696 if v && Array(v.value).detect(&:present?).nil?
696 697 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
697 698 end
698 699 else
699 700 if respond_to?(attribute) && send(attribute).blank? && !disabled_core_fields.include?(attribute)
700 701 next if attribute == 'category_id' && project.try(:issue_categories).blank?
701 702 next if attribute == 'fixed_version_id' && assignable_versions.blank?
702 703 errors.add attribute, :blank
703 704 end
704 705 end
705 706 end
706 707 end
707 708
708 709 # Overrides Redmine::Acts::Customizable::InstanceMethods#validate_custom_field_values
709 710 # so that custom values that are not editable are not validated (eg. a custom field that
710 711 # is marked as required should not trigger a validation error if the user is not allowed
711 712 # to edit this field).
712 713 def validate_custom_field_values
713 714 user = new_record? ? author : current_journal.try(:user)
714 715 if new_record? || custom_field_values_changed?
715 716 editable_custom_field_values(user).each(&:validate_value)
716 717 end
717 718 end
718 719
719 720 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
720 721 # even if the user turns off the setting later
721 722 def update_done_ratio_from_issue_status
722 723 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
723 724 self.done_ratio = status.default_done_ratio
724 725 end
725 726 end
726 727
727 728 def init_journal(user, notes = "")
728 729 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
729 730 end
730 731
731 732 # Returns the current journal or nil if it's not initialized
732 733 def current_journal
733 734 @current_journal
734 735 end
735 736
736 737 # Returns the names of attributes that are journalized when updating the issue
737 738 def journalized_attribute_names
738 739 names = Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on closed_on)
739 740 if tracker
740 741 names -= tracker.disabled_core_fields
741 742 end
742 743 names
743 744 end
744 745
745 746 # Returns the id of the last journal or nil
746 747 def last_journal_id
747 748 if new_record?
748 749 nil
749 750 else
750 751 journals.maximum(:id)
751 752 end
752 753 end
753 754
754 755 # Returns a scope for journals that have an id greater than journal_id
755 756 def journals_after(journal_id)
756 757 scope = journals.reorder("#{Journal.table_name}.id ASC")
757 758 if journal_id.present?
758 759 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
759 760 end
760 761 scope
761 762 end
762 763
763 764 # Returns the initial status of the issue
764 765 # Returns nil for a new issue
765 766 def status_was
766 767 if status_id_changed?
767 768 if status_id_was.to_i > 0
768 769 @status_was ||= IssueStatus.find_by_id(status_id_was)
769 770 end
770 771 else
771 772 @status_was ||= status
772 773 end
773 774 end
774 775
775 776 # Return true if the issue is closed, otherwise false
776 777 def closed?
777 778 status.present? && status.is_closed?
778 779 end
779 780
780 781 # Returns true if the issue was closed when loaded
781 782 def was_closed?
782 783 status_was.present? && status_was.is_closed?
783 784 end
784 785
785 786 # Return true if the issue is being reopened
786 787 def reopening?
787 788 if new_record?
788 789 false
789 790 else
790 791 status_id_changed? && !closed? && was_closed?
791 792 end
792 793 end
793 794 alias :reopened? :reopening?
794 795
795 796 # Return true if the issue is being closed
796 797 def closing?
797 798 if new_record?
798 799 closed?
799 800 else
800 801 status_id_changed? && closed? && !was_closed?
801 802 end
802 803 end
803 804
804 805 # Returns true if the issue is overdue
805 806 def overdue?
806 807 due_date.present? && (due_date < Date.today) && !closed?
807 808 end
808 809
809 810 # Is the amount of work done less than it should for the due date
810 811 def behind_schedule?
811 812 return false if start_date.nil? || due_date.nil?
812 813 done_date = start_date + ((due_date - start_date + 1) * done_ratio / 100).floor
813 814 return done_date <= Date.today
814 815 end
815 816
816 817 # Does this issue have children?
817 818 def children?
818 819 !leaf?
819 820 end
820 821
821 822 # Users the issue can be assigned to
822 823 def assignable_users
823 824 users = project.assignable_users.to_a
824 825 users << author if author && author.active?
825 826 users << assigned_to if assigned_to
826 827 users.uniq.sort
827 828 end
828 829
829 830 # Versions that the issue can be assigned to
830 831 def assignable_versions
831 832 return @assignable_versions if @assignable_versions
832 833
833 834 versions = project.shared_versions.open.to_a
834 835 if fixed_version
835 836 if fixed_version_id_changed?
836 837 # nothing to do
837 838 elsif project_id_changed?
838 839 if project.shared_versions.include?(fixed_version)
839 840 versions << fixed_version
840 841 end
841 842 else
842 843 versions << fixed_version
843 844 end
844 845 end
845 846 @assignable_versions = versions.uniq.sort
846 847 end
847 848
848 849 # Returns true if this issue is blocked by another issue that is still open
849 850 def blocked?
850 851 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
851 852 end
852 853
853 854 # Returns the default status of the issue based on its tracker
854 855 # Returns nil if tracker is nil
855 856 def default_status
856 857 tracker.try(:default_status)
857 858 end
858 859
859 860 # Returns an array of statuses that user is able to apply
860 861 def new_statuses_allowed_to(user=User.current, include_default=false)
861 862 if new_record? && @copied_from
862 863 [default_status, @copied_from.status].compact.uniq.sort
863 864 else
864 865 initial_status = nil
865 866 if new_record?
866 867 # nop
867 868 elsif tracker_id_changed?
868 869 if Tracker.where(:id => tracker_id_was, :default_status_id => status_id_was).any?
869 870 initial_status = default_status
870 871 elsif tracker.issue_status_ids.include?(status_id_was)
871 872 initial_status = IssueStatus.find_by_id(status_id_was)
872 873 else
873 874 initial_status = default_status
874 875 end
875 876 else
876 877 initial_status = status_was
877 878 end
878 879
879 880 initial_assigned_to_id = assigned_to_id_changed? ? assigned_to_id_was : assigned_to_id
880 881 assignee_transitions_allowed = initial_assigned_to_id.present? &&
881 882 (user.id == initial_assigned_to_id || user.group_ids.include?(initial_assigned_to_id))
882 883
883 884 statuses = []
884 885 statuses += IssueStatus.new_statuses_allowed(
885 886 initial_status,
886 887 user.admin ? Role.all.to_a : user.roles_for_project(project),
887 888 tracker,
888 889 author == user,
889 890 assignee_transitions_allowed
890 891 )
891 892 statuses << initial_status unless statuses.empty?
892 893 statuses << default_status if include_default || (new_record? && statuses.empty?)
893 894 statuses = statuses.compact.uniq.sort
894 895 if blocked?
895 896 statuses.reject!(&:is_closed?)
896 897 end
897 898 statuses
898 899 end
899 900 end
900 901
901 902 # Returns the previous assignee (user or group) if changed
902 903 def assigned_to_was
903 904 # assigned_to_id_was is reset before after_save callbacks
904 905 user_id = @previous_assigned_to_id || assigned_to_id_was
905 906 if user_id && user_id != assigned_to_id
906 907 @assigned_to_was ||= Principal.find_by_id(user_id)
907 908 end
908 909 end
909 910
910 911 # Returns the original tracker
911 912 def tracker_was
912 913 Tracker.find_by_id(tracker_id_was)
913 914 end
914 915
915 916 # Returns the users that should be notified
916 917 def notified_users
917 918 notified = []
918 919 # Author and assignee are always notified unless they have been
919 920 # locked or don't want to be notified
920 921 notified << author if author
921 922 if assigned_to
922 923 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
923 924 end
924 925 if assigned_to_was
925 926 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
926 927 end
927 928 notified = notified.select {|u| u.active? && u.notify_about?(self)}
928 929
929 930 notified += project.notified_users
930 931 notified.uniq!
931 932 # Remove users that can not view the issue
932 933 notified.reject! {|user| !visible?(user)}
933 934 notified
934 935 end
935 936
936 937 # Returns the email addresses that should be notified
937 938 def recipients
938 939 notified_users.collect(&:mail)
939 940 end
940 941
941 942 def each_notification(users, &block)
942 943 if users.any?
943 944 if custom_field_values.detect {|value| !value.custom_field.visible?}
944 945 users_by_custom_field_visibility = users.group_by do |user|
945 946 visible_custom_field_values(user).map(&:custom_field_id).sort
946 947 end
947 948 users_by_custom_field_visibility.values.each do |users|
948 949 yield(users)
949 950 end
950 951 else
951 952 yield(users)
952 953 end
953 954 end
954 955 end
955 956
956 957 def notify?
957 958 @notify != false
958 959 end
959 960
960 961 def notify=(arg)
961 962 @notify = arg
962 963 end
963 964
964 965 # Returns the number of hours spent on this issue
965 966 def spent_hours
966 967 @spent_hours ||= time_entries.sum(:hours) || 0
967 968 end
968 969
969 970 # Returns the total number of hours spent on this issue and its descendants
970 971 def total_spent_hours
971 972 @total_spent_hours ||= if leaf?
972 973 spent_hours
973 974 else
974 975 self_and_descendants.joins(:time_entries).sum("#{TimeEntry.table_name}.hours").to_f || 0.0
975 976 end
976 977 end
977 978
978 979 def total_estimated_hours
979 980 if leaf?
980 981 estimated_hours
981 982 else
982 983 @total_estimated_hours ||= self_and_descendants.sum(:estimated_hours)
983 984 end
984 985 end
985 986
986 987 def relations
987 988 @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
988 989 end
989 990
990 991 # Preloads relations for a collection of issues
991 992 def self.load_relations(issues)
992 993 if issues.any?
993 994 relations = IssueRelation.where("issue_from_id IN (:ids) OR issue_to_id IN (:ids)", :ids => issues.map(&:id)).all
994 995 issues.each do |issue|
995 996 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
996 997 end
997 998 end
998 999 end
999 1000
1000 1001 # Preloads visible spent time for a collection of issues
1001 1002 def self.load_visible_spent_hours(issues, user=User.current)
1002 1003 if issues.any?
1003 1004 hours_by_issue_id = TimeEntry.visible(user).where(:issue_id => issues.map(&:id)).group(:issue_id).sum(:hours)
1004 1005 issues.each do |issue|
1005 1006 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
1006 1007 end
1007 1008 end
1008 1009 end
1009 1010
1010 1011 # Preloads visible total spent time for a collection of issues
1011 1012 def self.load_visible_total_spent_hours(issues, user=User.current)
1012 1013 if issues.any?
1013 1014 hours_by_issue_id = TimeEntry.visible(user).joins(:issue).
1014 1015 joins("JOIN #{Issue.table_name} parent ON parent.root_id = #{Issue.table_name}.root_id" +
1015 1016 " AND parent.lft <= #{Issue.table_name}.lft AND parent.rgt >= #{Issue.table_name}.rgt").
1016 1017 where("parent.id IN (?)", issues.map(&:id)).group("parent.id").sum(:hours)
1017 1018 issues.each do |issue|
1018 1019 issue.instance_variable_set "@total_spent_hours", (hours_by_issue_id[issue.id] || 0)
1019 1020 end
1020 1021 end
1021 1022 end
1022 1023
1023 1024 # Preloads visible relations for a collection of issues
1024 1025 def self.load_visible_relations(issues, user=User.current)
1025 1026 if issues.any?
1026 1027 issue_ids = issues.map(&:id)
1027 1028 # Relations with issue_from in given issues and visible issue_to
1028 1029 relations_from = IssueRelation.joins(:issue_to => :project).
1029 1030 where(visible_condition(user)).where(:issue_from_id => issue_ids).to_a
1030 1031 # Relations with issue_to in given issues and visible issue_from
1031 1032 relations_to = IssueRelation.joins(:issue_from => :project).
1032 1033 where(visible_condition(user)).
1033 1034 where(:issue_to_id => issue_ids).to_a
1034 1035 issues.each do |issue|
1035 1036 relations =
1036 1037 relations_from.select {|relation| relation.issue_from_id == issue.id} +
1037 1038 relations_to.select {|relation| relation.issue_to_id == issue.id}
1038 1039
1039 1040 issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
1040 1041 end
1041 1042 end
1042 1043 end
1043 1044
1044 1045 # Finds an issue relation given its id.
1045 1046 def find_relation(relation_id)
1046 1047 IssueRelation.where("issue_to_id = ? OR issue_from_id = ?", id, id).find(relation_id)
1047 1048 end
1048 1049
1049 1050 # Returns all the other issues that depend on the issue
1050 1051 # The algorithm is a modified breadth first search (bfs)
1051 1052 def all_dependent_issues(except=[])
1052 1053 # The found dependencies
1053 1054 dependencies = []
1054 1055
1055 1056 # The visited flag for every node (issue) used by the breadth first search
1056 1057 eNOT_DISCOVERED = 0 # The issue is "new" to the algorithm, it has not seen it before.
1057 1058
1058 1059 ePROCESS_ALL = 1 # The issue is added to the queue. Process both children and relations of
1059 1060 # the issue when it is processed.
1060 1061
1061 1062 ePROCESS_RELATIONS_ONLY = 2 # The issue was added to the queue and will be output as dependent issue,
1062 1063 # but its children will not be added to the queue when it is processed.
1063 1064
1064 1065 eRELATIONS_PROCESSED = 3 # The related issues, the parent issue and the issue itself have been added to
1065 1066 # the queue, but its children have not been added.
1066 1067
1067 1068 ePROCESS_CHILDREN_ONLY = 4 # The relations and the parent of the issue have been added to the queue, but
1068 1069 # the children still need to be processed.
1069 1070
1070 1071 eALL_PROCESSED = 5 # The issue and all its children, its parent and its related issues have been
1071 1072 # added as dependent issues. It needs no further processing.
1072 1073
1073 1074 issue_status = Hash.new(eNOT_DISCOVERED)
1074 1075
1075 1076 # The queue
1076 1077 queue = []
1077 1078
1078 1079 # Initialize the bfs, add start node (self) to the queue
1079 1080 queue << self
1080 1081 issue_status[self] = ePROCESS_ALL
1081 1082
1082 1083 while (!queue.empty?) do
1083 1084 current_issue = queue.shift
1084 1085 current_issue_status = issue_status[current_issue]
1085 1086 dependencies << current_issue
1086 1087
1087 1088 # Add parent to queue, if not already in it.
1088 1089 parent = current_issue.parent
1089 1090 parent_status = issue_status[parent]
1090 1091
1091 1092 if parent && (parent_status == eNOT_DISCOVERED) && !except.include?(parent)
1092 1093 queue << parent
1093 1094 issue_status[parent] = ePROCESS_RELATIONS_ONLY
1094 1095 end
1095 1096
1096 1097 # Add children to queue, but only if they are not already in it and
1097 1098 # the children of the current node need to be processed.
1098 1099 if (current_issue_status == ePROCESS_CHILDREN_ONLY || current_issue_status == ePROCESS_ALL)
1099 1100 current_issue.children.each do |child|
1100 1101 next if except.include?(child)
1101 1102
1102 1103 if (issue_status[child] == eNOT_DISCOVERED)
1103 1104 queue << child
1104 1105 issue_status[child] = ePROCESS_ALL
1105 1106 elsif (issue_status[child] == eRELATIONS_PROCESSED)
1106 1107 queue << child
1107 1108 issue_status[child] = ePROCESS_CHILDREN_ONLY
1108 1109 elsif (issue_status[child] == ePROCESS_RELATIONS_ONLY)
1109 1110 queue << child
1110 1111 issue_status[child] = ePROCESS_ALL
1111 1112 end
1112 1113 end
1113 1114 end
1114 1115
1115 1116 # Add related issues to the queue, if they are not already in it.
1116 1117 current_issue.relations_from.map(&:issue_to).each do |related_issue|
1117 1118 next if except.include?(related_issue)
1118 1119
1119 1120 if (issue_status[related_issue] == eNOT_DISCOVERED)
1120 1121 queue << related_issue
1121 1122 issue_status[related_issue] = ePROCESS_ALL
1122 1123 elsif (issue_status[related_issue] == eRELATIONS_PROCESSED)
1123 1124 queue << related_issue
1124 1125 issue_status[related_issue] = ePROCESS_CHILDREN_ONLY
1125 1126 elsif (issue_status[related_issue] == ePROCESS_RELATIONS_ONLY)
1126 1127 queue << related_issue
1127 1128 issue_status[related_issue] = ePROCESS_ALL
1128 1129 end
1129 1130 end
1130 1131
1131 1132 # Set new status for current issue
1132 1133 if (current_issue_status == ePROCESS_ALL) || (current_issue_status == ePROCESS_CHILDREN_ONLY)
1133 1134 issue_status[current_issue] = eALL_PROCESSED
1134 1135 elsif (current_issue_status == ePROCESS_RELATIONS_ONLY)
1135 1136 issue_status[current_issue] = eRELATIONS_PROCESSED
1136 1137 end
1137 1138 end # while
1138 1139
1139 1140 # Remove the issues from the "except" parameter from the result array
1140 1141 dependencies -= except
1141 1142 dependencies.delete(self)
1142 1143
1143 1144 dependencies
1144 1145 end
1145 1146
1146 1147 # Returns an array of issues that duplicate this one
1147 1148 def duplicates
1148 1149 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
1149 1150 end
1150 1151
1151 1152 # Returns the due date or the target due date if any
1152 1153 # Used on gantt chart
1153 1154 def due_before
1154 1155 due_date || (fixed_version ? fixed_version.effective_date : nil)
1155 1156 end
1156 1157
1157 1158 # Returns the time scheduled for this issue.
1158 1159 #
1159 1160 # Example:
1160 1161 # Start Date: 2/26/09, End Date: 3/04/09
1161 1162 # duration => 6
1162 1163 def duration
1163 1164 (start_date && due_date) ? due_date - start_date : 0
1164 1165 end
1165 1166
1166 1167 # Returns the duration in working days
1167 1168 def working_duration
1168 1169 (start_date && due_date) ? working_days(start_date, due_date) : 0
1169 1170 end
1170 1171
1171 1172 def soonest_start(reload=false)
1172 1173 if @soonest_start.nil? || reload
1173 1174 dates = relations_to(reload).collect{|relation| relation.successor_soonest_start}
1174 1175 p = @parent_issue || parent
1175 1176 if p && Setting.parent_issue_dates == 'derived'
1176 1177 dates << p.soonest_start
1177 1178 end
1178 1179 @soonest_start = dates.compact.max
1179 1180 end
1180 1181 @soonest_start
1181 1182 end
1182 1183
1183 1184 # Sets start_date on the given date or the next working day
1184 1185 # and changes due_date to keep the same working duration.
1185 1186 def reschedule_on(date)
1186 1187 wd = working_duration
1187 1188 date = next_working_date(date)
1188 1189 self.start_date = date
1189 1190 self.due_date = add_working_days(date, wd)
1190 1191 end
1191 1192
1192 1193 # Reschedules the issue on the given date or the next working day and saves the record.
1193 1194 # If the issue is a parent task, this is done by rescheduling its subtasks.
1194 1195 def reschedule_on!(date)
1195 1196 return if date.nil?
1196 1197 if leaf? || !dates_derived?
1197 1198 if start_date.nil? || start_date != date
1198 1199 if start_date && start_date > date
1199 1200 # Issue can not be moved earlier than its soonest start date
1200 1201 date = [soonest_start(true), date].compact.max
1201 1202 end
1202 1203 reschedule_on(date)
1203 1204 begin
1204 1205 save
1205 1206 rescue ActiveRecord::StaleObjectError
1206 1207 reload
1207 1208 reschedule_on(date)
1208 1209 save
1209 1210 end
1210 1211 end
1211 1212 else
1212 1213 leaves.each do |leaf|
1213 1214 if leaf.start_date
1214 1215 # Only move subtask if it starts at the same date as the parent
1215 1216 # or if it starts before the given date
1216 1217 if start_date == leaf.start_date || date > leaf.start_date
1217 1218 leaf.reschedule_on!(date)
1218 1219 end
1219 1220 else
1220 1221 leaf.reschedule_on!(date)
1221 1222 end
1222 1223 end
1223 1224 end
1224 1225 end
1225 1226
1226 1227 def dates_derived?
1227 1228 !leaf? && Setting.parent_issue_dates == 'derived'
1228 1229 end
1229 1230
1230 1231 def priority_derived?
1231 1232 !leaf? && Setting.parent_issue_priority == 'derived'
1232 1233 end
1233 1234
1234 1235 def done_ratio_derived?
1235 1236 !leaf? && Setting.parent_issue_done_ratio == 'derived'
1236 1237 end
1237 1238
1238 1239 def <=>(issue)
1239 1240 if issue.nil?
1240 1241 -1
1241 1242 elsif root_id != issue.root_id
1242 1243 (root_id || 0) <=> (issue.root_id || 0)
1243 1244 else
1244 1245 (lft || 0) <=> (issue.lft || 0)
1245 1246 end
1246 1247 end
1247 1248
1248 1249 def to_s
1249 1250 "#{tracker} ##{id}: #{subject}"
1250 1251 end
1251 1252
1252 1253 # Returns a string of css classes that apply to the issue
1253 1254 def css_classes(user=User.current)
1254 1255 s = "issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
1255 1256 s << ' closed' if closed?
1256 1257 s << ' overdue' if overdue?
1257 1258 s << ' child' if child?
1258 1259 s << ' parent' unless leaf?
1259 1260 s << ' private' if is_private?
1260 1261 if user.logged?
1261 1262 s << ' created-by-me' if author_id == user.id
1262 1263 s << ' assigned-to-me' if assigned_to_id == user.id
1263 1264 s << ' assigned-to-my-group' if user.groups.any? {|g| g.id == assigned_to_id}
1264 1265 end
1265 1266 s
1266 1267 end
1267 1268
1268 1269 # Unassigns issues from +version+ if it's no longer shared with issue's project
1269 1270 def self.update_versions_from_sharing_change(version)
1270 1271 # Update issues assigned to the version
1271 1272 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
1272 1273 end
1273 1274
1274 1275 # Unassigns issues from versions that are no longer shared
1275 1276 # after +project+ was moved
1276 1277 def self.update_versions_from_hierarchy_change(project)
1277 1278 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
1278 1279 # Update issues of the moved projects and issues assigned to a version of a moved project
1279 1280 Issue.update_versions(
1280 1281 ["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)",
1281 1282 moved_project_ids, moved_project_ids]
1282 1283 )
1283 1284 end
1284 1285
1285 1286 def parent_issue_id=(arg)
1286 1287 s = arg.to_s.strip.presence
1287 1288 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
1288 1289 @invalid_parent_issue_id = nil
1289 1290 elsif s.blank?
1290 1291 @parent_issue = nil
1291 1292 @invalid_parent_issue_id = nil
1292 1293 else
1293 1294 @parent_issue = nil
1294 1295 @invalid_parent_issue_id = arg
1295 1296 end
1296 1297 end
1297 1298
1298 1299 def parent_issue_id
1299 1300 if @invalid_parent_issue_id
1300 1301 @invalid_parent_issue_id
1301 1302 elsif instance_variable_defined? :@parent_issue
1302 1303 @parent_issue.nil? ? nil : @parent_issue.id
1303 1304 else
1304 1305 parent_id
1305 1306 end
1306 1307 end
1307 1308
1308 1309 def set_parent_id
1309 1310 self.parent_id = parent_issue_id
1310 1311 end
1311 1312
1312 1313 # Returns true if issue's project is a valid
1313 1314 # parent issue project
1314 1315 def valid_parent_project?(issue=parent)
1315 1316 return true if issue.nil? || issue.project_id == project_id
1316 1317
1317 1318 case Setting.cross_project_subtasks
1318 1319 when 'system'
1319 1320 true
1320 1321 when 'tree'
1321 1322 issue.project.root == project.root
1322 1323 when 'hierarchy'
1323 1324 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1324 1325 when 'descendants'
1325 1326 issue.project.is_or_is_ancestor_of?(project)
1326 1327 else
1327 1328 false
1328 1329 end
1329 1330 end
1330 1331
1331 1332 # Returns an issue scope based on project and scope
1332 1333 def self.cross_project_scope(project, scope=nil)
1333 1334 if project.nil?
1334 1335 return Issue
1335 1336 end
1336 1337 case scope
1337 1338 when 'all', 'system'
1338 1339 Issue
1339 1340 when 'tree'
1340 1341 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1341 1342 :lft => project.root.lft, :rgt => project.root.rgt)
1342 1343 when 'hierarchy'
1343 1344 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt) OR (#{Project.table_name}.lft < :lft AND #{Project.table_name}.rgt > :rgt)",
1344 1345 :lft => project.lft, :rgt => project.rgt)
1345 1346 when 'descendants'
1346 1347 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1347 1348 :lft => project.lft, :rgt => project.rgt)
1348 1349 else
1349 1350 Issue.where(:project_id => project.id)
1350 1351 end
1351 1352 end
1352 1353
1353 1354 def self.by_tracker(project)
1354 1355 count_and_group_by(:project => project, :association => :tracker)
1355 1356 end
1356 1357
1357 1358 def self.by_version(project)
1358 1359 count_and_group_by(:project => project, :association => :fixed_version)
1359 1360 end
1360 1361
1361 1362 def self.by_priority(project)
1362 1363 count_and_group_by(:project => project, :association => :priority)
1363 1364 end
1364 1365
1365 1366 def self.by_category(project)
1366 1367 count_and_group_by(:project => project, :association => :category)
1367 1368 end
1368 1369
1369 1370 def self.by_assigned_to(project)
1370 1371 count_and_group_by(:project => project, :association => :assigned_to)
1371 1372 end
1372 1373
1373 1374 def self.by_author(project)
1374 1375 count_and_group_by(:project => project, :association => :author)
1375 1376 end
1376 1377
1377 1378 def self.by_subproject(project)
1378 1379 r = count_and_group_by(:project => project, :with_subprojects => true, :association => :project)
1379 1380 r.reject {|r| r["project_id"] == project.id.to_s}
1380 1381 end
1381 1382
1382 1383 # Query generator for selecting groups of issue counts for a project
1383 1384 # based on specific criteria
1384 1385 #
1385 1386 # Options
1386 1387 # * project - Project to search in.
1387 1388 # * with_subprojects - Includes subprojects issues if set to true.
1388 1389 # * association - Symbol. Association for grouping.
1389 1390 def self.count_and_group_by(options)
1390 1391 assoc = reflect_on_association(options[:association])
1391 1392 select_field = assoc.foreign_key
1392 1393
1393 1394 Issue.
1394 1395 visible(User.current, :project => options[:project], :with_subprojects => options[:with_subprojects]).
1395 1396 joins(:status, assoc.name).
1396 1397 group(:status_id, :is_closed, select_field).
1397 1398 count.
1398 1399 map do |columns, total|
1399 1400 status_id, is_closed, field_value = columns
1400 1401 is_closed = ['t', 'true', '1'].include?(is_closed.to_s)
1401 1402 {
1402 1403 "status_id" => status_id.to_s,
1403 1404 "closed" => is_closed,
1404 1405 select_field => field_value.to_s,
1405 1406 "total" => total.to_s
1406 1407 }
1407 1408 end
1408 1409 end
1409 1410
1410 1411 # Returns a scope of projects that user can assign the issue to
1411 1412 def allowed_target_projects(user=User.current)
1412 1413 current_project = new_record? ? nil : project
1413 1414 self.class.allowed_target_projects(user, current_project)
1414 1415 end
1415 1416
1416 1417 # Returns a scope of projects that user can assign issues to
1417 1418 # If current_project is given, it will be included in the scope
1418 1419 def self.allowed_target_projects(user=User.current, current_project=nil)
1419 1420 condition = Project.allowed_to_condition(user, :add_issues)
1420 1421 if current_project
1421 1422 condition = ["(#{condition}) OR #{Project.table_name}.id = ?", current_project.id]
1422 1423 end
1423 1424 Project.where(condition).having_trackers
1424 1425 end
1425 1426
1426 1427 private
1427 1428
1428 1429 def after_project_change
1429 1430 # Update project_id on related time entries
1430 1431 TimeEntry.where({:issue_id => id}).update_all(["project_id = ?", project_id])
1431 1432
1432 1433 # Delete issue relations
1433 1434 unless Setting.cross_project_issue_relations?
1434 1435 relations_from.clear
1435 1436 relations_to.clear
1436 1437 end
1437 1438
1438 1439 # Move subtasks that were in the same project
1439 1440 children.each do |child|
1440 1441 next unless child.project_id == project_id_was
1441 1442 # Change project and keep project
1442 1443 child.send :project=, project, true
1443 1444 unless child.save
1444 1445 raise ActiveRecord::Rollback
1445 1446 end
1446 1447 end
1447 1448 end
1448 1449
1449 1450 # Callback for after the creation of an issue by copy
1450 1451 # * adds a "copied to" relation with the copied issue
1451 1452 # * copies subtasks from the copied issue
1452 1453 def after_create_from_copy
1453 1454 return unless copy? && !@after_create_from_copy_handled
1454 1455
1455 1456 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1456 1457 if @current_journal
1457 1458 @copied_from.init_journal(@current_journal.user)
1458 1459 end
1459 1460 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1460 1461 unless relation.save
1461 1462 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1462 1463 end
1463 1464 end
1464 1465
1465 1466 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1466 1467 copy_options = (@copy_options || {}).merge(:subtasks => false)
1467 1468 copied_issue_ids = {@copied_from.id => self.id}
1468 1469 @copied_from.reload.descendants.reorder("#{Issue.table_name}.lft").each do |child|
1469 1470 # Do not copy self when copying an issue as a descendant of the copied issue
1470 1471 next if child == self
1471 1472 # Do not copy subtasks of issues that were not copied
1472 1473 next unless copied_issue_ids[child.parent_id]
1473 1474 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1474 1475 unless child.visible?
1475 1476 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1476 1477 next
1477 1478 end
1478 1479 copy = Issue.new.copy_from(child, copy_options)
1479 1480 if @current_journal
1480 1481 copy.init_journal(@current_journal.user)
1481 1482 end
1482 1483 copy.author = author
1483 1484 copy.project = project
1484 1485 copy.parent_issue_id = copied_issue_ids[child.parent_id]
1485 1486 unless copy.save
1486 1487 logger.error "Could not copy subtask ##{child.id} while copying ##{@copied_from.id} to ##{id} due to validation errors: #{copy.errors.full_messages.join(', ')}" if logger
1487 1488 next
1488 1489 end
1489 1490 copied_issue_ids[child.id] = copy.id
1490 1491 end
1491 1492 end
1492 1493 @after_create_from_copy_handled = true
1493 1494 end
1494 1495
1495 1496 def update_nested_set_attributes
1496 1497 if parent_id_changed?
1497 1498 update_nested_set_attributes_on_parent_change
1498 1499 end
1499 1500 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1500 1501 end
1501 1502
1502 1503 # Updates the nested set for when an existing issue is moved
1503 1504 def update_nested_set_attributes_on_parent_change
1504 1505 former_parent_id = parent_id_was
1505 1506 # delete invalid relations of all descendants
1506 1507 self_and_descendants.each do |issue|
1507 1508 issue.relations.each do |relation|
1508 1509 relation.destroy unless relation.valid?
1509 1510 end
1510 1511 end
1511 1512 # update former parent
1512 1513 recalculate_attributes_for(former_parent_id) if former_parent_id
1513 1514 end
1514 1515
1515 1516 def update_parent_attributes
1516 1517 if parent_id
1517 1518 recalculate_attributes_for(parent_id)
1518 1519 association(:parent).reset
1519 1520 end
1520 1521 end
1521 1522
1522 1523 def recalculate_attributes_for(issue_id)
1523 1524 if issue_id && p = Issue.find_by_id(issue_id)
1524 1525 if p.priority_derived?
1525 1526 # priority = highest priority of children
1526 1527 if priority_position = p.children.joins(:priority).maximum("#{IssuePriority.table_name}.position")
1527 1528 p.priority = IssuePriority.find_by_position(priority_position)
1528 1529 end
1529 1530 end
1530 1531
1531 1532 if p.dates_derived?
1532 1533 # start/due dates = lowest/highest dates of children
1533 1534 p.start_date = p.children.minimum(:start_date)
1534 1535 p.due_date = p.children.maximum(:due_date)
1535 1536 if p.start_date && p.due_date && p.due_date < p.start_date
1536 1537 p.start_date, p.due_date = p.due_date, p.start_date
1537 1538 end
1538 1539 end
1539 1540
1540 1541 if p.done_ratio_derived?
1541 1542 # done ratio = weighted average ratio of leaves
1542 1543 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1543 1544 child_count = p.children.count
1544 1545 if child_count > 0
1545 1546 average = p.children.where("estimated_hours > 0").average(:estimated_hours).to_f
1546 1547 if average == 0
1547 1548 average = 1
1548 1549 end
1549 1550 done = p.children.joins(:status).
1550 1551 sum("COALESCE(CASE WHEN estimated_hours > 0 THEN estimated_hours ELSE NULL END, #{average}) " +
1551 1552 "* (CASE WHEN is_closed = #{self.class.connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)").to_f
1552 1553 progress = done / (average * child_count)
1553 1554 p.done_ratio = progress.round
1554 1555 end
1555 1556 end
1556 1557 end
1557 1558
1558 1559 # ancestors will be recursively updated
1559 1560 p.save(:validate => false)
1560 1561 end
1561 1562 end
1562 1563
1563 1564 # Update issues so their versions are not pointing to a
1564 1565 # fixed_version that is not shared with the issue's project
1565 1566 def self.update_versions(conditions=nil)
1566 1567 # Only need to update issues with a fixed_version from
1567 1568 # a different project and that is not systemwide shared
1568 1569 Issue.joins(:project, :fixed_version).
1569 1570 where("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1570 1571 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1571 1572 " AND #{Version.table_name}.sharing <> 'system'").
1572 1573 where(conditions).each do |issue|
1573 1574 next if issue.project.nil? || issue.fixed_version.nil?
1574 1575 unless issue.project.shared_versions.include?(issue.fixed_version)
1575 1576 issue.init_journal(User.current)
1576 1577 issue.fixed_version = nil
1577 1578 issue.save
1578 1579 end
1579 1580 end
1580 1581 end
1581 1582
1582 1583 # Callback on file attachment
1583 1584 def attachment_added(attachment)
1584 1585 if current_journal && !attachment.new_record?
1585 1586 current_journal.journalize_attachment(attachment, :added)
1586 1587 end
1587 1588 end
1588 1589
1589 1590 # Callback on attachment deletion
1590 1591 def attachment_removed(attachment)
1591 1592 if current_journal && !attachment.new_record?
1592 1593 current_journal.journalize_attachment(attachment, :removed)
1593 1594 current_journal.save
1594 1595 end
1595 1596 end
1596 1597
1597 1598 # Called after a relation is added
1598 1599 def relation_added(relation)
1599 1600 if current_journal
1600 1601 current_journal.journalize_relation(relation, :added)
1601 1602 current_journal.save
1602 1603 end
1603 1604 end
1604 1605
1605 1606 # Called after a relation is removed
1606 1607 def relation_removed(relation)
1607 1608 if current_journal
1608 1609 current_journal.journalize_relation(relation, :removed)
1609 1610 current_journal.save
1610 1611 end
1611 1612 end
1612 1613
1613 1614 # Default assignment based on category
1614 1615 def default_assign
1615 1616 if assigned_to.nil? && category && category.assigned_to
1616 1617 self.assigned_to = category.assigned_to
1617 1618 end
1618 1619 end
1619 1620
1620 1621 # Updates start/due dates of following issues
1621 1622 def reschedule_following_issues
1622 1623 if start_date_changed? || due_date_changed?
1623 1624 relations_from.each do |relation|
1624 1625 relation.set_issue_to_dates
1625 1626 end
1626 1627 end
1627 1628 end
1628 1629
1629 1630 # Closes duplicates if the issue is being closed
1630 1631 def close_duplicates
1631 1632 if closing?
1632 1633 duplicates.each do |duplicate|
1633 1634 # Reload is needed in case the duplicate was updated by a previous duplicate
1634 1635 duplicate.reload
1635 1636 # Don't re-close it if it's already closed
1636 1637 next if duplicate.closed?
1637 1638 # Same user and notes
1638 1639 if @current_journal
1639 1640 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1640 1641 duplicate.private_notes = @current_journal.private_notes
1641 1642 end
1642 1643 duplicate.update_attribute :status, self.status
1643 1644 end
1644 1645 end
1645 1646 end
1646 1647
1647 1648 # Make sure updated_on is updated when adding a note and set updated_on now
1648 1649 # so we can set closed_on with the same value on closing
1649 1650 def force_updated_on_change
1650 1651 if @current_journal || changed?
1651 1652 self.updated_on = current_time_from_proper_timezone
1652 1653 if new_record?
1653 1654 self.created_on = updated_on
1654 1655 end
1655 1656 end
1656 1657 end
1657 1658
1658 1659 # Callback for setting closed_on when the issue is closed.
1659 1660 # The closed_on attribute stores the time of the last closing
1660 1661 # and is preserved when the issue is reopened.
1661 1662 def update_closed_on
1662 1663 if closing?
1663 1664 self.closed_on = updated_on
1664 1665 end
1665 1666 end
1666 1667
1667 1668 # Saves the changes in a Journal
1668 1669 # Called after_save
1669 1670 def create_journal
1670 1671 if current_journal
1671 1672 current_journal.save
1672 1673 end
1673 1674 end
1674 1675
1675 1676 def send_notification
1676 1677 if notify? && Setting.notified_events.include?('issue_added')
1677 1678 Mailer.deliver_issue_add(self)
1678 1679 end
1679 1680 end
1680 1681
1681 1682 # Stores the previous assignee so we can still have access
1682 1683 # to it during after_save callbacks (assigned_to_id_was is reset)
1683 1684 def set_assigned_to_was
1684 1685 @previous_assigned_to_id = assigned_to_id_was
1685 1686 end
1686 1687
1687 1688 # Clears the previous assignee at the end of after_save callbacks
1688 1689 def clear_assigned_to_was
1689 1690 @assigned_to_was = nil
1690 1691 @previous_assigned_to_id = nil
1691 1692 end
1692 1693
1693 1694 def clear_disabled_fields
1694 1695 if tracker
1695 1696 tracker.disabled_core_fields.each do |attribute|
1696 1697 send "#{attribute}=", nil
1697 1698 end
1698 1699 self.done_ratio ||= 0
1699 1700 end
1700 1701 end
1701 1702 end
@@ -1,170 +1,178
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 module Redmine
19 19 module Acts
20 20 module Customizable
21 21 def self.included(base)
22 22 base.extend ClassMethods
23 23 end
24 24
25 25 module ClassMethods
26 26 def acts_as_customizable(options = {})
27 27 return if self.included_modules.include?(Redmine::Acts::Customizable::InstanceMethods)
28 28 cattr_accessor :customizable_options
29 29 self.customizable_options = options
30 30 has_many :custom_values, lambda {includes(:custom_field).order("#{CustomField.table_name}.position")},
31 31 :as => :customized,
32 32 :dependent => :delete_all,
33 33 :validate => false
34 34
35 35 send :include, Redmine::Acts::Customizable::InstanceMethods
36 36 validate :validate_custom_field_values
37 37 after_save :save_custom_field_values
38 38 end
39 39 end
40 40
41 41 module InstanceMethods
42 42 def self.included(base)
43 43 base.extend ClassMethods
44 44 base.send :alias_method_chain, :reload, :custom_fields
45 45 end
46 46
47 47 def available_custom_fields
48 48 CustomField.where("type = '#{self.class.name}CustomField'").sorted.to_a
49 49 end
50 50
51 51 # Sets the values of the object's custom fields
52 52 # values is an array like [{'id' => 1, 'value' => 'foo'}, {'id' => 2, 'value' => 'bar'}]
53 53 def custom_fields=(values)
54 54 values_to_hash = values.inject({}) do |hash, v|
55 55 v = v.stringify_keys
56 56 if v['id'] && v.has_key?('value')
57 57 hash[v['id']] = v['value']
58 58 end
59 59 hash
60 60 end
61 61 self.custom_field_values = values_to_hash
62 62 end
63 63
64 64 # Sets the values of the object's custom fields
65 65 # values is a hash like {'1' => 'foo', 2 => 'bar'}
66 66 def custom_field_values=(values)
67 67 values = values.stringify_keys
68 68
69 69 custom_field_values.each do |custom_field_value|
70 70 key = custom_field_value.custom_field_id.to_s
71 71 if values.has_key?(key)
72 72 value = values[key]
73 73 if value.is_a?(Array)
74 74 value = value.reject(&:blank?).map(&:to_s).uniq
75 75 if value.empty?
76 76 value << ''
77 77 end
78 78 else
79 79 value = value.to_s
80 80 end
81 81 custom_field_value.value = value
82 82 end
83 83 end
84 84 @custom_field_values_changed = true
85 85 end
86 86
87 87 def custom_field_values
88 88 @custom_field_values ||= available_custom_fields.collect do |field|
89 89 x = CustomFieldValue.new
90 90 x.custom_field = field
91 91 x.customized = self
92 92 if field.multiple?
93 93 values = custom_values.select { |v| v.custom_field == field }
94 94 if values.empty?
95 95 values << custom_values.build(:customized => self, :custom_field => field)
96 96 end
97 97 x.value = values.map(&:value)
98 98 else
99 99 cv = custom_values.detect { |v| v.custom_field == field }
100 100 cv ||= custom_values.build(:customized => self, :custom_field => field)
101 101 x.value = cv.value
102 102 end
103 103 x.value_was = x.value.dup if x.value
104 104 x
105 105 end
106 106 end
107 107
108 108 def visible_custom_field_values
109 109 custom_field_values.select(&:visible?)
110 110 end
111 111
112 112 def custom_field_values_changed?
113 113 @custom_field_values_changed == true
114 114 end
115 115
116 116 def custom_value_for(c)
117 117 field_id = (c.is_a?(CustomField) ? c.id : c.to_i)
118 118 custom_values.detect {|v| v.custom_field_id == field_id }
119 119 end
120 120
121 121 def custom_field_value(c)
122 122 field_id = (c.is_a?(CustomField) ? c.id : c.to_i)
123 123 custom_field_values.detect {|v| v.custom_field_id == field_id }.try(:value)
124 124 end
125 125
126 126 def validate_custom_field_values
127 127 if new_record? || custom_field_values_changed?
128 128 custom_field_values.each(&:validate_value)
129 129 end
130 130 end
131 131
132 132 def save_custom_field_values
133 133 target_custom_values = []
134 134 custom_field_values.each do |custom_field_value|
135 135 if custom_field_value.value.is_a?(Array)
136 136 custom_field_value.value.each do |v|
137 137 target = custom_values.detect {|cv| cv.custom_field == custom_field_value.custom_field && cv.value == v}
138 138 target ||= custom_values.build(:customized => self, :custom_field => custom_field_value.custom_field, :value => v)
139 139 target_custom_values << target
140 140 end
141 141 else
142 142 target = custom_values.detect {|cv| cv.custom_field == custom_field_value.custom_field}
143 143 target ||= custom_values.build(:customized => self, :custom_field => custom_field_value.custom_field)
144 144 target.value = custom_field_value.value
145 145 target_custom_values << target
146 146 end
147 147 end
148 148 self.custom_values = target_custom_values
149 149 custom_values.each(&:save)
150 150 @custom_field_values_changed = false
151 151 true
152 152 end
153 153
154 def reassign_custom_field_values
155 if @custom_field_values
156 values = @custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
157 @custom_field_values = nil
158 self.custom_field_values = values
159 end
160 end
161
154 162 def reset_custom_values!
155 163 @custom_field_values = nil
156 164 @custom_field_values_changed = true
157 165 end
158 166
159 167 def reload_with_custom_fields(*args)
160 168 @custom_field_values = nil
161 169 @custom_field_values_changed = false
162 170 reload_without_custom_fields(*args)
163 171 end
164 172
165 173 module ClassMethods
166 174 end
167 175 end
168 176 end
169 177 end
170 178 end
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
General Comments 0
You need to be logged in to leave comments. Login now