##// END OF EJS Templates
Fixed that projects are not ordered alphabetically after renaming project (#11508)....
Jean-Philippe Lang -
r10004:e52219f09d23
parent child
Show More
@@ -1,946 +1,956
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Project < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20
21 21 # Project statuses
22 22 STATUS_ACTIVE = 1
23 23 STATUS_CLOSED = 5
24 24 STATUS_ARCHIVED = 9
25 25
26 26 # Maximum length for project identifiers
27 27 IDENTIFIER_MAX_LENGTH = 100
28 28
29 29 # Specific overidden Activities
30 30 has_many :time_entry_activities
31 31 has_many :members, :include => [:user, :roles], :conditions => "#{User.table_name}.type='User' AND #{User.table_name}.status=#{User::STATUS_ACTIVE}"
32 32 has_many :memberships, :class_name => 'Member'
33 33 has_many :member_principals, :class_name => 'Member',
34 34 :include => :principal,
35 35 :conditions => "#{Principal.table_name}.type='Group' OR (#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{User::STATUS_ACTIVE})"
36 36 has_many :users, :through => :members
37 37 has_many :principals, :through => :member_principals, :source => :principal
38 38
39 39 has_many :enabled_modules, :dependent => :delete_all
40 40 has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
41 41 has_many :issues, :dependent => :destroy, :include => [:status, :tracker]
42 42 has_many :issue_changes, :through => :issues, :source => :journals
43 43 has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
44 44 has_many :time_entries, :dependent => :delete_all
45 45 has_many :queries, :dependent => :delete_all
46 46 has_many :documents, :dependent => :destroy
47 47 has_many :news, :dependent => :destroy, :include => :author
48 48 has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
49 49 has_many :boards, :dependent => :destroy, :order => "position ASC"
50 50 has_one :repository, :conditions => ["is_default = ?", true]
51 51 has_many :repositories, :dependent => :destroy
52 52 has_many :changesets, :through => :repository
53 53 has_one :wiki, :dependent => :destroy
54 54 # Custom field for the project issues
55 55 has_and_belongs_to_many :issue_custom_fields,
56 56 :class_name => 'IssueCustomField',
57 57 :order => "#{CustomField.table_name}.position",
58 58 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
59 59 :association_foreign_key => 'custom_field_id'
60 60
61 61 acts_as_nested_set :order => 'name', :dependent => :destroy
62 62 acts_as_attachable :view_permission => :view_files,
63 63 :delete_permission => :manage_files
64 64
65 65 acts_as_customizable
66 66 acts_as_searchable :columns => ['name', 'identifier', 'description'], :project_key => 'id', :permission => nil
67 67 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
68 68 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o}},
69 69 :author => nil
70 70
71 71 attr_protected :status
72 72
73 73 validates_presence_of :name, :identifier
74 74 validates_uniqueness_of :identifier
75 75 validates_associated :repository, :wiki
76 76 validates_length_of :name, :maximum => 255
77 77 validates_length_of :homepage, :maximum => 255
78 78 validates_length_of :identifier, :in => 1..IDENTIFIER_MAX_LENGTH
79 79 # donwcase letters, digits, dashes but not digits only
80 80 validates_format_of :identifier, :with => /^(?!\d+$)[a-z0-9\-_]*$/, :if => Proc.new { |p| p.identifier_changed? }
81 81 # reserved words
82 82 validates_exclusion_of :identifier, :in => %w( new )
83 83
84 after_save :update_position_under_parent, :if => Proc.new {|project| project.name_changed?}
84 85 before_destroy :delete_all_members
85 86
86 87 scope :has_module, lambda { |mod| { :conditions => ["#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s] } }
87 88 scope :active, { :conditions => "#{Project.table_name}.status = #{STATUS_ACTIVE}"}
88 89 scope :status, lambda {|arg| arg.blank? ? {} : {:conditions => {:status => arg.to_i}} }
89 90 scope :all_public, { :conditions => { :is_public => true } }
90 91 scope :visible, lambda {|*args| {:conditions => Project.visible_condition(args.shift || User.current, *args) }}
91 92 scope :allowed_to, lambda {|*args|
92 93 user = User.current
93 94 permission = nil
94 95 if args.first.is_a?(Symbol)
95 96 permission = args.shift
96 97 else
97 98 user = args.shift
98 99 permission = args.shift
99 100 end
100 101 { :conditions => Project.allowed_to_condition(user, permission, *args) }
101 102 }
102 103 scope :like, lambda {|arg|
103 104 if arg.blank?
104 105 {}
105 106 else
106 107 pattern = "%#{arg.to_s.strip.downcase}%"
107 108 {:conditions => ["LOWER(identifier) LIKE :p OR LOWER(name) LIKE :p", {:p => pattern}]}
108 109 end
109 110 }
110 111
111 112 def initialize(attributes=nil, *args)
112 113 super
113 114
114 115 initialized = (attributes || {}).stringify_keys
115 116 if !initialized.key?('identifier') && Setting.sequential_project_identifiers?
116 117 self.identifier = Project.next_identifier
117 118 end
118 119 if !initialized.key?('is_public')
119 120 self.is_public = Setting.default_projects_public?
120 121 end
121 122 if !initialized.key?('enabled_module_names')
122 123 self.enabled_module_names = Setting.default_projects_modules
123 124 end
124 125 if !initialized.key?('trackers') && !initialized.key?('tracker_ids')
125 126 self.trackers = Tracker.sorted.all
126 127 end
127 128 end
128 129
129 130 def identifier=(identifier)
130 131 super unless identifier_frozen?
131 132 end
132 133
133 134 def identifier_frozen?
134 135 errors[:identifier].blank? && !(new_record? || identifier.blank?)
135 136 end
136 137
137 138 # returns latest created projects
138 139 # non public projects will be returned only if user is a member of those
139 140 def self.latest(user=nil, count=5)
140 141 visible(user).find(:all, :limit => count, :order => "created_on DESC")
141 142 end
142 143
143 144 # Returns true if the project is visible to +user+ or to the current user.
144 145 def visible?(user=User.current)
145 146 user.allowed_to?(:view_project, self)
146 147 end
147 148
148 149 # Returns a SQL conditions string used to find all projects visible by the specified user.
149 150 #
150 151 # Examples:
151 152 # Project.visible_condition(admin) => "projects.status = 1"
152 153 # Project.visible_condition(normal_user) => "((projects.status = 1) AND (projects.is_public = 1 OR projects.id IN (1,3,4)))"
153 154 # Project.visible_condition(anonymous) => "((projects.status = 1) AND (projects.is_public = 1))"
154 155 def self.visible_condition(user, options={})
155 156 allowed_to_condition(user, :view_project, options)
156 157 end
157 158
158 159 # Returns a SQL conditions string used to find all projects for which +user+ has the given +permission+
159 160 #
160 161 # Valid options:
161 162 # * :project => limit the condition to project
162 163 # * :with_subprojects => limit the condition to project and its subprojects
163 164 # * :member => limit the condition to the user projects
164 165 def self.allowed_to_condition(user, permission, options={})
165 166 perm = Redmine::AccessControl.permission(permission)
166 167 base_statement = (perm && perm.read? ? "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED}" : "#{Project.table_name}.status = #{Project::STATUS_ACTIVE}")
167 168 if perm && perm.project_module
168 169 # If the permission belongs to a project module, make sure the module is enabled
169 170 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
170 171 end
171 172 if options[:project]
172 173 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
173 174 project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
174 175 base_statement = "(#{project_statement}) AND (#{base_statement})"
175 176 end
176 177
177 178 if user.admin?
178 179 base_statement
179 180 else
180 181 statement_by_role = {}
181 182 unless options[:member]
182 183 role = user.logged? ? Role.non_member : Role.anonymous
183 184 if role.allowed_to?(permission)
184 185 statement_by_role[role] = "#{Project.table_name}.is_public = #{connection.quoted_true}"
185 186 end
186 187 end
187 188 if user.logged?
188 189 user.projects_by_role.each do |role, projects|
189 190 if role.allowed_to?(permission)
190 191 statement_by_role[role] = "#{Project.table_name}.id IN (#{projects.collect(&:id).join(',')})"
191 192 end
192 193 end
193 194 end
194 195 if statement_by_role.empty?
195 196 "1=0"
196 197 else
197 198 if block_given?
198 199 statement_by_role.each do |role, statement|
199 200 if s = yield(role, user)
200 201 statement_by_role[role] = "(#{statement} AND (#{s}))"
201 202 end
202 203 end
203 204 end
204 205 "((#{base_statement}) AND (#{statement_by_role.values.join(' OR ')}))"
205 206 end
206 207 end
207 208 end
208 209
209 210 # Returns the Systemwide and project specific activities
210 211 def activities(include_inactive=false)
211 212 if include_inactive
212 213 return all_activities
213 214 else
214 215 return active_activities
215 216 end
216 217 end
217 218
218 219 # Will create a new Project specific Activity or update an existing one
219 220 #
220 221 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
221 222 # does not successfully save.
222 223 def update_or_create_time_entry_activity(id, activity_hash)
223 224 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
224 225 self.create_time_entry_activity_if_needed(activity_hash)
225 226 else
226 227 activity = project.time_entry_activities.find_by_id(id.to_i)
227 228 activity.update_attributes(activity_hash) if activity
228 229 end
229 230 end
230 231
231 232 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
232 233 #
233 234 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
234 235 # does not successfully save.
235 236 def create_time_entry_activity_if_needed(activity)
236 237 if activity['parent_id']
237 238
238 239 parent_activity = TimeEntryActivity.find(activity['parent_id'])
239 240 activity['name'] = parent_activity.name
240 241 activity['position'] = parent_activity.position
241 242
242 243 if Enumeration.overridding_change?(activity, parent_activity)
243 244 project_activity = self.time_entry_activities.create(activity)
244 245
245 246 if project_activity.new_record?
246 247 raise ActiveRecord::Rollback, "Overridding TimeEntryActivity was not successfully saved"
247 248 else
248 249 self.time_entries.update_all("activity_id = #{project_activity.id}", ["activity_id = ?", parent_activity.id])
249 250 end
250 251 end
251 252 end
252 253 end
253 254
254 255 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
255 256 #
256 257 # Examples:
257 258 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
258 259 # project.project_condition(false) => "projects.id = 1"
259 260 def project_condition(with_subprojects)
260 261 cond = "#{Project.table_name}.id = #{id}"
261 262 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
262 263 cond
263 264 end
264 265
265 266 def self.find(*args)
266 267 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
267 268 project = find_by_identifier(*args)
268 269 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
269 270 project
270 271 else
271 272 super
272 273 end
273 274 end
274 275
275 276 def self.find_by_param(*args)
276 277 self.find(*args)
277 278 end
278 279
279 280 def reload(*args)
280 281 @shared_versions = nil
281 282 @rolled_up_versions = nil
282 283 @rolled_up_trackers = nil
283 284 @all_issue_custom_fields = nil
284 285 @all_time_entry_custom_fields = nil
285 286 @to_param = nil
286 287 @allowed_parents = nil
287 288 @allowed_permissions = nil
288 289 @actions_allowed = nil
289 290 super
290 291 end
291 292
292 293 def to_param
293 294 # id is used for projects with a numeric identifier (compatibility)
294 295 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id.to_s : identifier)
295 296 end
296 297
297 298 def active?
298 299 self.status == STATUS_ACTIVE
299 300 end
300 301
301 302 def archived?
302 303 self.status == STATUS_ARCHIVED
303 304 end
304 305
305 306 # Archives the project and its descendants
306 307 def archive
307 308 # Check that there is no issue of a non descendant project that is assigned
308 309 # to one of the project or descendant versions
309 310 v_ids = self_and_descendants.collect {|p| p.version_ids}.flatten
310 311 if v_ids.any? && Issue.find(:first, :include => :project,
311 312 :conditions => ["(#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?)" +
312 313 " AND #{Issue.table_name}.fixed_version_id IN (?)", lft, rgt, v_ids])
313 314 return false
314 315 end
315 316 Project.transaction do
316 317 archive!
317 318 end
318 319 true
319 320 end
320 321
321 322 # Unarchives the project
322 323 # All its ancestors must be active
323 324 def unarchive
324 325 return false if ancestors.detect {|a| !a.active?}
325 326 update_attribute :status, STATUS_ACTIVE
326 327 end
327 328
328 329 def close
329 330 self_and_descendants.status(STATUS_ACTIVE).update_all :status => STATUS_CLOSED
330 331 end
331 332
332 333 def reopen
333 334 self_and_descendants.status(STATUS_CLOSED).update_all :status => STATUS_ACTIVE
334 335 end
335 336
336 337 # Returns an array of projects the project can be moved to
337 338 # by the current user
338 339 def allowed_parents
339 340 return @allowed_parents if @allowed_parents
340 341 @allowed_parents = Project.find(:all, :conditions => Project.allowed_to_condition(User.current, :add_subprojects))
341 342 @allowed_parents = @allowed_parents - self_and_descendants
342 343 if User.current.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
343 344 @allowed_parents << nil
344 345 end
345 346 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
346 347 @allowed_parents << parent
347 348 end
348 349 @allowed_parents
349 350 end
350 351
351 352 # Sets the parent of the project with authorization check
352 353 def set_allowed_parent!(p)
353 354 unless p.nil? || p.is_a?(Project)
354 355 if p.to_s.blank?
355 356 p = nil
356 357 else
357 358 p = Project.find_by_id(p)
358 359 return false unless p
359 360 end
360 361 end
361 362 if p.nil?
362 363 if !new_record? && allowed_parents.empty?
363 364 return false
364 365 end
365 366 elsif !allowed_parents.include?(p)
366 367 return false
367 368 end
368 369 set_parent!(p)
369 370 end
370 371
371 372 # Sets the parent of the project
372 373 # Argument can be either a Project, a String, a Fixnum or nil
373 374 def set_parent!(p)
374 375 unless p.nil? || p.is_a?(Project)
375 376 if p.to_s.blank?
376 377 p = nil
377 378 else
378 379 p = Project.find_by_id(p)
379 380 return false unless p
380 381 end
381 382 end
382 383 if p == parent && !p.nil?
383 384 # Nothing to do
384 385 true
385 386 elsif p.nil? || (p.active? && move_possible?(p))
386 # Insert the project so that target's children or root projects stay alphabetically sorted
387 sibs = (p.nil? ? self.class.roots : p.children)
388 to_be_inserted_before = sibs.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
389 if to_be_inserted_before
390 move_to_left_of(to_be_inserted_before)
391 elsif p.nil?
392 if sibs.empty?
393 # move_to_root adds the project in first (ie. left) position
394 move_to_root
395 else
396 move_to_right_of(sibs.last) unless self == sibs.last
397 end
398 else
399 # move_to_child_of adds the project in last (ie.right) position
400 move_to_child_of(p)
401 end
387 set_or_update_position_under(p)
402 388 Issue.update_versions_from_hierarchy_change(self)
403 389 true
404 390 else
405 391 # Can not move to the given target
406 392 false
407 393 end
408 394 end
409 395
410 396 # Returns an array of the trackers used by the project and its active sub projects
411 397 def rolled_up_trackers
412 398 @rolled_up_trackers ||=
413 399 Tracker.find(:all, :joins => :projects,
414 400 :select => "DISTINCT #{Tracker.table_name}.*",
415 401 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> #{STATUS_ARCHIVED}", lft, rgt],
416 402 :order => "#{Tracker.table_name}.position")
417 403 end
418 404
419 405 # Closes open and locked project versions that are completed
420 406 def close_completed_versions
421 407 Version.transaction do
422 408 versions.find(:all, :conditions => {:status => %w(open locked)}).each do |version|
423 409 if version.completed?
424 410 version.update_attribute(:status, 'closed')
425 411 end
426 412 end
427 413 end
428 414 end
429 415
430 416 # Returns a scope of the Versions on subprojects
431 417 def rolled_up_versions
432 418 @rolled_up_versions ||=
433 419 Version.scoped(:include => :project,
434 420 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> #{STATUS_ARCHIVED}", lft, rgt])
435 421 end
436 422
437 423 # Returns a scope of the Versions used by the project
438 424 def shared_versions
439 425 if new_record?
440 426 Version.scoped(:include => :project,
441 427 :conditions => "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND #{Version.table_name}.sharing = 'system'")
442 428 else
443 429 @shared_versions ||= begin
444 430 r = root? ? self : root
445 431 Version.scoped(:include => :project,
446 432 :conditions => "#{Project.table_name}.id = #{id}" +
447 433 " OR (#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND (" +
448 434 " #{Version.table_name}.sharing = 'system'" +
449 435 " OR (#{Project.table_name}.lft >= #{r.lft} AND #{Project.table_name}.rgt <= #{r.rgt} AND #{Version.table_name}.sharing = 'tree')" +
450 436 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
451 437 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
452 438 "))")
453 439 end
454 440 end
455 441 end
456 442
457 443 # Returns a hash of project users grouped by role
458 444 def users_by_role
459 445 members.find(:all, :include => [:user, :roles]).inject({}) do |h, m|
460 446 m.roles.each do |r|
461 447 h[r] ||= []
462 448 h[r] << m.user
463 449 end
464 450 h
465 451 end
466 452 end
467 453
468 454 # Deletes all project's members
469 455 def delete_all_members
470 456 me, mr = Member.table_name, MemberRole.table_name
471 457 connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
472 458 Member.delete_all(['project_id = ?', id])
473 459 end
474 460
475 461 # Users/groups issues can be assigned to
476 462 def assignable_users
477 463 assignable = Setting.issue_group_assignment? ? member_principals : members
478 464 assignable.select {|m| m.roles.detect {|role| role.assignable?}}.collect {|m| m.principal}.sort
479 465 end
480 466
481 467 # Returns the mail adresses of users that should be always notified on project events
482 468 def recipients
483 469 notified_users.collect {|user| user.mail}
484 470 end
485 471
486 472 # Returns the users that should be notified on project events
487 473 def notified_users
488 474 # TODO: User part should be extracted to User#notify_about?
489 475 members.select {|m| m.mail_notification? || m.user.mail_notification == 'all'}.collect {|m| m.user}
490 476 end
491 477
492 478 # Returns an array of all custom fields enabled for project issues
493 479 # (explictly associated custom fields and custom fields enabled for all projects)
494 480 def all_issue_custom_fields
495 481 @all_issue_custom_fields ||= (IssueCustomField.for_all + issue_custom_fields).uniq.sort
496 482 end
497 483
498 484 # Returns an array of all custom fields enabled for project time entries
499 485 # (explictly associated custom fields and custom fields enabled for all projects)
500 486 def all_time_entry_custom_fields
501 487 @all_time_entry_custom_fields ||= (TimeEntryCustomField.for_all + time_entry_custom_fields).uniq.sort
502 488 end
503 489
504 490 def project
505 491 self
506 492 end
507 493
508 494 def <=>(project)
509 495 name.downcase <=> project.name.downcase
510 496 end
511 497
512 498 def to_s
513 499 name
514 500 end
515 501
516 502 # Returns a short description of the projects (first lines)
517 503 def short_description(length = 255)
518 504 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
519 505 end
520 506
521 507 def css_classes
522 508 s = 'project'
523 509 s << ' root' if root?
524 510 s << ' child' if child?
525 511 s << (leaf? ? ' leaf' : ' parent')
526 512 unless active?
527 513 if archived?
528 514 s << ' archived'
529 515 else
530 516 s << ' closed'
531 517 end
532 518 end
533 519 s
534 520 end
535 521
536 522 # The earliest start date of a project, based on it's issues and versions
537 523 def start_date
538 524 [
539 525 issues.minimum('start_date'),
540 526 shared_versions.collect(&:effective_date),
541 527 shared_versions.collect(&:start_date)
542 528 ].flatten.compact.min
543 529 end
544 530
545 531 # The latest due date of an issue or version
546 532 def due_date
547 533 [
548 534 issues.maximum('due_date'),
549 535 shared_versions.collect(&:effective_date),
550 536 shared_versions.collect {|v| v.fixed_issues.maximum('due_date')}
551 537 ].flatten.compact.max
552 538 end
553 539
554 540 def overdue?
555 541 active? && !due_date.nil? && (due_date < Date.today)
556 542 end
557 543
558 544 # Returns the percent completed for this project, based on the
559 545 # progress on it's versions.
560 546 def completed_percent(options={:include_subprojects => false})
561 547 if options.delete(:include_subprojects)
562 548 total = self_and_descendants.collect(&:completed_percent).sum
563 549
564 550 total / self_and_descendants.count
565 551 else
566 552 if versions.count > 0
567 553 total = versions.collect(&:completed_pourcent).sum
568 554
569 555 total / versions.count
570 556 else
571 557 100
572 558 end
573 559 end
574 560 end
575 561
576 562 # Return true if this project allows to do the specified action.
577 563 # action can be:
578 564 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
579 565 # * a permission Symbol (eg. :edit_project)
580 566 def allows_to?(action)
581 567 if archived?
582 568 # No action allowed on archived projects
583 569 return false
584 570 end
585 571 unless active? || Redmine::AccessControl.read_action?(action)
586 572 # No write action allowed on closed projects
587 573 return false
588 574 end
589 575 # No action allowed on disabled modules
590 576 if action.is_a? Hash
591 577 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
592 578 else
593 579 allowed_permissions.include? action
594 580 end
595 581 end
596 582
597 583 def module_enabled?(module_name)
598 584 module_name = module_name.to_s
599 585 enabled_modules.detect {|m| m.name == module_name}
600 586 end
601 587
602 588 def enabled_module_names=(module_names)
603 589 if module_names && module_names.is_a?(Array)
604 590 module_names = module_names.collect(&:to_s).reject(&:blank?)
605 591 self.enabled_modules = module_names.collect {|name| enabled_modules.detect {|mod| mod.name == name} || EnabledModule.new(:name => name)}
606 592 else
607 593 enabled_modules.clear
608 594 end
609 595 end
610 596
611 597 # Returns an array of the enabled modules names
612 598 def enabled_module_names
613 599 enabled_modules.collect(&:name)
614 600 end
615 601
616 602 # Enable a specific module
617 603 #
618 604 # Examples:
619 605 # project.enable_module!(:issue_tracking)
620 606 # project.enable_module!("issue_tracking")
621 607 def enable_module!(name)
622 608 enabled_modules << EnabledModule.new(:name => name.to_s) unless module_enabled?(name)
623 609 end
624 610
625 611 # Disable a module if it exists
626 612 #
627 613 # Examples:
628 614 # project.disable_module!(:issue_tracking)
629 615 # project.disable_module!("issue_tracking")
630 616 # project.disable_module!(project.enabled_modules.first)
631 617 def disable_module!(target)
632 618 target = enabled_modules.detect{|mod| target.to_s == mod.name} unless enabled_modules.include?(target)
633 619 target.destroy unless target.blank?
634 620 end
635 621
636 622 safe_attributes 'name',
637 623 'description',
638 624 'homepage',
639 625 'is_public',
640 626 'identifier',
641 627 'custom_field_values',
642 628 'custom_fields',
643 629 'tracker_ids',
644 630 'issue_custom_field_ids'
645 631
646 632 safe_attributes 'enabled_module_names',
647 633 :if => lambda {|project, user| project.new_record? || user.allowed_to?(:select_project_modules, project) }
648 634
649 635 # Returns an array of projects that are in this project's hierarchy
650 636 #
651 637 # Example: parents, children, siblings
652 638 def hierarchy
653 639 parents = project.self_and_ancestors || []
654 640 descendants = project.descendants || []
655 641 project_hierarchy = parents | descendants # Set union
656 642 end
657 643
658 644 # Returns an auto-generated project identifier based on the last identifier used
659 645 def self.next_identifier
660 646 p = Project.find(:first, :order => 'created_on DESC')
661 647 p.nil? ? nil : p.identifier.to_s.succ
662 648 end
663 649
664 650 # Copies and saves the Project instance based on the +project+.
665 651 # Duplicates the source project's:
666 652 # * Wiki
667 653 # * Versions
668 654 # * Categories
669 655 # * Issues
670 656 # * Members
671 657 # * Queries
672 658 #
673 659 # Accepts an +options+ argument to specify what to copy
674 660 #
675 661 # Examples:
676 662 # project.copy(1) # => copies everything
677 663 # project.copy(1, :only => 'members') # => copies members only
678 664 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
679 665 def copy(project, options={})
680 666 project = project.is_a?(Project) ? project : Project.find(project)
681 667
682 668 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
683 669 to_be_copied = to_be_copied & options[:only].to_a unless options[:only].nil?
684 670
685 671 Project.transaction do
686 672 if save
687 673 reload
688 674 to_be_copied.each do |name|
689 675 send "copy_#{name}", project
690 676 end
691 677 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
692 678 save
693 679 end
694 680 end
695 681 end
696 682
697 683
698 684 # Copies +project+ and returns the new instance. This will not save
699 685 # the copy
700 686 def self.copy_from(project)
701 687 begin
702 688 project = project.is_a?(Project) ? project : Project.find(project)
703 689 if project
704 690 # clear unique attributes
705 691 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
706 692 copy = Project.new(attributes)
707 693 copy.enabled_modules = project.enabled_modules
708 694 copy.trackers = project.trackers
709 695 copy.custom_values = project.custom_values.collect {|v| v.clone}
710 696 copy.issue_custom_fields = project.issue_custom_fields
711 697 return copy
712 698 else
713 699 return nil
714 700 end
715 701 rescue ActiveRecord::RecordNotFound
716 702 return nil
717 703 end
718 704 end
719 705
720 706 # Yields the given block for each project with its level in the tree
721 707 def self.project_tree(projects, &block)
722 708 ancestors = []
723 709 projects.sort_by(&:lft).each do |project|
724 710 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
725 711 ancestors.pop
726 712 end
727 713 yield project, ancestors.size
728 714 ancestors << project
729 715 end
730 716 end
731 717
732 718 private
733 719
734 720 # Copies wiki from +project+
735 721 def copy_wiki(project)
736 722 # Check that the source project has a wiki first
737 723 unless project.wiki.nil?
738 724 self.wiki ||= Wiki.new
739 725 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
740 726 wiki_pages_map = {}
741 727 project.wiki.pages.each do |page|
742 728 # Skip pages without content
743 729 next if page.content.nil?
744 730 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
745 731 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
746 732 new_wiki_page.content = new_wiki_content
747 733 wiki.pages << new_wiki_page
748 734 wiki_pages_map[page.id] = new_wiki_page
749 735 end
750 736 wiki.save
751 737 # Reproduce page hierarchy
752 738 project.wiki.pages.each do |page|
753 739 if page.parent_id && wiki_pages_map[page.id]
754 740 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
755 741 wiki_pages_map[page.id].save
756 742 end
757 743 end
758 744 end
759 745 end
760 746
761 747 # Copies versions from +project+
762 748 def copy_versions(project)
763 749 project.versions.each do |version|
764 750 new_version = Version.new
765 751 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
766 752 self.versions << new_version
767 753 end
768 754 end
769 755
770 756 # Copies issue categories from +project+
771 757 def copy_issue_categories(project)
772 758 project.issue_categories.each do |issue_category|
773 759 new_issue_category = IssueCategory.new
774 760 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
775 761 self.issue_categories << new_issue_category
776 762 end
777 763 end
778 764
779 765 # Copies issues from +project+
780 766 # Note: issues assigned to a closed version won't be copied due to validation rules
781 767 def copy_issues(project)
782 768 # Stores the source issue id as a key and the copied issues as the
783 769 # value. Used to map the two togeather for issue relations.
784 770 issues_map = {}
785 771
786 772 # Get issues sorted by root_id, lft so that parent issues
787 773 # get copied before their children
788 774 project.issues.find(:all, :order => 'root_id, lft').each do |issue|
789 775 new_issue = Issue.new
790 776 new_issue.copy_from(issue)
791 777 new_issue.project = self
792 778 # Reassign fixed_versions by name, since names are unique per
793 779 # project and the versions for self are not yet saved
794 780 if issue.fixed_version
795 781 new_issue.fixed_version = self.versions.select {|v| v.name == issue.fixed_version.name}.first
796 782 end
797 783 # Reassign the category by name, since names are unique per
798 784 # project and the categories for self are not yet saved
799 785 if issue.category
800 786 new_issue.category = self.issue_categories.select {|c| c.name == issue.category.name}.first
801 787 end
802 788 # Parent issue
803 789 if issue.parent_id
804 790 if copied_parent = issues_map[issue.parent_id]
805 791 new_issue.parent_issue_id = copied_parent.id
806 792 end
807 793 end
808 794
809 795 self.issues << new_issue
810 796 if new_issue.new_record?
811 797 logger.info "Project#copy_issues: issue ##{issue.id} could not be copied: #{new_issue.errors.full_messages}" if logger && logger.info
812 798 else
813 799 issues_map[issue.id] = new_issue unless new_issue.new_record?
814 800 end
815 801 end
816 802
817 803 # Relations after in case issues related each other
818 804 project.issues.each do |issue|
819 805 new_issue = issues_map[issue.id]
820 806 unless new_issue
821 807 # Issue was not copied
822 808 next
823 809 end
824 810
825 811 # Relations
826 812 issue.relations_from.each do |source_relation|
827 813 new_issue_relation = IssueRelation.new
828 814 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
829 815 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
830 816 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
831 817 new_issue_relation.issue_to = source_relation.issue_to
832 818 end
833 819 new_issue.relations_from << new_issue_relation
834 820 end
835 821
836 822 issue.relations_to.each do |source_relation|
837 823 new_issue_relation = IssueRelation.new
838 824 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
839 825 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
840 826 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
841 827 new_issue_relation.issue_from = source_relation.issue_from
842 828 end
843 829 new_issue.relations_to << new_issue_relation
844 830 end
845 831 end
846 832 end
847 833
848 834 # Copies members from +project+
849 835 def copy_members(project)
850 836 # Copy users first, then groups to handle members with inherited and given roles
851 837 members_to_copy = []
852 838 members_to_copy += project.memberships.select {|m| m.principal.is_a?(User)}
853 839 members_to_copy += project.memberships.select {|m| !m.principal.is_a?(User)}
854 840
855 841 members_to_copy.each do |member|
856 842 new_member = Member.new
857 843 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
858 844 # only copy non inherited roles
859 845 # inherited roles will be added when copying the group membership
860 846 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
861 847 next if role_ids.empty?
862 848 new_member.role_ids = role_ids
863 849 new_member.project = self
864 850 self.members << new_member
865 851 end
866 852 end
867 853
868 854 # Copies queries from +project+
869 855 def copy_queries(project)
870 856 project.queries.each do |query|
871 857 new_query = ::Query.new
872 858 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria")
873 859 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
874 860 new_query.project = self
875 861 new_query.user_id = query.user_id
876 862 self.queries << new_query
877 863 end
878 864 end
879 865
880 866 # Copies boards from +project+
881 867 def copy_boards(project)
882 868 project.boards.each do |board|
883 869 new_board = Board.new
884 870 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
885 871 new_board.project = self
886 872 self.boards << new_board
887 873 end
888 874 end
889 875
890 876 def allowed_permissions
891 877 @allowed_permissions ||= begin
892 878 module_names = enabled_modules.all(:select => :name).collect {|m| m.name}
893 879 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
894 880 end
895 881 end
896 882
897 883 def allowed_actions
898 884 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
899 885 end
900 886
901 887 # Returns all the active Systemwide and project specific activities
902 888 def active_activities
903 889 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
904 890
905 891 if overridden_activity_ids.empty?
906 892 return TimeEntryActivity.shared.active
907 893 else
908 894 return system_activities_and_project_overrides
909 895 end
910 896 end
911 897
912 898 # Returns all the Systemwide and project specific activities
913 899 # (inactive and active)
914 900 def all_activities
915 901 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
916 902
917 903 if overridden_activity_ids.empty?
918 904 return TimeEntryActivity.shared
919 905 else
920 906 return system_activities_and_project_overrides(true)
921 907 end
922 908 end
923 909
924 910 # Returns the systemwide active activities merged with the project specific overrides
925 911 def system_activities_and_project_overrides(include_inactive=false)
926 912 if include_inactive
927 913 return TimeEntryActivity.shared.
928 914 find(:all,
929 915 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
930 916 self.time_entry_activities
931 917 else
932 918 return TimeEntryActivity.shared.active.
933 919 find(:all,
934 920 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
935 921 self.time_entry_activities.active
936 922 end
937 923 end
938 924
939 925 # Archives subprojects recursively
940 926 def archive!
941 927 children.each do |subproject|
942 928 subproject.send :archive!
943 929 end
944 930 update_attribute :status, STATUS_ARCHIVED
945 931 end
932
933 def update_position_under_parent
934 set_or_update_position_under(parent)
935 end
936
937 # Inserts/moves the project so that target's children or root projects stay alphabetically sorted
938 def set_or_update_position_under(target_parent)
939 sibs = (target_parent.nil? ? self.class.roots : target_parent.children)
940 to_be_inserted_before = sibs.sort_by {|c| c.name.to_s.downcase}.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
941
942 if to_be_inserted_before
943 move_to_left_of(to_be_inserted_before)
944 elsif target_parent.nil?
945 if sibs.empty?
946 # move_to_root adds the project in first (ie. left) position
947 move_to_root
948 else
949 move_to_right_of(sibs.last) unless self == sibs.last
950 end
951 else
952 # move_to_child_of adds the project in last (ie.right) position
953 move_to_child_of(target_parent)
954 end
955 end
946 956 end
@@ -1,118 +1,167
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 ProjectNestedSetTest < ActiveSupport::TestCase
21 21
22 context "nested set" do
23 setup do
22 def setup
24 23 Project.delete_all
25 24
26 @a = Project.create!(:name => 'Project A', :identifier => 'projecta')
27 @a1 = Project.create!(:name => 'Project A1', :identifier => 'projecta1')
25 @a = Project.create!(:name => 'A', :identifier => 'projecta')
26 @a1 = Project.create!(:name => 'A1', :identifier => 'projecta1')
28 27 @a1.set_parent!(@a)
29 @a2 = Project.create!(:name => 'Project A2', :identifier => 'projecta2')
28 @a2 = Project.create!(:name => 'A2', :identifier => 'projecta2')
30 29 @a2.set_parent!(@a)
31 30
32 @b = Project.create!(:name => 'Project B', :identifier => 'projectb')
33 @b1 = Project.create!(:name => 'Project B1', :identifier => 'projectb1')
31 @b = Project.create!(:name => 'B', :identifier => 'projectb')
32 @b1 = Project.create!(:name => 'B1', :identifier => 'projectb1')
34 33 @b1.set_parent!(@b)
35 @b11 = Project.create!(:name => 'Project B11', :identifier => 'projectb11')
34 @b11 = Project.create!(:name => 'B11', :identifier => 'projectb11')
36 35 @b11.set_parent!(@b1)
37 @b2 = Project.create!(:name => 'Project B2', :identifier => 'projectb2')
36 @b2 = Project.create!(:name => 'B2', :identifier => 'projectb2')
38 37 @b2.set_parent!(@b)
39 38
40 @c = Project.create!(:name => 'Project C', :identifier => 'projectc')
41 @c1 = Project.create!(:name => 'Project C1', :identifier => 'projectc1')
39 @c = Project.create!(:name => 'C', :identifier => 'projectc')
40 @c1 = Project.create!(:name => 'C1', :identifier => 'projectc1')
42 41 @c1.set_parent!(@c)
43 42
44 [@a, @a1, @a2, @b, @b1, @b11, @b2, @c, @c1].each(&:reload)
43 @a, @a1, @a2, @b, @b1, @b11, @b2, @c, @c1 = *(Project.all.sort_by(&:name))
45 44 end
46 45
47 context "#create" do
48 should "build valid tree" do
49 assert_nested_set_values({
50 @a => [nil, 1, 6],
51 @a1 => [@a.id, 2, 3],
52 @a2 => [@a.id, 4, 5],
53 @b => [nil, 7, 14],
54 @b1 => [@b.id, 8, 11],
55 @b11 => [@b1.id,9, 10],
56 @b2 => [@b.id,12, 13],
57 @c => [nil, 15, 18],
58 @c1 => [@c.id,16, 17]
59 })
60 end
46 def test_valid_tree
47 assert_valid_nested_set
61 48 end
62 49
63 context "#set_parent!" do
64 should "keep valid tree" do
50 def test_moving_a_child_to_a_different_parent_should_keep_valid_tree
65 51 assert_no_difference 'Project.count' do
66 Project.find_by_name('Project B1').set_parent!(Project.find_by_name('Project A2'))
52 Project.find_by_name('B1').set_parent!(Project.find_by_name('A2'))
67 53 end
68 assert_nested_set_values({
69 @a => [nil, 1, 10],
70 @a2 => [@a.id, 4, 9],
71 @b1 => [@a2.id,5, 8],
72 @b11 => [@b1.id,6, 7],
73 @b => [nil, 11, 14],
74 @c => [nil, 15, 18]
75 })
54 assert_valid_nested_set
76 55 end
56
57 def test_renaming_a_root_to_first_position_should_update_nested_set_order
58 @c.name = '1'
59 @c.save!
60 assert_valid_nested_set
77 61 end
78 62
79 context "#destroy" do
80 context "a root with children" do
81 should "not mess up the tree" do
82 assert_difference 'Project.count', -4 do
83 Project.find_by_name('Project B').destroy
63 def test_renaming_a_root_to_middle_position_should_update_nested_set_order
64 @a.name = 'BA'
65 @a.save!
66 assert_valid_nested_set
84 67 end
85 assert_nested_set_values({
86 @a => [nil, 1, 6],
87 @a1 => [@a.id, 2, 3],
88 @a2 => [@a.id, 4, 5],
89 @c => [nil, 7, 10],
90 @c1 => [@c.id, 8, 9]
91 })
68
69 def test_renaming_a_root_to_last_position_should_update_nested_set_order
70 @a.name = 'D'
71 @a.save!
72 assert_valid_nested_set
92 73 end
74
75 def test_renaming_a_root_to_same_position_should_update_nested_set_order
76 @c.name = 'D'
77 @c.save!
78 assert_valid_nested_set
93 79 end
94 80
95 context "a child with children" do
96 should "not mess up the tree" do
97 assert_difference 'Project.count', -2 do
98 Project.find_by_name('Project B1').destroy
81 def test_renaming_a_child_should_update_nested_set_order
82 @a1.name = 'A3'
83 @a1.save!
84 assert_valid_nested_set
85 end
86
87 def test_renaming_a_child_with_child_should_update_nested_set_order
88 @b1.name = 'B3'
89 @b1.save!
90 assert_valid_nested_set
91 end
92
93 def test_adding_a_root_to_first_position_should_update_nested_set_order
94 project = Project.create!(:name => '1', :identifier => 'projectba')
95 assert_valid_nested_set
96 end
97
98 def test_adding_a_root_to_middle_position_should_update_nested_set_order
99 project = Project.create!(:name => 'BA', :identifier => 'projectba')
100 assert_valid_nested_set
101 end
102
103 def test_adding_a_root_to_last_position_should_update_nested_set_order
104 project = Project.create!(:name => 'Z', :identifier => 'projectba')
105 assert_valid_nested_set
99 106 end
100 assert_nested_set_values({
101 @a => [nil, 1, 6],
102 @b => [nil, 7, 10],
103 @b2 => [@b.id, 8, 9],
104 @c => [nil, 11, 14]
105 })
107
108 def test_destroying_a_root_with_children_should_keep_valid_tree
109 assert_difference 'Project.count', -4 do
110 Project.find_by_name('B').destroy
106 111 end
112 assert_valid_nested_set
107 113 end
114
115 def test_destroying_a_child_with_children_should_keep_valid_tree
116 assert_difference 'Project.count', -2 do
117 Project.find_by_name('B1').destroy
108 118 end
119 assert_valid_nested_set
109 120 end
110 121
122 private
123
111 124 def assert_nested_set_values(h)
112 125 assert Project.valid?
113 126 h.each do |project, expected|
114 127 project.reload
115 128 assert_equal expected, [project.parent_id, project.lft, project.rgt], "Unexpected nested set values for #{project.name}"
116 129 end
117 130 end
131
132 def assert_valid_nested_set
133 projects = Project.all
134 lft_rgt = projects.map {|p| [p.lft, p.rgt]}.flatten
135 assert_equal projects.size * 2, lft_rgt.uniq.size
136 assert_equal 1, lft_rgt.min
137 assert_equal projects.size * 2, lft_rgt.max
138
139 projects.each do |project|
140 # lft should always be < rgt
141 assert project.lft < project.rgt, "lft=#{project.lft} was not < rgt=#{project.rgt} for project #{project.name}"
142 if project.parent_id
143 # child lft/rgt values must be greater/lower
144 assert_not_nil project.parent, "parent was nil for project #{project.name}"
145 assert project.lft > project.parent.lft, "lft=#{project.lft} was not > parent.lft=#{project.parent.lft} for project #{project.name}"
146 assert project.rgt < project.parent.rgt, "rgt=#{project.rgt} was not < parent.rgt=#{project.parent.rgt} for project #{project.name}"
147 end
148 # no overlapping lft/rgt values
149 overlapping = projects.detect {|other|
150 other != project && (
151 (other.lft > project.lft && other.lft < project.rgt && other.rgt > project.rgt) ||
152 (other.rgt > project.lft && other.rgt < project.rgt && other.lft < project.lft)
153 )
154 }
155 assert_nil overlapping, (overlapping && "Project #{overlapping.name} (#{overlapping.lft}/#{overlapping.rgt}) overlapped #{project.name} (#{project.lft}/#{project.rgt})")
156 end
157
158 # root projects sorted alphabetically
159 assert_equal Project.roots.map(&:name).sort, Project.roots.sort_by(&:lft).map(&:name), "Root projects were not properly sorted"
160 projects.each do |project|
161 if project.children.any?
162 # sibling projects sorted alphabetically
163 assert_equal project.children.map(&:name).sort, project.children.order('lft').map(&:name), "Project #{project.name}'s children were not properly sorted"
164 end
165 end
166 end
118 167 end
General Comments 0
You need to be logged in to leave comments. Login now