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