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