##// END OF EJS Templates
Gantt perf: fixed that Project#start_date and #due_date run way too much queries....
Jean-Philippe Lang -
r10905:8ee0b52d590d
parent child
Show More
@@ -1,1395 +1,1399
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 include Redmine::Utils::DateCalculation
21 21
22 22 belongs_to :project
23 23 belongs_to :tracker
24 24 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
25 25 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
26 26 belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id'
27 27 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
28 28 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
29 29 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
30 30
31 31 has_many :journals, :as => :journalized, :dependent => :destroy
32 32 has_many :visible_journals,
33 33 :class_name => 'Journal',
34 34 :as => :journalized,
35 35 :conditions => Proc.new {
36 36 ["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false]
37 37 },
38 38 :readonly => true
39 39
40 40 has_many :time_entries, :dependent => :delete_all
41 41 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
42 42
43 43 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
44 44 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
45 45
46 46 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
47 47 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
48 48 acts_as_customizable
49 49 acts_as_watchable
50 50 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
51 51 :include => [:project, :visible_journals],
52 52 # sort by id so that limited eager loading doesn't break with postgresql
53 53 :order_column => "#{table_name}.id"
54 54 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
55 55 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
56 56 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
57 57
58 58 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
59 59 :author_key => :author_id
60 60
61 61 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
62 62
63 63 attr_reader :current_journal
64 64 delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
65 65
66 66 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
67 67
68 68 validates_length_of :subject, :maximum => 255
69 69 validates_inclusion_of :done_ratio, :in => 0..100
70 70 validates :estimated_hours, :numericality => {:greater_than_or_equal_to => 0, :allow_nil => true, :message => :invalid}
71 71 validates :start_date, :date => true
72 72 validates :due_date, :date => true
73 73 validate :validate_issue, :validate_required_fields
74 74
75 75 scope :visible, lambda {|*args|
76 76 includes(:project).where(Issue.visible_condition(args.shift || User.current, *args))
77 77 }
78 78
79 79 scope :open, lambda {|*args|
80 80 is_closed = args.size > 0 ? !args.first : false
81 81 includes(:status).where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
82 82 }
83 83
84 84 scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
85 85 scope :on_active_project, lambda {
86 86 includes(:status, :project, :tracker).where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
87 87 }
88 scope :fixed_version, lambda {|versions|
89 ids = [versions].flatten.compact.map {|v| v.is_a?(Version) ? v.id : v}
90 ids.any? ? where(:fixed_version_id => ids) : where('1=0')
91 }
88 92
89 93 before_create :default_assign
90 94 before_save :close_duplicates, :update_done_ratio_from_issue_status, :force_updated_on_change
91 95 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
92 96 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
93 97 # Should be after_create but would be called before previous after_save callbacks
94 98 after_save :after_create_from_copy
95 99 after_destroy :update_parent_attributes
96 100
97 101 # Returns a SQL conditions string used to find all issues visible by the specified user
98 102 def self.visible_condition(user, options={})
99 103 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
100 104 if user.logged?
101 105 case role.issues_visibility
102 106 when 'all'
103 107 nil
104 108 when 'default'
105 109 user_ids = [user.id] + user.groups.map(&:id)
106 110 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
107 111 when 'own'
108 112 user_ids = [user.id] + user.groups.map(&:id)
109 113 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
110 114 else
111 115 '1=0'
112 116 end
113 117 else
114 118 "(#{table_name}.is_private = #{connection.quoted_false})"
115 119 end
116 120 end
117 121 end
118 122
119 123 # Returns true if usr or current user is allowed to view the issue
120 124 def visible?(usr=nil)
121 125 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
122 126 if user.logged?
123 127 case role.issues_visibility
124 128 when 'all'
125 129 true
126 130 when 'default'
127 131 !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
128 132 when 'own'
129 133 self.author == user || user.is_or_belongs_to?(assigned_to)
130 134 else
131 135 false
132 136 end
133 137 else
134 138 !self.is_private?
135 139 end
136 140 end
137 141 end
138 142
139 143 # Returns true if user or current user is allowed to edit or add a note to the issue
140 144 def editable?(user=User.current)
141 145 user.allowed_to?(:edit_issues, project) || user.allowed_to?(:add_issue_notes, project)
142 146 end
143 147
144 148 def initialize(attributes=nil, *args)
145 149 super
146 150 if new_record?
147 151 # set default values for new records only
148 152 self.status ||= IssueStatus.default
149 153 self.priority ||= IssuePriority.default
150 154 self.watcher_user_ids = []
151 155 end
152 156 end
153 157
154 158 # AR#Persistence#destroy would raise and RecordNotFound exception
155 159 # if the issue was already deleted or updated (non matching lock_version).
156 160 # This is a problem when bulk deleting issues or deleting a project
157 161 # (because an issue may already be deleted if its parent was deleted
158 162 # first).
159 163 # The issue is reloaded by the nested_set before being deleted so
160 164 # the lock_version condition should not be an issue but we handle it.
161 165 def destroy
162 166 super
163 167 rescue ActiveRecord::RecordNotFound
164 168 # Stale or already deleted
165 169 begin
166 170 reload
167 171 rescue ActiveRecord::RecordNotFound
168 172 # The issue was actually already deleted
169 173 @destroyed = true
170 174 return freeze
171 175 end
172 176 # The issue was stale, retry to destroy
173 177 super
174 178 end
175 179
176 180 def reload(*args)
177 181 @workflow_rule_by_attribute = nil
178 182 @assignable_versions = nil
179 183 super
180 184 end
181 185
182 186 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
183 187 def available_custom_fields
184 188 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
185 189 end
186 190
187 191 # Copies attributes from another issue, arg can be an id or an Issue
188 192 def copy_from(arg, options={})
189 193 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
190 194 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
191 195 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
192 196 self.status = issue.status
193 197 self.author = User.current
194 198 unless options[:attachments] == false
195 199 self.attachments = issue.attachments.map do |attachement|
196 200 attachement.copy(:container => self)
197 201 end
198 202 end
199 203 @copied_from = issue
200 204 @copy_options = options
201 205 self
202 206 end
203 207
204 208 # Returns an unsaved copy of the issue
205 209 def copy(attributes=nil, copy_options={})
206 210 copy = self.class.new.copy_from(self, copy_options)
207 211 copy.attributes = attributes if attributes
208 212 copy
209 213 end
210 214
211 215 # Returns true if the issue is a copy
212 216 def copy?
213 217 @copied_from.present?
214 218 end
215 219
216 220 # Moves/copies an issue to a new project and tracker
217 221 # Returns the moved/copied issue on success, false on failure
218 222 def move_to_project(new_project, new_tracker=nil, options={})
219 223 ActiveSupport::Deprecation.warn "Issue#move_to_project is deprecated, use #project= instead."
220 224
221 225 if options[:copy]
222 226 issue = self.copy
223 227 else
224 228 issue = self
225 229 end
226 230
227 231 issue.init_journal(User.current, options[:notes])
228 232
229 233 # Preserve previous behaviour
230 234 # #move_to_project doesn't change tracker automatically
231 235 issue.send :project=, new_project, true
232 236 if new_tracker
233 237 issue.tracker = new_tracker
234 238 end
235 239 # Allow bulk setting of attributes on the issue
236 240 if options[:attributes]
237 241 issue.attributes = options[:attributes]
238 242 end
239 243
240 244 issue.save ? issue : false
241 245 end
242 246
243 247 def status_id=(sid)
244 248 self.status = nil
245 249 result = write_attribute(:status_id, sid)
246 250 @workflow_rule_by_attribute = nil
247 251 result
248 252 end
249 253
250 254 def priority_id=(pid)
251 255 self.priority = nil
252 256 write_attribute(:priority_id, pid)
253 257 end
254 258
255 259 def category_id=(cid)
256 260 self.category = nil
257 261 write_attribute(:category_id, cid)
258 262 end
259 263
260 264 def fixed_version_id=(vid)
261 265 self.fixed_version = nil
262 266 write_attribute(:fixed_version_id, vid)
263 267 end
264 268
265 269 def tracker_id=(tid)
266 270 self.tracker = nil
267 271 result = write_attribute(:tracker_id, tid)
268 272 @custom_field_values = nil
269 273 @workflow_rule_by_attribute = nil
270 274 result
271 275 end
272 276
273 277 def project_id=(project_id)
274 278 if project_id.to_s != self.project_id.to_s
275 279 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
276 280 end
277 281 end
278 282
279 283 def project=(project, keep_tracker=false)
280 284 project_was = self.project
281 285 write_attribute(:project_id, project ? project.id : nil)
282 286 association_instance_set('project', project)
283 287 if project_was && project && project_was != project
284 288 @assignable_versions = nil
285 289
286 290 unless keep_tracker || project.trackers.include?(tracker)
287 291 self.tracker = project.trackers.first
288 292 end
289 293 # Reassign to the category with same name if any
290 294 if category
291 295 self.category = project.issue_categories.find_by_name(category.name)
292 296 end
293 297 # Keep the fixed_version if it's still valid in the new_project
294 298 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
295 299 self.fixed_version = nil
296 300 end
297 301 # Clear the parent task if it's no longer valid
298 302 unless valid_parent_project?
299 303 self.parent_issue_id = nil
300 304 end
301 305 @custom_field_values = nil
302 306 end
303 307 end
304 308
305 309 def description=(arg)
306 310 if arg.is_a?(String)
307 311 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
308 312 end
309 313 write_attribute(:description, arg)
310 314 end
311 315
312 316 # Overrides assign_attributes so that project and tracker get assigned first
313 317 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
314 318 return if new_attributes.nil?
315 319 attrs = new_attributes.dup
316 320 attrs.stringify_keys!
317 321
318 322 %w(project project_id tracker tracker_id).each do |attr|
319 323 if attrs.has_key?(attr)
320 324 send "#{attr}=", attrs.delete(attr)
321 325 end
322 326 end
323 327 send :assign_attributes_without_project_and_tracker_first, attrs, *args
324 328 end
325 329 # Do not redefine alias chain on reload (see #4838)
326 330 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
327 331
328 332 def estimated_hours=(h)
329 333 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
330 334 end
331 335
332 336 safe_attributes 'project_id',
333 337 :if => lambda {|issue, user|
334 338 if issue.new_record?
335 339 issue.copy?
336 340 elsif user.allowed_to?(:move_issues, issue.project)
337 341 projects = Issue.allowed_target_projects_on_move(user)
338 342 projects.include?(issue.project) && projects.size > 1
339 343 end
340 344 }
341 345
342 346 safe_attributes 'tracker_id',
343 347 'status_id',
344 348 'category_id',
345 349 'assigned_to_id',
346 350 'priority_id',
347 351 'fixed_version_id',
348 352 'subject',
349 353 'description',
350 354 'start_date',
351 355 'due_date',
352 356 'done_ratio',
353 357 'estimated_hours',
354 358 'custom_field_values',
355 359 'custom_fields',
356 360 'lock_version',
357 361 'notes',
358 362 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
359 363
360 364 safe_attributes 'status_id',
361 365 'assigned_to_id',
362 366 'fixed_version_id',
363 367 'done_ratio',
364 368 'lock_version',
365 369 'notes',
366 370 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
367 371
368 372 safe_attributes 'notes',
369 373 :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
370 374
371 375 safe_attributes 'private_notes',
372 376 :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
373 377
374 378 safe_attributes 'watcher_user_ids',
375 379 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
376 380
377 381 safe_attributes 'is_private',
378 382 :if => lambda {|issue, user|
379 383 user.allowed_to?(:set_issues_private, issue.project) ||
380 384 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
381 385 }
382 386
383 387 safe_attributes 'parent_issue_id',
384 388 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
385 389 user.allowed_to?(:manage_subtasks, issue.project)}
386 390
387 391 def safe_attribute_names(user=nil)
388 392 names = super
389 393 names -= disabled_core_fields
390 394 names -= read_only_attribute_names(user)
391 395 names
392 396 end
393 397
394 398 # Safely sets attributes
395 399 # Should be called from controllers instead of #attributes=
396 400 # attr_accessible is too rough because we still want things like
397 401 # Issue.new(:project => foo) to work
398 402 def safe_attributes=(attrs, user=User.current)
399 403 return unless attrs.is_a?(Hash)
400 404
401 405 attrs = attrs.dup
402 406
403 407 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
404 408 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
405 409 if allowed_target_projects(user).collect(&:id).include?(p.to_i)
406 410 self.project_id = p
407 411 end
408 412 end
409 413
410 414 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
411 415 self.tracker_id = t
412 416 end
413 417
414 418 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
415 419 if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
416 420 self.status_id = s
417 421 end
418 422 end
419 423
420 424 attrs = delete_unsafe_attributes(attrs, user)
421 425 return if attrs.empty?
422 426
423 427 unless leaf?
424 428 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
425 429 end
426 430
427 431 if attrs['parent_issue_id'].present?
428 432 s = attrs['parent_issue_id'].to_s
429 433 unless (m = s.match(%r{\A#?(\d+)\z})) && Issue.visible(user).exists?(m[1])
430 434 @invalid_parent_issue_id = attrs.delete('parent_issue_id')
431 435 end
432 436 end
433 437
434 438 if attrs['custom_field_values'].present?
435 439 attrs['custom_field_values'] = attrs['custom_field_values'].reject {|k, v| read_only_attribute_names(user).include? k.to_s}
436 440 end
437 441
438 442 if attrs['custom_fields'].present?
439 443 attrs['custom_fields'] = attrs['custom_fields'].reject {|c| read_only_attribute_names(user).include? c['id'].to_s}
440 444 end
441 445
442 446 # mass-assignment security bypass
443 447 assign_attributes attrs, :without_protection => true
444 448 end
445 449
446 450 def disabled_core_fields
447 451 tracker ? tracker.disabled_core_fields : []
448 452 end
449 453
450 454 # Returns the custom_field_values that can be edited by the given user
451 455 def editable_custom_field_values(user=nil)
452 456 custom_field_values.reject do |value|
453 457 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
454 458 end
455 459 end
456 460
457 461 # Returns the names of attributes that are read-only for user or the current user
458 462 # For users with multiple roles, the read-only fields are the intersection of
459 463 # read-only fields of each role
460 464 # The result is an array of strings where sustom fields are represented with their ids
461 465 #
462 466 # Examples:
463 467 # issue.read_only_attribute_names # => ['due_date', '2']
464 468 # issue.read_only_attribute_names(user) # => []
465 469 def read_only_attribute_names(user=nil)
466 470 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
467 471 end
468 472
469 473 # Returns the names of required attributes for user or the current user
470 474 # For users with multiple roles, the required fields are the intersection of
471 475 # required fields of each role
472 476 # The result is an array of strings where sustom fields are represented with their ids
473 477 #
474 478 # Examples:
475 479 # issue.required_attribute_names # => ['due_date', '2']
476 480 # issue.required_attribute_names(user) # => []
477 481 def required_attribute_names(user=nil)
478 482 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
479 483 end
480 484
481 485 # Returns true if the attribute is required for user
482 486 def required_attribute?(name, user=nil)
483 487 required_attribute_names(user).include?(name.to_s)
484 488 end
485 489
486 490 # Returns a hash of the workflow rule by attribute for the given user
487 491 #
488 492 # Examples:
489 493 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
490 494 def workflow_rule_by_attribute(user=nil)
491 495 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
492 496
493 497 user_real = user || User.current
494 498 roles = user_real.admin ? Role.all : user_real.roles_for_project(project)
495 499 return {} if roles.empty?
496 500
497 501 result = {}
498 502 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).all
499 503 if workflow_permissions.any?
500 504 workflow_rules = workflow_permissions.inject({}) do |h, wp|
501 505 h[wp.field_name] ||= []
502 506 h[wp.field_name] << wp.rule
503 507 h
504 508 end
505 509 workflow_rules.each do |attr, rules|
506 510 next if rules.size < roles.size
507 511 uniq_rules = rules.uniq
508 512 if uniq_rules.size == 1
509 513 result[attr] = uniq_rules.first
510 514 else
511 515 result[attr] = 'required'
512 516 end
513 517 end
514 518 end
515 519 @workflow_rule_by_attribute = result if user.nil?
516 520 result
517 521 end
518 522 private :workflow_rule_by_attribute
519 523
520 524 def done_ratio
521 525 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
522 526 status.default_done_ratio
523 527 else
524 528 read_attribute(:done_ratio)
525 529 end
526 530 end
527 531
528 532 def self.use_status_for_done_ratio?
529 533 Setting.issue_done_ratio == 'issue_status'
530 534 end
531 535
532 536 def self.use_field_for_done_ratio?
533 537 Setting.issue_done_ratio == 'issue_field'
534 538 end
535 539
536 540 def validate_issue
537 541 if due_date && start_date && due_date < start_date
538 542 errors.add :due_date, :greater_than_start_date
539 543 end
540 544
541 545 if start_date && soonest_start && start_date < soonest_start
542 546 errors.add :start_date, :invalid
543 547 end
544 548
545 549 if fixed_version
546 550 if !assignable_versions.include?(fixed_version)
547 551 errors.add :fixed_version_id, :inclusion
548 552 elsif reopened? && fixed_version.closed?
549 553 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
550 554 end
551 555 end
552 556
553 557 # Checks that the issue can not be added/moved to a disabled tracker
554 558 if project && (tracker_id_changed? || project_id_changed?)
555 559 unless project.trackers.include?(tracker)
556 560 errors.add :tracker_id, :inclusion
557 561 end
558 562 end
559 563
560 564 # Checks parent issue assignment
561 565 if @invalid_parent_issue_id.present?
562 566 errors.add :parent_issue_id, :invalid
563 567 elsif @parent_issue
564 568 if !valid_parent_project?(@parent_issue)
565 569 errors.add :parent_issue_id, :invalid
566 570 elsif !new_record?
567 571 # moving an existing issue
568 572 if @parent_issue.root_id != root_id
569 573 # we can always move to another tree
570 574 elsif move_possible?(@parent_issue)
571 575 # move accepted inside tree
572 576 else
573 577 errors.add :parent_issue_id, :invalid
574 578 end
575 579 end
576 580 end
577 581 end
578 582
579 583 # Validates the issue against additional workflow requirements
580 584 def validate_required_fields
581 585 user = new_record? ? author : current_journal.try(:user)
582 586
583 587 required_attribute_names(user).each do |attribute|
584 588 if attribute =~ /^\d+$/
585 589 attribute = attribute.to_i
586 590 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
587 591 if v && v.value.blank?
588 592 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
589 593 end
590 594 else
591 595 if respond_to?(attribute) && send(attribute).blank?
592 596 errors.add attribute, :blank
593 597 end
594 598 end
595 599 end
596 600 end
597 601
598 602 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
599 603 # even if the user turns off the setting later
600 604 def update_done_ratio_from_issue_status
601 605 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
602 606 self.done_ratio = status.default_done_ratio
603 607 end
604 608 end
605 609
606 610 def init_journal(user, notes = "")
607 611 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
608 612 if new_record?
609 613 @current_journal.notify = false
610 614 else
611 615 @attributes_before_change = attributes.dup
612 616 @custom_values_before_change = {}
613 617 self.custom_field_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
614 618 end
615 619 @current_journal
616 620 end
617 621
618 622 # Returns the id of the last journal or nil
619 623 def last_journal_id
620 624 if new_record?
621 625 nil
622 626 else
623 627 journals.maximum(:id)
624 628 end
625 629 end
626 630
627 631 # Returns a scope for journals that have an id greater than journal_id
628 632 def journals_after(journal_id)
629 633 scope = journals.reorder("#{Journal.table_name}.id ASC")
630 634 if journal_id.present?
631 635 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
632 636 end
633 637 scope
634 638 end
635 639
636 640 # Return true if the issue is closed, otherwise false
637 641 def closed?
638 642 self.status.is_closed?
639 643 end
640 644
641 645 # Return true if the issue is being reopened
642 646 def reopened?
643 647 if !new_record? && status_id_changed?
644 648 status_was = IssueStatus.find_by_id(status_id_was)
645 649 status_new = IssueStatus.find_by_id(status_id)
646 650 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
647 651 return true
648 652 end
649 653 end
650 654 false
651 655 end
652 656
653 657 # Return true if the issue is being closed
654 658 def closing?
655 659 if !new_record? && status_id_changed?
656 660 status_was = IssueStatus.find_by_id(status_id_was)
657 661 status_new = IssueStatus.find_by_id(status_id)
658 662 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
659 663 return true
660 664 end
661 665 end
662 666 false
663 667 end
664 668
665 669 # Returns true if the issue is overdue
666 670 def overdue?
667 671 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
668 672 end
669 673
670 674 # Is the amount of work done less than it should for the due date
671 675 def behind_schedule?
672 676 return false if start_date.nil? || due_date.nil?
673 677 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
674 678 return done_date <= Date.today
675 679 end
676 680
677 681 # Does this issue have children?
678 682 def children?
679 683 !leaf?
680 684 end
681 685
682 686 # Users the issue can be assigned to
683 687 def assignable_users
684 688 users = project.assignable_users
685 689 users << author if author
686 690 users << assigned_to if assigned_to
687 691 users.uniq.sort
688 692 end
689 693
690 694 # Versions that the issue can be assigned to
691 695 def assignable_versions
692 696 return @assignable_versions if @assignable_versions
693 697
694 698 versions = project.shared_versions.open.all
695 699 if fixed_version
696 700 if fixed_version_id_changed?
697 701 # nothing to do
698 702 elsif project_id_changed?
699 703 if project.shared_versions.include?(fixed_version)
700 704 versions << fixed_version
701 705 end
702 706 else
703 707 versions << fixed_version
704 708 end
705 709 end
706 710 @assignable_versions = versions.uniq.sort
707 711 end
708 712
709 713 # Returns true if this issue is blocked by another issue that is still open
710 714 def blocked?
711 715 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
712 716 end
713 717
714 718 # Returns an array of statuses that user is able to apply
715 719 def new_statuses_allowed_to(user=User.current, include_default=false)
716 720 if new_record? && @copied_from
717 721 [IssueStatus.default, @copied_from.status].compact.uniq.sort
718 722 else
719 723 initial_status = nil
720 724 if new_record?
721 725 initial_status = IssueStatus.default
722 726 elsif status_id_was
723 727 initial_status = IssueStatus.find_by_id(status_id_was)
724 728 end
725 729 initial_status ||= status
726 730
727 731 statuses = initial_status.find_new_statuses_allowed_to(
728 732 user.admin ? Role.all : user.roles_for_project(project),
729 733 tracker,
730 734 author == user,
731 735 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
732 736 )
733 737 statuses << initial_status unless statuses.empty?
734 738 statuses << IssueStatus.default if include_default
735 739 statuses = statuses.compact.uniq.sort
736 740 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
737 741 end
738 742 end
739 743
740 744 def assigned_to_was
741 745 if assigned_to_id_changed? && assigned_to_id_was.present?
742 746 @assigned_to_was ||= User.find_by_id(assigned_to_id_was)
743 747 end
744 748 end
745 749
746 750 # Returns the users that should be notified
747 751 def notified_users
748 752 notified = []
749 753 # Author and assignee are always notified unless they have been
750 754 # locked or don't want to be notified
751 755 notified << author if author
752 756 if assigned_to
753 757 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
754 758 end
755 759 if assigned_to_was
756 760 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
757 761 end
758 762 notified = notified.select {|u| u.active? && u.notify_about?(self)}
759 763
760 764 notified += project.notified_users
761 765 notified.uniq!
762 766 # Remove users that can not view the issue
763 767 notified.reject! {|user| !visible?(user)}
764 768 notified
765 769 end
766 770
767 771 # Returns the email addresses that should be notified
768 772 def recipients
769 773 notified_users.collect(&:mail)
770 774 end
771 775
772 776 # Returns the number of hours spent on this issue
773 777 def spent_hours
774 778 @spent_hours ||= time_entries.sum(:hours) || 0
775 779 end
776 780
777 781 # Returns the total number of hours spent on this issue and its descendants
778 782 #
779 783 # Example:
780 784 # spent_hours => 0.0
781 785 # spent_hours => 50.2
782 786 def total_spent_hours
783 787 @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours",
784 788 :joins => "LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").to_f || 0.0
785 789 end
786 790
787 791 def relations
788 792 @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
789 793 end
790 794
791 795 # Preloads relations for a collection of issues
792 796 def self.load_relations(issues)
793 797 if issues.any?
794 798 relations = IssueRelation.all(:conditions => ["issue_from_id IN (:ids) OR issue_to_id IN (:ids)", {:ids => issues.map(&:id)}])
795 799 issues.each do |issue|
796 800 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
797 801 end
798 802 end
799 803 end
800 804
801 805 # Preloads visible spent time for a collection of issues
802 806 def self.load_visible_spent_hours(issues, user=User.current)
803 807 if issues.any?
804 808 hours_by_issue_id = TimeEntry.visible(user).sum(:hours, :group => :issue_id)
805 809 issues.each do |issue|
806 810 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
807 811 end
808 812 end
809 813 end
810 814
811 815 # Preloads visible relations for a collection of issues
812 816 def self.load_visible_relations(issues, user=User.current)
813 817 if issues.any?
814 818 issue_ids = issues.map(&:id)
815 819 # Relations with issue_from in given issues and visible issue_to
816 820 relations_from = IssueRelation.includes(:issue_to => [:status, :project]).where(visible_condition(user)).where(:issue_from_id => issue_ids).all
817 821 # Relations with issue_to in given issues and visible issue_from
818 822 relations_to = IssueRelation.includes(:issue_from => [:status, :project]).where(visible_condition(user)).where(:issue_to_id => issue_ids).all
819 823
820 824 issues.each do |issue|
821 825 relations =
822 826 relations_from.select {|relation| relation.issue_from_id == issue.id} +
823 827 relations_to.select {|relation| relation.issue_to_id == issue.id}
824 828
825 829 issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
826 830 end
827 831 end
828 832 end
829 833
830 834 # Finds an issue relation given its id.
831 835 def find_relation(relation_id)
832 836 IssueRelation.find(relation_id, :conditions => ["issue_to_id = ? OR issue_from_id = ?", id, id])
833 837 end
834 838
835 839 def all_dependent_issues(except=[])
836 840 except << self
837 841 dependencies = []
838 842 relations_from.each do |relation|
839 843 if relation.issue_to && !except.include?(relation.issue_to)
840 844 dependencies << relation.issue_to
841 845 dependencies += relation.issue_to.all_dependent_issues(except)
842 846 end
843 847 end
844 848 dependencies
845 849 end
846 850
847 851 # Returns an array of issues that duplicate this one
848 852 def duplicates
849 853 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
850 854 end
851 855
852 856 # Returns the due date or the target due date if any
853 857 # Used on gantt chart
854 858 def due_before
855 859 due_date || (fixed_version ? fixed_version.effective_date : nil)
856 860 end
857 861
858 862 # Returns the time scheduled for this issue.
859 863 #
860 864 # Example:
861 865 # Start Date: 2/26/09, End Date: 3/04/09
862 866 # duration => 6
863 867 def duration
864 868 (start_date && due_date) ? due_date - start_date : 0
865 869 end
866 870
867 871 # Returns the duration in working days
868 872 def working_duration
869 873 (start_date && due_date) ? working_days(start_date, due_date) : 0
870 874 end
871 875
872 876 def soonest_start(reload=false)
873 877 @soonest_start = nil if reload
874 878 @soonest_start ||= (
875 879 relations_to(reload).collect{|relation| relation.successor_soonest_start} +
876 880 ancestors.collect(&:soonest_start)
877 881 ).compact.max
878 882 end
879 883
880 884 # Sets start_date on the given date or the next working day
881 885 # and changes due_date to keep the same working duration.
882 886 def reschedule_on(date)
883 887 wd = working_duration
884 888 date = next_working_date(date)
885 889 self.start_date = date
886 890 self.due_date = add_working_days(date, wd)
887 891 end
888 892
889 893 # Reschedules the issue on the given date or the next working day and saves the record.
890 894 # If the issue is a parent task, this is done by rescheduling its subtasks.
891 895 def reschedule_on!(date)
892 896 return if date.nil?
893 897 if leaf?
894 898 if start_date.nil? || start_date != date
895 899 if start_date && start_date > date
896 900 # Issue can not be moved earlier than its soonest start date
897 901 date = [soonest_start(true), date].compact.max
898 902 end
899 903 reschedule_on(date)
900 904 begin
901 905 save
902 906 rescue ActiveRecord::StaleObjectError
903 907 reload
904 908 reschedule_on(date)
905 909 save
906 910 end
907 911 end
908 912 else
909 913 leaves.each do |leaf|
910 914 if leaf.start_date
911 915 # Only move subtask if it starts at the same date as the parent
912 916 # or if it starts before the given date
913 917 if start_date == leaf.start_date || date > leaf.start_date
914 918 leaf.reschedule_on!(date)
915 919 end
916 920 else
917 921 leaf.reschedule_on!(date)
918 922 end
919 923 end
920 924 end
921 925 end
922 926
923 927 def <=>(issue)
924 928 if issue.nil?
925 929 -1
926 930 elsif root_id != issue.root_id
927 931 (root_id || 0) <=> (issue.root_id || 0)
928 932 else
929 933 (lft || 0) <=> (issue.lft || 0)
930 934 end
931 935 end
932 936
933 937 def to_s
934 938 "#{tracker} ##{id}: #{subject}"
935 939 end
936 940
937 941 # Returns a string of css classes that apply to the issue
938 942 def css_classes
939 943 s = "issue status-#{status_id} #{priority.try(:css_classes)}"
940 944 s << ' closed' if closed?
941 945 s << ' overdue' if overdue?
942 946 s << ' child' if child?
943 947 s << ' parent' unless leaf?
944 948 s << ' private' if is_private?
945 949 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
946 950 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
947 951 s
948 952 end
949 953
950 954 # Saves an issue and a time_entry from the parameters
951 955 def save_issue_with_child_records(params, existing_time_entry=nil)
952 956 Issue.transaction do
953 957 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
954 958 @time_entry = existing_time_entry || TimeEntry.new
955 959 @time_entry.project = project
956 960 @time_entry.issue = self
957 961 @time_entry.user = User.current
958 962 @time_entry.spent_on = User.current.today
959 963 @time_entry.attributes = params[:time_entry]
960 964 self.time_entries << @time_entry
961 965 end
962 966
963 967 # TODO: Rename hook
964 968 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
965 969 if save
966 970 # TODO: Rename hook
967 971 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
968 972 else
969 973 raise ActiveRecord::Rollback
970 974 end
971 975 end
972 976 end
973 977
974 978 # Unassigns issues from +version+ if it's no longer shared with issue's project
975 979 def self.update_versions_from_sharing_change(version)
976 980 # Update issues assigned to the version
977 981 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
978 982 end
979 983
980 984 # Unassigns issues from versions that are no longer shared
981 985 # after +project+ was moved
982 986 def self.update_versions_from_hierarchy_change(project)
983 987 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
984 988 # Update issues of the moved projects and issues assigned to a version of a moved project
985 989 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
986 990 end
987 991
988 992 def parent_issue_id=(arg)
989 993 s = arg.to_s.strip.presence
990 994 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
991 995 @parent_issue.id
992 996 else
993 997 @parent_issue = nil
994 998 @invalid_parent_issue_id = arg
995 999 end
996 1000 end
997 1001
998 1002 def parent_issue_id
999 1003 if @invalid_parent_issue_id
1000 1004 @invalid_parent_issue_id
1001 1005 elsif instance_variable_defined? :@parent_issue
1002 1006 @parent_issue.nil? ? nil : @parent_issue.id
1003 1007 else
1004 1008 parent_id
1005 1009 end
1006 1010 end
1007 1011
1008 1012 # Returns true if issue's project is a valid
1009 1013 # parent issue project
1010 1014 def valid_parent_project?(issue=parent)
1011 1015 return true if issue.nil? || issue.project_id == project_id
1012 1016
1013 1017 case Setting.cross_project_subtasks
1014 1018 when 'system'
1015 1019 true
1016 1020 when 'tree'
1017 1021 issue.project.root == project.root
1018 1022 when 'hierarchy'
1019 1023 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1020 1024 when 'descendants'
1021 1025 issue.project.is_or_is_ancestor_of?(project)
1022 1026 else
1023 1027 false
1024 1028 end
1025 1029 end
1026 1030
1027 1031 # Extracted from the ReportsController.
1028 1032 def self.by_tracker(project)
1029 1033 count_and_group_by(:project => project,
1030 1034 :field => 'tracker_id',
1031 1035 :joins => Tracker.table_name)
1032 1036 end
1033 1037
1034 1038 def self.by_version(project)
1035 1039 count_and_group_by(:project => project,
1036 1040 :field => 'fixed_version_id',
1037 1041 :joins => Version.table_name)
1038 1042 end
1039 1043
1040 1044 def self.by_priority(project)
1041 1045 count_and_group_by(:project => project,
1042 1046 :field => 'priority_id',
1043 1047 :joins => IssuePriority.table_name)
1044 1048 end
1045 1049
1046 1050 def self.by_category(project)
1047 1051 count_and_group_by(:project => project,
1048 1052 :field => 'category_id',
1049 1053 :joins => IssueCategory.table_name)
1050 1054 end
1051 1055
1052 1056 def self.by_assigned_to(project)
1053 1057 count_and_group_by(:project => project,
1054 1058 :field => 'assigned_to_id',
1055 1059 :joins => User.table_name)
1056 1060 end
1057 1061
1058 1062 def self.by_author(project)
1059 1063 count_and_group_by(:project => project,
1060 1064 :field => 'author_id',
1061 1065 :joins => User.table_name)
1062 1066 end
1063 1067
1064 1068 def self.by_subproject(project)
1065 1069 ActiveRecord::Base.connection.select_all("select s.id as status_id,
1066 1070 s.is_closed as closed,
1067 1071 #{Issue.table_name}.project_id as project_id,
1068 1072 count(#{Issue.table_name}.id) as total
1069 1073 from
1070 1074 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
1071 1075 where
1072 1076 #{Issue.table_name}.status_id=s.id
1073 1077 and #{Issue.table_name}.project_id = #{Project.table_name}.id
1074 1078 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
1075 1079 and #{Issue.table_name}.project_id <> #{project.id}
1076 1080 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
1077 1081 end
1078 1082 # End ReportsController extraction
1079 1083
1080 1084 # Returns an array of projects that user can assign the issue to
1081 1085 def allowed_target_projects(user=User.current)
1082 1086 if new_record?
1083 1087 Project.all(:conditions => Project.allowed_to_condition(user, :add_issues))
1084 1088 else
1085 1089 self.class.allowed_target_projects_on_move(user)
1086 1090 end
1087 1091 end
1088 1092
1089 1093 # Returns an array of projects that user can move issues to
1090 1094 def self.allowed_target_projects_on_move(user=User.current)
1091 1095 Project.all(:conditions => Project.allowed_to_condition(user, :move_issues))
1092 1096 end
1093 1097
1094 1098 private
1095 1099
1096 1100 def after_project_change
1097 1101 # Update project_id on related time entries
1098 1102 TimeEntry.update_all(["project_id = ?", project_id], {:issue_id => id})
1099 1103
1100 1104 # Delete issue relations
1101 1105 unless Setting.cross_project_issue_relations?
1102 1106 relations_from.clear
1103 1107 relations_to.clear
1104 1108 end
1105 1109
1106 1110 # Move subtasks that were in the same project
1107 1111 children.each do |child|
1108 1112 next unless child.project_id == project_id_was
1109 1113 # Change project and keep project
1110 1114 child.send :project=, project, true
1111 1115 unless child.save
1112 1116 raise ActiveRecord::Rollback
1113 1117 end
1114 1118 end
1115 1119 end
1116 1120
1117 1121 # Callback for after the creation of an issue by copy
1118 1122 # * adds a "copied to" relation with the copied issue
1119 1123 # * copies subtasks from the copied issue
1120 1124 def after_create_from_copy
1121 1125 return unless copy? && !@after_create_from_copy_handled
1122 1126
1123 1127 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1124 1128 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1125 1129 unless relation.save
1126 1130 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1127 1131 end
1128 1132 end
1129 1133
1130 1134 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1131 1135 @copied_from.children.each do |child|
1132 1136 unless child.visible?
1133 1137 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1134 1138 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1135 1139 next
1136 1140 end
1137 1141 copy = Issue.new.copy_from(child, @copy_options)
1138 1142 copy.author = author
1139 1143 copy.project = project
1140 1144 copy.parent_issue_id = id
1141 1145 # Children subtasks are copied recursively
1142 1146 unless copy.save
1143 1147 logger.error "Could not copy subtask ##{child.id} while copying ##{@copied_from.id} to ##{id} due to validation errors: #{copy.errors.full_messages.join(', ')}" if logger
1144 1148 end
1145 1149 end
1146 1150 end
1147 1151 @after_create_from_copy_handled = true
1148 1152 end
1149 1153
1150 1154 def update_nested_set_attributes
1151 1155 if root_id.nil?
1152 1156 # issue was just created
1153 1157 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
1154 1158 set_default_left_and_right
1155 1159 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
1156 1160 if @parent_issue
1157 1161 move_to_child_of(@parent_issue)
1158 1162 end
1159 1163 reload
1160 1164 elsif parent_issue_id != parent_id
1161 1165 former_parent_id = parent_id
1162 1166 # moving an existing issue
1163 1167 if @parent_issue && @parent_issue.root_id == root_id
1164 1168 # inside the same tree
1165 1169 move_to_child_of(@parent_issue)
1166 1170 else
1167 1171 # to another tree
1168 1172 unless root?
1169 1173 move_to_right_of(root)
1170 1174 reload
1171 1175 end
1172 1176 old_root_id = root_id
1173 1177 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
1174 1178 target_maxright = nested_set_scope.maximum(right_column_name) || 0
1175 1179 offset = target_maxright + 1 - lft
1176 1180 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
1177 1181 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
1178 1182 self[left_column_name] = lft + offset
1179 1183 self[right_column_name] = rgt + offset
1180 1184 if @parent_issue
1181 1185 move_to_child_of(@parent_issue)
1182 1186 end
1183 1187 end
1184 1188 reload
1185 1189 # delete invalid relations of all descendants
1186 1190 self_and_descendants.each do |issue|
1187 1191 issue.relations.each do |relation|
1188 1192 relation.destroy unless relation.valid?
1189 1193 end
1190 1194 end
1191 1195 # update former parent
1192 1196 recalculate_attributes_for(former_parent_id) if former_parent_id
1193 1197 end
1194 1198 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1195 1199 end
1196 1200
1197 1201 def update_parent_attributes
1198 1202 recalculate_attributes_for(parent_id) if parent_id
1199 1203 end
1200 1204
1201 1205 def recalculate_attributes_for(issue_id)
1202 1206 if issue_id && p = Issue.find_by_id(issue_id)
1203 1207 # priority = highest priority of children
1204 1208 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :joins => :priority)
1205 1209 p.priority = IssuePriority.find_by_position(priority_position)
1206 1210 end
1207 1211
1208 1212 # start/due dates = lowest/highest dates of children
1209 1213 p.start_date = p.children.minimum(:start_date)
1210 1214 p.due_date = p.children.maximum(:due_date)
1211 1215 if p.start_date && p.due_date && p.due_date < p.start_date
1212 1216 p.start_date, p.due_date = p.due_date, p.start_date
1213 1217 end
1214 1218
1215 1219 # done ratio = weighted average ratio of leaves
1216 1220 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1217 1221 leaves_count = p.leaves.count
1218 1222 if leaves_count > 0
1219 1223 average = p.leaves.average(:estimated_hours).to_f
1220 1224 if average == 0
1221 1225 average = 1
1222 1226 end
1223 1227 done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :joins => :status).to_f
1224 1228 progress = done / (average * leaves_count)
1225 1229 p.done_ratio = progress.round
1226 1230 end
1227 1231 end
1228 1232
1229 1233 # estimate = sum of leaves estimates
1230 1234 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
1231 1235 p.estimated_hours = nil if p.estimated_hours == 0.0
1232 1236
1233 1237 # ancestors will be recursively updated
1234 1238 p.save(:validate => false)
1235 1239 end
1236 1240 end
1237 1241
1238 1242 # Update issues so their versions are not pointing to a
1239 1243 # fixed_version that is not shared with the issue's project
1240 1244 def self.update_versions(conditions=nil)
1241 1245 # Only need to update issues with a fixed_version from
1242 1246 # a different project and that is not systemwide shared
1243 1247 Issue.scoped(:conditions => conditions).all(
1244 1248 :conditions => "#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1245 1249 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1246 1250 " AND #{Version.table_name}.sharing <> 'system'",
1247 1251 :include => [:project, :fixed_version]
1248 1252 ).each do |issue|
1249 1253 next if issue.project.nil? || issue.fixed_version.nil?
1250 1254 unless issue.project.shared_versions.include?(issue.fixed_version)
1251 1255 issue.init_journal(User.current)
1252 1256 issue.fixed_version = nil
1253 1257 issue.save
1254 1258 end
1255 1259 end
1256 1260 end
1257 1261
1258 1262 # Callback on file attachment
1259 1263 def attachment_added(obj)
1260 1264 if @current_journal && !obj.new_record?
1261 1265 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
1262 1266 end
1263 1267 end
1264 1268
1265 1269 # Callback on attachment deletion
1266 1270 def attachment_removed(obj)
1267 1271 if @current_journal && !obj.new_record?
1268 1272 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :old_value => obj.filename)
1269 1273 @current_journal.save
1270 1274 end
1271 1275 end
1272 1276
1273 1277 # Default assignment based on category
1274 1278 def default_assign
1275 1279 if assigned_to.nil? && category && category.assigned_to
1276 1280 self.assigned_to = category.assigned_to
1277 1281 end
1278 1282 end
1279 1283
1280 1284 # Updates start/due dates of following issues
1281 1285 def reschedule_following_issues
1282 1286 if start_date_changed? || due_date_changed?
1283 1287 relations_from.each do |relation|
1284 1288 relation.set_issue_to_dates
1285 1289 end
1286 1290 end
1287 1291 end
1288 1292
1289 1293 # Closes duplicates if the issue is being closed
1290 1294 def close_duplicates
1291 1295 if closing?
1292 1296 duplicates.each do |duplicate|
1293 1297 # Reload is need in case the duplicate was updated by a previous duplicate
1294 1298 duplicate.reload
1295 1299 # Don't re-close it if it's already closed
1296 1300 next if duplicate.closed?
1297 1301 # Same user and notes
1298 1302 if @current_journal
1299 1303 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1300 1304 end
1301 1305 duplicate.update_attribute :status, self.status
1302 1306 end
1303 1307 end
1304 1308 end
1305 1309
1306 1310 # Make sure updated_on is updated when adding a note
1307 1311 def force_updated_on_change
1308 1312 if @current_journal
1309 1313 self.updated_on = current_time_from_proper_timezone
1310 1314 end
1311 1315 end
1312 1316
1313 1317 # Saves the changes in a Journal
1314 1318 # Called after_save
1315 1319 def create_journal
1316 1320 if @current_journal
1317 1321 # attributes changes
1318 1322 if @attributes_before_change
1319 1323 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
1320 1324 before = @attributes_before_change[c]
1321 1325 after = send(c)
1322 1326 next if before == after || (before.blank? && after.blank?)
1323 1327 @current_journal.details << JournalDetail.new(:property => 'attr',
1324 1328 :prop_key => c,
1325 1329 :old_value => before,
1326 1330 :value => after)
1327 1331 }
1328 1332 end
1329 1333 if @custom_values_before_change
1330 1334 # custom fields changes
1331 1335 custom_field_values.each {|c|
1332 1336 before = @custom_values_before_change[c.custom_field_id]
1333 1337 after = c.value
1334 1338 next if before == after || (before.blank? && after.blank?)
1335 1339
1336 1340 if before.is_a?(Array) || after.is_a?(Array)
1337 1341 before = [before] unless before.is_a?(Array)
1338 1342 after = [after] unless after.is_a?(Array)
1339 1343
1340 1344 # values removed
1341 1345 (before - after).reject(&:blank?).each do |value|
1342 1346 @current_journal.details << JournalDetail.new(:property => 'cf',
1343 1347 :prop_key => c.custom_field_id,
1344 1348 :old_value => value,
1345 1349 :value => nil)
1346 1350 end
1347 1351 # values added
1348 1352 (after - before).reject(&:blank?).each do |value|
1349 1353 @current_journal.details << JournalDetail.new(:property => 'cf',
1350 1354 :prop_key => c.custom_field_id,
1351 1355 :old_value => nil,
1352 1356 :value => value)
1353 1357 end
1354 1358 else
1355 1359 @current_journal.details << JournalDetail.new(:property => 'cf',
1356 1360 :prop_key => c.custom_field_id,
1357 1361 :old_value => before,
1358 1362 :value => after)
1359 1363 end
1360 1364 }
1361 1365 end
1362 1366 @current_journal.save
1363 1367 # reset current journal
1364 1368 init_journal @current_journal.user, @current_journal.notes
1365 1369 end
1366 1370 end
1367 1371
1368 1372 # Query generator for selecting groups of issue counts for a project
1369 1373 # based on specific criteria
1370 1374 #
1371 1375 # Options
1372 1376 # * project - Project to search in.
1373 1377 # * field - String. Issue field to key off of in the grouping.
1374 1378 # * joins - String. The table name to join against.
1375 1379 def self.count_and_group_by(options)
1376 1380 project = options.delete(:project)
1377 1381 select_field = options.delete(:field)
1378 1382 joins = options.delete(:joins)
1379 1383
1380 1384 where = "#{Issue.table_name}.#{select_field}=j.id"
1381 1385
1382 1386 ActiveRecord::Base.connection.select_all("select s.id as status_id,
1383 1387 s.is_closed as closed,
1384 1388 j.id as #{select_field},
1385 1389 count(#{Issue.table_name}.id) as total
1386 1390 from
1387 1391 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
1388 1392 where
1389 1393 #{Issue.table_name}.status_id=s.id
1390 1394 and #{where}
1391 1395 and #{Issue.table_name}.project_id=#{Project.table_name}.id
1392 1396 and #{visible_condition(User.current, :project => project)}
1393 1397 group by s.id, s.is_closed, j.id")
1394 1398 end
1395 1399 end
@@ -1,969 +1,971
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 Project < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20
21 21 # Project statuses
22 22 STATUS_ACTIVE = 1
23 23 STATUS_CLOSED = 5
24 24 STATUS_ARCHIVED = 9
25 25
26 26 # Maximum length for project identifiers
27 27 IDENTIFIER_MAX_LENGTH = 100
28 28
29 29 # Specific overidden Activities
30 30 has_many :time_entry_activities
31 31 has_many :members, :include => [:principal, :roles], :conditions => "#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{Principal::STATUS_ACTIVE}"
32 32 has_many :memberships, :class_name => 'Member'
33 33 has_many :member_principals, :class_name => 'Member',
34 34 :include => :principal,
35 35 :conditions => "#{Principal.table_name}.type='Group' OR (#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{Principal::STATUS_ACTIVE})"
36 36 has_many :users, :through => :members
37 37 has_many :principals, :through => :member_principals, :source => :principal
38 38
39 39 has_many :enabled_modules, :dependent => :delete_all
40 40 has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
41 41 has_many :issues, :dependent => :destroy, :include => [:status, :tracker]
42 42 has_many :issue_changes, :through => :issues, :source => :journals
43 43 has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
44 44 has_many :time_entries, :dependent => :delete_all
45 45 has_many :queries, :class_name => 'IssueQuery', :dependent => :delete_all
46 46 has_many :documents, :dependent => :destroy
47 47 has_many :news, :dependent => :destroy, :include => :author
48 48 has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
49 49 has_many :boards, :dependent => :destroy, :order => "position ASC"
50 50 has_one :repository, :conditions => ["is_default = ?", true]
51 51 has_many :repositories, :dependent => :destroy
52 52 has_many :changesets, :through => :repository
53 53 has_one :wiki, :dependent => :destroy
54 54 # Custom field for the project issues
55 55 has_and_belongs_to_many :issue_custom_fields,
56 56 :class_name => 'IssueCustomField',
57 57 :order => "#{CustomField.table_name}.position",
58 58 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
59 59 :association_foreign_key => 'custom_field_id'
60 60
61 61 acts_as_nested_set :order => 'name', :dependent => :destroy
62 62 acts_as_attachable :view_permission => :view_files,
63 63 :delete_permission => :manage_files
64 64
65 65 acts_as_customizable
66 66 acts_as_searchable :columns => ['name', 'identifier', 'description'], :project_key => 'id', :permission => nil
67 67 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
68 68 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o}},
69 69 :author => nil
70 70
71 71 attr_protected :status
72 72
73 73 validates_presence_of :name, :identifier
74 74 validates_uniqueness_of :identifier
75 75 validates_associated :repository, :wiki
76 76 validates_length_of :name, :maximum => 255
77 77 validates_length_of :homepage, :maximum => 255
78 78 validates_length_of :identifier, :in => 1..IDENTIFIER_MAX_LENGTH
79 79 # donwcase letters, digits, dashes but not digits only
80 80 validates_format_of :identifier, :with => /\A(?!\d+$)[a-z0-9\-_]*\z/, :if => Proc.new { |p| p.identifier_changed? }
81 81 # reserved words
82 82 validates_exclusion_of :identifier, :in => %w( new )
83 83
84 84 after_save :update_position_under_parent, :if => Proc.new {|project| project.name_changed?}
85 85 before_destroy :delete_all_members
86 86
87 87 scope :has_module, lambda {|mod|
88 88 where("#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s)
89 89 }
90 90 scope :active, lambda { where(:status => STATUS_ACTIVE) }
91 91 scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
92 92 scope :all_public, lambda { where(:is_public => true) }
93 93 scope :visible, lambda {|*args| where(Project.visible_condition(args.shift || User.current, *args)) }
94 94 scope :allowed_to, lambda {|*args|
95 95 user = User.current
96 96 permission = nil
97 97 if args.first.is_a?(Symbol)
98 98 permission = args.shift
99 99 else
100 100 user = args.shift
101 101 permission = args.shift
102 102 end
103 103 where(Project.allowed_to_condition(user, permission, *args))
104 104 }
105 105 scope :like, lambda {|arg|
106 106 if arg.blank?
107 107 where(nil)
108 108 else
109 109 pattern = "%#{arg.to_s.strip.downcase}%"
110 110 where("LOWER(identifier) LIKE :p OR LOWER(name) LIKE :p", :p => pattern)
111 111 end
112 112 }
113 113
114 114 def initialize(attributes=nil, *args)
115 115 super
116 116
117 117 initialized = (attributes || {}).stringify_keys
118 118 if !initialized.key?('identifier') && Setting.sequential_project_identifiers?
119 119 self.identifier = Project.next_identifier
120 120 end
121 121 if !initialized.key?('is_public')
122 122 self.is_public = Setting.default_projects_public?
123 123 end
124 124 if !initialized.key?('enabled_module_names')
125 125 self.enabled_module_names = Setting.default_projects_modules
126 126 end
127 127 if !initialized.key?('trackers') && !initialized.key?('tracker_ids')
128 128 self.trackers = Tracker.sorted.all
129 129 end
130 130 end
131 131
132 132 def identifier=(identifier)
133 133 super unless identifier_frozen?
134 134 end
135 135
136 136 def identifier_frozen?
137 137 errors[:identifier].blank? && !(new_record? || identifier.blank?)
138 138 end
139 139
140 140 # returns latest created projects
141 141 # non public projects will be returned only if user is a member of those
142 142 def self.latest(user=nil, count=5)
143 143 visible(user).limit(count).order("created_on DESC").all
144 144 end
145 145
146 146 # Returns true if the project is visible to +user+ or to the current user.
147 147 def visible?(user=User.current)
148 148 user.allowed_to?(:view_project, self)
149 149 end
150 150
151 151 # Returns a SQL conditions string used to find all projects visible by the specified user.
152 152 #
153 153 # Examples:
154 154 # Project.visible_condition(admin) => "projects.status = 1"
155 155 # Project.visible_condition(normal_user) => "((projects.status = 1) AND (projects.is_public = 1 OR projects.id IN (1,3,4)))"
156 156 # Project.visible_condition(anonymous) => "((projects.status = 1) AND (projects.is_public = 1))"
157 157 def self.visible_condition(user, options={})
158 158 allowed_to_condition(user, :view_project, options)
159 159 end
160 160
161 161 # Returns a SQL conditions string used to find all projects for which +user+ has the given +permission+
162 162 #
163 163 # Valid options:
164 164 # * :project => limit the condition to project
165 165 # * :with_subprojects => limit the condition to project and its subprojects
166 166 # * :member => limit the condition to the user projects
167 167 def self.allowed_to_condition(user, permission, options={})
168 168 perm = Redmine::AccessControl.permission(permission)
169 169 base_statement = (perm && perm.read? ? "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED}" : "#{Project.table_name}.status = #{Project::STATUS_ACTIVE}")
170 170 if perm && perm.project_module
171 171 # If the permission belongs to a project module, make sure the module is enabled
172 172 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
173 173 end
174 174 if options[:project]
175 175 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
176 176 project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
177 177 base_statement = "(#{project_statement}) AND (#{base_statement})"
178 178 end
179 179
180 180 if user.admin?
181 181 base_statement
182 182 else
183 183 statement_by_role = {}
184 184 unless options[:member]
185 185 role = user.logged? ? Role.non_member : Role.anonymous
186 186 if role.allowed_to?(permission)
187 187 statement_by_role[role] = "#{Project.table_name}.is_public = #{connection.quoted_true}"
188 188 end
189 189 end
190 190 if user.logged?
191 191 user.projects_by_role.each do |role, projects|
192 192 if role.allowed_to?(permission) && projects.any?
193 193 statement_by_role[role] = "#{Project.table_name}.id IN (#{projects.collect(&:id).join(',')})"
194 194 end
195 195 end
196 196 end
197 197 if statement_by_role.empty?
198 198 "1=0"
199 199 else
200 200 if block_given?
201 201 statement_by_role.each do |role, statement|
202 202 if s = yield(role, user)
203 203 statement_by_role[role] = "(#{statement} AND (#{s}))"
204 204 end
205 205 end
206 206 end
207 207 "((#{base_statement}) AND (#{statement_by_role.values.join(' OR ')}))"
208 208 end
209 209 end
210 210 end
211 211
212 212 # Returns the Systemwide and project specific activities
213 213 def activities(include_inactive=false)
214 214 if include_inactive
215 215 return all_activities
216 216 else
217 217 return active_activities
218 218 end
219 219 end
220 220
221 221 # Will create a new Project specific Activity or update an existing one
222 222 #
223 223 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
224 224 # does not successfully save.
225 225 def update_or_create_time_entry_activity(id, activity_hash)
226 226 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
227 227 self.create_time_entry_activity_if_needed(activity_hash)
228 228 else
229 229 activity = project.time_entry_activities.find_by_id(id.to_i)
230 230 activity.update_attributes(activity_hash) if activity
231 231 end
232 232 end
233 233
234 234 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
235 235 #
236 236 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
237 237 # does not successfully save.
238 238 def create_time_entry_activity_if_needed(activity)
239 239 if activity['parent_id']
240 240
241 241 parent_activity = TimeEntryActivity.find(activity['parent_id'])
242 242 activity['name'] = parent_activity.name
243 243 activity['position'] = parent_activity.position
244 244
245 245 if Enumeration.overridding_change?(activity, parent_activity)
246 246 project_activity = self.time_entry_activities.create(activity)
247 247
248 248 if project_activity.new_record?
249 249 raise ActiveRecord::Rollback, "Overridding TimeEntryActivity was not successfully saved"
250 250 else
251 251 self.time_entries.update_all("activity_id = #{project_activity.id}", ["activity_id = ?", parent_activity.id])
252 252 end
253 253 end
254 254 end
255 255 end
256 256
257 257 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
258 258 #
259 259 # Examples:
260 260 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
261 261 # project.project_condition(false) => "projects.id = 1"
262 262 def project_condition(with_subprojects)
263 263 cond = "#{Project.table_name}.id = #{id}"
264 264 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
265 265 cond
266 266 end
267 267
268 268 def self.find(*args)
269 269 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
270 270 project = find_by_identifier(*args)
271 271 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
272 272 project
273 273 else
274 274 super
275 275 end
276 276 end
277 277
278 278 def self.find_by_param(*args)
279 279 self.find(*args)
280 280 end
281 281
282 282 def reload(*args)
283 283 @shared_versions = nil
284 284 @rolled_up_versions = nil
285 285 @rolled_up_trackers = nil
286 286 @all_issue_custom_fields = nil
287 287 @all_time_entry_custom_fields = nil
288 288 @to_param = nil
289 289 @allowed_parents = nil
290 290 @allowed_permissions = nil
291 291 @actions_allowed = nil
292 @start_date = nil
293 @due_date = nil
292 294 super
293 295 end
294 296
295 297 def to_param
296 298 # id is used for projects with a numeric identifier (compatibility)
297 299 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id.to_s : identifier)
298 300 end
299 301
300 302 def active?
301 303 self.status == STATUS_ACTIVE
302 304 end
303 305
304 306 def archived?
305 307 self.status == STATUS_ARCHIVED
306 308 end
307 309
308 310 # Archives the project and its descendants
309 311 def archive
310 312 # Check that there is no issue of a non descendant project that is assigned
311 313 # to one of the project or descendant versions
312 314 v_ids = self_and_descendants.collect {|p| p.version_ids}.flatten
313 315 if v_ids.any? &&
314 316 Issue.
315 317 includes(:project).
316 318 where("#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?", lft, rgt).
317 319 where("#{Issue.table_name}.fixed_version_id IN (?)", v_ids).
318 320 exists?
319 321 return false
320 322 end
321 323 Project.transaction do
322 324 archive!
323 325 end
324 326 true
325 327 end
326 328
327 329 # Unarchives the project
328 330 # All its ancestors must be active
329 331 def unarchive
330 332 return false if ancestors.detect {|a| !a.active?}
331 333 update_attribute :status, STATUS_ACTIVE
332 334 end
333 335
334 336 def close
335 337 self_and_descendants.status(STATUS_ACTIVE).update_all :status => STATUS_CLOSED
336 338 end
337 339
338 340 def reopen
339 341 self_and_descendants.status(STATUS_CLOSED).update_all :status => STATUS_ACTIVE
340 342 end
341 343
342 344 # Returns an array of projects the project can be moved to
343 345 # by the current user
344 346 def allowed_parents
345 347 return @allowed_parents if @allowed_parents
346 348 @allowed_parents = Project.where(Project.allowed_to_condition(User.current, :add_subprojects)).all
347 349 @allowed_parents = @allowed_parents - self_and_descendants
348 350 if User.current.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
349 351 @allowed_parents << nil
350 352 end
351 353 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
352 354 @allowed_parents << parent
353 355 end
354 356 @allowed_parents
355 357 end
356 358
357 359 # Sets the parent of the project with authorization check
358 360 def set_allowed_parent!(p)
359 361 unless p.nil? || p.is_a?(Project)
360 362 if p.to_s.blank?
361 363 p = nil
362 364 else
363 365 p = Project.find_by_id(p)
364 366 return false unless p
365 367 end
366 368 end
367 369 if p.nil?
368 370 if !new_record? && allowed_parents.empty?
369 371 return false
370 372 end
371 373 elsif !allowed_parents.include?(p)
372 374 return false
373 375 end
374 376 set_parent!(p)
375 377 end
376 378
377 379 # Sets the parent of the project
378 380 # Argument can be either a Project, a String, a Fixnum or nil
379 381 def set_parent!(p)
380 382 unless p.nil? || p.is_a?(Project)
381 383 if p.to_s.blank?
382 384 p = nil
383 385 else
384 386 p = Project.find_by_id(p)
385 387 return false unless p
386 388 end
387 389 end
388 390 if p == parent && !p.nil?
389 391 # Nothing to do
390 392 true
391 393 elsif p.nil? || (p.active? && move_possible?(p))
392 394 set_or_update_position_under(p)
393 395 Issue.update_versions_from_hierarchy_change(self)
394 396 true
395 397 else
396 398 # Can not move to the given target
397 399 false
398 400 end
399 401 end
400 402
401 403 # Recalculates all lft and rgt values based on project names
402 404 # Unlike Project.rebuild!, these values are recalculated even if the tree "looks" valid
403 405 # Used in BuildProjectsTree migration
404 406 def self.rebuild_tree!
405 407 transaction do
406 408 update_all "lft = NULL, rgt = NULL"
407 409 rebuild!(false)
408 410 end
409 411 end
410 412
411 413 # Returns an array of the trackers used by the project and its active sub projects
412 414 def rolled_up_trackers
413 415 @rolled_up_trackers ||=
414 416 Tracker.
415 417 joins(:projects).
416 418 select("DISTINCT #{Tracker.table_name}.*").
417 419 where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> #{STATUS_ARCHIVED}", lft, rgt).
418 420 sorted.
419 421 all
420 422 end
421 423
422 424 # Closes open and locked project versions that are completed
423 425 def close_completed_versions
424 426 Version.transaction do
425 427 versions.where(:status => %w(open locked)).all.each do |version|
426 428 if version.completed?
427 429 version.update_attribute(:status, 'closed')
428 430 end
429 431 end
430 432 end
431 433 end
432 434
433 435 # Returns a scope of the Versions on subprojects
434 436 def rolled_up_versions
435 437 @rolled_up_versions ||=
436 438 Version.scoped(:include => :project,
437 439 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> #{STATUS_ARCHIVED}", lft, rgt])
438 440 end
439 441
440 442 # Returns a scope of the Versions used by the project
441 443 def shared_versions
442 444 if new_record?
443 445 Version.scoped(:include => :project,
444 446 :conditions => "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND #{Version.table_name}.sharing = 'system'")
445 447 else
446 448 @shared_versions ||= begin
447 449 r = root? ? self : root
448 450 Version.scoped(:include => :project,
449 451 :conditions => "#{Project.table_name}.id = #{id}" +
450 452 " OR (#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND (" +
451 453 " #{Version.table_name}.sharing = 'system'" +
452 454 " OR (#{Project.table_name}.lft >= #{r.lft} AND #{Project.table_name}.rgt <= #{r.rgt} AND #{Version.table_name}.sharing = 'tree')" +
453 455 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
454 456 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
455 457 "))")
456 458 end
457 459 end
458 460 end
459 461
460 462 # Returns a hash of project users grouped by role
461 463 def users_by_role
462 464 members.includes(:user, :roles).all.inject({}) do |h, m|
463 465 m.roles.each do |r|
464 466 h[r] ||= []
465 467 h[r] << m.user
466 468 end
467 469 h
468 470 end
469 471 end
470 472
471 473 # Deletes all project's members
472 474 def delete_all_members
473 475 me, mr = Member.table_name, MemberRole.table_name
474 476 connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
475 477 Member.delete_all(['project_id = ?', id])
476 478 end
477 479
478 480 # Users/groups issues can be assigned to
479 481 def assignable_users
480 482 assignable = Setting.issue_group_assignment? ? member_principals : members
481 483 assignable.select {|m| m.roles.detect {|role| role.assignable?}}.collect {|m| m.principal}.sort
482 484 end
483 485
484 486 # Returns the mail adresses of users that should be always notified on project events
485 487 def recipients
486 488 notified_users.collect {|user| user.mail}
487 489 end
488 490
489 491 # Returns the users that should be notified on project events
490 492 def notified_users
491 493 # TODO: User part should be extracted to User#notify_about?
492 494 members.select {|m| m.principal.present? && (m.mail_notification? || m.principal.mail_notification == 'all')}.collect {|m| m.principal}
493 495 end
494 496
495 497 # Returns an array of all custom fields enabled for project issues
496 498 # (explictly associated custom fields and custom fields enabled for all projects)
497 499 def all_issue_custom_fields
498 500 @all_issue_custom_fields ||= (IssueCustomField.for_all + issue_custom_fields).uniq.sort
499 501 end
500 502
501 503 # Returns an array of all custom fields enabled for project time entries
502 504 # (explictly associated custom fields and custom fields enabled for all projects)
503 505 def all_time_entry_custom_fields
504 506 @all_time_entry_custom_fields ||= (TimeEntryCustomField.for_all + time_entry_custom_fields).uniq.sort
505 507 end
506 508
507 509 def project
508 510 self
509 511 end
510 512
511 513 def <=>(project)
512 514 name.downcase <=> project.name.downcase
513 515 end
514 516
515 517 def to_s
516 518 name
517 519 end
518 520
519 521 # Returns a short description of the projects (first lines)
520 522 def short_description(length = 255)
521 523 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
522 524 end
523 525
524 526 def css_classes
525 527 s = 'project'
526 528 s << ' root' if root?
527 529 s << ' child' if child?
528 530 s << (leaf? ? ' leaf' : ' parent')
529 531 unless active?
530 532 if archived?
531 533 s << ' archived'
532 534 else
533 535 s << ' closed'
534 536 end
535 537 end
536 538 s
537 539 end
538 540
539 541 # The earliest start date of a project, based on it's issues and versions
540 542 def start_date
541 [
543 @start_date ||= [
542 544 issues.minimum('start_date'),
543 shared_versions.collect(&:effective_date),
544 shared_versions.collect(&:start_date)
545 ].flatten.compact.min
545 shared_versions.minimum('effective_date'),
546 Issue.fixed_version(shared_versions).minimum('start_date')
547 ].compact.min
546 548 end
547 549
548 550 # The latest due date of an issue or version
549 551 def due_date
550 [
552 @due_date ||= [
551 553 issues.maximum('due_date'),
552 shared_versions.collect(&:effective_date),
553 shared_versions.collect {|v| v.fixed_issues.maximum('due_date')}
554 ].flatten.compact.max
554 shared_versions.maximum('effective_date'),
555 Issue.fixed_version(shared_versions).maximum('due_date')
556 ].compact.max
555 557 end
556 558
557 559 def overdue?
558 560 active? && !due_date.nil? && (due_date < Date.today)
559 561 end
560 562
561 563 # Returns the percent completed for this project, based on the
562 564 # progress on it's versions.
563 565 def completed_percent(options={:include_subprojects => false})
564 566 if options.delete(:include_subprojects)
565 567 total = self_and_descendants.collect(&:completed_percent).sum
566 568
567 569 total / self_and_descendants.count
568 570 else
569 571 if versions.count > 0
570 572 total = versions.collect(&:completed_percent).sum
571 573
572 574 total / versions.count
573 575 else
574 576 100
575 577 end
576 578 end
577 579 end
578 580
579 581 # Return true if this project allows to do the specified action.
580 582 # action can be:
581 583 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
582 584 # * a permission Symbol (eg. :edit_project)
583 585 def allows_to?(action)
584 586 if archived?
585 587 # No action allowed on archived projects
586 588 return false
587 589 end
588 590 unless active? || Redmine::AccessControl.read_action?(action)
589 591 # No write action allowed on closed projects
590 592 return false
591 593 end
592 594 # No action allowed on disabled modules
593 595 if action.is_a? Hash
594 596 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
595 597 else
596 598 allowed_permissions.include? action
597 599 end
598 600 end
599 601
600 602 def module_enabled?(module_name)
601 603 module_name = module_name.to_s
602 604 enabled_modules.detect {|m| m.name == module_name}
603 605 end
604 606
605 607 def enabled_module_names=(module_names)
606 608 if module_names && module_names.is_a?(Array)
607 609 module_names = module_names.collect(&:to_s).reject(&:blank?)
608 610 self.enabled_modules = module_names.collect {|name| enabled_modules.detect {|mod| mod.name == name} || EnabledModule.new(:name => name)}
609 611 else
610 612 enabled_modules.clear
611 613 end
612 614 end
613 615
614 616 # Returns an array of the enabled modules names
615 617 def enabled_module_names
616 618 enabled_modules.collect(&:name)
617 619 end
618 620
619 621 # Enable a specific module
620 622 #
621 623 # Examples:
622 624 # project.enable_module!(:issue_tracking)
623 625 # project.enable_module!("issue_tracking")
624 626 def enable_module!(name)
625 627 enabled_modules << EnabledModule.new(:name => name.to_s) unless module_enabled?(name)
626 628 end
627 629
628 630 # Disable a module if it exists
629 631 #
630 632 # Examples:
631 633 # project.disable_module!(:issue_tracking)
632 634 # project.disable_module!("issue_tracking")
633 635 # project.disable_module!(project.enabled_modules.first)
634 636 def disable_module!(target)
635 637 target = enabled_modules.detect{|mod| target.to_s == mod.name} unless enabled_modules.include?(target)
636 638 target.destroy unless target.blank?
637 639 end
638 640
639 641 safe_attributes 'name',
640 642 'description',
641 643 'homepage',
642 644 'is_public',
643 645 'identifier',
644 646 'custom_field_values',
645 647 'custom_fields',
646 648 'tracker_ids',
647 649 'issue_custom_field_ids'
648 650
649 651 safe_attributes 'enabled_module_names',
650 652 :if => lambda {|project, user| project.new_record? || user.allowed_to?(:select_project_modules, project) }
651 653
652 654 # Returns an array of projects that are in this project's hierarchy
653 655 #
654 656 # Example: parents, children, siblings
655 657 def hierarchy
656 658 parents = project.self_and_ancestors || []
657 659 descendants = project.descendants || []
658 660 project_hierarchy = parents | descendants # Set union
659 661 end
660 662
661 663 # Returns an auto-generated project identifier based on the last identifier used
662 664 def self.next_identifier
663 665 p = Project.order('created_on DESC').first
664 666 p.nil? ? nil : p.identifier.to_s.succ
665 667 end
666 668
667 669 # Copies and saves the Project instance based on the +project+.
668 670 # Duplicates the source project's:
669 671 # * Wiki
670 672 # * Versions
671 673 # * Categories
672 674 # * Issues
673 675 # * Members
674 676 # * Queries
675 677 #
676 678 # Accepts an +options+ argument to specify what to copy
677 679 #
678 680 # Examples:
679 681 # project.copy(1) # => copies everything
680 682 # project.copy(1, :only => 'members') # => copies members only
681 683 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
682 684 def copy(project, options={})
683 685 project = project.is_a?(Project) ? project : Project.find(project)
684 686
685 687 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
686 688 to_be_copied = to_be_copied & options[:only].to_a unless options[:only].nil?
687 689
688 690 Project.transaction do
689 691 if save
690 692 reload
691 693 to_be_copied.each do |name|
692 694 send "copy_#{name}", project
693 695 end
694 696 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
695 697 save
696 698 end
697 699 end
698 700 end
699 701
700 702 # Returns a new unsaved Project instance with attributes copied from +project+
701 703 def self.copy_from(project)
702 704 project = project.is_a?(Project) ? project : Project.find(project)
703 705 # clear unique attributes
704 706 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
705 707 copy = Project.new(attributes)
706 708 copy.enabled_modules = project.enabled_modules
707 709 copy.trackers = project.trackers
708 710 copy.custom_values = project.custom_values.collect {|v| v.clone}
709 711 copy.issue_custom_fields = project.issue_custom_fields
710 712 copy
711 713 end
712 714
713 715 # Yields the given block for each project with its level in the tree
714 716 def self.project_tree(projects, &block)
715 717 ancestors = []
716 718 projects.sort_by(&:lft).each do |project|
717 719 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
718 720 ancestors.pop
719 721 end
720 722 yield project, ancestors.size
721 723 ancestors << project
722 724 end
723 725 end
724 726
725 727 private
726 728
727 729 # Copies wiki from +project+
728 730 def copy_wiki(project)
729 731 # Check that the source project has a wiki first
730 732 unless project.wiki.nil?
731 733 self.wiki ||= Wiki.new
732 734 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
733 735 wiki_pages_map = {}
734 736 project.wiki.pages.each do |page|
735 737 # Skip pages without content
736 738 next if page.content.nil?
737 739 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
738 740 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
739 741 new_wiki_page.content = new_wiki_content
740 742 wiki.pages << new_wiki_page
741 743 wiki_pages_map[page.id] = new_wiki_page
742 744 end
743 745 wiki.save
744 746 # Reproduce page hierarchy
745 747 project.wiki.pages.each do |page|
746 748 if page.parent_id && wiki_pages_map[page.id]
747 749 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
748 750 wiki_pages_map[page.id].save
749 751 end
750 752 end
751 753 end
752 754 end
753 755
754 756 # Copies versions from +project+
755 757 def copy_versions(project)
756 758 project.versions.each do |version|
757 759 new_version = Version.new
758 760 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
759 761 self.versions << new_version
760 762 end
761 763 end
762 764
763 765 # Copies issue categories from +project+
764 766 def copy_issue_categories(project)
765 767 project.issue_categories.each do |issue_category|
766 768 new_issue_category = IssueCategory.new
767 769 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
768 770 self.issue_categories << new_issue_category
769 771 end
770 772 end
771 773
772 774 # Copies issues from +project+
773 775 def copy_issues(project)
774 776 # Stores the source issue id as a key and the copied issues as the
775 777 # value. Used to map the two togeather for issue relations.
776 778 issues_map = {}
777 779
778 780 # Store status and reopen locked/closed versions
779 781 version_statuses = versions.reject(&:open?).map {|version| [version, version.status]}
780 782 version_statuses.each do |version, status|
781 783 version.update_attribute :status, 'open'
782 784 end
783 785
784 786 # Get issues sorted by root_id, lft so that parent issues
785 787 # get copied before their children
786 788 project.issues.reorder('root_id, lft').all.each do |issue|
787 789 new_issue = Issue.new
788 790 new_issue.copy_from(issue, :subtasks => false, :link => false)
789 791 new_issue.project = self
790 792 # Reassign fixed_versions by name, since names are unique per project
791 793 if issue.fixed_version && issue.fixed_version.project == project
792 794 new_issue.fixed_version = self.versions.detect {|v| v.name == issue.fixed_version.name}
793 795 end
794 796 # Reassign the category by name, since names are unique per project
795 797 if issue.category
796 798 new_issue.category = self.issue_categories.detect {|c| c.name == issue.category.name}
797 799 end
798 800 # Parent issue
799 801 if issue.parent_id
800 802 if copied_parent = issues_map[issue.parent_id]
801 803 new_issue.parent_issue_id = copied_parent.id
802 804 end
803 805 end
804 806
805 807 self.issues << new_issue
806 808 if new_issue.new_record?
807 809 logger.info "Project#copy_issues: issue ##{issue.id} could not be copied: #{new_issue.errors.full_messages}" if logger && logger.info
808 810 else
809 811 issues_map[issue.id] = new_issue unless new_issue.new_record?
810 812 end
811 813 end
812 814
813 815 # Restore locked/closed version statuses
814 816 version_statuses.each do |version, status|
815 817 version.update_attribute :status, status
816 818 end
817 819
818 820 # Relations after in case issues related each other
819 821 project.issues.each do |issue|
820 822 new_issue = issues_map[issue.id]
821 823 unless new_issue
822 824 # Issue was not copied
823 825 next
824 826 end
825 827
826 828 # Relations
827 829 issue.relations_from.each do |source_relation|
828 830 new_issue_relation = IssueRelation.new
829 831 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
830 832 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
831 833 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
832 834 new_issue_relation.issue_to = source_relation.issue_to
833 835 end
834 836 new_issue.relations_from << new_issue_relation
835 837 end
836 838
837 839 issue.relations_to.each do |source_relation|
838 840 new_issue_relation = IssueRelation.new
839 841 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
840 842 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
841 843 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
842 844 new_issue_relation.issue_from = source_relation.issue_from
843 845 end
844 846 new_issue.relations_to << new_issue_relation
845 847 end
846 848 end
847 849 end
848 850
849 851 # Copies members from +project+
850 852 def copy_members(project)
851 853 # Copy users first, then groups to handle members with inherited and given roles
852 854 members_to_copy = []
853 855 members_to_copy += project.memberships.select {|m| m.principal.is_a?(User)}
854 856 members_to_copy += project.memberships.select {|m| !m.principal.is_a?(User)}
855 857
856 858 members_to_copy.each do |member|
857 859 new_member = Member.new
858 860 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
859 861 # only copy non inherited roles
860 862 # inherited roles will be added when copying the group membership
861 863 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
862 864 next if role_ids.empty?
863 865 new_member.role_ids = role_ids
864 866 new_member.project = self
865 867 self.members << new_member
866 868 end
867 869 end
868 870
869 871 # Copies queries from +project+
870 872 def copy_queries(project)
871 873 project.queries.each do |query|
872 874 new_query = IssueQuery.new
873 875 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria")
874 876 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
875 877 new_query.project = self
876 878 new_query.user_id = query.user_id
877 879 self.queries << new_query
878 880 end
879 881 end
880 882
881 883 # Copies boards from +project+
882 884 def copy_boards(project)
883 885 project.boards.each do |board|
884 886 new_board = Board.new
885 887 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
886 888 new_board.project = self
887 889 self.boards << new_board
888 890 end
889 891 end
890 892
891 893 def allowed_permissions
892 894 @allowed_permissions ||= begin
893 895 module_names = enabled_modules.all(:select => :name).collect {|m| m.name}
894 896 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
895 897 end
896 898 end
897 899
898 900 def allowed_actions
899 901 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
900 902 end
901 903
902 904 # Returns all the active Systemwide and project specific activities
903 905 def active_activities
904 906 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
905 907
906 908 if overridden_activity_ids.empty?
907 909 return TimeEntryActivity.shared.active
908 910 else
909 911 return system_activities_and_project_overrides
910 912 end
911 913 end
912 914
913 915 # Returns all the Systemwide and project specific activities
914 916 # (inactive and active)
915 917 def all_activities
916 918 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
917 919
918 920 if overridden_activity_ids.empty?
919 921 return TimeEntryActivity.shared
920 922 else
921 923 return system_activities_and_project_overrides(true)
922 924 end
923 925 end
924 926
925 927 # Returns the systemwide active activities merged with the project specific overrides
926 928 def system_activities_and_project_overrides(include_inactive=false)
927 929 if include_inactive
928 930 return TimeEntryActivity.shared.
929 931 where("id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)).all +
930 932 self.time_entry_activities
931 933 else
932 934 return TimeEntryActivity.shared.active.
933 935 where("id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)).all +
934 936 self.time_entry_activities.active
935 937 end
936 938 end
937 939
938 940 # Archives subprojects recursively
939 941 def archive!
940 942 children.each do |subproject|
941 943 subproject.send :archive!
942 944 end
943 945 update_attribute :status, STATUS_ARCHIVED
944 946 end
945 947
946 948 def update_position_under_parent
947 949 set_or_update_position_under(parent)
948 950 end
949 951
950 952 # Inserts/moves the project so that target's children or root projects stay alphabetically sorted
951 953 def set_or_update_position_under(target_parent)
952 954 sibs = (target_parent.nil? ? self.class.roots : target_parent.children)
953 955 to_be_inserted_before = sibs.sort_by {|c| c.name.to_s.downcase}.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
954 956
955 957 if to_be_inserted_before
956 958 move_to_left_of(to_be_inserted_before)
957 959 elsif target_parent.nil?
958 960 if sibs.empty?
959 961 # move_to_root adds the project in first (ie. left) position
960 962 move_to_root
961 963 else
962 964 move_to_right_of(sibs.last) unless self == sibs.last
963 965 end
964 966 else
965 967 # move_to_child_of adds the project in last (ie.right) position
966 968 move_to_child_of(target_parent)
967 969 end
968 970 end
969 971 end
@@ -1,1947 +1,1957
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 :groups_users,
23 23 :trackers, :projects_trackers,
24 24 :enabled_modules,
25 25 :versions,
26 26 :issue_statuses, :issue_categories, :issue_relations, :workflows,
27 27 :enumerations,
28 28 :issues, :journals, :journal_details,
29 29 :custom_fields, :custom_fields_projects, :custom_fields_trackers, :custom_values,
30 30 :time_entries
31 31
32 32 include Redmine::I18n
33 33
34 34 def teardown
35 35 User.current = nil
36 36 end
37 37
38 38 def test_create
39 39 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3,
40 40 :status_id => 1, :priority => IssuePriority.all.first,
41 41 :subject => 'test_create',
42 42 :description => 'IssueTest#test_create', :estimated_hours => '1:30')
43 43 assert issue.save
44 44 issue.reload
45 45 assert_equal 1.5, issue.estimated_hours
46 46 end
47 47
48 48 def test_create_minimal
49 49 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3,
50 50 :status_id => 1, :priority => IssuePriority.all.first,
51 51 :subject => 'test_create')
52 52 assert issue.save
53 53 assert issue.description.nil?
54 54 assert_nil issue.estimated_hours
55 55 end
56 56
57 57 def test_start_date_format_should_be_validated
58 58 set_language_if_valid 'en'
59 59 ['2012', 'ABC', '2012-15-20'].each do |invalid_date|
60 60 issue = Issue.new(:start_date => invalid_date)
61 61 assert !issue.valid?
62 62 assert_include 'Start date is not a valid date', issue.errors.full_messages, "No error found for invalid date #{invalid_date}"
63 63 end
64 64 end
65 65
66 66 def test_due_date_format_should_be_validated
67 67 set_language_if_valid 'en'
68 68 ['2012', 'ABC', '2012-15-20'].each do |invalid_date|
69 69 issue = Issue.new(:due_date => invalid_date)
70 70 assert !issue.valid?
71 71 assert_include 'Due date is not a valid date', issue.errors.full_messages, "No error found for invalid date #{invalid_date}"
72 72 end
73 73 end
74 74
75 75 def test_due_date_lesser_than_start_date_should_not_validate
76 76 set_language_if_valid 'en'
77 77 issue = Issue.new(:start_date => '2012-10-06', :due_date => '2012-10-02')
78 78 assert !issue.valid?
79 79 assert_include 'Due date must be greater than start date', issue.errors.full_messages
80 80 end
81 81
82 82 def test_estimated_hours_should_be_validated
83 83 set_language_if_valid 'en'
84 84 ['-2'].each do |invalid|
85 85 issue = Issue.new(:estimated_hours => invalid)
86 86 assert !issue.valid?
87 87 assert_include 'Estimated time is invalid', issue.errors.full_messages
88 88 end
89 89 end
90 90
91 91 def test_create_with_required_custom_field
92 92 set_language_if_valid 'en'
93 93 field = IssueCustomField.find_by_name('Database')
94 94 field.update_attribute(:is_required, true)
95 95
96 96 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1,
97 97 :status_id => 1, :subject => 'test_create',
98 98 :description => 'IssueTest#test_create_with_required_custom_field')
99 99 assert issue.available_custom_fields.include?(field)
100 100 # No value for the custom field
101 101 assert !issue.save
102 102 assert_equal ["Database can't be blank"], issue.errors.full_messages
103 103 # Blank value
104 104 issue.custom_field_values = { field.id => '' }
105 105 assert !issue.save
106 106 assert_equal ["Database can't be blank"], issue.errors.full_messages
107 107 # Invalid value
108 108 issue.custom_field_values = { field.id => 'SQLServer' }
109 109 assert !issue.save
110 110 assert_equal ["Database is not included in the list"], issue.errors.full_messages
111 111 # Valid value
112 112 issue.custom_field_values = { field.id => 'PostgreSQL' }
113 113 assert issue.save
114 114 issue.reload
115 115 assert_equal 'PostgreSQL', issue.custom_value_for(field).value
116 116 end
117 117
118 118 def test_create_with_group_assignment
119 119 with_settings :issue_group_assignment => '1' do
120 120 assert Issue.new(:project_id => 2, :tracker_id => 1, :author_id => 1,
121 121 :subject => 'Group assignment',
122 122 :assigned_to_id => 11).save
123 123 issue = Issue.first(:order => 'id DESC')
124 124 assert_kind_of Group, issue.assigned_to
125 125 assert_equal Group.find(11), issue.assigned_to
126 126 end
127 127 end
128 128
129 129 def test_create_with_parent_issue_id
130 130 issue = Issue.new(:project_id => 1, :tracker_id => 1,
131 131 :author_id => 1, :subject => 'Group assignment',
132 132 :parent_issue_id => 1)
133 133 assert_save issue
134 134 assert_equal 1, issue.parent_issue_id
135 135 assert_equal Issue.find(1), issue.parent
136 136 end
137 137
138 138 def test_create_with_sharp_parent_issue_id
139 139 issue = Issue.new(:project_id => 1, :tracker_id => 1,
140 140 :author_id => 1, :subject => 'Group assignment',
141 141 :parent_issue_id => "#1")
142 142 assert_save issue
143 143 assert_equal 1, issue.parent_issue_id
144 144 assert_equal Issue.find(1), issue.parent
145 145 end
146 146
147 147 def test_create_with_invalid_parent_issue_id
148 148 set_language_if_valid 'en'
149 149 issue = Issue.new(:project_id => 1, :tracker_id => 1,
150 150 :author_id => 1, :subject => 'Group assignment',
151 151 :parent_issue_id => '01ABC')
152 152 assert !issue.save
153 153 assert_equal '01ABC', issue.parent_issue_id
154 154 assert_include 'Parent task is invalid', issue.errors.full_messages
155 155 end
156 156
157 157 def test_create_with_invalid_sharp_parent_issue_id
158 158 set_language_if_valid 'en'
159 159 issue = Issue.new(:project_id => 1, :tracker_id => 1,
160 160 :author_id => 1, :subject => 'Group assignment',
161 161 :parent_issue_id => '#01ABC')
162 162 assert !issue.save
163 163 assert_equal '#01ABC', issue.parent_issue_id
164 164 assert_include 'Parent task is invalid', issue.errors.full_messages
165 165 end
166 166
167 167 def assert_visibility_match(user, issues)
168 168 assert_equal issues.collect(&:id).sort, Issue.all.select {|issue| issue.visible?(user)}.collect(&:id).sort
169 169 end
170 170
171 171 def test_visible_scope_for_anonymous
172 172 # Anonymous user should see issues of public projects only
173 173 issues = Issue.visible(User.anonymous).all
174 174 assert issues.any?
175 175 assert_nil issues.detect {|issue| !issue.project.is_public?}
176 176 assert_nil issues.detect {|issue| issue.is_private?}
177 177 assert_visibility_match User.anonymous, issues
178 178 end
179 179
180 180 def test_visible_scope_for_anonymous_without_view_issues_permissions
181 181 # Anonymous user should not see issues without permission
182 182 Role.anonymous.remove_permission!(:view_issues)
183 183 issues = Issue.visible(User.anonymous).all
184 184 assert issues.empty?
185 185 assert_visibility_match User.anonymous, issues
186 186 end
187 187
188 188 def test_anonymous_should_not_see_private_issues_with_issues_visibility_set_to_default
189 189 assert Role.anonymous.update_attribute(:issues_visibility, 'default')
190 190 issue = Issue.generate!(:author => User.anonymous, :assigned_to => User.anonymous, :is_private => true)
191 191 assert_nil Issue.where(:id => issue.id).visible(User.anonymous).first
192 192 assert !issue.visible?(User.anonymous)
193 193 end
194 194
195 195 def test_anonymous_should_not_see_private_issues_with_issues_visibility_set_to_own
196 196 assert Role.anonymous.update_attribute(:issues_visibility, 'own')
197 197 issue = Issue.generate!(:author => User.anonymous, :assigned_to => User.anonymous, :is_private => true)
198 198 assert_nil Issue.where(:id => issue.id).visible(User.anonymous).first
199 199 assert !issue.visible?(User.anonymous)
200 200 end
201 201
202 202 def test_visible_scope_for_non_member
203 203 user = User.find(9)
204 204 assert user.projects.empty?
205 205 # Non member user should see issues of public projects only
206 206 issues = Issue.visible(user).all
207 207 assert issues.any?
208 208 assert_nil issues.detect {|issue| !issue.project.is_public?}
209 209 assert_nil issues.detect {|issue| issue.is_private?}
210 210 assert_visibility_match user, issues
211 211 end
212 212
213 213 def test_visible_scope_for_non_member_with_own_issues_visibility
214 214 Role.non_member.update_attribute :issues_visibility, 'own'
215 215 Issue.create!(:project_id => 1, :tracker_id => 1, :author_id => 9, :subject => 'Issue by non member')
216 216 user = User.find(9)
217 217
218 218 issues = Issue.visible(user).all
219 219 assert issues.any?
220 220 assert_nil issues.detect {|issue| issue.author != user}
221 221 assert_visibility_match user, issues
222 222 end
223 223
224 224 def test_visible_scope_for_non_member_without_view_issues_permissions
225 225 # Non member user should not see issues without permission
226 226 Role.non_member.remove_permission!(:view_issues)
227 227 user = User.find(9)
228 228 assert user.projects.empty?
229 229 issues = Issue.visible(user).all
230 230 assert issues.empty?
231 231 assert_visibility_match user, issues
232 232 end
233 233
234 234 def test_visible_scope_for_member
235 235 user = User.find(9)
236 236 # User should see issues of projects for which he has view_issues permissions only
237 237 Role.non_member.remove_permission!(:view_issues)
238 238 Member.create!(:principal => user, :project_id => 3, :role_ids => [2])
239 239 issues = Issue.visible(user).all
240 240 assert issues.any?
241 241 assert_nil issues.detect {|issue| issue.project_id != 3}
242 242 assert_nil issues.detect {|issue| issue.is_private?}
243 243 assert_visibility_match user, issues
244 244 end
245 245
246 246 def test_visible_scope_for_member_with_groups_should_return_assigned_issues
247 247 user = User.find(8)
248 248 assert user.groups.any?
249 249 Member.create!(:principal => user.groups.first, :project_id => 1, :role_ids => [2])
250 250 Role.non_member.remove_permission!(:view_issues)
251 251
252 252 issue = Issue.create(:project_id => 1, :tracker_id => 1, :author_id => 3,
253 253 :status_id => 1, :priority => IssuePriority.all.first,
254 254 :subject => 'Assignment test',
255 255 :assigned_to => user.groups.first,
256 256 :is_private => true)
257 257
258 258 Role.find(2).update_attribute :issues_visibility, 'default'
259 259 issues = Issue.visible(User.find(8)).all
260 260 assert issues.any?
261 261 assert issues.include?(issue)
262 262
263 263 Role.find(2).update_attribute :issues_visibility, 'own'
264 264 issues = Issue.visible(User.find(8)).all
265 265 assert issues.any?
266 266 assert issues.include?(issue)
267 267 end
268 268
269 269 def test_visible_scope_for_admin
270 270 user = User.find(1)
271 271 user.members.each(&:destroy)
272 272 assert user.projects.empty?
273 273 issues = Issue.visible(user).all
274 274 assert issues.any?
275 275 # Admin should see issues on private projects that he does not belong to
276 276 assert issues.detect {|issue| !issue.project.is_public?}
277 277 # Admin should see private issues of other users
278 278 assert issues.detect {|issue| issue.is_private? && issue.author != user}
279 279 assert_visibility_match user, issues
280 280 end
281 281
282 282 def test_visible_scope_with_project
283 283 project = Project.find(1)
284 284 issues = Issue.visible(User.find(2), :project => project).all
285 285 projects = issues.collect(&:project).uniq
286 286 assert_equal 1, projects.size
287 287 assert_equal project, projects.first
288 288 end
289 289
290 290 def test_visible_scope_with_project_and_subprojects
291 291 project = Project.find(1)
292 292 issues = Issue.visible(User.find(2), :project => project, :with_subprojects => true).all
293 293 projects = issues.collect(&:project).uniq
294 294 assert projects.size > 1
295 295 assert_equal [], projects.select {|p| !p.is_or_is_descendant_of?(project)}
296 296 end
297 297
298 298 def test_visible_and_nested_set_scopes
299 299 assert_equal 0, Issue.find(1).descendants.visible.all.size
300 300 end
301 301
302 302 def test_open_scope
303 303 issues = Issue.open.all
304 304 assert_nil issues.detect(&:closed?)
305 305 end
306 306
307 307 def test_open_scope_with_arg
308 308 issues = Issue.open(false).all
309 309 assert_equal issues, issues.select(&:closed?)
310 310 end
311 311
312 def test_fixed_version_scope_with_a_version_should_return_its_fixed_issues
313 version = Version.find(2)
314 assert version.fixed_issues.any?
315 assert_equal version.fixed_issues.to_a.sort, Issue.fixed_version(version).to_a.sort
316 end
317
318 def test_fixed_version_scope_with_empty_array_should_return_no_result
319 assert_equal 0, Issue.fixed_version([]).count
320 end
321
312 322 def test_errors_full_messages_should_include_custom_fields_errors
313 323 field = IssueCustomField.find_by_name('Database')
314 324
315 325 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1,
316 326 :status_id => 1, :subject => 'test_create',
317 327 :description => 'IssueTest#test_create_with_required_custom_field')
318 328 assert issue.available_custom_fields.include?(field)
319 329 # Invalid value
320 330 issue.custom_field_values = { field.id => 'SQLServer' }
321 331
322 332 assert !issue.valid?
323 333 assert_equal 1, issue.errors.full_messages.size
324 334 assert_equal "Database #{I18n.translate('activerecord.errors.messages.inclusion')}",
325 335 issue.errors.full_messages.first
326 336 end
327 337
328 338 def test_update_issue_with_required_custom_field
329 339 field = IssueCustomField.find_by_name('Database')
330 340 field.update_attribute(:is_required, true)
331 341
332 342 issue = Issue.find(1)
333 343 assert_nil issue.custom_value_for(field)
334 344 assert issue.available_custom_fields.include?(field)
335 345 # No change to custom values, issue can be saved
336 346 assert issue.save
337 347 # Blank value
338 348 issue.custom_field_values = { field.id => '' }
339 349 assert !issue.save
340 350 # Valid value
341 351 issue.custom_field_values = { field.id => 'PostgreSQL' }
342 352 assert issue.save
343 353 issue.reload
344 354 assert_equal 'PostgreSQL', issue.custom_value_for(field).value
345 355 end
346 356
347 357 def test_should_not_update_attributes_if_custom_fields_validation_fails
348 358 issue = Issue.find(1)
349 359 field = IssueCustomField.find_by_name('Database')
350 360 assert issue.available_custom_fields.include?(field)
351 361
352 362 issue.custom_field_values = { field.id => 'Invalid' }
353 363 issue.subject = 'Should be not be saved'
354 364 assert !issue.save
355 365
356 366 issue.reload
357 367 assert_equal "Can't print recipes", issue.subject
358 368 end
359 369
360 370 def test_should_not_recreate_custom_values_objects_on_update
361 371 field = IssueCustomField.find_by_name('Database')
362 372
363 373 issue = Issue.find(1)
364 374 issue.custom_field_values = { field.id => 'PostgreSQL' }
365 375 assert issue.save
366 376 custom_value = issue.custom_value_for(field)
367 377 issue.reload
368 378 issue.custom_field_values = { field.id => 'MySQL' }
369 379 assert issue.save
370 380 issue.reload
371 381 assert_equal custom_value.id, issue.custom_value_for(field).id
372 382 end
373 383
374 384 def test_should_not_update_custom_fields_on_changing_tracker_with_different_custom_fields
375 385 issue = Issue.create!(:project_id => 1, :tracker_id => 1, :author_id => 1,
376 386 :status_id => 1, :subject => 'Test',
377 387 :custom_field_values => {'2' => 'Test'})
378 388 assert !Tracker.find(2).custom_field_ids.include?(2)
379 389
380 390 issue = Issue.find(issue.id)
381 391 issue.attributes = {:tracker_id => 2, :custom_field_values => {'1' => ''}}
382 392
383 393 issue = Issue.find(issue.id)
384 394 custom_value = issue.custom_value_for(2)
385 395 assert_not_nil custom_value
386 396 assert_equal 'Test', custom_value.value
387 397 end
388 398
389 399 def test_assigning_tracker_id_should_reload_custom_fields_values
390 400 issue = Issue.new(:project => Project.find(1))
391 401 assert issue.custom_field_values.empty?
392 402 issue.tracker_id = 1
393 403 assert issue.custom_field_values.any?
394 404 end
395 405
396 406 def test_assigning_attributes_should_assign_project_and_tracker_first
397 407 seq = sequence('seq')
398 408 issue = Issue.new
399 409 issue.expects(:project_id=).in_sequence(seq)
400 410 issue.expects(:tracker_id=).in_sequence(seq)
401 411 issue.expects(:subject=).in_sequence(seq)
402 412 issue.attributes = {:tracker_id => 2, :project_id => 1, :subject => 'Test'}
403 413 end
404 414
405 415 def test_assigning_tracker_and_custom_fields_should_assign_custom_fields
406 416 attributes = ActiveSupport::OrderedHash.new
407 417 attributes['custom_field_values'] = { '1' => 'MySQL' }
408 418 attributes['tracker_id'] = '1'
409 419 issue = Issue.new(:project => Project.find(1))
410 420 issue.attributes = attributes
411 421 assert_equal 'MySQL', issue.custom_field_value(1)
412 422 end
413 423
414 424 def test_should_update_issue_with_disabled_tracker
415 425 p = Project.find(1)
416 426 issue = Issue.find(1)
417 427
418 428 p.trackers.delete(issue.tracker)
419 429 assert !p.trackers.include?(issue.tracker)
420 430
421 431 issue.reload
422 432 issue.subject = 'New subject'
423 433 assert issue.save
424 434 end
425 435
426 436 def test_should_not_set_a_disabled_tracker
427 437 p = Project.find(1)
428 438 p.trackers.delete(Tracker.find(2))
429 439
430 440 issue = Issue.find(1)
431 441 issue.tracker_id = 2
432 442 issue.subject = 'New subject'
433 443 assert !issue.save
434 444 assert_not_nil issue.errors[:tracker_id]
435 445 end
436 446
437 447 def test_category_based_assignment
438 448 issue = Issue.create(:project_id => 1, :tracker_id => 1, :author_id => 3,
439 449 :status_id => 1, :priority => IssuePriority.all.first,
440 450 :subject => 'Assignment test',
441 451 :description => 'Assignment test', :category_id => 1)
442 452 assert_equal IssueCategory.find(1).assigned_to, issue.assigned_to
443 453 end
444 454
445 455 def test_new_statuses_allowed_to
446 456 WorkflowTransition.delete_all
447 457 WorkflowTransition.create!(:role_id => 1, :tracker_id => 1,
448 458 :old_status_id => 1, :new_status_id => 2,
449 459 :author => false, :assignee => false)
450 460 WorkflowTransition.create!(:role_id => 1, :tracker_id => 1,
451 461 :old_status_id => 1, :new_status_id => 3,
452 462 :author => true, :assignee => false)
453 463 WorkflowTransition.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 1,
454 464 :new_status_id => 4, :author => false,
455 465 :assignee => true)
456 466 WorkflowTransition.create!(:role_id => 1, :tracker_id => 1,
457 467 :old_status_id => 1, :new_status_id => 5,
458 468 :author => true, :assignee => true)
459 469 status = IssueStatus.find(1)
460 470 role = Role.find(1)
461 471 tracker = Tracker.find(1)
462 472 user = User.find(2)
463 473
464 474 issue = Issue.generate!(:tracker => tracker, :status => status,
465 475 :project_id => 1, :author_id => 1)
466 476 assert_equal [1, 2], issue.new_statuses_allowed_to(user).map(&:id)
467 477
468 478 issue = Issue.generate!(:tracker => tracker, :status => status,
469 479 :project_id => 1, :author => user)
470 480 assert_equal [1, 2, 3, 5], issue.new_statuses_allowed_to(user).map(&:id)
471 481
472 482 issue = Issue.generate!(:tracker => tracker, :status => status,
473 483 :project_id => 1, :author_id => 1,
474 484 :assigned_to => user)
475 485 assert_equal [1, 2, 4, 5], issue.new_statuses_allowed_to(user).map(&:id)
476 486
477 487 issue = Issue.generate!(:tracker => tracker, :status => status,
478 488 :project_id => 1, :author => user,
479 489 :assigned_to => user)
480 490 assert_equal [1, 2, 3, 4, 5], issue.new_statuses_allowed_to(user).map(&:id)
481 491 end
482 492
483 493 def test_new_statuses_allowed_to_should_return_all_transitions_for_admin
484 494 admin = User.find(1)
485 495 issue = Issue.find(1)
486 496 assert !admin.member_of?(issue.project)
487 497 expected_statuses = [issue.status] +
488 498 WorkflowTransition.find_all_by_old_status_id(
489 499 issue.status_id).map(&:new_status).uniq.sort
490 500 assert_equal expected_statuses, issue.new_statuses_allowed_to(admin)
491 501 end
492 502
493 503 def test_new_statuses_allowed_to_should_return_default_and_current_status_when_copying
494 504 issue = Issue.find(1).copy
495 505 assert_equal [1], issue.new_statuses_allowed_to(User.find(2)).map(&:id)
496 506
497 507 issue = Issue.find(2).copy
498 508 assert_equal [1, 2], issue.new_statuses_allowed_to(User.find(2)).map(&:id)
499 509 end
500 510
501 511 def test_safe_attributes_names_should_not_include_disabled_field
502 512 tracker = Tracker.new(:core_fields => %w(assigned_to_id fixed_version_id))
503 513
504 514 issue = Issue.new(:tracker => tracker)
505 515 assert_include 'tracker_id', issue.safe_attribute_names
506 516 assert_include 'status_id', issue.safe_attribute_names
507 517 assert_include 'subject', issue.safe_attribute_names
508 518 assert_include 'description', issue.safe_attribute_names
509 519 assert_include 'custom_field_values', issue.safe_attribute_names
510 520 assert_include 'custom_fields', issue.safe_attribute_names
511 521 assert_include 'lock_version', issue.safe_attribute_names
512 522
513 523 tracker.core_fields.each do |field|
514 524 assert_include field, issue.safe_attribute_names
515 525 end
516 526
517 527 tracker.disabled_core_fields.each do |field|
518 528 assert_not_include field, issue.safe_attribute_names
519 529 end
520 530 end
521 531
522 532 def test_safe_attributes_should_ignore_disabled_fields
523 533 tracker = Tracker.find(1)
524 534 tracker.core_fields = %w(assigned_to_id due_date)
525 535 tracker.save!
526 536
527 537 issue = Issue.new(:tracker => tracker)
528 538 issue.safe_attributes = {'start_date' => '2012-07-14', 'due_date' => '2012-07-14'}
529 539 assert_nil issue.start_date
530 540 assert_equal Date.parse('2012-07-14'), issue.due_date
531 541 end
532 542
533 543 def test_safe_attributes_should_accept_target_tracker_enabled_fields
534 544 source = Tracker.find(1)
535 545 source.core_fields = []
536 546 source.save!
537 547 target = Tracker.find(2)
538 548 target.core_fields = %w(assigned_to_id due_date)
539 549 target.save!
540 550
541 551 issue = Issue.new(:tracker => source)
542 552 issue.safe_attributes = {'tracker_id' => 2, 'due_date' => '2012-07-14'}
543 553 assert_equal target, issue.tracker
544 554 assert_equal Date.parse('2012-07-14'), issue.due_date
545 555 end
546 556
547 557 def test_safe_attributes_should_not_include_readonly_fields
548 558 WorkflowPermission.delete_all
549 559 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
550 560 :role_id => 1, :field_name => 'due_date',
551 561 :rule => 'readonly')
552 562 user = User.find(2)
553 563
554 564 issue = Issue.new(:project_id => 1, :tracker_id => 1)
555 565 assert_equal %w(due_date), issue.read_only_attribute_names(user)
556 566 assert_not_include 'due_date', issue.safe_attribute_names(user)
557 567
558 568 issue.send :safe_attributes=, {'start_date' => '2012-07-14', 'due_date' => '2012-07-14'}, user
559 569 assert_equal Date.parse('2012-07-14'), issue.start_date
560 570 assert_nil issue.due_date
561 571 end
562 572
563 573 def test_safe_attributes_should_not_include_readonly_custom_fields
564 574 cf1 = IssueCustomField.create!(:name => 'Writable field',
565 575 :field_format => 'string',
566 576 :is_for_all => true, :tracker_ids => [1])
567 577 cf2 = IssueCustomField.create!(:name => 'Readonly field',
568 578 :field_format => 'string',
569 579 :is_for_all => true, :tracker_ids => [1])
570 580 WorkflowPermission.delete_all
571 581 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
572 582 :role_id => 1, :field_name => cf2.id.to_s,
573 583 :rule => 'readonly')
574 584 user = User.find(2)
575 585 issue = Issue.new(:project_id => 1, :tracker_id => 1)
576 586 assert_equal [cf2.id.to_s], issue.read_only_attribute_names(user)
577 587 assert_not_include cf2.id.to_s, issue.safe_attribute_names(user)
578 588
579 589 issue.send :safe_attributes=, {'custom_field_values' => {
580 590 cf1.id.to_s => 'value1', cf2.id.to_s => 'value2'
581 591 }}, user
582 592 assert_equal 'value1', issue.custom_field_value(cf1)
583 593 assert_nil issue.custom_field_value(cf2)
584 594
585 595 issue.send :safe_attributes=, {'custom_fields' => [
586 596 {'id' => cf1.id.to_s, 'value' => 'valuea'},
587 597 {'id' => cf2.id.to_s, 'value' => 'valueb'}
588 598 ]}, user
589 599 assert_equal 'valuea', issue.custom_field_value(cf1)
590 600 assert_nil issue.custom_field_value(cf2)
591 601 end
592 602
593 603 def test_editable_custom_field_values_should_return_non_readonly_custom_values
594 604 cf1 = IssueCustomField.create!(:name => 'Writable field', :field_format => 'string',
595 605 :is_for_all => true, :tracker_ids => [1, 2])
596 606 cf2 = IssueCustomField.create!(:name => 'Readonly field', :field_format => 'string',
597 607 :is_for_all => true, :tracker_ids => [1, 2])
598 608 WorkflowPermission.delete_all
599 609 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1, :role_id => 1,
600 610 :field_name => cf2.id.to_s, :rule => 'readonly')
601 611 user = User.find(2)
602 612
603 613 issue = Issue.new(:project_id => 1, :tracker_id => 1)
604 614 values = issue.editable_custom_field_values(user)
605 615 assert values.detect {|value| value.custom_field == cf1}
606 616 assert_nil values.detect {|value| value.custom_field == cf2}
607 617
608 618 issue.tracker_id = 2
609 619 values = issue.editable_custom_field_values(user)
610 620 assert values.detect {|value| value.custom_field == cf1}
611 621 assert values.detect {|value| value.custom_field == cf2}
612 622 end
613 623
614 624 def test_safe_attributes_should_accept_target_tracker_writable_fields
615 625 WorkflowPermission.delete_all
616 626 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
617 627 :role_id => 1, :field_name => 'due_date',
618 628 :rule => 'readonly')
619 629 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 2,
620 630 :role_id => 1, :field_name => 'start_date',
621 631 :rule => 'readonly')
622 632 user = User.find(2)
623 633
624 634 issue = Issue.new(:project_id => 1, :tracker_id => 1, :status_id => 1)
625 635
626 636 issue.send :safe_attributes=, {'start_date' => '2012-07-12',
627 637 'due_date' => '2012-07-14'}, user
628 638 assert_equal Date.parse('2012-07-12'), issue.start_date
629 639 assert_nil issue.due_date
630 640
631 641 issue.send :safe_attributes=, {'start_date' => '2012-07-15',
632 642 'due_date' => '2012-07-16',
633 643 'tracker_id' => 2}, user
634 644 assert_equal Date.parse('2012-07-12'), issue.start_date
635 645 assert_equal Date.parse('2012-07-16'), issue.due_date
636 646 end
637 647
638 648 def test_safe_attributes_should_accept_target_status_writable_fields
639 649 WorkflowPermission.delete_all
640 650 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
641 651 :role_id => 1, :field_name => 'due_date',
642 652 :rule => 'readonly')
643 653 WorkflowPermission.create!(:old_status_id => 2, :tracker_id => 1,
644 654 :role_id => 1, :field_name => 'start_date',
645 655 :rule => 'readonly')
646 656 user = User.find(2)
647 657
648 658 issue = Issue.new(:project_id => 1, :tracker_id => 1, :status_id => 1)
649 659
650 660 issue.send :safe_attributes=, {'start_date' => '2012-07-12',
651 661 'due_date' => '2012-07-14'},
652 662 user
653 663 assert_equal Date.parse('2012-07-12'), issue.start_date
654 664 assert_nil issue.due_date
655 665
656 666 issue.send :safe_attributes=, {'start_date' => '2012-07-15',
657 667 'due_date' => '2012-07-16',
658 668 'status_id' => 2},
659 669 user
660 670 assert_equal Date.parse('2012-07-12'), issue.start_date
661 671 assert_equal Date.parse('2012-07-16'), issue.due_date
662 672 end
663 673
664 674 def test_required_attributes_should_be_validated
665 675 cf = IssueCustomField.create!(:name => 'Foo', :field_format => 'string',
666 676 :is_for_all => true, :tracker_ids => [1, 2])
667 677
668 678 WorkflowPermission.delete_all
669 679 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
670 680 :role_id => 1, :field_name => 'due_date',
671 681 :rule => 'required')
672 682 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
673 683 :role_id => 1, :field_name => 'category_id',
674 684 :rule => 'required')
675 685 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
676 686 :role_id => 1, :field_name => cf.id.to_s,
677 687 :rule => 'required')
678 688
679 689 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 2,
680 690 :role_id => 1, :field_name => 'start_date',
681 691 :rule => 'required')
682 692 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 2,
683 693 :role_id => 1, :field_name => cf.id.to_s,
684 694 :rule => 'required')
685 695 user = User.find(2)
686 696
687 697 issue = Issue.new(:project_id => 1, :tracker_id => 1,
688 698 :status_id => 1, :subject => 'Required fields',
689 699 :author => user)
690 700 assert_equal [cf.id.to_s, "category_id", "due_date"],
691 701 issue.required_attribute_names(user).sort
692 702 assert !issue.save, "Issue was saved"
693 703 assert_equal ["Category can't be blank", "Due date can't be blank", "Foo can't be blank"],
694 704 issue.errors.full_messages.sort
695 705
696 706 issue.tracker_id = 2
697 707 assert_equal [cf.id.to_s, "start_date"], issue.required_attribute_names(user).sort
698 708 assert !issue.save, "Issue was saved"
699 709 assert_equal ["Foo can't be blank", "Start date can't be blank"],
700 710 issue.errors.full_messages.sort
701 711
702 712 issue.start_date = Date.today
703 713 issue.custom_field_values = {cf.id.to_s => 'bar'}
704 714 assert issue.save
705 715 end
706 716
707 717 def test_required_attribute_names_for_multiple_roles_should_intersect_rules
708 718 WorkflowPermission.delete_all
709 719 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
710 720 :role_id => 1, :field_name => 'due_date',
711 721 :rule => 'required')
712 722 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
713 723 :role_id => 1, :field_name => 'start_date',
714 724 :rule => 'required')
715 725 user = User.find(2)
716 726 member = Member.find(1)
717 727 issue = Issue.new(:project_id => 1, :tracker_id => 1, :status_id => 1)
718 728
719 729 assert_equal %w(due_date start_date), issue.required_attribute_names(user).sort
720 730
721 731 member.role_ids = [1, 2]
722 732 member.save!
723 733 assert_equal [], issue.required_attribute_names(user.reload)
724 734
725 735 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
726 736 :role_id => 2, :field_name => 'due_date',
727 737 :rule => 'required')
728 738 assert_equal %w(due_date), issue.required_attribute_names(user)
729 739
730 740 member.role_ids = [1, 2, 3]
731 741 member.save!
732 742 assert_equal [], issue.required_attribute_names(user.reload)
733 743
734 744 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
735 745 :role_id => 2, :field_name => 'due_date',
736 746 :rule => 'readonly')
737 747 # required + readonly => required
738 748 assert_equal %w(due_date), issue.required_attribute_names(user)
739 749 end
740 750
741 751 def test_read_only_attribute_names_for_multiple_roles_should_intersect_rules
742 752 WorkflowPermission.delete_all
743 753 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
744 754 :role_id => 1, :field_name => 'due_date',
745 755 :rule => 'readonly')
746 756 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
747 757 :role_id => 1, :field_name => 'start_date',
748 758 :rule => 'readonly')
749 759 user = User.find(2)
750 760 member = Member.find(1)
751 761 issue = Issue.new(:project_id => 1, :tracker_id => 1, :status_id => 1)
752 762
753 763 assert_equal %w(due_date start_date), issue.read_only_attribute_names(user).sort
754 764
755 765 member.role_ids = [1, 2]
756 766 member.save!
757 767 assert_equal [], issue.read_only_attribute_names(user.reload)
758 768
759 769 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1,
760 770 :role_id => 2, :field_name => 'due_date',
761 771 :rule => 'readonly')
762 772 assert_equal %w(due_date), issue.read_only_attribute_names(user)
763 773 end
764 774
765 775 def test_copy
766 776 issue = Issue.new.copy_from(1)
767 777 assert issue.copy?
768 778 assert issue.save
769 779 issue.reload
770 780 orig = Issue.find(1)
771 781 assert_equal orig.subject, issue.subject
772 782 assert_equal orig.tracker, issue.tracker
773 783 assert_equal "125", issue.custom_value_for(2).value
774 784 end
775 785
776 786 def test_copy_should_copy_status
777 787 orig = Issue.find(8)
778 788 assert orig.status != IssueStatus.default
779 789
780 790 issue = Issue.new.copy_from(orig)
781 791 assert issue.save
782 792 issue.reload
783 793 assert_equal orig.status, issue.status
784 794 end
785 795
786 796 def test_copy_should_add_relation_with_copied_issue
787 797 copied = Issue.find(1)
788 798 issue = Issue.new.copy_from(copied)
789 799 assert issue.save
790 800 issue.reload
791 801
792 802 assert_equal 1, issue.relations.size
793 803 relation = issue.relations.first
794 804 assert_equal 'copied_to', relation.relation_type
795 805 assert_equal copied, relation.issue_from
796 806 assert_equal issue, relation.issue_to
797 807 end
798 808
799 809 def test_copy_should_copy_subtasks
800 810 issue = Issue.generate_with_descendants!
801 811
802 812 copy = issue.reload.copy
803 813 copy.author = User.find(7)
804 814 assert_difference 'Issue.count', 1+issue.descendants.count do
805 815 assert copy.save
806 816 end
807 817 copy.reload
808 818 assert_equal %w(Child1 Child2), copy.children.map(&:subject).sort
809 819 child_copy = copy.children.detect {|c| c.subject == 'Child1'}
810 820 assert_equal %w(Child11), child_copy.children.map(&:subject).sort
811 821 assert_equal copy.author, child_copy.author
812 822 end
813 823
814 824 def test_copy_should_copy_subtasks_to_target_project
815 825 issue = Issue.generate_with_descendants!
816 826
817 827 copy = issue.copy(:project_id => 3)
818 828 assert_difference 'Issue.count', 1+issue.descendants.count do
819 829 assert copy.save
820 830 end
821 831 assert_equal [3], copy.reload.descendants.map(&:project_id).uniq
822 832 end
823 833
824 834 def test_copy_should_not_copy_subtasks_twice_when_saving_twice
825 835 issue = Issue.generate_with_descendants!
826 836
827 837 copy = issue.reload.copy
828 838 assert_difference 'Issue.count', 1+issue.descendants.count do
829 839 assert copy.save
830 840 assert copy.save
831 841 end
832 842 end
833 843
834 844 def test_should_not_call_after_project_change_on_creation
835 845 issue = Issue.new(:project_id => 1, :tracker_id => 1, :status_id => 1,
836 846 :subject => 'Test', :author_id => 1)
837 847 issue.expects(:after_project_change).never
838 848 issue.save!
839 849 end
840 850
841 851 def test_should_not_call_after_project_change_on_update
842 852 issue = Issue.find(1)
843 853 issue.project = Project.find(1)
844 854 issue.subject = 'No project change'
845 855 issue.expects(:after_project_change).never
846 856 issue.save!
847 857 end
848 858
849 859 def test_should_call_after_project_change_on_project_change
850 860 issue = Issue.find(1)
851 861 issue.project = Project.find(2)
852 862 issue.expects(:after_project_change).once
853 863 issue.save!
854 864 end
855 865
856 866 def test_adding_journal_should_update_timestamp
857 867 issue = Issue.find(1)
858 868 updated_on_was = issue.updated_on
859 869
860 870 issue.init_journal(User.first, "Adding notes")
861 871 assert_difference 'Journal.count' do
862 872 assert issue.save
863 873 end
864 874 issue.reload
865 875
866 876 assert_not_equal updated_on_was, issue.updated_on
867 877 end
868 878
869 879 def test_should_close_duplicates
870 880 # Create 3 issues
871 881 issue1 = Issue.generate!
872 882 issue2 = Issue.generate!
873 883 issue3 = Issue.generate!
874 884
875 885 # 2 is a dupe of 1
876 886 IssueRelation.create!(:issue_from => issue2, :issue_to => issue1,
877 887 :relation_type => IssueRelation::TYPE_DUPLICATES)
878 888 # And 3 is a dupe of 2
879 889 IssueRelation.create!(:issue_from => issue3, :issue_to => issue2,
880 890 :relation_type => IssueRelation::TYPE_DUPLICATES)
881 891 # And 3 is a dupe of 1 (circular duplicates)
882 892 IssueRelation.create!(:issue_from => issue3, :issue_to => issue1,
883 893 :relation_type => IssueRelation::TYPE_DUPLICATES)
884 894
885 895 assert issue1.reload.duplicates.include?(issue2)
886 896
887 897 # Closing issue 1
888 898 issue1.init_journal(User.first, "Closing issue1")
889 899 issue1.status = IssueStatus.where(:is_closed => true).first
890 900 assert issue1.save
891 901 # 2 and 3 should be also closed
892 902 assert issue2.reload.closed?
893 903 assert issue3.reload.closed?
894 904 end
895 905
896 906 def test_should_not_close_duplicated_issue
897 907 issue1 = Issue.generate!
898 908 issue2 = Issue.generate!
899 909
900 910 # 2 is a dupe of 1
901 911 IssueRelation.create(:issue_from => issue2, :issue_to => issue1,
902 912 :relation_type => IssueRelation::TYPE_DUPLICATES)
903 913 # 2 is a dup of 1 but 1 is not a duplicate of 2
904 914 assert !issue2.reload.duplicates.include?(issue1)
905 915
906 916 # Closing issue 2
907 917 issue2.init_journal(User.first, "Closing issue2")
908 918 issue2.status = IssueStatus.where(:is_closed => true).first
909 919 assert issue2.save
910 920 # 1 should not be also closed
911 921 assert !issue1.reload.closed?
912 922 end
913 923
914 924 def test_assignable_versions
915 925 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1,
916 926 :status_id => 1, :fixed_version_id => 1,
917 927 :subject => 'New issue')
918 928 assert_equal ['open'], issue.assignable_versions.collect(&:status).uniq
919 929 end
920 930
921 931 def test_should_not_be_able_to_assign_a_new_issue_to_a_closed_version
922 932 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1,
923 933 :status_id => 1, :fixed_version_id => 1,
924 934 :subject => 'New issue')
925 935 assert !issue.save
926 936 assert_not_nil issue.errors[:fixed_version_id]
927 937 end
928 938
929 939 def test_should_not_be_able_to_assign_a_new_issue_to_a_locked_version
930 940 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1,
931 941 :status_id => 1, :fixed_version_id => 2,
932 942 :subject => 'New issue')
933 943 assert !issue.save
934 944 assert_not_nil issue.errors[:fixed_version_id]
935 945 end
936 946
937 947 def test_should_be_able_to_assign_a_new_issue_to_an_open_version
938 948 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1,
939 949 :status_id => 1, :fixed_version_id => 3,
940 950 :subject => 'New issue')
941 951 assert issue.save
942 952 end
943 953
944 954 def test_should_be_able_to_update_an_issue_assigned_to_a_closed_version
945 955 issue = Issue.find(11)
946 956 assert_equal 'closed', issue.fixed_version.status
947 957 issue.subject = 'Subject changed'
948 958 assert issue.save
949 959 end
950 960
951 961 def test_should_not_be_able_to_reopen_an_issue_assigned_to_a_closed_version
952 962 issue = Issue.find(11)
953 963 issue.status_id = 1
954 964 assert !issue.save
955 965 assert_not_nil issue.errors[:base]
956 966 end
957 967
958 968 def test_should_be_able_to_reopen_and_reassign_an_issue_assigned_to_a_closed_version
959 969 issue = Issue.find(11)
960 970 issue.status_id = 1
961 971 issue.fixed_version_id = 3
962 972 assert issue.save
963 973 end
964 974
965 975 def test_should_be_able_to_reopen_an_issue_assigned_to_a_locked_version
966 976 issue = Issue.find(12)
967 977 assert_equal 'locked', issue.fixed_version.status
968 978 issue.status_id = 1
969 979 assert issue.save
970 980 end
971 981
972 982 def test_should_not_be_able_to_keep_unshared_version_when_changing_project
973 983 issue = Issue.find(2)
974 984 assert_equal 2, issue.fixed_version_id
975 985 issue.project_id = 3
976 986 assert_nil issue.fixed_version_id
977 987 issue.fixed_version_id = 2
978 988 assert !issue.save
979 989 assert_include 'Target version is not included in the list', issue.errors.full_messages
980 990 end
981 991
982 992 def test_should_keep_shared_version_when_changing_project
983 993 Version.find(2).update_attribute :sharing, 'tree'
984 994
985 995 issue = Issue.find(2)
986 996 assert_equal 2, issue.fixed_version_id
987 997 issue.project_id = 3
988 998 assert_equal 2, issue.fixed_version_id
989 999 assert issue.save
990 1000 end
991 1001
992 1002 def test_allowed_target_projects_on_move_should_include_projects_with_issue_tracking_enabled
993 1003 assert_include Project.find(2), Issue.allowed_target_projects_on_move(User.find(2))
994 1004 end
995 1005
996 1006 def test_allowed_target_projects_on_move_should_not_include_projects_with_issue_tracking_disabled
997 1007 Project.find(2).disable_module! :issue_tracking
998 1008 assert_not_include Project.find(2), Issue.allowed_target_projects_on_move(User.find(2))
999 1009 end
1000 1010
1001 1011 def test_move_to_another_project_with_same_category
1002 1012 issue = Issue.find(1)
1003 1013 issue.project = Project.find(2)
1004 1014 assert issue.save
1005 1015 issue.reload
1006 1016 assert_equal 2, issue.project_id
1007 1017 # Category changes
1008 1018 assert_equal 4, issue.category_id
1009 1019 # Make sure time entries were move to the target project
1010 1020 assert_equal 2, issue.time_entries.first.project_id
1011 1021 end
1012 1022
1013 1023 def test_move_to_another_project_without_same_category
1014 1024 issue = Issue.find(2)
1015 1025 issue.project = Project.find(2)
1016 1026 assert issue.save
1017 1027 issue.reload
1018 1028 assert_equal 2, issue.project_id
1019 1029 # Category cleared
1020 1030 assert_nil issue.category_id
1021 1031 end
1022 1032
1023 1033 def test_move_to_another_project_should_clear_fixed_version_when_not_shared
1024 1034 issue = Issue.find(1)
1025 1035 issue.update_attribute(:fixed_version_id, 1)
1026 1036 issue.project = Project.find(2)
1027 1037 assert issue.save
1028 1038 issue.reload
1029 1039 assert_equal 2, issue.project_id
1030 1040 # Cleared fixed_version
1031 1041 assert_equal nil, issue.fixed_version
1032 1042 end
1033 1043
1034 1044 def test_move_to_another_project_should_keep_fixed_version_when_shared_with_the_target_project
1035 1045 issue = Issue.find(1)
1036 1046 issue.update_attribute(:fixed_version_id, 4)
1037 1047 issue.project = Project.find(5)
1038 1048 assert issue.save
1039 1049 issue.reload
1040 1050 assert_equal 5, issue.project_id
1041 1051 # Keep fixed_version
1042 1052 assert_equal 4, issue.fixed_version_id
1043 1053 end
1044 1054
1045 1055 def test_move_to_another_project_should_clear_fixed_version_when_not_shared_with_the_target_project
1046 1056 issue = Issue.find(1)
1047 1057 issue.update_attribute(:fixed_version_id, 1)
1048 1058 issue.project = Project.find(5)
1049 1059 assert issue.save
1050 1060 issue.reload
1051 1061 assert_equal 5, issue.project_id
1052 1062 # Cleared fixed_version
1053 1063 assert_equal nil, issue.fixed_version
1054 1064 end
1055 1065
1056 1066 def test_move_to_another_project_should_keep_fixed_version_when_shared_systemwide
1057 1067 issue = Issue.find(1)
1058 1068 issue.update_attribute(:fixed_version_id, 7)
1059 1069 issue.project = Project.find(2)
1060 1070 assert issue.save
1061 1071 issue.reload
1062 1072 assert_equal 2, issue.project_id
1063 1073 # Keep fixed_version
1064 1074 assert_equal 7, issue.fixed_version_id
1065 1075 end
1066 1076
1067 1077 def test_move_to_another_project_should_keep_parent_if_valid
1068 1078 issue = Issue.find(1)
1069 1079 issue.update_attribute(:parent_issue_id, 2)
1070 1080 issue.project = Project.find(3)
1071 1081 assert issue.save
1072 1082 issue.reload
1073 1083 assert_equal 2, issue.parent_id
1074 1084 end
1075 1085
1076 1086 def test_move_to_another_project_should_clear_parent_if_not_valid
1077 1087 issue = Issue.find(1)
1078 1088 issue.update_attribute(:parent_issue_id, 2)
1079 1089 issue.project = Project.find(2)
1080 1090 assert issue.save
1081 1091 issue.reload
1082 1092 assert_nil issue.parent_id
1083 1093 end
1084 1094
1085 1095 def test_move_to_another_project_with_disabled_tracker
1086 1096 issue = Issue.find(1)
1087 1097 target = Project.find(2)
1088 1098 target.tracker_ids = [3]
1089 1099 target.save
1090 1100 issue.project = target
1091 1101 assert issue.save
1092 1102 issue.reload
1093 1103 assert_equal 2, issue.project_id
1094 1104 assert_equal 3, issue.tracker_id
1095 1105 end
1096 1106
1097 1107 def test_copy_to_the_same_project
1098 1108 issue = Issue.find(1)
1099 1109 copy = issue.copy
1100 1110 assert_difference 'Issue.count' do
1101 1111 copy.save!
1102 1112 end
1103 1113 assert_kind_of Issue, copy
1104 1114 assert_equal issue.project, copy.project
1105 1115 assert_equal "125", copy.custom_value_for(2).value
1106 1116 end
1107 1117
1108 1118 def test_copy_to_another_project_and_tracker
1109 1119 issue = Issue.find(1)
1110 1120 copy = issue.copy(:project_id => 3, :tracker_id => 2)
1111 1121 assert_difference 'Issue.count' do
1112 1122 copy.save!
1113 1123 end
1114 1124 copy.reload
1115 1125 assert_kind_of Issue, copy
1116 1126 assert_equal Project.find(3), copy.project
1117 1127 assert_equal Tracker.find(2), copy.tracker
1118 1128 # Custom field #2 is not associated with target tracker
1119 1129 assert_nil copy.custom_value_for(2)
1120 1130 end
1121 1131
1122 1132 context "#copy" do
1123 1133 setup do
1124 1134 @issue = Issue.find(1)
1125 1135 end
1126 1136
1127 1137 should "not create a journal" do
1128 1138 copy = @issue.copy(:project_id => 3, :tracker_id => 2, :assigned_to_id => 3)
1129 1139 copy.save!
1130 1140 assert_equal 0, copy.reload.journals.size
1131 1141 end
1132 1142
1133 1143 should "allow assigned_to changes" do
1134 1144 copy = @issue.copy(:project_id => 3, :tracker_id => 2, :assigned_to_id => 3)
1135 1145 assert_equal 3, copy.assigned_to_id
1136 1146 end
1137 1147
1138 1148 should "allow status changes" do
1139 1149 copy = @issue.copy(:project_id => 3, :tracker_id => 2, :status_id => 2)
1140 1150 assert_equal 2, copy.status_id
1141 1151 end
1142 1152
1143 1153 should "allow start date changes" do
1144 1154 date = Date.today
1145 1155 copy = @issue.copy(:project_id => 3, :tracker_id => 2, :start_date => date)
1146 1156 assert_equal date, copy.start_date
1147 1157 end
1148 1158
1149 1159 should "allow due date changes" do
1150 1160 date = Date.today
1151 1161 copy = @issue.copy(:project_id => 3, :tracker_id => 2, :due_date => date)
1152 1162 assert_equal date, copy.due_date
1153 1163 end
1154 1164
1155 1165 should "set current user as author" do
1156 1166 User.current = User.find(9)
1157 1167 copy = @issue.copy(:project_id => 3, :tracker_id => 2)
1158 1168 assert_equal User.current, copy.author
1159 1169 end
1160 1170
1161 1171 should "create a journal with notes" do
1162 1172 date = Date.today
1163 1173 notes = "Notes added when copying"
1164 1174 copy = @issue.copy(:project_id => 3, :tracker_id => 2, :start_date => date)
1165 1175 copy.init_journal(User.current, notes)
1166 1176 copy.save!
1167 1177
1168 1178 assert_equal 1, copy.journals.size
1169 1179 journal = copy.journals.first
1170 1180 assert_equal 0, journal.details.size
1171 1181 assert_equal notes, journal.notes
1172 1182 end
1173 1183 end
1174 1184
1175 1185 def test_valid_parent_project
1176 1186 issue = Issue.find(1)
1177 1187 issue_in_same_project = Issue.find(2)
1178 1188 issue_in_child_project = Issue.find(5)
1179 1189 issue_in_grandchild_project = Issue.generate!(:project_id => 6, :tracker_id => 1)
1180 1190 issue_in_other_child_project = Issue.find(6)
1181 1191 issue_in_different_tree = Issue.find(4)
1182 1192
1183 1193 with_settings :cross_project_subtasks => '' do
1184 1194 assert_equal true, issue.valid_parent_project?(issue_in_same_project)
1185 1195 assert_equal false, issue.valid_parent_project?(issue_in_child_project)
1186 1196 assert_equal false, issue.valid_parent_project?(issue_in_grandchild_project)
1187 1197 assert_equal false, issue.valid_parent_project?(issue_in_different_tree)
1188 1198 end
1189 1199
1190 1200 with_settings :cross_project_subtasks => 'system' do
1191 1201 assert_equal true, issue.valid_parent_project?(issue_in_same_project)
1192 1202 assert_equal true, issue.valid_parent_project?(issue_in_child_project)
1193 1203 assert_equal true, issue.valid_parent_project?(issue_in_different_tree)
1194 1204 end
1195 1205
1196 1206 with_settings :cross_project_subtasks => 'tree' do
1197 1207 assert_equal true, issue.valid_parent_project?(issue_in_same_project)
1198 1208 assert_equal true, issue.valid_parent_project?(issue_in_child_project)
1199 1209 assert_equal true, issue.valid_parent_project?(issue_in_grandchild_project)
1200 1210 assert_equal false, issue.valid_parent_project?(issue_in_different_tree)
1201 1211
1202 1212 assert_equal true, issue_in_child_project.valid_parent_project?(issue_in_same_project)
1203 1213 assert_equal true, issue_in_child_project.valid_parent_project?(issue_in_other_child_project)
1204 1214 end
1205 1215
1206 1216 with_settings :cross_project_subtasks => 'descendants' do
1207 1217 assert_equal true, issue.valid_parent_project?(issue_in_same_project)
1208 1218 assert_equal false, issue.valid_parent_project?(issue_in_child_project)
1209 1219 assert_equal false, issue.valid_parent_project?(issue_in_grandchild_project)
1210 1220 assert_equal false, issue.valid_parent_project?(issue_in_different_tree)
1211 1221
1212 1222 assert_equal true, issue_in_child_project.valid_parent_project?(issue)
1213 1223 assert_equal false, issue_in_child_project.valid_parent_project?(issue_in_other_child_project)
1214 1224 end
1215 1225 end
1216 1226
1217 1227 def test_recipients_should_include_previous_assignee
1218 1228 user = User.find(3)
1219 1229 user.members.update_all ["mail_notification = ?", false]
1220 1230 user.update_attribute :mail_notification, 'only_assigned'
1221 1231
1222 1232 issue = Issue.find(2)
1223 1233 issue.assigned_to = nil
1224 1234 assert_include user.mail, issue.recipients
1225 1235 issue.save!
1226 1236 assert !issue.recipients.include?(user.mail)
1227 1237 end
1228 1238
1229 1239 def test_recipients_should_not_include_users_that_cannot_view_the_issue
1230 1240 issue = Issue.find(12)
1231 1241 assert issue.recipients.include?(issue.author.mail)
1232 1242 # copy the issue to a private project
1233 1243 copy = issue.copy(:project_id => 5, :tracker_id => 2)
1234 1244 # author is not a member of project anymore
1235 1245 assert !copy.recipients.include?(copy.author.mail)
1236 1246 end
1237 1247
1238 1248 def test_recipients_should_include_the_assigned_group_members
1239 1249 group_member = User.generate!
1240 1250 group = Group.generate!
1241 1251 group.users << group_member
1242 1252
1243 1253 issue = Issue.find(12)
1244 1254 issue.assigned_to = group
1245 1255 assert issue.recipients.include?(group_member.mail)
1246 1256 end
1247 1257
1248 1258 def test_watcher_recipients_should_not_include_users_that_cannot_view_the_issue
1249 1259 user = User.find(3)
1250 1260 issue = Issue.find(9)
1251 1261 Watcher.create!(:user => user, :watchable => issue)
1252 1262 assert issue.watched_by?(user)
1253 1263 assert !issue.watcher_recipients.include?(user.mail)
1254 1264 end
1255 1265
1256 1266 def test_issue_destroy
1257 1267 Issue.find(1).destroy
1258 1268 assert_nil Issue.find_by_id(1)
1259 1269 assert_nil TimeEntry.find_by_issue_id(1)
1260 1270 end
1261 1271
1262 1272 def test_destroying_a_deleted_issue_should_not_raise_an_error
1263 1273 issue = Issue.find(1)
1264 1274 Issue.find(1).destroy
1265 1275
1266 1276 assert_nothing_raised do
1267 1277 assert_no_difference 'Issue.count' do
1268 1278 issue.destroy
1269 1279 end
1270 1280 assert issue.destroyed?
1271 1281 end
1272 1282 end
1273 1283
1274 1284 def test_destroying_a_stale_issue_should_not_raise_an_error
1275 1285 issue = Issue.find(1)
1276 1286 Issue.find(1).update_attribute :subject, "Updated"
1277 1287
1278 1288 assert_nothing_raised do
1279 1289 assert_difference 'Issue.count', -1 do
1280 1290 issue.destroy
1281 1291 end
1282 1292 assert issue.destroyed?
1283 1293 end
1284 1294 end
1285 1295
1286 1296 def test_blocked
1287 1297 blocked_issue = Issue.find(9)
1288 1298 blocking_issue = Issue.find(10)
1289 1299
1290 1300 assert blocked_issue.blocked?
1291 1301 assert !blocking_issue.blocked?
1292 1302 end
1293 1303
1294 1304 def test_blocked_issues_dont_allow_closed_statuses
1295 1305 blocked_issue = Issue.find(9)
1296 1306
1297 1307 allowed_statuses = blocked_issue.new_statuses_allowed_to(users(:users_002))
1298 1308 assert !allowed_statuses.empty?
1299 1309 closed_statuses = allowed_statuses.select {|st| st.is_closed?}
1300 1310 assert closed_statuses.empty?
1301 1311 end
1302 1312
1303 1313 def test_unblocked_issues_allow_closed_statuses
1304 1314 blocking_issue = Issue.find(10)
1305 1315
1306 1316 allowed_statuses = blocking_issue.new_statuses_allowed_to(users(:users_002))
1307 1317 assert !allowed_statuses.empty?
1308 1318 closed_statuses = allowed_statuses.select {|st| st.is_closed?}
1309 1319 assert !closed_statuses.empty?
1310 1320 end
1311 1321
1312 1322 def test_reschedule_an_issue_without_dates
1313 1323 with_settings :non_working_week_days => [] do
1314 1324 issue = Issue.new(:start_date => nil, :due_date => nil)
1315 1325 issue.reschedule_on '2012-10-09'.to_date
1316 1326 assert_equal '2012-10-09'.to_date, issue.start_date
1317 1327 assert_equal '2012-10-09'.to_date, issue.due_date
1318 1328 end
1319 1329
1320 1330 with_settings :non_working_week_days => %w(6 7) do
1321 1331 issue = Issue.new(:start_date => nil, :due_date => nil)
1322 1332 issue.reschedule_on '2012-10-09'.to_date
1323 1333 assert_equal '2012-10-09'.to_date, issue.start_date
1324 1334 assert_equal '2012-10-09'.to_date, issue.due_date
1325 1335
1326 1336 issue = Issue.new(:start_date => nil, :due_date => nil)
1327 1337 issue.reschedule_on '2012-10-13'.to_date
1328 1338 assert_equal '2012-10-15'.to_date, issue.start_date
1329 1339 assert_equal '2012-10-15'.to_date, issue.due_date
1330 1340 end
1331 1341 end
1332 1342
1333 1343 def test_reschedule_an_issue_with_start_date
1334 1344 with_settings :non_working_week_days => [] do
1335 1345 issue = Issue.new(:start_date => '2012-10-09', :due_date => nil)
1336 1346 issue.reschedule_on '2012-10-13'.to_date
1337 1347 assert_equal '2012-10-13'.to_date, issue.start_date
1338 1348 assert_equal '2012-10-13'.to_date, issue.due_date
1339 1349 end
1340 1350
1341 1351 with_settings :non_working_week_days => %w(6 7) do
1342 1352 issue = Issue.new(:start_date => '2012-10-09', :due_date => nil)
1343 1353 issue.reschedule_on '2012-10-11'.to_date
1344 1354 assert_equal '2012-10-11'.to_date, issue.start_date
1345 1355 assert_equal '2012-10-11'.to_date, issue.due_date
1346 1356
1347 1357 issue = Issue.new(:start_date => '2012-10-09', :due_date => nil)
1348 1358 issue.reschedule_on '2012-10-13'.to_date
1349 1359 assert_equal '2012-10-15'.to_date, issue.start_date
1350 1360 assert_equal '2012-10-15'.to_date, issue.due_date
1351 1361 end
1352 1362 end
1353 1363
1354 1364 def test_reschedule_an_issue_with_start_and_due_dates
1355 1365 with_settings :non_working_week_days => [] do
1356 1366 issue = Issue.new(:start_date => '2012-10-09', :due_date => '2012-10-15')
1357 1367 issue.reschedule_on '2012-10-13'.to_date
1358 1368 assert_equal '2012-10-13'.to_date, issue.start_date
1359 1369 assert_equal '2012-10-19'.to_date, issue.due_date
1360 1370 end
1361 1371
1362 1372 with_settings :non_working_week_days => %w(6 7) do
1363 1373 issue = Issue.new(:start_date => '2012-10-09', :due_date => '2012-10-19') # 8 working days
1364 1374 issue.reschedule_on '2012-10-11'.to_date
1365 1375 assert_equal '2012-10-11'.to_date, issue.start_date
1366 1376 assert_equal '2012-10-23'.to_date, issue.due_date
1367 1377
1368 1378 issue = Issue.new(:start_date => '2012-10-09', :due_date => '2012-10-19')
1369 1379 issue.reschedule_on '2012-10-13'.to_date
1370 1380 assert_equal '2012-10-15'.to_date, issue.start_date
1371 1381 assert_equal '2012-10-25'.to_date, issue.due_date
1372 1382 end
1373 1383 end
1374 1384
1375 1385 def test_rescheduling_an_issue_to_a_later_due_date_should_reschedule_following_issue
1376 1386 issue1 = Issue.generate!(:start_date => '2012-10-15', :due_date => '2012-10-17')
1377 1387 issue2 = Issue.generate!(:start_date => '2012-10-15', :due_date => '2012-10-17')
1378 1388 IssueRelation.create!(:issue_from => issue1, :issue_to => issue2,
1379 1389 :relation_type => IssueRelation::TYPE_PRECEDES)
1380 1390 assert_equal Date.parse('2012-10-18'), issue2.reload.start_date
1381 1391
1382 1392 issue1.due_date = '2012-10-23'
1383 1393 issue1.save!
1384 1394 issue2.reload
1385 1395 assert_equal Date.parse('2012-10-24'), issue2.start_date
1386 1396 assert_equal Date.parse('2012-10-26'), issue2.due_date
1387 1397 end
1388 1398
1389 1399 def test_rescheduling_an_issue_to_an_earlier_due_date_should_reschedule_following_issue
1390 1400 issue1 = Issue.generate!(:start_date => '2012-10-15', :due_date => '2012-10-17')
1391 1401 issue2 = Issue.generate!(:start_date => '2012-10-15', :due_date => '2012-10-17')
1392 1402 IssueRelation.create!(:issue_from => issue1, :issue_to => issue2,
1393 1403 :relation_type => IssueRelation::TYPE_PRECEDES)
1394 1404 assert_equal Date.parse('2012-10-18'), issue2.reload.start_date
1395 1405
1396 1406 issue1.start_date = '2012-09-17'
1397 1407 issue1.due_date = '2012-09-18'
1398 1408 issue1.save!
1399 1409 issue2.reload
1400 1410 assert_equal Date.parse('2012-09-19'), issue2.start_date
1401 1411 assert_equal Date.parse('2012-09-21'), issue2.due_date
1402 1412 end
1403 1413
1404 1414 def test_rescheduling_reschedule_following_issue_earlier_should_consider_other_preceding_issues
1405 1415 issue1 = Issue.generate!(:start_date => '2012-10-15', :due_date => '2012-10-17')
1406 1416 issue2 = Issue.generate!(:start_date => '2012-10-15', :due_date => '2012-10-17')
1407 1417 issue3 = Issue.generate!(:start_date => '2012-10-01', :due_date => '2012-10-02')
1408 1418 IssueRelation.create!(:issue_from => issue1, :issue_to => issue2,
1409 1419 :relation_type => IssueRelation::TYPE_PRECEDES)
1410 1420 IssueRelation.create!(:issue_from => issue3, :issue_to => issue2,
1411 1421 :relation_type => IssueRelation::TYPE_PRECEDES)
1412 1422 assert_equal Date.parse('2012-10-18'), issue2.reload.start_date
1413 1423
1414 1424 issue1.start_date = '2012-09-17'
1415 1425 issue1.due_date = '2012-09-18'
1416 1426 issue1.save!
1417 1427 issue2.reload
1418 1428 # Issue 2 must start after Issue 3
1419 1429 assert_equal Date.parse('2012-10-03'), issue2.start_date
1420 1430 assert_equal Date.parse('2012-10-05'), issue2.due_date
1421 1431 end
1422 1432
1423 1433 def test_rescheduling_a_stale_issue_should_not_raise_an_error
1424 1434 with_settings :non_working_week_days => [] do
1425 1435 stale = Issue.find(1)
1426 1436 issue = Issue.find(1)
1427 1437 issue.subject = "Updated"
1428 1438 issue.save!
1429 1439 date = 10.days.from_now.to_date
1430 1440 assert_nothing_raised do
1431 1441 stale.reschedule_on!(date)
1432 1442 end
1433 1443 assert_equal date, stale.reload.start_date
1434 1444 end
1435 1445 end
1436 1446
1437 1447 def test_overdue
1438 1448 assert Issue.new(:due_date => 1.day.ago.to_date).overdue?
1439 1449 assert !Issue.new(:due_date => Date.today).overdue?
1440 1450 assert !Issue.new(:due_date => 1.day.from_now.to_date).overdue?
1441 1451 assert !Issue.new(:due_date => nil).overdue?
1442 1452 assert !Issue.new(:due_date => 1.day.ago.to_date,
1443 1453 :status => IssueStatus.where(:is_closed => true).first
1444 1454 ).overdue?
1445 1455 end
1446 1456
1447 1457 context "#behind_schedule?" do
1448 1458 should "be false if the issue has no start_date" do
1449 1459 assert !Issue.new(:start_date => nil,
1450 1460 :due_date => 1.day.from_now.to_date,
1451 1461 :done_ratio => 0).behind_schedule?
1452 1462 end
1453 1463
1454 1464 should "be false if the issue has no end_date" do
1455 1465 assert !Issue.new(:start_date => 1.day.from_now.to_date,
1456 1466 :due_date => nil,
1457 1467 :done_ratio => 0).behind_schedule?
1458 1468 end
1459 1469
1460 1470 should "be false if the issue has more done than it's calendar time" do
1461 1471 assert !Issue.new(:start_date => 50.days.ago.to_date,
1462 1472 :due_date => 50.days.from_now.to_date,
1463 1473 :done_ratio => 90).behind_schedule?
1464 1474 end
1465 1475
1466 1476 should "be true if the issue hasn't been started at all" do
1467 1477 assert Issue.new(:start_date => 1.day.ago.to_date,
1468 1478 :due_date => 1.day.from_now.to_date,
1469 1479 :done_ratio => 0).behind_schedule?
1470 1480 end
1471 1481
1472 1482 should "be true if the issue has used more calendar time than it's done ratio" do
1473 1483 assert Issue.new(:start_date => 100.days.ago.to_date,
1474 1484 :due_date => Date.today,
1475 1485 :done_ratio => 90).behind_schedule?
1476 1486 end
1477 1487 end
1478 1488
1479 1489 context "#assignable_users" do
1480 1490 should "be Users" do
1481 1491 assert_kind_of User, Issue.find(1).assignable_users.first
1482 1492 end
1483 1493
1484 1494 should "include the issue author" do
1485 1495 non_project_member = User.generate!
1486 1496 issue = Issue.generate!(:author => non_project_member)
1487 1497
1488 1498 assert issue.assignable_users.include?(non_project_member)
1489 1499 end
1490 1500
1491 1501 should "include the current assignee" do
1492 1502 user = User.generate!
1493 1503 issue = Issue.generate!(:assigned_to => user)
1494 1504 user.lock!
1495 1505
1496 1506 assert Issue.find(issue.id).assignable_users.include?(user)
1497 1507 end
1498 1508
1499 1509 should "not show the issue author twice" do
1500 1510 assignable_user_ids = Issue.find(1).assignable_users.collect(&:id)
1501 1511 assert_equal 2, assignable_user_ids.length
1502 1512
1503 1513 assignable_user_ids.each do |user_id|
1504 1514 assert_equal 1, assignable_user_ids.select {|i| i == user_id}.length,
1505 1515 "User #{user_id} appears more or less than once"
1506 1516 end
1507 1517 end
1508 1518
1509 1519 context "with issue_group_assignment" do
1510 1520 should "include groups" do
1511 1521 issue = Issue.new(:project => Project.find(2))
1512 1522
1513 1523 with_settings :issue_group_assignment => '1' do
1514 1524 assert_equal %w(Group User), issue.assignable_users.map {|a| a.class.name}.uniq.sort
1515 1525 assert issue.assignable_users.include?(Group.find(11))
1516 1526 end
1517 1527 end
1518 1528 end
1519 1529
1520 1530 context "without issue_group_assignment" do
1521 1531 should "not include groups" do
1522 1532 issue = Issue.new(:project => Project.find(2))
1523 1533
1524 1534 with_settings :issue_group_assignment => '0' do
1525 1535 assert_equal %w(User), issue.assignable_users.map {|a| a.class.name}.uniq.sort
1526 1536 assert !issue.assignable_users.include?(Group.find(11))
1527 1537 end
1528 1538 end
1529 1539 end
1530 1540 end
1531 1541
1532 1542 def test_create_should_send_email_notification
1533 1543 ActionMailer::Base.deliveries.clear
1534 1544 issue = Issue.new(:project_id => 1, :tracker_id => 1,
1535 1545 :author_id => 3, :status_id => 1,
1536 1546 :priority => IssuePriority.all.first,
1537 1547 :subject => 'test_create', :estimated_hours => '1:30')
1538 1548
1539 1549 assert issue.save
1540 1550 assert_equal 1, ActionMailer::Base.deliveries.size
1541 1551 end
1542 1552
1543 1553 def test_stale_issue_should_not_send_email_notification
1544 1554 ActionMailer::Base.deliveries.clear
1545 1555 issue = Issue.find(1)
1546 1556 stale = Issue.find(1)
1547 1557
1548 1558 issue.init_journal(User.find(1))
1549 1559 issue.subject = 'Subjet update'
1550 1560 assert issue.save
1551 1561 assert_equal 1, ActionMailer::Base.deliveries.size
1552 1562 ActionMailer::Base.deliveries.clear
1553 1563
1554 1564 stale.init_journal(User.find(1))
1555 1565 stale.subject = 'Another subjet update'
1556 1566 assert_raise ActiveRecord::StaleObjectError do
1557 1567 stale.save
1558 1568 end
1559 1569 assert ActionMailer::Base.deliveries.empty?
1560 1570 end
1561 1571
1562 1572 def test_journalized_description
1563 1573 IssueCustomField.delete_all
1564 1574
1565 1575 i = Issue.first
1566 1576 old_description = i.description
1567 1577 new_description = "This is the new description"
1568 1578
1569 1579 i.init_journal(User.find(2))
1570 1580 i.description = new_description
1571 1581 assert_difference 'Journal.count', 1 do
1572 1582 assert_difference 'JournalDetail.count', 1 do
1573 1583 i.save!
1574 1584 end
1575 1585 end
1576 1586
1577 1587 detail = JournalDetail.first(:order => 'id DESC')
1578 1588 assert_equal i, detail.journal.journalized
1579 1589 assert_equal 'attr', detail.property
1580 1590 assert_equal 'description', detail.prop_key
1581 1591 assert_equal old_description, detail.old_value
1582 1592 assert_equal new_description, detail.value
1583 1593 end
1584 1594
1585 1595 def test_blank_descriptions_should_not_be_journalized
1586 1596 IssueCustomField.delete_all
1587 1597 Issue.update_all("description = NULL", "id=1")
1588 1598
1589 1599 i = Issue.find(1)
1590 1600 i.init_journal(User.find(2))
1591 1601 i.subject = "blank description"
1592 1602 i.description = "\r\n"
1593 1603
1594 1604 assert_difference 'Journal.count', 1 do
1595 1605 assert_difference 'JournalDetail.count', 1 do
1596 1606 i.save!
1597 1607 end
1598 1608 end
1599 1609 end
1600 1610
1601 1611 def test_journalized_multi_custom_field
1602 1612 field = IssueCustomField.create!(:name => 'filter', :field_format => 'list',
1603 1613 :is_filter => true, :is_for_all => true,
1604 1614 :tracker_ids => [1],
1605 1615 :possible_values => ['value1', 'value2', 'value3'],
1606 1616 :multiple => true)
1607 1617
1608 1618 issue = Issue.create!(:project_id => 1, :tracker_id => 1,
1609 1619 :subject => 'Test', :author_id => 1)
1610 1620
1611 1621 assert_difference 'Journal.count' do
1612 1622 assert_difference 'JournalDetail.count' do
1613 1623 issue.init_journal(User.first)
1614 1624 issue.custom_field_values = {field.id => ['value1']}
1615 1625 issue.save!
1616 1626 end
1617 1627 assert_difference 'JournalDetail.count' do
1618 1628 issue.init_journal(User.first)
1619 1629 issue.custom_field_values = {field.id => ['value1', 'value2']}
1620 1630 issue.save!
1621 1631 end
1622 1632 assert_difference 'JournalDetail.count', 2 do
1623 1633 issue.init_journal(User.first)
1624 1634 issue.custom_field_values = {field.id => ['value3', 'value2']}
1625 1635 issue.save!
1626 1636 end
1627 1637 assert_difference 'JournalDetail.count', 2 do
1628 1638 issue.init_journal(User.first)
1629 1639 issue.custom_field_values = {field.id => nil}
1630 1640 issue.save!
1631 1641 end
1632 1642 end
1633 1643 end
1634 1644
1635 1645 def test_description_eol_should_be_normalized
1636 1646 i = Issue.new(:description => "CR \r LF \n CRLF \r\n")
1637 1647 assert_equal "CR \r\n LF \r\n CRLF \r\n", i.description
1638 1648 end
1639 1649
1640 1650 def test_saving_twice_should_not_duplicate_journal_details
1641 1651 i = Issue.first
1642 1652 i.init_journal(User.find(2), 'Some notes')
1643 1653 # initial changes
1644 1654 i.subject = 'New subject'
1645 1655 i.done_ratio = i.done_ratio + 10
1646 1656 assert_difference 'Journal.count' do
1647 1657 assert i.save
1648 1658 end
1649 1659 # 1 more change
1650 1660 i.priority = IssuePriority.where("id <> ?", i.priority_id).first
1651 1661 assert_no_difference 'Journal.count' do
1652 1662 assert_difference 'JournalDetail.count', 1 do
1653 1663 i.save
1654 1664 end
1655 1665 end
1656 1666 # no more change
1657 1667 assert_no_difference 'Journal.count' do
1658 1668 assert_no_difference 'JournalDetail.count' do
1659 1669 i.save
1660 1670 end
1661 1671 end
1662 1672 end
1663 1673
1664 1674 def test_all_dependent_issues
1665 1675 IssueRelation.delete_all
1666 1676 assert IssueRelation.create!(:issue_from => Issue.find(1),
1667 1677 :issue_to => Issue.find(2),
1668 1678 :relation_type => IssueRelation::TYPE_PRECEDES)
1669 1679 assert IssueRelation.create!(:issue_from => Issue.find(2),
1670 1680 :issue_to => Issue.find(3),
1671 1681 :relation_type => IssueRelation::TYPE_PRECEDES)
1672 1682 assert IssueRelation.create!(:issue_from => Issue.find(3),
1673 1683 :issue_to => Issue.find(8),
1674 1684 :relation_type => IssueRelation::TYPE_PRECEDES)
1675 1685
1676 1686 assert_equal [2, 3, 8], Issue.find(1).all_dependent_issues.collect(&:id).sort
1677 1687 end
1678 1688
1679 1689 def test_all_dependent_issues_with_persistent_circular_dependency
1680 1690 IssueRelation.delete_all
1681 1691 assert IssueRelation.create!(:issue_from => Issue.find(1),
1682 1692 :issue_to => Issue.find(2),
1683 1693 :relation_type => IssueRelation::TYPE_PRECEDES)
1684 1694 assert IssueRelation.create!(:issue_from => Issue.find(2),
1685 1695 :issue_to => Issue.find(3),
1686 1696 :relation_type => IssueRelation::TYPE_PRECEDES)
1687 1697
1688 1698 r = IssueRelation.create!(:issue_from => Issue.find(3),
1689 1699 :issue_to => Issue.find(7),
1690 1700 :relation_type => IssueRelation::TYPE_PRECEDES)
1691 1701 IssueRelation.update_all("issue_to_id = 1", ["id = ?", r.id])
1692 1702
1693 1703 assert_equal [2, 3], Issue.find(1).all_dependent_issues.collect(&:id).sort
1694 1704 end
1695 1705
1696 1706 def test_all_dependent_issues_with_persistent_multiple_circular_dependencies
1697 1707 IssueRelation.delete_all
1698 1708 assert IssueRelation.create!(:issue_from => Issue.find(1),
1699 1709 :issue_to => Issue.find(2),
1700 1710 :relation_type => IssueRelation::TYPE_RELATES)
1701 1711 assert IssueRelation.create!(:issue_from => Issue.find(2),
1702 1712 :issue_to => Issue.find(3),
1703 1713 :relation_type => IssueRelation::TYPE_RELATES)
1704 1714 assert IssueRelation.create!(:issue_from => Issue.find(3),
1705 1715 :issue_to => Issue.find(8),
1706 1716 :relation_type => IssueRelation::TYPE_RELATES)
1707 1717
1708 1718 r = IssueRelation.create!(:issue_from => Issue.find(8),
1709 1719 :issue_to => Issue.find(7),
1710 1720 :relation_type => IssueRelation::TYPE_RELATES)
1711 1721 IssueRelation.update_all("issue_to_id = 2", ["id = ?", r.id])
1712 1722
1713 1723 r = IssueRelation.create!(:issue_from => Issue.find(3),
1714 1724 :issue_to => Issue.find(7),
1715 1725 :relation_type => IssueRelation::TYPE_RELATES)
1716 1726 IssueRelation.update_all("issue_to_id = 1", ["id = ?", r.id])
1717 1727
1718 1728 assert_equal [2, 3, 8], Issue.find(1).all_dependent_issues.collect(&:id).sort
1719 1729 end
1720 1730
1721 1731 context "#done_ratio" do
1722 1732 setup do
1723 1733 @issue = Issue.find(1)
1724 1734 @issue_status = IssueStatus.find(1)
1725 1735 @issue_status.update_attribute(:default_done_ratio, 50)
1726 1736 @issue2 = Issue.find(2)
1727 1737 @issue_status2 = IssueStatus.find(2)
1728 1738 @issue_status2.update_attribute(:default_done_ratio, 0)
1729 1739 end
1730 1740
1731 1741 teardown do
1732 1742 Setting.issue_done_ratio = 'issue_field'
1733 1743 end
1734 1744
1735 1745 context "with Setting.issue_done_ratio using the issue_field" do
1736 1746 setup do
1737 1747 Setting.issue_done_ratio = 'issue_field'
1738 1748 end
1739 1749
1740 1750 should "read the issue's field" do
1741 1751 assert_equal 0, @issue.done_ratio
1742 1752 assert_equal 30, @issue2.done_ratio
1743 1753 end
1744 1754 end
1745 1755
1746 1756 context "with Setting.issue_done_ratio using the issue_status" do
1747 1757 setup do
1748 1758 Setting.issue_done_ratio = 'issue_status'
1749 1759 end
1750 1760
1751 1761 should "read the Issue Status's default done ratio" do
1752 1762 assert_equal 50, @issue.done_ratio
1753 1763 assert_equal 0, @issue2.done_ratio
1754 1764 end
1755 1765 end
1756 1766 end
1757 1767
1758 1768 context "#update_done_ratio_from_issue_status" do
1759 1769 setup do
1760 1770 @issue = Issue.find(1)
1761 1771 @issue_status = IssueStatus.find(1)
1762 1772 @issue_status.update_attribute(:default_done_ratio, 50)
1763 1773 @issue2 = Issue.find(2)
1764 1774 @issue_status2 = IssueStatus.find(2)
1765 1775 @issue_status2.update_attribute(:default_done_ratio, 0)
1766 1776 end
1767 1777
1768 1778 context "with Setting.issue_done_ratio using the issue_field" do
1769 1779 setup do
1770 1780 Setting.issue_done_ratio = 'issue_field'
1771 1781 end
1772 1782
1773 1783 should "not change the issue" do
1774 1784 @issue.update_done_ratio_from_issue_status
1775 1785 @issue2.update_done_ratio_from_issue_status
1776 1786
1777 1787 assert_equal 0, @issue.read_attribute(:done_ratio)
1778 1788 assert_equal 30, @issue2.read_attribute(:done_ratio)
1779 1789 end
1780 1790 end
1781 1791
1782 1792 context "with Setting.issue_done_ratio using the issue_status" do
1783 1793 setup do
1784 1794 Setting.issue_done_ratio = 'issue_status'
1785 1795 end
1786 1796
1787 1797 should "change the issue's done ratio" do
1788 1798 @issue.update_done_ratio_from_issue_status
1789 1799 @issue2.update_done_ratio_from_issue_status
1790 1800
1791 1801 assert_equal 50, @issue.read_attribute(:done_ratio)
1792 1802 assert_equal 0, @issue2.read_attribute(:done_ratio)
1793 1803 end
1794 1804 end
1795 1805 end
1796 1806
1797 1807 test "#by_tracker" do
1798 1808 User.current = User.anonymous
1799 1809 groups = Issue.by_tracker(Project.find(1))
1800 1810 assert_equal 3, groups.size
1801 1811 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
1802 1812 end
1803 1813
1804 1814 test "#by_version" do
1805 1815 User.current = User.anonymous
1806 1816 groups = Issue.by_version(Project.find(1))
1807 1817 assert_equal 3, groups.size
1808 1818 assert_equal 3, groups.inject(0) {|sum, group| sum + group['total'].to_i}
1809 1819 end
1810 1820
1811 1821 test "#by_priority" do
1812 1822 User.current = User.anonymous
1813 1823 groups = Issue.by_priority(Project.find(1))
1814 1824 assert_equal 4, groups.size
1815 1825 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
1816 1826 end
1817 1827
1818 1828 test "#by_category" do
1819 1829 User.current = User.anonymous
1820 1830 groups = Issue.by_category(Project.find(1))
1821 1831 assert_equal 2, groups.size
1822 1832 assert_equal 3, groups.inject(0) {|sum, group| sum + group['total'].to_i}
1823 1833 end
1824 1834
1825 1835 test "#by_assigned_to" do
1826 1836 User.current = User.anonymous
1827 1837 groups = Issue.by_assigned_to(Project.find(1))
1828 1838 assert_equal 2, groups.size
1829 1839 assert_equal 2, groups.inject(0) {|sum, group| sum + group['total'].to_i}
1830 1840 end
1831 1841
1832 1842 test "#by_author" do
1833 1843 User.current = User.anonymous
1834 1844 groups = Issue.by_author(Project.find(1))
1835 1845 assert_equal 4, groups.size
1836 1846 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
1837 1847 end
1838 1848
1839 1849 test "#by_subproject" do
1840 1850 User.current = User.anonymous
1841 1851 groups = Issue.by_subproject(Project.find(1))
1842 1852 # Private descendant not visible
1843 1853 assert_equal 1, groups.size
1844 1854 assert_equal 2, groups.inject(0) {|sum, group| sum + group['total'].to_i}
1845 1855 end
1846 1856
1847 1857 def test_recently_updated_scope
1848 1858 #should return the last updated issue
1849 1859 assert_equal Issue.reorder("updated_on DESC").first, Issue.recently_updated.limit(1).first
1850 1860 end
1851 1861
1852 1862 def test_on_active_projects_scope
1853 1863 assert Project.find(2).archive
1854 1864
1855 1865 before = Issue.on_active_project.length
1856 1866 # test inclusion to results
1857 1867 issue = Issue.generate!(:tracker => Project.find(2).trackers.first)
1858 1868 assert_equal before + 1, Issue.on_active_project.length
1859 1869
1860 1870 # Move to an archived project
1861 1871 issue.project = Project.find(2)
1862 1872 assert issue.save
1863 1873 assert_equal before, Issue.on_active_project.length
1864 1874 end
1865 1875
1866 1876 context "Issue#recipients" do
1867 1877 setup do
1868 1878 @project = Project.find(1)
1869 1879 @author = User.generate!
1870 1880 @assignee = User.generate!
1871 1881 @issue = Issue.generate!(:project => @project, :assigned_to => @assignee, :author => @author)
1872 1882 end
1873 1883
1874 1884 should "include project recipients" do
1875 1885 assert @project.recipients.present?
1876 1886 @project.recipients.each do |project_recipient|
1877 1887 assert @issue.recipients.include?(project_recipient)
1878 1888 end
1879 1889 end
1880 1890
1881 1891 should "include the author if the author is active" do
1882 1892 assert @issue.author, "No author set for Issue"
1883 1893 assert @issue.recipients.include?(@issue.author.mail)
1884 1894 end
1885 1895
1886 1896 should "include the assigned to user if the assigned to user is active" do
1887 1897 assert @issue.assigned_to, "No assigned_to set for Issue"
1888 1898 assert @issue.recipients.include?(@issue.assigned_to.mail)
1889 1899 end
1890 1900
1891 1901 should "not include users who opt out of all email" do
1892 1902 @author.update_attribute(:mail_notification, :none)
1893 1903
1894 1904 assert !@issue.recipients.include?(@issue.author.mail)
1895 1905 end
1896 1906
1897 1907 should "not include the issue author if they are only notified of assigned issues" do
1898 1908 @author.update_attribute(:mail_notification, :only_assigned)
1899 1909
1900 1910 assert !@issue.recipients.include?(@issue.author.mail)
1901 1911 end
1902 1912
1903 1913 should "not include the assigned user if they are only notified of owned issues" do
1904 1914 @assignee.update_attribute(:mail_notification, :only_owner)
1905 1915
1906 1916 assert !@issue.recipients.include?(@issue.assigned_to.mail)
1907 1917 end
1908 1918 end
1909 1919
1910 1920 def test_last_journal_id_with_journals_should_return_the_journal_id
1911 1921 assert_equal 2, Issue.find(1).last_journal_id
1912 1922 end
1913 1923
1914 1924 def test_last_journal_id_without_journals_should_return_nil
1915 1925 assert_nil Issue.find(3).last_journal_id
1916 1926 end
1917 1927
1918 1928 def test_journals_after_should_return_journals_with_greater_id
1919 1929 assert_equal [Journal.find(2)], Issue.find(1).journals_after('1')
1920 1930 assert_equal [], Issue.find(1).journals_after('2')
1921 1931 end
1922 1932
1923 1933 def test_journals_after_with_blank_arg_should_return_all_journals
1924 1934 assert_equal [Journal.find(1), Journal.find(2)], Issue.find(1).journals_after('')
1925 1935 end
1926 1936
1927 1937 def test_css_classes_should_include_priority
1928 1938 issue = Issue.new(:priority => IssuePriority.find(8))
1929 1939 classes = issue.css_classes.split(' ')
1930 1940 assert_include 'priority-8', classes
1931 1941 assert_include 'priority-highest', classes
1932 1942 end
1933 1943
1934 1944 def test_save_attachments_with_hash_should_save_attachments_in_keys_order
1935 1945 set_tmp_attachments_directory
1936 1946 issue = Issue.generate!
1937 1947 issue.save_attachments({
1938 1948 'p0' => {'file' => mock_file_with_options(:original_filename => 'upload')},
1939 1949 '3' => {'file' => mock_file_with_options(:original_filename => 'bar')},
1940 1950 '1' => {'file' => mock_file_with_options(:original_filename => 'foo')}
1941 1951 })
1942 1952 issue.attach_saved_attachments
1943 1953
1944 1954 assert_equal 3, issue.reload.attachments.count
1945 1955 assert_equal %w(upload foo bar), issue.attachments.map(&:filename)
1946 1956 end
1947 1957 end
General Comments 0
You need to be logged in to leave comments. Login now