##// END OF EJS Templates
Let user always see his private notes (#17632)....
Jean-Philippe Lang -
r15799:7b32a0371d55
parent child
Show More

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

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