##// END OF EJS Templates
Code cleanup....
Jean-Philippe Lang -
r8078:cd0113a2875f
parent child
Show More
@@ -1,983 +1,982
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 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
21 21 belongs_to :project
22 22 belongs_to :tracker
23 23 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
24 24 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
25 25 belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id'
26 26 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
27 27 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
28 28 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
29 29
30 30 has_many :journals, :as => :journalized, :dependent => :destroy
31 31 has_many :time_entries, :dependent => :delete_all
32 32 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
33 33
34 34 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
35 35 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
36 36
37 37 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
38 38 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
39 39 acts_as_customizable
40 40 acts_as_watchable
41 41 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
42 42 :include => [:project, :journals],
43 43 # sort by id so that limited eager loading doesn't break with postgresql
44 44 :order_column => "#{table_name}.id"
45 45 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
46 46 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
47 47 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
48 48
49 49 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
50 50 :author_key => :author_id
51 51
52 52 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
53 53
54 54 attr_reader :current_journal
55 55
56 56 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
57 57
58 58 validates_length_of :subject, :maximum => 255
59 59 validates_inclusion_of :done_ratio, :in => 0..100
60 60 validates_numericality_of :estimated_hours, :allow_nil => true
61 61 validate :validate_issue
62 62
63 63 named_scope :visible, lambda {|*args| { :include => :project,
64 64 :conditions => Issue.visible_condition(args.shift || User.current, *args) } }
65 65
66 66 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
67 67
68 68 named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
69 69 named_scope :with_limit, lambda { |limit| { :limit => limit} }
70 70 named_scope :on_active_project, :include => [:status, :project, :tracker],
71 71 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
72 72
73 73 named_scope :without_version, lambda {
74 74 {
75 75 :conditions => { :fixed_version_id => nil}
76 76 }
77 77 }
78 78
79 79 named_scope :with_query, lambda {|query|
80 80 {
81 81 :conditions => Query.merge_conditions(query.statement)
82 82 }
83 83 }
84 84
85 85 before_create :default_assign
86 86 before_save :close_duplicates, :update_done_ratio_from_issue_status
87 87 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
88 88 after_destroy :update_parent_attributes
89 89
90 90 # Returns a SQL conditions string used to find all issues visible by the specified user
91 91 def self.visible_condition(user, options={})
92 92 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
93 93 case role.issues_visibility
94 94 when 'all'
95 95 nil
96 96 when 'default'
97 97 user_ids = [user.id] + user.groups.map(&:id)
98 98 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
99 99 when 'own'
100 100 user_ids = [user.id] + user.groups.map(&:id)
101 101 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
102 102 else
103 103 '1=0'
104 104 end
105 105 end
106 106 end
107 107
108 108 # Returns true if usr or current user is allowed to view the issue
109 109 def visible?(usr=nil)
110 110 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
111 111 case role.issues_visibility
112 112 when 'all'
113 113 true
114 114 when 'default'
115 115 !self.is_private? || self.author == user || user.is_or_belongs_to?(assigned_to)
116 116 when 'own'
117 117 self.author == user || user.is_or_belongs_to?(assigned_to)
118 118 else
119 119 false
120 120 end
121 121 end
122 122 end
123 123
124 124 def after_initialize
125 125 if new_record?
126 126 # set default values for new records only
127 127 self.status ||= IssueStatus.default
128 128 self.priority ||= IssuePriority.default
129 129 end
130 130 end
131 131
132 132 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
133 133 def available_custom_fields
134 134 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
135 135 end
136 136
137 137 def copy_from(arg)
138 138 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
139 139 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
140 140 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
141 141 self.status = issue.status
142 142 self
143 143 end
144 144
145 145 # Moves/copies an issue to a new project and tracker
146 146 # Returns the moved/copied issue on success, false on failure
147 147 def move_to_project(*args)
148 148 ret = Issue.transaction do
149 149 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
150 150 end || false
151 151 end
152 152
153 153 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
154 154 options ||= {}
155 155 issue = options[:copy] ? self.class.new.copy_from(self) : self
156 156
157 157 if new_project && issue.project_id != new_project.id
158 158 # delete issue relations
159 159 unless Setting.cross_project_issue_relations?
160 160 issue.relations_from.clear
161 161 issue.relations_to.clear
162 162 end
163 163 # issue is moved to another project
164 164 # reassign to the category with same name if any
165 165 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
166 166 issue.category = new_category
167 167 # Keep the fixed_version if it's still valid in the new_project
168 168 unless new_project.shared_versions.include?(issue.fixed_version)
169 169 issue.fixed_version = nil
170 170 end
171 171 issue.project = new_project
172 172 if issue.parent && issue.parent.project_id != issue.project_id
173 173 issue.parent_issue_id = nil
174 174 end
175 175 end
176 176 if new_tracker
177 177 issue.tracker = new_tracker
178 178 issue.reset_custom_values!
179 179 end
180 180 if options[:copy]
181 181 issue.author = User.current
182 182 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
183 183 issue.status = if options[:attributes] && options[:attributes][:status_id]
184 184 IssueStatus.find_by_id(options[:attributes][:status_id])
185 185 else
186 186 self.status
187 187 end
188 188 end
189 189 # Allow bulk setting of attributes on the issue
190 190 if options[:attributes]
191 191 issue.attributes = options[:attributes]
192 192 end
193 193 if issue.save
194 194 if options[:copy]
195 195 if current_journal && current_journal.notes.present?
196 196 issue.init_journal(current_journal.user, current_journal.notes)
197 197 issue.current_journal.notify = false
198 198 issue.save
199 199 end
200 200 else
201 201 # Manually update project_id on related time entries
202 202 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
203 203
204 204 issue.children.each do |child|
205 205 unless child.move_to_project_without_transaction(new_project)
206 206 # Move failed and transaction was rollback'd
207 207 return false
208 208 end
209 209 end
210 210 end
211 211 else
212 212 return false
213 213 end
214 214 issue
215 215 end
216 216
217 217 def status_id=(sid)
218 218 self.status = nil
219 219 write_attribute(:status_id, sid)
220 220 end
221 221
222 222 def priority_id=(pid)
223 223 self.priority = nil
224 224 write_attribute(:priority_id, pid)
225 225 end
226 226
227 227 def tracker_id=(tid)
228 228 self.tracker = nil
229 229 result = write_attribute(:tracker_id, tid)
230 230 @custom_field_values = nil
231 231 result
232 232 end
233 233
234 234 def description=(arg)
235 235 if arg.is_a?(String)
236 236 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
237 237 end
238 238 write_attribute(:description, arg)
239 239 end
240 240
241 241 # Overrides attributes= so that project and tracker get assigned first
242 242 def attributes_with_project_and_tracker_first=(new_attributes, *args)
243 243 return if new_attributes.nil?
244 244 attrs = new_attributes.dup
245 245 attrs.stringify_keys!
246 246
247 247 %w(project project_id tracker tracker_id).each do |attr|
248 248 if attrs.has_key?(attr)
249 249 send "#{attr}=", attrs.delete(attr)
250 250 end
251 251 end
252 252 send :attributes_without_project_and_tracker_first=, attrs, *args
253 253 end
254 254 # Do not redefine alias chain on reload (see #4838)
255 255 alias_method_chain(:attributes=, :project_and_tracker_first) unless method_defined?(:attributes_without_project_and_tracker_first=)
256 256
257 257 def estimated_hours=(h)
258 258 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
259 259 end
260 260
261 261 safe_attributes 'tracker_id',
262 262 'status_id',
263 'parent_issue_id',
264 263 'category_id',
265 264 'assigned_to_id',
266 265 'priority_id',
267 266 'fixed_version_id',
268 267 'subject',
269 268 'description',
270 269 'start_date',
271 270 'due_date',
272 271 'done_ratio',
273 272 'estimated_hours',
274 273 'custom_field_values',
275 274 'custom_fields',
276 275 'lock_version',
277 276 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
278 277
279 278 safe_attributes 'status_id',
280 279 'assigned_to_id',
281 280 'fixed_version_id',
282 281 'done_ratio',
283 282 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
284 283
285 284 safe_attributes 'watcher_user_ids',
286 285 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
287 286
288 287 safe_attributes 'is_private',
289 288 :if => lambda {|issue, user|
290 289 user.allowed_to?(:set_issues_private, issue.project) ||
291 290 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
292 291 }
293 292
293 safe_attributes 'parent_issue_id',
294 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
295 user.allowed_to?(:manage_subtasks, issue.project)}
296
294 297 # Safely sets attributes
295 298 # Should be called from controllers instead of #attributes=
296 299 # attr_accessible is too rough because we still want things like
297 300 # Issue.new(:project => foo) to work
298 301 # TODO: move workflow/permission checks from controllers to here
299 302 def safe_attributes=(attrs, user=User.current)
300 303 return unless attrs.is_a?(Hash)
301 304
302 305 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
303 306 attrs = delete_unsafe_attributes(attrs, user)
304 307 return if attrs.empty?
305 308
306 309 # Tracker must be set before since new_statuses_allowed_to depends on it.
307 310 if t = attrs.delete('tracker_id')
308 311 self.tracker_id = t
309 312 end
310 313
311 314 if attrs['status_id']
312 315 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
313 316 attrs.delete('status_id')
314 317 end
315 318 end
316 319
317 320 unless leaf?
318 321 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
319 322 end
320 323
321 if attrs.has_key?('parent_issue_id')
322 if !user.allowed_to?(:manage_subtasks, project)
323 attrs.delete('parent_issue_id')
324 elsif !attrs['parent_issue_id'].blank?
325 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
326 end
324 if attrs['parent_issue_id'].present?
325 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
327 326 end
328 327
329 328 # mass-assignment security bypass
330 329 self.send :attributes=, attrs, false
331 330 end
332 331
333 332 def done_ratio
334 333 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
335 334 status.default_done_ratio
336 335 else
337 336 read_attribute(:done_ratio)
338 337 end
339 338 end
340 339
341 340 def self.use_status_for_done_ratio?
342 341 Setting.issue_done_ratio == 'issue_status'
343 342 end
344 343
345 344 def self.use_field_for_done_ratio?
346 345 Setting.issue_done_ratio == 'issue_field'
347 346 end
348 347
349 348 def validate_issue
350 349 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
351 350 errors.add :due_date, :not_a_date
352 351 end
353 352
354 353 if self.due_date and self.start_date and self.due_date < self.start_date
355 354 errors.add :due_date, :greater_than_start_date
356 355 end
357 356
358 357 if start_date && soonest_start && start_date < soonest_start
359 358 errors.add :start_date, :invalid
360 359 end
361 360
362 361 if fixed_version
363 362 if !assignable_versions.include?(fixed_version)
364 363 errors.add :fixed_version_id, :inclusion
365 364 elsif reopened? && fixed_version.closed?
366 365 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
367 366 end
368 367 end
369 368
370 369 # Checks that the issue can not be added/moved to a disabled tracker
371 370 if project && (tracker_id_changed? || project_id_changed?)
372 371 unless project.trackers.include?(tracker)
373 372 errors.add :tracker_id, :inclusion
374 373 end
375 374 end
376 375
377 376 # Checks parent issue assignment
378 377 if @parent_issue
379 378 if @parent_issue.project_id != project_id
380 379 errors.add :parent_issue_id, :not_same_project
381 380 elsif !new_record?
382 381 # moving an existing issue
383 382 if @parent_issue.root_id != root_id
384 383 # we can always move to another tree
385 384 elsif move_possible?(@parent_issue)
386 385 # move accepted inside tree
387 386 else
388 387 errors.add :parent_issue_id, :not_a_valid_parent
389 388 end
390 389 end
391 390 end
392 391 end
393 392
394 393 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
395 394 # even if the user turns off the setting later
396 395 def update_done_ratio_from_issue_status
397 396 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
398 397 self.done_ratio = status.default_done_ratio
399 398 end
400 399 end
401 400
402 401 def init_journal(user, notes = "")
403 402 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
404 403 @issue_before_change = self.clone
405 404 @issue_before_change.status = self.status
406 405 @custom_values_before_change = {}
407 406 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
408 407 # Make sure updated_on is updated when adding a note.
409 408 updated_on_will_change!
410 409 @current_journal
411 410 end
412 411
413 412 # Return true if the issue is closed, otherwise false
414 413 def closed?
415 414 self.status.is_closed?
416 415 end
417 416
418 417 # Return true if the issue is being reopened
419 418 def reopened?
420 419 if !new_record? && status_id_changed?
421 420 status_was = IssueStatus.find_by_id(status_id_was)
422 421 status_new = IssueStatus.find_by_id(status_id)
423 422 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
424 423 return true
425 424 end
426 425 end
427 426 false
428 427 end
429 428
430 429 # Return true if the issue is being closed
431 430 def closing?
432 431 if !new_record? && status_id_changed?
433 432 status_was = IssueStatus.find_by_id(status_id_was)
434 433 status_new = IssueStatus.find_by_id(status_id)
435 434 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
436 435 return true
437 436 end
438 437 end
439 438 false
440 439 end
441 440
442 441 # Returns true if the issue is overdue
443 442 def overdue?
444 443 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
445 444 end
446 445
447 446 # Is the amount of work done less than it should for the due date
448 447 def behind_schedule?
449 448 return false if start_date.nil? || due_date.nil?
450 449 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
451 450 return done_date <= Date.today
452 451 end
453 452
454 453 # Does this issue have children?
455 454 def children?
456 455 !leaf?
457 456 end
458 457
459 458 # Users the issue can be assigned to
460 459 def assignable_users
461 460 users = project.assignable_users
462 461 users << author if author
463 462 users << assigned_to if assigned_to
464 463 users.uniq.sort
465 464 end
466 465
467 466 # Versions that the issue can be assigned to
468 467 def assignable_versions
469 468 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
470 469 end
471 470
472 471 # Returns true if this issue is blocked by another issue that is still open
473 472 def blocked?
474 473 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
475 474 end
476 475
477 476 # Returns an array of status that user is able to apply
478 477 def new_statuses_allowed_to(user, include_default=false)
479 478 statuses = status.find_new_statuses_allowed_to(
480 479 user.roles_for_project(project),
481 480 tracker,
482 481 author == user,
483 482 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
484 483 )
485 484 statuses << status unless statuses.empty?
486 485 statuses << IssueStatus.default if include_default
487 486 statuses = statuses.uniq.sort
488 487 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
489 488 end
490 489
491 490 # Returns the mail adresses of users that should be notified
492 491 def recipients
493 492 notified = project.notified_users
494 493 # Author and assignee are always notified unless they have been
495 494 # locked or don't want to be notified
496 495 notified << author if author && author.active? && author.notify_about?(self)
497 496 if assigned_to
498 497 if assigned_to.is_a?(Group)
499 498 notified += assigned_to.users.select {|u| u.active? && u.notify_about?(self)}
500 499 else
501 500 notified << assigned_to if assigned_to.active? && assigned_to.notify_about?(self)
502 501 end
503 502 end
504 503 notified.uniq!
505 504 # Remove users that can not view the issue
506 505 notified.reject! {|user| !visible?(user)}
507 506 notified.collect(&:mail)
508 507 end
509 508
510 509 # Returns the number of hours spent on this issue
511 510 def spent_hours
512 511 @spent_hours ||= time_entries.sum(:hours) || 0
513 512 end
514 513
515 514 # Returns the total number of hours spent on this issue and its descendants
516 515 #
517 516 # Example:
518 517 # spent_hours => 0.0
519 518 # spent_hours => 50.2
520 519 def total_spent_hours
521 520 @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
522 521 end
523 522
524 523 def relations
525 524 @relations ||= (relations_from + relations_to).sort
526 525 end
527 526
528 527 # Preloads relations for a collection of issues
529 528 def self.load_relations(issues)
530 529 if issues.any?
531 530 relations = IssueRelation.all(:conditions => ["issue_from_id IN (:ids) OR issue_to_id IN (:ids)", {:ids => issues.map(&:id)}])
532 531 issues.each do |issue|
533 532 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
534 533 end
535 534 end
536 535 end
537 536
538 537 # Preloads visible spent time for a collection of issues
539 538 def self.load_visible_spent_hours(issues, user=User.current)
540 539 if issues.any?
541 540 hours_by_issue_id = TimeEntry.visible(user).sum(:hours, :group => :issue_id)
542 541 issues.each do |issue|
543 542 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
544 543 end
545 544 end
546 545 end
547 546
548 547 # Finds an issue relation given its id.
549 548 def find_relation(relation_id)
550 549 IssueRelation.find(relation_id, :conditions => ["issue_to_id = ? OR issue_from_id = ?", id, id])
551 550 end
552 551
553 552 def all_dependent_issues(except=[])
554 553 except << self
555 554 dependencies = []
556 555 relations_from.each do |relation|
557 556 if relation.issue_to && !except.include?(relation.issue_to)
558 557 dependencies << relation.issue_to
559 558 dependencies += relation.issue_to.all_dependent_issues(except)
560 559 end
561 560 end
562 561 dependencies
563 562 end
564 563
565 564 # Returns an array of issues that duplicate this one
566 565 def duplicates
567 566 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
568 567 end
569 568
570 569 # Returns the due date or the target due date if any
571 570 # Used on gantt chart
572 571 def due_before
573 572 due_date || (fixed_version ? fixed_version.effective_date : nil)
574 573 end
575 574
576 575 # Returns the time scheduled for this issue.
577 576 #
578 577 # Example:
579 578 # Start Date: 2/26/09, End Date: 3/04/09
580 579 # duration => 6
581 580 def duration
582 581 (start_date && due_date) ? due_date - start_date : 0
583 582 end
584 583
585 584 def soonest_start
586 585 @soonest_start ||= (
587 586 relations_to.collect{|relation| relation.successor_soonest_start} +
588 587 ancestors.collect(&:soonest_start)
589 588 ).compact.max
590 589 end
591 590
592 591 def reschedule_after(date)
593 592 return if date.nil?
594 593 if leaf?
595 594 if start_date.nil? || start_date < date
596 595 self.start_date, self.due_date = date, date + duration
597 596 save
598 597 end
599 598 else
600 599 leaves.each do |leaf|
601 600 leaf.reschedule_after(date)
602 601 end
603 602 end
604 603 end
605 604
606 605 def <=>(issue)
607 606 if issue.nil?
608 607 -1
609 608 elsif root_id != issue.root_id
610 609 (root_id || 0) <=> (issue.root_id || 0)
611 610 else
612 611 (lft || 0) <=> (issue.lft || 0)
613 612 end
614 613 end
615 614
616 615 def to_s
617 616 "#{tracker} ##{id}: #{subject}"
618 617 end
619 618
620 619 # Returns a string of css classes that apply to the issue
621 620 def css_classes
622 621 s = "issue status-#{status.position} priority-#{priority.position}"
623 622 s << ' closed' if closed?
624 623 s << ' overdue' if overdue?
625 624 s << ' child' if child?
626 625 s << ' parent' unless leaf?
627 626 s << ' private' if is_private?
628 627 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
629 628 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
630 629 s
631 630 end
632 631
633 632 # Saves an issue, time_entry, attachments, and a journal from the parameters
634 633 # Returns false if save fails
635 634 def save_issue_with_child_records(params, existing_time_entry=nil)
636 635 Issue.transaction do
637 636 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
638 637 @time_entry = existing_time_entry || TimeEntry.new
639 638 @time_entry.project = project
640 639 @time_entry.issue = self
641 640 @time_entry.user = User.current
642 641 @time_entry.spent_on = User.current.today
643 642 @time_entry.attributes = params[:time_entry]
644 643 self.time_entries << @time_entry
645 644 end
646 645
647 646 if valid?
648 647 attachments = Attachment.attach_files(self, params[:attachments])
649 648 # TODO: Rename hook
650 649 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
651 650 begin
652 651 if save
653 652 # TODO: Rename hook
654 653 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
655 654 else
656 655 raise ActiveRecord::Rollback
657 656 end
658 657 rescue ActiveRecord::StaleObjectError
659 658 attachments[:files].each(&:destroy)
660 659 errors.add :base, l(:notice_locking_conflict)
661 660 raise ActiveRecord::Rollback
662 661 end
663 662 end
664 663 end
665 664 end
666 665
667 666 # Unassigns issues from +version+ if it's no longer shared with issue's project
668 667 def self.update_versions_from_sharing_change(version)
669 668 # Update issues assigned to the version
670 669 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
671 670 end
672 671
673 672 # Unassigns issues from versions that are no longer shared
674 673 # after +project+ was moved
675 674 def self.update_versions_from_hierarchy_change(project)
676 675 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
677 676 # Update issues of the moved projects and issues assigned to a version of a moved project
678 677 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
679 678 end
680 679
681 680 def parent_issue_id=(arg)
682 681 parent_issue_id = arg.blank? ? nil : arg.to_i
683 682 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
684 683 @parent_issue.id
685 684 else
686 685 @parent_issue = nil
687 686 nil
688 687 end
689 688 end
690 689
691 690 def parent_issue_id
692 691 if instance_variable_defined? :@parent_issue
693 692 @parent_issue.nil? ? nil : @parent_issue.id
694 693 else
695 694 parent_id
696 695 end
697 696 end
698 697
699 698 # Extracted from the ReportsController.
700 699 def self.by_tracker(project)
701 700 count_and_group_by(:project => project,
702 701 :field => 'tracker_id',
703 702 :joins => Tracker.table_name)
704 703 end
705 704
706 705 def self.by_version(project)
707 706 count_and_group_by(:project => project,
708 707 :field => 'fixed_version_id',
709 708 :joins => Version.table_name)
710 709 end
711 710
712 711 def self.by_priority(project)
713 712 count_and_group_by(:project => project,
714 713 :field => 'priority_id',
715 714 :joins => IssuePriority.table_name)
716 715 end
717 716
718 717 def self.by_category(project)
719 718 count_and_group_by(:project => project,
720 719 :field => 'category_id',
721 720 :joins => IssueCategory.table_name)
722 721 end
723 722
724 723 def self.by_assigned_to(project)
725 724 count_and_group_by(:project => project,
726 725 :field => 'assigned_to_id',
727 726 :joins => User.table_name)
728 727 end
729 728
730 729 def self.by_author(project)
731 730 count_and_group_by(:project => project,
732 731 :field => 'author_id',
733 732 :joins => User.table_name)
734 733 end
735 734
736 735 def self.by_subproject(project)
737 736 ActiveRecord::Base.connection.select_all("select s.id as status_id,
738 737 s.is_closed as closed,
739 738 #{Issue.table_name}.project_id as project_id,
740 739 count(#{Issue.table_name}.id) as total
741 740 from
742 741 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
743 742 where
744 743 #{Issue.table_name}.status_id=s.id
745 744 and #{Issue.table_name}.project_id = #{Project.table_name}.id
746 745 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
747 746 and #{Issue.table_name}.project_id <> #{project.id}
748 747 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
749 748 end
750 749 # End ReportsController extraction
751 750
752 751 # Returns an array of projects that current user can move issues to
753 752 def self.allowed_target_projects_on_move
754 753 projects = []
755 754 if User.current.admin?
756 755 # admin is allowed to move issues to any active (visible) project
757 756 projects = Project.visible.all
758 757 elsif User.current.logged?
759 758 if Role.non_member.allowed_to?(:move_issues)
760 759 projects = Project.visible.all
761 760 else
762 761 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
763 762 end
764 763 end
765 764 projects
766 765 end
767 766
768 767 private
769 768
770 769 def update_nested_set_attributes
771 770 if root_id.nil?
772 771 # issue was just created
773 772 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
774 773 set_default_left_and_right
775 774 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
776 775 if @parent_issue
777 776 move_to_child_of(@parent_issue)
778 777 end
779 778 reload
780 779 elsif parent_issue_id != parent_id
781 780 former_parent_id = parent_id
782 781 # moving an existing issue
783 782 if @parent_issue && @parent_issue.root_id == root_id
784 783 # inside the same tree
785 784 move_to_child_of(@parent_issue)
786 785 else
787 786 # to another tree
788 787 unless root?
789 788 move_to_right_of(root)
790 789 reload
791 790 end
792 791 old_root_id = root_id
793 792 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
794 793 target_maxright = nested_set_scope.maximum(right_column_name) || 0
795 794 offset = target_maxright + 1 - lft
796 795 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
797 796 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
798 797 self[left_column_name] = lft + offset
799 798 self[right_column_name] = rgt + offset
800 799 if @parent_issue
801 800 move_to_child_of(@parent_issue)
802 801 end
803 802 end
804 803 reload
805 804 # delete invalid relations of all descendants
806 805 self_and_descendants.each do |issue|
807 806 issue.relations.each do |relation|
808 807 relation.destroy unless relation.valid?
809 808 end
810 809 end
811 810 # update former parent
812 811 recalculate_attributes_for(former_parent_id) if former_parent_id
813 812 end
814 813 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
815 814 end
816 815
817 816 def update_parent_attributes
818 817 recalculate_attributes_for(parent_id) if parent_id
819 818 end
820 819
821 820 def recalculate_attributes_for(issue_id)
822 821 if issue_id && p = Issue.find_by_id(issue_id)
823 822 # priority = highest priority of children
824 823 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
825 824 p.priority = IssuePriority.find_by_position(priority_position)
826 825 end
827 826
828 827 # start/due dates = lowest/highest dates of children
829 828 p.start_date = p.children.minimum(:start_date)
830 829 p.due_date = p.children.maximum(:due_date)
831 830 if p.start_date && p.due_date && p.due_date < p.start_date
832 831 p.start_date, p.due_date = p.due_date, p.start_date
833 832 end
834 833
835 834 # done ratio = weighted average ratio of leaves
836 835 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
837 836 leaves_count = p.leaves.count
838 837 if leaves_count > 0
839 838 average = p.leaves.average(:estimated_hours).to_f
840 839 if average == 0
841 840 average = 1
842 841 end
843 842 done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :include => :status).to_f
844 843 progress = done / (average * leaves_count)
845 844 p.done_ratio = progress.round
846 845 end
847 846 end
848 847
849 848 # estimate = sum of leaves estimates
850 849 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
851 850 p.estimated_hours = nil if p.estimated_hours == 0.0
852 851
853 852 # ancestors will be recursively updated
854 853 p.save(false)
855 854 end
856 855 end
857 856
858 857 # Update issues so their versions are not pointing to a
859 858 # fixed_version that is not shared with the issue's project
860 859 def self.update_versions(conditions=nil)
861 860 # Only need to update issues with a fixed_version from
862 861 # a different project and that is not systemwide shared
863 862 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
864 863 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
865 864 " AND #{Version.table_name}.sharing <> 'system'",
866 865 conditions),
867 866 :include => [:project, :fixed_version]
868 867 ).each do |issue|
869 868 next if issue.project.nil? || issue.fixed_version.nil?
870 869 unless issue.project.shared_versions.include?(issue.fixed_version)
871 870 issue.init_journal(User.current)
872 871 issue.fixed_version = nil
873 872 issue.save
874 873 end
875 874 end
876 875 end
877 876
878 877 # Callback on attachment deletion
879 878 def attachment_added(obj)
880 879 if @current_journal && !obj.new_record?
881 880 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
882 881 end
883 882 end
884 883
885 884 # Callback on attachment deletion
886 885 def attachment_removed(obj)
887 886 journal = init_journal(User.current)
888 887 journal.details << JournalDetail.new(:property => 'attachment',
889 888 :prop_key => obj.id,
890 889 :old_value => obj.filename)
891 890 journal.save
892 891 end
893 892
894 893 # Default assignment based on category
895 894 def default_assign
896 895 if assigned_to.nil? && category && category.assigned_to
897 896 self.assigned_to = category.assigned_to
898 897 end
899 898 end
900 899
901 900 # Updates start/due dates of following issues
902 901 def reschedule_following_issues
903 902 if start_date_changed? || due_date_changed?
904 903 relations_from.each do |relation|
905 904 relation.set_issue_to_dates
906 905 end
907 906 end
908 907 end
909 908
910 909 # Closes duplicates if the issue is being closed
911 910 def close_duplicates
912 911 if closing?
913 912 duplicates.each do |duplicate|
914 913 # Reload is need in case the duplicate was updated by a previous duplicate
915 914 duplicate.reload
916 915 # Don't re-close it if it's already closed
917 916 next if duplicate.closed?
918 917 # Same user and notes
919 918 if @current_journal
920 919 duplicate.init_journal(@current_journal.user, @current_journal.notes)
921 920 end
922 921 duplicate.update_attribute :status, self.status
923 922 end
924 923 end
925 924 end
926 925
927 926 # Saves the changes in a Journal
928 927 # Called after_save
929 928 def create_journal
930 929 if @current_journal
931 930 # attributes changes
932 931 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
933 932 before = @issue_before_change.send(c)
934 933 after = send(c)
935 934 next if before == after || (before.blank? && after.blank?)
936 935 @current_journal.details << JournalDetail.new(:property => 'attr',
937 936 :prop_key => c,
938 937 :old_value => @issue_before_change.send(c),
939 938 :value => send(c))
940 939 }
941 940 # custom fields changes
942 941 custom_values.each {|c|
943 942 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
944 943 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
945 944 @current_journal.details << JournalDetail.new(:property => 'cf',
946 945 :prop_key => c.custom_field_id,
947 946 :old_value => @custom_values_before_change[c.custom_field_id],
948 947 :value => c.value)
949 948 }
950 949 @current_journal.save
951 950 # reset current journal
952 951 init_journal @current_journal.user, @current_journal.notes
953 952 end
954 953 end
955 954
956 955 # Query generator for selecting groups of issue counts for a project
957 956 # based on specific criteria
958 957 #
959 958 # Options
960 959 # * project - Project to search in.
961 960 # * field - String. Issue field to key off of in the grouping.
962 961 # * joins - String. The table name to join against.
963 962 def self.count_and_group_by(options)
964 963 project = options.delete(:project)
965 964 select_field = options.delete(:field)
966 965 joins = options.delete(:joins)
967 966
968 967 where = "#{Issue.table_name}.#{select_field}=j.id"
969 968
970 969 ActiveRecord::Base.connection.select_all("select s.id as status_id,
971 970 s.is_closed as closed,
972 971 j.id as #{select_field},
973 972 count(#{Issue.table_name}.id) as total
974 973 from
975 974 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
976 975 where
977 976 #{Issue.table_name}.status_id=s.id
978 977 and #{where}
979 978 and #{Issue.table_name}.project_id=#{Project.table_name}.id
980 979 and #{visible_condition(User.current, :project => project)}
981 980 group by s.id, s.is_closed, j.id")
982 981 end
983 982 end
General Comments 0
You need to be logged in to leave comments. Login now