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