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