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