##// END OF EJS Templates
Fixed that open scope on Project#issues raises an error (#11545)....
Jean-Philippe Lang -
r10016:8fb1a7e3ccc7
parent child
Show More
@@ -1,1250 +1,1249
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
21 21 belongs_to :project
22 22 belongs_to :tracker
23 23 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
24 24 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
25 25 belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id'
26 26 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
27 27 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
28 28 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
29 29
30 30 has_many :journals, :as => :journalized, :dependent => :destroy
31 31 has_many :time_entries, :dependent => :delete_all
32 32 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
33 33
34 34 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
35 35 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
36 36
37 37 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
38 38 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
39 39 acts_as_customizable
40 40 acts_as_watchable
41 41 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
42 42 :include => [:project, :journals],
43 43 # sort by id so that limited eager loading doesn't break with postgresql
44 44 :order_column => "#{table_name}.id"
45 45 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
46 46 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
47 47 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
48 48
49 49 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
50 50 :author_key => :author_id
51 51
52 52 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
53 53
54 54 attr_reader :current_journal
55 55
56 56 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
57 57
58 58 validates_length_of :subject, :maximum => 255
59 59 validates_inclusion_of :done_ratio, :in => 0..100
60 60 validates_numericality_of :estimated_hours, :allow_nil => true
61 61 validate :validate_issue, :validate_required_fields
62 62
63 63 scope :visible,
64 64 lambda {|*args| { :include => :project,
65 65 :conditions => Issue.visible_condition(args.shift || User.current, *args) } }
66 66
67 class << self; undef :open; end
68 67 scope :open, lambda {|*args|
69 68 is_closed = args.size > 0 ? !args.first : false
70 69 {:conditions => ["#{IssueStatus.table_name}.is_closed = ?", is_closed], :include => :status}
71 70 }
72 71
73 72 scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
74 73 scope :with_limit, lambda { |limit| { :limit => limit} }
75 74 scope :on_active_project, :include => [:status, :project, :tracker],
76 75 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
77 76
78 77 before_create :default_assign
79 78 before_save :close_duplicates, :update_done_ratio_from_issue_status, :force_updated_on_change
80 79 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
81 80 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
82 81 after_destroy :update_parent_attributes
83 82
84 83 # Returns a SQL conditions string used to find all issues visible by the specified user
85 84 def self.visible_condition(user, options={})
86 85 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
87 86 case role.issues_visibility
88 87 when 'all'
89 88 nil
90 89 when 'default'
91 90 user_ids = [user.id] + user.groups.map(&:id)
92 91 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
93 92 when 'own'
94 93 user_ids = [user.id] + user.groups.map(&:id)
95 94 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
96 95 else
97 96 '1=0'
98 97 end
99 98 end
100 99 end
101 100
102 101 # Returns true if usr or current user is allowed to view the issue
103 102 def visible?(usr=nil)
104 103 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
105 104 case role.issues_visibility
106 105 when 'all'
107 106 true
108 107 when 'default'
109 108 !self.is_private? || self.author == user || user.is_or_belongs_to?(assigned_to)
110 109 when 'own'
111 110 self.author == user || user.is_or_belongs_to?(assigned_to)
112 111 else
113 112 false
114 113 end
115 114 end
116 115 end
117 116
118 117 def initialize(attributes=nil, *args)
119 118 super
120 119 if new_record?
121 120 # set default values for new records only
122 121 self.status ||= IssueStatus.default
123 122 self.priority ||= IssuePriority.default
124 123 self.watcher_user_ids = []
125 124 end
126 125 end
127 126
128 127 # AR#Persistence#destroy would raise and RecordNotFound exception
129 128 # if the issue was already deleted or updated (non matching lock_version).
130 129 # This is a problem when bulk deleting issues or deleting a project
131 130 # (because an issue may already be deleted if its parent was deleted
132 131 # first).
133 132 # The issue is reloaded by the nested_set before being deleted so
134 133 # the lock_version condition should not be an issue but we handle it.
135 134 def destroy
136 135 super
137 136 rescue ActiveRecord::RecordNotFound
138 137 # Stale or already deleted
139 138 begin
140 139 reload
141 140 rescue ActiveRecord::RecordNotFound
142 141 # The issue was actually already deleted
143 142 @destroyed = true
144 143 return freeze
145 144 end
146 145 # The issue was stale, retry to destroy
147 146 super
148 147 end
149 148
150 149 def reload(*args)
151 150 @workflow_rule_by_attribute = nil
152 151 @assignable_versions = nil
153 152 super
154 153 end
155 154
156 155 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
157 156 def available_custom_fields
158 157 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
159 158 end
160 159
161 160 # Copies attributes from another issue, arg can be an id or an Issue
162 161 def copy_from(arg, options={})
163 162 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
164 163 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
165 164 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
166 165 self.status = issue.status
167 166 self.author = User.current
168 167 unless options[:attachments] == false
169 168 self.attachments = issue.attachments.map do |attachement|
170 169 attachement.copy(:container => self)
171 170 end
172 171 end
173 172 @copied_from = issue
174 173 self
175 174 end
176 175
177 176 # Returns an unsaved copy of the issue
178 177 def copy(attributes=nil, copy_options={})
179 178 copy = self.class.new.copy_from(self, copy_options)
180 179 copy.attributes = attributes if attributes
181 180 copy
182 181 end
183 182
184 183 # Returns true if the issue is a copy
185 184 def copy?
186 185 @copied_from.present?
187 186 end
188 187
189 188 # Moves/copies an issue to a new project and tracker
190 189 # Returns the moved/copied issue on success, false on failure
191 190 def move_to_project(new_project, new_tracker=nil, options={})
192 191 ActiveSupport::Deprecation.warn "Issue#move_to_project is deprecated, use #project= instead."
193 192
194 193 if options[:copy]
195 194 issue = self.copy
196 195 else
197 196 issue = self
198 197 end
199 198
200 199 issue.init_journal(User.current, options[:notes])
201 200
202 201 # Preserve previous behaviour
203 202 # #move_to_project doesn't change tracker automatically
204 203 issue.send :project=, new_project, true
205 204 if new_tracker
206 205 issue.tracker = new_tracker
207 206 end
208 207 # Allow bulk setting of attributes on the issue
209 208 if options[:attributes]
210 209 issue.attributes = options[:attributes]
211 210 end
212 211
213 212 issue.save ? issue : false
214 213 end
215 214
216 215 def status_id=(sid)
217 216 self.status = nil
218 217 result = write_attribute(:status_id, sid)
219 218 @workflow_rule_by_attribute = nil
220 219 result
221 220 end
222 221
223 222 def priority_id=(pid)
224 223 self.priority = nil
225 224 write_attribute(:priority_id, pid)
226 225 end
227 226
228 227 def category_id=(cid)
229 228 self.category = nil
230 229 write_attribute(:category_id, cid)
231 230 end
232 231
233 232 def fixed_version_id=(vid)
234 233 self.fixed_version = nil
235 234 write_attribute(:fixed_version_id, vid)
236 235 end
237 236
238 237 def tracker_id=(tid)
239 238 self.tracker = nil
240 239 result = write_attribute(:tracker_id, tid)
241 240 @custom_field_values = nil
242 241 @workflow_rule_by_attribute = nil
243 242 result
244 243 end
245 244
246 245 def project_id=(project_id)
247 246 if project_id.to_s != self.project_id.to_s
248 247 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
249 248 end
250 249 end
251 250
252 251 def project=(project, keep_tracker=false)
253 252 project_was = self.project
254 253 write_attribute(:project_id, project ? project.id : nil)
255 254 association_instance_set('project', project)
256 255 if project_was && project && project_was != project
257 256 @assignable_versions = nil
258 257
259 258 unless keep_tracker || project.trackers.include?(tracker)
260 259 self.tracker = project.trackers.first
261 260 end
262 261 # Reassign to the category with same name if any
263 262 if category
264 263 self.category = project.issue_categories.find_by_name(category.name)
265 264 end
266 265 # Keep the fixed_version if it's still valid in the new_project
267 266 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
268 267 self.fixed_version = nil
269 268 end
270 269 if parent && parent.project_id != project_id
271 270 self.parent_issue_id = nil
272 271 end
273 272 @custom_field_values = nil
274 273 end
275 274 end
276 275
277 276 def description=(arg)
278 277 if arg.is_a?(String)
279 278 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
280 279 end
281 280 write_attribute(:description, arg)
282 281 end
283 282
284 283 # Overrides assign_attributes so that project and tracker get assigned first
285 284 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
286 285 return if new_attributes.nil?
287 286 attrs = new_attributes.dup
288 287 attrs.stringify_keys!
289 288
290 289 %w(project project_id tracker tracker_id).each do |attr|
291 290 if attrs.has_key?(attr)
292 291 send "#{attr}=", attrs.delete(attr)
293 292 end
294 293 end
295 294 send :assign_attributes_without_project_and_tracker_first, attrs, *args
296 295 end
297 296 # Do not redefine alias chain on reload (see #4838)
298 297 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
299 298
300 299 def estimated_hours=(h)
301 300 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
302 301 end
303 302
304 303 safe_attributes 'project_id',
305 304 :if => lambda {|issue, user|
306 305 if issue.new_record?
307 306 issue.copy?
308 307 elsif user.allowed_to?(:move_issues, issue.project)
309 308 projects = Issue.allowed_target_projects_on_move(user)
310 309 projects.include?(issue.project) && projects.size > 1
311 310 end
312 311 }
313 312
314 313 safe_attributes 'tracker_id',
315 314 'status_id',
316 315 'category_id',
317 316 'assigned_to_id',
318 317 'priority_id',
319 318 'fixed_version_id',
320 319 'subject',
321 320 'description',
322 321 'start_date',
323 322 'due_date',
324 323 'done_ratio',
325 324 'estimated_hours',
326 325 'custom_field_values',
327 326 'custom_fields',
328 327 'lock_version',
329 328 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
330 329
331 330 safe_attributes 'status_id',
332 331 'assigned_to_id',
333 332 'fixed_version_id',
334 333 'done_ratio',
335 334 'lock_version',
336 335 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
337 336
338 337 safe_attributes 'watcher_user_ids',
339 338 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
340 339
341 340 safe_attributes 'is_private',
342 341 :if => lambda {|issue, user|
343 342 user.allowed_to?(:set_issues_private, issue.project) ||
344 343 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
345 344 }
346 345
347 346 safe_attributes 'parent_issue_id',
348 347 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
349 348 user.allowed_to?(:manage_subtasks, issue.project)}
350 349
351 350 def safe_attribute_names(user=nil)
352 351 names = super
353 352 names -= disabled_core_fields
354 353 names -= read_only_attribute_names(user)
355 354 names
356 355 end
357 356
358 357 # Safely sets attributes
359 358 # Should be called from controllers instead of #attributes=
360 359 # attr_accessible is too rough because we still want things like
361 360 # Issue.new(:project => foo) to work
362 361 def safe_attributes=(attrs, user=User.current)
363 362 return unless attrs.is_a?(Hash)
364 363
365 364 attrs = attrs.dup
366 365
367 366 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
368 367 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
369 368 if allowed_target_projects(user).collect(&:id).include?(p.to_i)
370 369 self.project_id = p
371 370 end
372 371 end
373 372
374 373 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
375 374 self.tracker_id = t
376 375 end
377 376
378 377 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
379 378 if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
380 379 self.status_id = s
381 380 end
382 381 end
383 382
384 383 attrs = delete_unsafe_attributes(attrs, user)
385 384 return if attrs.empty?
386 385
387 386 unless leaf?
388 387 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
389 388 end
390 389
391 390 if attrs['parent_issue_id'].present?
392 391 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
393 392 end
394 393
395 394 if attrs['custom_field_values'].present?
396 395 attrs['custom_field_values'] = attrs['custom_field_values'].reject {|k, v| read_only_attribute_names(user).include? k.to_s}
397 396 end
398 397
399 398 if attrs['custom_fields'].present?
400 399 attrs['custom_fields'] = attrs['custom_fields'].reject {|c| read_only_attribute_names(user).include? c['id'].to_s}
401 400 end
402 401
403 402 # mass-assignment security bypass
404 403 assign_attributes attrs, :without_protection => true
405 404 end
406 405
407 406 def disabled_core_fields
408 407 tracker ? tracker.disabled_core_fields : []
409 408 end
410 409
411 410 # Returns the custom_field_values that can be edited by the given user
412 411 def editable_custom_field_values(user=nil)
413 412 custom_field_values.reject do |value|
414 413 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
415 414 end
416 415 end
417 416
418 417 # Returns the names of attributes that are read-only for user or the current user
419 418 # For users with multiple roles, the read-only fields are the intersection of
420 419 # read-only fields of each role
421 420 # The result is an array of strings where sustom fields are represented with their ids
422 421 #
423 422 # Examples:
424 423 # issue.read_only_attribute_names # => ['due_date', '2']
425 424 # issue.read_only_attribute_names(user) # => []
426 425 def read_only_attribute_names(user=nil)
427 426 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
428 427 end
429 428
430 429 # Returns the names of required attributes for user or the current user
431 430 # For users with multiple roles, the required fields are the intersection of
432 431 # required fields of each role
433 432 # The result is an array of strings where sustom fields are represented with their ids
434 433 #
435 434 # Examples:
436 435 # issue.required_attribute_names # => ['due_date', '2']
437 436 # issue.required_attribute_names(user) # => []
438 437 def required_attribute_names(user=nil)
439 438 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
440 439 end
441 440
442 441 # Returns true if the attribute is required for user
443 442 def required_attribute?(name, user=nil)
444 443 required_attribute_names(user).include?(name.to_s)
445 444 end
446 445
447 446 # Returns a hash of the workflow rule by attribute for the given user
448 447 #
449 448 # Examples:
450 449 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
451 450 def workflow_rule_by_attribute(user=nil)
452 451 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
453 452
454 453 user_real = user || User.current
455 454 roles = user_real.admin ? Role.all : user_real.roles_for_project(project)
456 455 return {} if roles.empty?
457 456
458 457 result = {}
459 458 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).all
460 459 if workflow_permissions.any?
461 460 workflow_rules = workflow_permissions.inject({}) do |h, wp|
462 461 h[wp.field_name] ||= []
463 462 h[wp.field_name] << wp.rule
464 463 h
465 464 end
466 465 workflow_rules.each do |attr, rules|
467 466 next if rules.size < roles.size
468 467 uniq_rules = rules.uniq
469 468 if uniq_rules.size == 1
470 469 result[attr] = uniq_rules.first
471 470 else
472 471 result[attr] = 'required'
473 472 end
474 473 end
475 474 end
476 475 @workflow_rule_by_attribute = result if user.nil?
477 476 result
478 477 end
479 478 private :workflow_rule_by_attribute
480 479
481 480 def done_ratio
482 481 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
483 482 status.default_done_ratio
484 483 else
485 484 read_attribute(:done_ratio)
486 485 end
487 486 end
488 487
489 488 def self.use_status_for_done_ratio?
490 489 Setting.issue_done_ratio == 'issue_status'
491 490 end
492 491
493 492 def self.use_field_for_done_ratio?
494 493 Setting.issue_done_ratio == 'issue_field'
495 494 end
496 495
497 496 def validate_issue
498 497 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
499 498 errors.add :due_date, :not_a_date
500 499 end
501 500
502 501 if self.due_date and self.start_date and self.due_date < self.start_date
503 502 errors.add :due_date, :greater_than_start_date
504 503 end
505 504
506 505 if start_date && soonest_start && start_date < soonest_start
507 506 errors.add :start_date, :invalid
508 507 end
509 508
510 509 if fixed_version
511 510 if !assignable_versions.include?(fixed_version)
512 511 errors.add :fixed_version_id, :inclusion
513 512 elsif reopened? && fixed_version.closed?
514 513 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
515 514 end
516 515 end
517 516
518 517 # Checks that the issue can not be added/moved to a disabled tracker
519 518 if project && (tracker_id_changed? || project_id_changed?)
520 519 unless project.trackers.include?(tracker)
521 520 errors.add :tracker_id, :inclusion
522 521 end
523 522 end
524 523
525 524 # Checks parent issue assignment
526 525 if @parent_issue
527 526 if @parent_issue.project_id != project_id
528 527 errors.add :parent_issue_id, :not_same_project
529 528 elsif !new_record?
530 529 # moving an existing issue
531 530 if @parent_issue.root_id != root_id
532 531 # we can always move to another tree
533 532 elsif move_possible?(@parent_issue)
534 533 # move accepted inside tree
535 534 else
536 535 errors.add :parent_issue_id, :not_a_valid_parent
537 536 end
538 537 end
539 538 end
540 539 end
541 540
542 541 # Validates the issue against additional workflow requirements
543 542 def validate_required_fields
544 543 user = new_record? ? author : current_journal.try(:user)
545 544
546 545 required_attribute_names(user).each do |attribute|
547 546 if attribute =~ /^\d+$/
548 547 attribute = attribute.to_i
549 548 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
550 549 if v && v.value.blank?
551 550 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
552 551 end
553 552 else
554 553 if respond_to?(attribute) && send(attribute).blank?
555 554 errors.add attribute, :blank
556 555 end
557 556 end
558 557 end
559 558 end
560 559
561 560 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
562 561 # even if the user turns off the setting later
563 562 def update_done_ratio_from_issue_status
564 563 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
565 564 self.done_ratio = status.default_done_ratio
566 565 end
567 566 end
568 567
569 568 def init_journal(user, notes = "")
570 569 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
571 570 if new_record?
572 571 @current_journal.notify = false
573 572 else
574 573 @attributes_before_change = attributes.dup
575 574 @custom_values_before_change = {}
576 575 self.custom_field_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
577 576 end
578 577 @current_journal
579 578 end
580 579
581 580 # Returns the id of the last journal or nil
582 581 def last_journal_id
583 582 if new_record?
584 583 nil
585 584 else
586 585 journals.maximum(:id)
587 586 end
588 587 end
589 588
590 589 # Returns a scope for journals that have an id greater than journal_id
591 590 def journals_after(journal_id)
592 591 scope = journals.reorder("#{Journal.table_name}.id ASC")
593 592 if journal_id.present?
594 593 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
595 594 end
596 595 scope
597 596 end
598 597
599 598 # Return true if the issue is closed, otherwise false
600 599 def closed?
601 600 self.status.is_closed?
602 601 end
603 602
604 603 # Return true if the issue is being reopened
605 604 def reopened?
606 605 if !new_record? && status_id_changed?
607 606 status_was = IssueStatus.find_by_id(status_id_was)
608 607 status_new = IssueStatus.find_by_id(status_id)
609 608 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
610 609 return true
611 610 end
612 611 end
613 612 false
614 613 end
615 614
616 615 # Return true if the issue is being closed
617 616 def closing?
618 617 if !new_record? && status_id_changed?
619 618 status_was = IssueStatus.find_by_id(status_id_was)
620 619 status_new = IssueStatus.find_by_id(status_id)
621 620 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
622 621 return true
623 622 end
624 623 end
625 624 false
626 625 end
627 626
628 627 # Returns true if the issue is overdue
629 628 def overdue?
630 629 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
631 630 end
632 631
633 632 # Is the amount of work done less than it should for the due date
634 633 def behind_schedule?
635 634 return false if start_date.nil? || due_date.nil?
636 635 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
637 636 return done_date <= Date.today
638 637 end
639 638
640 639 # Does this issue have children?
641 640 def children?
642 641 !leaf?
643 642 end
644 643
645 644 # Users the issue can be assigned to
646 645 def assignable_users
647 646 users = project.assignable_users
648 647 users << author if author
649 648 users << assigned_to if assigned_to
650 649 users.uniq.sort
651 650 end
652 651
653 652 # Versions that the issue can be assigned to
654 653 def assignable_versions
655 654 return @assignable_versions if @assignable_versions
656 655
657 656 versions = project.shared_versions.open.all
658 657 if fixed_version
659 658 if fixed_version_id_changed?
660 659 # nothing to do
661 660 elsif project_id_changed?
662 661 if project.shared_versions.include?(fixed_version)
663 662 versions << fixed_version
664 663 end
665 664 else
666 665 versions << fixed_version
667 666 end
668 667 end
669 668 @assignable_versions = versions.uniq.sort
670 669 end
671 670
672 671 # Returns true if this issue is blocked by another issue that is still open
673 672 def blocked?
674 673 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
675 674 end
676 675
677 676 # Returns an array of statuses that user is able to apply
678 677 def new_statuses_allowed_to(user=User.current, include_default=false)
679 678 if new_record? && @copied_from
680 679 [IssueStatus.default, @copied_from.status].compact.uniq.sort
681 680 else
682 681 initial_status = nil
683 682 if new_record?
684 683 initial_status = IssueStatus.default
685 684 elsif status_id_was
686 685 initial_status = IssueStatus.find_by_id(status_id_was)
687 686 end
688 687 initial_status ||= status
689 688
690 689 statuses = initial_status.find_new_statuses_allowed_to(
691 690 user.admin ? Role.all : user.roles_for_project(project),
692 691 tracker,
693 692 author == user,
694 693 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
695 694 )
696 695 statuses << initial_status unless statuses.empty?
697 696 statuses << IssueStatus.default if include_default
698 697 statuses = statuses.compact.uniq.sort
699 698 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
700 699 end
701 700 end
702 701
703 702 def assigned_to_was
704 703 if assigned_to_id_changed? && assigned_to_id_was.present?
705 704 @assigned_to_was ||= User.find_by_id(assigned_to_id_was)
706 705 end
707 706 end
708 707
709 708 # Returns the mail adresses of users that should be notified
710 709 def recipients
711 710 notified = []
712 711 # Author and assignee are always notified unless they have been
713 712 # locked or don't want to be notified
714 713 notified << author if author
715 714 if assigned_to
716 715 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
717 716 end
718 717 if assigned_to_was
719 718 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
720 719 end
721 720 notified = notified.select {|u| u.active? && u.notify_about?(self)}
722 721
723 722 notified += project.notified_users
724 723 notified.uniq!
725 724 # Remove users that can not view the issue
726 725 notified.reject! {|user| !visible?(user)}
727 726 notified.collect(&:mail)
728 727 end
729 728
730 729 # Returns the number of hours spent on this issue
731 730 def spent_hours
732 731 @spent_hours ||= time_entries.sum(:hours) || 0
733 732 end
734 733
735 734 # Returns the total number of hours spent on this issue and its descendants
736 735 #
737 736 # Example:
738 737 # spent_hours => 0.0
739 738 # spent_hours => 50.2
740 739 def total_spent_hours
741 740 @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours",
742 741 :joins => "LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").to_f || 0.0
743 742 end
744 743
745 744 def relations
746 745 @relations ||= (relations_from + relations_to).sort
747 746 end
748 747
749 748 # Preloads relations for a collection of issues
750 749 def self.load_relations(issues)
751 750 if issues.any?
752 751 relations = IssueRelation.all(:conditions => ["issue_from_id IN (:ids) OR issue_to_id IN (:ids)", {:ids => issues.map(&:id)}])
753 752 issues.each do |issue|
754 753 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
755 754 end
756 755 end
757 756 end
758 757
759 758 # Preloads visible spent time for a collection of issues
760 759 def self.load_visible_spent_hours(issues, user=User.current)
761 760 if issues.any?
762 761 hours_by_issue_id = TimeEntry.visible(user).sum(:hours, :group => :issue_id)
763 762 issues.each do |issue|
764 763 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
765 764 end
766 765 end
767 766 end
768 767
769 768 # Finds an issue relation given its id.
770 769 def find_relation(relation_id)
771 770 IssueRelation.find(relation_id, :conditions => ["issue_to_id = ? OR issue_from_id = ?", id, id])
772 771 end
773 772
774 773 def all_dependent_issues(except=[])
775 774 except << self
776 775 dependencies = []
777 776 relations_from.each do |relation|
778 777 if relation.issue_to && !except.include?(relation.issue_to)
779 778 dependencies << relation.issue_to
780 779 dependencies += relation.issue_to.all_dependent_issues(except)
781 780 end
782 781 end
783 782 dependencies
784 783 end
785 784
786 785 # Returns an array of issues that duplicate this one
787 786 def duplicates
788 787 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
789 788 end
790 789
791 790 # Returns the due date or the target due date if any
792 791 # Used on gantt chart
793 792 def due_before
794 793 due_date || (fixed_version ? fixed_version.effective_date : nil)
795 794 end
796 795
797 796 # Returns the time scheduled for this issue.
798 797 #
799 798 # Example:
800 799 # Start Date: 2/26/09, End Date: 3/04/09
801 800 # duration => 6
802 801 def duration
803 802 (start_date && due_date) ? due_date - start_date : 0
804 803 end
805 804
806 805 def soonest_start
807 806 @soonest_start ||= (
808 807 relations_to.collect{|relation| relation.successor_soonest_start} +
809 808 ancestors.collect(&:soonest_start)
810 809 ).compact.max
811 810 end
812 811
813 812 def reschedule_after(date)
814 813 return if date.nil?
815 814 if leaf?
816 815 if start_date.nil? || start_date < date
817 816 self.start_date, self.due_date = date, date + duration
818 817 begin
819 818 save
820 819 rescue ActiveRecord::StaleObjectError
821 820 reload
822 821 self.start_date, self.due_date = date, date + duration
823 822 save
824 823 end
825 824 end
826 825 else
827 826 leaves.each do |leaf|
828 827 leaf.reschedule_after(date)
829 828 end
830 829 end
831 830 end
832 831
833 832 def <=>(issue)
834 833 if issue.nil?
835 834 -1
836 835 elsif root_id != issue.root_id
837 836 (root_id || 0) <=> (issue.root_id || 0)
838 837 else
839 838 (lft || 0) <=> (issue.lft || 0)
840 839 end
841 840 end
842 841
843 842 def to_s
844 843 "#{tracker} ##{id}: #{subject}"
845 844 end
846 845
847 846 # Returns a string of css classes that apply to the issue
848 847 def css_classes
849 848 s = "issue status-#{status_id} priority-#{priority_id}"
850 849 s << ' closed' if closed?
851 850 s << ' overdue' if overdue?
852 851 s << ' child' if child?
853 852 s << ' parent' unless leaf?
854 853 s << ' private' if is_private?
855 854 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
856 855 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
857 856 s
858 857 end
859 858
860 859 # Saves an issue and a time_entry from the parameters
861 860 def save_issue_with_child_records(params, existing_time_entry=nil)
862 861 Issue.transaction do
863 862 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
864 863 @time_entry = existing_time_entry || TimeEntry.new
865 864 @time_entry.project = project
866 865 @time_entry.issue = self
867 866 @time_entry.user = User.current
868 867 @time_entry.spent_on = User.current.today
869 868 @time_entry.attributes = params[:time_entry]
870 869 self.time_entries << @time_entry
871 870 end
872 871
873 872 # TODO: Rename hook
874 873 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
875 874 if save
876 875 # TODO: Rename hook
877 876 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
878 877 else
879 878 raise ActiveRecord::Rollback
880 879 end
881 880 end
882 881 end
883 882
884 883 # Unassigns issues from +version+ if it's no longer shared with issue's project
885 884 def self.update_versions_from_sharing_change(version)
886 885 # Update issues assigned to the version
887 886 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
888 887 end
889 888
890 889 # Unassigns issues from versions that are no longer shared
891 890 # after +project+ was moved
892 891 def self.update_versions_from_hierarchy_change(project)
893 892 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
894 893 # Update issues of the moved projects and issues assigned to a version of a moved project
895 894 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
896 895 end
897 896
898 897 def parent_issue_id=(arg)
899 898 parent_issue_id = arg.blank? ? nil : arg.to_i
900 899 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
901 900 @parent_issue.id
902 901 else
903 902 @parent_issue = nil
904 903 nil
905 904 end
906 905 end
907 906
908 907 def parent_issue_id
909 908 if instance_variable_defined? :@parent_issue
910 909 @parent_issue.nil? ? nil : @parent_issue.id
911 910 else
912 911 parent_id
913 912 end
914 913 end
915 914
916 915 # Extracted from the ReportsController.
917 916 def self.by_tracker(project)
918 917 count_and_group_by(:project => project,
919 918 :field => 'tracker_id',
920 919 :joins => Tracker.table_name)
921 920 end
922 921
923 922 def self.by_version(project)
924 923 count_and_group_by(:project => project,
925 924 :field => 'fixed_version_id',
926 925 :joins => Version.table_name)
927 926 end
928 927
929 928 def self.by_priority(project)
930 929 count_and_group_by(:project => project,
931 930 :field => 'priority_id',
932 931 :joins => IssuePriority.table_name)
933 932 end
934 933
935 934 def self.by_category(project)
936 935 count_and_group_by(:project => project,
937 936 :field => 'category_id',
938 937 :joins => IssueCategory.table_name)
939 938 end
940 939
941 940 def self.by_assigned_to(project)
942 941 count_and_group_by(:project => project,
943 942 :field => 'assigned_to_id',
944 943 :joins => User.table_name)
945 944 end
946 945
947 946 def self.by_author(project)
948 947 count_and_group_by(:project => project,
949 948 :field => 'author_id',
950 949 :joins => User.table_name)
951 950 end
952 951
953 952 def self.by_subproject(project)
954 953 ActiveRecord::Base.connection.select_all("select s.id as status_id,
955 954 s.is_closed as closed,
956 955 #{Issue.table_name}.project_id as project_id,
957 956 count(#{Issue.table_name}.id) as total
958 957 from
959 958 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
960 959 where
961 960 #{Issue.table_name}.status_id=s.id
962 961 and #{Issue.table_name}.project_id = #{Project.table_name}.id
963 962 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
964 963 and #{Issue.table_name}.project_id <> #{project.id}
965 964 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
966 965 end
967 966 # End ReportsController extraction
968 967
969 968 # Returns an array of projects that user can assign the issue to
970 969 def allowed_target_projects(user=User.current)
971 970 if new_record?
972 971 Project.all(:conditions => Project.allowed_to_condition(user, :add_issues))
973 972 else
974 973 self.class.allowed_target_projects_on_move(user)
975 974 end
976 975 end
977 976
978 977 # Returns an array of projects that user can move issues to
979 978 def self.allowed_target_projects_on_move(user=User.current)
980 979 Project.all(:conditions => Project.allowed_to_condition(user, :move_issues))
981 980 end
982 981
983 982 private
984 983
985 984 def after_project_change
986 985 # Update project_id on related time entries
987 986 TimeEntry.update_all(["project_id = ?", project_id], {:issue_id => id})
988 987
989 988 # Delete issue relations
990 989 unless Setting.cross_project_issue_relations?
991 990 relations_from.clear
992 991 relations_to.clear
993 992 end
994 993
995 994 # Move subtasks
996 995 children.each do |child|
997 996 # Change project and keep project
998 997 child.send :project=, project, true
999 998 unless child.save
1000 999 raise ActiveRecord::Rollback
1001 1000 end
1002 1001 end
1003 1002 end
1004 1003
1005 1004 def update_nested_set_attributes
1006 1005 if root_id.nil?
1007 1006 # issue was just created
1008 1007 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
1009 1008 set_default_left_and_right
1010 1009 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
1011 1010 if @parent_issue
1012 1011 move_to_child_of(@parent_issue)
1013 1012 end
1014 1013 reload
1015 1014 elsif parent_issue_id != parent_id
1016 1015 former_parent_id = parent_id
1017 1016 # moving an existing issue
1018 1017 if @parent_issue && @parent_issue.root_id == root_id
1019 1018 # inside the same tree
1020 1019 move_to_child_of(@parent_issue)
1021 1020 else
1022 1021 # to another tree
1023 1022 unless root?
1024 1023 move_to_right_of(root)
1025 1024 reload
1026 1025 end
1027 1026 old_root_id = root_id
1028 1027 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
1029 1028 target_maxright = nested_set_scope.maximum(right_column_name) || 0
1030 1029 offset = target_maxright + 1 - lft
1031 1030 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
1032 1031 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
1033 1032 self[left_column_name] = lft + offset
1034 1033 self[right_column_name] = rgt + offset
1035 1034 if @parent_issue
1036 1035 move_to_child_of(@parent_issue)
1037 1036 end
1038 1037 end
1039 1038 reload
1040 1039 # delete invalid relations of all descendants
1041 1040 self_and_descendants.each do |issue|
1042 1041 issue.relations.each do |relation|
1043 1042 relation.destroy unless relation.valid?
1044 1043 end
1045 1044 end
1046 1045 # update former parent
1047 1046 recalculate_attributes_for(former_parent_id) if former_parent_id
1048 1047 end
1049 1048 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1050 1049 end
1051 1050
1052 1051 def update_parent_attributes
1053 1052 recalculate_attributes_for(parent_id) if parent_id
1054 1053 end
1055 1054
1056 1055 def recalculate_attributes_for(issue_id)
1057 1056 if issue_id && p = Issue.find_by_id(issue_id)
1058 1057 # priority = highest priority of children
1059 1058 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :joins => :priority)
1060 1059 p.priority = IssuePriority.find_by_position(priority_position)
1061 1060 end
1062 1061
1063 1062 # start/due dates = lowest/highest dates of children
1064 1063 p.start_date = p.children.minimum(:start_date)
1065 1064 p.due_date = p.children.maximum(:due_date)
1066 1065 if p.start_date && p.due_date && p.due_date < p.start_date
1067 1066 p.start_date, p.due_date = p.due_date, p.start_date
1068 1067 end
1069 1068
1070 1069 # done ratio = weighted average ratio of leaves
1071 1070 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1072 1071 leaves_count = p.leaves.count
1073 1072 if leaves_count > 0
1074 1073 average = p.leaves.average(:estimated_hours).to_f
1075 1074 if average == 0
1076 1075 average = 1
1077 1076 end
1078 1077 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
1079 1078 progress = done / (average * leaves_count)
1080 1079 p.done_ratio = progress.round
1081 1080 end
1082 1081 end
1083 1082
1084 1083 # estimate = sum of leaves estimates
1085 1084 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
1086 1085 p.estimated_hours = nil if p.estimated_hours == 0.0
1087 1086
1088 1087 # ancestors will be recursively updated
1089 1088 p.save(:validate => false)
1090 1089 end
1091 1090 end
1092 1091
1093 1092 # Update issues so their versions are not pointing to a
1094 1093 # fixed_version that is not shared with the issue's project
1095 1094 def self.update_versions(conditions=nil)
1096 1095 # Only need to update issues with a fixed_version from
1097 1096 # a different project and that is not systemwide shared
1098 1097 Issue.scoped(:conditions => conditions).all(
1099 1098 :conditions => "#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1100 1099 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1101 1100 " AND #{Version.table_name}.sharing <> 'system'",
1102 1101 :include => [:project, :fixed_version]
1103 1102 ).each do |issue|
1104 1103 next if issue.project.nil? || issue.fixed_version.nil?
1105 1104 unless issue.project.shared_versions.include?(issue.fixed_version)
1106 1105 issue.init_journal(User.current)
1107 1106 issue.fixed_version = nil
1108 1107 issue.save
1109 1108 end
1110 1109 end
1111 1110 end
1112 1111
1113 1112 # Callback on attachment deletion
1114 1113 def attachment_added(obj)
1115 1114 if @current_journal && !obj.new_record?
1116 1115 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
1117 1116 end
1118 1117 end
1119 1118
1120 1119 # Callback on attachment deletion
1121 1120 def attachment_removed(obj)
1122 1121 if @current_journal && !obj.new_record?
1123 1122 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :old_value => obj.filename)
1124 1123 @current_journal.save
1125 1124 end
1126 1125 end
1127 1126
1128 1127 # Default assignment based on category
1129 1128 def default_assign
1130 1129 if assigned_to.nil? && category && category.assigned_to
1131 1130 self.assigned_to = category.assigned_to
1132 1131 end
1133 1132 end
1134 1133
1135 1134 # Updates start/due dates of following issues
1136 1135 def reschedule_following_issues
1137 1136 if start_date_changed? || due_date_changed?
1138 1137 relations_from.each do |relation|
1139 1138 relation.set_issue_to_dates
1140 1139 end
1141 1140 end
1142 1141 end
1143 1142
1144 1143 # Closes duplicates if the issue is being closed
1145 1144 def close_duplicates
1146 1145 if closing?
1147 1146 duplicates.each do |duplicate|
1148 1147 # Reload is need in case the duplicate was updated by a previous duplicate
1149 1148 duplicate.reload
1150 1149 # Don't re-close it if it's already closed
1151 1150 next if duplicate.closed?
1152 1151 # Same user and notes
1153 1152 if @current_journal
1154 1153 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1155 1154 end
1156 1155 duplicate.update_attribute :status, self.status
1157 1156 end
1158 1157 end
1159 1158 end
1160 1159
1161 1160 # Make sure updated_on is updated when adding a note
1162 1161 def force_updated_on_change
1163 1162 if @current_journal
1164 1163 self.updated_on = current_time_from_proper_timezone
1165 1164 end
1166 1165 end
1167 1166
1168 1167 # Saves the changes in a Journal
1169 1168 # Called after_save
1170 1169 def create_journal
1171 1170 if @current_journal
1172 1171 # attributes changes
1173 1172 if @attributes_before_change
1174 1173 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
1175 1174 before = @attributes_before_change[c]
1176 1175 after = send(c)
1177 1176 next if before == after || (before.blank? && after.blank?)
1178 1177 @current_journal.details << JournalDetail.new(:property => 'attr',
1179 1178 :prop_key => c,
1180 1179 :old_value => before,
1181 1180 :value => after)
1182 1181 }
1183 1182 end
1184 1183 if @custom_values_before_change
1185 1184 # custom fields changes
1186 1185 custom_field_values.each {|c|
1187 1186 before = @custom_values_before_change[c.custom_field_id]
1188 1187 after = c.value
1189 1188 next if before == after || (before.blank? && after.blank?)
1190 1189
1191 1190 if before.is_a?(Array) || after.is_a?(Array)
1192 1191 before = [before] unless before.is_a?(Array)
1193 1192 after = [after] unless after.is_a?(Array)
1194 1193
1195 1194 # values removed
1196 1195 (before - after).reject(&:blank?).each do |value|
1197 1196 @current_journal.details << JournalDetail.new(:property => 'cf',
1198 1197 :prop_key => c.custom_field_id,
1199 1198 :old_value => value,
1200 1199 :value => nil)
1201 1200 end
1202 1201 # values added
1203 1202 (after - before).reject(&:blank?).each do |value|
1204 1203 @current_journal.details << JournalDetail.new(:property => 'cf',
1205 1204 :prop_key => c.custom_field_id,
1206 1205 :old_value => nil,
1207 1206 :value => value)
1208 1207 end
1209 1208 else
1210 1209 @current_journal.details << JournalDetail.new(:property => 'cf',
1211 1210 :prop_key => c.custom_field_id,
1212 1211 :old_value => before,
1213 1212 :value => after)
1214 1213 end
1215 1214 }
1216 1215 end
1217 1216 @current_journal.save
1218 1217 # reset current journal
1219 1218 init_journal @current_journal.user, @current_journal.notes
1220 1219 end
1221 1220 end
1222 1221
1223 1222 # Query generator for selecting groups of issue counts for a project
1224 1223 # based on specific criteria
1225 1224 #
1226 1225 # Options
1227 1226 # * project - Project to search in.
1228 1227 # * field - String. Issue field to key off of in the grouping.
1229 1228 # * joins - String. The table name to join against.
1230 1229 def self.count_and_group_by(options)
1231 1230 project = options.delete(:project)
1232 1231 select_field = options.delete(:field)
1233 1232 joins = options.delete(:joins)
1234 1233
1235 1234 where = "#{Issue.table_name}.#{select_field}=j.id"
1236 1235
1237 1236 ActiveRecord::Base.connection.select_all("select s.id as status_id,
1238 1237 s.is_closed as closed,
1239 1238 j.id as #{select_field},
1240 1239 count(#{Issue.table_name}.id) as total
1241 1240 from
1242 1241 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
1243 1242 where
1244 1243 #{Issue.table_name}.status_id=s.id
1245 1244 and #{where}
1246 1245 and #{Issue.table_name}.project_id=#{Project.table_name}.id
1247 1246 and #{visible_condition(User.current, :project => project)}
1248 1247 group by s.id, s.is_closed, j.id")
1249 1248 end
1250 1249 end
@@ -1,279 +1,278
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 Version < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20 after_update :update_issues_from_sharing_change
21 21 belongs_to :project
22 22 has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id', :dependent => :nullify
23 23 acts_as_customizable
24 24 acts_as_attachable :view_permission => :view_files,
25 25 :delete_permission => :manage_files
26 26
27 27 VERSION_STATUSES = %w(open locked closed)
28 28 VERSION_SHARINGS = %w(none descendants hierarchy tree system)
29 29
30 30 validates_presence_of :name
31 31 validates_uniqueness_of :name, :scope => [:project_id]
32 32 validates_length_of :name, :maximum => 60
33 33 validates_format_of :effective_date, :with => /^\d{4}-\d{2}-\d{2}$/, :message => :not_a_date, :allow_nil => true
34 34 validates_inclusion_of :status, :in => VERSION_STATUSES
35 35 validates_inclusion_of :sharing, :in => VERSION_SHARINGS
36 36
37 37 scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
38 class << self; undef :open; end
39 38 scope :open, :conditions => {:status => 'open'}
40 39 scope :visible, lambda {|*args| { :include => :project,
41 40 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
42 41
43 42 safe_attributes 'name',
44 43 'description',
45 44 'effective_date',
46 45 'due_date',
47 46 'wiki_page_title',
48 47 'status',
49 48 'sharing',
50 49 'custom_field_values'
51 50
52 51 # Returns true if +user+ or current user is allowed to view the version
53 52 def visible?(user=User.current)
54 53 user.allowed_to?(:view_issues, self.project)
55 54 end
56 55
57 56 # Version files have same visibility as project files
58 57 def attachments_visible?(*args)
59 58 project.present? && project.attachments_visible?(*args)
60 59 end
61 60
62 61 def start_date
63 62 @start_date ||= fixed_issues.minimum('start_date')
64 63 end
65 64
66 65 def due_date
67 66 effective_date
68 67 end
69 68
70 69 def due_date=(arg)
71 70 self.effective_date=(arg)
72 71 end
73 72
74 73 # Returns the total estimated time for this version
75 74 # (sum of leaves estimated_hours)
76 75 def estimated_hours
77 76 @estimated_hours ||= fixed_issues.leaves.sum(:estimated_hours).to_f
78 77 end
79 78
80 79 # Returns the total reported time for this version
81 80 def spent_hours
82 81 @spent_hours ||= TimeEntry.sum(:hours, :joins => :issue, :conditions => ["#{Issue.table_name}.fixed_version_id = ?", id]).to_f
83 82 end
84 83
85 84 def closed?
86 85 status == 'closed'
87 86 end
88 87
89 88 def open?
90 89 status == 'open'
91 90 end
92 91
93 92 # Returns true if the version is completed: due date reached and no open issues
94 93 def completed?
95 94 effective_date && (effective_date <= Date.today) && (open_issues_count == 0)
96 95 end
97 96
98 97 def behind_schedule?
99 98 if completed_pourcent == 100
100 99 return false
101 100 elsif due_date && start_date
102 101 done_date = start_date + ((due_date - start_date+1)* completed_pourcent/100).floor
103 102 return done_date <= Date.today
104 103 else
105 104 false # No issues so it's not late
106 105 end
107 106 end
108 107
109 108 # Returns the completion percentage of this version based on the amount of open/closed issues
110 109 # and the time spent on the open issues.
111 110 def completed_pourcent
112 111 if issues_count == 0
113 112 0
114 113 elsif open_issues_count == 0
115 114 100
116 115 else
117 116 issues_progress(false) + issues_progress(true)
118 117 end
119 118 end
120 119
121 120 # Returns the percentage of issues that have been marked as 'closed'.
122 121 def closed_pourcent
123 122 if issues_count == 0
124 123 0
125 124 else
126 125 issues_progress(false)
127 126 end
128 127 end
129 128
130 129 # Returns true if the version is overdue: due date reached and some open issues
131 130 def overdue?
132 131 effective_date && (effective_date < Date.today) && (open_issues_count > 0)
133 132 end
134 133
135 134 # Returns assigned issues count
136 135 def issues_count
137 136 load_issue_counts
138 137 @issue_count
139 138 end
140 139
141 140 # Returns the total amount of open issues for this version.
142 141 def open_issues_count
143 142 load_issue_counts
144 143 @open_issues_count
145 144 end
146 145
147 146 # Returns the total amount of closed issues for this version.
148 147 def closed_issues_count
149 148 load_issue_counts
150 149 @closed_issues_count
151 150 end
152 151
153 152 def wiki_page
154 153 if project.wiki && !wiki_page_title.blank?
155 154 @wiki_page ||= project.wiki.find_page(wiki_page_title)
156 155 end
157 156 @wiki_page
158 157 end
159 158
160 159 def to_s; name end
161 160
162 161 def to_s_with_project
163 162 "#{project} - #{name}"
164 163 end
165 164
166 165 # Versions are sorted by effective_date and name
167 166 # Those with no effective_date are at the end, sorted by name
168 167 def <=>(version)
169 168 if self.effective_date
170 169 if version.effective_date
171 170 if self.effective_date == version.effective_date
172 171 name == version.name ? id <=> version.id : name <=> version.name
173 172 else
174 173 self.effective_date <=> version.effective_date
175 174 end
176 175 else
177 176 -1
178 177 end
179 178 else
180 179 if version.effective_date
181 180 1
182 181 else
183 182 name == version.name ? id <=> version.id : name <=> version.name
184 183 end
185 184 end
186 185 end
187 186
188 187 def self.fields_for_order_statement(table=nil)
189 188 table ||= table_name
190 189 ["(CASE WHEN #{table}.effective_date IS NULL THEN 1 ELSE 0 END)", "#{table}.effective_date", "#{table}.name", "#{table}.id"]
191 190 end
192 191
193 192 scope :sorted, order(fields_for_order_statement)
194 193
195 194 # Returns the sharings that +user+ can set the version to
196 195 def allowed_sharings(user = User.current)
197 196 VERSION_SHARINGS.select do |s|
198 197 if sharing == s
199 198 true
200 199 else
201 200 case s
202 201 when 'system'
203 202 # Only admin users can set a systemwide sharing
204 203 user.admin?
205 204 when 'hierarchy', 'tree'
206 205 # Only users allowed to manage versions of the root project can
207 206 # set sharing to hierarchy or tree
208 207 project.nil? || user.allowed_to?(:manage_versions, project.root)
209 208 else
210 209 true
211 210 end
212 211 end
213 212 end
214 213 end
215 214
216 215 private
217 216
218 217 def load_issue_counts
219 218 unless @issue_count
220 219 @open_issues_count = 0
221 220 @closed_issues_count = 0
222 221 fixed_issues.count(:all, :group => :status).each do |status, count|
223 222 if status.is_closed?
224 223 @closed_issues_count += count
225 224 else
226 225 @open_issues_count += count
227 226 end
228 227 end
229 228 @issue_count = @open_issues_count + @closed_issues_count
230 229 end
231 230 end
232 231
233 232 # Update the issue's fixed versions. Used if a version's sharing changes.
234 233 def update_issues_from_sharing_change
235 234 if sharing_changed?
236 235 if VERSION_SHARINGS.index(sharing_was).nil? ||
237 236 VERSION_SHARINGS.index(sharing).nil? ||
238 237 VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
239 238 Issue.update_versions_from_sharing_change self
240 239 end
241 240 end
242 241 end
243 242
244 243 # Returns the average estimated time of assigned issues
245 244 # or 1 if no issue has an estimated time
246 245 # Used to weigth unestimated issues in progress calculation
247 246 def estimated_average
248 247 if @estimated_average.nil?
249 248 average = fixed_issues.average(:estimated_hours).to_f
250 249 if average == 0
251 250 average = 1
252 251 end
253 252 @estimated_average = average
254 253 end
255 254 @estimated_average
256 255 end
257 256
258 257 # Returns the total progress of open or closed issues. The returned percentage takes into account
259 258 # the amount of estimated time set for this version.
260 259 #
261 260 # Examples:
262 261 # issues_progress(true) => returns the progress percentage for open issues.
263 262 # issues_progress(false) => returns the progress percentage for closed issues.
264 263 def issues_progress(open)
265 264 @issues_progress ||= {}
266 265 @issues_progress[open] ||= begin
267 266 progress = 0
268 267 if issues_count > 0
269 268 ratio = open ? 'done_ratio' : 100
270 269
271 270 done = fixed_issues.sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}",
272 271 :joins => :status,
273 272 :conditions => ["#{IssueStatus.table_name}.is_closed = ?", !open]).to_f
274 273 progress = done / (estimated_average * issues_count)
275 274 end
276 275 progress
277 276 end
278 277 end
279 278 end
@@ -1,105 +1,110
1 1 require 'active_record'
2 2
3 3 module ActiveRecord
4 4 class Base
5 5 include Redmine::I18n
6 6 # Translate attribute names for validation errors display
7 7 def self.human_attribute_name(attr, *args)
8 8 attr = attr.to_s.sub(/_id$/, '')
9 9
10 10 l("field_#{name.underscore.gsub('/', '_')}_#{attr}", :default => ["field_#{attr}".to_sym, attr])
11 11 end
12 12 end
13
14 # Undefines private Kernel#open method to allow using `open` scopes in models.
15 # See Defect #11545 (http://www.redmine.org/issues/11545) for details.
16 class Base ; undef open ; end
17 class Relation ; undef open ; end
13 18 end
14 19
15 20 module ActionView
16 21 module Helpers
17 22 module DateHelper
18 23 # distance_of_time_in_words breaks when difference is greater than 30 years
19 24 def distance_of_date_in_words(from_date, to_date = 0, options = {})
20 25 from_date = from_date.to_date if from_date.respond_to?(:to_date)
21 26 to_date = to_date.to_date if to_date.respond_to?(:to_date)
22 27 distance_in_days = (to_date - from_date).abs
23 28
24 29 I18n.with_options :locale => options[:locale], :scope => :'datetime.distance_in_words' do |locale|
25 30 case distance_in_days
26 31 when 0..60 then locale.t :x_days, :count => distance_in_days.round
27 32 when 61..720 then locale.t :about_x_months, :count => (distance_in_days / 30).round
28 33 else locale.t :over_x_years, :count => (distance_in_days / 365).floor
29 34 end
30 35 end
31 36 end
32 37 end
33 38 end
34 39
35 40 class Resolver
36 41 def find_all(name, prefix=nil, partial=false, details={}, key=nil, locals=[])
37 42 cached(key, [name, prefix, partial], details, locals) do
38 43 if details[:formats] & [:xml, :json]
39 44 details = details.dup
40 45 details[:formats] = details[:formats].dup + [:api]
41 46 end
42 47 find_templates(name, prefix, partial, details)
43 48 end
44 49 end
45 50 end
46 51 end
47 52
48 53 ActionView::Base.field_error_proc = Proc.new{ |html_tag, instance| html_tag || ''.html_safe }
49 54
50 55 require 'mail'
51 56
52 57 module DeliveryMethods
53 58 class AsyncSMTP < ::Mail::SMTP
54 59 def deliver!(*args)
55 60 Thread.start do
56 61 super *args
57 62 end
58 63 end
59 64 end
60 65
61 66 class AsyncSendmail < ::Mail::Sendmail
62 67 def deliver!(*args)
63 68 Thread.start do
64 69 super *args
65 70 end
66 71 end
67 72 end
68 73
69 74 class TmpFile
70 75 def initialize(*args); end
71 76
72 77 def deliver!(mail)
73 78 dest_dir = File.join(Rails.root, 'tmp', 'emails')
74 79 Dir.mkdir(dest_dir) unless File.directory?(dest_dir)
75 80 File.open(File.join(dest_dir, mail.message_id.gsub(/[<>]/, '') + '.eml'), 'wb') {|f| f.write(mail.encoded) }
76 81 end
77 82 end
78 83 end
79 84
80 85 ActionMailer::Base.add_delivery_method :async_smtp, DeliveryMethods::AsyncSMTP
81 86 ActionMailer::Base.add_delivery_method :async_sendmail, DeliveryMethods::AsyncSendmail
82 87 ActionMailer::Base.add_delivery_method :tmp_file, DeliveryMethods::TmpFile
83 88
84 89 module ActionController
85 90 module MimeResponds
86 91 class Collector
87 92 def api(&block)
88 93 any(:xml, :json, &block)
89 94 end
90 95 end
91 96 end
92 97 end
93 98
94 99 module ActionController
95 100 class Base
96 101 # Displays an explicit message instead of a NoMethodError exception
97 102 # when trying to start Redmine with an old session_store.rb
98 103 # TODO: remove it in a later version
99 104 def self.session=(*args)
100 105 $stderr.puts "Please remove config/initializers/session_store.rb and run `rake generate_secret_token`.\n" +
101 106 "Setting the session secret with ActionController.session= is no longer supported in Rails 3."
102 107 exit 1
103 108 end
104 109 end
105 110 end
@@ -1,1176 +1,1180
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 ProjectTest < ActiveSupport::TestCase
21 21 fixtures :projects, :trackers, :issue_statuses, :issues,
22 22 :journals, :journal_details,
23 23 :enumerations, :users, :issue_categories,
24 24 :projects_trackers,
25 25 :custom_fields,
26 26 :custom_fields_projects,
27 27 :custom_fields_trackers,
28 28 :custom_values,
29 29 :roles,
30 30 :member_roles,
31 31 :members,
32 32 :enabled_modules,
33 33 :workflows,
34 34 :versions,
35 35 :wikis, :wiki_pages, :wiki_contents, :wiki_content_versions,
36 36 :groups_users,
37 37 :boards,
38 38 :repositories
39 39
40 40 def setup
41 41 @ecookbook = Project.find(1)
42 42 @ecookbook_sub1 = Project.find(3)
43 43 set_tmp_attachments_directory
44 44 User.current = nil
45 45 end
46 46
47 47 def test_truth
48 48 assert_kind_of Project, @ecookbook
49 49 assert_equal "eCookbook", @ecookbook.name
50 50 end
51 51
52 52 def test_default_attributes
53 53 with_settings :default_projects_public => '1' do
54 54 assert_equal true, Project.new.is_public
55 55 assert_equal false, Project.new(:is_public => false).is_public
56 56 end
57 57
58 58 with_settings :default_projects_public => '0' do
59 59 assert_equal false, Project.new.is_public
60 60 assert_equal true, Project.new(:is_public => true).is_public
61 61 end
62 62
63 63 with_settings :sequential_project_identifiers => '1' do
64 64 assert !Project.new.identifier.blank?
65 65 assert Project.new(:identifier => '').identifier.blank?
66 66 end
67 67
68 68 with_settings :sequential_project_identifiers => '0' do
69 69 assert Project.new.identifier.blank?
70 70 assert !Project.new(:identifier => 'test').blank?
71 71 end
72 72
73 73 with_settings :default_projects_modules => ['issue_tracking', 'repository'] do
74 74 assert_equal ['issue_tracking', 'repository'], Project.new.enabled_module_names
75 75 end
76 76
77 77 assert_equal Tracker.all.sort, Project.new.trackers.sort
78 78 assert_equal Tracker.find(1, 3).sort, Project.new(:tracker_ids => [1, 3]).trackers.sort
79 79 end
80 80
81 81 def test_update
82 82 assert_equal "eCookbook", @ecookbook.name
83 83 @ecookbook.name = "eCook"
84 84 assert @ecookbook.save, @ecookbook.errors.full_messages.join("; ")
85 85 @ecookbook.reload
86 86 assert_equal "eCook", @ecookbook.name
87 87 end
88 88
89 89 def test_validate_identifier
90 90 to_test = {"abc" => true,
91 91 "ab12" => true,
92 92 "ab-12" => true,
93 93 "ab_12" => true,
94 94 "12" => false,
95 95 "new" => false}
96 96
97 97 to_test.each do |identifier, valid|
98 98 p = Project.new
99 99 p.identifier = identifier
100 100 p.valid?
101 101 if valid
102 102 assert p.errors['identifier'].blank?, "identifier #{identifier} was not valid"
103 103 else
104 104 assert p.errors['identifier'].present?, "identifier #{identifier} was valid"
105 105 end
106 106 end
107 107 end
108 108
109 109 def test_identifier_should_not_be_frozen_for_a_new_project
110 110 assert_equal false, Project.new.identifier_frozen?
111 111 end
112 112
113 113 def test_identifier_should_not_be_frozen_for_a_saved_project_with_blank_identifier
114 114 Project.update_all(["identifier = ''"], "id = 1")
115 115
116 116 assert_equal false, Project.find(1).identifier_frozen?
117 117 end
118 118
119 119 def test_identifier_should_be_frozen_for_a_saved_project_with_valid_identifier
120 120 assert_equal true, Project.find(1).identifier_frozen?
121 121 end
122 122
123 123 def test_members_should_be_active_users
124 124 Project.all.each do |project|
125 125 assert_nil project.members.detect {|m| !(m.user.is_a?(User) && m.user.active?) }
126 126 end
127 127 end
128 128
129 129 def test_users_should_be_active_users
130 130 Project.all.each do |project|
131 131 assert_nil project.users.detect {|u| !(u.is_a?(User) && u.active?) }
132 132 end
133 133 end
134 134
135 def test_open_scope_on_issues_association
136 assert_kind_of Issue, Project.find(1).issues.open.first
137 end
138
135 139 def test_archive
136 140 user = @ecookbook.members.first.user
137 141 @ecookbook.archive
138 142 @ecookbook.reload
139 143
140 144 assert !@ecookbook.active?
141 145 assert @ecookbook.archived?
142 146 assert !user.projects.include?(@ecookbook)
143 147 # Subproject are also archived
144 148 assert !@ecookbook.children.empty?
145 149 assert @ecookbook.descendants.active.empty?
146 150 end
147 151
148 152 def test_archive_should_fail_if_versions_are_used_by_non_descendant_projects
149 153 # Assign an issue of a project to a version of a child project
150 154 Issue.find(4).update_attribute :fixed_version_id, 4
151 155
152 156 assert_no_difference "Project.count(:all, :conditions => 'status = #{Project::STATUS_ARCHIVED}')" do
153 157 assert_equal false, @ecookbook.archive
154 158 end
155 159 @ecookbook.reload
156 160 assert @ecookbook.active?
157 161 end
158 162
159 163 def test_unarchive
160 164 user = @ecookbook.members.first.user
161 165 @ecookbook.archive
162 166 # A subproject of an archived project can not be unarchived
163 167 assert !@ecookbook_sub1.unarchive
164 168
165 169 # Unarchive project
166 170 assert @ecookbook.unarchive
167 171 @ecookbook.reload
168 172 assert @ecookbook.active?
169 173 assert !@ecookbook.archived?
170 174 assert user.projects.include?(@ecookbook)
171 175 # Subproject can now be unarchived
172 176 @ecookbook_sub1.reload
173 177 assert @ecookbook_sub1.unarchive
174 178 end
175 179
176 180 def test_destroy
177 181 # 2 active members
178 182 assert_equal 2, @ecookbook.members.size
179 183 # and 1 is locked
180 184 assert_equal 3, Member.find(:all, :conditions => ['project_id = ?', @ecookbook.id]).size
181 185 # some boards
182 186 assert @ecookbook.boards.any?
183 187
184 188 @ecookbook.destroy
185 189 # make sure that the project non longer exists
186 190 assert_raise(ActiveRecord::RecordNotFound) { Project.find(@ecookbook.id) }
187 191 # make sure related data was removed
188 192 assert_nil Member.first(:conditions => {:project_id => @ecookbook.id})
189 193 assert_nil Board.first(:conditions => {:project_id => @ecookbook.id})
190 194 assert_nil Issue.first(:conditions => {:project_id => @ecookbook.id})
191 195 end
192 196
193 197 def test_destroy_should_destroy_subtasks
194 198 issues = (0..2).to_a.map {Issue.create!(:project_id => 1, :tracker_id => 1, :author_id => 1, :subject => 'test')}
195 199 issues[0].update_attribute :parent_issue_id, issues[1].id
196 200 issues[2].update_attribute :parent_issue_id, issues[1].id
197 201 assert_equal 2, issues[1].children.count
198 202
199 203 assert_nothing_raised do
200 204 Project.find(1).destroy
201 205 end
202 206 assert Issue.find_all_by_id(issues.map(&:id)).empty?
203 207 end
204 208
205 209 def test_destroying_root_projects_should_clear_data
206 210 Project.roots.each do |root|
207 211 root.destroy
208 212 end
209 213
210 214 assert_equal 0, Project.count, "Projects were not deleted: #{Project.all.inspect}"
211 215 assert_equal 0, Member.count, "Members were not deleted: #{Member.all.inspect}"
212 216 assert_equal 0, MemberRole.count
213 217 assert_equal 0, Issue.count
214 218 assert_equal 0, Journal.count
215 219 assert_equal 0, JournalDetail.count
216 220 assert_equal 0, Attachment.count
217 221 assert_equal 0, EnabledModule.count
218 222 assert_equal 0, IssueCategory.count
219 223 assert_equal 0, IssueRelation.count
220 224 assert_equal 0, Board.count
221 225 assert_equal 0, Message.count
222 226 assert_equal 0, News.count
223 227 assert_equal 0, Query.count(:conditions => "project_id IS NOT NULL")
224 228 assert_equal 0, Repository.count
225 229 assert_equal 0, Changeset.count
226 230 assert_equal 0, Change.count
227 231 assert_equal 0, Comment.count
228 232 assert_equal 0, TimeEntry.count
229 233 assert_equal 0, Version.count
230 234 assert_equal 0, Watcher.count
231 235 assert_equal 0, Wiki.count
232 236 assert_equal 0, WikiPage.count
233 237 assert_equal 0, WikiContent.count
234 238 assert_equal 0, WikiContent::Version.count
235 239 assert_equal 0, Project.connection.select_all("SELECT * FROM projects_trackers").size
236 240 assert_equal 0, Project.connection.select_all("SELECT * FROM custom_fields_projects").size
237 241 assert_equal 0, CustomValue.count(:conditions => {:customized_type => ['Project', 'Issue', 'TimeEntry', 'Version']})
238 242 end
239 243
240 244 def test_move_an_orphan_project_to_a_root_project
241 245 sub = Project.find(2)
242 246 sub.set_parent! @ecookbook
243 247 assert_equal @ecookbook.id, sub.parent.id
244 248 @ecookbook.reload
245 249 assert_equal 4, @ecookbook.children.size
246 250 end
247 251
248 252 def test_move_an_orphan_project_to_a_subproject
249 253 sub = Project.find(2)
250 254 assert sub.set_parent!(@ecookbook_sub1)
251 255 end
252 256
253 257 def test_move_a_root_project_to_a_project
254 258 sub = @ecookbook
255 259 assert sub.set_parent!(Project.find(2))
256 260 end
257 261
258 262 def test_should_not_move_a_project_to_its_children
259 263 sub = @ecookbook
260 264 assert !(sub.set_parent!(Project.find(3)))
261 265 end
262 266
263 267 def test_set_parent_should_add_roots_in_alphabetical_order
264 268 ProjectCustomField.delete_all
265 269 Project.delete_all
266 270 Project.create!(:name => 'Project C', :identifier => 'project-c').set_parent!(nil)
267 271 Project.create!(:name => 'Project B', :identifier => 'project-b').set_parent!(nil)
268 272 Project.create!(:name => 'Project D', :identifier => 'project-d').set_parent!(nil)
269 273 Project.create!(:name => 'Project A', :identifier => 'project-a').set_parent!(nil)
270 274
271 275 assert_equal 4, Project.count
272 276 assert_equal Project.all.sort_by(&:name), Project.all.sort_by(&:lft)
273 277 end
274 278
275 279 def test_set_parent_should_add_children_in_alphabetical_order
276 280 ProjectCustomField.delete_all
277 281 parent = Project.create!(:name => 'Parent', :identifier => 'parent')
278 282 Project.create!(:name => 'Project C', :identifier => 'project-c').set_parent!(parent)
279 283 Project.create!(:name => 'Project B', :identifier => 'project-b').set_parent!(parent)
280 284 Project.create!(:name => 'Project D', :identifier => 'project-d').set_parent!(parent)
281 285 Project.create!(:name => 'Project A', :identifier => 'project-a').set_parent!(parent)
282 286
283 287 parent.reload
284 288 assert_equal 4, parent.children.size
285 289 assert_equal parent.children.all.sort_by(&:name), parent.children.all
286 290 end
287 291
288 292 def test_rebuild_should_sort_children_alphabetically
289 293 ProjectCustomField.delete_all
290 294 parent = Project.create!(:name => 'Parent', :identifier => 'parent')
291 295 Project.create!(:name => 'Project C', :identifier => 'project-c').move_to_child_of(parent)
292 296 Project.create!(:name => 'Project B', :identifier => 'project-b').move_to_child_of(parent)
293 297 Project.create!(:name => 'Project D', :identifier => 'project-d').move_to_child_of(parent)
294 298 Project.create!(:name => 'Project A', :identifier => 'project-a').move_to_child_of(parent)
295 299
296 300 Project.update_all("lft = NULL, rgt = NULL")
297 301 Project.rebuild!
298 302
299 303 parent.reload
300 304 assert_equal 4, parent.children.size
301 305 assert_equal parent.children.all.sort_by(&:name), parent.children.all
302 306 end
303 307
304 308
305 309 def test_set_parent_should_update_issue_fixed_version_associations_when_a_fixed_version_is_moved_out_of_the_hierarchy
306 310 # Parent issue with a hierarchy project's fixed version
307 311 parent_issue = Issue.find(1)
308 312 parent_issue.update_attribute(:fixed_version_id, 4)
309 313 parent_issue.reload
310 314 assert_equal 4, parent_issue.fixed_version_id
311 315
312 316 # Should keep fixed versions for the issues
313 317 issue_with_local_fixed_version = Issue.find(5)
314 318 issue_with_local_fixed_version.update_attribute(:fixed_version_id, 4)
315 319 issue_with_local_fixed_version.reload
316 320 assert_equal 4, issue_with_local_fixed_version.fixed_version_id
317 321
318 322 # Local issue with hierarchy fixed_version
319 323 issue_with_hierarchy_fixed_version = Issue.find(13)
320 324 issue_with_hierarchy_fixed_version.update_attribute(:fixed_version_id, 6)
321 325 issue_with_hierarchy_fixed_version.reload
322 326 assert_equal 6, issue_with_hierarchy_fixed_version.fixed_version_id
323 327
324 328 # Move project out of the issue's hierarchy
325 329 moved_project = Project.find(3)
326 330 moved_project.set_parent!(Project.find(2))
327 331 parent_issue.reload
328 332 issue_with_local_fixed_version.reload
329 333 issue_with_hierarchy_fixed_version.reload
330 334
331 335 assert_equal 4, issue_with_local_fixed_version.fixed_version_id, "Fixed version was not keep on an issue local to the moved project"
332 336 assert_equal nil, issue_with_hierarchy_fixed_version.fixed_version_id, "Fixed version is still set after moving the Project out of the hierarchy where the version is defined in"
333 337 assert_equal nil, parent_issue.fixed_version_id, "Fixed version is still set after moving the Version out of the hierarchy for the issue."
334 338 end
335 339
336 340 def test_parent
337 341 p = Project.find(6).parent
338 342 assert p.is_a?(Project)
339 343 assert_equal 5, p.id
340 344 end
341 345
342 346 def test_ancestors
343 347 a = Project.find(6).ancestors
344 348 assert a.first.is_a?(Project)
345 349 assert_equal [1, 5], a.collect(&:id)
346 350 end
347 351
348 352 def test_root
349 353 r = Project.find(6).root
350 354 assert r.is_a?(Project)
351 355 assert_equal 1, r.id
352 356 end
353 357
354 358 def test_children
355 359 c = Project.find(1).children
356 360 assert c.first.is_a?(Project)
357 361 assert_equal [5, 3, 4], c.collect(&:id)
358 362 end
359 363
360 364 def test_descendants
361 365 d = Project.find(1).descendants
362 366 assert d.first.is_a?(Project)
363 367 assert_equal [5, 6, 3, 4], d.collect(&:id)
364 368 end
365 369
366 370 def test_allowed_parents_should_be_empty_for_non_member_user
367 371 Role.non_member.add_permission!(:add_project)
368 372 user = User.find(9)
369 373 assert user.memberships.empty?
370 374 User.current = user
371 375 assert Project.new.allowed_parents.compact.empty?
372 376 end
373 377
374 378 def test_allowed_parents_with_add_subprojects_permission
375 379 Role.find(1).remove_permission!(:add_project)
376 380 Role.find(1).add_permission!(:add_subprojects)
377 381 User.current = User.find(2)
378 382 # new project
379 383 assert !Project.new.allowed_parents.include?(nil)
380 384 assert Project.new.allowed_parents.include?(Project.find(1))
381 385 # existing root project
382 386 assert Project.find(1).allowed_parents.include?(nil)
383 387 # existing child
384 388 assert Project.find(3).allowed_parents.include?(Project.find(1))
385 389 assert !Project.find(3).allowed_parents.include?(nil)
386 390 end
387 391
388 392 def test_allowed_parents_with_add_project_permission
389 393 Role.find(1).add_permission!(:add_project)
390 394 Role.find(1).remove_permission!(:add_subprojects)
391 395 User.current = User.find(2)
392 396 # new project
393 397 assert Project.new.allowed_parents.include?(nil)
394 398 assert !Project.new.allowed_parents.include?(Project.find(1))
395 399 # existing root project
396 400 assert Project.find(1).allowed_parents.include?(nil)
397 401 # existing child
398 402 assert Project.find(3).allowed_parents.include?(Project.find(1))
399 403 assert Project.find(3).allowed_parents.include?(nil)
400 404 end
401 405
402 406 def test_allowed_parents_with_add_project_and_subprojects_permission
403 407 Role.find(1).add_permission!(:add_project)
404 408 Role.find(1).add_permission!(:add_subprojects)
405 409 User.current = User.find(2)
406 410 # new project
407 411 assert Project.new.allowed_parents.include?(nil)
408 412 assert Project.new.allowed_parents.include?(Project.find(1))
409 413 # existing root project
410 414 assert Project.find(1).allowed_parents.include?(nil)
411 415 # existing child
412 416 assert Project.find(3).allowed_parents.include?(Project.find(1))
413 417 assert Project.find(3).allowed_parents.include?(nil)
414 418 end
415 419
416 420 def test_users_by_role
417 421 users_by_role = Project.find(1).users_by_role
418 422 assert_kind_of Hash, users_by_role
419 423 role = Role.find(1)
420 424 assert_kind_of Array, users_by_role[role]
421 425 assert users_by_role[role].include?(User.find(2))
422 426 end
423 427
424 428 def test_rolled_up_trackers
425 429 parent = Project.find(1)
426 430 parent.trackers = Tracker.find([1,2])
427 431 child = parent.children.find(3)
428 432
429 433 assert_equal [1, 2], parent.tracker_ids
430 434 assert_equal [2, 3], child.trackers.collect(&:id)
431 435
432 436 assert_kind_of Tracker, parent.rolled_up_trackers.first
433 437 assert_equal Tracker.find(1), parent.rolled_up_trackers.first
434 438
435 439 assert_equal [1, 2, 3], parent.rolled_up_trackers.collect(&:id)
436 440 assert_equal [2, 3], child.rolled_up_trackers.collect(&:id)
437 441 end
438 442
439 443 def test_rolled_up_trackers_should_ignore_archived_subprojects
440 444 parent = Project.find(1)
441 445 parent.trackers = Tracker.find([1,2])
442 446 child = parent.children.find(3)
443 447 child.trackers = Tracker.find([1,3])
444 448 parent.children.each(&:archive)
445 449
446 450 assert_equal [1,2], parent.rolled_up_trackers.collect(&:id)
447 451 end
448 452
449 453 context "#rolled_up_versions" do
450 454 setup do
451 455 @project = Project.generate!
452 456 @parent_version_1 = Version.generate!(:project => @project)
453 457 @parent_version_2 = Version.generate!(:project => @project)
454 458 end
455 459
456 460 should "include the versions for the current project" do
457 461 assert_same_elements [@parent_version_1, @parent_version_2], @project.rolled_up_versions
458 462 end
459 463
460 464 should "include versions for a subproject" do
461 465 @subproject = Project.generate!
462 466 @subproject.set_parent!(@project)
463 467 @subproject_version = Version.generate!(:project => @subproject)
464 468
465 469 assert_same_elements [
466 470 @parent_version_1,
467 471 @parent_version_2,
468 472 @subproject_version
469 473 ], @project.rolled_up_versions
470 474 end
471 475
472 476 should "include versions for a sub-subproject" do
473 477 @subproject = Project.generate!
474 478 @subproject.set_parent!(@project)
475 479 @sub_subproject = Project.generate!
476 480 @sub_subproject.set_parent!(@subproject)
477 481 @sub_subproject_version = Version.generate!(:project => @sub_subproject)
478 482
479 483 @project.reload
480 484
481 485 assert_same_elements [
482 486 @parent_version_1,
483 487 @parent_version_2,
484 488 @sub_subproject_version
485 489 ], @project.rolled_up_versions
486 490 end
487 491
488 492 should "only check active projects" do
489 493 @subproject = Project.generate!
490 494 @subproject.set_parent!(@project)
491 495 @subproject_version = Version.generate!(:project => @subproject)
492 496 assert @subproject.archive
493 497
494 498 @project.reload
495 499
496 500 assert !@subproject.active?
497 501 assert_same_elements [@parent_version_1, @parent_version_2], @project.rolled_up_versions
498 502 end
499 503 end
500 504
501 505 def test_shared_versions_none_sharing
502 506 p = Project.find(5)
503 507 v = Version.create!(:name => 'none_sharing', :project => p, :sharing => 'none')
504 508 assert p.shared_versions.include?(v)
505 509 assert !p.children.first.shared_versions.include?(v)
506 510 assert !p.root.shared_versions.include?(v)
507 511 assert !p.siblings.first.shared_versions.include?(v)
508 512 assert !p.root.siblings.first.shared_versions.include?(v)
509 513 end
510 514
511 515 def test_shared_versions_descendants_sharing
512 516 p = Project.find(5)
513 517 v = Version.create!(:name => 'descendants_sharing', :project => p, :sharing => 'descendants')
514 518 assert p.shared_versions.include?(v)
515 519 assert p.children.first.shared_versions.include?(v)
516 520 assert !p.root.shared_versions.include?(v)
517 521 assert !p.siblings.first.shared_versions.include?(v)
518 522 assert !p.root.siblings.first.shared_versions.include?(v)
519 523 end
520 524
521 525 def test_shared_versions_hierarchy_sharing
522 526 p = Project.find(5)
523 527 v = Version.create!(:name => 'hierarchy_sharing', :project => p, :sharing => 'hierarchy')
524 528 assert p.shared_versions.include?(v)
525 529 assert p.children.first.shared_versions.include?(v)
526 530 assert p.root.shared_versions.include?(v)
527 531 assert !p.siblings.first.shared_versions.include?(v)
528 532 assert !p.root.siblings.first.shared_versions.include?(v)
529 533 end
530 534
531 535 def test_shared_versions_tree_sharing
532 536 p = Project.find(5)
533 537 v = Version.create!(:name => 'tree_sharing', :project => p, :sharing => 'tree')
534 538 assert p.shared_versions.include?(v)
535 539 assert p.children.first.shared_versions.include?(v)
536 540 assert p.root.shared_versions.include?(v)
537 541 assert p.siblings.first.shared_versions.include?(v)
538 542 assert !p.root.siblings.first.shared_versions.include?(v)
539 543 end
540 544
541 545 def test_shared_versions_system_sharing
542 546 p = Project.find(5)
543 547 v = Version.create!(:name => 'system_sharing', :project => p, :sharing => 'system')
544 548 assert p.shared_versions.include?(v)
545 549 assert p.children.first.shared_versions.include?(v)
546 550 assert p.root.shared_versions.include?(v)
547 551 assert p.siblings.first.shared_versions.include?(v)
548 552 assert p.root.siblings.first.shared_versions.include?(v)
549 553 end
550 554
551 555 def test_shared_versions
552 556 parent = Project.find(1)
553 557 child = parent.children.find(3)
554 558 private_child = parent.children.find(5)
555 559
556 560 assert_equal [1,2,3], parent.version_ids.sort
557 561 assert_equal [4], child.version_ids
558 562 assert_equal [6], private_child.version_ids
559 563 assert_equal [7], Version.find_all_by_sharing('system').collect(&:id)
560 564
561 565 assert_equal 6, parent.shared_versions.size
562 566 parent.shared_versions.each do |version|
563 567 assert_kind_of Version, version
564 568 end
565 569
566 570 assert_equal [1,2,3,4,6,7], parent.shared_versions.collect(&:id).sort
567 571 end
568 572
569 573 def test_shared_versions_should_ignore_archived_subprojects
570 574 parent = Project.find(1)
571 575 child = parent.children.find(3)
572 576 child.archive
573 577 parent.reload
574 578
575 579 assert_equal [1,2,3], parent.version_ids.sort
576 580 assert_equal [4], child.version_ids
577 581 assert !parent.shared_versions.collect(&:id).include?(4)
578 582 end
579 583
580 584 def test_shared_versions_visible_to_user
581 585 user = User.find(3)
582 586 parent = Project.find(1)
583 587 child = parent.children.find(5)
584 588
585 589 assert_equal [1,2,3], parent.version_ids.sort
586 590 assert_equal [6], child.version_ids
587 591
588 592 versions = parent.shared_versions.visible(user)
589 593
590 594 assert_equal 4, versions.size
591 595 versions.each do |version|
592 596 assert_kind_of Version, version
593 597 end
594 598
595 599 assert !versions.collect(&:id).include?(6)
596 600 end
597 601
598 602 def test_shared_versions_for_new_project_should_include_system_shared_versions
599 603 p = Project.find(5)
600 604 v = Version.create!(:name => 'system_sharing', :project => p, :sharing => 'system')
601 605
602 606 assert_include v, Project.new.shared_versions
603 607 end
604 608
605 609 def test_next_identifier
606 610 ProjectCustomField.delete_all
607 611 Project.create!(:name => 'last', :identifier => 'p2008040')
608 612 assert_equal 'p2008041', Project.next_identifier
609 613 end
610 614
611 615 def test_next_identifier_first_project
612 616 Project.delete_all
613 617 assert_nil Project.next_identifier
614 618 end
615 619
616 620 def test_enabled_module_names
617 621 with_settings :default_projects_modules => ['issue_tracking', 'repository'] do
618 622 project = Project.new
619 623
620 624 project.enabled_module_names = %w(issue_tracking news)
621 625 assert_equal %w(issue_tracking news), project.enabled_module_names.sort
622 626 end
623 627 end
624 628
625 629 context "enabled_modules" do
626 630 setup do
627 631 @project = Project.find(1)
628 632 end
629 633
630 634 should "define module by names and preserve ids" do
631 635 # Remove one module
632 636 modules = @project.enabled_modules.slice(0..-2)
633 637 assert modules.any?
634 638 assert_difference 'EnabledModule.count', -1 do
635 639 @project.enabled_module_names = modules.collect(&:name)
636 640 end
637 641 @project.reload
638 642 # Ids should be preserved
639 643 assert_equal @project.enabled_module_ids.sort, modules.collect(&:id).sort
640 644 end
641 645
642 646 should "enable a module" do
643 647 @project.enabled_module_names = []
644 648 @project.reload
645 649 assert_equal [], @project.enabled_module_names
646 650 #with string
647 651 @project.enable_module!("issue_tracking")
648 652 assert_equal ["issue_tracking"], @project.enabled_module_names
649 653 #with symbol
650 654 @project.enable_module!(:gantt)
651 655 assert_equal ["issue_tracking", "gantt"], @project.enabled_module_names
652 656 #don't add a module twice
653 657 @project.enable_module!("issue_tracking")
654 658 assert_equal ["issue_tracking", "gantt"], @project.enabled_module_names
655 659 end
656 660
657 661 should "disable a module" do
658 662 #with string
659 663 assert @project.enabled_module_names.include?("issue_tracking")
660 664 @project.disable_module!("issue_tracking")
661 665 assert ! @project.reload.enabled_module_names.include?("issue_tracking")
662 666 #with symbol
663 667 assert @project.enabled_module_names.include?("gantt")
664 668 @project.disable_module!(:gantt)
665 669 assert ! @project.reload.enabled_module_names.include?("gantt")
666 670 #with EnabledModule object
667 671 first_module = @project.enabled_modules.first
668 672 @project.disable_module!(first_module)
669 673 assert ! @project.reload.enabled_module_names.include?(first_module.name)
670 674 end
671 675 end
672 676
673 677 def test_enabled_module_names_should_not_recreate_enabled_modules
674 678 project = Project.find(1)
675 679 # Remove one module
676 680 modules = project.enabled_modules.slice(0..-2)
677 681 assert modules.any?
678 682 assert_difference 'EnabledModule.count', -1 do
679 683 project.enabled_module_names = modules.collect(&:name)
680 684 end
681 685 project.reload
682 686 # Ids should be preserved
683 687 assert_equal project.enabled_module_ids.sort, modules.collect(&:id).sort
684 688 end
685 689
686 690 def test_copy_from_existing_project
687 691 source_project = Project.find(1)
688 692 copied_project = Project.copy_from(1)
689 693
690 694 assert copied_project
691 695 # Cleared attributes
692 696 assert copied_project.id.blank?
693 697 assert copied_project.name.blank?
694 698 assert copied_project.identifier.blank?
695 699
696 700 # Duplicated attributes
697 701 assert_equal source_project.description, copied_project.description
698 702 assert_equal source_project.enabled_modules, copied_project.enabled_modules
699 703 assert_equal source_project.trackers, copied_project.trackers
700 704
701 705 # Default attributes
702 706 assert_equal 1, copied_project.status
703 707 end
704 708
705 709 def test_activities_should_use_the_system_activities
706 710 project = Project.find(1)
707 711 assert_equal project.activities, TimeEntryActivity.find(:all, :conditions => {:active => true} )
708 712 end
709 713
710 714
711 715 def test_activities_should_use_the_project_specific_activities
712 716 project = Project.find(1)
713 717 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project})
714 718 assert overridden_activity.save!
715 719
716 720 assert project.activities.include?(overridden_activity), "Project specific Activity not found"
717 721 end
718 722
719 723 def test_activities_should_not_include_the_inactive_project_specific_activities
720 724 project = Project.find(1)
721 725 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project, :parent => TimeEntryActivity.find(:first), :active => false})
722 726 assert overridden_activity.save!
723 727
724 728 assert !project.activities.include?(overridden_activity), "Inactive Project specific Activity found"
725 729 end
726 730
727 731 def test_activities_should_not_include_project_specific_activities_from_other_projects
728 732 project = Project.find(1)
729 733 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => Project.find(2)})
730 734 assert overridden_activity.save!
731 735
732 736 assert !project.activities.include?(overridden_activity), "Project specific Activity found on a different project"
733 737 end
734 738
735 739 def test_activities_should_handle_nils
736 740 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => Project.find(1), :parent => TimeEntryActivity.find(:first)})
737 741 TimeEntryActivity.delete_all
738 742
739 743 # No activities
740 744 project = Project.find(1)
741 745 assert project.activities.empty?
742 746
743 747 # No system, one overridden
744 748 assert overridden_activity.save!
745 749 project.reload
746 750 assert_equal [overridden_activity], project.activities
747 751 end
748 752
749 753 def test_activities_should_override_system_activities_with_project_activities
750 754 project = Project.find(1)
751 755 parent_activity = TimeEntryActivity.find(:first)
752 756 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project, :parent => parent_activity})
753 757 assert overridden_activity.save!
754 758
755 759 assert project.activities.include?(overridden_activity), "Project specific Activity not found"
756 760 assert !project.activities.include?(parent_activity), "System Activity found when it should have been overridden"
757 761 end
758 762
759 763 def test_activities_should_include_inactive_activities_if_specified
760 764 project = Project.find(1)
761 765 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project, :parent => TimeEntryActivity.find(:first), :active => false})
762 766 assert overridden_activity.save!
763 767
764 768 assert project.activities(true).include?(overridden_activity), "Inactive Project specific Activity not found"
765 769 end
766 770
767 771 test 'activities should not include active System activities if the project has an override that is inactive' do
768 772 project = Project.find(1)
769 773 system_activity = TimeEntryActivity.find_by_name('Design')
770 774 assert system_activity.active?
771 775 overridden_activity = TimeEntryActivity.create!(:name => "Project", :project => project, :parent => system_activity, :active => false)
772 776 assert overridden_activity.save!
773 777
774 778 assert !project.activities.include?(overridden_activity), "Inactive Project specific Activity not found"
775 779 assert !project.activities.include?(system_activity), "System activity found when the project has an inactive override"
776 780 end
777 781
778 782 def test_close_completed_versions
779 783 Version.update_all("status = 'open'")
780 784 project = Project.find(1)
781 785 assert_not_nil project.versions.detect {|v| v.completed? && v.status == 'open'}
782 786 assert_not_nil project.versions.detect {|v| !v.completed? && v.status == 'open'}
783 787 project.close_completed_versions
784 788 project.reload
785 789 assert_nil project.versions.detect {|v| v.completed? && v.status != 'closed'}
786 790 assert_not_nil project.versions.detect {|v| !v.completed? && v.status == 'open'}
787 791 end
788 792
789 793 context "Project#copy" do
790 794 setup do
791 795 ProjectCustomField.destroy_all # Custom values are a mess to isolate in tests
792 796 Project.destroy_all :identifier => "copy-test"
793 797 @source_project = Project.find(2)
794 798 @project = Project.new(:name => 'Copy Test', :identifier => 'copy-test')
795 799 @project.trackers = @source_project.trackers
796 800 @project.enabled_module_names = @source_project.enabled_modules.collect(&:name)
797 801 end
798 802
799 803 should "copy issues" do
800 804 @source_project.issues << Issue.generate!(:status => IssueStatus.find_by_name('Closed'),
801 805 :subject => "copy issue status",
802 806 :tracker_id => 1,
803 807 :assigned_to_id => 2,
804 808 :project_id => @source_project.id)
805 809 assert @project.valid?
806 810 assert @project.issues.empty?
807 811 assert @project.copy(@source_project)
808 812
809 813 assert_equal @source_project.issues.size, @project.issues.size
810 814 @project.issues.each do |issue|
811 815 assert issue.valid?
812 816 assert ! issue.assigned_to.blank?
813 817 assert_equal @project, issue.project
814 818 end
815 819
816 820 copied_issue = @project.issues.first(:conditions => {:subject => "copy issue status"})
817 821 assert copied_issue
818 822 assert copied_issue.status
819 823 assert_equal "Closed", copied_issue.status.name
820 824 end
821 825
822 826 should "change the new issues to use the copied version" do
823 827 User.current = User.find(1)
824 828 assigned_version = Version.generate!(:name => "Assigned Issues", :status => 'open')
825 829 @source_project.versions << assigned_version
826 830 assert_equal 3, @source_project.versions.size
827 831 Issue.generate_for_project!(@source_project,
828 832 :fixed_version_id => assigned_version.id,
829 833 :subject => "change the new issues to use the copied version",
830 834 :tracker_id => 1,
831 835 :project_id => @source_project.id)
832 836
833 837 assert @project.copy(@source_project)
834 838 @project.reload
835 839 copied_issue = @project.issues.first(:conditions => {:subject => "change the new issues to use the copied version"})
836 840
837 841 assert copied_issue
838 842 assert copied_issue.fixed_version
839 843 assert_equal "Assigned Issues", copied_issue.fixed_version.name # Same name
840 844 assert_not_equal assigned_version.id, copied_issue.fixed_version.id # Different record
841 845 end
842 846
843 847 should "copy issue relations" do
844 848 Setting.cross_project_issue_relations = '1'
845 849
846 850 second_issue = Issue.generate!(:status_id => 5,
847 851 :subject => "copy issue relation",
848 852 :tracker_id => 1,
849 853 :assigned_to_id => 2,
850 854 :project_id => @source_project.id)
851 855 source_relation = IssueRelation.create!(:issue_from => Issue.find(4),
852 856 :issue_to => second_issue,
853 857 :relation_type => "relates")
854 858 source_relation_cross_project = IssueRelation.create!(:issue_from => Issue.find(1),
855 859 :issue_to => second_issue,
856 860 :relation_type => "duplicates")
857 861
858 862 assert @project.copy(@source_project)
859 863 assert_equal @source_project.issues.count, @project.issues.count
860 864 copied_issue = @project.issues.find_by_subject("Issue on project 2") # Was #4
861 865 copied_second_issue = @project.issues.find_by_subject("copy issue relation")
862 866
863 867 # First issue with a relation on project
864 868 assert_equal 1, copied_issue.relations.size, "Relation not copied"
865 869 copied_relation = copied_issue.relations.first
866 870 assert_equal "relates", copied_relation.relation_type
867 871 assert_equal copied_second_issue.id, copied_relation.issue_to_id
868 872 assert_not_equal source_relation.id, copied_relation.id
869 873
870 874 # Second issue with a cross project relation
871 875 assert_equal 2, copied_second_issue.relations.size, "Relation not copied"
872 876 copied_relation = copied_second_issue.relations.select {|r| r.relation_type == 'duplicates'}.first
873 877 assert_equal "duplicates", copied_relation.relation_type
874 878 assert_equal 1, copied_relation.issue_from_id, "Cross project relation not kept"
875 879 assert_not_equal source_relation_cross_project.id, copied_relation.id
876 880 end
877 881
878 882 should "copy issue attachments" do
879 883 issue = Issue.generate!(:subject => "copy with attachment", :tracker_id => 1, :project_id => @source_project.id)
880 884 Attachment.create!(:container => issue, :file => uploaded_test_file("testfile.txt", "text/plain"), :author_id => 1)
881 885 @source_project.issues << issue
882 886 assert @project.copy(@source_project)
883 887
884 888 copied_issue = @project.issues.first(:conditions => {:subject => "copy with attachment"})
885 889 assert_not_nil copied_issue
886 890 assert_equal 1, copied_issue.attachments.count, "Attachment not copied"
887 891 assert_equal "testfile.txt", copied_issue.attachments.first.filename
888 892 end
889 893
890 894 should "copy memberships" do
891 895 assert @project.valid?
892 896 assert @project.members.empty?
893 897 assert @project.copy(@source_project)
894 898
895 899 assert_equal @source_project.memberships.size, @project.memberships.size
896 900 @project.memberships.each do |membership|
897 901 assert membership
898 902 assert_equal @project, membership.project
899 903 end
900 904 end
901 905
902 906 should "copy memberships with groups and additional roles" do
903 907 group = Group.create!(:lastname => "Copy group")
904 908 user = User.find(7)
905 909 group.users << user
906 910 # group role
907 911 Member.create!(:project_id => @source_project.id, :principal => group, :role_ids => [2])
908 912 member = Member.find_by_user_id_and_project_id(user.id, @source_project.id)
909 913 # additional role
910 914 member.role_ids = [1]
911 915
912 916 assert @project.copy(@source_project)
913 917 member = Member.find_by_user_id_and_project_id(user.id, @project.id)
914 918 assert_not_nil member
915 919 assert_equal [1, 2], member.role_ids.sort
916 920 end
917 921
918 922 should "copy project specific queries" do
919 923 assert @project.valid?
920 924 assert @project.queries.empty?
921 925 assert @project.copy(@source_project)
922 926
923 927 assert_equal @source_project.queries.size, @project.queries.size
924 928 @project.queries.each do |query|
925 929 assert query
926 930 assert_equal @project, query.project
927 931 end
928 932 assert_equal @source_project.queries.map(&:user_id).sort, @project.queries.map(&:user_id).sort
929 933 end
930 934
931 935 should "copy versions" do
932 936 @source_project.versions << Version.generate!
933 937 @source_project.versions << Version.generate!
934 938
935 939 assert @project.versions.empty?
936 940 assert @project.copy(@source_project)
937 941
938 942 assert_equal @source_project.versions.size, @project.versions.size
939 943 @project.versions.each do |version|
940 944 assert version
941 945 assert_equal @project, version.project
942 946 end
943 947 end
944 948
945 949 should "copy wiki" do
946 950 assert_difference 'Wiki.count' do
947 951 assert @project.copy(@source_project)
948 952 end
949 953
950 954 assert @project.wiki
951 955 assert_not_equal @source_project.wiki, @project.wiki
952 956 assert_equal "Start page", @project.wiki.start_page
953 957 end
954 958
955 959 should "copy wiki pages and content with hierarchy" do
956 960 assert_difference 'WikiPage.count', @source_project.wiki.pages.size do
957 961 assert @project.copy(@source_project)
958 962 end
959 963
960 964 assert @project.wiki
961 965 assert_equal @source_project.wiki.pages.size, @project.wiki.pages.size
962 966
963 967 @project.wiki.pages.each do |wiki_page|
964 968 assert wiki_page.content
965 969 assert !@source_project.wiki.pages.include?(wiki_page)
966 970 end
967 971
968 972 parent = @project.wiki.find_page('Parent_page')
969 973 child1 = @project.wiki.find_page('Child_page_1')
970 974 child2 = @project.wiki.find_page('Child_page_2')
971 975 assert_equal parent, child1.parent
972 976 assert_equal parent, child2.parent
973 977 end
974 978
975 979 should "copy issue categories" do
976 980 assert @project.copy(@source_project)
977 981
978 982 assert_equal 2, @project.issue_categories.size
979 983 @project.issue_categories.each do |issue_category|
980 984 assert !@source_project.issue_categories.include?(issue_category)
981 985 end
982 986 end
983 987
984 988 should "copy boards" do
985 989 assert @project.copy(@source_project)
986 990
987 991 assert_equal 1, @project.boards.size
988 992 @project.boards.each do |board|
989 993 assert !@source_project.boards.include?(board)
990 994 end
991 995 end
992 996
993 997 should "change the new issues to use the copied issue categories" do
994 998 issue = Issue.find(4)
995 999 issue.update_attribute(:category_id, 3)
996 1000
997 1001 assert @project.copy(@source_project)
998 1002
999 1003 @project.issues.each do |issue|
1000 1004 assert issue.category
1001 1005 assert_equal "Stock management", issue.category.name # Same name
1002 1006 assert_not_equal IssueCategory.find(3), issue.category # Different record
1003 1007 end
1004 1008 end
1005 1009
1006 1010 should "limit copy with :only option" do
1007 1011 assert @project.members.empty?
1008 1012 assert @project.issue_categories.empty?
1009 1013 assert @source_project.issues.any?
1010 1014
1011 1015 assert @project.copy(@source_project, :only => ['members', 'issue_categories'])
1012 1016
1013 1017 assert @project.members.any?
1014 1018 assert @project.issue_categories.any?
1015 1019 assert @project.issues.empty?
1016 1020 end
1017 1021
1018 1022 end
1019 1023
1020 1024 context "#start_date" do
1021 1025 setup do
1022 1026 ProjectCustomField.destroy_all # Custom values are a mess to isolate in tests
1023 1027 @project = Project.generate!(:identifier => 'test0')
1024 1028 @project.trackers << Tracker.generate!
1025 1029 end
1026 1030
1027 1031 should "be nil if there are no issues on the project" do
1028 1032 assert_nil @project.start_date
1029 1033 end
1030 1034
1031 1035 should "be tested when issues have no start date"
1032 1036
1033 1037 should "be the earliest start date of it's issues" do
1034 1038 early = 7.days.ago.to_date
1035 1039 Issue.generate_for_project!(@project, :start_date => Date.today)
1036 1040 Issue.generate_for_project!(@project, :start_date => early)
1037 1041
1038 1042 assert_equal early, @project.start_date
1039 1043 end
1040 1044
1041 1045 end
1042 1046
1043 1047 context "#due_date" do
1044 1048 setup do
1045 1049 ProjectCustomField.destroy_all # Custom values are a mess to isolate in tests
1046 1050 @project = Project.generate!(:identifier => 'test0')
1047 1051 @project.trackers << Tracker.generate!
1048 1052 end
1049 1053
1050 1054 should "be nil if there are no issues on the project" do
1051 1055 assert_nil @project.due_date
1052 1056 end
1053 1057
1054 1058 should "be tested when issues have no due date"
1055 1059
1056 1060 should "be the latest due date of it's issues" do
1057 1061 future = 7.days.from_now.to_date
1058 1062 Issue.generate_for_project!(@project, :due_date => future)
1059 1063 Issue.generate_for_project!(@project, :due_date => Date.today)
1060 1064
1061 1065 assert_equal future, @project.due_date
1062 1066 end
1063 1067
1064 1068 should "be the latest due date of it's versions" do
1065 1069 future = 7.days.from_now.to_date
1066 1070 @project.versions << Version.generate!(:effective_date => future)
1067 1071 @project.versions << Version.generate!(:effective_date => Date.today)
1068 1072
1069 1073
1070 1074 assert_equal future, @project.due_date
1071 1075
1072 1076 end
1073 1077
1074 1078 should "pick the latest date from it's issues and versions" do
1075 1079 future = 7.days.from_now.to_date
1076 1080 far_future = 14.days.from_now.to_date
1077 1081 Issue.generate_for_project!(@project, :due_date => far_future)
1078 1082 @project.versions << Version.generate!(:effective_date => future)
1079 1083
1080 1084 assert_equal far_future, @project.due_date
1081 1085 end
1082 1086
1083 1087 end
1084 1088
1085 1089 context "Project#completed_percent" do
1086 1090 setup do
1087 1091 ProjectCustomField.destroy_all # Custom values are a mess to isolate in tests
1088 1092 @project = Project.generate!(:identifier => 'test0')
1089 1093 @project.trackers << Tracker.generate!
1090 1094 end
1091 1095
1092 1096 context "no versions" do
1093 1097 should "be 100" do
1094 1098 assert_equal 100, @project.completed_percent
1095 1099 end
1096 1100 end
1097 1101
1098 1102 context "with versions" do
1099 1103 should "return 0 if the versions have no issues" do
1100 1104 Version.generate!(:project => @project)
1101 1105 Version.generate!(:project => @project)
1102 1106
1103 1107 assert_equal 0, @project.completed_percent
1104 1108 end
1105 1109
1106 1110 should "return 100 if the version has only closed issues" do
1107 1111 v1 = Version.generate!(:project => @project)
1108 1112 Issue.generate_for_project!(@project, :status => IssueStatus.find_by_name('Closed'), :fixed_version => v1)
1109 1113 v2 = Version.generate!(:project => @project)
1110 1114 Issue.generate_for_project!(@project, :status => IssueStatus.find_by_name('Closed'), :fixed_version => v2)
1111 1115
1112 1116 assert_equal 100, @project.completed_percent
1113 1117 end
1114 1118
1115 1119 should "return the averaged completed percent of the versions (not weighted)" do
1116 1120 v1 = Version.generate!(:project => @project)
1117 1121 Issue.generate_for_project!(@project, :status => IssueStatus.find_by_name('New'), :estimated_hours => 10, :done_ratio => 50, :fixed_version => v1)
1118 1122 v2 = Version.generate!(:project => @project)
1119 1123 Issue.generate_for_project!(@project, :status => IssueStatus.find_by_name('New'), :estimated_hours => 10, :done_ratio => 50, :fixed_version => v2)
1120 1124
1121 1125 assert_equal 50, @project.completed_percent
1122 1126 end
1123 1127
1124 1128 end
1125 1129 end
1126 1130
1127 1131 context "#notified_users" do
1128 1132 setup do
1129 1133 @project = Project.generate!
1130 1134 @role = Role.generate!
1131 1135
1132 1136 @user_with_membership_notification = User.generate!(:mail_notification => 'selected')
1133 1137 Member.create!(:project => @project, :roles => [@role], :principal => @user_with_membership_notification, :mail_notification => true)
1134 1138
1135 1139 @all_events_user = User.generate!(:mail_notification => 'all')
1136 1140 Member.create!(:project => @project, :roles => [@role], :principal => @all_events_user)
1137 1141
1138 1142 @no_events_user = User.generate!(:mail_notification => 'none')
1139 1143 Member.create!(:project => @project, :roles => [@role], :principal => @no_events_user)
1140 1144
1141 1145 @only_my_events_user = User.generate!(:mail_notification => 'only_my_events')
1142 1146 Member.create!(:project => @project, :roles => [@role], :principal => @only_my_events_user)
1143 1147
1144 1148 @only_assigned_user = User.generate!(:mail_notification => 'only_assigned')
1145 1149 Member.create!(:project => @project, :roles => [@role], :principal => @only_assigned_user)
1146 1150
1147 1151 @only_owned_user = User.generate!(:mail_notification => 'only_owner')
1148 1152 Member.create!(:project => @project, :roles => [@role], :principal => @only_owned_user)
1149 1153 end
1150 1154
1151 1155 should "include members with a mail notification" do
1152 1156 assert @project.notified_users.include?(@user_with_membership_notification)
1153 1157 end
1154 1158
1155 1159 should "include users with the 'all' notification option" do
1156 1160 assert @project.notified_users.include?(@all_events_user)
1157 1161 end
1158 1162
1159 1163 should "not include users with the 'none' notification option" do
1160 1164 assert !@project.notified_users.include?(@no_events_user)
1161 1165 end
1162 1166
1163 1167 should "not include users with the 'only_my_events' notification option" do
1164 1168 assert !@project.notified_users.include?(@only_my_events_user)
1165 1169 end
1166 1170
1167 1171 should "not include users with the 'only_assigned' notification option" do
1168 1172 assert !@project.notified_users.include?(@only_assigned_user)
1169 1173 end
1170 1174
1171 1175 should "not include users with the 'only_owner' notification option" do
1172 1176 assert !@project.notified_users.include?(@only_owned_user)
1173 1177 end
1174 1178 end
1175 1179
1176 1180 end
General Comments 0
You need to be logged in to leave comments. Login now