##// END OF EJS Templates
Adds css classes to parent/child issues (#7986)....
Jean-Philippe Lang -
r5101:437c5658d732
parent child
Show More
@@ -1,881 +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 109 (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
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 s << ' child' if child?
536 s << ' parent' unless leaf?
535 537 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
536 538 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
537 539 s
538 540 end
539 541
540 542 # Saves an issue, time_entry, attachments, and a journal from the parameters
541 543 # Returns false if save fails
542 544 def save_issue_with_child_records(params, existing_time_entry=nil)
543 545 Issue.transaction do
544 546 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
545 547 @time_entry = existing_time_entry || TimeEntry.new
546 548 @time_entry.project = project
547 549 @time_entry.issue = self
548 550 @time_entry.user = User.current
549 551 @time_entry.spent_on = Date.today
550 552 @time_entry.attributes = params[:time_entry]
551 553 self.time_entries << @time_entry
552 554 end
553 555
554 556 if valid?
555 557 attachments = Attachment.attach_files(self, params[:attachments])
556 558
557 559 attachments[:files].each {|a| @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
558 560 # TODO: Rename hook
559 561 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
560 562 begin
561 563 if save
562 564 # TODO: Rename hook
563 565 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
564 566 else
565 567 raise ActiveRecord::Rollback
566 568 end
567 569 rescue ActiveRecord::StaleObjectError
568 570 attachments[:files].each(&:destroy)
569 571 errors.add_to_base l(:notice_locking_conflict)
570 572 raise ActiveRecord::Rollback
571 573 end
572 574 end
573 575 end
574 576 end
575 577
576 578 # Unassigns issues from +version+ if it's no longer shared with issue's project
577 579 def self.update_versions_from_sharing_change(version)
578 580 # Update issues assigned to the version
579 581 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
580 582 end
581 583
582 584 # Unassigns issues from versions that are no longer shared
583 585 # after +project+ was moved
584 586 def self.update_versions_from_hierarchy_change(project)
585 587 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
586 588 # Update issues of the moved projects and issues assigned to a version of a moved project
587 589 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
588 590 end
589 591
590 592 def parent_issue_id=(arg)
591 593 parent_issue_id = arg.blank? ? nil : arg.to_i
592 594 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
593 595 @parent_issue.id
594 596 else
595 597 @parent_issue = nil
596 598 nil
597 599 end
598 600 end
599 601
600 602 def parent_issue_id
601 603 if instance_variable_defined? :@parent_issue
602 604 @parent_issue.nil? ? nil : @parent_issue.id
603 605 else
604 606 parent_id
605 607 end
606 608 end
607 609
608 610 # Extracted from the ReportsController.
609 611 def self.by_tracker(project)
610 612 count_and_group_by(:project => project,
611 613 :field => 'tracker_id',
612 614 :joins => Tracker.table_name)
613 615 end
614 616
615 617 def self.by_version(project)
616 618 count_and_group_by(:project => project,
617 619 :field => 'fixed_version_id',
618 620 :joins => Version.table_name)
619 621 end
620 622
621 623 def self.by_priority(project)
622 624 count_and_group_by(:project => project,
623 625 :field => 'priority_id',
624 626 :joins => IssuePriority.table_name)
625 627 end
626 628
627 629 def self.by_category(project)
628 630 count_and_group_by(:project => project,
629 631 :field => 'category_id',
630 632 :joins => IssueCategory.table_name)
631 633 end
632 634
633 635 def self.by_assigned_to(project)
634 636 count_and_group_by(:project => project,
635 637 :field => 'assigned_to_id',
636 638 :joins => User.table_name)
637 639 end
638 640
639 641 def self.by_author(project)
640 642 count_and_group_by(:project => project,
641 643 :field => 'author_id',
642 644 :joins => User.table_name)
643 645 end
644 646
645 647 def self.by_subproject(project)
646 648 ActiveRecord::Base.connection.select_all("select s.id as status_id,
647 649 s.is_closed as closed,
648 650 i.project_id as project_id,
649 651 count(i.id) as total
650 652 from
651 653 #{Issue.table_name} i, #{IssueStatus.table_name} s
652 654 where
653 655 i.status_id=s.id
654 656 and i.project_id IN (#{project.descendants.active.collect{|p| p.id}.join(',')})
655 657 group by s.id, s.is_closed, i.project_id") if project.descendants.active.any?
656 658 end
657 659 # End ReportsController extraction
658 660
659 661 # Returns an array of projects that current user can move issues to
660 662 def self.allowed_target_projects_on_move
661 663 projects = []
662 664 if User.current.admin?
663 665 # admin is allowed to move issues to any active (visible) project
664 666 projects = Project.visible.all
665 667 elsif User.current.logged?
666 668 if Role.non_member.allowed_to?(:move_issues)
667 669 projects = Project.visible.all
668 670 else
669 671 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
670 672 end
671 673 end
672 674 projects
673 675 end
674 676
675 677 private
676 678
677 679 def update_nested_set_attributes
678 680 if root_id.nil?
679 681 # issue was just created
680 682 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
681 683 set_default_left_and_right
682 684 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
683 685 if @parent_issue
684 686 move_to_child_of(@parent_issue)
685 687 end
686 688 reload
687 689 elsif parent_issue_id != parent_id
688 690 former_parent_id = parent_id
689 691 # moving an existing issue
690 692 if @parent_issue && @parent_issue.root_id == root_id
691 693 # inside the same tree
692 694 move_to_child_of(@parent_issue)
693 695 else
694 696 # to another tree
695 697 unless root?
696 698 move_to_right_of(root)
697 699 reload
698 700 end
699 701 old_root_id = root_id
700 702 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
701 703 target_maxright = nested_set_scope.maximum(right_column_name) || 0
702 704 offset = target_maxright + 1 - lft
703 705 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
704 706 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
705 707 self[left_column_name] = lft + offset
706 708 self[right_column_name] = rgt + offset
707 709 if @parent_issue
708 710 move_to_child_of(@parent_issue)
709 711 end
710 712 end
711 713 reload
712 714 # delete invalid relations of all descendants
713 715 self_and_descendants.each do |issue|
714 716 issue.relations.each do |relation|
715 717 relation.destroy unless relation.valid?
716 718 end
717 719 end
718 720 # update former parent
719 721 recalculate_attributes_for(former_parent_id) if former_parent_id
720 722 end
721 723 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
722 724 end
723 725
724 726 def update_parent_attributes
725 727 recalculate_attributes_for(parent_id) if parent_id
726 728 end
727 729
728 730 def recalculate_attributes_for(issue_id)
729 731 if issue_id && p = Issue.find_by_id(issue_id)
730 732 # priority = highest priority of children
731 733 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
732 734 p.priority = IssuePriority.find_by_position(priority_position)
733 735 end
734 736
735 737 # start/due dates = lowest/highest dates of children
736 738 p.start_date = p.children.minimum(:start_date)
737 739 p.due_date = p.children.maximum(:due_date)
738 740 if p.start_date && p.due_date && p.due_date < p.start_date
739 741 p.start_date, p.due_date = p.due_date, p.start_date
740 742 end
741 743
742 744 # done ratio = weighted average ratio of leaves
743 745 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
744 746 leaves_count = p.leaves.count
745 747 if leaves_count > 0
746 748 average = p.leaves.average(:estimated_hours).to_f
747 749 if average == 0
748 750 average = 1
749 751 end
750 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
751 753 progress = done / (average * leaves_count)
752 754 p.done_ratio = progress.round
753 755 end
754 756 end
755 757
756 758 # estimate = sum of leaves estimates
757 759 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
758 760 p.estimated_hours = nil if p.estimated_hours == 0.0
759 761
760 762 # ancestors will be recursively updated
761 763 p.save(false)
762 764 end
763 765 end
764 766
765 767 # Update issues so their versions are not pointing to a
766 768 # fixed_version that is not shared with the issue's project
767 769 def self.update_versions(conditions=nil)
768 770 # Only need to update issues with a fixed_version from
769 771 # a different project and that is not systemwide shared
770 772 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
771 773 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
772 774 " AND #{Version.table_name}.sharing <> 'system'",
773 775 conditions),
774 776 :include => [:project, :fixed_version]
775 777 ).each do |issue|
776 778 next if issue.project.nil? || issue.fixed_version.nil?
777 779 unless issue.project.shared_versions.include?(issue.fixed_version)
778 780 issue.init_journal(User.current)
779 781 issue.fixed_version = nil
780 782 issue.save
781 783 end
782 784 end
783 785 end
784 786
785 787 # Callback on attachment deletion
786 788 def attachment_removed(obj)
787 789 journal = init_journal(User.current)
788 790 journal.details << JournalDetail.new(:property => 'attachment',
789 791 :prop_key => obj.id,
790 792 :old_value => obj.filename)
791 793 journal.save
792 794 end
793 795
794 796 # Default assignment based on category
795 797 def default_assign
796 798 if assigned_to.nil? && category && category.assigned_to
797 799 self.assigned_to = category.assigned_to
798 800 end
799 801 end
800 802
801 803 # Updates start/due dates of following issues
802 804 def reschedule_following_issues
803 805 if start_date_changed? || due_date_changed?
804 806 relations_from.each do |relation|
805 807 relation.set_issue_to_dates
806 808 end
807 809 end
808 810 end
809 811
810 812 # Closes duplicates if the issue is being closed
811 813 def close_duplicates
812 814 if closing?
813 815 duplicates.each do |duplicate|
814 816 # Reload is need in case the duplicate was updated by a previous duplicate
815 817 duplicate.reload
816 818 # Don't re-close it if it's already closed
817 819 next if duplicate.closed?
818 820 # Same user and notes
819 821 if @current_journal
820 822 duplicate.init_journal(@current_journal.user, @current_journal.notes)
821 823 end
822 824 duplicate.update_attribute :status, self.status
823 825 end
824 826 end
825 827 end
826 828
827 829 # Saves the changes in a Journal
828 830 # Called after_save
829 831 def create_journal
830 832 if @current_journal
831 833 # attributes changes
832 834 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
833 835 @current_journal.details << JournalDetail.new(:property => 'attr',
834 836 :prop_key => c,
835 837 :old_value => @issue_before_change.send(c),
836 838 :value => send(c)) unless send(c)==@issue_before_change.send(c)
837 839 }
838 840 # custom fields changes
839 841 custom_values.each {|c|
840 842 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
841 843 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
842 844 @current_journal.details << JournalDetail.new(:property => 'cf',
843 845 :prop_key => c.custom_field_id,
844 846 :old_value => @custom_values_before_change[c.custom_field_id],
845 847 :value => c.value)
846 848 }
847 849 @current_journal.save
848 850 # reset current journal
849 851 init_journal @current_journal.user, @current_journal.notes
850 852 end
851 853 end
852 854
853 855 # Query generator for selecting groups of issue counts for a project
854 856 # based on specific criteria
855 857 #
856 858 # Options
857 859 # * project - Project to search in.
858 860 # * field - String. Issue field to key off of in the grouping.
859 861 # * joins - String. The table name to join against.
860 862 def self.count_and_group_by(options)
861 863 project = options.delete(:project)
862 864 select_field = options.delete(:field)
863 865 joins = options.delete(:joins)
864 866
865 867 where = "i.#{select_field}=j.id"
866 868
867 869 ActiveRecord::Base.connection.select_all("select s.id as status_id,
868 870 s.is_closed as closed,
869 871 j.id as #{select_field},
870 872 count(i.id) as total
871 873 from
872 874 #{Issue.table_name} i, #{IssueStatus.table_name} s, #{joins} j
873 875 where
874 876 i.status_id=s.id
875 877 and #{where}
876 878 and i.project_id=#{project.id}
877 879 group by s.id, s.is_closed, j.id")
878 880 end
879 881
880 882
881 883 end
General Comments 0
You need to be logged in to leave comments. Login now