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