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