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