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